From 6b7622dd42e7014cbfb27cea95ce2f42d2ae6361 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Wed, 30 Sep 2026 15:55:37 +0800 Subject: [PATCH 1/4] test(oauth): expose source ceiling and userinfo write boundaries --- .github/workflows/integration-regressions.yml | 4 +- tests/test_oauth_authority.py | 69 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 tests/test_oauth_authority.py diff --git a/.github/workflows/integration-regressions.yml b/.github/workflows/integration-regressions.yml index 5e6cb99a..7c55a8fa 100644 --- a/.github/workflows/integration-regressions.yml +++ b/.github/workflows/integration-regressions.yml @@ -29,6 +29,7 @@ on: - 'src/msg/storage/session.py' - 'src/msg/storage/read_only.py' - 'tests/test_oauth.py' + - 'tests/test_oauth_authority.py' - 'tests/test_http_effect_order.py' - 'tests/test_read_entry_contract_matrix.py' - 'tests/test_read_query_keyset_boundary.py' @@ -65,6 +66,7 @@ on: - 'src/msg/storage/session.py' - 'src/msg/storage/read_only.py' - 'tests/test_oauth.py' + - 'tests/test_oauth_authority.py' - 'tests/test_http_effect_order.py' - 'tests/test_read_entry_contract_matrix.py' - 'tests/test_read_query_keyset_boundary.py' @@ -182,7 +184,7 @@ jobs: - name: Run recovery, keyset, effect-order, path and webhook regressions run: >- python -m pytest -q - tests/test_oauth.py + tests/test_oauth.py tests/test_oauth_authority.py tests/test_http_effect_order.py tests/test_legacy_routes_removed.py tests/test_read_query_keyset_boundary.py tests/test_read_entry_contract_matrix.py tests/test_recovery_runtime_generation.py diff --git a/tests/test_oauth_authority.py b/tests/test_oauth_authority.py new file mode 100644 index 00000000..e055b9a0 --- /dev/null +++ b/tests/test_oauth_authority.py @@ -0,0 +1,69 @@ +"""Derived access must retain current source authority and read-only semantics.""" + +import os +from dataclasses import replace + +import pytest +from read_only_evidence import business_snapshot, readonly_evidence +from test_oauth import device_tokens, oauth as oauth +from test_service import call + +from msg.core.codec import b64, unb64, wire +from msg.core.models import Scope + + +@pytest.mark.asyncio +@pytest.mark.parametrize('kind', ['oauth', 'api']) +@pytest.mark.parametrize('contraction', ['operations', 'scope']) +async def test_derived_credentials_recheck_current_source_ceiling(oauth, kind, contraction): + app, key, subject, _http = oauth + if kind == 'oauth': + issued = await device_tokens(oauth) + credential_id, _, value = issued['access_token'].partition('.') + token = (credential_id, unb64(value)) + else: + issued = await call( + app, + 'identity.token_create', + {'nonce': b64(os.urandom(32)), 'ttl': 60, 'recovery_secret': b64(os.urandom(32))}, + key=key, + subject=subject, + contract_version=3, + ) + assert issued.status == 'ok', wire(issued) + token = (issued.data['credential_id'], unb64(issued.data['token'])) + allowed = await call(app, 'discovery.get', {'id': '/main'}, subject=subject, token=token) + assert allowed.status == 'ok', wire(allowed) + async with app.metadata.transaction(write=True) as tx: + parent = await tx.credential(key.key_id) + owner = await tx.subject(subject) + # Keep the signer, subject/auth_version, revocation and deadlines intact. + # Only the authoritative source ceiling changes, just as in #76 vectors. + ceiling = ( + () + if contraction == 'operations' + else tuple( + replace(grant, scope=Scope(resource_id='t_store', descendants=True)) + for grant in parent.ceiling + ) + ) + await tx.save_credential(replace(parent, ceiling=ceiling), owner.auth_version) + before = await business_snapshot(app) + denied = await call(app, 'discovery.get', {'id': '/main'}, subject=subject, token=token) + assert denied.status == 'error' and denied.error.code == 'invalid_grant', wire(denied) + assert await business_snapshot(app) == before + + +@pytest.mark.asyncio +@pytest.mark.parametrize('method', ['GET', 'POST']) +async def test_userinfo_is_read_only_including_authoritative_oauth_state(oauth, monkeypatch, method): + app, _key, subject, http = oauth + issued = await device_tokens(oauth) + async with readonly_evidence(app, monkeypatch): + response = await http.request( + method, + '/oauth/userinfo', + headers={'Authorization': 'Bearer ' + issued['access_token']}, + ) + assert response.status_code == 200, response.text + assert response.json() == {'sub': subject, 'preferred_username': 'oauth-owner'} From 16fc693b837fc983b19629dd7f534167de63e17e Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Wed, 30 Sep 2026 16:08:44 +0800 Subject: [PATCH 2/4] fix(oauth): enforce live source ceilings and keep userinfo reads side-effect free --- src/msg/security/oauth.py | 31 ++++++++++++++++++++++++++----- src/msg/transports/oauth_http.py | 9 ++++++--- tests/test_oauth_authority.py | 5 ++++- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/msg/security/oauth.py b/src/msg/security/oauth.py index 273d7cd7..b7121a5b 100644 --- a/src/msg/security/oauth.py +++ b/src/msg/security/oauth.py @@ -15,9 +15,10 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat -from msg.core.codec import b64, canonical, loads, parse_time, wire +from msg.core.codec import b64, canonical, decode, loads, parse_time, wire from msg.core.errors import Failure, require -from msg.core.models import Credential +from msg.core.models import CapabilityGrant, Credential +from msg.security.policy import constraints_subset, scope_subset from msg.security.quarantine import require_live_authority DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code' @@ -67,6 +68,24 @@ def scopes_for(client, value): return scopes +async def require_ceiling(tx, ceiling, parent): + # A derived credential is a limit, never a durable copy of authority which + # its signing source has since lost. Reuse the same containment vocabulary + # as certificate/delegation validation, including scope and constraints. + for grant in ceiling: + require( + any([ + allowed.capability == grant.capability + and allowed.version == grant.version + and grant.operations <= allowed.operations + and await scope_subset(grant.scope, allowed.scope, tx) + and constraints_subset(grant.constraints, allowed.constraints) + for allowed in parent.ceiling + ]), + 'invalid_grant', + ) + + async def require_source(tx, body, now): parent = await tx.credential(body['parent']) subject = await tx.subject(body['subject']) @@ -79,6 +98,9 @@ async def require_source(tx, body, now): and (parent.expires_at is None or parent.expires_at > now), 'invalid_grant', ) + await require_ceiling( + tx, tuple(decode(CapabilityGrant, raw) for raw in body.get('ceiling', ())), parent + ) if body.get('custodial'): row = tx.one( 'SELECT signing_key_id,status FROM custodial_vault WHERE subject=?', @@ -98,6 +120,7 @@ async def require_binding(tx, credential, now, config): source = loads(api[0]) require(credential.source_credential_id == source['parent'], 'invalid_grant') await require_source(tx, source, now) + await require_ceiling(tx, credential.ceiling, await tx.credential(source['parent'])) row = tx.one('SELECT body FROM oauth_states WHERE id=?', ('access:' + credential.id,)) if row is None: require( @@ -112,6 +135,7 @@ async def require_binding(tx, credential, now, config): client = client_for(config, family['client_id']) require(set(family['scopes']) <= client.scopes and not family.get('revoked'), 'invalid_grant') await require_source(tx, family, now) + await require_ceiling(tx, credential.ceiling, await tx.credential(family['parent'])) class OAuthService: @@ -381,9 +405,6 @@ async def exchange(self, tx, args): return None, 'unsupported_grant_type' async def tokens(self, tx, source, *, family_id=None, expiry=None): - from msg.core.codec import decode - from msg.core.models import CapabilityGrant - now = self.app.clock() subject = await require_source(tx, source, now) if family_id is None: diff --git a/src/msg/transports/oauth_http.py b/src/msg/transports/oauth_http.py index 886e6469..6487252c 100644 --- a/src/msg/transports/oauth_http.py +++ b/src/msg/transports/oauth_http.py @@ -235,8 +235,11 @@ async def dispatch(self, request): ], }) # Rate limits commit independently, including rejected code guesses. - async with self.service.metadata.transaction(write=True) as tx: - self.oauth.rate(tx, request.client.host if request.client else '') + # Userinfo is a read: do not mutate retained OAuth state during GET or + # POST reads. Authentication still checks the current source/family. + if path != '/oauth/userinfo': + async with self.service.metadata.transaction(write=True) as tx: + self.oauth.rate(tx, request.client.host if request.client else '') if path == '/oauth/login': require(request.method == 'GET', 'method_not_allowed') async with self.service.metadata.transaction(write=True) as tx: @@ -346,7 +349,7 @@ async def dispatch(self, request): 'method_not_allowed', ) args = await form(request) if request.method == 'POST' else {} - async with self.service.metadata.transaction(write=True) as tx: + async with self.service.metadata.transaction(write=path != '/oauth/userinfo') as tx: self.oauth.fence(tx) if path == '/oauth/device_authorization': require(set(args) <= {'client_id', 'scope'}, 'invalid_request') diff --git a/tests/test_oauth_authority.py b/tests/test_oauth_authority.py index e055b9a0..251390e8 100644 --- a/tests/test_oauth_authority.py +++ b/tests/test_oauth_authority.py @@ -56,7 +56,9 @@ async def test_derived_credentials_recheck_current_source_ceiling(oauth, kind, c @pytest.mark.asyncio @pytest.mark.parametrize('method', ['GET', 'POST']) -async def test_userinfo_is_read_only_including_authoritative_oauth_state(oauth, monkeypatch, method): +async def test_userinfo_is_read_only_including_authoritative_oauth_state( + oauth, monkeypatch, method +): app, _key, subject, http = oauth issued = await device_tokens(oauth) async with readonly_evidence(app, monkeypatch): @@ -64,6 +66,7 @@ async def test_userinfo_is_read_only_including_authoritative_oauth_state(oauth, method, '/oauth/userinfo', headers={'Authorization': 'Bearer ' + issued['access_token']}, + **({'json': {}} if method == 'POST' else {}), ) assert response.status_code == 200, response.text assert response.json() == {'sub': subject, 'preferred_username': 'oauth-owner'} From f55374a23e698d8594ea10937bced7bdf90d5acc Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Wed, 30 Sep 2026 16:38:26 +0800 Subject: [PATCH 3/4] Preserve custodial OAuth login under live policy ceilings --- src/msg/security/authentication.py | 12 ++- src/msg/security/oauth.py | 49 ++++++---- tests/test_oauth.py | 17 +++- tests/test_oauth_custodial_ceiling.py | 126 ++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 20 deletions(-) create mode 100644 tests/test_oauth_custodial_ceiling.py diff --git a/src/msg/security/authentication.py b/src/msg/security/authentication.py index df459a56..f5f69384 100644 --- a/src/msg/security/authentication.py +++ b/src/msg/security/authentication.py @@ -114,7 +114,9 @@ async def authenticate(self, request, session, *, entry): old = await session.credential(request.arguments['credential_id']) from msg.security.oauth import require_binding - await require_binding(session, old, now, self.oauth_config) + await require_binding( + session, old, now, self.oauth_config, custodial_ceiling=self.temporary_ceiling() + ) require( old.kind == 'token' and old.subject_id == request.subject, 'recovery_unavailable' ) @@ -193,7 +195,13 @@ async def authenticate(self, request, session, *, entry): ids = () from msg.security.oauth import require_binding - await require_binding(session, credential, now, self.oauth_config) + await require_binding( + session, + credential, + now, + self.oauth_config, + custodial_ceiling=self.temporary_ceiling(), + ) else: raise Failure('invalid_proof') if credential.revoked_at is not None: diff --git a/src/msg/security/oauth.py b/src/msg/security/oauth.py index b7121a5b..7286a686 100644 --- a/src/msg/security/oauth.py +++ b/src/msg/security/oauth.py @@ -80,13 +80,13 @@ async def require_ceiling(tx, ceiling, parent): and grant.operations <= allowed.operations and await scope_subset(grant.scope, allowed.scope, tx) and constraints_subset(grant.constraints, allowed.constraints) - for allowed in parent.ceiling + for allowed in parent ]), 'invalid_grant', ) -async def require_source(tx, body, now): +async def require_source(tx, body, now, *, custodial_ceiling): parent = await tx.credential(body['parent']) subject = await tx.subject(body['subject']) require( @@ -98,29 +98,36 @@ async def require_source(tx, body, now): and (parent.expires_at is None or parent.expires_at > now), 'invalid_grant', ) - await require_ceiling( - tx, tuple(decode(CapabilityGrant, raw) for raw in body.get('ceiling', ())), parent - ) if body.get('custodial'): row = tx.one( 'SELECT signing_key_id,status FROM custodial_vault WHERE subject=?', (subject.resource_id,), ) require(subject.kind == 'custodial' and row == (parent.id, 'active'), 'invalid_grant') + # Vault keys deliberately carry no request grants. Custodial sessions retain + # their captured token limits under the current temporary policy, while the + # vault key above remains only their live identity/revocation binding. + await require_ceiling( + tx, + tuple(decode(CapabilityGrant, raw) for raw in body.get('ceiling', ())), + custodial_ceiling if body.get('custodial') else parent.ceiling, + ) if body.get('session'): _, session = get(tx, body['session'], now) require(not session.get('revoked'), 'invalid_grant') - await require_source(tx, session, now) + await require_source(tx, session, now, custodial_ceiling=custodial_ceiling) return subject -async def require_binding(tx, credential, now, config): +async def require_binding(tx, credential, now, config, *, custodial_ceiling): api = tx.one('SELECT body FROM oauth_states WHERE id=?', ('api:' + credential.id,)) if api is not None: source = loads(api[0]) require(credential.source_credential_id == source['parent'], 'invalid_grant') - await require_source(tx, source, now) - await require_ceiling(tx, credential.ceiling, await tx.credential(source['parent'])) + await require_source(tx, source, now, custodial_ceiling=custodial_ceiling) + await require_ceiling( + tx, credential.ceiling, tuple(decode(CapabilityGrant, raw) for raw in source['ceiling']) + ) row = tx.one('SELECT body FROM oauth_states WHERE id=?', ('access:' + credential.id,)) if row is None: require( @@ -134,8 +141,10 @@ async def require_binding(tx, credential, now, config): require(credential.source_credential_id == family['parent'], 'invalid_grant') client = client_for(config, family['client_id']) require(set(family['scopes']) <= client.scopes and not family.get('revoked'), 'invalid_grant') - await require_source(tx, family, now) - await require_ceiling(tx, credential.ceiling, await tx.credential(family['parent'])) + await require_source(tx, family, now, custodial_ceiling=custodial_ceiling) + await require_ceiling( + tx, credential.ceiling, tuple(decode(CapabilityGrant, raw) for raw in family['ceiling']) + ) class OAuthService: @@ -265,7 +274,7 @@ async def pending_result(self, tx, value, client_id, kind): return None, 'authorization_pending' if body['status'] == 'denied': return None, 'access_denied' - await require_source(tx, body, now) + await require_source(tx, body, now, custodial_ceiling=self.app.temporary_ceiling()) body['status'] = 'consumed' save(tx, locator['user'], body) if kind == 'login': @@ -283,7 +292,9 @@ async def session(self, tx, cookie): id = state_id('session', cookie) expiry, body = get(tx, id, self.app.clock()) require(not body.get('revoked'), 'invalid_grant') - await require_source(tx, body, self.app.clock()) + await require_source( + tx, body, self.app.clock(), custodial_ceiling=self.app.temporary_ceiling() + ) source = { name: body[name] for name in ('subject', 'parent', 'auth_version', 'custodial', 'ceiling', 'auth_time') @@ -373,7 +384,7 @@ async def exchange(self, tx, args): ), 'invalid_grant', ) - await require_source(tx, body, now) + await require_source(tx, body, now, custodial_ceiling=self.app.temporary_ceiling()) body['consumed'] = True body['family'] = 'family:' + uuid4().hex save(tx, id, body) @@ -397,7 +408,7 @@ async def exchange(self, tx, args): require(not family.get('revoked'), 'invalid_grant') client = client_for(self.config, family['client_id']) require(set(family['scopes']) <= client.scopes, 'invalid_scope') - await require_source(tx, family, now) + await require_source(tx, family, now, custodial_ceiling=self.app.temporary_ceiling()) old['used'] = True save(tx, id, old) # A refresh does not extend the family's absolute lifetime. @@ -406,7 +417,9 @@ async def exchange(self, tx, args): async def tokens(self, tx, source, *, family_id=None, expiry=None): now = self.app.clock() - subject = await require_source(tx, source, now) + subject = await require_source( + tx, source, now, custodial_ceiling=self.app.temporary_ceiling() + ) if family_id is None: family_id = 'family:' + uuid4().hex expiry = now + timedelta(seconds=self.config.session_ttl) @@ -517,7 +530,9 @@ async def bearer(self, tx, value): 'invalid_token', ) try: - await require_binding(tx, credential, now, self.config) + await require_binding( + tx, credential, now, self.config, custodial_ceiling=self.app.temporary_ceiling() + ) except Failure as exc: if exc.code in {'recovery_runtime_stale', 'recovery_quarantined'}: raise diff --git a/tests/test_oauth.py b/tests/test_oauth.py index ecf441f8..29bf0965 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -364,7 +364,7 @@ async def test_api_key_private_signature_rotation_delivery_and_header(oauth, tmp @pytest.mark.asyncio -async def test_custodial_signup_login_and_device_consent_reuses_vault(oauth): +async def test_custodial_signup_login_and_device_consent_reuses_vault(oauth, monkeypatch): app, _, _, http = oauth await http.get('/oauth/signup') binder = http.cookies.get('msg_login') @@ -413,6 +413,21 @@ async def test_custodial_signup_login_and_device_consent_reuses_vault(oauth): assert response.status_code == 200, response.text async with app.metadata.transaction(write=False) as tx: assert tx.one('SELECT COUNT(*) FROM custodial_vault')[0] == 1 + key_id = tx.one('SELECT signing_key_id FROM custodial_vault')[0] + assert (await tx.credential(key_id)).ceiling == () + access = response.json()['access_token'] + assert ( + await http.get('/oauth/userinfo', headers={'Authorization': 'Bearer ' + access}) + ).status_code == 200 + # The vault never receives request grants to make login work. Current policy + # loss must invalidate already-issued custodial access without mutating reads. + from read_only_evidence import readonly_evidence + + monkeypatch.setattr(app, 'temporary_ceiling', lambda: ()) + monkeypatch.setattr(app.authenticator, 'temporary_ceiling', lambda: ()) + async with readonly_evidence(app, monkeypatch): + denied = await http.get('/oauth/userinfo', headers={'Authorization': 'Bearer ' + access}) + assert denied.status_code == 401 and denied.json()['error'] == 'invalid_token' @pytest.mark.parametrize( diff --git a/tests/test_oauth_custodial_ceiling.py b/tests/test_oauth_custodial_ceiling.py new file mode 100644 index 00000000..40d99bc5 --- /dev/null +++ b/tests/test_oauth_custodial_ceiling.py @@ -0,0 +1,126 @@ +"""A vault identity binding is not a request grant or an unlimited ceiling.""" + +from datetime import UTC, datetime +from types import SimpleNamespace + +import pytest + +from msg.core.codec import canonical, wire +from msg.core.errors import Failure +from msg.core.models import CapabilityGrant, Scope +from msg.security.oauth import require_binding, require_source + +NOW = datetime(2026, 9, 27, tzinfo=UTC) + + +def grant(*operations, descendants=True): + return CapabilityGrant( + capability='content', + version=1, + scope=Scope(resource_id='root', descendants=descendants), + operations=frozenset(operations), + constraints={}, + ) + + +class SourceSession: + def __init__(self, *, custodial=True): + self.parent = SimpleNamespace( + id='key', + subject_id='user', + revoked_at=None, + not_before=NOW, + expires_at=None, + ceiling=() if custodial else (grant('read'),), + ) + self.user = SimpleNamespace( + resource_id='user', + local_only=False, + auth_version=0, + kind='custodial' if custodial else 'registered', + ) + self.vault = ('key', 'active') + self.body = { + 'parent': 'key', + 'subject': 'user', + 'auth_version': 0, + 'custodial': custodial, + 'ceiling': wire((grant('read'),)), + } + self.api = False + + async def credential(self, _id): + return self.parent + + async def subject(self, _id): + return self.user + + def one(self, query, args): + if 'custodial_vault' in query: + return self.vault + if self.api and args[0] == 'api:t_api_test': + return (canonical(self.body).decode(),) + return None + + +@pytest.mark.asyncio +async def test_active_vault_with_empty_request_ceiling_preserves_captured_policy(): + tx = SourceSession() + assert await require_source(tx, tx.body, NOW, custodial_ceiling=(grant('read'),)) is tx.user + assert tx.parent.ceiling == () + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'fault', ['empty_policy', 'narrow_scope', 'revoked', 'inactive', 'foreign_vault'] +) +async def test_custodial_source_still_fails_closed_on_current_authority_loss(fault): + tx = SourceSession() + policy = (grant('read'),) + if fault == 'empty_policy': + policy = () + elif fault == 'narrow_scope': + policy = (grant('read', descendants=False),) + elif fault == 'revoked': + tx.parent.revoked_at = NOW + elif fault == 'inactive': + tx.vault = ('key', 'destroyed') + else: + tx.vault = ('other-key', 'active') + with pytest.raises(Failure, match='invalid_grant'): + await require_source(tx, tx.body, NOW, custodial_ceiling=policy) + + +@pytest.mark.asyncio +async def test_empty_registered_key_ceiling_never_uses_custodial_policy(): + tx = SourceSession(custodial=False) + tx.parent.ceiling = () + with pytest.raises(Failure, match='invalid_grant'): + await require_source(tx, tx.body, NOW, custodial_ceiling=(grant('read'),)) + + +@pytest.mark.asyncio +async def test_registered_source_remains_independent_of_custodial_policy(): + tx = SourceSession(custodial=False) + assert await require_source(tx, tx.body, NOW, custodial_ceiling=()) is tx.user + + +@pytest.mark.asyncio +@pytest.mark.parametrize('expanded', [False, True]) +async def test_derived_credential_cannot_exceed_captured_custodial_grants(expanded): + tx = SourceSession() + tx.api = True + credential = SimpleNamespace( + id='t_api_test', + source_credential_id='key', + ceiling=(grant('read', 'write') if expanded else grant('read'),), + ) + if expanded: + with pytest.raises(Failure, match='invalid_grant'): + await require_binding( + tx, credential, NOW, None, custodial_ceiling=(grant('read', 'write'),) + ) + else: + await require_binding( + tx, credential, NOW, None, custodial_ceiling=(grant('read', 'write'),) + ) From c92135bb96df5e46b02f8fc3246f2f2f6b5c0f5f Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Wed, 30 Sep 2026 16:49:52 +0800 Subject: [PATCH 4/4] Preserve API binding state shape in source ceiling validation --- src/msg/security/oauth.py | 4 ++- tests/test_oauth_custodial_ceiling.py | 50 +++++++++++++++++++++++---- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/msg/security/oauth.py b/src/msg/security/oauth.py index 7286a686..383943b5 100644 --- a/src/msg/security/oauth.py +++ b/src/msg/security/oauth.py @@ -125,8 +125,10 @@ async def require_binding(tx, credential, now, config, *, custodial_ceiling): source = loads(api[0]) require(credential.source_credential_id == source['parent'], 'invalid_grant') await require_source(tx, source, now, custodial_ceiling=custodial_ceiling) + # API bindings predate OAuth families and store no captured ceiling. + # Their credential grants remain bounded by the current signing source. await require_ceiling( - tx, credential.ceiling, tuple(decode(CapabilityGrant, raw) for raw in source['ceiling']) + tx, credential.ceiling, (await tx.credential(source['parent'])).ceiling ) row = tx.one('SELECT body FROM oauth_states WHERE id=?', ('access:' + credential.id,)) if row is None: diff --git a/tests/test_oauth_custodial_ceiling.py b/tests/test_oauth_custodial_ceiling.py index 40d99bc5..005ea1a9 100644 --- a/tests/test_oauth_custodial_ceiling.py +++ b/tests/test_oauth_custodial_ceiling.py @@ -1,6 +1,6 @@ """A vault identity binding is not a request grant or an unlimited ceiling.""" -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from types import SimpleNamespace import pytest @@ -8,6 +8,7 @@ from msg.core.codec import canonical, wire from msg.core.errors import Failure from msg.core.models import CapabilityGrant, Scope +from msg.oauth_config import OAuthConfig from msg.security.oauth import require_binding, require_source NOW = datetime(2026, 9, 27, tzinfo=UTC) @@ -47,7 +48,7 @@ def __init__(self, *, custodial=True): 'custodial': custodial, 'ceiling': wire((grant('read'),)), } - self.api = False + self.api = self.oauth = False async def credential(self, _id): return self.parent @@ -59,7 +60,18 @@ def one(self, query, args): if 'custodial_vault' in query: return self.vault if self.api and args[0] == 'api:t_api_test': - return (canonical(self.body).decode(),) + return ( + canonical({ + name: self.body[name] for name in ('subject', 'parent', 'auth_version') + }).decode(), + ) + if self.oauth and args[0] == 'access:t_oauth_test': + return (canonical({'family': 'family:test'}).decode(),) + if self.oauth and args[0] == 'family:test': + return ( + wire(NOW + timedelta(hours=1)), + canonical({**self.body, 'client_id': 'msg-cli', 'scopes': ['msg.read']}).decode(), + ) return None @@ -109,18 +121,42 @@ async def test_registered_source_remains_independent_of_custodial_policy(): @pytest.mark.parametrize('expanded', [False, True]) async def test_derived_credential_cannot_exceed_captured_custodial_grants(expanded): tx = SourceSession() - tx.api = True + tx.oauth = True credential = SimpleNamespace( - id='t_api_test', + id='t_oauth_test', source_credential_id='key', ceiling=(grant('read', 'write') if expanded else grant('read'),), ) if expanded: with pytest.raises(Failure, match='invalid_grant'): await require_binding( - tx, credential, NOW, None, custodial_ceiling=(grant('read', 'write'),) + tx, + credential, + NOW, + OAuthConfig(enabled=True), + custodial_ceiling=(grant('read', 'write'),), ) else: await require_binding( - tx, credential, NOW, None, custodial_ceiling=(grant('read', 'write'),) + tx, + credential, + NOW, + OAuthConfig(enabled=True), + custodial_ceiling=(grant('read', 'write'),), ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('contracted', [False, True]) +async def test_api_binding_without_captured_ceiling_uses_current_signing_key(contracted): + tx = SourceSession(custodial=False) + tx.api = True + credential = SimpleNamespace( + id='t_api_test', source_credential_id='key', ceiling=(grant('read'),) + ) + if contracted: + tx.parent.ceiling = () + with pytest.raises(Failure, match='invalid_grant'): + await require_binding(tx, credential, NOW, None, custodial_ceiling=()) + else: + await require_binding(tx, credential, NOW, None, custodial_ceiling=())