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

@@ -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) ------------------------------------------