Files
windy-git/api/tests/test_hub_jwt.py
Grant Whitmer 5b16114b98
Some checks failed
check / gate (push) Successful in 37s
canary / probe (push) Has been cancelled
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>
2026-09-23 02:58:56 -04:00

170 lines
5.4 KiB
Python

"""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")))
# 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
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_or_missing_type_is_refused():
await _refused(_sign(_claims(type="agent")))
await _refused(_sign(_claims(type=None)))
@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", "windy_mail"])))
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"]