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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user