auth: token contract v1 (aud windy_git, both issuers); CI for eternitas
Some checks failed
check / gate (push) Successful in 37s
canary / probe (push) Has been cancelled

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 02:58:56 -04:00
parent 18ea9a4686
commit 5b16114b98
6 changed files with 43 additions and 22 deletions

View File

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

View File

@@ -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: <published at /.well-known/jwks.json>}
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")

View File

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

View File

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