From c464cad8f6cb5d198a7f20953e6ef4dcbf1e7c1f Mon Sep 17 00:00:00 2001 From: Ivan Wei Date: Fri, 7 Aug 2026 17:21:52 -0400 Subject: [PATCH 1/3] feat: overlay IssuerConfiguration on .well-known metadata and harden offer parsing Signed-off-by: Ivan Wei --- oid4vc/oid4vc/models/issuer_config.py | 23 +- oid4vc/oid4vc/public_routes/metadata.py | 147 ++++++------ oid4vc/oid4vc/routes/helpers.py | 27 ++- .../oid4vc/tests/routes/test_public_routes.py | 222 ++++++++++++++++++ oid4vc/oid4vc/tests/test_routes.py | 5 +- 5 files changed, 338 insertions(+), 86 deletions(-) diff --git a/oid4vc/oid4vc/models/issuer_config.py b/oid4vc/oid4vc/models/issuer_config.py index 6ee32e8b4..2c76f3d85 100644 --- a/oid4vc/oid4vc/models/issuer_config.py +++ b/oid4vc/oid4vc/models/issuer_config.py @@ -73,25 +73,34 @@ def record_value(self) -> dict: return {prop: getattr(self, prop) for prop in self.ISSUER_ATTRS} def issuer_metadata(self, base_url: str) -> dict: - """Return a representation of this record as issuer metadata.""" + """Return this record as OID4VCI §12.2.4 Credential Issuer metadata. + + Emits every non-null attribute in :data:`ISSUER_ATTRS`. Normalizes + `authorization_servers` to public URL strings (§12.2.4 array-of-strings + form). Fills the two REQUIRED fields (`credential_issuer`, + `credential_endpoint`) from `base_url` when absent; optional fields + stay absent unless configured. + """ metadata: dict[str, Any] = { prop: getattr(self, prop) for prop in self.ISSUER_ATTRS if getattr(self, prop) is not None } if metadata.get("authorization_servers"): - metadata["authorization_servers"] = [ - server.get("public_url", None) + # §12.2.4: array of strings, non-empty. Drop entries without public_url. + public_urls = [ + server["public_url"] for server in metadata["authorization_servers"] + if server.get("public_url") ] + if public_urls: + metadata["authorization_servers"] = public_urls + else: + metadata.pop("authorization_servers") if not metadata.get("credential_issuer"): metadata["credential_issuer"] = base_url if not metadata.get("credential_endpoint"): metadata["credential_endpoint"] = f"{base_url}/credential" - if not metadata.get("nonce_endpoint"): - metadata["nonce_endpoint"] = f"{base_url}/nonce" - if not metadata.get("notification_endpoint"): - metadata["notification_endpoint"] = f"{base_url}/notification" return metadata diff --git a/oid4vc/oid4vc/public_routes/metadata.py b/oid4vc/oid4vc/public_routes/metadata.py index 584cb0dd2..7c3cef77d 100644 --- a/oid4vc/oid4vc/public_routes/metadata.py +++ b/oid4vc/oid4vc/public_routes/metadata.py @@ -7,6 +7,7 @@ from acapy_agent.admin.request_context import AdminRequestContext from acapy_agent.messaging.models.openapi import OpenAPISchema +from acapy_agent.storage.error import StorageNotFoundError from acapy_agent.wallet.error import WalletError, WalletNotFoundError from acapy_agent.wallet.util import b64_to_bytes from aiohttp import web @@ -17,12 +18,50 @@ from ..cred_processor import CredProcessors from ..did_utils import retrieve_or_create_did_jwk from ..jwt import jwt_sign +from ..models.issuer_config import IssuerConfiguration from ..models.supported_cred import SupportedCredential -from ..utils import get_first_auth_server LOGGER = logging.getLogger(__name__) +async def _load_issuer_context(session, wallet_id: str | None): + """Load IssuerConfiguration and the first AS entry with a public_url.""" + try: + cfg = await IssuerConfiguration.retrieve_by_id( + session, wallet_id or "default-wallet" + ) + except StorageNotFoundError: + cfg = None + servers = (cfg.authorization_servers if cfg else None) or [] + auth = next((a for a in servers if a.get("public_url")), None) + return cfg, auth + + +def _build_cred_configs(credentials_supported, processors) -> dict: + """Build the credential_configurations_supported map.""" + result: dict = {} + for supported in credentials_supported: + try: + issuer = processors.issuer_for_format(supported.format) + except Exception: + issuer = None + result[supported.identifier] = supported.to_issuer_metadata(issuer=issuer) + return result + + +def _apply_overlay( + metadata: dict, issuer_config, cred_configs: dict, base_url: str, enable_nonce: bool +) -> None: + """Overlay DB config (keeping server-derived cred_configs) and gate nonce_endpoint.""" + if issuer_config: + metadata.update(issuer_config.issuer_metadata(base_url)) + metadata["credential_configurations_supported"] = cred_configs + if enable_nonce: + metadata.setdefault("nonce_endpoint", f"{base_url}/nonce") + else: + metadata.pop("nonce_endpoint", None) + + class BatchCredentialIssuanceSchema(OpenAPISchema): """Batch credential issuance schema.""" @@ -51,8 +90,10 @@ class CredentialIssuerMetadataSchema(OpenAPISchema): required=False, metadata={"description": "The nonce endpoint."}, ) - credential_configurations_supported = fields.List( - fields.Dict(), + credential_configurations_supported = fields.Dict( + keys=fields.Str(), + values=fields.Dict(), + required=True, metadata={"description": "The supported credentials."}, ) batch_credential_issuance = fields.Nested( @@ -68,8 +109,8 @@ async def credential_issuer_metadata(request: web.Request): """Credential issuer metadata endpoint. If the client sends `Accept: application/jwt`, the metadata is returned as - a signed JWT (OID4VCI 1.0 §11.2.2 — signed metadata). Otherwise, plain - JSON is returned. + a signed JWT (OID4VCI 1.0 §12.2.2 — metadata retrieval; §12.2.3 defines + the signed metadata format). Otherwise, plain JSON is returned. """ context: AdminRequestContext = request["context"] config = Config.from_settings(context.settings) @@ -78,42 +119,34 @@ async def credential_issuer_metadata(request: web.Request): async with context.session() as session: # TODO If there's a lot, this will be a problem credentials_supported = await SupportedCredential.query(session) - auth_server = await get_first_auth_server(session, context.profile) wallet_id = request.match_info.get("wallet_id") subpath = f"/tenant/{wallet_id}" if wallet_id else "" + issuer_config, auth_server = await _load_issuer_context(session, wallet_id) + metadata: dict[str, Any] = {"credential_issuer": f"{public_url}{subpath}"} if auth_server: - # Point directly at the auth server's public URL so the wallet - # performs OAuth discovery and token requests against the auth server. metadata["authorization_servers"] = [auth_server["public_url"]] else: - # When ACA-Py is its own authorization server (no external auth server), - # include token_endpoint directly in the credential issuer metadata. - # This is technically an extension beyond OID4VCI spec §11.2.1 (which - # says token_endpoint belongs in AS metadata via /.well-known/oauth- - # authorization-server), but some wallets (e.g. waltid) read - # token_endpoint from resolveCIProviderMetadata() and NPE if absent, - # rather than performing AS discovery. + # Extension for wallets (e.g. waltid) that read token_endpoint from + # credential issuer metadata instead of doing AS discovery. metadata["token_endpoint"] = f"{public_url}{subpath}/token" metadata["credential_endpoint"] = f"{public_url}{subpath}/credential" metadata["notification_endpoint"] = f"{public_url}{subpath}/notification" - if config.enable_nonce_endpoint: - metadata["nonce_endpoint"] = f"{public_url}{subpath}/nonce" - processors = context.inject(CredProcessors) - cred_configs = {} - for supported in credentials_supported: - try: - issuer = processors.issuer_for_format(supported.format) - except Exception: - issuer = None - cred_configs[supported.identifier] = supported.to_issuer_metadata( - issuer=issuer - ) + cred_configs = _build_cred_configs( + credentials_supported, context.inject(CredProcessors) + ) metadata["credential_configurations_supported"] = cred_configs - # OID4VCI 1.0 §11.2.2: if client requests signed metadata, sign and return - # the metadata document as a JWT with Content-Type: application/jwt. + _apply_overlay( + metadata, + issuer_config, + cred_configs, + f"{public_url}{subpath}", + config.enable_nonce_endpoint, + ) + + # OID4VCI 1.0 §12.2.2/§12.2.3: signed metadata as JWT with application/jwt. accept = request.headers.get("Accept", "") vm: str | None = None jwk_public: dict | None = None @@ -122,7 +155,6 @@ async def credential_issuer_metadata(request: web.Request): async with context.profile.session() as sig_session: jwk_info = await retrieve_or_create_did_jwk(sig_session) vm = f"{jwk_info.did}#0" - # Decode the public JWK from the did:jwk DID. # did:jwk: — reverse _create_default_did. jwk_encoded = jwk_info.did[len("did:jwk:") :] jwk_public = json.loads(b64_to_bytes(jwk_encoded, urlsafe=True).decode()) @@ -130,8 +162,9 @@ async def credential_issuer_metadata(request: web.Request): LOGGER.warning("Cannot sign metadata JWT: %s", err) if "application/jwt" in accept and vm and jwk_public: - # Build JWT payload: include all metadata fields + required JWT claims - issuer_url = f"{public_url}{subpath}" + # §12.2.3: `sub` MUST match the Credential Issuer Identifier — use the + # value in metadata so a DB-overridden credential_issuer is honored. + issuer_url = metadata["credential_issuer"] payload = { **metadata, "iss": issuer_url, @@ -141,13 +174,11 @@ async def credential_issuer_metadata(request: web.Request): try: signed_jwt = await jwt_sign( context.profile, - # Include the public JWK in the header — OID4VCI §11.2.2 / RFC 7515 - # requires either `jwk` or `x5c` for signed issuer metadata. - # The `jwk` must have `kid` matching the JWT header's `kid` so - # the conformance suite can locate the correct key. + # OID4VCI §12.2.3 requires typ=openidvci-issuer-metadata+jwt. + # `jwk` in the header conveys the key (RFC 7515 §4.1.3). headers={ "jwk": {**jwk_public, "kid": vm}, - "typ": "openid-credential-issuer", + "typ": "openidvci-issuer-metadata+jwt", }, payload=payload, verification_method=vm, @@ -181,61 +212,43 @@ async def openid_configuration(request: web.Request): async with context.session() as session: # TODO If there's a lot, this will be a problem credentials_supported = await SupportedCredential.query(session) - auth_server = await get_first_auth_server(session, context.profile) wallet_id = request.match_info.get("wallet_id") subpath = f"/tenant/{wallet_id}" if wallet_id else "" base_url = f"{public_url}{subpath}" + issuer_config, auth_server = await _load_issuer_context(session, wallet_id) - processors = context.inject(CredProcessors) - cred_configs = {} - for supported in credentials_supported: - try: - issuer = processors.issuer_for_format(supported.format) - except Exception: - issuer = None - cred_configs[supported.identifier] = supported.to_issuer_metadata( - issuer=issuer - ) + cred_configs = _build_cred_configs( + credentials_supported, context.inject(CredProcessors) + ) - # Combined OIDC Discovery + OID4VCI metadata metadata: dict[str, Any] = { - # OIDC Discovery fields (RFC 8414 / OIDC Discovery required fields) "issuer": base_url, - # authorization_endpoint is required by CheckServerConfiguration in the - # OIDF conformance suite (condition.common.CheckServerConfiguration checks - # for "authorization_endpoint", "token_endpoint", and "issuer"). - # For pre-authorized_code flow the authorization endpoint is not invoked, - # but it must be advertised in the AS metadata. + # Required by OIDF CheckServerConfiguration; unused in pre-auth flow. "authorization_endpoint": f"{base_url}/authorize", - "token_endpoint": f"{base_url}/token", "response_types_supported": ["code"], - # DPoP support - required by HAIP profile (DPOP-5.1). - # Advertise the algorithms supported for DPoP proof JWTs. "dpop_signing_alg_values_supported": ["ES256", "ES384", "ES512"], - # OAuth 2.0 AS Metadata fields "grant_types_supported": [ "urn:ietf:params:oauth:grant-type:pre-authorized_code" ], - # RFC 9396 Rich Authorization Requests — advertise the authorization_details - # type(s) supported (required by OID4VCI HAIP AS metadata validation). "authorization_details_types_supported": ["openid_credential"], - # OID4VCI fields "credential_issuer": base_url, "credential_endpoint": f"{base_url}/credential", "notification_endpoint": f"{base_url}/notification", "credential_configurations_supported": cred_configs, } - if config.enable_nonce_endpoint: - # OID4VCI nonce endpoint for server-generated nonces (HAIP required). - # Wallets call this before building a credential proof to get a fresh - # nonce that ACA-Py validates in the JWT proof `nonce` claim. - metadata["nonce_endpoint"] = f"{base_url}/nonce" + # token_endpoint belongs to the AS; only advertise when ACA-Py is its own AS. + if not auth_server: + metadata["token_endpoint"] = f"{base_url}/token" if auth_server: metadata["authorization_servers"] = [auth_server["public_url"]] + _apply_overlay( + metadata, issuer_config, cred_configs, base_url, config.enable_nonce_endpoint + ) + LOGGER.debug("OPENID CONFIG: %s", metadata) return web.json_response(metadata) diff --git a/oid4vc/oid4vc/routes/helpers.py b/oid4vc/oid4vc/routes/helpers.py index 0b7ca5dfc..85bfdb286 100644 --- a/oid4vc/oid4vc/routes/helpers.py +++ b/oid4vc/oid4vc/routes/helpers.py @@ -80,17 +80,22 @@ async def _parse_cred_offer(context: AdminRequestContext, exchange_id: str) -> d supported = await SupportedCredential.retrieve_by_id( session, record.supported_cred_id ) - auth_server = await get_first_auth_server(session, context.profile) - record.code = await _create_pre_auth_code( - context.profile, - config, - auth_server, - record.refresh_id, - supported.identifier, - record.pin, - ) - record.state = OID4VCIExchangeRecord.STATE_OFFER_CREATED - await record.save(session, reason="Credential offer created") + if record.state == OID4VCIExchangeRecord.STATE_CREATED: + auth_server = await get_first_auth_server(session, context.profile) + record.code = await _create_pre_auth_code( + context.profile, + config, + auth_server, + record.refresh_id, + supported.identifier, + record.pin, + ) + record.state = OID4VCIExchangeRecord.STATE_OFFER_CREATED + await record.save(session, reason="Credential offer created") + elif record.state != OID4VCIExchangeRecord.STATE_OFFER_CREATED: + raise web.HTTPBadRequest( + reason="This exchange record has already been used." + ) except (StorageError, BaseModelError) as err: raise web.HTTPBadRequest(reason=err.roll_up) from err diff --git a/oid4vc/oid4vc/tests/routes/test_public_routes.py b/oid4vc/oid4vc/tests/routes/test_public_routes.py index 63734a0c2..135a04348 100644 --- a/oid4vc/oid4vc/tests/routes/test_public_routes.py +++ b/oid4vc/oid4vc/tests/routes/test_public_routes.py @@ -80,6 +80,228 @@ async def test_issuer_metadata(context: AdminRequestContext, req: web.Request): ) +@pytest.mark.asyncio +async def test_metadata_overlays_issuer_configuration( + context: AdminRequestContext, req: web.Request +): + """IssuerConfiguration values override default generated metadata fields. + + Also verifies that when an external authorization server is configured, no + `token_endpoint` is advertised in either metadata document. + """ + + wallet_id = req.match_info.get("wallet_id") + display = [ + { + "name": "University Credential", + "locale": "en-US", + "logo": { + "uri": "https://exampleuniversity.com/public/logo.png", + "alt_text": "a square logo of a university", + }, + } + ] + request_encryption = { + "keys": [ + { + "kty": "EC", + "crv": "P-256", + "x": "f83OJ3D2xF4Jqk8rVqYf5UEoR2L7iB42t1R6kzjzA6o", + "y": "x_FEzRu9yQ1rZtQxCkVwYg1oHc3mG5m0kYqf9u0Qf6A", + "use": "enc", + "alg": "ECDH-ES", + "kid": "ec-p256-enc-1", + } + ] + } + response_encryption = { + "alg_values_supported": ["ECDH-ES", "ECDH-ES+A256KW"], + "enc_values_supported": ["A256GCM", "A128GCM"], + "encryption_required": True, + "zip_values_supported": ["DEF"], + } + async with context.session() as session: + issuer_config = IssuerConfiguration( + configuration_id=wallet_id, + new_with_id=True, + credential_issuer="https://issuer.example.com", + authorization_servers=[ + { + "public_url": "https://auth.example.com", + "private_url": "https://auth.internal", + "auth_type": "client_secret_basic", + "client_credentials": {"client_id": "abc", "client_secret": "xyz"}, + } + ], + credential_endpoint="https://issuer.example.com/custom-credential", + nonce_endpoint="https://issuer.example.com/custom-nonce", + deferred_credential_endpoint="https://issuer.example.com/deferred", + notification_endpoint="https://issuer.example.com/notify", + credential_request_encryption=request_encryption, + credential_response_encryption=response_encryption, + batch_credential_issuance={"batch_size": 100}, + display=display, + ) + await issuer_config.save(session) + + supported = SupportedCredential( + format="jwt_vc_json", + identifier="StoredConfigCredential", + credential_metadata={"claims": [{"path": ["name"]}]}, + ) + await supported.save(session) + + for endpoint in ( + test_module.credential_issuer_metadata, + test_module.openid_configuration, + ): + with patch.object(_metadata_module, "web", autospec=True) as mock_web: + await endpoint(req) + metadata = mock_web.json_response.call_args.args[0] + + # DB overrides the mandatory fields + assert metadata["credential_issuer"] == "https://issuer.example.com" + assert ( + metadata["credential_endpoint"] + == "https://issuer.example.com/custom-credential" + ) + # authorization_servers normalized to public URLs (§12.2.4) + assert metadata["authorization_servers"] == ["https://auth.example.com"] + # All configured optional fields flow through the overlay + assert metadata["nonce_endpoint"] == "https://issuer.example.com/custom-nonce" + assert ( + metadata["deferred_credential_endpoint"] + == "https://issuer.example.com/deferred" + ) + assert metadata["notification_endpoint"] == "https://issuer.example.com/notify" + assert metadata["credential_request_encryption"] == request_encryption + assert metadata["credential_response_encryption"] == response_encryption + assert metadata["batch_credential_issuance"] == {"batch_size": 100} + assert metadata["display"] == display + # server-derived credentials are not clobbered by the overlay + assert "StoredConfigCredential" in metadata["credential_configurations_supported"] + # external AS present -> no token_endpoint at the credential issuer + assert "token_endpoint" not in metadata + + +@pytest.mark.asyncio +async def test_authorization_servers_drops_entries_without_public_url( + context: AdminRequestContext, req: web.Request +): + """§12.2.4: authorization_servers is an array of strings, non-empty. + + Filter out DB entries missing `public_url` so we never publish `null`. + """ + wallet_id = req.match_info.get("wallet_id") + async with context.session() as session: + issuer_config = IssuerConfiguration( + configuration_id=wallet_id, + new_with_id=True, + authorization_servers=[ + {"private_url": "https://intra.example.com"}, # no public_url + {"public_url": "https://auth.example.com"}, + ], + ) + await issuer_config.save(session) + + with patch.object(_metadata_module, "web", autospec=True) as mock_web: + await test_module.credential_issuer_metadata(req) + metadata = mock_web.json_response.call_args.args[0] + assert metadata["authorization_servers"] == ["https://auth.example.com"] + assert None not in metadata["authorization_servers"] + + +@pytest.mark.asyncio +async def test_signed_metadata_uses_spec_typ_header( + monkeypatch, context: AdminRequestContext, req: web.Request +): + """§12.2.3: signed metadata JWT MUST use typ=openidvci-issuer-metadata+jwt. + + Also verifies `sub` matches the (possibly DB-overridden) credential_issuer. + """ + from types import SimpleNamespace + + req.headers = {"Accept": "application/jwt"} + + wallet_id = req.match_info.get("wallet_id") + async with context.session() as session: + await IssuerConfiguration( + configuration_id=wallet_id, + new_with_id=True, + credential_issuer="https://issuer.example.com", + ).save(session) + + monkeypatch.setattr( + _metadata_module, + "retrieve_or_create_did_jwk", + AsyncMock( + return_value=SimpleNamespace( + # did:jwk: + did="did:jwk:eyJrdHkiOiJPS1AiLCJjcnYiOiJFZDI1NTE5IiwieCI6IkFBIn0" + ) + ), + ) + captured = {} + + async def fake_sign(profile, headers, payload, verification_method): + captured["headers"] = headers + captured["payload"] = payload + return "signed.jwt.value" + + monkeypatch.setattr(_metadata_module, "jwt_sign", fake_sign) + + with patch.object(_metadata_module, "web", autospec=True) as mock_web: + mock_web.Response.return_value = MagicMock() + await test_module.credential_issuer_metadata(req) + + assert captured["headers"]["typ"] == "openidvci-issuer-metadata+jwt" + # §12.2.3: sub REQUIRED = Credential Issuer Identifier; iat REQUIRED. + assert captured["payload"]["sub"] == "https://issuer.example.com" + assert captured["payload"]["sub"] == captured["payload"]["credential_issuer"] + assert isinstance(captured["payload"]["iat"], int) + + +@pytest.mark.asyncio +async def test_metadata_suppresses_nonce_endpoint_when_disabled( + monkeypatch, context: AdminRequestContext, req: web.Request +): + """DB-configured `nonce_endpoint` is suppressed when local nonce is off. + + PoP validation uses direct c_nonce comparison when `enable_nonce_endpoint` + is False, so publishing a nonce endpoint would advertise a flow the server + cannot validate. The overlay is preserved for every other DB field. + """ + wallet_id = req.match_info.get("wallet_id") + async with context.session() as session: + issuer_config = IssuerConfiguration( + configuration_id=wallet_id, + new_with_id=True, + nonce_endpoint="https://issuer.example.com/custom-nonce", + display=[{"name": "Example Issuer", "locale": "en"}], + ) + await issuer_config.save(session) + + monkeypatch.setattr( + _metadata_module.Config, + "from_settings", + lambda settings: MagicMock( + endpoint="http://localhost:8020", + enable_nonce_endpoint=False, + ), + ) + + for endpoint in ( + test_module.credential_issuer_metadata, + test_module.openid_configuration, + ): + with patch.object(_metadata_module, "web", autospec=True) as mock_web: + await endpoint(req) + metadata = mock_web.json_response.call_args.args[0] + assert "nonce_endpoint" not in metadata + # Other DB overlay fields still flow through + assert metadata["display"] == [{"name": "Example Issuer", "locale": "en"}] + + @pytest.mark.asyncio async def test_get_token(context: AdminRequestContext, req: web.Request): """Test token issuance endpoint.""" diff --git a/oid4vc/oid4vc/tests/test_routes.py b/oid4vc/oid4vc/tests/test_routes.py index 7dc1e30e1..7df99b697 100644 --- a/oid4vc/oid4vc/tests/test_routes.py +++ b/oid4vc/oid4vc/tests/test_routes.py @@ -85,7 +85,7 @@ async def test_parse_cred_offer(monkeypatch, context): mock_record.pin = "1234" mock_record.refresh_id = "refresh_id" mock_record.code = None - mock_record.state = None + mock_record.state = OID4VCIExchangeRecord.STATE_CREATED mock_record.save = AsyncMock() monkeypatch.setattr( "oid4vc.routes.helpers.OID4VCIExchangeRecord.retrieve_by_id", @@ -101,6 +101,9 @@ async def test_parse_cred_offer(monkeypatch, context): monkeypatch.setattr( "oid4vc.routes.helpers._create_pre_auth_code", AsyncMock(return_value="code123") ) + monkeypatch.setattr( + "oid4vc.routes.helpers.get_first_auth_server", AsyncMock(return_value=None) + ) offer = await _parse_cred_offer(context, "exchange_id") assert offer["credential_issuer"].startswith("http://localhost:8020") assert ( From a580f436c4f90278cd3227b9334bb8c50242e5fc Mon Sep 17 00:00:00 2001 From: Ivan Wei Date: Fri, 14 Aug 2026 17:36:06 -0400 Subject: [PATCH 2/3] feat: add attestation support Signed-off-by: Ivan Wei --- oid4vc/auth_server/.gitignore | 3 + oid4vc/auth_server/admin/config.py | 40 +- oid4vc/auth_server/admin/main.py | 13 +- oid4vc/auth_server/admin/models.py | 23 + .../repositories/tenant_key_repository.py | 6 +- .../wallet_provider_repository.py | 54 ++ oid4vc/auth_server/admin/routers/internal.py | 35 +- .../auth_server/admin/routers/migrations.py | 11 +- oid4vc/auth_server/admin/routers/tenants.py | 24 +- .../admin/routers/wallet_providers.py | 66 ++ oid4vc/auth_server/admin/schemas/client.py | 6 +- oid4vc/auth_server/admin/schemas/internal.py | 12 +- oid4vc/auth_server/admin/schemas/migration.py | 1 + .../admin/schemas/wallet_provider.py | 104 +++ oid4vc/auth_server/admin/security/bearer.py | 51 +- .../admin/services/client_service.py | 37 +- .../admin/services/internal_service.py | 95 ++- .../admin/services/signing_service.py | 10 +- .../admin/services/tenant_service.py | 122 ++- .../admin/services/wallet_provider_service.py | 93 +++ oid4vc/auth_server/admin/utils/crypto.py | 37 +- .../versions/0002_add_wallet_provider.py | 32 + .../versions/0002_tx_code_and_jti_seen.py | 47 ++ oid4vc/auth_server/core/consts.py | 19 +- oid4vc/auth_server/core/crypto/crypto.py | 22 +- oid4vc/auth_server/core/db/cached_session.py | 63 +- oid4vc/auth_server/core/db/session.py | 4 +- .../core/observability/observability.py | 13 +- .../core/repositories/client_repository.py | 4 +- .../auth_server/core/security/client_auth.py | 215 ----- .../auth_server/core/security/jwks_cache.py | 167 ++++ oid4vc/auth_server/core/security/utils.py | 64 +- oid4vc/auth_server/core/utils/encoding.py | 13 + oid4vc/auth_server/core/utils/logging.py | 83 +- oid4vc/auth_server/core/utils/retry.py | 4 +- oid4vc/auth_server/docs/auth-server-design.md | 368 +++++++-- oid4vc/auth_server/docs/tenant-admin.md | 5 +- oid4vc/auth_server/poetry.lock | 633 ++++++++++++++- oid4vc/auth_server/pyproject.toml | 5 +- .../auth_server/resources/.env.admin.example | 43 +- .../auth_server/resources/.env.demo.example | 21 + .../auth_server/resources/.env.tenant.example | 53 +- .../resources/attestation_demo.ipynb | 363 +++++++++ oid4vc/auth_server/resources/dev_seed.py | 24 +- oid4vc/auth_server/resources/seeds.txt | 5 - oid4vc/auth_server/tenant/config.py | 37 +- oid4vc/auth_server/tenant/deps.py | 75 +- oid4vc/auth_server/tenant/main.py | 5 +- oid4vc/auth_server/tenant/models.py | 31 +- oid4vc/auth_server/tenant/oauth/grants.py | 115 ++- oid4vc/auth_server/tenant/oauth/server.py | 85 +- .../repositories/access_token_repository.py | 21 +- .../auth_server/tenant/repositories/base.py | 40 + .../tenant/repositories/grant_repository.py | 24 + .../repositories/refresh_token_repository.py | 34 +- .../auth_server/tenant/routers/introspect.py | 6 +- oid4vc/auth_server/tenant/routers/token.py | 16 +- .../auth_server/tenant/routers/well_known.py | 14 +- .../tenant/security/client_auth.py | 253 +++++- .../auth_server/tenant/security/jti_cache.py | 61 ++ oid4vc/auth_server/tenant/security/token.py | 24 + .../tenant/services/attestation_service.py | 491 +++++++++++ .../tenant/services/grant_service.py | 6 +- .../tenant/services/introspect_service.py | 32 +- .../tenant/services/signing_service.py | 7 +- .../tenant/services/token_service.py | 130 ++- .../tenant/services/well_known_service.py | 57 +- .../services/test_admin_signing_service.py | 6 +- .../admin/services/test_internal_service.py | 42 +- .../tests/core/security/test_jwks_cache.py | 190 +++++ .../tests/tenant/oauth/test_grants.py | 93 ++- .../services/test_attestation_service.py | 762 ++++++++++++++++++ .../services/test_introspect_service.py | 4 +- .../tenant/services/test_signing_service.py | 18 +- .../tenant/services/test_token_service.py | 117 +++ .../services/test_well_known_service.py | 67 ++ .../security => tenant}/test_client_auth.py | 235 +++--- .../auth_server/tests/tenant/test_config.py | 28 + .../tests/tenant/test_tenant_deps.py | 2 +- 79 files changed, 5122 insertions(+), 1119 deletions(-) create mode 100644 oid4vc/auth_server/admin/repositories/wallet_provider_repository.py create mode 100644 oid4vc/auth_server/admin/routers/wallet_providers.py create mode 100644 oid4vc/auth_server/admin/schemas/wallet_provider.py create mode 100644 oid4vc/auth_server/admin/services/wallet_provider_service.py create mode 100644 oid4vc/auth_server/alembic/admin/versions/0002_add_wallet_provider.py create mode 100644 oid4vc/auth_server/alembic/tenant/versions/0002_tx_code_and_jti_seen.py delete mode 100644 oid4vc/auth_server/core/security/client_auth.py create mode 100644 oid4vc/auth_server/core/security/jwks_cache.py create mode 100644 oid4vc/auth_server/core/utils/encoding.py create mode 100644 oid4vc/auth_server/resources/.env.demo.example create mode 100644 oid4vc/auth_server/resources/attestation_demo.ipynb create mode 100644 oid4vc/auth_server/tenant/repositories/base.py create mode 100644 oid4vc/auth_server/tenant/security/jti_cache.py create mode 100644 oid4vc/auth_server/tenant/security/token.py create mode 100644 oid4vc/auth_server/tenant/services/attestation_service.py create mode 100644 oid4vc/auth_server/tests/core/security/test_jwks_cache.py create mode 100644 oid4vc/auth_server/tests/tenant/services/test_attestation_service.py create mode 100644 oid4vc/auth_server/tests/tenant/services/test_well_known_service.py rename oid4vc/auth_server/tests/{core/security => tenant}/test_client_auth.py (68%) create mode 100644 oid4vc/auth_server/tests/tenant/test_config.py diff --git a/oid4vc/auth_server/.gitignore b/oid4vc/auth_server/.gitignore index a74bd2f5e..a74f83a09 100644 --- a/oid4vc/auth_server/.gitignore +++ b/oid4vc/auth_server/.gitignore @@ -203,6 +203,9 @@ cython_debug/ .env.* !.env.*.example +# Local demo/dev key material +resources/.demo_keys.json + # Local test/analysis artifacts .test-reports/ .VSCodeCounter/ diff --git a/oid4vc/auth_server/admin/config.py b/oid4vc/auth_server/admin/config.py index 36a64eec2..9ea7a1f1f 100644 --- a/oid4vc/auth_server/admin/config.py +++ b/oid4vc/auth_server/admin/config.py @@ -1,44 +1,48 @@ -"""Application configuration.""" +"""Admin settings.""" from pydantic_settings import BaseSettings, SettingsConfigDict +from sqlalchemy.engine import URL class Settings(BaseSettings): - """Application configuration.""" + """Admin env vars (ADMIN_ prefix).""" model_config = SettingsConfigDict(env_prefix="ADMIN_", extra="ignore") + # App metadata APP_ROOT_PATH: str = "" APP_TITLE: str = "OAuth 2.0 Authorization Server Admin API" APP_VERSION: str = "0.1.0" OPENAPI_URL: str = "" - OAUTH_ISSUER: str = "" - OAUTH_CLIENT_ID: str = "" - OAUTH_JWKS_URL: str = "" - MANAGE_AUTH_TOKEN: str = "manage_auth_token" + # Bearer tokens + MANAGE_AUTH_TOKEN: str = "" + INTERNAL_AUTH_TOKEN: str = "" + # Database DB_DRIVER_ASYNC: str = "postgresql+asyncpg" DB_DRIVER_SYNC: str = "postgresql+psycopg" DB_HOST: str = "localhost" DB_PORT: int = 5432 - DB_NAME: str = "auth_server_admin" DB_SCHEMA: str = "admin" DB_USER: str = "postgres" DB_PASSWORD: str = "postgres" + # Tenant database TENANT_DB_NAME: str = "auth_server_tenant" TENANT_DB_SCHEMA: str = "auth" - INTERNAL_AUTH_TOKEN: str = "internal_auth_token" + # Client settings + MIN_CLIENT_SECRET_LENGTH: int = 32 + # Key encryption KEY_VERIFY_GRACE_TTL: int = 604800 # seconds, JWKS grace after retirement KEY_ENC_SECRETS: dict[str, str] = {} KEY_ENC_VERSION: int = 1 - # CORS settings - CORS_ALLOW_ORIGINS: list[str] = ["*"] + # CORS + CORS_ALLOW_ORIGINS: list[str] = [] CORS_ALLOW_METHODS: list[str] = ["GET", "POST", "PATCH", "DELETE", "OPTIONS"] CORS_ALLOW_HEADERS: list[str] = ["Authorization", "Content-Type"] CORS_ALLOW_CREDENTIALS: bool = False @@ -60,13 +64,15 @@ def DB_URL_SYNC(self) -> str: def _get_db_conn_str(self, use_async: bool = True) -> str: """Return DB connection string by protocol.""" - DB_DRIVER = self.DB_DRIVER_ASYNC if use_async else self.DB_DRIVER_SYNC - return ( - f"{DB_DRIVER}://" - f"{self.DB_USER}:{self.DB_PASSWORD}" - f"@{self.DB_HOST}:{self.DB_PORT}" - f"/{self.DB_NAME}" - ) + driver = self.DB_DRIVER_ASYNC if use_async else self.DB_DRIVER_SYNC + return URL.create( + drivername=driver, + username=self.DB_USER, + password=self.DB_PASSWORD, + host=self.DB_HOST, + port=self.DB_PORT, + database=self.DB_NAME, + ).render_as_string(hide_password=False) settings = Settings() diff --git a/oid4vc/auth_server/admin/main.py b/oid4vc/auth_server/admin/main.py index bc6bc1986..dca9c312e 100644 --- a/oid4vc/auth_server/admin/main.py +++ b/oid4vc/auth_server/admin/main.py @@ -10,7 +10,7 @@ from admin.config import settings from admin.deps import db_manager -from admin.routers import internal, migrations, tenants +from admin.routers import internal, migrations, tenants, wallet_providers from core.observability.observability import ( RequestContextMiddleware, setup_structlog_json, @@ -26,6 +26,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # Startup logic setup_structlog_json() db_manager.init(settings.DB_URL) + # Load wallet providers into JWKS cache + async with db_manager.session() as session: + from admin.services.internal_service import load_wallet_providers + + await load_wallet_providers(session) # Warn if encryption keys are not configured; secrets will be stored in plaintext try: active_ver = str(getattr(settings, "KEY_ENC_VERSION", 1)) @@ -64,6 +69,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app.add_middleware(RequestContextMiddleware) app.include_router(tenants.router, prefix="/admin", tags=["tenants"]) +app.include_router(wallet_providers.router, prefix="/admin", tags=["wallet-providers"]) app.include_router(migrations.router, prefix="/admin", tags=["migrations"]) app.include_router(internal.router, prefix="/internal", tags=["internal"]) @@ -76,11 +82,10 @@ async def health_check(): await session.execute(text("SELECT 1")) return ORJSONResponse(content={"status": "ok"}, status_code=status.HTTP_200_OK) except Exception as ex: - error_message = f"database_unavailable: {ex}" - logger.error(f"Health check failed: {error_message}") + logger.error("Health check failed: %s", ex) return ORJSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={"status": "fail", "error": error_message}, + content={"status": "fail", "error": "database_unavailable"}, ) diff --git a/oid4vc/auth_server/admin/models.py b/oid4vc/auth_server/admin/models.py index 0234ae891..3aa071dec 100644 --- a/oid4vc/auth_server/admin/models.py +++ b/oid4vc/auth_server/admin/models.py @@ -71,3 +71,26 @@ class TenantKey(Base): updated_at: Mapped[datetime | None] = mapped_column( TIMESTAMP(timezone=True), nullable=True, onupdate=func.now() ) + + +class WalletProvider(Base): + """Trusted wallet provider for attestation verification (allow list).""" + + __tablename__ = "wallet_provider" + __table_args__ = ( + UniqueConstraint("iss", name="uq_wallet_provider_iss"), + {"schema": settings.DB_SCHEMA}, + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + iss: Mapped[str] = mapped_column(Text, nullable=False) + jwks: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + jwks_uri: Mapped[str | None] = mapped_column(Text, nullable=True) + name: Mapped[str | None] = mapped_column(Text, nullable=True) + active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), nullable=False, default=func.now() + ) + updated_at: Mapped[datetime | None] = mapped_column( + TIMESTAMP(timezone=True), nullable=True, onupdate=func.now() + ) diff --git a/oid4vc/auth_server/admin/repositories/tenant_key_repository.py b/oid4vc/auth_server/admin/repositories/tenant_key_repository.py index f1852621c..c8ad50086 100644 --- a/oid4vc/auth_server/admin/repositories/tenant_key_repository.py +++ b/oid4vc/auth_server/admin/repositories/tenant_key_repository.py @@ -1,5 +1,8 @@ """Data-access layer for tenant keys.""" +from datetime import datetime, timezone + +from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from admin.models import TenantKey @@ -18,9 +21,6 @@ async def add(self, key: TenantKey) -> None: async def update_status(self, tenant_id: int, kid: str, status: str) -> int: """Update key status for a tenant key; returns number of rows changed.""" - from datetime import datetime, timezone - - from sqlalchemy import update stmt = ( update(TenantKey) diff --git a/oid4vc/auth_server/admin/repositories/wallet_provider_repository.py b/oid4vc/auth_server/admin/repositories/wallet_provider_repository.py new file mode 100644 index 000000000..4312a225a --- /dev/null +++ b/oid4vc/auth_server/admin/repositories/wallet_provider_repository.py @@ -0,0 +1,54 @@ +"""Repository for wallet provider allow list.""" + +from typing import Sequence + +from sqlalchemy import delete, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from admin.models import WalletProvider + + +class WalletProviderRepository: + """Data-access layer for the wallet_provider allow list table.""" + + def __init__(self, session: AsyncSession) -> None: + """Initialize the repository with a database session.""" + self.session = session + + async def list(self, active_only: bool = False) -> Sequence[WalletProvider]: + """Return all wallet providers, optionally filtered to active only.""" + stmt = select(WalletProvider) + if active_only: + stmt = stmt.where(WalletProvider.active.is_(True)) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def get(self, id: int) -> WalletProvider | None: + """Fetch a single wallet provider by primary key.""" + result = await self.session.execute( + select(WalletProvider).where(WalletProvider.id == id) + ) + return result.scalar_one_or_none() + + async def get_by_iss(self, iss: str) -> WalletProvider | None: + """Look up a wallet provider by its issuer identifier.""" + result = await self.session.execute( + select(WalletProvider).where(WalletProvider.iss == iss) + ) + return result.scalar_one_or_none() + + async def update_values(self, id: int, values: dict) -> int: + """Apply a partial update and return the number of rows affected.""" + if not values: + return 0 + res = await self.session.execute( + update(WalletProvider).where(WalletProvider.id == id).values(**values) + ) + return res.rowcount or 0 + + async def delete(self, id: int) -> int: + """Delete a wallet provider by pkey and return the number of rows removed.""" + res = await self.session.execute( + delete(WalletProvider).where(WalletProvider.id == id) + ) + return res.rowcount or 0 diff --git a/oid4vc/auth_server/admin/routers/internal.py b/oid4vc/auth_server/admin/routers/internal.py index 1912ebf52..092e40941 100644 --- a/oid4vc/auth_server/admin/routers/internal.py +++ b/oid4vc/auth_server/admin/routers/internal.py @@ -1,6 +1,6 @@ -"""API for tenant SERVICE helpers: DB info, JWKS, JWT signing.""" +"""API for tenant SERVICE helpers: DB info, JWKS, JWT signing, wallet provider lookup.""" -from fastapi import APIRouter, Depends, Path +from fastapi import APIRouter, Depends, Path, Query from sqlalchemy.ext.asyncio import AsyncSession from admin.deps import get_db_session @@ -11,13 +11,17 @@ TenantJwksResponse, ) from admin.security.bearer import require_internal_auth -from admin.services.internal_service import get_tenant_db, get_tenant_jwks +from admin.services.internal_service import ( + get_tenant_db, + get_tenant_jwks, + lookup_wallet_provider, +) from admin.services.signing_service import sign_tenant_jwt -router = APIRouter(prefix="/tenants/{uid}", dependencies=[Depends(require_internal_auth)]) +router = APIRouter(dependencies=[Depends(require_internal_auth)]) -@router.get("/db", response_model=TenantDbResponse) +@router.get("/tenants/{uid}/db", response_model=TenantDbResponse) async def get_db( uid: str = Path(...), db: AsyncSession = Depends(get_db_session), @@ -26,7 +30,7 @@ async def get_db( return await get_tenant_db(db, uid) -@router.get("/jwks", response_model=TenantJwksResponse) +@router.get("/tenants/{uid}/jwks", response_model=TenantJwksResponse) async def get_jwks( uid: str = Path(...), db: AsyncSession = Depends(get_db_session), @@ -35,7 +39,11 @@ async def get_jwks( return await get_tenant_jwks(db, uid) -@router.post("/jwts", response_model=JwtSignResponse, response_model_exclude_none=True) +@router.post( + "/tenants/{uid}/jwts", + response_model=JwtSignResponse, + response_model_exclude_none=True, +) async def sign_jwt( body: JwtSignRequest, uid: str = Path(...), @@ -43,3 +51,16 @@ async def sign_jwt( ): """Sign a JWT for the tenant.""" return await sign_tenant_jwt(db, uid, body) + + +@router.get("/wallet-providers/lookup") +async def wallet_provider_lookup( + iss: str = Query(...), + kid: str | None = Query(None), + db: AsyncSession = Depends(get_db_session), +): + """Look up a wallet provider key by iss + optional kid from cache.""" + result = await lookup_wallet_provider(db, iss, kid) + if not result: + return {"found": False} + return {"found": True, **result} diff --git a/oid4vc/auth_server/admin/routers/migrations.py b/oid4vc/auth_server/admin/routers/migrations.py index 68ab4ef3d..b99c43556 100644 --- a/oid4vc/auth_server/admin/routers/migrations.py +++ b/oid4vc/auth_server/admin/routers/migrations.py @@ -9,6 +9,9 @@ from admin.schemas.migration import MigrationAction, MigrationRequest from admin.services.alembic_service import run_tenant_migration from admin.utils.db_utils import resolve_tenant_urls +from core.utils.logging import get_logger + +logger = get_logger(__name__) router = APIRouter(dependencies=[Depends(require_admin_auth)]) @@ -33,11 +36,17 @@ async def migrate_tenant( sync_url=sync_url, schema=schema, action="upgrade", rev=rev ) else: + if not body.confirm: + raise HTTPException( + status_code=400, + detail="downgrade requires confirm=true", + ) rev = body.rev or "-1" run_tenant_migration( sync_url=sync_url, schema=schema, action="downgrade", rev=rev ) except Exception as ex: - raise HTTPException(status_code=500, detail=f"Alembic failed: {ex}") from ex + logger.exception("Alembic migration failed for tenant %s", uid) + raise HTTPException(status_code=500, detail="migration_failed") from ex return {"status": "ok", "action": body.action, "rev": rev, "tenant": uid} diff --git a/oid4vc/auth_server/admin/routers/tenants.py b/oid4vc/auth_server/admin/routers/tenants.py index de91c496e..c5fb0eb59 100644 --- a/oid4vc/auth_server/admin/routers/tenants.py +++ b/oid4vc/auth_server/admin/routers/tenants.py @@ -15,7 +15,7 @@ @router.get("/tenants", response_model=list[TenantOut]) async def list_tenants(db: AsyncSession = Depends(get_db_session)): - """List tenants via repository.""" + """List tenants.""" svc = TenantService(db) rows = await svc.list() return [TenantOut.model_validate(r) for r in rows] @@ -23,7 +23,7 @@ async def list_tenants(db: AsyncSession = Depends(get_db_session)): @router.get("/tenants/{uid}", response_model=TenantOut) async def get_tenant(uid: str, db: AsyncSession = Depends(get_db_session)): - """Get a specific tenant via repository.""" + """Get a tenant by uid.""" svc = TenantService(db) row = await svc.get(uid) if not row: @@ -33,7 +33,7 @@ async def get_tenant(uid: str, db: AsyncSession = Depends(get_db_session)): @router.post("/tenants", response_model=TenantOut, status_code=201) async def create_tenant(body: TenantIn, db: AsyncSession = Depends(get_db_session)): - """Create a new tenant via repository.""" + """Create a tenant.""" svc = TenantService(db) row = await svc.create(body) return TenantOut.model_validate(row) @@ -43,7 +43,7 @@ async def create_tenant(body: TenantIn, db: AsyncSession = Depends(get_db_sessio async def update_tenant( uid: str, body: TenantIn, db: AsyncSession = Depends(get_db_session) ): - """Update a tenant via repository.""" + """Update a tenant.""" svc = TenantService(db) await svc.update(uid, body) return {"status": "updated", "uid": uid} @@ -51,7 +51,7 @@ async def update_tenant( @router.delete("/tenants/{uid}") async def delete_tenant(uid: str, db: AsyncSession = Depends(get_db_session)): - """Delete a tenant via repository.""" + """Delete a tenant.""" svc = TenantService(db) deleted = await svc.delete(uid) if deleted == 0: @@ -66,7 +66,7 @@ async def delete_tenant(uid: str, db: AsyncSession = Depends(get_db_session)): @router.get("/tenants/{uid}/keys") async def get_tenant_keys(uid: str, db: AsyncSession = Depends(get_db_session)): - """Get jwks for a tenant via service.""" + """Get JWKS for a tenant.""" return await get_tenant_jwks(db, uid) @@ -74,7 +74,7 @@ async def get_tenant_keys(uid: str, db: AsyncSession = Depends(get_db_session)): async def generate_tenant_keypair( uid: str, body: KeyGenIn, db: AsyncSession = Depends(get_db_session) ): - """Generate a keypair for a tenant via service.""" + """Generate a signing keypair.""" svc = TenantService(db) return await svc.generate_keypair(uid, body) @@ -98,7 +98,7 @@ async def update_key_status( @router.get("/tenants/{uid}/clients", response_model=list[ClientOut]) async def list_clients(uid: str, db: AsyncSession = Depends(get_db_session)): - """List clients via repository.""" + """List clients.""" svc = TenantService(db) rows = await svc.list_clients(uid) return [ClientOut.model_validate(r) for r in rows] @@ -108,7 +108,7 @@ async def list_clients(uid: str, db: AsyncSession = Depends(get_db_session)): async def get_client( uid: str, client_id: str, db: AsyncSession = Depends(get_db_session) ): - """Get a specific client via repository.""" + """Get a client by ID.""" svc = TenantService(db) row = await svc.get_client(uid, client_id) if not row: @@ -120,7 +120,7 @@ async def get_client( async def create_client( uid: str, body: ClientIn, db: AsyncSession = Depends(get_db_session) ): - """Create a new client via repository.""" + """Create a client.""" svc = TenantService(db) row = await svc.create_client(uid, body) return ClientOut.model_validate(row) @@ -130,7 +130,7 @@ async def create_client( async def update_client( uid: str, client_id: str, body: ClientIn, db: AsyncSession = Depends(get_db_session) ): - """Update a client via repository.""" + """Update a client.""" svc = TenantService(db) await svc.update_client(uid, client_id, body) return {"status": "updated", "client_id": client_id} @@ -140,7 +140,7 @@ async def update_client( async def delete_client( uid: str, client_id: str, db: AsyncSession = Depends(get_db_session) ): - """Delete a client via repository.""" + """Delete a client.""" svc = TenantService(db) deleted = await svc.delete_client(uid, client_id) if deleted == 0: diff --git a/oid4vc/auth_server/admin/routers/wallet_providers.py b/oid4vc/auth_server/admin/routers/wallet_providers.py new file mode 100644 index 000000000..c5f6a98d6 --- /dev/null +++ b/oid4vc/auth_server/admin/routers/wallet_providers.py @@ -0,0 +1,66 @@ +"""Admin CRUD endpoints for trusted wallet providers.""" + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from admin.deps import get_db_session +from admin.schemas.wallet_provider import ( + WalletProviderIn, + WalletProviderOut, + WalletProviderUpdate, +) +from admin.security.bearer import require_admin_auth +from admin.services.wallet_provider_service import WalletProviderService + +router = APIRouter(dependencies=[Depends(require_admin_auth)]) + + +@router.get("/wallet-providers", response_model=list[WalletProviderOut]) +async def list_wallet_providers( + active_only: bool = Query(False), + db: AsyncSession = Depends(get_db_session), +): + """List all trusted wallet providers, with optional active-only filter.""" + svc = WalletProviderService(db) + rows = await svc.list(active_only=active_only) + return [WalletProviderOut.model_validate(r) for r in rows] + + +@router.post("/wallet-providers", response_model=WalletProviderOut, status_code=201) +async def create_wallet_provider( + body: WalletProviderIn, + db: AsyncSession = Depends(get_db_session), +): + """Register a new trusted wallet provider.""" + row = await WalletProviderService(db).create(body) + return WalletProviderOut.model_validate(row) + + +@router.get("/wallet-providers/{provider_id}", response_model=WalletProviderOut) +async def get_wallet_provider( + provider_id: int, + db: AsyncSession = Depends(get_db_session), +): + """Retrieve a single wallet provider by ID.""" + row = await WalletProviderService(db).get(provider_id) + return WalletProviderOut.model_validate(row) + + +@router.patch("/wallet-providers/{provider_id}", response_model=WalletProviderOut) +async def update_wallet_provider( + provider_id: int, + body: WalletProviderUpdate, + db: AsyncSession = Depends(get_db_session), +): + """Partially update a wallet provider (e.g. rotate key or deactivate).""" + row = await WalletProviderService(db).update(provider_id, body) + return WalletProviderOut.model_validate(row) + + +@router.delete("/wallet-providers/{provider_id}", status_code=204) +async def delete_wallet_provider( + provider_id: int, + db: AsyncSession = Depends(get_db_session), +): + """Remove a wallet provider from the allow list.""" + await WalletProviderService(db).delete(provider_id) diff --git a/oid4vc/auth_server/admin/schemas/client.py b/oid4vc/auth_server/admin/schemas/client.py index df6e814a5..94707a6d9 100644 --- a/oid4vc/auth_server/admin/schemas/client.py +++ b/oid4vc/auth_server/admin/schemas/client.py @@ -13,15 +13,15 @@ class ClientIn(BaseModel): default=None, ) client_auth_method: str | None = Field( - description="Auth method: client_secret_basic|client_secret_jwt|private_key_jwt", + description="Auth method: client_secret_basic|private_key_jwt", default=None, ) client_auth_signing_alg: str | None = Field( - description="e.g., ES256 or HS256", + description="e.g., ES256", default=None, ) client_secret: str | None = Field( - description="For client_secret_basic or client_secret_jwt", + description="For client_secret_basic", default=None, ) jwks: dict[str, Any] | None = Field( diff --git a/oid4vc/auth_server/admin/schemas/internal.py b/oid4vc/auth_server/admin/schemas/internal.py index 81730820e..0bcc30fbe 100644 --- a/oid4vc/auth_server/admin/schemas/internal.py +++ b/oid4vc/auth_server/admin/schemas/internal.py @@ -1,20 +1,20 @@ -"""Schemas for signing JWTs for tenants.""" +"""Internal API schemas.""" from typing import Literal from pydantic import BaseModel class JwtSignRequest(BaseModel): - """Payload for signing a JWT.""" + """JWT signing input.""" - alg: Literal["ES256"] | None = None + alg: Literal["ES256", "ES384", "EdDSA"] | None = None kid: str | None = None claims: dict ttl_seconds: int | None = None # if exp not in claims class JwtSignResponse(BaseModel): - """Response for signing a JWT.""" + """Signed JWT output.""" jwt: str kid: str @@ -23,13 +23,13 @@ class JwtSignResponse(BaseModel): class TenantDbResponse(BaseModel): - """Response model for tenant database connection.""" + """Tenant DB coordinates.""" db_url: str db_schema: str class TenantJwksResponse(BaseModel): - """Response model for tenant JWKS (JSON Web Key Set).""" + """Tenant public keyset.""" keys: list[dict] diff --git a/oid4vc/auth_server/admin/schemas/migration.py b/oid4vc/auth_server/admin/schemas/migration.py index 01df439f4..e9be36c37 100644 --- a/oid4vc/auth_server/admin/schemas/migration.py +++ b/oid4vc/auth_server/admin/schemas/migration.py @@ -16,3 +16,4 @@ class MigrationRequest(BaseModel): action: MigrationAction rev: str | None = None + confirm: bool = False diff --git a/oid4vc/auth_server/admin/schemas/wallet_provider.py b/oid4vc/auth_server/admin/schemas/wallet_provider.py new file mode 100644 index 000000000..8c37a7bf8 --- /dev/null +++ b/oid4vc/auth_server/admin/schemas/wallet_provider.py @@ -0,0 +1,104 @@ +"""Wallet provider schemas.""" + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class WalletProviderIn(BaseModel): + """Create input.""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "iss": "https://wallet.example.com", + "jwks": { + "keys": [ + { + "kid": "wallet-example-key-1", + "kty": "EC", + "crv": "P-256", + "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU", + "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0", + } + ] + }, + "name": "Example Wallet Provider", + "active": True, + } + } + ) + + iss: str = Field(description="Wallet provider issuer identifier (URL)") + jwks: dict[str, Any] | None = Field(default=None, description="Inline JWKS document") + jwks_uri: str | None = Field( + default=None, description="URL to fetch the provider's JWKS" + ) + name: str | None = Field(default=None, description="Display name for the provider") + active: bool = Field(default=True, description="Whether this provider is active") + + @model_validator(mode="after") + def _require_jwks_or_uri(self): + if not self.jwks and not self.jwks_uri: + raise ValueError("Either jwks or jwks_uri must be provided") + return self + + +class WalletProviderUpdate(BaseModel): + """Partial update input.""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "Example Wallet Provider (v2)", + "active": False, + } + } + ) + + iss: str | None = Field(default=None, description="Updated issuer identifier") + jwks: dict[str, Any] | None = Field( + default=None, description="Updated inline JWKS document" + ) + jwks_uri: str | None = Field(default=None, description="Updated JWKS URI") + name: str | None = Field(default=None, description="Updated display name") + active: bool | None = Field(default=None, description="Updated active status") + + +class WalletProviderOut(BaseModel): + """API response.""" + + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ + "example": { + "id": 1, + "iss": "https://wallet.example.com", + "jwks": { + "keys": [ + { + "kid": "wallet-example-key-1", + "kty": "EC", + "crv": "P-256", + "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU", + "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0", + } + ] + }, + "name": "Example Wallet Provider", + "active": True, + "created_at": "2026-04-27T14:30:00", + "updated_at": None, + } + }, + ) + + id: int + iss: str + jwks: dict[str, Any] | None = None + jwks_uri: str | None = None + name: str | None = None + active: bool + created_at: datetime + updated_at: datetime | None = None diff --git a/oid4vc/auth_server/admin/security/bearer.py b/oid4vc/auth_server/admin/security/bearer.py index 33df04574..56dca7cdc 100644 --- a/oid4vc/auth_server/admin/security/bearer.py +++ b/oid4vc/auth_server/admin/security/bearer.py @@ -1,5 +1,7 @@ """Bearer auth dependencies for Admin API (router-level guards).""" +import secrets + from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer @@ -9,31 +11,24 @@ _security = HTTPBearer(auto_error=False) -def require_internal_auth( - credentials: HTTPAuthorizationCredentials | None = Depends(_security), -) -> bool: - """Validate internal routes via Bearer from settings.""" - token = credentials.credentials if credentials else "" - expected = getattr(settings, "INTERNAL_AUTH_TOKEN", "") - if not token or token != expected: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="unauthorized", - headers={"WWW-Authenticate": "Bearer"}, - ) - return True - - -def require_admin_auth( - credentials: HTTPAuthorizationCredentials | None = Depends(_security), -) -> bool: - """Validate admin routes via Bearer (swap to OIDC later).""" - token = credentials.credentials if credentials else "" - expected = getattr(settings, "MANAGE_AUTH_TOKEN", "") - if not token or token != expected: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="unauthorized", - headers={"WWW-Authenticate": "Bearer"}, - ) - return True +def _bearer_guard(settings_attr: str): + """Create a bearer token dependency checking against a settings value.""" + + def _guard( + credentials: HTTPAuthorizationCredentials | None = Depends(_security), + ) -> bool: + token = credentials.credentials if credentials else "" + expected = getattr(settings, settings_attr, "") + if not token or not expected or not secrets.compare_digest(token, expected): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="unauthorized", + headers={"WWW-Authenticate": "Bearer"}, + ) + return True + + return _guard + + +require_internal_auth = _bearer_guard("INTERNAL_AUTH_TOKEN") +require_admin_auth = _bearer_guard("MANAGE_AUTH_TOKEN") diff --git a/oid4vc/auth_server/admin/services/client_service.py b/oid4vc/auth_server/admin/services/client_service.py index c5d40aa0c..617735313 100644 --- a/oid4vc/auth_server/admin/services/client_service.py +++ b/oid4vc/auth_server/admin/services/client_service.py @@ -5,6 +5,7 @@ from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession +from admin.config import settings from admin.repositories.client_repository import ClientRepository from admin.schemas.client import ClientIn from core.consts import CLIENT_AUTH_METHODS, ClientAuthMethod @@ -16,7 +17,7 @@ class ClientService: """Client orchestration.""" def __init__(self, session: AsyncSession): - """Constructor.""" + """Bind to session.""" self.session = session self.repo = ClientRepository(session) @@ -33,24 +34,24 @@ async def create(self, data: ClientIn) -> Client: if not signing_alg: if method == ClientAuthMethod.PRIVATE_KEY_JWT: signing_alg = "ES256" - elif method == ClientAuthMethod.CLIENT_SECRET_JWT: - signing_alg = "HS256" # Validate fields by method secret_hash: str | None = None if method == ClientAuthMethod.PRIVATE_KEY_JWT: if not (data.jwks or data.jwks_uri): raise HTTPException(status_code=400, detail="jwks_or_uri_required") - elif method in ( - ClientAuthMethod.CLIENT_SECRET_JWT, - ClientAuthMethod.CLIENT_SECRET_BASIC, - ): + elif method == ClientAuthMethod.CLIENT_SECRET_BASIC: if not data.client_secret: raise HTTPException(status_code=400, detail="client_secret_required") - if method == ClientAuthMethod.CLIENT_SECRET_BASIC: - secret_hash = hash_secret_pbkdf2(data.client_secret) - else: - secret_hash = data.client_secret + if len(data.client_secret) < settings.MIN_CLIENT_SECRET_LENGTH: + raise HTTPException( + status_code=400, + detail=( + "client_secret_too_short " + f"(min {settings.MIN_CLIENT_SECRET_LENGTH} chars)" + ), + ) + secret_hash = hash_secret_pbkdf2(data.client_secret) client_id = data.client_id or uuid.uuid4().hex @@ -72,12 +73,12 @@ async def create(self, data: ClientIn) -> Client: return client async def list(self) -> list[Client]: - """List all clients.""" + """All clients.""" rows = await self.repo.list() return list(rows) async def get(self, client_id: str) -> Client | None: - """Get client by client_id.""" + """Single client or None.""" return await self.repo.get_by_client_id(client_id) async def update(self, client_id: str, data: ClientIn) -> int: @@ -88,6 +89,16 @@ async def update(self, client_id: str, data: ClientIn) -> int: values = { k: v for k, v in data.model_dump(exclude_unset=True).items() if v is not None } + if "client_secret" in values: + if len(values["client_secret"]) < settings.MIN_CLIENT_SECRET_LENGTH: + raise HTTPException( + status_code=400, + detail=( + "client_secret_too_short " + f"(min {settings.MIN_CLIENT_SECRET_LENGTH} chars)", + ), + ) + values["client_secret"] = hash_secret_pbkdf2(values["client_secret"]) changed = await self.repo.update_values(row.id, values) await self.session.commit() return changed diff --git a/oid4vc/auth_server/admin/services/internal_service.py b/oid4vc/auth_server/admin/services/internal_service.py index e2bd55b0a..6714c2a24 100644 --- a/oid4vc/auth_server/admin/services/internal_service.py +++ b/oid4vc/auth_server/admin/services/internal_service.py @@ -3,18 +3,55 @@ from typing import Dict, List from datetime import datetime, timezone, timedelta -from authlib.jose import JsonWebKey +from joserfc import jwk from fastapi import HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from admin.config import settings -from admin.models import Tenant, TenantKey +from admin.models import Tenant, TenantKey, WalletProvider from admin.utils.db_utils import resolve_tenant_urls from admin.utils.keys import is_time_valid +from core.security.jwks_cache import JWKSCache +from core.utils.logging import get_logger + +logger = get_logger(__name__) MAX_TTL_SECONDS = 3600 +# Cache for wallet provider JWKS (keyed by iss) +_provider_jwks_cache = JWKSCache(ttl=300) + + +async def load_wallet_providers(session: AsyncSession) -> None: + """Load all active wallet providers into the JWKS cache (startup).""" + rows = ( + ( + await session.execute( + select(WalletProvider).where(WalletProvider.active.is_(True)) + ) + ) + .scalars() + .all() + ) + for row in rows: + _provider_jwks_cache.put( + row.iss, + row.jwks, + jwks_uri=row.jwks_uri, + ) + logger.info("Loaded %d wallet providers into cache", len(rows)) + + +def refresh_provider_cache(iss: str, jwks: dict | None, jwks_uri: str | None) -> None: + """Refresh a single provider's cache entry (called after create/update).""" + _provider_jwks_cache.put(iss, jwks, jwks_uri=jwks_uri) + + +def invalidate_provider_cache(iss: str) -> None: + """Remove a provider from cache (called after delete/deactivate).""" + _provider_jwks_cache.invalidate(iss) + async def get_tenant_db(session: AsyncSession, uid: str) -> Dict[str, str]: """Return tenant DB URL and schema.""" @@ -60,8 +97,58 @@ def _include(row: TenantKey) -> bool: for row in rows: if not row.public_jwk or not _include(row): continue - jwk_obj = JsonWebKey.import_key(row.public_jwk) - jwk_dict = jwk_obj.as_dict(is_private=False, kid=row.kid, alg=row.alg, use="sig") + jwk_obj = jwk.import_key(row.public_jwk) + jwk_dict = jwk_obj.as_dict(private=False, kid=row.kid, alg=row.alg, use="sig") if jwk_dict is not None: keys.append(jwk_dict) return {"keys": keys} + + +async def _revalidate_provider(session: AsyncSession, iss: str) -> bool: + """Re-read a provider from the DB when its cache entry is missing or stale. + + Keeps deactivations visible to every worker process, not just the one that + served the write. + """ + if not _provider_jwks_cache.is_stale(iss): + return True + + row = ( + await session.execute(select(WalletProvider).where(WalletProvider.iss == iss)) + ).scalar_one_or_none() + if not row or not row.active: + _provider_jwks_cache.invalidate(iss) + return False + + _provider_jwks_cache.put(row.iss, row.jwks, jwks_uri=row.jwks_uri) + return True + + +async def lookup_wallet_provider( + session: AsyncSession, iss: str, kid: str | None = None +) -> dict | None: + """Look up wallet provider key(s) by iss (+ optional kid). + + When *kid* is provided, returns a single key dict under ``public_key``. + When *kid* is omitted, returns all cached keys under ``keys``. + """ + if not await _revalidate_provider(session, iss): + logger.info("No active wallet provider for %s", iss) + return None + + if kid: + key = await _provider_jwks_cache.get_key(iss, kid) + if key: + return {"iss": iss, "public_key": key.as_dict(private=False)} + logger.info("kid %r not found for provider %s", kid, iss) + return None + + # No kid — return full keyset for trial verification + key_set = _provider_jwks_cache.get_keyset(iss) + if not key_set or not key_set.keys: + logger.info("No keys cached for provider %s", iss) + return None + return { + "iss": iss, + "keys": [k.as_dict(private=False) for k in key_set.keys], + } diff --git a/oid4vc/auth_server/admin/services/signing_service.py b/oid4vc/auth_server/admin/services/signing_service.py index 977ab3022..0347f8f31 100644 --- a/oid4vc/auth_server/admin/services/signing_service.py +++ b/oid4vc/auth_server/admin/services/signing_service.py @@ -2,7 +2,7 @@ from datetime import datetime, timezone -from authlib.jose import JsonWebKey, jwt +from joserfc import jwk, jwt from fastapi import HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -11,6 +11,7 @@ from admin.schemas.internal import JwtSignRequest, JwtSignResponse from admin.utils.crypto import decrypt_private_pem from admin.utils.keys import select_signing_key +from core.consts import SUPPORTED_SIGNING_ALGS, ALG_KEY_FAMILY MAX_TTL_SECONDS = 3600 @@ -36,7 +37,7 @@ async def sign_tenant_jwt( raise HTTPException(status_code=404, detail="signing_key_not_found") alg = req.alg or key.alg - if alg != "ES256": + if alg not in SUPPORTED_SIGNING_ALGS: raise HTTPException(status_code=400, detail="unsupported_alg") # Claims validation @@ -66,8 +67,9 @@ async def sign_tenant_jwt( # Sign pem = decrypt_private_pem(key.private_pem_enc) # type: ignore - jwk_key = JsonWebKey.import_key(pem) # type: ignore + key_family = ALG_KEY_FAMILY.get(alg, "EC") + jwk_key = jwk.import_key(pem, key_family) header = {"alg": alg, "kid": key.kid, "typ": "JWT"} - token = jwt.encode(header, claims, jwk_key).decode() + token = jwt.encode(header, claims, jwk_key) return JwtSignResponse(jwt=token, kid=key.kid, alg=alg, exp=exp_val) diff --git a/oid4vc/auth_server/admin/services/tenant_service.py b/oid4vc/auth_server/admin/services/tenant_service.py index 34dce8c0d..bc9e83b0f 100644 --- a/oid4vc/auth_server/admin/services/tenant_service.py +++ b/oid4vc/auth_server/admin/services/tenant_service.py @@ -2,14 +2,15 @@ from __future__ import annotations +import asyncio import secrets import uuid from datetime import datetime, timezone import psycopg -from authlib.jose import JsonWebKey +from joserfc import jwk from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 from fastapi import HTTPException from psycopg import sql from sqlalchemy.exc import IntegrityError @@ -26,14 +27,18 @@ from admin.utils.crypto import encrypt_db_password, encrypt_private_pem from admin.utils.db_utils import build_sync_url, resolve_tenant_urls, url_to_dsn from core.db.cached_session import cached_session +from core.consts import SUPPORTED_SIGNING_ALGS, ALG_KEY_FAMILY from core.models import Client +from core.utils.logging import get_logger + +logger = get_logger(__name__) class TenantService: """Tenant orchestration.""" def __init__(self, session: AsyncSession): - """Constructor.""" + """Bind to session.""" self.session = session self.repo = TenantRepository(session) @@ -103,15 +108,18 @@ async def create(self, data: TenantIn) -> Tenant: db_password = secrets.token_urlsafe(32) try: - self._provision( - db_name=db_name, - db_schema=db_schema, - db_user=db_user, - db_password=db_password, + await asyncio.to_thread( + lambda: self._provision( + db_name=db_name, + db_schema=db_schema, + db_user=db_user, + db_password=db_password, + ) ) except Exception as ex: await self.session.rollback() - raise HTTPException(status_code=500, detail=f"provision_failed: {ex}") + logger.exception("Tenant provision failed: %s", ex) + raise HTTPException(status_code=500, detail="provision_failed") # Run Alembic migrations via shared service alembic_url = build_sync_url(db_name, db_user, db_password) @@ -124,7 +132,8 @@ async def create(self, data: TenantIn) -> Tenant: ) except Exception as ex: await self.session.rollback() - raise HTTPException(status_code=500, detail=f"migration_failed: {ex}") + logger.exception("Tenant migration failed: %s", ex) + raise HTTPException(status_code=500, detail="migration_failed") tenant.db_name = db_name tenant.db_schema = db_schema @@ -166,16 +175,24 @@ async def delete(self, uid: str) -> int: return deleted async def generate_keypair(self, uid: str, body) -> dict: - """Generate and store a tenant signing keypair (ES256).""" + """Generate and store a tenant signing keypair.""" + repo = self.repo tenant = await repo.get_by_uid(uid) if not tenant: raise HTTPException(status_code=404, detail="tenant_not_found") - if body.alg != "ES256": + if body.alg not in SUPPORTED_SIGNING_ALGS: raise HTTPException(status_code=400, detail="unsupported_alg") - prv = ec.generate_private_key(ec.SECP256R1()) + # Generate private key based on algorithm + if body.alg == "EdDSA": + prv = ed25519.Ed25519PrivateKey.generate() + elif body.alg == "ES384": + prv = ec.generate_private_key(ec.SECP384R1()) + else: # ES256 + prv = ec.generate_private_key(ec.SECP256R1()) + private_pem = prv.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, @@ -183,12 +200,8 @@ async def generate_keypair(self, uid: str, body) -> dict: ).decode("utf-8") private_pem_enc = encrypt_private_pem(private_pem) - pub = prv.public_key() - public_pem = pub.public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ).decode("utf-8") - public_jwk = JsonWebKey.import_key(public_pem).as_dict() # type: ignore + key_family = ALG_KEY_FAMILY[body.alg] + public_jwk = jwk.import_key(private_pem, key_family).as_dict(private=False) # type: ignore if public_jwk is None: raise HTTPException(status_code=500, detail="Failed to create JWK from PEM") @@ -246,72 +259,43 @@ async def update_key_status(self, uid: str, kid: str, new_status: str) -> dict: await self.session.commit() return {"status": new_status, "kid": kid, "uid": uid} - async def create_client(self, uid: str, data: ClientIn) -> Client: - """Create a client record in the tenant DB.""" + async def _with_client_service(self, uid: str, op: str, callback): + """Resolve tenant, open a session, run callback(ClientService).""" tenant = await self.repo.get_by_uid(uid) if not tenant: raise HTTPException(status_code=404, detail="tenant_not_found") - async_url, _, schema = resolve_tenant_urls(tenant) try: async with cached_session(async_url, schema) as tenant_db: - svc = ClientService(tenant_db) - return await svc.create(data) + return await callback(ClientService(tenant_db)) + except HTTPException: + raise except Exception as ex: - raise HTTPException(status_code=500, detail=f"onboard_failed: {ex}") + logger.exception("Client %s failed for tenant %s: %s", op, uid, ex) + raise HTTPException(status_code=500, detail="onboard_failed") + + async def create_client(self, uid: str, data: ClientIn) -> Client: + """Create a client record in the tenant DB.""" + return await self._with_client_service( + uid, "create", lambda svc: svc.create(data) + ) async def list_clients(self, uid: str) -> list[Client]: """List all clients.""" - tenant = await self.repo.get_by_uid(uid) - if not tenant: - raise HTTPException(status_code=404, detail="tenant_not_found") - - async_url, _, schema = resolve_tenant_urls(tenant) - try: - async with cached_session(async_url, schema) as tenant_db: - svc = ClientService(tenant_db) - return await svc.list() - except Exception as ex: - raise HTTPException(status_code=500, detail=f"onboard_failed: {ex}") + return await self._with_client_service(uid, "list", lambda svc: svc.list()) async def get_client(self, uid: str, client_id: str) -> Client | None: """Get a specific client.""" - tenant = await self.repo.get_by_uid(uid) - if not tenant: - raise HTTPException(status_code=404, detail="tenant_not_found") - - async_url, _, schema = resolve_tenant_urls(tenant) - try: - async with cached_session(async_url, schema) as tenant_db: - svc = ClientService(tenant_db) - return await svc.get(client_id) - except Exception as ex: - raise HTTPException(status_code=500, detail=f"onboard_failed: {ex}") + return await self._with_client_service(uid, "get", lambda svc: svc.get(client_id)) async def update_client(self, uid: str, client_id: str, data: ClientIn) -> int: """Update a client.""" - tenant = await self.repo.get_by_uid(uid) - if not tenant: - raise HTTPException(status_code=404, detail="tenant_not_found") - - async_url, _, schema = resolve_tenant_urls(tenant) - try: - async with cached_session(async_url, schema) as tenant_db: - svc = ClientService(tenant_db) - return await svc.update(client_id, data) - except Exception as ex: - raise HTTPException(status_code=500, detail=f"onboard_failed: {ex}") + return await self._with_client_service( + uid, "update", lambda svc: svc.update(client_id, data) + ) async def delete_client(self, uid: str, client_id: str) -> int: """Delete a client.""" - tenant = await self.repo.get_by_uid(uid) - if not tenant: - raise HTTPException(status_code=404, detail="tenant_not_found") - - async_url, _, schema = resolve_tenant_urls(tenant) - try: - async with cached_session(async_url, schema) as tenant_db: - svc = ClientService(tenant_db) - return await svc.delete(client_id) - except Exception as ex: - raise HTTPException(status_code=500, detail=f"onboard_failed: {ex}") + return await self._with_client_service( + uid, "delete", lambda svc: svc.delete(client_id) + ) diff --git a/oid4vc/auth_server/admin/services/wallet_provider_service.py b/oid4vc/auth_server/admin/services/wallet_provider_service.py new file mode 100644 index 000000000..90b454e2e --- /dev/null +++ b/oid4vc/auth_server/admin/services/wallet_provider_service.py @@ -0,0 +1,93 @@ +"""Wallet provider service.""" + +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from admin.models import WalletProvider +from admin.repositories.wallet_provider_repository import WalletProviderRepository +from admin.schemas.wallet_provider import WalletProviderIn, WalletProviderUpdate +from admin.services.internal_service import ( + invalidate_provider_cache, + refresh_provider_cache, +) + + +class WalletProviderService: + """Wallet provider CRUD.""" + + def __init__(self, session: AsyncSession): + """Bind to session.""" + self.session = session + self.repo = WalletProviderRepository(session) + + async def create(self, data: WalletProviderIn) -> WalletProvider: + """Create a new wallet provider. Raises 409 if iss already exists.""" + existing = await self.repo.get_by_iss(data.iss) + if existing: + raise HTTPException( + status_code=409, + detail="wallet_provider_exists", + ) + + provider = WalletProvider( + iss=data.iss, + jwks=data.jwks, + jwks_uri=data.jwks_uri, + name=data.name, + active=data.active, + ) + self.session.add(provider) + await self.session.commit() + await self.session.refresh(provider) + refresh_provider_cache(provider.iss, provider.jwks, provider.jwks_uri) + return provider + + async def list(self, active_only: bool = False) -> list[WalletProvider]: + """Return all wallet providers, optionally filtered to active only.""" + rows = await self.repo.list(active_only=active_only) + return list(rows) + + async def get(self, provider_id: int) -> WalletProvider: + """Fetch a single wallet provider by ID. Raises 404 if not found.""" + row = await self.repo.get(provider_id) + if not row: + raise HTTPException(status_code=404, detail="wallet_provider_not_found") + return row + + async def update( + self, provider_id: int, data: WalletProviderUpdate + ) -> WalletProvider: + """Partially update a wallet provider. Raises 404 if not found.""" + row = await self.repo.get(provider_id) + if not row: + raise HTTPException(status_code=404, detail="wallet_provider_not_found") + + old_iss = row.iss + values = data.model_dump(exclude_none=True) + if values: + await self.repo.update_values(provider_id, values) + await self.session.commit() + await self.session.refresh(row) + if old_iss != row.iss: + invalidate_provider_cache(old_iss) + if row.active: + refresh_provider_cache(row.iss, row.jwks, row.jwks_uri) + else: + invalidate_provider_cache(row.iss) + return row + + async def delete(self, provider_id: int) -> None: + """Delete a wallet provider by ID. Raises 404 if not found.""" + row = await self.repo.get(provider_id) + if not row: + raise HTTPException(status_code=404, detail="wallet_provider_not_found") + invalidate_provider_cache(row.iss) + await self.repo.delete(provider_id) + await self.session.commit() + + async def lookup(self, iss: str) -> WalletProvider | None: + """Look up an active provider by iss (for attestation verification).""" + row = await self.repo.get_by_iss(iss) + if row and row.active: + return row + return None diff --git a/oid4vc/auth_server/admin/utils/crypto.py b/oid4vc/auth_server/admin/utils/crypto.py index a856ad193..ab32a6ace 100644 --- a/oid4vc/auth_server/admin/utils/crypto.py +++ b/oid4vc/auth_server/admin/utils/crypto.py @@ -1,23 +1,15 @@ """Crypto helpers (simple mode).""" -import base64 import os import secrets -from authlib.jose import jwk +from joserfc.jwk import ECKey from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.ciphers.aead import AESGCM from admin.config import settings - - -def _b64url_decode_padded(s: str) -> bytes: - return base64.urlsafe_b64decode(s + "===") - - -def _b64url_encode_no_pad(b: bytes) -> str: - return base64.urlsafe_b64encode(b).decode("utf-8").rstrip("=") +from core.utils.encoding import b64url_decode, b64url_encode def _load_key(version: int = 1) -> bytes | None: @@ -26,7 +18,7 @@ def _load_key(version: int = 1) -> bytes | None: if not secret: return None try: - return _b64url_decode_padded(secret) + return b64url_decode(secret) except Exception: return None @@ -40,7 +32,7 @@ def _aead_encrypt(plaintext: str) -> str: aesgcm = AESGCM(key) nonce = os.urandom(12) ct = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None) - blob = _b64url_encode_no_pad(nonce + ct) + blob = b64url_encode(nonce + ct) # Always prefix with version, e.g., v1:... or v2:... return f"v{version}:{blob}" @@ -49,7 +41,9 @@ def _aead_decrypt(blob: str) -> str: """AES-GCM decrypt with version prefix, assume v1 if missing.""" version = 1 b64_blob = blob + has_version_prefix = False if isinstance(blob, str) and blob.startswith("v") and ":" in blob[:6]: + has_version_prefix = True # Parse version prefix, e.g., v2:... vpart, b64_blob = blob.split(":", 1) try: @@ -58,19 +52,27 @@ def _aead_decrypt(blob: str) -> str: version = 1 key = _load_key(version) if not key: - # fallback: if v1 and no key, return as plaintext + if has_version_prefix: + raise ValueError("decryption key unavailable for version %d" % version) + # No version prefix and no key — treat as unencrypted plaintext return blob try: - raw = _b64url_decode_padded(b64_blob) - except Exception: + raw = b64url_decode(b64_blob) + except Exception as exc: + if has_version_prefix: + raise ValueError("failed to decode encrypted blob") from exc return blob if len(raw) < 12 + 16: + if has_version_prefix: + raise ValueError("encrypted blob too short") return blob nonce, ct_tag = raw[:12], raw[12:] aesgcm = AESGCM(key) try: return aesgcm.decrypt(nonce, ct_tag, None).decode("utf-8") - except Exception: + except Exception as exc: + if has_version_prefix: + raise ValueError("AEAD decryption failed") from exc return blob @@ -103,7 +105,7 @@ def generate_es256_keypair(kid: str | None = None, encrypt: bool = True) -> dict encryption_algorithm=serialization.NoEncryption(), ).decode("utf-8") - public_jwk = jwk.dumps(private_pem, kty="EC", crv="P-256", is_private=False) + public_jwk = ECKey.import_key(private_pem).as_dict(private=False) _kid = kid or f"as-{secrets.token_hex(4)}" public_jwk["kid"] = _kid public_jwk["alg"] = "ES256" @@ -116,5 +118,4 @@ def generate_es256_keypair(kid: str | None = None, encrypt: bool = True) -> dict "alg": "ES256", "public_jwk": public_jwk, "private_pem_enc": private_pem_enc, - "private_pem": private_pem, } diff --git a/oid4vc/auth_server/alembic/admin/versions/0002_add_wallet_provider.py b/oid4vc/auth_server/alembic/admin/versions/0002_add_wallet_provider.py new file mode 100644 index 000000000..89d7f6221 --- /dev/null +++ b/oid4vc/auth_server/alembic/admin/versions/0002_add_wallet_provider.py @@ -0,0 +1,32 @@ +from alembic import op + +revision = "0002_add_wallet_provider" +down_revision = "0001_init_admin" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + CREATE TABLE IF NOT EXISTS wallet_provider ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + iss TEXT NOT NULL, + jwks JSONB, + jwks_uri TEXT, + name TEXT, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ, + UNIQUE (iss) + ); + + CREATE INDEX IF NOT EXISTS idx_wallet_provider_iss + ON wallet_provider (iss); + + CREATE INDEX IF NOT EXISTS idx_wallet_provider_active + ON wallet_provider (active); + """) + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS wallet_provider;") diff --git a/oid4vc/auth_server/alembic/tenant/versions/0002_tx_code_and_jti_seen.py b/oid4vc/auth_server/alembic/tenant/versions/0002_tx_code_and_jti_seen.py new file mode 100644 index 000000000..ce4500692 --- /dev/null +++ b/oid4vc/auth_server/alembic/tenant/versions/0002_tx_code_and_jti_seen.py @@ -0,0 +1,47 @@ +from alembic import op + +revision = "0002_tx_code_and_jti_seen" +down_revision = "0001_init_tenant" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + ALTER TABLE pre_auth_code + ADD COLUMN IF NOT EXISTS user_pin_attempts INTEGER NOT NULL DEFAULT 0; + + CREATE TABLE IF NOT EXISTS jti_seen ( + jti TEXT PRIMARY KEY, + expires_at TIMESTAMPTZ NOT NULL, + metadata JSONB + ); + CREATE INDEX IF NOT EXISTS ix_jti_seen_expires_at + ON jti_seen (expires_at); + + DROP TABLE IF EXISTS dpop_jti; + """) + + +def downgrade() -> None: + op.execute(""" + CREATE TABLE IF NOT EXISTS dpop_jti ( + id SERIAL PRIMARY KEY, + subject_id INTEGER NOT NULL REFERENCES subject(id) + ON UPDATE CASCADE ON DELETE CASCADE, + jti TEXT NOT NULL UNIQUE, + htm TEXT, + htu TEXT, + cnf_jkt TEXT, + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_dpop_jti_expires_at + ON dpop_jti (expires_at); + + DROP INDEX IF EXISTS ix_jti_seen_expires_at; + DROP TABLE IF EXISTS jti_seen; + + ALTER TABLE pre_auth_code + DROP COLUMN IF EXISTS user_pin_attempts; + """) diff --git a/oid4vc/auth_server/core/consts.py b/oid4vc/auth_server/core/consts.py index 4237d1d5e..d0cea1e77 100644 --- a/oid4vc/auth_server/core/consts.py +++ b/oid4vc/auth_server/core/consts.py @@ -2,29 +2,38 @@ class OAuth2Flow: - """OAuth2 grant types.""" + """Internal flow identifiers.""" PRE_AUTH_CODE = "pre_auth_code" REFRESH_TOKEN = "refresh_token" class OAuth2GrantType: - """OAuth2 grant types.""" + """Wire-level grant_type URNs.""" PRE_AUTH_CODE = "urn:ietf:params:oauth:grant-type:pre-authorized_code" REFRESH_TOKEN = "refresh_token" class ClientAuthMethod: - """OAuth2 client authentication methods.""" + """token_endpoint_auth_method values.""" CLIENT_SECRET_BASIC = "client_secret_basic" PRIVATE_KEY_JWT = "private_key_jwt" - CLIENT_SECRET_JWT = "client_secret_jwt" CLIENT_AUTH_METHODS: tuple[str, ...] = ( ClientAuthMethod.CLIENT_SECRET_BASIC, ClientAuthMethod.PRIVATE_KEY_JWT, - ClientAuthMethod.CLIENT_SECRET_JWT, ) + +SUPPORTED_SIGNING_ALGS: tuple[str, ...] = ("ES256", "EdDSA", "ES384") + +# Mapping from signing algorithm to joserfc key family ("EC" or "OKP") +ALG_KEY_FAMILY: dict[str, str] = { + "ES256": "EC", + "ES384": "EC", + "EdDSA": "OKP", +} + +PBKDF2_ALLOWED_ALGOS: frozenset[str] = frozenset({"sha256", "sha384", "sha512"}) diff --git a/oid4vc/auth_server/core/crypto/crypto.py b/oid4vc/auth_server/core/crypto/crypto.py index 85e9e61b9..3ef01964e 100644 --- a/oid4vc/auth_server/core/crypto/crypto.py +++ b/oid4vc/auth_server/core/crypto/crypto.py @@ -1,18 +1,12 @@ """Minimal PBKDF2 helpers for hashing and verifying shared secrets.""" -import base64 import hashlib import hmac import os from typing import Tuple - -def _b64url_nopad(data: bytes) -> str: - return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=") - - -def _b64url_decode_padded(data: str) -> bytes: - return base64.urlsafe_b64decode(data + "===") +from core.consts import PBKDF2_ALLOWED_ALGOS +from core.utils.encoding import b64url_decode, b64url_encode def hash_secret_pbkdf2( @@ -29,8 +23,8 @@ def hash_secret_pbkdf2( salt = os.urandom(salt_len) dk = hashlib.pbkdf2_hmac(algo, secret.encode("utf-8"), salt, iterations, dklen) return f"pbkdf2:{algo}:{iterations}$%s$%s" % ( - _b64url_nopad(salt), - _b64url_nopad(dk), + b64url_encode(salt), + b64url_encode(dk), ) @@ -43,8 +37,8 @@ def _parse_pbkdf2(encoded: str) -> Tuple[str, int, bytes, bytes]: algo, rest2 = rest.split(":", 1) iter_s, salt_b64, hash_b64 = rest2.split("$") iterations = int(iter_s) - salt = _b64url_decode_padded(salt_b64) - dk = _b64url_decode_padded(hash_b64) + salt = b64url_decode(salt_b64) + dk = b64url_decode(hash_b64) return algo, iterations, salt, dk except Exception as ex: raise ValueError("invalid pbkdf2 format") from ex @@ -54,6 +48,10 @@ def verify_secret_pbkdf2(secret: str, encoded: str) -> bool: """Verify a PBKDF2-HMAC hash string produced by hash_secret_pbkdf2.""" try: algo, iterations, salt, expected = _parse_pbkdf2(encoded) + if algo not in PBKDF2_ALLOWED_ALGOS: + return False + if iterations > 1_000_000: + return False dklen = len(expected) actual = hashlib.pbkdf2_hmac( algo, secret.encode("utf-8"), salt, iterations, dklen diff --git a/oid4vc/auth_server/core/db/cached_session.py b/oid4vc/auth_server/core/db/cached_session.py index d97167680..91b0f871d 100644 --- a/oid4vc/auth_server/core/db/cached_session.py +++ b/oid4vc/auth_server/core/db/cached_session.py @@ -1,37 +1,58 @@ -"""Cached session factory.""" +"""LRU-cached async engine pool, keyed by (url, schema).""" +import asyncio +from collections import OrderedDict from contextlib import asynccontextmanager -from functools import lru_cache from typing import AsyncIterator -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine - - -@lru_cache(maxsize=256) -def _session_factory(async_url: str, schema: str) -> async_sessionmaker[AsyncSession]: - """Return a cached sessionmaker for the given database and schema.""" - engine = create_async_engine( - async_url, - pool_pre_ping=True, - connect_args={"server_settings": {"search_path": schema}}, - ) - return async_sessionmaker(engine, expire_on_commit=False) +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +_engines: OrderedDict[tuple[str, str], AsyncEngine] = OrderedDict() +_DEFAULT_MAX = 64 + + +def _session_factory( + async_url: str, schema: str, *, max_engines: int = _DEFAULT_MAX +) -> async_sessionmaker[AsyncSession]: + """Get or create a sessionmaker, evicting LRU engines over max.""" + key = (async_url, schema) + if key in _engines: + _engines.move_to_end(key) + else: + _engines[key] = create_async_engine( + async_url, + pool_pre_ping=True, + connect_args={"server_settings": {"search_path": schema}}, + ) + while len(_engines) > max_engines: + _, evicted = _engines.popitem(last=False) + try: + asyncio.get_event_loop().create_task(evicted.dispose()) + except RuntimeError: + pass + return async_sessionmaker(_engines[key], expire_on_commit=False) @asynccontextmanager async def cached_session(async_url: str, schema: str) -> AsyncIterator[AsyncSession]: - """Yield an AsyncSession backed by a cached factory.""" + """Context manager for a session from the cached pool.""" session = _session_factory(async_url, schema)() try: yield session + except Exception: + await session.rollback() + raise finally: await session.close() async def dispose_cached_engines() -> None: - """Dispose all cached engines.""" - factories = list(_session_factory.cache_info().cache.keys()) # type: ignore[attr-defined] - for args in factories: - sessionmaker = _session_factory(*args) - await sessionmaker.bind.dispose() # type: ignore[union-attr] - _session_factory.cache_clear() + """Shut down all pooled engines.""" + for engine in _engines.values(): + await engine.dispose() + _engines.clear() diff --git a/oid4vc/auth_server/core/db/session.py b/oid4vc/auth_server/core/db/session.py index 4a0af53eb..ef5cb8cb5 100644 --- a/oid4vc/auth_server/core/db/session.py +++ b/oid4vc/auth_server/core/db/session.py @@ -16,13 +16,13 @@ class DatabaseSessionManager: """Async SQLAlchemy session manager.""" def __init__(self, *, search_path: str | None = None) -> None: - """Constructor.""" + """Set up with optional schema search_path.""" self._engine: AsyncEngine | None = None self._sessionmaker: async_sessionmaker[AsyncSession] | None = None self._search_path = search_path def init(self, url: str) -> None: - """Initialize engine and sessionmaker.""" + """Create engine and bind sessionmaker.""" connect_args = {} if self._search_path: connect_args["server_settings"] = {"search_path": self._search_path} diff --git a/oid4vc/auth_server/core/observability/observability.py b/oid4vc/auth_server/core/observability/observability.py index 4e596e401..4e7c9e0c0 100644 --- a/oid4vc/auth_server/core/observability/observability.py +++ b/oid4vc/auth_server/core/observability/observability.py @@ -74,7 +74,7 @@ class RequestContextMiddleware(BaseHTTPMiddleware): """Bind request_id/method/path to logs and set X-Request-ID.""" def __init__(self, app, header_name: str = "X-Request-ID") -> None: - """Constructor.""" + """Attach X-Request-ID propagation.""" super().__init__(app) self.header_name = header_name self._logger = logging.getLogger(__name__) @@ -84,7 +84,7 @@ async def dispatch( request: Request, call_next: typing.Callable[[Request], typing.Awaitable[Response]], ): - """Bind request_id to logs and set X-Request-ID header.""" + """Bind request context and propagate X-Request-ID.""" request_id = request.headers.get(self.header_name) if not request_id: request_id = str(uuid.uuid4()) @@ -143,3 +143,12 @@ def current_request_id(default: str | None = None) -> str | None: except Exception: return default return default + + +def internal_api_headers(token: str) -> dict[str, str]: + """Build headers for internal API calls (auth + request-ID propagation).""" + headers = {"Authorization": f"Bearer {token}"} + rid = current_request_id() + if rid: + headers["X-Request-ID"] = rid + return headers diff --git a/oid4vc/auth_server/core/repositories/client_repository.py b/oid4vc/auth_server/core/repositories/client_repository.py index 9ff2dd04e..ecc51dfbe 100644 --- a/oid4vc/auth_server/core/repositories/client_repository.py +++ b/oid4vc/auth_server/core/repositories/client_repository.py @@ -10,11 +10,11 @@ class ClientRepository: """Data-access for clients.""" def __init__(self, session: AsyncSession) -> None: - """Constructor.""" + """Wrap a session.""" self.session = session async def get_by_client_id(self, client_id: str) -> Client | None: - """Get client by client_id.""" + """Lookup by client_id.""" res = await self.session.execute( select(Client).where(Client.client_id == client_id) ) diff --git a/oid4vc/auth_server/core/security/client_auth.py b/oid4vc/auth_server/core/security/client_auth.py deleted file mode 100644 index 9b3d290ee..000000000 --- a/oid4vc/auth_server/core/security/client_auth.py +++ /dev/null @@ -1,215 +0,0 @@ -"""Client authentication for issuer APIs.""" - -import json -from typing import Any, Mapping - -import httpx -from authlib.jose import JsonWebKey, jwt -from fastapi import HTTPException, Request, status -from fastapi.security import HTTPAuthorizationCredentials, HTTPBasicCredentials -from sqlalchemy.ext.asyncio import AsyncSession - -from core.consts import CLIENT_AUTH_METHODS -from core.consts import ClientAuthMethod as CLIENT_AUTH_METHOD -from core.crypto.crypto import verify_secret_pbkdf2 -from core.models import Client as AuthClient -from core.repositories.client_repository import ClientRepository -from core.security.utils import jwt_header_unverified, jwt_payload_unverified -from core.utils.logging import get_logger - -logger = get_logger(__name__) - - -async def _load_jwks(client) -> dict | None: - if isinstance(client.jwks, dict): - return client.jwks - if client.jwks and isinstance(client.jwks, str): - try: - return json.loads(client.jwks) - except Exception: - return None - if client.jwks_uri: - try: - async with httpx.AsyncClient(timeout=5.0) as h: - r = await h.get(client.jwks_uri) - r.raise_for_status() - data = r.json() - return data if isinstance(data, dict) else None - except Exception: - return None - return None - - -def _audiences_for(request: Request) -> list[str]: - # Full URL without query - url = str(request.url) - base = url.split("?", 1)[0] - return [base] - - -def _validate_jwt_alg(token: str, expected_alg: str): - """Validate the 'alg' field in the JWT header.""" - header = jwt_header_unverified(token) - if header.get("alg") != expected_alg: - raise HTTPException(status_code=401, detail="invalid_alg") - - -def _validate_jwt_claims(decoded: dict[str, Any], request: Request): - """Validate standard JWT claims.""" - for claim in ("iss", "sub", "aud", "exp", "iat"): - if claim not in decoded: - raise HTTPException(status_code=401, detail=f"missing_{claim}") - aud = decoded.get("aud") - expected_aud = _audiences_for(request) - if isinstance(aud, str): - aud = [aud] - if not aud or not any(a in expected_aud for a in aud): - raise HTTPException(status_code=401, detail="invalid_audience") - - -def _decode_and_validate_jwt( - token: str, - key_material: Any, - request: Request, - expected_alg: str | None = None, -) -> Mapping[str, Any]: - """Decode, validate, and return JWT claims using provided key material.""" - - if expected_alg: - _validate_jwt_alg(token, expected_alg) - - try: - claims = jwt.decode(token, key_material) # type: ignore[arg-type] - claims.validate(now=None, leeway=30) - _validate_jwt_claims(claims, request) - except Exception as exc: - raise HTTPException(status_code=401, detail="invalid_client_assertion") from exc - - if not isinstance(claims, Mapping): - raise HTTPException(status_code=401, detail="invalid_client_assertion") - - return claims - - -async def _authenticate_private_key_jwt( - client: AuthClient, token: str, request: Request -) -> Mapping[str, Any]: - """Validate private_key_jwt assertions.""" - - jwks = await _load_jwks(client) - if not isinstance(jwks, dict) or not jwks.get("keys"): - raise HTTPException(status_code=401, detail="invalid_client_keys") - - keys = JsonWebKey.import_key_set(jwks) - return _decode_and_validate_jwt( - token, - keys, - request, - expected_alg=client.client_auth_signing_alg, - ) - - -async def _authenticate_client_secret_jwt( - client: AuthClient, token: str, request: Request, presented_client_id: str -) -> Mapping[str, Any]: - """Validate client_secret_jwt assertions signed with a shared secret.""" - - secret = client.client_secret or "" - if not secret: - raise HTTPException(status_code=401, detail="unauthorized_client") - - claims = _decode_and_validate_jwt( - token, - secret, - request, - expected_alg=client.client_auth_signing_alg, - ) - - if str(claims.get("sub")) != str(presented_client_id): - raise HTTPException(status_code=401, detail="invalid_client") - - return claims - - -def _authenticate_client_secret_basic(client: AuthClient, token: str) -> None: - """Validate client_secret_basic credentials.""" - - secret_hash = client.client_secret - if secret_hash and token and verify_secret_pbkdf2(token, secret_hash): - return - raise HTTPException(status_code=401, detail="invalid_client") - - -async def base_client_auth( - db: AsyncSession, - request: Request, - basic_creds: HTTPBasicCredentials | None = None, - credentials: HTTPAuthorizationCredentials | None = None, -) -> AuthClient: - """Authenticate client and return the persisted Client model.""" - client_id: str | None = None - token: str | None = None - - scheme = credentials.scheme.lower() if credentials and credentials.scheme else "" - cred = credentials.credentials if credentials else "" - - if scheme == "bearer" and cred: - token = cred - try: - claims = jwt_payload_unverified(token) or {} - client_id = claims.get("sub") - except Exception as ex: - logger.exception("Failed to decode bearer token: %s", ex) - raise HTTPException(status_code=401, detail="invalid_client_assertion") - elif basic_creds and basic_creds.username is not None: - client_id = basic_creds.username - token = basic_creds.password or "" - scheme = "basic" - else: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="unauthorized", - headers={"WWW-Authenticate": "Bearer, Basic"}, - ) - - if not client_id or not token: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="unauthorized", - headers={"WWW-Authenticate": "Bearer, Basic"}, - ) - - repo = ClientRepository(db) - client = await repo.get_by_client_id(str(client_id)) - if client is None: - raise HTTPException(status_code=401, detail="invalid_client") - - allowed = (client.client_auth_method or "").lower() - if allowed not in set(CLIENT_AUTH_METHODS): - raise HTTPException(status_code=401, detail="unauthorized_client") - - if allowed == CLIENT_AUTH_METHOD.CLIENT_SECRET_BASIC and scheme != "basic": - raise HTTPException(status_code=401, detail="unauthorized_client") - if ( - allowed - in {CLIENT_AUTH_METHOD.PRIVATE_KEY_JWT, CLIENT_AUTH_METHOD.CLIENT_SECRET_JWT} - and scheme != "bearer" - ): - raise HTTPException(status_code=401, detail="unauthorized_client") - - if allowed == CLIENT_AUTH_METHOD.PRIVATE_KEY_JWT: - await _authenticate_private_key_jwt(client, token, request) - request.state.client_id = str(client.client_id) - return client - - if allowed == CLIENT_AUTH_METHOD.CLIENT_SECRET_JWT: - await _authenticate_client_secret_jwt(client, token, request, str(client_id)) - request.state.client_id = str(client.client_id) - return client - - if allowed == CLIENT_AUTH_METHOD.CLIENT_SECRET_BASIC: - _authenticate_client_secret_basic(client, token) - request.state.client_id = str(client.client_id) - return client - - raise HTTPException(status_code=401, detail="unauthorized_client") diff --git a/oid4vc/auth_server/core/security/jwks_cache.py b/oid4vc/auth_server/core/security/jwks_cache.py new file mode 100644 index 000000000..13643f2b9 --- /dev/null +++ b/oid4vc/auth_server/core/security/jwks_cache.py @@ -0,0 +1,167 @@ +"""JWKS cache with TTL and refresh-on-kid-miss.""" + +import asyncio +import ipaddress +import socket +import time +from collections import OrderedDict +from urllib.parse import urlparse + +import httpx +from joserfc.jwk import Key, KeySet + +from core.utils.logging import get_logger + +logger = get_logger(__name__) + + +class JWKSCache: + """Cache JWKS documents with TTL and refresh-on-kid-miss. + + Two usage patterns: + URI mode — ``get_jwks(uri, kid=...)`` fetches/caches by URI. + Put mode — ``put(key, jwks, jwks_uri=...)`` pre-loads a keyset, + ``get_key(key, kid)`` resolves a single key with refresh-on-miss. + """ + + def __init__(self, ttl: int = 300, max_size: int = 128): + """TTL in seconds, max entries before LRU eviction.""" + self._cache: OrderedDict[str, tuple[float, KeySet | None, str | None]] = ( + OrderedDict() + ) + self._ttl = ttl + self._max_size = max_size + + @staticmethod + def _find_key(key_set: KeySet, kid: str) -> Key | None: + """Return key by kid, or None if not found.""" + try: + return key_set.get_by_kid(kid) + except Exception: + return None + + @staticmethod + async def _check_ssrf(uri: str) -> None: + """Block URIs that resolve to non-globally-routable addresses.""" + parsed = urlparse(uri) + if parsed.scheme not in ("https", "http"): + raise ValueError(f"Unsupported scheme: {parsed.scheme}") + hostname = parsed.hostname + if not hostname: + raise ValueError("Missing hostname") + + # IP literal + try: + addr = ipaddress.ip_address(hostname) + except ValueError: + addr = None + + if addr is not None: + if not addr.is_global: + raise ValueError(f"Blocked address: {hostname}") + return + + # Hostname — resolve and check all addresses + loop = asyncio.get_running_loop() + infos = await loop.run_in_executor( + None, + lambda: socket.getaddrinfo( + hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM + ), + ) + for info in infos: + if not ipaddress.ip_address(info[4][0]).is_global: + raise ValueError(f"Blocked address: {info[4][0]} (from {hostname})") + + @staticmethod + async def _fetch(uri: str) -> KeySet | None: + """Fetch JWKS from URI after SSRF validation. Returns None if empty.""" + await JWKSCache._check_ssrf(uri) + async with httpx.AsyncClient(timeout=10) as client: + data = (await client.get(uri)).raise_for_status().json() + keys = data.get("keys") if isinstance(data, dict) else None + return KeySet.import_key_set(data) if keys else None + + def _touch(self, key: str) -> None: + """Move key to end and evict LRU if over capacity.""" + self._cache.move_to_end(key) + while len(self._cache) > self._max_size: + self._cache.popitem(last=False) + + # ── Put mode ─────────────────────────────────────────── + + def put(self, key: str, jwks: dict | None, *, jwks_uri: str | None = None) -> None: + """Store a JWKS document directly (e.g. loaded from DB).""" + key_set = KeySet.import_key_set(jwks) if (jwks and jwks.get("keys")) else None + self._cache[key] = (time.time(), key_set, jwks_uri) + self._touch(key) + + def get_keyset(self, key: str) -> KeySet | None: + """Return the full KeySet for a cache key, or None if not cached.""" + cached = self._cache.get(key) + if not cached: + return None + self._touch(key) + _, key_set, _ = cached + return key_set + + def is_stale(self, key: str) -> bool: + """Return True when the entry is absent or older than the TTL.""" + cached = self._cache.get(key) + if not cached: + return True + return (time.time() - cached[0]) >= self._ttl + + async def get_key(self, key: str, kid: str) -> Key | None: + """Return a single key by kid, refreshing from jwks_uri on miss.""" + cached = self._cache.get(key) + if not cached: + return None + + self._touch(key) + _, key_set, refresh_uri = cached + + if key_set: + found = self._find_key(key_set, kid) + if found: + return found + + if not refresh_uri: + return None + + logger.info("kid %r miss for %s, refreshing", kid, key) + try: + key_set = await self._fetch(refresh_uri) + self._cache[key] = (time.time(), key_set, refresh_uri) + self._touch(key) + return self._find_key(key_set, kid) if key_set else None + except Exception: + logger.warning("Failed to refresh JWKS from %s", refresh_uri, exc_info=True) + return None + + # ── URI mode ─────────────────────────────────────────── + + async def get_jwks(self, uri: str, *, kid: str | None = None) -> KeySet | None: + """Return cached KeySet by URI, fetching/refreshing as needed.""" + now = time.time() + cached = self._cache.get(uri) + + if cached: + ts, key_set, _ = cached + if now - ts < self._ttl: + if key_set and (kid is None or self._find_key(key_set, kid)): + return key_set + logger.info("kid %r miss for %s, refreshing", kid, uri) + + try: + key_set = await self._fetch(uri) + self._cache[uri] = (time.time(), key_set, uri) + self._touch(uri) + return key_set + except Exception: + logger.warning("Failed to fetch JWKS from %s", uri, exc_info=True) + return cached[1] if cached else None + + def invalidate(self, key: str) -> None: + """Remove a cached entry.""" + self._cache.pop(key, None) diff --git a/oid4vc/auth_server/core/security/utils.py b/oid4vc/auth_server/core/security/utils.py index 945d2e852..d27369e28 100644 --- a/oid4vc/auth_server/core/security/utils.py +++ b/oid4vc/auth_server/core/security/utils.py @@ -2,55 +2,33 @@ import base64 import hashlib -import secrets -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from typing import Any -from authlib.jose import JsonWebKey, jwt +from joserfc import jwt +from joserfc.jws import extract_compact +from joserfc.jwk import KeySet +from core.consts import SUPPORTED_SIGNING_ALGS from core.utils.json import safe_json_loads -from tenant.config import settings def utcnow() -> datetime: - """UTC now.""" + """Current time, UTC.""" return datetime.now(timezone.utc) -def new_refresh_token() -> str: - """Generate a new refresh token.""" - return secrets.token_urlsafe(settings.TOKEN_BYTES) - - def hash_token(value: str) -> str: - """Hash a token value.""" + """SHA-256 of a token, base64url-encoded.""" d = hashlib.sha256(value.encode("utf-8")).digest() return base64.urlsafe_b64encode(d).decode("ascii").rstrip("=") -def compute_access_exp(now: datetime | None = None) -> datetime: - """Compute access token expiry.""" - now = now or utcnow() - return now + timedelta(seconds=settings.ACCESS_TOKEN_TTL) - - -def compute_refresh_exp(now: datetime | None = None) -> datetime: - """Compute refresh token expiry.""" - now = now or utcnow() - return now + timedelta(seconds=settings.REFRESH_TOKEN_TTL) - - -def b64url_decode(data: str) -> bytes: - """Decode base64url without verification (for JWT header/payload).""" - pad = "=" * (-len(data) % 4) - return base64.urlsafe_b64decode(data + pad) - - def jwt_payload_unverified(jwt_str: str) -> dict[str, Any]: """Return unverified JWT payload as dict (no signature check).""" try: - _, payload, _ = jwt_str.split(".", 2) - return {} if not payload else safe_json_loads(b64url_decode(payload)) + obj = extract_compact(jwt_str.encode()) + return safe_json_loads(obj.payload) except Exception: return {} @@ -58,24 +36,24 @@ def jwt_payload_unverified(jwt_str: str) -> dict[str, Any]: def jwt_header_unverified(jwt_str: str) -> dict[str, Any]: """Return unverified JWT header as dict (no signature check).""" try: - header, _, _ = jwt_str.split(".", 2) - return {} if not header else safe_json_loads(b64url_decode(header)) + obj = extract_compact(jwt_str.encode()) + return dict(obj.headers()) except Exception: return {} def verify_access_jwt(token: str, jwks: dict, expected_iss: str | None = None): - """Verify JWT signature & claims using Authlib.""" - # Provide a Key Set; Authlib will pick by 'kid' automatically - key_set = JsonWebKey.import_key_set(jwks) + """Verify JWT signature & claims using joserfc.""" + + key_set = KeySet.import_key_set(jwks) + + result = jwt.decode(token, key_set, algorithms=list(SUPPORTED_SIGNING_ALGS)) - # Optionally constrain 'iss' if you want a strict match - claims_options = {} + claims_opts = {} if expected_iss: - claims_options["iss"] = {"essential": True, "values": [expected_iss]} + claims_opts["iss"] = {"essential": True, "values": [expected_iss]} - claims = jwt.decode(token, key_set, claims_options=claims_options) - # exp/nbf/iats checks - claims.validate(now=datetime.now(timezone.utc)) + claims_registry = jwt.JWTClaimsRegistry(**claims_opts) + claims_registry.validate(result.claims) - return claims + return result.claims diff --git a/oid4vc/auth_server/core/utils/encoding.py b/oid4vc/auth_server/core/utils/encoding.py new file mode 100644 index 000000000..4ab1062fe --- /dev/null +++ b/oid4vc/auth_server/core/utils/encoding.py @@ -0,0 +1,13 @@ +"""Base64url (no-pad) encode/decode.""" + +import base64 + + +def b64url_encode(data: bytes) -> str: + """Unpadded base64url.""" + return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=") + + +def b64url_decode(data: str) -> bytes: + """Reverse of b64url_encode, tolerates missing padding.""" + return base64.urlsafe_b64decode(data + "===") diff --git a/oid4vc/auth_server/core/utils/logging.py b/oid4vc/auth_server/core/utils/logging.py index 51864fa17..a115e2a16 100755 --- a/oid4vc/auth_server/core/utils/logging.py +++ b/oid4vc/auth_server/core/utils/logging.py @@ -1,81 +1,8 @@ -"""Logging helpers: unified `get_logger` with structlog or stdlib fallback.""" +"""Structlog wrapper.""" -import os -import logging -from logging.handlers import RotatingFileHandler +import structlog -try: - import structlog # type: ignore - HAS_STRUCTLOG = True -except Exception: # pragma: no cover - optional dependency - structlog = None # type: ignore - HAS_STRUCTLOG = False - - -def get_logger( - name: str, - log_file: str = "logs/app.log", - level: int = logging.INFO, - max_bytes: int = 5 * 1024 * 1024, - backup_count: int = 3, - enable_console: bool = True, - enable_file: bool = False, -): - """Return a structlog logger if available, else a configured stdlib logger.""" - - if HAS_STRUCTLOG: - # Defer import use to avoid type issues when structlog missing - return structlog.get_logger(name) # type: ignore[no-any-return] - - # Fallback to stdlib logger - return create_logger( - name=name, - log_file=log_file, - level=level, - max_bytes=max_bytes, - backup_count=backup_count, - enable_console=enable_console, - enable_file=enable_file, - ) - - -def create_logger( - name: str, - log_file: str, - level: int, - max_bytes: int, - backup_count: int, - enable_console: bool, - enable_file: bool, -) -> logging.Logger: - """Create a logger instance.""" - - logger = logging.getLogger(name) - logger.setLevel(level) - logger.propagate = False - - formatter = logging.Formatter( - "[%(asctime)s] [%(levelname)s] %(name)s: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - - if enable_console and not any( - isinstance(h, logging.StreamHandler) for h in logger.handlers - ): - console_handler = logging.StreamHandler() - console_handler.setFormatter(formatter) - logger.addHandler(console_handler) - - if enable_file and not any( - isinstance(h, RotatingFileHandler) for h in logger.handlers - ): - if log_dir := os.path.dirname(log_file): - os.makedirs(log_dir, exist_ok=True) - handler = RotatingFileHandler( - log_file, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8" - ) - handler.setFormatter(formatter) - logger.addHandler(handler) - - return logger +def get_logger(name: str): + """Bound logger.""" + return structlog.get_logger(name) diff --git a/oid4vc/auth_server/core/utils/retry.py b/oid4vc/auth_server/core/utils/retry.py index 2877bf7e8..350d6af2f 100644 --- a/oid4vc/auth_server/core/utils/retry.py +++ b/oid4vc/auth_server/core/utils/retry.py @@ -8,6 +8,8 @@ from functools import wraps from typing import Any, Callable, Iterable, Optional, Type +from core.utils.logging import get_logger + def with_retries( *, @@ -21,7 +23,7 @@ def with_retries( ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """Retry a callable on specified exceptions with backoff.""" - log = logger or logging.getLogger(__name__) + log = logger or get_logger(__name__) retry_on_tuple = tuple(retry_on) def _next_delay(current: float) -> float: diff --git a/oid4vc/auth_server/docs/auth-server-design.md b/oid4vc/auth_server/docs/auth-server-design.md index d0fa1b9e7..425bc8aaf 100644 --- a/oid4vc/auth_server/docs/auth-server-design.md +++ b/oid4vc/auth_server/docs/auth-server-design.md @@ -13,7 +13,7 @@ This system supports **secure, standards-aligned issuance of Verifiable Credenti - 🔐 **Pre-Authorized Code Flow** – Enables issuance without user login - 🛡️ **DPoP-bound Access Tokens** – Proof-of-possession enforcement ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449.html)) - 📄 **Authorization Details** – Credential-specific authorization rules ([RFC 9396](https://www.rfc-editor.org/rfc/rfc9396.html)) -- 🧾 **Attestation PoP** – Verified by the Authorization Server using attestation PoP JWT presented by the wallet +- 🧾 **Attestation PoP** – Wallet Provider signs an attestation JWT (`kid`-based); Wallet Instance signs an attestation PoP JWT; the Authorization Server verifies both against a managed allow list of trusted providers - 🔁 **Refresh Token Rotation** – Mitigates token reuse and supports long-lived sessions - 🧠 **Token Introspection** – Fine-grained validation with embedded credential metadata - 🌐 **Metadata Discovery** – Standards-based wallet interoperability via `.well-known` endpoints @@ -84,7 +84,7 @@ The system supports multi-tenancy for data isolation in OID4VCI workflows. The C | 3 | Refresh Token issued | Auth Server | Every access token comes with a refresh token. | | 4 | Refresh Token rotation | Auth Server | One-time-use refresh tokens; replaced with each request. | | 5 | Decoupled authorization | Auth Server + Config | Tokens validated externally or by config-swappable AS. | -| 6 | Attestation verification | Authorization Server | Verify `client_attestation` PoP JWT at `/token` | +| 6 | Attestation verification | Authorization Server | Verify attestation JWT + attestation PoP JWT at `/token` | | 7 | `/nonce` endpoint | Credential Issuer | Required by OID4VCI; prevents nonce reuse. | --- @@ -153,10 +153,10 @@ sequenceDiagram CredentialIssuer-->>Wallet: credential_offer_uri Wallet->>AuthorizationServer: POST /token - Note over Wallet, AuthorizationServer: Includes:
- pre-authorized_code
- DPoP JWT
- client_attestation PoP JWT + Note over Wallet, AuthorizationServer: Includes:
- pre-authorized_code
- Header: OAuth-Client-Attestation
- Header: OAuth-Client-Attestation-PoP alt Valid `/token` request - AuthorizationServer->>AuthorizationServer: Validate DPoP + Attestation PoP - AuthorizationServer->>DB: Store access_token (with cnf.jkt, amr, attestation metadata) + refresh_token + AuthorizationServer->>AuthorizationServer: Validate Attestation (kid lookup in allow list) + AuthorizationServer->>DB: Store access_token (with amr, attestation metadata) + refresh_token AuthorizationServer-->>Wallet: access_token, refresh_token else Invalid AuthorizationServer-->>Wallet: HTTP 400/401 @@ -166,9 +166,9 @@ sequenceDiagram CredentialIssuer-->>Wallet: nonce Wallet->>CredentialIssuer: POST /credential - Note over Wallet, CredentialIssuer: Includes:
- access_token
- DPoP JWT
- credential proof (with /nonce) + Note over Wallet, CredentialIssuer: Includes:
- access_token
- credential proof (with /nonce) CredentialIssuer->>AuthorizationServer: POST /introspect - AuthorizationServer->>DB: Validate token, return cnf.jkt, amr, attestation metadata + AuthorizationServer->>DB: Validate token, return amr, attestation metadata CredentialIssuer-->>Wallet: Verifiable Credential ``` @@ -200,63 +200,124 @@ sequenceDiagram | Component | Validates | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| Authorization Server | Pre-auth code; **DPoP JWT** (proof-of-possession); **Attestation PoP JWT** per policy; refresh token rotation (validated for `used=false`) | -| Credential Issuer | Introspection (active token, realm match); **DPoP match (`cnf.jkt`)**; Nonce proof | +| Authorization Server | Pre-auth code; **Attestation JWT** (verified via `kid` + allow list); **Attestation PoP JWT** (wallet instance proof); refresh token rotation (validated for `used=false`) | +| Credential Issuer | Introspection (active token, realm match); Nonce proof | --- ### 📦 Notes -- Attestation PoP is verified by the Authorization Server at `/token`. -- DPoP `jkt` thumbprint is stored in `access_token.cnf_jkt`, and enforced by both the AS (at `/token`) and Issuer (at `/credential`). -- The Issuer uses `/introspect` to validate the access token and retrieve embedded claims (e.g., `cnf.jkt`, `amr`, and `attestation` metadata), then performs its own DPoP check. +- Attestation JWT is verified by the Authorization Server at `/token` using `kid` + `iss` lookup against the allow list of trusted Wallet Providers. +- The attestation `cnf.jwk` contains the wallet instance's public key. The `OAuth-Client-Attestation-PoP` JWT proves the wallet holds the corresponding private key. +- The Issuer uses `/introspect` to validate the access token and retrieve embedded claims (e.g., `amr` and `attestation` metadata). - Wallets **do not** send attestation to `/credential`; it’s only relevant at `/token`. --- ## 🧾 Attestation PoP -**Purpose:** Provide an additional verifiable PoP signal at `/token`, without any interaction with external attestation providers. +**Purpose:** Provide an additional verifiable PoP signal at `/token`, ensuring the client is a legitimate wallet attested by a trusted Wallet Provider. ### Flow -1. **Client Attestation PoP JWT (policy-driven)** +1. **Attestation JWT (signed by Wallet Provider)** - - Issued by the wallet provider and signed. - - Contains claims the Auth Server can verify (e.g., `iss`, `sub`, `iat`, `exp`, a key-binding claim). + - Wallet Provider signs the JWT with their private key. + - Header: `typ` (`oauth-client-attestation+jwt`), `alg`, `kid`. + - Payload: `iss` (Wallet Provider identifier), `sub` (client_id — see note below), `cnf.jwk`, `iat`, `exp`. + - The `cnf.jwk` contains the wallet instance's public key, binding the attestation to the wallet instance. + - Sent via the `OAuth-Client-Attestation` HTTP header. + - **`sub` claim semantics (OID4VCI §15.4.4):** The `sub` value SHOULD identify the **wallet type** (e.g., `https://wallet.example.org`), NOT a unique per-instance identifier. All wallet instances of the same Wallet Provider share the same `sub` value to prevent cross-Authorization-Server correlation of individual users. -2. **Verification (Authorization Server)** +2. **Attestation PoP JWT (signed by Wallet Instance)** - - Validate signature and required claims. - - Optionally bind to the same key used for DPoP by matching a thumbprint (`jkt`) claim. - - Apply trust policy: `auto_trust`, `allow_list`, or `deny_list`. + - Wallet Instance signs a proof-of-possession JWT with the private key corresponding to the `cnf.jwk` in the attestation. + - Header: `typ` (`oauth-client-attestation-pop+jwt`), `alg`. + - Payload: `iss` (client_id — MUST match `sub` of the attestation JWT), `aud` (Authorization Server issuer identifier), `jti` (unique identifier for replay detection), `iat`, and optionally `challenge` (server-provided). + - Proves the wallet holds the attested key. + - Sent via the `OAuth-Client-Attestation-PoP` HTTP header. -3. **Outcome** - - If policy requires attestation PoP and verification fails → `invalid_attestation`. - - On success, record outcome in `ACCESS_TOKEN.metadata` and add `"att-pop"` to `amr`. +3. **Allow List (Trusted Wallet Providers)** + + - Admin registers trusted providers via API: `iss` + JWKS document (inline `jwks`) or `jwks_uri`. + - Each provider row stores the full keyset; individual keys are resolved by `kid` at verification time. + - Provider keys are obtained out-of-band (e.g., published on wallet provider's website). + - If `jwks_uri` is provided, keys are fetched on-demand with SSRF protection and LRU caching. + - This is a closed ecosystem — no DID resolution or discoverability needed. + +4. **Verification (Authorization Server)** + + - Extract `kid` from header and `iss` from payload. + - Look up the provider's public key by `iss` + `kid` in the allow list. + - Verify attestation JWT signature using the provider's public key. + - Validate claims (`iss`, `sub`, `iat`, `exp`) and time validity (± clock skew). + - Extract `cnf.jwk` and verify the `OAuth-Client-Attestation-PoP` JWT signature against it (proves wallet instance holds the attested key). + +5. **Outcome** + - If `iss` + `kid` not found → `invalid_client_attestation` (untrusted provider). + - If signature or claims invalid → `invalid_client_attestation`. + - On success: + - Compute `cnf_jkt = thumbprint(cnf.jwk)` (RFC 7638) and store on `ACCESS_TOKEN.cnf_jkt`. + - Record attestation metadata (iss, kid, sub, cnf_jkt, pop_jti, iat, exp) in `ACCESS_TOKEN.metadata`. + - Add `"att-pop"` to `amr`. + - On refresh without new attestation headers, the previous token's attestation metadata and `cnf_jkt` are carried forward to the new access token. ### Example `/token` Request (with Attestation PoP) ```http POST /token Content-Type: application/x-www-form-urlencoded -DPoP: +OAuth-Client-Attestation: +OAuth-Client-Attestation-PoP: grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code& -pre-authorized_code=abc123& -client_attestation=eyJhbGciOiJSUzI1NiIs... +pre-authorized_code=abc123 ``` -### Trust Policy Modes +```mermaid +sequenceDiagram + participant WalletProvider as Wallet Provider + participant Wallet + participant AuthServer as Authorization Server + participant Issuer as Credential Issuer + participant DB + + Note over WalletProvider,AuthServer: 1. Admin: Register Trusted Wallet Providers (prerequisite) + + WalletProvider-->>AuthServer: Out-of-band: publish public key(s) + Note over AuthServer: Admin registers via API:
iss + JWKS (or jwks_uri)
stored in allow list table + + Note over WalletProvider,Wallet: 2. Wallet Attestation Provisioning -- **auto_trust** – Accept any syntactically valid, verifiable PoP JWT. -- **allow_list** – Accept only if (`sub`, optional `jkt`) pair appears in a configured list. -- **deny_list** – Explicitly reject known-bad `sub` (and/or `jkt`). + Wallet->>WalletProvider: Request attestation (app identity + device proof) + WalletProvider->>WalletProvider: Verify integrity, sign attestation JWT (kid: "11") + WalletProvider-->>Wallet: Attestation JWT + Note over Wallet: Wallet caches attestation JWT
(valid until exp, reusable across requests) -**Errors (Auth Server):** + Note over Wallet,AuthServer: 3. Token Request with Attestation -- `invalid_attestation` – Missing/malformed/expired/failed verification. -- `invalid_request` – Violates trust policy. + Wallet->>Wallet: Generate Attestation PoP JWT (signed with cnf.jwk key) + Wallet->>AuthServer: POST /token + OAuth-Client-Attestation + OAuth-Client-Attestation-PoP + + AuthServer->>DB: Look up provider key by iss + kid + AuthServer->>AuthServer: Validate attestation + PoP + + alt Attestation valid + AuthServer->>DB: Store access_token with attestation metadata + amr: ["att-pop"] + AuthServer-->>Wallet: 200 access_token, refresh_token + else Invalid or untrusted + AuthServer-->>Wallet: 400 invalid_client_attestation (untrusted provider) + end + + Note over Wallet,AuthServer: Credential Request & Introspection + + Wallet->>Issuer: POST /credential (Bearer access_token) + Issuer->>AuthServer: POST /introspect (token) + AuthServer->>DB: Look up access_token + metadata + AuthServer-->>Issuer: active: true, amr, attestation metadata + Issuer->>Issuer: Check attestation metadata from introspection (optional policy) + Issuer-->>Wallet: Verifiable Credential +``` --- @@ -264,13 +325,64 @@ client_attestation=eyJhbGciOiJSUzI1NiIs... ### ✅ Overview -DPoP ensures that access tokens are bound to a client’s private key, preventing unauthorized use of stolen tokens (RFC 9449). +DPoP ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449.html)) binds access tokens to a client's asymmetric key pair, preventing unauthorized use of stolen tokens. Even if an attacker exfiltrates an access token, they cannot use it without the corresponding private key. ### ✅ Support -- **Required for `/token` and `/credential`**. -- Access tokens include a `cnf.jkt` claim (JWK Thumbprint) binding the token to the client’s DPoP key. -- Replay protection via `jti` on DPoP JWTs. +- **Required at `/token`** (Authorization Server) and **`/credential`** (Credential Issuer). +- Access tokens are issued with `"token_type": "DPoP"` (not `"Bearer"`). +- Access tokens include a `cnf.jkt` claim (JWK Thumbprint per [RFC 7638](https://www.rfc-editor.org/rfc/rfc7638.html)) binding the token to the client's DPoP key. +- Replay protection via `jti` claim on DPoP proof JWTs, tracked in `JTI_SEEN`. +- Server-provided nonce support via `DPoP-Nonce` response header and `use_dpop_nonce` error. + +### 🔑 DPoP Key vs. Attestation Key + +DPoP and Client Attestation are **independent mechanisms** that serve different purposes: + +| Mechanism | Purpose | Key | +|-----------|---------|-----| +| Client Attestation | Proves the client is a legitimate wallet instance | `cnf.jwk` in attestation JWT | +| DPoP | Binds the access token to the client, preventing token theft | DPoP key pair (in `DPoP` header `jwk`) | + +The wallet MAY use the **same key** for both (the attestation `cnf.jwk` private key also signs DPoP proofs), or it MAY use separate keys. The Authorization Server does not enforce a relationship between them — they are validated independently. + +### 🧬 DPoP Proof JWT Structure + +Each DPoP proof is a JWT with: + +**Header:** +```json +{ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." } +} +``` + +**Payload (at `/token`):** +```json +{ + "jti": "unique-id-per-request", + "htm": "POST", + "htu": "https://auth.example.com/tenants/{tenant-id}/token", + "iat": 1721028000, + "nonce": "server-provided-nonce" +} +``` + +**Payload (at `/credential` — resource server):** +```json +{ + "jti": "unique-id-per-request", + "htm": "POST", + "htu": "https://issuer.example.com/credential", + "iat": 1721028000, + "ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo", + "nonce": "server-provided-nonce" +} +``` + +The `ath` claim is the base64url-encoded SHA-256 hash of the access token — required when the DPoP proof accompanies a token at a resource server (RFC 9449 §4.2). ### 🧬 Token Binding Flow with DPoP @@ -279,19 +391,87 @@ sequenceDiagram participant Wallet participant AuthServer as Authorization Server participant Issuer as Credential Issuer - Wallet->>AuthServer: POST /token + DPoP JWT + Attestation PoP - AuthServer-->>Wallet: access_token (with cnf.jkt, amr) + refresh_token - Wallet->>Issuer: POST /credential + DPoP JWT + + Note over Wallet: Generate DPoP key pair (once per session) + + Wallet->>AuthServer: POST /token + DPoP header (htm=POST, htu=/token) + Note over AuthServer: Validate DPoP proof:
typ, alg, jwk, htm, htu, jti, iat, nonce + AuthServer->>AuthServer: Derive cnf.jkt = thumbprint(proof.jwk) + AuthServer-->>Wallet: token_type: "DPoP", access_token (cnf.jkt bound), refresh_token + + Note over Wallet: Generate fresh DPoP proof for /credential
(same key, new jti, ath = hash(access_token)) + + Wallet->>Issuer: POST /credential + DPoP header (htm=POST, htu=/credential, ath) + Issuer->>AuthServer: POST /introspect (token) + AuthServer-->>Issuer: active: true, cnf.jkt: "..." + Note over Issuer: Validate DPoP proof + verify
thumbprint(proof.jwk) == cnf.jkt from introspection Issuer-->>Wallet: Verifiable Credential ``` -### 🛠️ Implementation Steps +### 🔄 Nonce Flow (Optional Server-Enforced) + +The Authorization Server MAY require a nonce in DPoP proofs to limit proof reuse windows: + +```mermaid +sequenceDiagram + participant Wallet + participant AuthServer as Authorization Server + + Wallet->>AuthServer: POST /token + DPoP (no nonce) + AuthServer-->>Wallet: 400 use_dpop_nonce + DPoP-Nonce: "server-nonce-1" + Wallet->>AuthServer: POST /token + DPoP (nonce: "server-nonce-1") + AuthServer-->>Wallet: 200 access_token + DPoP-Nonce: "server-nonce-2" + Note over Wallet: Cache nonce for next request to this server +``` + +### 🛠️ Authorization Server Validation (RFC 9449 §4.3) + +When the `DPoP` header is present at `/token`, the Authorization Server MUST: + +1. Verify exactly one `DPoP` header is present. +2. Decode and validate the JWT structure. +3. Verify `typ` header is `dpop+jwt`. +4. Verify `alg` is a supported asymmetric algorithm (e.g., `ES256`) and not `none`. +5. Verify the JWT signature using the public key in the `jwk` header. +6. Verify `jwk` does not contain a private key. +7. Verify `htm` matches the HTTP method (`POST`). +8. Verify `htu` matches the request URI (scheme + authority + path, no query/fragment). +9. If nonce is required, verify the `nonce` claim matches the server-provided value. +10. Verify `iat` is within an acceptable time window. +11. Verify `jti` has not been seen before (replay prevention via `JTI_SEEN` table). +12. Derive `cnf.jkt` = base64url(SHA-256(canonicalized JWK)) and store on the access token. + +### 🛠️ Credential Issuer Validation + +When the wallet presents a DPoP-bound token at `/credential`, the Credential Issuer MUST: + +1. Validate the DPoP proof (steps 1–11 above, with `htu` = credential endpoint URL). +2. Verify the `ath` claim equals base64url(SHA-256(access_token)). +3. Call `/introspect` to get the token's `cnf.jkt`. +4. Verify `thumbprint(proof.jwk) == cnf.jkt` (key binding check). + +### 📦 Storage & Metadata + +- `ACCESS_TOKEN.cnf_jkt`: Stores the DPoP key thumbprint bound to the token. +- `JTI_SEEN.metadata`: For DPoP proofs, stores `{"htm": "POST", "htu": "...", "cnf_jkt": "..."}`. +- DPoP nonces: Stored server-side with time-based rotation (configurable window). + +### 🔒 Error Responses -1. Client generates DPoP key pair (public JWK appears in `DPoP` header). -2. Client sends DPoP header with `/token`. -3. Auth Server validates signature, `htu`/`htm`, and `jti` uniqueness; derives `cnf.jkt`. -4. Issued access token contains `cnf.jkt`. -5. Issuer enforces DPoP on `/credential` by matching request DPoP proof to `cnf.jkt`. +| Error | HTTP Status | When | +|-------|-------------|------| +| `invalid_dpop_proof` | 400 | Malformed proof, bad signature, wrong `htm`/`htu`, expired `iat`, unknown `alg` | +| `use_dpop_nonce` | 400 | Server requires nonce but none provided, or nonce is stale (includes `DPoP-Nonce` header) | +| `invalid_token` | 401 | DPoP key thumbprint doesn't match `cnf.jkt` on the token (at resource server) | + +### 📝 `.well-known` Discovery + +The Authorization Server metadata SHOULD include: +```json +{ + "dpop_signing_alg_values_supported": ["ES256"] +} +``` --- @@ -324,13 +504,13 @@ sequenceDiagram Wallet->>CredentialIssuer: GET /.well-known/openid-credential-issuer CredentialIssuer-->>Wallet: Metadata (token/credential endpoints) - Wallet->>AuthServer: POST /token (pre-auth-code + DPoP + attestation PoP) - AuthServer->>DB: Validate + bind DPoP + attestation policy + Wallet->>AuthServer: POST /token (pre-auth-code + attestation PoP headers) + AuthServer->>DB: Validate + verify attestation (kid lookup) AuthServer-->>Wallet: access_token + refresh_token - Wallet->>CredentialIssuer: POST /credential + DPoP + Wallet->>CredentialIssuer: POST /credential CredentialIssuer->>AuthServer: POST /introspect - AuthServer-->>CredentialIssuer: token is active + authorization_details + cnf.jkt + amr/attestation + AuthServer-->>CredentialIssuer: token is active + authorization_details + amr/attestation CredentialIssuer-->>Wallet: Verifiable Credential ``` @@ -377,9 +557,9 @@ sequenceDiagram | Endpoint | Method | Auth | Description | | ----------------------------------- | ------ | ----------------------------------------- | ------------------------------------ | -| `/token` | POST | DPoP + optional attestation PoP | Token exchange (pre-auth or refresh) | -| `/introspect` | POST | private_key_jwt \| client_secret_basic \| shared_bearer | Token validation + attestation | -| `/grants/pre-authorized-code` | POST | private_key_jwt \| client_secret_basic \| shared_bearer | Issue a pre-authorized code grant. | +| `/token` | POST | Optional attestation PoP (via headers) | Token exchange (pre-auth or refresh) | +| `/introspect` | POST | private_key_jwt \| client_secret_basic | Token validation + attestation | +| `/grants/pre-authorized-code` | POST | private_key_jwt \| client_secret_basic | Issue a pre-authorized code grant. | | `/.well-known/openid-configuration` | GET | None | Auth Server metadata discovery | ### Credential Issuer @@ -387,7 +567,7 @@ sequenceDiagram | Endpoint | Method | Auth | Description | | --------------------------------------- | ------ | ------------- | -------------------------------- | | `/credential_offer` | GET | None | Offer URI with pre-auth code | -| `/credential` | POST | Bearer + DPoP | Request VC | +| `/credential` | POST | Bearer | Request VC | | `/nonce` | GET | None | Nonce for credential proof | | `/.well-known/openid-credential-issuer` | GET | None | OID4VC Issuer metadata discovery | @@ -395,19 +575,19 @@ sequenceDiagram ### 🔐 `POST /token` -Exchanges a pre-authorized code or refresh token for an access token (and new refresh token for rotation). If policy requires attestation PoP, the request must include `client_attestation`. +Exchanges a pre-authorized code or refresh token for an access token (and new refresh token for rotation). If policy requires attestation PoP, the request must include `OAuth-Client-Attestation` and `OAuth-Client-Attestation-PoP` headers. **Request** ```http POST /token Content-Type: application/x-www-form-urlencoded -DPoP: +OAuth-Client-Attestation: +OAuth-Client-Attestation-PoP: grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code& pre-authorized_code=abc123& -tx_code=1234& -client_attestation=eyJhbGciOiJSUzI1NiIs... +tx_code=1234 ``` **Response** @@ -426,14 +606,13 @@ client_attestation=eyJhbGciOiJSUzI1NiIs... "types": ["VerifiableCredential", "OntarioBusinessCard"] } ], - "cnf": { "jkt": "base64url-encoded-thumbprint" }, - "amr": ["dpop", "att-pop"] + "amr": ["att-pop"] } ``` **Errors** -- HTTP 400 `invalid_request`: Invalid pre-authorized code, DPoP JWT, malformed attestation PoP. +- HTTP 400 `invalid_request`: Invalid pre-authorized code, malformed attestation PoP. - HTTP 401 `invalid_token`: Invalid or revoked refresh token. - HTTP 401 `invalid_attestation`: Attestation PoP required by policy but missing or failed verification. @@ -475,15 +654,15 @@ token_type_hint=access_token "cnf": { "jkt": "QmFzZTY0ZW5jb2RlZFRodW1icHJpbnQ=" }, "iss": "https://auth.example.com", "realm": "tenant1", - "amr": ["dpop", "att-pop"], + "amr": ["att-pop"], "attestation": { "present": true, "verified": true, - "policy": "allow_list", - "decision": "trusted", - "jkt": "QmFzZTY0ZW5jb2RlZFRodW1icHJpbnQ=", - "sub": "wallet.app.id:123", - "hash": "sha256:2f1c...ab", + "iss": "https://wallet-provider.example.com", + "kid": "11", + "sub": "https://wallet.example.org", + "cnf_jkt": "QmFzZTY0ZW5jb2RlZFRodW1icHJpbnQ=", + "pop_jti": "unique-pop-jti-value", "iat": 1721028000, "exp": 1721031600 } @@ -506,7 +685,6 @@ Request a credential using an access token. (No attestation here; enforced at `/ ```http POST /credential Authorization: Bearer eyJhbGciOi... -DPoP: Content-Type: application/json { @@ -527,7 +705,7 @@ Content-Type: application/json **Errors** -- HTTP 400 `invalid_request`: Invalid access token, DPoP JWT, or nonce proof. +- HTTP 400 `invalid_request`: Invalid access token or nonce proof. --- @@ -641,7 +819,7 @@ GET /.well-known/openid-configuration { "issuer": "https://auth.example.com", "token_endpoint": "https://auth.example.com/token", - "token_endpoint_auth_methods_supported": ["none"], + "token_endpoint_auth_methods_supported": ["private_key_jwt", "client_secret_basic"], "grant_types_supported": [ "urn:ietf:params:oauth:grant-type:pre-authorized_code", "refresh_token" @@ -655,9 +833,9 @@ GET /.well-known/openid-configuration ### 🔍 Error Handling - Standard OAuth2 errors: `invalid_token`, `expired_token`, `invalid_grant`, etc. -- DPoP errors: `invalid_dpop_proof`, `replay_detected`. +- DPoP errors (planned): `invalid_dpop_proof`, `replay_detected`. - Nonce errors: `invalid_request` for invalid/expired nonces; `too_many_requests` for exceeding rate limits. -- Attestation errors: `invalid_attestation` or `invalid_request` if `client_attestation` is missing/invalid or fails policy. +- Attestation errors: `invalid_attestation` if attestation headers are missing/invalid, signature verification fails, Attestation-PoP verification fails, or `iss` + `kid` not found in the allow list. --- @@ -676,7 +854,6 @@ erDiagram SUBJECT ||--o{ PRE_AUTH_CODE : issued SUBJECT ||--o{ ACCESS_TOKEN : manages SUBJECT ||--o{ REFRESH_TOKEN : manages - SUBJECT ||--o{ DPOP_JTI : manages CLIENT SUBJECT { @@ -712,7 +889,7 @@ erDiagram INT id PK INT subject_id FK INT access_token_id FK - TEXT token UK + TEXT token_hash UK "SHA-256" TIMESTAMPTZ issued_at TIMESTAMPTZ expires_at BOOLEAN used @@ -720,17 +897,6 @@ erDiagram JSONB metadata } - DPOP_JTI { - INT id PK - INT subject_id FK - TEXT jti UK - TEXT htm - TEXT htu - TEXT cnf_jkt - TIMESTAMPTZ issued_at - TIMESTAMPTZ expires_at - } - NONCE { INT id PK TEXT value UK @@ -750,6 +916,23 @@ erDiagram TIMESTAMPTZ created_at TIMESTAMPTZ updated_at } + + WALLET_PROVIDER { + INT id PK + TEXT iss "UNIQUE INDEX" + JSONB jwks "Inline JWKS document (nullable)" + TEXT jwks_uri "Remote JWKS endpoint (nullable)" + TEXT name + BOOLEAN active + TIMESTAMPTZ created_at + TIMESTAMPTZ updated_at + } + + JTI_SEEN { + TEXT jti PK + TIMESTAMPTZ expires_at + JSONB metadata "htm, htu, cnf_jkt for DPoP" + } ``` **Note**: ACCESS_TOKEN.metadata may include amr and attestation outcome. @@ -764,14 +947,21 @@ erDiagram - **Token Format**: Access tokens are JWTs with claims (`aud`, `exp`, `cnf`, `amr`, etc.). - **DPoP Key Lifecycle**: Clients manage DPoP key pairs; rotate if compromised. - **CORS**: Restrict cross-origin requests where applicable. +- **Refresh Token Hashing**: Refresh tokens are stored as SHA-256 hashes; raw values are never persisted. +- **JTI Replay Prevention**: Client assertion, attestation PoP, and DPoP JTIs are tracked in `JTI_SEEN` (PostgreSQL INSERT ON CONFLICT). Entries persist until the JWT's `exp` claim for client assertions, or a fixed window for attestation PoPs and DPoP proofs. DPoP entries store `htm`, `htu`, and `cnf_jkt` in the `metadata` column. +- **SSRF Protection**: `jwks_uri` fetches validate the scheme, resolve DNS hostnames, and reject private/loopback/link-local/reserved IP addresses before making HTTP requests. +- **LRU-bounded Caches**: Tenant context cache, engine pools, and JWKS caches use `OrderedDict`-based LRU eviction to prevent unbounded memory growth. +- **Migration Safety**: Downgrade operations via `POST /admin/tenants/{uid}/migrations` require explicit `confirm: true` in the request body. +- **Proxy Trust**: `PROXY_TRUSTED_HOSTS` controls which reverse proxies are trusted to set `X-Forwarded-For` headers. Default: `127.0.0.1`. Set to the specific LB/proxy IP in production; never use `"*"`. +- **Algorithm Enforcement**: JWT verification restricts accepted algorithms to the client's configured `client_auth_signing_alg` (or `ES256` default), preventing algorithm confusion attacks. ## 📘 Terminology - **JWT (JSON Web Token)**: A compact, signed token format (RFC 7519). - **JWK (JSON Web Key)**: JSON representation of a cryptographic key (RFC 7517). -- **JKT (JWK Thumbprint)**: Base64url-encoded hash of a JWK (RFC 7638). Used in DPoP and, optionally, in attestation PoP binding. -- **JTI (JWT ID)**: Unique ID for a JWT, used for replay prevention in DPoP flows. -- **DPoP**: Mechanism to bind access tokens to a client’s key (RFC 9449). -- **Attestation PoP**: JWT from the wallet, verified by the Authorization Server to assert client/app properties under a trust policy. +- **JKT (JWK Thumbprint)**: Base64url-encoded hash of a JWK (RFC 7638). Used in DPoP and in attestation `cnf.jwk` binding. +- **JTI (JWT ID)**: Unique ID for a JWT, used for replay prevention. +- **DPoP**: Mechanism to bind access tokens to a client's key (RFC 9449). Independent from attestation. +- **Attestation PoP**: Two-part client attestation mechanism. The `OAuth-Client-Attestation` header carries a JWT signed by a trusted Wallet Provider (identified by `iss` + `kid`), verified against a managed allow list. The `OAuth-Client-Attestation-PoP` header carries a JWT signed by the Wallet Instance, proving possession of the key in `cnf.jwk`. - **Pre-authorized Code**: One-time-use code issued by the AS to enable issuance without user login. - **Realm**: Logical identifier mapping a token to a specific tenant. diff --git a/oid4vc/auth_server/docs/tenant-admin.md b/oid4vc/auth_server/docs/tenant-admin.md index fdda124ea..8cce8289f 100644 --- a/oid4vc/auth_server/docs/tenant-admin.md +++ b/oid4vc/auth_server/docs/tenant-admin.md @@ -66,6 +66,8 @@ sequenceDiagram - DELETE `/admin/tenants/{uid}/clients/{client_id}` - Migrations - POST `/admin/tenants/{uid}/migrations` + - Body: `{ "action": "upgrade"|"downgrade", "rev": "", "confirm": true }` + - Downgrade requires `confirm: true` (returns 400 otherwise) ### 🔑 Key Management @@ -107,11 +109,10 @@ graph PRE_AUTH_CODE[PRE_AUTH_CODE] ACCESS_TOKEN[ACCESS_TOKEN] REFRESH_TOKEN[REFRESH_TOKEN] - DPOP_JTI[DPOP_JTI] + JTI_SEEN[JTI_SEEN] SUBJECT -->|has| PRE_AUTH_CODE SUBJECT -->|has| ACCESS_TOKEN SUBJECT -->|has| REFRESH_TOKEN - SUBJECT -->|has| DPOP_JTI TENANT_ANCHOR((( ))) end diff --git a/oid4vc/auth_server/poetry.lock b/oid4vc/auth_server/poetry.lock index f59e920c9..4e45c990c 100644 --- a/oid4vc/auth_server/poetry.lock +++ b/oid4vc/auth_server/poetry.lock @@ -51,6 +51,35 @@ sniffio = ">=1.1" [package.extras] trio = ["trio (>=0.31.0)"] +[[package]] +name = "appnope" +version = "0.1.4" +description = "Disable App Nap on macOS >= 10.9" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +markers = "platform_system == \"Darwin\"" +files = [ + {file = "appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c"}, + {file = "appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee"}, +] + +[[package]] +name = "asttokens" +version = "3.0.2" +description = "Annotate AST trees with source code positions" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933"}, + {file = "asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2"}, +] + +[package.extras] +astroid = ["astroid (>=2,<5)"] +test = ["astroid (>=2,<5)", "pytest (<9.0)", "pytest-cov", "pytest-xdist"] + [[package]] name = "asyncpg" version = "0.30.0" @@ -117,18 +146,19 @@ test = ["distro (>=1.9.0,<1.10.0)", "flake8 (>=6.1,<7.0)", "flake8-pyi (>=24.1.0 [[package]] name = "authlib" -version = "1.6.12" +version = "1.7.2" description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "authlib-1.6.12-py2.py3-none-any.whl", hash = "sha256:e9229ad7fde610b139dd12f5edbe97eab9ee78bfb85691247e767727850b99ab"}, - {file = "authlib-1.6.12.tar.gz", hash = "sha256:0656d8482f28fc8221929d5f35b2bde5d13e10555ebc06b4561b0d622e83b1bd"}, + {file = "authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f"}, + {file = "authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231"}, ] [package.dependencies] cryptography = "*" +joserfc = ">=1.6.0" [[package]] name = "certifi" @@ -148,8 +178,7 @@ version = "2.0.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "platform_python_implementation != \"PyPy\"" +groups = ["main", "dev"] files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, @@ -236,6 +265,7 @@ files = [ {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] +markers = {main = "platform_python_implementation != \"PyPy\"", dev = "implementation_name == \"pypy\""} [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} @@ -268,6 +298,21 @@ files = [ ] markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} +[[package]] +name = "comm" +version = "0.2.3" +description = "Jupyter Python Comm implementation, for usage in ipykernel, xeus-python etc." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417"}, + {file = "comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971"}, +] + +[package.extras] +test = ["pytest"] + [[package]] name = "coverage" version = "7.10.7" @@ -447,6 +492,61 @@ cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy [package.extras] ssh = ["bcrypt (>=3.1.5)"] +[[package]] +name = "debugpy" +version = "1.8.21" +description = "An implementation of the Debug Adapter Protocol for Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "debugpy-1.8.21-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:8eeab7b5462f683452c57c0126aaa5ec4e974ddb705f39ba87dff8818c8e08f9"}, + {file = "debugpy-1.8.21-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:0fddfdc130ac6d8bfc0415b0409822fa901c8f310e5c945ac5653a0352532344"}, + {file = "debugpy-1.8.21-cp310-cp310-win32.whl", hash = "sha256:72b5d676c4cbfac3bac5bb01c138a4656e843f93f03ce2a5f4e394ad49fbee73"}, + {file = "debugpy-1.8.21-cp310-cp310-win_amd64.whl", hash = "sha256:a7fe47fd23da57b9e0bec3f4a8ee65a2dc55782455ed7f2141d75ab5d2eaeef5"}, + {file = "debugpy-1.8.21-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:da456226c7b4c69e35dbe35dcee6623d912000a77816db7856a41af1c72a0264"}, + {file = "debugpy-1.8.21-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:f68b891688e61bdc08b8d364d919ff0051e0b94657b39dcd027bc3173edb7cdc"}, + {file = "debugpy-1.8.21-cp311-cp311-win32.whl", hash = "sha256:f843a8b08c2edeaf9b1582eed4f25441af21a297c22ff16bf76a662557aa9c9e"}, + {file = "debugpy-1.8.21-cp311-cp311-win_amd64.whl", hash = "sha256:84c564d8cc701d41843b29a92814c1f1bef6798724ca9d675c284ad9f6a547d7"}, + {file = "debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e"}, + {file = "debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176"}, + {file = "debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9"}, + {file = "debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c"}, + {file = "debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88"}, + {file = "debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2"}, + {file = "debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1"}, + {file = "debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0"}, + {file = "debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782"}, + {file = "debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e"}, + {file = "debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c"}, + {file = "debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8"}, + {file = "debugpy-1.8.21-cp38-cp38-macosx_15_0_x86_64.whl", hash = "sha256:0042da0ecd0a8b50dc4a54395ecd870d258d73fa18776f50c91fdcabdcad2675"}, + {file = "debugpy-1.8.21-cp38-cp38-manylinux_2_34_x86_64.whl", hash = "sha256:ffd932c6796afadab6993ec96745918a8cb2444dbd392074f769db5ea40ab440"}, + {file = "debugpy-1.8.21-cp38-cp38-win32.whl", hash = "sha256:4e7c2d784d78ad4b71a5f8cd7b59c167719ec8a7a0211dbb3eb1bfeda78bc4e2"}, + {file = "debugpy-1.8.21-cp38-cp38-win_amd64.whl", hash = "sha256:aa9d941d6dfe3d0407e4b3ca0b9ec466030e260fbf1174094f68785680f66db6"}, + {file = "debugpy-1.8.21-cp39-cp39-macosx_15_0_x86_64.whl", hash = "sha256:9f5171176a0084b95d2ebe55a4d1f7b2a75b74c5dbec577ebd3a85c740551c36"}, + {file = "debugpy-1.8.21-cp39-cp39-manylinux_2_34_x86_64.whl", hash = "sha256:f15c10084f9861b5e8414a48f18f8e4aadf51a98a59e72c16aa28281ca994672"}, + {file = "debugpy-1.8.21-cp39-cp39-win32.whl", hash = "sha256:4e70cc8b5079f885cb43910924ee0aab73b8b6b2a14eff23afdd9895d86e79eb"}, + {file = "debugpy-1.8.21-cp39-cp39-win_amd64.whl", hash = "sha256:e935f9dc0501be523c8a8e1853c39432e1354e9ece717ae5998fd2371c4542c3"}, + {file = "debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92"}, + {file = "debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6"}, +] + +[[package]] +name = "executing" +version = "2.2.1" +description = "Get the currently executing AST node of a frame, and other information" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017"}, + {file = "executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4"}, +] + +[package.extras] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich ; python_version >= \"3.11\""] + [[package]] name = "fastapi" version = "0.116.2" @@ -720,6 +820,169 @@ files = [ {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] +[[package]] +name = "ipykernel" +version = "6.31.0" +description = "IPython Kernel for Jupyter" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "ipykernel-6.31.0-py3-none-any.whl", hash = "sha256:abe5386f6ced727a70e0eb0cf1da801fa7c5fa6ff82147747d5a0406cd8c94af"}, + {file = "ipykernel-6.31.0.tar.gz", hash = "sha256:2372ce8bc1ff4f34e58cafed3a0feb2194b91fc7cad0fc72e79e47b45ee9e8f6"}, +] + +[package.dependencies] +appnope = {version = ">=0.1.2", markers = "platform_system == \"Darwin\""} +comm = ">=0.1.1" +debugpy = ">=1.6.5" +ipython = ">=7.23.1" +jupyter-client = ">=8.0.0" +jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +matplotlib-inline = ">=0.1" +nest-asyncio = ">=1.4" +packaging = ">=22" +psutil = ">=5.7" +pyzmq = ">=25" +tornado = ">=6.2" +traitlets = ">=5.4.0" + +[package.extras] +cov = ["coverage[toml]", "matplotlib", "pytest-cov", "trio"] +docs = ["intersphinx-registry", "myst-parser", "pydata-sphinx-theme", "sphinx", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling", "trio"] +pyqt5 = ["pyqt5"] +pyside6 = ["pyside6"] +test = ["flaky", "ipyparallel", "pre-commit", "pytest (>=7.0,<9)", "pytest-asyncio (>=0.23.5)", "pytest-cov", "pytest-timeout"] + +[[package]] +name = "ipython" +version = "9.16.1" +description = "IPython: Productive Interactive Computing" +optional = false +python-versions = ">=3.11" +groups = ["dev"] +files = [ + {file = "ipython-9.16.1-py3-none-any.whl", hash = "sha256:4acae635506f6d352d94c4899a19d5f85f8bc4d230932342dca556fdab1c69b4"}, + {file = "ipython-9.16.1.tar.gz", hash = "sha256:5a3d1f9a47ff216d6cf9cf863124f6a2c1a198d1354c546a4d24a370a283b64c"}, +] + +[package.dependencies] +colorama = {version = ">=0.4.4", markers = "sys_platform == \"win32\""} +ipython-pygments-lexers = ">=1.0.0" +jedi = ">=0.18.2" +matplotlib-inline = ">=0.1.6" +pexpect = {version = ">4.6", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""} +prompt_toolkit = ">=3.0.41,<3.1.0" +psutil = {version = ">=7", markers = "sys_platform != \"emscripten\" and sys_platform != \"cygwin\""} +pygments = ">=2.14.0" +stack_data = ">=0.6.0" +traitlets = ">=5.13.0" + +[package.extras] +all = ["argcomplete (>=3.0)", "ipython[doc,matplotlib,test,test-extra]"] +black = ["black"] +doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[matplotlib,test]", "setuptools (>=80.0)", "sphinx (>=8.0)", "sphinx-rtd-theme (>=0.1.8)", "sphinx_toml (==0.0.4)", "typing_extensions"] +matplotlib = ["matplotlib (>3.9)"] +test = ["packaging (>=23.0.0)", "pytest (>=7.0.0)", "pytest-asyncio (>=1.0.0)", "setuptools (>=80.0)", "testpath (>=0.2)"] +test-extra = ["curio", "ipykernel (>6.30)", "ipython[matplotlib]", "ipython[test]", "jupyter_ai", "nbclient", "nbformat", "numpy (>=2.0)", "pandas (>2.1)", "trio (>=0.22.0)"] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +description = "Defines a variety of Pygments lexers for highlighting IPython code." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c"}, + {file = "ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81"}, +] + +[package.dependencies] +pygments = "*" + +[[package]] +name = "jedi" +version = "0.20.0" +description = "An autocompletion tool for Python that can be used for text editors." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67"}, + {file = "jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011"}, +] + +[package.dependencies] +parso = ">=0.8.6,<0.9.0" + +[package.extras] +dev = ["Django", "attrs", "colorama", "docopt", "flake8 (==7.1.2)", "pytest (<9.0.0)", "types-setuptools (==80.9.0.20250529)", "typing-extensions", "zuban (==0.7.0)"] +docs = ["Jinja2 (==3.1.6)", "MarkupSafe (==3.0.3)", "Pygments (==2.20.0)", "Sphinx (==9.1.0)", "alabaster (==1.0.0)", "babel (==2.18.0)", "certifi (==2026.4.22)", "charset-normalizer (==3.4.7)", "docutils (==0.22.4)", "idna (==3.13)", "imagesize (==2.0.0)", "iniconfig (==2.3.0)", "packaging (==26.2)", "pluggy (==1.6.0)", "pytest (==9.0.3)", "requests (==2.33.1)", "roman-numerals (==4.1.0)", "snowballstemmer (==3.0.1)", "sphinx-rtd-theme (==3.1.0)", "sphinxcontrib-applehelp (==2.0.0)", "sphinxcontrib-devhelp (==2.0.0)", "sphinxcontrib-htmlhelp (==2.1.0)", "sphinxcontrib-jquery (==4.1)", "sphinxcontrib-jsmath (==1.0.1)", "sphinxcontrib-qthelp (==2.0.0)", "sphinxcontrib-serializinghtml (==2.0.0)", "urllib3 (==2.6.3)"] + +[[package]] +name = "joserfc" +version = "1.7.4" +description = "The ultimate Python library for JOSE RFCs, including JWS, JWE, JWK, JWA, JWT" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "joserfc-1.7.4-py3-none-any.whl", hash = "sha256:32d46c2cd5e3203c13e87a6c61333cab310b1ba80cd54b4c4f386a848a122463"}, + {file = "joserfc-1.7.4.tar.gz", hash = "sha256:b3bc561672ae541b17a9237053b48a03dacddd92d68047b3ecdfb4b5714a88ed"}, +] + +[package.dependencies] +cryptography = ">=45.0.1" + +[package.extras] +drafts = ["pycryptodome"] + +[[package]] +name = "jupyter-client" +version = "8.9.1" +description = "Jupyter protocol implementation and client libraries" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81"}, + {file = "jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa"}, +] + +[package.dependencies] +jupyter-core = ">=5.1" +python-dateutil = ">=2.8.2" +pyzmq = ">=25.0" +tornado = ">=6.4.1" +traitlets = ">=5.3" +typing-extensions = ">=4.13.0" + +[package.extras] +docs = ["ipykernel", "myst-parser", "pydata-sphinx-theme", "sphinx (>=4)", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"] +orjson = ["orjson"] +test = ["anyio", "coverage", "ipykernel (>=6.14)", "msgpack", "mypy ; platform_python_implementation != \"PyPy\"", "paramiko ; sys_platform == \"win32\"", "pre-commit", "pytest", "pytest-cov", "pytest-jupyter[client] (>=0.6.2)", "pytest-timeout"] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +description = "Jupyter core package. A base package on which Jupyter projects rely." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407"}, + {file = "jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508"}, +] + +[package.dependencies] +platformdirs = ">=2.5" +traitlets = ">=5.3" + +[package.extras] +docs = ["intersphinx-registry", "myst-parser", "pydata-sphinx-theme", "sphinx-autodoc-typehints", "sphinxcontrib-spelling", "traitlets"] +test = ["ipykernel", "pre-commit", "pytest (<9)", "pytest-cov", "pytest-timeout"] + [[package]] name = "mako" version = "1.3.10" @@ -839,6 +1102,24 @@ files = [ {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, ] +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +description = "Inline Matplotlib backend for Jupyter" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6"}, + {file = "matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79"}, +] + +[package.dependencies] +traitlets = "*" + +[package.extras] +test = ["flake8", "matplotlib", "nbdime", "nbval", "notebook", "pytest"] + [[package]] name = "more-itertools" version = "10.8.0" @@ -851,6 +1132,18 @@ files = [ {file = "more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd"}, ] +[[package]] +name = "nest-asyncio" +version = "1.6.0" +description = "Patch asyncio to allow nested event loops" +optional = false +python-versions = ">=3.5" +groups = ["dev"] +files = [ + {file = "nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c"}, + {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"}, +] + [[package]] name = "orjson" version = "3.11.6" @@ -947,6 +1240,50 @@ files = [ {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] +[[package]] +name = "parso" +version = "0.8.7" +description = "A Python Parser" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c"}, + {file = "parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1"}, +] + +[package.extras] +qa = ["flake8 (==5.0.4)", "types-setuptools (==67.2.0.1)", "zuban (==0.5.1)"] +testing = ["docopt", "pytest"] + +[[package]] +name = "pexpect" +version = "4.9.0" +description = "Pexpect allows easy control of interactive console applications." +optional = false +python-versions = "*" +groups = ["dev"] +markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\"" +files = [ + {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, + {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, +] + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "platformdirs" +version = "4.11.3" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7"}, + {file = "platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab"}, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -963,6 +1300,56 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "prompt-toolkit" +version = "3.0.53" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2"}, + {file = "prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6"}, +] + +[package.dependencies] +wcwidth = ">=0.1.4" + +[[package]] +name = "psutil" +version = "7.2.2" +description = "Cross-platform lib for process and system monitoring." +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b"}, + {file = "psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea"}, + {file = "psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63"}, + {file = "psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312"}, + {file = "psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b"}, + {file = "psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9"}, + {file = "psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00"}, + {file = "psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9"}, + {file = "psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a"}, + {file = "psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf"}, + {file = "psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1"}, + {file = "psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841"}, + {file = "psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486"}, + {file = "psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979"}, + {file = "psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9"}, + {file = "psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e"}, + {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8"}, + {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc"}, + {file = "psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988"}, + {file = "psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee"}, + {file = "psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372"}, +] + +[package.extras] +dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pyreadline3 ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""] +test = ["psleak", "pytest", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "setuptools", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""] + [[package]] name = "psycopg" version = "3.2.10" @@ -1060,18 +1447,46 @@ files = [ {file = "psycopg_binary-3.2.10-cp39-cp39-win_amd64.whl", hash = "sha256:6220d6efd6e2df7b67d70ed60d653106cd3b70c5cb8cbe4e9f0a142a5db14015"}, ] +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +optional = false +python-versions = "*" +groups = ["dev"] +markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\"" +files = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +description = "Safely evaluate AST nodes without side effects" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"}, + {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"}, +] + +[package.extras] +tests = ["pytest"] + [[package]] name = "pycparser" version = "2.23" description = "C parser in Python" optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" +groups = ["main", "dev"] files = [ {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, ] +markers = {main = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", dev = "implementation_name == \"pypy\""} [[package]] name = "pydantic" @@ -1328,6 +1743,21 @@ pytest = ">=6.2.5" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "python-dotenv" version = "1.2.2" @@ -1438,6 +1868,111 @@ files = [ {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] +[[package]] +name = "pyzmq" +version = "27.1.0" +description = "Python bindings for 0MQ" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pyzmq-27.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4"}, + {file = "pyzmq-27.1.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556"}, + {file = "pyzmq-27.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf7b38f9fd7b81cb6d9391b2946382c8237fd814075c6aa9c3b746d53076023b"}, + {file = "pyzmq-27.1.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03ff0b279b40d687691a6217c12242ee71f0fba28bf8626ff50e3ef0f4410e1e"}, + {file = "pyzmq-27.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:677e744fee605753eac48198b15a2124016c009a11056f93807000ab11ce6526"}, + {file = "pyzmq-27.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd2fec2b13137416a1c5648b7009499bcc8fea78154cd888855fa32514f3dad1"}, + {file = "pyzmq-27.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08e90bb4b57603b84eab1d0ca05b3bbb10f60c1839dc471fc1c9e1507bef3386"}, + {file = "pyzmq-27.1.0-cp310-cp310-win32.whl", hash = "sha256:a5b42d7a0658b515319148875fcb782bbf118dd41c671b62dae33666c2213bda"}, + {file = "pyzmq-27.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0bb87227430ee3aefcc0ade2088100e528d5d3298a0a715a64f3d04c60ba02f"}, + {file = "pyzmq-27.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:9a916f76c2ab8d045b19f2286851a38e9ac94ea91faf65bd64735924522a8b32"}, + {file = "pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86"}, + {file = "pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581"}, + {file = "pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f"}, + {file = "pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e"}, + {file = "pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e"}, + {file = "pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2"}, + {file = "pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394"}, + {file = "pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f"}, + {file = "pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97"}, + {file = "pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07"}, + {file = "pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc"}, + {file = "pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113"}, + {file = "pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233"}, + {file = "pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31"}, + {file = "pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28"}, + {file = "pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856"}, + {file = "pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496"}, + {file = "pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd"}, + {file = "pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf"}, + {file = "pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f"}, + {file = "pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5"}, + {file = "pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6"}, + {file = "pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7"}, + {file = "pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05"}, + {file = "pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9"}, + {file = "pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128"}, + {file = "pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39"}, + {file = "pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97"}, + {file = "pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db"}, + {file = "pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c"}, + {file = "pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2"}, + {file = "pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e"}, + {file = "pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a"}, + {file = "pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea"}, + {file = "pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96"}, + {file = "pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d"}, + {file = "pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146"}, + {file = "pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd"}, + {file = "pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a"}, + {file = "pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92"}, + {file = "pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0"}, + {file = "pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7"}, + {file = "pyzmq-27.1.0-cp38-cp38-macosx_10_15_universal2.whl", hash = "sha256:18339186c0ed0ce5835f2656cdfb32203125917711af64da64dbaa3d949e5a1b"}, + {file = "pyzmq-27.1.0-cp38-cp38-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:753d56fba8f70962cd8295fb3edb40b9b16deaa882dd2b5a3a2039f9ff7625aa"}, + {file = "pyzmq-27.1.0-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b721c05d932e5ad9ff9344f708c96b9e1a485418c6618d765fca95d4daacfbef"}, + {file = "pyzmq-27.1.0-cp38-cp38-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be883ff3d722e6085ee3f4afc057a50f7f2e0c72d289fd54df5706b4e3d3a50"}, + {file = "pyzmq-27.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:b2e592db3a93128daf567de9650a2f3859017b3f7a66bc4ed6e4779d6034976f"}, + {file = "pyzmq-27.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ad68808a61cbfbbae7ba26d6233f2a4aa3b221de379ce9ee468aa7a83b9c36b0"}, + {file = "pyzmq-27.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:e2687c2d230e8d8584fbea433c24382edfeda0c60627aca3446aa5e58d5d1831"}, + {file = "pyzmq-27.1.0-cp38-cp38-win32.whl", hash = "sha256:a1aa0ee920fb3825d6c825ae3f6c508403b905b698b6460408ebd5bb04bbb312"}, + {file = "pyzmq-27.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:df7cd397ece96cf20a76fae705d40efbab217d217897a5053267cd88a700c266"}, + {file = "pyzmq-27.1.0-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:96c71c32fff75957db6ae33cd961439f386505c6e6b377370af9b24a1ef9eafb"}, + {file = "pyzmq-27.1.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:49d3980544447f6bd2968b6ac913ab963a49dcaa2d4a2990041f16057b04c429"}, + {file = "pyzmq-27.1.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:849ca054d81aa1c175c49484afaaa5db0622092b5eccb2055f9f3bb8f703782d"}, + {file = "pyzmq-27.1.0-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3970778e74cb7f85934d2b926b9900e92bfe597e62267d7499acc39c9c28e345"}, + {file = "pyzmq-27.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:da96ecdcf7d3919c3be2de91a8c513c186f6762aa6cf7c01087ed74fad7f0968"}, + {file = "pyzmq-27.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9541c444cfe1b1c0156c5c86ece2bb926c7079a18e7b47b0b1b3b1b875e5d098"}, + {file = "pyzmq-27.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e30a74a39b93e2e1591b58eb1acef4902be27c957a8720b0e368f579b82dc22f"}, + {file = "pyzmq-27.1.0-cp39-cp39-win32.whl", hash = "sha256:b1267823d72d1e40701dcba7edc45fd17f71be1285557b7fe668887150a14b78"}, + {file = "pyzmq-27.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:0c996ded912812a2fcd7ab6574f4ad3edc27cb6510349431e4930d4196ade7db"}, + {file = "pyzmq-27.1.0-cp39-cp39-win_arm64.whl", hash = "sha256:346e9ba4198177a07e7706050f35d733e08c1c1f8ceacd5eb6389d653579ffbc"}, + {file = "pyzmq-27.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c17e03cbc9312bee223864f1a2b13a99522e0dc9f7c5df0177cd45210ac286e6"}, + {file = "pyzmq-27.1.0-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f328d01128373cb6763823b2b4e7f73bdf767834268c565151eacb3b7a392f90"}, + {file = "pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1790386614232e1b3a40a958454bdd42c6d1811837b15ddbb052a032a43f62"}, + {file = "pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:448f9cb54eb0cee4732b46584f2710c8bc178b0e5371d9e4fc8125201e413a74"}, + {file = "pyzmq-27.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:05b12f2d32112bf8c95ef2e74ec4f1d4beb01f8b5e703b38537f8849f92cb9ba"}, + {file = "pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066"}, + {file = "pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604"}, + {file = "pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c"}, + {file = "pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271"}, + {file = "pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355"}, + {file = "pyzmq-27.1.0-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:50081a4e98472ba9f5a02850014b4c9b629da6710f8f14f3b15897c666a28f1b"}, + {file = "pyzmq-27.1.0-pp38-pypy38_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:510869f9df36ab97f89f4cff9d002a89ac554c7ac9cadd87d444aa4cf66abd27"}, + {file = "pyzmq-27.1.0-pp38-pypy38_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1f8426a01b1c4098a750973c37131cf585f61c7911d735f729935a0c701b68d3"}, + {file = "pyzmq-27.1.0-pp38-pypy38_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:726b6a502f2e34c6d2ada5e702929586d3ac948a4dbbb7fed9854ec8c0466027"}, + {file = "pyzmq-27.1.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:bd67e7c8f4654bef471c0b1ca6614af0b5202a790723a58b79d9584dc8022a78"}, + {file = "pyzmq-27.1.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:722ea791aa233ac0a819fc2c475e1292c76930b31f1d828cb61073e2fe5e208f"}, + {file = "pyzmq-27.1.0-pp39-pypy39_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:01f9437501886d3a1dd4b02ef59fb8cc384fa718ce066d52f175ee49dd5b7ed8"}, + {file = "pyzmq-27.1.0-pp39-pypy39_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4a19387a3dddcc762bfd2f570d14e2395b2c9701329b266f83dd87a2b3cbd381"}, + {file = "pyzmq-27.1.0-pp39-pypy39_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c618fbcd069e3a29dcd221739cacde52edcc681f041907867e0f5cc7e85f172"}, + {file = "pyzmq-27.1.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ff8d114d14ac671d88c89b9224c63d6c4e5a613fe8acd5594ce53d752a3aafe9"}, + {file = "pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540"}, +] + +[package.dependencies] +cffi = {version = "*", markers = "implementation_name == \"pypy\""} + [[package]] name = "ruff" version = "0.16.1" @@ -1466,6 +2001,18 @@ files = [ {file = "ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7"}, ] +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1594,6 +2141,26 @@ postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] pymysql = ["pymysql"] sqlcipher = ["sqlcipher3_binary"] +[[package]] +name = "stack-data" +version = "0.6.3" +description = "Extract data from python stack frames and tracebacks for informative displays" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, + {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, +] + +[package.dependencies] +asttokens = ">=2.1.0" +executing = ">=1.2.0" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] + [[package]] name = "starlette" version = "0.48.0" @@ -1624,6 +2191,42 @@ files = [ {file = "structlog-25.4.0.tar.gz", hash = "sha256:186cd1b0a8ae762e29417095664adf1d6a31702160a46dacb7796ea82f7409e4"}, ] +[[package]] +name = "tornado" +version = "6.5.8" +description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "tornado-6.5.8-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403"}, + {file = "tornado-6.5.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb"}, + {file = "tornado-6.5.8-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58"}, + {file = "tornado-6.5.8-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808"}, + {file = "tornado-6.5.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22"}, + {file = "tornado-6.5.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195"}, + {file = "tornado-6.5.8-cp39-abi3-win32.whl", hash = "sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836"}, + {file = "tornado-6.5.8-cp39-abi3-win_amd64.whl", hash = "sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee"}, + {file = "tornado-6.5.8-cp39-abi3-win_arm64.whl", hash = "sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26"}, + {file = "tornado-6.5.8.tar.gz", hash = "sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f"}, +] + +[[package]] +name = "traitlets" +version = "5.16.1" +description = "Traitlets Python configuration system" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "traitlets-5.16.1-py3-none-any.whl", hash = "sha256:f775618166caa0396c8e337099240f2bd3e5e917d203b2e6fbe21a58d3cb1f6b"}, + {file = "traitlets-5.16.1.tar.gz", hash = "sha256:ed900c2b631aa3a112811139fa97b8d2c3bad5e989656bba4b7e52c7852c18c1"}, +] + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["argcomplete (>=3.0.3) ; python_version < \"3.12\"", "argcomplete (>=3.5.2) ; python_version >= \"3.12\"", "mypy (>=2.0) ; implementation_name != \"pypy\"", "pre-commit", "pytest (>=7.0,<10.0)", "pytest-mock", "pytest-mypy-testing ; implementation_name != \"pypy\""] + [[package]] name = "typeguard" version = "4.4.4" @@ -1876,6 +2479,18 @@ files = [ [package.dependencies] anyio = ">=3.0.0" +[[package]] +name = "wcwidth" +version = "0.8.2" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85"}, + {file = "wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda"}, +] + [[package]] name = "websockets" version = "15.0.1" @@ -1958,4 +2573,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "37f1e42ac8e312f391cced0b1f8c98685b7454d2a2e6ce8aa3f9a39a843692ca" +content-hash = "660be1b1daa7877ea55367d957003e37f3fe70cec7fc648d0a05af7539502644" diff --git a/oid4vc/auth_server/pyproject.toml b/oid4vc/auth_server/pyproject.toml index b7529ab5f..1326d4ceb 100644 --- a/oid4vc/auth_server/pyproject.toml +++ b/oid4vc/auth_server/pyproject.toml @@ -10,7 +10,7 @@ python = "^3.13" orjson = "^3.11.6" fastapi = "^0.116" uvicorn = { extras = ["standard"], version = ">=0.35,<0.52" } -authlib = "^1.6" +authlib = "^1.7.2" SQLAlchemy = { version = "^2.0", extras = ["asyncio"] } asyncpg = "^0.30" psycopg = { extras = ["binary"], version = "^3.2.9" } @@ -27,10 +27,11 @@ pytest-asyncio = "^1.1" pytest-cov = "^6.0" coverage = "^7.6" ruff = "^0.16.0" +ipykernel = "^6.29" [tool.ruff] line-length = 90 -extend-exclude = ["alembic/**/versions/*"] +extend-exclude = ["alembic/**/versions/*", "**/*.ipynb"] [tool.ruff.format] quote-style = "double" diff --git a/oid4vc/auth_server/resources/.env.admin.example b/oid4vc/auth_server/resources/.env.admin.example index c76a55cef..1d3776990 100644 --- a/oid4vc/auth_server/resources/.env.admin.example +++ b/oid4vc/auth_server/resources/.env.admin.example @@ -1,5 +1,18 @@ # Admin API environment (example values; replace in real use) +# App metadata +# ADMIN_APP_ROOT_PATH= +# ADMIN_APP_TITLE=OAuth 2.0 Authorization Server Admin API +# ADMIN_APP_VERSION=0.1.0 +# ADMIN_OPENAPI_URL= + +# Bearer tokens +ADMIN_INTERNAL_AUTH_TOKEN=****** +ADMIN_MANAGE_AUTH_TOKEN=****** + +# Database +# ADMIN_DB_DRIVER_ASYNC=postgresql+asyncpg +# ADMIN_DB_DRIVER_SYNC=postgresql+psycopg ADMIN_DB_USER=postgres ADMIN_DB_PASSWORD=postgres ADMIN_DB_HOST=localhost @@ -7,28 +20,20 @@ ADMIN_DB_PORT=5432 ADMIN_DB_NAME=auth_server_admin ADMIN_DB_SCHEMA=admin -# Bearer token for internal admin endpoints (masked) -ADMIN_INTERNAL_AUTH_TOKEN=****** +# Tenant database +# ADMIN_TENANT_DB_NAME=auth_server_tenant +# ADMIN_TENANT_DB_SCHEMA=auth -# Optional: envelope encryption for secrets (JSON map of version->key) +# Client settings +# ADMIN_MIN_CLIENT_SECRET_LENGTH=32 + +# Key encryption # ADMIN_KEY_ENC_SECRETS={"1":""} # ADMIN_KEY_ENC_VERSION=1 +# ADMIN_KEY_VERIFY_GRACE_TTL=604800 -# Optional: CORS settings -# ADMIN_CORS_ALLOW_ORIGINS=* +# CORS +# ADMIN_CORS_ALLOW_ORIGINS=[] # ADMIN_CORS_ALLOW_METHODS=GET,POST,PATCH,DELETE,OPTIONS # ADMIN_CORS_ALLOW_HEADERS=Authorization,Content-Type -# ADMIN_CORS_ALLOW_CREDENTIALS=false - -# Optional: Application metadata -# ADMIN_APP_ROOT_PATH= -# ADMIN_APP_TITLE=OAuth 2.0 Authorization Server Admin API -# ADMIN_APP_VERSION=0.1.0 -# ADMIN_OPENAPI_URL= - -# Optional: Tenant DB config -# ADMIN_TENANT_DB_NAME=auth_server_tenant -# ADMIN_TENANT_DB_SCHEMA=auth - -# Optional: Key verification grace period (in seconds) -# ADMIN_KEY_VERIFY_GRACE_TTL=604800 \ No newline at end of file +# ADMIN_CORS_ALLOW_CREDENTIALS=false \ No newline at end of file diff --git a/oid4vc/auth_server/resources/.env.demo.example b/oid4vc/auth_server/resources/.env.demo.example new file mode 100644 index 000000000..1c14deaa1 --- /dev/null +++ b/oid4vc/auth_server/resources/.env.demo.example @@ -0,0 +1,21 @@ +# Attestation demo notebook environment (example values; copy to .env.demo). +# The .env.demo file is gitignored; this .example file is committed. + +# Tenant / client identifiers +DEMO_TENANT_UID= +DEMO_ISSUER_CLIENT_ID= +DEMO_ISSUER_CLIENT_SECRET= + +# Server URLs +DEMO_ADMIN_URL=http://localhost:9000 +DEMO_TENANT_URL=http://localhost:9001 + +# Admin manage token (Authorization: Bearer ) +DEMO_MANAGE_TOKEN= + +# Wallet provider metadata +DEMO_WALLET_PROVIDER_ISS=https://wallet-provider.example.com +DEMO_ATTESTATION_TTL=86400 + +# Positive if the server clock is ahead of your local clock (seconds). +DEMO_CLOCK_OFFSET=0 diff --git a/oid4vc/auth_server/resources/.env.tenant.example b/oid4vc/auth_server/resources/.env.tenant.example index 2f099a2e8..d8e2e8b68 100644 --- a/oid4vc/auth_server/resources/.env.tenant.example +++ b/oid4vc/auth_server/resources/.env.tenant.example @@ -1,27 +1,52 @@ # Tenant API environment (example values; replace in real use) +# App metadata +# TENANT_APP_ROOT_PATH= +# TENANT_APP_TITLE=OAuth 2.0 Authorization Server Tenant API +# TENANT_APP_VERSION=0.1.0 +# TENANT_OPENAPI_URL= TENANT_ISSUER_BASE_URL=http://localhost:9001 -# Admin Internal base used by tenant to fetch per-tenant DB/JWKS +# Token settings +# OID4VCI §13.10: bearer access tokens >5 min MUST NOT be issued; set ≤300 +# TENANT_ACCESS_TOKEN_TTL=300 +# TENANT_REFRESH_TOKEN_TTL=604800 +# TENANT_PRE_AUTH_CODE_TTL=600 +# TENANT_MAX_TX_CODE_ATTEMPTS=3 +# TENANT_TOKEN_BYTES=48 +# TENANT_INCLUDE_NONCE=false +# TENANT_NONCE_BYTES=16 + +# Attestation-Based Client Auth (draft-ietf-oauth-attestation-based-client-auth-07) +# Set ENABLED=false to fully disable; REQUIRED=true requires ENABLED=true +# TENANT_ATTESTATION_ENABLED=true +# TENANT_ATTESTATION_REQUIRED=false +# TENANT_ATTESTATION_CLOCK_SKEW_SECONDS=60 + +# Database +# TENANT_DB_DRIVER_ASYNC=postgresql+asyncpg +# TENANT_DB_DRIVER_SYNC=postgresql+psycopg +# TENANT_DB_HOST=localhost +# TENANT_DB_PORT=5432 + +# Networking +# TENANT_TRUSTED_NETWORKS=["127.0.0.0/8","10.0.0.0/8","172.16.0.0/12","192.168.0.0/16"] TENANT_INTERNAL_BASE_URL=http://localhost:9000/internal TENANT_INTERNAL_AUTH_TOKEN=****** -# Caching TTLs (seconds) +# Caching # TENANT_CONTEXT_CACHE_TTL=900 +# TENANT_WELL_KNOWN_CACHE_TTL=300 -# Token TTLs (seconds) -# TENANT_ACCESS_TOKEN_TTL=900 -# TENANT_REFRESH_TOKEN_TTL=604800 -# TENANT_PRE_AUTH_CODE_TTL=600 +# Key encryption +# TENANT_KEY_ENC_SECRETS={"1":""} +# TENANT_KEY_ENC_VERSION=1 -# Optional networking and CORS -# TENANT_TRUSTED_NETWORKS= # e.g., 10.0.0.0/8,192.168.0.0/16 -# TENANT_CORS_ALLOW_ORIGINS=* +# CORS +# TENANT_CORS_ALLOW_ORIGINS=[] # TENANT_CORS_ALLOW_METHODS=GET,POST,OPTIONS # TENANT_CORS_ALLOW_HEADERS=Authorization,Content-Type +# TENANT_CORS_ALLOW_CREDENTIALS=false -# Optional: Application metadata -# TENANT_APP_ROOT_PATH= -# TENANT_APP_TITLE= -# TENANT_APP_VERSION= -# TENANT_OPENAPI_URL= \ No newline at end of file +# Proxy +# TENANT_PROXY_TRUSTED_HOSTS=127.0.0.1 \ No newline at end of file diff --git a/oid4vc/auth_server/resources/attestation_demo.ipynb b/oid4vc/auth_server/resources/attestation_demo.ipynb new file mode 100644 index 000000000..4f46a4abc --- /dev/null +++ b/oid4vc/auth_server/resources/attestation_demo.ipynb @@ -0,0 +1,363 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "19199d24", + "metadata": {}, + "source": [ + "# Attestation Generator\n", + "\n", + "Generates all the intermediate payloads for the **OID4VCI attestation** flow.\n", + "Copy the output into **Swagger UI** or **Postman** to execute each step.\n", + "\n", + "**Prerequisites:**\n", + "- Admin server running on localhost:9000\n", + "- Tenant server running on localhost:9001\n", + "- A seeded tenant + issuer client (run `dev_seed.py` first)" + ] + }, + { + "cell_type": "markdown", + "id": "bbfc4a58", + "metadata": {}, + "source": [ + "## Imports & Helpers" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "98a14c9e", + "metadata": {}, + "outputs": [], + "source": [ + "import base64\n", + "import hashlib\n", + "import json\n", + "import time\n", + "from typing import Any\n", + "\n", + "from joserfc import jwk, jwt as jose_jwt\n", + "from IPython.display import JSON, display, Markdown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19352eb0", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "KEYS_FILE = os.path.join(os.path.dirname(os.path.abspath(\"__file__\")), \".demo_keys.json\")\n", + "\n", + "\n", + "def _gen_ec_key() -> tuple[dict[str, Any], dict[str, Any]]:\n", + " \"\"\"Generate an ES256 key pair, returning (private_jwk, public_jwk).\"\"\"\n", + " key = jwk.generate_key(\"EC\", \"P-256\")\n", + " private_jwk = key.as_dict(private=True)\n", + " public_jwk = key.as_dict(private=False)\n", + " return private_jwk, public_jwk\n", + "\n", + "\n", + "def _load_keys() -> dict[str, Any]:\n", + " \"\"\"Load persisted keys from disk, or return empty dict.\"\"\"\n", + " if os.path.exists(KEYS_FILE):\n", + " with open(KEYS_FILE, \"r\") as f:\n", + " return json.load(f)\n", + " return {}\n", + "\n", + "\n", + "def _save_keys(data: dict[str, Any]) -> None:\n", + " \"\"\"Persist keys to disk.\"\"\"\n", + " with open(KEYS_FILE, \"w\") as f:\n", + " json.dump(data, f, indent=2)\n", + "\n", + "\n", + "def _get_or_create_keypair(label: str) -> tuple[dict[str, Any], dict[str, Any], bool]:\n", + " \"\"\"Load keypair from disk if it exists, otherwise generate and save.\n", + "\n", + " Returns (private_jwk, public_jwk, was_loaded_from_disk).\n", + " \"\"\"\n", + " store = _load_keys()\n", + " if label in store:\n", + " entry = store[label]\n", + " return entry[\"private\"], entry[\"public\"], True\n", + " private_jwk, public_jwk = _gen_ec_key()\n", + " store[label] = {\"private\": private_jwk, \"public\": public_jwk}\n", + " _save_keys(store)\n", + " return private_jwk, public_jwk, False\n", + "\n", + "\n", + "def _thumbprint(j: dict[str, Any]) -> str:\n", + " \"\"\"Compute RFC 7638 JWK thumbprint (base64url, no padding).\"\"\"\n", + " ordered = {\"crv\": j[\"crv\"], \"kty\": j[\"kty\"], \"x\": j[\"x\"], \"y\": j[\"y\"]}\n", + " canonical = json.dumps(ordered, separators=(\",\", \":\"), sort_keys=True).encode()\n", + " digest = hashlib.sha256(canonical).digest()\n", + " return base64.urlsafe_b64encode(digest).decode().rstrip(\"=\")\n", + "\n", + "\n", + "def _sign_jwt(\n", + " payload: dict[str, Any],\n", + " private_jwk: dict[str, Any],\n", + " header_extra: dict[str, Any] | None = None,\n", + ") -> str:\n", + " \"\"\"Sign a JWT with joserfc.\"\"\"\n", + " header = {\"alg\": \"ES256\", \"typ\": \"JWT\"}\n", + " if header_extra:\n", + " header.update(header_extra)\n", + " key = jwk.import_key(private_jwk)\n", + " token = jose_jwt.encode(header, payload, key)\n", + " return token\n", + "\n", + "\n", + "def _decode_jwt_part(token: str, index: int) -> dict[str, Any]:\n", + " \"\"\"Decode a JWT header (0) or payload (1) for display.\"\"\"\n", + " part = token.split(\".\")[index]\n", + " padded = part + \"=\" * (-len(part) % 4)\n", + " return json.loads(base64.urlsafe_b64decode(padded))\n", + "\n", + "\n", + "def show(label: str, data):\n", + " \"\"\"Pretty-print a labelled JSON blob.\"\"\"\n", + " display(Markdown(f\"### {label}\"))\n", + " display(JSON(data))" + ] + }, + { + "cell_type": "markdown", + "id": "79ddf657", + "metadata": {}, + "source": [ + "## Configuration" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "97c57632", + "metadata": {}, + "outputs": [], + "source": [ + "from dotenv import load_dotenv\n", + "\n", + "load_dotenv(\".env.demo\")\n", + "\n", + "TENANT_UID = os.environ[\"DEMO_TENANT_UID\"]\n", + "ISSUER_CLIENT_ID = os.environ[\"DEMO_ISSUER_CLIENT_ID\"]\n", + "ISSUER_CLIENT_SECRET = os.environ[\"DEMO_ISSUER_CLIENT_SECRET\"]\n", + "\n", + "ADMIN_URL = os.environ[\"DEMO_ADMIN_URL\"]\n", + "TENANT_URL = os.environ[\"DEMO_TENANT_URL\"]\n", + "MANAGE_TOKEN = os.environ[\"DEMO_MANAGE_TOKEN\"]\n", + "\n", + "WALLET_PROVIDER_ISS = os.environ[\"DEMO_WALLET_PROVIDER_ISS\"]\n", + "ATTESTATION_TTL = int(os.environ[\"DEMO_ATTESTATION_TTL\"])\n", + "CLOCK_OFFSET = int(os.environ[\"DEMO_CLOCK_OFFSET\"])\n", + "\n", + "print(f\"Tenant URL: {TENANT_URL}\")\n", + "print(f\"Tenant UID: {TENANT_UID}\")\n", + "print(f\"Issuer Client ID: {ISSUER_CLIENT_ID}\")\n", + "print(f\"Wallet Provider ISS: {WALLET_PROVIDER_ISS}\")\n", + "print(f\"Clock offset: {CLOCK_OFFSET}s\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "94a25fcd", + "metadata": {}, + "source": [ + "## Step 1: Wallet Provider generates signing key pair\n", + "\n", + "The provider's private key signs attestation JWTs. The public key is registered on the admin server." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5f495b8c", + "metadata": {}, + "outputs": [], + "source": [ + "provider_private_jwk, provider_public_jwk, loaded = _get_or_create_keypair(\"provider\")\n", + "provider_kid = provider_public_jwk.get(\"kid\") or _thumbprint(provider_public_jwk)\n", + "\n", + "print(\"🔑 Loaded from disk\" if loaded else \"🆕 Generated new keypair (saved to disk)\")\n", + "show(\"Wallet Provider private JWK (kept secret)\", provider_private_jwk)\n", + "show(\"Wallet Provider public JWK (registered on admin)\", provider_public_jwk)\n", + "print(f\"provider_kid: {provider_kid}\")\n", + "print(f\"provider_iss: {WALLET_PROVIDER_ISS}\")" + ] + }, + { + "cell_type": "markdown", + "id": "44f76c3f", + "metadata": {}, + "source": [ + "## Step 2: Register Wallet Provider on Admin\n", + "\n", + "Use this payload to register the provider's public key on the admin allow list (via Swagger/Postman)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "448299a8", + "metadata": {}, + "outputs": [], + "source": [ + "wp_registration = {\n", + " \"iss\": WALLET_PROVIDER_ISS,\n", + " \"jwks\": {\"keys\": [{**provider_public_jwk, \"kid\": provider_kid}]},\n", + " \"name\": \"Demo Wallet Provider\",\n", + " \"active\": True,\n", + "}\n", + "\n", + "display(Markdown(f\"**POST** `{ADMIN_URL}/admin/wallet-providers`\"))\n", + "display(Markdown(f\"**Authorization:** `Bearer {MANAGE_TOKEN}`\"))\n", + "show(\"Request body\", wp_registration)\n", + "\n", + "display(Markdown(\"### Copy-paste JSON\"))\n", + "print(json.dumps(wp_registration, indent=2))" + ] + }, + { + "cell_type": "markdown", + "id": "6bf50dc8", + "metadata": {}, + "source": [ + "## Step 3: Wallet generates its own key pair\n", + "\n", + "The wallet's public key will be embedded in the attestation JWT's `cnf.jwk` claim to bind the attestation to this specific wallet instance." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cd85967b", + "metadata": {}, + "outputs": [], + "source": [ + "wallet_private_jwk, wallet_public_jwk, loaded = _get_or_create_keypair(\"wallet\")\n", + "wallet_kid = (\n", + " wallet_public_jwk.get(\"kid\")\n", + " or _thumbprint(wallet_public_jwk)\n", + ")\n", + "\n", + "print(\"🔑 Loaded from disk\" if loaded else \"🆕 Generated new keypair (saved to disk)\")\n", + "show(\"Wallet private JWK (kept on device)\", wallet_private_jwk)\n", + "show(\n", + " \"Wallet public JWK (sent to provider)\",\n", + " wallet_public_jwk,\n", + ")\n", + "print(f\"wallet_kid (thumbprint): {wallet_kid}\")" + ] + }, + { + "cell_type": "markdown", + "id": "8593dc59", + "metadata": {}, + "source": [ + "## Step 4: Wallet Provider issues Attestation JWT\n", + "\n", + "The provider signs an `oauth-client-attestation+jwt` binding it to the wallet's public key via the `cnf.jwk` claim.\n", + "This token is long-lived (1 day) and can be reused across multiple `/token` calls." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f55aa9ca", + "metadata": {}, + "outputs": [], + "source": [ + "now = int(time.time()) + CLOCK_OFFSET\n", + "\n", + "attestation_payload = {\n", + " \"iss\": WALLET_PROVIDER_ISS,\n", + " \"sub\": ISSUER_CLIENT_ID,\n", + " \"iat\": now,\n", + " \"exp\": now + ATTESTATION_TTL,\n", + " \"cnf\": {\"jwk\": wallet_public_jwk},\n", + "}\n", + "attestation_header = {\n", + " \"alg\": \"ES256\",\n", + " \"typ\": \"oauth-client-attestation+jwt\",\n", + " \"kid\": provider_kid,\n", + "}\n", + "key = jwk.import_key(provider_private_jwk)\n", + "attestation_jwt = jose_jwt.encode(attestation_header, attestation_payload, key)\n", + "\n", + "show(\"Attestation JWT header\", attestation_header)\n", + "show(\"Attestation JWT payload\", attestation_payload)\n", + "display(Markdown(\"### Signed Attestation JWT\"))\n", + "print(attestation_jwt)" + ] + }, + { + "cell_type": "markdown", + "id": "a5431c0e", + "metadata": {}, + "source": [ + "## Step 5: Wallet creates Attestation PoP JWT & output headers\n", + "\n", + "The wallet signs an `oauth-client-attestation-pop+jwt` proving possession of the `cnf.jwk` private key.\n", + "Required claims per draft-08 §5.2: `aud`, `jti`, `iat`. Optional: `challenge` (if AS provides one).\n", + "\n", + "**Re-run this cell before each `/token` call** — the PoP needs a fresh `jti` (replay protection)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1766b550", + "metadata": {}, + "outputs": [], + "source": [ + "now = int(time.time()) + CLOCK_OFFSET\n", + "\n", + "pop_payload = {\n", + " \"iat\": now,\n", + " \"exp\": now + ATTESTATION_TTL,\n", + " \"aud\": f\"{TENANT_URL}/tenants/{TENANT_UID}\",\n", + " \"jti\": base64.urlsafe_b64encode(\n", + " hashlib.sha256(str(now).encode()).digest()[:16]\n", + " ).decode().rstrip(\"=\"),\n", + "}\n", + "pop_header = {\n", + " \"alg\": \"ES256\",\n", + " \"typ\": \"oauth-client-attestation-pop+jwt\",\n", + "}\n", + "wallet_key = jwk.import_key(wallet_private_jwk)\n", + "pop_jwt = jose_jwt.encode(pop_header, pop_payload, wallet_key)\n", + "\n", + "show(\"PoP JWT header\", pop_header)\n", + "show(\"PoP JWT payload\", pop_payload)\n", + "display(Markdown(\"### Signed Attestation PoP JWT\"))\n", + "print(pop_jwt)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "auth-server-oJvz6-Yz-py3.13", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/oid4vc/auth_server/resources/dev_seed.py b/oid4vc/auth_server/resources/dev_seed.py index 368452e58..68cee0364 100644 --- a/oid4vc/auth_server/resources/dev_seed.py +++ b/oid4vc/auth_server/resources/dev_seed.py @@ -11,7 +11,7 @@ import secrets from typing import Any -from authlib.jose import JsonWebKey +from joserfc import jwk from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec @@ -36,8 +36,8 @@ def _gen_es256_keypair() -> tuple[str, dict[str, Any]]: ) .decode("utf-8") ) - public_jwk = JsonWebKey.import_key(public_pem).as_dict() # type: ignore - return private_pem, public_jwk # type: ignore + public_jwk = jwk.import_key(public_pem).as_dict(private=False) + return private_pem, public_jwk async def _ensure_tenant(svc: TenantService, uid: str, name: str): @@ -79,18 +79,7 @@ async def main() -> None: ) await svc.create_client(tenant.uid, pk_payload) - # 2) shared_bearer (HS256) - sb_client_id = f"dev-shared-{secrets.token_hex(4)}" - sb_secret = secrets.token_urlsafe(32) - sb_payload = ClientIn( - client_id=sb_client_id, - client_auth_method="shared_bearer", - client_auth_signing_alg="HS256", - client_secret=sb_secret, - ) - await svc.create_client(tenant.uid, sb_payload) - - # 3) client_secret_basic (PBKDF2 stored) + # 2) client_secret_basic (PBKDF2 stored) cs_client_id = f"dev-basic-{secrets.token_hex(4)}" cs_secret = secrets.token_urlsafe(24) cs_payload = ClientIn( @@ -108,11 +97,6 @@ async def main() -> None: print(" jwks (public):", jwks) print(" private_key_pem (keep secret):\n", private_pem) - print("\nshared_bearer client:") - print(" client_id:", sb_client_id) - print(" signing_alg: HS256") - print(" shared_secret (keep secret):", sb_secret) - print("\nclient_secret_basic client:") print(" client_id:", cs_client_id) print(" client_secret (keep secret):", cs_secret) diff --git a/oid4vc/auth_server/resources/seeds.txt b/oid4vc/auth_server/resources/seeds.txt index 7f367ef46..238014012 100644 --- a/oid4vc/auth_server/resources/seeds.txt +++ b/oid4vc/auth_server/resources/seeds.txt @@ -12,11 +12,6 @@ uJIifTHLZ+V4eboAkbDMUvdpF6ShRANCAAQ6wBY6nQoMttW5f7p/ycoPRnC3faSy -----END PRIVATE KEY----- -shared_bearer client: - client_id: dev-shared-7d2cb250 - signing_alg: HS256 - shared_secret (keep secret): hoqmL67QBlqXbk-myK0gVJLHjsHk6OrYjYcVcpgVtsM - client_secret_basic client: client_id: dev-basic-ac699ffb client_secret (keep secret): EYLdcOzRryToxTlaiIiWTDulEA5VPZoH \ No newline at end of file diff --git a/oid4vc/auth_server/tenant/config.py b/oid4vc/auth_server/tenant/config.py index 0898ea361..05f49f1bd 100644 --- a/oid4vc/auth_server/tenant/config.py +++ b/oid4vc/auth_server/tenant/config.py @@ -1,50 +1,69 @@ -"""Application configuration.""" +"""Tenant settings.""" +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): - """Application configuration.""" + """Tenant env vars (TENANT_ prefix).""" model_config = SettingsConfigDict(env_prefix="TENANT_", extra="ignore") + # App metadata APP_ROOT_PATH: str = "" APP_TITLE: str = "OAuth 2.0 Authorization Server Tenant API" APP_VERSION: str = "0.1.0" OPENAPI_URL: str = "" - ISSUER_BASE_URL: str = "http://localhost:9001" + # Token settings ACCESS_TOKEN_TTL: int = 900 REFRESH_TOKEN_TTL: int = 604800 PRE_AUTH_CODE_TTL: int = 600 + MAX_TX_CODE_ATTEMPTS: int = 3 TOKEN_BYTES: int = 48 - INCLUDE_NONCE: bool = True + INCLUDE_NONCE: bool = False NONCE_BYTES: int = 16 + # Attestation + ATTESTATION_ENABLED: bool = True + ATTESTATION_REQUIRED: bool = False + ATTESTATION_CLOCK_SKEW_SECONDS: int = 60 + + # Database DB_DRIVER_ASYNC: str = "postgresql+asyncpg" DB_DRIVER_SYNC: str = "postgresql+psycopg" DB_HOST: str = "localhost" DB_PORT: int = 5432 + # Networking TRUSTED_NETWORKS: list[str] = [] - INTERNAL_BASE_URL: str = "http://localhost:9000" - INTERNAL_AUTH_TOKEN: str = "admin-internal-auth-token" + INTERNAL_AUTH_TOKEN: str = "" + + # Caching CONTEXT_CACHE_TTL: int = 900 WELL_KNOWN_CACHE_TTL: int = 300 + # Key encryption KEY_ENC_SECRETS: dict[str, str] = {} KEY_ENC_VERSION: int = 1 - # CORS settings - CORS_ALLOW_ORIGINS: list[str] = ["*"] + # CORS + CORS_ALLOW_ORIGINS: list[str] = [] CORS_ALLOW_METHODS: list[str] = ["GET", "POST", "OPTIONS"] CORS_ALLOW_HEADERS: list[str] = ["Authorization", "Content-Type"] CORS_ALLOW_CREDENTIALS: bool = False - # Proxy settings + # Proxy PROXY_TRUSTED_HOSTS: list[str] | str = "127.0.0.1" + @model_validator(mode="after") + def validate_security_features(self): + """Reject required security features that are disabled.""" + if self.ATTESTATION_REQUIRED and not self.ATTESTATION_ENABLED: + raise ValueError("ATTESTATION_REQUIRED requires ATTESTATION_ENABLED") + return self + settings = Settings() diff --git a/oid4vc/auth_server/tenant/deps.py b/oid4vc/auth_server/tenant/deps.py index 8110ca6cf..ed450dc7b 100644 --- a/oid4vc/auth_server/tenant/deps.py +++ b/oid4vc/auth_server/tenant/deps.py @@ -1,21 +1,29 @@ -"""Per-tenant dependencies: DB session + JWKS, cached by tenant uid via Admin API.""" +"""Tenant deps: DB session + JWKS resolution, cached per uid.""" +import re import time -from functools import lru_cache +from collections import OrderedDict from typing import AsyncIterator import httpx from fastapi import Depends, HTTPException, Request from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import AsyncSession -from core.observability.observability import current_request_id +from core.db.cached_session import ( + _session_factory, + dispose_cached_engines, +) +from core.observability.observability import internal_api_headers from core.utils.retry import with_retries from tenant.config import settings -# In-memory cache: uid -> (timestamp, ctx) -_CACHE: dict[str, tuple[float, dict]] = {} +# In-memory cache: uid -> (timestamp, ctx), LRU-bounded +_CACHE: OrderedDict[str, tuple[float, dict]] = OrderedDict() +_MAX_CACHE = 256 _TTL = settings.CONTEXT_CACHE_TTL +# Allowlist blocks / \ % and null — the only chars that enable URL path traversal +_UID_RE = re.compile(r"^[a-zA-Z0-9._-]{1,64}$") @with_retries( @@ -33,7 +41,7 @@ ), ) async def _get_admin_json(client: httpx.AsyncClient, url: str, headers: dict) -> dict: - """GET JSON from Admin with retries on transient errors.""" + """GET JSON from admin; retries configured by decorator.""" res = await client.get(url, headers=headers) if res.status_code >= 500: raise httpx.HTTPStatusError("server error", request=res.request, response=res) @@ -42,21 +50,19 @@ async def _get_admin_json(client: httpx.AsyncClient, url: str, headers: dict) -> async def _fetch_tenant_ctx(uid: str | None = None) -> dict: - """Fetch DB and JWKS from Admin and update cache.""" + """Fetch tenant DB + JWKS from admin, update local cache.""" if not uid: raise HTTPException(status_code=400, detail="Missing tenant uid.") + if not _UID_RE.match(uid): + raise HTTPException(status_code=400, detail="invalid_tenant") base = f"{settings.INTERNAL_BASE_URL}/tenants/{uid}" - headers = {"Authorization": f"Bearer {settings.INTERNAL_AUTH_TOKEN}"} - rid = current_request_id() - if rid: - headers["X-Request-ID"] = rid + headers = internal_api_headers(settings.INTERNAL_AUTH_TOKEN) async with httpx.AsyncClient(timeout=10) as client: try: db_data = await _get_admin_json(client, f"{base}/db", headers) except Exception as ex: - # Fatal: cannot operate without DB coordinates for the tenant raise HTTPException( status_code=503, detail="admin_tenant_db_service_unavailable" ) from ex @@ -76,18 +82,21 @@ async def _fetch_tenant_ctx(uid: str | None = None) -> dict: "jwks": jwks_data, } _CACHE[uid] = (time.time(), ctx) + _CACHE.move_to_end(uid) + while len(_CACHE) > _MAX_CACHE: + _CACHE.popitem(last=False) return ctx -async def _load_tenant_ctx(request: Request, force: bool = False) -> dict: - """Resolve tenant ctx (db + jwks) from Admin and cache by TTL.""" +async def _load_tenant_ctx(request: Request) -> dict: + """Load tenant context from cache or fetch from admin.""" uid: str | None = request.path_params.get("uid") if not uid: raise HTTPException(status_code=400, detail="tenant uid missing in path") now = time.time() cached = _CACHE.get(uid) - if not force and cached is not None: + if cached is not None: ts, ctx = cached if now - ts < _TTL: return ctx @@ -96,7 +105,7 @@ async def _load_tenant_ctx(request: Request, force: bool = False) -> dict: async def get_tenant_ctx(uid: str, key: str) -> dict: - """Return a specific section of the tenant ctx ("db" or "jwks").""" + """Get a section of tenant context by key.""" now = time.time() cached = _CACHE.get(uid) if cached is not None: @@ -114,7 +123,7 @@ async def get_tenant_ctx(uid: str, key: str) -> dict: async def get_tenant_jwks(uid: str) -> dict: - """Helper for `.well-known/jwks.json`.""" + """Return normalized JWKS for a tenant.""" jwks = await get_tenant_ctx(uid, "jwks") # Pass through if already spec-compliant; otherwise normalize sensibly if isinstance(jwks, dict) and isinstance(jwks.get("keys"), list): @@ -124,26 +133,27 @@ async def get_tenant_jwks(uid: str) -> dict: return {"keys": []} -@lru_cache(maxsize=256) -def _sessionmaker_for(url: str, schema: str) -> async_sessionmaker[AsyncSession]: - """Cache a sessionmaker per (url, schema).""" - engine = create_async_engine( - url, - pool_pre_ping=True, - connect_args={"server_settings": {"search_path": schema}}, - ) - return async_sessionmaker(engine, expire_on_commit=False) +_MAX_ENGINES = 128 + + +def _sessionmaker_for(url: str, schema: str): + """Delegate to shared engine cache with tenant-sized limit.""" + return _session_factory(url, schema, max_engines=_MAX_ENGINES) + + +async def dispose_engines() -> None: + """Shutdown hook — dispose pooled engines.""" + await dispose_cached_engines() async def get_db_session( request: Request, ctx: dict = Depends(_load_tenant_ctx), ) -> AsyncIterator[AsyncSession]: - """FastAPI dependency to inject an AsyncSession per request.""" + """Yield a per-request async DB session for the tenant.""" def open_session(db: dict) -> AsyncSession: - sm = _sessionmaker_for(db["url"], db["schema"]) # cached by (url, schema) - return sm() + return _sessionmaker_for(db["url"], db["schema"])() db = ctx["db"] session = open_session(db) @@ -151,7 +161,7 @@ def open_session(db: dict) -> AsyncSession: try: await session.execute(text("SELECT 1")) except Exception: - # Close the broken session and refresh ctx, then retry once + # Stale connection — refresh ctx and retry once await session.close() uid = request.path_params.get("uid") fresh = await _fetch_tenant_ctx(uid) @@ -159,5 +169,8 @@ def open_session(db: dict) -> AsyncSession: await session.execute(text("SELECT 1")) # Yield exactly once yield session + except Exception: + await session.rollback() + raise finally: await session.close() diff --git a/oid4vc/auth_server/tenant/main.py b/oid4vc/auth_server/tenant/main.py index 03b2bdccc..f8c4262e0 100644 --- a/oid4vc/auth_server/tenant/main.py +++ b/oid4vc/auth_server/tenant/main.py @@ -17,7 +17,7 @@ from core.utils.logging import get_logger from tenant.config import settings -from .deps import get_db_session +from .deps import dispose_engines, get_db_session from .routers.grants import router as grants_router from .routers.introspect import router as introspect_router from .routers.token import router as token_router @@ -33,6 +33,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: """Startup/shutdown hooks.""" setup_structlog_json() yield + await dispose_engines() app = FastAPI( @@ -88,5 +89,5 @@ async def log_unhandled_exception(request: Request, ex: Exception): ) return ORJSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={"status": "fail", "error": f"Internal Server Error: {ex}"}, + content={"error": "server_error", "error_description": "Internal Server Error"}, ) diff --git a/oid4vc/auth_server/tenant/models.py b/oid4vc/auth_server/tenant/models.py index 00c3bf3db..03e427c36 100644 --- a/oid4vc/auth_server/tenant/models.py +++ b/oid4vc/auth_server/tenant/models.py @@ -34,16 +34,13 @@ class Subject(Base): TIMESTAMP(timezone=True), nullable=True, onupdate=func.now() ) pre_auth_codes: Mapped[list["PreAuthCode"]] = relationship( - back_populates="subject", cascade="all, delete-orphan", lazy="selectin" + back_populates="subject", cascade="all, delete-orphan", lazy="noload" ) access_tokens: Mapped[list["AccessToken"]] = relationship( - back_populates="subject", cascade="all, delete-orphan", lazy="selectin" + back_populates="subject", cascade="all, delete-orphan", lazy="noload" ) refresh_tokens: Mapped[list["RefreshToken"]] = relationship( - back_populates="subject", cascade="all, delete-orphan", lazy="selectin" - ) - dpop_jtis: Mapped[list["DpopJti"]] = relationship( - back_populates="subject", cascade="all, delete-orphan", lazy="selectin" + back_populates="subject", cascade="all, delete-orphan", lazy="noload" ) @@ -58,6 +55,9 @@ class PreAuthCode(Base): ) code: Mapped[str] = mapped_column(Text, nullable=False) tx_code: Mapped[str | None] = mapped_column("user_pin", Text, nullable=True) + tx_code_attempts: Mapped[int] = mapped_column( + "user_pin_attempts", Integer, nullable=False, default=0 + ) authorization_details: Mapped[list[dict[str, Any]] | None] = mapped_column( JSONB, nullable=True ) @@ -119,18 +119,11 @@ class RefreshToken(Base): ) -class DpopJti(Base): - """DpopJti model.""" +class JtiSeen(Base): + """JTI replay-prevention for private_key_jwt and attestation PoP.""" - __tablename__ = "dpop_jti" - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - subject_id: Mapped[int] = mapped_column( - ForeignKey("subject.id", onupdate="CASCADE", ondelete="CASCADE"), nullable=False - ) - jti: Mapped[str] = mapped_column(Text, nullable=False, unique=True) - htm: Mapped[str | None] = mapped_column(Text, nullable=True) - htu: Mapped[str | None] = mapped_column(Text, nullable=True) - cnf_jkt: Mapped[str | None] = mapped_column(Text, nullable=True) - issued_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) + __tablename__ = "jti_seen" + + jti: Mapped[str] = mapped_column(Text, primary_key=True) expires_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) - subject: Mapped["Subject"] = relationship(back_populates="dpop_jtis", lazy="joined") + jti_metadata: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True) diff --git a/oid4vc/auth_server/tenant/oauth/grants.py b/oid4vc/auth_server/tenant/oauth/grants.py index b122540a6..63a74fddc 100644 --- a/oid4vc/auth_server/tenant/oauth/grants.py +++ b/oid4vc/auth_server/tenant/oauth/grants.py @@ -5,10 +5,16 @@ from authlib.oauth2.rfc6749 import grants from authlib.oauth2.rfc6749.errors import InvalidRequestError +from fastapi import HTTPException as FastAPIHTTPException from starlette.requests import Request from core.consts import OAuth2Flow, OAuth2GrantType +from tenant.config import settings from tenant.oauth.integration.context import get_context, update_context +from tenant.services.attestation_service import ( + AttestationLookupError, + validate_client_attestation, +) class _BaseTenantGrant(grants.BaseGrant): @@ -16,20 +22,72 @@ class _BaseTenantGrant(grants.BaseGrant): TOKEN_ENDPOINT_AUTH_METHODS = ["none"] - async def authenticate_token_endpoint_client(self): # type: ignore[override] + async def authenticate_token_endpoint_client(self): """Bypass client authentication.""" return None request: Request + def _resolve_uid(self) -> str: + """Resolve tenant UID from request context, falling back to the URL path.""" + extra = get_context(self.request) + uid = getattr(extra, "uid", None) + if not uid: + url = getattr(self.request, "uri", None) or getattr(self.request, "url", "") + path = urlparse(url).path if url else "" + parts = [p for p in path.split("/") if p] + try: + tidx = parts.index("tenants") + uid = parts[tidx + 1] + except Exception: + uid = None + if not uid: + raise InvalidRequestError(description="missing_tenant_uid") + return uid + + def _resolve_tenant(self) -> tuple[str, Any]: + """Extract tenant UID and DB session from request context. + + Returns (uid, db). Raises InvalidRequestError on failure. + """ + uid = self._resolve_uid() + db = getattr(get_context(self.request), "db", None) + if db is None: + raise InvalidRequestError(description="server_error") + return uid, db + + async def _validate_attestation(self, *, required: bool) -> dict[str, Any] | None: + """Extract attestation headers and validate against allow list.""" + if not settings.ATTESTATION_ENABLED: + return None + headers = getattr(self.request, "headers", {}) or {} + extra = get_context(self.request) + # Audience pinning requires the tenant, so resolve it before validating. + uid = self._resolve_uid() + try: + return await validate_client_attestation( + client_attestation=headers.get("oauth-client-attestation") or None, + client_attestation_pop=headers.get("oauth-client-attestation-pop") + or None, + attestation_required=required, + expected_audience=f"{settings.ISSUER_BASE_URL}/tenants/{uid}", + db=getattr(extra, "db", None), + ) + except AttestationLookupError as ex: + # Provider trust could not be evaluated; never report as client error. + raise FastAPIHTTPException( + status_code=503, detail="attestation_provider_unavailable" + ) from ex + class PreAuthorizedCodeGrant(_BaseTenantGrant): """OID4VCI pre-authorized_code grant.""" _code: str | None = None _tx_code: str | None = None + _attestation_meta: dict[str, Any] | None = None - async def validate_token_request(self): # type: ignore[override] + async def validate_token_request(self): """Validate pre-authorized_code request.""" payload = getattr(self.request, "payload", None) data = getattr(payload, "data", {}) if payload is not None else {} @@ -38,25 +96,13 @@ async def validate_token_request(self): # type: ignore[override] raise InvalidRequestError(description="missing pre-authorized_code") self._code = str(code) self._tx_code = data.get("tx_code") or None + self._attestation_meta = await self._validate_attestation( + required=settings.ATTESTATION_REQUIRED + ) - async def create_token_response(self): # type: ignore[override] + async def create_token_response(self): """Create token response for pre-authorized_code.""" - extra = get_context(self.request) - uid = getattr(extra, "uid", None) - if not uid: - url = getattr(self.request, "uri", None) or getattr(self.request, "url", "") - path = urlparse(url).path if url else "" - parts = [p for p in path.split("/") if p] - try: - tidx = parts.index("tenants") - uid = parts[tidx + 1] - except Exception: - uid = None - if not uid: - raise InvalidRequestError(description="missing_tenant_uid") - db = getattr(extra, "db", None) - if db is None: - raise InvalidRequestError(description="server_error") + uid, db = self._resolve_tenant() # Stash context for save_token update_context( self.request, @@ -65,6 +111,7 @@ async def create_token_response(self): # type: ignore[override] "uid": uid, "code": self._code or "", "tx_code": self._tx_code, + "attestation": self._attestation_meta, "realm": uid, }, ) @@ -74,7 +121,7 @@ async def create_token_response(self): # type: ignore[override] return 200, token_data, [] @classmethod - def check_token_endpoint(cls, request) -> bool: # type: ignore[override] + def check_token_endpoint(cls, request) -> bool: """Return True when request payload grant_type matches.""" try: payload = getattr(request, "payload", None) @@ -88,8 +135,9 @@ class RotatingRefreshTokenGrant(_BaseTenantGrant): """Refresh token grant with rotation.""" _refresh_token: str | None = None + _attestation_meta: dict[str, Any] | None = None - async def validate_token_request(self): # type: ignore[override] + async def validate_token_request(self): """Validate refresh_token request.""" payload = getattr(self.request, "payload", None) data = getattr(payload, "data", {}) if payload is not None else {} @@ -97,25 +145,13 @@ async def validate_token_request(self): # type: ignore[override] if not refresh_token: raise InvalidRequestError(description="missing refresh_token") self._refresh_token = str(refresh_token) + self._attestation_meta = await self._validate_attestation( + required=settings.ATTESTATION_REQUIRED + ) - async def create_token_response(self): # type: ignore[override] + async def create_token_response(self): """Create token response for refresh_token.""" - extra = get_context(self.request) - uid = getattr(extra, "uid", None) - if not uid: - url = getattr(self.request, "uri", None) or getattr(self.request, "url", "") - path = urlparse(url).path if url else "" - parts = [p for p in path.split("/") if p] - try: - tidx = parts.index("tenants") - uid = parts[tidx + 1] - except Exception: - uid = None - if not uid: - raise InvalidRequestError(description="missing_tenant_uid") - db = getattr(extra, "db", None) - if db is None: - raise InvalidRequestError(description="server_error") + uid, db = self._resolve_tenant() # Stash context for save_token update_context( self.request, @@ -123,6 +159,7 @@ async def create_token_response(self): # type: ignore[override] "flow": OAuth2Flow.REFRESH_TOKEN, "uid": uid, "refresh_token": self._refresh_token or "", + "attestation": self._attestation_meta, "realm": uid, }, ) @@ -132,7 +169,7 @@ async def create_token_response(self): # type: ignore[override] return 200, token_data, [] @classmethod - def check_token_endpoint(cls, request) -> bool: # type: ignore[override] + def check_token_endpoint(cls, request) -> bool: """Return True when request payload grant_type is refresh_token.""" try: payload = getattr(request, "payload", None) diff --git a/oid4vc/auth_server/tenant/oauth/server.py b/oid4vc/auth_server/tenant/oauth/server.py index be524ae37..7252af399 100644 --- a/oid4vc/auth_server/tenant/oauth/server.py +++ b/oid4vc/auth_server/tenant/oauth/server.py @@ -2,6 +2,7 @@ from typing import Any +import structlog from authlib.oauth2.rfc6749 import AuthorizationServer from authlib.oauth2.rfc6749.errors import ( InvalidGrantError, @@ -15,6 +16,8 @@ from tenant.oauth.integration.server import CoreAuthorizationServer from tenant.services.token_service import TokenService +logger = structlog.get_logger(__name__) + _server: AuthorizationServer | None = None @@ -27,6 +30,33 @@ def get_authorization_server() -> AuthorizationServer: async def _save_token(token: dict[str, Any], request: Any): # pragma: no cover """Persist tokens based on flow context and finalize payload.""" + + def _fill_response( + access_token_obj: Any, + refresh_token_val: str, + response_meta: dict[str, Any], + ) -> None: + token.update( + { + "access_token": access_token_obj.token, + "refresh_token": refresh_token_val, + "token_type": "Bearer", + "expires_in": int( + ( + access_token_obj.expires_at - access_token_obj.issued_at + ).total_seconds() + ), + } + ) + if response_meta.get("authorization_details"): + token["authorization_details"] = response_meta["authorization_details"] + if response_meta.get("c_nonce"): + token["c_nonce"] = response_meta["c_nonce"] + if response_meta.get("c_nonce_expires_in"): + token["c_nonce_expires_in"] = int(response_meta["c_nonce_expires_in"]) + if response_meta.get("amr"): + token["amr"] = response_meta["amr"] + extra = get_context(request) ctx = getattr(extra, "token_ctx", None) or {} uid = getattr(extra, "uid", None) @@ -41,6 +71,7 @@ async def _save_token(token: dict[str, Any], request: Any): # pragma: no cover if flow == OAuth2Flow.PRE_AUTH_CODE: code = ctx.get("code") or "" tx_code = ctx.get("tx_code") + attestation = ctx.get("attestation") ( access_token, refresh_token, @@ -51,31 +82,14 @@ async def _save_token(token: dict[str, Any], request: Any): # pragma: no cover code=code, realm=realm, tx_code=tx_code, + attestation=attestation, ) - token.update( - { - "access_token": access_token.token, - "refresh_token": refresh_token, - "token_type": "Bearer", - "expires_in": int( - ( - access_token.expires_at - access_token.issued_at - ).total_seconds() - ), - } - ) - if response_meta.get("authorization_details"): - token["authorization_details"] = response_meta[ - "authorization_details" - ] - if response_meta.get("c_nonce"): - token["c_nonce"] = response_meta["c_nonce"] - if response_meta.get("c_nonce_expires_in"): - token["c_nonce_expires_in"] = int(response_meta["c_nonce_expires_in"]) + _fill_response(access_token, refresh_token, response_meta) return if flow == OAuth2Flow.REFRESH_TOKEN: refresh_token = ctx.get("refresh_token") or "" + attestation = ctx.get("attestation") ( new_access, new_refresh_token, @@ -85,36 +99,27 @@ async def _save_token(token: dict[str, Any], request: Any): # pragma: no cover uid=uid, refresh_token_value=refresh_token, realm=realm, + attestation=attestation, ) - token.update( - { - "access_token": new_access.token, - "refresh_token": new_refresh_token, - "token_type": "Bearer", - "expires_in": int( - (new_access.expires_at - new_access.issued_at).total_seconds() - ), - } - ) - if response_meta.get("authorization_details"): - token["authorization_details"] = response_meta[ - "authorization_details" - ] - if response_meta.get("c_nonce"): - token["c_nonce"] = response_meta["c_nonce"] - if response_meta.get("c_nonce_expires_in"): - token["c_nonce_expires_in"] = int(response_meta["c_nonce_expires_in"]) + _fill_response(new_access, new_refresh_token, response_meta) return except FastAPIHTTPException as e: # map service errors to OAuth errors + detail = getattr(e, "detail", None) or "unknown" + logger.warning( + "token_service_error", + status_code=e.status_code, + detail=detail, + flow=flow, + ) if e.status_code == 400: - detail = getattr(e, "detail", None) if detail == "invalid_grant": raise InvalidGrantError(description="invalid_grant") - raise InvalidRequestError(description="invalid_request") + raise InvalidRequestError(description=detail) if e.status_code == 401: raise InvalidGrantError(description="invalid_grant") raise InvalidRequestError(description="server_error") except Exception: + logger.exception("token_save_unexpected_error", flow=flow) raise InvalidRequestError(description="server_error") raise InvalidRequestError(description="unknown_token_flow") diff --git a/oid4vc/auth_server/tenant/repositories/access_token_repository.py b/oid4vc/auth_server/tenant/repositories/access_token_repository.py index 6d136e65c..471e59a50 100644 --- a/oid4vc/auth_server/tenant/repositories/access_token_repository.py +++ b/oid4vc/auth_server/tenant/repositories/access_token_repository.py @@ -1,29 +1,18 @@ """AccessToken repository.""" -from datetime import datetime, timezone +from datetime import datetime from typing import Union from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession from tenant.models import AccessToken +from tenant.repositories.base import BaseTokenRepository -class AccessTokenRepository: +class AccessTokenRepository(BaseTokenRepository): """Repository for access tokens.""" - def __init__(self, db: AsyncSession): - """Constructor.""" - self.db = db - - @staticmethod - def _to_dt(value: Union[int, float, datetime]) -> datetime: - """Normalize epoch seconds or datetime to UTC datetime.""" - if isinstance(value, datetime): - return ( - value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc) - ) - return datetime.fromtimestamp(float(value), tz=timezone.utc) + _model = AccessToken async def create( self, @@ -32,6 +21,7 @@ async def create( issued_at: Union[int, float, datetime], expires_at: Union[int, float, datetime], token_metadata: dict | None = None, + cnf_jkt: str | None = None, ) -> AccessToken: """Create and add a new access token.""" issued_dt = self._to_dt(issued_at) @@ -42,6 +32,7 @@ async def create( issued_at=issued_dt, expires_at=expires_dt, token_metadata=token_metadata or {}, + cnf_jkt=cnf_jkt, ) self.db.add(access_token) await self.db.flush() diff --git a/oid4vc/auth_server/tenant/repositories/base.py b/oid4vc/auth_server/tenant/repositories/base.py new file mode 100644 index 000000000..33d6a5c95 --- /dev/null +++ b/oid4vc/auth_server/tenant/repositories/base.py @@ -0,0 +1,40 @@ +"""Base repository for token models with shared behavior.""" + +from datetime import datetime, timezone +from typing import Any, Union + +from sqlalchemy import update +from sqlalchemy.ext.asyncio import AsyncSession + + +class BaseTokenRepository: + """Base for token repositories that have subject_id + revoked columns.""" + + _model: Any = None # Subclasses set this to their SQLAlchemy model + + def __init__(self, db: AsyncSession): + """Constructor.""" + self.db = db + + @staticmethod + def _to_dt(value: Union[int, float, datetime]) -> datetime: + """Normalize epoch seconds or datetime to UTC datetime.""" + if isinstance(value, datetime): + return ( + value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc) + ) + return datetime.fromtimestamp(float(value), tz=timezone.utc) + + async def revoke_all_for_subject(self, subject_id: int) -> int: + """Revoke all tokens for a subject (breach response).""" + model = self._model + stmt = ( + update(model) + .where( + model.subject_id == subject_id, + model.revoked.is_(False), + ) + .values(revoked=True) + ) + res = await self.db.execute(stmt) + return res.rowcount diff --git a/oid4vc/auth_server/tenant/repositories/grant_repository.py b/oid4vc/auth_server/tenant/repositories/grant_repository.py index 40fccfa3e..d0e045ec7 100644 --- a/oid4vc/auth_server/tenant/repositories/grant_repository.py +++ b/oid4vc/auth_server/tenant/repositories/grant_repository.py @@ -45,6 +45,30 @@ async def consume_valid(self, pac_id: int, now) -> bool: res = await self.db.execute(stmt) return bool(res.rowcount and res.rowcount > 0) + async def increment_tx_code_attempts(self, pac_id: int, max_attempts: int) -> int: + """Bump attempt counter; burns the code if limit hit. Returns new count.""" + stmt = ( + update(PreAuthCode) + .where( + PreAuthCode.id == pac_id, + PreAuthCode.used.is_(False), + ) + .values(tx_code_attempts=PreAuthCode.tx_code_attempts + 1) + .returning(PreAuthCode.tx_code_attempts) + ) + res = await self.db.execute(stmt) + new_count = res.scalar_one_or_none() + if new_count is None: + return max_attempts # already consumed + if new_count >= max_attempts: + consume_stmt = ( + update(PreAuthCode) + .where(PreAuthCode.id == pac_id, PreAuthCode.used.is_(False)) + .values(used=True) + ) + await self.db.execute(consume_stmt) + return new_count + async def create_pre_auth_code( self, *, diff --git a/oid4vc/auth_server/tenant/repositories/refresh_token_repository.py b/oid4vc/auth_server/tenant/repositories/refresh_token_repository.py index f1f05c48c..0f6bfa579 100644 --- a/oid4vc/auth_server/tenant/repositories/refresh_token_repository.py +++ b/oid4vc/auth_server/tenant/repositories/refresh_token_repository.py @@ -1,28 +1,18 @@ """RefreshToken repository.""" -from datetime import datetime, timezone from typing import Union +from datetime import datetime -from sqlalchemy import update -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update from tenant.models import RefreshToken +from tenant.repositories.base import BaseTokenRepository -class RefreshTokenRepository: +class RefreshTokenRepository(BaseTokenRepository): """Repository for refresh tokens.""" - def __init__(self, db: AsyncSession): - """Constructor.""" - self.db = db - - @staticmethod - def _to_dt(value: Union[int, float, datetime]) -> datetime: - if isinstance(value, datetime): - return ( - value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc) - ) - return datetime.fromtimestamp(float(value), tz=timezone.utc) + _model = RefreshToken async def create( self, @@ -64,3 +54,17 @@ async def consume_valid(self, token_hash: str, now) -> tuple[int, int] | None: res = await self.db.execute(stmt) row = res.first() return tuple(row) if row is not None else None + + async def is_token_reuse(self, token_hash: str) -> int | None: + """Check if a refresh token was already consumed (reuse detection). + + Returns the subject_id if the token exists and is already used, + None otherwise (token doesn't exist or was never consumed). + """ + stmt = select(RefreshToken.subject_id).where( + RefreshToken.token_hash == token_hash, + RefreshToken.used.is_(True), + ) + res = await self.db.execute(stmt) + row = res.first() + return row[0] if row is not None else None diff --git a/oid4vc/auth_server/tenant/routers/introspect.py b/oid4vc/auth_server/tenant/routers/introspect.py index 6737fcea5..07cfe0782 100644 --- a/oid4vc/auth_server/tenant/routers/introspect.py +++ b/oid4vc/auth_server/tenant/routers/introspect.py @@ -25,4 +25,8 @@ async def introspect( ): """Return RFC 7662-style token introspection payload.""" data = await introspect_access_token(db, uid, token) - return ORJSONResponse(data, status_code=200) + return ORJSONResponse( + data, + status_code=200, + headers={"Cache-Control": "no-store", "Pragma": "no-cache"}, + ) diff --git a/oid4vc/auth_server/tenant/routers/token.py b/oid4vc/auth_server/tenant/routers/token.py index f22568517..06eda2d95 100644 --- a/oid4vc/auth_server/tenant/routers/token.py +++ b/oid4vc/auth_server/tenant/routers/token.py @@ -20,15 +20,20 @@ async def token_endpoint( description="Grant type", enum=["urn:ietf:params:oauth:grant-type:pre-authorized_code", "refresh_token"], ), - pre_authorized_code: str | None = Form(None, alias="pre-authorized_code"), - pre_authorized_code_alt: str | None = Form(None, alias="pre_authorized_code"), + pre_authorized_code: str | None = Form(None), tx_code: str | None = Form(None), refresh_token: str | None = Form(None), db: AsyncSession = Depends(get_db_session), ): """Delegate token issuance to Authlib AuthorizationServer with custom grants.""" - pac_value = pre_authorized_code or pre_authorized_code_alt + # Real OID4VCI wallets send "pre-authorized_code" (hyphen); Swagger sends + # "pre_authorized_code" (underscore). Accept both. + pac_value = pre_authorized_code + if not pac_value: + raw_form = await request.form() + pac_value = raw_form.get("pre-authorized_code") or None # type: ignore[assignment] + form_data = { "grant_type": grant_type, "pre-authorized_code": pac_value, @@ -40,4 +45,7 @@ async def token_endpoint( server = get_authorization_server() status_code, body, headers = await server.create_token_response_async(oauth2_req) # type: ignore[attr-defined] - return ORJSONResponse(body, status_code=status_code, headers=dict(headers)) + resp_headers = dict(headers) + resp_headers["Cache-Control"] = "no-store" + resp_headers["Pragma"] = "no-cache" + return ORJSONResponse(body, status_code=status_code, headers=resp_headers) diff --git a/oid4vc/auth_server/tenant/routers/well_known.py b/oid4vc/auth_server/tenant/routers/well_known.py index 4f5325ac6..060ddf62e 100644 --- a/oid4vc/auth_server/tenant/routers/well_known.py +++ b/oid4vc/auth_server/tenant/routers/well_known.py @@ -1,6 +1,6 @@ """OIDC discovery and JWKS endpoints (thin router).""" -from fastapi import APIRouter, Path, Request, Response +from fastapi import APIRouter, HTTPException, Path, Request, Response from fastapi.responses import ORJSONResponse from tenant.config import settings @@ -21,9 +21,12 @@ async def openid_configuration( request: Request, response: Response, uid: str = Path(...) ): """Return OIDC discovery for the tenant.""" - payload = build_oauth_auth_server(uid, request) + try: + payload = await build_oauth_auth_server(uid, request) + except HTTPException: + raise HTTPException(status_code=404, detail="tenant_not_found") ttl = settings.CONTEXT_CACHE_TTL - response.headers["Cache-Control"] = f"public, max-age={ttl}" + response.headers["Cache-Control"] = f"private, max-age={ttl}" return payload @@ -38,7 +41,10 @@ async def jwks( uid: str = Path(...), ): """Return JWKS (RFC 7517) for the tenant.""" - keys = await load_tenant_jwks(uid) + try: + keys = await load_tenant_jwks(uid) + except HTTPException: + raise HTTPException(status_code=404, detail="tenant_not_found") ttl = settings.CONTEXT_CACHE_TTL response.headers["Cache-Control"] = f"public, max-age={ttl}" return keys diff --git a/oid4vc/auth_server/tenant/security/client_auth.py b/oid4vc/auth_server/tenant/security/client_auth.py index c14ddb2da..bd1cea791 100644 --- a/oid4vc/auth_server/tenant/security/client_auth.py +++ b/oid4vc/auth_server/tenant/security/client_auth.py @@ -1,28 +1,271 @@ -"""Tenant client authentication dependency.""" +"""Tenant client authentication (private_key_jwt, client_secret_basic).""" -from fastapi import Depends, Request, Security +import json +from typing import Any, Mapping + +from fastapi import Depends, HTTPException, Request, Security, status from fastapi.security import ( HTTPAuthorizationCredentials, HTTPBasic, HTTPBasicCredentials, HTTPBearer, ) +from joserfc import jwt +from joserfc.jwk import KeySet from sqlalchemy.ext.asyncio import AsyncSession +from core.consts import CLIENT_AUTH_METHODS, SUPPORTED_SIGNING_ALGS +from core.consts import ClientAuthMethod as CLIENT_AUTH_METHOD +from core.crypto.crypto import verify_secret_pbkdf2 from core.models import Client as AuthClient -from core.security.client_auth import base_client_auth +from core.repositories.client_repository import ClientRepository +from core.security.jwks_cache import JWKSCache +from core.security.utils import jwt_header_unverified, jwt_payload_unverified +from core.utils.logging import get_logger from tenant.deps import get_db_session +from tenant.security.jti_cache import JtiCache + +logger = get_logger(__name__) + +_pkjwt_jti_cache = JtiCache(window=300) +_jwks_uri_cache = JWKSCache(ttl=300) basic_security = HTTPBasic(auto_error=False) bearer_security = HTTPBearer(auto_error=False) +async def _load_jwks(client, kid: str | None = None) -> KeySet | None: + jwks_data: dict | None = None + if isinstance(client.jwks, dict): + jwks_data = client.jwks + elif client.jwks and isinstance(client.jwks, str): + try: + jwks_data = json.loads(client.jwks) + except Exception: + return None + if jwks_data: + return KeySet.import_key_set(jwks_data) + if client.jwks_uri: + return await _jwks_uri_cache.get_jwks(client.jwks_uri, kid=kid) + return None + + +def _audiences_for(request: Request) -> list[str]: + # Full URL without query + url = str(request.url) + base = url.split("?", 1)[0] + return [base] + + +def _validate_jwt_alg(token: str, expected_alg: str): + """Validate the 'alg' field in the JWT header.""" + header = jwt_header_unverified(token) + if header.get("alg") != expected_alg: + raise HTTPException(status_code=401, detail="invalid_alg") + + +def _validate_jwt_claims(decoded: dict[str, Any], request: Request): + """Validate standard JWT claims.""" + for claim in ("iss", "sub", "aud", "exp", "iat", "jti"): + if claim not in decoded: + raise HTTPException(status_code=401, detail=f"missing_{claim}") + aud = decoded.get("aud") + expected_aud = _audiences_for(request) + if isinstance(aud, str): + aud = [aud] + if not aud or not any(a in expected_aud for a in aud): + raise HTTPException(status_code=401, detail="invalid_audience") + + +async def _decode_and_validate_jwt( + token: str, + key_material: Any, + request: Request, + db: AsyncSession, + expected_alg: str | None = None, +) -> Mapping[str, Any]: + """Decode, validate, and return JWT claims.""" + + if expected_alg: + _validate_jwt_alg(token, expected_alg) + algorithms = [expected_alg] + else: + # No client-level restriction; still limit to asymmetric algs + header = jwt_header_unverified(token) + alg = header.get("alg") + if alg not in SUPPORTED_SIGNING_ALGS: + raise HTTPException(status_code=401, detail="unsupported_alg") + algorithms = [alg] + + try: + result = jwt.decode(token, key_material, algorithms=algorithms) # type: ignore[arg-type] + claims = result.claims + claims_registry = jwt.JWTClaimsRegistry() + claims_registry.validate(claims, leeway=30) + _validate_jwt_claims(claims, request) + except HTTPException: + raise + except Exception as exc: + logger.warning("client_assertion decode/validate failed: %s", exc) + raise HTTPException(status_code=401, detail="invalid_client") from exc + + if not isinstance(claims, Mapping): + logger.warning("JWT claims is not a mapping") + raise HTTPException(status_code=401, detail="invalid_client") + + # Replay check (RFC 7523 §3) — store until JWT expires + jti = claims.get("jti") + exp = claims.get("exp") + if not await _pkjwt_jti_cache.check_and_store(jti, db, expires_at=exp): + logger.warning("replayed jti=%s for sub=%s", jti, claims.get("sub")) + raise HTTPException(status_code=401, detail="invalid_client") + + return claims + + +async def _authenticate_private_key_jwt( + client: AuthClient, + token: str, + request: Request, + db: AsyncSession, +) -> Mapping[str, Any]: + """Validate private_key_jwt assertions.""" + + header = jwt_header_unverified(token) + kid = header.get("kid") + keys = await _load_jwks(client, kid=kid) + if not keys or not keys.keys: + logger.warning("no keys found for client=%s kid=%s", client.client_id, kid) + raise HTTPException(status_code=401, detail="invalid_client") + + claims = await _decode_and_validate_jwt( + token, + keys, + request, + db, + expected_alg=client.client_auth_signing_alg, + ) + + # iss and sub must match client_id (RFC 7523 §3) + if str(claims.get("iss")) != str(client.client_id): + logger.warning( + "iss mismatch: got %s, expected %s", + claims.get("iss"), + client.client_id, + ) + raise HTTPException(status_code=401, detail="invalid_client") + if str(claims.get("sub")) != str(client.client_id): + logger.warning( + "sub mismatch: got %s, expected %s", + claims.get("sub"), + client.client_id, + ) + raise HTTPException(status_code=401, detail="invalid_client") + + return claims + + +def _authenticate_client_secret_basic(client: AuthClient, token: str) -> None: + """Validate client_secret_basic credentials.""" + + secret_hash = client.client_secret + if secret_hash and token and verify_secret_pbkdf2(token, secret_hash): + return + logger.warning( + "secret mismatch for client=%s", + getattr(client, "client_id", "?"), + ) + raise HTTPException(status_code=401, detail="invalid_client") + + +async def _base_client_auth( + db: AsyncSession, + request: Request, + basic_creds: HTTPBasicCredentials | None = None, + credentials: HTTPAuthorizationCredentials | None = None, +) -> AuthClient: + """Authenticate client and return the persisted Client model.""" + client_id: str | None = None + token: str | None = None + + scheme = credentials.scheme.lower() if credentials and credentials.scheme else "" + cred = credentials.credentials if credentials else "" + + if scheme == "bearer" and cred: + token = cred + try: + claims = jwt_payload_unverified(token) or {} + client_id = claims.get("sub") + except Exception as ex: + logger.exception("Failed to decode bearer token: %s", ex) + raise HTTPException(status_code=401, detail="invalid_client_assertion") + elif basic_creds and basic_creds.username is not None: + client_id = basic_creds.username + token = basic_creds.password or "" + scheme = "basic" + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="unauthorized", + headers={"WWW-Authenticate": "Bearer, Basic"}, + ) + + if not client_id or not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="unauthorized", + headers={"WWW-Authenticate": "Bearer, Basic"}, + ) + + repo = ClientRepository(db) + client = await repo.get_by_client_id(str(client_id)) + if client is None: + logger.warning("unknown client_id=%s", client_id) + raise HTTPException(status_code=401, detail="invalid_client") + + allowed = (client.client_auth_method or "").lower() + if allowed not in set(CLIENT_AUTH_METHODS): + logger.warning( + "unsupported auth method=%s for client=%s", + allowed, + client.client_id, + ) + raise HTTPException(status_code=401, detail="invalid_client") + + if allowed == CLIENT_AUTH_METHOD.CLIENT_SECRET_BASIC and scheme != "basic": + logger.warning( + "client=%s requires basic auth, got scheme=%s", + client.client_id, + scheme, + ) + raise HTTPException(status_code=401, detail="invalid_client") + if allowed == CLIENT_AUTH_METHOD.PRIVATE_KEY_JWT and scheme != "bearer": + logger.warning( + "client=%s requires private_key_jwt, got scheme=%s", + client.client_id, + scheme, + ) + raise HTTPException(status_code=401, detail="invalid_client") + + if allowed == CLIENT_AUTH_METHOD.PRIVATE_KEY_JWT: + await _authenticate_private_key_jwt(client, token, request, db) + request.state.client_id = str(client.client_id) + return client + + if allowed == CLIENT_AUTH_METHOD.CLIENT_SECRET_BASIC: + _authenticate_client_secret_basic(client, token) + request.state.client_id = str(client.client_id) + return client + + raise HTTPException(status_code=401, detail="invalid_client") + + async def client_auth( request: Request, basic_creds: HTTPBasicCredentials | None = Security(basic_security), credentials: HTTPAuthorizationCredentials | None = Security(bearer_security), db: AsyncSession = Depends(get_db_session), ) -> AuthClient: - """Authenticate client and return the persisted Client model for tenant context.""" + """FastAPI dependency: authenticate client for tenant endpoints.""" - return await base_client_auth(db, request, basic_creds, credentials) + return await _base_client_auth(db, request, basic_creds, credentials) diff --git a/oid4vc/auth_server/tenant/security/jti_cache.py b/oid4vc/auth_server/tenant/security/jti_cache.py new file mode 100644 index 000000000..ebc635f61 --- /dev/null +++ b/oid4vc/auth_server/tenant/security/jti_cache.py @@ -0,0 +1,61 @@ +"""JTI replay prevention (PostgreSQL-backed). + +Covers: private_key_jwt (RFC 7523), attestation PoP (draft-07). +""" + +from __future__ import annotations + +import time +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from sqlalchemy.dialects.postgresql import insert + +from tenant.models import JtiSeen + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + +class JtiCache: + """INSERT ON CONFLICT dedup for JTI values.""" + + def __init__(self, *, window: int | float = 300): + """Window = default expiry if caller doesn't provide one.""" + self._window = window + + async def check_and_store( + self, + jti: str | None, + db: AsyncSession, + now: float | None = None, + expires_at: float | None = None, + metadata: dict | None = None, + ) -> bool: + """Return True if fresh (stored); False if missing or replayed.""" + if not jti: + return False + if now is None: + now = time.time() + + exp_ts = expires_at if expires_at is not None else now + self._window + exp_dt = datetime.fromtimestamp(exp_ts, tz=timezone.utc) + now_dt = datetime.fromtimestamp(now, tz=timezone.utc) + + # Conflict update only fires if existing row is already expired. + stmt = ( + insert(JtiSeen) + .values(jti=jti, expires_at=exp_dt, jti_metadata=metadata) + .on_conflict_do_update( + index_elements=[JtiSeen.jti], + set_={ + JtiSeen.expires_at: exp_dt, + JtiSeen.jti_metadata: metadata, + }, + where=JtiSeen.expires_at <= now_dt, + ) + .returning(JtiSeen.jti) + ) + result = await db.execute(stmt) + row = result.first() + return row is not None diff --git a/oid4vc/auth_server/tenant/security/token.py b/oid4vc/auth_server/tenant/security/token.py new file mode 100644 index 000000000..e024e96e7 --- /dev/null +++ b/oid4vc/auth_server/tenant/security/token.py @@ -0,0 +1,24 @@ +"""Tenant token lifecycle helpers.""" + +import secrets +from datetime import datetime, timedelta + +from core.security.utils import utcnow +from tenant.config import settings + + +def new_refresh_token() -> str: + """Opaque refresh token.""" + return secrets.token_urlsafe(settings.TOKEN_BYTES) + + +def compute_access_exp(now: datetime | None = None) -> datetime: + """Access token expiry from now.""" + now = now or utcnow() + return now + timedelta(seconds=settings.ACCESS_TOKEN_TTL) + + +def compute_refresh_exp(now: datetime | None = None) -> datetime: + """Refresh token expiry from now.""" + now = now or utcnow() + return now + timedelta(seconds=settings.REFRESH_TOKEN_TTL) diff --git a/oid4vc/auth_server/tenant/services/attestation_service.py b/oid4vc/auth_server/tenant/services/attestation_service.py new file mode 100644 index 000000000..dd5d14c17 --- /dev/null +++ b/oid4vc/auth_server/tenant/services/attestation_service.py @@ -0,0 +1,491 @@ +"""Client attestation validation for tenant token flow. + +Verifies attestation JWTs signed by trusted Wallet Providers. +Provider public keys are looked up from the admin allow list via internal API. +""" + +import json +from datetime import datetime, timezone +from typing import Any + +import httpx +from authlib.oauth2.rfc6749.errors import InvalidRequestError +from joserfc import jwk, jwt +from joserfc.jws import extract_compact + +from core.observability.observability import internal_api_headers +from core.consts import SUPPORTED_SIGNING_ALGS +from tenant.security.jti_cache import JtiCache +from core.utils.logging import get_logger +from tenant.config import settings + +logger = get_logger(__name__) + + +# --- Attestation PoP jti replay cache (draft-07 §9 step 10) --- +_attest_pop_jti_cache = JtiCache() + + +def _jti_window() -> int: + """Replay window for PoP jti values, read live so overrides take effect.""" + return int(settings.ATTESTATION_CLOCK_SKEW_SECONDS) * 2 + + +class InvalidAttestationError(InvalidRequestError): + """OAuth error for invalid client attestation.""" + + error = "invalid_client_attestation" + + +class AttestationLookupError(Exception): + """Provider lookup could not be completed; not a client error.""" + + +def _jwt_extract(jwt_token: str) -> tuple[dict[str, Any], dict[str, Any]]: + """Extract JWT header and payload without signature verification.""" + try: + obj = extract_compact(jwt_token.encode()) + header = obj.headers() + payload = json.loads(obj.payload) + except Exception as ex: + logger.debug( + "Attestation JWT extract failed: %s (token_len=%d)", + ex, + len(jwt_token), + exc_info=True, + ) + raise InvalidAttestationError(description="malformed_attestation") from ex + if not isinstance(header, dict) or not isinstance(payload, dict): + logger.debug( + "Attestation JWT extract produced non-dict parts: " + "header_type=%s payload_type=%s", + type(header).__name__, + type(payload).__name__, + ) + raise InvalidAttestationError(description="malformed_attestation") + return header, payload + + +def _now_ts() -> int: + return int(datetime.now(timezone.utc).timestamp()) + + +def _required_claim_str(claims: dict[str, Any], name: str) -> str: + value = claims.get(name) + if not isinstance(value, str) or not value: + logger.debug( + "Attestation claim %r missing/invalid: type=%s", + name, + type(value).__name__, + ) + raise InvalidAttestationError(description=f"missing_{name}") + return value + + +def _required_claim_int(claims: dict[str, Any], name: str) -> int: + value = claims.get(name) + if not isinstance(value, int): + logger.debug( + "Attestation claim %r missing/invalid: type=%s", + name, + type(value).__name__, + ) + raise InvalidAttestationError(description=f"missing_{name}") + return value + + +def _thumbprint(jwk_dict: dict[str, Any]) -> str: + """Compute JWK thumbprint per RFC 7638 using joserfc.""" + try: + return jwk.thumbprint(jwk_dict) + except Exception as ex: + logger.debug( + "JWK thumbprint computation failed: %s (kty=%s, crv=%s)", + ex, + jwk_dict.get("kty"), + jwk_dict.get("crv"), + exc_info=True, + ) + raise InvalidAttestationError(description="invalid_jwk") from ex + + +def _extract_cnf_jwk(claims: dict[str, Any]) -> dict[str, Any] | None: + """Extract cnf.jwk from attestation payload.""" + cnf = claims.get("cnf") + if not isinstance(cnf, dict): + return None + cnf_key = cnf.get("jwk") + if isinstance(cnf_key, dict): + return cnf_key + return None + + +async def _lookup_provider_key( + iss: str, kid: str | None = None +) -> dict[str, Any] | list[dict[str, Any]] | None: + """Look up provider key(s) from admin internal API. + + When *kid* is provided, returns a single JWK dict. + When *kid* is absent, returns a list of JWK dicts for trial verification. + """ + url = f"{settings.INTERNAL_BASE_URL}/wallet-providers/lookup" + headers = internal_api_headers(settings.INTERNAL_AUTH_TOKEN) + params: dict[str, str] = {"iss": iss} + if kid: + params["kid"] = kid + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(url, params=params, headers=headers) + if resp.status_code != 200: + logger.debug( + "Provider lookup non-200 iss=%s kid=%s status=%d", + iss, + kid, + resp.status_code, + ) + raise AttestationLookupError(f"lookup status {resp.status_code}") + data = resp.json() + if not data.get("found"): + logger.debug("Provider lookup not found iss=%s kid=%s", iss, kid) + return None + # Single key when kid was specified + if kid: + logger.debug("Provider lookup hit iss=%s kid=%s (single key)", iss, kid) + return data.get("public_key") + # All keys when kid was absent + keys = data.get("keys") or [] + logger.debug( + "Provider lookup hit iss=%s (no kid, %d keys for trial)", + iss, + len(keys) if isinstance(keys, list) else -1, + ) + return keys + except AttestationLookupError: + raise + except Exception as ex: + logger.warning( + "Failed to look up provider key iss=%s kid=%s", + iss, + kid, + exc_info=True, + ) + raise AttestationLookupError("provider lookup failed") from ex + + +def _verify_signature( + token: str, + public_key_jwk: dict[str, Any] | list[dict[str, Any]], + *, + error: str = "attestation_signature_invalid", +) -> dict[str, Any]: + """Verify JWT signature. Accepts a single JWK or list for trial.""" + # joserfc KeySet lookup requires a `kid`; draft-07 5.1 makes it optional, + # so unlabelled keys are tried one by one. + if isinstance(public_key_jwk, list): + for candidate in public_key_jwk: + try: + key = jwk.import_key(candidate) + result = jwt.decode(token, key, algorithms=list(SUPPORTED_SIGNING_ALGS)) + return dict(result.claims) + except Exception: + continue + logger.debug( + "Signature verification failed (%s): trial keys n=%d, algs=%s", + error, + len(public_key_jwk), + list(SUPPORTED_SIGNING_ALGS), + ) + raise InvalidAttestationError(description=error) + + try: + key = jwk.import_key(public_key_jwk) + result = jwt.decode(token, key, algorithms=list(SUPPORTED_SIGNING_ALGS)) + return dict(result.claims) + except Exception as ex: + logger.debug( + "Signature verification failed (%s): %s [kty=%s kid=%s crv=%s, algs=%s]", + error, + ex, + public_key_jwk.get("kty"), + public_key_jwk.get("kid"), + public_key_jwk.get("crv"), + list(SUPPORTED_SIGNING_ALGS), + exc_info=True, + ) + raise InvalidAttestationError(description=error) from ex + + +async def validate_client_attestation( + *, + client_attestation: str | None, + client_attestation_pop: str | None = None, + attestation_required: bool, + expected_audience: str, + db=None, +) -> dict[str, Any] | None: + """Validate client attestation JWT + PoP against the trusted provider allow list. + + Implements draft-ietf-oauth-attestation-based-client-auth-07 §6.2 and §9. + """ + if not client_attestation: + # draft-07 9 step 1: a PoP without its attestation is a malformed request. + if client_attestation_pop: + logger.debug("Attestation PoP present without attestation header") + raise InvalidAttestationError(description="missing_client_attestation") + if attestation_required: + logger.debug("Attestation required but missing") + raise InvalidAttestationError(description="missing_client_attestation") + return None + + logger.debug( + "Validating client attestation (attestation_len=%d, pop_present=%s, " + "expected_audience=%s, attestation_required=%s)", + len(client_attestation), + bool(client_attestation_pop), + expected_audience, + attestation_required, + ) + + # --- Attestation JWT validation (§5.1, §9 steps 2-6) --- + + # 1. Decode header and payload (without verification first, to extract iss + kid) + header, claims = _jwt_extract(client_attestation) + logger.debug( + "Attestation header: typ=%s alg=%s kid=%s | claims keys=%s", + header.get("typ"), + header.get("alg"), + header.get("kid"), + sorted(claims.keys()), + ) + + # 2. Validate typ header (§5.1: REQUIRED, MUST be "oauth-client-attestation+jwt") + typ = header.get("typ") + if typ != "oauth-client-attestation+jwt": + logger.debug("Attestation typ mismatch: got %r", typ) + raise InvalidAttestationError(description="invalid_attestation_typ") + + # 3. Extract kid from header (OPTIONAL per spec), iss from payload + kid = header.get("kid") + if kid is not None and not isinstance(kid, str): + logger.debug("Attestation header kid invalid type: %s", type(kid).__name__) + raise InvalidAttestationError(description="invalid_kid") + issuer = _required_claim_str(claims, "iss") + logger.debug("Attestation iss=%s kid=%s", issuer, kid) + + # 4. Look up provider key(s) by iss + optional kid (§9 step 5) + provider_key = await _lookup_provider_key(issuer, kid or None) + if not provider_key: + logger.debug("Attestation provider untrusted/unknown: iss=%s kid=%s", issuer, kid) + raise InvalidAttestationError(description="untrusted_provider") + + # 5. Verify signature using provider's public key (§9 step 5) + verified_claims = _verify_signature(client_attestation, provider_key) + + # 6. Validate required claims (§5.1) + subject = _required_claim_str(verified_claims, "sub") + issued_at = verified_claims.get("iat") + expires_at = verified_claims.get("exp") + if not isinstance(expires_at, int): + logger.debug( + "Attestation missing/invalid exp: type=%s", type(expires_at).__name__ + ) + raise InvalidAttestationError(description="missing_exp") + + # 7. Check time validity (§9 step 11) + skew = int(settings.ATTESTATION_CLOCK_SKEW_SECONDS) + now = _now_ts() + if issued_at is not None and int(issued_at) > now + skew: + logger.debug( + "Attestation iat in future: iat=%s now=%d skew=%d iss=%s", + issued_at, + now, + skew, + issuer, + ) + raise InvalidAttestationError(description="attestation_not_yet_valid") + if int(expires_at) <= now - skew: + logger.debug( + "Attestation expired: exp=%d now=%d skew=%d iss=%s sub=%s", + int(expires_at), + now, + skew, + issuer, + subject, + ) + raise InvalidAttestationError(description="attestation_expired") + nbf = verified_claims.get("nbf") + if isinstance(nbf, int) and nbf > now + skew: + logger.debug( + "Attestation nbf in future: nbf=%d now=%d skew=%d iss=%s", + nbf, + now, + skew, + issuer, + ) + raise InvalidAttestationError(description="attestation_not_yet_valid") + + # 8. Extract cnf.jwk — REQUIRED per §5.1 + cnf_jwk = _extract_cnf_jwk(verified_claims) + if not cnf_jwk: + logger.debug( + "Attestation missing cnf.jwk: cnf=%r iss=%s sub=%s", + verified_claims.get("cnf"), + issuer, + subject, + ) + raise InvalidAttestationError(description="missing_cnf_jwk") + + # 8b. cnf key MUST NOT be a private key (§9 step 6) + if "d" in cnf_jwk: + logger.debug( + "Attestation cnf.jwk contains private key material: iss=%s sub=%s kty=%s", + issuer, + subject, + cnf_jwk.get("kty"), + ) + raise InvalidAttestationError(description="cnf_contains_private_key") + + cnf_jkt = _thumbprint(cnf_jwk) + logger.debug("Attestation cnf jkt=%s (kty=%s)", cnf_jkt, cnf_jwk.get("kty")) + + # --- Attestation PoP JWT validation (§5.2, §9 steps 7-10) --- + + if not client_attestation_pop: + logger.debug("Attestation PoP missing: iss=%s sub=%s", issuer, subject) + raise InvalidAttestationError(description="missing_client_attestation_pop") + + # 9. Validate PoP typ header + # (§5.2: REQUIRED, MUST be "oauth-client-attestation-pop+jwt") + pop_header, _ = _jwt_extract(client_attestation_pop) + pop_typ = pop_header.get("typ") + if pop_typ != "oauth-client-attestation-pop+jwt": + logger.debug("Attestation PoP typ mismatch: got %r", pop_typ) + raise InvalidAttestationError(description="invalid_attestation_pop_typ") + + # 10. Verify PoP signature using cnf.jwk from attestation (§9 step 7) + pop_claims = _verify_signature( + client_attestation_pop, cnf_jwk, error="attestation_pop_signature_invalid" + ) + logger.debug( + "Attestation PoP claims keys=%s aud=%r iat=%s exp=%s", + sorted(pop_claims.keys()), + pop_claims.get("aud"), + pop_claims.get("iat"), + pop_claims.get("exp"), + ) + + # 11. Verify PoP aud (§5.2: REQUIRED, must be AS issuer identifier) + pop_aud = pop_claims.get("aud") + if not pop_aud: + logger.debug("Attestation PoP missing aud: iss=%s sub=%s", issuer, subject) + raise InvalidAttestationError(description="missing_attestation_pop_aud") + + # 11b. Validate PoP aud value matches the expected AS issuer (§5.2, §8 step 10) + aud_values = pop_aud if isinstance(pop_aud, list) else [pop_aud] + if expected_audience not in aud_values: + logger.debug( + "Attestation PoP aud mismatch: expected=%s got=%r iss=%s sub=%s", + expected_audience, + pop_aud, + issuer, + subject, + ) + raise InvalidAttestationError(description="attestation_pop_aud_mismatch") + + # 12. Verify PoP jti is present (§5.2: REQUIRED) + pop_jti = pop_claims.get("jti") + if not isinstance(pop_jti, str) or not pop_jti: + logger.debug( + "Attestation PoP missing/invalid jti: type=%s iss=%s sub=%s", + type(pop_jti).__name__, + issuer, + subject, + ) + raise InvalidAttestationError(description="missing_attestation_pop_jti") + + # 12b. Check jti replay (§12.1: SHOULD detect replay via jti) + jti_window = _jti_window() + if not await _attest_pop_jti_cache.check_and_store( + pop_jti, db, expires_at=now + jti_window + ): + logger.debug( + "Attestation PoP jti replay detected: jti=%s iss=%s sub=%s", + pop_jti, + issuer, + subject, + ) + raise InvalidAttestationError(description="attestation_pop_jti_replay") + + # 13. Verify PoP iat is present and within window (§5.2: REQUIRED) + pop_iat = pop_claims.get("iat") + if not isinstance(pop_iat, int): + logger.debug( + "Attestation PoP missing/invalid iat: type=%s iss=%s sub=%s", + type(pop_iat).__name__, + issuer, + subject, + ) + raise InvalidAttestationError(description="missing_attestation_pop_iat") + if pop_iat > now + skew: + logger.debug( + "Attestation PoP iat in future: iat=%d now=%d skew=%d iss=%s", + pop_iat, + now, + skew, + issuer, + ) + raise InvalidAttestationError(description="attestation_pop_not_yet_valid") + if pop_iat < now - jti_window: + logger.debug( + "Attestation PoP too old: iat=%d now=%d window=%d iss=%s", + pop_iat, + now, + jti_window, + issuer, + ) + raise InvalidAttestationError(description="attestation_pop_too_old") + + # 14. Verify PoP nbf if present (RFC7519 via §5.2 rule 5) + pop_nbf = pop_claims.get("nbf") + if isinstance(pop_nbf, int) and pop_nbf > now + skew: + logger.debug( + "Attestation PoP nbf in future: nbf=%d now=%d skew=%d iss=%s", + pop_nbf, + now, + skew, + issuer, + ) + raise InvalidAttestationError(description="attestation_pop_not_yet_valid") + + # 15. Verify PoP exp if present (RFC7519 via §5.2 rule 5) + pop_exp = pop_claims.get("exp") + if isinstance(pop_exp, int) and pop_exp <= now - skew: + logger.debug( + "Attestation PoP exp passed: exp=%d now=%d skew=%d iss=%s", + pop_exp, + now, + skew, + issuer, + ) + raise InvalidAttestationError(description="attestation_pop_expired") + + logger.debug( + "Attestation verified: iss=%s sub=%s kid=%s cnf_jkt=%s pop_jti=%s", + issuer, + subject, + kid, + cnf_jkt, + pop_jti, + ) + + return { + "present": True, + "verified": True, + "iss": issuer, + "kid": kid, + "sub": subject, + "cnf_jkt": cnf_jkt, + "pop_jti": pop_jti, + "iat": issued_at, + "exp": expires_at, + } diff --git a/oid4vc/auth_server/tenant/services/grant_service.py b/oid4vc/auth_server/tenant/services/grant_service.py index e4816ea4c..ba35a8a67 100644 --- a/oid4vc/auth_server/tenant/services/grant_service.py +++ b/oid4vc/auth_server/tenant/services/grant_service.py @@ -15,7 +15,7 @@ def new_code() -> str: - """Generate a new code.""" + """Random pre-auth code.""" return secrets.token_urlsafe(settings.TOKEN_BYTES) @@ -38,6 +38,7 @@ async def ensure_subject( return subj.id except IntegrityError: # Race: subject with this uid created concurrently + await db.rollback() sid2 = await repo.get_id_by_uid(uid) if sid2 is None: raise @@ -59,7 +60,8 @@ async def create_pre_authorized_code( else: authorization_details_dict = None now = utcnow() - ttl = ttl_seconds if ttl_seconds and ttl_seconds > 0 else settings.PRE_AUTH_CODE_TTL + max_ttl = settings.PRE_AUTH_CODE_TTL + ttl = min(ttl_seconds, max_ttl) if ttl_seconds and ttl_seconds > 0 else max_ttl repo = GrantRepository(db) pac = await repo.create_pre_auth_code( subject_id=sid, diff --git a/oid4vc/auth_server/tenant/services/introspect_service.py b/oid4vc/auth_server/tenant/services/introspect_service.py index d967135a6..104114a3c 100644 --- a/oid4vc/auth_server/tenant/services/introspect_service.py +++ b/oid4vc/auth_server/tenant/services/introspect_service.py @@ -7,6 +7,8 @@ from core.security.utils import utcnow from tenant.repositories.access_token_repository import AccessTokenRepository +_INACTIVE: dict[str, Any] = {"active": False} + async def introspect_access_token( db: AsyncSession, tenant_uid: str, token_str: str @@ -15,28 +17,32 @@ async def introspect_access_token( repo = AccessTokenRepository(db) token = await repo.get_by_token(token_str) - if token is None: - return {"active": False} - - if token.revoked or token.expires_at is None or token.expires_at <= utcnow(): - return {"active": False} - if not token.subject or not token.subject.uid: - return {"active": False} + # Evaluate all conditions regardless of outcome to normalize timing + active = token is not None + if active: + active = not token.revoked + if active: + active = token.expires_at is not None and token.expires_at > utcnow() + if active: + active = bool(token.subject and token.subject.uid) + if active: + meta = token.token_metadata or {} + active = meta.get("realm") == tenant_uid + else: + meta = {} - meta = token.token_metadata or {} - realm = meta.get("realm") - if realm is not None and realm != tenant_uid: - return {"active": False} + if not active: + return _INACTIVE - token_type = meta.get("token_type") or ("DPoP" if token.cnf_jkt else "Bearer") + token_type = meta.get("token_type") or "Bearer" resp: dict[str, Any] = { "active": True, "token_type": token_type, "sub": token.subject.uid, "exp": int(token.expires_at.timestamp()), "iat": int(token.issued_at.timestamp()), - "realm": realm, + "realm": meta.get("realm"), } if token.cnf_jkt: resp["cnf"] = {"jkt": token.cnf_jkt} diff --git a/oid4vc/auth_server/tenant/services/signing_service.py b/oid4vc/auth_server/tenant/services/signing_service.py index 2253c6079..40387fa0b 100644 --- a/oid4vc/auth_server/tenant/services/signing_service.py +++ b/oid4vc/auth_server/tenant/services/signing_service.py @@ -4,7 +4,7 @@ import httpx -from core.observability.observability import current_request_id +from core.observability.observability import internal_api_headers from core.utils.retry import with_retries from tenant.config import settings @@ -31,10 +31,7 @@ async def remote_sign_jwt( payload: dict[str, Any] = {"claims": claims} if kid: payload["kid"] = kid - headers = {"Authorization": f"Bearer {settings.INTERNAL_AUTH_TOKEN}"} - rid = current_request_id() - if rid: - headers["X-Request-ID"] = rid + headers = internal_api_headers(settings.INTERNAL_AUTH_TOKEN) async with httpx.AsyncClient(timeout=10.0) as client: res = await client.post(url, json=payload, headers=headers) # 4xx and 5xx will be handled by decorator's should_retry predicate diff --git a/oid4vc/auth_server/tenant/services/token_service.py b/oid4vc/auth_server/tenant/services/token_service.py index da4085495..0e11f527e 100644 --- a/oid4vc/auth_server/tenant/services/token_service.py +++ b/oid4vc/auth_server/tenant/services/token_service.py @@ -1,24 +1,33 @@ """Issue/rotate tokens via remote signer, using tenant DB only.""" +import hmac import secrets from typing import Any from fastapi import HTTPException, status +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from core.utils.logging import get_logger + from core.security.utils import ( - compute_access_exp, - compute_refresh_exp, hash_token, - new_refresh_token, utcnow, ) from tenant.config import settings +from tenant.models import Subject from tenant.repositories.access_token_repository import AccessTokenRepository from tenant.repositories.grant_repository import GrantRepository from tenant.repositories.refresh_token_repository import RefreshTokenRepository +from tenant.security.token import ( + compute_access_exp, + compute_refresh_exp, + new_refresh_token, +) from tenant.services.signing_service import remote_sign_jwt +logger = get_logger(__name__) + def _coerce_authorization_details(value: Any) -> list[dict[str, Any]]: """Return authorization_details as a list of dicts, filtering invalid entries.""" @@ -29,6 +38,23 @@ def _coerce_authorization_details(value: Any) -> list[dict[str, Any]]: return [] +def _coerce_amr(value: Any) -> list[str]: + """Return amr as a list of non-empty strings.""" + if isinstance(value, str): + return [value] if value else [] + if isinstance(value, list): + return [item for item in value if isinstance(item, str) and item] + return [] + + +def _merge_amr(existing: Any, value: str) -> list[str]: + """Return unique AMR values preserving order.""" + amr_values = _coerce_amr(existing) + if value not in amr_values: + amr_values.append(value) + return amr_values + + class TokenService: """Issue/rotate tokens via remote signer, using tenant DB only.""" @@ -39,6 +65,7 @@ async def issue_by_pre_auth_code( code: str, realm: str, tx_code: str | None = None, + attestation: dict[str, Any] | None = None, ): """Issue access+refresh from a pre-auth code.""" grant_repo = GrantRepository(db) @@ -49,15 +76,24 @@ async def issue_by_pre_auth_code( now = utcnow() pac = await grant_repo.get_by_code(code) - if pac is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_grant" + if pac is None or pac.used or pac.expires_at <= now: + reason = ( + "not_found" if pac is None else "already_used" if pac.used else "expired" ) - if pac.tx_code and tx_code != pac.tx_code: + logger.warning("pre_auth_code rejected: %s", reason) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_grant" ) - # Atomically consume the PAC to prevent race/double-spend + # tx_code brute-force check + if pac.tx_code and not hmac.compare_digest(tx_code or "", pac.tx_code): + attempts = await grant_repo.increment_tx_code_attempts( + pac.id, settings.MAX_TX_CODE_ATTEMPTS + ) + await db.commit() + remaining = max(0, settings.MAX_TX_CODE_ATTEMPTS - attempts) + detail = "invalid_grant" if remaining == 0 else "invalid_tx_code" + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) + # Consume the PAC atomically (prevents double-spend) consumed = await grant_repo.consume_valid(pac.id, now) if not consumed: raise HTTPException( @@ -85,20 +121,22 @@ async def issue_by_pre_auth_code( c_nonce_expires_in = settings.ACCESS_TOKEN_TTL response_meta["c_nonce"] = c_nonce response_meta["c_nonce_expires_in"] = c_nonce_expires_in + if isinstance(attestation, dict): + response_meta["attestation"] = attestation + response_meta["amr"] = _merge_amr(response_meta.get("amr"), "att-pop") - sign_res = await remote_sign_jwt( - uid=uid, - claims=claims, - ) + sign_res = await remote_sign_jwt(uid=uid, claims=claims) token_meta: dict[str, Any] = {"iss": issuer, "realm": realm} token_meta.update(response_meta) + cnf_jkt = attestation.get("cnf_jkt") if isinstance(attestation, dict) else None access_token = await access_repo.create( subject_id=pac.subject_id, token=sign_res["jwt"], issued_at=now, expires_at=access_exp, token_metadata=token_meta, + cnf_jkt=cnf_jkt, ) refresh_token = new_refresh_token() @@ -119,6 +157,7 @@ async def rotate_by_refresh_token( uid: str, refresh_token_value: str, realm: str, + attestation: dict[str, Any] | None = None, ): """Rotate tokens using a refresh token.""" access_repo = AccessTokenRepository(db) @@ -131,6 +170,26 @@ async def rotate_by_refresh_token( token_hash = hash_token(refresh_token_value) res = await refresh_repo.consume_valid(token_hash=token_hash, now=now) if not res: + # Check if this is a reuse of an already-consumed token (breach signal) + reuse_subject = await refresh_repo.is_token_reuse(token_hash) + if reuse_subject is not None: + logger.warning( + "refresh token reuse detected, revoking family subject_id=%s", + reuse_subject, + ) + # SELECT FOR UPDATE to serialize concurrent issuance + # before revoking the subject's token family. + await db.execute( + select(Subject.id) + .where(Subject.id == reuse_subject) + .with_for_update() + ) + # Revoke the token family (OAuth Security BCP §4.14.2) + await access_repo.revoke_all_for_subject(reuse_subject) + await refresh_repo.revoke_all_for_subject(reuse_subject) + await db.commit() + else: + logger.warning("refresh token not found or expired") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_token" ) @@ -142,6 +201,24 @@ async def rotate_by_refresh_token( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="subject_uid_missing", ) + # draft-07 10.3: the refresh token is bound to the attested client instance + # key, so rotation MUST present the same key. + bound_cnf_jkt = getattr(prev_access, "cnf_jkt", None) or None + if bound_cnf_jkt: + presented_cnf_jkt = ( + attestation.get("cnf_jkt") if isinstance(attestation, dict) else None + ) + if presented_cnf_jkt != bound_cnf_jkt: + logger.warning( + "refresh attestation key mismatch subject_id=%s", subject_id + ) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid_token", + ) + + # Revoke the previous access token immediately on rotation + prev_access.revoked = True prev_meta = prev_access.token_metadata or {} prev_authz = ( _coerce_authorization_details(prev_meta.get("authorization_details")) @@ -163,19 +240,40 @@ async def rotate_by_refresh_token( response_meta["c_nonce"] = c_nonce response_meta["c_nonce_expires_in"] = c_nonce_expires_in - sign_res = await remote_sign_jwt( - uid=uid, - claims=claims, + effective_attestation = None + if isinstance(attestation, dict): + effective_attestation = attestation + elif isinstance(prev_meta, dict) and isinstance( + prev_meta.get("attestation"), dict + ): + effective_attestation = prev_meta.get("attestation") + if isinstance(effective_attestation, dict): + response_meta["attestation"] = effective_attestation + + amr_values = ( + _coerce_amr(prev_meta.get("amr")) if isinstance(prev_meta, dict) else [] ) + if isinstance(effective_attestation, dict): + amr_values = _merge_amr(amr_values, "att-pop") + if amr_values: + response_meta["amr"] = amr_values - token_meta = {"iss": issuer, "realm": realm} + sign_res = await remote_sign_jwt(uid=uid, claims=claims) + + token_meta: dict[str, Any] = {"iss": issuer, "realm": realm} token_meta.update(response_meta) + cnf_jkt = ( + effective_attestation.get("cnf_jkt") + if isinstance(effective_attestation, dict) + else None + ) new_access_token = await access_repo.create( subject_id=subject_id, token=sign_res["jwt"], issued_at=now, expires_at=access_exp, token_metadata=token_meta, + cnf_jkt=cnf_jkt, ) refresh_token = new_refresh_token() diff --git a/oid4vc/auth_server/tenant/services/well_known_service.py b/oid4vc/auth_server/tenant/services/well_known_service.py index 5e87a855f..577eb6c1d 100644 --- a/oid4vc/auth_server/tenant/services/well_known_service.py +++ b/oid4vc/auth_server/tenant/services/well_known_service.py @@ -4,18 +4,20 @@ from fastapi import Request -from core.consts import OAuth2GrantType +from core.consts import OAuth2GrantType, SUPPORTED_SIGNING_ALGS +from core.utils.logging import get_logger from tenant.config import settings -from tenant.deps import get_tenant_jwks +from tenant.deps import get_tenant_ctx, get_tenant_jwks -try: - _TRUSTED_NETWORKS = [ - ipaddress.ip_network(cidr.strip()) - for cidr in getattr(settings, "TRUSTED_NETWORKS", []) - if cidr and cidr.strip() - ] -except ValueError: - _TRUSTED_NETWORKS = [] +logger = get_logger(__name__) + +_TRUSTED_NETWORKS: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = [] +for _cidr in getattr(settings, "TRUSTED_NETWORKS", []): + if _cidr and isinstance(_cidr, str) and _cidr.strip(): + try: + _TRUSTED_NETWORKS.append(ipaddress.ip_network(_cidr.strip())) + except ValueError: + logger.warning("Invalid CIDR in TRUSTED_NETWORKS, skipping: %s", _cidr) def is_internal_request(request: Request) -> bool: @@ -30,38 +32,51 @@ def is_internal_request(request: Request) -> bool: return any(ip_obj in net for net in _TRUSTED_NETWORKS) -def build_oauth_auth_server(uid: str, request: Request) -> dict: +async def build_oauth_auth_server(uid: str, request: Request) -> dict: """Build OIDC discovery for a tenant.""" + # Verify tenant exists (triggers fetch/cache from Admin API) + await get_tenant_ctx(uid, "db") + base_url = settings.ISSUER_BASE_URL + f"/tenants/{uid}" well_known_base_url = settings.ISSUER_BASE_URL + "/.well-known" doc = { "issuer": base_url, "token_endpoint": f"{base_url}/token", - "token_endpoint_auth_methods_supported": [ - "client_secret_basic", - "client_secret_jwt", - "private_key_jwt", - ], + "response_types_supported": [], + "token_endpoint_auth_methods_supported": ["none"], + "token_endpoint_auth_signing_alg_values_supported": list(SUPPORTED_SIGNING_ALGS), "grant_types_supported": [ OAuth2GrantType.PRE_AUTH_CODE, OAuth2GrantType.REFRESH_TOKEN, ], "authorization_details_types_supported": ["openid_credential"], + "pre-authorized_grant_anonymous_access_supported": True, "jwks_uri": f"{well_known_base_url}/jwks.json/tenants/{uid}", } + if settings.ATTESTATION_ENABLED: + if settings.ATTESTATION_REQUIRED: + doc["token_endpoint_auth_methods_supported"] = ["attest_jwt_client_auth"] + doc["pre-authorized_grant_anonymous_access_supported"] = False + else: + doc["token_endpoint_auth_methods_supported"].append("attest_jwt_client_auth") + doc["client_attestation_signing_alg_values_supported"] = list( + SUPPORTED_SIGNING_ALGS + ) + doc["client_attestation_pop_signing_alg_values_supported"] = list( + SUPPORTED_SIGNING_ALGS + ) + if is_internal_request(request): doc["introspection_endpoint"] = f"{base_url}/introspect" doc["introspection_endpoint_auth_methods_supported"] = [ "client_secret_basic", - "client_secret_jwt", "private_key_jwt", ] - doc["introspection_endpoint_auth_signing_alg_values_supported"] = [ - "ES256", - "HS256", - ] + doc["introspection_endpoint_auth_signing_alg_values_supported"] = list( + SUPPORTED_SIGNING_ALGS + ) return doc diff --git a/oid4vc/auth_server/tests/admin/services/test_admin_signing_service.py b/oid4vc/auth_server/tests/admin/services/test_admin_signing_service.py index 6afed88c0..59efe7c44 100644 --- a/oid4vc/auth_server/tests/admin/services/test_admin_signing_service.py +++ b/oid4vc/auth_server/tests/admin/services/test_admin_signing_service.py @@ -49,12 +49,14 @@ async def test_sign_tenant_jwt_success(monkeypatch): signing_service, "datetime", SimpleNamespace(now=lambda tz=None: now) ) monkeypatch.setattr(signing_service, "decrypt_private_pem", lambda pem: "private-key") - monkeypatch.setattr(signing_service.JsonWebKey, "import_key", lambda pem: "jwk") + monkeypatch.setattr(signing_service.jwk, "import_key", lambda pem, kty=None: "jwk") class FakeJwt: @staticmethod def encode(header, claims, jwk): - return "token".encode() + return "token" + + JWTClaimsRegistry = None monkeypatch.setattr(signing_service, "jwt", FakeJwt) diff --git a/oid4vc/auth_server/tests/admin/services/test_internal_service.py b/oid4vc/auth_server/tests/admin/services/test_internal_service.py index 4a3449c4f..75fe8dfb1 100644 --- a/oid4vc/auth_server/tests/admin/services/test_internal_service.py +++ b/oid4vc/auth_server/tests/admin/services/test_internal_service.py @@ -4,6 +4,7 @@ import pytest from fastapi import HTTPException +from joserfc import jwk as jose_jwk from sqlalchemy.ext.asyncio import AsyncSession from admin.services import internal_service @@ -122,7 +123,7 @@ def now(cls, tz=None): def _import_key(jwk): return SimpleNamespace(as_dict=lambda **kwargs: {**jwk, **kwargs}) - monkeypatch.setattr(internal_service.JsonWebKey, "import_key", _import_key) + monkeypatch.setattr(internal_service.jwk, "import_key", _import_key) session = cast(AsyncSession, DummySession(DummyListResult(rows))) @@ -139,3 +140,42 @@ async def test_get_tenant_jwks_empty_when_no_rows(monkeypatch): result = await internal_service.get_tenant_jwks(session, "tenant-1") assert result == {"keys": []} + + +@pytest.mark.asyncio +async def test_lookup_revalidates_stale_provider_from_db(monkeypatch): + """A stale cache entry is refreshed from the DB before use.""" + key = jose_jwk.ECKey.generate_key("P-256") + jwks = {"keys": [key.as_dict(private=False, kid="k1")]} + row = SimpleNamespace(iss="https://wp.example", jwks=jwks, jwks_uri=None, active=True) + + internal_service._provider_jwks_cache.invalidate(row.iss) + session = cast(AsyncSession, DummySession(DummyScalarResult(row))) + + result = await internal_service.lookup_wallet_provider(session, row.iss) + + assert result is not None + assert len(result["keys"]) == 1 + + +@pytest.mark.asyncio +async def test_lookup_drops_deactivated_provider_on_revalidation(monkeypatch): + """A provider deactivated in another worker stops being trusted once stale.""" + key = jose_jwk.ECKey.generate_key("P-256") + jwks = {"keys": [key.as_dict(private=False, kid="k1")]} + iss = "https://revoked.example" + + # Seed the cache as if this worker had loaded it at startup. + internal_service._provider_jwks_cache.put(iss, jwks, jwks_uri=None) + assert internal_service._provider_jwks_cache.get_keyset(iss) is not None + + # Force staleness; the DB now reports the provider as inactive. + monkeypatch.setattr( + internal_service._provider_jwks_cache, "is_stale", lambda _key: True + ) + inactive = SimpleNamespace(iss=iss, jwks=jwks, jwks_uri=None, active=False) + session = cast(AsyncSession, DummySession(DummyScalarResult(inactive))) + + result = await internal_service.lookup_wallet_provider(session, iss) + + assert result is None diff --git a/oid4vc/auth_server/tests/core/security/test_jwks_cache.py b/oid4vc/auth_server/tests/core/security/test_jwks_cache.py new file mode 100644 index 000000000..0027bfdd3 --- /dev/null +++ b/oid4vc/auth_server/tests/core/security/test_jwks_cache.py @@ -0,0 +1,190 @@ +from unittest.mock import AsyncMock, patch + +import httpx +from joserfc.jwk import ECKey, KeySet + +from core.security.jwks_cache import JWKSCache + +# Generate real EC keys so KeySet.import_key_set works +_raw_k1 = ECKey.generate_key("P-256").as_dict(private=False, kid="k1") +_raw_k2 = ECKey.generate_key("P-256").as_dict(private=False, kid="k2") + +JWKS_A = {"keys": [_raw_k1]} +JWKS_B = {"keys": [_raw_k1, _raw_k2]} +KS_A = KeySet.import_key_set(JWKS_A) +KS_B = KeySet.import_key_set(JWKS_B) + + +# ── URI mode (get_jwks) ────────────────────────────────── + + +class TestJWKSCacheGetJwks: + async def test_returns_fetched_jwks(self): + cache = JWKSCache(ttl=300) + with patch.object(cache, "_fetch", new=AsyncMock(return_value=KS_A)): + result = await cache.get_jwks("https://example.com/jwks") + assert len(result.keys) == 1 + assert result.keys[0].kid == "k1" + + async def test_returns_cached_within_ttl(self): + cache = JWKSCache(ttl=300) + fetch = AsyncMock(return_value=KS_A) + with patch.object(cache, "_fetch", new=fetch): + await cache.get_jwks("https://example.com/jwks") + result = await cache.get_jwks("https://example.com/jwks") + assert len(result.keys) == 1 + assert fetch.call_count == 1 + + async def test_refreshes_after_ttl_expires(self): + cache = JWKSCache(ttl=10) + fetch = AsyncMock(return_value=KS_A) + with patch.object(cache, "_fetch", new=fetch): + await cache.get_jwks("https://example.com/jwks") + ts, ks, uri = cache._cache["https://example.com/jwks"] + cache._cache["https://example.com/jwks"] = (ts - 20, ks, uri) + await cache.get_jwks("https://example.com/jwks") + assert fetch.call_count == 2 + + async def test_refreshes_on_kid_miss(self): + cache = JWKSCache(ttl=300) + fetch = AsyncMock(side_effect=[KS_A, KS_B]) + with patch.object(cache, "_fetch", new=fetch): + await cache.get_jwks("https://example.com/jwks") + result = await cache.get_jwks( + "https://example.com/jwks", + kid="k2", + ) + assert len(result.keys) == 2 + assert fetch.call_count == 2 + + async def test_no_refresh_when_kid_found(self): + cache = JWKSCache(ttl=300) + fetch = AsyncMock(return_value=KS_A) + with patch.object(cache, "_fetch", new=fetch): + await cache.get_jwks("https://example.com/jwks") + result = await cache.get_jwks( + "https://example.com/jwks", + kid="k1", + ) + assert len(result.keys) == 1 + assert fetch.call_count == 1 + + async def test_returns_stale_cache_on_fetch_failure(self): + cache = JWKSCache(ttl=10) + with patch.object(cache, "_fetch", new=AsyncMock(return_value=KS_A)): + await cache.get_jwks("https://example.com/jwks") + ts, ks, uri = cache._cache["https://example.com/jwks"] + cache._cache["https://example.com/jwks"] = (ts - 20, ks, uri) + with patch.object( + cache, + "_fetch", + new=AsyncMock(side_effect=httpx.HTTPError("timeout")), + ): + result = await cache.get_jwks("https://example.com/jwks") + assert len(result.keys) == 1 + + async def test_returns_none_on_fetch_failure_no_stale(self): + cache = JWKSCache(ttl=300) + with patch.object( + cache, + "_fetch", + new=AsyncMock(side_effect=httpx.HTTPError("fail")), + ): + assert await cache.get_jwks("https://example.com/jwks") is None + + async def test_separate_uris_cached_independently(self): + cache = JWKSCache(ttl=300) + ks_other = KeySet.import_key_set({"keys": [_raw_k2]}) + fetch = AsyncMock(side_effect=[KS_A, ks_other]) + with patch.object(cache, "_fetch", new=fetch): + r1 = await cache.get_jwks("https://a.example.com/jwks") + r2 = await cache.get_jwks("https://b.example.com/jwks") + assert r1.keys[0].kid == "k1" + assert r2.keys[0].kid == "k2" + assert fetch.call_count == 2 + + +class TestJWKSCacheInvalidate: + async def test_invalidate_forces_refetch(self): + cache = JWKSCache(ttl=300) + fetch = AsyncMock(return_value=KS_A) + with patch.object(cache, "_fetch", new=fetch): + await cache.get_jwks("https://example.com/jwks") + cache.invalidate("https://example.com/jwks") + await cache.get_jwks("https://example.com/jwks") + assert fetch.call_count == 2 + + async def test_invalidate_nonexistent_is_noop(self): + cache = JWKSCache(ttl=300) + cache.invalidate("https://nonexistent.example.com/jwks") + + +# ── Put mode (put + get_key) ───────────────────────────── + + +class TestJWKSCachePut: + def test_put_stores_entry(self): + cache = JWKSCache(ttl=300) + cache.put("iss1", JWKS_A) + assert "iss1" in cache._cache + + def test_put_with_jwks_uri(self): + cache = JWKSCache(ttl=300) + cache.put("iss1", JWKS_A, jwks_uri="https://p.example/jwks") + _, _, uri = cache._cache["iss1"] + assert uri == "https://p.example/jwks" + + def test_put_overwrites_existing(self): + cache = JWKSCache(ttl=300) + cache.put("iss1", JWKS_A) + cache.put("iss1", JWKS_B) + _, ks, _ = cache._cache["iss1"] + assert len(ks.keys) == 2 + + +class TestJWKSCacheGetKey: + async def test_returns_key_from_put_entry(self): + cache = JWKSCache(ttl=300) + cache.put("iss1", JWKS_A) + result = await cache.get_key("iss1", "k1") + assert result is not None + assert result.kid == "k1" + + async def test_returns_none_for_unknown_iss(self): + cache = JWKSCache(ttl=300) + assert await cache.get_key("unknown", "k1") is None + + async def test_inline_kid_miss_returns_none(self): + cache = JWKSCache(ttl=300) + cache.put("iss1", JWKS_A) + assert await cache.get_key("iss1", "k999") is None + + async def test_jwks_uri_refreshes_on_kid_miss(self): + cache = JWKSCache(ttl=300) + cache.put("iss1", JWKS_A, jwks_uri="https://p.example/jwks") + with patch.object(cache, "_fetch", new=AsyncMock(return_value=KS_B)): + result = await cache.get_key("iss1", "k2") + assert result is not None + assert result.kid == "k2" + + async def test_jwks_uri_refresh_still_missing(self): + cache = JWKSCache(ttl=300) + cache.put("iss1", JWKS_A, jwks_uri="https://p.example/jwks") + with patch.object(cache, "_fetch", new=AsyncMock(return_value=KS_A)): + assert await cache.get_key("iss1", "k999") is None + + async def test_jwks_uri_refresh_failure(self): + cache = JWKSCache(ttl=300) + cache.put("iss1", JWKS_A, jwks_uri="https://p.example/jwks") + with patch.object( + cache, + "_fetch", + new=AsyncMock(side_effect=httpx.HTTPError("fail")), + ): + assert await cache.get_key("iss1", "k999") is None + + async def test_invalidate_then_get_key(self): + cache = JWKSCache(ttl=300) + cache.put("iss1", JWKS_A) + cache.invalidate("iss1") + assert await cache.get_key("iss1", "k1") is None diff --git a/oid4vc/auth_server/tests/tenant/oauth/test_grants.py b/oid4vc/auth_server/tests/tenant/oauth/test_grants.py index 536957b4f..7a1f45314 100644 --- a/oid4vc/auth_server/tests/tenant/oauth/test_grants.py +++ b/oid4vc/auth_server/tests/tenant/oauth/test_grants.py @@ -1,10 +1,13 @@ from types import SimpleNamespace from typing import Any, cast +from unittest.mock import AsyncMock import pytest from authlib.oauth2.rfc6749 import AuthorizationServer, OAuth2Request +from fastapi import HTTPException from tenant.oauth.grants import PreAuthorizedCodeGrant, RotatingRefreshTokenGrant +from tenant.services.attestation_service import AttestationLookupError class DummyServer(AuthorizationServer): @@ -16,8 +19,13 @@ async def save_token(self, token, request): # type: ignore[override] self.saved = (token, request) -def make_request(data: dict, *, url: str = "https://example.org/token") -> OAuth2Request: - req = OAuth2Request(method="POST", uri=url) +def make_request( + data: dict, + *, + url: str = "https://example.org/token", + headers: dict[str, str] | None = None, +) -> OAuth2Request: + req = OAuth2Request(method="POST", uri=url, headers=headers) cast(Any, req).payload = SimpleNamespace(data=data, grant_type=data.get("grant_type")) return req @@ -90,3 +98,84 @@ async def test_refresh_grant_create_token_response(monkeypatch): assert extra_ctx.token_ctx.get("refresh_token") == "rt" assert extra_ctx.token_ctx.get("realm") == "tenant-2" assert server.saved is not None and server.saved[0] == {} + + +@pytest.mark.asyncio +async def test_disabled_security_features_skip_validation(monkeypatch): + server = DummyServer() + request = make_request({"pre-authorized_code": "abc"}) + attestation_validator = AsyncMock() + monkeypatch.setattr("tenant.oauth.grants.settings.ATTESTATION_ENABLED", False) + monkeypatch.setattr( + "tenant.oauth.grants.validate_client_attestation", attestation_validator + ) + + grant = PreAuthorizedCodeGrant(request, server) + await grant.validate_token_request() + + attestation_validator.assert_not_awaited() + assert grant._attestation_meta is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("enabled", "required", "expected_required"), + [ + (False, False, None), + (True, False, False), + (True, True, True), + ], +) +async def test_attestation_policy_matrix( + monkeypatch, enabled, required, expected_required +): + server = DummyServer() + request = make_request( + {"pre_authorized_code": "abc"}, + headers={ + "OAuth-Client-Attestation": "attestation", + "OAuth-Client-Attestation-PoP": "proof", + }, + ) + extra_ctx = SimpleNamespace(uid="tenant-1", db=object()) + validator = AsyncMock(return_value={"cnf_jkt": "attestation-jkt"}) + monkeypatch.setattr("tenant.oauth.grants.settings.ATTESTATION_ENABLED", enabled) + monkeypatch.setattr("tenant.oauth.grants.settings.ATTESTATION_REQUIRED", required) + monkeypatch.setattr("tenant.oauth.grants.get_context", lambda _req: extra_ctx) + monkeypatch.setattr("tenant.oauth.grants.validate_client_attestation", validator) + + grant = PreAuthorizedCodeGrant(request, server) + await grant.validate_token_request() + + if expected_required is None: + validator.assert_not_awaited() + assert grant._attestation_meta is None + else: + assert validator.await_args.kwargs["attestation_required"] is expected_required + assert grant._attestation_meta == {"cnf_jkt": "attestation-jkt"} + + +@pytest.mark.asyncio +async def test_provider_lookup_outage_is_not_a_client_error(monkeypatch): + """A provider lookup outage returns 503, not a 4xx client error.""" + server = DummyServer() + request = make_request( + {"pre_authorized_code": "abc"}, + headers={ + "OAuth-Client-Attestation": "attestation", + "OAuth-Client-Attestation-PoP": "proof", + }, + ) + extra_ctx = SimpleNamespace(uid="tenant-1", db=object()) + monkeypatch.setattr("tenant.oauth.grants.get_context", lambda _req: extra_ctx) + monkeypatch.setattr("tenant.oauth.grants.settings.ATTESTATION_ENABLED", True) + monkeypatch.setattr( + "tenant.oauth.grants.validate_client_attestation", + AsyncMock(side_effect=AttestationLookupError("admin unreachable")), + ) + + grant = PreAuthorizedCodeGrant(request, server) + with pytest.raises(HTTPException) as exc_info: + await grant.validate_token_request() + + assert exc_info.value.status_code == 503 diff --git a/oid4vc/auth_server/tests/tenant/services/test_attestation_service.py b/oid4vc/auth_server/tests/tenant/services/test_attestation_service.py new file mode 100644 index 000000000..fa155beb3 --- /dev/null +++ b/oid4vc/auth_server/tests/tenant/services/test_attestation_service.py @@ -0,0 +1,762 @@ +"""Tests for attestation service (kid + allow list design).""" + +import base64 +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest +from joserfc import jwk as jose_jwk +from joserfc import jwt as jose_jwt + +from tenant.services import attestation_service + + +def _b64(data: dict) -> str: + encoded = json.dumps(data, separators=(",", ":")).encode("utf-8") + return base64.urlsafe_b64encode(encoded).decode("ascii").rstrip("=") + + +# --- Helpers to generate real signed JWTs --- + + +def _generate_provider_key(): + """Generate a wallet provider EC key pair.""" + key = jose_jwk.ECKey.generate_key("P-256") + return key, key.as_dict(private=False) + + +def _generate_wallet_key(): + """Generate a wallet instance EC key pair.""" + key = jose_jwk.ECKey.generate_key("P-256") + return key, key.as_dict(private=False) + + +def _sign_attestation(provider_key, kid, payload): + """Sign an attestation JWT with the provider's key.""" + header = {"alg": "ES256", "typ": "oauth-client-attestation+jwt", "kid": kid} + return jose_jwt.encode(header, payload, provider_key) + + +def _sign_pop(wallet_key, payload): + """Sign an attestation PoP JWT with the wallet instance's key.""" + header = {"alg": "ES256", "typ": "oauth-client-attestation-pop+jwt"} + return jose_jwt.encode(header, payload, wallet_key) + + +def _make_attestation_and_pop( + provider_key, + kid, + wallet_key, + wallet_public, + *, + iss="https://wallet-provider.example", + sub="Ontario Wallet", + att_iat=1_699_999_900, + att_exp=1_700_000_300, + pop_iss=None, + pop_aud="https://as.example.com", + pop_jti="unique-jti-1", + pop_iat=1_700_000_000, +): + """Build both attestation + PoP JWTs with sensible defaults.""" + attestation = _sign_attestation( + provider_key, + kid, + { + "iss": iss, + "sub": sub, + "cnf": {"jwk": wallet_public}, + "iat": att_iat, + "exp": att_exp, + }, + ) + pop = _sign_pop( + wallet_key, + { + "iss": pop_iss if pop_iss is not None else sub, + "aud": pop_aud, + "jti": pop_jti, + "iat": pop_iat, + }, + ) + return attestation, pop + + +@pytest.fixture +def default_settings(monkeypatch): + monkeypatch.setattr( + attestation_service.settings, + "ATTESTATION_CLOCK_SKEW_SECONDS", + 60, + raising=False, + ) + monkeypatch.setattr( + attestation_service.settings, + "INTERNAL_BASE_URL", + "http://admin:9000/internal", + raising=False, + ) + monkeypatch.setattr( + attestation_service.settings, + "INTERNAL_AUTH_TOKEN", + "test-token", + raising=False, + ) + # Swap DB-backed JTI cache for async-compatible in-memory version in tests + + _seen: set[str] = set() + + async def _async_check_and_store(jti, db=None, now=None, expires_at=None): + if not jti: + return False + if jti in _seen: + return False + _seen.add(jti) + return True + + test_cache = MagicMock() + test_cache.check_and_store = _async_check_and_store + monkeypatch.setattr(attestation_service, "_attest_pop_jti_cache", test_cache) + + +@pytest.fixture +def provider_keys(): + """Fixture: generate provider key pair.""" + key, public = _generate_provider_key() + return key, public + + +async def test_optional_missing_returns_none(default_settings): + result = await attestation_service.validate_client_attestation( + client_attestation=None, + attestation_required=False, + expected_audience="https://as.example.com", + ) + assert result is None + + +async def test_required_missing_raises(default_settings): + with pytest.raises(attestation_service.InvalidAttestationError) as exc_info: + await attestation_service.validate_client_attestation( + client_attestation=None, + attestation_required=True, + expected_audience="https://as.example.com", + ) + assert exc_info.value.error == "invalid_client_attestation" + + +async def test_invalid_kid_type_raises(default_settings): + """Attestation JWT with non-string kid in header should fail.""" + # Build a JWT with kid as integer (invalid type) + header = _b64({"alg": "ES256", "typ": "oauth-client-attestation+jwt", "kid": 123}) + payload = _b64( + {"iss": "https://provider.example", "sub": "wallet", "iat": 1, "exp": 2} + ) + token = f"{header}.{payload}.fakesig" + + with pytest.raises(attestation_service.InvalidAttestationError) as exc_info: + await attestation_service.validate_client_attestation( + client_attestation=token, + attestation_required=True, + expected_audience="https://as.example.com", + ) + assert "invalid_kid" in exc_info.value.description + + +async def test_untrusted_provider_raises(default_settings, provider_keys, monkeypatch): + """Attestation from unknown provider (not in allow list) should fail.""" + monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000) + provider_key, _ = provider_keys + + token = _sign_attestation( + provider_key, + "key-1", + { + "iss": "https://unknown-provider.example", + "sub": "wallet-app", + "iat": 1_699_999_900, + "exp": 1_700_000_300, + }, + ) + + # Mock the lookup to return not found + mock_lookup = AsyncMock(return_value=None) + monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup) + + with pytest.raises(attestation_service.InvalidAttestationError) as exc_info: + await attestation_service.validate_client_attestation( + client_attestation=token, + attestation_required=True, + expected_audience="https://as.example.com", + ) + assert "untrusted_provider" in exc_info.value.description + mock_lookup.assert_awaited_once_with("https://unknown-provider.example", "key-1") + + +async def test_valid_attestation_with_provider_lookup( + default_settings, provider_keys, monkeypatch +): + """Valid attestation + PoP with kid lookup from allow list succeeds.""" + monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000) + provider_key, provider_public = provider_keys + wallet_key, wallet_public = _generate_wallet_key() + + attestation, pop = _make_attestation_and_pop( + provider_key, + "key-1", + wallet_key, + wallet_public, + ) + + # Mock the lookup to return the provider's public key + mock_lookup = AsyncMock(return_value=provider_public) + monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup) + + result = await attestation_service.validate_client_attestation( + client_attestation=attestation, + client_attestation_pop=pop, + attestation_required=True, + expected_audience="https://as.example.com", + ) + + assert result is not None + assert result["verified"] is True + assert result["iss"] == "https://wallet-provider.example" + assert result["kid"] == "key-1" + assert result["sub"] == "Ontario Wallet" + assert result["pop_jti"] == "unique-jti-1" + assert result["cnf_jkt"] is not None + mock_lookup.assert_awaited_once_with("https://wallet-provider.example", "key-1") + + +async def test_invalid_signature_raises(default_settings, provider_keys, monkeypatch): + """Attestation signed with wrong key should fail signature verification.""" + monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000) + _, provider_public = provider_keys + wallet_key, wallet_public = _generate_wallet_key() + + # Sign with a DIFFERENT key + wrong_key, _ = _generate_provider_key() + token = _sign_attestation( + wrong_key, + "key-1", + { + "iss": "https://wallet-provider.example", + "sub": "Ontario Wallet", + "cnf": {"jwk": wallet_public}, + "iat": 1_699_999_900, + "exp": 1_700_000_300, + }, + ) + + pop = _sign_pop( + wallet_key, + { + "iss": "Ontario Wallet", + "aud": "https://as.example.com", + "jti": "jti-1", + "iat": 1_700_000_000, + }, + ) + + # Return the REAL provider's public key — signature won't match + mock_lookup = AsyncMock(return_value=provider_public) + monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup) + + with pytest.raises(attestation_service.InvalidAttestationError) as exc_info: + await attestation_service.validate_client_attestation( + client_attestation=token, + client_attestation_pop=pop, + attestation_required=True, + expected_audience="https://as.example.com", + ) + assert "signature_invalid" in exc_info.value.description + + +async def test_expired_attestation_raises(default_settings, provider_keys, monkeypatch): + """Expired attestation should fail.""" + monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_001_000) + provider_key, provider_public = provider_keys + wallet_key, wallet_public = _generate_wallet_key() + + token = _sign_attestation( + provider_key, + "key-1", + { + "iss": "https://wallet-provider.example", + "sub": "Ontario Wallet", + "cnf": {"jwk": wallet_public}, + "iat": 1_699_999_900, + "exp": 1_700_000_300, # expired by now (1_700_001_000) + }, + ) + + pop = _sign_pop( + wallet_key, + { + "iss": "Ontario Wallet", + "aud": "https://as.example.com", + "jti": "jti-1", + "iat": 1_700_001_000, + }, + ) + + mock_lookup = AsyncMock(return_value=provider_public) + monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup) + + with pytest.raises(attestation_service.InvalidAttestationError) as exc_info: + await attestation_service.validate_client_attestation( + client_attestation=token, + client_attestation_pop=pop, + attestation_required=True, + expected_audience="https://as.example.com", + ) + assert "expired" in exc_info.value.description + + +# --- New tests for typ, cnf, and PoP validation --- + + +async def test_invalid_attestation_typ_raises(default_settings, monkeypatch): + """Attestation JWT with wrong typ header should fail.""" + provider_key, _ = _generate_provider_key() + # Sign with wrong typ + header = {"alg": "ES256", "typ": "jwt", "kid": "key-1"} + token = jose_jwt.encode( + header, + { + "iss": "https://provider.example", + "sub": "wallet", + "iat": 1, + "exp": 2, + }, + provider_key, + ) + + with pytest.raises(attestation_service.InvalidAttestationError) as exc_info: + await attestation_service.validate_client_attestation( + client_attestation=token, + attestation_required=True, + expected_audience="https://as.example.com", + ) + assert "invalid_attestation_typ" in exc_info.value.description + + +async def test_missing_cnf_jwk_raises(default_settings, provider_keys, monkeypatch): + """Attestation without cnf.jwk should fail (now required).""" + monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000) + provider_key, provider_public = provider_keys + + # Attestation without cnf claim + token = _sign_attestation( + provider_key, + "key-1", + { + "iss": "https://wallet-provider.example", + "sub": "Ontario Wallet", + "iat": 1_699_999_900, + "exp": 1_700_000_300, + }, + ) + + mock_lookup = AsyncMock(return_value=provider_public) + monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup) + + with pytest.raises(attestation_service.InvalidAttestationError) as exc_info: + await attestation_service.validate_client_attestation( + client_attestation=token, + client_attestation_pop=None, + attestation_required=True, + expected_audience="https://as.example.com", + ) + assert "missing_cnf_jwk" in exc_info.value.description + + +async def test_missing_pop_raises(default_settings, provider_keys, monkeypatch): + """Attestation present but PoP missing should fail.""" + monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000) + provider_key, provider_public = provider_keys + _, wallet_public = _generate_wallet_key() + + token = _sign_attestation( + provider_key, + "key-1", + { + "iss": "https://wallet-provider.example", + "sub": "Ontario Wallet", + "cnf": {"jwk": wallet_public}, + "iat": 1_699_999_900, + "exp": 1_700_000_300, + }, + ) + + mock_lookup = AsyncMock(return_value=provider_public) + monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup) + + with pytest.raises(attestation_service.InvalidAttestationError) as exc_info: + await attestation_service.validate_client_attestation( + client_attestation=token, + client_attestation_pop=None, + attestation_required=True, + expected_audience="https://as.example.com", + ) + assert "missing_client_attestation_pop" in exc_info.value.description + + +async def test_pop_wrong_signature_raises(default_settings, provider_keys, monkeypatch): + """PoP signed with wrong key should fail.""" + monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000) + provider_key, provider_public = provider_keys + _, wallet_public = _generate_wallet_key() + + token = _sign_attestation( + provider_key, + "key-1", + { + "iss": "https://wallet-provider.example", + "sub": "Ontario Wallet", + "cnf": {"jwk": wallet_public}, + "iat": 1_699_999_900, + "exp": 1_700_000_300, + }, + ) + + # Sign PoP with a DIFFERENT key (not the wallet key) + wrong_key, _ = _generate_wallet_key() + pop = _sign_pop( + wrong_key, + { + "iss": "Ontario Wallet", + "aud": "https://as.example.com", + "jti": "jti-1", + "iat": 1_700_000_000, + }, + ) + + mock_lookup = AsyncMock(return_value=provider_public) + monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup) + + with pytest.raises(attestation_service.InvalidAttestationError) as exc_info: + await attestation_service.validate_client_attestation( + client_attestation=token, + client_attestation_pop=pop, + attestation_required=True, + expected_audience="https://as.example.com", + ) + assert "pop_signature_invalid" in exc_info.value.description + + +async def test_pop_missing_aud_raises(default_settings, provider_keys, monkeypatch): + """PoP without aud claim should fail.""" + monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000) + provider_key, provider_public = provider_keys + wallet_key, wallet_public = _generate_wallet_key() + + attestation = _sign_attestation( + provider_key, + "key-1", + { + "iss": "https://wallet-provider.example", + "sub": "Ontario Wallet", + "cnf": {"jwk": wallet_public}, + "iat": 1_699_999_900, + "exp": 1_700_000_300, + }, + ) + pop = _sign_pop( + wallet_key, + { + "iss": "Ontario Wallet", + "jti": "jti-1", + "iat": 1_700_000_000, + }, + ) + + mock_lookup = AsyncMock(return_value=provider_public) + monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup) + + with pytest.raises(attestation_service.InvalidAttestationError) as exc_info: + await attestation_service.validate_client_attestation( + client_attestation=attestation, + client_attestation_pop=pop, + attestation_required=True, + expected_audience="https://as.example.com", + ) + assert "missing_attestation_pop_aud" in exc_info.value.description + + +async def test_pop_aud_mismatch_raises(default_settings, provider_keys, monkeypatch): + """PoP aud that doesn't match expected_audience should fail.""" + monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000) + provider_key, provider_public = provider_keys + wallet_key, wallet_public = _generate_wallet_key() + + attestation, pop = _make_attestation_and_pop( + provider_key, + "key-1", + wallet_key, + wallet_public, + pop_aud="https://other-as.example.com", + ) + + mock_lookup = AsyncMock(return_value=provider_public) + monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup) + + with pytest.raises(attestation_service.InvalidAttestationError) as exc_info: + await attestation_service.validate_client_attestation( + client_attestation=attestation, + client_attestation_pop=pop, + attestation_required=True, + expected_audience="https://as.example.com", + ) + assert "attestation_pop_aud_mismatch" in exc_info.value.description + + +async def test_pop_aud_array_accepted(default_settings, provider_keys, monkeypatch): + """PoP aud as array containing expected_audience should succeed.""" + monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000) + provider_key, provider_public = provider_keys + wallet_key, wallet_public = _generate_wallet_key() + + attestation = _sign_attestation( + provider_key, + "key-1", + { + "iss": "https://wallet-provider.example", + "sub": "Ontario Wallet", + "cnf": {"jwk": wallet_public}, + "iat": 1_699_999_900, + "exp": 1_700_000_300, + }, + ) + pop = _sign_pop( + wallet_key, + { + "iss": "Ontario Wallet", + "aud": ["https://as.example.com", "https://other.example.com"], + "jti": "jti-1", + "iat": 1_700_000_000, + }, + ) + + mock_lookup = AsyncMock(return_value=provider_public) + monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup) + + result = await attestation_service.validate_client_attestation( + client_attestation=attestation, + client_attestation_pop=pop, + attestation_required=True, + expected_audience="https://as.example.com", + ) + assert result is not None + assert result["verified"] is True + + +async def test_pop_missing_jti_raises(default_settings, provider_keys, monkeypatch): + """PoP without jti claim should fail.""" + monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000) + provider_key, provider_public = provider_keys + wallet_key, wallet_public = _generate_wallet_key() + + attestation = _sign_attestation( + provider_key, + "key-1", + { + "iss": "https://wallet-provider.example", + "sub": "Ontario Wallet", + "cnf": {"jwk": wallet_public}, + "iat": 1_699_999_900, + "exp": 1_700_000_300, + }, + ) + pop = _sign_pop( + wallet_key, + { + "iss": "Ontario Wallet", + "aud": "https://as.example.com", + "iat": 1_700_000_000, + }, + ) + + mock_lookup = AsyncMock(return_value=provider_public) + monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup) + + with pytest.raises(attestation_service.InvalidAttestationError) as exc_info: + await attestation_service.validate_client_attestation( + client_attestation=attestation, + client_attestation_pop=pop, + attestation_required=True, + expected_audience="https://as.example.com", + ) + assert "missing_attestation_pop_jti" in exc_info.value.description + + +async def test_pop_invalid_typ_raises(default_settings, provider_keys, monkeypatch): + """PoP with wrong typ header should fail.""" + monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000) + provider_key, provider_public = provider_keys + wallet_key, wallet_public = _generate_wallet_key() + + attestation = _sign_attestation( + provider_key, + "key-1", + { + "iss": "https://wallet-provider.example", + "sub": "Ontario Wallet", + "cnf": {"jwk": wallet_public}, + "iat": 1_699_999_900, + "exp": 1_700_000_300, + }, + ) + # Sign PoP with wrong typ + pop_header = {"alg": "ES256", "typ": "jwt"} + pop = jose_jwt.encode( + pop_header, + { + "iss": "Ontario Wallet", + "aud": "https://as.example.com", + "jti": "jti-1", + "iat": 1_700_000_000, + }, + wallet_key, + ) + + mock_lookup = AsyncMock(return_value=provider_public) + monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup) + + with pytest.raises(attestation_service.InvalidAttestationError) as exc_info: + await attestation_service.validate_client_attestation( + client_attestation=attestation, + client_attestation_pop=pop, + attestation_required=True, + expected_audience="https://as.example.com", + ) + assert "invalid_attestation_pop_typ" in exc_info.value.description + + +async def test_pop_jti_replay_raises(default_settings, provider_keys, monkeypatch): + """Replayed PoP jti should be rejected.""" + monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000) + provider_key, provider_public = provider_keys + wallet_key, wallet_public = _generate_wallet_key() + + attestation, pop = _make_attestation_and_pop( + provider_key, + "key-1", + wallet_key, + wallet_public, + pop_jti="replay-jti-1", + ) + + mock_lookup = AsyncMock(return_value=provider_public) + monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup) + + # First call succeeds + result = await attestation_service.validate_client_attestation( + client_attestation=attestation, + client_attestation_pop=pop, + attestation_required=True, + expected_audience="https://as.example.com", + ) + assert result is not None + assert result["verified"] is True + + # Second call with same jti is rejected + with pytest.raises(attestation_service.InvalidAttestationError) as exc_info: + await attestation_service.validate_client_attestation( + client_attestation=attestation, + client_attestation_pop=pop, + attestation_required=True, + expected_audience="https://as.example.com", + ) + assert "attestation_pop_jti_replay" in exc_info.value.description + + +async def test_attestation_without_kid_verifies_by_trial( + default_settings, provider_keys, monkeypatch +): + """draft-07 5.1 makes `kid` optional; unlabelled keys are tried in turn.""" + monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000) + provider_key, provider_public = provider_keys + wallet_key, wallet_public = _generate_wallet_key() + + # Attestation header carries no kid. + attestation = jose_jwt.encode( + {"alg": "ES256", "typ": "oauth-client-attestation+jwt"}, + { + "iss": "https://wallet-provider.example", + "sub": "Ontario Wallet", + "cnf": {"jwk": wallet_public}, + "iat": 1_699_999_900, + "exp": 1_700_000_300, + }, + provider_key, + ) + pop = _sign_pop( + wallet_key, + { + "iss": "Ontario Wallet", + "aud": "https://as.example.com", + "jti": "nokid-jti-1", + "iat": 1_700_000_000, + }, + ) + + # No kid -> admin returns the full key set for trial verification. + decoy, _ = _generate_provider_key() + mock_lookup = AsyncMock(return_value=[decoy.as_dict(private=False), provider_public]) + monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup) + + result = await attestation_service.validate_client_attestation( + client_attestation=attestation, + client_attestation_pop=pop, + attestation_required=True, + expected_audience="https://as.example.com", + ) + + assert result is not None and result["verified"] is True + assert result["kid"] is None + mock_lookup.assert_awaited_once_with("https://wallet-provider.example", None) + + +async def test_pop_without_attestation_raises(default_settings): + """draft-07 9 step 1: both headers are required together.""" + with pytest.raises(attestation_service.InvalidAttestationError) as exc_info: + await attestation_service.validate_client_attestation( + client_attestation=None, + client_attestation_pop="some-pop-jwt", + attestation_required=False, + expected_audience="https://as.example.com", + ) + assert "missing_client_attestation" in exc_info.value.description + + +async def test_provider_lookup_failure_is_not_a_client_error( + default_settings, provider_keys, monkeypatch +): + """A lookup outage must not be reported as an untrusted provider.""" + monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000) + provider_key, _ = provider_keys + + token = _sign_attestation( + provider_key, + "key-1", + { + "iss": "https://wallet-provider.example", + "sub": "wallet-app", + "iat": 1_699_999_900, + "exp": 1_700_000_300, + }, + ) + + mock_lookup = AsyncMock( + side_effect=attestation_service.AttestationLookupError("admin down") + ) + monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup) + + with pytest.raises(attestation_service.AttestationLookupError): + await attestation_service.validate_client_attestation( + client_attestation=token, + attestation_required=True, + expected_audience="https://as.example.com", + ) diff --git a/oid4vc/auth_server/tests/tenant/services/test_introspect_service.py b/oid4vc/auth_server/tests/tenant/services/test_introspect_service.py index 899e28137..733b5a654 100644 --- a/oid4vc/auth_server/tests/tenant/services/test_introspect_service.py +++ b/oid4vc/auth_server/tests/tenant/services/test_introspect_service.py @@ -34,7 +34,7 @@ async def test_introspect_returns_active_payload(monkeypatch): "realm": "tenant-1", "iss": "https://issuer", "authorization_details": [{"type": "openid_credential"}], - "amr": ["dpop"], + "amr": ["att-pop"], "attestation": {"type": "device"}, "scope": "openid", "c_nonce": "nonce", @@ -54,7 +54,7 @@ async def test_introspect_returns_active_payload(monkeypatch): assert resp["active"] is True assert resp["sub"] == "subject-1" - assert resp["token_type"] == "DPoP" + assert resp["token_type"] == "Bearer" assert resp["realm"] == "tenant-1" assert resp["iss"] == "https://issuer" assert resp["authorization_details"] == [{"type": "openid_credential"}] diff --git a/oid4vc/auth_server/tests/tenant/services/test_signing_service.py b/oid4vc/auth_server/tests/tenant/services/test_signing_service.py index 7c8eb4359..beab56af2 100644 --- a/oid4vc/auth_server/tests/tenant/services/test_signing_service.py +++ b/oid4vc/auth_server/tests/tenant/services/test_signing_service.py @@ -42,7 +42,11 @@ async def post(self, url, json, headers): ) monkeypatch.setattr(signing_service.settings, "INTERNAL_BASE_URL", "https://admin") monkeypatch.setattr(signing_service.settings, "INTERNAL_AUTH_TOKEN", "token") - monkeypatch.setattr(signing_service, "current_request_id", lambda: "req-123") + monkeypatch.setattr( + signing_service, + "internal_api_headers", + lambda token: {"Authorization": f"Bearer {token}", "X-Request-ID": "req-123"}, + ) result = await signing_service.remote_sign_jwt( uid="tenant-1", claims={"sub": "abc"}, kid="kid1" @@ -81,7 +85,11 @@ async def post(self, url, *args, **kwargs): ) monkeypatch.setattr(signing_service.settings, "INTERNAL_BASE_URL", "https://admin") monkeypatch.setattr(signing_service.settings, "INTERNAL_AUTH_TOKEN", "token") - monkeypatch.setattr(signing_service, "current_request_id", lambda: None) + monkeypatch.setattr( + signing_service, + "internal_api_headers", + lambda token: {"Authorization": f"Bearer {token}"}, + ) result = await signing_service.remote_sign_jwt(uid="tenant-1", claims={"sub": "abc"}) @@ -109,7 +117,11 @@ async def post(self, url, *args, **kwargs): ) monkeypatch.setattr(signing_service.settings, "INTERNAL_BASE_URL", "https://admin") monkeypatch.setattr(signing_service.settings, "INTERNAL_AUTH_TOKEN", "token") - monkeypatch.setattr(signing_service, "current_request_id", lambda: None) + monkeypatch.setattr( + signing_service, + "internal_api_headers", + lambda token: {"Authorization": f"Bearer {token}"}, + ) with pytest.raises(signing_service.httpx.HTTPStatusError): await signing_service.remote_sign_jwt(uid="tenant-1", claims={}) diff --git a/oid4vc/auth_server/tests/tenant/services/test_token_service.py b/oid4vc/auth_server/tests/tenant/services/test_token_service.py index 5f11d3a91..20877379b 100644 --- a/oid4vc/auth_server/tests/tenant/services/test_token_service.py +++ b/oid4vc/auth_server/tests/tenant/services/test_token_service.py @@ -8,6 +8,8 @@ from tenant.services import token_service +_FAR_FUTURE = datetime(2099, 1, 1, tzinfo=timezone.utc) + class DummySession(AsyncSession): def __init__(self): @@ -26,6 +28,8 @@ async def test_issue_by_pre_auth_code_excludes_realm_from_claims(monkeypatch): pac = SimpleNamespace( id=1, tx_code=None, + used=False, + expires_at=_FAR_FUTURE, subject=SimpleNamespace(uid="sub-123"), subject_id=10, authorization_details=[{"type": "openid_credential", "format": "mso_mdoc"}], @@ -275,6 +279,8 @@ async def test_issue_by_pre_auth_code_includes_nonce_when_enabled(monkeypatch): pac = SimpleNamespace( id=5, tx_code=None, + used=False, + expires_at=_FAR_FUTURE, subject=SimpleNamespace(uid="sub-789"), subject_id=77, authorization_details=None, @@ -458,3 +464,114 @@ async def create(self, **kwargs): refresh_meta = refresh_repo_instance.created_with.get("token_metadata") assert refresh_meta is not None assert refresh_meta["realm"] == "tenant-rot" + + +def _rotation_stubs(monkeypatch, prev_access, *, now): + """Wire the repositories and helpers used by rotate_by_refresh_token.""" + access_exp = now + timedelta(minutes=10) + refresh_exp = now + timedelta(days=3) + + class StubAccessRepo: + def __init__(self, _db): + self.created_with = None + + async def create(self, **kwargs): + self.created_with = kwargs + return SimpleNamespace(id=88, token=kwargs["token"]) + + async def get_by_id(self, _access_token_id): + return prev_access + + class StubRefreshRepo: + def __init__(self, _db): + self.created_with = None + + async def consume_valid(self, token_hash, now): + return (55, prev_access.id) + + async def create(self, **kwargs): + self.created_with = kwargs + return SimpleNamespace() + + monkeypatch.setattr(token_service, "AccessTokenRepository", StubAccessRepo) + monkeypatch.setattr(token_service, "RefreshTokenRepository", StubRefreshRepo) + monkeypatch.setattr(token_service, "hash_token", lambda value: f"hash-{value}") + monkeypatch.setattr(token_service, "utcnow", lambda: now) + monkeypatch.setattr(token_service, "compute_access_exp", lambda _now: access_exp) + monkeypatch.setattr(token_service, "compute_refresh_exp", lambda _now: refresh_exp) + monkeypatch.setattr(token_service, "new_refresh_token", lambda: "refresh-new") + monkeypatch.setattr(token_service.settings, "INCLUDE_NONCE", False, raising=False) + monkeypatch.setattr( + token_service, "remote_sign_jwt", AsyncMock(return_value={"jwt": "new-signed"}) + ) + + +async def test_rotate_requires_matching_attestation_key(monkeypatch): + """draft-07 10.3: refreshing MUST present the bound client instance key.""" + now = datetime(2025, 2, 2, tzinfo=timezone.utc) + prev_access = SimpleNamespace( + id=77, + subject=SimpleNamespace(uid="sub-456"), + token_metadata={"realm": "tenant-2"}, + cnf_jkt="bound-jkt", + ) + _rotation_stubs(monkeypatch, prev_access, now=now) + + with pytest.raises(HTTPException) as exc_info: + await token_service.TokenService.rotate_by_refresh_token( + db=DummySession(), + uid="tenant-2", + refresh_token_value="existing-refresh", + realm="tenant-2", + attestation={"cnf_jkt": "different-jkt"}, + ) + assert exc_info.value.status_code == 401 + + +async def test_rotate_rejects_missing_attestation_when_bound(monkeypatch): + """A bound refresh token cannot be redeemed without any attestation.""" + now = datetime(2025, 2, 2, tzinfo=timezone.utc) + prev_access = SimpleNamespace( + id=77, + subject=SimpleNamespace(uid="sub-456"), + token_metadata={"realm": "tenant-2"}, + cnf_jkt="bound-jkt", + ) + _rotation_stubs(monkeypatch, prev_access, now=now) + + with pytest.raises(HTTPException) as exc_info: + await token_service.TokenService.rotate_by_refresh_token( + db=DummySession(), + uid="tenant-2", + refresh_token_value="existing-refresh", + realm="tenant-2", + attestation=None, + ) + assert exc_info.value.status_code == 401 + + +async def test_rotate_accepts_matching_attestation_key(monkeypatch): + """Presenting the bound key rotates successfully and stays bound.""" + now = datetime(2025, 2, 2, tzinfo=timezone.utc) + prev_access = SimpleNamespace( + id=77, + subject=SimpleNamespace(uid="sub-456"), + token_metadata={"realm": "tenant-2"}, + cnf_jkt="bound-jkt", + ) + _rotation_stubs(monkeypatch, prev_access, now=now) + + ( + access_token, + refresh_token, + _meta, + ) = await token_service.TokenService.rotate_by_refresh_token( + db=DummySession(), + uid="tenant-2", + refresh_token_value="existing-refresh", + realm="tenant-2", + attestation={"cnf_jkt": "bound-jkt"}, + ) + + assert access_token.token == "new-signed" + assert refresh_token == "refresh-new" diff --git a/oid4vc/auth_server/tests/tenant/services/test_well_known_service.py b/oid4vc/auth_server/tests/tenant/services/test_well_known_service.py new file mode 100644 index 000000000..1dc19980e --- /dev/null +++ b/oid4vc/auth_server/tests/tenant/services/test_well_known_service.py @@ -0,0 +1,67 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from core.consts import SUPPORTED_SIGNING_ALGS +from tenant.services.well_known_service import build_oauth_auth_server + + +@pytest.mark.asyncio +async def test_security_features_are_not_advertised_when_disabled(monkeypatch): + monkeypatch.setattr("tenant.services.well_known_service.get_tenant_ctx", AsyncMock()) + monkeypatch.setattr( + "tenant.services.well_known_service.settings.ATTESTATION_ENABLED", False + ) + + doc = await build_oauth_auth_server( + "tenant-1", SimpleNamespace(client=SimpleNamespace(host="203.0.113.1")) + ) + + assert doc["token_endpoint_auth_methods_supported"] == ["none"] + assert "client_attestation_signing_alg_values_supported" not in doc + assert "client_attestation_pop_signing_alg_values_supported" not in doc + + +@pytest.mark.asyncio +async def test_security_features_are_advertised_when_enabled(monkeypatch): + monkeypatch.setattr("tenant.services.well_known_service.get_tenant_ctx", AsyncMock()) + monkeypatch.setattr( + "tenant.services.well_known_service.settings.ATTESTATION_ENABLED", True + ) + monkeypatch.setattr( + "tenant.services.well_known_service.settings.ATTESTATION_REQUIRED", False + ) + + doc = await build_oauth_auth_server( + "tenant-1", SimpleNamespace(client=SimpleNamespace(host="203.0.113.1")) + ) + + assert doc["token_endpoint_auth_methods_supported"] == [ + "none", + "attest_jwt_client_auth", + ] + assert doc["client_attestation_signing_alg_values_supported"] == list( + SUPPORTED_SIGNING_ALGS + ) + assert doc["client_attestation_pop_signing_alg_values_supported"] == list( + SUPPORTED_SIGNING_ALGS + ) + + +@pytest.mark.asyncio +async def test_required_attestation_does_not_advertise_none(monkeypatch): + monkeypatch.setattr("tenant.services.well_known_service.get_tenant_ctx", AsyncMock()) + monkeypatch.setattr( + "tenant.services.well_known_service.settings.ATTESTATION_ENABLED", True + ) + monkeypatch.setattr( + "tenant.services.well_known_service.settings.ATTESTATION_REQUIRED", True + ) + + doc = await build_oauth_auth_server( + "tenant-1", SimpleNamespace(client=SimpleNamespace(host="203.0.113.1")) + ) + + assert doc["token_endpoint_auth_methods_supported"] == ["attest_jwt_client_auth"] + assert doc["pre-authorized_grant_anonymous_access_supported"] is False diff --git a/oid4vc/auth_server/tests/core/security/test_client_auth.py b/oid4vc/auth_server/tests/tenant/test_client_auth.py similarity index 68% rename from oid4vc/auth_server/tests/core/security/test_client_auth.py rename to oid4vc/auth_server/tests/tenant/test_client_auth.py index bf3742669..48c0c8284 100644 --- a/oid4vc/auth_server/tests/core/security/test_client_auth.py +++ b/oid4vc/auth_server/tests/tenant/test_client_auth.py @@ -6,10 +6,11 @@ import pytest from fastapi import HTTPException from fastapi.security import HTTPAuthorizationCredentials, HTTPBasicCredentials +from joserfc.jwk import ECKey, KeySet from starlette.requests import Request from core.consts import ClientAuthMethod -from core.security import client_auth +from tenant.security import client_auth def make_request() -> Request: @@ -48,22 +49,29 @@ def fake_client(**attrs) -> client_auth.AuthClient: return cast(client_auth.AuthClient, SimpleNamespace(**attrs)) +# Generate a real EC key for tests that need valid JWKS +_test_key = ECKey.generate_key("P-256", auto_kid=True) +_test_jwks = {"keys": [_test_key.as_dict(private=False)]} + + @pytest.mark.asyncio async def test_load_jwks_from_dict(): - client = fake_client(jwks={"keys": [1]}, jwks_uri=None) + client = fake_client(jwks=_test_jwks, jwks_uri=None) result = await client_auth._load_jwks(client) - assert result == {"keys": [1]} + assert isinstance(result, KeySet) + assert len(result.keys) == 1 @pytest.mark.asyncio async def test_load_jwks_from_json_string(): - client = fake_client(jwks=json.dumps({"keys": [2]}), jwks_uri=None) + client = fake_client(jwks=json.dumps(_test_jwks), jwks_uri=None) result = await client_auth._load_jwks(client) - assert result == {"keys": [2]} + assert isinstance(result, KeySet) + assert len(result.keys) == 1 @pytest.mark.asyncio @@ -77,54 +85,28 @@ async def test_load_jwks_invalid_string_returns_none(): @pytest.mark.asyncio async def test_load_jwks_from_uri(monkeypatch): - class FakeResponse: - def __init__(self, payload): - self._payload = payload - - def raise_for_status(self): - return None - - def json(self): - return self._payload - - class FakeAsyncClient: - def __init__(self): - self.called_with = None - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return False - - async def get(self, url): - self.called_with = url - return FakeResponse({"keys": [3]}) - - fake_instance = FakeAsyncClient() - monkeypatch.setattr(client_auth.httpx, "AsyncClient", lambda **_: fake_instance) + fake_keyset = KeySet.import_key_set(_test_jwks) + monkeypatch.setattr( + client_auth, + "_jwks_uri_cache", + MagicMock(get_jwks=AsyncMock(return_value=fake_keyset)), + ) client = fake_client(jwks=None, jwks_uri="https://example.org/jwks.json") result = await client_auth._load_jwks(client) - assert result == {"keys": [3]} - assert fake_instance.called_with == "https://example.org/jwks.json" + assert isinstance(result, KeySet) + assert len(result.keys) == 1 @pytest.mark.asyncio async def test_load_jwks_uri_failure_returns_none(monkeypatch): - class FakeAsyncClient: - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return False - - async def get(self, url): - raise RuntimeError("boom") - - monkeypatch.setattr(client_auth.httpx, "AsyncClient", lambda **_: FakeAsyncClient()) + monkeypatch.setattr( + client_auth, + "_jwks_uri_cache", + MagicMock(get_jwks=AsyncMock(return_value=None)), + ) client = fake_client(jwks=None, jwks_uri="https://bad.example.org") result = await client_auth._load_jwks(client) @@ -156,6 +138,7 @@ def test_validate_jwt_claims_success(): "aud": str(request.url), "exp": 123, "iat": 100, + "jti": "unique-id-1", } client_auth._validate_jwt_claims(claims, request) @@ -179,6 +162,7 @@ def test_validate_jwt_claims_invalid_audience(): "aud": ["https://other"], "exp": 123, "iat": 100, + "jti": "unique-id-2", } with pytest.raises(HTTPException) as exc_info: @@ -188,149 +172,124 @@ def test_validate_jwt_claims_invalid_audience(): assert exc_info.value.detail == "invalid_audience" -def test_decode_and_validate_jwt_success(monkeypatch): +@pytest.mark.asyncio +async def test_decode_and_validate_jwt_success(monkeypatch): request = make_request() - class DummyClaims(dict): - def validate(self, now=None, leeway=None): - return None - - payload = DummyClaims( - { - "iss": "issuer", - "sub": "subject", - "aud": str(request.url), - "exp": 123, - "iat": 100, - } - ) + payload = { + "iss": "issuer", + "sub": "subject", + "aud": str(request.url), + "exp": 123, + "iat": 100, + "jti": "unique-id-3", + } - jwt_decode_mock = MagicMock(return_value=payload) + class FakeToken: + claims = payload + + jwt_decode_mock = MagicMock(return_value=FakeToken()) monkeypatch.setattr(client_auth.jwt, "decode", jwt_decode_mock) monkeypatch.setattr(client_auth, "_validate_jwt_alg", lambda *_: None) - claims = client_auth._decode_and_validate_jwt( + # Also mock JWTClaimsRegistry to avoid real validation + fake_registry = MagicMock() + fake_registry.validate = MagicMock() + monkeypatch.setattr( + client_auth.jwt, "JWTClaimsRegistry", MagicMock(return_value=fake_registry) + ) + monkeypatch.setattr( + client_auth, + "_pkjwt_jti_cache", + MagicMock(check_and_store=AsyncMock(return_value=True)), + ) + + claims = await client_auth._decode_and_validate_jwt( token="token", key_material="secret", request=request, + db=AsyncMock(), expected_alg="RS256", ) assert claims == payload - jwt_decode_mock.assert_called_once_with("token", "secret") + jwt_decode_mock.assert_called_once_with("token", "secret", algorithms=["RS256"]) -def test_decode_and_validate_jwt_decode_failure(monkeypatch): +@pytest.mark.asyncio +async def test_decode_and_validate_jwt_decode_failure(monkeypatch): request = make_request() - def raise_error(*_): + def raise_error(*_args, **_kwargs): raise ValueError("bad token") monkeypatch.setattr(client_auth.jwt, "decode", raise_error) + monkeypatch.setattr(client_auth, "jwt_header_unverified", lambda _: {"alg": "ES256"}) with pytest.raises(HTTPException) as exc_info: - client_auth._decode_and_validate_jwt("token", "secret", request) + await client_auth._decode_and_validate_jwt( + "token", "secret", request, db=AsyncMock() + ) assert exc_info.value.status_code == 401 - assert exc_info.value.detail == "invalid_client_assertion" + assert exc_info.value.detail == "invalid_client" -def test_decode_and_validate_jwt_non_mapping(monkeypatch): +@pytest.mark.asyncio +async def test_decode_and_validate_jwt_non_mapping(monkeypatch): request = make_request() class Dummy: - def validate(self, now=None, leeway=None): - return None + claims = "not-a-mapping" - monkeypatch.setattr(client_auth.jwt, "decode", lambda *_: Dummy()) + fake_registry = MagicMock() + fake_registry.validate = MagicMock() + monkeypatch.setattr( + client_auth.jwt, "JWTClaimsRegistry", MagicMock(return_value=fake_registry) + ) + monkeypatch.setattr(client_auth.jwt, "decode", lambda *_args, **_kwargs: Dummy()) monkeypatch.setattr(client_auth, "_validate_jwt_claims", lambda *_: None) + monkeypatch.setattr(client_auth, "jwt_header_unverified", lambda _: {"alg": "ES256"}) with pytest.raises(HTTPException) as exc_info: - client_auth._decode_and_validate_jwt("token", "secret", request) + await client_auth._decode_and_validate_jwt( + "token", "secret", request, db=AsyncMock() + ) - assert exc_info.value.detail == "invalid_client_assertion" + assert exc_info.value.detail == "invalid_client" @pytest.mark.asyncio async def test_authenticate_private_key_jwt_success(monkeypatch): + fake_keyset = KeySet.import_key_set(_test_jwks) client = fake_client( client_auth_signing_alg="RS256", client_id="client-1", - jwks={"keys": ["key"]}, + jwks=_test_jwks, jwks_uri=None, client_secret=None, ) - monkeypatch.setattr( - client_auth, "_load_jwks", AsyncMock(return_value={"keys": ["key"]}) - ) - import_mock = MagicMock(return_value="imported-keys") - monkeypatch.setattr(client_auth.JsonWebKey, "import_key_set", import_mock) - decode_mock = MagicMock(return_value={"sub": "client-1"}) + monkeypatch.setattr(client_auth, "_load_jwks", AsyncMock(return_value=fake_keyset)) + decode_mock = AsyncMock(return_value={"sub": "client-1", "iss": "client-1"}) monkeypatch.setattr(client_auth, "_decode_and_validate_jwt", decode_mock) result = await client_auth._authenticate_private_key_jwt( - client, "token", make_request() + client, "token", make_request(), db=AsyncMock() ) - assert result == {"sub": "client-1"} - import_mock.assert_called_once_with({"keys": ["key"]}) + assert result == {"sub": "client-1", "iss": "client-1"} decode_mock.assert_called_once() @pytest.mark.asyncio async def test_authenticate_private_key_jwt_missing_keys(monkeypatch): - client = fake_client(jwks=None, jwks_uri=None, client_secret=None) + client = fake_client(jwks=None, jwks_uri=None, client_secret=None, client_id="c1") monkeypatch.setattr(client_auth, "_load_jwks", AsyncMock(return_value=None)) with pytest.raises(HTTPException) as exc_info: - await client_auth._authenticate_private_key_jwt(client, "token", make_request()) - - assert exc_info.value.detail == "invalid_client_keys" - - -@pytest.mark.asyncio -async def test_authenticate_shared_key_jwt_success(monkeypatch): - client = fake_client( - client_id="client-1", - client_secret="secret", - client_auth_signing_alg="HS256", - ) - decode_mock = MagicMock(return_value={"sub": "client-1"}) - monkeypatch.setattr(client_auth, "_decode_and_validate_jwt", decode_mock) - - result = await client_auth._authenticate_shared_key_jwt( - client, "token", make_request(), "client-1" - ) - - assert result == {"sub": "client-1"} - decode_mock.assert_called_once() - - -@pytest.mark.asyncio -async def test_authenticate_shared_key_jwt_missing_secret(): - client = fake_client(client_secret=None, client_auth_signing_alg="HS256") - - with pytest.raises(HTTPException) as exc_info: - await client_auth._authenticate_shared_key_jwt( - client, "token", make_request(), "client-1" - ) - - assert exc_info.value.detail == "unauthorized_client" - - -@pytest.mark.asyncio -async def test_authenticate_shared_key_jwt_sub_mismatch(monkeypatch): - client = fake_client(client_secret="secret", client_auth_signing_alg="HS256") - monkeypatch.setattr( - client_auth, - "_decode_and_validate_jwt", - MagicMock(return_value={"sub": "other"}), - ) - - with pytest.raises(HTTPException) as exc_info: - await client_auth._authenticate_shared_key_jwt( - client, "token", make_request(), "client-1" + await client_auth._authenticate_private_key_jwt( + client, "token", make_request(), db=AsyncMock() ) assert exc_info.value.detail == "invalid_client" @@ -375,7 +334,7 @@ async def test_base_client_auth_private_key_jwt_success(monkeypatch, stub_client private_key_mock = AsyncMock(return_value={}) monkeypatch.setattr(client_auth, "_authenticate_private_key_jwt", private_key_mock) - result = await client_auth.base_client_auth( + result = await client_auth._base_client_auth( db=AsyncMock(), request=request, credentials=credentials, @@ -384,7 +343,9 @@ async def test_base_client_auth_private_key_jwt_success(monkeypatch, stub_client assert result is client assert request.state.client_id == "client-1" private_key_mock.assert_awaited_once() - called_client, called_token, called_request = private_key_mock.await_args.args + called_client, called_token, called_request, called_db = ( + private_key_mock.await_args.args + ) assert called_client is client assert called_token == "token-123" assert called_request is request @@ -410,7 +371,7 @@ async def test_base_client_auth_client_secret_basic_success( client_auth, "verify_secret_pbkdf2", lambda token, stored: token == "clear-secret" ) - result = await client_auth.base_client_auth( + result = await client_auth._base_client_auth( db=AsyncMock(), request=request, basic_creds=basic_creds, @@ -439,7 +400,7 @@ async def test_base_client_auth_client_secret_basic_invalid_secret( monkeypatch.setattr(client_auth, "verify_secret_pbkdf2", lambda token, stored: False) with pytest.raises(HTTPException) as exc_info: - await client_auth.base_client_auth( + await client_auth._base_client_auth( db=AsyncMock(), request=request, basic_creds=basic_creds, @@ -454,7 +415,7 @@ async def test_base_client_auth_missing_credentials(): request = make_request() with pytest.raises(HTTPException) as exc_info: - await client_auth.base_client_auth(db=AsyncMock(), request=request) + await client_auth._base_client_auth(db=AsyncMock(), request=request) assert exc_info.value.status_code == 401 assert exc_info.value.detail == "unauthorized" @@ -476,14 +437,14 @@ async def test_base_client_auth_scheme_not_allowed(monkeypatch, stub_client_repo stub_client_repo(lambda cid: client if cid == "client-1" else None) with pytest.raises(HTTPException) as exc_info: - await client_auth.base_client_auth( + await client_auth._base_client_auth( db=AsyncMock(), request=request, basic_creds=basic_creds, ) assert exc_info.value.status_code == 401 - assert exc_info.value.detail == "unauthorized_client" + assert exc_info.value.detail == "invalid_client" @pytest.mark.asyncio @@ -497,7 +458,7 @@ async def test_base_client_auth_unknown_client(monkeypatch, stub_client_repo): ) with pytest.raises(HTTPException) as exc_info: - await client_auth.base_client_auth( + await client_auth._base_client_auth( db=AsyncMock(), request=request, credentials=credentials, diff --git a/oid4vc/auth_server/tests/tenant/test_config.py b/oid4vc/auth_server/tests/tenant/test_config.py new file mode 100644 index 000000000..51745b996 --- /dev/null +++ b/oid4vc/auth_server/tests/tenant/test_config.py @@ -0,0 +1,28 @@ +import pytest +from pydantic import ValidationError + +from tenant.config import Settings + + +@pytest.mark.parametrize( + ("enabled", "required"), + [(False, False), (True, False), (True, True)], +) +def test_attestation_config_accepts_valid_states(enabled, required): + config = Settings( + _env_file=None, + ATTESTATION_ENABLED=enabled, + ATTESTATION_REQUIRED=required, + ) + + assert config.ATTESTATION_ENABLED is enabled + assert config.ATTESTATION_REQUIRED is required + + +def test_attestation_config_rejects_required_when_disabled(): + with pytest.raises(ValidationError, match="ATTESTATION_REQUIRED"): + Settings( + _env_file=None, + ATTESTATION_ENABLED=False, + ATTESTATION_REQUIRED=True, + ) diff --git a/oid4vc/auth_server/tests/tenant/test_tenant_deps.py b/oid4vc/auth_server/tests/tenant/test_tenant_deps.py index c50df3098..1d893aa3e 100644 --- a/oid4vc/auth_server/tests/tenant/test_tenant_deps.py +++ b/oid4vc/auth_server/tests/tenant/test_tenant_deps.py @@ -148,7 +148,7 @@ def __init__(self): self.path_params = {"uid": "tenant-1"} request = DummyRequest() - ctx = await deps._load_tenant_ctx(cast(deps.Request, request), force=False) + ctx = await deps._load_tenant_ctx(cast(deps.Request, request)) async def consume(): async for session in deps.get_db_session(cast(deps.Request, request), ctx): From b1d3b58baa17e4bae6cb1f8bc3b12a5a98056075 Mon Sep 17 00:00:00 2001 From: Ivan Wei Date: Fri, 14 Aug 2026 18:43:51 -0400 Subject: [PATCH 3/3] chore: make DB connection pool sizing configurable Signed-off-by: Ivan Wei --- oid4vc/auth_server/admin/config.py | 4 ++++ oid4vc/auth_server/admin/main.py | 7 ++++++- oid4vc/auth_server/core/db/cached_session.py | 11 ++++++++++- oid4vc/auth_server/core/db/session.py | 12 +++++++++++- oid4vc/auth_server/resources/.env.admin.example | 4 ++++ oid4vc/auth_server/resources/.env.tenant.example | 4 ++++ oid4vc/auth_server/tenant/config.py | 4 ++++ oid4vc/auth_server/tenant/deps.py | 9 ++++++++- 8 files changed, 51 insertions(+), 4 deletions(-) diff --git a/oid4vc/auth_server/admin/config.py b/oid4vc/auth_server/admin/config.py index 9ea7a1f1f..8ed1851b7 100644 --- a/oid4vc/auth_server/admin/config.py +++ b/oid4vc/auth_server/admin/config.py @@ -28,6 +28,10 @@ class Settings(BaseSettings): DB_SCHEMA: str = "admin" DB_USER: str = "postgres" DB_PASSWORD: str = "postgres" + # Sized per replica: total connections = replicas x (size + overflow) + DB_POOL_SIZE: int = 5 + DB_MAX_OVERFLOW: int = 10 + DB_POOL_RECYCLE: int = 1800 # Tenant database TENANT_DB_NAME: str = "auth_server_tenant" diff --git a/oid4vc/auth_server/admin/main.py b/oid4vc/auth_server/admin/main.py index dca9c312e..2a8ee5946 100644 --- a/oid4vc/auth_server/admin/main.py +++ b/oid4vc/auth_server/admin/main.py @@ -25,7 +25,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: """Lifespan event handler.""" # Startup logic setup_structlog_json() - db_manager.init(settings.DB_URL) + db_manager.init( + settings.DB_URL, + pool_size=settings.DB_POOL_SIZE, + max_overflow=settings.DB_MAX_OVERFLOW, + pool_recycle=settings.DB_POOL_RECYCLE, + ) # Load wallet providers into JWKS cache async with db_manager.session() as session: from admin.services.internal_service import load_wallet_providers diff --git a/oid4vc/auth_server/core/db/cached_session.py b/oid4vc/auth_server/core/db/cached_session.py index 91b0f871d..5fe91334a 100644 --- a/oid4vc/auth_server/core/db/cached_session.py +++ b/oid4vc/auth_server/core/db/cached_session.py @@ -17,7 +17,13 @@ def _session_factory( - async_url: str, schema: str, *, max_engines: int = _DEFAULT_MAX + async_url: str, + schema: str, + *, + max_engines: int = _DEFAULT_MAX, + pool_size: int = 5, + max_overflow: int = 10, + pool_recycle: int = 1800, ) -> async_sessionmaker[AsyncSession]: """Get or create a sessionmaker, evicting LRU engines over max.""" key = (async_url, schema) @@ -27,6 +33,9 @@ def _session_factory( _engines[key] = create_async_engine( async_url, pool_pre_ping=True, + pool_size=pool_size, + max_overflow=max_overflow, + pool_recycle=pool_recycle, connect_args={"server_settings": {"search_path": schema}}, ) while len(_engines) > max_engines: diff --git a/oid4vc/auth_server/core/db/session.py b/oid4vc/auth_server/core/db/session.py index ef5cb8cb5..a997b6e2c 100644 --- a/oid4vc/auth_server/core/db/session.py +++ b/oid4vc/auth_server/core/db/session.py @@ -21,7 +21,14 @@ def __init__(self, *, search_path: str | None = None) -> None: self._sessionmaker: async_sessionmaker[AsyncSession] | None = None self._search_path = search_path - def init(self, url: str) -> None: + def init( + self, + url: str, + *, + pool_size: int = 5, + max_overflow: int = 10, + pool_recycle: int = 1800, + ) -> None: """Create engine and bind sessionmaker.""" connect_args = {} if self._search_path: @@ -30,6 +37,9 @@ def init(self, url: str) -> None: self._engine = create_async_engine( url, pool_pre_ping=True, + pool_size=pool_size, + max_overflow=max_overflow, + pool_recycle=pool_recycle, connect_args=connect_args or None, future=True, ) diff --git a/oid4vc/auth_server/resources/.env.admin.example b/oid4vc/auth_server/resources/.env.admin.example index 1d3776990..982958ee4 100644 --- a/oid4vc/auth_server/resources/.env.admin.example +++ b/oid4vc/auth_server/resources/.env.admin.example @@ -19,6 +19,10 @@ ADMIN_DB_HOST=localhost ADMIN_DB_PORT=5432 ADMIN_DB_NAME=auth_server_admin ADMIN_DB_SCHEMA=admin +# Pool is per replica; total connections = replicas x (size + overflow) +# ADMIN_DB_POOL_SIZE=5 +# ADMIN_DB_MAX_OVERFLOW=10 +# ADMIN_DB_POOL_RECYCLE=1800 # Tenant database # ADMIN_TENANT_DB_NAME=auth_server_tenant diff --git a/oid4vc/auth_server/resources/.env.tenant.example b/oid4vc/auth_server/resources/.env.tenant.example index d8e2e8b68..9332c312a 100644 --- a/oid4vc/auth_server/resources/.env.tenant.example +++ b/oid4vc/auth_server/resources/.env.tenant.example @@ -28,6 +28,10 @@ TENANT_ISSUER_BASE_URL=http://localhost:9001 # TENANT_DB_DRIVER_SYNC=postgresql+psycopg # TENANT_DB_HOST=localhost # TENANT_DB_PORT=5432 +# Pool is per replica; total connections = replicas x tenant DBs x (size + overflow) +# TENANT_DB_POOL_SIZE=5 +# TENANT_DB_MAX_OVERFLOW=10 +# TENANT_DB_POOL_RECYCLE=1800 # Networking # TENANT_TRUSTED_NETWORKS=["127.0.0.0/8","10.0.0.0/8","172.16.0.0/12","192.168.0.0/16"] diff --git a/oid4vc/auth_server/tenant/config.py b/oid4vc/auth_server/tenant/config.py index 05f49f1bd..4b4c99843 100644 --- a/oid4vc/auth_server/tenant/config.py +++ b/oid4vc/auth_server/tenant/config.py @@ -35,6 +35,10 @@ class Settings(BaseSettings): DB_DRIVER_SYNC: str = "postgresql+psycopg" DB_HOST: str = "localhost" DB_PORT: int = 5432 + # Sized per replica: total connections = replicas x tenant DBs x (size + overflow) + DB_POOL_SIZE: int = 5 + DB_MAX_OVERFLOW: int = 10 + DB_POOL_RECYCLE: int = 1800 # Networking TRUSTED_NETWORKS: list[str] = [] diff --git a/oid4vc/auth_server/tenant/deps.py b/oid4vc/auth_server/tenant/deps.py index ed450dc7b..ccface9ab 100644 --- a/oid4vc/auth_server/tenant/deps.py +++ b/oid4vc/auth_server/tenant/deps.py @@ -138,7 +138,14 @@ async def get_tenant_jwks(uid: str) -> dict: def _sessionmaker_for(url: str, schema: str): """Delegate to shared engine cache with tenant-sized limit.""" - return _session_factory(url, schema, max_engines=_MAX_ENGINES) + return _session_factory( + url, + schema, + max_engines=_MAX_ENGINES, + pool_size=settings.DB_POOL_SIZE, + max_overflow=settings.DB_MAX_OVERFLOW, + pool_recycle=settings.DB_POOL_RECYCLE, + ) async def dispose_engines() -> None: