Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions oid4vc/demo/frontend/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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 });
Expand Down
76 changes: 75 additions & 1 deletion oid4vc/integration/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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."""
Expand Down
19 changes: 17 additions & 2 deletions oid4vc/integration/tests/test_interop/test_credo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")

Expand Down
9 changes: 8 additions & 1 deletion oid4vc/integration/tests/test_interop/test_sphereon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
1 change: 0 additions & 1 deletion oid4vc/integration/tests/test_pre_auth_code_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
26 changes: 26 additions & 0 deletions oid4vc/oid4vc/public_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +29,7 @@
docs,
form_schema,
match_info_schema,
querystring_schema,
request_schema,
response_schema,
)
Expand All @@ -47,13 +49,33 @@
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"
NONCE_BYTES = 16
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."""

Expand Down Expand Up @@ -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,
Expand Down
85 changes: 68 additions & 17 deletions oid4vc/oid4vc/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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": {
Expand All @@ -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."""
Expand Down Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion oid4vc/oid4vc/tests/routes/test_public_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down