From 18ea9a4686fd2f4d901d4e0348591b76de402cdc Mon Sep 17 00:00:00 2001 From: Grant Whitmer Date: Wed, 23 Sep 2026 02:55:17 -0400 Subject: [PATCH] =?UTF-8?q?auth:=20G3.2=20hub=20JWKS=20verifier=20?= =?UTF-8?q?=E2=80=94=20humans=20can=20sign=20in=20to=20the=20plane=20(SSO?= =?UTF-8?q?=20#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/app/auth.py | 44 +++++----- api/app/config.py | 18 ++-- api/app/hub_jwt.py | 129 +++++++++++++++++++++++++++++ api/tests/test_hub_jwt.py | 156 +++++++++++++++++++++++++++++++++++ api/tests/test_invariants.py | 7 +- 5 files changed, 327 insertions(+), 27 deletions(-) create mode 100644 api/app/hub_jwt.py create mode 100644 api/tests/test_hub_jwt.py diff --git a/api/app/auth.py b/api/app/auth.py index 44cbf19..a60e472 100644 --- a/api/app/auth.py +++ b/api/app/auth.py @@ -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 diff --git a/api/app/config.py b/api/app/config.py index d379f9c..c450b3b 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -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) ------------------------------------------ diff --git a/api/app/hub_jwt.py b/api/app/hub_jwt.py new file mode 100644 index 0000000..3163061 --- /dev/null +++ b/api/app/hub_jwt.py @@ -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: } + 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") + ) diff --git a/api/tests/test_hub_jwt.py b/api/tests/test_hub_jwt.py new file mode 100644 index 0000000..b768caa --- /dev/null +++ b/api/tests/test_hub_jwt.py @@ -0,0 +1,156 @@ +"""G3.2 / I-8 — human tokens are verified, never read (SSO #14, 2026-09-23). + +Behavioral: every case signs a real RS256 token with a locally generated key +and drives `get_caller`, so a green run means the gate refuses what it must — +not that some string appears in auth.py. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import time + +import jwt +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from api.app import hub_jwt +from api.app.config import Settings +from api.app.errors import RepairPointer + +KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) +OTHER = rsa.generate_private_key(public_exponent=65537, key_size=2048) +IDENTITY = "5e1b9569-7f01-489d-bf14-6fe5a367fa3f" + + +def _claims(**over): + now = int(time.time()) + c = { + "iss": "windy-identity", + "type": "human", + "sub": "row-id-not-identity", + "windy_identity_id": IDENTITY, + "email": "grant@example.com", + "iat": now, + "exp": now + 900, + } + c.update(over) + return {k: v for k, v in c.items() if v is not None} + + +def _sign(claims, key=KEY, alg="RS256"): + return jwt.encode(claims, key, algorithm=alg, headers={"kid": "test"}) + + +class _Req: + def __init__(self, settings): + self.app = type("A", (), {"state": type("S", (), {"settings": settings})()})() + + +@pytest.fixture(autouse=True) +def _local_jwks(monkeypatch): + """The hub's JWKS, served from KEY's public half — no network.""" + + class _Key: + key = KEY.public_key() + + class _Client: + def get_signing_key_from_jwt(self, token): + return _Key() + + monkeypatch.setattr(hub_jwt, "_client", lambda base_url: _Client()) + + +async def _caller(token, **settings): + from api.app.auth import get_caller + + s = Settings(environment="production", **settings) + return await get_caller(_Req(s), authorization=f"Bearer {token}", x_service_token=None) + + +async def _refused(token, **settings): + with pytest.raises(RepairPointer) as exc: + await _caller(token, **settings) + assert exc.value.status_code == 401 and exc.value.code == "token_invalid" + return exc.value + + +@pytest.mark.asyncio +async def test_genuine_hub_token_is_a_human_named_by_windy_identity_id(): + c = await _caller(_sign(_claims())) + assert c.actor_type == "human" + assert c.identity_id == IDENTITY # NOT `sub`, which is the per-row user id + + +@pytest.mark.asyncio +async def test_forged_signature_is_refused(): + await _refused(_sign(_claims(), key=OTHER)) + + +@pytest.mark.asyncio +async def test_expired_token_is_refused(): + await _refused(_sign(_claims(iat=int(time.time()) - 2000, exp=int(time.time()) - 60))) + + +@pytest.mark.asyncio +async def test_wrong_issuer_and_id_tokens_are_refused(): + await _refused(_sign(_claims(iss="https://evil.example"))) + # An id_token (discovery-URL issuer, a relying party's aud) is not a bearer. + await _refused(_sign(_claims(iss="https://account.windyword.ai", aud="some-other-client"))) + + +@pytest.mark.asyncio +async def test_hs256_confusion_is_refused(): + # The classic forgery: HMAC the token with the PUBLIC key as the secret. + pub = KEY.public_key().public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo + ) + header = base64.urlsafe_b64encode(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()).rstrip( + b"=" + ) + body = base64.urlsafe_b64encode(json.dumps(_claims()).encode()).rstrip(b"=") + sig = base64.urlsafe_b64encode( + hmac.new(pub, header + b"." + body, hashlib.sha256).digest() + ).rstrip(b"=") + await _refused((header + b"." + body + b"." + sig).decode()) + + +@pytest.mark.asyncio +async def test_non_human_type_is_refused(): + await _refused(_sign(_claims(type="agent"))) + + +@pytest.mark.asyncio +async def test_missing_windy_identity_is_refused_not_read_from_sub(): + await _refused(_sign(_claims(windy_identity_id=None))) + + +@pytest.mark.asyncio +async def test_aud_is_tolerated_when_it_names_windy_git_and_refused_otherwise(): + """PyJWT rejects ANY aud-bearing token when no audience is configured — the + trap that would break the day the hub starts emitting aud.""" + c = await _caller(_sign(_claims(aud=["windy-chat", "windy-git"]))) + assert c.identity_id == IDENTITY + await _refused(_sign(_claims(aud="windy-chat"))) + + +@pytest.mark.asyncio +async def test_require_aud_refuses_tokens_without_it(): + await _refused(_sign(_claims()), hub_require_aud=True) + c = await _caller(_sign(_claims(aud="windy-git")), hub_require_aud=True) + assert c.identity_id == IDENTITY + + +@pytest.mark.asyncio +async def test_production_verifies_even_if_the_flag_is_off(): + """require_verified_jwt=False is a local-dev convenience; production must + never take the unverified path.""" + await _refused(_sign(_claims(), key=OTHER), require_verified_jwt=False) + + +def test_algorithm_list_is_exactly_rs256(): + assert hub_jwt.ALGORITHMS == ["RS256"] diff --git a/api/tests/test_invariants.py b/api/tests/test_invariants.py index 0ccbf4a..30fbf9d 100644 --- a/api/tests/test_invariants.py +++ b/api/tests/test_invariants.py @@ -308,12 +308,13 @@ def test_g36_trust_client_never_soft_allows(): def test_g36_unverified_human_jwt_is_refused_in_production(): """I-8 applied to ourselves: an unverified JWT is an authentication bypass, - not a shortcut. Until G3.2's JWKS verifier exists, production refuses.""" + not a shortcut. G3.2's verifier now exists; behavioral proof that forged, + expired, mis-issued and mis-audienced tokens are refused lives in + test_hub_jwt.py. Here: the gate defaults closed.""" from api.app.config import Settings assert Settings().require_verified_jwt is True - src = (ROOT / "api" / "app" / "auth.py").read_text() - assert "human_signin_not_ready" in src + assert Settings().hub_issuers == ["windy-identity"] def test_no_auth_bypass_env_var_anywhere():