diff --git a/oid4vc/demo/frontend/index.js b/oid4vc/demo/frontend/index.js index 9a467d321..f87fdf2e1 100644 --- a/oid4vc/demo/frontend/index.js +++ b/oid4vc/demo/frontend/index.js @@ -232,7 +232,17 @@ async function issue_jwt_credential(req, res) { // Generate QRCode and send it to the browser via HTMX events logger.info(JSON.stringify(offerResponse.data)); logger.info(exchangeId); - const qrcode = credentialOffer.offer_uri; + + let qrcode; + if (credentialOffer.hasOwnProperty("credential_offer")) { + // credential offer is passed by value + qrcode = credentialOffer.credential_offer + } else { + // credential offer is passed by reference, and the wallet must dereference it using the + // /oid4vci/dereference-credential-offer endpoint + qrcode = credentialOffer.credential_offer_uri + } + events.emit(`issuance-${req.body.registrationId}`, {type: "message", message: `Sending offer to user: ${qrcode}`}); events.emit(`issuance-${req.body.registrationId}`, {type: "qrcode", credentialOffer, exchangeId, qrcode}); exchangeCache.set(exchangeId, { exchangeId, credentialOffer, did, supportedCredId, registrationId: req.body.registrationId }); @@ -431,7 +441,17 @@ async function issue_sdjwt_credential(req, res) { // Generate QRCode and send it to the browser via HTMX events logger.info(JSON.stringify(offerResponse.data)); logger.info(exchangeId); - const qrcode = credentialOffer.offer_uri; + + let qrcode; + if (credentialOffer.hasOwnProperty("credential_offer")) { + // credential offer is passed by value + qrcode = credentialOffer.credential_offer + } else { + // credential offer is passed by reference, and the wallet must dereference it using the + // /oid4vci/dereference-credential-offer endpoint + qrcode = credentialOffer.credential_offer_uri + } + events.emit(`issuance-${req.body.registrationId}`, {type: "message", message: `Sending offer to user: ${qrcode}`}); events.emit(`issuance-${req.body.registrationId}`, {type: "qrcode", credentialOffer, exchangeId, qrcode}); exchangeCache.set(exchangeId, { exchangeId, credentialOffer, did, supportedCredId, registrationId: req.body.registrationId }); diff --git a/oid4vc/integration/tests/conftest.py b/oid4vc/integration/tests/conftest.py index 5dcad0e0e..515dfecf9 100644 --- a/oid4vc/integration/tests/conftest.py +++ b/oid4vc/integration/tests/conftest.py @@ -2,6 +2,9 @@ from uuid import uuid4 from acapy_controller.controller import Controller +from aiohttp import ClientSession +from urllib.parse import urlparse, parse_qs + import pytest import pytest_asyncio @@ -75,6 +78,34 @@ async def offer(controller: Controller, issuer_did: str, supported_cred_id: str) ) yield offer +@pytest_asyncio.fixture +async def offer_by_ref(controller: Controller, issuer_did: str, supported_cred_id: str): + """Create a credential offer.""" + exchange = await controller.post( + "/oid4vci/exchange/create", + json={ + "supported_cred_id": supported_cred_id, + "credential_subject": {"name": "alice"}, + "verification_method": issuer_did + "#0", + }, + ) + + exchange_param = {"exchange_id": exchange["exchange_id"]} + offer_ref_full = await controller.get( + "/oid4vci/credential-offer-by-ref", + params=exchange_param, + ) + + offer_ref = urlparse(offer_ref_full["credential_offer_uri"]) + offer_ref = parse_qs(offer_ref.query)["credential_offer"][0] + async with ClientSession( + headers=controller.headers + ) as session: + async with session.request( + "GET", url=offer_ref, params=exchange_param, headers=controller.headers + ) as offer: + yield await offer.json() + @pytest_asyncio.fixture async def sdjwt_supported_cred_id(controller: Controller, issuer_did: str): @@ -174,11 +205,54 @@ async def sdjwt_offer( "/oid4vci/credential-offer", params={"exchange_id": exchange["exchange_id"]}, ) - offer_uri = offer["offer_uri"] + offer_uri = offer["credential_offer"] yield offer_uri +@pytest_asyncio.fixture +async def sdjwt_offer_by_ref( + controller: Controller, issuer_did: str, sdjwt_supported_cred_id: str +): + """Create a cred offer for an SD-JWT VC.""" + exchange = await controller.post( + "/oid4vci/exchange/create", + json={ + "supported_cred_id": sdjwt_supported_cred_id, + "credential_subject": { + "given_name": "Erika", + "family_name": "Mustermann", + "source_document_type": "id_card", + "age_equal_or_over": { + "12": True, + "14": True, + "16": True, + "18": True, + "21": True, + "65": False, + }, + }, + "verification_method": issuer_did + "#0", + }, + ) + + exchange_param = {"exchange_id": exchange["exchange_id"]} + offer_ref_full = await controller.get( + "/oid4vci/credential-offer-by-ref", + params=exchange_param, + ) + + offer_ref = urlparse(offer_ref_full["credential_offer_uri"]) + offer_ref = parse_qs(offer_ref.query)["credential_offer"][0] + async with ClientSession( + headers=controller.headers + ) as session: + async with session.request( + "GET", url=offer_ref, params=exchange_param, headers=controller.headers + ) as offer: + yield (await offer.json())["credential_offer"] + + @pytest_asyncio.fixture async def presentation_definition_id(controller: Controller, issuer_did: str): """Create a supported credential.""" diff --git a/oid4vc/integration/tests/test_interop/test_credo.py b/oid4vc/integration/tests/test_interop/test_credo.py index 1b1725377..821a9db20 100644 --- a/oid4vc/integration/tests/test_interop/test_credo.py +++ b/oid4vc/integration/tests/test_interop/test_credo.py @@ -9,7 +9,15 @@ @pytest.mark.asyncio async def test_accept_credential_offer(credo: CredoWrapper, offer: Dict[str, Any]): """Test OOB DIDExchange Protocol.""" - await credo.openid4vci_accept_offer(offer["offer_uri"]) + await credo.openid4vci_accept_offer(offer["credential_offer"]) + + +@pytest.mark.interop +@pytest.mark.asyncio +async def test_accept_credential_offer_by_ref(credo: CredoWrapper, offer_by_ref: Dict[str, Any]): + """Test OOB DIDExchange Protocol where offer is passed by reference from the + credential-offer-by-ref endpoint and then dereferenced.""" + await credo.openid4vci_accept_offer(offer_by_ref["credential_offer"]) @pytest.mark.interop @@ -19,13 +27,20 @@ async def test_accept_credential_offer_sdjwt(credo: CredoWrapper, sdjwt_offer: s await credo.openid4vci_accept_offer(sdjwt_offer) +@pytest.mark.interop +@pytest.mark.asyncio +async def test_accept_credential_offer_sdjwt_by_ref(credo: CredoWrapper, sdjwt_offer_by_ref: str): + """Test OOB DIDExchange Protocol where offer is passed by reference from the + credential-offer-by-ref endpoint and then dereferenced.""" + await credo.openid4vci_accept_offer(sdjwt_offer_by_ref) + @pytest.mark.interop @pytest.mark.asyncio async def test_accept_auth_request( controller: Controller, credo: CredoWrapper, offer: Dict[str, Any], request_uri: str ): """Test OOB DIDExchange Protocol.""" - await credo.openid4vci_accept_offer(offer["offer_uri"]) + await credo.openid4vci_accept_offer(offer["credential_offer"]) await credo.openid4vp_accept_request(request_uri) await controller.event_with_values("oid4vp", state="presentation-valid") diff --git a/oid4vc/integration/tests/test_interop/test_sphereon.py b/oid4vc/integration/tests/test_interop/test_sphereon.py index 6b078ff25..2c6ac4c38 100644 --- a/oid4vc/integration/tests/test_interop/test_sphereon.py +++ b/oid4vc/integration/tests/test_interop/test_sphereon.py @@ -19,4 +19,11 @@ async def test_api(sphereon: SphereaonWrapper): @pytest.mark.asyncio async def test_sphereon_pre_auth(sphereon: SphereaonWrapper, offer: Dict[str, Any]): """Test receive offer for pre auth code flow.""" - await sphereon.accept_credential_offer(offer["offer_uri"]) + await sphereon.accept_credential_offer(offer["credential_offer"]) + +@pytest.mark.interop +@pytest.mark.asyncio +async def test_sphereon_pre_auth_by_ref(sphereon: SphereaonWrapper, offer_by_ref: Dict[str, Any]): + """Test receive offer for pre auth code flow, where offer is passed by reference from the + credential-offer-by-ref endpoint and then dereferenced.""" + await sphereon.accept_credential_offer(offer_by_ref["credential_offer"]) diff --git a/oid4vc/integration/tests/test_pre_auth_code_flow.py b/oid4vc/integration/tests/test_pre_auth_code_flow.py index dc86e1909..22efd75f6 100644 --- a/oid4vc/integration/tests/test_pre_auth_code_flow.py +++ b/oid4vc/integration/tests/test_pre_auth_code_flow.py @@ -11,7 +11,6 @@ async def test_pre_auth_code_flow_ed25519(test_client: OpenID4VCIClient, offer: did = test_client.generate_did("ed25519") response = await test_client.receive_offer(offer, did) - @pytest.mark.asyncio async def test_pre_auth_code_flow_secp256k1(test_client: OpenID4VCIClient, offer: str): """Connect to AFJ.""" diff --git a/oid4vc/oid4vc/public_routes.py b/oid4vc/oid4vc/public_routes.py index 92e3b3c5f..123a96ac6 100644 --- a/oid4vc/oid4vc/public_routes.py +++ b/oid4vc/oid4vc/public_routes.py @@ -6,6 +6,7 @@ import time import uuid from secrets import token_urlsafe +from urllib.parse import quote from typing import Any, Dict, List, Optional from acapy_agent.admin.request_context import AdminRequestContext @@ -28,6 +29,7 @@ docs, form_schema, match_info_schema, + querystring_schema, request_schema, response_schema, ) @@ -47,6 +49,7 @@ from .models.exchange import OID4VCIExchangeRecord from .models.supported_cred import SupportedCredential from .pop_result import PopResult +from .routes import _parse_cred_offer, CredOfferQuerySchema, CredOfferResponseSchemaVal LOGGER = logging.getLogger(__name__) PRE_AUTHORIZED_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:pre-authorized_code" @@ -54,6 +57,25 @@ EXPIRES_IN = 86400 +@docs(tags=["oid4vci"], summary="Dereference a credential offer.") +@querystring_schema(CredOfferQuerySchema()) +@response_schema(CredOfferResponseSchemaVal(), 200) +async def dereference_cred_offer(request: web.BaseRequest): + """Dereference a credential offer. + + Reference URI is acquired from the /oid4vci/credential-offer-by-ref endpoint + (see routes.get_cred_offer_by_ref()). + """ + context: AdminRequestContext = request["context"] + exchange_id = request.query["exchange_id"] + + offer = await _parse_cred_offer(context, exchange_id) + return web.json_response({ + "offer": offer, + "credential_offer": f"openid-credential-offer://?credential_offer={quote(json.dumps(offer))}", + }) + + class CredentialIssuerMetadataSchema(OpenAPISchema): """Credential issuer metadata schema.""" @@ -644,6 +666,10 @@ async def register(app: web.Application, multitenant: bool): subpath = "/tenant/{wallet_id}" if multitenant else "" app.add_routes( [ + web.get(f"{subpath}/oid4vci/dereference-credential-offer", + dereference_cred_offer, + allow_head=False + ), web.get( f"{subpath}/.well-known/openid-credential-issuer", credential_issuer_metadata, diff --git a/oid4vc/oid4vc/routes.py b/oid4vc/oid4vc/routes.py index 07fc4c966..0705e6fe8 100644 --- a/oid4vc/oid4vc/routes.py +++ b/oid4vc/oid4vc/routes.py @@ -320,32 +320,36 @@ class CredOfferSchema(OpenAPISchema): grants = fields.Nested(CredOfferGrantSchema(), required=True) -class CredOfferResponseSchema(OpenAPISchema): +class CredOfferResponseSchemaVal(OpenAPISchema): """Credential Offer Schema.""" - offer_uri = fields.Str( + credential_offer = fields.Str( required=True, metadata={ - "description": "The URL of the credential issuer.", + "description": "The URL of the credential value for display by QR code.", "example": "openid-credential-offer://...", }, ) offer = fields.Nested(CredOfferSchema(), required=True) +class CredOfferResponseSchemaRef(OpenAPISchema): + """Credential Offer Schema.""" -@docs(tags=["oid4vci"], summary="Get a credential offer") -@querystring_schema(CredOfferQuerySchema()) -@response_schema(CredOfferResponseSchema(), 200) -@tenant_authentication -async def get_cred_offer(request: web.BaseRequest): - """Endpoint to retrieve an OpenID4VCI compliant offer. + credential_offer_uri = fields.Str( + required=True, + metadata={ + "description": "A URL which references the credential for display.", + "example": "openid-credential-offer://...", + }, + ) + offer = fields.Nested(CredOfferSchema(), required=True) - For example, can be used in QR-Code presented to a compliant wallet. +async def _parse_cred_offer(context: AdminRequestContext, exchange_id: str) -> dict: + """Helper function for cred_offer request parsing. + + Used in get_cred_offer and public_routes.dereference_cred_offer endpoints. """ - context: AdminRequestContext = request["context"] config = Config.from_settings(context.settings) - exchange_id = request.query["exchange_id"] - code = secrets.token_urlsafe(CODE_BYTES) try: @@ -368,7 +372,7 @@ async def get_cred_offer(request: web.BaseRequest): else None ) subpath = f"/tenant/{wallet_id}" if wallet_id else "" - offer = { + return { "credential_issuer": f"{config.endpoint}{subpath}", "credentials": [supported.identifier], "grants": { @@ -378,15 +382,57 @@ async def get_cred_offer(request: web.BaseRequest): } }, } + +@docs(tags=["oid4vci"], summary="Get a credential offer by value") +@querystring_schema(CredOfferQuerySchema()) +@response_schema(CredOfferResponseSchemaVal(), 200) +@tenant_authentication +async def get_cred_offer(request: web.BaseRequest): + """Endpoint to retrieve an OpenID4VCI compliant offer by value. + + For example, can be used in QR-Code presented to a compliant wallet. + """ + context: AdminRequestContext = request["context"] + exchange_id = request.query["exchange_id"] + + offer = await _parse_cred_offer(context, exchange_id) offer_uri = quote(json.dumps(offer)) - full_uri = f"openid-credential-offer://?credential_offer={offer_uri}" offer_response = { "offer": offer, - "offer_uri": full_uri, + "credential_offer": f"openid-credential-offer://?credential_offer={offer_uri}" } - return web.json_response(offer_response) +@docs(tags=["oid4vci"], summary="Get a credential offer by reference") +@querystring_schema(CredOfferQuerySchema()) +@response_schema(CredOfferResponseSchemaRef(), 200) +@tenant_authentication +async def get_cred_offer_by_ref(request: web.BaseRequest): + """Endpoint to retrieve an OpenID4VCI compliant offer by reference. + + credential_offer_uri can be dereferenced at the /oid4vc/dereference-credential-offer + (see public_routes.dereference_cred_offer) + + For example, can be used in QR-Code presented to a compliant wallet. + """ + context: AdminRequestContext = request["context"] + exchange_id = request.query["exchange_id"] + wallet_id = ( + context.profile.settings.get("wallet.id") + if context.profile.settings.get("multitenant.enabled") + else None + ) + + offer = await _parse_cred_offer(context, exchange_id) + + config = Config.from_settings(context.settings) + subpath = f"/tenant/{wallet_id}" if wallet_id else "" + ref_uri = f"{config.endpoint}{subpath}/oid4vci/dereference-credential-offer" + offer_response = { + "offer": offer, + "credential_offer_uri": f"openid-credential-offer://?credential_offer={quote(ref_uri)}" + } + return web.json_response(offer_response) class SupportedCredCreateRequestSchema(OpenAPISchema): """Schema for SupportedCredCreateRequestSchema.""" @@ -1185,6 +1231,11 @@ async def register(app: web.Application): app.add_routes( [ web.get("/oid4vci/credential-offer", get_cred_offer, allow_head=False), + web.get( + "/oid4vci/credential-offer-by-ref", + get_cred_offer_by_ref, + allow_head=False + ), web.get( "/oid4vci/exchange/records", list_exchange_records, diff --git a/oid4vc/oid4vc/tests/routes/test_public_routes.py b/oid4vc/oid4vc/tests/routes/test_public_routes.py index 3ad009463..94a527cb9 100644 --- a/oid4vc/oid4vc/tests/routes/test_public_routes.py +++ b/oid4vc/oid4vc/tests/routes/test_public_routes.py @@ -7,7 +7,6 @@ from oid4vc import public_routes as test_module - @pytest.mark.asyncio async def test_issuer_metadata(context: AdminRequestContext, req: web.Request): """Test issuer metadata endpoint."""