diff --git a/README.md b/README.md index 3289d3d..3308b93 100644 --- a/README.md +++ b/README.md @@ -199,19 +199,116 @@ ENV IRI_API_PARAMS='{ \ }' ``` -## Globus auth integration - -You can optionally use globus for authorization. Steps to use globus: -- ask someone to add your globus account to the IRI Resource Server -- log into globus and make a secret for yourself for the IRI Resource Server -- if you want to create tokens during developent, also create a separate globus app -- `cp local-template.env local.env` and fill in the missing values -- to mint a token, run `make globus`, click the link and copy the code from the browser url bar back into the terminal -- you can also run `make manage-globus` but be sure to not accidentally delete the `iri-api` scope. (Maybe it's better if you don't run this app) -- now you can run `make` for the dev server and enjoy using your globus iri access tokens (in the demo adapter they will all resolve to the user `gtorok`) -- for your facility: - - implement the `get_current_user_globus` method (see iri_adapter.py). Here you can look at the linked globus identities and session info to determine what the local username is - - make sure the values in `local.env` are available in the deployed app +## Authentication + +The IRI API supports three authentication paths, tried in order. The first path that +successfully identifies a user short-circuits the chain. If all three fail, a `401` is +returned with a combined error message from each failed attempt. + +``` +1. AmSC PingAM OIDC JWKS-offline JWT validation IRI_AUTH_AMSC=true + OIDC_* vars +2. Globus introspection token introspection call IRI_AUTH_GLOBUS=true + GLOBUS_RS_* vars +3. Facility API key adapter.get_current_user() always active +``` + +Both external IdP paths default to **off** and must be explicitly opted in. +Accepted truthy values: `true`, `1`, `on`, `yes`. +Accepted falsy values: `false`, `0`, `off`, `no`. + +### AmSC PingAM OIDC + +Validates inbound JWTs offline via the IdP's JWKS — no introspection round-trip. +Signing algorithms are derived from the discovery document's +`id_token_signing_alg_values_supported` field; `HS*` (HMAC) algorithms are always +rejected even if advertised. + +After the JWT is validated, if profile claims (`name`, `email`, etc.) are absent from +the token (common with PingAM, which issues minimal access tokens containing +only `sub`), the IRI API automatically calls the IdP's `userinfo_endpoint` with the +bearer token and merges the returned claims into `token_info`. This means +`get_current_user_oidc(api_key, client_ip, token_info)` in the adapter will always +receive a fully-enriched dict. IdPs that already embed profile claims in the token +(e.g. Keycloak) skip the extra call. The UserInfo fetch fails gracefully — if the +endpoint is unreachable the JWT claims are still passed through unchanged and +authentication succeeds. + +| Variable | Default | Required | Description | +|---|---|---|---| +| `IRI_AUTH_AMSC` | `false` | — | Enable this path. Must be `true` to activate. | +| `OIDC_DISCOVERY_URI` | _(none)_ | ✓ | Full URL to the `.well-known/openid-configuration` endpoint. | +| `OIDC_CLIENT_ID` | _(none)_ | ✓ | OIDC client ID. Used as the default expected audience. | +| `OIDC_REQUIRED_AUDIENCE` | _(value of `OIDC_CLIENT_ID`)_ | — | Override the expected `aud` claim. Set this when tokens are issued for a different client ID than the one used for discovery (e.g. Kong's service-account client vs. the user-facing app client). | +| `OIDC_REQUIRED_SCOPES` | _(none)_ | — | Space- or comma-separated scopes that must be present in the token. Also accepted as `OIDC_REQUIRED_SCOPE`. | +| `OIDC_DISCOVERY_TIMEOUT_SECONDS` | `10` | — | HTTP timeout (seconds) for discovery + JWKS requests. | +| `OIDC_DISCOVERY_CACHE_TTL_SECONDS` | `300` | — | Seconds to cache the JWKS keyset in memory before re-fetching. Cache hits/misses are logged at `INFO`. | + +Minimal example: +```bash +IRI_AUTH_AMSC=true +OIDC_DISCOVERY_URI=https://identity.dev.amsc.ornl.gov/am/oauth2/.well-known/openid-configuration +OIDC_CLIENT_ID=019de45f-94a0-77c8-918b-10f37667733d +``` + +### Globus token introspection + +Calls Globus Auth to introspect the bearer token. Enforces `active`, `exp`/`nbf`, +the required IRI scope, and a recent `session_info.authentications` entry. +Implement `get_current_user_globus(api_key, client_ip, globus_introspect)` in your +facility adapter to map the Globus identity to a local user ID. + +| Variable | Default | Required | Description | +|---|---|---|---| +| `IRI_AUTH_GLOBUS` | `false` | — | Enable this path. Must be `true` to activate. | +| `GLOBUS_RS_ID` | _(none)_ | ✓ | Globus resource-server client ID. | +| `GLOBUS_RS_SECRET` | _(none)_ | ✓ | Globus resource-server client secret. | +| `GLOBUS_RS_SCOPE_SUFFIX` | _(none)_ | ✓ | Appended to `https://auth.globus.org/scopes/{GLOBUS_RS_ID}/` to form the required scope. | + +Minimal example: +```bash +IRI_AUTH_GLOBUS=true +GLOBUS_RS_ID= +GLOBUS_RS_SECRET= +GLOBUS_RS_SCOPE_SUFFIX= +``` + +### Facility-specific API key + +Always active — no env flags. Delegates entirely to +`adapter.get_current_user(api_key, client_ip)`. If this path also raises, all three +failure messages are combined into the `401` detail. + +### Adapter methods called per path + +| Auth path | Adapter method called on success | +|---|---| +| AmSC PingAM OIDC | `get_current_user_oidc(api_key, client_ip, token_info)` | +| Globus introspection | `get_current_user_globus(api_key, client_ip, globus_introspect)` | +| Facility API key | `get_current_user(api_key, client_ip)` | + +After any path succeeds, `get_user(user_id, api_key, client_ip, token_info, globus_introspect)` +is called to load the full user object. `token_info` and `globus_introspect` are `None` +when the facility API key path won. + +### Example: both external IdPs enabled + +```bash +IRI_AUTH_AMSC=true +OIDC_DISCOVERY_URI=https://identity.dev.amsc.ornl.gov/am/oauth2/.well-known/openid-configuration +OIDC_CLIENT_ID=019de45f-94a0-77c8-918b-10f37667733d +OIDC_REQUIRED_AUDIENCE=019de45f-94a0-77c8-918b-10f37667733d # optional if same as CLIENT_ID + +IRI_AUTH_GLOBUS=true +GLOBUS_RS_ID=... +GLOBUS_RS_SECRET=... +GLOBUS_RS_SCOPE_SUFFIX=... +``` + +### Example: API key only (no external IdP) + +```bash +# Leave IRI_AUTH_AMSC and IRI_AUTH_GLOBUS unset (or set to false). +# Only facility-specific adapter.get_current_user() will be tried. +``` ## Next steps diff --git a/app/demo_adapter.py b/app/demo_adapter.py index 81fffbb..20bf760 100644 --- a/app/demo_adapter.py +++ b/app/demo_adapter.py @@ -652,24 +652,33 @@ async def get_current_user( raise HTTPException(status_code=401, detail="Invalid API key") return "gtorok" - async def get_current_user_globus( + async def get_current_user_oidc( self: "DemoAdapter", api_key: str, client_ip: str, - globus_introspect: dict | None, + token_info: dict | None, ) -> str: """ - Decode the api_key and return the authenticated user's id from information returned by introspecting a globus token. + Decode the api_key and return the authenticated user's id from information returned by an OIDC token. This method is not called directly, rather authorized endpoints "depend" on it. (https://fastapi.tiangolo.com/tutorial/dependencies/) """ return "gtorok" + async def get_current_user_globus( + self: "DemoAdapter", + api_key: str, + client_ip: str, + globus_introspect: dict | None, + ) -> str: + return "gtorok" + async def get_user( self: "DemoAdapter", user_id: str, api_key: str, client_ip: str | None, + token_info: dict | None, globus_introspect: dict | None, ) -> User: if user_id != self.user.id: diff --git a/app/routers/iri_router.py b/app/routers/iri_router.py index 0542193..451c11b 100644 --- a/app/routers/iri_router.py +++ b/app/routers/iri_router.py @@ -1,10 +1,16 @@ from abc import ABC, abstractmethod +import asyncio import os import logging import importlib +import threading import time from typing import Any import globus_sdk +import httpx +from authlib.jose import JsonWebKey, JsonWebToken, KeySet +from authlib.jose.errors import JoseError +from cachetools import TTLCache from fastapi import Body, Request, Depends, HTTPException, APIRouter from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials @@ -12,13 +18,174 @@ from ..types.user import User bearer_scheme = HTTPBearer() - - +_DISCOVERY_TIMEOUT_SECONDS = float(os.environ.get("OIDC_DISCOVERY_TIMEOUT_SECONDS", "10")) +_DISCOVERY_CACHE_TTL_SECONDS = float(os.environ.get("OIDC_DISCOVERY_CACHE_TTL_SECONDS", "300")) +_oidc_remote_cache_lock = threading.Lock() +_oidc_remote_cache: TTLCache[str, tuple[dict[str, Any], KeySet]] = TTLCache(maxsize=128, ttl=_DISCOVERY_CACHE_TTL_SECONDS) +_oidc_remote_stale_cache: dict[str, tuple[dict[str, Any], KeySet]] = {} + +# Globus introspection (kept alongside AmSC PingAM OIDC). Each external +# IdP path can be independently turned on/off with IRI_AUTH_AMSC / IRI_AUTH_GLOBUS. GLOBUS_RS_ID = os.environ.get("GLOBUS_RS_ID") GLOBUS_RS_SECRET = os.environ.get("GLOBUS_RS_SECRET") GLOBUS_RS_SCOPE_SUFFIX = os.environ.get("GLOBUS_RS_SCOPE_SUFFIX") +def _env_true(name: str, default: bool = False) -> bool: + """Boolean env var checker.""" + raw = os.environ.get(name) + if raw is None or raw == "": + return default + return raw.strip().lower() not in {"0", "false", "off", "no"} + + +def _amsc_oidc_enabled() -> bool: + """AmSC PingAM OIDC: on if IRI_AUTH_AMSC != off AND OIDC_DISCOVERY_URI/CLIENT_ID configured.""" + return _env_true("IRI_AUTH_AMSC", False) and _oidc_auth_config() is not None + + +def _globus_enabled() -> bool: + """Globus introspection: on if IRI_AUTH_GLOBUS != off AND GLOBUS_RS_ID/SECRET/SCOPE_SUFFIX configured.""" + return bool(_env_true("IRI_AUTH_GLOBUS", False) and GLOBUS_RS_ID + and GLOBUS_RS_SECRET and GLOBUS_RS_SCOPE_SUFFIX) + + +def _oidc_auth_config() -> dict[str, str] | None: + discovery_uri = os.environ.get("OIDC_DISCOVERY_URI") + client_id = os.environ.get("OIDC_CLIENT_ID") + + if not discovery_uri or not client_id: + return None + + required_scopes = tuple( + scope + for scope in ( + os.environ.get("OIDC_REQUIRED_SCOPES") + or os.environ.get("OIDC_REQUIRED_SCOPE") + or "" + ).replace(",", " ").split() + if scope + ) + + return { + "discovery_uri": discovery_uri, + "client_id": client_id, + "required_scopes": required_scopes, + "required_audience": os.environ.get("OIDC_REQUIRED_AUDIENCE") or client_id, + } + + +async def _fetch_oidc_remote_state(discovery_uri: str) -> tuple[dict[str, Any], KeySet]: + """Fetch the OIDC discovery.""" + async with httpx.AsyncClient(timeout=_DISCOVERY_TIMEOUT_SECONDS) as client: + metadata_resp = await client.get(discovery_uri, headers={"Accept": "application/json"}) + metadata_resp.raise_for_status() + metadata = metadata_resp.json() + jwks_uri = metadata.get("jwks_uri") + if not jwks_uri: + raise RuntimeError("OIDC discovery document is missing jwks_uri") + jwks_resp = await client.get(jwks_uri, headers={"Accept": "application/json"}) + jwks_resp.raise_for_status() + return metadata, JsonWebKey.import_key_set(jwks_resp.json()) + + +async def _load_oidc_remote_state(discovery_uri: str) -> tuple[dict[str, Any], KeySet]: + """TTL-cached wrapper around fetching oidc remote state. + On refresh failure we fall back to the last cached state so a transient + IdP outage doesn't take the whole IRI service down. + """ + _log = logging.getLogger(__name__) + cached: tuple[dict[str, Any], KeySet] | None = None + stale: tuple[dict[str, Any], KeySet] | None = None + with _oidc_remote_cache_lock: + cached = _oidc_remote_cache.get(discovery_uri) + stale = _oidc_remote_stale_cache.get(discovery_uri) + if cached: + _log.info("OIDC JWKS cache HIT for %s (TTL %.0fs)", discovery_uri, _DISCOVERY_CACHE_TTL_SECONDS) + return cached + + _log.info("OIDC JWKS cache MISS for %s — fetching discovery + JWKS", discovery_uri) + try: + metadata, key_set = await _fetch_oidc_remote_state(discovery_uri) + except Exception: + if stale: + logging.getLogger(__name__).warning( + "OIDC discovery refresh failed for %s; reusing cached metadata + JWKS", + discovery_uri, + exc_info=True, + ) + return stale + raise + + with _oidc_remote_cache_lock: + _oidc_remote_cache[discovery_uri] = (metadata, key_set) + _oidc_remote_stale_cache[discovery_uri] = (metadata, key_set) + _log.info("OIDC JWKS cache STORED for %s (TTL %.0fs)", discovery_uri, _DISCOVERY_CACHE_TTL_SECONDS) + return metadata, key_set + + +async def _decode_oidc_jwt(api_key: str, discovery_uri: str, required_audience: str) -> dict[str, Any]: + """Verify the JWT signature against the IdP's JWKS and enforce required claims.""" + metadata, key_set = await _load_oidc_remote_state(discovery_uri) + # Use algorithms from the discovery document; exclude HS* (HMAC) — a leaked + # HMAC secret can forge tokens, so only asymmetric algorithms are acceptable. + algs_advertised = metadata.get("id_token_signing_alg_values_supported") or [] + algorithms = [alg for alg in algs_advertised if not alg.startswith("HS")] + if not algorithms: + raise RuntimeError("OIDC discovery document advertises no asymmetric signing algorithms") + claims_options = { + "iss": {"essential": True, "value": metadata["issuer"]}, + "aud": {"essential": True, "value": required_audience}, + "exp": {"essential": True}, + "nbf": {"essential": True}, + "iat": {"essential": True}, + } + def decode_and_validate() -> dict[str, Any]: + claims = JsonWebToken(algorithms).decode(api_key, key_set, claims_options=claims_options) + claims.validate() + return dict(claims) + + return await asyncio.to_thread(decode_and_validate) + + +async def _get_userinfo(bearer_token: str, discovery_uri: str, token_info: dict[str, Any]) -> dict[str, Any]: + """PingAM (and some other IdPs) issue access tokens that contain only sub. + Profile claims (name, email, given_name, ...) are available via the standard + UserInfo endpoint. + + Fails gracefully — if the UserInfo call fails for any reason the original + token_info is returned unchanged and auth still succeeds. + """ + _log = logging.getLogger(__name__) + + # Fast path: profile claims already present (e.g. Keycloak embeds them) + if token_info.get("name") or token_info.get("email"): + return token_info + + metadata, _ = await _load_oidc_remote_state(discovery_uri) + userinfo_endpoint = metadata.get("userinfo_endpoint") + if not userinfo_endpoint: + _log.warning("OIDC discovery document missing userinfo_endpoint; profile claims unavailable") + return token_info + + try: + async with httpx.AsyncClient(timeout=_DISCOVERY_TIMEOUT_SECONDS) as client: + resp = await client.get( + userinfo_endpoint, + headers={"Authorization": f"Bearer {bearer_token}", "Accept": "application/json"}, + ) + resp.raise_for_status() + userinfo = resp.json() + _log.info("OIDC UserInfo returned claims: %s", list(userinfo.keys())) + for key, value in userinfo.items(): + if key not in token_info: + token_info[key] = value + except Exception: + _log.warning("Failed to fetch OIDC UserInfo; proceeding without profile claims", exc_info=True) + + return token_info + + def get_client_ip(request: Request) -> str | None: forwarded_for = request.headers.get("X-Forwarded-For") if forwarded_for: @@ -82,43 +249,84 @@ def create_adapter(router_name, router_adapter): return AdapterClass() + async def get_oidc_token_info(self, api_key: str) -> dict[str, Any]: + """Validate a bearer JWT against the configured OIDC provider.""" + config = _oidc_auth_config() + if not config: + raise RuntimeError("OIDC auth is not configured") + + try: + token_info = await _decode_oidc_jwt( + api_key, + config["discovery_uri"], + config["required_audience"], + ) + except httpx.HTTPError as exc: + raise RuntimeError(f"OIDC discovery/JWKS request failed: {exc}") from exc + except JoseError as exc: + raise RuntimeError(f"OIDC JWT validation failed: {exc}") from exc + + logging.getLogger().info("PING OIDC JWT VALIDATION CLAIMS:") + logging.getLogger().info(token_info) + + # PingAM access tokens contain only sub; name/email/etc. come from the UserInfo endpoint. + token_info = await _get_userinfo(api_key, config["discovery_uri"], token_info) + + required_scopes = config["required_scopes"] + if required_scopes: + raw_scope = token_info.get("scope") + if isinstance(raw_scope, str): + token_scope = {s for s in raw_scope.split() if s} + elif isinstance(raw_scope, list): + token_scope = {str(s) for s in raw_scope if str(s)} + else: + token_scope = set() + missing_scopes = [s for s in required_scopes if s not in token_scope] + if missing_scopes: + raise Exception(f"Token missing required scopes: {', '.join(missing_scopes)}") + + return token_info + + async def get_globus_info(self, api_key: str) -> dict: - """Returns the linked identities and the session info objects""" - # Introspect the IRI API token using resource server credentials + """Returns the linked identities and the session info objects. + + Introspects the IRI API token against Globus Auth using the resource-server + client credentials. Enforces active/exp/nbf, the required IRI scope, and + a recent session_info.authentications presence (RFC §3F session freshness). + """ globus_client = globus_sdk.ConfidentialAppAuthClient(GLOBUS_RS_ID, GLOBUS_RS_SECRET) - # grab identity_set_detail for linked identities and session_info to see how the user logged in introspect = globus_client.oauth2_token_introspect(api_key, include="identity_set_detail,session_info") logging.getLogger().info("IRI TOKEN INTROSPECTION:") logging.getLogger().info(introspect) if not introspect.get("active"): raise Exception("Inactive token") - # Check exp (expiration time) claim exp = introspect.get("exp") if exp and time.time() >= exp: raise Exception("Token has expired") - # Check nbf (not before) claim nbf = introspect.get("nbf") if nbf and time.time() < nbf: raise Exception("Token not yet valid") - # Check if token has the required IRI scope token_scope = introspect.get("scope", "").split() - GLOBUS_SCOPE = f"https://auth.globus.org/scopes/{GLOBUS_RS_ID}/{GLOBUS_RS_SCOPE_SUFFIX}" - if GLOBUS_SCOPE not in token_scope: - raise Exception(f"Token missing required scope: {GLOBUS_SCOPE}") + required_scope = f"https://auth.globus.org/scopes/{GLOBUS_RS_ID}/{GLOBUS_RS_SCOPE_SUFFIX}" + if required_scope not in token_scope: + raise Exception(f"Token missing required scope: {required_scope}") session_info = introspect.get("session_info") - if not session_info: - raise Exception("No recent login was found in the token (missing session_info). " - "Please re-authenticate to obtain a valid session.") - + raise Exception( + "No recent login was found in the token (missing session_info). " + "Please re-authenticate to obtain a valid session." + ) authentications = session_info.get("authentications") if not authentications: - raise Exception("No recent login was found in the token (empty session_info.authentications). " - "Please re-authenticate to obtain a valid session.") + raise Exception( + "No recent login was found in the token (empty session_info.authentications). " + "Please re-authenticate to obtain a valid session." + ) return introspect @@ -128,25 +336,49 @@ async def current_user( request: Request, credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), ): + """Authenticate user by using the configured IdP chain. + Order: + 1. AmSC PingAM OIDC —> JWKS validation, controlled by IRI_AUTH_AMSC. + 2. Globus —> token introspection, controlled by IRI_AUTH_GLOBUS. + 3. Facility-specific —> adapter.get_current_user(). Always on + Breaks on the first successful auth mode. + """ token = credentials.credentials ip_address = get_client_ip(request) user_id = None + token_info = None globus_introspect = None exc_msg = "" - try: - if GLOBUS_RS_ID and GLOBUS_RS_SECRET and GLOBUS_RS_SCOPE_SUFFIX: - try: - globus_introspect = await self.get_globus_info(token) - user_id = await self.adapter.get_current_user_globus(token, ip_address, globus_introspect) - except Exception as globus_exc: - logging.getLogger().exception("Globus error:", exc_info=globus_exc) - exc_msg = f"Globus authentication failed: {str(globus_exc)}. || " - if not user_id: + + # 1. AmSC PingAM OIDC + if _amsc_oidc_enabled(): + try: + token_info = await self.get_oidc_token_info(token) + user_id = await self.adapter.get_current_user_oidc(token, ip_address, token_info) + except Exception as oidc_exc: + logging.getLogger().exception("AmSC OIDC auth error:", exc_info=oidc_exc) + exc_msg += f"AmSC OIDC authentication failed: {str(oidc_exc)}. || " + token_info = None + + # 2. Globus introspection + if not user_id and _globus_enabled(): + try: + globus_introspect = await self.get_globus_info(token) + user_id = await self.adapter.get_current_user_globus(token, ip_address, globus_introspect) + except Exception as globus_exc: + logging.getLogger().exception("Globus auth error:", exc_info=globus_exc) + exc_msg += f"Globus authentication failed: {str(globus_exc)}. || " + globus_introspect = None + + # 3. Facility-specific + if not user_id: + try: user_id = await self.adapter.get_current_user(token, ip_address) - except Exception as exc: - logging.getLogger().exception("Facility Specific auth failed: ", exc_info=exc) - exc_msg += f"Facility Specific authentication failed: {str(exc)}" - raise HTTPException(status_code=401, detail=exc_msg) from exc + except Exception as exc: + logging.getLogger().exception("Facility Specific auth failed:", exc_info=exc) + exc_msg += f"Facility Specific authentication failed: {str(exc)}" + raise HTTPException(status_code=401, detail=exc_msg) from exc + if not user_id: raise HTTPException(status_code=403, detail="Authentication succeeded but no user ID was identified. Contact Facility Admin.") @@ -154,6 +386,7 @@ async def current_user( user_id=user_id, api_key=token, client_ip=ip_address, + token_info=token_info, globus_introspect=globus_introspect, ) @@ -195,6 +428,15 @@ async def get_current_user(self: "AuthenticatedAdapter", api_key: str, client_ip """ pass + @abstractmethod + async def get_current_user_oidc(self: "AuthenticatedAdapter", api_key: str, client_ip: str | None, token_info: dict | None) -> str: + """ + Decode the api_key and return the authenticated user's id from information returned by an OIDC token. + This method is not called directly, rather authorized endpoints "depend" on it. + (https://fastapi.tiangolo.com/tutorial/dependencies/) + """ + pass + @abstractmethod async def get_current_user_globus(self: "AuthenticatedAdapter", api_key: str, client_ip: str | None, globus_introspect: dict | None) -> str: """ @@ -205,8 +447,12 @@ async def get_current_user_globus(self: "AuthenticatedAdapter", api_key: str, cl pass @abstractmethod - async def get_user(self: "AuthenticatedAdapter", user_id: str, api_key: str, client_ip: str | None, globus_introspect: dict | None) -> User: + async def get_user(self: "AuthenticatedAdapter", user_id: str, api_key: str, client_ip: str | None, token_info: dict | None, globus_introspect: dict | None) -> User: """ Retrieve additional user information (name, email, etc.) for the given user_id. + ``token_info`` is populated when AmSC OIDC validation produced it; + ``globus_introspect`` is populated when Globus introspection produced it. + Both may be None when the request was authenticated via the + facility-specific api_key path. """ pass diff --git a/local-template.env b/local-template.env index 68ad9b0..84839b9 100644 --- a/local-template.env +++ b/local-template.env @@ -1,9 +1,21 @@ -# globus app credentials -export GLOBUS_APP_ID= -export GLOBUS_APP_SECRET= +# optional dev app credentials for local token acquisition tooling +export OIDC_APP_ID= +export OIDC_APP_SECRET= -# the resource server's credentials -export GLOBUS_RS_ID=ed3e577d-f7f3-4639-b96e-ff5a8445d699 -export GLOBUS_RS_SECRET= +# the client metadata IRI uses for JWT audience validation +export OIDC_CLIENT_ID= +export OIDC_DISCOVERY_URI= -export GLOBUS_RS_SCOPE_SUFFIX=iri_api +# optional: override the JWT audience check (defaults to OIDC_CLIENT_ID) +export OIDC_REQUIRED_AUDIENCE= + +# optional: require specific scopes on accepted access tokens +export OIDC_REQUIRED_SCOPES="openid profile email" + +# optional live-test helpers +# OIDC_CLIENT_SECRET is only needed if you want the live test to exchange an auth code. +export OIDC_CLIENT_SECRET= +export OIDC_TOKEN_ENDPOINT= +export OIDC_REDIRECT_URI=urn:ietf:wg:oauth:2.0:oob +export OIDC_AUTHORIZATION_CODE= +export OIDC_LIVE_ACCESS_TOKEN= diff --git a/pyproject.toml b/pyproject.toml index 4e34d7e..d2f2d68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,9 @@ dependencies = [ "opentelemetry-instrumentation-fastapi>=0.60b1,<0.61b0", "opentelemetry-exporter-otlp>=1.39.1,<1.40.0", "globus-sdk>=4.3.1", + "authlib>=1.3.0", + "httpx>=0.27.0", + "cachetools>=5.3.0", "typer>=0.24.1", ] [tool.ruff] diff --git a/test/test_oidc_auth.py b/test/test_oidc_auth.py new file mode 100644 index 0000000..c1a67b6 --- /dev/null +++ b/test/test_oidc_auth.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Focused tests for OIDC JWT authentication in the IRI router.""" + +import json +import os +import unittest +from unittest.mock import patch +from urllib.parse import urlencode +from urllib.request import Request as UrlRequest, urlopen + +from fastapi.testclient import TestClient + +os.environ.setdefault("IRI_SHOW_MISSING_ROUTES", "true") + +from app.main import APP +from app.routers import iri_router + + +class _FakeHttpxResponse: + def __init__(self, payload: dict): + self._payload = payload + + def json(self) -> dict: + return self._payload + + def raise_for_status(self) -> None: + return None + + +class _FakeAsyncClient: + def __init__(self, responses: dict[str, dict], requests_seen: list[str], *args, **kwargs): + self._responses = responses + self._requests_seen = requests_seen + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def get(self, url: str, headers: dict | None = None): + self._requests_seen.append(url) + if url not in self._responses: + raise AssertionError(f"unexpected URL opened in test: {url}") + return _FakeHttpxResponse(self._responses[url]) + + +def _exchange_live_authorization_code() -> str: + token_endpoint = os.environ["OIDC_TOKEN_ENDPOINT"] + client_id = os.environ["OIDC_CLIENT_ID"] + client_secret = os.environ["OIDC_CLIENT_SECRET"] + redirect_uri = os.environ.get("OIDC_REDIRECT_URI", "urn:ietf:wg:oauth:2.0:oob") + authorization_code = os.environ["OIDC_AUTHORIZATION_CODE"] + + request = UrlRequest( + token_endpoint, + data=urlencode( + { + "grant_type": "authorization_code", + "client_id": client_id, + "client_secret": client_secret, + "code": authorization_code, + "redirect_uri": redirect_uri, + } + ).encode("utf-8"), + headers={ + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + method="POST", + ) + with urlopen(request, timeout=20) as response: + payload = json.loads(response.read().decode("utf-8")) + access_token = payload.get("access_token") + if not access_token: + raise AssertionError(f"No access_token found in live token response: {payload}") + return access_token + + +class OidcAuthTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + iri_router._oidc_remote_cache.clear() + iri_router._oidc_remote_stale_cache.clear() + + async def test_load_oidc_remote_state_fetches_and_caches_with_async_httpx(self): + discovery_uri = "https://identity.example.test/.well-known/openid-configuration" + jwks_uri = "https://identity.example.test/oauth2/jwks" + requests_seen = [] + responses = { + discovery_uri: { + "issuer": "https://identity.example.test/oauth2", + "jwks_uri": jwks_uri, + }, + jwks_uri: {"keys": []}, + } + + def fake_async_client(*args, **kwargs): + return _FakeAsyncClient(responses, requests_seen, *args, **kwargs) + + with patch("app.routers.iri_router.httpx.AsyncClient", side_effect=fake_async_client), \ + patch("app.routers.iri_router.JsonWebKey.import_key_set", return_value="fake-key-set"): + metadata, key_set = await iri_router._load_oidc_remote_state(discovery_uri) + cached_metadata, cached_key_set = await iri_router._load_oidc_remote_state(discovery_uri) + + self.assertEqual(metadata["jwks_uri"], jwks_uri) + self.assertEqual(key_set, "fake-key-set") + self.assertEqual(cached_metadata, metadata) + self.assertEqual(cached_key_set, key_set) + self.assertEqual(requests_seen, [discovery_uri, jwks_uri]) + + async def test_load_oidc_remote_state_reuses_stale_cache_on_refresh_failure(self): + discovery_uri = "https://identity.example.test/.well-known/openid-configuration" + cached_metadata = {"issuer": "https://identity.example.test/oauth2", "jwks_uri": "cached"} + cached_key_set = object() + iri_router._oidc_remote_stale_cache[discovery_uri] = (cached_metadata, cached_key_set) + + async def fail_fetch(uri: str): + raise RuntimeError("temporary IdP outage") + + with patch("app.routers.iri_router._fetch_oidc_remote_state", side_effect=fail_fetch): + metadata, key_set = await iri_router._load_oidc_remote_state(discovery_uri) + + self.assertIs(metadata, cached_metadata) + self.assertIs(key_set, cached_key_set) + + def test_account_projects_accepts_live_oidc_token_when_configured(self): + discovery_uri = os.environ.get("OIDC_DISCOVERY_URI") + client_id = os.environ.get("OIDC_CLIENT_ID") + client_secret = os.environ.get("OIDC_CLIENT_SECRET") + live_access_token = os.environ.get("OIDC_LIVE_ACCESS_TOKEN") + authorization_code = os.environ.get("OIDC_AUTHORIZATION_CODE") + token_endpoint = os.environ.get("OIDC_TOKEN_ENDPOINT") + + if not discovery_uri or not client_id: + self.skipTest("Live OIDC test requires OIDC_DISCOVERY_URI and OIDC_CLIENT_ID.") + if not live_access_token and not authorization_code: + self.skipTest("Live OIDC test requires OIDC_LIVE_ACCESS_TOKEN or OIDC_AUTHORIZATION_CODE.") + if authorization_code and (not token_endpoint or not client_secret): + self.skipTest("Live OIDC authorization-code exchange requires OIDC_TOKEN_ENDPOINT and OIDC_CLIENT_SECRET.") + + access_token = live_access_token or _exchange_live_authorization_code() + client = TestClient(APP) + response = client.get("/api/v1/account/projects", headers={"authorization": f"Bearer {access_token}"}) + + print(f"LIVE OIDC token: {access_token}") + self.assertEqual(response.status_code, 200, response.text) + self.assertGreaterEqual(len(response.json()), 1) + + +if __name__ == "__main__": + unittest.main()