From 448834373d1a7e2ae9e22cce0b111bb8a9538ca8 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Wed, 30 Sep 2026 18:14:31 +0800 Subject: [PATCH] Integrate validated OAuth authority and bounded Range fixes --- .github/workflows/integration-regressions.yml | 4 +- src/msg/extensions/hosting.py | 2 +- src/msg/security/authentication.py | 12 +- src/msg/security/oauth.py | 70 ++++++-- src/msg/transports/http_routes.py | 6 +- src/msg/transports/oauth_http.py | 9 +- tests/test_hosting_same_origin.py | 9 + tests/test_http_range_bounds.py | 70 ++++++++ tests/test_oauth.py | 17 +- tests/test_oauth_authority.py | 72 ++++++++ tests/test_oauth_custodial_ceiling.py | 162 ++++++++++++++++++ 11 files changed, 408 insertions(+), 25 deletions(-) create mode 100644 tests/test_http_range_bounds.py create mode 100644 tests/test_oauth_authority.py create mode 100644 tests/test_oauth_custodial_ceiling.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/src/msg/extensions/hosting.py b/src/msg/extensions/hosting.py index 602b31d8..368a79c2 100644 --- a/src/msg/extensions/hosting.py +++ b/src/msg/extensions/hosting.py @@ -330,7 +330,7 @@ def hosted_range(request, size, etag): requested = request.headers.get('range') if not requested or request.headers.get('if-range') not in {None, etag}: return (0, size), 200, {} - match = re.fullmatch(r'bytes=(\d*)-(\d*)', requested) + match = re.fullmatch(r'bytes=(\d*)-(\d*)', requested) if len(requested) <= 128 else None require(match is not None and any(match.groups()), 'range_not_satisfiable') left, right = match.groups() if left: 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 273d7cd7..383943b5 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,7 +68,25 @@ def scopes_for(client, value): return scopes -async def require_source(tx, body, now): +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 + ]), + 'invalid_grant', + ) + + +async def require_source(tx, body, now, *, custodial_ceiling): parent = await tx.credential(body['parent']) subject = await tx.subject(body['subject']) require( @@ -85,19 +104,32 @@ async def require_source(tx, body, now): (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_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, (await tx.credential(source['parent'])).ceiling + ) row = tx.one('SELECT body FROM oauth_states WHERE id=?', ('access:' + credential.id,)) if row is None: require( @@ -111,7 +143,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_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: @@ -241,7 +276,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': @@ -259,7 +294,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') @@ -349,7 +386,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) @@ -373,7 +410,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. @@ -381,11 +418,10 @@ 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) + 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) @@ -496,7 +532,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/src/msg/transports/http_routes.py b/src/msg/transports/http_routes.py index de81cb19..fee84e1f 100644 --- a/src/msg/transports/http_routes.py +++ b/src/msg/transports/http_routes.py @@ -2431,7 +2431,11 @@ async def dispatch(request: Request): status = 200 requested_range = request.headers.get('range') if requested_range and (request.headers.get('if-range') in {None, etag}): - match = re.fullmatch(r'bytes=(\d*)-(\d*)', requested_range) + match = ( + re.fullmatch(r'bytes=(\d*)-(\d*)', requested_range) + if len(requested_range) <= 128 + else None + ) require(match is not None and any(match.groups()), 'range_not_satisfiable') left, right = match.groups() if left: 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_hosting_same_origin.py b/tests/test_hosting_same_origin.py index da692d4b..f30e98ed 100644 --- a/tests/test_hosting_same_origin.py +++ b/tests/test_hosting_same_origin.py @@ -91,6 +91,15 @@ async def test_same_domain_hosted_html_head_304_range_raw_and_no_write_route(ins partial = await http.get('/@web-owner/web/index.html', headers={'Range': 'bytes=0-14'}) assert partial.status_code == 206 and partial.content == html[:15] isolated(partial) + for malformed in ( + 'bytes=' + '9' * 5000 + '-', + 'bytes=0-' + '9' * 5000, + 'bytes=-' + '9' * 5000, + ): + refused = await http.get('/@web-owner/web/index.html', headers={'Range': malformed}) + assert refused.status_code == 416 + assert refused.json()['error'] == 'range_not_satisfiable' + isolated(refused) raw = await http.get(f'/_read/{published}/raw') assert raw.status_code == 200 assert 'attachment' in raw.headers['content-disposition'] diff --git a/tests/test_http_range_bounds.py b/tests/test_http_range_bounds.py new file mode 100644 index 00000000..1f0329a7 --- /dev/null +++ b/tests/test_http_range_bounds.py @@ -0,0 +1,70 @@ +"""Malformed Range fields have bounded parsing and stable client errors.""" + +import pytest +from starlette.requests import Request + +from msg.core.errors import Failure +from msg.extensions.hosting import hosted_range + + +@pytest.mark.parametrize( + 'value', + ['bytes=' + '9' * 5000 + '-', 'bytes=0-' + '9' * 5000, 'bytes=-' + '9' * 5000], + ids=['start', 'end', 'suffix'], +) +def test_hosted_range_rejects_oversized_numbers_with_contract_error(value): + request = Request({'type': 'http', 'headers': [(b'range', value.encode())]}) + with pytest.raises(Failure, match='range_not_satisfiable'): + hosted_range(request, 12, '"etag"') + + +@pytest.mark.parametrize( + 'value,expected', + [('bytes=0-4', (0, 5)), ('bytes=5-', (5, 12)), ('bytes=-5', (7, 12)), ('bytes=-99', (0, 12))], +) +def test_hosted_range_keeps_normal_prefix_open_and_suffix_ranges(value, expected): + request = Request({'type': 'http', 'headers': [(b'range', value.encode())]}) + interval, status, _ = hosted_range(request, 12, '"etag"') + assert interval == expected and status == 206 + + +def test_stale_if_range_ignores_range_before_parsing(): + request = Request({ + 'type': 'http', + 'headers': [(b'range', b'bytes=' + b'9' * 5000 + b'-'), (b'if-range', b'"stale"')], + }) + assert hosted_range(request, 12, '"etag"') == ((0, 12), 200, {}) + + +@pytest.mark.asyncio +async def test_raw_http_rejects_oversized_range_without_read_effects(installed, monkeypatch): + import httpx + from read_only_evidence import readonly_evidence + from test_service import call, register + + from msg.transports.http import create_app + + app, _ = installed + key, subject, _ = await register(app, 'range-reader') + created = await call( + app, + 'content.post_create', + {'parent': '/main', 'body': 'range content'}, + key=key, + subject=subject, + ) + assert created.status == 'ok' + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + async with readonly_evidence(app, monkeypatch): + for value in ( + 'bytes=' + '9' * 5000 + '-', + 'bytes=0-' + '9' * 5000, + 'bytes=-' + '9' * 5000, + ): + result = await http.get( + '/_id/' + created.resources[0].id + '/raw', headers={'Range': value} + ) + assert result.status_code == 416 + assert result.json()['error']['code'] == 'range_not_satisfiable' 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_authority.py b/tests/test_oauth_authority.py new file mode 100644 index 00000000..251390e8 --- /dev/null +++ b/tests/test_oauth_authority.py @@ -0,0 +1,72 @@ +"""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']}, + **({'json': {}} if method == 'POST' else {}), + ) + assert response.status_code == 200, response.text + assert response.json() == {'sub': subject, 'preferred_username': 'oauth-owner'} diff --git a/tests/test_oauth_custodial_ceiling.py b/tests/test_oauth_custodial_ceiling.py new file mode 100644 index 00000000..005ea1a9 --- /dev/null +++ b/tests/test_oauth_custodial_ceiling.py @@ -0,0 +1,162 @@ +"""A vault identity binding is not a request grant or an unlimited ceiling.""" + +from datetime import UTC, datetime, timedelta +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.oauth_config import OAuthConfig +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 = self.oauth = 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({ + 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 + + +@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.oauth = True + credential = SimpleNamespace( + 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, + OAuthConfig(enabled=True), + custodial_ceiling=(grant('read', 'write'),), + ) + else: + await require_binding( + 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=())