auth: G3.2 hub JWKS verifier — humans can sign in to the plane (SSO #14)
The human path refused every token in production (503 human_signin_not_ready) because no verifier existed. api/app/hub_jwt.py verifies hub access tokens against account.windyword.ai's JWKS: - RS256 only (closes alg:none and HS256-with-public-key confusion) - iss must be "windy-identity" — what hub ACCESS tokens carry (observed live); id_tokens (discovery-URL issuer) are not accepted as bearers - aud optional today, must name Windy Git when present; hub_require_aud flips it mandatory once the hub emits it. PyJWT's own aud check is off on purpose: it rejects ANY aud-bearing token when no audience is given. - type must be human; identity = windy_identity_id, never sub (per-row id) - production verifies even if require_verified_jwt is off 11 behavioral tests sign real RS256 tokens with a local key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ from fastapi import Header, Request
|
||||
from api.app.config import Settings
|
||||
from api.app.ept import EptInvalid, looks_like_ept, verify_ept
|
||||
from api.app.errors import RepairPointer, passport_unresolvable
|
||||
from api.app.hub_jwt import HubTokenInvalid, verify_hub_token
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -239,22 +240,29 @@ async def get_caller(
|
||||
allowed_actions=actions,
|
||||
)
|
||||
|
||||
# --- human (account-server RS256) -------------------------------------
|
||||
if settings.is_production and settings.require_verified_jwt:
|
||||
# I-8, applied to ourselves. G3.2's JWKS verifier is not written yet, and
|
||||
# an unverified JWT is an authentication bypass rather than a shortcut.
|
||||
# Refusing is the only honest answer until the verifier exists.
|
||||
raise RepairPointer(
|
||||
status_code=503,
|
||||
code="human_signin_not_ready",
|
||||
speak="Signing in isn't switched on yet. Nothing you have is affected.",
|
||||
machine_cause=(
|
||||
"JWKS verification (G3.2) is not implemented; refusing to accept "
|
||||
"an unverified human token in production"
|
||||
),
|
||||
remediation_tool=None,
|
||||
)
|
||||
# --- human (hub RS256 access token, G3.2) ------------------------------
|
||||
# Production ALWAYS verifies, whatever require_verified_jwt says: the flag
|
||||
# only exists to let local dev run against unsigned fixture tokens.
|
||||
if settings.require_verified_jwt or settings.is_production:
|
||||
try:
|
||||
human = verify_hub_token(
|
||||
token,
|
||||
settings.account_server_base_url,
|
||||
issuers=tuple(settings.hub_issuers),
|
||||
audiences=tuple(settings.hub_audiences),
|
||||
require_aud=settings.hub_require_aud,
|
||||
)
|
||||
except HubTokenInvalid as exc:
|
||||
raise RepairPointer(
|
||||
status_code=401,
|
||||
code="token_invalid",
|
||||
speak="We couldn't confirm that sign-in. Try signing in again.",
|
||||
machine_cause=f"hub token verification failed: {exc}",
|
||||
remediation_tool=None,
|
||||
) from exc
|
||||
return Caller(actor_type=ActorType.human, identity_id=human.identity_id)
|
||||
|
||||
# Local dev only (require_verified_jwt=False outside production).
|
||||
identity_id = _unverified_claim(token, "windy_identity_id") or _unverified_claim(token, "sub")
|
||||
if not identity_id:
|
||||
raise RepairPointer(
|
||||
@@ -274,10 +282,8 @@ def _unverified_claim(token: str, claim: str) -> str | None:
|
||||
on the result re-establishes trust independently: an agent's authority comes
|
||||
from a live Eternitas trust lookup, never from the token's own assertions.
|
||||
|
||||
⚠️ Full RS256/ES256 JWKS verification for the human path lands in G3.2's
|
||||
verifier and MUST be in place before `api.windygit.com` accepts a human
|
||||
token from outside. Until then the human path is reachable only from inside
|
||||
the tunnel, and `settings.require_verified_jwt` refuses it in production.
|
||||
Humans are verified by hub_jwt.verify_hub_token (G3.2); this reader backs
|
||||
only the local-dev path, which production never takes.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
|
||||
@@ -53,16 +53,24 @@ class Settings(BaseSettings):
|
||||
|
||||
# ---- account-server OIDC (human identity) -----------------------------
|
||||
account_server_base_url: str = "https://account.windyword.ai"
|
||||
# G3.2 — what a hub ACCESS token must say about itself (see hub_jwt.py).
|
||||
# The hub signs access tokens with iss "windy-identity" (observed
|
||||
# 2026-09-23), not its discovery-doc issuer URL; id_tokens carry the URL and
|
||||
# are deliberately NOT accepted as bearers.
|
||||
hub_issuers: list[str] = ["windy-identity"]
|
||||
# SSO matrix (lane 8c): once the hub emits `aud`, it must name one of these.
|
||||
hub_audiences: list[str] = ["windy-git", "https://api.windygit.com"]
|
||||
# Flip to True once the hub emits aud on every access token.
|
||||
hub_require_aud: bool = False
|
||||
|
||||
# Internal callers (the Cloud portal calling /internal/*). A first-class
|
||||
# caller class, not a bypass: unset means service calls are REFUSED.
|
||||
service_token: str = ""
|
||||
|
||||
# ⚠️ FAIL-CLOSED GATE. Full RS256/ES256 JWKS verification lands in G3.2.
|
||||
# Until it does, the human token path must not be reachable in production —
|
||||
# accepting an unverified JWT is not a shortcut, it is an authentication
|
||||
# bypass. Agents are unaffected: their authority comes from a live Eternitas
|
||||
# trust lookup, not from anything the token asserts about itself.
|
||||
# ⚠️ FAIL-CLOSED GATE. Human tokens are verified against the hub's JWKS
|
||||
# (G3.2, hub_jwt.py). False only enables the unverified local-dev path, and
|
||||
# production verifies regardless — an unverified JWT is a bypass, not a
|
||||
# shortcut.
|
||||
require_verified_jwt: bool = True
|
||||
|
||||
# ---- storage law (I-3, G4.4) ------------------------------------------
|
||||
|
||||
129
api/app/hub_jwt.py
Normal file
129
api/app/hub_jwt.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Human token verification (G3.2) — hub access tokens from account.windyword.ai.
|
||||
|
||||
Until this existed the human path refused every token in production (503
|
||||
`human_signin_not_ready`), because reading an unverified JWT's claims is an
|
||||
authentication bypass, not a shortcut. This module is what lets it say yes.
|
||||
|
||||
The token it accepts is the hub's ACCESS token, as observed live 2026-09-23:
|
||||
|
||||
header {alg: RS256, typ: JWT, kid: <published at /.well-known/jwks.json>}
|
||||
claims iss = "windy-identity" ← NOT the discovery doc's issuer URL
|
||||
type = "human", exp - iat = 900 s
|
||||
sub = per-row user id ← NOT the cross-product identity
|
||||
windy_identity_id = the Windy Account UUID (what Gitea's OIDC links on)
|
||||
no `aud` yet
|
||||
|
||||
What it refuses, by construction:
|
||||
|
||||
* **Anything but RS256.** One algorithm, never a list. Closes `alg: none` and
|
||||
HS256-with-the-public-key confusion.
|
||||
* **An unknown `kid`**, a wrong issuer, an expired token — library-checked.
|
||||
* **An id_token used as a bearer.** id_tokens carry iss = the discovery URL and
|
||||
aud = some relying party; they prove a login happened to *someone else's*
|
||||
client, not that this caller may act here.
|
||||
* **A non-human `type`.** An agent's authority comes from its EPT and a live
|
||||
Eternitas lookup, never from a hub token dressed as a person.
|
||||
* **A token with no `windy_identity_id`.** `sub` is a different namespace (the
|
||||
per-row user id); falling back to it would silently mint identities that
|
||||
match nothing Gitea knows.
|
||||
|
||||
`aud` (SSO matrix, lane 8c): the hub will start emitting it once every
|
||||
consumer is ready. Today it is optional; when present it MUST name Windy Git.
|
||||
`hub_require_aud=True` makes it mandatory — flip it once the hub emits it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import jwt
|
||||
from jwt import PyJWKClient
|
||||
|
||||
ALGORITHMS = ["RS256"] # exactly one. Never widen this list.
|
||||
|
||||
_jwks_client: PyJWKClient | None = None
|
||||
_jwks_url: str | None = None
|
||||
|
||||
|
||||
class HubTokenInvalid(Exception):
|
||||
"""Not a valid, currently-signed hub access token for a human."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedHuman:
|
||||
identity_id: str
|
||||
email: str | None
|
||||
expires_at: int | None
|
||||
|
||||
|
||||
def _client(base_url: str) -> PyJWKClient:
|
||||
"""Cached JWKS client; refetches on an unknown kid so rotation self-heals."""
|
||||
global _jwks_client, _jwks_url
|
||||
url = f"{base_url.rstrip('/')}/.well-known/jwks.json"
|
||||
if _jwks_client is None or _jwks_url != url:
|
||||
_jwks_client = PyJWKClient(url, cache_keys=True, lifespan=300)
|
||||
_jwks_url = url
|
||||
return _jwks_client
|
||||
|
||||
|
||||
def verify_hub_token(
|
||||
token: str,
|
||||
base_url: str,
|
||||
*,
|
||||
issuers: tuple[str, ...],
|
||||
audiences: tuple[str, ...],
|
||||
require_aud: bool,
|
||||
signing_key=None,
|
||||
) -> VerifiedHuman:
|
||||
"""Verify a hub access token. Raises HubTokenInvalid on ANY doubt.
|
||||
|
||||
`signing_key` exists for tests only (a locally generated key, no network).
|
||||
"""
|
||||
try:
|
||||
key = (
|
||||
signing_key
|
||||
if signing_key is not None
|
||||
else _client(base_url).get_signing_key_from_jwt(token).key
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - unknown kid, unreachable JWKS, malformed
|
||||
raise HubTokenInvalid(f"no usable signing key: {type(exc).__name__}: {exc}") from exc
|
||||
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
key,
|
||||
algorithms=ALGORITHMS,
|
||||
issuer=list(issuers),
|
||||
options={
|
||||
"require": ["iss", "exp", "iat"],
|
||||
"verify_signature": True,
|
||||
"verify_exp": True,
|
||||
"verify_iss": True,
|
||||
# Checked by hand below: PyJWT rejects any token CARRYING aud
|
||||
# when no audience is passed, which would break the moment the
|
||||
# hub starts emitting it — the exact trap the SSO matrix names.
|
||||
"verify_aud": False,
|
||||
},
|
||||
)
|
||||
except jwt.PyJWTError as exc:
|
||||
raise HubTokenInvalid(f"{type(exc).__name__}: {exc}") from exc
|
||||
|
||||
aud = claims.get("aud")
|
||||
if aud is None:
|
||||
if require_aud:
|
||||
raise HubTokenInvalid("token carries no aud and hub_require_aud is on")
|
||||
else:
|
||||
presented = {aud} if isinstance(aud, str) else set(aud) if isinstance(aud, list) else set()
|
||||
if not presented & set(audiences):
|
||||
raise HubTokenInvalid(f"aud {sorted(presented)} does not name Windy Git")
|
||||
|
||||
if claims.get("type", "human") != "human":
|
||||
raise HubTokenInvalid(f"token type {claims.get('type')!r} is not a human access token")
|
||||
|
||||
identity = claims.get("windy_identity_id") or claims.get("windyIdentityId")
|
||||
if not isinstance(identity, str) or not identity.strip():
|
||||
raise HubTokenInvalid("token carries no windy_identity_id")
|
||||
|
||||
return VerifiedHuman(
|
||||
identity_id=identity, email=claims.get("email"), expires_at=claims.get("exp")
|
||||
)
|
||||
Reference in New Issue
Block a user