diff --git a/README.md b/README.md index ecd85c69..81694567 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ The demo adapter's [`DemoAuthMixin`](https://github.com/doe-iri/iri-facility-api ### AmSC authentication -Optional, off by default. When enabled, `IriRouter.current_user` validates an AmSC Keycard bearer token (RIG audience-scopes it to this facility before forwarding it) *before* falling back to the facility-specific auth path above: JWKS signature/issuer/audience/expiry verification, an optional Ping userinfo freshness check, and mapping the tokens active `amsc_project_context` claim to a local facility username via a JSON file. See [`app/amsc_auth.py`](app/amsc_auth.py) for the implementation details +Optional, off by default. When enabled, `IriRouter.current_user` validates an AmSC Keycard bearer token (RIG audience-scopes it to this facility before forwarding it) *before* falling back to the facility-specific auth path above: JWKS signature/issuer/audience/expiry verification, a DNSBL revocation check, an optional Ping userinfo freshness check, and mapping the tokens active `amsc_project_context` claim to a local facility username via a JSON file. See [`app/amsc_auth.py`](app/amsc_auth.py) for the implementation details | Variable | Default | Description | |---|---|---| @@ -136,10 +136,13 @@ Optional, off by default. When enabled, `IriRouter.current_user` validates an Am | `AMSC_TOKEN_ALGORITHMS` | _(derived from discovery)_ | Comma-separated list of accepted JWT signing algorithms. If unset, derived from the discovery output `id_token_signing_alg_values_supported`. Falls back to `RS256,ES256,RS384,RS512,ES384,ES512` if discovery is unset, unreachable, or has nothing usable after filtering. | | `AMSC_TOKEN_LEEWAY_SECONDS` | `30` | Clock-skew leeway applied to `exp`/`nbf` checks. | | `AMSC_TOKEN_JWKS_CACHE_TTL_SECONDS` | `3600` | How long JWKS keys and the discovery endpoint are cached before refetching. | +| `AMSC_REVOCATION_CHECK_ENABLED` | `true` | After signature validation, look the token up in the AmSC DNS blocklist (DNSBL): a TXT query for `sha256( + "\0" + jti)` as lowercase hex, split into two 32-character labels under `AMSC_REVOCATION_DNSBL_ZONE`. The issuer is reduced to its hostname, so `https://identity.amsc.ornl.gov/am/oauth2` hashes as `identity.amsc.ornl.gov`. Any TXT record (e.g. `"exp=1793391277"`) means revoked: 401, with no fallback to facility-specific auth. NXDOMAIN or no TXT record means not revoked. Resolver errors and timeouts fail open (logged as a warning), the same as RIG. A token without a `jti` claim is rejected while the check is on. | +| `AMSC_REVOCATION_DNSBL_ZONE` | `revoked.americansciencecloud.org` | DNS zone the revocation lookups are made under. | +| `AMSC_REVOCATION_DNSBL_TIMEOUT_SECONDS` | `2` | Total time budget for one revocation lookup before it fails open. | | `AMSC_USERINFO_VALIDATION_ENABLED` | `false` | When `true`, every AmSC-authenticated request also calls the userinfo endpoint (Ping) with the caller's token to catch revocation that offline JWT validation cannot see. Fail-closed: if Ping is unreachable or rejects the token, the request is denied. | | `AMSC_USERINFO_URL` | _(derived from discovery)_ | Explicit userinfo endpoint, overrides the discovery endpoint. | | `AMSC_USERINFO_TIMEOUT_SECONDS` | `5` | Timeout for the discovery endpoint fetch and the userinfo call. | -| `AMSC_PROJECT_MAPPING_FILE` | _(required when enabled)_ | Path to a JSON file mapping each AmSC `amsc_project_context` this facility has provisioned to a local facility username. See [`examples/demo-adapter/amsc_project_mapping.json`](examples/demo-adapter/amsc_project_mapping.json) for the format. An `amsc_project_context` with no entry is a 401, not a silent fallback. | +| `AMSC_PROJECT_MAPPING_FILE` | _(required when enabled)_ | Path to a JSON file mapping each AmSC `amsc_project_context` this facility has provisioned to a local facility username. See [`examples/demo-adapter/amsc_project_mapping.json`](examples/demo-adapter/amsc_project_mapping.json) for the format. Each entry must be an object `{"local_user": "", "allowed_sub": ["", ...]}` (a bare username string is rejected); when `allowed_sub` is present, only those token `sub` values may use the project (an empty list admits nobody), and when it is absent any `sub` carrying the project is accepted. An `amsc_project_context` with no entry, or a `sub` not in `allowed_sub`, is a 401, not a silent fallback. | Startup fails fast with a clear error if `AMSC_TOKEN_ENABLED=true` but required configuration is missing. diff --git a/app/amsc_auth.py b/app/amsc_auth.py index 5966ec8c..fb6c5f3f 100644 --- a/app/amsc_auth.py +++ b/app/amsc_auth.py @@ -2,18 +2,25 @@ 1. Validate the Keycard is well-formed and currently valid: JWKS signature verification, issuer/audience/expiry checks (``validate_amsc_token``). 2. Validate the claims carried inside it (amsc_project_context, sub) are present. -3. Optionally call the AmSC userinfo endpoint (Ping) to catch tokens revoked since issuance. -4. Map the tokens amsc_project_context to a local facility username via a facility-maintained JSON file. +3. Reject a revoked Keycard: DNS TXT lookup of its (iss, jti) in the AmSC DNSBL (on by default). +4. Optionally call the AmSC userinfo endpoint (Ping) to catch tokens revoked since issuance. +5. Map the tokens amsc_project_context to a local facility username via a facility-maintained JSON file. AmSC project with no local mapping entry is a hard authentication failure (401). + An entry may restrict the project to specific subs via allowed_sub; any other sub is rejected (401). To turns this on with AMSC_TOKEN_ENABLED=true and supply issuer/audience/JWKS/mapping configuration. """ import asyncio +import hashlib import json import os import time -from typing import Any +from typing import Any, NamedTuple +from urllib.parse import urlsplit +import dns.asyncresolver +import dns.exception +import dns.resolver import httpx import jwt from jwt import PyJWKClient @@ -27,6 +34,12 @@ _ASYMMETRIC_ALGORITHMS = frozenset({"RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "EdDSA"}) +DEFAULT_REVOCATION_DNSBL_ZONE = "revoked.americansciencecloud.org" + + +class AmscTokenRevokedError(ValueError): + """The Keycard's (iss, jti) is published in the AmSC DNSBL: a definitive rejection, not a soft failure.""" + def _isittrue(value: str | None) -> bool: return (value or "").strip().lower() in ("true", "1", "on", "yes") @@ -38,10 +51,15 @@ def enabled() -> bool: def userinfo_check_enabled() -> bool: - """Whether step 3 (userinfo check against Ping) is turned on.""" + """Whether step 4 (userinfo check against Ping) is turned on.""" return _isittrue(os.environ.get("AMSC_USERINFO_VALIDATION_ENABLED")) +def revocation_check_enabled() -> bool: + """Whether step 3 (DNSBL revocation lookup) is turned on. On unless explicitly disabled.""" + return _isittrue(os.environ.get("AMSC_REVOCATION_CHECK_ENABLED", "true")) + + def _discovery_url() -> str: return os.environ.get("AMSC_OIDC_DISCOVERY_URL", "").strip() @@ -113,6 +131,14 @@ def _mapping_file() -> str: return os.environ.get("AMSC_PROJECT_MAPPING_FILE", "").strip() +def _revocation_zone() -> str: + return os.environ.get("AMSC_REVOCATION_DNSBL_ZONE", DEFAULT_REVOCATION_DNSBL_ZONE).strip().strip(".") + + +def _revocation_timeout_seconds() -> float: + return float(os.environ.get("AMSC_REVOCATION_DNSBL_TIMEOUT_SECONDS", "2")) + + # --------------------------------------------------------------------------- # OIDC discovery document (.well-known/openid-configuration) caching # --------------------------------------------------------------------------- @@ -177,7 +203,8 @@ def _jwks_client(jwks_url: str) -> PyJWKClient: async def validate_amsc_token(token: str) -> dict: """Validate an AmSC Keycard's signature, issuer, audience, and expiry. - Also enforce presence of the sub and amsc_project_context. + Also enforce presence of the sub and amsc_project_context, and (when enabled) + reject a revoked token with AmscTokenRevokedError. """ if not enabled(): raise ValueError("AmSC token authentication is disabled") @@ -225,11 +252,70 @@ async def validate_amsc_token(token: str) -> dict: if not claims.get("amsc_project_context"): raise ValueError("AmSC token is missing the required amsc_project_context claim") + # Only after the signature check: iss/jti are then trusted, and forged tokens cannot drive DNS lookups. + if revocation_check_enabled(): + await check_amsc_revocation(claims) + return claims # --------------------------------------------------------------------------- -# Step 3: optional userinfo freshness check +# Step 3: DNSBL revocation check (RFC Identity Section 2F) +# --------------------------------------------------------------------------- + +def _issuer_host(issuer: str) -> str: + """Reduce an ``iss`` claim to the bare hostname used in the DNSBL key. + + PingAM issues ``https://identity.dev.amsc.ornl.gov/am/oauth2``; the revocation + publisher hashes only ``identity.dev.amsc.ornl.gov`` (no scheme, port or path). + """ + issuer = issuer.strip() + return urlsplit(issuer if "://" in issuer else f"//{issuer}").hostname or "" + + +def revocation_dns_name(issuer: str, jti: str, zone: str) -> str: + """Return the DNSBL TXT name for a token: sha256( + "\\0" + jti) under zone. + + The issuer is part of the hash so identical jti values from different issuers never + collide. The 64-char hex digest is split into two 32-char labels because a DNS label + is limited to 63 octets. + """ + host = _issuer_host(issuer) + if not host: + raise ValueError("AmSC token issuer has no hostname; cannot build the revocation lookup key") + digest = hashlib.sha256(f"{host}\0{jti}".encode("utf-8")).hexdigest() + return f"{digest[:32]}.{digest[32:]}.{zone}" + + +async def check_amsc_revocation(claims: dict) -> None: + """Raise AmscTokenRevokedError if any TXT record exists for the token's (iss, jti). + + NXDOMAIN / no TXT answer means not revoked. Resolver failures (timeout, SERVFAIL, + no nameservers) fail open with a warning, as RIG does: the DNSBL is a kill switch on + top of short token lifetimes, so a DNS outage must not deny every AmSC request. + No in-process cache: the zone's 60s TTL and the local recursive resolver's cache + bound both freshness and lookup cost. A token without jti cannot be checked and + is rejected. + """ + issuer = claims.get("iss") + jti = claims.get("jti") + if not isinstance(issuer, str) or not isinstance(jti, str) or not jti: + raise ValueError("AmSC token is missing the iss/jti claims required for the revocation check") + name = revocation_dns_name(issuer, jti, _revocation_zone()) + try: + answer = await dns.asyncresolver.resolve(name, "TXT", lifetime=_revocation_timeout_seconds()) + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): + return + except dns.exception.DNSException as exc: + logger.warning(f"amsc_auth: revocation lookup {name} for jti={jti} failed ({exc.__class__.__name__}: {exc}); failing open") + return + records = " ".join(rdata.to_text() for rdata in answer) + logger.warning(f"amsc_auth: rejected revoked AmSC token sub={claims.get('sub')} jti={jti} dnsbl={name} txt={records}") + raise AmscTokenRevokedError("AmSC token has been revoked") + + +# --------------------------------------------------------------------------- +# Step 4: optional userinfo freshness check # --------------------------------------------------------------------------- async def check_amsc_userinfo(token: str) -> dict: @@ -261,15 +347,44 @@ async def check_amsc_userinfo(token: str) -> dict: # --------------------------------------------------------------------------- -# Step 4: amsc_project_context -> local facility username mapping (JSON) +# Step 5: amsc_project_context -> local facility username mapping (JSON) # --------------------------------------------------------------------------- -_mapping_cache: dict[str, str] = {} +class ProjectMapping(NamedTuple): + """One project_mapping entry. allowed_sub=None means any sub carrying the project is accepted.""" + + local_user: str + allowed_sub: frozenset[str] | None = None + + +_MAPPING_ENTRY_KEYS = frozenset({"local_user", "allowed_sub"}) + +_mapping_cache: dict[str, ProjectMapping] = {} _mapping_mtime: float = 0.0 -def _load_project_mapping() -> dict[str, str]: - """Return the amsc_project_context -> local username mapping, reloading on file change.""" +def _parse_mapping_entry(project: str, entry: Any) -> ProjectMapping: + """Parse a {"local_user": ..., "allowed_sub": [...]} project_mapping entry.""" + if not isinstance(entry, dict): + raise ValueError(f"AmSC project mapping for '{project}' must be an object with 'local_user'") + # A misspelled key (e.g. "allowed_subs") would otherwise silently open the project to every sub. + unknown = set(entry) - _MAPPING_ENTRY_KEYS + if unknown: + raise ValueError(f"AmSC project mapping for '{project}' has unknown key(s): {', '.join(sorted(unknown))}") + local_user = entry.get("local_user") + if not isinstance(local_user, str) or not local_user: + raise ValueError(f"AmSC project mapping for '{project}' requires a non-empty 'local_user'") + if "allowed_sub" not in entry: + return ProjectMapping(local_user=local_user) + allowed_sub = entry["allowed_sub"] + # An explicit empty list is kept as-is and admits nobody (fail closed), rather than being read as "all". + if not isinstance(allowed_sub, list) or not all(isinstance(sub, str) and sub for sub in allowed_sub): + raise ValueError(f"AmSC project mapping for '{project}': 'allowed_sub' must be a list of non-empty strings") + return ProjectMapping(local_user=local_user, allowed_sub=frozenset(allowed_sub)) + + +def _load_project_mapping() -> dict[str, ProjectMapping]: + """Return the amsc_project_context -> ProjectMapping table, reloading on file change.""" global _mapping_cache, _mapping_mtime path = _mapping_file() if not path: @@ -286,19 +401,25 @@ def _load_project_mapping() -> dict[str, str]: mapping = data.get("project_mapping") or {} if not isinstance(mapping, dict): raise ValueError(f"AmSC project mapping file {path}: 'project_mapping' must be a mapping") - _mapping_cache = {str(key): str(value) for key, value in mapping.items()} + _mapping_cache = {str(key): _parse_mapping_entry(str(key), value) for key, value in mapping.items()} _mapping_mtime = mtime logger.info(f"amsc_auth: reloaded project mapping from {path} ({len(_mapping_cache)} project(s))") return _mapping_cache -def resolve_amsc_project(amsc_project_context: str) -> str: - """Map an AmSC amsc_project_context claim to a local facility username.""" +def resolve_amsc_project(amsc_project_context: str, sub: str | None = None) -> str: + """Map an AmSC amsc_project_context claim to a local facility username. + + When the project entry lists allowed_sub, the token's sub must be in it; a + caller that does not pass sub is rejected for such projects (fail closed). + """ mapping = _load_project_mapping() - username = mapping.get(amsc_project_context) - if not username: + entry = mapping.get(amsc_project_context) + if entry is None: raise ValueError(f"No local mapping for AmSC project '{amsc_project_context}'") - return username + if entry.allowed_sub is not None and sub not in entry.allowed_sub: + raise ValueError(f"AmSC subject is not allowed for project '{amsc_project_context}'") + return entry.local_user def _validate_startup_config() -> None: @@ -316,9 +437,14 @@ def _validate_startup_config() -> None: missing.append("AMSC_PROJECT_MAPPING_FILE") if userinfo_check_enabled() and not os.environ.get("AMSC_USERINFO_URL", "").strip() and not _discovery_url(): missing.append("AMSC_USERINFO_URL or AMSC_OIDC_DISCOVERY_URL (required by AMSC_USERINFO_VALIDATION_ENABLED)") + if revocation_check_enabled() and not _revocation_zone(): + missing.append("AMSC_REVOCATION_DNSBL_ZONE (required by AMSC_REVOCATION_CHECK_ENABLED)") if missing: raise RuntimeError("AMSC_TOKEN_ENABLED is true but required configuration is missing: " + ", ".join(missing)) - logger.info(f"amsc_auth: AMSC token authentication enabled (issuer={_issuer()}, audience={_audience()}, userinfo_check={userinfo_check_enabled()})") + logger.info( + f"amsc_auth: AMSC token authentication enabled (issuer={_issuer()}, audience={_audience()}, userinfo_check={userinfo_check_enabled()}, " + f"revocation_check={revocation_check_enabled()}, dnsbl_zone={_revocation_zone()})" + ) _validate_startup_config() diff --git a/app/routers/iri_router.py b/app/routers/iri_router.py index 60d2b45e..a55cf30e 100644 --- a/app/routers/iri_router.py +++ b/app/routers/iri_router.py @@ -113,11 +113,16 @@ async def current_user( amsc_claims.get("amsc_email"), user_id, ) + except amsc_auth.AmscTokenRevokedError: + raise except Exception as amsc_exc: logging.getLogger().exception("AmSC error:", exc_info=amsc_exc) exc_msg = f"AmSC authentication failed: {str(amsc_exc)}. || " if not user_id: user_id = await self.adapter.get_current_user(token, ip_address) + except amsc_auth.AmscTokenRevokedError as exc: + # Definitive verdict on a validly signed AmSC token: never let the facility fallback accept it. + raise HTTPException(status_code=401, detail=f"AmSC authentication failed: {exc}") 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)}" @@ -151,9 +156,10 @@ async def get_current_user_amsc(self: "AuthenticatedAdapter", api_key: str, clie Return the authenticated users local id for an already-validated AmSC Keycard. Default implementation: map the token's active `amsc_project_context` - claim to a local facility username via the configured YAML mapping file. + claim to a local facility username via the configured JSON mapping file, + enforcing the entry's allowed_sub list against the token's `sub` when present. """ - return amsc_auth.resolve_amsc_project(amsc_claims["amsc_project_context"]) + return amsc_auth.resolve_amsc_project(amsc_claims["amsc_project_context"], amsc_claims["sub"]) @abstractmethod async def get_user(self: "AuthenticatedAdapter", user_id: str, api_key: str, client_ip: str | None) -> User: diff --git a/local-template.env b/local-template.env index cce22d63..ab9d264a 100644 --- a/local-template.env +++ b/local-template.env @@ -22,4 +22,6 @@ export GLOBUS_RS_SCOPE_SUFFIX=iri_api #export AMSC_TOKEN_AUDIENCE=https://iri.myfacility.gov #export AMSC_OIDC_DISCOVERY_URL=https://identity.dev.amsc.ornl.gov/am/oauth2/.well-known/openid-configuration #export AMSC_USERINFO_VALIDATION_ENABLED=false +#export AMSC_REVOCATION_CHECK_ENABLED=true +#export AMSC_REVOCATION_DNSBL_ZONE=revoked.americansciencecloud.org #export AMSC_PROJECT_MAPPING_FILE=examples/demo-adapter/amsc_project_mapping.yaml diff --git a/pyproject.toml b/pyproject.toml index 2951f4cd..0a777972 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "typer>=0.24.1", "pyjwt[crypto]>=2.10.1,<3.0.0", "httpx>=0.28.1,<0.29.0", + "dnspython>=2.8.0,<3.0.0", ] [project.optional-dependencies] dev = ["pytest>=9"]