Skip to content

Commit def1537

Browse files
Athan13Athan Massouras
andauthored
oid4vc: cred_offer_uri (#1530)
* feat: first attempt at returning cred_offers by value Signed-off-by: Athan Massouras <athan@indicio.tech> * 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 <athan@indicio.tech> * fix: some progress in dereferencing cred offer Signed-off-by: Athan Massouras <athan@indicio.tech> * fix: credential offers by reference implemented and tested in credo and sphereon Signed-off-by: Athan Massouras <athan@indicio.tech> * fix: demo returns credential by value, not by reference Signed-off-by: Athan Massouras <athan@indicio.tech> * fix: linter Signed-off-by: Athan Massouras <athan@indicio.tech> --------- Signed-off-by: Athan Massouras <athan@indicio.tech> Co-authored-by: Athan Massouras <athan@indicio.tech>
1 parent f4c133a commit def1537

8 files changed

Lines changed: 216 additions & 25 deletions

File tree

oid4vc/demo/frontend/index.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,17 @@ async function issue_jwt_credential(req, res) {
232232
// Generate QRCode and send it to the browser via HTMX events
233233
logger.info(JSON.stringify(offerResponse.data));
234234
logger.info(exchangeId);
235-
const qrcode = credentialOffer.offer_uri;
235+
236+
let qrcode;
237+
if (credentialOffer.hasOwnProperty("credential_offer")) {
238+
// credential offer is passed by value
239+
qrcode = credentialOffer.credential_offer
240+
} else {
241+
// credential offer is passed by reference, and the wallet must dereference it using the
242+
// /oid4vci/dereference-credential-offer endpoint
243+
qrcode = credentialOffer.credential_offer_uri
244+
}
245+
236246
events.emit(`issuance-${req.body.registrationId}`, {type: "message", message: `Sending offer to user: ${qrcode}`});
237247
events.emit(`issuance-${req.body.registrationId}`, {type: "qrcode", credentialOffer, exchangeId, qrcode});
238248
exchangeCache.set(exchangeId, { exchangeId, credentialOffer, did, supportedCredId, registrationId: req.body.registrationId });
@@ -431,7 +441,17 @@ async function issue_sdjwt_credential(req, res) {
431441
// Generate QRCode and send it to the browser via HTMX events
432442
logger.info(JSON.stringify(offerResponse.data));
433443
logger.info(exchangeId);
434-
const qrcode = credentialOffer.offer_uri;
444+
445+
let qrcode;
446+
if (credentialOffer.hasOwnProperty("credential_offer")) {
447+
// credential offer is passed by value
448+
qrcode = credentialOffer.credential_offer
449+
} else {
450+
// credential offer is passed by reference, and the wallet must dereference it using the
451+
// /oid4vci/dereference-credential-offer endpoint
452+
qrcode = credentialOffer.credential_offer_uri
453+
}
454+
435455
events.emit(`issuance-${req.body.registrationId}`, {type: "message", message: `Sending offer to user: ${qrcode}`});
436456
events.emit(`issuance-${req.body.registrationId}`, {type: "qrcode", credentialOffer, exchangeId, qrcode});
437457
exchangeCache.set(exchangeId, { exchangeId, credentialOffer, did, supportedCredId, registrationId: req.body.registrationId });

oid4vc/integration/tests/conftest.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
from uuid import uuid4
33

44
from acapy_controller.controller import Controller
5+
from aiohttp import ClientSession
6+
from urllib.parse import urlparse, parse_qs
7+
58
import pytest
69
import pytest_asyncio
710

@@ -75,6 +78,34 @@ async def offer(controller: Controller, issuer_did: str, supported_cred_id: str)
7578
)
7679
yield offer
7780

81+
@pytest_asyncio.fixture
82+
async def offer_by_ref(controller: Controller, issuer_did: str, supported_cred_id: str):
83+
"""Create a credential offer."""
84+
exchange = await controller.post(
85+
"/oid4vci/exchange/create",
86+
json={
87+
"supported_cred_id": supported_cred_id,
88+
"credential_subject": {"name": "alice"},
89+
"verification_method": issuer_did + "#0",
90+
},
91+
)
92+
93+
exchange_param = {"exchange_id": exchange["exchange_id"]}
94+
offer_ref_full = await controller.get(
95+
"/oid4vci/credential-offer-by-ref",
96+
params=exchange_param,
97+
)
98+
99+
offer_ref = urlparse(offer_ref_full["credential_offer_uri"])
100+
offer_ref = parse_qs(offer_ref.query)["credential_offer"][0]
101+
async with ClientSession(
102+
headers=controller.headers
103+
) as session:
104+
async with session.request(
105+
"GET", url=offer_ref, params=exchange_param, headers=controller.headers
106+
) as offer:
107+
yield await offer.json()
108+
78109

79110
@pytest_asyncio.fixture
80111
async def sdjwt_supported_cred_id(controller: Controller, issuer_did: str):
@@ -174,11 +205,54 @@ async def sdjwt_offer(
174205
"/oid4vci/credential-offer",
175206
params={"exchange_id": exchange["exchange_id"]},
176207
)
177-
offer_uri = offer["offer_uri"]
208+
offer_uri = offer["credential_offer"]
178209

179210
yield offer_uri
180211

181212

213+
@pytest_asyncio.fixture
214+
async def sdjwt_offer_by_ref(
215+
controller: Controller, issuer_did: str, sdjwt_supported_cred_id: str
216+
):
217+
"""Create a cred offer for an SD-JWT VC."""
218+
exchange = await controller.post(
219+
"/oid4vci/exchange/create",
220+
json={
221+
"supported_cred_id": sdjwt_supported_cred_id,
222+
"credential_subject": {
223+
"given_name": "Erika",
224+
"family_name": "Mustermann",
225+
"source_document_type": "id_card",
226+
"age_equal_or_over": {
227+
"12": True,
228+
"14": True,
229+
"16": True,
230+
"18": True,
231+
"21": True,
232+
"65": False,
233+
},
234+
},
235+
"verification_method": issuer_did + "#0",
236+
},
237+
)
238+
239+
exchange_param = {"exchange_id": exchange["exchange_id"]}
240+
offer_ref_full = await controller.get(
241+
"/oid4vci/credential-offer-by-ref",
242+
params=exchange_param,
243+
)
244+
245+
offer_ref = urlparse(offer_ref_full["credential_offer_uri"])
246+
offer_ref = parse_qs(offer_ref.query)["credential_offer"][0]
247+
async with ClientSession(
248+
headers=controller.headers
249+
) as session:
250+
async with session.request(
251+
"GET", url=offer_ref, params=exchange_param, headers=controller.headers
252+
) as offer:
253+
yield (await offer.json())["credential_offer"]
254+
255+
182256
@pytest_asyncio.fixture
183257
async def presentation_definition_id(controller: Controller, issuer_did: str):
184258
"""Create a supported credential."""

oid4vc/integration/tests/test_interop/test_credo.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,15 @@
99
@pytest.mark.asyncio
1010
async def test_accept_credential_offer(credo: CredoWrapper, offer: Dict[str, Any]):
1111
"""Test OOB DIDExchange Protocol."""
12-
await credo.openid4vci_accept_offer(offer["offer_uri"])
12+
await credo.openid4vci_accept_offer(offer["credential_offer"])
13+
14+
15+
@pytest.mark.interop
16+
@pytest.mark.asyncio
17+
async def test_accept_credential_offer_by_ref(credo: CredoWrapper, offer_by_ref: Dict[str, Any]):
18+
"""Test OOB DIDExchange Protocol where offer is passed by reference from the
19+
credential-offer-by-ref endpoint and then dereferenced."""
20+
await credo.openid4vci_accept_offer(offer_by_ref["credential_offer"])
1321

1422

1523
@pytest.mark.interop
@@ -19,13 +27,20 @@ async def test_accept_credential_offer_sdjwt(credo: CredoWrapper, sdjwt_offer: s
1927
await credo.openid4vci_accept_offer(sdjwt_offer)
2028

2129

30+
@pytest.mark.interop
31+
@pytest.mark.asyncio
32+
async def test_accept_credential_offer_sdjwt_by_ref(credo: CredoWrapper, sdjwt_offer_by_ref: str):
33+
"""Test OOB DIDExchange Protocol where offer is passed by reference from the
34+
credential-offer-by-ref endpoint and then dereferenced."""
35+
await credo.openid4vci_accept_offer(sdjwt_offer_by_ref)
36+
2237
@pytest.mark.interop
2338
@pytest.mark.asyncio
2439
async def test_accept_auth_request(
2540
controller: Controller, credo: CredoWrapper, offer: Dict[str, Any], request_uri: str
2641
):
2742
"""Test OOB DIDExchange Protocol."""
28-
await credo.openid4vci_accept_offer(offer["offer_uri"])
43+
await credo.openid4vci_accept_offer(offer["credential_offer"])
2944
await credo.openid4vp_accept_request(request_uri)
3045
await controller.event_with_values("oid4vp", state="presentation-valid")
3146

oid4vc/integration/tests/test_interop/test_sphereon.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,11 @@ async def test_api(sphereon: SphereaonWrapper):
1919
@pytest.mark.asyncio
2020
async def test_sphereon_pre_auth(sphereon: SphereaonWrapper, offer: Dict[str, Any]):
2121
"""Test receive offer for pre auth code flow."""
22-
await sphereon.accept_credential_offer(offer["offer_uri"])
22+
await sphereon.accept_credential_offer(offer["credential_offer"])
23+
24+
@pytest.mark.interop
25+
@pytest.mark.asyncio
26+
async def test_sphereon_pre_auth_by_ref(sphereon: SphereaonWrapper, offer_by_ref: Dict[str, Any]):
27+
"""Test receive offer for pre auth code flow, where offer is passed by reference from the
28+
credential-offer-by-ref endpoint and then dereferenced."""
29+
await sphereon.accept_credential_offer(offer_by_ref["credential_offer"])

oid4vc/integration/tests/test_pre_auth_code_flow.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ async def test_pre_auth_code_flow_ed25519(test_client: OpenID4VCIClient, offer:
1111
did = test_client.generate_did("ed25519")
1212
response = await test_client.receive_offer(offer, did)
1313

14-
1514
@pytest.mark.asyncio
1615
async def test_pre_auth_code_flow_secp256k1(test_client: OpenID4VCIClient, offer: str):
1716
"""Connect to AFJ."""

oid4vc/oid4vc/public_routes.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import time
77
import uuid
88
from secrets import token_urlsafe
9+
from urllib.parse import quote
910
from typing import Any, Dict, List, Optional
1011

1112
from acapy_agent.admin.request_context import AdminRequestContext
@@ -28,6 +29,7 @@
2829
docs,
2930
form_schema,
3031
match_info_schema,
32+
querystring_schema,
3133
request_schema,
3234
response_schema,
3335
)
@@ -53,13 +55,33 @@
5355
from .models.exchange import OID4VCIExchangeRecord
5456
from .models.supported_cred import SupportedCredential
5557
from .pop_result import PopResult
58+
from .routes import _parse_cred_offer, CredOfferQuerySchema, CredOfferResponseSchemaVal
5659

5760
LOGGER = logging.getLogger(__name__)
5861
PRE_AUTHORIZED_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:pre-authorized_code"
5962
NONCE_BYTES = 16
6063
EXPIRES_IN = 86400
6164

6265

66+
@docs(tags=["oid4vci"], summary="Dereference a credential offer.")
67+
@querystring_schema(CredOfferQuerySchema())
68+
@response_schema(CredOfferResponseSchemaVal(), 200)
69+
async def dereference_cred_offer(request: web.BaseRequest):
70+
"""Dereference a credential offer.
71+
72+
Reference URI is acquired from the /oid4vci/credential-offer-by-ref endpoint
73+
(see routes.get_cred_offer_by_ref()).
74+
"""
75+
context: AdminRequestContext = request["context"]
76+
exchange_id = request.query["exchange_id"]
77+
78+
offer = await _parse_cred_offer(context, exchange_id)
79+
return web.json_response({
80+
"offer": offer,
81+
"credential_offer": f"openid-credential-offer://?credential_offer={quote(json.dumps(offer))}",
82+
})
83+
84+
6385
class CredentialIssuerMetadataSchema(OpenAPISchema):
6486
"""Credential issuer metadata schema."""
6587

@@ -698,6 +720,10 @@ async def register(app: web.Application, multitenant: bool):
698720
subpath = "/tenant/{wallet_id}" if multitenant else ""
699721
app.add_routes(
700722
[
723+
web.get(f"{subpath}/oid4vci/dereference-credential-offer",
724+
dereference_cred_offer,
725+
allow_head=False
726+
),
701727
web.get(
702728
f"{subpath}/.well-known/openid-credential-issuer",
703729
credential_issuer_metadata,

oid4vc/oid4vc/routes.py

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -327,32 +327,36 @@ class CredOfferSchema(OpenAPISchema):
327327
grants = fields.Nested(CredOfferGrantSchema(), required=True)
328328

329329

330-
class CredOfferResponseSchema(OpenAPISchema):
330+
class CredOfferResponseSchemaVal(OpenAPISchema):
331331
"""Credential Offer Schema."""
332332

333-
offer_uri = fields.Str(
333+
credential_offer = fields.Str(
334334
required=True,
335335
metadata={
336-
"description": "The URL of the credential issuer.",
336+
"description": "The URL of the credential value for display by QR code.",
337337
"example": "openid-credential-offer://...",
338338
},
339339
)
340340
offer = fields.Nested(CredOfferSchema(), required=True)
341341

342+
class CredOfferResponseSchemaRef(OpenAPISchema):
343+
"""Credential Offer Schema."""
342344

343-
@docs(tags=["oid4vci"], summary="Get a credential offer")
344-
@querystring_schema(CredOfferQuerySchema())
345-
@response_schema(CredOfferResponseSchema(), 200)
346-
@tenant_authentication
347-
async def get_cred_offer(request: web.BaseRequest):
348-
"""Endpoint to retrieve an OpenID4VCI compliant offer.
345+
credential_offer_uri = fields.Str(
346+
required=True,
347+
metadata={
348+
"description": "A URL which references the credential for display.",
349+
"example": "openid-credential-offer://...",
350+
},
351+
)
352+
offer = fields.Nested(CredOfferSchema(), required=True)
349353

350-
For example, can be used in QR-Code presented to a compliant wallet.
354+
async def _parse_cred_offer(context: AdminRequestContext, exchange_id: str) -> dict:
355+
"""Helper function for cred_offer request parsing.
356+
357+
Used in get_cred_offer and public_routes.dereference_cred_offer endpoints.
351358
"""
352-
context: AdminRequestContext = request["context"]
353359
config = Config.from_settings(context.settings)
354-
exchange_id = request.query["exchange_id"]
355-
356360
code = secrets.token_urlsafe(CODE_BYTES)
357361

358362
try:
@@ -375,7 +379,7 @@ async def get_cred_offer(request: web.BaseRequest):
375379
else None
376380
)
377381
subpath = f"/tenant/{wallet_id}" if wallet_id else ""
378-
offer = {
382+
return {
379383
"credential_issuer": f"{config.endpoint}{subpath}",
380384
"credentials": [supported.identifier],
381385
"grants": {
@@ -385,15 +389,57 @@ async def get_cred_offer(request: web.BaseRequest):
385389
}
386390
},
387391
}
392+
393+
@docs(tags=["oid4vci"], summary="Get a credential offer by value")
394+
@querystring_schema(CredOfferQuerySchema())
395+
@response_schema(CredOfferResponseSchemaVal(), 200)
396+
@tenant_authentication
397+
async def get_cred_offer(request: web.BaseRequest):
398+
"""Endpoint to retrieve an OpenID4VCI compliant offer by value.
399+
400+
For example, can be used in QR-Code presented to a compliant wallet.
401+
"""
402+
context: AdminRequestContext = request["context"]
403+
exchange_id = request.query["exchange_id"]
404+
405+
offer = await _parse_cred_offer(context, exchange_id)
388406
offer_uri = quote(json.dumps(offer))
389-
full_uri = f"openid-credential-offer://?credential_offer={offer_uri}"
390407
offer_response = {
391408
"offer": offer,
392-
"offer_uri": full_uri,
409+
"credential_offer": f"openid-credential-offer://?credential_offer={offer_uri}"
393410
}
394-
395411
return web.json_response(offer_response)
396412

413+
@docs(tags=["oid4vci"], summary="Get a credential offer by reference")
414+
@querystring_schema(CredOfferQuerySchema())
415+
@response_schema(CredOfferResponseSchemaRef(), 200)
416+
@tenant_authentication
417+
async def get_cred_offer_by_ref(request: web.BaseRequest):
418+
"""Endpoint to retrieve an OpenID4VCI compliant offer by reference.
419+
420+
credential_offer_uri can be dereferenced at the /oid4vc/dereference-credential-offer
421+
(see public_routes.dereference_cred_offer)
422+
423+
For example, can be used in QR-Code presented to a compliant wallet.
424+
"""
425+
context: AdminRequestContext = request["context"]
426+
exchange_id = request.query["exchange_id"]
427+
wallet_id = (
428+
context.profile.settings.get("wallet.id")
429+
if context.profile.settings.get("multitenant.enabled")
430+
else None
431+
)
432+
433+
offer = await _parse_cred_offer(context, exchange_id)
434+
435+
config = Config.from_settings(context.settings)
436+
subpath = f"/tenant/{wallet_id}" if wallet_id else ""
437+
ref_uri = f"{config.endpoint}{subpath}/oid4vci/dereference-credential-offer"
438+
offer_response = {
439+
"offer": offer,
440+
"credential_offer_uri": f"openid-credential-offer://?credential_offer={quote(ref_uri)}"
441+
}
442+
return web.json_response(offer_response)
397443

398444
class SupportedCredCreateRequestSchema(OpenAPISchema):
399445
"""Schema for SupportedCredCreateRequestSchema."""
@@ -1401,6 +1447,11 @@ async def register(app: web.Application):
14011447
app.add_routes(
14021448
[
14031449
web.get("/oid4vci/credential-offer", get_cred_offer, allow_head=False),
1450+
web.get(
1451+
"/oid4vci/credential-offer-by-ref",
1452+
get_cred_offer_by_ref,
1453+
allow_head=False
1454+
),
14041455
web.get(
14051456
"/oid4vci/exchange/records",
14061457
list_exchange_records,

oid4vc/oid4vc/tests/routes/test_public_routes.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
from oid4vc import public_routes as test_module
99

10-
1110
@pytest.mark.asyncio
1211
async def test_issuer_metadata(context: AdminRequestContext, req: web.Request):
1312
"""Test issuer metadata endpoint."""

0 commit comments

Comments
 (0)