import asyncio import json import time import httpx import jwt import pytest from cryptography.hazmat.primitives.asymmetric import rsa from hub_core.security.identity import AccessFailure, OIDCVerifier @pytest.fixture(scope='module') def signing_key(): return rsa.generate_private_key(public_exponent=65537, key_size=2048) def setup(signing_key, changes=None, header_changes=None): now = int(time.time()) claims = dict(iss='https://issuer.example', sub='immutable-root', aud='hub-core', iat=now, exp=now+300, nbf=now, tenant='tenant:platform', principal_type='human', groups=[], roles=[], scope='openid', assurance=dict(level='aal2', methods=['pwd', 'otp'], mfa=True, source='key-cape', at=now)) claims.update(changes or {}) headers = {'kid': 'key-1', 'typ': 'at+jwt', **(header_changes or {})} token = jwt.encode(claims, signing_key, algorithm='RS256', headers=headers) jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(signing_key.public_key())) jwk.update(kid='key-1', use='sig', alg='RS256') responses = {'discovery': 200, 'keys': [jwk]} def handle(request): if request.url.path.endswith('openid-configuration'): return httpx.Response(responses['discovery'], json={ 'issuer': 'https://issuer.example', 'jwks_uri': 'https://issuer.example/keys', }) return httpx.Response(200, json={'keys': responses['keys']}) client = httpx.AsyncClient(transport=httpx.MockTransport(handle)) verifier = OIDCVerifier(issuer='https://issuer.example', audience='hub-core', client=client) return token, verifier, responses, client def test_accepts_valid_iam_access_token(signing_key): token, verifier, _, client = setup(signing_key) async def run(): async with client: actor = await verifier.authenticate(token) assert actor.subject == 'immutable-root' assert actor.tenant == 'tenant:platform' asyncio.run(run()) @pytest.mark.parametrize('claims,headers', [ ({'iss': 'https://evil.example'}, {}), ({'aud': 'different'}, {}), ({'exp': 1}, {}), ({'nbf': int(time.time())+3600}, {}), ({'iat': int(time.time())+3600}, {}), ({'sub': ''}, {}), ({'roles': 'platform-root'}, {}), ({'groups': {}}, {}), ({'tenant': None}, {}), ({'scope': None}, {}), ({'assurance': {'level': 'aal2'}}, {}), ({'principal_type': 'root'}, {}), ({'exp': int(time.time())+3600}, {}), ({}, {'typ': 'JWT'}), ({}, {'kid': 'unknown'}), ({'principal_type': 'agent', 'agent': {'id': 'a', 'mode': 'delegated'}}, {}), ]) def test_invalid_tokens_are_401(signing_key, claims, headers): token, verifier, _, client = setup(signing_key, claims, headers) async def run(): async with client: with pytest.raises(AccessFailure) as result: await verifier.authenticate(token) assert result.value.status == 401 asyncio.run(run()) def test_key_rotation_removes_old_trust_and_outage_fails_closed(signing_key): token, verifier, responses, client = setup(signing_key) second = rsa.generate_private_key(public_exponent=65537, key_size=2048) next_jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(second.public_key())) next_jwk.update(kid='key-2', alg='RS256', use='sig') claims = jwt.decode(token, options={'verify_signature': False}) rotated = jwt.encode(claims, second, algorithm='RS256', headers={'kid': 'key-2', 'typ': 'at+jwt'}) async def run(): async with client: await verifier.authenticate(token) responses['keys'] = [next_jwk] verifier._loaded -= 2 await verifier.authenticate(rotated) with pytest.raises(AccessFailure) as result: await verifier.authenticate(token) assert result.value.status == 401 responses['discovery'] = 503 verifier._loaded = 0 with pytest.raises(AccessFailure) as result: await verifier.authenticate(rotated) assert result.value.status == 503 asyncio.run(run()) def test_untrusted_issuer_configuration_rejected(): for issuer in ['http://issuer.example', 'https://localhost', 'https://u:p@example.com']: with pytest.raises(ValueError): OIDCVerifier(issuer=issuer, audience='hub-core', client=None)