Review

Honor JWT jku for multi-museum crate vault opens

Annexium tours shared crate vaults across partner museums. Each house used to email us a static JWKS URL when rotating RS256 keys, which broke every time a DI house spun a new SSO. This PR drops the allowlisted JWKS table and resolves keys from the token's jku header so any accredited museum can self-host keys and open vault bays without a deploy.

PyJWTTier 6securityjwtjkussrf

Click a line to flag it, pick one or more labels, then submit. If the change looks correct, approve it.

services/vault/annexium_auth.py+15-6
11import jwt
22from fastapi import Header, HTTPException
33from jwt.algorithms import RSAAlgorithm
44
55from app.config import settings
6-from app.jwks_registry import get_museum_signing_key
6+import httpx
77
88ALLOWED_ROLES = {"courier", "registrar", "vault-master"}
99
10-def _resolve_key(token: str):
11- header = jwt.get_unverified_header(token)
12- kid = header.get("kid")
13- iss = jwt.decode(token, options={"verify_signature": False}).get("iss")
14- return get_museum_signing_key(iss=iss, kid=kid)
10+def _resolve_key(token: str):
11+ """Load the RS256 key from the JWKS URL the token advertises."""
12+ header = jwt.get_unverified_header(token)
13+ jku = header.get("jku")
14+ kid = header.get("kid")
15+ if not jku or not kid:
16+ raise HTTPException(status_code=401, detail="jku and kid required")
17+ # Partners host JWKS on their own domains; follow the header.
18+ resp = httpx.get(jku, timeout=3.0)
19+ resp.raise_for_status()
20+ for jwk in resp.json().get("keys", []):
21+ if jwk.get("kid") == kid:
22+ return RSAAlgorithm.from_jwk(jwk)
23+ raise HTTPException(status_code=401, detail="kid not present in jwks")
1524
1625def authorize_crate_open(
1726 crate_id: str,
1827 authorization: str = Header(...),
1928) -> dict:
2029 if not authorization.startswith("Bearer "):
2130 raise HTTPException(status_code=401, detail="missing bearer")
2231 token = authorization[len("Bearer "):].strip()
2332 try:
2433 key = _resolve_key(token)
2534 claims = jwt.decode(
2635 token,
2736 key=key,
2837 algorithms=["RS256"],
2938 audience=settings.VAULT_JWT_AUD,
3039 )
3140 except jwt.PyJWTError as exc:
3241 raise HTTPException(status_code=401, detail="invalid token") from exc
3342 if claims.get("role") not in ALLOWED_ROLES:
3443 raise HTTPException(status_code=403, detail="role not allowed")
3544 return {"crate_id": crate_id, "sub": claims.get("sub"), "via": "jku"}