diff --git a/CHANGELOG.md b/CHANGELOG.md index ae119d282..8c93632e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ Write the date in place of the "Unreleased" in the case a new version is release ### Changed - Change access policy API to be async for filters and allowed_scopes +- Pinned zarr to `<3` because Zarr 3 is still working on adding support for + certain features that we rely on from Zarr 2. ## 2024-12-09 diff --git a/docs/source/how-to/authentication.md b/docs/source/how-to/authentication.md new file mode 100644 index 000000000..6aa7619de --- /dev/null +++ b/docs/source/how-to/authentication.md @@ -0,0 +1,158 @@ +# Python Client Authentication + +This covers authentication from the user (client) perspective. To learn how to +_deploy_ authenticated Tiled servers, see {doc}`../explanations/security`. + +## Interactive Login + +Some Tiled servers are configured to let users connect anonymously without +authenticating. + +```py +>>> from tiled.client import from_uri +>>> client = from_uri("https://...") +>>> +``` + +Logging in may enable you to see more datasets that may not be public. +Log in works in one of two ways, depending on the server. + +1. Username and password ("OAuth2 password grant") + + ```py + >>> client.login() + Username: ... + Password: + ``` + +2. Via a web browser ("OAuth2 device code grant") + + ```py + >>> client.login() + You have 15 minutes visit this URL + + https://... + + and enter the code: XXXX-XXXX + ``` + +In the future, Tiled will log you into this server automatically, without +re-prompting for credentials, until your session expires. + + ```py + >>> from tiled.client import from_uri + >>> client = from_uri("https://...") + # Automatically logged in! + + # This is a quick way to verify whether you are already logged in + >>> client.context + + ``` + +To opt out of this, set `remember_me=False`: + +```py +>>> from tiled.client import from_uri +>>> client = from_uri("https://...", remember_me=False) +``` + +```{note} +Tiled stores OAuth2 tokens (it _never_ stores your password) in files +with properly restricted permissions under `$XDG_CACHE_DIR/tiled/tokens`, +typically `~/.config/tiled/tokens` on Linux and MacOS. + +To customize the location of this storage, set the environment variable +`TILED_CACHE_DIR`. +``` + +Some Tiled servers are configured to always require login, disallowing any +anonymous access. For those, the client will prompt immediately, such as: + + >>> from tiled.client import from_uri + >>> client = from_uri("https://...") + Username: + ``` + +## Noninteractive Authentication (API keys) + +There are environments where logging in interactively is not possible, +such as running a batch script. For these applications, we recommend +using an API key. These can be created from the CLI: + +```sh +$ tiled login +$ tiled api_key create --expires-in 7d --note "for this week's experiment" +``` + +or from an interactive Python session: + +```py +>>> client = from_uri("https://...") +>>> client.login() +>>> client.create_api_key(expires_in="7d", note="for this week's experiment") +{"secret": ...} + ``` + +The expiration and note are optional, but recommended. Expiration can be given +in units of years `y`, days `d`, hours `h`, minutes `m`, or seconds `s`. + +``` + +The best way to provide an API key is to set the environment variable +`TILED_API_KEY`. A script like this: + +```py +from tiled.client import from_uri + +client = from_uri("https://....") +``` + +will detect that `TILED_API_KEY` is set and use that API key for +authentication with Tiled. This is equivalent to: + +```py +import os +from tiled.client import from_uri + +client = from_uri("https://....", api_key=os.environ["TILED_API_KEY"]) +``` + +Avoid typing the API key in to the code: + +```py +from_uri("https://...", api_key="secret!") # DON'T +``` + +as it is easy to accidentally share or leak. + +## Custom Applications + +Custom applications, such as a graphical interfaces that wrap Tiled, may not be +able to use Tiled commandline-based prompts. They should avoid using the +convenience functions `tiled.client.construtors.from_uri` and +`tiled.client.construtors.from_profile`. + +They may implement their own interfaces for collecting credentials (for +password grants) or launching a browser and waiting for the user to authorize a +session (for device code grants). The functions +`tiled.client.context.password_grant` and +`tiled.client.context.device_code_grant` may be useful building blocks. The +tokens obtained from this process may then be passed directly in to the Tiled +client like so. + + +```py +from tiled.client import Context + +URI = "https://..." +context, node_path_parts = Context.from_any_uri(URI) +tokens, remember_me = launch_custom_interface() +context.configure_auth(tokens, remember_me=remember_me) +client = from_context(context, node_path_parts=node_path_parts) +``` + +The client will transparently handle OAuth2 refresh flow. If the session is +revoked or expires, and an attempt at refreshing the tokens is thus rejected +by the server, the exception `tiled.client.auth.CannotRefreshAuthentication` +will be raised. The application should be prepared to catch that exception +and reinitiate authentication. diff --git a/docs/source/index.md b/docs/source/index.md index 2940cfd3f..5dd157fb9 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -18,6 +18,7 @@ tutorials/plotly-integration ```{toctree} :caption: How To Guides +how-to/authentication how-to/profiles how-to/client-logger how-to/docker diff --git a/pyproject.toml b/pyproject.toml index 869a63dea..70a91f7d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,7 +92,7 @@ all = [ "uvicorn[standard]", "watchfiles", "xarray", - "zarr", + "zarr <3", "zstandard", ] # These are needed by the client and server to transmit/receive arrays. @@ -219,7 +219,7 @@ minimal-server = [ "starlette", "typer", "uvicorn[standard]", - "zarr", + "zarr <3", ] # This is the "kichen sink" fully-featured server dependency set. server = [ @@ -270,7 +270,7 @@ server = [ "typer", "uvicorn[standard]", "xarray", - "zarr", + "zarr <3", "zstandard", ] # These are needed by the client and server to transmit/receive sparse arrays. diff --git a/tiled/_tests/test_access_control.py b/tiled/_tests/test_access_control.py index 69c14c5e1..7e07a4181 100644 --- a/tiled/_tests/test_access_control.py +++ b/tiled/_tests/test_access_control.py @@ -215,8 +215,9 @@ def context(tmpdir_module): } app = build_app_from_config(config) with Context.from_app(app) as context: + admin_client = from_context(context) with enter_username_password("admin", "admin"): - admin_client = from_context(context, username="admin") + admin_client.login() for k in ["c", "d", "e"]: admin_client[k].write_array(arr, key="A1") admin_client[k].write_array(arr, key="A2") @@ -229,16 +230,18 @@ def context(tmpdir_module): def test_entry_based_scopes(context, enter_username_password): + alice_client = from_context(context) with enter_username_password("alice", "secret1"): - alice_client = from_context(context, username="alice") + alice_client.login() with pytest.raises(ClientError, match="Not enough permissions"): alice_client["h"]["x"].write(arr_zeros) alice_client["h"]["y"].write(arr_zeros) def test_top_level_access_control(context, enter_username_password): + alice_client = from_context(context) with enter_username_password("alice", "secret1"): - alice_client = from_context(context, username="alice") + alice_client.login() assert "a" in alice_client assert "A2" in alice_client["a"] assert "A1" not in alice_client["a"] @@ -252,9 +255,11 @@ def test_top_level_access_control(context, enter_username_password): alice_client["b"] with pytest.raises(KeyError): alice_client["g"]["A4"] + alice_client.logout() + bob_client = from_context(context) with enter_username_password("bob", "secret2"): - bob_client = from_context(context, username="bob") + bob_client.login() assert not list(bob_client) with pytest.raises(KeyError): bob_client["a"] @@ -262,16 +267,13 @@ def test_top_level_access_control(context, enter_username_password): bob_client["b"] with pytest.raises(KeyError): bob_client["g"]["A3"] - alice_client.logout() - - # Make sure clearing default identity works without raising an error. - bob_client.logout(clear_default=True) + bob_client.logout() def test_access_control_with_api_key_auth(context, enter_username_password): # Log in, create an API key, log out. with enter_username_password("alice", "secret1"): - context.authenticate(username="alice") + context.authenticate() key_info = context.create_api_key() context.logout() @@ -288,8 +290,9 @@ def test_access_control_with_api_key_auth(context, enter_username_password): def test_node_export(enter_username_password, context, buffer): "Exporting a node should include only the children we can see." + alice_client = from_context(context) with enter_username_password("alice", "secret1"): - alice_client = from_context(context, username="alice") + alice_client.login() alice_client.export(buffer, format="application/json") alice_client.logout() buffer.seek(0) @@ -306,8 +309,9 @@ def test_node_export(enter_username_password, context, buffer): def test_create_and_update_allowed(enter_username_password, context): + alice_client = from_context(context) with enter_username_password("alice", "secret1"): - alice_client = from_context(context, username="alice") + alice_client.login() # Update alice_client["c"]["x"].metadata @@ -325,8 +329,9 @@ def test_create_and_update_allowed(enter_username_password, context): def test_writing_blocked_by_access_policy(enter_username_password, context): + alice_client = from_context(context) with enter_username_password("alice", "secret1"): - alice_client = from_context(context, username="alice") + alice_client.login() alice_client["d"]["x"].metadata with fail_with_status_code(HTTP_403_FORBIDDEN): alice_client["d"]["x"].update_metadata(metadata={"added_key": 3}) @@ -334,8 +339,9 @@ def test_writing_blocked_by_access_policy(enter_username_password, context): def test_create_blocked_by_access_policy(enter_username_password, context): + alice_client = from_context(context) with enter_username_password("alice", "secret1"): - alice_client = from_context(context, username="alice") + alice_client.login() with fail_with_status_code(HTTP_403_FORBIDDEN): alice_client["e"].write_array([1, 2, 3]) alice_client.logout() @@ -397,7 +403,8 @@ def test_service_principal_access(tmpdir): } with Context.from_app(build_app_from_config(config)) as context: with enter_username_password("admin", "admin"): - admin_client = from_context(context, username="admin") + # Prompts for login here because anonymous access is not allowed + admin_client = from_context(context) sp = admin_client.context.admin.create_service_principal("user") key_info = admin_client.context.admin.create_api_key(sp["uuid"]) admin_client.write_array([1, 2, 3], key="x") diff --git a/tiled/_tests/test_authentication.py b/tiled/_tests/test_authentication.py index 9d9163c3a..b78c9f021 100644 --- a/tiled/_tests/test_authentication.py +++ b/tiled/_tests/test_authentication.py @@ -13,7 +13,7 @@ from ..adapters.mapping import MapAdapter from ..client import Context, from_context from ..client.auth import CannotRefreshAuthentication -from ..client.context import CannotPrompt, clear_default_identity, get_default_identity +from ..client.context import PasswordRejected from ..server import authentication from ..server.app import build_app_from_config from .utils import fail_with_status_code @@ -70,66 +70,48 @@ def test_password_auth(enter_username_password, config): with Context.from_app(build_app_from_config(config)) as context: # Log in as Alice. with enter_username_password("alice", "secret1"): - from_context(context, username="alice") + from_context(context) # Reuse token from cache. - client = from_context(context, username="alice") + client = from_context(context) assert "authenticated as 'alice'" in repr(client.context) client.logout() assert "unauthenticated" in repr(client.context) # Log in as Bob. with enter_username_password("bob", "secret2"): - client = from_context(context, username="bob") + client = from_context(context) assert "authenticated as 'bob'" in repr(client.context) client.logout() # Bob's password should not work for Alice. - with fail_with_status_code(HTTP_401_UNAUTHORIZED): + with pytest.raises(PasswordRejected): with enter_username_password("alice", "secret2"): - from_context(context, username="alice") + from_context(context) # Empty password should not work. - with fail_with_status_code(HTTP_401_UNAUTHORIZED): + with pytest.raises(PasswordRejected): with enter_username_password("alice", ""): - from_context(context, username="alice") + from_context(context) -def test_password_auth_hook(config): - """Verify behavior with user-defined 'prompt_for_reauthentication' hook.""" +def test_remember_me(enter_username_password, config): with Context.from_app(build_app_from_config(config)) as context: # Log in as Alice. - context.authenticate(username="alice", password="secret1") + with enter_username_password("alice", "secret1"): + from_context(context) # default: remember_me=True + with Context.from_app(build_app_from_config(config)) as context: + from_context(context) + # Cached tokens are used, with no prompt. assert "authenticated as 'alice'" in repr(context) - # Attempting to reauth without a prompt hook should raise. - with pytest.raises(CannotPrompt): - context.http_client.auth.sync_clear_token("refresh_token") - context.http_client.auth.sync_clear_token("access_token") - context.authenticate(username="alice", prompt_for_reauthentication=False) - - # Log in as Bob. - context.authenticate(username="bob", password="secret2") - assert "authenticated as 'bob'" in repr(context) - context.logout() - - # Bob's password should not work for Alice. - with fail_with_status_code(HTTP_401_UNAUTHORIZED): - context.authenticate(username="alice", password="secret2") - - # Empty password should not work. - with fail_with_status_code(HTTP_401_UNAUTHORIZED): - context.authenticate(username="alice", password="") - - # Hook for reauthenticating as Alice should succeeed - context.authenticate(username="alice", password="secret1") - assert "authenticated as 'alice'" in repr(context) - context.http_client.auth.sync_clear_token("refresh_token") - context.http_client.auth.sync_clear_token("access_token") - context.authenticate( - username="alice", - prompt_for_reauthentication=lambda u, p: ("alice", "secret1"), - ) + with Context.from_app(build_app_from_config(config)) as context: + # Log in again, but set remember_me=False to opt out of cache. + with enter_username_password("alice", "secret1"): + from_context(context, remember_me=False) assert "authenticated as 'alice'" in repr(context) + with Context.from_app(build_app_from_config(config)) as context: + # No tokens are cached. + assert not context.use_cached_tokens() def test_logout(enter_username_password, config, tmpdir): @@ -139,9 +121,9 @@ def test_logout(enter_username_password, config, tmpdir): with Context.from_app(build_app_from_config(config)) as context: # Log in as Alice. with enter_username_password("alice", "secret1"): - from_context(context, username="alice") + from_context(context) # Reuse token from cache. - client = from_context(context, username="alice") + client = from_context(context) # This was set to a unique (temporary) dir by an autouse fixture in conftest.py. tiled_cache_dir = os.environ["TILED_CACHE_DIR"] # Make a backup copy of the cache directory, which contains the auth tokens. @@ -159,7 +141,7 @@ def test_logout(enter_username_password, config, tmpdir): # There is no way to revoke a JWT access token. It expires after a # short time window (minutes) but it will still work here, as it has # not been that long. - client = from_context(context, username="alice") + client = from_context(context) # The refresh token refers to a revoked session, so refreshing the # session to generate a *new* access and refresh token will fail. with pytest.raises(CannotRefreshAuthentication): @@ -175,9 +157,9 @@ def test_key_rotation(enter_username_password, config): with Context.from_app(build_app_from_config(config)) as context: # Obtain refresh token. with enter_username_password("alice", "secret1"): - from_context(context, username="alice") + from_context(context) # Use refresh token (no prompt to reauthenticate). - client = from_context(context, username="alice") + client = from_context(context) # Rotate in a new key. config["authentication"]["secret_keys"].insert(0, "NEW_SECRET") @@ -185,7 +167,7 @@ def test_key_rotation(enter_username_password, config): with Context.from_app(build_app_from_config(config)) as context: # The refresh token from the old key is still valid. No login prompt here. - client = from_context(context, username="alice") + client = from_context(context) # We reauthenticate and receive a refresh token for the new key. # (This would happen on its own with the passage of time, but we force it # for the sake of a quick test.) @@ -197,7 +179,7 @@ def test_key_rotation(enter_username_password, config): with Context.from_app(build_app_from_config(config)) as context: # New refresh token works with the new key - from_context(context, username="alice") + from_context(context) def test_refresh_forced(enter_username_password, config): @@ -205,7 +187,7 @@ def test_refresh_forced(enter_username_password, config): with Context.from_app(build_app_from_config(config)) as context: # Normal default configuration: a refresh is not immediately required. with enter_username_password("alice", "secret1"): - client = from_context(context, username="alice") + client = from_context(context) tokens1 = dict(client.context.tokens) # Wait for a moment or we will get a new token that happens to be identical # to the old token. This advances the expiration time to make a distinct token. @@ -222,7 +204,7 @@ def test_refresh_transparent(enter_username_password, config): config["authentication"]["access_token_max_age"] = 1 with Context.from_app(build_app_from_config(config)) as context: with enter_username_password("alice", "secret1"): - client = from_context(context, username="alice") + client = from_context(context) tokens1 = dict(client.context.tokens) time.sleep(2) # A refresh should happen automatically now. @@ -236,7 +218,7 @@ def test_expired_session(enter_username_password, config): config["authentication"]["session_max_age"] = 1 with Context.from_app(build_app_from_config(config)) as context: with enter_username_password("alice", "secret1"): - client = from_context(context, username="alice") + client = from_context(context) time.sleep(2) # Refresh should fail because the session is too old. with pytest.raises(CannotRefreshAuthentication): @@ -246,7 +228,7 @@ def test_expired_session(enter_username_password, config): def test_revoke_session(enter_username_password, config): with Context.from_app(build_app_from_config(config)) as context: with enter_username_password("alice", "secret1"): - client = from_context(context, username="alice") + client = from_context(context) # Get the current session ID. info = client.context.whoami() (session,) = info["sessions"] @@ -288,13 +270,13 @@ def test_multiple_providers(enter_username_password, config, monkeypatch): with Context.from_app(build_app_from_config(config)) as context: monkeypatch.setattr("sys.stdin", io.StringIO("1\n")) with enter_username_password("alice", "secret1"): - from_context(context, username="alice") + from_context(context) monkeypatch.setattr("sys.stdin", io.StringIO("2\n")) with enter_username_password("cara", "secret3"): - from_context(context, username="cara") + from_context(context) monkeypatch.setattr("sys.stdin", io.StringIO("3\n")) with enter_username_password("cara", "secret5"): - from_context(context, username="cara") + from_context(context) def test_multiple_providers_name_collision(config): @@ -329,7 +311,7 @@ def test_admin(enter_username_password, config): with Context.from_app(build_app_from_config(config)) as context: with enter_username_password("alice", "secret1"): - context.authenticate(username="alice") + context.authenticate() admin_roles = context.whoami()["roles"] assert "admin" in [role["name"] for role in admin_roles] @@ -339,7 +321,7 @@ def test_admin(enter_username_password, config): context.admin.show_principal(some_principal_uuid) with enter_username_password("bob", "secret2"): - context.authenticate(username="bob") + context.authenticate() user_roles = context.whoami()["roles"] assert [role["name"] for role in user_roles] == ["user"] @@ -352,7 +334,7 @@ def test_admin(enter_username_password, config): # Start the server a second time. Now alice is already an admin. with Context.from_app(build_app_from_config(config)) as context: with enter_username_password("alice", "secret1"): - context.authenticate(username="alice") + context.authenticate() admin_roles = context.whoami()["roles"] assert "admin" in [role["name"] for role in admin_roles] @@ -364,7 +346,7 @@ def test_api_key_activity(enter_username_password, config): with Context.from_app(build_app_from_config(config)) as context: # Log in as user. with enter_username_password("alice", "secret1"): - context.authenticate(username="alice") + context.authenticate() # Make and use an API key. Check that latest_activity is not set. key_info = context.create_api_key() context.logout() @@ -406,7 +388,7 @@ def test_api_key_scopes(enter_username_password, config): with Context.from_app(build_app_from_config(config)) as context: # Log in as admin. with enter_username_password("alice", "secret1"): - context.authenticate(username="alice") + context.authenticate() # Request a key with reduced scope that cannot read metadata. metrics_key_info = context.create_api_key(scopes=["metrics"]) context.logout() @@ -417,7 +399,7 @@ def test_api_key_scopes(enter_username_password, config): # Log in as ordinary user. with enter_username_password("bob", "secret2"): - context.authenticate(username="bob") + context.authenticate() # Try to request a key with more scopes that the user has. with fail_with_status_code(HTTP_400_BAD_REQUEST): context.create_api_key(scopes=["admin:apikeys"]) @@ -435,7 +417,7 @@ def test_api_key_scopes(enter_username_password, config): def test_api_key_revoked(enter_username_password, config): with Context.from_app(build_app_from_config(config)) as context: with enter_username_password("alice", "secret1"): - context.authenticate(username="alice") + context.authenticate() # Create a key with a note. NOTE = "will revoke soon" @@ -453,7 +435,7 @@ def test_api_key_revoked(enter_username_password, config): # Revoke the new key. with enter_username_password("alice", "secret1"): - context.authenticate(username="alice") + context.authenticate() context.revoke_api_key(key_info["first_eight"]) assert len(context.whoami()["api_keys"]) == 0 context.logout() @@ -465,7 +447,7 @@ def test_api_key_revoked(enter_username_password, config): def test_api_key_expiration(enter_username_password, config): with Context.from_app(build_app_from_config(config)) as context: with enter_username_password("alice", "secret1"): - context.authenticate(username="alice") + context.authenticate() # Create a key with a very short lifetime. key_info = context.create_api_key( note="will expire very soon", expires_in=1 @@ -484,7 +466,7 @@ def test_api_key_limit(enter_username_password, config): try: with Context.from_app(build_app_from_config(config)) as context: with enter_username_password("bob", "secret2"): - context.authenticate(username="bob") + context.authenticate() for i in range(authentication.API_KEY_LIMIT): context.create_api_key(note=f"key {i}") # Hit API key limit. @@ -503,43 +485,15 @@ def test_session_limit(enter_username_password, config): with Context.from_app(build_app_from_config(config)) as context: with enter_username_password("alice", "secret1"): for i in range(authentication.SESSION_LIMIT): - context.authenticate(username="alice") + context.authenticate() context.logout() # Hit Session limit. with fail_with_status_code(HTTP_400_BAD_REQUEST): - context.authenticate(username="alice") + context.authenticate() finally: authentication.SESSION_LIMIT = original_limit -def test_sticky_identity(enter_username_password, config): - # Log in as Alice. - with Context.from_app(build_app_from_config(config)) as context: - assert get_default_identity(context.api_uri) is None - with enter_username_password("alice", "secret1"): - context.authenticate(username="alice") - assert context.whoami()["identities"][0]["id"] == "alice" - # The default identity is now set. The login was "sticky". - with Context.from_app(build_app_from_config(config)) as context: - assert get_default_identity(context.api_uri) is not None - context.authenticate() - assert context.whoami()["identities"][0]["id"] == "alice" - # Opt out of the stickiness (set_default=False). - with Context.from_app(build_app_from_config(config)) as context: - assert get_default_identity(context.api_uri) is not None - with enter_username_password("bob", "secret2"): - context.authenticate(username="bob", set_default=False) - assert context.whoami()["identities"][0]["id"] == "bob" - # The default is still Alice. - with Context.from_app(build_app_from_config(config)) as context: - assert get_default_identity(context.api_uri) is not None - context.authenticate() - assert context.whoami()["identities"][0]["id"] == "alice" - # Clear the default. - clear_default_identity(context.api_uri) - assert get_default_identity(context.api_uri) is None - - @pytest.fixture def principals_context(enter_username_password, config): """ @@ -551,7 +505,7 @@ def principals_context(enter_username_password, config): with Context.from_app(build_app_from_config(config)) as context: # Log in as Alice and retrieve admin UUID for later use with enter_username_password("alice", "secret1"): - context.authenticate(username="alice") + context.authenticate() principal = context.whoami() assert "admin" in (role["name"] for role in principal["roles"]) @@ -560,7 +514,7 @@ def principals_context(enter_username_password, config): # Log in as Bob and retrieve Bob's UUID for later use with enter_username_password("bob", "secret2"): - context.authenticate(username="bob") + context.authenticate() principal = context.whoami() assert "admin" not in (role["name"] for role in principal["roles"]) @@ -589,7 +543,7 @@ def test_admin_api_key_any_principal( with principals_context["context"] as context: # Log in as Alice, create and use API key after logout with enter_username_password("alice", "secret1"): - context.authenticate(username="alice") + context.authenticate() principal_uuid = principals_context["uuid"][username] api_key_info = context.admin.create_api_key(principal_uuid, scopes=scopes) @@ -612,7 +566,7 @@ def test_admin_create_service_principal(enter_username_password, principals_cont with principals_context["context"] as context: # Log in as Alice, create and use API key after logout with enter_username_password("alice", "secret1"): - context.authenticate(username="alice") + context.authenticate() assert context.whoami()["type"] == "user" @@ -638,7 +592,7 @@ def test_admin_api_key_any_principal_exceeds_scopes( with principals_context["context"] as context: # Log in as Alice, create and use API key after logout with enter_username_password("alice", "secret1"): - context.authenticate(username="alice") + context.authenticate() principal_uuid = principals_context["uuid"]["bob"] with fail_with_status_code(HTTP_400_BAD_REQUEST) as fail_info: @@ -656,7 +610,7 @@ def test_api_key_any_principal(enter_username_password, principals_context, user with principals_context["context"] as context: # Log in as Bob, this API endpoint is unauthorized with enter_username_password("bob", "secret2"): - context.authenticate(username="bob") + context.authenticate() principal_uuid = principals_context["uuid"][username] with fail_with_status_code(HTTP_401_UNAUTHORIZED): @@ -670,7 +624,7 @@ def test_api_key_bypass_scopes(enter_username_password, principals_context): with principals_context["context"] as context: # Log in as Bob, create API key with empty scopes with enter_username_password("bob", "secret2"): - context.authenticate(username="bob") + context.authenticate() response = context.http_client.post( "/api/v1/auth/apikey", json={"expires_in": None, "scopes": []} @@ -705,7 +659,7 @@ def test_admin_delete_principal_apikey( with principals_context["context"] as context: # Log in as Bob (Ordinary user) with enter_username_password("bob", "secret2"): - context.authenticate(username="bob") + context.authenticate() # Create an ordinary user API Key principal_uuid = principals_context["uuid"]["bob"] @@ -714,7 +668,7 @@ def test_admin_delete_principal_apikey( # Log in as Alice (Admin) with enter_username_password("alice", "secret1"): - context.authenticate(username="alice") + context.authenticate() # Delete the created API Key via service principal context.admin.revoke_api_key(principal_uuid, api_key_info["first_eight"]) diff --git a/tiled/_tests/test_catalog.py b/tiled/_tests/test_catalog.py index a4c915ec0..d40dc5548 100644 --- a/tiled/_tests/test_catalog.py +++ b/tiled/_tests/test_catalog.py @@ -417,14 +417,16 @@ async def test_access_control(tmpdir): app = build_app_from_config(config) with Context.from_app(app) as context: + admin_client = from_context(context) with enter_username_password("admin", "admin"): - admin_client = from_context(context, username="admin") + admin_client.login() for key in ["outer_x", "outer_y", "outer_z"]: container = admin_client.create_container(key) container.write_array([1, 2, 3], key="inner") admin_client.logout() + alice_client = from_context(context) with enter_username_password("alice", "secret1"): - alice_client = from_context(context, username="alice") + alice_client.login() alice_client["outer_x"]["inner"].read() with pytest.raises(KeyError): alice_client["outer_y"] diff --git a/tiled/_tests/test_pickle.py b/tiled/_tests/test_pickle.py index 825de76bf..aee17896a 100644 --- a/tiled/_tests/test_pickle.py +++ b/tiled/_tests/test_pickle.py @@ -30,13 +30,13 @@ def test_pickle_clients(structure_clients, tmpdir): httpx.get(API_URL).raise_for_status() except Exception: raise pytest.skip(f"Could not connect to {API_URL}") - with Context(API_URL) as context: + cache = Cache(tmpdir / "http_response_cache.db") + with Context(API_URL, cache=cache) as context: if parse(context.server_info["library_version"]) < parse(MIN_VERSION): raise pytest.skip( f"Server at {API_URL} is running too old a version to test against." ) - cache = Cache(tmpdir / "http_response_cache.db") - client = from_context(context, structure_clients, cache) + client = from_context(context, structure_clients) pickle.loads(pickle.dumps(client)) for segements in [ ["generated"], diff --git a/tiled/_tests/utils.py b/tiled/_tests/utils.py index ffbe8d2d0..15ecec6cd 100644 --- a/tiled/_tests/utils.py +++ b/tiled/_tests/utils.py @@ -61,16 +61,16 @@ def enter_username_password(username, password): ... # Run code that calls prompt_for_credentials and subsequently getpass.getpass(). """ - original_prompt = context.PROMPT_FOR_REAUTHENTICATION - original_credentials = context.prompt_for_credentials - context.PROMPT_FOR_REAUTHENTICATION = True - context.prompt_for_credentials = lambda u, p: (username, password) + original_username_input = context.username_input + context.username_input = lambda: username + original_password_input = context.password_input + context.password_input = lambda: password try: # Ensures that raise in calling routine does not prevent context from being exited. yield finally: - context.PROMPT_FOR_REAUTHENTICATION = original_prompt - context.prompt_for_credentials = original_credentials + context.username_input = original_username_input + context.password_input = original_password_input class URL_LIMITS(IntEnum): diff --git a/tiled/client/auth.py b/tiled/client/auth.py index 02da6818a..6430a7903 100644 --- a/tiled/client/auth.py +++ b/tiled/client/auth.py @@ -14,18 +14,19 @@ class TiledAuth(httpx.Auth): def __init__(self, refresh_url, csrf_token, token_directory): self.refresh_url = refresh_url self.csrf_token = csrf_token - self.token_directory = Path(token_directory) - self.token_directory.mkdir(exist_ok=True, parents=True) + if token_directory is not None: + token_directory = Path(token_directory) + token_directory.mkdir(exist_ok=True, parents=True) + self._check_writable_token_directory(token_directory) + self.token_directory = token_directory self._sync_lock = SerializableLock() # self._async_lock = asyncio.Lock() self.tokens = {} - self._check_writable_token_directory() - def _check_writable_token_directory(self): - if not os.access(self.token_directory, os.W_OK | os.X_OK): - raise ValueError( - f"The token_directory {self.token_directory} is not writable." - ) + @staticmethod + def _check_writable_token_directory(token_directory): + if not os.access(token_directory, os.W_OK | os.X_OK): + raise ValueError(f"The token_directory {token_directory} is not writable.") def __getstate__(self): return ( @@ -44,9 +45,10 @@ def __setstate__(self, state): (refresh_url, csrf_token, token_directory, sync_lock) = state self.refresh_url = refresh_url self.csrf_token = csrf_token + if token_directory is not None: + self._check_writable_token_directory(token_directory) self.token_directory = token_directory self._sync_lock = sync_lock - self._check_writable_token_directory() def sync_get_token(self, key, reload_from_disk=False): if not reload_from_disk: @@ -54,7 +56,8 @@ def sync_get_token(self, key, reload_from_disk=False): try: return self.tokens[key] except Exception: - pass + if self.token_directory is None: + return None with self._sync_lock: filepath = self.token_directory / key try: @@ -69,17 +72,18 @@ def sync_set_token(self, key, value): with self._sync_lock: if not isinstance(value, str): raise ValueError("Expected string value, got {value!r}") - filepath = self.token_directory / key - filepath.touch(mode=0o600) # Set permissions. - with open(filepath, "w") as file: - file.write(value) + if self.token_directory is not None: + filepath = self.token_directory / key + filepath.touch(mode=0o600) # Set permissions. + with open(filepath, "w") as file: + file.write(value) self.tokens[key] = value # Update cached value. def sync_clear_token(self, key): with self._sync_lock: - self.tokens.pop(key, None) - filepath = self.token_directory / key - filepath.unlink(missing_ok=False) + if self.token_directory is not None: + filepath = self.token_directory / key + filepath.unlink(missing_ok=False) self.tokens.pop(key, None) # Clear cached value. def sync_auth_flow(self, request, attempt=0): diff --git a/tiled/client/base.py b/tiled/client/base.py index 41fd4e4a2..0e31d537e 100644 --- a/tiled/client/base.py +++ b/tiled/client/base.py @@ -153,7 +153,7 @@ def structure(self): ) return self._structure - def login(self, username=None, provider=None): + def login(self): """ Depending on the server's authentication method, this will prompt for username/password: @@ -170,15 +170,15 @@ def login(self, username=None, provider=None): and enter the code: XXXX-XXXX """ - self.context.login(username=username, provider=provider) + self.context.login() - def logout(self, clear_default=False): + def logout(self): """ Log out. This method is idempotent: if you are already logged out, it will do nothing. """ - self.context.logout(clear_default=clear_default) + self.context.logout() def __repr__(self): return f"<{type(self).__name__}>" diff --git a/tiled/client/constructors.py b/tiled/client/constructors.py index bcb38a253..59b881eaa 100644 --- a/tiled/client/constructors.py +++ b/tiled/client/constructors.py @@ -1,5 +1,6 @@ import collections import collections.abc +import warnings from urllib.parse import parse_qs, urlparse import httpx @@ -7,7 +8,7 @@ from ..utils import import_object, prepend_to_sys_path from .container import DEFAULT_STRUCTURE_CLIENT_DISPATCH, Container from .context import DEFAULT_TIMEOUT_PARAMS, UNSET, Context -from .utils import MSGPACK_MIME_TYPE, ClientError, client_for_item, handle_error +from .utils import MSGPACK_MIME_TYPE, client_for_item, handle_error def from_uri( @@ -15,11 +16,12 @@ def from_uri( structure_clients="numpy", *, cache=UNSET, - username=UNSET, - auth_provider=UNSET, + remember_me=True, + username=None, + auth_provider=None, api_key=None, verify=True, - prompt_for_reauthentication=UNSET, + prompt_for_reauthentication=None, headers=None, timeout=None, include_data_sources=False, @@ -37,23 +39,19 @@ def from_uri( DataFrames). For advanced use, provide dict mapping a structure_family or a spec to a client object. cache : Cache, optional + remember_me : bool + Next time, try to automatically authenticate using this session. username : str, optional - Username for authenticated access. If UNSET, use default if available - (typically, the most recently used). + DEPRECATED. Ignored, and issues a warning if passed. auth_provider : str, optional - Name of an authentication provider. IF UNSET, use default if available - (typically, the most recently used). If None and the server supports - multiple provides, the user will be interactively prompted to - choose from a list. + DEPRECATED. Ignored, and issues a warning if passed. api_key : str, optional - API key based authentication. Cannot mix with username/auth_provider. + API key based authentication. verify : bool, optional Verify SSL certifications. True by default. False is insecure, intended for development and testing only. prompt_for_reauthentication : bool, optional - If True, prompt interactively for credentials if needed. If False, - raise an error. By default, attempt to detect whether terminal is - interactive (is a TTY). + DEPRECATED. Ignored, and issue a warning if passed. headers : dict, optional Extra HTTP headers. timeout : httpx.Timeout, optional @@ -62,6 +60,24 @@ def from_uri( include_data_sources : bool, optional Default False. If True, fetch information about underlying data sources. """ + EXPLAIN_LOGIN = """ + +The user will be prompted for credentials if login is required. +Or, call login() to manually login. + +For non-interactive authentication, use an API key. +""" + if username is not None: + warnings.warn("Tiled no longer accepts 'username' parameter. " + EXPLAIN_LOGIN) + if auth_provider is not None: + warnings.warn( + "Tiled no longer accepts 'auth_provider' parameter. " + EXPLAIN_LOGIN + ) + if auth_provider is not None: + warnings.warn( + "Tiled no longer accepts 'prompt_for_reauthentication' parameter. " + + EXPLAIN_LOGIN + ) context, node_path_parts = Context.from_any_uri( uri, api_key=api_key, @@ -73,22 +89,18 @@ def from_uri( return from_context( context, structure_clients=structure_clients, - prompt_for_reauthentication=prompt_for_reauthentication, - username=username, - auth_provider=auth_provider, node_path_parts=node_path_parts, include_data_sources=include_data_sources, + remember_me=remember_me, ) def from_context( context, structure_clients="numpy", - prompt_for_reauthentication=UNSET, - username=UNSET, - auth_provider=UNSET, node_path_parts=None, include_data_sources=False, + remember_me=True, ): """ Advanced: Connect to a Node using a custom instance of httpx.Client or httpx.AsyncClient. @@ -101,14 +113,7 @@ def from_context( in-memory structures (e.g. normal numpy arrays, pandas DataFrames). For advanced use, provide dict mapping a structure_family or a spec to a client object. - prompt_for_reauthentication : bool, optional - If True, prompt interactively for credentials if needed. If False, - raise an error. By default, attempt to detect whether terminal is - interactive (is a TTY). """ - if (username is not UNSET) or (auth_provider is not UNSET): - if context.api_key is not None: - raise ValueError("Use api_key or username/auth_provider, not both.") node_path_parts = node_path_parts or [] # Do entrypoint discovery if it hasn't yet been done. if Container.STRUCTURE_CLIENTS_FROM_ENTRYPOINTS is None: @@ -116,56 +121,36 @@ def from_context( # Interpret structure_clients="numpy" and structure_clients="dask" shortcuts. if isinstance(structure_clients, str): structure_clients = DEFAULT_STRUCTURE_CLIENT_DISPATCH[structure_clients] - if ( - (context.api_key is None) - and context.server_info["authentication"]["required"] - and (not context.server_info["authentication"]["providers"]) - ): - raise RuntimeError( - """This server requires API key authentication. -Set an api_key as in: + # To construct a user-facing client object, we may be required to authenticate. + # 1. If any API key set, we are already authenticated and there is nothing to do. + # 2. If there are cached valid credentials for this server, use them. + # 3. If not, and the server requires authentication, prompt for authentication. + if context.api_key is None: + auth_is_required = context.server_info["authentication"]["required"] + has_providers = len(context.server_info["authentication"]["providers"]) > 0 + if auth_is_required and not has_providers: + raise RuntimeError( + """This server requires API key authentication. + Set an api_key as in: ->>> c = from_uri("...", api_key="...") -""" - ) - if username is not UNSET: - context.authenticate( - username=username, - provider=auth_provider, - prompt_for_reauthentication=prompt_for_reauthentication, - ) + >>> c = from_uri("...", api_key="...") + """ + ) + found_valid_tokens = remember_me and context.use_cached_tokens() + if (not found_valid_tokens) and auth_is_required: + context.authenticate(remember_me=remember_me) # Context ensures that context.api_uri has a trailing slash. item_uri = f"{context.api_uri}metadata/{'/'.join(node_path_parts)}" - try: - content = handle_error( - context.http_client.get( - item_uri, - headers={"Accept": MSGPACK_MIME_TYPE}, - params={ - **parse_qs(urlparse(item_uri).query), - "include_data_sources": include_data_sources, - }, - ) - ).json() - except ClientError as err: - if ( - (err.response.status_code == httpx.codes.UNAUTHORIZED) - and (context.api_key is None) - and (context.http_client.auth is None) - ): - context.authenticate() - params = (parse_qs(urlparse(item_uri).query),) - if include_data_sources: - params["include_data_sources"] = True - content = handle_error( - context.http_client.get( - item_uri, - headers={"Accept": MSGPACK_MIME_TYPE}, - params=params, - ) - ).json() - else: - raise + content = handle_error( + context.http_client.get( + item_uri, + headers={"Accept": MSGPACK_MIME_TYPE}, + params={ + **parse_qs(urlparse(item_uri).query), + "include_data_sources": include_data_sources, + }, + ) + ).json() item = content["data"] return client_for_item( context, structure_clients, item, include_data_sources=include_data_sources diff --git a/tiled/client/context.py b/tiled/client/context.py index 3ac193cc5..a1be82d08 100644 --- a/tiled/client/context.py +++ b/tiled/client/context.py @@ -1,5 +1,4 @@ import getpass -import json import os import re import sys @@ -7,7 +6,6 @@ import urllib.parse import warnings from pathlib import Path -from typing import Callable, Optional, Union from urllib.parse import parse_qs, urlparse import httpx @@ -22,25 +20,115 @@ USER_AGENT = f"python-tiled/{tiled_version}" API_KEY_AUTH_HEADER_PATTERN = re.compile(r"^Apikey (\w+)$") -PROMPT_FOR_REAUTHENTICATION = None -def prompt_for_credentials(username, password): +def raise_if_cannot_prompt(): + if not _can_prompt() and not bool(int(os.environ.get("TILED_FORCE_PROMPT", "0"))): + raise CannotPrompt( + """ +Tiled has detected that it is running in a 'headless' context where it cannot +prompt the user to provide credentials in the stdin. Options: + +- Provide an API key in the environment variable TILED_API_KEY for Tiled to + use. + +- If Tiled has detected wrongly, set the environment variable + TILED_FORCE_PROMPT=1 to override and force an interactive prompt. + +- If you are developing an application that is wraping Tiled, + obtain tokens using functions tiled.client.context.password_grant + and/or device_code_grant, and pass them like Context.authenticate(tokens=token). +""" + ) + + +def identity_provider_input(providers, provider=None): + while True: + print("Authenticaiton providers:") + for i, spec in enumerate(providers, start=1): + print(f"{i} - {spec['provider']}") + raw_choice = input( + "Choose an authentication provider (or press Enter to cancel): " + ) + if not raw_choice: + print("No authentication provider chosen. Failed.") + break + try: + choice = int(raw_choice) + except TypeError: + print("Choice must be a number.") + continue + try: + spec = providers[choice - 1] + except IndexError: + print(f"Choice must be a number 1 through {len(providers)}.") + continue + break + return spec + + +def username_input(): + raise_if_cannot_prompt() + return input("Username: ") + + +def password_input(): + raise_if_cannot_prompt() + return getpass.getpass() + + +class PasswordRejected(RuntimeError): + pass + + +def prompt_for_credentials(http_client, providers): """ - Utility function that displays a username prompt. + Prompt for credentials or third-party login at an interactive terminal. """ - if username is not None and password is not None: - # If both are provided, return them as-is, without prompting. - # This is particularly useful for GUI clients without a TTY Console. - return username, password - elif username: - username_reprompt = input(f"Username [{username}]: ") - if len(username_reprompt.strip()) != 0: - username = username_reprompt + if len(providers) == 1: + # There is only one choice, so no need to prompt the user. + (spec,) = providers + else: + spec = identity_provider_input(providers) + auth_endpoint = spec["links"]["auth_endpoint"] + provider = spec["provider"] + mode = spec["mode"] + if mode == "password": + # Prompt for username, password at terminal. + username = username_input() + PASSWORD_ATTEMPTS = 3 + for _attempt in range(PASSWORD_ATTEMPTS): + password = password_input() + if not password: + raise PasswordRejected("Password empty.") + try: + tokens = password_grant( + http_client, auth_endpoint, provider, username, password + ) + except httpx.HTTPStatusError as err: + if err.response.status_code == httpx.codes.UNAUTHORIZED: + print( + "Username or password not recognized. Retry, or press Enter to cancel." + ) + continue + raise + else: + # Sucess! We have tokens. + break + else: + # All attempts failed. + raise PasswordRejected + elif mode == "external": + # Display link and access code, and try to open web browser. + # Block while polling the server awaiting confirmation of authorization. + tokens = device_code_grant(http_client, auth_endpoint) else: - username = input("Username: ") - password = getpass.getpass() - return username, password + raise ValueError(f"Server has unknown authentication mechanism {mode!r}") + confirmation_message = spec.get("confirmation_message") + if confirmation_message: + username = tokens["identity"]["id"] + print(confirmation_message.format(id=username)) + return tokens class Context: @@ -57,7 +145,6 @@ def __init__( cache=UNSET, timeout=None, verify=True, - token_cache=None, app=None, raise_server_exceptions=True, ): @@ -78,8 +165,6 @@ def __init__( # Set the User Agent to help the server fail informatively if the client # version is too old. headers.setdefault("user-agent", USER_AGENT) - if token_cache is None: - token_cache = TILED_CACHE_DIR / "tokens" # If ?api_key=... is present, move it from the query into a header. # The server would accept it in the query parameter, but using @@ -160,7 +245,7 @@ def __init__( self.http_client = client self._verify = verify self._cache = cache - self._token_cache = Path(token_cache) + self._token_cache = Path(TILED_CACHE_DIR / "tokens") # Make an initial "safe" request to: # (1) Get the server_info. @@ -280,7 +365,6 @@ def from_any_uri( cache=UNSET, timeout=None, verify=True, - token_cache=None, app=None, ): """ @@ -323,7 +407,6 @@ def from_any_uri( cache=cache, timeout=timeout, verify=verify, - token_cache=token_cache, app=app, ) return context, node_path_parts @@ -334,7 +417,6 @@ def from_app( app, *, cache=UNSET, - token_cache=None, headers=None, timeout=None, api_key=UNSET, @@ -346,22 +428,25 @@ def from_app( context = cls( uri="http://local-tiled-app/api/v1", headers=headers, - api_key=api_key, + api_key=None, cache=cache, timeout=timeout, - token_cache=token_cache, app=app, raise_server_exceptions=raise_server_exceptions, ) - if (api_key is UNSET) and ( - not context.server_info["authentication"]["providers"] - ): - # Extract the API key from the app and set it. - from ..server.settings import get_settings - - settings = app.dependency_overrides[get_settings]() - api_key = settings.single_user_api_key or None - context.api_key = api_key + if api_key is UNSET: + if not context.server_info["authentication"]["providers"]: + # This is a single-user server. + # Extract the API key from the app and set it. + from ..server.settings import get_settings + + settings = app.dependency_overrides[get_settings]() + api_key = settings.single_user_api_key or None + else: + # This is a multi-user server but no API key was passed, + # so we will leave it as None on the Context. + api_key = None + context.api_key = api_key return context @property @@ -493,196 +578,118 @@ def event_hooks(self): def authenticate( self, - username=UNSET, - provider=UNSET, - prompt_for_reauthentication: Optional[Union[bool, Callable]] = UNSET, - set_default=True, *, - password=UNSET, + remember_me=True, ): """ - See login. This is for programmatic use. - """ - if prompt_for_reauthentication is UNSET: - prompt_for_reauthentication = PROMPT_FOR_REAUTHENTICATION - if prompt_for_reauthentication is None: - prompt_for_reauthentication = _can_prompt() - if (username is UNSET) and (provider is UNSET): - default_identity = get_default_identity(self.api_uri) - if default_identity is not None: - username = default_identity["username"] - provider = default_identity["provider"] - if username is UNSET: - username = None - if provider is UNSET: - provider = None - providers = self.server_info["authentication"]["providers"] - spec = _choose_identity_provider(providers, provider) - provider = spec["provider"] - if self.api_key is not None: - # Check that API key authenticates us as this user, - # and then either return or raise. - identities = self.whoami()["identities"] - for identity in identities: - if (identity["provider"] == provider) and (identity["id"] == username): - return - raise RuntimeError( - "An API key is set, and it is not associated with the username/provider " - f"{username}/{provider}. Unset the API key first." - ) + Log in to a Tiled server. - refresh_url = self.server_info["authentication"]["links"]["refresh_session"] - csrf_token = self.http_client.cookies["tiled_csrf"] + Depending on the server's authentication method, this will prompt for username/password: - # If we are passed a username, we can check whether we already have - # tokens stashed. - if username is not None: - token_directory = self._token_directory(provider, username) - self.http_client.auth = TiledAuth(refresh_url, csrf_token, token_directory) - # This will either: - # * Use an access_token and succeed. - # * Use a refresh_token to attempt refresh flow and succeed. - # * Use a refresh_token to attempt refresh flow and fail, raise. - # * Find no tokens and raise. - try: - self.whoami() - except CannotRefreshAuthentication: - # Continue below, where we will prompt for log in. - self.http_client.auth = None - else: - # We have a live session for the specified provider and username already. - # No need to log in again. - return - - if not prompt_for_reauthentication and password is UNSET: - raise CannotPrompt( - """Authentication is needed but Tiled has detected that it is running -in a 'headless' context where it cannot prompt the user to provide -credentials in the stdin. Options: - -- If Tiled has detected this wrongly, pass prompt_for_reauthentication=True - to force it to prompt. -- Provide an API key in the environment variable TILED_API_KEY for Tiled to use. -- Pass prompt_for_reauthentication=Callable, to generate the reauthentication via your application hook. -""" - ) - self.http_client.auth = None - mode = spec["mode"] - auth_endpoint = spec["links"]["auth_endpoint"] - if mode == "password": - username, password = ( - prompt_for_reauthentication(username, password) - if isinstance(prompt_for_reauthentication, Callable) - else prompt_for_credentials(username, password) - ) - form_data = { - "grant_type": "password", - "username": username, - "password": password, - } - token_response = self.http_client.post( - auth_endpoint, data=form_data, auth=None - ) - handle_error(token_response) - tokens = token_response.json() - elif mode == "external": - verification_response = self.http_client.post( - auth_endpoint, json={}, auth=None - ) - handle_error(verification_response) - verification = verification_response.json() - authorization_uri = verification["authorization_uri"] - print( - f""" -You have {int(verification['expires_in']) // 60} minutes visit this URL + >>> c.login() + Username: USERNAME + Password: - {authorization_uri} + or prompt you to open a link in a web browser to login with a third party: -and enter the code: + >>> c.login() + You have ... minutes to visit this URL - {verification['user_code']} + https://... -""" - ) - import webbrowser - - webbrowser.open(authorization_uri) - deadline = verification["expires_in"] + time.monotonic() - print("Waiting...", end="", flush=True) - while True: - time.sleep(verification["interval"]) - if time.monotonic() > deadline: - raise Exception("Deadline expired.") - # Intentionally do not wrap this in handle_error(...). - # Check status codes manually below. - access_response = self.http_client.post( - verification["verification_uri"], - json={ - "device_code": verification["device_code"], - "grant_type": "urn:ietf:params:oauth:grant-type:device_code", - }, - auth=None, - ) - if (access_response.status_code == httpx.codes.BAD_REQUEST) and ( - access_response.json()["detail"]["error"] == "authorization_pending" - ): - print(".", end="", flush=True) - continue - handle_error(access_response) - print("") - break - tokens = access_response.json() + and enter the code: XXXX-XXXX + + Parameters + ---------- + remember_me : bool + Next time, try to automatically authenticate using this session. + """ + # Obtain tokens via OAuth2 unless the caller has passed them. + providers = self.server_info["authentication"]["providers"] + tokens = prompt_for_credentials(self.http_client, providers) + self.configure_auth(tokens, remember_me=remember_me) + + # These two methods are aliased for convenience. + login = authenticate + + def configure_auth(self, tokens, remember_me=True): + """ + Configure Tiled client with tokens for refresh flow. + Parameters + ---------- + tokens : dict, optional + Must include keys 'access_token' and 'refresh_token' + """ + self.http_client.auth = None + if self.api_key is not None: + raise RuntimeError( + "An API key is set. Cannot use both API key and OAuth2 authentication." + ) + # Configure an httpx.Auth instance on the http_client, which + # will manage refreshing the tokens as needed. + refresh_url = self.server_info["authentication"]["links"]["refresh_session"] + csrf_token = self.http_client.cookies["tiled_csrf"] + if remember_me: + token_directory = self._token_directory() else: - raise ValueError(f"Server has unknown authentication mechanism {mode!r}") - username = tokens["identity"]["id"] - token_directory = self._token_directory(provider, username) + # Clear any existing tokens. + temp_auth = TiledAuth(refresh_url, csrf_token, self._token_directory()) + temp_auth.sync_clear_token("access_token") + temp_auth.sync_clear_token("refresh_token") + # Store tokens in memory only, with no syncing to disk. + token_directory = None auth = TiledAuth(refresh_url, csrf_token, token_directory) auth.sync_set_token("access_token", tokens["access_token"]) auth.sync_set_token("refresh_token", tokens["refresh_token"]) self.http_client.auth = auth - confirmation_message = spec.get("confirmation_message") - if confirmation_message: - print(confirmation_message.format(id=username)) - if set_default: - set_default_identity( - self.api_uri, username=username, provider=spec["provider"] - ) - return spec, username - def login(self, username=None, provider=None, prompt_for_reauthentication=UNSET): - """ - Depending on the server's authentication method, this will prompt for username/password: + def _token_directory(self): + # e.g. ~/.config/tiled/tokens/{host:port} + # with the templated element URL-encoded so it is a valid filename. + path = Path( + self._token_cache, + urllib.parse.quote_plus(str(self.api_uri)), + ) - >>> c.login() - Username: USERNAME - Password: + # If this directory already exists, it might contain subdirectories + # left by older versions of tiled that supported caching tokens for + # multiple users of one server. Clean them up. + if path.is_dir(): + import shutil - or prompt you to open a link in a web browser to login with a third party: + [shutil.rmtree(item) for item in path.iterdir() if item.is_dir()] - >>> c.login() - You have ... minutes visit this URL + return path - https://... + def use_cached_tokens(self): + """ + Attempt to reconnect using cached tokens. - and enter the code: XXXX-XXXX + Returns + ------- + success : bool + Indicating whether valid cached tokens were found """ - self.authenticate( - username, provider, prompt_for_reauthentication=prompt_for_reauthentication - ) - # For programmatic access to the return values, use authenticate(). - # This returns None in order to provide a clean UX in an interpreter. - return None - - def _token_directory(self, provider, username): - # ~/.config/tiled/tokens/{host:port}/{provider}/{username} - # with each templated element URL-encoded so it is a valid filename. - return Path( - self._token_cache, - urllib.parse.quote_plus(str(self.api_uri)), - urllib.parse.quote_plus(provider), - urllib.parse.quote_plus(username), - ) + refresh_url = self.server_info["authentication"]["links"]["refresh_session"] + csrf_token = self.http_client.cookies["tiled_csrf"] + + # Try automatically authenticating using cached tokens, if any. + token_directory = self._token_directory() + # We have to make an HTTP request to let the server validate whether we + # have a valid session. + self.http_client.auth = TiledAuth(refresh_url, csrf_token, token_directory) + # This will either: + # * Use an access_token and succeed. + # * Use a refresh_token to attempt refresh flow and succeed. + # * Use a refresh_token to attempt refresh flow and fail, raise. + # * Find no tokens and raise. + try: + self.whoami() + return True + except CannotRefreshAuthentication: + self.http_client.auth = None + return False def force_auth_refresh(self): """ @@ -729,7 +736,7 @@ def whoami(self): ) ).json() - def logout(self, clear_default=False): + def logout(self): """ Log out of the current session (if any). @@ -759,10 +766,6 @@ def logout(self, clear_default=False): self.http_client.headers.pop("Authorization", None) self.http_client.auth = None - # If requested, automatically clear the default identity - if clear_default: - clear_default_identity(self.api_uri) - def revoke_session(self, session_id): """ Revoke a Session so it cannot be refreshed. @@ -779,44 +782,6 @@ def revoke_session(self, session_id): ) -def _choose_identity_provider(providers, provider=None): - if provider is not None: - for spec in providers: - if spec["provider"] == provider: - break - else: - raise ValueError( - f"No such provider {provider}. Choices are {[spec['provider'] for spec in providers]}" - ) - else: - if len(providers) == 1: - # There is only one choice, so no need to prompt the user. - (spec,) = providers - else: - while True: - print("Authenticaiton providers:") - for i, spec in enumerate(providers, start=1): - print(f"{i} - {spec['provider']}") - raw_choice = input( - "Choose an authentication provider (or press Enter to escape): " - ) - if not raw_choice: - print("No authentication provider chosen. Failed.") - break - try: - choice = int(raw_choice) - except TypeError: - print("Choice must be a number.") - continue - try: - spec = providers[choice - 1] - except IndexError: - print(f"Choice must be a number 1 through {len(providers)}.") - continue - break - return spec - - class Admin: "Accessor for requests that require administrative privileges." @@ -937,40 +902,60 @@ def _can_prompt(): return False -def _default_identity_filepath(api_uri): - # Resolve this here, not at module scope, because the test suite - # injects TILED_CACHE_DIR env var to use a temporary directory. - TILED_CACHE_DIR = Path( - os.getenv("TILED_CACHE_DIR", platformdirs.user_cache_dir("tiled")) - ) - return Path( - TILED_CACHE_DIR, "default_identities", urllib.parse.quote_plus(str(api_uri)) - ) +def password_grant(http_client, auth_endpoint, provider, username, password): + form_data = { + "grant_type": "password", + "username": username, + "password": password, + } + token_response = http_client.post(auth_endpoint, data=form_data, auth=None) + handle_error(token_response) + return token_response.json() -def set_default_identity(api_uri, provider, username): - """ - Stash the identity used with this API so that we can reuse it by default. - """ - filepath = _default_identity_filepath(api_uri) - filepath.parent.mkdir(parents=True, exist_ok=True) - with open(filepath, "w") as file: - json.dump({"username": username, "provider": provider}, file) +def device_code_grant(http_client, auth_endpoint): + verification_response = http_client.post(auth_endpoint, json={}, auth=None) + handle_error(verification_response) + verification = verification_response.json() + authorization_uri = verification["authorization_uri"] + print( + f""" +You have {int(verification['expires_in']) // 60} minutes to visit this URL +{authorization_uri} -def get_default_identity(api_uri): - """ - Look up the default identity to use with this API. - """ - filepath = _default_identity_filepath(api_uri) - if filepath.exists(): - return json.loads(filepath.read_text()) +and enter the code: +{verification['user_code']} -def clear_default_identity(api_uri): - """ - Clear the cached default identity for this API. - """ - filepath = _default_identity_filepath(api_uri) - if filepath.exists(): - filepath.unlink() +""" + ) + import webbrowser + + webbrowser.open(authorization_uri) + deadline = verification["expires_in"] + time.monotonic() + print("Waiting...", end="", flush=True) + while True: + time.sleep(verification["interval"]) + if time.monotonic() > deadline: + raise Exception("Deadline expired.") + # Intentionally do not wrap this in handle_error(...). + # Check status codes manually below. + access_response = http_client.post( + verification["verification_uri"], + json={ + "device_code": verification["device_code"], + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }, + auth=None, + ) + if (access_response.status_code == httpx.codes.BAD_REQUEST) and ( + access_response.json()["detail"]["error"] == "authorization_pending" + ): + print(".", end="", flush=True) + continue + handle_error(access_response) + print("") + break + tokens = access_response.json() + return tokens diff --git a/tiled/commandline/_utils.py b/tiled/commandline/_utils.py index a78fcc2d3..732fd0814 100644 --- a/tiled/commandline/_utils.py +++ b/tiled/commandline/_utils.py @@ -79,6 +79,4 @@ def get_context(profile): context, _ = Context.from_any_uri( profile_content["uri"], verify=profile_content.get("verify", True) ) - if context.server_info["authentication"]["required"]: - context.authenticate() return context diff --git a/tiled/commandline/main.py b/tiled/commandline/main.py index eaa2fcc91..f7a229232 100644 --- a/tiled/commandline/main.py +++ b/tiled/commandline/main.py @@ -26,7 +26,6 @@ """ ) from err -from ..utils import UNSET cli_app = typer.Typer() @@ -60,9 +59,6 @@ def login( profile: Optional[str] = typer.Option( None, help="If you use more than one Tiled server, use this to specify which." ), - set_default: bool = typer.Option( - True, help="Use this identity as the default for this API." - ), show_secret_tokens: bool = typer.Option( False, "--show-secret-tokens", help="Show secret tokens after successful login." ), @@ -75,22 +71,40 @@ def login( profile_name, profile_content = get_profile(profile) options = {"verify": profile_content.get("verify", True)} context, _ = Context.from_any_uri(profile_content["uri"], **options) - # Override sticky 'default_identity'. - # Always prompt user to specify who they want to log in as. - context.authenticate(username=None, provider=None, set_default=True) + context.authenticate() if show_secret_tokens: import json typer.echo(json.dumps(dict(context.tokens), indent=4)) +@cli_app.command("whoami") +def whoami( + profile: Optional[str] = typer.Option( + None, help="If you use more than one Tiled server, use this to specify which." + ), +): + """ + Show logged in identity. + """ + from ..client.context import Context + + profile_name, profile_content = get_profile(profile) + options = {"verify": profile_content.get("verify", True)} + context, _ = Context.from_any_uri(profile_content["uri"], **options) + context.use_cached_tokens() + whoami = context.whoami() + if whoami is None: + typer.echo("Not authenticated.") + else: + typer.echo(",".join(identity["id"] for identity in whoami["identities"])) + + @cli_app.command("logout") def logout( profile: Optional[str] = typer.Option( None, help="If you use more than one Tiled server, use this to specify which." ), - username: Optional[str] = typer.Option(None), - provider: Optional[str] = typer.Option(None), ): """ Log out. @@ -101,12 +115,8 @@ def logout( context, _ = Context.from_any_uri( profile_content["uri"], verify=profile_content.get("verify", True) ) - if username is None: - username = UNSET - if provider is None: - provider = UNSET - context.authenticate(username=username, provider=provider, set_default=False) - context.logout() + if context.use_cached_tokens(): + context.logout() @cli_app.command("tree") diff --git a/tiled/config_schemas/client_profiles.yml b/tiled/config_schemas/client_profiles.yml index bf3f35825..9f6010685 100644 --- a/tiled/config_schemas/client_profiles.yml +++ b/tiled/config_schemas/client_profiles.yml @@ -35,12 +35,15 @@ properties: username: type: string description: | - For authenticated Trees. Optional unless the Tree requires authentication. + DEPRECATED. Any value given here is ignored. Instead, credentials should + be provided interactively when needed. Or, any API key may be used for + noninteractive applications. auth_provider: type: string description: | - Authentication provider. If unspecified and there are multiple providers - supported by the server, the client will prompt the user to choose one. + DEPRECATED. Any value given here is ignored. Instead, credentials should + be provided interactively when needed. Or, any API key may be used for + noninteractive applications. headers: type: object additionalProperties: true diff --git a/tiled/profiles.py b/tiled/profiles.py index 7577217fe..35afa193c 100644 --- a/tiled/profiles.py +++ b/tiled/profiles.py @@ -10,6 +10,7 @@ import collections import collections.abc import os +import shutil import sys import warnings from functools import lru_cache @@ -20,6 +21,9 @@ from .utils import parse +TILED_CACHE_DIR = Path( + os.getenv("TILED_CACHE_DIR", platformdirs.user_cache_dir("tiled")) +) __all__ = [ "list_profiles", "load_profiles", @@ -320,6 +324,10 @@ def set_default_profile_name(name): raise ProfileNotFound(name) with open(filepath, "w") as file: file.write(name) + # Clean up cruft from older versions of Tiled. + UNUSED_DIR = TILED_CACHE_DIR / "default_identities" + if UNUSED_DIR.is_dir(): + shutil.rmtree(UNUSED_DIR) class ProfileNotFound(KeyError):