Skip to content

Commit

Permalink
Merge remote-tracking branch 'upstream/main' into webvh
Browse files Browse the repository at this point in the history
  • Loading branch information
jamshale committed Feb 24, 2025
2 parents 3204c45 + def1537 commit 59155cd
Show file tree
Hide file tree
Showing 8 changed files with 216 additions and 25 deletions.
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 @@ -53,13 +55,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 @@ -698,6 +720,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 @@ -327,32 +327,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 @@ -375,7 +379,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 @@ -385,15 +389,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 @@ -1401,6 +1447,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

0 comments on commit 59155cd

Please sign in to comment.