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:
2026-09-23 02:55:17 -04:00
parent 32e8ac8474
commit 18ea9a4686
5 changed files with 327 additions and 27 deletions

View File

@@ -27,6 +27,7 @@ from fastapi import Header, Request
from api.app.config import Settings from api.app.config import Settings
from api.app.ept import EptInvalid, looks_like_ept, verify_ept from api.app.ept import EptInvalid, looks_like_ept, verify_ept
from api.app.errors import RepairPointer, passport_unresolvable from api.app.errors import RepairPointer, passport_unresolvable
from api.app.hub_jwt import HubTokenInvalid, verify_hub_token
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -239,22 +240,29 @@ async def get_caller(
allowed_actions=actions, allowed_actions=actions,
) )
# --- human (account-server RS256) ------------------------------------- # --- human (hub RS256 access token, G3.2) ------------------------------
if settings.is_production and settings.require_verified_jwt: # Production ALWAYS verifies, whatever require_verified_jwt says: the flag
# I-8, applied to ourselves. G3.2's JWKS verifier is not written yet, and # only exists to let local dev run against unsigned fixture tokens.
# an unverified JWT is an authentication bypass rather than a shortcut. if settings.require_verified_jwt or settings.is_production:
# Refusing is the only honest answer until the verifier exists. try:
raise RepairPointer( human = verify_hub_token(
status_code=503, token,
code="human_signin_not_ready", settings.account_server_base_url,
speak="Signing in isn't switched on yet. Nothing you have is affected.", issuers=tuple(settings.hub_issuers),
machine_cause=( audiences=tuple(settings.hub_audiences),
"JWKS verification (G3.2) is not implemented; refusing to accept " require_aud=settings.hub_require_aud,
"an unverified human token in production" )
), except HubTokenInvalid as exc:
remediation_tool=None, 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") identity_id = _unverified_claim(token, "windy_identity_id") or _unverified_claim(token, "sub")
if not identity_id: if not identity_id:
raise RepairPointer( 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 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. 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 Humans are verified by hub_jwt.verify_hub_token (G3.2); this reader backs
verifier and MUST be in place before `api.windygit.com` accepts a human only the local-dev path, which production never takes.
token from outside. Until then the human path is reachable only from inside
the tunnel, and `settings.require_verified_jwt` refuses it in production.
""" """
import base64 import base64
import json import json

View File

@@ -53,16 +53,24 @@ class Settings(BaseSettings):
# ---- account-server OIDC (human identity) ----------------------------- # ---- account-server OIDC (human identity) -----------------------------
account_server_base_url: str = "https://account.windyword.ai" 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 # Internal callers (the Cloud portal calling /internal/*). A first-class
# caller class, not a bypass: unset means service calls are REFUSED. # caller class, not a bypass: unset means service calls are REFUSED.
service_token: str = "" service_token: str = ""
# ⚠️ FAIL-CLOSED GATE. Full RS256/ES256 JWKS verification lands in G3.2. # ⚠️ FAIL-CLOSED GATE. Human tokens are verified against the hub's JWKS
# Until it does, the human token path must not be reachable in production — # (G3.2, hub_jwt.py). False only enables the unverified local-dev path, and
# accepting an unverified JWT is not a shortcut, it is an authentication # production verifies regardless — an unverified JWT is a bypass, not a
# bypass. Agents are unaffected: their authority comes from a live Eternitas # shortcut.
# trust lookup, not from anything the token asserts about itself.
require_verified_jwt: bool = True require_verified_jwt: bool = True
# ---- storage law (I-3, G4.4) ------------------------------------------ # ---- storage law (I-3, G4.4) ------------------------------------------

129
api/app/hub_jwt.py Normal file
View 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")
)

156
api/tests/test_hub_jwt.py Normal file
View File

@@ -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"]

View File

@@ -308,12 +308,13 @@ def test_g36_trust_client_never_soft_allows():
def test_g36_unverified_human_jwt_is_refused_in_production(): def test_g36_unverified_human_jwt_is_refused_in_production():
"""I-8 applied to ourselves: an unverified JWT is an authentication bypass, """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 from api.app.config import Settings
assert Settings().require_verified_jwt is True assert Settings().require_verified_jwt is True
src = (ROOT / "api" / "app" / "auth.py").read_text() assert Settings().hub_issuers == ["windy-identity"]
assert "human_signin_not_ready" in src
def test_no_auth_bypass_env_var_anywhere(): def test_no_auth_bypass_env_var_anywhere():