From b7a129160180a20346cade14f7f604b10fb57af7 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Wed, 6 May 2026 13:41:19 -0700 Subject: [PATCH 01/42] initial add of code from vegbank module, minor changes to get tests passing --- pyproject.toml | 7 +- src/dataone/auth.py | 606 +++++++++++++++++++++++++++++++++++++++++++- tests/test_auth.py | 38 ++- uv.lock | 366 ++++++++++++++++++++++++++ 4 files changed, 997 insertions(+), 20 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a0fccfa..0637e9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,12 @@ authors = [ { name = "Matthew B. Jones", email = "jones@nceas.ucsb.edu" } ] requires-python = ">=3.13" -dependencies = [] +dependencies = [ + "authlib>=1.7.2", + "flask>=3.1.3", + "requests>=2.33.1", + "werkzeug>=3.1.8", +] [project.scripts] dataone = "dataone:main" diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 082564e..ed51503 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -2,22 +2,616 @@ Implements OIDC / OAuth 2.0 login via a configurable OIDC provider using authlib. +Deployment Modes +---------------- +The API supports three access modes controlled by the ``VB_ACCESS_MODE`` environment variable: + +``read_only`` + Authentication disabled. All endpoints are public. File uploads disabled. + +``open`` + Authentication disabled. All endpoints are public. File uploads allowed. + +``authenticated`` + Full authentication and authorization enabled. Protected endpoints require valid JWT tokens with appropriate scopes. + +Decorator overview +-------------------------------------------------- +``require_token`` + Protects an endpoint that requires *any* valid, unexpired JWT issued by + the configured OIDC provider. + +``require_scope(scope)`` + Same as ``require_token`` but additionally asserts that the token contains the + correct Vegbank scope (e.g. ``"vegbank:admin"``, ``"vegbank:contributor"``, + ``"vegbank:user"``). + """ +import functools +import json import logging +import os +import re +from requests import RequestException + +import requests as _requests + +from authlib.integrations.base_client.errors import OAuthError +from authlib.integrations.flask_client import OAuth +from authlib.jose import JsonWebKey, jwt +from authlib.jose.errors import BadSignatureError, DecodeError, InvalidTokenError +from authlib.oauth2 import OAuth2Error +from authlib.oauth2.rfc6749.errors import InvalidGrantError, InvalidClientError + +from flask import Blueprint, g, jsonify, request, url_for +from werkzeug.middleware.proxy_fix import ProxyFix + +_DEFAULT_SECRETS_PATH = "/etc/vegbank/oidc/client_secrets.json" +MAX_TOKEN_LEN = 16_384 # Token length limit in characters (~16 KB) to prevent DoS attacks + +class MissingParameterError(Exception): + """Raised when a required request parameter is missing.""" + +# Standard OIDC scopes — overridable via environment variable +DEFAULT_SCOPES = os.getenv("VB_OIDC_DEFAULT_SCOPES", "openid email profile") + +# VegBank-specific scopes — configurable via environment variables set by Helm +SCOPE_ADMIN = os.getenv("VB_SCOPE_ADMIN", "vegbank:admin") +SCOPE_CONTRIBUTOR = os.getenv("VB_SCOPE_CONTRIBUTOR", "vegbank:contributor") +SCOPE_USER = os.getenv("VB_SCOPE_USER", "vegbank:user") + +# Deployment modes +ACCESS_MODE_READ_ONLY = "read_only" # Read-only mode: no uploads, no auth +ACCESS_MODE_OPEN = "open" # Open mode: uploads allowed, no auth +ACCESS_MODE_AUTHENTICATED = "authenticated" # Authenticated mode: auth required, full access control # Initialize module-level logger logger = logging.getLogger(__name__) +oauth = OAuth() +auth_bp = Blueprint("auth", __name__) + + +def load_client_secrets(filepath: str | None = None) -> dict: + """Load client secrets from a JSON file. + + Args: + filepath: Optional explicit path. Falls back to the + ``OIDC_CLIENT_SECRETS_FILE`` environment variable + + Returns: + Parsed dict of client credentials. + """ + # accept either explicit filepath argument or environment variable, with a default fallback + resolved = ( + filepath + or os.getenv("OIDC_CLIENT_SECRETS_FILE") + or _DEFAULT_SECRETS_PATH + ) + with open(resolved, "r") as f: + return json.load(f) + + +def init_oauth(app) -> bool: + """Initialise the OAuth client and register the OIDC provider. + + Call once at app startup, after creating the Flask instance. + + Args: + app: The Flask application instance. + + Returns: + True on success, False if the secrets file is missing (auth unavailable). + """ + # In read_only or open mode, skip OAuth initialization + mode = get_access_mode() + if mode != ACCESS_MODE_AUTHENTICATED: + logger.warning("Access mode '%s': skipping OAuth initialisation.", mode) + return True + + try: + secrets = load_client_secrets() + except (FileNotFoundError, json.JSONDecodeError) as exc: + logger.warning("Could not load client secrets (%s). Auth unavailable.", exc) + return False + + # Trust X-Forwarded-Proto / X-Forwarded-Host headers injected by nginx + # so Flask builds correct https:// redirect URIs behind the ingress. + # Only apply ProxyFix once to avoid nested wrapping. + if not isinstance(app.wsgi_app, ProxyFix): + app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) + + oauth.init_app(app) + + # Build scope string from: standard OIDC defaults (VB_OIDC_DEFAULT_SCOPES) + + # VegBank-specific scopes (set by Helm values). Deduplicate while preserving order. + base_scopes = DEFAULT_SCOPES.split() + vb_scopes = [SCOPE_ADMIN, SCOPE_CONTRIBUTOR, SCOPE_USER] + scope_request = " ".join(dict.fromkeys(base_scopes + vb_scopes)) + + oauth.register( + name="vegbank_oidc", + client_id=secrets.get("client_id"), + client_secret=secrets.get("client_secret"), + server_metadata_url=secrets.get("server_metadata_url"), + client_kwargs={"scope": scope_request}, + ) + + logger.info("OAuth client initialised.") + return True + + +@functools.lru_cache(maxsize=1) +def get_jwks_keys(): + """Fetch and cache the JWKS signing keys from the OIDC provider. + + These keys are used to validate JWT token signatures. Care must be taken to fetch + them only from trustworthy sources (via the OIDC provider's metadata endpoint over + HTTPS). The keys may change periodically, so the cache will be invalidated and keys + will be refetched on the next call after the application is restarted. + + Returns: + authlib.jose.JsonWebKey: A ``JsonWebKeySet`` ready for ``jwt.decode``. + + Raises: + ValueError: If the OIDC server metadata does not expose a ``jwks_uri``. + requests.RequestException: If errors while fetching the JWKS. + """ + metadata = oauth.vegbank_oidc.load_server_metadata() + jwks_uri = metadata.get("jwks_uri") + if not jwks_uri: + raise ValueError("OIDC provider metadata does not contain 'jwks_uri'") + + response = _requests.get(jwks_uri, timeout=10) + response.raise_for_status() + return JsonWebKey.import_key_set(response.json()) + + +def _extract_bearer_token(): + """Extract the raw JWT string from the + ``Authorization: Bearer …`` header. + + Returns: + str | None: The token string, or ``None`` if the header is absent / malformed. + """ + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:] + + # caps the token length to prevent huge tokens from causing DoS issues in downstream processing. + if len(token) > MAX_TOKEN_LEN: + return None # triggers 401 + return token + return None + + +def _decode_and_validate_token(token_str: str): + """Decode *and* full-validate a JWT against the OIDC provider's JWKS. + + Validates signature, issuer (``iss``), audience (``aud``), and authorized-party (``azp``) claims. + + Args: + token_str: Raw JWT string + + Returns: + The validated claims object. + + Raises: + DecodeError: Token could not be decoded. + InvalidTokenError: Signature is valid but one or more claims are + invalid (such as expired tokens). + BadSignatureError: JWKS signature verification failed. + ValueError: ``jwks_uri`` missing from OIDC metadata. + requests.RequestException: Network / HTTP error fetching JWKS. + """ + jwks = get_jwks_keys() + metadata = oauth.vegbank_oidc.load_server_metadata() + issuer = metadata.get("issuer") + + client_id = load_client_secrets().get("client_id") + + claims = jwt.decode( + token_str, + jwks, + claims_options={ + "iss": {"essential": True, "value": issuer}, + "aud": {"essential": True, "value": client_id}, + "azp": {"essential": True, "value": client_id}, + }, + ) + claims.validate() + return claims + + +def _auth_error_response(message, status, details=None): + """Generate a uniform JSON error response for authentication/authorization errors. + + All auth-related error responses should use this helper to guarantee a consistent ``{"error": {"message": ..., "details": ...}}`` object. + + Args: + message: Error description. + status: HTTP status code. + details: Optional additional context (``str(exc)``). Omitted from the response when *None*. + + Returns: + Tuple of (JSON response, status code). + """ + error = {"message": message} + if details is not None: + error["details"] = details + return jsonify({"error": error}), status + + +def _token_error_response(exc): + """Produce a uniform JSON error response for token validation/exchange failures.""" + error_map = { + DecodeError: ("Token decoding failed", 401), + InvalidClientError: ("OIDC client authentication failed", 401), + InvalidTokenError: ("Token validation failed", 401), + InvalidGrantError: ("Invalid or expired refresh token", 401), + BadSignatureError: ("Token signature verification failed", 401), + OAuthError: ("Authorization failed", 401), + OAuth2Error: ("An OAuth2 error occurred", 401), + KeyError: ("Invalid token structure", 401), + TypeError: ("Invalid token structure", 401), + MissingParameterError: ("Missing required parameter", 400), + ValueError: ("OIDC provider configuration error", 500), + _requests.RequestException: ("Failed to fetch OIDC provider keys", 502), + } + for exc_types, (message, status) in error_map.items(): + if isinstance(exc, exc_types): + return _auth_error_response(message, status, details=str(exc)) + # Unexpected exception — treat as server error + return _auth_error_response("Internal authentication error", 500, details=str(exc)) + + +def _token_response(token: dict, message: str = "Token exchange successful"): + """Produce a uniform JSON response with access and refresh tokens. + + Args: + token: Dict containing token data with 'access_token' and 'refresh_token' keys. + message: Optional message to include in response. + + Returns: + Tuple of (JSON response, 200 status code). + """ + return ( + jsonify( + { + "message": message, + "token": { + "access_token": token.get("access_token"), + "refresh_token": token.get("refresh_token"), + }, + } + ), + 200, + ) + + +_ORCID_HTTPS_PREFIX = "https://orcid.org/" +_ORCID_HTTP_PREFIX = "http://orcid.org/" + + +def extract_orcid(claims: dict | None) -> str | None: + """Extract a normalised ORCID iD URI from JWT claims. + + Reads the ``orcid`` claim. The returned value is always the canonical + HTTPS URI form (``https://orcid.org/XXXX-XXXX-XXXX-XXXX``). + + Args: + claims: Decoded JWT claims dict, or ``None``. + + Returns: + Canonical ORCID URI (e.g. ``"https://orcid.org/0000-0002-1825-0097"``), + or ``None`` if the ``orcid`` claim is absent or malformed. + """ + if not claims: + return None + + raw = claims.get("orcid") + + if not raw or not isinstance(raw, str): + return None + + # Strip http(s)://orcid.org/ prefix, leaving just the bare ID + if raw.startswith(_ORCID_HTTPS_PREFIX): + bare = raw[len(_ORCID_HTTPS_PREFIX):] + elif raw.startswith(_ORCID_HTTP_PREFIX): + bare = raw[len(_ORCID_HTTP_PREFIX):] + else: + bare = raw + + # Validate: XXXX-XXXX-XXXX-XXXX where the last character may be X (checksum digit) + if not re.fullmatch(r"\d{4}-\d{4}-\d{4}-\d{3}[0-9X]", bare): + return None + + return _ORCID_HTTPS_PREFIX + bare + + +def _store_user_context(claims): + """Store decoded token claims in request context.""" + g.token_claims = claims + + +def _validate_and_extract_claims(required_scope=None): + """Validate bearer token and optionally check required scope. + + Args: + required_scope: Optional scope string to validate. + + Returns: + Tuple of (claims_dict, error_response_tuple) where error_response_tuple is None on success. + """ + token_str = _extract_bearer_token() + if not token_str: + return None, _auth_error_response("Missing or invalid Authorization header", 401) + + try: + claims = _decode_and_validate_token(token_str) + except (DecodeError, InvalidTokenError, BadSignatureError, ValueError, RequestException) as exc: + return None, _token_error_response(exc) + + # Scope check if required + if required_scope: + token_scopes = claims.get("scope", "").split() + if required_scope not in token_scopes: + return None, _auth_error_response( + f"Insufficient scope. Required: {required_scope}", + 403, + details=f"Available scopes: {' '.join(token_scopes)}", + ) + + return claims, None + -def _echo_inputs(value: int) -> int: - """Echo arguments for testing + +def require_token(methods=None): + """Decorator - protect an endpoint that requires *any* valid JWT. + + **Only enforces authentication when accessMode='authenticated'.** + In 'read_only' and 'open' modes, this decorator allows all requests. + + Returns ``401`` if the token is missing, expired, or otherwise invalid. + + Can enforce auth on specific HTTP methods only. If ``methods`` is None, + protects all methods. Args: - value: Integer input to be tested + methods: Optional list of HTTP method names (e.g., ``['POST', 'PUT', 'DELETE']``) to protect. + If None, all methods are protected. + If the current request method is not in the list, auth is skipped. + + Example: + ``@require_token(methods=['POST', 'PUT', 'DELETE'])`` - only protect write operations + """ + def decorator(f): + @functools.wraps(f) + def decorated(*args, **kwargs): + mode = get_access_mode() + + # In read_only or open mode, skip auth entirely + if mode != ACCESS_MODE_AUTHENTICATED: + logger.warning("Access mode '%s': skipping token validation", mode) + return f(None, *args, **kwargs) + + # If methods are specified, only enforce auth for those methods + if methods is not None and request.method not in methods: + # No auth required for this method; pass None as claims + return f(None, *args, **kwargs) + + claims, error = _validate_and_extract_claims() + if error: + return error + + _store_user_context(claims) + return f(claims, *args, **kwargs) + + return decorated + + return decorator + + +def require_scope(required_scope: str, methods=None): + """Decorator factory - protect an endpoint that requires a specific scope. + + **Only enforces authorization when accessMode='authenticated'.** + In 'read_only' and 'open' modes, this decorator allows all requests. + + Supported VegBank scopes: + + * ``vegbank:admin`` - admin ops + * ``vegbank:contributor`` - create/update access for vegbank data + * ``vegbank:user`` - create/update access for user datasets + + Returns ``401`` for missing / invalid tokens, ``403`` if the required scope + is absent from the token. + + Can enforce auth on specific HTTP methods only. If ``methods`` is None, + protects all methods. + + **Claims Parameter Injection:** + + This decorator injects a ``claims`` keyword argument into wrapped functions. + The ``claims`` dict contains user info (e.g., preferred_username, email, scopes) + extracted from the JWT token. Claims are only populated in 'authenticated' mode; + in other modes, claims is None. Route handlers that need audit logging should + accept a ``claims=None`` parameter and check it before use. + + Args: + required_scope: Valid OAuth 2.0 scope string that must be present in the token's ``scope`` claim. + methods: Optional list of HTTP method names (e.g., ``['POST', 'PUT', 'DELETE']``) to protect. + If None, all methods are protected. + If the current request method is not in the list, auth is skipped. + + Example: + ``@require_scope(SCOPE_CONTRIBUTOR, methods=['POST'])`` - only protect POST operations + + Handler Example: + ``def my_handler(vb_code, claims=None):`` - claims are injected as kwargs + """ + def decorator(f): + @functools.wraps(f) + def decorated(*args, **kwargs): + mode = get_access_mode() + + # In read_only or open mode, skip auth entirely + if mode != ACCESS_MODE_AUTHENTICATED: + logger.warning("Access mode '%s': skipping scope validation", mode) + # Store None in g for consistency + g.token_claims = None + return f(*args, **kwargs) + + # If methods are specified, only enforce auth for those methods + if methods is not None and request.method not in methods: + # No auth required for this method; store None as claims + g.token_claims = None + return f(*args, **kwargs) + + claims, error = _validate_and_extract_claims(required_scope=required_scope) + if error: + return error + + _store_user_context(claims) + # Pass claims as keyword argument for explicit access in handlers + kwargs['claims'] = claims + return f(*args, **kwargs) + + return decorated + + return decorator + + +@auth_bp.route("/login", methods=["GET"]) +def login(): + """Initiate the OIDC login flow. + + Sends the user to the provider's login page. After successful + authentication the provider redirects back to the ``/authorize`` + callback. + + Args: + (None) + + Returns: + 302 redirect to the provider's authorization endpoint. + 401/500 JSON error response if login fails. + 403 JSON response if authentication is disabled for the current access mode. + + """ + mode = get_access_mode() + if mode != ACCESS_MODE_AUTHENTICATED: + return _auth_error_response(f"Authentication is disabled in '{mode}' mode.", 403) + + try: + return oauth.vegbank_oidc.authorize_redirect(url_for("main.auth.authorize", _external=True)) + except (OAuthError, RequestException) as exc: + logger.warning("OIDC authorize_redirect error: %s", exc) + return _token_error_response(exc) + + +@auth_bp.route("/authorize", methods=["GET"]) +def authorize(): + """OIDC authorization callback endpoint. + + Keycloak redirects here after a successful login with a short-lived + authorization code. This endpoint exchanges that code for an access + token, stores the token and returns it to the caller. + + Returns: + 200 JSON with ``token`` on success. + 401 JSON with error details on failure. + 403 JSON response if authentication is disabled for the current access mode. + """ + mode = get_access_mode() + if mode != ACCESS_MODE_AUTHENTICATED: + return _auth_error_response(f"Authentication is disabled in '{mode}' mode.", 403) + + try: + token = oauth.vegbank_oidc.authorize_access_token() + except (OAuthError, RequestException) as exc: + logger.debug("OIDC token exchange error: %s", exc) + return _token_error_response(exc) + + return _token_response(token, message="Authorization successful") + + +@auth_bp.route("/refresh", methods=["POST"]) +def refresh_token(): + """Re-validate the user session and return a new access token using the refresh token. + + When an access token expires, the client can call this endpoint with the refresh token + to obtain a new access token without requiring the user to log in again. The client + can also pass the desired scopes for the new access token, which must be a subset + of the original scopes granted to the refresh token. + + Parameters (in JSON body): + - ``refresh_token`` (string, required): The refresh token issued by the OIDC provider. + - ``scope`` (string, optional): Space-separated list of scopes to request for the new access token. If omitted, the new access token will have the same scopes as the original token. + + Returns: + 200 JSON with new ``access_token`` and ``refresh_token`` on success. + 400 JSON if the request is missing required parameters. + 401 JSON if the refresh token is invalid, expired, or if client authentication fails. + 500 JSON for unexpected server errors. + """ + # Get the refresh token and desired scopes from the JSON body + data = request.get_json(silent=True) + if not data: + return _token_error_response(MissingParameterError("refresh_token")) + + user_refresh_token = data.get("refresh_token") + if not user_refresh_token: + return _token_error_response(MissingParameterError("refresh_token")) + + # The client should pass the scopes that it would like to request for the + # new access token. If no scopes are provided, we will attempt to get a + # new access token with the same scopes as the original token. The + # requested scopes must match or be a subset of the original scopes granted + # to the token, otherwise the OIDC provider will reject the request. + requested_scope = data.get("scope") + + # Use Authlib to exchange the refresh token for a new access token + try: + if not requested_scope: + # If no scope is provided, omit the scope parameter to get the same scopes as the original token + new_tokens = oauth.vegbank_oidc.fetch_access_token( + grant_type="refresh_token", + refresh_token=user_refresh_token, + ) + else: + new_tokens = oauth.vegbank_oidc.fetch_access_token( + grant_type="refresh_token", + refresh_token=user_refresh_token, + scope=requested_scope, + ) + return _token_response(new_tokens, message="Authorization successful") + except InvalidGrantError as exc: + # The refresh token was invalid, expired, or revoked by the provider + logger.debug("The refresh token is invalid or expired: %s", exc) + return _token_error_response(exc) + except InvalidClientError as exc: + # The client_id or client_secret is wrong + logger.warning("OIDC client authentication failed: %s", exc) + return _token_error_response(exc) + except OAuth2Error as exc: + logger.debug("An OAuth2 error occurred: %s", exc) + return _token_error_response(exc) + except Exception as exc: + # A safety net for non-OAuth errors (e.g., network issues) + logger.error("Unexpected Exception during refresh: %s", exc, exc_info=True) + return _token_error_response(exc) + +def get_access_mode() -> str: + """Get the current access mode from environment. + Returns: - Integer value of the argument passed in + str: One of 'read_only', 'open', or 'authenticated'. Defaults to 'authenticated'. """ - logger.debug("Received input value: %s", value) - return value + mode = os.getenv("VB_ACCESS_MODE", ACCESS_MODE_AUTHENTICATED).lower() + if mode not in (ACCESS_MODE_READ_ONLY, ACCESS_MODE_OPEN, ACCESS_MODE_AUTHENTICATED): + logger.warning(f"Invalid access mode '{mode}', falling back to '{ACCESS_MODE_AUTHENTICATED}'") + return ACCESS_MODE_AUTHENTICATED + return mode \ No newline at end of file diff --git a/tests/test_auth.py b/tests/test_auth.py index 660b364..556180f 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,19 +1,31 @@ -"""Tests for the authentication module.""" +"""Unit tests for auth.py helpers.""" -from dataone.auth import _echo_inputs +from dataone.auth import extract_orcid -class TestEchoInputs: - """Test suite for _echo_inputs function.""" +def test_extract_orcid_returns_https_uri_from_https_orcid_claim(): + """Test that extract_orcid returns the canonical HTTPS URI when the orcid claim is already a full HTTPS URI.""" + claims = {"orcid": "https://orcid.org/0000-0002-1825-0097"} + assert extract_orcid(claims) == "https://orcid.org/0000-0002-1825-0097" - def test_echo_inputs_returns_same_value(self) -> None: - """Test that _echo_inputs returns the input value unchanged.""" - assert _echo_inputs(42) == 42 - def test_echo_inputs_zero(self) -> None: - """Test _echo_inputs with zero.""" - assert _echo_inputs(0) == 0 +def test_extract_orcid_normalises_http_orcid_claim_to_https(): + """Test that extract_orcid upgrades an http:// orcid claim URI to the canonical https:// URI.""" + claims = {"orcid": "http://orcid.org/0000-0002-1825-0097"} + assert extract_orcid(claims) == "https://orcid.org/0000-0002-1825-0097" - def test_echo_inputs_negative(self) -> None: - """Test _echo_inputs with negative integer.""" - assert _echo_inputs(-5) == -5 + +def test_extract_orcid_normalises_bare_id_to_https_uri(): + """Test that extract_orcid expands a bare ORCID iD to the canonical HTTPS URI.""" + claims = {"orcid": "0000-0002-1825-0097"} + assert extract_orcid(claims) == "https://orcid.org/0000-0002-1825-0097" + + +def test_extract_orcid_returns_none_for_none_input(): + """Test that extract_orcid returns None when called with None instead of a claims dict.""" + assert extract_orcid(None) is None + + +def test_extract_orcid_returns_none_for_empty_claims(): + """Test that extract_orcid returns None when called with an empty claims dict.""" + assert extract_orcid({}) is None diff --git a/uv.lock b/uv.lock index 2e16e25..5a6eab2 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,151 @@ version = 1 revision = 3 requires-python = ">=3.13" +[[package]] +name = "authlib" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -11,10 +156,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +] + [[package]] name = "dataone-auth" version = "0.1.0" source = { editable = "." } +dependencies = [ + { name = "authlib" }, + { name = "flask" }, + { name = "requests" }, + { name = "werkzeug" }, +] [package.dev-dependencies] dev = [ @@ -23,6 +227,12 @@ dev = [ ] [package.metadata] +requires-dist = [ + { name = "authlib", specifier = ">=1.7.2" }, + { name = "flask", specifier = ">=3.1.3" }, + { name = "requests", specifier = ">=2.33.1" }, + { name = "werkzeug", specifier = ">=3.1.8" }, +] [package.metadata.requires-dev] dev = [ @@ -30,6 +240,32 @@ dev = [ { name = "ruff", specifier = ">=0.15.12" }, ] +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -39,6 +275,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joserfc" +version = "1.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/dc/5f768c2e391e9afabe5d18e3221346deb5fb6338565f1ccc9e7c6d7befdd/joserfc-1.6.5.tar.gz", hash = "sha256:1482a7db78fb4602e44ed89e51b599d052e091288c7c532c5b694e20149dec48", size = 231881, upload-time = "2026-05-06T04:58:13.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/3b/ad1cb22e75c963b1f07c8a2329bf47227ce7e4361df5eb2fb101b2ce33ef/joserfc-1.6.5-py3-none-any.whl", hash = "sha256:e9878a0f8243fe7b95e11fdda81374ca9f7a689e302751579d3dfdeec559675e", size = 70464, upload-time = "2026-05-06T04:58:11.668Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -57,6 +378,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -82,6 +412,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + [[package]] name = "ruff" version = "0.15.12" @@ -106,3 +451,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] From 7d9c6d32e52b5b5dfd85c3e2f97bfeb16b093772 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 7 May 2026 16:09:55 -0700 Subject: [PATCH 02/42] add basic factory and adapter structure --- pyproject.toml | 13 ++ src/dataone/adapters/base.py | 24 ++++ src/dataone/adapters/fastapi.py | 11 ++ src/dataone/adapters/flask.py | 11 ++ src/dataone/factory.py | 19 +++ tests/test_factory.py | 41 +++++++ uv.lock | 206 ++++++++++++++++++++++++++++++++ 7 files changed, 325 insertions(+) create mode 100644 src/dataone/adapters/base.py create mode 100644 src/dataone/adapters/fastapi.py create mode 100644 src/dataone/adapters/flask.py create mode 100644 src/dataone/factory.py create mode 100644 tests/test_factory.py diff --git a/pyproject.toml b/pyproject.toml index 0637e9a..484696e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,19 @@ dependencies = [ [project.scripts] dataone = "dataone:main" +[project.optional-dependencies] +flask = [ + "flask>=3.1.3", +] +fastapi = [ + "fastapi>=0.136.1", + "httpx>=0.28.1", + "starlette>=1.0.0", +] +starlette = [ + "httpx>=0.28.1", +] + [tool.hatch.build.targets.wheel] packages = ["src/dataone"] diff --git a/src/dataone/adapters/base.py b/src/dataone/adapters/base.py new file mode 100644 index 0000000..6d8e50c --- /dev/null +++ b/src/dataone/adapters/base.py @@ -0,0 +1,24 @@ +class BaseAuthAdapter: + def __init__(self, config: dict): + self.config = config + self.oauth = self._initialize_oauth() + self._setup_providers() + + def _initialize_oauth(self): + raise NotImplementedError + + def _setup_providers(self): + self.register( + name="vegbank_oidc", + #client_id=secrets.get("client_id"), + #client_secret=secrets.get("client_secret"), + #server_metadata_url=secrets.get("server_metadata_url"), + #client_kwargs={"scope": scope_request}, + ) + + def __getattr__(self, name): + """ + Delegate all unknown attribute/method lookups to the underlying Authlib OAuth object. + This automatically exposes .register(), .init_app(), etc. + """ + return getattr(self.oauth, name) \ No newline at end of file diff --git a/src/dataone/adapters/fastapi.py b/src/dataone/adapters/fastapi.py new file mode 100644 index 0000000..f137db7 --- /dev/null +++ b/src/dataone/adapters/fastapi.py @@ -0,0 +1,11 @@ +from .base import BaseAuthAdapter + +class FastAPIAuthAdapter(BaseAuthAdapter): + def _initialize_oauth(self): + from authlib.integrations.starlette_client import OAuth + return OAuth() + + async def login(self, name: str, request, **kwargs): + client = self.oauth.create_client(name) + # FastAPI/Starlette is async and requires the request object + return await client.authorize_redirect(request, **kwargs) \ No newline at end of file diff --git a/src/dataone/adapters/flask.py b/src/dataone/adapters/flask.py new file mode 100644 index 0000000..a0dd4ec --- /dev/null +++ b/src/dataone/adapters/flask.py @@ -0,0 +1,11 @@ +from .base import BaseAuthAdapter + +class FlaskAuthAdapter(BaseAuthAdapter): + def _initialize_oauth(self): + from authlib.integrations.flask_client import OAuth + return OAuth() + + def login(self, name: str, **kwargs): + client = self.oauth.create_client(name) + # Standard Flask is synchronous, no request object needed + return client.authorize_redirect(**kwargs) \ No newline at end of file diff --git a/src/dataone/factory.py b/src/dataone/factory.py new file mode 100644 index 0000000..1002591 --- /dev/null +++ b/src/dataone/factory.py @@ -0,0 +1,19 @@ +class AuthFactory: + + _registry = { + "flask": "dataone.adapters.flask.FlaskAuthAdapter", + "fastapi": "dataone.adapters.fastapi.FastAPIAuthAdapter", + "starlette": "dataone.adapters.fastapi.FastAPIAuthAdapter", + } + + @classmethod + def create_client(cls, framework: str, config: dict): + import_path = cls._registry.get(framework.lower()) + if not import_path: + raise ValueError(f"Unsupported framework: {framework}") + + module_path, class_name = import_path.rsplit(".", 1) + module = __import__(module_path, fromlist=[class_name]) + AdapterClass = getattr(module, class_name) + + return AdapterClass(config=config) \ No newline at end of file diff --git a/tests/test_factory.py b/tests/test_factory.py new file mode 100644 index 0000000..9bde9e8 --- /dev/null +++ b/tests/test_factory.py @@ -0,0 +1,41 @@ +# tests/test_factory.py +import pytest +from dataone.factory import AuthFactory + +# Mock config to pass into our adapters +MOCK_CONFIG = { + "GOOGLE_ID": "mock_id", + "GOOGLE_SECRET": "mock_secret" +} + +def test_factory_returns_flask_adapter(): + # Skip test if Flask isn't installed in this environment + pytest.importorskip("flask") + + # Import inside the test to avoid top-level crashes + from dataone.adapters.flask import FlaskAuthAdapter + + # Act + adapter = AuthFactory.create_client("flask", config=MOCK_CONFIG) + + # Assert + assert isinstance(adapter, FlaskAuthAdapter) + assert adapter.config == MOCK_CONFIG + +def test_factory_returns_fastapi_adapter(): + # Skip test if Starlette/FastAPI aren't installed in this environment + pytest.importorskip("starlette") + + from dataone.adapters.fastapi import FastAPIAuthAdapter + + # Act + adapter = AuthFactory.create_client("fastapi", config=MOCK_CONFIG) + + # Assert + assert isinstance(adapter, FastAPIAuthAdapter) + assert adapter.config == MOCK_CONFIG + +def test_factory_raises_error_on_unknown_framework(): + # Act & Assert + with pytest.raises(ValueError, match="Unsupported framework"): + AuthFactory.create_client("django", config=MOCK_CONFIG) \ No newline at end of file diff --git a/uv.lock b/uv.lock index 5a6eab2..5b49c7f 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,36 @@ version = 1 revision = 3 requires-python = ">=3.13" +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + [[package]] name = "authlib" version = "1.7.2" @@ -220,6 +250,19 @@ dependencies = [ { name = "werkzeug" }, ] +[package.optional-dependencies] +fastapi = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "starlette" }, +] +flask = [ + { name = "flask" }, +] +starlette = [ + { name = "httpx" }, +] + [package.dev-dependencies] dev = [ { name = "pytest" }, @@ -229,10 +272,16 @@ dev = [ [package.metadata] requires-dist = [ { name = "authlib", specifier = ">=1.7.2" }, + { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.136.1" }, { name = "flask", specifier = ">=3.1.3" }, + { name = "flask", marker = "extra == 'flask'", specifier = ">=3.1.3" }, + { name = "httpx", marker = "extra == 'fastapi'", specifier = ">=0.28.1" }, + { name = "httpx", marker = "extra == 'starlette'", specifier = ">=0.28.1" }, { name = "requests", specifier = ">=2.33.1" }, + { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=1.0.0" }, { name = "werkzeug", specifier = ">=3.1.8" }, ] +provides-extras = ["flask", "fastapi", "starlette"] [package.metadata.requires-dev] dev = [ @@ -240,6 +289,22 @@ dev = [ { name = "ruff", specifier = ">=0.15.12" }, ] +[[package]] +name = "fastapi" +version = "0.136.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, +] + [[package]] name = "flask" version = "3.1.3" @@ -257,6 +322,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.13" @@ -387,6 +489,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -452,6 +625,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" From bfbbe9d90d5a8cf3a0897934e7ceb2261f405aa3 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 10:01:50 -0700 Subject: [PATCH 03/42] rework how client is configured --- src/dataone/adapters/base.py | 27 +++++++++++++++++++-------- src/dataone/factory.py | 4 ++-- tests/test_factory.py | 19 +++++++++++-------- 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/src/dataone/adapters/base.py b/src/dataone/adapters/base.py index 6d8e50c..f1567f1 100644 --- a/src/dataone/adapters/base.py +++ b/src/dataone/adapters/base.py @@ -1,6 +1,13 @@ +import os + class BaseAuthAdapter: - def __init__(self, config: dict): - self.config = config + + DEFAULT_PROVIDER_NAME = "vegbank_oidc" + DEFAULT_SCOPES = "openid email profile" + + def __init__(self, secrets, scopes): + self.secrets = secrets + self.scopes = scopes self.oauth = self._initialize_oauth() self._setup_providers() @@ -8,12 +15,16 @@ def _initialize_oauth(self): raise NotImplementedError def _setup_providers(self): - self.register( - name="vegbank_oidc", - #client_id=secrets.get("client_id"), - #client_secret=secrets.get("client_secret"), - #server_metadata_url=secrets.get("server_metadata_url"), - #client_kwargs={"scope": scope_request}, + + base_scopes = self.DEFAULT_SCOPES.split() + scope_request = " ".join(dict.fromkeys(base_scopes + self.scopes)) + + self.oauth.register( + name=self.DEFAULT_PROVIDER_NAME, + client_id=self.secrets.get("client_id"), + client_secret=self.secrets.get("client_secret"), + server_metadata_url=self.secrets.get("server_metadata_url"), + client_kwargs={"scope": scope_request}, ) def __getattr__(self, name): diff --git a/src/dataone/factory.py b/src/dataone/factory.py index 1002591..13fd65c 100644 --- a/src/dataone/factory.py +++ b/src/dataone/factory.py @@ -7,7 +7,7 @@ class AuthFactory: } @classmethod - def create_client(cls, framework: str, config: dict): + def create_client(cls, framework: str, secrets: dict, scopes: list): import_path = cls._registry.get(framework.lower()) if not import_path: raise ValueError(f"Unsupported framework: {framework}") @@ -16,4 +16,4 @@ def create_client(cls, framework: str, config: dict): module = __import__(module_path, fromlist=[class_name]) AdapterClass = getattr(module, class_name) - return AdapterClass(config=config) \ No newline at end of file + return AdapterClass(secrets=secrets, scopes=scopes) \ No newline at end of file diff --git a/tests/test_factory.py b/tests/test_factory.py index 9bde9e8..f72d26c 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -3,11 +3,14 @@ from dataone.factory import AuthFactory # Mock config to pass into our adapters -MOCK_CONFIG = { - "GOOGLE_ID": "mock_id", - "GOOGLE_SECRET": "mock_secret" +MOCK_SECRETS = { + "client_id": "test client", + "client_secret": "a string", + "server_metadata_url": "https://url.com", } +MOCK_SCOPES = ["vegbank:admin", "vegbank:contributor", "vegbank:user"] + def test_factory_returns_flask_adapter(): # Skip test if Flask isn't installed in this environment pytest.importorskip("flask") @@ -16,11 +19,11 @@ def test_factory_returns_flask_adapter(): from dataone.adapters.flask import FlaskAuthAdapter # Act - adapter = AuthFactory.create_client("flask", config=MOCK_CONFIG) + adapter = AuthFactory.create_client("flask", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) # Assert assert isinstance(adapter, FlaskAuthAdapter) - assert adapter.config == MOCK_CONFIG + assert adapter.secrets == MOCK_SECRETS def test_factory_returns_fastapi_adapter(): # Skip test if Starlette/FastAPI aren't installed in this environment @@ -29,13 +32,13 @@ def test_factory_returns_fastapi_adapter(): from dataone.adapters.fastapi import FastAPIAuthAdapter # Act - adapter = AuthFactory.create_client("fastapi", config=MOCK_CONFIG) + adapter = AuthFactory.create_client("fastapi", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) # Assert assert isinstance(adapter, FastAPIAuthAdapter) - assert adapter.config == MOCK_CONFIG + assert adapter.secrets == MOCK_SECRETS def test_factory_raises_error_on_unknown_framework(): # Act & Assert with pytest.raises(ValueError, match="Unsupported framework"): - AuthFactory.create_client("django", config=MOCK_CONFIG) \ No newline at end of file + AuthFactory.create_client("django", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) \ No newline at end of file From 5acfdc057b8f4388138db62165c5056009e7dc2b Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 11:19:57 -0700 Subject: [PATCH 04/42] remove login methods --- src/dataone/adapters/fastapi.py | 7 +------ src/dataone/adapters/flask.py | 7 +------ 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/dataone/adapters/fastapi.py b/src/dataone/adapters/fastapi.py index f137db7..11e27c8 100644 --- a/src/dataone/adapters/fastapi.py +++ b/src/dataone/adapters/fastapi.py @@ -3,9 +3,4 @@ class FastAPIAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): from authlib.integrations.starlette_client import OAuth - return OAuth() - - async def login(self, name: str, request, **kwargs): - client = self.oauth.create_client(name) - # FastAPI/Starlette is async and requires the request object - return await client.authorize_redirect(request, **kwargs) \ No newline at end of file + return OAuth() \ No newline at end of file diff --git a/src/dataone/adapters/flask.py b/src/dataone/adapters/flask.py index a0dd4ec..897c5a9 100644 --- a/src/dataone/adapters/flask.py +++ b/src/dataone/adapters/flask.py @@ -3,9 +3,4 @@ class FlaskAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): from authlib.integrations.flask_client import OAuth - return OAuth() - - def login(self, name: str, **kwargs): - client = self.oauth.create_client(name) - # Standard Flask is synchronous, no request object needed - return client.authorize_redirect(**kwargs) \ No newline at end of file + return OAuth() \ No newline at end of file From ab8eb4490f8062315db80a637df6d8c57df09a61 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 11:20:12 -0700 Subject: [PATCH 05/42] add all the token validation methods --- src/dataone/adapters/base.py | 93 +++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/src/dataone/adapters/base.py b/src/dataone/adapters/base.py index f1567f1..1a9e0fd 100644 --- a/src/dataone/adapters/base.py +++ b/src/dataone/adapters/base.py @@ -1,4 +1,5 @@ -import os +import requests +from authlib.jose import jwt, JsonWebKey class BaseAuthAdapter: @@ -27,6 +28,96 @@ def _setup_providers(self): client_kwargs={"scope": scope_request}, ) + def get_jwks_keys(): + """Fetch and cache the JWKS signing keys from the OIDC provider. + + These keys are used to validate JWT token signatures. Care must be taken to fetch + them only from trustworthy sources (via the OIDC provider's metadata endpoint over + HTTPS). The keys may change periodically, so the cache will be invalidated and keys + will be refetched on the next call after the application is restarted. + + Returns: + authlib.jose.JsonWebKey: A ``JsonWebKeySet`` ready for ``jwt.decode``. + + Raises: + ValueError: If the OIDC server metadata does not expose a ``jwks_uri``. + requests.RequestException: If errors while fetching the JWKS. + """ + + if hasattr(self, '_cached_jwks'): + return self._cached_jwks + + provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) + metadata = provider.load_server_metadata() + + jwks_uri = metadata.get("jwks_uri") + if not jwks_uri: + raise ValueError("OIDC provider metadata missing 'jwks_uri'") + + jwks_uri = metadata.get("jwks_uri") + if not jwks_uri: + raise ValueError("OIDC provider metadata does not contain 'jwks_uri'") + + response = requests.get(jwks_uri, timeout=10) + response.raise_for_status() + self._cached_jwks = JsonWebKey.import_key_set(response.json()) + + return self._cached_jwks + + def decode_and_validate_token(self, token_str: str): + """Decode *and* full-validate a JWT against the OIDC provider's JWKS. + + Validates signature, issuer (iss), audience (aud), and authorized-party (azp) claims. + """ + + jwks = self.get_jwks_keys() + + provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) + metadata = provider.load_server_metadata() + issuer = metadata.get("issuer") + + client_id = self.secrets.get("client_id") + + claims = jwt.decode( + token_str, + jwks, + claims_options={ + "iss": {"essential": True, "value": issuer}, + "aud": {"essential": True, "value": client_id}, + "azp": {"essential": True, "value": client_id}, + }, + ) + claims.validate() + return claims + + def validate_and_extract_claims(self, token_str: str, required_scope: str = None): + """Validate a token string and optionally check required scope. + + Args: + token_str: The raw JWT string. + required_scope: Optional scope string to validate. + + Returns: + The validated claims dict. + + Raises: + Exception: JoseError from Authlib if token is invalid/expired. + InsufficientScopeError: If the token lacks the required scope. + """ + # 1. Do the crypto math (the method we wrote previously) + claims = self.decode_and_validate_token(token_str) + + # 2. Scope check if required + if required_scope: + token_scopes = claims.get("scope", "").split() + if required_scope not in token_scopes: + raise InsufficientScopeError( + f"Insufficient scope. Required: {required_scope}. " + f"Available: {' '.join(token_scopes)}" + ) + + return claims + def __getattr__(self, name): """ Delegate all unknown attribute/method lookups to the underlying Authlib OAuth object. From 53640381cceffd41aaa5d782988fb3f555b26695 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 11:20:20 -0700 Subject: [PATCH 06/42] prune out all the code we moved --- src/dataone/auth.py | 542 +------------------------------------------- 1 file changed, 2 insertions(+), 540 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index ed51503..28228a0 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -1,77 +1,10 @@ -"""Authentication module for the VegBank API. - -Implements OIDC / OAuth 2.0 login via a configurable OIDC provider using authlib. - -Deployment Modes ----------------- -The API supports three access modes controlled by the ``VB_ACCESS_MODE`` environment variable: - -``read_only`` - Authentication disabled. All endpoints are public. File uploads disabled. - -``open`` - Authentication disabled. All endpoints are public. File uploads allowed. - -``authenticated`` - Full authentication and authorization enabled. Protected endpoints require valid JWT tokens with appropriate scopes. - -Decorator overview --------------------------------------------------- -``require_token`` - Protects an endpoint that requires *any* valid, unexpired JWT issued by - the configured OIDC provider. - -``require_scope(scope)`` - Same as ``require_token`` but additionally asserts that the token contains the - correct Vegbank scope (e.g. ``"vegbank:admin"``, ``"vegbank:contributor"``, - ``"vegbank:user"``). - -""" - -import functools -import json -import logging import os import re -from requests import RequestException - -import requests as _requests -from authlib.integrations.base_client.errors import OAuthError -from authlib.integrations.flask_client import OAuth -from authlib.jose import JsonWebKey, jwt -from authlib.jose.errors import BadSignatureError, DecodeError, InvalidTokenError -from authlib.oauth2 import OAuth2Error -from authlib.oauth2.rfc6749.errors import InvalidGrantError, InvalidClientError - -from flask import Blueprint, g, jsonify, request, url_for -from werkzeug.middleware.proxy_fix import ProxyFix - -_DEFAULT_SECRETS_PATH = "/etc/vegbank/oidc/client_secrets.json" -MAX_TOKEN_LEN = 16_384 # Token length limit in characters (~16 KB) to prevent DoS attacks class MissingParameterError(Exception): """Raised when a required request parameter is missing.""" -# Standard OIDC scopes — overridable via environment variable -DEFAULT_SCOPES = os.getenv("VB_OIDC_DEFAULT_SCOPES", "openid email profile") - -# VegBank-specific scopes — configurable via environment variables set by Helm -SCOPE_ADMIN = os.getenv("VB_SCOPE_ADMIN", "vegbank:admin") -SCOPE_CONTRIBUTOR = os.getenv("VB_SCOPE_CONTRIBUTOR", "vegbank:contributor") -SCOPE_USER = os.getenv("VB_SCOPE_USER", "vegbank:user") - -# Deployment modes -ACCESS_MODE_READ_ONLY = "read_only" # Read-only mode: no uploads, no auth -ACCESS_MODE_OPEN = "open" # Open mode: uploads allowed, no auth -ACCESS_MODE_AUTHENTICATED = "authenticated" # Authenticated mode: auth required, full access control - -# Initialize module-level logger -logger = logging.getLogger(__name__) - -oauth = OAuth() -auth_bp = Blueprint("auth", __name__) - def load_client_secrets(filepath: str | None = None) -> dict: """Load client secrets from a JSON file. @@ -93,207 +26,10 @@ def load_client_secrets(filepath: str | None = None) -> dict: return json.load(f) -def init_oauth(app) -> bool: - """Initialise the OAuth client and register the OIDC provider. - - Call once at app startup, after creating the Flask instance. - - Args: - app: The Flask application instance. - - Returns: - True on success, False if the secrets file is missing (auth unavailable). - """ - # In read_only or open mode, skip OAuth initialization - mode = get_access_mode() - if mode != ACCESS_MODE_AUTHENTICATED: - logger.warning("Access mode '%s': skipping OAuth initialisation.", mode) - return True - - try: - secrets = load_client_secrets() - except (FileNotFoundError, json.JSONDecodeError) as exc: - logger.warning("Could not load client secrets (%s). Auth unavailable.", exc) - return False - - # Trust X-Forwarded-Proto / X-Forwarded-Host headers injected by nginx - # so Flask builds correct https:// redirect URIs behind the ingress. - # Only apply ProxyFix once to avoid nested wrapping. - if not isinstance(app.wsgi_app, ProxyFix): - app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) - - oauth.init_app(app) - - # Build scope string from: standard OIDC defaults (VB_OIDC_DEFAULT_SCOPES) + - # VegBank-specific scopes (set by Helm values). Deduplicate while preserving order. - base_scopes = DEFAULT_SCOPES.split() - vb_scopes = [SCOPE_ADMIN, SCOPE_CONTRIBUTOR, SCOPE_USER] - scope_request = " ".join(dict.fromkeys(base_scopes + vb_scopes)) - - oauth.register( - name="vegbank_oidc", - client_id=secrets.get("client_id"), - client_secret=secrets.get("client_secret"), - server_metadata_url=secrets.get("server_metadata_url"), - client_kwargs={"scope": scope_request}, - ) - - logger.info("OAuth client initialised.") - return True - - -@functools.lru_cache(maxsize=1) -def get_jwks_keys(): - """Fetch and cache the JWKS signing keys from the OIDC provider. - - These keys are used to validate JWT token signatures. Care must be taken to fetch - them only from trustworthy sources (via the OIDC provider's metadata endpoint over - HTTPS). The keys may change periodically, so the cache will be invalidated and keys - will be refetched on the next call after the application is restarted. - - Returns: - authlib.jose.JsonWebKey: A ``JsonWebKeySet`` ready for ``jwt.decode``. - - Raises: - ValueError: If the OIDC server metadata does not expose a ``jwks_uri``. - requests.RequestException: If errors while fetching the JWKS. - """ - metadata = oauth.vegbank_oidc.load_server_metadata() - jwks_uri = metadata.get("jwks_uri") - if not jwks_uri: - raise ValueError("OIDC provider metadata does not contain 'jwks_uri'") - - response = _requests.get(jwks_uri, timeout=10) - response.raise_for_status() - return JsonWebKey.import_key_set(response.json()) - - -def _extract_bearer_token(): - """Extract the raw JWT string from the - ``Authorization: Bearer …`` header. - - Returns: - str | None: The token string, or ``None`` if the header is absent / malformed. - """ - auth_header = request.headers.get("Authorization", "") - if auth_header.startswith("Bearer "): - token = auth_header[7:] - - # caps the token length to prevent huge tokens from causing DoS issues in downstream processing. - if len(token) > MAX_TOKEN_LEN: - return None # triggers 401 - return token - return None - - -def _decode_and_validate_token(token_str: str): - """Decode *and* full-validate a JWT against the OIDC provider's JWKS. - - Validates signature, issuer (``iss``), audience (``aud``), and authorized-party (``azp``) claims. - - Args: - token_str: Raw JWT string - - Returns: - The validated claims object. - - Raises: - DecodeError: Token could not be decoded. - InvalidTokenError: Signature is valid but one or more claims are - invalid (such as expired tokens). - BadSignatureError: JWKS signature verification failed. - ValueError: ``jwks_uri`` missing from OIDC metadata. - requests.RequestException: Network / HTTP error fetching JWKS. - """ - jwks = get_jwks_keys() - metadata = oauth.vegbank_oidc.load_server_metadata() - issuer = metadata.get("issuer") - - client_id = load_client_secrets().get("client_id") - - claims = jwt.decode( - token_str, - jwks, - claims_options={ - "iss": {"essential": True, "value": issuer}, - "aud": {"essential": True, "value": client_id}, - "azp": {"essential": True, "value": client_id}, - }, - ) - claims.validate() - return claims - - -def _auth_error_response(message, status, details=None): - """Generate a uniform JSON error response for authentication/authorization errors. - - All auth-related error responses should use this helper to guarantee a consistent ``{"error": {"message": ..., "details": ...}}`` object. - - Args: - message: Error description. - status: HTTP status code. - details: Optional additional context (``str(exc)``). Omitted from the response when *None*. - - Returns: - Tuple of (JSON response, status code). - """ - error = {"message": message} - if details is not None: - error["details"] = details - return jsonify({"error": error}), status - - -def _token_error_response(exc): - """Produce a uniform JSON error response for token validation/exchange failures.""" - error_map = { - DecodeError: ("Token decoding failed", 401), - InvalidClientError: ("OIDC client authentication failed", 401), - InvalidTokenError: ("Token validation failed", 401), - InvalidGrantError: ("Invalid or expired refresh token", 401), - BadSignatureError: ("Token signature verification failed", 401), - OAuthError: ("Authorization failed", 401), - OAuth2Error: ("An OAuth2 error occurred", 401), - KeyError: ("Invalid token structure", 401), - TypeError: ("Invalid token structure", 401), - MissingParameterError: ("Missing required parameter", 400), - ValueError: ("OIDC provider configuration error", 500), - _requests.RequestException: ("Failed to fetch OIDC provider keys", 502), - } - for exc_types, (message, status) in error_map.items(): - if isinstance(exc, exc_types): - return _auth_error_response(message, status, details=str(exc)) - # Unexpected exception — treat as server error - return _auth_error_response("Internal authentication error", 500, details=str(exc)) - - -def _token_response(token: dict, message: str = "Token exchange successful"): - """Produce a uniform JSON response with access and refresh tokens. - - Args: - token: Dict containing token data with 'access_token' and 'refresh_token' keys. - message: Optional message to include in response. - - Returns: - Tuple of (JSON response, 200 status code). - """ - return ( - jsonify( - { - "message": message, - "token": { - "access_token": token.get("access_token"), - "refresh_token": token.get("refresh_token"), - }, - } - ), - 200, - ) - - _ORCID_HTTPS_PREFIX = "https://orcid.org/" _ORCID_HTTP_PREFIX = "http://orcid.org/" - +# leave this in as a helper def extract_orcid(claims: dict | None) -> str | None: """Extract a normalised ORCID iD URI from JWT claims. @@ -329,281 +65,7 @@ def extract_orcid(claims: dict | None) -> str | None: return _ORCID_HTTPS_PREFIX + bare - -def _store_user_context(claims): - """Store decoded token claims in request context.""" - g.token_claims = claims - - -def _validate_and_extract_claims(required_scope=None): - """Validate bearer token and optionally check required scope. - - Args: - required_scope: Optional scope string to validate. - - Returns: - Tuple of (claims_dict, error_response_tuple) where error_response_tuple is None on success. - """ - token_str = _extract_bearer_token() - if not token_str: - return None, _auth_error_response("Missing or invalid Authorization header", 401) - - try: - claims = _decode_and_validate_token(token_str) - except (DecodeError, InvalidTokenError, BadSignatureError, ValueError, RequestException) as exc: - return None, _token_error_response(exc) - - # Scope check if required - if required_scope: - token_scopes = claims.get("scope", "").split() - if required_scope not in token_scopes: - return None, _auth_error_response( - f"Insufficient scope. Required: {required_scope}", - 403, - details=f"Available scopes: {' '.join(token_scopes)}", - ) - - return claims, None - - - -def require_token(methods=None): - """Decorator - protect an endpoint that requires *any* valid JWT. - - **Only enforces authentication when accessMode='authenticated'.** - In 'read_only' and 'open' modes, this decorator allows all requests. - - Returns ``401`` if the token is missing, expired, or otherwise invalid. - - Can enforce auth on specific HTTP methods only. If ``methods`` is None, - protects all methods. - - Args: - methods: Optional list of HTTP method names (e.g., ``['POST', 'PUT', 'DELETE']``) to protect. - If None, all methods are protected. - If the current request method is not in the list, auth is skipped. - - Example: - ``@require_token(methods=['POST', 'PUT', 'DELETE'])`` - only protect write operations - """ - def decorator(f): - @functools.wraps(f) - def decorated(*args, **kwargs): - mode = get_access_mode() - - # In read_only or open mode, skip auth entirely - if mode != ACCESS_MODE_AUTHENTICATED: - logger.warning("Access mode '%s': skipping token validation", mode) - return f(None, *args, **kwargs) - - # If methods are specified, only enforce auth for those methods - if methods is not None and request.method not in methods: - # No auth required for this method; pass None as claims - return f(None, *args, **kwargs) - - claims, error = _validate_and_extract_claims() - if error: - return error - - _store_user_context(claims) - return f(claims, *args, **kwargs) - - return decorated - - return decorator - - -def require_scope(required_scope: str, methods=None): - """Decorator factory - protect an endpoint that requires a specific scope. - - **Only enforces authorization when accessMode='authenticated'.** - In 'read_only' and 'open' modes, this decorator allows all requests. - - Supported VegBank scopes: - - * ``vegbank:admin`` - admin ops - * ``vegbank:contributor`` - create/update access for vegbank data - * ``vegbank:user`` - create/update access for user datasets - - Returns ``401`` for missing / invalid tokens, ``403`` if the required scope - is absent from the token. - - Can enforce auth on specific HTTP methods only. If ``methods`` is None, - protects all methods. - - **Claims Parameter Injection:** - - This decorator injects a ``claims`` keyword argument into wrapped functions. - The ``claims`` dict contains user info (e.g., preferred_username, email, scopes) - extracted from the JWT token. Claims are only populated in 'authenticated' mode; - in other modes, claims is None. Route handlers that need audit logging should - accept a ``claims=None`` parameter and check it before use. - - Args: - required_scope: Valid OAuth 2.0 scope string that must be present in the token's ``scope`` claim. - methods: Optional list of HTTP method names (e.g., ``['POST', 'PUT', 'DELETE']``) to protect. - If None, all methods are protected. - If the current request method is not in the list, auth is skipped. - - Example: - ``@require_scope(SCOPE_CONTRIBUTOR, methods=['POST'])`` - only protect POST operations - - Handler Example: - ``def my_handler(vb_code, claims=None):`` - claims are injected as kwargs - """ - def decorator(f): - @functools.wraps(f) - def decorated(*args, **kwargs): - mode = get_access_mode() - - # In read_only or open mode, skip auth entirely - if mode != ACCESS_MODE_AUTHENTICATED: - logger.warning("Access mode '%s': skipping scope validation", mode) - # Store None in g for consistency - g.token_claims = None - return f(*args, **kwargs) - - # If methods are specified, only enforce auth for those methods - if methods is not None and request.method not in methods: - # No auth required for this method; store None as claims - g.token_claims = None - return f(*args, **kwargs) - - claims, error = _validate_and_extract_claims(required_scope=required_scope) - if error: - return error - - _store_user_context(claims) - # Pass claims as keyword argument for explicit access in handlers - kwargs['claims'] = claims - return f(*args, **kwargs) - - return decorated - - return decorator - - -@auth_bp.route("/login", methods=["GET"]) -def login(): - """Initiate the OIDC login flow. - - Sends the user to the provider's login page. After successful - authentication the provider redirects back to the ``/authorize`` - callback. - - Args: - (None) - - Returns: - 302 redirect to the provider's authorization endpoint. - 401/500 JSON error response if login fails. - 403 JSON response if authentication is disabled for the current access mode. - - """ - mode = get_access_mode() - if mode != ACCESS_MODE_AUTHENTICATED: - return _auth_error_response(f"Authentication is disabled in '{mode}' mode.", 403) - - try: - return oauth.vegbank_oidc.authorize_redirect(url_for("main.auth.authorize", _external=True)) - except (OAuthError, RequestException) as exc: - logger.warning("OIDC authorize_redirect error: %s", exc) - return _token_error_response(exc) - - -@auth_bp.route("/authorize", methods=["GET"]) -def authorize(): - """OIDC authorization callback endpoint. - - Keycloak redirects here after a successful login with a short-lived - authorization code. This endpoint exchanges that code for an access - token, stores the token and returns it to the caller. - - Returns: - 200 JSON with ``token`` on success. - 401 JSON with error details on failure. - 403 JSON response if authentication is disabled for the current access mode. - """ - mode = get_access_mode() - if mode != ACCESS_MODE_AUTHENTICATED: - return _auth_error_response(f"Authentication is disabled in '{mode}' mode.", 403) - - try: - token = oauth.vegbank_oidc.authorize_access_token() - except (OAuthError, RequestException) as exc: - logger.debug("OIDC token exchange error: %s", exc) - return _token_error_response(exc) - - return _token_response(token, message="Authorization successful") - - -@auth_bp.route("/refresh", methods=["POST"]) -def refresh_token(): - """Re-validate the user session and return a new access token using the refresh token. - - When an access token expires, the client can call this endpoint with the refresh token - to obtain a new access token without requiring the user to log in again. The client - can also pass the desired scopes for the new access token, which must be a subset - of the original scopes granted to the refresh token. - - Parameters (in JSON body): - - ``refresh_token`` (string, required): The refresh token issued by the OIDC provider. - - ``scope`` (string, optional): Space-separated list of scopes to request for the new access token. If omitted, the new access token will have the same scopes as the original token. - - Returns: - 200 JSON with new ``access_token`` and ``refresh_token`` on success. - 400 JSON if the request is missing required parameters. - 401 JSON if the refresh token is invalid, expired, or if client authentication fails. - 500 JSON for unexpected server errors. - """ - # Get the refresh token and desired scopes from the JSON body - data = request.get_json(silent=True) - if not data: - return _token_error_response(MissingParameterError("refresh_token")) - - user_refresh_token = data.get("refresh_token") - if not user_refresh_token: - return _token_error_response(MissingParameterError("refresh_token")) - - # The client should pass the scopes that it would like to request for the - # new access token. If no scopes are provided, we will attempt to get a - # new access token with the same scopes as the original token. The - # requested scopes must match or be a subset of the original scopes granted - # to the token, otherwise the OIDC provider will reject the request. - requested_scope = data.get("scope") - - # Use Authlib to exchange the refresh token for a new access token - try: - if not requested_scope: - # If no scope is provided, omit the scope parameter to get the same scopes as the original token - new_tokens = oauth.vegbank_oidc.fetch_access_token( - grant_type="refresh_token", - refresh_token=user_refresh_token, - ) - else: - new_tokens = oauth.vegbank_oidc.fetch_access_token( - grant_type="refresh_token", - refresh_token=user_refresh_token, - scope=requested_scope, - ) - return _token_response(new_tokens, message="Authorization successful") - except InvalidGrantError as exc: - # The refresh token was invalid, expired, or revoked by the provider - logger.debug("The refresh token is invalid or expired: %s", exc) - return _token_error_response(exc) - except InvalidClientError as exc: - # The client_id or client_secret is wrong - logger.warning("OIDC client authentication failed: %s", exc) - return _token_error_response(exc) - except OAuth2Error as exc: - logger.debug("An OAuth2 error occurred: %s", exc) - return _token_error_response(exc) - except Exception as exc: - # A safety net for non-OAuth errors (e.g., network issues) - logger.error("Unexpected Exception during refresh: %s", exc, exc_info=True) - return _token_error_response(exc) - - +# probably remove def get_access_mode() -> str: """Get the current access mode from environment. From d16c62a62a0de6d3bdcf55f86082e4223a981d7d Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 11:23:38 -0700 Subject: [PATCH 07/42] rename this file --- src/dataone/{auth.py => utils.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/dataone/{auth.py => utils.py} (100%) diff --git a/src/dataone/auth.py b/src/dataone/utils.py similarity index 100% rename from src/dataone/auth.py rename to src/dataone/utils.py From fbb156bac035df17270603e5a33f1855d0c13747 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 11:24:51 -0700 Subject: [PATCH 08/42] add example application code doc --- docs/app-code.md | 372 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 docs/app-code.md diff --git a/docs/app-code.md b/docs/app-code.md new file mode 100644 index 0000000..badcb58 --- /dev/null +++ b/docs/app-code.md @@ -0,0 +1,372 @@ +# Changes to application code + +## Flask + +### Initialize client + + +``` +from werkzeug.middleware.proxy_fix import ProxyFix +from flask import current_app, g +# Assuming the user imports your factory +from dataone.factory import AuthFactory +from dataone.utils import load_client_secrets + +def init_oauth(app) -> bool: + """Initialise the OAuth client and register the OIDC provider.""" + + mode = get_access_mode() + if mode != ACCESS_MODE_AUTHENTICATED: + logger.warning("Access mode '%s': skipping OAuth initialisation.", mode) + return True + + try: + vb_secrets = load_client_secrets() + except (FileNotFoundError, json.JSONDecodeError) as exc: + logger.warning("Could not load client secrets (%s). Auth unavailable.", exc) + return False + + if not isinstance(app.wsgi_app, ProxyFix): + app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) + + vb_scopes = [SCOPE_ADMIN, SCOPE_CONTRIBUTOR, SCOPE_USER] + + auth_client = AuthFactory.create_client("flask", vb_secrets, vb_scopes) + + auth_client.init_app(app) + + # attach to app context so Flask routes can access it later + app.extensions['dataone_auth'] = auth_client + + logger.info("OAuth client initialised.") + return True +``` + +### Protect endpoints with decorator + +``` +def require_token(methods=None, required_scope=None): + def decorator(f): + @functools.wraps(f) + def decorated(*args, **kwargs): + mode = get_access_mode() + if mode != ACCESS_MODE_AUTHENTICATED: + return f(None, *args, **kwargs) + + if methods is not None and request.method not in methods: + return f(None, *args, **kwargs) + + # Framework specific: Extract the token + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:] + + # caps the token length to prevent huge tokens from causing DoS issues in downstream processing. + if len(token) > MAX_TOKEN_LEN: + return None # triggers 401 + + adapter = current_app.extensions['dataone_auth'] + + try: + claims = adapter.validate_and_extract_claims(token_str, required_scope) + except InsufficientScopeError as e: + # Framework specific: Return 403 Forbidden + return jsonify({"error": str(e)}), 403 + except Exception as e: + # Framework specific: Return 401 Unauthorized + return jsonify({"error": f"Invalid token: {str(e)}"}), 401 + + g.token_claims = claims + return f(claims, *args, **kwargs) + + return decorated + return decorator + +``` + +``` +def require_scope(required_scope: str, methods=None): + def decorator(f): + @functools.wraps(f) + def decorated(*args, **kwargs): + mode = get_access_mode() + + # In read_only or open mode, skip auth entirely + if mode != ACCESS_MODE_AUTHENTICATED: + logger.warning("Access mode '%s': skipping scope validation", mode) + # Store None in g for consistency + g.token_claims = None + return f(*args, **kwargs) + + # If methods are specified, only enforce auth for those methods + if methods is not None and request.method not in methods: + # No auth required for this method; store None as claims + g.token_claims = None + return f(*args, **kwargs) + + adapter = current_app.extensions['dataone_auth'] + + claims, error = adapter.validate_and_extract_claims(required_scope=required_scope) + if error: + return error + + g.token_claims = claims + # Pass claims as keyword argument for explicit access in handlers + kwargs['claims'] = claims + return f(*args, **kwargs) + + return decorated + + return decorator +``` + +### API Endpoints + +**login** + +``` +@auth_bp.route("/login", methods=["GET"]) +def login(): + """Initiate the OIDC login flow. + + Sends the user to the provider's login page. After successful + authentication the provider redirects back to the ``/authorize`` + callback. + + Args: + (None) + + Returns: + 302 redirect to the provider's authorization endpoint. + 401/500 JSON error response if login fails. + 403 JSON response if authentication is disabled for the current access mode. + + """ + mode = get_access_mode() + if mode != ACCESS_MODE_AUTHENTICATED: + return _auth_error_response(f"Authentication is disabled in '{mode}' mode.", 403) + + adapter = current_app.extensions['dataone_auth'] + oidc_client = adapter.vegbank_oidc # maybe get this dynamically + + try: + return adapter.authorize_redirect(url_for("main.auth.authorize", _external=True)) + except (OAuthError, RequestException) as exc: + logger.warning("OIDC authorize_redirect error: %s", exc) + return _token_error_response(exc) + +``` + +**refresh** + +``` +@auth_bp.route("/refresh", methods=["POST"]) +def refresh_token(): + """Re-validate the user session and return a new access token using the refresh token. + + When an access token expires, the client can call this endpoint with the refresh token + to obtain a new access token without requiring the user to log in again. The client + can also pass the desired scopes for the new access token, which must be a subset + of the original scopes granted to the refresh token. + + Parameters (in JSON body): + - ``refresh_token`` (string, required): The refresh token issued by the OIDC provider. + - ``scope`` (string, optional): Space-separated list of scopes to request for the new access token. If omitted, the new access token will have the same scopes as the original token. + + Returns: + 200 JSON with new ``access_token`` and ``refresh_token`` on success. + 400 JSON if the request is missing required parameters. + 401 JSON if the refresh token is invalid, expired, or if client authentication fails. + 500 JSON for unexpected server errors. + """ + + adapter = current_app.extensions.get('dataone_auth') + + # Get the refresh token and desired scopes from the JSON body + data = request.get_json(silent=True) + if not data: + return _token_error_response(MissingParameterError("refresh_token")) + + user_refresh_token = data.get("refresh_token") + if not user_refresh_token: + return _token_error_response(MissingParameterError("refresh_token")) + + # The client should pass the scopes that it would like to request for the + # new access token. If no scopes are provided, we will attempt to get a + # new access token with the same scopes as the original token. The + # requested scopes must match or be a subset of the original scopes granted + # to the token, otherwise the OIDC provider will reject the request. + requested_scope = data.get("scope") + + # Use Authlib to exchange the refresh token for a new access token + try: + oidc_client = adapter.vegbank_oidc # maybe get this dynamically + if not requested_scope: + # If no scope is provided, omit the scope parameter to get the same scopes as the original token + new_tokens = oidc_client.fetch_access_token( + grant_type="refresh_token", + refresh_token=user_refresh_token, + ) + else: + new_tokens = oidc_client.fetch_access_token( + grant_type="refresh_token", + refresh_token=user_refresh_token, + scope=requested_scope, + ) + return _token_response(new_tokens, message="Authorization successful") + except InvalidGrantError as exc: + # The refresh token was invalid, expired, or revoked by the provider + logger.debug("The refresh token is invalid or expired: %s", exc) + return _token_error_response(exc) + except InvalidClientError as exc: + # The client_id or client_secret is wrong + logger.warning("OIDC client authentication failed: %s", exc) + return _token_error_response(exc) + except OAuth2Error as exc: + logger.debug("An OAuth2 error occurred: %s", exc) + return _token_error_response(exc) + except Exception as exc: + # A safety net for non-OAuth errors (e.g., network issues) + logger.error("Unexpected Exception during refresh: %s", exc, exc_info=True) + return _token_error_response(exc) + +``` + +**authorize** + +``` + +@auth_bp.route("/authorize", methods=["GET"]) +def authorize(): + """OIDC authorization callback endpoint. + + Keycloak redirects here after a successful login with a short-lived + authorization code. This endpoint exchanges that code for an access + token, stores the token and returns it to the caller. + + Returns: + 200 JSON with ``token`` on success. + 401 JSON with error details on failure. + 403 JSON response if authentication is disabled for the current access mode. + """ + mode = get_access_mode() + if mode != ACCESS_MODE_AUTHENTICATED: + return _auth_error_response(f"Authentication is disabled in '{mode}' mode.", 403) + + adapter = current_app.extensions.get('dataone_auth') + oidc_client = adapter.vegbank_oidc + + try: + token = oidc_client.authorize_access_token() + except (OAuthError, RequestException) as exc: + logger.debug("OIDC token exchange error: %s", exc) + return _token_error_response(exc) + + return _token_response(token, message="Authorization successful") + +``` + +### Response/Error Classes + +``` +def _auth_error_response(message, status, details=None): + """Generate a uniform JSON error response for authentication/authorization errors. + + All auth-related error responses should use this helper to guarantee a consistent ``{"error": {"message": ..., "details": ...}}`` object. + + Args: + message: Error description. + status: HTTP status code. + details: Optional additional context (``str(exc)``). Omitted from the response when *None*. + + Returns: + Tuple of (JSON response, status code). + """ + error = {"message": message} + if details is not None: + error["details"] = details + return jsonify({"error": error}), status + + +def _token_error_response(exc): + """Produce a uniform JSON error response for token validation/exchange failures.""" + error_map = { + DecodeError: ("Token decoding failed", 401), + InvalidClientError: ("OIDC client authentication failed", 401), + InvalidTokenError: ("Token validation failed", 401), + InvalidGrantError: ("Invalid or expired refresh token", 401), + BadSignatureError: ("Token signature verification failed", 401), + OAuthError: ("Authorization failed", 401), + OAuth2Error: ("An OAuth2 error occurred", 401), + KeyError: ("Invalid token structure", 401), + TypeError: ("Invalid token structure", 401), + MissingParameterError: ("Missing required parameter", 400), + ValueError: ("OIDC provider configuration error", 500), + _requests.RequestException: ("Failed to fetch OIDC provider keys", 502), + } + for exc_types, (message, status) in error_map.items(): + if isinstance(exc, exc_types): + return _auth_error_response(message, status, details=str(exc)) + # Unexpected exception — treat as server error + return _auth_error_response("Internal authentication error", 500, details=str(exc)) + + +def _token_response(token: dict, message: str = "Token exchange successful"): + """Produce a uniform JSON response with access and refresh tokens. + + Args: + token: Dict containing token data with 'access_token' and 'refresh_token' keys. + message: Optional message to include in response. + + Returns: + Tuple of (JSON response, 200 status code). + """ + return ( + jsonify( + { + "message": message, + "token": { + "access_token": token.get("access_token"), + "refresh_token": token.get("refresh_token"), + }, + } + ), + 200, + ) + +``` + +## FastAPI + +``` +from fastapi import FastAPI +from dataone.factory import AuthFactory +from dataone.auth import load_client_secrets +import logging + +logger = logging.getLogger(__name__) + +# 1. Create the FastAPI instance +app = FastAPI() + +def init_auth_client(): + """Initialise the DataOne Auth Client for FastAPI.""" + + try: + ogdc_secrets = load_client_secrets() + except Exception as exc: + logger.warning("Auth unavailable: %s", exc) + return None + + # Define ogdc-specific scopes if they differ, or use defaults + ogdc_scopes = ["ogdc:admin", "ogdc:user"] + + # This might return an 'httpx' based Async client instead of a 'requests' one + auth_client = AuthFactory.create_client("fastapi", ogdc_secrets, ogdc_scopes) + + return auth_client + +# 3. Store it in the app state for easy access +app.state.auth = init_auth_client() + +``` \ No newline at end of file From 2deb34b671ed3e82268d8c74b5df94af46ee8cb1 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 11:45:07 -0700 Subject: [PATCH 09/42] fix import --- tests/test_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index 556180f..870bc0a 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,6 +1,6 @@ """Unit tests for auth.py helpers.""" -from dataone.auth import extract_orcid +from dataone.utils import extract_orcid def test_extract_orcid_returns_https_uri_from_https_orcid_claim(): From 39f69ae36cd2b668bdd792491d5913c01ab99f2f Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 13:57:40 -0700 Subject: [PATCH 10/42] restructure to only have one module --- src/dataone/adapters/fastapi.py | 6 -- src/dataone/adapters/flask.py | 6 -- src/dataone/{adapters/base.py => auth.py} | 125 +++++++++++++++++++++- src/dataone/factory.py | 19 ---- src/dataone/utils.py | 79 -------------- tests/test_auth.py | 40 ++++++- tests/test_factory.py | 44 -------- 7 files changed, 162 insertions(+), 157 deletions(-) delete mode 100644 src/dataone/adapters/fastapi.py delete mode 100644 src/dataone/adapters/flask.py rename src/dataone/{adapters/base.py => auth.py} (52%) delete mode 100644 src/dataone/factory.py delete mode 100644 src/dataone/utils.py delete mode 100644 tests/test_factory.py diff --git a/src/dataone/adapters/fastapi.py b/src/dataone/adapters/fastapi.py deleted file mode 100644 index 11e27c8..0000000 --- a/src/dataone/adapters/fastapi.py +++ /dev/null @@ -1,6 +0,0 @@ -from .base import BaseAuthAdapter - -class FastAPIAuthAdapter(BaseAuthAdapter): - def _initialize_oauth(self): - from authlib.integrations.starlette_client import OAuth - return OAuth() \ No newline at end of file diff --git a/src/dataone/adapters/flask.py b/src/dataone/adapters/flask.py deleted file mode 100644 index 897c5a9..0000000 --- a/src/dataone/adapters/flask.py +++ /dev/null @@ -1,6 +0,0 @@ -from .base import BaseAuthAdapter - -class FlaskAuthAdapter(BaseAuthAdapter): - def _initialize_oauth(self): - from authlib.integrations.flask_client import OAuth - return OAuth() \ No newline at end of file diff --git a/src/dataone/adapters/base.py b/src/dataone/auth.py similarity index 52% rename from src/dataone/adapters/base.py rename to src/dataone/auth.py index 1a9e0fd..0c77745 100644 --- a/src/dataone/adapters/base.py +++ b/src/dataone/auth.py @@ -1,6 +1,119 @@ +import os +import re +import json import requests from authlib.jose import jwt, JsonWebKey + +class MissingParameterError(Exception): + """Raised when a required request parameter is missing.""" + + +def load_client_secrets(filepath: str | None = None) -> dict: + """Load client secrets from a JSON file. + + Args: + filepath: Optional explicit path. Falls back to the + ``OIDC_CLIENT_SECRETS_FILE`` environment variable + + Returns: + Parsed dict of client credentials. + """ + # accept either explicit filepath argument or environment variable, with a default fallback + resolved = ( + filepath + or os.getenv("OIDC_CLIENT_SECRETS_FILE") + or _DEFAULT_SECRETS_PATH + ) + with open(resolved, "r") as f: + return json.load(f) + +def extract_token_from_header(auth_header: str): + """Extracts and safely bounds a Bearer token from an Authorization header.""" + + if not auth_header or not auth_header.startswith("Bearer "): + return None + + token = auth_header[7:].strip() + + # caps the token length to prevent huge tokens from causing DoS issues in downstream processing. + if len(token) > MAX_TOKEN_LEN: + return None + + return token + +_ORCID_HTTPS_PREFIX = "https://orcid.org/" +_ORCID_HTTP_PREFIX = "http://orcid.org/" + +# leave this in as a helper +def extract_orcid(claims: dict | None) -> str | None: + """Extract a normalised ORCID iD URI from JWT claims. + + Reads the ``orcid`` claim. The returned value is always the canonical + HTTPS URI form (``https://orcid.org/XXXX-XXXX-XXXX-XXXX``). + + Args: + claims: Decoded JWT claims dict, or ``None``. + + Returns: + Canonical ORCID URI (e.g. ``"https://orcid.org/0000-0002-1825-0097"``), + or ``None`` if the ``orcid`` claim is absent or malformed. + """ + if not claims: + return None + + raw = claims.get("orcid") + + if not raw or not isinstance(raw, str): + return None + + # Strip http(s)://orcid.org/ prefix, leaving just the bare ID + if raw.startswith(_ORCID_HTTPS_PREFIX): + bare = raw[len(_ORCID_HTTPS_PREFIX):] + elif raw.startswith(_ORCID_HTTP_PREFIX): + bare = raw[len(_ORCID_HTTP_PREFIX):] + else: + bare = raw + + # Validate: XXXX-XXXX-XXXX-XXXX where the last character may be X (checksum digit) + if not re.fullmatch(r"\d{4}-\d{4}-\d{4}-\d{3}[0-9X]", bare): + return None + + return _ORCID_HTTPS_PREFIX + bare + +# probably remove +def get_access_mode() -> str: + """Get the current access mode from environment. + + Returns: + str: One of 'read_only', 'open', or 'authenticated'. Defaults to 'authenticated'. + """ + mode = os.getenv("VB_ACCESS_MODE", ACCESS_MODE_AUTHENTICATED).lower() + if mode not in (ACCESS_MODE_READ_ONLY, ACCESS_MODE_OPEN, ACCESS_MODE_AUTHENTICATED): + logger.warning(f"Invalid access mode '{mode}', falling back to '{ACCESS_MODE_AUTHENTICATED}'") + return ACCESS_MODE_AUTHENTICATED + return mode + +class AuthFactory: + + _registry = { + "flask": "dataone.auth.FlaskAuthAdapter", + "fastapi": "dataone.auth.FastAPIAuthAdapter", + "starlette": "dataone.auth.FastAPIAuthAdapter", + } + + @classmethod + def create_client(cls, framework: str, secrets: dict, scopes: list): + import_path = cls._registry.get(framework.lower()) + if not import_path: + raise ValueError(f"Unsupported framework: {framework}") + + module_path, class_name = import_path.rsplit(".", 1) + module = __import__(module_path, fromlist=[class_name]) + AdapterClass = getattr(module, class_name) + + return AdapterClass(secrets=secrets, scopes=scopes) + class BaseAuthAdapter: DEFAULT_PROVIDER_NAME = "vegbank_oidc" @@ -123,4 +236,14 @@ def __getattr__(self, name): Delegate all unknown attribute/method lookups to the underlying Authlib OAuth object. This automatically exposes .register(), .init_app(), etc. """ - return getattr(self.oauth, name) \ No newline at end of file + return getattr(self.oauth, name) + +class FastAPIAuthAdapter(BaseAuthAdapter): + def _initialize_oauth(self): + from authlib.integrations.starlette_client import OAuth + return OAuth() + +class FlaskAuthAdapter(BaseAuthAdapter): + def _initialize_oauth(self): + from authlib.integrations.flask_client import OAuth + return OAuth() \ No newline at end of file diff --git a/src/dataone/factory.py b/src/dataone/factory.py deleted file mode 100644 index 13fd65c..0000000 --- a/src/dataone/factory.py +++ /dev/null @@ -1,19 +0,0 @@ -class AuthFactory: - - _registry = { - "flask": "dataone.adapters.flask.FlaskAuthAdapter", - "fastapi": "dataone.adapters.fastapi.FastAPIAuthAdapter", - "starlette": "dataone.adapters.fastapi.FastAPIAuthAdapter", - } - - @classmethod - def create_client(cls, framework: str, secrets: dict, scopes: list): - import_path = cls._registry.get(framework.lower()) - if not import_path: - raise ValueError(f"Unsupported framework: {framework}") - - module_path, class_name = import_path.rsplit(".", 1) - module = __import__(module_path, fromlist=[class_name]) - AdapterClass = getattr(module, class_name) - - return AdapterClass(secrets=secrets, scopes=scopes) \ No newline at end of file diff --git a/src/dataone/utils.py b/src/dataone/utils.py deleted file mode 100644 index 28228a0..0000000 --- a/src/dataone/utils.py +++ /dev/null @@ -1,79 +0,0 @@ -import os -import re - - -class MissingParameterError(Exception): - """Raised when a required request parameter is missing.""" - - -def load_client_secrets(filepath: str | None = None) -> dict: - """Load client secrets from a JSON file. - - Args: - filepath: Optional explicit path. Falls back to the - ``OIDC_CLIENT_SECRETS_FILE`` environment variable - - Returns: - Parsed dict of client credentials. - """ - # accept either explicit filepath argument or environment variable, with a default fallback - resolved = ( - filepath - or os.getenv("OIDC_CLIENT_SECRETS_FILE") - or _DEFAULT_SECRETS_PATH - ) - with open(resolved, "r") as f: - return json.load(f) - - -_ORCID_HTTPS_PREFIX = "https://orcid.org/" -_ORCID_HTTP_PREFIX = "http://orcid.org/" - -# leave this in as a helper -def extract_orcid(claims: dict | None) -> str | None: - """Extract a normalised ORCID iD URI from JWT claims. - - Reads the ``orcid`` claim. The returned value is always the canonical - HTTPS URI form (``https://orcid.org/XXXX-XXXX-XXXX-XXXX``). - - Args: - claims: Decoded JWT claims dict, or ``None``. - - Returns: - Canonical ORCID URI (e.g. ``"https://orcid.org/0000-0002-1825-0097"``), - or ``None`` if the ``orcid`` claim is absent or malformed. - """ - if not claims: - return None - - raw = claims.get("orcid") - - if not raw or not isinstance(raw, str): - return None - - # Strip http(s)://orcid.org/ prefix, leaving just the bare ID - if raw.startswith(_ORCID_HTTPS_PREFIX): - bare = raw[len(_ORCID_HTTPS_PREFIX):] - elif raw.startswith(_ORCID_HTTP_PREFIX): - bare = raw[len(_ORCID_HTTP_PREFIX):] - else: - bare = raw - - # Validate: XXXX-XXXX-XXXX-XXXX where the last character may be X (checksum digit) - if not re.fullmatch(r"\d{4}-\d{4}-\d{4}-\d{3}[0-9X]", bare): - return None - - return _ORCID_HTTPS_PREFIX + bare - -# probably remove -def get_access_mode() -> str: - """Get the current access mode from environment. - - Returns: - str: One of 'read_only', 'open', or 'authenticated'. Defaults to 'authenticated'. - """ - mode = os.getenv("VB_ACCESS_MODE", ACCESS_MODE_AUTHENTICATED).lower() - if mode not in (ACCESS_MODE_READ_ONLY, ACCESS_MODE_OPEN, ACCESS_MODE_AUTHENTICATED): - logger.warning(f"Invalid access mode '{mode}', falling back to '{ACCESS_MODE_AUTHENTICATED}'") - return ACCESS_MODE_AUTHENTICATED - return mode \ No newline at end of file diff --git a/tests/test_auth.py b/tests/test_auth.py index 870bc0a..05f02fb 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,6 +1,7 @@ """Unit tests for auth.py helpers.""" - -from dataone.utils import extract_orcid +import pytest +from dataone.auth import extract_orcid +from dataone.auth import AuthFactory def test_extract_orcid_returns_https_uri_from_https_orcid_claim(): @@ -29,3 +30,38 @@ def test_extract_orcid_returns_none_for_none_input(): def test_extract_orcid_returns_none_for_empty_claims(): """Test that extract_orcid returns None when called with an empty claims dict.""" assert extract_orcid({}) is None + +MOCK_SECRETS = { + "client_id": "test client", + "client_secret": "a string", + "server_metadata_url": "https://url.com", +} + +MOCK_SCOPES = ["vegbank:admin", "vegbank:contributor", "vegbank:user"] + +def test_factory_returns_flask_adapter(): + # Skip test if Flask isn't installed in this environment + pytest.importorskip("flask") + + from dataone.auth import FlaskAuthAdapter + + adapter = AuthFactory.create_client("flask", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) + + assert isinstance(adapter, FlaskAuthAdapter) + assert adapter.secrets == MOCK_SECRETS + +def test_factory_returns_fastapi_adapter(): + # Skip test if Starlette/FastAPI aren't installed in this environment + pytest.importorskip("starlette") + + from dataone.auth import FastAPIAuthAdapter + + adapter = AuthFactory.create_client("fastapi", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) + + assert isinstance(adapter, FastAPIAuthAdapter) + assert adapter.secrets == MOCK_SECRETS + +def test_factory_raises_error_on_unknown_framework(): + + with pytest.raises(ValueError, match="Unsupported framework"): + AuthFactory.create_client("django", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) \ No newline at end of file diff --git a/tests/test_factory.py b/tests/test_factory.py deleted file mode 100644 index f72d26c..0000000 --- a/tests/test_factory.py +++ /dev/null @@ -1,44 +0,0 @@ -# tests/test_factory.py -import pytest -from dataone.factory import AuthFactory - -# Mock config to pass into our adapters -MOCK_SECRETS = { - "client_id": "test client", - "client_secret": "a string", - "server_metadata_url": "https://url.com", -} - -MOCK_SCOPES = ["vegbank:admin", "vegbank:contributor", "vegbank:user"] - -def test_factory_returns_flask_adapter(): - # Skip test if Flask isn't installed in this environment - pytest.importorskip("flask") - - # Import inside the test to avoid top-level crashes - from dataone.adapters.flask import FlaskAuthAdapter - - # Act - adapter = AuthFactory.create_client("flask", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) - - # Assert - assert isinstance(adapter, FlaskAuthAdapter) - assert adapter.secrets == MOCK_SECRETS - -def test_factory_returns_fastapi_adapter(): - # Skip test if Starlette/FastAPI aren't installed in this environment - pytest.importorskip("starlette") - - from dataone.adapters.fastapi import FastAPIAuthAdapter - - # Act - adapter = AuthFactory.create_client("fastapi", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) - - # Assert - assert isinstance(adapter, FastAPIAuthAdapter) - assert adapter.secrets == MOCK_SECRETS - -def test_factory_raises_error_on_unknown_framework(): - # Act & Assert - with pytest.raises(ValueError, match="Unsupported framework"): - AuthFactory.create_client("django", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) \ No newline at end of file From 1c611967aff38f53311d6c635691eb0e39cd5b46 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 15:41:02 -0700 Subject: [PATCH 11/42] add self to jwts keys --- src/dataone/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 0c77745..327c1a3 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -141,7 +141,7 @@ def _setup_providers(self): client_kwargs={"scope": scope_request}, ) - def get_jwks_keys(): + def get_jwks_keys(self): """Fetch and cache the JWKS signing keys from the OIDC provider. These keys are used to validate JWT token signatures. Care must be taken to fetch From 0c3f3e7f7adb34b62fd2882307ec0cefd178d6fa Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 15:56:04 -0700 Subject: [PATCH 12/42] add some missing default params --- src/dataone/auth.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 327c1a3..e192930 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -4,6 +4,8 @@ import requests from authlib.jose import jwt, JsonWebKey +MAX_TOKEN_LEN = 16_384 +_DEFAULT_SECRETS_PATH = "./client_secrets.json" class MissingParameterError(Exception): """Raised when a required request parameter is missing.""" From 789101ac8172c029007a60729024a420ba8e5108 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 8 May 2026 16:07:33 -0700 Subject: [PATCH 13/42] improve error handling --- src/dataone/auth.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index e192930..ed42168 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -240,6 +240,8 @@ def __getattr__(self, name): """ return getattr(self.oauth, name) +# adapters + class FastAPIAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): from authlib.integrations.starlette_client import OAuth @@ -248,4 +250,14 @@ def _initialize_oauth(self): class FlaskAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): from authlib.integrations.flask_client import OAuth - return OAuth() \ No newline at end of file + return OAuth() + +# exceptions + +class AuthError(Exception): + """Base exception for dataone-auth""" + pass + +class InsufficientScopeError(AuthError): + """Raised when the token is valid but doesn't have the right scope""" + pass \ No newline at end of file From 260449ec5fc9c5584b2d5541c77640b5e03ff0ce Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Mon, 11 May 2026 14:58:03 -0700 Subject: [PATCH 14/42] improve error catching --- src/dataone/auth.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index ed42168..73593a7 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -33,8 +33,13 @@ def load_client_secrets(filepath: str | None = None) -> dict: def extract_token_from_header(auth_header: str): """Extracts and safely bounds a Bearer token from an Authorization header.""" + # check there is a token if not auth_header or not auth_header.startswith("Bearer "): return None + + # make sure it looks like a JWT token + if token.count('.') != 2: + return None token = auth_header[7:].strip() @@ -219,16 +224,13 @@ def validate_and_extract_claims(self, token_str: str, required_scope: str = None Exception: JoseError from Authlib if token is invalid/expired. InsufficientScopeError: If the token lacks the required scope. """ - # 1. Do the crypto math (the method we wrote previously) claims = self.decode_and_validate_token(token_str) - # 2. Scope check if required if required_scope: token_scopes = claims.get("scope", "").split() if required_scope not in token_scopes: raise InsufficientScopeError( - f"Insufficient scope. Required: {required_scope}. " - f"Available: {' '.join(token_scopes)}" + f"Required: '{target_scope}'. Available: {[s for s in token_scopes]}" ) return claims From dcdf6bf259088833cdd6149338086aac01b5be04 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Mon, 11 May 2026 15:00:45 -0700 Subject: [PATCH 15/42] fix the code... --- src/dataone/auth.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 73593a7..64ff40c 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -36,13 +36,13 @@ def extract_token_from_header(auth_header: str): # check there is a token if not auth_header or not auth_header.startswith("Bearer "): return None - + + token = auth_header[7:].strip() + # make sure it looks like a JWT token if token.count('.') != 2: return None - token = auth_header[7:].strip() - # caps the token length to prevent huge tokens from causing DoS issues in downstream processing. if len(token) > MAX_TOKEN_LEN: return None From 07ace970832faa1f9e322d02e96ac8c4453e6fd3 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Tue, 12 May 2026 09:03:13 -0700 Subject: [PATCH 16/42] fix var name --- src/dataone/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 64ff40c..431727f 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -230,7 +230,7 @@ def validate_and_extract_claims(self, token_str: str, required_scope: str = None token_scopes = claims.get("scope", "").split() if required_scope not in token_scopes: raise InsufficientScopeError( - f"Required: '{target_scope}'. Available: {[s for s in token_scopes]}" + f"Required: '{required_scope}'. Available: {[s for s in token_scopes]}" ) return claims From b3f6f6f051e4aa296fc2283b3794e27b8da81009 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Tue, 12 May 2026 09:16:03 -0700 Subject: [PATCH 17/42] change default provider name to dataone_oidc --- src/dataone/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 431727f..1b27972 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -123,7 +123,7 @@ def create_client(cls, framework: str, secrets: dict, scopes: list): class BaseAuthAdapter: - DEFAULT_PROVIDER_NAME = "vegbank_oidc" + DEFAULT_PROVIDER_NAME = "dataone_oidc" DEFAULT_SCOPES = "openid email profile" def __init__(self, secrets, scopes): From b6eb238f1a20b60b9806e21a3582e7e4e47576cf Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Tue, 12 May 2026 15:33:58 -0700 Subject: [PATCH 18/42] change access mode param --- src/dataone/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 1b27972..0e5717c 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -95,7 +95,7 @@ def get_access_mode() -> str: Returns: str: One of 'read_only', 'open', or 'authenticated'. Defaults to 'authenticated'. """ - mode = os.getenv("VB_ACCESS_MODE", ACCESS_MODE_AUTHENTICATED).lower() + mode = os.getenv("ACCESS_MODE", "authenticated").lower() if mode not in (ACCESS_MODE_READ_ONLY, ACCESS_MODE_OPEN, ACCESS_MODE_AUTHENTICATED): logger.warning(f"Invalid access mode '{mode}', falling back to '{ACCESS_MODE_AUTHENTICATED}'") return ACCESS_MODE_AUTHENTICATED From d2716250892eea9845758fd0bf3e7e0801247cfd Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Tue, 12 May 2026 15:47:28 -0700 Subject: [PATCH 19/42] add access modes --- src/dataone/auth.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 0e5717c..9299b8f 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -7,6 +7,10 @@ MAX_TOKEN_LEN = 16_384 _DEFAULT_SECRETS_PATH = "./client_secrets.json" +ACCESS_MODE_AUTHENTICATED = "authenticated" +ACCESS_MODE_READ_ONLY = "read_only" +ACCESS_MODE_OPEN = "open" + class MissingParameterError(Exception): """Raised when a required request parameter is missing.""" From 6be7143498f2a6cbfb5a9d79751b8cc50ccc6c0a Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Tue, 12 May 2026 16:08:36 -0700 Subject: [PATCH 20/42] override baseauth for fastAPI to await appropriately --- src/dataone/auth.py | 57 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 9299b8f..e885ff4 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -253,6 +253,63 @@ def _initialize_oauth(self): from authlib.integrations.starlette_client import OAuth return OAuth() + async def get_jwks_keys(self): + """Async override for fetching JWKS.""" + if hasattr(self, '_cached_jwks'): + return self._cached_jwks + + provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) + # Starlette requires await here + metadata = await provider.load_server_metadata() + + jwks_uri = metadata.get("jwks_uri") + if not jwks_uri: + raise ValueError("OIDC provider metadata missing 'jwks_uri'") + + # Non-blocking HTTP request + async with httpx.AsyncClient() as client: + response = await client.get(jwks_uri, timeout=10) + response.raise_for_status() + + self._cached_jwks = JsonWebKey.import_key_set(response.json()) + return self._cached_jwks + + async def decode_and_validate_token(self, token_str: str): + """Async override for decoding.""" + jwks = await self.get_jwks_keys() + + provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) + # Starlette requires await here too + metadata = await provider.load_server_metadata() + issuer = metadata.get("issuer") + + client_id = self.secrets.get("client_id") + + claims = jwt.decode( + token_str, + jwks, + claims_options={ + "iss": {"essential": True, "value": issuer}, + "aud": {"essential": True, "value": client_id}, + "azp": {"essential": True, "value": client_id}, + }, + ) + claims.validate() + return claims + + async def validate_and_extract_claims(self, token_str: str, required_scope: str = None): + """Async override for claim extraction.""" + claims = await self.decode_and_validate_token(token_str) + + if required_scope: + token_scopes = claims.get("scope", "").split() + if required_scope not in token_scopes: + raise InsufficientScopeError( + f"Required: '{required_scope}'. Available: {[s for s in token_scopes]}" + ) + + return claims + class FlaskAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): from authlib.integrations.flask_client import OAuth From 04112df7d1a87bec467df7cad9c622de1e9891b0 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Tue, 12 May 2026 16:11:24 -0700 Subject: [PATCH 21/42] ruff fixes --- src/dataone/auth.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index e885ff4..e0e637c 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -1,8 +1,9 @@ +import json import os import re -import json + import requests -from authlib.jose import jwt, JsonWebKey +from authlib.jose import JsonWebKey, jwt MAX_TOKEN_LEN = 16_384 _DEFAULT_SECRETS_PATH = "./client_secrets.json" @@ -31,7 +32,7 @@ def load_client_secrets(filepath: str | None = None) -> dict: or os.getenv("OIDC_CLIENT_SECRETS_FILE") or _DEFAULT_SECRETS_PATH ) - with open(resolved, "r") as f: + with open(resolved) as f: return json.load(f) def extract_token_from_header(auth_header: str): From 542762e40de722e6380c6f03fc15bfbfec850022 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Tue, 12 May 2026 16:12:11 -0700 Subject: [PATCH 22/42] add httpx --- pyproject.toml | 1 + src/dataone/auth.py | 2 ++ uv.lock | 2 ++ 3 files changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 484696e..d81b525 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ requires-python = ">=3.13" dependencies = [ "authlib>=1.7.2", "flask>=3.1.3", + "httpx>=0.28.1", "requests>=2.33.1", "werkzeug>=3.1.8", ] diff --git a/src/dataone/auth.py b/src/dataone/auth.py index e0e637c..5501cd8 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -1,10 +1,12 @@ import json import os import re +import httpx import requests from authlib.jose import JsonWebKey, jwt + MAX_TOKEN_LEN = 16_384 _DEFAULT_SECRETS_PATH = "./client_secrets.json" diff --git a/uv.lock b/uv.lock index 5b49c7f..6bd822e 100644 --- a/uv.lock +++ b/uv.lock @@ -246,6 +246,7 @@ source = { editable = "." } dependencies = [ { name = "authlib" }, { name = "flask" }, + { name = "httpx" }, { name = "requests" }, { name = "werkzeug" }, ] @@ -275,6 +276,7 @@ requires-dist = [ { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.136.1" }, { name = "flask", specifier = ">=3.1.3" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=3.1.3" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", marker = "extra == 'fastapi'", specifier = ">=0.28.1" }, { name = "httpx", marker = "extra == 'starlette'", specifier = ">=0.28.1" }, { name = "requests", specifier = ">=2.33.1" }, From 9976d908d537f7d294bd956b08b81a6a87cf023a Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Tue, 12 May 2026 16:12:21 -0700 Subject: [PATCH 23/42] more ruff fixes --- tests/test_auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index 05f02fb..a17e90a 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,7 +1,7 @@ """Unit tests for auth.py helpers.""" import pytest -from dataone.auth import extract_orcid -from dataone.auth import AuthFactory + +from dataone.auth import AuthFactory, extract_orcid def test_extract_orcid_returns_https_uri_from_https_orcid_claim(): From 267d9fcace5477367c332f739e13939b1e3c2e2b Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 14 May 2026 14:18:00 -0700 Subject: [PATCH 24/42] bring exception handling helpers into lib --- src/dataone/auth.py | 153 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 124 insertions(+), 29 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 5501cd8..d51ca2a 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -1,11 +1,17 @@ import json import os import re -import httpx +import authlib.integrations.base_client.errors as base_client_errors +import httpx import requests from authlib.jose import JsonWebKey, jwt +from authlib.oauth2.rfc6749.errors import ( + OAuth2Error, +) +from requests import RequestException +### Params MAX_TOKEN_LEN = 16_384 _DEFAULT_SECRETS_PATH = "./client_secrets.json" @@ -14,9 +20,23 @@ ACCESS_MODE_READ_ONLY = "read_only" ACCESS_MODE_OPEN = "open" +_ORCID_HTTPS_PREFIX = "https://orcid.org/" +_ORCID_HTTP_PREFIX = "http://orcid.org/" + +### Exceptions + class MissingParameterError(Exception): """Raised when a required request parameter is missing.""" +class AuthError(Exception): + """Base exception for dataone-auth""" + pass + +class InsufficientScopeError(AuthError): + """Raised when the token is valid but doesn't have the right scope""" + pass + +### Helpers def load_client_secrets(filepath: str | None = None) -> dict: """Load client secrets from a JSON file. @@ -28,7 +48,8 @@ def load_client_secrets(filepath: str | None = None) -> dict: Returns: Parsed dict of client credentials. """ - # accept either explicit filepath argument or environment variable, with a default fallback + # accept either explicit filepath argument or environment variable, with a default + # fallback resolved = ( filepath or os.getenv("OIDC_CLIENT_SECRETS_FILE") @@ -50,16 +71,13 @@ def extract_token_from_header(auth_header: str): if token.count('.') != 2: return None - # caps the token length to prevent huge tokens from causing DoS issues in downstream processing. + # caps the token length to prevent huge tokens from causing DoS issues in downstream + # processing. if len(token) > MAX_TOKEN_LEN: return None return token -_ORCID_HTTPS_PREFIX = "https://orcid.org/" -_ORCID_HTTP_PREFIX = "http://orcid.org/" - -# leave this in as a helper def extract_orcid(claims: dict | None) -> str | None: """Extract a normalised ORCID iD URI from JWT claims. @@ -95,19 +113,20 @@ def extract_orcid(claims: dict | None) -> str | None: return _ORCID_HTTPS_PREFIX + bare -# probably remove def get_access_mode() -> str: """Get the current access mode from environment. Returns: - str: One of 'read_only', 'open', or 'authenticated'. Defaults to 'authenticated'. + str: One of 'read_only', 'open', or 'authenticated'. Defaults to + 'authenticated'. """ mode = os.getenv("ACCESS_MODE", "authenticated").lower() if mode not in (ACCESS_MODE_READ_ONLY, ACCESS_MODE_OPEN, ACCESS_MODE_AUTHENTICATED): - logger.warning(f"Invalid access mode '{mode}', falling back to '{ACCESS_MODE_AUTHENTICATED}'") return ACCESS_MODE_AUTHENTICATED return mode +### Factory + class AuthFactory: _registry = { @@ -155,13 +174,47 @@ def _setup_providers(self): client_kwargs={"scope": scope_request}, ) + ERROR_MAP = { + KeyError: ("Invalid token structure", 401), + TypeError: ("Invalid token structure", 401), + ValueError: ("OIDC provider configuration error", 500), + RequestException: ("Failed to fetch OIDC provider keys", 502), + InsufficientScopeError: ("Insufficient permissions", 403), + base_client_errors.OAuthError: ("Authorization failed", 401), + OAuth2Error: ("An OAuth2 error occurred", 401), + } + + def _resolve_error(self, exc: Exception): + """Logic to determine message and status from an exception.""" + # Check for specific Authlib errors (handle imports or strings) + for exc_type, (msg, code) in self.ERROR_MAP.items(): + # Parentheses let us wrap this logic across lines cleanly + is_match = ( + isinstance(exc, exc_type) if not isinstance(exc_type, str) + else type(exc).__name__ == exc_type + ) + + if is_match: + return msg, code + + return "Internal authentication error", 500 + + def error_handler(self, exc: Exception): + """This will be implemented by subclasses.""" + raise NotImplementedError + + def token_response(self, token: dict, message: str): + """This will be implemented by subclasses.""" + raise NotImplementedError + def get_jwks_keys(self): """Fetch and cache the JWKS signing keys from the OIDC provider. - These keys are used to validate JWT token signatures. Care must be taken to fetch - them only from trustworthy sources (via the OIDC provider's metadata endpoint over - HTTPS). The keys may change periodically, so the cache will be invalidated and keys - will be refetched on the next call after the application is restarted. + These keys are used to validate JWT token signatures. Care must be taken to + fetch them only from trustworthy sources (via the OIDC provider's metadata + endpoint over HTTPS). The keys may change periodically, so the cache will be + invalidated and keys will be refetched on the next call after the application + is restarted. Returns: authlib.jose.JsonWebKey: A ``JsonWebKeySet`` ready for ``jwt.decode``. @@ -194,7 +247,8 @@ def get_jwks_keys(self): def decode_and_validate_token(self, token_str: str): """Decode *and* full-validate a JWT against the OIDC provider's JWKS. - Validates signature, issuer (iss), audience (aud), and authorized-party (azp) claims. + Validates signature, issuer (iss), audience (aud), and authorized-party (azp) + claims. """ jwks = self.get_jwks_keys() @@ -237,25 +291,52 @@ def validate_and_extract_claims(self, token_str: str, required_scope: str = None token_scopes = claims.get("scope", "").split() if required_scope not in token_scopes: raise InsufficientScopeError( - f"Required: '{required_scope}'. Available: {[s for s in token_scopes]}" + f"Required: '{required_scope}'." + "Available: {[s for s in token_scopes]}" ) return claims def __getattr__(self, name): """ - Delegate all unknown attribute/method lookups to the underlying Authlib OAuth object. - This automatically exposes .register(), .init_app(), etc. + Delegate all unknown attribute/method lookups to the underlying Authlib OAut + object. This automatically exposes .register(), .init_app(), etc. """ return getattr(self.oauth, name) -# adapters +### Adapters class FastAPIAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): from authlib.integrations.starlette_client import OAuth + from fastapi.responses import JSONResponse + self._response_class = JSONResponse return OAuth() + def error_handler(self, exc: Exception): + msg, code = self._resolve_error(exc) + return self._response_class( + status_code=code, + content={ + "error": { + "message": msg, + "details": str(exc) + } + } + ) + + def token_response(self, token: dict, message: str = "Success"): + return self._response_class( + status_code=200, + content={ + "message": message, + "token": { + "access_token": token.get("access_token"), + "refresh_token": token.get("refresh_token"), + } + } + ) + async def get_jwks_keys(self): """Async override for fetching JWKS.""" if hasattr(self, '_cached_jwks'): @@ -300,7 +381,9 @@ async def decode_and_validate_token(self, token_str: str): claims.validate() return claims - async def validate_and_extract_claims(self, token_str: str, required_scope: str = None): + async def validate_and_extract_claims(self, + token_str: str, + required_scope: str = None): """Async override for claim extraction.""" claims = await self.decode_and_validate_token(token_str) @@ -308,7 +391,8 @@ async def validate_and_extract_claims(self, token_str: str, required_scope: str token_scopes = claims.get("scope", "").split() if required_scope not in token_scopes: raise InsufficientScopeError( - f"Required: '{required_scope}'. Available: {[s for s in token_scopes]}" + f"Required: '{required_scope}'." + "Available: {[s for s in token_scopes]}" ) return claims @@ -318,12 +402,23 @@ def _initialize_oauth(self): from authlib.integrations.flask_client import OAuth return OAuth() -# exceptions + def error_handler(self, exc: Exception): + from flask import jsonify + msg, code = self._resolve_error(exc) + return jsonify({ + "error": { + "message": msg, + "details": str(exc) + } + }), code + + def token_response(self, token: dict, message: str = "Success"): + from flask import jsonify + return jsonify({ + "message": message, + "token": { + "access_token": token.get("access_token"), + "refresh_token": token.get("refresh_token"), + } + }), 200 -class AuthError(Exception): - """Base exception for dataone-auth""" - pass - -class InsufficientScopeError(AuthError): - """Raised when the token is valid but doesn't have the right scope""" - pass \ No newline at end of file From d7b172bab411d85e69363805288436a3ab9eb052 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 14 May 2026 14:18:13 -0700 Subject: [PATCH 25/42] ruff fixes --- tests/test_auth.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index a17e90a..8a9f134 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -5,13 +5,15 @@ def test_extract_orcid_returns_https_uri_from_https_orcid_claim(): - """Test that extract_orcid returns the canonical HTTPS URI when the orcid claim is already a full HTTPS URI.""" + """Test that extract_orcid returns the canonical HTTPS URI when the orcid claim is + already a full HTTPS URI.""" claims = {"orcid": "https://orcid.org/0000-0002-1825-0097"} assert extract_orcid(claims) == "https://orcid.org/0000-0002-1825-0097" def test_extract_orcid_normalises_http_orcid_claim_to_https(): - """Test that extract_orcid upgrades an http:// orcid claim URI to the canonical https:// URI.""" + """Test that extract_orcid upgrades an http:// orcid claim URI to the canonical + https:// URI.""" claims = {"orcid": "http://orcid.org/0000-0002-1825-0097"} assert extract_orcid(claims) == "https://orcid.org/0000-0002-1825-0097" @@ -23,7 +25,8 @@ def test_extract_orcid_normalises_bare_id_to_https_uri(): def test_extract_orcid_returns_none_for_none_input(): - """Test that extract_orcid returns None when called with None instead of a claims dict.""" + """Test that extract_orcid returns None when called with None instead of a + claims dict.""" assert extract_orcid(None) is None @@ -45,7 +48,9 @@ def test_factory_returns_flask_adapter(): from dataone.auth import FlaskAuthAdapter - adapter = AuthFactory.create_client("flask", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) + adapter = AuthFactory.create_client("flask", + secrets=MOCK_SECRETS, + scopes=MOCK_SCOPES) assert isinstance(adapter, FlaskAuthAdapter) assert adapter.secrets == MOCK_SECRETS @@ -56,7 +61,9 @@ def test_factory_returns_fastapi_adapter(): from dataone.auth import FastAPIAuthAdapter - adapter = AuthFactory.create_client("fastapi", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) + adapter = AuthFactory.create_client("fastapi", + secrets=MOCK_SECRETS, + scopes=MOCK_SCOPES) assert isinstance(adapter, FastAPIAuthAdapter) assert adapter.secrets == MOCK_SECRETS @@ -64,4 +71,6 @@ def test_factory_returns_fastapi_adapter(): def test_factory_raises_error_on_unknown_framework(): with pytest.raises(ValueError, match="Unsupported framework"): - AuthFactory.create_client("django", secrets=MOCK_SECRETS, scopes=MOCK_SCOPES) \ No newline at end of file + AuthFactory.create_client("django", + secrets=MOCK_SECRETS, + scopes=MOCK_SCOPES) \ No newline at end of file From e593bf3b244c7d7c18dc58d295a844661dddc14a Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 14 May 2026 14:45:08 -0700 Subject: [PATCH 26/42] update error map --- src/dataone/auth.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index d51ca2a..b807a6f 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -2,13 +2,13 @@ import os import re -import authlib.integrations.base_client.errors as base_client_errors import httpx import requests +from authlib.integrations.base_client.errors import OAuthError from authlib.jose import JsonWebKey, jwt -from authlib.oauth2.rfc6749.errors import ( - OAuth2Error, -) +from authlib.jose.errors import BadSignatureError, DecodeError, InvalidTokenError +from authlib.oauth2 import OAuth2Error +from authlib.oauth2.rfc6749.errors import InvalidClientError, InvalidGrantError from requests import RequestException ### Params @@ -175,13 +175,18 @@ def _setup_providers(self): ) ERROR_MAP = { + DecodeError: ("Token decoding failed", 401), + InvalidClientError: ("OIDC client authentication failed", 401), + InvalidTokenError: ("Token validation failed", 401), + InvalidGrantError: ("Invalid or expired refresh token", 401), + BadSignatureError: ("Token signature verification failed", 401), + OAuthError: ("Authorization failed", 401), + OAuth2Error: ("An OAuth2 error occurred", 401), KeyError: ("Invalid token structure", 401), TypeError: ("Invalid token structure", 401), + MissingParameterError: ("Missing required parameter", 400), ValueError: ("OIDC provider configuration error", 500), RequestException: ("Failed to fetch OIDC provider keys", 502), - InsufficientScopeError: ("Insufficient permissions", 403), - base_client_errors.OAuthError: ("Authorization failed", 401), - OAuth2Error: ("An OAuth2 error occurred", 401), } def _resolve_error(self, exc: Exception): From 5a1637f70da7c6ce3615e19cbe16692a6aa054c3 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 14 May 2026 15:17:25 -0700 Subject: [PATCH 27/42] add better error checking for token extraction --- src/dataone/auth.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index b807a6f..2de60f5 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -36,6 +36,10 @@ class InsufficientScopeError(AuthError): """Raised when the token is valid but doesn't have the right scope""" pass +class TokenExtractionError(ValueError): + """Raised when the Authorization header is missing or malformed.""" + pass + ### Helpers def load_client_secrets(filepath: str | None = None) -> dict: @@ -59,22 +63,28 @@ def load_client_secrets(filepath: str | None = None) -> dict: return json.load(f) def extract_token_from_header(auth_header: str): - """Extracts and safely bounds a Bearer token from an Authorization header.""" + """Extracts and validates a Bearer token. Raises ValueError on failure.""" - # check there is a token - if not auth_header or not auth_header.startswith("Bearer "): - return None + if not auth_header: + raise TokenExtractionError("Missing Authorization header") + + if not auth_header.startswith("Bearer "): + raise TokenExtractionError( + "Invalid Authorization header format. Expected 'Bearer '" + ) token = auth_header[7:].strip() + + if not token: + raise TokenExtractionError("Token is empty") - # make sure it looks like a JWT token + # Check JWT structure if token.count('.') != 2: - return None + raise TokenExtractionError("Token is malformed (invalid JWT structure)") - # caps the token length to prevent huge tokens from causing DoS issues in downstream - # processing. + # DoS protection if len(token) > MAX_TOKEN_LEN: - return None + raise TokenExtractionError("Token exceeds maximum allowed length") return token @@ -175,6 +185,7 @@ def _setup_providers(self): ) ERROR_MAP = { + TokenExtractionError: ("Invalid token or header", 401), DecodeError: ("Token decoding failed", 401), InvalidClientError: ("OIDC client authentication failed", 401), InvalidTokenError: ("Token validation failed", 401), From 4ea10b19f85263bfe26c03322651398d0802815d Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 14 May 2026 15:29:51 -0700 Subject: [PATCH 28/42] add login, refresh, auth methods for both adapters --- src/dataone/auth.py | 73 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 2de60f5..434c73a 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -313,6 +313,22 @@ def validate_and_extract_claims(self, token_str: str, required_scope: str = None return claims + def login(self, redirect_uri: str): + raise NotImplementedError + + def authorize(self): + raise NotImplementedError + + def refresh(self, request_json: dict): + + refresh_token = request_json.get("refresh_token") + if not refresh_token: + raise TokenExtractionError("Missing refresh_token in request body") + + scope = request_json.get("scope") + # Call the specific implementation's fetch method + return self._do_refresh(refresh_token, scope) + def __getattr__(self, name): """ Delegate all unknown attribute/method lookups to the underlying Authlib OAut @@ -412,6 +428,43 @@ async def validate_and_extract_claims(self, ) return claims + + async def login(self, redirect_uri: str): + """Returns a Starlette/FastAPI RedirectResponse.""" + # The Starlette client's authorize_redirect is async + return await self.dataone_oidc.authorize_redirect(redirect_uri) + + async def authorize(self): + """Exchanges code for token and returns a JSONResponse.""" + try: + # Must await the token exchange in FastAPI + token = await self.dataone_oidc.authorize_access_token() + return self.token_response(token) + except Exception as e: + return self.error_handler(e) + + async def refresh(self, request_json: dict): + """Logic to handle refresh token exchange.""" + refresh_token = request_json.get("refresh_token") + if not refresh_token: + # This triggers our mapped TokenExtractionError (401) + return self.error_handler(TokenExtractionError("Missing refresh_token")) + + scope = request_json.get("scope") + + try: + kwargs = { + "grant_type": "refresh_token", + "refresh_token": refresh_token + } + if scope: + kwargs["scope"] = scope + + # The Starlette fetch_access_token is async + new_tokens = await self.dataone_oidc.fetch_access_token(**kwargs) + return self.token_response(new_tokens, message="Token refresh successful") + except Exception as e: + return self.error_handler(e) class FlaskAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): @@ -438,3 +491,23 @@ def token_response(self, token: dict, message: str = "Success"): } }), 200 + def login(self, redirect_uri: str): + return self.dataone_oidc.authorize_redirect(redirect_uri) + + def authorize(self): + try: + token = self.dataone_oidc.authorize_access_token() + return self.token_response(token) + except Exception as e: + return self.error_handler(e) + + def _do_refresh(self, refresh_token, scope=None): + try: + kwargs = {"grant_type": "refresh_token", "refresh_token": refresh_token} + if scope: + kwargs["scope"] = scope + new_tokens = self.dataone_oidc.fetch_access_token(**kwargs) + return self.token_response(new_tokens) + except Exception as e: + return self.error_handler(e) + From 54441edf1e41d84e2162c402a3a934b4e5e57ce0 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 14 May 2026 15:44:14 -0700 Subject: [PATCH 29/42] add request to fastAPI calls --- src/dataone/auth.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 434c73a..48dddf9 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -313,10 +313,10 @@ def validate_and_extract_claims(self, token_str: str, required_scope: str = None return claims - def login(self, redirect_uri: str): + def login(self, redirect_uri: str, request=None): raise NotImplementedError - def authorize(self): + def authorize(self, request=None): raise NotImplementedError def refresh(self, request_json: dict): @@ -429,16 +429,16 @@ async def validate_and_extract_claims(self, return claims - async def login(self, redirect_uri: str): + async def login(self, request, redirect_uri: str): """Returns a Starlette/FastAPI RedirectResponse.""" # The Starlette client's authorize_redirect is async - return await self.dataone_oidc.authorize_redirect(redirect_uri) + return await self.dataone_oidc.authorize_redirect(request, redirect_uri) - async def authorize(self): + async def authorize(self, request): """Exchanges code for token and returns a JSONResponse.""" try: # Must await the token exchange in FastAPI - token = await self.dataone_oidc.authorize_access_token() + token = await self.dataone_oidc.authorize_access_token(request) return self.token_response(token) except Exception as e: return self.error_handler(e) From 6cd156477b2f094fcd75c9e80dda3cd89a370081 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 14 May 2026 15:58:11 -0700 Subject: [PATCH 30/42] add require_scope methods --- src/dataone/auth.py | 47 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 48dddf9..ea339f4 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -1,3 +1,4 @@ +import functools import json import os import re @@ -329,6 +330,9 @@ def refresh(self, request_json: dict): # Call the specific implementation's fetch method return self._do_refresh(refresh_token, scope) + def require_scope(self, required_scope: str): + raise NotImplementedError + def __getattr__(self, name): """ Delegate all unknown attribute/method lookups to the underlying Authlib OAut @@ -466,6 +470,29 @@ async def refresh(self, request_json: dict): except Exception as e: return self.error_handler(e) + def require_scope(self, required_scope: str): + """Returns a dependency for FastAPI's Depends().""" + async def dependency(request): + from fastapi import HTTPException + # Handle 'read_only' logic + if self.access_mode != "authenticated": + return None + + try: + auth_header = request.headers.get("Authorization") + token = extract_token_from_header(auth_header) + # This call is async in FastAPI + claims = await self.validate_and_extract_claims(token, required_scope) + return claims + except Exception as e: + # In FastAPI, we RAISE the error handler's result + error_res = self.error_handler(e) + raise HTTPException( + status_code=error_res.status_code, + detail=json.loads(error_res.body.decode())["error"] + ) + return dependency + class FlaskAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): from authlib.integrations.flask_client import OAuth @@ -511,3 +538,23 @@ def _do_refresh(self, refresh_token, scope=None): except Exception as e: return self.error_handler(e) + def require_scope(self, required_scope: str): + def decorator(f): + @functools.wraps(f) + def decorated(*args, **kwargs): + # Handle the 'read_only' logic inside the adapter + if self.access_mode != "authenticated": + return f(None, *args, **kwargs) + + try: + from flask import request + token = extract_token_from_header( + request.headers.get("Authorization")) + claims = self.validate_and_extract_claims(token, required_scope) + # Pass claims into the route + return f(claims, *args, **kwargs) + except Exception as e: + return self.error_handler(e) + return decorated + return decorator + From 546e806f5983c227aaa613d5d965aa1c2cb4704f Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 14 May 2026 16:03:58 -0700 Subject: [PATCH 31/42] need this imports --- src/dataone/auth.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index ea339f4..3248acc 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -472,8 +472,10 @@ async def refresh(self, request_json: dict): def require_scope(self, required_scope: str): """Returns a dependency for FastAPI's Depends().""" - async def dependency(request): + from fastapi import Request + async def dependency(request: Request): from fastapi import HTTPException + from .auth import extract_token_from_header # Handle 'read_only' logic if self.access_mode != "authenticated": return None From c52dc7341efec01047211ec12b5a3de297c694bf Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Thu, 14 May 2026 16:08:05 -0700 Subject: [PATCH 32/42] on startup, get access mode --- src/dataone/auth.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 3248acc..25cf41b 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -168,6 +168,7 @@ def __init__(self, secrets, scopes): self.scopes = scopes self.oauth = self._initialize_oauth() self._setup_providers() + self.access_mode = get_access_mode() def _initialize_oauth(self): raise NotImplementedError From 75e0690613eb6b8b0a5c8973d04ff0a0e70036f1 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 15 May 2026 12:26:52 -0700 Subject: [PATCH 33/42] add docs everywhere --- src/dataone/auth.py | 423 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 371 insertions(+), 52 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 25cf41b..618440c 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -26,32 +26,40 @@ ### Exceptions -class MissingParameterError(Exception): - """Raised when a required request parameter is missing.""" - class AuthError(Exception): - """Base exception for dataone-auth""" + """Base exception for dataone-auth.""" pass +class MissingParameterError(AuthError): + """Raised when a required request parameter is missing.""" + class InsufficientScopeError(AuthError): - """Raised when the token is valid but doesn't have the right scope""" + """Raised when the token is valid but doesn't have the right scope.""" pass -class TokenExtractionError(ValueError): +class TokenExtractionError(AuthError): """Raised when the Authorization header is missing or malformed.""" pass +class ConfigurationError(AuthError): + pass + ### Helpers def load_client_secrets(filepath: str | None = None) -> dict: """Load client secrets from a JSON file. Args: - filepath: Optional explicit path. Falls back to the - ``OIDC_CLIENT_SECRETS_FILE`` environment variable + filepath: Optional explicit path. Falls back to the ``OIDC_CLIENT_SECRETS_FILE`` + environment variable, then finally to the default path of + "./client_secrets.json" Returns: Parsed dict of client credentials. + + Raises: + ConfigurationError: If the secrets file cannot be found at the resolved path, + or if the file does not contain valid JSON. """ # accept either explicit filepath argument or environment variable, with a default # fallback @@ -60,14 +68,32 @@ def load_client_secrets(filepath: str | None = None) -> dict: or os.getenv("OIDC_CLIENT_SECRETS_FILE") or _DEFAULT_SECRETS_PATH ) - with open(resolved) as f: - return json.load(f) + try: + with open(resolved) as f: + return json.load(f) + except FileNotFoundError: + raise ConfigurationError(f"Could not find OIDC secrets file at {resolved}") + except json.JSONDecodeError: + raise ConfigurationError(f"OIDC secrets file at {resolved} is not valid JSON") def extract_token_from_header(auth_header: str): - """Extracts and validates a Bearer token. Raises ValueError on failure.""" + """Extracts and validates a Bearer token from an auth header string. + + Args: + auth_header: Auth header as a string (e.g., "Bearer "). + + Returns: + The extracted JWT token. + + Raises: + MissingParameterError: If no header is supplied. + TokenExtractionError: If the token is empty, malformed, or exceeds the allowed + length. + + """ if not auth_header: - raise TokenExtractionError("Missing Authorization header") + raise MissingParameterError("Missing Authorization header") if not auth_header.startswith("Bearer "): raise TokenExtractionError( @@ -139,6 +165,13 @@ def get_access_mode() -> str: ### Factory class AuthFactory: + """Factory for generating framework-specific authentication adapters. + + This factory uses a registry and dynamic imports to instantiate the correct + adapter (e.g., Flask or FastAPI) based on the running application. This pattern + ensures that a Flask application does not need to install FastAPI/Starlette + dependencies, and vice versa. + """ _registry = { "flask": "dataone.auth.FlaskAuthAdapter", @@ -148,6 +181,23 @@ class AuthFactory: @classmethod def create_client(cls, framework: str, secrets: dict, scopes: list): + """Creates and returns the appropriate authentication adapter. + + Args: + framework: A string identifying the target web framework (e.g., "flask", + "fastapi"). + secrets: A dictionary containing the OIDC client credentials, typically + loaded via `load_client_secrets()`. + scopes: A list of default OIDC scopes to request from the authorization + server (e.g., ["ogdc:admin"]). + + Returns: + BaseAuthAdapter: An instantiated, framework-specific adapter (such as + `FlaskAuthAdapter` or `FastAPIAuthAdapter`). + + Raises: + ValueError: If the framework string is not found in the registry. + """ import_path = cls._registry.get(framework.lower()) if not import_path: raise ValueError(f"Unsupported framework: {framework}") @@ -159,11 +209,46 @@ def create_client(cls, framework: str, secrets: dict, scopes: list): return AdapterClass(secrets=secrets, scopes=scopes) class BaseAuthAdapter: + """Base adapter for handling OIDC authentication. + + This class manages the core Authlib registry initialization, OIDC provider + setup, and access mode configuration. It is designed to be subclassed by + framework-specific adapters (e.g., FlaskAuthAdapter, FastAPIAuthAdapter) + that implement the actual request handling and dependency/decorator logic. + + Attributes: + DEFAULT_PROVIDER_NAME (str): The internal registry name for the OIDC provider. + DEFAULT_SCOPES (str): The standard base scopes requested during login. + access_mode (str): The current operating mode ('authenticated', 'read_only', + or 'open'), loaded during initialization. + """ DEFAULT_PROVIDER_NAME = "dataone_oidc" DEFAULT_SCOPES = "openid email profile" + ERROR_MAP = { + TokenExtractionError: ("Invalid token or header", 401), + DecodeError: ("Token decoding failed", 401), + InvalidClientError: ("OIDC client authentication failed", 401), + InvalidTokenError: ("Token validation failed", 401), + InvalidGrantError: ("Invalid or expired refresh token", 401), + BadSignatureError: ("Token signature verification failed", 401), + OAuthError: ("Authorization failed", 401), + OAuth2Error: ("An OAuth2 error occurred", 401), + KeyError: ("Invalid token structure", 401), + TypeError: ("Invalid token structure", 401), + MissingParameterError: ("Missing required parameter", 400), + ValueError: ("OIDC provider configuration error", 500), + RequestException: ("Failed to fetch OIDC provider keys", 502), + } + def __init__(self, secrets, scopes): + """Initializes the base authentication adapter. + + Args: + secrets: Dictionary of OIDC client credentials. + scopes: List of additional OIDC scopes to request. + """ self.secrets = secrets self.scopes = scopes self.oauth = self._initialize_oauth() @@ -171,9 +256,11 @@ def __init__(self, secrets, scopes): self.access_mode = get_access_mode() def _initialize_oauth(self): + """This is implemented by subclasses.""" raise NotImplementedError def _setup_providers(self): + """Registers the OIDC provider using loaded secrets and scopes.""" base_scopes = self.DEFAULT_SCOPES.split() scope_request = " ".join(dict.fromkeys(base_scopes + self.scopes)) @@ -186,24 +273,18 @@ def _setup_providers(self): client_kwargs={"scope": scope_request}, ) - ERROR_MAP = { - TokenExtractionError: ("Invalid token or header", 401), - DecodeError: ("Token decoding failed", 401), - InvalidClientError: ("OIDC client authentication failed", 401), - InvalidTokenError: ("Token validation failed", 401), - InvalidGrantError: ("Invalid or expired refresh token", 401), - BadSignatureError: ("Token signature verification failed", 401), - OAuthError: ("Authorization failed", 401), - OAuth2Error: ("An OAuth2 error occurred", 401), - KeyError: ("Invalid token structure", 401), - TypeError: ("Invalid token structure", 401), - MissingParameterError: ("Missing required parameter", 400), - ValueError: ("OIDC provider configuration error", 500), - RequestException: ("Failed to fetch OIDC provider keys", 502), - } - def _resolve_error(self, exc: Exception): - """Logic to determine message and status from an exception.""" + """Resolves an exception to an error message and HTTP status code. + + Evaluates the exception against ERROR_MAP, matching by direct class type + or class name (string) to avoid hard dependency imports. + + Args: + exc: The caught exception to be resolved. + + Returns: + A tuple containing the error message (str) and HTTP status code (int). + """ # Check for specific Authlib errors (handle imports or strings) for exc_type, (msg, code) in self.ERROR_MAP.items(): # Parentheses let us wrap this logic across lines cleanly @@ -218,11 +299,11 @@ def _resolve_error(self, exc: Exception): return "Internal authentication error", 500 def error_handler(self, exc: Exception): - """This will be implemented by subclasses.""" + """This is implemented by subclasses.""" raise NotImplementedError def token_response(self, token: dict, message: str): - """This will be implemented by subclasses.""" + """This is implemented by subclasses.""" raise NotImplementedError def get_jwks_keys(self): @@ -263,12 +344,17 @@ def get_jwks_keys(self): return self._cached_jwks def decode_and_validate_token(self, token_str: str): - """Decode *and* full-validate a JWT against the OIDC provider's JWKS. + """Decodes and validates a JWT using the provider's JWKS. - Validates signature, issuer (iss), audience (aud), and authorized-party (azp) - claims. - """ + Enforces signature validity as well as the exact issuer (iss), + audience (aud), and authorized party (azp) claims. + Args: + token_str: The raw JWT string to validate. + + Returns: + The validated token claims object. + """ jwks = self.get_jwks_keys() provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) @@ -316,22 +402,19 @@ def validate_and_extract_claims(self, token_str: str, required_scope: str = None return claims def login(self, redirect_uri: str, request=None): + """This is implemented by subclasses.""" raise NotImplementedError def authorize(self, request=None): + """This is implemented by subclasses.""" raise NotImplementedError def refresh(self, request_json: dict): - - refresh_token = request_json.get("refresh_token") - if not refresh_token: - raise TokenExtractionError("Missing refresh_token in request body") - - scope = request_json.get("scope") - # Call the specific implementation's fetch method - return self._do_refresh(refresh_token, scope) + """This is implemented by subclasses.""" + raise NotImplementedError def require_scope(self, required_scope: str): + """This is implemented by subclasses.""" raise NotImplementedError def __getattr__(self, name): @@ -345,12 +428,26 @@ def __getattr__(self, name): class FastAPIAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): + """Initializes the Starlette-based OAuth registry for FastAPI. + + Sets the internal response class to FastAPI's JSONResponse and returns + the instantiated Authlib OAuth object. + """ from authlib.integrations.starlette_client import OAuth from fastapi.responses import JSONResponse self._response_class = JSONResponse return OAuth() def error_handler(self, exc: Exception): + """Formats an exception into a FastAPI JSON response. + + Args: + exc: The exception caught during authentication or request processing. + + Returns: + A JSONResponse object containing the resolved HTTP status code + and formatted error payload. + """ msg, code = self._resolve_error(exc) return self._response_class( status_code=code, @@ -363,6 +460,18 @@ def error_handler(self, exc: Exception): ) def token_response(self, token: dict, message: str = "Success"): + """Formats successful token data into a FastAPI JSON response. + + Args: + token: A dictionary containing the token data (must include at least + 'access_token' and 'refresh_token' keys). + message: An optional success message to include in the response payload. + Defaults to "Success". + + Returns: + A JSONResponse object with a 200 status code and the standardized + token payload. + """ return self._response_class( status_code=200, content={ @@ -375,12 +484,24 @@ def token_response(self, token: dict, message: str = "Success"): ) async def get_jwks_keys(self): - """Async override for fetching JWKS.""" + """Asynchronously fetches and caches the OIDC provider's JWKS. + + Retrieves the provider metadata to find the `jwks_uri`, makes a non-blocking + HTTP request to fetch the keys using `httpx`, and caches the parsed key set + to prevent redundant network calls. + + Returns: + The parsed JsonWebKey set. + + Raises: + ValueError: If the provider metadata does not contain a 'jwks_uri'. + httpx.HTTPStatusError: If the network request to the `jwks_uri` fails. + """ if hasattr(self, '_cached_jwks'): return self._cached_jwks provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) - # Starlette requires await here + # FastAPI requires await here metadata = await provider.load_server_metadata() jwks_uri = metadata.get("jwks_uri") @@ -421,7 +542,18 @@ async def decode_and_validate_token(self, token_str: str): async def validate_and_extract_claims(self, token_str: str, required_scope: str = None): - """Async override for claim extraction.""" + """Asynchronously decodes and validates a JWT using the provider's JWKS. + + This overrides the base method to support Starlette/FastAPI's asynchronous + metadata and JWKS fetching. It enforces signature validity as well as exact + matching for issuer (iss), audience (aud), and authorized party (azp) claims. + + Args: + token_str: The raw JWT string to validate. + + Returns: + The validated token claims object. + """ claims = await self.decode_and_validate_token(token_str) if required_scope: @@ -435,12 +567,49 @@ async def validate_and_extract_claims(self, return claims async def login(self, request, redirect_uri: str): - """Returns a Starlette/FastAPI RedirectResponse.""" + """Asynchronously initiates the OIDC login flow. + + Uses the Starlette OAuth client to generate a redirect response that + sends the user to the authorization server. + + Args: + request: The incoming Starlette or FastAPI Request object. + redirect_uri: The callback URL where the authorization server will + redirect the user after authentication. + + Returns: + A Starlette RedirectResponse object pointing to the OIDC provider. + + Example: + @app.get("/login") + async def login(request: Request): + return await auth_adapter.login( + request=request, + redirect_uri=str(request.url_for("authorize")) + ) + """ # The Starlette client's authorize_redirect is async return await self.dataone_oidc.authorize_redirect(request, redirect_uri) async def authorize(self, request): - """Exchanges code for token and returns a JSONResponse.""" + """Asynchronously exchanges an authorization code for an access token. + + This method is designed to be used in the OIDC callback route. It + processes the incoming redirect from the authorization server, extracts + the code, and fetches the final tokens. + + Args: + request: The incoming FastAPI Request object containing the auth code. + + Returns: + A JSONResponse containing the extracted tokens on success, or a + formatted error response on failure. + + Example: + @app.get("/authorize") + async def authorize(request: Request): + return await auth_adapter.authorize(request=request) + """ try: # Must await the token exchange in FastAPI token = await self.dataone_oidc.authorize_access_token(request) @@ -449,7 +618,25 @@ async def authorize(self, request): return self.error_handler(e) async def refresh(self, request_json: dict): - """Logic to handle refresh token exchange.""" + """Asynchronously exchanges a refresh token for new access tokens. + + Overrides the synchronous base method to accommodate FastAPI's async + token fetching. + + Args: + request_json: A dictionary (typically the parsed JSON body of the + request) containing at least a 'refresh_token'. + + Returns: + A JSONResponse containing the new access and refresh tokens, or an + error response if the token is missing or invalid. + + Example: + @app.post("/refresh") + async def refresh(request: Request): + body = await request.json() + return await auth_adapter.refresh(body) + """ refresh_token = request_json.get("refresh_token") if not refresh_token: # This triggers our mapped TokenExtractionError (401) @@ -472,10 +659,38 @@ async def refresh(self, request_json: dict): return self.error_handler(e) def require_scope(self, required_scope: str): - """Returns a dependency for FastAPI's Depends().""" + """Creates a FastAPI dependency to enforce scope requirements on routes. + + This method returns an async function designed to be injected into FastAPI + endpoints using `Depends()`. It extracts the token, validates it against + the requested scope, and returns the claims. If the adapter's access mode + is not set to 'authenticated' (e.g., 'read_only'), validation is bypassed. + + Args: + required_scope: The specific OAuth scope required to access the route + (e.g., "read:data" or "write:admin"). + + Returns: + An asynchronous callable dependency that returns validated token claims. + + Raises: + fastapi.HTTPException: If token validation fails. The internal exception + is translated into a standard FastAPI HTTP error + using the adapter's error handler. + + Example: + from fastapi import Depends + + @app.get("/secure-data") + async def get_secure_data( + claims: dict = Depends(auth_adapter.require_scope("read:data")) + ): + return {"message": "Access granted", "user": claims.get("sub")} + """ from fastapi import Request async def dependency(request: Request): from fastapi import HTTPException + from .auth import extract_token_from_header # Handle 'read_only' logic if self.access_mode != "authenticated": @@ -498,10 +713,24 @@ async def dependency(request: Request): class FlaskAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): + """Initializes the Flask-based OAuth registry. + + Sets the internal response class to FastAPI's JSONResponse and returns + the instantiated Authlib OAuth object. + """ from authlib.integrations.flask_client import OAuth return OAuth() def error_handler(self, exc: Exception): + """Formats an exception into a Flask JSON response. + + Args: + exc: The exception caught during authentication or request processing. + + Returns: + A flask.Response object containing the resolved HTTP status code + and formatted error payload. + """ from flask import jsonify msg, code = self._resolve_error(exc) return jsonify({ @@ -512,6 +741,18 @@ def error_handler(self, exc: Exception): }), code def token_response(self, token: dict, message: str = "Success"): + """Formats successful token data into a Flask JSON response. + + Args: + token: A dictionary containing the token data (must include at least + 'access_token' and 'refresh_token' keys). + message: An optional success message to include in the response payload. + Defaults to "Success". + + Returns: + A flask.Response object with a 200 status code and the standardized + token payload. + """ from flask import jsonify return jsonify({ "message": message, @@ -522,26 +763,104 @@ def token_response(self, token: dict, message: str = "Success"): }), 200 def login(self, redirect_uri: str): + """Initiates the OIDC login flow for Flask. + + Uses the Flask Authlib client to generate a redirect response that + sends the user to the authorization server. + + Args: + redirect_uri: The callback URL where the authorization server will + redirect the user after authentication. + + Returns: + A Flask Response object (redirect) pointing to the OIDC provider. + + Example: + @app.route("/login") + def login(): + return auth_client.login( + redirect_uri=url_for("authorize", _external=True) + ) + """ return self.dataone_oidc.authorize_redirect(redirect_uri) def authorize(self): + """Exchanges an authorization code for an access token in Flask. + + This method should be called within the OIDC callback route. It + automatically handles the code exchange by accessing the global + Flask request object. + + Returns: + A Flask Response object (JSON) containing the tokens on success, + or a formatted error response on failure. + + Example: + @app.route("/authorize") + def authorize(): + return auth_client.authorize() + """ try: token = self.dataone_oidc.authorize_access_token() return self.token_response(token) except Exception as e: return self.error_handler(e) - def _do_refresh(self, refresh_token, scope=None): + def refresh(self, request_json: dict): + """Executes the synchronous token refresh request for Flask. + + Args: + request_json: A dictionary (the parsed JSON body) containing + at least a 'refresh_token'. + + Returns: + A Flask Response object (JSON) containing the new tokens or + an error response if the exchange fails. + + Example: + @app.route("/refresh", methods=["POST"]) + def refresh_route(): + return auth_adapter.refresh(request.get_json()) + """ + refresh_token = request_json.get("refresh_token") + if not refresh_token: + # We return the error handler result instead of raising + # to match the Flask return-style flow. + return self.error_handler(TokenExtractionError("Missing refresh_token")) + + scope = request_json.get("scope") try: kwargs = {"grant_type": "refresh_token", "refresh_token": refresh_token} if scope: kwargs["scope"] = scope + new_tokens = self.dataone_oidc.fetch_access_token(**kwargs) - return self.token_response(new_tokens) + return self.token_response(new_tokens, message="Token refresh successful") except Exception as e: return self.error_handler(e) def require_scope(self, required_scope: str): + """Creates a Flask decorator to enforce scope requirements on routes. + + This method returns a decorator that extracts the Bearer token from the + 'Authorization' header, validates it, and injects the resulting claims + into the decorated function as the first argument. If the adapter is in + 'read_only' or 'open' mode, validation is bypassed and 'None' is passed + for the claims. + + Args: + required_scope: The specific OAuth scope required to access the route + (e.g., "read:data"). + + Returns: + A decorator function that wraps a Flask route handler. + + Example: + @app.route("/secure-data") + @auth_adapter.require_scope("read:data") + def get_secure_data(claims): + return {"message": "Access granted", "user": claims.get("sub")} + """ def decorator(f): @functools.wraps(f) def decorated(*args, **kwargs): From 4d64c98bf0fe16c6587c753125a0e2be43a8357c Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 15 May 2026 13:04:28 -0700 Subject: [PATCH 34/42] make code a little drier, and prefix some internal methods with _ --- src/dataone/auth.py | 117 +++++++++++++++++++++----------------------- 1 file changed, 55 insertions(+), 62 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index 618440c..a9cfdd3 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -162,6 +162,19 @@ def get_access_mode() -> str: return ACCESS_MODE_AUTHENTICATED return mode +def decode_claims(token_str: str, jwks: str, client_id: str, issuer: str): + claims = jwt.decode( + token_str, + jwks, + claims_options={ + "iss": {"essential": True, "value": issuer}, + "aud": {"essential": True, "value": client_id}, + "azp": {"essential": True, "value": client_id}, + }, + ) + claims.validate() + return claims + ### Factory class AuthFactory: @@ -298,15 +311,27 @@ def _resolve_error(self, exc: Exception): return "Internal authentication error", 500 - def error_handler(self, exc: Exception): + def _verify_scope(self, claims: dict, required_scope: str | None): + """Internal helper to check if the required scope exists in claims.""" + if not required_scope: + return + + token_scopes = claims.get("scope", "").split() + if required_scope not in token_scopes: + raise InsufficientScopeError( + f"Required: '{required_scope}'. " + f"Available: {token_scopes}" + ) + + def _error_handler(self, exc: Exception): """This is implemented by subclasses.""" raise NotImplementedError - def token_response(self, token: dict, message: str): + def _token_response(self, token: dict, message: str): """This is implemented by subclasses.""" raise NotImplementedError - def get_jwks_keys(self): + def _get_jwks_keys(self): """Fetch and cache the JWKS signing keys from the OIDC provider. These keys are used to validate JWT token signatures. Care must be taken to @@ -343,7 +368,7 @@ def get_jwks_keys(self): return self._cached_jwks - def decode_and_validate_token(self, token_str: str): + def _decode_and_validate_token(self, token_str: str): """Decodes and validates a JWT using the provider's JWKS. Enforces signature validity as well as the exact issuer (iss), @@ -355,7 +380,7 @@ def decode_and_validate_token(self, token_str: str): Returns: The validated token claims object. """ - jwks = self.get_jwks_keys() + jwks = self._get_jwks_keys() provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) metadata = provider.load_server_metadata() @@ -363,17 +388,7 @@ def decode_and_validate_token(self, token_str: str): client_id = self.secrets.get("client_id") - claims = jwt.decode( - token_str, - jwks, - claims_options={ - "iss": {"essential": True, "value": issuer}, - "aud": {"essential": True, "value": client_id}, - "azp": {"essential": True, "value": client_id}, - }, - ) - claims.validate() - return claims + return decode_claims(token_str, jwks, client_id, issuer) def validate_and_extract_claims(self, token_str: str, required_scope: str = None): """Validate a token string and optionally check required scope. @@ -389,15 +404,9 @@ def validate_and_extract_claims(self, token_str: str, required_scope: str = None Exception: JoseError from Authlib if token is invalid/expired. InsufficientScopeError: If the token lacks the required scope. """ - claims = self.decode_and_validate_token(token_str) + claims = self._decode_and_validate_token(token_str) - if required_scope: - token_scopes = claims.get("scope", "").split() - if required_scope not in token_scopes: - raise InsufficientScopeError( - f"Required: '{required_scope}'." - "Available: {[s for s in token_scopes]}" - ) + self._verify_scope(claims, required_scope) return claims @@ -438,7 +447,7 @@ def _initialize_oauth(self): self._response_class = JSONResponse return OAuth() - def error_handler(self, exc: Exception): + def _error_handler(self, exc: Exception): """Formats an exception into a FastAPI JSON response. Args: @@ -459,7 +468,7 @@ def error_handler(self, exc: Exception): } ) - def token_response(self, token: dict, message: str = "Success"): + def _token_response(self, token: dict, message: str = "Success"): """Formats successful token data into a FastAPI JSON response. Args: @@ -483,7 +492,7 @@ def token_response(self, token: dict, message: str = "Success"): } ) - async def get_jwks_keys(self): + async def _get_jwks_keys(self): """Asynchronously fetches and caches the OIDC provider's JWKS. Retrieves the provider metadata to find the `jwks_uri`, makes a non-blocking @@ -516,9 +525,9 @@ async def get_jwks_keys(self): self._cached_jwks = JsonWebKey.import_key_set(response.json()) return self._cached_jwks - async def decode_and_validate_token(self, token_str: str): + async def _decode_and_validate_token(self, token_str: str): """Async override for decoding.""" - jwks = await self.get_jwks_keys() + jwks = await self._get_jwks_keys() provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) # Starlette requires await here too @@ -527,17 +536,7 @@ async def decode_and_validate_token(self, token_str: str): client_id = self.secrets.get("client_id") - claims = jwt.decode( - token_str, - jwks, - claims_options={ - "iss": {"essential": True, "value": issuer}, - "aud": {"essential": True, "value": client_id}, - "azp": {"essential": True, "value": client_id}, - }, - ) - claims.validate() - return claims + return decode_claims(token_str, jwks, client_id, issuer) async def validate_and_extract_claims(self, token_str: str, @@ -554,15 +553,9 @@ async def validate_and_extract_claims(self, Returns: The validated token claims object. """ - claims = await self.decode_and_validate_token(token_str) + claims = await self._decode_and_validate_token(token_str) - if required_scope: - token_scopes = claims.get("scope", "").split() - if required_scope not in token_scopes: - raise InsufficientScopeError( - f"Required: '{required_scope}'." - "Available: {[s for s in token_scopes]}" - ) + self._verify_scope(claims, required_scope) return claims @@ -613,9 +606,9 @@ async def authorize(request: Request): try: # Must await the token exchange in FastAPI token = await self.dataone_oidc.authorize_access_token(request) - return self.token_response(token) + return self._token_response(token) except Exception as e: - return self.error_handler(e) + return self._error_handler(e) async def refresh(self, request_json: dict): """Asynchronously exchanges a refresh token for new access tokens. @@ -640,7 +633,7 @@ async def refresh(request: Request): refresh_token = request_json.get("refresh_token") if not refresh_token: # This triggers our mapped TokenExtractionError (401) - return self.error_handler(TokenExtractionError("Missing refresh_token")) + return self._error_handler(TokenExtractionError("Missing refresh_token")) scope = request_json.get("scope") @@ -654,9 +647,9 @@ async def refresh(request: Request): # The Starlette fetch_access_token is async new_tokens = await self.dataone_oidc.fetch_access_token(**kwargs) - return self.token_response(new_tokens, message="Token refresh successful") + return self._token_response(new_tokens, message="Token refresh successful") except Exception as e: - return self.error_handler(e) + return self._error_handler(e) def require_scope(self, required_scope: str): """Creates a FastAPI dependency to enforce scope requirements on routes. @@ -704,7 +697,7 @@ async def dependency(request: Request): return claims except Exception as e: # In FastAPI, we RAISE the error handler's result - error_res = self.error_handler(e) + error_res = self._error_handler(e) raise HTTPException( status_code=error_res.status_code, detail=json.loads(error_res.body.decode())["error"] @@ -721,7 +714,7 @@ def _initialize_oauth(self): from authlib.integrations.flask_client import OAuth return OAuth() - def error_handler(self, exc: Exception): + def _error_handler(self, exc: Exception): """Formats an exception into a Flask JSON response. Args: @@ -740,7 +733,7 @@ def error_handler(self, exc: Exception): } }), code - def token_response(self, token: dict, message: str = "Success"): + def _token_response(self, token: dict, message: str = "Success"): """Formats successful token data into a Flask JSON response. Args: @@ -802,9 +795,9 @@ def authorize(): """ try: token = self.dataone_oidc.authorize_access_token() - return self.token_response(token) + return self._token_response(token) except Exception as e: - return self.error_handler(e) + return self._error_handler(e) def refresh(self, request_json: dict): """Executes the synchronous token refresh request for Flask. @@ -826,7 +819,7 @@ def refresh_route(): if not refresh_token: # We return the error handler result instead of raising # to match the Flask return-style flow. - return self.error_handler(TokenExtractionError("Missing refresh_token")) + return self._error_handler(TokenExtractionError("Missing refresh_token")) scope = request_json.get("scope") try: @@ -835,9 +828,9 @@ def refresh_route(): kwargs["scope"] = scope new_tokens = self.dataone_oidc.fetch_access_token(**kwargs) - return self.token_response(new_tokens, message="Token refresh successful") + return self._token_response(new_tokens, message="Token refresh successful") except Exception as e: - return self.error_handler(e) + return self._error_handler(e) def require_scope(self, required_scope: str): """Creates a Flask decorator to enforce scope requirements on routes. @@ -876,7 +869,7 @@ def decorated(*args, **kwargs): # Pass claims into the route return f(claims, *args, **kwargs) except Exception as e: - return self.error_handler(e) + return self._error_handler(e) return decorated return decorator From 7d9036feae57831f98a136e34d29e8b4144b26d2 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 15 May 2026 13:10:57 -0700 Subject: [PATCH 35/42] add token structure and claims decoding tests --- tests/test_auth.py | 87 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index 8a9f134..572d679 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,7 +1,10 @@ """Unit tests for auth.py helpers.""" import pytest -from dataone.auth import AuthFactory, extract_orcid +from dataone.auth import ( + AuthFactory, extract_orcid, extract_token_from_header, MissingParameterError, + TokenExtractionError, decode_claims +) def test_extract_orcid_returns_https_uri_from_https_orcid_claim(): @@ -34,6 +37,88 @@ def test_extract_orcid_returns_none_for_empty_claims(): """Test that extract_orcid returns None when called with an empty claims dict.""" assert extract_orcid({}) is None +def test_extract_token_success(): + """Test standard valid Bearer token extraction.""" + token = "header.payload.signature" + auth_header = f"Bearer {token}" + assert extract_token_from_header(auth_header) == token + +def test_extract_token_missing_header(): + """Test error when header is None or empty string.""" + with pytest.raises(MissingParameterError, match="Missing Authorization header"): + extract_token_from_header("") + +def test_extract_token_invalid_format(): + """Test error when 'Bearer ' prefix is missing.""" + with pytest.raises(TokenExtractionError, match="Invalid Authorization header format"): + extract_token_from_header("Token abc.def.ghi") + +def test_extract_token_empty_after_prefix(): + """Test error when header is just 'Bearer ' with no content.""" + with pytest.raises(TokenExtractionError, match="Token is empty"): + extract_token_from_header("Bearer ") + +def test_extract_token_malformed_jwt(): + """Test error when token doesn't have 2 dots.""" + with pytest.raises(TokenExtractionError, match="Token is malformed"): + extract_token_from_header("Bearer not-a-jwt") + +def test_extract_token_too_long(): + """Test DoS protection for oversized tokens.""" + long_token = "a.b." + ("c" * 20000) # Exceeds default 16,384 + with pytest.raises(TokenExtractionError, match="Token exceeds maximum allowed length"): + extract_token_from_header(f"Bearer {long_token}") + + +import pytest +from authlib.jose import jwt, JsonWebKey + +def test_decode_claims_success(): + # generate a simple RSA key for testing + key = JsonWebKey.generate_key('RSA', 2048, is_private=True) + public_jwks = JsonWebKey.import_key_set([key.as_dict(is_private=False)]) + + # setup mock claims/headers + header = {'alg': 'RS256', 'kid': key.as_dict().get('kid')} + payload = { + "iss": "https://auth.example.com", + "aud": "my_client_id", + "azp": "my_client_id", + "sub": "12345", + "scope": "openid profile" + } + + # create a signed token + token = jwt.encode(header, payload, key).decode('utf-8') + + # test + result = decode_claims( + token_str=token, + jwks=public_jwks, + client_id="my_client_id", + issuer="https://auth.example.com" + ) + + assert result['sub'] == "12345" + assert result['iss'] == "https://auth.example.com" + +def test_decode_claims_invalid_issuer(): + key = JsonWebKey.generate_key('RSA', 2048, is_private=True) + public_jwks = JsonWebKey.import_key_set([key.as_dict(is_private=False)]) + + # token has 'wrong-issuer' + payload = { + "iss": "wrong-issuer", + "aud": "my_client_id", + "azp": "my_client_id" + } + token = jwt.encode({'alg': 'RS256'}, payload, key).decode('utf-8') + + # this should raise an error because the 'value' doesn't match the claims_options + from authlib.jose.errors import InvalidClaimError + with pytest.raises(InvalidClaimError): + decode_claims(token, public_jwks, "my_client_id", "https://auth.example.com") + MOCK_SECRETS = { "client_id": "test client", "client_secret": "a string", From 7607771f80f6ef60ec48b6604187566c1848b360 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 15 May 2026 13:40:07 -0700 Subject: [PATCH 36/42] convert from jose to joserfc --- src/dataone/auth.py | 76 ++++++++++++++++++++++++++++----------------- tests/test_auth.py | 61 +++++++++++++++++++++++------------- 2 files changed, 87 insertions(+), 50 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index a9cfdd3..b0e6730 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -6,10 +6,11 @@ import httpx import requests from authlib.integrations.base_client.errors import OAuthError -from authlib.jose import JsonWebKey, jwt -from authlib.jose.errors import BadSignatureError, DecodeError, InvalidTokenError from authlib.oauth2 import OAuth2Error from authlib.oauth2.rfc6749.errors import InvalidClientError, InvalidGrantError +from joserfc import jwt +from joserfc.errors import JoseError +from joserfc.jwk import KeySet from requests import RequestException ### Params @@ -41,6 +42,10 @@ class TokenExtractionError(AuthError): """Raised when the Authorization header is missing or malformed.""" pass +class InvalidTokenError(AuthError): + """Raised when claims like iss or aud do not match expectations.""" + pass + class ConfigurationError(AuthError): pass @@ -162,17 +167,37 @@ def get_access_mode() -> str: return ACCESS_MODE_AUTHENTICATED return mode -def decode_claims(token_str: str, jwks: str, client_id: str, issuer: str): - claims = jwt.decode( - token_str, - jwks, - claims_options={ - "iss": {"essential": True, "value": issuer}, - "aud": {"essential": True, "value": client_id}, - "azp": {"essential": True, "value": client_id}, - }, - ) - claims.validate() + +def decode_claims(token_str, jwks, client_id, issuer): + """Decodes and validates a JWT using joserfc. + + Args: + token_str: The raw encoded JWT string. + jwks: The KeySet object returned by _get_jwks_keys. + client_id: The expected audience (aud) and authorized party (azp). + issuer: The expected issuer (iss) URI of the token. + + Returns: + The validated claims object. + + Raises: + ValueError: If the issuer, audience, or azp claims do not match + the expected values. + """ + token = jwt.decode(token_str, jwks) + + # standard joserfc validation (checks exp, nbf, etc.) + claims = token.claims + registry = jwt.JWTClaimsRegistry() + registry.validate(claims) + + if claims.get("iss") != issuer: + raise InvalidTokenError("Invalid issuer") + if claims.get("aud") != client_id: + raise InvalidTokenError("Invalid audience") + if claims.get("azp") and claims.get("azp") != client_id: + raise InvalidTokenError("Invalid authorized party (azp)") + return claims ### Factory @@ -241,11 +266,10 @@ class BaseAuthAdapter: ERROR_MAP = { TokenExtractionError: ("Invalid token or header", 401), - DecodeError: ("Token decoding failed", 401), + JoseError: ("Token decoding or signature verification failed", 401), # <- New + InvalidTokenError: ("Token validation failed", 401), # <- Now your custom error InvalidClientError: ("OIDC client authentication failed", 401), - InvalidTokenError: ("Token validation failed", 401), InvalidGrantError: ("Invalid or expired refresh token", 401), - BadSignatureError: ("Token signature verification failed", 401), OAuthError: ("Authorization failed", 401), OAuth2Error: ("An OAuth2 error occurred", 401), KeyError: ("Invalid token structure", 401), @@ -358,14 +382,12 @@ def _get_jwks_keys(self): if not jwks_uri: raise ValueError("OIDC provider metadata missing 'jwks_uri'") - jwks_uri = metadata.get("jwks_uri") - if not jwks_uri: - raise ValueError("OIDC provider metadata does not contain 'jwks_uri'") - response = requests.get(jwks_uri, timeout=10) response.raise_for_status() - self._cached_jwks = JsonWebKey.import_key_set(response.json()) - + + # joserfc uses KeySet.import_key_set + self._cached_jwks = KeySet.import_key_set(response.json()) + return self._cached_jwks def _decode_and_validate_token(self, token_str: str): @@ -507,22 +529,20 @@ async def _get_jwks_keys(self): httpx.HTTPStatusError: If the network request to the `jwks_uri` fails. """ if hasattr(self, '_cached_jwks'): - return self._cached_jwks + return self._cached_jwks provider = getattr(self.oauth, self.DEFAULT_PROVIDER_NAME) - # FastAPI requires await here metadata = await provider.load_server_metadata() - + jwks_uri = metadata.get("jwks_uri") if not jwks_uri: raise ValueError("OIDC provider metadata missing 'jwks_uri'") - - # Non-blocking HTTP request + async with httpx.AsyncClient() as client: response = await client.get(jwks_uri, timeout=10) response.raise_for_status() - self._cached_jwks = JsonWebKey.import_key_set(response.json()) + self._cached_jwks = KeySet.import_key_set(response.json()) return self._cached_jwks async def _decode_and_validate_token(self, token_str: str): diff --git a/tests/test_auth.py b/tests/test_auth.py index 572d679..e832271 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,9 +1,16 @@ """Unit tests for auth.py helpers.""" import pytest +from joserfc import jwt +from joserfc.jwk import KeySet, RSAKey from dataone.auth import ( - AuthFactory, extract_orcid, extract_token_from_header, MissingParameterError, - TokenExtractionError, decode_claims + AuthFactory, + InvalidTokenError, + MissingParameterError, + TokenExtractionError, + decode_claims, + extract_orcid, + extract_token_from_header, ) @@ -50,7 +57,8 @@ def test_extract_token_missing_header(): def test_extract_token_invalid_format(): """Test error when 'Bearer ' prefix is missing.""" - with pytest.raises(TokenExtractionError, match="Invalid Authorization header format"): + with pytest.raises(TokenExtractionError, + match="Invalid Authorization header format"): extract_token_from_header("Token abc.def.ghi") def test_extract_token_empty_after_prefix(): @@ -66,20 +74,24 @@ def test_extract_token_malformed_jwt(): def test_extract_token_too_long(): """Test DoS protection for oversized tokens.""" long_token = "a.b." + ("c" * 20000) # Exceeds default 16,384 - with pytest.raises(TokenExtractionError, match="Token exceeds maximum allowed length"): + with pytest.raises(TokenExtractionError, + match="Token exceeds maximum allowed length"): extract_token_from_header(f"Bearer {long_token}") - -import pytest -from authlib.jose import jwt, JsonWebKey - def test_decode_claims_success(): - # generate a simple RSA key for testing - key = JsonWebKey.generate_key('RSA', 2048, is_private=True) - public_jwks = JsonWebKey.import_key_set([key.as_dict(is_private=False)]) + # generate rsa key + raw_key = RSAKey.generate_key(2048) + + # export to dict and strictly set a string 'kid' + private_jwk = raw_key.as_dict(is_private=True) + private_jwk['kid'] = 'test-key-id-1' + + # re-import the key so it officially has the kid, and create the public JWKS + key = RSAKey.import_key(private_jwk) + public_jwk = KeySet.import_key_set({"keys": [key.as_dict(is_private=False)]}) # setup mock claims/headers - header = {'alg': 'RS256', 'kid': key.as_dict().get('kid')} + header = {'alg': 'RS256', 'kid': 'test-key-id-1'} payload = { "iss": "https://auth.example.com", "aud": "my_client_id", @@ -89,12 +101,12 @@ def test_decode_claims_success(): } # create a signed token - token = jwt.encode(header, payload, key).decode('utf-8') + token = jwt.encode(header, payload, key) # test result = decode_claims( token_str=token, - jwks=public_jwks, + jwks=public_jwk, client_id="my_client_id", issuer="https://auth.example.com" ) @@ -102,23 +114,28 @@ def test_decode_claims_success(): assert result['sub'] == "12345" assert result['iss'] == "https://auth.example.com" + def test_decode_claims_invalid_issuer(): - key = JsonWebKey.generate_key('RSA', 2048, is_private=True) - public_jwks = JsonWebKey.import_key_set([key.as_dict(is_private=False)]) + raw_key = RSAKey.generate_key(2048) + + private_jwk = raw_key.as_dict(is_private=True) + private_jwk['kid'] = 'test-key-id-2' + + key = RSAKey.import_key(private_jwk) + public_jwk = KeySet.import_key_set({"keys": [key.as_dict(is_private=False)]}) # token has 'wrong-issuer' + header = {'alg': 'RS256', 'kid': 'test-key-id-2'} payload = { "iss": "wrong-issuer", "aud": "my_client_id", "azp": "my_client_id" } - token = jwt.encode({'alg': 'RS256'}, payload, key).decode('utf-8') - - # this should raise an error because the 'value' doesn't match the claims_options - from authlib.jose.errors import InvalidClaimError - with pytest.raises(InvalidClaimError): - decode_claims(token, public_jwks, "my_client_id", "https://auth.example.com") + token = jwt.encode(header, payload, key) + # should raise InvalidTokenError + with pytest.raises(InvalidTokenError, match="Invalid issuer"): + decode_claims(token, public_jwk, "my_client_id", "https://auth.example.com") MOCK_SECRETS = { "client_id": "test client", "client_secret": "a string", From a1a7817932c6667f43a5c86a8d501319fc97fe8f Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 15 May 2026 13:40:20 -0700 Subject: [PATCH 37/42] update deps --- pyproject.toml | 1 + uv.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d81b525..562042a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "authlib>=1.7.2", "flask>=3.1.3", "httpx>=0.28.1", + "joserfc>=1.6.5", "requests>=2.33.1", "werkzeug>=3.1.8", ] diff --git a/uv.lock b/uv.lock index 6bd822e..fe2adfe 100644 --- a/uv.lock +++ b/uv.lock @@ -247,6 +247,7 @@ dependencies = [ { name = "authlib" }, { name = "flask" }, { name = "httpx" }, + { name = "joserfc" }, { name = "requests" }, { name = "werkzeug" }, ] @@ -279,6 +280,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", marker = "extra == 'fastapi'", specifier = ">=0.28.1" }, { name = "httpx", marker = "extra == 'starlette'", specifier = ">=0.28.1" }, + { name = "joserfc", specifier = ">=1.6.5" }, { name = "requests", specifier = ">=2.33.1" }, { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=1.0.0" }, { name = "werkzeug", specifier = ">=3.1.8" }, From dfe6465d287b4350bc39b8199f102ae944c63f68 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 15 May 2026 14:22:31 -0700 Subject: [PATCH 38/42] update docs --- README.md | 110 +++++++++++++- docs/app-code.md | 372 ----------------------------------------------- 2 files changed, 105 insertions(+), 377 deletions(-) delete mode 100644 docs/app-code.md diff --git a/README.md b/README.md index e00991d..6c17ef9 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - Contact us: support@dataone.org - [DataONE discussions](https://github.com/DataONEorg/dataone/discussions) -*Product overview goes here.* Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. +The `dataone-auth` module provides a framework-agnostic OpenID Connect (OIDC) authentication adapter for Python applications. Using `joserfc` and `Authlib`, its core purpose is to handle JSON Web Token (JWT) validation so that DataONE Python applications can seamlessly integrate OIDC authentication, regardless of what framework that application uses. Currently, `dataone-auth` supports both Flask and FastAPI. To integrate `dataone-auth` into an existing app, the application only needs to create an auth client (`create_client`), use the `login`, `authorize`, `refresh` methods on the corresponding endpoints, and utilize the `require_scope` helper either as a decorator or `Depends` function to protect secure endpoints. For more detail on usage, see the examples below. DataONE creates open source, community projects. We [welcome contributions](./CONTRIBUTING.md) in many forms, including code, graphics, documentation, bug reports, testing, etc. Use the [DataONE discussions](https://github.com/DataONEorg/dataone/discussions) to discuss these contributions with us. @@ -37,15 +37,115 @@ To run the code formatter and linter, use Ruff: - `uv run ruff check .` -## Usage Example +## Usage Examples -To view more details about the Public API - see interface documentation +### Flask + +Below is a minimal example for a Flask application. For the Flask implementation, applying `ProxyFix` is recommended to ensure correct redirect URIs when the app is running behind a reverse proxy or load balancer. Following standard Flask extension patterns, the `auth_client` must be explicitly bound to the application using `init_app()`. Once initialized, protect any endpoint by stacking the `@auth_client.require_scope(...)` decorator below the route definition. This automatically intercepts the Bearer token, validates the OIDC claims against the provider's JWKS, and injects the resulting claims dictionary into the view function. ```python -import dataone.auth +import json +import os +from flask import Flask, jsonify, request, url_for +from werkzeug.middleware.proxy_fix import ProxyFix +from dataone.auth import AuthFactory, load_client_secrets + +# --- Constants & Logging --- +ACCESS_MODE_AUTHENTICATED = "authenticated" +scopes = ["ogdc:admin"] + +# --- App Initialization --- +app = Flask(__name__) +app.config.update({"SECRET_KEY": os.getenv("FLASK_SECRET_KEY", os.urandom(32).hex())}) + +if not isinstance(app.wsgi_app, ProxyFix): + app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) + +# --- Auth Setup --- +secrets = load_client_secrets() +auth_client = AuthFactory.create_client("flask", secrets, scopes) +auth_client.init_app(app) +app.extensions['dataone_auth'] = auth_client + +# --- Routes --- +@app.route("/login") +def login(): + return auth_client.login(redirect_uri=url_for("authorize", _external=True)) + +@app.route("/authorize") +def authorize(): + return auth_client.authorize() + +@app.route("/refresh", methods=["POST"]) +def refresh_token(): + return auth_client.refresh(request_json=request.get_json(silent=True)) + +@app.route("/profile", methods=["GET"]) +@auth_client.require_scope("ogdc:admin") +def profile(claims): + """Protected resource endpoint requiring 'ogdc:admin' scope.""" + return jsonify({ + "message": f"Authorization succeeded, {claims.get('name', 'User')}", + "claims": claims # The claims object is already a dictionary! + }), 200 + +# --- Execution --- +if __name__ == "__main__": + app.run(host="0.0.0.0", port=int("4000"), debug=True) +``` -# Example code here... +### FastAPI +Below is a minimal example for a FastAPI application. Unlike Flask, FastAPI doesn't require an `init_app` step; the `auth_client` is ready to use immediately upon creation. Note that `SessionMiddleware` must be added to the app to handle the OIDC state and nonce during the browser-based login and authorization flow. For the API endpoints, the heavy lifting happens within the `Depends(auth_client.require_scope(...))` dependency, which automatically intercepts the Bearer token, validates the OIDC claims against the provider's JWKS, and injects the ready-to-use claims dictionary into the route handler. + +```python +import os +from fastapi import Depends, FastAPI, Request +from fastapi.security import HTTPBearer +from starlette.middleware.sessions import SessionMiddleware +from dataone.auth import AuthFactory, load_client_secrets + +# --- Constants & Logging --- +ACCESS_MODE_AUTHENTICATED = "authenticated" +scopes = ["ogdc:admin"] + +# --- App Initialization --- +app = FastAPI(title="DataONE OIDC API") +app.add_middleware( + SessionMiddleware, + secret_key=os.getenv("SECRET_KEY", os.urandom(32).hex()) +) + +# --- Auth Setup --- +secrets = load_client_secrets() +auth_client = AuthFactory.create_client("fastapi", secrets, scopes) + +security = HTTPBearer() +# --- Routes --- +@app.get("/login") +async def login(request: Request): + return await auth_client.login(request, redirect_uri=str(request.url_for("authorize"))) + +@app.get("/authorize") +async def authorize(request: Request): + return await auth_client.authorize(request) + +@app.post("/refresh") +async def refresh(request: Request): + return await auth_client.refresh(await request.json()) + +@app.get("/profile") +async def profile(claims: dict = Depends(auth_client.require_scope("ogdc:admin"))): + """Protected resource endpoint.""" + return { + "message": f"Authorization succeeded, {claims.get('name', 'User')}", + "claims": claims + } + +# --- Execution --- +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=4000, proxy_headers=True, forwarded_allow_ips="*") ``` ## License diff --git a/docs/app-code.md b/docs/app-code.md deleted file mode 100644 index badcb58..0000000 --- a/docs/app-code.md +++ /dev/null @@ -1,372 +0,0 @@ -# Changes to application code - -## Flask - -### Initialize client - - -``` -from werkzeug.middleware.proxy_fix import ProxyFix -from flask import current_app, g -# Assuming the user imports your factory -from dataone.factory import AuthFactory -from dataone.utils import load_client_secrets - -def init_oauth(app) -> bool: - """Initialise the OAuth client and register the OIDC provider.""" - - mode = get_access_mode() - if mode != ACCESS_MODE_AUTHENTICATED: - logger.warning("Access mode '%s': skipping OAuth initialisation.", mode) - return True - - try: - vb_secrets = load_client_secrets() - except (FileNotFoundError, json.JSONDecodeError) as exc: - logger.warning("Could not load client secrets (%s). Auth unavailable.", exc) - return False - - if not isinstance(app.wsgi_app, ProxyFix): - app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) - - vb_scopes = [SCOPE_ADMIN, SCOPE_CONTRIBUTOR, SCOPE_USER] - - auth_client = AuthFactory.create_client("flask", vb_secrets, vb_scopes) - - auth_client.init_app(app) - - # attach to app context so Flask routes can access it later - app.extensions['dataone_auth'] = auth_client - - logger.info("OAuth client initialised.") - return True -``` - -### Protect endpoints with decorator - -``` -def require_token(methods=None, required_scope=None): - def decorator(f): - @functools.wraps(f) - def decorated(*args, **kwargs): - mode = get_access_mode() - if mode != ACCESS_MODE_AUTHENTICATED: - return f(None, *args, **kwargs) - - if methods is not None and request.method not in methods: - return f(None, *args, **kwargs) - - # Framework specific: Extract the token - auth_header = request.headers.get("Authorization", "") - if auth_header.startswith("Bearer "): - token = auth_header[7:] - - # caps the token length to prevent huge tokens from causing DoS issues in downstream processing. - if len(token) > MAX_TOKEN_LEN: - return None # triggers 401 - - adapter = current_app.extensions['dataone_auth'] - - try: - claims = adapter.validate_and_extract_claims(token_str, required_scope) - except InsufficientScopeError as e: - # Framework specific: Return 403 Forbidden - return jsonify({"error": str(e)}), 403 - except Exception as e: - # Framework specific: Return 401 Unauthorized - return jsonify({"error": f"Invalid token: {str(e)}"}), 401 - - g.token_claims = claims - return f(claims, *args, **kwargs) - - return decorated - return decorator - -``` - -``` -def require_scope(required_scope: str, methods=None): - def decorator(f): - @functools.wraps(f) - def decorated(*args, **kwargs): - mode = get_access_mode() - - # In read_only or open mode, skip auth entirely - if mode != ACCESS_MODE_AUTHENTICATED: - logger.warning("Access mode '%s': skipping scope validation", mode) - # Store None in g for consistency - g.token_claims = None - return f(*args, **kwargs) - - # If methods are specified, only enforce auth for those methods - if methods is not None and request.method not in methods: - # No auth required for this method; store None as claims - g.token_claims = None - return f(*args, **kwargs) - - adapter = current_app.extensions['dataone_auth'] - - claims, error = adapter.validate_and_extract_claims(required_scope=required_scope) - if error: - return error - - g.token_claims = claims - # Pass claims as keyword argument for explicit access in handlers - kwargs['claims'] = claims - return f(*args, **kwargs) - - return decorated - - return decorator -``` - -### API Endpoints - -**login** - -``` -@auth_bp.route("/login", methods=["GET"]) -def login(): - """Initiate the OIDC login flow. - - Sends the user to the provider's login page. After successful - authentication the provider redirects back to the ``/authorize`` - callback. - - Args: - (None) - - Returns: - 302 redirect to the provider's authorization endpoint. - 401/500 JSON error response if login fails. - 403 JSON response if authentication is disabled for the current access mode. - - """ - mode = get_access_mode() - if mode != ACCESS_MODE_AUTHENTICATED: - return _auth_error_response(f"Authentication is disabled in '{mode}' mode.", 403) - - adapter = current_app.extensions['dataone_auth'] - oidc_client = adapter.vegbank_oidc # maybe get this dynamically - - try: - return adapter.authorize_redirect(url_for("main.auth.authorize", _external=True)) - except (OAuthError, RequestException) as exc: - logger.warning("OIDC authorize_redirect error: %s", exc) - return _token_error_response(exc) - -``` - -**refresh** - -``` -@auth_bp.route("/refresh", methods=["POST"]) -def refresh_token(): - """Re-validate the user session and return a new access token using the refresh token. - - When an access token expires, the client can call this endpoint with the refresh token - to obtain a new access token without requiring the user to log in again. The client - can also pass the desired scopes for the new access token, which must be a subset - of the original scopes granted to the refresh token. - - Parameters (in JSON body): - - ``refresh_token`` (string, required): The refresh token issued by the OIDC provider. - - ``scope`` (string, optional): Space-separated list of scopes to request for the new access token. If omitted, the new access token will have the same scopes as the original token. - - Returns: - 200 JSON with new ``access_token`` and ``refresh_token`` on success. - 400 JSON if the request is missing required parameters. - 401 JSON if the refresh token is invalid, expired, or if client authentication fails. - 500 JSON for unexpected server errors. - """ - - adapter = current_app.extensions.get('dataone_auth') - - # Get the refresh token and desired scopes from the JSON body - data = request.get_json(silent=True) - if not data: - return _token_error_response(MissingParameterError("refresh_token")) - - user_refresh_token = data.get("refresh_token") - if not user_refresh_token: - return _token_error_response(MissingParameterError("refresh_token")) - - # The client should pass the scopes that it would like to request for the - # new access token. If no scopes are provided, we will attempt to get a - # new access token with the same scopes as the original token. The - # requested scopes must match or be a subset of the original scopes granted - # to the token, otherwise the OIDC provider will reject the request. - requested_scope = data.get("scope") - - # Use Authlib to exchange the refresh token for a new access token - try: - oidc_client = adapter.vegbank_oidc # maybe get this dynamically - if not requested_scope: - # If no scope is provided, omit the scope parameter to get the same scopes as the original token - new_tokens = oidc_client.fetch_access_token( - grant_type="refresh_token", - refresh_token=user_refresh_token, - ) - else: - new_tokens = oidc_client.fetch_access_token( - grant_type="refresh_token", - refresh_token=user_refresh_token, - scope=requested_scope, - ) - return _token_response(new_tokens, message="Authorization successful") - except InvalidGrantError as exc: - # The refresh token was invalid, expired, or revoked by the provider - logger.debug("The refresh token is invalid or expired: %s", exc) - return _token_error_response(exc) - except InvalidClientError as exc: - # The client_id or client_secret is wrong - logger.warning("OIDC client authentication failed: %s", exc) - return _token_error_response(exc) - except OAuth2Error as exc: - logger.debug("An OAuth2 error occurred: %s", exc) - return _token_error_response(exc) - except Exception as exc: - # A safety net for non-OAuth errors (e.g., network issues) - logger.error("Unexpected Exception during refresh: %s", exc, exc_info=True) - return _token_error_response(exc) - -``` - -**authorize** - -``` - -@auth_bp.route("/authorize", methods=["GET"]) -def authorize(): - """OIDC authorization callback endpoint. - - Keycloak redirects here after a successful login with a short-lived - authorization code. This endpoint exchanges that code for an access - token, stores the token and returns it to the caller. - - Returns: - 200 JSON with ``token`` on success. - 401 JSON with error details on failure. - 403 JSON response if authentication is disabled for the current access mode. - """ - mode = get_access_mode() - if mode != ACCESS_MODE_AUTHENTICATED: - return _auth_error_response(f"Authentication is disabled in '{mode}' mode.", 403) - - adapter = current_app.extensions.get('dataone_auth') - oidc_client = adapter.vegbank_oidc - - try: - token = oidc_client.authorize_access_token() - except (OAuthError, RequestException) as exc: - logger.debug("OIDC token exchange error: %s", exc) - return _token_error_response(exc) - - return _token_response(token, message="Authorization successful") - -``` - -### Response/Error Classes - -``` -def _auth_error_response(message, status, details=None): - """Generate a uniform JSON error response for authentication/authorization errors. - - All auth-related error responses should use this helper to guarantee a consistent ``{"error": {"message": ..., "details": ...}}`` object. - - Args: - message: Error description. - status: HTTP status code. - details: Optional additional context (``str(exc)``). Omitted from the response when *None*. - - Returns: - Tuple of (JSON response, status code). - """ - error = {"message": message} - if details is not None: - error["details"] = details - return jsonify({"error": error}), status - - -def _token_error_response(exc): - """Produce a uniform JSON error response for token validation/exchange failures.""" - error_map = { - DecodeError: ("Token decoding failed", 401), - InvalidClientError: ("OIDC client authentication failed", 401), - InvalidTokenError: ("Token validation failed", 401), - InvalidGrantError: ("Invalid or expired refresh token", 401), - BadSignatureError: ("Token signature verification failed", 401), - OAuthError: ("Authorization failed", 401), - OAuth2Error: ("An OAuth2 error occurred", 401), - KeyError: ("Invalid token structure", 401), - TypeError: ("Invalid token structure", 401), - MissingParameterError: ("Missing required parameter", 400), - ValueError: ("OIDC provider configuration error", 500), - _requests.RequestException: ("Failed to fetch OIDC provider keys", 502), - } - for exc_types, (message, status) in error_map.items(): - if isinstance(exc, exc_types): - return _auth_error_response(message, status, details=str(exc)) - # Unexpected exception — treat as server error - return _auth_error_response("Internal authentication error", 500, details=str(exc)) - - -def _token_response(token: dict, message: str = "Token exchange successful"): - """Produce a uniform JSON response with access and refresh tokens. - - Args: - token: Dict containing token data with 'access_token' and 'refresh_token' keys. - message: Optional message to include in response. - - Returns: - Tuple of (JSON response, 200 status code). - """ - return ( - jsonify( - { - "message": message, - "token": { - "access_token": token.get("access_token"), - "refresh_token": token.get("refresh_token"), - }, - } - ), - 200, - ) - -``` - -## FastAPI - -``` -from fastapi import FastAPI -from dataone.factory import AuthFactory -from dataone.auth import load_client_secrets -import logging - -logger = logging.getLogger(__name__) - -# 1. Create the FastAPI instance -app = FastAPI() - -def init_auth_client(): - """Initialise the DataOne Auth Client for FastAPI.""" - - try: - ogdc_secrets = load_client_secrets() - except Exception as exc: - logger.warning("Auth unavailable: %s", exc) - return None - - # Define ogdc-specific scopes if they differ, or use defaults - ogdc_scopes = ["ogdc:admin", "ogdc:user"] - - # This might return an 'httpx' based Async client instead of a 'requests' one - auth_client = AuthFactory.create_client("fastapi", ogdc_secrets, ogdc_scopes) - - return auth_client - -# 3. Store it in the app state for easy access -app.state.auth = init_auth_client() - -``` \ No newline at end of file From c895b06dff2bcad0be4f0da71a8616cffd1a06e0 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 15 May 2026 15:07:52 -0700 Subject: [PATCH 39/42] add require_token decorator/depends --- src/dataone/auth.py | 130 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 125 insertions(+), 5 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index b0e6730..f321a88 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -423,12 +423,14 @@ def validate_and_extract_claims(self, token_str: str, required_scope: str = None The validated claims dict. Raises: - Exception: JoseError from Authlib if token is invalid/expired. + JoseError: If the token is invalid, expired, or has an incorrect + issuer/audience. InsufficientScopeError: If the token lacks the required scope. """ claims = self._decode_and_validate_token(token_str) - self._verify_scope(claims, required_scope) + if required_scope: + self._verify_scope(claims, required_scope) return claims @@ -448,6 +450,10 @@ def require_scope(self, required_scope: str): """This is implemented by subclasses.""" raise NotImplementedError + def require_token(self): + """This is implemented by subclasses.""" + raise NotImplementedError + def __getattr__(self, name): """ Delegate all unknown attribute/method lookups to the underlying Authlib OAut @@ -575,7 +581,8 @@ async def validate_and_extract_claims(self, """ claims = await self._decode_and_validate_token(token_str) - self._verify_scope(claims, required_scope) + if required_scope: + self._verify_scope(claims, required_scope) return claims @@ -682,6 +689,9 @@ def require_scope(self, required_scope: str): Args: required_scope: The specific OAuth scope required to access the route (e.g., "read:data" or "write:admin"). + methods: Optional list of HTTP method names (e.g., ['POST', 'PUT']) to + protect. If None, all methods are protected. If the current request + method is not in this list, authentication is bypassed. Returns: An asynchronous callable dependency that returns validated token claims. @@ -724,6 +734,63 @@ async def dependency(request: Request): ) return dependency + def require_token(self, methods=None): + """Creates a FastAPI dependency to enforce token requirements on routes. + + This method returns an async function designed to be injected into FastAPI + endpoints using `Depends()`. It extracts the token, validates it, and returns + the claims. If the adapter's access mode is not set to 'authenticated' (e.g., + 'read_only'), validation is bypassed. + + Args: + methods: Optional list of HTTP method names (e.g., ['POST', 'PUT']) to + protect. If None, all methods are protected. If the current request + method is not in this list, authentication is bypassed. + + Returns: + An asynchronous callable dependency that returns validated token claims. + + Raises: + fastapi.HTTPException: If token validation fails. The internal exception + is translated into a standard FastAPI HTTP error + using the adapter's error handler. + + Example: + from fastapi import Depends + + @app.get("/secure-data") + async def get_secure_data( + claims: dict = Depends(auth_adapter.require_token(methods=["POST"])) + ): + return {"message": "Access granted", "user": claims.get("sub")} + """ + from fastapi import Request + async def dependency(request: Request): + from fastapi import HTTPException + + from .auth import extract_token_from_header + # Handle 'read_only' logic + if self.access_mode != "authenticated": + return None + + if methods is not None and request.method not in methods: + return None + + try: + auth_header = request.headers.get("Authorization") + token = extract_token_from_header(auth_header) + # This call is async in FastAPI + claims = await self.validate_and_extract_claims(token) + return claims + except Exception as e: + # In FastAPI, we RAISE the error handler's result + error_res = self._error_handler(e) + raise HTTPException( + status_code=error_res.status_code, + detail=json.loads(error_res.body.decode())["error"] + ) + return dependency + class FlaskAuthAdapter(BaseAuthAdapter): def _initialize_oauth(self): """Initializes the Flask-based OAuth registry. @@ -852,7 +919,7 @@ def refresh_route(): except Exception as e: return self._error_handler(e) - def require_scope(self, required_scope: str): + def require_scope(self, required_scope: str, methods: None): """Creates a Flask decorator to enforce scope requirements on routes. This method returns a decorator that extracts the Bearer token from the @@ -864,6 +931,9 @@ def require_scope(self, required_scope: str): Args: required_scope: The specific OAuth scope required to access the route (e.g., "read:data"). + methods: Optional list of HTTP method names (e.g., ['POST', 'PUT']) to + protect. If None, all methods are protected. If the current request + method is not in this list, authentication is bypassed. Returns: A decorator function that wraps a Flask route handler. @@ -877,9 +947,12 @@ def get_secure_data(claims): def decorator(f): @functools.wraps(f) def decorated(*args, **kwargs): - # Handle the 'read_only' logic inside the adapter + from flask import request if self.access_mode != "authenticated": return f(None, *args, **kwargs) + + if methods is not None and request.method not in methods: + return f(None, *args, **kwargs) try: from flask import request @@ -893,3 +966,50 @@ def decorated(*args, **kwargs): return decorated return decorator + def require_token(self, methods=None): + """Creates a Flask decorator to enforce token authentication on routes. + + This method returns a decorator that extracts the Bearer token from the + 'Authorization' header, validates it, and injects the resulting claims + into the decorated function as the first argument. If the adapter is in + 'read_only' or 'open' mode, validation is bypassed and 'None' is passed + for the claims. + + Args: + methods: Optional list of HTTP method names (e.g., ['POST', 'PUT']) to + protect. If None, all methods are protected. If the current request + method is not in this list, authentication is bypassed. + + Returns: + A decorator function that wraps a Flask route handler. + + Example: + @app.route("/any-authenticated-user", methods=["GET", "POST"]) + @auth_adapter.require_token(methods=["POST"]) + def handle_data(claims): + user_id = claims.get("sub") if claims else "Anonymous" + return {"message": "Success", "user": user_id} + """ + def decorator(f): + @functools.wraps(f) + def decorated(*args, **kwargs): + from flask import request + mode = self.get_access_mode() + if mode != "authenticated": + return f(None, *args, **kwargs) + + # filter http methods + if methods is not None and request.method not in methods: + return f(None, *args, **kwargs) + + try: + from flask import request + token = extract_token_from_header( + request.headers.get("Authorization")) + claims = self.validate_and_extract_claims(token) + # Pass claims into the route + return f(claims, *args, **kwargs) + except Exception as e: + return self._error_handler(e) + return decorated + return decorator From ba8026e63f12510cf1c7878c92eaf9cf3777b1f5 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 15 May 2026 15:16:13 -0700 Subject: [PATCH 40/42] fix up methods in fn sigs --- src/dataone/auth.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index f321a88..cf9b602 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -446,11 +446,11 @@ def refresh(self, request_json: dict): """This is implemented by subclasses.""" raise NotImplementedError - def require_scope(self, required_scope: str): + def require_scope(self, required_scope: str, methods=None): """This is implemented by subclasses.""" raise NotImplementedError - def require_token(self): + def require_token(self, methods=None): """This is implemented by subclasses.""" raise NotImplementedError @@ -678,7 +678,7 @@ async def refresh(request: Request): except Exception as e: return self._error_handler(e) - def require_scope(self, required_scope: str): + def require_scope(self, required_scope: str, methods=None): """Creates a FastAPI dependency to enforce scope requirements on routes. This method returns an async function designed to be injected into FastAPI From bf2bd54a4ce45519402830bea234afd70defd444 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 15 May 2026 15:18:17 -0700 Subject: [PATCH 41/42] make access mode call consistent with the rest --- src/dataone/auth.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/dataone/auth.py b/src/dataone/auth.py index cf9b602..c9d83c9 100644 --- a/src/dataone/auth.py +++ b/src/dataone/auth.py @@ -994,8 +994,7 @@ def decorator(f): @functools.wraps(f) def decorated(*args, **kwargs): from flask import request - mode = self.get_access_mode() - if mode != "authenticated": + if self.access_mode != "authenticated": return f(None, *args, **kwargs) # filter http methods From 37f7c9f37d5108c05b60c29a7042b1cc24171b62 Mon Sep 17 00:00:00 2001 From: Jeanette Clark Date: Fri, 15 May 2026 15:23:38 -0700 Subject: [PATCH 42/42] add require_token to docs --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6c17ef9..9a37097 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ To run the code formatter and linter, use Ruff: ### Flask -Below is a minimal example for a Flask application. For the Flask implementation, applying `ProxyFix` is recommended to ensure correct redirect URIs when the app is running behind a reverse proxy or load balancer. Following standard Flask extension patterns, the `auth_client` must be explicitly bound to the application using `init_app()`. Once initialized, protect any endpoint by stacking the `@auth_client.require_scope(...)` decorator below the route definition. This automatically intercepts the Bearer token, validates the OIDC claims against the provider's JWKS, and injects the resulting claims dictionary into the view function. +Below is a minimal example for a Flask application. For the Flask implementation, applying `ProxyFix` is recommended to ensure correct redirect URIs when the app is running behind a reverse proxy or load balancer. Following standard Flask extension patterns, the `auth_client` must be explicitly bound to the application using `init_app()`. Once initialized, protect any endpoint by stacking the `@auth_client.require_scope(...)` decorator below the route definition. This automatically intercepts the Bearer token, validates the OIDC claims against the provider's JWKS, and injects the resulting claims dictionary into the view function. Note that routes can also use `@auth_client.require_token(...)` if checking scopes is not necessary. Optionally, HTTP methods can be passed to either decorator to specify auth requirements based on method if necessary. ```python import json @@ -96,7 +96,7 @@ if __name__ == "__main__": ### FastAPI -Below is a minimal example for a FastAPI application. Unlike Flask, FastAPI doesn't require an `init_app` step; the `auth_client` is ready to use immediately upon creation. Note that `SessionMiddleware` must be added to the app to handle the OIDC state and nonce during the browser-based login and authorization flow. For the API endpoints, the heavy lifting happens within the `Depends(auth_client.require_scope(...))` dependency, which automatically intercepts the Bearer token, validates the OIDC claims against the provider's JWKS, and injects the ready-to-use claims dictionary into the route handler. +Below is a minimal example for a FastAPI application. Unlike Flask, FastAPI doesn't require an `init_app` step; the `auth_client` is ready to use immediately upon creation. Note that `SessionMiddleware` must be added to the app to handle the OIDC state and nonce during the browser-based login and authorization flow. For the API endpoints, the heavy lifting happens within the `Depends(auth_client.require_scope(...))` dependency, which automatically intercepts the Bearer token, validates the OIDC claims against the provider's JWKS, and injects the ready-to-use claims dictionary into the route handler. Note that routes can also use `Depends(auth_client.require_token(...))` if checking scopes is not necessary. Optionally, HTTP methods can be passed to either `Depends` to specify auth requirements based on method if necessary. ```python import os