From 50167fed51ed1de0bf2276a9f143b0a290c95c3d Mon Sep 17 00:00:00 2001 From: Athan Massouras Date: Sun, 2 Feb 2025 21:33:13 -0800 Subject: [PATCH 1/6] feat: first attempt at returning cred_offers by value Signed-off-by: Athan Massouras --- oid4vc/oid4vc/public_routes.py | 13 +++++++++++++ oid4vc/oid4vc/routes.py | 1 + 2 files changed, 14 insertions(+) diff --git a/oid4vc/oid4vc/public_routes.py b/oid4vc/oid4vc/public_routes.py index 92e3b3c5f..dc3654c9a 100644 --- a/oid4vc/oid4vc/public_routes.py +++ b/oid4vc/oid4vc/public_routes.py @@ -47,6 +47,7 @@ from .models.exchange import OID4VCIExchangeRecord from .models.supported_cred import SupportedCredential from .pop_result import PopResult +from .routes import CredOfferResponseSchema, get_cred_offer LOGGER = logging.getLogger(__name__) PRE_AUTHORIZED_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:pre-authorized_code" @@ -54,6 +55,17 @@ EXPIRES_IN = 86400 +@docs(tags=["oid4vci"], summary="Dereference a credential offer.") +@response_schema(CredOfferResponseSchema, 200) +async def dereference_cred_offer(request: web.BaseRequest): + """Get a credential offer from a URI that has been acquired from the /oid4vci/credential-offer + endpoint (see routes.get_cred_offer()). + + Works identically to routes.get_cred_offer() when returning by value. + """ + return get_cred_offer(request) + + class CredentialIssuerMetadataSchema(OpenAPISchema): """Credential issuer metadata schema.""" @@ -644,6 +656,7 @@ 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..ba63bb3dd 100644 --- a/oid4vc/oid4vc/routes.py +++ b/oid4vc/oid4vc/routes.py @@ -378,6 +378,7 @@ async def get_cred_offer(request: web.BaseRequest): } }, } + offer_uri = quote(json.dumps(offer)) full_uri = f"openid-credential-offer://?credential_offer={offer_uri}" offer_response = { From 618095d3f95ffdb4231a7628f181de01d77a6697 Mon Sep 17 00:00:00 2001 From: Athan Massouras Date: Sat, 8 Feb 2025 16:00:49 -0800 Subject: [PATCH 2/6] fix: add /credential-offer-by-ref endpoint to receive credential-offer-uris change return dict to make it spec compliant Signed-off-by: Athan Massouras --- oid4vc/demo/frontend/index.js | 24 +++++++- oid4vc/integration/tests/conftest.py | 2 +- .../tests/test_interop/test_credo.py | 4 +- .../tests/test_interop/test_sphereon.py | 2 +- oid4vc/oid4vc/public_routes.py | 17 ++++-- oid4vc/oid4vc/routes.py | 57 ++++++++++++------- .../oid4vc/tests/routes/test_public_routes.py | 1 - 7 files changed, 76 insertions(+), 31 deletions(-) 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..9558f729e 100644 --- a/oid4vc/integration/tests/conftest.py +++ b/oid4vc/integration/tests/conftest.py @@ -174,7 +174,7 @@ 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 diff --git a/oid4vc/integration/tests/test_interop/test_credo.py b/oid4vc/integration/tests/test_interop/test_credo.py index 1b1725377..71899c97f 100644 --- a/oid4vc/integration/tests/test_interop/test_credo.py +++ b/oid4vc/integration/tests/test_interop/test_credo.py @@ -9,7 +9,7 @@ @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 @@ -25,7 +25,7 @@ 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..c16fd4f8c 100644 --- a/oid4vc/integration/tests/test_interop/test_sphereon.py +++ b/oid4vc/integration/tests/test_interop/test_sphereon.py @@ -19,4 +19,4 @@ 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"]) diff --git a/oid4vc/oid4vc/public_routes.py b/oid4vc/oid4vc/public_routes.py index dc3654c9a..ae0a11ac2 100644 --- a/oid4vc/oid4vc/public_routes.py +++ b/oid4vc/oid4vc/public_routes.py @@ -28,6 +28,7 @@ docs, form_schema, match_info_schema, + querystring_schema, request_schema, response_schema, ) @@ -47,7 +48,7 @@ from .models.exchange import OID4VCIExchangeRecord from .models.supported_cred import SupportedCredential from .pop_result import PopResult -from .routes import CredOfferResponseSchema, get_cred_offer +from .routes import CredOfferResponseSchema, CredOfferQuerySchema, _parse_cred_offer LOGGER = logging.getLogger(__name__) PRE_AUTHORIZED_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:pre-authorized_code" @@ -56,14 +57,19 @@ @docs(tags=["oid4vci"], summary="Dereference a credential offer.") +@querystring_schema(CredOfferQuerySchema()) @response_schema(CredOfferResponseSchema, 200) async def dereference_cred_offer(request: web.BaseRequest): """Get a credential offer from a URI that has been acquired from the /oid4vci/credential-offer endpoint (see routes.get_cred_offer()). - Works identically to routes.get_cred_offer() when returning by value. + Works identically to routes.get_cred_offer() when returning by value, except it returns as a + JSON object rather than a URL encoding thereof. """ - return get_cred_offer(request) + context: AdminRequestContext = request["context"] + exchange_id = request.query["exchange_id"] + offer = await _parse_cred_offer(context, exchange_id) + return web.json_response(offer) class CredentialIssuerMetadataSchema(OpenAPISchema): @@ -656,7 +662,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}/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 ba63bb3dd..4b96acead 100644 --- a/oid4vc/oid4vc/routes.py +++ b/oid4vc/oid4vc/routes.py @@ -332,20 +332,10 @@ class CredOfferResponseSchema(OpenAPISchema): ) offer = fields.Nested(CredOfferSchema(), required=True) - -@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. - - For example, can be used in QR-Code presented to a compliant wallet. - """ - context: AdminRequestContext = request["context"] +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""" config = Config.from_settings(context.settings) - exchange_id = request.query["exchange_id"] - code = secrets.token_urlsafe(CODE_BYTES) try: @@ -368,7 +358,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": { @@ -379,13 +369,39 @@ async def get_cred_offer(request: web.BaseRequest): }, } - offer_uri = quote(json.dumps(offer)) - full_uri = f"openid-credential-offer://?credential_offer={offer_uri}" - offer_response = { - "offer": offer, - "offer_uri": full_uri, - } +@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, return_uri: bool = False): + """Endpoint to retrieve an OpenID4VCI compliant 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 + ) + subpath = f"/tenant/{wallet_id}" if wallet_id else "" + offer = await _parse_cred_offer(context, exchange_id) + if return_uri: + # Return a reference to the credential, which can be accessed at the following + # public endpoint (see public_routes.dereference_cred_offer()): + offer_response = { + "offer": offer, + "credential_offer": f"{subpath}/oid4vc/dereference-credential-offer" + } + else: + # Return the credential by value. + offer_uri = quote(json.dumps(offer)) + offer_response = { + "offer": offer, + "credential_offer_uri": f"openid-credential-offer://?credential_offer={offer_uri}" + } return web.json_response(offer_response) @@ -1186,6 +1202,7 @@ 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", lambda r: get_cred_offer(r, True), 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.""" From 1a643458ba61d33ddf9b6b0917a8bc8e8ccd256a Mon Sep 17 00:00:00 2001 From: Athan Massouras Date: Sat, 8 Feb 2025 18:13:04 -0800 Subject: [PATCH 3/6] fix: some progress in dereferencing cred offer Signed-off-by: Athan Massouras --- oid4vc/demo/frontend/index.js | 2 +- oid4vc/oid4vc/public_routes.py | 8 ++-- oid4vc/oid4vc/routes.py | 75 +++++++++++++++++++++++----------- 3 files changed, 57 insertions(+), 28 deletions(-) diff --git a/oid4vc/demo/frontend/index.js b/oid4vc/demo/frontend/index.js index f87fdf2e1..20bc56fc2 100644 --- a/oid4vc/demo/frontend/index.js +++ b/oid4vc/demo/frontend/index.js @@ -214,7 +214,7 @@ async function issue_jwt_credential(req, res) { // Get Credential Offer information - const credentialOfferUrl = `${API_BASE_URL}/oid4vci/credential-offer`; + const credentialOfferUrl = `${API_BASE_URL}/oid4vci/credential-offer-by-ref`; const queryParams = { user_pin_required: false, exchange_id: exchangeId, diff --git a/oid4vc/oid4vc/public_routes.py b/oid4vc/oid4vc/public_routes.py index ae0a11ac2..9bc69b9d7 100644 --- a/oid4vc/oid4vc/public_routes.py +++ b/oid4vc/oid4vc/public_routes.py @@ -48,7 +48,7 @@ from .models.exchange import OID4VCIExchangeRecord from .models.supported_cred import SupportedCredential from .pop_result import PopResult -from .routes import CredOfferResponseSchema, CredOfferQuerySchema, _parse_cred_offer +from .routes import _parse_cred_offer, CredOfferQuerySchema, CredOfferSchema LOGGER = logging.getLogger(__name__) PRE_AUTHORIZED_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:pre-authorized_code" @@ -57,8 +57,8 @@ @docs(tags=["oid4vci"], summary="Dereference a credential offer.") -@querystring_schema(CredOfferQuerySchema()) -@response_schema(CredOfferResponseSchema, 200) +@querystring_schema(CredOfferQuerySchema) +@response_schema(CredOfferSchema, 200) async def dereference_cred_offer(request: web.BaseRequest): """Get a credential offer from a URI that has been acquired from the /oid4vci/credential-offer endpoint (see routes.get_cred_offer()). @@ -662,7 +662,7 @@ 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", + web.get(f"/oid4vci/dereference-credential-offer", dereference_cred_offer, allow_head=False ), diff --git a/oid4vc/oid4vc/routes.py b/oid4vc/oid4vc/routes.py index 4b96acead..c734e8689 100644 --- a/oid4vc/oid4vc/routes.py +++ b/oid4vc/oid4vc/routes.py @@ -320,13 +320,25 @@ 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.""" + + credential_offer_uri = fields.Str( + required=True, + metadata={ + "description": "A URL which references the credential for display by QR code.", "example": "openid-credential-offer://...", }, ) @@ -369,12 +381,12 @@ async def _parse_cred_offer(context: AdminRequestContext, exchange_id: str) -> d }, } -@docs(tags=["oid4vci"], summary="Get a credential offer") +@docs(tags=["oid4vci"], summary="Get a credential offer by value") @querystring_schema(CredOfferQuerySchema()) -@response_schema(CredOfferResponseSchema(), 200) +@response_schema(CredOfferResponseSchemaVal(), 200) @tenant_authentication -async def get_cred_offer(request: web.BaseRequest, return_uri: bool = False): - """Endpoint to retrieve an OpenID4VCI compliant offer. +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. """ @@ -385,25 +397,42 @@ async def get_cred_offer(request: web.BaseRequest, return_uri: bool = False): if context.profile.settings.get("multitenant.enabled") else None ) - subpath = f"/tenant/{wallet_id}" if wallet_id else "" offer = await _parse_cred_offer(context, exchange_id) - if return_uri: - # Return a reference to the credential, which can be accessed at the following - # public endpoint (see public_routes.dereference_cred_offer()): - offer_response = { - "offer": offer, - "credential_offer": f"{subpath}/oid4vc/dereference-credential-offer" - } - else: - # Return the credential by value. - offer_uri = quote(json.dumps(offer)) - offer_response = { - "offer": offer, - "credential_offer_uri": f"openid-credential-offer://?credential_offer={offer_uri}" - } + offer_uri = quote(json.dumps(offer)) + offer_response = { + "offer": offer, + "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 + ) + subpath = f"/tenant/{wallet_id}" if wallet_id else "" + + offer = await _parse_cred_offer(context, exchange_id) + # TODO: JANK JANK JANK you need to fix this + ref_uri = f"https://url:port/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.""" @@ -1202,7 +1231,7 @@ 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", lambda r: get_cred_offer(r, True), 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, From 55490f9fea0712faf6092f54bb9f93a1804c5397 Mon Sep 17 00:00:00 2001 From: Athan Massouras Date: Sat, 15 Feb 2025 17:54:26 -0800 Subject: [PATCH 4/6] fix: credential offers by reference implemented and tested in credo and sphereon Signed-off-by: Athan Massouras --- oid4vc/integration/tests/conftest.py | 74 +++++++++++++++++++ .../tests/test_interop/test_credo.py | 15 ++++ .../tests/test_interop/test_sphereon.py | 7 ++ .../tests/test_pre_auth_code_flow.py | 1 - oid4vc/oid4vc/public_routes.py | 15 ++-- oid4vc/oid4vc/routes.py | 7 +- 6 files changed, 110 insertions(+), 9 deletions(-) diff --git a/oid4vc/integration/tests/conftest.py b/oid4vc/integration/tests/conftest.py index 9558f729e..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): @@ -179,6 +210,49 @@ async def sdjwt_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 71899c97f..821a9db20 100644 --- a/oid4vc/integration/tests/test_interop/test_credo.py +++ b/oid4vc/integration/tests/test_interop/test_credo.py @@ -12,6 +12,14 @@ async def test_accept_credential_offer(credo: CredoWrapper, offer: Dict[str, Any 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 @pytest.mark.asyncio async def test_accept_credential_offer_sdjwt(credo: CredoWrapper, sdjwt_offer: str): @@ -19,6 +27,13 @@ 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( diff --git a/oid4vc/integration/tests/test_interop/test_sphereon.py b/oid4vc/integration/tests/test_interop/test_sphereon.py index c16fd4f8c..2c6ac4c38 100644 --- a/oid4vc/integration/tests/test_interop/test_sphereon.py +++ b/oid4vc/integration/tests/test_interop/test_sphereon.py @@ -20,3 +20,10 @@ async def test_api(sphereon: SphereaonWrapper): 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["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 9bc69b9d7..184cceb25 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 @@ -48,7 +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, CredOfferSchema +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" @@ -57,8 +58,8 @@ @docs(tags=["oid4vci"], summary="Dereference a credential offer.") -@querystring_schema(CredOfferQuerySchema) -@response_schema(CredOfferSchema, 200) +@querystring_schema(CredOfferQuerySchema()) +@response_schema(CredOfferResponseSchemaVal(), 200) async def dereference_cred_offer(request: web.BaseRequest): """Get a credential offer from a URI that has been acquired from the /oid4vci/credential-offer endpoint (see routes.get_cred_offer()). @@ -68,8 +69,12 @@ async def dereference_cred_offer(request: web.BaseRequest): """ context: AdminRequestContext = request["context"] exchange_id = request.query["exchange_id"] + offer = await _parse_cred_offer(context, exchange_id) - return web.json_response(offer) + return web.json_response({ + "offer": offer, + "credential_offer": f"openid-credential-offer://?credential_offer={quote(json.dumps(offer))}", + }) class CredentialIssuerMetadataSchema(OpenAPISchema): @@ -662,7 +667,7 @@ async def register(app: web.Application, multitenant: bool): subpath = "/tenant/{wallet_id}" if multitenant else "" app.add_routes( [ - web.get(f"/oid4vci/dereference-credential-offer", + web.get(f"{subpath}/oid4vci/dereference-credential-offer", dereference_cred_offer, allow_head=False ), diff --git a/oid4vc/oid4vc/routes.py b/oid4vc/oid4vc/routes.py index c734e8689..d1ddb28c3 100644 --- a/oid4vc/oid4vc/routes.py +++ b/oid4vc/oid4vc/routes.py @@ -423,11 +423,12 @@ async def get_cred_offer_by_ref(request: web.BaseRequest): if context.profile.settings.get("multitenant.enabled") else None ) - subpath = f"/tenant/{wallet_id}" if wallet_id else "" offer = await _parse_cred_offer(context, exchange_id) - # TODO: JANK JANK JANK you need to fix this - ref_uri = f"https://url:port/oid4vci/dereference-credential-offer" + + 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)}" From d33e8fb4b4e7566e501627214d712e7dae49234e Mon Sep 17 00:00:00 2001 From: Athan Massouras Date: Sat, 15 Feb 2025 18:14:28 -0800 Subject: [PATCH 5/6] fix: demo returns credential by value, not by reference Signed-off-by: Athan Massouras --- oid4vc/demo/frontend/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oid4vc/demo/frontend/index.js b/oid4vc/demo/frontend/index.js index 20bc56fc2..f87fdf2e1 100644 --- a/oid4vc/demo/frontend/index.js +++ b/oid4vc/demo/frontend/index.js @@ -214,7 +214,7 @@ async function issue_jwt_credential(req, res) { // Get Credential Offer information - const credentialOfferUrl = `${API_BASE_URL}/oid4vci/credential-offer-by-ref`; + const credentialOfferUrl = `${API_BASE_URL}/oid4vci/credential-offer`; const queryParams = { user_pin_required: false, exchange_id: exchangeId, From 4f649b8efdee88cee8893b59d3d97bcb3999160a Mon Sep 17 00:00:00 2001 From: Athan Massouras Date: Sat, 15 Feb 2025 18:38:56 -0800 Subject: [PATCH 6/6] fix: linter Signed-off-by: Athan Massouras --- oid4vc/oid4vc/public_routes.py | 9 ++++----- oid4vc/oid4vc/routes.py | 25 ++++++++++++++----------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/oid4vc/oid4vc/public_routes.py b/oid4vc/oid4vc/public_routes.py index 184cceb25..123a96ac6 100644 --- a/oid4vc/oid4vc/public_routes.py +++ b/oid4vc/oid4vc/public_routes.py @@ -61,11 +61,10 @@ @querystring_schema(CredOfferQuerySchema()) @response_schema(CredOfferResponseSchemaVal(), 200) async def dereference_cred_offer(request: web.BaseRequest): - """Get a credential offer from a URI that has been acquired from the /oid4vci/credential-offer - endpoint (see routes.get_cred_offer()). - - Works identically to routes.get_cred_offer() when returning by value, except it returns as a - JSON object rather than a URL encoding thereof. + """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"] diff --git a/oid4vc/oid4vc/routes.py b/oid4vc/oid4vc/routes.py index d1ddb28c3..0705e6fe8 100644 --- a/oid4vc/oid4vc/routes.py +++ b/oid4vc/oid4vc/routes.py @@ -338,15 +338,17 @@ class CredOfferResponseSchemaRef(OpenAPISchema): credential_offer_uri = fields.Str( required=True, metadata={ - "description": "A URL which references the credential for display by QR code.", + "description": "A URL which references the credential for display.", "example": "openid-credential-offer://...", }, ) offer = fields.Nested(CredOfferSchema(), required=True) 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""" + """Helper function for cred_offer request parsing. + + Used in get_cred_offer and public_routes.dereference_cred_offer endpoints. + """ config = Config.from_settings(context.settings) code = secrets.token_urlsafe(CODE_BYTES) @@ -392,11 +394,6 @@ async def get_cred_offer(request: web.BaseRequest): """ 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) offer_uri = quote(json.dumps(offer)) @@ -411,8 +408,10 @@ async def get_cred_offer(request: web.BaseRequest): @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) + """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. """ @@ -1232,7 +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/credential-offer-by-ref", + get_cred_offer_by_ref, + allow_head=False + ), web.get( "/oid4vci/exchange/records", list_exchange_records,