From 5b16114b98ce6b5f8989c32bcd41aa1215c6f83b Mon Sep 17 00:00:00 2001 From: Grant Whitmer Date: Wed, 23 Sep 2026 02:58:56 -0400 Subject: [PATCH] auth: token contract v1 (aud windy_git, both issuers); CI for eternitas - hub_jwt: aud list is ["windy_git"] (contract v1 array). Dropped "windy-git": that is Gitea's OIDC client_id, so a forge id_token would have passed the aud check. `type: human` is now REQUIRED (id_tokens have none), which makes accepting the discovery-URL issuer safe. - runner job ceiling 30m -> 90m: eternitas's serial pytest is ~50 min and would have been killed mid-suite. - eternitas (private) added to the GitHub status bridge. Co-Authored-By: Claude Opus 5.5 --- api/app/config.py | 15 +++++++++------ api/app/hub_jwt.py | 18 +++++++++++------- api/tests/test_hub_jwt.py | 25 +++++++++++++++++++------ api/tests/test_invariants.py | 2 +- deploy/runner/config.yaml | 3 ++- scripts/pr_status_bridge.py | 2 +- 6 files changed, 43 insertions(+), 22 deletions(-) diff --git a/api/app/config.py b/api/app/config.py index c450b3b..1008b45 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -54,12 +54,15 @@ 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"] + # Token contract v1 (lane 8c, 2026-09-23): access tokens may carry either + # issuer. id_tokens are kept out by `type` + `windy_identity_id` + aud, not + # by issuer. + hub_issuers: list[str] = ["windy-identity", "https://account.windyword.ai"] + # Contract v1: aud is an ARRAY; first-party tokens list every product, and + # Windy Git's entry is `windy_git` (underscore). ⚠️ NEVER add "windy-git" + # (hyphen): that is Gitea's OIDC client_id, so an id_token minted for the + # forge would carry it and pass as a bearer here. + hub_audiences: list[str] = ["windy_git"] # Flip to True once the hub emits aud on every access token. hub_require_aud: bool = False diff --git a/api/app/hub_jwt.py b/api/app/hub_jwt.py index 3163061..1603478 100644 --- a/api/app/hub_jwt.py +++ b/api/app/hub_jwt.py @@ -7,7 +7,7 @@ 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 + claims iss = "windy-identity" (contract v1 also allows the discovery 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) @@ -18,17 +18,19 @@ 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. +* **An id_token used as a bearer.** id_tokens prove a login happened to a + relying party (for the forge: aud `windy-git`), not that this caller may act + here. They carry no `type` and no `windy_identity_id`, and their aud is a + client id, not the product name `windy_git` — any one of the three refuses. * **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. +`aud` (token contract v1, lane 8c): an array; first-party tokens list every +product and Windy Git's is `windy_git`. Optional until the hub emits it; when +present it MUST include `windy_git`. `hub_require_aud=True` makes it mandatory — flip it once the hub emits it. """ @@ -117,7 +119,9 @@ def verify_hub_token( if not presented & set(audiences): raise HubTokenInvalid(f"aud {sorted(presented)} does not name Windy Git") - if claims.get("type", "human") != "human": + # REQUIRED, not defaulted: id_tokens carry no `type`, and this is one of the + # two claims (with windy_identity_id) that keep them from acting as bearers. + if claims.get("type") != "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") diff --git a/api/tests/test_hub_jwt.py b/api/tests/test_hub_jwt.py index b768caa..b545bde 100644 --- a/api/tests/test_hub_jwt.py +++ b/api/tests/test_hub_jwt.py @@ -99,8 +99,20 @@ async def test_expired_token_is_refused(): @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"))) + # Contract v1: the discovery-URL issuer is legal for ACCESS tokens... + c = await _caller(_sign(_claims(iss="https://account.windyword.ai"))) + assert c.identity_id == IDENTITY + # ...but an id_token minted for the forge (aud = Gitea's client id + # "windy-git", no type, sub = identity) must never act as a bearer here. + id_token = _claims( + iss="https://account.windyword.ai", + aud="windy-git", + type=None, + windy_identity_id=None, + sub=IDENTITY, + ) + await _refused(_sign(id_token)) + await _refused(_sign(dict(id_token, windy_identity_id=IDENTITY, type="human"))) @pytest.mark.asyncio @@ -120,8 +132,9 @@ async def test_hs256_confusion_is_refused(): @pytest.mark.asyncio -async def test_non_human_type_is_refused(): +async def test_non_human_or_missing_type_is_refused(): await _refused(_sign(_claims(type="agent"))) + await _refused(_sign(_claims(type=None))) @pytest.mark.asyncio @@ -133,15 +146,15 @@ async def test_missing_windy_identity_is_refused_not_read_from_sub(): 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"]))) + c = await _caller(_sign(_claims(aud=["windy_chat", "windy_git", "windy_mail"]))) assert c.identity_id == IDENTITY - await _refused(_sign(_claims(aud="windy-chat"))) + 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) + c = await _caller(_sign(_claims(aud=["windy_git"])), hub_require_aud=True) assert c.identity_id == IDENTITY diff --git a/api/tests/test_invariants.py b/api/tests/test_invariants.py index 30fbf9d..a1436a0 100644 --- a/api/tests/test_invariants.py +++ b/api/tests/test_invariants.py @@ -314,7 +314,7 @@ def test_g36_unverified_human_jwt_is_refused_in_production(): from api.app.config import Settings assert Settings().require_verified_jwt is True - assert Settings().hub_issuers == ["windy-identity"] + assert "windy-git" not in Settings().hub_audiences # Gitea's client_id: id_token confusion def test_no_auth_bypass_env_var_anywhere(): diff --git a/deploy/runner/config.yaml b/deploy/runner/config.yaml index 50d66ef..490fda9 100644 --- a/deploy/runner/config.yaml +++ b/deploy/runner/config.yaml @@ -12,7 +12,8 @@ log: runner: file: /data/.runner capacity: 1 # per runner; parallelism = number of runner services (6). See docker-compose.yml - timeout: 30m + timeout: 90m # hard ceiling per job. eternitas's serial pytest is ~50 min; keep + # timeout-minutes in each workflow — a hang still reads as a hang shutdown_timeout: 3m insecure: false fetch_timeout: 5s diff --git a/scripts/pr_status_bridge.py b/scripts/pr_status_bridge.py index b8f24d6..cfdd5fc 100755 --- a/scripts/pr_status_bridge.py +++ b/scripts/pr_status_bridge.py @@ -46,7 +46,7 @@ WG_OWNER = os.environ.get("WINDYGIT_OWNER", "windyadmin") REPOS = os.environ.get( "BRIDGE_REPOS", "windy-chat windy-mail windy-calendar Windy-Clone WindyCloud windy-search windy-connect" - " windy-drops windy-code-web windy-code windy-traveler windy-registry", + " windy-drops windy-code-web windy-code windy-traveler windy-registry eternitas", ).split() # Gitea run status -> GitHub status state. `skipped` is deliberately absent: a