From 125f5a988644bbb5e3949586a86507cac6f1a032 Mon Sep 17 00:00:00 2001 From: David Pastl Date: Mon, 22 Jun 2026 13:09:12 -0600 Subject: [PATCH 01/10] Moving towards common auth --- pyproject.toml | 1 + tiled/authenticators.py | 1223 ++----------------------------------- tiled/server/protocols.py | 53 +- 3 files changed, 87 insertions(+), 1190 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e14674e4c..53b579709 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ requires-python = ">=3.10" # data structures you care about. # Use pip install tiled[all] to get everything. dependencies = [ + "bluesky-authentication", "httpx >=0.20.0,!=0.23.1", "json-merge-patch", "jsonpatch", diff --git a/tiled/authenticators.py b/tiled/authenticators.py index c3126481c..8c771875a 100644 --- a/tiled/authenticators.py +++ b/tiled/authenticators.py @@ -1,1185 +1,58 @@ -import asyncio -import base64 -import functools -import logging -import re -import secrets -import uuid -from collections.abc import Iterable -from datetime import timedelta -from typing import Any, Dict, List, Mapping, Optional, cast +import warnings -import httpx -from cachetools import TTLCache, cached -from fastapi import APIRouter, Request -from fastapi.security import OAuth2, OAuth2AuthorizationCodeBearer -from jose import JWTError, jwt -from pydantic import Secret -from starlette.responses import RedirectResponse - -from .server.protocols import ( - ExternalAuthenticator, - InternalAuthenticator, - UserSessionState, -) -from .server.utils import get_root_url -from .utils import modules_available - -logger = logging.getLogger(__name__) - - -class DummyAuthenticator(InternalAuthenticator): - """ - For test and demo purposes only! - - Accept any username and any password. - - """ - - def __init__(self, confirmation_message: str = ""): - self.confirmation_message = confirmation_message - - async def authenticate(self, username: str, password: str) -> UserSessionState: - return UserSessionState(username, {}) - - -class DictionaryAuthenticator(InternalAuthenticator): - """ - For test and demo purposes only! - - Check passwords from a dictionary of usernames mapped to passwords. - """ - - configuration_schema = """ -$schema": http://json-schema.org/draft-07/schema# -type: object -additionalProperties: false -properties: - users_to_password: - type: object - description: | - Mapping usernames to password. Environment variable expansion should be - used to avoid placing passwords directly in configuration. - confirmation_message: - type: string - description: May be displayed by client after successful login. -""" - - def __init__( - self, users_to_passwords: Mapping[str, str], confirmation_message: str = "" - ): - self._users_to_passwords = users_to_passwords - self.confirmation_message = confirmation_message - - async def authenticate( - self, username: str, password: str - ) -> Optional[UserSessionState]: - true_password = self._users_to_passwords.get(username) - if not true_password: - # Username is not valid. - return - if secrets.compare_digest(true_password, password): - return UserSessionState(username, {}) - - -class PAMAuthenticator(InternalAuthenticator): - configuration_schema = """ -$schema": http://json-schema.org/draft-07/schema# -type: object -additionalProperties: false -properties: - service: - type: string - description: PAM service. Default is 'login'. - confirmation_message: - type: string - description: May be displayed by client after successful login. -""" - - def __init__(self, service: str = "login", confirmation_message: str = ""): - if not modules_available("pamela"): - raise ModuleNotFoundError( - "This PAMAuthenticator requires the module 'pamela' to be installed." - ) - self.service = service - self.confirmation_message = confirmation_message - # TODO Try to open a PAM session. - - async def authenticate( - self, username: str, password: str - ) -> Optional[UserSessionState]: - import pamela - - try: - pamela.authenticate(username, password, service=self.service) - return UserSessionState(username, {}) - except pamela.PAMError: - # Authentication failed. - return - - -class OIDCAuthenticator(ExternalAuthenticator): - configuration_schema = """ -$schema": http://json-schema.org/draft-07/schema# -type: object -additionalProperties: false -properties: - audience: - type: string - client_id: - type: string - client_secret: - type: string - well_known_uri: - type: string - confirmation_message: - type: string - redirect_on_success: - type: string - redirect_on_failure: - type: string -""" - - def __init__( - self, - audience: str, - client_id: str, - client_secret: str, - well_known_uri: str, - confirmation_message: str = "", - redirect_on_success: Optional[str] = None, - redirect_on_failure: Optional[str] = None, - ): - self._audience = audience - self._client_id = client_id - self._client_secret = Secret(client_secret) - self._well_known_url = well_known_uri - self.confirmation_message = confirmation_message - self.redirect_on_success = redirect_on_success - self.redirect_on_failure = redirect_on_failure - - @functools.cached_property - def _config_from_oidc_url(self) -> dict[str, Any]: - response: httpx.Response = httpx.get(self._well_known_url) - response.raise_for_status() - return response.json() - - @functools.cached_property - def client_id(self) -> str: - return self._client_id - - @functools.cached_property - def id_token_signing_alg_values_supported(self) -> list[str]: - return cast( - list[str], - self._config_from_oidc_url.get("id_token_signing_alg_values_supported"), - ) - - @functools.cached_property - def issuer(self) -> str: - return cast(str, self._config_from_oidc_url.get("issuer")) - - @functools.cached_property - def jwks_uri(self) -> str: - return cast(str, self._config_from_oidc_url.get("jwks_uri")) - - @functools.cached_property - def token_endpoint(self) -> str: - return cast(str, self._config_from_oidc_url.get("token_endpoint")) - - @functools.cached_property - def authorization_endpoint(self) -> httpx.URL: - return httpx.URL( - cast(str, self._config_from_oidc_url.get("authorization_endpoint")) - ) - - @functools.cached_property - def device_authorization_endpoint(self) -> str: - return cast( - str, self._config_from_oidc_url.get("device_authorization_endpoint") - ) - - @functools.cached_property - def end_session_endpoint(self) -> str: - return cast(str, self._config_from_oidc_url.get("end_session_endpoint")) - - @cached(TTLCache(maxsize=1, ttl=timedelta(hours=1).total_seconds())) - def keys(self) -> List[str]: - return httpx.get(self.jwks_uri).raise_for_status().json().get("keys", []) - - def decode_token( - self, id_token: str, access_token: Optional[str] = None - ) -> dict[str, Any]: - return jwt.decode( - id_token, - key=self.keys(), - algorithms=self.id_token_signing_alg_values_supported, - audience=self._audience, - issuer=self.issuer, - access_token=access_token, - ) - - async def authenticate(self, request: Request) -> Optional[UserSessionState]: - code = request.query_params.get("code") - if not code: - logger.warning( - "Authentication failed: No authorization code parameter provided." - ) - return None - # A proxy in the middle may make the request into something like - # 'http://localhost:8000/...' so we fix the first part but keep - # the original URI path. - redirect_uri = f"{get_root_url(request)}{request.url.path}" - response = await exchange_code( - self.token_endpoint, - code, - self._client_id, - self._client_secret.get_secret_value(), - redirect_uri, - ) - response_body = response.json() - if response.is_error: - logger.error("Authentication error: %r", response_body) - return None - response_body = response.json() - id_token = response_body["id_token"] - access_token = response_body["access_token"] - try: - verified_body = self.decode_token(id_token, access_token) - except JWTError: - logger.exception( - "Authentication error. Unverified token: %r", - jwt.get_unverified_claims(id_token), - ) - return None - return UserSessionState(verified_body["sub"], {}) - - -class ProxiedOIDCAuthenticator(OIDCAuthenticator): - configuration_schema = """ -$schema": http://json-schema.org/draft-07/schema# -type: object -additionalProperties: false -properties: - audience: - type: string - client_id: - type: string - well_known_uri: - type: string - scopes: - type: array - items: - type: string - description: | - Optional list of OAuth2 scopes to request. If provided, authorization - should be enforced by an external policy agent (for example ExternalPolicyDecisionPoint) - rather than by this authenticator. - device_flow_client_id: - type: string - confirmation_message: - type: string -""" - - def __init__( - self, - audience: str, - client_id: str, - well_known_uri: str, - device_flow_client_id: str, - scopes: Optional[List[str]] = None, - confirmation_message: str = "", - ): - super().__init__( - audience=audience, - client_id=client_id, - client_secret="", - well_known_uri=well_known_uri, - confirmation_message=confirmation_message, - ) - self.scopes = scopes - self.device_flow_client_id = device_flow_client_id - self._oidc_bearer = OAuth2AuthorizationCodeBearer( - authorizationUrl=str(self.authorization_endpoint), - tokenUrl=self.token_endpoint, - ) - - @property - def oauth2_schema(self) -> OAuth2: - return self._oidc_bearer - - -class EntraAuthenticator(ProxiedOIDCAuthenticator): - def __init__( - self, - audience: str, - client_id: str, - well_known_uri: str, - device_flow_client_id: str, - extra_scopes: Optional[List[str]] = None, - confirmation_message: str = "", - scopes_map: Optional[Dict[str, list[str]]] = None, - client_secret: str = "", - redirect_on_success: Optional[str] = None, - ): - self.scopes_map = scopes_map if scopes_map is not None else {} - self.extra_scopes = extra_scopes or [] - super().__init__( - audience, - client_id, - well_known_uri, - device_flow_client_id, - scopes=None, # not used by Entra; enforcement is via scopes_map - confirmation_message=confirmation_message, - ) - # Override the empty secret from ProxiedOIDCAuthenticator if provided. - if client_secret: - self._client_secret = Secret(client_secret) - self.redirect_on_success = redirect_on_success - - @property - def scopes(self): - mapped = set() - for tiled_scopes in self.scopes_map.values(): - mapped.update(tiled_scopes) - return list(mapped) - - @scopes.setter - def scopes(self, value): - pass # ignored; scopes are derived from scopes_map - - def decode_token( - self, id_token: str, access_token: Optional[str] = None - ) -> dict[str, Any]: - claims = super().decode_token(id_token, access_token) - - # sub generated by Entra is an opaque string; generate a stable UUID - # for Tiled based on "iss|sub" for uniqueness across tenants. - # Preserve the original Entra sub separately so it can be used as a - # fallback display name — it is more human-readable than the UUID5 hex. - original_sub = claims.get("sub") - issuer = claims.get("iss", "") - claims["sub"] = uuid.uuid5(uuid.NAMESPACE_URL, f"{issuer}|{original_sub}").hex - claims["entra_sub"] = original_sub - - # Derive a human-readable username from the token claims. - # Priority: nameID (explicit app config) → preferred_username (v2 tokens) - # → upn (v1 tokens) → email → original Entra sub (opaque but stable and - # meaningful, unlike the UUID5 hex stored in claims["sub"]). - # - # Note: preferred_username / upn are often absent from *access* tokens - # unless explicitly added as optional claims in the Entra app registration. - # They are typically present in id_tokens. If none are found, the - # original_sub is used and a warning is emitted so operators know to add - # the optional claim. - claims["entra_username"] = ( - claims.get("nameID") - or claims.get("preferred_username") - or claims.get("upn") - or claims.get("email") - ) - - if user := claims.get("entra_username"): - user = user.strip() - if "\\" in user: - user = user.rsplit("\\", 1)[-1] - elif "@" in user: - user = user.split("@", 1)[0] - else: - # No human-readable claim was found. Fall back to the original - # Entra sub (opaque but at least stable and not a UUID5 hex). - # This produces a workable identity but authz policies that match - # on username will need to use the Entra sub value. - user = original_sub - logger.warning( - "EntraAuthenticator: no human-readable username claim found in token " - "(checked nameID, preferred_username, upn, email). " - "Falling back to Entra sub=%r. " - "To fix: add 'preferred_username' as an optional claim in the " - "Entra app registration → Token configuration → Optional claims → Access token.", - original_sub, - ) - claims["user"] = user - - # Translate Entra scopes to tiled scopes. - # The "scp" claim is present in access tokens but may be absent from - # id_tokens (e.g. during the authorization code flow). When absent, - # assume all mapped scopes were granted (Entra would not have issued - # the tokens if the user lacked the requested scopes). - scp_raw = claims.get("scp", "") - tiled_scope_set = set() - if scp_raw: - for scope in scp_raw.split(" "): - mapped_scopes = self.scopes_map.get(scope) - if mapped_scopes is None: - logger.warning("Unmapped Entra scope in 'scp': %s", scope) - continue - tiled_scope_set.update(mapped_scopes) - else: - # No scp claim — grant all tiled scopes from the map. - for mapped_scopes in self.scopes_map.values(): - tiled_scope_set.update(mapped_scopes) - claims["scope"] = " ".join(tiled_scope_set) - - return claims - - async def authenticate(self, request: Request) -> Optional[UserSessionState]: - """Complete the Entra OIDC authorization-code flow and return a session. - - After a successful code exchange the Entra ``access_token`` and - ``refresh_token`` are stored in ``UserSessionState.state`` under the - keys ``entra_access_token`` and ``entra_refresh_token`` respectively. - Tiled persists this state in the session DB and embeds it verbatim in - every Tiled HMAC access token, making the tokens available to downstream - services that rely on Tiled authentication via ``get_session_state()``. - - Security note: the Entra access token is therefore visible inside the - Tiled JWT (base64-encoded, not encrypted). The Tiled access token is - short-lived (default 15 min) and only transmitted over HTTPS, which - limits the exposure window. - - The ``refresh_token`` enables silent renewal: when the Entra access - token expires (~1 h), a downstream service can call the Entra token - endpoint with ``grant_type=refresh_token`` to obtain a fresh pair and - write it back to the session DB so subsequent Tiled ``slide_session`` - calls propagate the update automatically. - """ - code = request.query_params.get("code") - if not code: - logger.warning( - "Authentication failed: No authorization code parameter provided." - ) - return None - redirect_uri = f"{get_root_url(request)}{request.url.path}" - response = await exchange_code( - self.token_endpoint, - code, - self._client_id, - self._client_secret.get_secret_value(), - redirect_uri, - extra_scopes=self.extra_scopes, - ) - response_body = response.json() - if response.is_error: - logger.error("Authentication error: %r", response_body) - return None - id_token = response_body["id_token"] - access_token = response_body.get("access_token") - refresh_token = response_body.get("refresh_token") - try: - verified_body = self.decode_token(id_token, access_token) - except JWTError: - logger.exception( - "Authentication error. Unverified token: %r", - jwt.get_unverified_claims(id_token), - ) - return None - # Log the id_token claims available for username resolution so - # misconfigurations (missing optional claims) are easy to diagnose. - # Logged at DEBUG to avoid leaking PII in production logs by default. - logger.debug( - "EntraAuthenticator.authenticate: id_token claims present: %s", - sorted(verified_body.keys()), - ) - logger.debug( - "EntraAuthenticator.authenticate: entra_username=%r user=%r entra_sub=%r", - verified_body.get("entra_username"), - verified_body.get("user"), - verified_body.get("entra_sub"), - ) - # Use the human-readable username (e.g. "dallan") instead of the - # opaque UUID-based "sub" that OIDCAuthenticator would use. - username = verified_body.get("user") or verified_body["sub"] - # Store the Entra access and refresh tokens so that downstream - # services that rely on Tiled authentication can perform an OBO exchange - # to obtain per-user tokens for other services. The refresh token - # allows silent renewal without requiring the user to re-authenticate. - state: dict = {} - if access_token: - state["entra_access_token"] = access_token - if refresh_token: - state["entra_refresh_token"] = refresh_token - return UserSessionState(username, state) - - -async def exchange_code( - token_uri: str, - auth_code: str, - client_id: str, - client_secret: str, - redirect_uri: str, - extra_scopes: Optional[List[str]] = None, -) -> httpx.Response: - """Exchange an authorization code for tokens at the IdP token endpoint. - - Explicitly requests ``openid offline_access`` scopes in the token POST body - so that the IdP returns a ``refresh_token`` unconditionally. This is safe - even when ``offline_access`` was already included in the authorization URL - scope — the IdP simply ignores duplicates. Including it here makes the - refresh token reliable regardless of how the authorization URL was - constructed, which is important for downstream OBO refresh flows. - - ``extra_scopes`` (e.g. ``["api:///access_as_user"]``) are - appended to the scope string. Entra only issues an ``access_token`` whose - ``aud`` matches the requested resource scope, so any scope that a downstream - OBO exchange will use as the ``assertion`` audience **must** be included - here — requesting it only on the authorization URL redirect is not - sufficient, because Entra does not carry scopes from the redirect into the - token POST implicitly. - """ - scopes = {"openid", "offline_access"} - if extra_scopes: - scopes.update(extra_scopes) - auth_value = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() - response = httpx.post( - url=token_uri, - data={ - "grant_type": "authorization_code", - "client_id": client_id, - "redirect_uri": redirect_uri, - "code": auth_code, - "client_secret": client_secret, - "scope": " ".join(sorted(scopes)), - }, - headers={"Authorization": f"Basic {auth_value}"}, - ) - return response - - -class SAMLAuthenticator(ExternalAuthenticator): - def __init__( - self, - saml_settings, # See EXAMPLE_SAML_SETTINGS below. - attribute_name: str, # which SAML attribute to use as 'id' for Identity - confirmation_message: str = "", - ): - self.saml_settings = saml_settings - self.attribute_name = attribute_name - self.confirmation_message = confirmation_message - self.authorization_endpoint = "/login" - - router = APIRouter() - - if not modules_available("onelogin"): - # The PyPI package name is 'python3-saml' - # but it imports as 'onelogin'. - # https://github.com/onelogin/python3-saml - raise ModuleNotFoundError( - "This SAMLAuthenticator requires 'python3-saml' to be installed." - ) - - from onelogin.saml2.auth import OneLogin_Saml2_Auth - - @router.get("/login") - async def saml_login(request: Request) -> RedirectResponse: - req = await prepare_saml_from_fastapi_request(request) - auth = OneLogin_Saml2_Auth(req, self.saml_settings) - callback_url = auth.login() - return RedirectResponse(url=callback_url) - - self.include_routers = [router] - - async def authenticate(self, request: Request) -> Optional[UserSessionState]: - if not modules_available("onelogin"): - raise ModuleNotFoundError( - "This SAMLAuthenticator requires the module 'oneline' to be installed." - ) - from onelogin.saml2.auth import OneLogin_Saml2_Auth - - req = await prepare_saml_from_fastapi_request(request, True) - auth = OneLogin_Saml2_Auth(req, self.saml_settings) - auth.process_response() # Process IdP response - errors = auth.get_errors() # This method receives an array with the errors - if errors: - raise Exception( - "Error when processing SAML Response: %s %s" - % (", ".join(errors), auth.get_last_error_reason()) - ) - if auth.is_authenticated(): - # Return a string that the Identity can use as id. - attribute_as_list = auth.get_attributes()[self.attribute_name] - # Confused in what situation this would have more than one item.... - assert len(attribute_as_list) == 1 - return UserSessionState(attribute_as_list[0], {}) - else: - return None - - -async def prepare_saml_from_fastapi_request(request: Request) -> Mapping[str, str]: - form_data = await request.form() - rv = { - "http_host": request.client.host, - "server_port": request.url.port, - "script_name": request.url.path, - "post_data": {}, - "get_data": {} - # Advanced request options - # "https": "", - # "request_uri": "", - # "query_string": "", - # "validate_signature_from_qs": False, - # "lowercase_urlencoding": False - } - if request.query_params: - rv["get_data"] = (request.query_params,) - if "SAMLResponse" in form_data: - SAMLResponse = form_data["SAMLResponse"] - rv["post_data"]["SAMLResponse"] = SAMLResponse - if "RelayState" in form_data: - RelayState = form_data["RelayState"] - rv["post_data"]["RelayState"] = RelayState - return rv +def _ensure_shared_package_on_path() -> None: + try: + import bluesky_authentication # noqa: F401 -class LDAPAuthenticator(InternalAuthenticator): - """ - The authenticator code is based on https://github.com/jupyterhub/ldapauthenticator - The parameter ``use_tls`` was added for convenience of testing. + return + except ModuleNotFoundError: + pass - Parameters - ---------- - server_address: str or list(str) - Address(es) of the LDAP server(s) to contact. A string value may represent a single - server, a list of strings may represent one or more servers. If a server address - includes port, then the value of ``server_port`` is ignored, otherwise ``server_port`` - or the default port is used to access the server. + import sys + from pathlib import Path - Could be an IP address or hostname. - server_port: int or None - Port on which to contact the LDAP server. Default port is used if ``None``. + candidate = Path(__file__).resolve().parents[2] / "bluesky-authentication" / "src" + if candidate.exists(): + sys.path.insert(0, str(candidate)) - Defaults to ``636`` if ``use_ssl`` is set, ``389`` otherwise. - use_ssl: boolean - Use SSL to communicate with the LDAP server. - Deprecated in version 3 of LDAP. Your LDAP server must be configured to support this, however. - use_tls: boolean - Enable/disable TLS if ``use_ssl`` is False. By default TLS is enabled. It should not be disabled - in production systems. +_ensure_shared_package_on_path() - connect_timeout: float - Timeout used for connecting to the LDAP server. Default: 5. - - receive_timeout: float - Timeout used for communication with the LDAP server, e.g. this timeout is used to wait for - completion of 2FA. For smooth operation it should probably exceed timeout set at LDAP server. - Default: 60. - - bind_dn_template: list or str - Template from which to construct the full dn - when authenticating to LDAP. ``{username}`` is replaced - with the actual username used to log in. - - If your LDAP is set in such a way that the userdn can not - be formed from a template, but must be looked up with an attribute - (such as uid or ``sAMAccountName``), please see ``lookup_dn``. It might - be particularly relevant for ActiveDirectory installs. - - Unicode Example: - - .. code-block:: - - "uid={username},ou=people,dc=wikimedia,dc=org" - - List Example: - - .. code-block:: - - [ - "uid={username},ou=people,dc=wikimedia,dc=org", - "uid={username},ou=Developers,dc=wikimedia,dc=org" - ] - allowed_groups: list or None - List of LDAP group DNs that users could be members of to be granted access. - - If a user is in any one of the listed groups, then that user is granted access. - Membership is tested by fetching info about each group and looking for the User's - dn to be a value of one of `member` or `uniqueMember`, *or* if the username being - used to log in with is value of the `uid`. - - Set to an empty list or None to allow all users that have an LDAP account to log in, - without performing any group membership checks. - valid_username_regex: str - Regex for validating usernames - those that do not match this regex will be rejected. - - This is primarily used as a measure against LDAP injection, which has fatal security - considerations. The default works for most LDAP installations, but some users might need - to modify it to fit their custom installs. If you are modifying it, be sure to understand - the implications of allowing additional characters in usernames and what that means for - LDAP injection issues. See https://www.owasp.org/index.php/LDAP_injection for an overview - of LDAP injection. - lookup_dn: boolean - Form user's DN by looking up an entry from directory - - By default, LDAPAuthenticator finds the user's DN by using `bind_dn_template`. - However, in some installations, the user's DN does not contain the username, and - hence needs to be looked up. You can set this to True and then use ``user_search_base`` - and ``user_attribute`` to accomplish this. - user_search_base: str - Base for looking up user accounts in the directory, if `lookup_dn` is set to True. - - LDAPAuthenticator will search all objects matching under this base where the `user_attribute` - is set to the current username to form the userdn. - - For example, if all users objects existed under the base ou=people,dc=wikimedia,dc=org, and - the username users use is set with the attribute `uid`, you can use the following config: - - .. code-block:: - - c.LDAPAuthenticator.lookup_dn = True - c.LDAPAuthenticator.lookup_dn_search_filter = '({login_attr}={login})' - c.LDAPAuthenticator.lookup_dn_search_user = 'ldap_search_user_technical_account' - c.LDAPAuthenticator.lookup_dn_search_password = 'secret' - c.LDAPAuthenticator.user_search_base = 'ou=people,dc=wikimedia,dc=org' - c.LDAPAuthenticator.user_attribute = 'sAMAccountName' - c.LDAPAuthenticator.lookup_dn_user_dn_attribute = 'cn' - c.LDAPAuthenticator.bind_dn_template = '{username}' - user_attribute: str - Attribute containing user's name, if ``lookup_dn`` is set to True. - - See ``user_search_base`` for info on how this attribute is used. - - For most LDAP servers, this is uid. For Active Directory, it is - sAMAccountName. - lookup_dn_search_filter: str or None - How to query LDAP for user name lookup, if ``lookup_dn`` is set to True. - lookup_dn_search_user: str or None - Technical account for user lookup, if ``lookup_dn`` is set to True. - - If both lookup_dn_search_user and lookup_dn_search_password are None, - then anonymous LDAP query will be done. - lookup_dn_search_password: str or None - Technical account for user lookup, if ``lookup_dn`` is set to True. - lookup_dn_user_dn_attribute: str or None - Attribute containing user's name needed for building DN string, if ``lookup_dn`` is set to True. - - See ``user_search_base`` for info on how this attribute is used. - - For most LDAP servers, this is username. For Active Directory, it is cn. - escape_userdn: boolean - If set to True, escape special chars in userdn when authenticating in LDAP. - - On some LDAP servers, when userdn contains chars like '(', ')', '\' - authentication may fail when those chars - are not escaped. - search_filter: str - LDAP3 Search Filter whose results are allowed access - attributes: list or None - List of attributes to be searched - auth_state_attributes: list or None - List of attributes to be returned in auth_state for a user - use_lookup_dn_username: boolean - If set to true uses the ``lookup_dn_user_dn_attribute`` attribute as username instead of - the supplied one. - - This can be useful in an heterogeneous environment, when supplying a UNIX username - to authenticate against AD. - confirmation_message: str - May be displayed by client after successful login. - - Examples - -------- - - Using the authenticator class (the code runs in ``asyncio`` loop): - - .. code-block:: - - from bluesky_httpserver.authenticators import LDAPAuthenticator - authenticator = LDAPAuthenticator( - "localhost", 1389, bind_dn_template="cn={username},ou=users,dc=example,dc=org", use_tls=False - ) - await authenticator.authenticate("user01", "password1") - await authenticator.authenticate("user02", "password2") - - - Simple example of a config file (e.g. ``config_ldap.yml``): - - .. code-block:: - - uvicorn: - host: localhost - port: 60610 - authentication: - providers: - - provider: ldap_local - authenticator: bluesky_httpserver.authenticators:LDAPAuthenticator - args: - server_address: localhost - server_port: 1389 - bind_dn_template: "cn={username},ou=users,dc=example,dc=org" - use_tls: false - use_ssl: false - tiled_admins: - - provider: ldap_local - id: user02 - """ - - def __init__( - self, - server_address, - server_port=None, - *, - use_ssl=False, - use_tls=True, - connect_timeout=5, - receive_timeout=60, - bind_dn_template=None, - allowed_groups=None, - valid_username_regex=r"^[a-z][.a-z0-9_-]*$", - lookup_dn=False, - user_search_base=None, - user_attribute=None, - lookup_dn_search_filter="({login_attr}={login})", - lookup_dn_search_user=None, - lookup_dn_search_password=None, - lookup_dn_user_dn_attribute=None, - escape_userdn=False, - search_filter="", - attributes=None, - auth_state_attributes=None, - use_lookup_dn_username=True, - confirmation_message="", - ): - self.use_ssl = use_ssl - self.use_tls = use_tls - self.connect_timeout = connect_timeout - self.receive_timeout = receive_timeout - self.bind_dn_template = bind_dn_template - self.allowed_groups = allowed_groups - self.valid_username_regex = valid_username_regex - self.lookup_dn = lookup_dn - self.user_search_base = user_search_base - self.user_attribute = user_attribute - self.lookup_dn_search_filter = lookup_dn_search_filter - self.lookup_dn_search_user = lookup_dn_search_user - self.lookup_dn_search_password = lookup_dn_search_password - self.lookup_dn_user_dn_attribute = lookup_dn_user_dn_attribute - self.escape_userdn = escape_userdn - self.search_filter = search_filter - self.attributes = attributes if attributes else [] - self.auth_state_attributes = ( - auth_state_attributes if auth_state_attributes else [] - ) - self.use_lookup_dn_username = use_lookup_dn_username - - if isinstance(server_address, str): - server_address_list = [server_address] - elif isinstance(server_address, Iterable): - server_address_list = list(server_address) - else: - raise TypeError( - f"Unsupported type of `server_address` (list): server_address={server_address} " - f"type(server_address)={type(server_address)}" - ) - if not server_address_list: - raise ValueError( - "No servers are specified: 'server_address' is an empty list" - ) - - self.server_address_list = server_address_list - self.server_port = ( - server_port if server_port is not None else self._server_port_default() - ) - self.confirmation_message = confirmation_message - - def _server_port_default(self): - if self.use_ssl: - return 636 # default SSL port for LDAP - else: - return 389 # default plaintext port for LDAP - - async def resolve_username(self, username_supplied_by_user): - import ldap3 - - search_dn = self.lookup_dn_search_user - if self.escape_userdn: - search_dn = ldap3.utils.conv.escape_filter_chars(search_dn) - conn = await asyncio.get_running_loop().run_in_executor( - None, self.get_connection, search_dn, self.lookup_dn_search_password - ) - is_bound = await asyncio.get_running_loop().run_in_executor(None, conn.bind) - if not is_bound: - msg = "Failed to connect to LDAP server with search user '{search_dn}'" - self.log.warning(msg.format(search_dn=search_dn)) - return (None, None) - - search_filter = self.lookup_dn_search_filter.format( - login_attr=self.user_attribute, login=username_supplied_by_user - ) - msg = "\n".join( - [ - "Looking up user with:", - " search_base = '{search_base}'", - " search_filter = '{search_filter}'", - " attributes = '{attributes}'", - ] - ) - logger.debug( - msg.format( - search_base=self.user_search_base, - search_filter=search_filter, - attributes=self.user_attribute, - ) - ) - - search_func = functools.partial( - conn.search, - search_base=self.user_search_base, - search_scope=ldap3.SUBTREE, - search_filter=search_filter, - attributes=[self.lookup_dn_user_dn_attribute], - ) - await asyncio.get_running_loop().run_in_executor(None, search_func) - - response = conn.response - if len(response) == 0 or "attributes" not in response[0].keys(): - msg = ( - "No entry found for user '{username}' " - "when looking up attribute '{attribute}'" - ) - logger.warning( - msg.format( - username=username_supplied_by_user, attribute=self.user_attribute - ) - ) - return (None, None) - - user_dn = response[0]["attributes"][self.lookup_dn_user_dn_attribute] - if isinstance(user_dn, list): - if len(user_dn) == 0: - return (None, None) - elif len(user_dn) == 1: - user_dn = user_dn[0] - else: - msg = ( - "A lookup of the username '{username}' returned a list " - "of entries for the attribute '{attribute}'. Only the " - "first among these ('{first_entry}') was used. The other " - "entries ({other_entries}) were ignored." - ) - logger.warning( - msg.format( - username=username_supplied_by_user, - attribute=self.lookup_dn_user_dn_attribute, - first_entry=user_dn[0], - other_entries=", ".join(user_dn[1:]), - ) - ) - user_dn = user_dn[0] - - return (user_dn, response[0]["dn"]) - - def get_connection(self, userdn, password): - import ldap3 - - # NOTE: setting 'active=False' essentially disables exclusion of inactive servers from the pool. - # It probably does not matter if the pool contains only one server, but it could have implications - # when there are multiple servers in the pool. It is not clear what those implications are. - # But using the default 'activate=True' results in the thread being blocked indefinitely - # at the step of creating 'ldap3.Connection' regardless of timeouts in case all the servers are - # inactive (e.g. the pool has one server and it is unaccessible), which is unacceptable. - # Further investigation may be needed in the future. - server_pool = ldap3.ServerPool(None, ldap3.RANDOM, active=False) - for address in self.server_address_list: - if re.search(r".+:\d+", address): - # Port is found in the address - address_split = address.split(":") - server_addr = ":".join(address_split[:-1]) - server_port = int(address_split[-1]) - else: - # Use the default port - server_addr = address - server_port = self.server_port - - server = ldap3.Server( - server_addr, - port=server_port, - use_ssl=self.use_ssl, - connect_timeout=self.connect_timeout, - ) - server_pool.add(server) - - auto_bind_no_ssl = ( - ldap3.AUTO_BIND_TLS_BEFORE_BIND if self.use_tls else ldap3.AUTO_BIND_NO_TLS - ) - auto_bind = ldap3.AUTO_BIND_NO_TLS if self.use_ssl else auto_bind_no_ssl - conn = ldap3.Connection( - server_pool, - user=userdn, - password=password, - auto_bind=auto_bind, - receive_timeout=self.receive_timeout, - ) - return conn - - async def get_user_attributes(self, conn, userdn): - attrs = {} - if self.auth_state_attributes: - search_func = functools.partial( - conn.search, - userdn, - "(objectClass=*)", - attributes=self.auth_state_attributes, - ) - found = await asyncio.get_running_loop().run_in_executor(None, search_func) - if found: - attrs = conn.entries[0].entry_attributes_as_dict - return attrs - - async def authenticate( - self, username: str, password: str - ) -> Optional[UserSessionState]: - import ldap3 - - username_saved = username # Save the user name passed as a parameter - - # Protect against invalid usernames as well as LDAP injection attacks - if not re.match(self.valid_username_regex, username): - logger.warning( - "username:%s Illegal characters in username, must match regex %s", - username, - self.valid_username_regex, - ) - return None - - # No empty passwords! - if password is None or password.strip() == "": - logger.warning("username:%s Login denied for blank password", username) - return None - - # bind_dn_template should be of type List[str] - bind_dn_template = self.bind_dn_template - if isinstance(bind_dn_template, str): - bind_dn_template = [bind_dn_template] - - # sanity check - if not self.lookup_dn and not bind_dn_template: - logger.warning( - "Login not allowed, please configure 'lookup_dn' or 'bind_dn_template'." - ) - return None - - if self.lookup_dn: - username, resolved_dn = await self.resolve_username(username) - if not username: - return None - if str(self.lookup_dn_user_dn_attribute).upper() == "CN": - # Only escape commas if the lookup attribute is CN - username = re.subn(r"([^\\]),", r"\1\,", username)[0] - if not bind_dn_template: - bind_dn_template = [resolved_dn] - - is_bound = False - for dn in bind_dn_template: - if not dn: - logger.warning("Ignoring blank 'bind_dn_template' entry!") - continue - userdn = dn.format(username=username) - if self.escape_userdn: - userdn = ldap3.utils.conv.escape_filter_chars(userdn) - msg = "Attempting to bind {username} with {userdn}" - logger.debug(msg.format(username=username, userdn=userdn)) - msg = "Status of user bind {username} with {userdn} : {is_bound}" - try: - conn = await asyncio.get_running_loop().run_in_executor( - None, self.get_connection, userdn, password - ) - except ldap3.core.exceptions.LDAPBindError as exc: - is_bound = False - msg += "\n{exc_type}: {exc_msg}".format( - exc_type=exc.__class__.__name__, - exc_msg=exc.args[0] if exc.args else "", - ) - else: - if conn.bound: - is_bound = True - else: - is_bound = await asyncio.get_running_loop().run_in_executor( - None, conn.bind - ) - - msg = msg.format(username=username, userdn=userdn, is_bound=is_bound) - logger.debug(msg) - if is_bound: - break - - if not is_bound: - msg = "Invalid password for user '{username}'" - logger.warning(msg.format(username=username)) - return None - - if self.search_filter: - search_filter = self.search_filter.format( - userattr=self.user_attribute, username=username - ) - - search_func = functools.partial( - conn.search, - search_base=self.user_search_base, - search_scope=ldap3.SUBTREE, - search_filter=search_filter, - attributes=self.attributes, - ) - await asyncio.get_running_loop().run_in_executor(None, search_func) - - n_users = len(conn.response) - if n_users == 0: - msg = "User with '{userattr}={username}' not found in directory" - logger.warning( - msg.format(userattr=self.user_attribute, username=username) - ) - return None - if n_users > 1: - msg = ( - "Duplicate users found! " - "{n_users} users found with '{userattr}={username}'" - ) - logger.warning( - msg.format( - userattr=self.user_attribute, username=username, n_users=n_users - ) - ) - return None - - if self.allowed_groups: - logger.debug("username:%s Using dn %s", username, userdn) - found = False - for group in self.allowed_groups: - group_filter = ( - "(|" - "(member={userdn})" - "(uniqueMember={userdn})" - "(memberUid={uid})" - ")" - ) - group_filter = group_filter.format(userdn=userdn, uid=username) - group_attributes = ["member", "uniqueMember", "memberUid"] - - search_func = functools.partial( - conn.search, - group, - search_scope=ldap3.BASE, - search_filter=group_filter, - attributes=group_attributes, - ) - found = await asyncio.get_running_loop().run_in_executor( - None, search_func - ) - if found: - break - - if not found: - # If we reach here, then none of the groups matched - msg = "username:{username} User not in any of the allowed groups" - logger.warning(msg.format(username=username)) - return None +warnings.warn( + "Importing authenticators from 'tiled.authenticators' is deprecated and will be " + "removed in a future release. Use 'bluesky_authentication.authenticators' and " + "'bluesky_authentication.protocols' instead.", + DeprecationWarning, + stacklevel=2, +) - if not self.use_lookup_dn_username: - username = username_saved +from bluesky_authentication.authenticators import ( # noqa: F401 + DictionaryAuthenticator, + DummyAuthenticator, + EntraAuthenticator, + LDAPAuthenticator, + OIDCAuthenticator, + PAMAuthenticator, + ProxiedOIDCAuthenticator, + SAMLAuthenticator, +) +from bluesky_authentication.protocols import ( # noqa: F401 + ExternalAuthenticator, + InternalAuthenticator, + UserSessionState, +) - user_info = await self.get_user_attributes(conn, userdn) - if user_info: - logger.debug("username:%s attributes:%s", username, user_info) - # this path might never have been worked out...is it ever hit? - return UserSessionState(username, user_info) - return UserSessionState(username, {}) +__all__ = [ + "DictionaryAuthenticator", + "DummyAuthenticator", + "EntraAuthenticator", + "ExternalAuthenticator", + "InternalAuthenticator", + "LDAPAuthenticator", + "OIDCAuthenticator", + "PAMAuthenticator", + "ProxiedOIDCAuthenticator", + "SAMLAuthenticator", + "UserSessionState", +] diff --git a/tiled/server/protocols.py b/tiled/server/protocols.py index 6df1ac8e6..26902c658 100644 --- a/tiled/server/protocols.py +++ b/tiled/server/protocols.py @@ -1,23 +1,46 @@ -from abc import ABC -from dataclasses import dataclass -from typing import Optional +def _ensure_shared_package_on_path() -> None: + try: + import bluesky_authentication # noqa: F401 -from fastapi import Request + return + except ModuleNotFoundError: + pass + import sys + from pathlib import Path -@dataclass -class UserSessionState: - """Data transfer class to communicate custom session state information.""" + candidate = Path(__file__).resolve().parents[3] / "bluesky-authentication" / "src" + if candidate.exists(): + sys.path.insert(0, str(candidate)) - user_name: str - state: dict = None +_ensure_shared_package_on_path() -class InternalAuthenticator(ABC): - def authenticate(self, username: str, password: str) -> Optional[UserSessionState]: - raise NotImplementedError +try: + from bluesky_authentication.protocols import ( + ExternalAuthenticator, + InternalAuthenticator, + UserSessionState, + ) +except ModuleNotFoundError: + from abc import ABC + from dataclasses import dataclass + from typing import Optional -class ExternalAuthenticator(ABC): - def authenticate(self, request: Request) -> Optional[UserSessionState]: - raise NotImplementedError + from fastapi import Request + + @dataclass + class UserSessionState: + """Data transfer class to communicate custom session state information.""" + + user_name: str + state: dict = None + + class InternalAuthenticator(ABC): + def authenticate(self, username: str, password: str) -> Optional[UserSessionState]: + raise NotImplementedError + + class ExternalAuthenticator(ABC): + def authenticate(self, request: Request) -> Optional[UserSessionState]: + raise NotImplementedError From 3d3f25e31c8b221265028d291fcbdc5bb325d8f1 Mon Sep 17 00:00:00 2001 From: David Pastl Date: Wed, 24 Jun 2026 15:29:22 -0600 Subject: [PATCH 02/10] A first attempt to migrate to using bluesky_auth --- config.example.yml | 12 +- docs/source/conf.py | 2 +- docs/source/explanations/security.md | 14 +- example_configs/external_service/custom.py | 2 +- example_configs/google_auth.yml | 2 +- example_configs/keycloak_oidc/config.yaml | 2 +- example_configs/multiple_providers.yml | 6 +- example_configs/orcid_auth.yml | 2 +- example_configs/saml.yml | 2 +- example_configs/simple_oidc/config.yml | 2 +- example_configs/toy_authentication.yml | 2 +- tests/conftest.py | 2 +- tests/test_access_control.py | 2 +- tests/test_authentication.py | 8 +- tests/test_authenticators.py | 350 ------------------ tests/test_config.py | 33 +- tests/test_configs/config_in_memory_authn.yml | 2 +- .../config_missing_secret_keys.yml | 2 +- .../config_missing_secret_keys_public.yml | 2 +- .../test_configs/config_with_secret_keys.yml | 2 +- tests/test_device_flow.py | 10 +- tiled/config.py | 2 +- .../config_schemas/service_configuration.yml | 5 +- tiled/server/app.py | 2 +- tiled/server/authentication.py | 2 +- tiled/server/router.py | 2 +- 26 files changed, 78 insertions(+), 396 deletions(-) delete mode 100644 tests/test_authenticators.py diff --git a/config.example.yml b/config.example.yml index 329f74c9f..f7a0f0304 100644 --- a/config.example.yml +++ b/config.example.yml @@ -162,6 +162,10 @@ # Authentication # ----------------------------------------------------------------------------- authentication: + # Canonical authenticator import path: + # bluesky_authentication.authenticators:ClassName + # Backward-compatible legacy path still supported: + # tiled.authenticators:ClassName # --------------------------------------------------------------------------- # Identity Providers @@ -176,7 +180,7 @@ authentication: # # providers: # - provider: local - # authenticator: tiled.authenticators:PAMAuthenticator + # authenticator: bluesky_authentication.authenticators:PAMAuthenticator # args: # service: login # PAM service name. Default is "login". @@ -200,7 +204,7 @@ authentication: # # providers: # - provider: google # Must match the label used in the redirect URIs above - # authenticator: tiled.authenticators:OIDCAuthenticator + # authenticator: bluesky_authentication.authenticators:OIDCAuthenticator # args: # audience: tiled # Checked against the "aud" claim in the token # client_id: YOUR_CLIENT_ID_HERE @@ -226,7 +230,7 @@ authentication: # # providers: # - provider: toy - # authenticator: tiled.authenticators:DictionaryAuthenticator + # authenticator: bluesky_authentication.authenticators:DictionaryAuthenticator # args: # users_to_passwords: # alice: ${ALICE_PASSWORD} @@ -239,7 +243,7 @@ authentication: # # providers: # - provider: toy - # authenticator: tiled.authenticators:DummyAuthenticator + # authenticator: bluesky_authentication.authenticators:DummyAuthenticator # --------------------------------------------------------------------------- diff --git a/docs/source/conf.py b/docs/source/conf.py index ac2eb6317..be12e5958 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -324,7 +324,7 @@ def parse_schema(d, md=[], depth=0, pre=""): providers=[ AuthenticationProviderSpec( provider="dummy", - authenticator="tiled.authenticators:DummyAuthenticator", + authenticator="bluesky_authentication.authenticators:DummyAuthenticator", ) ] ), diff --git a/docs/source/explanations/security.md b/docs/source/explanations/security.md index 2856cb9fd..645998642 100644 --- a/docs/source/explanations/security.md +++ b/docs/source/explanations/security.md @@ -132,6 +132,10 @@ Tiled is designed to integrate with external user-management systems via a plugg Authenticator interface. For those familiar with JupyterHub, these are very similar to JupyterHub Authenticators. Authenticators fall into two groups: +The canonical import path for built-in authenticators is +`bluesky_authentication.authenticators:*`. For backward compatibility, +`tiled.authenticators:*` remains supported for existing configurations. + * Authenticators that accept user credentials directly, following the OAuth2 and OpenAPI standards as shown below, and validate the credentials using some underlying authentication mechanism, such as PAM. @@ -176,7 +180,7 @@ The configuration file(s) should include: ```yaml authentication: - authenticator: tiled.authenticators:PAMAuthenticator + authenticator: bluesky_authentication.authenticators:PAMAuthenticator ``` Here is a complete working example: @@ -185,7 +189,7 @@ Here is a complete working example: # pam_config.yml authentication: providers: - - authenticator: tiled.authenticators:PAMAuthenticator + - authenticator: bluesky_authentication.authenticators:PAMAuthenticator # This 'provider' can be any string; it is used to differentiate # authentication providers when multiple ones are supported. provider: local @@ -251,7 +255,7 @@ The configuration file(s) must include the following. authentication: providers: - provider: example.com - authenticator: tiled.authenticators:OIDCAuthenticator + authenticator: bluesky_authentication.authenticators:OIDCAuthenticator args: # Values should come from your OIDC provider configuration # The audience claim is checked by the OIDC Client (Tiled) @@ -282,7 +286,7 @@ should only for used for development and demos. authentication: providers: - provider: toy - authenticator: tiled.authenticators:DictionaryAuthenticator + authenticator: bluesky_authentication.authenticators:DictionaryAuthenticator args: users_to_passwords: alice: ${ALICE_PASSWORD} @@ -304,7 +308,7 @@ The ``DummyAuthenticator`` accepts *any* username and password combination. authentication: providers: - provider: toy - authenticator: tiled.authenticators:DummyAuthenticator + authenticator: bluesky_authentication.authenticators:DummyAuthenticator trees: - path: / tree: tiled.examples.generated_minimal:tree diff --git a/example_configs/external_service/custom.py b/example_configs/external_service/custom.py index 43b1e5b1b..bc5234c42 100644 --- a/example_configs/external_service/custom.py +++ b/example_configs/external_service/custom.py @@ -1,7 +1,7 @@ import numpy +from bluesky_authentication.protocols import UserSessionState from tiled.adapters.array import ArrayAdapter -from tiled.authenticators import UserSessionState from tiled.server.protocols import InternalAuthenticator from tiled.structures.core import StructureFamily diff --git a/example_configs/google_auth.yml b/example_configs/google_auth.yml index 0848a2d9c..ea493e191 100644 --- a/example_configs/google_auth.yml +++ b/example_configs/google_auth.yml @@ -2,7 +2,7 @@ authentication: providers: - provider: google - authenticator: tiled.authenticators:OIDCAuthenticator + authenticator: bluesky_authentication.authenticators:OIDCAuthenticator args: audience: tiled # something unique to ensure received headers are for you # These values come from https://console.cloud.google.com/apis/credential diff --git a/example_configs/keycloak_oidc/config.yaml b/example_configs/keycloak_oidc/config.yaml index 0c0ccfc65..2a34df572 100644 --- a/example_configs/keycloak_oidc/config.yaml +++ b/example_configs/keycloak_oidc/config.yaml @@ -1,7 +1,7 @@ authentication: providers: - provider: keycloak_oidc - authenticator: tiled.authenticators:ProxiedOIDCAuthenticator + authenticator: bluesky_authentication.authenticators:ProxiedOIDCAuthenticator args: audience: tiled_aud client_id: tiled diff --git a/example_configs/multiple_providers.yml b/example_configs/multiple_providers.yml index c97bcf65c..45ea486a2 100644 --- a/example_configs/multiple_providers.yml +++ b/example_configs/multiple_providers.yml @@ -1,21 +1,21 @@ authentication: providers: - provider: one - authenticator: tiled.authenticators:DictionaryAuthenticator + authenticator: bluesky_authentication.authenticators:DictionaryAuthenticator args: users_to_passwords: alice: ${ALICE_PASSWORD} bob: ${BOB_PASSWORD} cara: ${CARA_PASSWORD} - provider: two - authenticator: tiled.authenticators:DictionaryAuthenticator + authenticator: bluesky_authentication.authenticators:DictionaryAuthenticator args: users_to_passwords: alice: ${ALICE_PASSWORD} bob: ${BOB_PASSWORD} cara: ${CARA_PASSWORD} - provider: three - authenticator: tiled.authenticators:DictionaryAuthenticator + authenticator: bluesky_authentication.authenticators:DictionaryAuthenticator args: users_to_passwords: alice: ${ALICE_PASSWORD} diff --git a/example_configs/orcid_auth.yml b/example_configs/orcid_auth.yml index fade7cd5c..53c9287ee 100644 --- a/example_configs/orcid_auth.yml +++ b/example_configs/orcid_auth.yml @@ -2,7 +2,7 @@ authentication: providers: - provider: orcid - authenticator: tiled.authenticators:OIDCAuthenticator + authenticator: bluesky_authentication.authenticators:OIDCAuthenticator args: audience: tiled # something unique to ensure received headers are for you # These values come from https://orcid.org/developer-tools diff --git a/example_configs/saml.yml b/example_configs/saml.yml index a03596114..f6213b3af 100644 --- a/example_configs/saml.yml +++ b/example_configs/saml.yml @@ -4,7 +4,7 @@ authentication: providers: - provider: saml - authenticator: tiled.authenticators:SAMLAuthenticator + authenticator: bluesky_authentication.authenticators:SAMLAuthenticator args: attribute_name: "email" saml_settings: diff --git a/example_configs/simple_oidc/config.yml b/example_configs/simple_oidc/config.yml index 5dbfc1c83..b45c03bb8 100644 --- a/example_configs/simple_oidc/config.yml +++ b/example_configs/simple_oidc/config.yml @@ -7,7 +7,7 @@ authentication: providers: - provider: simple_oidc - authenticator: tiled.authenticators:OIDCAuthenticator + authenticator: bluesky_authentication.authenticators:OIDCAuthenticator args: audience: ${OIDC_CLIENT_ID} client_id: ${OIDC_CLIENT_ID} diff --git a/example_configs/toy_authentication.yml b/example_configs/toy_authentication.yml index fef68eb2a..da572a2a2 100644 --- a/example_configs/toy_authentication.yml +++ b/example_configs/toy_authentication.yml @@ -1,7 +1,7 @@ authentication: providers: - provider: toy - authenticator: tiled.authenticators:DictionaryAuthenticator + authenticator: bluesky_authentication.authenticators:DictionaryAuthenticator args: users_to_passwords: alice: ${ALICE_PASSWORD} diff --git a/tests/conftest.py b/tests/conftest.py index 5bad1c977..51d4cd204 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -373,7 +373,7 @@ def minio_uri(): "providers": [ { "provider": "toy", - "authenticator": "tiled.authenticators:DictionaryAuthenticator", + "authenticator": "bluesky_authentication.authenticators:DictionaryAuthenticator", "args": { "users_to_passwords": {"alice": "secret1", "bob": "secret2"}, }, diff --git a/tests/test_access_control.py b/tests/test_access_control.py index 1ea4e0f3b..0b2178064 100644 --- a/tests/test_access_control.py +++ b/tests/test_access_control.py @@ -34,7 +34,7 @@ "providers": [ { "provider": "toy", - "authenticator": "tiled.authenticators:DictionaryAuthenticator", + "authenticator": "bluesky_authentication.authenticators:DictionaryAuthenticator", "args": { "users_to_passwords": { "alice": "alice", diff --git a/tests/test_authentication.py b/tests/test_authentication.py index d46f72675..10ad0cff0 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -260,12 +260,12 @@ def test_multiple_providers(enter_username_password, config, monkeypatch): [ { "provider": "second", - "authenticator": "tiled.authenticators:DictionaryAuthenticator", + "authenticator": "bluesky_authentication.authenticators:DictionaryAuthenticator", "args": {"users_to_passwords": {"cara": "secret3", "doug": "secret4"}}, }, { "provider": "third", - "authenticator": "tiled.authenticators:DictionaryAuthenticator", + "authenticator": "bluesky_authentication.authenticators:DictionaryAuthenticator", "args": { # Duplicate 'cara' username. "users_to_passwords": {"cara": "secret5", "emilia": "secret6"} @@ -292,12 +292,12 @@ def test_multiple_providers_name_collision(config): config["authentication"]["providers"] = [ { "provider": "some_name", - "authenticator": "tiled.authenticators:DictionaryAuthenticator", + "authenticator": "bluesky_authentication.authenticators:DictionaryAuthenticator", "args": {"users_to_passwords": {"cara": "secret3", "doug": "secret4"}}, }, { "provider": "some_name", # duplicate! - "authenticator": "tiled.authenticators:DictionaryAuthenticator", + "authenticator": "bluesky_authentication.authenticators:DictionaryAuthenticator", "args": { # Duplicate 'cara' username. "users_to_passwords": {"cara": "secret5", "emilia": "secret6"} diff --git a/tests/test_authenticators.py b/tests/test_authenticators.py deleted file mode 100644 index 7ca3f3148..000000000 --- a/tests/test_authenticators.py +++ /dev/null @@ -1,350 +0,0 @@ -import asyncio -import logging -import os -import time -from typing import Any, Tuple - -import httpx -import pytest -from cryptography.hazmat.primitives.asymmetric import rsa -from jose import ExpiredSignatureError, jwt -from jose.backends import RSAKey -from respx import MockRouter -from starlette.datastructures import URL, QueryParams - -from tiled.authenticators import ( - EntraAuthenticator, - LDAPAuthenticator, - OIDCAuthenticator, - ProxiedOIDCAuthenticator, -) - -# Set this if there is an LDAP container running for testing. -# See continuous_integration/docker-configs/ldap-docker-compose.yml -TILED_TEST_LDAP = os.getenv("TILED_TEST_LDAP") - - -# fmt: off -@pytest.mark.filterwarnings("ignore", category=DeprecationWarning) -@pytest.mark.parametrize("ldap_server_address, ldap_server_port", [ - ("localhost", 1389), - ("localhost:1389", 904), # Random port, ignored - ("localhost:1389", None), - ("127.0.0.1", 1389), - ("127.0.0.1:1389", 904), - (["localhost"], 1389), - (["localhost", "127.0.0.1"], 1389), - (["localhost", "127.0.0.1:1389"], 1389), - (["localhost:1389", "127.0.0.1:1389"], None), -]) -# fmt: on -@pytest.mark.parametrize("use_tls,use_ssl", [(False, False)]) -def test_LDAPAuthenticator_01(use_tls, use_ssl, ldap_server_address, ldap_server_port): - """ - Basic test for ``LDAPAuthenticator``. - - TODO: The test could be extended with enabled TLS or SSL, but it requires configuration - of the LDAP server. - """ - if not TILED_TEST_LDAP: - pytest.skip("Run an LDAP container and set TILED_TEST_LDAP to run") - authenticator = LDAPAuthenticator( - ldap_server_address, - ldap_server_port, - bind_dn_template="cn={username},ou=users,dc=example,dc=org", - use_tls=use_tls, - use_ssl=use_ssl, - ) - - async def testing(): - assert (await authenticator.authenticate("user01", "password1")).user_name == "user01" - assert (await authenticator.authenticate("user02", "password2")).user_name == "user02" - assert (await authenticator.authenticate("user02a", "password2")) is None - assert (await authenticator.authenticate("user02", "password2a")) is None - - asyncio.run(testing()) - - -@pytest.fixture -def well_known_url(base_url: str) -> str: - return f"{base_url}.well-known/openid-configuration" - - -@pytest.fixture -def mock_oidc_server( - respx_mock: MockRouter, - well_known_url: str, - well_known_response: dict[str, Any], - json_web_keyset: list[dict[str, Any]], -) -> MockRouter: - respx_mock.get(well_known_url).mock( - return_value=httpx.Response(httpx.codes.OK, json=well_known_response) - ) - respx_mock.get(well_known_response["jwks_uri"]).mock( - return_value=httpx.Response(httpx.codes.OK, json={"keys": json_web_keyset}) - ) - return respx_mock - - -def test_oidc_authenticator_caching( - mock_oidc_server: MockRouter, - well_known_url: str, - well_known_response: dict[str, Any], - json_web_keyset: list[dict[str, Any]] -): - - authenticator = OIDCAuthenticator("tiled", "tiled", "secret", well_known_uri=well_known_url) - assert authenticator.client_id == "tiled" - assert authenticator.authorization_endpoint == well_known_response["authorization_endpoint"] - assert authenticator.id_token_signing_alg_values_supported == well_known_response[ - "id_token_signing_alg_values_supported" - ] - assert authenticator.issuer == well_known_response["issuer"] - assert authenticator.jwks_uri == well_known_response["jwks_uri"] - assert authenticator.token_endpoint == well_known_response["token_endpoint"] - assert authenticator.device_authorization_endpoint == well_known_response["device_authorization_endpoint"] - assert authenticator.end_session_endpoint == well_known_response["end_session_endpoint"] - - # Cached the result of .well_known, only GET once - assert len(mock_oidc_server.calls) == 1 - call_request = mock_oidc_server.calls[0].request - assert call_request.method == "GET" - assert call_request.url == well_known_url - - assert authenticator.keys() == json_web_keyset - assert len(mock_oidc_server.calls) == 2 # Called also to jwks - keys_request = mock_oidc_server.calls[1].request - assert keys_request.method == "GET" - assert keys_request.url == well_known_response["jwks_uri"] - - for _ in range(10): - assert authenticator.keys() == json_web_keyset - - assert len(mock_oidc_server.calls) == 2 # Getting keys is cached - keys_request = mock_oidc_server.calls[1].request - assert keys_request.method == "GET" - assert keys_request.url == well_known_response["jwks_uri"] - - -@pytest.mark.parametrize("issued", [True, False]) -@pytest.mark.parametrize("expired", [True, False]) -def test_oidc_decoding( - mock_oidc_server: MockRouter, - well_known_url: str, - issued: bool, - expired: bool, - keys: Tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey] -): - private_key, _ = keys - authenticator = OIDCAuthenticator("tiled", "tiled", "secret", well_known_uri=well_known_url) - access_token = token(issued, expired) - encrypted_access_token = encrypted_token(access_token, private_key) - - if not expired: - # Decode does not currently care if issued_at_time > current time - assert authenticator.decode_token(encrypted_access_token) == access_token - - else: - with pytest.raises(ExpiredSignatureError): - authenticator.decode_token(encrypted_access_token) - - -def test_entra_decoding_ignores_unmapped_scopes(caplog): - def mock_decode_token(self, id_token, access_token): - return { - "iss": "https://login.microsoftonline.com/example-tenant/v2.0", - "sub": "opaque-sub", - "preferred_username": "alice@example.org", - "scp": "known.scope unknown.scope", - } - - original_decode_token = OIDCAuthenticator.decode_token - OIDCAuthenticator.decode_token = mock_decode_token - try: - caplog.set_level(logging.WARNING) - - authenticator = object.__new__(EntraAuthenticator) - authenticator.scopes_map = {"known.scope": ["read:metadata"]} - claims = authenticator.decode_token("id-token", "access-token") - - assert claims["entra_sub"] == "opaque-sub" - assert claims["entra_username"] == "alice@example.org" - assert claims["user"] == "alice" - assert claims["scope"] == "read:metadata" - assert any( - "Unmapped Entra scope in 'scp': unknown.scope" in record.message - for record in caplog.records - ) - finally: - OIDCAuthenticator.decode_token = original_decode_token - - -@pytest.fixture -def keys() -> Tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey]: - # Key generated just for these tests - private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - public_key = private_key.public_key() - return (private_key, public_key) - - -@pytest.fixture -def json_web_keyset(keys: Tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey]) -> list[dict[str, Any]]: - _, public_key = keys - return [ - RSAKey(key=public_key, algorithm="RS256").to_dict() - ] - - -def token(issued: bool, expired: bool) -> dict[str, str]: - now = time.time() - dummy_token = { - "aud": "tiled", - "exp": (now - 1500) if expired else (now + 1500), - "iat": (now - 1500) if issued else (now + 1500), - "iss": "https://example.com/realms/example", - "sub": "Jane Doe", - } - return dummy_token - - -def encrypted_token(token: dict[str, str], private_key: rsa.RSAPrivateKey) -> str: - return jwt.encode( - token, - key=private_key, - algorithm="RS256", - headers={"kid": "secret"}, - ) - - -@pytest.mark.asyncio -async def test_proxied_oidc_token_retrieval(well_known_url: str, mock_oidc_server: MockRouter): - authenticator = ProxiedOIDCAuthenticator("tiled", "tiled", well_known_url, device_flow_client_id="tiled-cli") - test_request = httpx.Request("GET", "http://example.com", headers={ - "Authorization": "bearer FOO" - }) - - assert "FOO" == await authenticator.oauth2_schema(test_request) - - -def create_mock_OIDC_request(query_params=None): - """Helper function to create a realistic request object for testing.""" - if query_params is None: - query_params = {} - - class MockRequest: - def __init__(self, query_params): - self.query_params = QueryParams(query_params) - self.scope = { - "type": "http", - "scheme": "http", - "server": ("localhost", 8000), - "path": "/api/v1/auth/provider/orcid/code", - "headers": [] - } - self.headers = {"host": "localhost:8000"} - self.url = URL("http://localhost:8000/api/v1/auth/provider/orcid/code") - - return MockRequest(query_params) - - -@pytest.mark.asyncio -async def test_OIDCAuthenticator_mock( - mock_oidc_server: MockRouter, - well_known_url: str, - well_known_response: dict[str, Any], - monkeypatch -): - """ - Test OIDCAuthenticator with mocked external dependencies using respx. - """ - # Mock JWT token payload - mock_jwt_payload = { - "sub": "0009-0008-8698-7745", - "aud": "APP-TEST-CLIENT-ID", - "iss": well_known_response["issuer"], - "exp": 9999999999, # Far future - "iat": 1000000000, - "given_name": "Test User" - } - - # Add token exchange endpoint to existing mock_oidc_server - mock_oidc_server.post(well_known_response["token_endpoint"]).mock( - return_value=httpx.Response(200, json={ - "access_token": "mock-access-token", - "id_token": "mock-id-token", - "token_type": "bearer" - }) - ) - - authenticator = OIDCAuthenticator( - audience="APP-TEST-CLIENT-ID", - client_id="APP-TEST-CLIENT-ID", - client_secret="test-secret", - well_known_uri=well_known_url # Use the fixture - ) - - mock_request = create_mock_OIDC_request({"code": "test-auth-code"}) - - def mock_jwt_decode(*args, **kwargs): - return mock_jwt_payload - - def mock_jwk_construct(*args, **kwargs): - class MockJWK: - pass - return MockJWK() - - monkeypatch.setattr("jose.jwt.decode", mock_jwt_decode) - monkeypatch.setattr("jose.jwk.construct", mock_jwk_construct) - - # Test authentication - user_session = await authenticator.authenticate(mock_request) - - assert user_session is not None - assert user_session.user_name == "0009-0008-8698-7745" - - -@pytest.mark.asyncio -async def test_OIDCAuthenticator_missing_code_parameter(well_known_url: str): - """ - Test OIDCAuthenticator when the 'code' query parameter is missing. - """ - authenticator = OIDCAuthenticator( - audience="APP-TEST-CLIENT-ID", - client_id="APP-TEST-CLIENT-ID", - client_secret="test-secret", - well_known_uri=well_known_url # Use the fixture - ) - - mock_request = create_mock_OIDC_request({}) # Empty, no code parameter - - result = await authenticator.authenticate(mock_request) - assert result is None - - -@pytest.mark.asyncio -async def test_OIDCAuthenticator_token_exchange_failure( - well_known_url: str, mock_oidc_server, well_known_response -): - """ - Test OIDCAuthenticator when token exchange fails. - """ - # Mock the token exchange endpoint to return an error - mock_oidc_server.post(well_known_response["token_endpoint"]).mock( - return_value=httpx.Response(400, json={ - 'error': 'invalid_client', - 'error_description': 'Client not found: APP-TEST-CLIENT-ID' - }) - ) - - authenticator = OIDCAuthenticator( - audience="APP-TEST-CLIENT-ID", - client_id="APP-TEST-CLIENT-ID", - client_secret="test-secret", - well_known_uri=well_known_url - ) - - mock_request = create_mock_OIDC_request({"code": "invalid-code"}) - - # This should return None, not raise an exception - result = await authenticator.authenticate(mock_request) - assert result is None diff --git a/tests/test_config.py b/tests/test_config.py index 4082ce44c..2b6145662 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -193,11 +193,11 @@ def test_duplicate_auth_providers(): "providers": [ { "provider": "one", - "authenticator": "tiled.authenticators:DummyAuthenticator", + "authenticator": "bluesky_authentication.authenticators:DummyAuthenticator", }, { "provider": "one", - "authenticator": "tiled.authenticators:DictionaryAuthenticator", + "authenticator": "bluesky_authentication.authenticators:DictionaryAuthenticator", }, ] } @@ -205,6 +205,27 @@ def test_duplicate_auth_providers(): ) +def test_legacy_authenticator_import_path_is_supported(): + config = Config.model_validate( + { + "trees": [{"tree": f"{__name__}:tree", "path": "/"}], + "authentication": { + "providers": [ + { + "provider": "legacy", + "authenticator": "tiled.authenticators:DictionaryAuthenticator", + "args": { + "users_to_passwords": {"alice": "secret"}, + }, + } + ], + "secret_keys": ["SECRET"], + }, + } + ) + assert "legacy" in config.authentication.authenticators + + @respx.mock def test_proxied_authenticator_single_instance_required( well_known_response: dict[str, Any] @@ -226,7 +247,7 @@ def test_proxied_authenticator_single_instance_required( "providers": [ { "provider": "one", - "authenticator": "tiled.authenticators:ProxiedOIDCAuthenticator", + "authenticator": "bluesky_authentication.authenticators:ProxiedOIDCAuthenticator", "args": { "audience": "tiled", "client_id": "tiled", @@ -236,7 +257,7 @@ def test_proxied_authenticator_single_instance_required( }, { "provider": "two", - "authenticator": "tiled.authenticators:ProxiedOIDCAuthenticator", + "authenticator": "bluesky_authentication.authenticators:ProxiedOIDCAuthenticator", "args": { "audience": "tiled", "client_id": "tiled", @@ -268,11 +289,11 @@ def test_proxied_authenticator_is_not_used_with_other_authenticators( "providers": [ { "provider": "one", - "authenticator": "tiled.authenticators:DummyAuthenticator", + "authenticator": "bluesky_authentication.authenticators:DummyAuthenticator", }, { "provider": "two", - "authenticator": "tiled.authenticators:ProxiedOIDCAuthenticator", + "authenticator": "bluesky_authentication.authenticators:ProxiedOIDCAuthenticator", "args": { "audience": "tiled", "client_id": "tiled", diff --git a/tests/test_configs/config_in_memory_authn.yml b/tests/test_configs/config_in_memory_authn.yml index 9377c2a56..07b5986bd 100644 --- a/tests/test_configs/config_in_memory_authn.yml +++ b/tests/test_configs/config_in_memory_authn.yml @@ -8,7 +8,7 @@ uvicorn: authentication: providers: - provider: test - authenticator: tiled.authenticators:DictionaryAuthenticator + authenticator: bluesky_authentication.authenticators:DictionaryAuthenticator args: users_to_passwords: alice: PASSWORD diff --git a/tests/test_configs/config_missing_secret_keys.yml b/tests/test_configs/config_missing_secret_keys.yml index 662405a16..ddcb20a5c 100644 --- a/tests/test_configs/config_missing_secret_keys.yml +++ b/tests/test_configs/config_missing_secret_keys.yml @@ -8,7 +8,7 @@ uvicorn: authentication: providers: - provider: test - authenticator: tiled.authenticators:DictionaryAuthenticator + authenticator: bluesky_authentication.authenticators:DictionaryAuthenticator args: users_to_passwords: alice: secret1 diff --git a/tests/test_configs/config_missing_secret_keys_public.yml b/tests/test_configs/config_missing_secret_keys_public.yml index 0639d3415..c5efeda00 100644 --- a/tests/test_configs/config_missing_secret_keys_public.yml +++ b/tests/test_configs/config_missing_secret_keys_public.yml @@ -11,7 +11,7 @@ authentication: allow_anonymous_access: true providers: - provider: test - authenticator: tiled.authenticators:DictionaryAuthenticator + authenticator: bluesky_authentication.authenticators:DictionaryAuthenticator args: users_to_passwords: alice: secret1 diff --git a/tests/test_configs/config_with_secret_keys.yml b/tests/test_configs/config_with_secret_keys.yml index 438318096..13a4e8196 100644 --- a/tests/test_configs/config_with_secret_keys.yml +++ b/tests/test_configs/config_with_secret_keys.yml @@ -11,7 +11,7 @@ authentication: allow_anonymous_access: true providers: - provider: test - authenticator: tiled.authenticators:DictionaryAuthenticator + authenticator: bluesky_authentication.authenticators:DictionaryAuthenticator args: users_to_passwords: alice: PASSWORD diff --git a/tests/test_device_flow.py b/tests/test_device_flow.py index ca40e9137..097cba567 100644 --- a/tests/test_device_flow.py +++ b/tests/test_device_flow.py @@ -32,7 +32,7 @@ def oidc_config(well_known_url: str) -> Dict[str, Any]: "providers": [ { "provider": "keycloak_oidc", - "authenticator": "tiled.authenticators:ProxiedOIDCAuthenticator", + "authenticator": "bluesky_authentication.authenticators:ProxiedOIDCAuthenticator", "args": { "audience": "tiled_aud", "client_id": "tiled", @@ -250,7 +250,7 @@ def decoded_token(base_url: str) -> Dict[str, Any]: @pytest.fixture -@patch("tiled.authenticators.OIDCAuthenticator.decode_token") +@patch("bluesky_authentication.authenticators.OIDCAuthenticator.decode_token") def client( decode_token: MagicMock, context, @@ -275,7 +275,7 @@ def client( return client -@patch("tiled.authenticators.OIDCAuthenticator.decode_token") +@patch("bluesky_authentication.authenticators.OIDCAuthenticator.decode_token") def test_whoami_endpoint( decode_token: MagicMock, client, @@ -286,7 +286,7 @@ def test_whoami_endpoint( assert info["identities"][0]["id"] == decoded_token["sub"] -@patch("tiled.authenticators.OIDCAuthenticator.decode_token") +@patch("bluesky_authentication.authenticators.OIDCAuthenticator.decode_token") def test_client_refresh( decode_token: MagicMock, context, @@ -336,7 +336,7 @@ def test_client_refresh( assert client.context.whoami() == {} -@patch("tiled.authenticators.OIDCAuthenticator.decode_token") +@patch("bluesky_authentication.authenticators.OIDCAuthenticator.decode_token") def test_logout( decode_token: MagicMock, context, diff --git a/tiled/config.py b/tiled/config.py index a94281e88..83abd972c 100644 --- a/tiled/config.py +++ b/tiled/config.py @@ -17,7 +17,7 @@ SettingsConfigDict, ) -from tiled.authenticators import ProxiedOIDCAuthenticator +from bluesky_authentication.authenticators import ProxiedOIDCAuthenticator from tiled.server.protocols import ExternalAuthenticator, InternalAuthenticator from tiled.type_aliases import AppTask, TaskMap diff --git a/tiled/config_schemas/service_configuration.yml b/tiled/config_schemas/service_configuration.yml index 38d1adc73..671b08129 100644 --- a/tiled/config_schemas/service_configuration.yml +++ b/tiled/config_schemas/service_configuration.yml @@ -179,9 +179,12 @@ properties: description: | Type of Authenticator to use. - These are typically from the tiled.authenticators module, + These are typically from the bluesky_authentication.authenticators module, though user-defined ones may be used as well. + For backward compatibility, import paths from the deprecated + tiled.authenticators module are also supported. + This is given as an import path. In an import path, packages/modules are separated by dots, and the object itself it separated by a colon. diff --git a/tiled/server/app.py b/tiled/server/app.py index 73506b749..259477df5 100644 --- a/tiled/server/app.py +++ b/tiled/server/app.py @@ -37,7 +37,7 @@ ) from ..access_control.protocols import AccessPolicy -from ..authenticators import ProxiedOIDCAuthenticator +from bluesky_authentication.authenticators import ProxiedOIDCAuthenticator from ..catalog.adapter import WouldDeleteData from ..config import ( Authentication, diff --git a/tiled/server/authentication.py b/tiled/server/authentication.py index 5328a7d9b..708f94aa8 100644 --- a/tiled/server/authentication.py +++ b/tiled/server/authentication.py @@ -41,8 +41,8 @@ HTTP_409_CONFLICT, ) +from bluesky_authentication.authenticators import ProxiedOIDCAuthenticator from tiled.access_control.scopes import NO_SCOPES, PUBLIC_SCOPES, SINGLE_USER_SCOPES -from tiled.authenticators import ProxiedOIDCAuthenticator # To hide third-party warning # .../jose/backends/cryptography_backend.py:18: CryptographyDeprecationWarning: diff --git a/tiled/server/router.py b/tiled/server/router.py index 2d5d96cdb..8170bbec0 100644 --- a/tiled/server/router.py +++ b/tiled/server/router.py @@ -40,7 +40,7 @@ ) from tiled.adapters.protocols import AnyAdapter -from tiled.authenticators import ProxiedOIDCAuthenticator +from bluesky_authentication.authenticators import ProxiedOIDCAuthenticator from tiled.media_type_registration import SerializationRegistry from tiled.query_registration import QueryRegistry from tiled.schemas import About From b38556fc058a434ba5ba52d3a3a0aec8a6bd24f3 Mon Sep 17 00:00:00 2001 From: David Pastl Date: Thu, 25 Jun 2026 10:39:13 -0600 Subject: [PATCH 03/10] Starting ci hopefully --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 0df2c61ad..f2c13dc34 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ on the `conda-forge` channel, which pixi uses by default. See [Getting Started][] for more detailed installation instructions. + ## First Steps See [What is Tiled?][] for an overview of Tiled's goals. Then see From 2d0232a6553eecbef5d9a80bbeef66edbad39a0c Mon Sep 17 00:00:00 2001 From: David Pastl Date: Thu, 25 Jun 2026 13:10:23 -0600 Subject: [PATCH 04/10] Uses bluesky-auth properly now --- .github/workflows/ci.yml | 4 ++-- .github/workflows/docs.yml | 2 +- .github/workflows/publish-docs.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eec3971ab..bf6a4e263 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install tiled - run: uv sync --all-extras + run: uv sync --all-extras --with "bluesky-authentication @ git+https://github.com/davidpcls/bluesky-authentication.git@main" # TODO Find a new image to use. # https://github.com/bluesky/tiled/issues/1109 @@ -128,7 +128,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install tiled - run: uv sync --all-extras + run: uv sync --all-extras --with "bluesky-authentication @ git+https://github.com/davidpcls/bluesky-authentication.git@main" env: TILED_BUILD_SKIP_UI: 1 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e4e8ca48c..a16b158b2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -21,7 +21,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install tiled - run: uv sync --all-extras --group docs + run: uv sync --all-extras --group docs --with "bluesky-authentication @ git+https://github.com/davidpcls/bluesky-authentication.git@main" - name: Build Docs shell: bash -l {0} diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index b590ad028..fa884fc79 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -32,7 +32,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install tiled - run: uv sync --all-extras --group docs + run: uv sync --all-extras --group docs --with "bluesky-authentication @ git+https://github.com/davidpcls/bluesky-authentication.git@main" - name: Build Docs shell: bash -l {0} From 6274fda66b23453bf3cccb50dfb25bd6261f38e3 Mon Sep 17 00:00:00 2001 From: David Pastl Date: Tue, 30 Jun 2026 11:22:19 -0600 Subject: [PATCH 05/10] Migarting routes --- tiled/authn_database/base.py | 8 +- tiled/authn_database/core.py | 391 +++-------------------------------- tiled/authn_database/orm.py | 265 +++--------------------- 3 files changed, 59 insertions(+), 605 deletions(-) diff --git a/tiled/authn_database/base.py b/tiled/authn_database/base.py index 952d0743f..7f7b40c28 100644 --- a/tiled/authn_database/base.py +++ b/tiled/authn_database/base.py @@ -1,7 +1,3 @@ -from sqlalchemy.orm import DeclarativeBase +from bluesky_authentication.auth_store.base import Base - -# Everything imports this so we put it in its own module to -# avoid circular imports. -class Base(DeclarativeBase): - pass +__all__ = ["Base"] diff --git a/tiled/authn_database/core.py b/tiled/authn_database/core.py index de8d0364c..66c0faee0 100644 --- a/tiled/authn_database/core.py +++ b/tiled/authn_database/core.py @@ -1,360 +1,33 @@ -import hashlib -import uuid as uuid_module -from datetime import datetime, timezone -from typing import Optional - -from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession -from sqlalchemy.future import select -from sqlalchemy.orm import selectinload -from sqlalchemy.sql import func - -from .base import Base -from .orm import APIKey, Identity, PendingSession, Principal, Role, Session - -# This is list of all valid alembic revisions (from current to oldest). -ALL_REVISIONS = [ - "2d1b550e12e0", - "d829476bc173", - "27e069ba3bf5", - "a806cc635ab2", - "0c705a02954c", - "d88e91ea03f9", - "13024b8a6b74", - "769180ce732e", - "c7bd2573716d", - "4a9dfaba4a98", - "56809bcbfcb0", - "722ff4e4fcc7", - "481830dd6c11", +from bluesky_authentication.auth_store.core import ( + ALL_REVISIONS, + REQUIRED_REVISION, + create_default_roles, + create_service, + create_user, + get_or_create_principal, + initialize_database, + latest_principal_activity, + lookup_valid_api_key, + lookup_valid_pending_session_by_device_code, + lookup_valid_pending_session_by_user_code, + lookup_valid_session, + make_admin_by_identity, + purge_expired, +) + +__all__ = [ + "ALL_REVISIONS", + "REQUIRED_REVISION", + "create_default_roles", + "create_service", + "create_user", + "get_or_create_principal", + "initialize_database", + "latest_principal_activity", + "lookup_valid_api_key", + "lookup_valid_pending_session_by_device_code", + "lookup_valid_pending_session_by_user_code", + "lookup_valid_session", + "make_admin_by_identity", + "purge_expired", ] -REQUIRED_REVISION = ALL_REVISIONS[0] - - -async def create_default_roles(db): - default_roles = [ - Role( - name="user", - description="Default Role for users.", - scopes=[ - "read:metadata", - "read:data", - "create:node", - "write:metadata", - "write:data", - "delete:revision", - "delete:node", - "create:apikeys", - "revoke:apikeys", - ], - ), - Role( - name="admin", - description="Role with elevated privileges.", - scopes=[ - "read:metadata", - "read:data", - "create:node", - "register", - "write:metadata", - "write:data", - "delete:revision", - "delete:node", - "admin:apikeys", - "read:principals", - "write:principals", - "metrics", - "read:webhooks", - "write:webhooks", - ], - ), - ] - - roles_result = await db.execute(select(Role.name)) - existing_role_names = set(roles_result.scalars().all()) - roles_to_add = [ - role for role in default_roles if role.name not in existing_role_names - ] - - if roles_to_add: - db.add_all(roles_to_add) - await db.commit() - - -async def initialize_database(engine: AsyncEngine) -> None: - async with engine.begin() as conn: - # Create all tables. - await conn.run_sync(Base.metadata.create_all) - - # Initialize Roles table. - async with AsyncSession(engine) as db: - await create_default_roles(db) - - -async def purge_expired(db: AsyncSession, cls) -> int: - """ - Remove expired entries. - """ - now = datetime.now(timezone.utc) - num_expired = 0 - statement = ( - select(cls) - .filter(cls.expiration_time.is_not(None)) - .filter(cls.expiration_time.replace(tzinfo=timezone.utc) < now) - ) - result = await db.execute(statement) - for obj in result.scalars(): - num_expired += 1 - await db.delete(obj) - if num_expired: - await db.commit() - return num_expired - - -async def create_user(db: AsyncSession, identity_provider: str, id: str) -> Principal: - user_role = (await db.execute(select(Role).filter(Role.name == "user"))).scalar() - assert user_role is not None, "User role is missing from Roles table" - principal = Principal(type="user", roles=[user_role]) - db.add(principal) - await db.commit() - identity = Identity( - provider=identity_provider, - id=id, - principal_id=principal.id, - ) - db.add(identity) - await db.commit() - refreshed_principal = ( - await db.execute( - select(Principal) - .filter(Principal.id == principal.id) - .options(selectinload(Principal.identities)) - ) - ).scalar() - assert ( - refreshed_principal is not None - ), f"Newly created user {id} ({identity_provider}) missing from Principals table" - return refreshed_principal - - -async def create_service(db: AsyncSession, role: str) -> Principal: - role_ = (await db.execute(select(Role).filter(Role.name == role))).scalar() - if role_ is None: - raise ValueError(f"Role named {role!r} is not found") - principal = Principal(type="service", roles=[role_]) - db.add(principal) - await db.commit() - return principal - - -async def lookup_valid_session(db: AsyncSession, session_id: str) -> Optional[Session]: - if isinstance(session_id, int): - # Old versions of tiled used an integer sid. - # Reject any of those old sessions and force reauthentication. - return None - - session = ( - await db.execute( - select(Session) - .options( - selectinload(Session.principal).selectinload(Principal.roles), - selectinload(Session.principal).selectinload(Principal.identities), - ) - .filter(Session.uuid == uuid_module.UUID(hex=session_id)) - ) - ).scalar() - if session is None: - return None - if session.expiration_time is not None and session.expiration_time.replace( - tzinfo=timezone.utc - ) < datetime.now(timezone.utc): - await db.delete(session) - await db.commit() - return None - return session - - -async def lookup_valid_pending_session_by_device_code( - db: AsyncSession, device_code: str -) -> Optional[PendingSession]: - hashed_device_code = hashlib.sha256(device_code).digest() - pending_session = ( - await db.execute( - select(PendingSession) - .filter(PendingSession.hashed_device_code == hashed_device_code) - .options( - selectinload(PendingSession.session) - .selectinload(Session.principal) - .selectinload(Principal.identities), - ) - ) - ).scalar() - if pending_session is None: - return None - if ( - pending_session.expiration_time is not None - and pending_session.expiration_time.replace(tzinfo=timezone.utc) - < datetime.now(timezone.utc) - ): - await db.delete(pending_session) - await db.commit() - return None - return pending_session - - -async def lookup_valid_pending_session_by_user_code( - db: AsyncSession, user_code: str -) -> Optional[PendingSession]: - pending_session = ( - await db.execute( - select(PendingSession).filter(PendingSession.user_code == user_code) - ) - ).scalar() - if pending_session is None: - return None - if ( - pending_session.expiration_time is not None - and pending_session.expiration_time.replace(tzinfo=timezone.utc) - < datetime.now(timezone.utc) - ): - await db.delete(pending_session) - await db.commit() - return None - return pending_session - - -async def make_admin_by_identity( - db: AsyncSession, identity_provider: str, id: str -) -> Principal: - identity = ( - await db.execute( - select(Identity) - .options(selectinload(Identity.principal).selectinload(Principal.roles)) - .filter(Identity.id == id) - .filter(Identity.provider == identity_provider) - ) - ).scalar() - if identity is None: - principal = await create_user(db, identity_provider, id) - else: - principal = identity.principal - - # check if principal already has admin role - for role in principal.roles: - if role.name == "admin": - return principal - - admin_role = (await db.execute(select(Role).filter(Role.name == "admin"))).scalar() - assert admin_role is not None, "Admin role is missing from Roles table" - principal.roles.append(admin_role) - await db.commit() - return principal - - -async def lookup_valid_api_key(db: AsyncSession, secret: bytes) -> Optional[APIKey]: - """ - Look up an API key. Ensure that it is valid. - """ - - now = datetime.now(timezone.utc) - hashed_secret = hashlib.sha256(secret).digest() - api_key = ( - await db.execute( - select(APIKey) - .options( - selectinload(APIKey.principal).selectinload(Principal.roles), - selectinload(APIKey.principal).selectinload(Principal.identities), - selectinload(APIKey.principal).selectinload(Principal.sessions), - ) - .filter(APIKey.first_eight == secret.hex()[:8]) - .filter(APIKey.hashed_secret == hashed_secret) - ) - ).scalar() - if api_key is None: - # No match - validated_api_key = None - elif (api_key.expiration_time is not None) and ( - api_key.expiration_time.replace(tzinfo=timezone.utc) < now - ): - # Match is expired. Delete it. - await db.delete(api_key) - await db.commit() - validated_api_key = None - elif api_key.principal is None: - # The Principal for the API key no longer exists. Delete it. - await db.delete(api_key) - await db.commit() - validated_api_key = None - else: - validated_api_key = api_key - return validated_api_key - - -async def latest_principal_activity( - db: AsyncSession, principal: Principal -) -> Optional[datetime]: - """ - The most recent time this Principal has logged in with an Identity, - refreshed a Session, or used an APIKey. - - Note that activity that is authenticated using an access token is not - captured here. As usual with JWTs, those requests do not interact with - this database, for performance reasons. Therefore, this may lag actual - activity by as much as the max age of an access token (default: 15 - minutes). - """ - latest_identity_activity = ( - await db.execute( - select(func.max(Identity.latest_login)).filter( - Identity.principal_id == principal.id - ) - ) - ).scalar() - latest_session_activity = ( - await db.execute( - select(func.max(Session.time_last_refreshed)).filter( - Session.principal_id == principal.id - ) - ) - ).scalar() - latest_api_key_activity = ( - await db.execute( - select(func.max(APIKey.latest_activity)).filter( - APIKey.principal_id == principal.id - ) - ) - ).scalar() - all_activity = [ - latest_identity_activity, - latest_api_key_activity, - latest_session_activity, - ] - if all([t is None for t in all_activity]): - return None - return max(t for t in all_activity if t is not None) - - -async def get_or_create_principal( - db: AsyncSession, identity_provider: str, id: str -) -> Principal: - """ - Look up a Principal by identity, creating one if it does not exist. - Does not create a Session -- intended for proxied OIDC where session - management is handled externally. - """ - identity = ( - await db.execute( - select(Identity) - .options( - selectinload(Identity.principal).selectinload(Principal.roles), - selectinload(Identity.principal).selectinload(Principal.identities), - ) - .filter(Identity.id == id) - .filter(Identity.provider == identity_provider) - ) - ).scalar() - if identity is not None: - now = datetime.now(timezone.utc) - identity.latest_login = now - await db.commit() - return identity.principal - return await create_user(db, identity_provider, id) diff --git a/tiled/authn_database/orm.py b/tiled/authn_database/orm.py index bf16933bf..09b2b3c4d 100644 --- a/tiled/authn_database/orm.py +++ b/tiled/authn_database/orm.py @@ -1,242 +1,27 @@ -import json -import uuid as uuid_module - -from sqlalchemy import ( - JSON, - Boolean, - Column, - DateTime, - Enum, - ForeignKey, - Integer, - LargeBinary, - Table, - Unicode, -) -from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.orm import Mapped, relationship -from sqlalchemy.sql import func -from sqlalchemy.types import TypeDecorator - -from ..server.schemas import PrincipalType -from .base import Base - -# Use JSON with SQLite and JSONB with PostgreSQL. -JSONVariant = JSON().with_variant(JSONB(), "postgresql") - - -class JSONList(TypeDecorator): - """Represents an immutable structure as a JSON-encoded list. - - Usage:: - - JSONList(255) - - """ - - impl = Unicode - cache_ok = True - - def process_bind_param(self, value, dialect): - if value is None: - # Allow None for columns that are nullable - return None - else: - # Make sure we don't get passed some iterable like a dict. - if not isinstance(value, list): - raise ValueError("JSONList must be given a literal `list` type.") - value = json.dumps(value) - return value - - def process_result_value(self, value, dialect): - if value is not None: - value = json.loads(value) - return value - - -class UUID(TypeDecorator): - """Represents a UUID in a dialect-agnostic way - - Postgres has built-in support but SQLite does not, so we - just use a 36-character Unicode column. - - We could use 16-byte LargeBinary, which would be more compact - but we decided it was worth the cost to make the content easily - inspectable by external database management and development tools. - """ - - impl = Unicode(36) - cache_ok = True - - def process_bind_param(self, value, dialect): - if value is not None: - if not isinstance(value, uuid_module.UUID): - raise ValueError(f"Expected uuid.UUID, got {type(value)}") - return str(value) - - def process_result_value(self, value, dialect): - if value is not None: - return uuid_module.UUID(hex=value) - - -class Timestamped: - """ - Mixin for providing timestamps of creation and update time. - - These are not used by application code, but they may be useful for - forensics. - """ - - __mapper_args__ = {"eager_defaults": True} - - time_created = Column( - DateTime(timezone=True), - server_default=func.now(), - nullable=False, - ) - time_updated = Column( - DateTime(timezone=True), - onupdate=func.now(), - nullable=True, - ) # null until first update - - def __repr__(self): - return ( - f"{type(self).__name__}(" - + ", ".join( - f"{key}={value!r}" - for key, value in self.__dict__.items() - if not key.startswith("_") - ) - + ")" - ) - - -principal_role_association_table = Table( - "principal_role_association", - Base.metadata, - Column("principal_id", Integer, ForeignKey("principals.id"), primary_key=True), - Column("role_id", Integer, ForeignKey("roles.id"), primary_key=True), +from bluesky_authentication.auth_store.orm import ( + APIKey, + Identity, + JSONList, + JSONVariant, + PendingSession, + Principal, + PrincipalType, + Role, + Session, + UUID, + principal_role_association_table, ) - -class Principal(Timestamped, Base): - __tablename__ = "principals" - - # This id is internal, never exposed to the client. - id = Column(Integer, primary_key=True, index=True, autoincrement=True) - # This uuid is public. - uuid = Column( - UUID, - index=True, - nullable=False, - default=lambda: uuid_module.uuid4(), - ) - type = Column(Enum(PrincipalType), nullable=False) - # In the future we may add other information. - - identities: Mapped[list["Identity"]] = relationship(back_populates="principal") - api_keys: Mapped[list["APIKey"]] = relationship(back_populates="principal") - roles: Mapped[list["Role"]] = relationship( - secondary=principal_role_association_table, - back_populates="principals", - lazy="joined", - ) - sessions: Mapped[list["Session"]] = relationship( - "Session", back_populates="principal" - ) - - -class Identity(Timestamped, Base): - __tablename__ = "identities" - - # An (id, provider) pair must be unique. - id = Column(Unicode(255), primary_key=True, nullable=False) - provider = Column(Unicode(255), primary_key=True, nullable=False) - principal_id = Column(Integer, ForeignKey("principals.id"), nullable=False) - latest_login = Column(DateTime(timezone=True), nullable=True) - # In the future we may add a notion of "primary" identity. - - principal: Mapped[Principal] = relationship(back_populates="identities") - - -class Role(Timestamped, Base): - __tablename__ = "roles" - - id = Column(Integer, primary_key=True, index=True, autoincrement=True) - name = Column(Unicode(255), index=True, unique=True, nullable=False) - description = Column(Unicode(1023), nullable=True) - scopes = Column(JSONList(511), nullable=False) - principals: Mapped[list[Principal]] = relationship( - secondary=principal_role_association_table, back_populates="roles" - ) - - -class APIKey(Timestamped, Base): - __tablename__ = "api_keys" - - # Store the first_eight characters of the hex-encoded secret. - # The key holder can use this to identity the key. - # We do not store the full secret, only its sha256-hashed value. - # A primary key on (first_eight, hashed_secret) enables - # fast lookups. - first_eight = Column(Unicode(8), primary_key=True, index=True, nullable=False) - hashed_secret = Column( - LargeBinary(32), primary_key=True, index=True, nullable=False - ) - expiration_time = Column(DateTime(timezone=True), nullable=True) - latest_activity = Column(DateTime(timezone=True), nullable=True) - note = Column(Unicode(1023), nullable=True) - principal_id = Column(Integer, ForeignKey("principals.id"), nullable=False) - scopes = Column(JSONList(511), nullable=False) - access_tags = Column(JSONList(511), nullable=True) - # In the future we could make it possible to disable API keys - # without deleting them from the database, for forensics and - # record-keeping. - - principal: Mapped[Principal] = relationship( - back_populates="api_keys", lazy="joined" - ) - - -class Session(Timestamped, Base): - """ - This related to refresh tokens, which have a session uuid ("sid") claim. - - When the client attempts to use a refresh token, we first check - here to ensure that the "session", which is associated with a chain - of refresh tokens that came from a single authentication, are still valid. - """ - - __tablename__ = "sessions" - - # This id is internal, never exposed to the client. - id = Column(Integer, primary_key=True, index=True, autoincrement=True) - # This uuid is exposed to the client. - uuid = Column(UUID, index=True, nullable=False, default=uuid_module.uuid4) - time_last_refreshed = Column(DateTime(timezone=True), nullable=True) - refresh_count = Column(Integer, nullable=False, default=0) - expiration_time = Column(DateTime(timezone=True), nullable=False) - principal_id = Column(Integer, ForeignKey("principals.id"), nullable=False) - revoked = Column(Boolean, default=False, nullable=False) - # State allows for custom authenticator information to be stored in the session. - state = Column(JSONVariant, nullable=False) - principal: Mapped[Principal] = relationship( - back_populates="sessions", lazy="joined" - ) - - -class PendingSession(Base): - """ - This is used only in Device Code Flow. - """ - - __tablename__ = "pending_sessions" - - hashed_device_code = Column( - LargeBinary(32), primary_key=True, index=True, nullable=False - ) - user_code = Column(Unicode(8), index=True, nullable=False) - expiration_time = Column(DateTime(timezone=True), nullable=False) - session_id = Column(Integer, ForeignKey("sessions.id"), nullable=True) - session: Mapped[Session] = relationship(lazy="joined") +__all__ = [ + "APIKey", + "Identity", + "JSONList", + "JSONVariant", + "PendingSession", + "Principal", + "PrincipalType", + "Role", + "Session", + "UUID", + "principal_role_association_table", +] From d207b42bc1001a6376021662201dd8db6346c52c Mon Sep 17 00:00:00 2001 From: David Pastl Date: Tue, 30 Jun 2026 11:31:59 -0600 Subject: [PATCH 06/10] Adding in adapter for common auth routes --- tiled/server/app.py | 27 ++------ tiled/server/authentication.py | 113 +++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 22 deletions(-) diff --git a/tiled/server/app.py b/tiled/server/app.py index 259477df5..296af510d 100644 --- a/tiled/server/app.py +++ b/tiled/server/app.py @@ -60,7 +60,7 @@ from ..validation_registration import ValidationRegistry, default_validation_registry from .authentication import move_api_key from .compression import CompressionMiddleware -from .protocols import ExternalAuthenticator, InternalAuthenticator +from .protocols import ExternalAuthenticator from .router import get_metrics_router, get_router from .settings import Settings, get_settings from .utils import API_KEY_COOKIE_NAME, CSRF_COOKIE_NAME, get_root_url, record_timing @@ -433,9 +433,7 @@ async def unhandled_exception_handler( # Delay this imports to avoid delaying startup with the SQL and cryptography # imports if they are not needed. from .authentication import ( - add_external_routes, - add_internal_routes, - authentication_router, + build_shared_authentication_router, oauth2_scheme, ) @@ -444,25 +442,10 @@ async def unhandled_exception_handler( oauth2_scheme.model.flows.password.tokenUrl = ( f"/api/v1/auth/provider/{first_provider}/token" ) - # Authenticators provide Router(s) for their particular flow. - # Collect them in the authentication_router. - authentication_router = authentication_router() - # This adds the universal routes like /session/refresh and /session/revoke. - # Below we will add routes specific to our authentication providers. - + authentication_router = build_shared_authentication_router(authenticators) for provider, authenticator in authenticators.items(): - if isinstance(authenticator, InternalAuthenticator): - add_internal_routes(authentication_router, provider, authenticator) - elif isinstance(authenticator, ExternalAuthenticator): - add_external_routes(authentication_router, provider, authenticator) - if isinstance(authenticator, ProxiedOIDCAuthenticator): - app.state.provider = provider - else: - raise ValueError(f"unknown authenticator type {type(authenticator)}") - for custom_router in getattr(authenticator, "include_routers", []): - authentication_router.include_router( - custom_router, prefix=f"/provider/{provider}" - ) + if isinstance(authenticator, ProxiedOIDCAuthenticator): + app.state.provider = provider # And add this authentication_router itself to the app. app.include_router(authentication_router, prefix="/api/v1/auth") app.state.authenticated = True diff --git a/tiled/server/authentication.py b/tiled/server/authentication.py index 708f94aa8..0e260e314 100644 --- a/tiled/server/authentication.py +++ b/tiled/server/authentication.py @@ -18,6 +18,7 @@ Security, WebSocket, ) +from fastapi.routing import APIRoute from fastapi.responses import RedirectResponse from fastapi.security import ( OAuth2PasswordBearer, @@ -41,6 +42,10 @@ HTTP_409_CONFLICT, ) +from bluesky_authentication.integration import ( + AuthProviderRegistration, + build_authentication_router, +) from bluesky_authentication.authenticators import ProxiedOIDCAuthenticator from tiled.access_control.scopes import NO_SCOPES, PUBLIC_SCOPES, SINGLE_USER_SCOPES @@ -1171,6 +1176,114 @@ async def handle_credentials_route( return tokens +def _extract_route_endpoint(router: APIRouter, path: str, method: str) -> Callable: + method = method.upper() + for route in router.routes: + if isinstance(route, APIRoute) and route.path == path and method in route.methods: + return route.endpoint + msg = f"Could not find route endpoint for path={path!r}, method={method!r}." + raise RuntimeError(msg) + + +class TiledAuthRouteAdapter: + def __init__(self) -> None: + self._external_endpoint_cache: dict[str, dict[str, Callable]] = {} + self._internal_endpoint_cache: dict[str, Callable] = {} + + def include_base_routes(self, router: APIRouter) -> None: + router.include_router(authentication_router()) + + def build_internal_token_route( + self, authenticator: InternalAuthenticator, provider: str + ) -> Callable: + if provider in self._internal_endpoint_cache: + return self._internal_endpoint_cache[provider] + + temp_router = APIRouter() + add_internal_routes(temp_router, provider, authenticator) + endpoint = _extract_route_endpoint(temp_router, f"/provider/{provider}/token", "POST") + self._internal_endpoint_cache[provider] = endpoint + return endpoint + + def _external_endpoints( + self, authenticator: ExternalAuthenticator, provider: str + ) -> dict[str, Callable]: + if provider in self._external_endpoint_cache: + return self._external_endpoint_cache[provider] + + temp_router = APIRouter() + add_external_routes(temp_router, provider, authenticator) + endpoints = { + "code": _extract_route_endpoint(temp_router, f"/provider/{provider}/code", "GET"), + "authorize_get": _extract_route_endpoint( + temp_router, f"/provider/{provider}/authorize", "GET" + ), + "authorize_post": _extract_route_endpoint( + temp_router, f"/provider/{provider}/authorize", "POST" + ), + "device_code_get": _extract_route_endpoint( + temp_router, f"/provider/{provider}/device_code", "GET" + ), + "device_code_post": _extract_route_endpoint( + temp_router, f"/provider/{provider}/device_code", "POST" + ), + "token": _extract_route_endpoint(temp_router, f"/provider/{provider}/token", "POST"), + } + self._external_endpoint_cache[provider] = endpoints + return endpoints + + def build_external_code_route( + self, authenticator: ExternalAuthenticator, provider: str + ) -> Callable: + return self._external_endpoints(authenticator, provider)["code"] + + def build_external_authorize_route( + self, authenticator: ExternalAuthenticator, provider: str + ) -> Callable: + return self._external_endpoints(authenticator, provider)["authorize_get"] + + def build_device_code_authorize_route( + self, authenticator: ExternalAuthenticator, provider: str + ) -> Callable: + return self._external_endpoints(authenticator, provider)["authorize_post"] + + def build_device_code_form_route( + self, authenticator: ExternalAuthenticator, provider: str + ) -> Callable: + return self._external_endpoints(authenticator, provider)["device_code_get"] + + def build_device_code_submit_route( + self, authenticator: ExternalAuthenticator, provider: str + ) -> Callable: + return self._external_endpoints(authenticator, provider)["device_code_post"] + + def build_device_code_token_route( + self, authenticator: ExternalAuthenticator, provider: str + ) -> Callable: + return self._external_endpoints(authenticator, provider)["token"] + + def include_authenticator_routes( + self, + router: APIRouter, + *, + provider: str, + authenticator: InternalAuthenticator | ExternalAuthenticator, + ) -> None: + for custom_router in getattr(authenticator, "include_routers", []): + router.include_router(custom_router, prefix=f"/provider/{provider}") + + +def build_shared_authentication_router( + authenticators: dict[str, InternalAuthenticator | ExternalAuthenticator], +) -> APIRouter: + adapter = TiledAuthRouteAdapter() + providers = [ + AuthProviderRegistration(provider=provider, authenticator=authenticator) + for provider, authenticator in authenticators.items() + ] + return build_authentication_router(providers, adapter) + + async def generate_apikey(db: AsyncSession, principal, apikey_params, request): if apikey_params.scopes is None: scopes = ["inherit"] From c56e46b6dafe516d3ae94c279065475d04b6fa91 Mon Sep 17 00:00:00 2001 From: David Pastl Date: Tue, 30 Jun 2026 12:58:57 -0600 Subject: [PATCH 07/10] Migrating to latest bluesky-auth --- tiled/authenticators.py | 19 -- tiled/authn_database/base.py | 7 +- tiled/authn_database/core.py | 315 +++++++++++++++++++++++++++++++-- tiled/authn_database/orm.py | 190 ++++++++++++++++++-- tiled/server/authentication.py | 68 ++++--- tiled/server/protocols.py | 57 ++---- 6 files changed, 524 insertions(+), 132 deletions(-) diff --git a/tiled/authenticators.py b/tiled/authenticators.py index 8c771875a..d2b7f41d3 100644 --- a/tiled/authenticators.py +++ b/tiled/authenticators.py @@ -1,24 +1,5 @@ import warnings - -def _ensure_shared_package_on_path() -> None: - try: - import bluesky_authentication # noqa: F401 - - return - except ModuleNotFoundError: - pass - - import sys - from pathlib import Path - - candidate = Path(__file__).resolve().parents[2] / "bluesky-authentication" / "src" - if candidate.exists(): - sys.path.insert(0, str(candidate)) - - -_ensure_shared_package_on_path() - warnings.warn( "Importing authenticators from 'tiled.authenticators' is deprecated and will be " "removed in a future release. Use 'bluesky_authentication.authenticators' and " diff --git a/tiled/authn_database/base.py b/tiled/authn_database/base.py index 7f7b40c28..785a46665 100644 --- a/tiled/authn_database/base.py +++ b/tiled/authn_database/base.py @@ -1,3 +1,8 @@ -from bluesky_authentication.auth_store.base import Base +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + """Declarative base for authentication store models.""" + __all__ = ["Base"] diff --git a/tiled/authn_database/core.py b/tiled/authn_database/core.py index 66c0faee0..dc7814b87 100644 --- a/tiled/authn_database/core.py +++ b/tiled/authn_database/core.py @@ -1,19 +1,302 @@ -from bluesky_authentication.auth_store.core import ( - ALL_REVISIONS, - REQUIRED_REVISION, - create_default_roles, - create_service, - create_user, - get_or_create_principal, - initialize_database, - latest_principal_activity, - lookup_valid_api_key, - lookup_valid_pending_session_by_device_code, - lookup_valid_pending_session_by_user_code, - lookup_valid_session, - make_admin_by_identity, - purge_expired, -) +import hashlib +import uuid as uuid_module +from datetime import datetime, timezone + +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession +from sqlalchemy.future import select +from sqlalchemy.orm import selectinload +from sqlalchemy.sql import func + +from .base import Base +from .orm import APIKey, Identity, PendingSession, Principal, Role, Session + +ALL_REVISIONS = [ + "2d1b550e12e0", + "d829476bc173", + "27e069ba3bf5", + "a806cc635ab2", + "0c705a02954c", + "d88e91ea03f9", + "13024b8a6b74", + "769180ce732e", + "c7bd2573716d", + "4a9dfaba4a98", + "56809bcbfcb0", + "722ff4e4fcc7", + "481830dd6c11", +] +REQUIRED_REVISION = ALL_REVISIONS[0] + + +async def create_default_roles(db: AsyncSession) -> None: + default_roles = [ + Role( + name="user", + description="Default Role for users.", + scopes=[ + "read:metadata", + "read:data", + "create:node", + "write:metadata", + "write:data", + "delete:revision", + "delete:node", + "create:apikeys", + "revoke:apikeys", + ], + ), + Role( + name="admin", + description="Role with elevated privileges.", + scopes=[ + "read:metadata", + "read:data", + "create:node", + "register", + "write:metadata", + "write:data", + "delete:revision", + "delete:node", + "admin:apikeys", + "read:principals", + "write:principals", + "metrics", + "read:webhooks", + "write:webhooks", + ], + ), + ] + + roles_result = await db.execute(select(Role.name)) + existing_role_names = set(roles_result.scalars().all()) + roles_to_add = [role for role in default_roles if role.name not in existing_role_names] + if roles_to_add: + db.add_all(roles_to_add) + await db.commit() + + +async def initialize_database(engine: AsyncEngine) -> None: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + async with AsyncSession(engine) as db: + await create_default_roles(db) + + +async def purge_expired(db: AsyncSession, cls) -> int: + now = datetime.now(timezone.utc) + num_expired = 0 + result = await db.execute( + select(cls) + .filter(cls.expiration_time.is_not(None)) + .filter(cls.expiration_time < now) + ) + for obj in result.scalars(): + num_expired += 1 + await db.delete(obj) + if num_expired: + await db.commit() + return num_expired + + +async def create_user(db: AsyncSession, identity_provider: str, id: str) -> Principal: + user_role = (await db.execute(select(Role).filter(Role.name == "user"))).scalar() + if user_role is None: + msg = "User role is missing from Roles table" + raise RuntimeError(msg) + principal = Principal(type="user", roles=[user_role]) + db.add(principal) + await db.commit() + db.add(Identity(provider=identity_provider, id=id, principal_id=principal.id)) + await db.commit() + refreshed = ( + await db.execute( + select(Principal) + .filter(Principal.id == principal.id) + .options(selectinload(Principal.identities)) + ) + ).scalar() + if refreshed is None: + msg = "Principal not found after creation" + raise RuntimeError(msg) + return refreshed + + +async def create_service(db: AsyncSession, role: str) -> Principal: + role_ = (await db.execute(select(Role).filter(Role.name == role))).scalar() + if role_ is None: + msg = f"Role named {role!r} is not found" + raise ValueError(msg) + principal = Principal(type="service", roles=[role_]) + db.add(principal) + await db.commit() + return principal + + +async def lookup_valid_session(db: AsyncSession, session_id: str) -> Session | None: + if isinstance(session_id, int): + return None + session = ( + await db.execute( + select(Session) + .options( + selectinload(Session.principal).selectinload(Principal.roles), + selectinload(Session.principal).selectinload(Principal.identities), + ) + .filter(Session.uuid == uuid_module.UUID(hex=session_id)) + ) + ).scalar() + if session is None: + return None + if session.expiration_time is not None and session.expiration_time.replace( + tzinfo=timezone.utc + ) < datetime.now(timezone.utc): + await db.delete(session) + await db.commit() + return None + return session + + +async def lookup_valid_pending_session_by_device_code( + db: AsyncSession, device_code: bytes +) -> PendingSession | None: + hashed_device_code = hashlib.sha256(device_code).digest() + pending = ( + await db.execute( + select(PendingSession) + .filter(PendingSession.hashed_device_code == hashed_device_code) + .options( + selectinload(PendingSession.session) + .selectinload(Session.principal) + .selectinload(Principal.identities), + ) + ) + ).scalar() + if pending is None: + return None + if pending.expiration_time is not None and pending.expiration_time.replace( + tzinfo=timezone.utc + ) < datetime.now(timezone.utc): + await db.delete(pending) + await db.commit() + return None + return pending + + +async def lookup_valid_pending_session_by_user_code( + db: AsyncSession, user_code: str +) -> PendingSession | None: + pending = ( + await db.execute(select(PendingSession).filter(PendingSession.user_code == user_code)) + ).scalar() + if pending is None: + return None + if pending.expiration_time is not None and pending.expiration_time.replace( + tzinfo=timezone.utc + ) < datetime.now(timezone.utc): + await db.delete(pending) + await db.commit() + return None + return pending + + +async def make_admin_by_identity( + db: AsyncSession, identity_provider: str, id: str +) -> Principal: + identity = ( + await db.execute( + select(Identity) + .options(selectinload(Identity.principal).selectinload(Principal.roles)) + .filter(Identity.id == id) + .filter(Identity.provider == identity_provider) + ) + ).scalar() + principal = await create_user(db, identity_provider, id) if identity is None else identity.principal + for role in principal.roles: + if role.name == "admin": + return principal + admin_role = (await db.execute(select(Role).filter(Role.name == "admin"))).scalar() + if admin_role is None: + msg = "Admin role is missing from Roles table" + raise RuntimeError(msg) + principal.roles.append(admin_role) + await db.commit() + return principal + + +async def lookup_valid_api_key(db: AsyncSession, secret: bytes) -> APIKey | None: + now = datetime.now(timezone.utc) + hashed_secret = hashlib.sha256(secret).digest() + api_key = ( + await db.execute( + select(APIKey) + .options( + selectinload(APIKey.principal).selectinload(Principal.roles), + selectinload(APIKey.principal).selectinload(Principal.identities), + selectinload(APIKey.principal).selectinload(Principal.sessions), + ) + .filter(APIKey.first_eight == secret.hex()[:8]) + .filter(APIKey.hashed_secret == hashed_secret) + ) + ).scalar() + if api_key is None: + return None + if (api_key.expiration_time is not None) and ( + api_key.expiration_time.replace(tzinfo=timezone.utc) < now + ): + await db.delete(api_key) + await db.commit() + return None + if api_key.principal is None: + await db.delete(api_key) + await db.commit() + return None + return api_key + + +async def latest_principal_activity( + db: AsyncSession, principal: Principal +) -> datetime | None: + latest_identity_activity = ( + await db.execute( + select(func.max(Identity.latest_login)).filter(Identity.principal_id == principal.id) + ) + ).scalar() + latest_session_activity = ( + await db.execute( + select(func.max(Session.time_last_refreshed)).filter(Session.principal_id == principal.id) + ) + ).scalar() + latest_api_key_activity = ( + await db.execute( + select(func.max(APIKey.latest_activity)).filter(APIKey.principal_id == principal.id) + ) + ).scalar() + all_activity = [latest_identity_activity, latest_api_key_activity, latest_session_activity] + if all(t is None for t in all_activity): + return None + return max(t for t in all_activity if t is not None) + + +async def get_or_create_principal( + db: AsyncSession, identity_provider: str, id: str +) -> Principal: + identity = ( + await db.execute( + select(Identity) + .options( + selectinload(Identity.principal).selectinload(Principal.roles), + selectinload(Identity.principal).selectinload(Principal.identities), + ) + .filter(Identity.id == id) + .filter(Identity.provider == identity_provider) + ) + ).scalar() + if identity is not None: + identity.latest_login = datetime.now(timezone.utc) + await db.commit() + return identity.principal + return await create_user(db, identity_provider, id) + __all__ = [ "ALL_REVISIONS", diff --git a/tiled/authn_database/orm.py b/tiled/authn_database/orm.py index 09b2b3c4d..ff6771b48 100644 --- a/tiled/authn_database/orm.py +++ b/tiled/authn_database/orm.py @@ -1,17 +1,182 @@ -from bluesky_authentication.auth_store.orm import ( - APIKey, - Identity, - JSONList, - JSONVariant, - PendingSession, - Principal, - PrincipalType, - Role, - Session, - UUID, - principal_role_association_table, +import json +import uuid as uuid_module +from enum import Enum +from typing import ClassVar + +from sqlalchemy import ( + JSON, + Boolean, + Column, + DateTime, + ForeignKey, + Integer, + LargeBinary, + Table, + Unicode, +) +from sqlalchemy import ( + Enum as SQLEnum, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, relationship +from sqlalchemy.sql import func +from sqlalchemy.types import TypeDecorator + +from .base import Base + +JSONVariant = JSON().with_variant(JSONB(), "postgresql") + + +class PrincipalType(str, Enum): + user = "user" + service = "service" + + +class JSONList(TypeDecorator): + impl = Unicode + cache_ok = True + + def process_bind_param(self, value, _dialect): + if value is None: + return None + if not isinstance(value, list): + msg = "JSONList must be given a literal `list` type." + raise TypeError(msg) + return json.dumps(value) + + def process_result_value(self, value, _dialect): + if value is not None: + return json.loads(value) + return value + + +class UUID(TypeDecorator): + impl = Unicode(36) + cache_ok = True + + def process_bind_param(self, value, _dialect): + if value is not None: + if not isinstance(value, uuid_module.UUID): + msg = f"Expected uuid.UUID, got {type(value)}" + raise ValueError(msg) + return str(value) + return None + + def process_result_value(self, value, _dialect): + if value is not None: + return uuid_module.UUID(hex=value) + return None + + +class Timestamped: + __mapper_args__: ClassVar[dict[str, bool]] = {"eager_defaults": True} + + time_created = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + time_updated = Column( + DateTime(timezone=True), + onupdate=func.now(), + nullable=True, + ) + + +principal_role_association_table = Table( + "principal_role_association", + Base.metadata, + Column("principal_id", Integer, ForeignKey("principals.id"), primary_key=True), + Column("role_id", Integer, ForeignKey("roles.id"), primary_key=True), ) + +class Principal(Timestamped, Base): + __tablename__ = "principals" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + uuid = Column(UUID, index=True, nullable=False, default=uuid_module.uuid4) + type = Column(SQLEnum(PrincipalType), nullable=False) + + identities: Mapped[list["Identity"]] = relationship(back_populates="principal") + api_keys: Mapped[list["APIKey"]] = relationship(back_populates="principal") + roles: Mapped[list["Role"]] = relationship( + secondary=principal_role_association_table, + back_populates="principals", + lazy="joined", + ) + sessions: Mapped[list["Session"]] = relationship( + "Session", back_populates="principal" + ) + + +class Identity(Timestamped, Base): + __tablename__ = "identities" + + id = Column(Unicode(255), primary_key=True, nullable=False) + provider = Column(Unicode(255), primary_key=True, nullable=False) + principal_id = Column(Integer, ForeignKey("principals.id"), nullable=False) + latest_login = Column(DateTime(timezone=True), nullable=True) + + principal: Mapped[Principal] = relationship(back_populates="identities") + + +class Role(Timestamped, Base): + __tablename__ = "roles" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + name = Column(Unicode(255), index=True, unique=True, nullable=False) + description = Column(Unicode(1023), nullable=True) + scopes = Column(JSONList(511), nullable=False) + principals: Mapped[list[Principal]] = relationship( + secondary=principal_role_association_table, back_populates="roles" + ) + + +class APIKey(Timestamped, Base): + __tablename__ = "api_keys" + + first_eight = Column(Unicode(8), primary_key=True, index=True, nullable=False) + hashed_secret = Column( + LargeBinary(32), primary_key=True, index=True, nullable=False + ) + expiration_time = Column(DateTime(timezone=True), nullable=True) + latest_activity = Column(DateTime(timezone=True), nullable=True) + note = Column(Unicode(1023), nullable=True) + principal_id = Column(Integer, ForeignKey("principals.id"), nullable=False) + scopes = Column(JSONList(511), nullable=False) + access_tags = Column(JSONList(511), nullable=True) + + principal: Mapped[Principal] = relationship(back_populates="api_keys", lazy="joined") + + +class Session(Timestamped, Base): + __tablename__ = "sessions" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + uuid = Column(UUID, index=True, nullable=False, default=uuid_module.uuid4) + time_last_refreshed = Column(DateTime(timezone=True), nullable=True) + refresh_count = Column(Integer, nullable=False, default=0) + expiration_time = Column(DateTime(timezone=True), nullable=False) + principal_id = Column(Integer, ForeignKey("principals.id"), nullable=False) + revoked = Column(Boolean, default=False, nullable=False) + state = Column(JSONVariant, nullable=False) + + principal: Mapped[Principal] = relationship(back_populates="sessions", lazy="joined") + + +class PendingSession(Base): + __tablename__ = "pending_sessions" + + hashed_device_code = Column( + LargeBinary(32), primary_key=True, index=True, nullable=False + ) + user_code = Column(Unicode(8), index=True, nullable=False) + expiration_time = Column(DateTime(timezone=True), nullable=False) + session_id = Column(Integer, ForeignKey("sessions.id"), nullable=True) + session: Mapped[Session] = relationship(lazy="joined") + + __all__ = [ "APIKey", "Identity", @@ -22,6 +187,7 @@ "PrincipalType", "Role", "Session", + "Timestamped", "UUID", "principal_role_association_table", ] diff --git a/tiled/server/authentication.py b/tiled/server/authentication.py index 0e260e314..0151ad7a8 100644 --- a/tiled/server/authentication.py +++ b/tiled/server/authentication.py @@ -42,6 +42,7 @@ HTTP_409_CONFLICT, ) +from bluesky_authentication import tokens as auth_tokens from bluesky_authentication.integration import ( AuthProviderRegistration, build_authentication_router, @@ -54,7 +55,7 @@ # int_from_bytes is deprecated, use int.from_bytes instead with warnings.catch_warnings(): warnings.simplefilter("ignore") - from jose import ExpiredSignatureError, JWTError, jwt + from jose import ExpiredSignatureError from pydantic import BaseModel @@ -79,8 +80,6 @@ from .settings import Settings, get_settings from .utils import API_KEY_COOKIE_NAME, get_base_url -ALGORITHM = "HS256" - # Max API keys and Sessions allowed to Principal. # This is here for at least two reasons: # 1. Ensure that the routes which list API keys and sessions, which are @@ -137,25 +136,24 @@ async def __call__(self, request: Request) -> Optional[str]: def create_access_token(data, secret_key, expires_delta): - to_encode = data.copy() - expire = utcnow() + expires_delta - to_encode.update({"exp": expire, "type": "access"}) - encoded_jwt = jwt.encode(to_encode, secret_key, algorithm=ALGORITHM) - return encoded_jwt + return auth_tokens.create_access_token( + data, + secret_key, + expires_delta, + utcnow=utcnow, + ) def create_refresh_token(session_id, secret_key, expires_delta): - expire = utcnow() + expires_delta - to_encode = { - "type": "refresh", - "sid": session_id, - "exp": expire, - } - encoded_jwt = jwt.encode(to_encode, secret_key, algorithm=ALGORITHM) - return encoded_jwt + return auth_tokens.create_refresh_token( + session_id, + secret_key, + expires_delta, + utcnow=utcnow, + ) -def decode_token( +async def decode_token( token: str, secret_keys: List[str], proxied_authenticator: Optional[ProxiedOIDCAuthenticator] = None, @@ -165,21 +163,15 @@ def decode_token( detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) - # Try tiled-issued keys first (covers both normal auth and auth-code flow - # tokens issued via create_tokens_from_session). - for secret_key in secret_keys: - try: - payload = jwt.decode(token, secret_key, algorithms=[ALGORITHM]) - return payload - except ExpiredSignatureError: - raise - except JWTError: - continue - # If none of the tiled keys worked, try the proxied authenticator - # (e.g. tokens issued directly by an OIDC provider in the device code flow). - if proxied_authenticator: - return proxied_authenticator.decode_token(token) - raise credentials_exception + proxied_decoder = ( + proxied_authenticator.decode_token if proxied_authenticator is not None else None + ) + return await auth_tokens.decode_token( + token, + secret_keys, + proxied_decoder=proxied_decoder, + credentials_exception=credentials_exception, + ) async def get_api_key( @@ -227,7 +219,7 @@ async def get_decoded_access_token( if not access_token: return None try: - payload = decode_token( + payload = await decode_token( access_token, settings.secret_keys, settings.authenticator ) except ExpiredSignatureError: @@ -299,7 +291,7 @@ def get_api_key_websocket( return api_key -def get_decoded_access_token_websocket( +async def get_decoded_access_token_websocket( websocket: WebSocket, access_token: Optional[str] = Query(None), settings: Settings = Depends(get_settings), @@ -308,7 +300,7 @@ def get_decoded_access_token_websocket( if not access_token: return None try: - return decode_token(access_token, settings.secret_keys, settings.authenticator) + return await decode_token(access_token, settings.secret_keys, settings.authenticator) except ExpiredSignatureError: raise HTTPException( status_code=HTTP_401_UNAUTHORIZED, @@ -503,7 +495,7 @@ async def authenticate_websocket_first_message( return True, principal, access_tags, scopes elif access_token is not None: try: - decoded = decode_token( + decoded = await decode_token( access_token, settings.secret_keys, settings.authenticator ) except Exception: @@ -1558,7 +1550,7 @@ async def revoke_session( ): "Mark a Session as revoked so it cannot be refreshed again." request.state.endpoint = "auth" - payload = decode_token(refresh_token.refresh_token, settings.secret_keys) + payload = await decode_token(refresh_token.refresh_token, settings.secret_keys) session_id = payload["sid"] async with db_factory() as db: # Find this session in the database. @@ -1601,7 +1593,7 @@ async def revoke_session_by_id( async def slide_session(refresh_token, settings, db): try: - payload = decode_token(refresh_token, settings.secret_keys) + payload = await decode_token(refresh_token, settings.secret_keys) except ExpiredSignatureError: raise HTTPException( status_code=HTTP_401_UNAUTHORIZED, diff --git a/tiled/server/protocols.py b/tiled/server/protocols.py index 26902c658..e2f796943 100644 --- a/tiled/server/protocols.py +++ b/tiled/server/protocols.py @@ -1,46 +1,11 @@ -def _ensure_shared_package_on_path() -> None: - try: - import bluesky_authentication # noqa: F401 - - return - except ModuleNotFoundError: - pass - - import sys - from pathlib import Path - - candidate = Path(__file__).resolve().parents[3] / "bluesky-authentication" / "src" - if candidate.exists(): - sys.path.insert(0, str(candidate)) - - -_ensure_shared_package_on_path() - - -try: - from bluesky_authentication.protocols import ( - ExternalAuthenticator, - InternalAuthenticator, - UserSessionState, - ) -except ModuleNotFoundError: - from abc import ABC - from dataclasses import dataclass - from typing import Optional - - from fastapi import Request - - @dataclass - class UserSessionState: - """Data transfer class to communicate custom session state information.""" - - user_name: str - state: dict = None - - class InternalAuthenticator(ABC): - def authenticate(self, username: str, password: str) -> Optional[UserSessionState]: - raise NotImplementedError - - class ExternalAuthenticator(ABC): - def authenticate(self, request: Request) -> Optional[UserSessionState]: - raise NotImplementedError +from bluesky_authentication.protocols import ( # noqa: F401 + ExternalAuthenticator, + InternalAuthenticator, + UserSessionState, +) + +__all__ = [ + "ExternalAuthenticator", + "InternalAuthenticator", + "UserSessionState", +] From 4f1874cd9b6ea35e1bd7b3321224155f59d53c2f Mon Sep 17 00:00:00 2001 From: David Pastl Date: Tue, 30 Jun 2026 14:36:29 -0600 Subject: [PATCH 08/10] Cleaned up how tokens are decoded --- tiled/server/authentication.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/tiled/server/authentication.py b/tiled/server/authentication.py index 0151ad7a8..154e40a88 100644 --- a/tiled/server/authentication.py +++ b/tiled/server/authentication.py @@ -163,15 +163,12 @@ async def decode_token( detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) - proxied_decoder = ( - proxied_authenticator.decode_token if proxied_authenticator is not None else None - ) - return await auth_tokens.decode_token( - token, - secret_keys, - proxied_decoder=proxied_decoder, - credentials_exception=credentials_exception, - ) + payload = auth_tokens.decode_token_with_secret_keys(token, secret_keys) + if payload is not None: + return payload + if proxied_authenticator is not None: + return await proxied_authenticator.decode_token(token) + raise credentials_exception async def get_api_key( From 6c435f79a5438f33ad8682e47744c64347a21e6b Mon Sep 17 00:00:00 2001 From: David Pastl Date: Tue, 30 Jun 2026 15:21:18 -0600 Subject: [PATCH 09/10] Removing unnecessary database changes Originally I wanted to migrate to a matching database for tiled and bluesky-httpserver but have since thought better of that. --- tiled/authn_database/base.py | 7 +- tiled/authn_database/core.py | 184 ++++++++++++++++++++------------- tiled/authn_database/orm.py | 139 +++++++++++++++++-------- tiled/server/authentication.py | 16 +-- 4 files changed, 219 insertions(+), 127 deletions(-) diff --git a/tiled/authn_database/base.py b/tiled/authn_database/base.py index 785a46665..952d0743f 100644 --- a/tiled/authn_database/base.py +++ b/tiled/authn_database/base.py @@ -1,8 +1,7 @@ from sqlalchemy.orm import DeclarativeBase +# Everything imports this so we put it in its own module to +# avoid circular imports. class Base(DeclarativeBase): - """Declarative base for authentication store models.""" - - -__all__ = ["Base"] + pass diff --git a/tiled/authn_database/core.py b/tiled/authn_database/core.py index dc7814b87..de8d0364c 100644 --- a/tiled/authn_database/core.py +++ b/tiled/authn_database/core.py @@ -1,6 +1,7 @@ import hashlib import uuid as uuid_module from datetime import datetime, timezone +from typing import Optional from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession from sqlalchemy.future import select @@ -10,6 +11,7 @@ from .base import Base from .orm import APIKey, Identity, PendingSession, Principal, Role, Session +# This is list of all valid alembic revisions (from current to oldest). ALL_REVISIONS = [ "2d1b550e12e0", "d829476bc173", @@ -28,7 +30,7 @@ REQUIRED_REVISION = ALL_REVISIONS[0] -async def create_default_roles(db: AsyncSession) -> None: +async def create_default_roles(db): default_roles = [ Role( name="user", @@ -69,7 +71,10 @@ async def create_default_roles(db: AsyncSession) -> None: roles_result = await db.execute(select(Role.name)) existing_role_names = set(roles_result.scalars().all()) - roles_to_add = [role for role in default_roles if role.name not in existing_role_names] + roles_to_add = [ + role for role in default_roles if role.name not in existing_role_names + ] + if roles_to_add: db.add_all(roles_to_add) await db.commit() @@ -77,19 +82,26 @@ async def create_default_roles(db: AsyncSession) -> None: async def initialize_database(engine: AsyncEngine) -> None: async with engine.begin() as conn: + # Create all tables. await conn.run_sync(Base.metadata.create_all) + + # Initialize Roles table. async with AsyncSession(engine) as db: await create_default_roles(db) async def purge_expired(db: AsyncSession, cls) -> int: + """ + Remove expired entries. + """ now = datetime.now(timezone.utc) num_expired = 0 - result = await db.execute( + statement = ( select(cls) .filter(cls.expiration_time.is_not(None)) - .filter(cls.expiration_time < now) + .filter(cls.expiration_time.replace(tzinfo=timezone.utc) < now) ) + result = await db.execute(statement) for obj in result.scalars(): num_expired += 1 await db.delete(obj) @@ -100,41 +112,46 @@ async def purge_expired(db: AsyncSession, cls) -> int: async def create_user(db: AsyncSession, identity_provider: str, id: str) -> Principal: user_role = (await db.execute(select(Role).filter(Role.name == "user"))).scalar() - if user_role is None: - msg = "User role is missing from Roles table" - raise RuntimeError(msg) + assert user_role is not None, "User role is missing from Roles table" principal = Principal(type="user", roles=[user_role]) db.add(principal) await db.commit() - db.add(Identity(provider=identity_provider, id=id, principal_id=principal.id)) + identity = Identity( + provider=identity_provider, + id=id, + principal_id=principal.id, + ) + db.add(identity) await db.commit() - refreshed = ( + refreshed_principal = ( await db.execute( select(Principal) .filter(Principal.id == principal.id) .options(selectinload(Principal.identities)) ) ).scalar() - if refreshed is None: - msg = "Principal not found after creation" - raise RuntimeError(msg) - return refreshed + assert ( + refreshed_principal is not None + ), f"Newly created user {id} ({identity_provider}) missing from Principals table" + return refreshed_principal async def create_service(db: AsyncSession, role: str) -> Principal: role_ = (await db.execute(select(Role).filter(Role.name == role))).scalar() if role_ is None: - msg = f"Role named {role!r} is not found" - raise ValueError(msg) + raise ValueError(f"Role named {role!r} is not found") principal = Principal(type="service", roles=[role_]) db.add(principal) await db.commit() return principal -async def lookup_valid_session(db: AsyncSession, session_id: str) -> Session | None: +async def lookup_valid_session(db: AsyncSession, session_id: str) -> Optional[Session]: if isinstance(session_id, int): + # Old versions of tiled used an integer sid. + # Reject any of those old sessions and force reauthentication. return None + session = ( await db.execute( select(Session) @@ -157,10 +174,10 @@ async def lookup_valid_session(db: AsyncSession, session_id: str) -> Session | N async def lookup_valid_pending_session_by_device_code( - db: AsyncSession, device_code: bytes -) -> PendingSession | None: + db: AsyncSession, device_code: str +) -> Optional[PendingSession]: hashed_device_code = hashlib.sha256(device_code).digest() - pending = ( + pending_session = ( await db.execute( select(PendingSession) .filter(PendingSession.hashed_device_code == hashed_device_code) @@ -171,32 +188,38 @@ async def lookup_valid_pending_session_by_device_code( ) ) ).scalar() - if pending is None: + if pending_session is None: return None - if pending.expiration_time is not None and pending.expiration_time.replace( - tzinfo=timezone.utc - ) < datetime.now(timezone.utc): - await db.delete(pending) + if ( + pending_session.expiration_time is not None + and pending_session.expiration_time.replace(tzinfo=timezone.utc) + < datetime.now(timezone.utc) + ): + await db.delete(pending_session) await db.commit() return None - return pending + return pending_session async def lookup_valid_pending_session_by_user_code( db: AsyncSession, user_code: str -) -> PendingSession | None: - pending = ( - await db.execute(select(PendingSession).filter(PendingSession.user_code == user_code)) +) -> Optional[PendingSession]: + pending_session = ( + await db.execute( + select(PendingSession).filter(PendingSession.user_code == user_code) + ) ).scalar() - if pending is None: + if pending_session is None: return None - if pending.expiration_time is not None and pending.expiration_time.replace( - tzinfo=timezone.utc - ) < datetime.now(timezone.utc): - await db.delete(pending) + if ( + pending_session.expiration_time is not None + and pending_session.expiration_time.replace(tzinfo=timezone.utc) + < datetime.now(timezone.utc) + ): + await db.delete(pending_session) await db.commit() return None - return pending + return pending_session async def make_admin_by_identity( @@ -210,20 +233,28 @@ async def make_admin_by_identity( .filter(Identity.provider == identity_provider) ) ).scalar() - principal = await create_user(db, identity_provider, id) if identity is None else identity.principal + if identity is None: + principal = await create_user(db, identity_provider, id) + else: + principal = identity.principal + + # check if principal already has admin role for role in principal.roles: if role.name == "admin": return principal + admin_role = (await db.execute(select(Role).filter(Role.name == "admin"))).scalar() - if admin_role is None: - msg = "Admin role is missing from Roles table" - raise RuntimeError(msg) + assert admin_role is not None, "Admin role is missing from Roles table" principal.roles.append(admin_role) await db.commit() return principal -async def lookup_valid_api_key(db: AsyncSession, secret: bytes) -> APIKey | None: +async def lookup_valid_api_key(db: AsyncSession, secret: bytes) -> Optional[APIKey]: + """ + Look up an API key. Ensure that it is valid. + """ + now = datetime.now(timezone.utc) hashed_secret = hashlib.sha256(secret).digest() api_key = ( @@ -239,40 +270,65 @@ async def lookup_valid_api_key(db: AsyncSession, secret: bytes) -> APIKey | None ) ).scalar() if api_key is None: - return None - if (api_key.expiration_time is not None) and ( + # No match + validated_api_key = None + elif (api_key.expiration_time is not None) and ( api_key.expiration_time.replace(tzinfo=timezone.utc) < now ): + # Match is expired. Delete it. await db.delete(api_key) await db.commit() - return None - if api_key.principal is None: + validated_api_key = None + elif api_key.principal is None: + # The Principal for the API key no longer exists. Delete it. await db.delete(api_key) await db.commit() - return None - return api_key + validated_api_key = None + else: + validated_api_key = api_key + return validated_api_key async def latest_principal_activity( db: AsyncSession, principal: Principal -) -> datetime | None: +) -> Optional[datetime]: + """ + The most recent time this Principal has logged in with an Identity, + refreshed a Session, or used an APIKey. + + Note that activity that is authenticated using an access token is not + captured here. As usual with JWTs, those requests do not interact with + this database, for performance reasons. Therefore, this may lag actual + activity by as much as the max age of an access token (default: 15 + minutes). + """ latest_identity_activity = ( await db.execute( - select(func.max(Identity.latest_login)).filter(Identity.principal_id == principal.id) + select(func.max(Identity.latest_login)).filter( + Identity.principal_id == principal.id + ) ) ).scalar() latest_session_activity = ( await db.execute( - select(func.max(Session.time_last_refreshed)).filter(Session.principal_id == principal.id) + select(func.max(Session.time_last_refreshed)).filter( + Session.principal_id == principal.id + ) ) ).scalar() latest_api_key_activity = ( await db.execute( - select(func.max(APIKey.latest_activity)).filter(APIKey.principal_id == principal.id) + select(func.max(APIKey.latest_activity)).filter( + APIKey.principal_id == principal.id + ) ) ).scalar() - all_activity = [latest_identity_activity, latest_api_key_activity, latest_session_activity] - if all(t is None for t in all_activity): + all_activity = [ + latest_identity_activity, + latest_api_key_activity, + latest_session_activity, + ] + if all([t is None for t in all_activity]): return None return max(t for t in all_activity if t is not None) @@ -280,6 +336,11 @@ async def latest_principal_activity( async def get_or_create_principal( db: AsyncSession, identity_provider: str, id: str ) -> Principal: + """ + Look up a Principal by identity, creating one if it does not exist. + Does not create a Session -- intended for proxied OIDC where session + management is handled externally. + """ identity = ( await db.execute( select(Identity) @@ -292,25 +353,8 @@ async def get_or_create_principal( ) ).scalar() if identity is not None: - identity.latest_login = datetime.now(timezone.utc) + now = datetime.now(timezone.utc) + identity.latest_login = now await db.commit() return identity.principal return await create_user(db, identity_provider, id) - - -__all__ = [ - "ALL_REVISIONS", - "REQUIRED_REVISION", - "create_default_roles", - "create_service", - "create_user", - "get_or_create_principal", - "initialize_database", - "latest_principal_activity", - "lookup_valid_api_key", - "lookup_valid_pending_session_by_device_code", - "lookup_valid_pending_session_by_user_code", - "lookup_valid_session", - "make_admin_by_identity", - "purge_expired", -] diff --git a/tiled/authn_database/orm.py b/tiled/authn_database/orm.py index ff6771b48..bf16933bf 100644 --- a/tiled/authn_database/orm.py +++ b/tiled/authn_database/orm.py @@ -1,75 +1,93 @@ import json import uuid as uuid_module -from enum import Enum -from typing import ClassVar from sqlalchemy import ( JSON, Boolean, Column, DateTime, + Enum, ForeignKey, Integer, LargeBinary, Table, Unicode, ) -from sqlalchemy import ( - Enum as SQLEnum, -) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, relationship from sqlalchemy.sql import func from sqlalchemy.types import TypeDecorator +from ..server.schemas import PrincipalType from .base import Base +# Use JSON with SQLite and JSONB with PostgreSQL. JSONVariant = JSON().with_variant(JSONB(), "postgresql") -class PrincipalType(str, Enum): - user = "user" - service = "service" +class JSONList(TypeDecorator): + """Represents an immutable structure as a JSON-encoded list. + Usage:: + + JSONList(255) + + """ -class JSONList(TypeDecorator): impl = Unicode cache_ok = True - def process_bind_param(self, value, _dialect): + def process_bind_param(self, value, dialect): if value is None: + # Allow None for columns that are nullable return None - if not isinstance(value, list): - msg = "JSONList must be given a literal `list` type." - raise TypeError(msg) - return json.dumps(value) + else: + # Make sure we don't get passed some iterable like a dict. + if not isinstance(value, list): + raise ValueError("JSONList must be given a literal `list` type.") + value = json.dumps(value) + return value - def process_result_value(self, value, _dialect): + def process_result_value(self, value, dialect): if value is not None: - return json.loads(value) + value = json.loads(value) return value class UUID(TypeDecorator): + """Represents a UUID in a dialect-agnostic way + + Postgres has built-in support but SQLite does not, so we + just use a 36-character Unicode column. + + We could use 16-byte LargeBinary, which would be more compact + but we decided it was worth the cost to make the content easily + inspectable by external database management and development tools. + """ + impl = Unicode(36) cache_ok = True - def process_bind_param(self, value, _dialect): + def process_bind_param(self, value, dialect): if value is not None: if not isinstance(value, uuid_module.UUID): - msg = f"Expected uuid.UUID, got {type(value)}" - raise ValueError(msg) + raise ValueError(f"Expected uuid.UUID, got {type(value)}") return str(value) - return None - def process_result_value(self, value, _dialect): + def process_result_value(self, value, dialect): if value is not None: return uuid_module.UUID(hex=value) - return None class Timestamped: - __mapper_args__: ClassVar[dict[str, bool]] = {"eager_defaults": True} + """ + Mixin for providing timestamps of creation and update time. + + These are not used by application code, but they may be useful for + forensics. + """ + + __mapper_args__ = {"eager_defaults": True} time_created = Column( DateTime(timezone=True), @@ -80,7 +98,18 @@ class Timestamped: DateTime(timezone=True), onupdate=func.now(), nullable=True, - ) + ) # null until first update + + def __repr__(self): + return ( + f"{type(self).__name__}(" + + ", ".join( + f"{key}={value!r}" + for key, value in self.__dict__.items() + if not key.startswith("_") + ) + + ")" + ) principal_role_association_table = Table( @@ -94,9 +123,17 @@ class Timestamped: class Principal(Timestamped, Base): __tablename__ = "principals" + # This id is internal, never exposed to the client. id = Column(Integer, primary_key=True, index=True, autoincrement=True) - uuid = Column(UUID, index=True, nullable=False, default=uuid_module.uuid4) - type = Column(SQLEnum(PrincipalType), nullable=False) + # This uuid is public. + uuid = Column( + UUID, + index=True, + nullable=False, + default=lambda: uuid_module.uuid4(), + ) + type = Column(Enum(PrincipalType), nullable=False) + # In the future we may add other information. identities: Mapped[list["Identity"]] = relationship(back_populates="principal") api_keys: Mapped[list["APIKey"]] = relationship(back_populates="principal") @@ -113,10 +150,12 @@ class Principal(Timestamped, Base): class Identity(Timestamped, Base): __tablename__ = "identities" + # An (id, provider) pair must be unique. id = Column(Unicode(255), primary_key=True, nullable=False) provider = Column(Unicode(255), primary_key=True, nullable=False) principal_id = Column(Integer, ForeignKey("principals.id"), nullable=False) latest_login = Column(DateTime(timezone=True), nullable=True) + # In the future we may add a notion of "primary" identity. principal: Mapped[Principal] = relationship(back_populates="identities") @@ -136,6 +175,11 @@ class Role(Timestamped, Base): class APIKey(Timestamped, Base): __tablename__ = "api_keys" + # Store the first_eight characters of the hex-encoded secret. + # The key holder can use this to identity the key. + # We do not store the full secret, only its sha256-hashed value. + # A primary key on (first_eight, hashed_secret) enables + # fast lookups. first_eight = Column(Unicode(8), primary_key=True, index=True, nullable=False) hashed_secret = Column( LargeBinary(32), primary_key=True, index=True, nullable=False @@ -146,26 +190,47 @@ class APIKey(Timestamped, Base): principal_id = Column(Integer, ForeignKey("principals.id"), nullable=False) scopes = Column(JSONList(511), nullable=False) access_tags = Column(JSONList(511), nullable=True) + # In the future we could make it possible to disable API keys + # without deleting them from the database, for forensics and + # record-keeping. - principal: Mapped[Principal] = relationship(back_populates="api_keys", lazy="joined") + principal: Mapped[Principal] = relationship( + back_populates="api_keys", lazy="joined" + ) class Session(Timestamped, Base): + """ + This related to refresh tokens, which have a session uuid ("sid") claim. + + When the client attempts to use a refresh token, we first check + here to ensure that the "session", which is associated with a chain + of refresh tokens that came from a single authentication, are still valid. + """ + __tablename__ = "sessions" + # This id is internal, never exposed to the client. id = Column(Integer, primary_key=True, index=True, autoincrement=True) + # This uuid is exposed to the client. uuid = Column(UUID, index=True, nullable=False, default=uuid_module.uuid4) time_last_refreshed = Column(DateTime(timezone=True), nullable=True) refresh_count = Column(Integer, nullable=False, default=0) expiration_time = Column(DateTime(timezone=True), nullable=False) principal_id = Column(Integer, ForeignKey("principals.id"), nullable=False) revoked = Column(Boolean, default=False, nullable=False) + # State allows for custom authenticator information to be stored in the session. state = Column(JSONVariant, nullable=False) - - principal: Mapped[Principal] = relationship(back_populates="sessions", lazy="joined") + principal: Mapped[Principal] = relationship( + back_populates="sessions", lazy="joined" + ) class PendingSession(Base): + """ + This is used only in Device Code Flow. + """ + __tablename__ = "pending_sessions" hashed_device_code = Column( @@ -175,19 +240,3 @@ class PendingSession(Base): expiration_time = Column(DateTime(timezone=True), nullable=False) session_id = Column(Integer, ForeignKey("sessions.id"), nullable=True) session: Mapped[Session] = relationship(lazy="joined") - - -__all__ = [ - "APIKey", - "Identity", - "JSONList", - "JSONVariant", - "PendingSession", - "Principal", - "PrincipalType", - "Role", - "Session", - "Timestamped", - "UUID", - "principal_role_association_table", -] diff --git a/tiled/server/authentication.py b/tiled/server/authentication.py index 154e40a88..143a7b3bd 100644 --- a/tiled/server/authentication.py +++ b/tiled/server/authentication.py @@ -153,7 +153,7 @@ def create_refresh_token(session_id, secret_key, expires_delta): ) -async def decode_token( +def decode_token( token: str, secret_keys: List[str], proxied_authenticator: Optional[ProxiedOIDCAuthenticator] = None, @@ -167,7 +167,7 @@ async def decode_token( if payload is not None: return payload if proxied_authenticator is not None: - return await proxied_authenticator.decode_token(token) + return proxied_authenticator.decode_token(token) raise credentials_exception @@ -216,7 +216,7 @@ async def get_decoded_access_token( if not access_token: return None try: - payload = await decode_token( + payload = decode_token( access_token, settings.secret_keys, settings.authenticator ) except ExpiredSignatureError: @@ -288,7 +288,7 @@ def get_api_key_websocket( return api_key -async def get_decoded_access_token_websocket( +def get_decoded_access_token_websocket( websocket: WebSocket, access_token: Optional[str] = Query(None), settings: Settings = Depends(get_settings), @@ -297,7 +297,7 @@ async def get_decoded_access_token_websocket( if not access_token: return None try: - return await decode_token(access_token, settings.secret_keys, settings.authenticator) + return decode_token(access_token, settings.secret_keys, settings.authenticator) except ExpiredSignatureError: raise HTTPException( status_code=HTTP_401_UNAUTHORIZED, @@ -492,7 +492,7 @@ async def authenticate_websocket_first_message( return True, principal, access_tags, scopes elif access_token is not None: try: - decoded = await decode_token( + decoded = decode_token( access_token, settings.secret_keys, settings.authenticator ) except Exception: @@ -1547,7 +1547,7 @@ async def revoke_session( ): "Mark a Session as revoked so it cannot be refreshed again." request.state.endpoint = "auth" - payload = await decode_token(refresh_token.refresh_token, settings.secret_keys) + payload = decode_token(refresh_token.refresh_token, settings.secret_keys) session_id = payload["sid"] async with db_factory() as db: # Find this session in the database. @@ -1590,7 +1590,7 @@ async def revoke_session_by_id( async def slide_session(refresh_token, settings, db): try: - payload = await decode_token(refresh_token, settings.secret_keys) + payload = decode_token(refresh_token, settings.secret_keys) except ExpiredSignatureError: raise HTTPException( status_code=HTTP_401_UNAUTHORIZED, From deb48d21cdcd40776464e4c1daf5c3ce9dc92af4 Mon Sep 17 00:00:00 2001 From: David Pastl Date: Thu, 2 Jul 2026 11:24:29 -0600 Subject: [PATCH 10/10] Universal token decoding --- tiled/server/authentication.py | 227 ++++++++++++++++++--------------- 1 file changed, 122 insertions(+), 105 deletions(-) diff --git a/tiled/server/authentication.py b/tiled/server/authentication.py index 143a7b3bd..c54227bae 100644 --- a/tiled/server/authentication.py +++ b/tiled/server/authentication.py @@ -18,7 +18,6 @@ Security, WebSocket, ) -from fastapi.routing import APIRoute from fastapi.responses import RedirectResponse from fastapi.security import ( OAuth2PasswordBearer, @@ -48,6 +47,7 @@ build_authentication_router, ) from bluesky_authentication.authenticators import ProxiedOIDCAuthenticator +from bluesky_authentication.tokens import decode_token from tiled.access_control.scopes import NO_SCOPES, PUBLIC_SCOPES, SINGLE_USER_SCOPES # To hide third-party warning @@ -153,22 +153,6 @@ def create_refresh_token(session_id, secret_key, expires_delta): ) -def decode_token( - token: str, - secret_keys: List[str], - proxied_authenticator: Optional[ProxiedOIDCAuthenticator] = None, -) -> dict[str, Any]: - credentials_exception = HTTPException( - status_code=HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - payload = auth_tokens.decode_token_with_secret_keys(token, secret_keys) - if payload is not None: - return payload - if proxied_authenticator is not None: - return proxied_authenticator.decode_token(token) - raise credentials_exception async def get_api_key( @@ -217,7 +201,9 @@ async def get_decoded_access_token( return None try: payload = decode_token( - access_token, settings.secret_keys, settings.authenticator + access_token, + settings.secret_keys, + proxied_decoder=settings.authenticator.decode_token if settings.authenticator else None, ) except ExpiredSignatureError: raise HTTPException( @@ -297,7 +283,11 @@ def get_decoded_access_token_websocket( if not access_token: return None try: - return decode_token(access_token, settings.secret_keys, settings.authenticator) + return decode_token( + access_token, + settings.secret_keys, + proxied_decoder=settings.authenticator.decode_token if settings.authenticator else None, + ) except ExpiredSignatureError: raise HTTPException( status_code=HTTP_401_UNAUTHORIZED, @@ -493,7 +483,9 @@ async def authenticate_websocket_first_message( elif access_token is not None: try: decoded = decode_token( - access_token, settings.secret_keys, settings.authenticator + access_token, + settings.secret_keys, + proxied_decoder=settings.authenticator.decode_token if settings.authenticator else None, ) except Exception: return False, None, None, NO_SCOPES @@ -886,19 +878,15 @@ async def create_tokens_from_session( } -def add_external_routes( - router: APIRouter, provider: str, authenticator: ExternalAuthenticator -): +def build_external_code_route( + provider: str, authenticator: ExternalAuthenticator +) -> Callable: if not SHARE_TILED_PATH: raise Exception( "Static assets could not be found and are required for " "setting up external OAuth authentication." ) - templates = Jinja2Templates(Path(SHARE_TILED_PATH, "templates")) - "Build an auth_code route function for this Authenticator." - - @router.get(f"/provider/{provider}/code") async def auth_code_route( request: Request, response: Response, @@ -945,15 +933,18 @@ async def auth_code_route( else: return tokens - @router.get(f"/provider/{provider}/authorize") + return auth_code_route + + +def build_external_authorize_route( + provider: str, authenticator: ExternalAuthenticator +) -> Callable: async def authorize_redirect_route( request: Request, state: Optional[str] = Query(None), ): """Redirect browser to OAuth provider for authentication.""" - redirect_uri = f"{get_base_url(request)}/auth/provider/{provider}/code" - scopes = {"openid", "offline_access"} scopes.update(getattr(authenticator, "extra_scopes", [])) params = { @@ -965,13 +956,15 @@ async def authorize_redirect_route( } if state: params["state"] = state - auth_url = authenticator.authorization_endpoint.copy_with(params=params) return RedirectResponse(url=str(auth_url)) - "Build an /authorize route function for this Authenticator." + return authorize_redirect_route - @router.post(f"/provider/{provider}/authorize") + +def build_device_code_authorize_route( + provider: str, authenticator: ExternalAuthenticator +) -> Callable: async def device_code_authorize_route( request: Request, db_factory: Callable[[], Optional[AsyncSession]] = Depends( @@ -991,19 +984,27 @@ async def device_code_authorize_route( } ) return { - "authorization_uri": str( - authorization_uri - ), # URL that user should visit in browser - "verification_uri": str( - verification_uri - ), # URL that terminal client will poll - "interval": DEVICE_CODE_POLLING_INTERVAL, # suggested polling interval + "authorization_uri": str(authorization_uri), + "verification_uri": str(verification_uri), + "interval": DEVICE_CODE_POLLING_INTERVAL, "device_code": pending_session["device_code"], - "expires_in": DEVICE_CODE_MAX_AGE, # seconds + "expires_in": DEVICE_CODE_MAX_AGE, "user_code": pending_session["user_code"], } - @router.get(f"/provider/{provider}/device_code") + return device_code_authorize_route + + +def build_device_code_form_route( + provider: str, authenticator: ExternalAuthenticator +) -> Callable: + if not SHARE_TILED_PATH: + raise Exception( + "Static assets could not be found and are required for " + "setting up external OAuth authentication." + ) + templates = Jinja2Templates(Path(SHARE_TILED_PATH, "templates")) + async def device_code_user_code_form_route( request: Request, code: str, @@ -1020,9 +1021,19 @@ async def device_code_user_code_form_route( }, ) - "Build an /authorize route function for this Authenticator." + return device_code_user_code_form_route + + +def build_device_code_submit_route( + provider: str, authenticator: ExternalAuthenticator +) -> Callable: + if not SHARE_TILED_PATH: + raise Exception( + "Static assets could not be found and are required for " + "setting up external OAuth authentication." + ) + templates = Jinja2Templates(Path(SHARE_TILED_PATH, "templates")) - @router.post(f"/provider/{provider}/device_code") async def device_code_user_code_submit_route( request: Request, code: str = Form(), @@ -1086,9 +1097,12 @@ async def device_code_user_code_submit_route( }, ) - "Build an /authorize route function for this Authenticator." + return device_code_user_code_submit_route + - @router.post(f"/provider/{provider}/token") +def build_device_code_token_route( + provider: str, authenticator: ExternalAuthenticator +) -> Callable: async def device_code_token_route( request: Request, body: schemas.DeviceCode, @@ -1102,7 +1116,6 @@ async def device_code_token_route( try: device_code = bytes.fromhex(device_code_hex) except Exception: - # Not valid hex, therefore not a valid device_code raise HTTPException( status_code=HTTP_401_UNAUTHORIZED, detail="Invalid device code" ) @@ -1120,19 +1133,17 @@ async def device_code_token_route( HTTP_400_BAD_REQUEST, {"error": "authorization_pending"} ) session = pending_session.session - # The pending session can only be used once. await db.delete(pending_session) await db.commit() tokens = await create_tokens_from_session(settings, db, session, provider) return tokens + return device_code_token_route -def add_internal_routes( - router: APIRouter, provider: str, authenticator: InternalAuthenticator -): - "Register a handle_credentials route function for this Authenticator." - @router.post(f"/provider/{provider}/token") +def build_internal_token_route( + provider: str, authenticator: InternalAuthenticator +) -> Callable: async def handle_credentials_route( request: Request, form_data: OAuth2PasswordRequestForm = Depends(), @@ -1164,92 +1175,98 @@ async def handle_credentials_route( tokens = await create_tokens_from_session(settings, db, session, provider) return tokens + return handle_credentials_route -def _extract_route_endpoint(router: APIRouter, path: str, method: str) -> Callable: - method = method.upper() - for route in router.routes: - if isinstance(route, APIRoute) and route.path == path and method in route.methods: - return route.endpoint - msg = f"Could not find route endpoint for path={path!r}, method={method!r}." - raise RuntimeError(msg) +# --------------------------------------------------------------------------- +# Keep add_*_routes as thin wrappers for backward compatibility +# (tiled's own test suite may import them directly). +# --------------------------------------------------------------------------- + + +def add_external_routes( + router: APIRouter, provider: str, authenticator: ExternalAuthenticator +): + router.add_api_route( + f"/provider/{provider}/code", + build_external_code_route(provider, authenticator), + methods=["GET"], + ) + router.add_api_route( + f"/provider/{provider}/authorize", + build_external_authorize_route(provider, authenticator), + methods=["GET"], + ) + router.add_api_route( + f"/provider/{provider}/authorize", + build_device_code_authorize_route(provider, authenticator), + methods=["POST"], + ) + router.add_api_route( + f"/provider/{provider}/device_code", + build_device_code_form_route(provider, authenticator), + methods=["GET"], + ) + router.add_api_route( + f"/provider/{provider}/device_code", + build_device_code_submit_route(provider, authenticator), + methods=["POST"], + ) + router.add_api_route( + f"/provider/{provider}/token", + build_device_code_token_route(provider, authenticator), + methods=["POST"], + ) -class TiledAuthRouteAdapter: - def __init__(self) -> None: - self._external_endpoint_cache: dict[str, dict[str, Callable]] = {} - self._internal_endpoint_cache: dict[str, Callable] = {} +def add_internal_routes( + router: APIRouter, provider: str, authenticator: InternalAuthenticator +): + router.add_api_route( + f"/provider/{provider}/token", + build_internal_token_route(provider, authenticator), + methods=["POST"], + ) + + +class TiledAuthRouteAdapter: def include_base_routes(self, router: APIRouter) -> None: router.include_router(authentication_router()) def build_internal_token_route( self, authenticator: InternalAuthenticator, provider: str ) -> Callable: - if provider in self._internal_endpoint_cache: - return self._internal_endpoint_cache[provider] - - temp_router = APIRouter() - add_internal_routes(temp_router, provider, authenticator) - endpoint = _extract_route_endpoint(temp_router, f"/provider/{provider}/token", "POST") - self._internal_endpoint_cache[provider] = endpoint - return endpoint - - def _external_endpoints( - self, authenticator: ExternalAuthenticator, provider: str - ) -> dict[str, Callable]: - if provider in self._external_endpoint_cache: - return self._external_endpoint_cache[provider] - - temp_router = APIRouter() - add_external_routes(temp_router, provider, authenticator) - endpoints = { - "code": _extract_route_endpoint(temp_router, f"/provider/{provider}/code", "GET"), - "authorize_get": _extract_route_endpoint( - temp_router, f"/provider/{provider}/authorize", "GET" - ), - "authorize_post": _extract_route_endpoint( - temp_router, f"/provider/{provider}/authorize", "POST" - ), - "device_code_get": _extract_route_endpoint( - temp_router, f"/provider/{provider}/device_code", "GET" - ), - "device_code_post": _extract_route_endpoint( - temp_router, f"/provider/{provider}/device_code", "POST" - ), - "token": _extract_route_endpoint(temp_router, f"/provider/{provider}/token", "POST"), - } - self._external_endpoint_cache[provider] = endpoints - return endpoints + return build_internal_token_route(provider, authenticator) def build_external_code_route( self, authenticator: ExternalAuthenticator, provider: str ) -> Callable: - return self._external_endpoints(authenticator, provider)["code"] + return build_external_code_route(provider, authenticator) def build_external_authorize_route( self, authenticator: ExternalAuthenticator, provider: str ) -> Callable: - return self._external_endpoints(authenticator, provider)["authorize_get"] + return build_external_authorize_route(provider, authenticator) def build_device_code_authorize_route( self, authenticator: ExternalAuthenticator, provider: str ) -> Callable: - return self._external_endpoints(authenticator, provider)["authorize_post"] + return build_device_code_authorize_route(provider, authenticator) def build_device_code_form_route( self, authenticator: ExternalAuthenticator, provider: str ) -> Callable: - return self._external_endpoints(authenticator, provider)["device_code_get"] + return build_device_code_form_route(provider, authenticator) def build_device_code_submit_route( self, authenticator: ExternalAuthenticator, provider: str ) -> Callable: - return self._external_endpoints(authenticator, provider)["device_code_post"] + return build_device_code_submit_route(provider, authenticator) def build_device_code_token_route( self, authenticator: ExternalAuthenticator, provider: str ) -> Callable: - return self._external_endpoints(authenticator, provider)["token"] + return build_device_code_token_route(provider, authenticator) def include_authenticator_routes( self,