Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|
Expand All @@ -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(<iss host> + "\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": "<user>", "allowed_sub": ["<AUID>", ...]}` (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.

Expand Down
160 changes: 143 additions & 17 deletions app/amsc_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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()

Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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(<issuer host> + "\\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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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()
10 changes: 8 additions & 2 deletions app/routers/iri_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}"
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions local-template.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading