Skip to content

Clean up Python client auth #836

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Jan 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
158 changes: 158 additions & 0 deletions docs/source/how-to/authentication.md
Original file line number Diff line number Diff line change
@@ -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://...")
>>> <Container ...>
```

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
<Context authenticated as '...'>
```

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:
Comment on lines +101 to +102
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this be a good place to also recommend setting an appropriately "short" lifetime for the token, when it used on shared workstations?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, done. While I'm at it, I added support for expressions with units like "1d".


```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.


Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a good place to link:

def prompt_for_credentials(http_client, providers):
"""
Prompt for credentials or third-party login at an interactive terminal.
"""
spec = _choose_identity_provider(providers)
auth_endpoint = spec["links"]["auth_endpoint"]
provider = spec["provider"]
mode = spec["mode"]
if mode == "password":
# Prompt for username, password at terminal.
username = input("Username: ")
PASSWORD_ATTEMPTS = 3
for _attempt in range(PASSWORD_ATTEMPTS):
password = getpass.getpass()
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
else:
# Sucess! We have tokens.
break
else:
# All attempts failed.
raise RuntimeError("Password attempts failed.")
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:
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

as a sensible default.

```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.
1 change: 1 addition & 0 deletions docs/source/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -219,7 +219,7 @@ minimal-server = [
"starlette",
"typer",
"uvicorn[standard]",
"zarr",
"zarr <3",
]
# This is the "kichen sink" fully-featured server dependency set.
server = [
Expand Down Expand Up @@ -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.
Expand Down
35 changes: 21 additions & 14 deletions tiled/_tests/test_access_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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"]
Expand All @@ -252,26 +255,25 @@ 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"]
with pytest.raises(KeyError):
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()

Expand All @@ -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)
Expand All @@ -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
Expand All @@ -325,17 +329,19 @@ 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})
alice_client.logout()


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()
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading