diff --git a/oid4vc/demo/frontend/index.js b/oid4vc/demo/frontend/index.js index d96a21efa..1828353fc 100644 --- a/oid4vc/demo/frontend/index.js +++ b/oid4vc/demo/frontend/index.js @@ -110,7 +110,7 @@ async function issue_jwt_credential(req, res) { // Create credential schema - const createCredentialSupportedUrl = `${API_BASE_URL}/oid4vci/credential-supported/create`; + const createCredentialSupportedUrl = `${API_BASE_URL}/oid4vci/credential-supported/create/jwt`; const createCredentialSupportedOptions = { method: "POST", headers: commonHeaders, @@ -130,43 +130,38 @@ async function issue_jwt_credential(req, res) { }, ], format: "jwt_vc_json", - format_data: { - credentialSubject: { - degree: {}, - given_name: { - display: [ - { - name: "Given Name", - locale: "en-US", - }, - ], - }, - gpa: { - display: [ - { - name: "GPA", - }, - ], - }, - last_name: { - display: [ - { - name: "Surname", - locale: "en-US", - }, - ], - }, + credentialSubject: { + degree: {}, + given_name: { + display: [ + { + name: "Given Name", + locale: "en-US", + }, + ], + }, + gpa: { + display: [ + { + name: "GPA", + }, + ], + }, + last_name: { + display: [ + { + name: "Surname", + locale: "en-US", + }, + ], }, - types: ["VerifiableCredential", "UniversityDegreeCredential"], }, + type: ["VerifiableCredential", "UniversityDegreeCredential"], id: "UniversityDegreeCredential", - vc_additional_data: { - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://www.w3.org/2018/credentials/examples/v1", - ], - type: ["VerifiableCredential", "UniversityDegreeCredential"], - }, + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1", + ] }), }; @@ -274,7 +269,7 @@ async function issue_sdjwt_credential(req, res) { // Create credential schema - const createCredentialSupportedUrl = `${API_BASE_URL}/oid4vci/credential-supported/create`; + const createCredentialSupportedUrl = `${API_BASE_URL}/oid4vci/credential-supported/create/sd-jwt`; const createCredentialSupportedOptions = { method: "POST", headers: commonHeaders, @@ -290,57 +285,53 @@ async function issue_sdjwt_credential(req, res) { "text_color": "#FFFFFF" } ], - format_data: { - vct: "ExampleIDCard", - "claims": { - "given_name": { + vct: "ExampleIDCard", + "claims": { + "given_name": { + "mandatory": true, + "value_type": "string", + }, + "family_name": { + "mandatory": true, + "value_type": "string", + }, + "age_equal_or_over": { + "12": { "mandatory": true, - "value_type": "string", + "value_type": "boolean", }, - "family_name": { + "14": { "mandatory": true, - "value_type": "string", + "value_type": "boolean", }, - "age_equal_or_over": { - "12": { - "mandatory": true, - "value_type": "boolean", - }, - "14": { - "mandatory": true, - "value_type": "boolean", - }, - "16": { - "mandatory": true, - "value_type": "boolean", - }, - "18": { - "mandatory": true, - "value_type": "boolean", - }, - "21": { - "mandatory": true, - "value_type": "boolean", - }, - "65": { - "mandatory": true, - "value_type": "boolean", - }, - } - }, + "16": { + "mandatory": true, + "value_type": "boolean", + }, + "18": { + "mandatory": true, + "value_type": "boolean", + }, + "21": { + "mandatory": true, + "value_type": "boolean", + }, + "65": { + "mandatory": true, + "value_type": "boolean", + }, + } }, - vc_additional_data: { - sd_list: [ - "/given_name", - "/family_name", - "/age_equal_or_over/12", - "/age_equal_or_over/14", - "/age_equal_or_over/16", - "/age_equal_or_over/18", - "/age_equal_or_over/21", - "/age_equal_or_over/65" - ] - } + sd_list: [ + "/given_name", + "/family_name", + "/age_equal_or_over/12", + "/age_equal_or_over/14", + "/age_equal_or_over/16", + "/age_equal_or_over/18", + "/age_equal_or_over/21", + "/age_equal_or_over/65" + ] }), }; diff --git a/oid4vc/oid4vc/routes.py b/oid4vc/oid4vc/routes.py index 4c5bc410c..bd9314b35 100644 --- a/oid4vc/oid4vc/routes.py +++ b/oid4vc/oid4vc/routes.py @@ -107,9 +107,7 @@ async def list_exchange_records(request: web.BaseRequest): try: async with context.profile.session() as session: if exchange_id := request.query.get("exchange_id"): - record = await OID4VCIExchangeRecord.retrieve_by_id( - session, exchange_id - ) + record = await OID4VCIExchangeRecord.retrieve_by_id(session, exchange_id) results = [record.serialize()] else: filter_ = { @@ -472,6 +470,131 @@ async def supported_credential_create(request: web.Request): return web.json_response(record.serialize()) +class JwtSupportedCredCreateRequestSchema(OpenAPISchema): + """Schema for SupportedCredCreateRequestSchema.""" + + format = fields.Str(required=True, metadata={"example": "jwt_vc_json"}) + identifier = fields.Str( + data_key="id", required=True, metadata={"example": "UniversityDegreeCredential"} + ) + cryptographic_binding_methods_supported = fields.List( + fields.Str(), metadata={"example": ["did"]} + ) + cryptographic_suites_supported = fields.List( + fields.Str(), metadata={"example": ["ES256K"]} + ) + display = fields.List( + fields.Dict(), + metadata={ + "example": [ + { + "name": "University Credential", + "locale": "en-US", + "logo": { + "url": "https://w3c-ccg.github.io/vc-ed/plugfest-1-2022/images/JFF_LogoLockup.png", + "alt_text": "a square logo of a university", + }, + "background_color": "#12107c", + "text_color": "#FFFFFF", + } + ] + }, + ) + type = fields.List( + fields.Str, + required=True, + metadata={ + "description": "List of credential types supported.", + "example": ["VerifiableCredential", "UniversityDegreeCredential"] + }, + ) + credential_subject = fields.Dict( + keys=fields.Str, + data_key="credentialSubject", + required=False, + metadata={ + "description": "Metadata about the Credential Subject to help with display.", + "example": { + "given_name": {"display": [{"name": "Given Name", "locale": "en-US"}]}, + "last_name": {"display": [{"name": "Surname", "locale": "en-US"}]}, + "degree": {}, + "gpa": {"display": [{"name": "GPA"}]}, + }, + }, + ) + order = fields.List( + fields.Str, + required=False, + metadata={ + "description": ( + "The order in which claims should be displayed. This is not well defined " + "by the spec right now. Best to omit for now." + ) + }, + ) + context = fields.List( + fields.Raw, + data_key="@context", + required=True, + metadata={ + "example": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1", + ], + }, + ) + + +@docs( + tags=["oid4vci"], summary="Register a configuration for a supported JWT VC credential" +) +@request_schema(JwtSupportedCredCreateRequestSchema()) +@response_schema(SupportedCredentialSchema()) +@tenant_authentication +async def supported_credential_create_jwt(request: web.Request): + """Request handler for creating a credential supported record.""" + context = request["context"] + assert isinstance(context, AdminRequestContext) + profile = context.profile + + body: Dict[str, Any] = await request.json() + LOGGER.info(f"body: {body}") + body["identifier"] = body.pop("id") + format_data = {} + format_data["types"] = body.pop("type") + format_data["credential_subject"] = body.pop("credentialSubject", None) + format_data["context"] = body.pop("@context") + format_data["order"] = body.pop("order", None) + vc_additional_data = {} + vc_additional_data["@context"] = format_data["context"] + # type vs types is deliberate; OID4VCI spec is inconsistent with VCDM + vc_additional_data["type"] = format_data["types"] + + record = SupportedCredential( + **body, + format_data=format_data, + vc_additional_data=vc_additional_data, + ) + + registered_processors = context.inject(CredProcessors) + if record.format not in registered_processors.issuers: + raise web.HTTPBadRequest( + reason=f"Format {record.format} is not supported by" + " currently registered processors" + ) + + processor = registered_processors.issuer_for_format(record.format) + try: + processor.validate_supported_credential(record) + except ValueError as err: + raise web.HTTPBadRequest(reason=str(err)) from err + + async with profile.session() as session: + await record.save(session, reason="Save credential supported record.") + + return web.json_response(record.serialize()) + + class SupportedCredentialQuerySchema(OpenAPISchema): """Query filters for credential supported record list query.""" @@ -561,9 +684,7 @@ async def supported_credential_remove(request: web.Request): try: async with context.session() as session: - record = await SupportedCredential.retrieve_by_id( - session, supported_cred_id - ) + record = await SupportedCredential.retrieve_by_id(session, supported_cred_id) await record.delete_record(session) except StorageNotFoundError as err: raise web.HTTPNotFound(reason=err.roll_up) from err @@ -733,9 +854,7 @@ async def list_oid4vp_presentations(request: web.Request): try: async with context.profile.session() as session: if presentation_id := request.query.get("presentation_id"): - record = await OID4VPPresentation.retrieve_by_id( - session, presentation_id - ) + record = await OID4VPPresentation.retrieve_by_id(session, presentation_id) results = [record.serialize()] else: filter_ = { @@ -913,9 +1032,7 @@ async def create_did_jwk(request: web.Request): jwk = json.loads(key.get_jwk_public()) jwk["use"] = "sig" - did = "did:jwk:" + bytes_to_b64( - json.dumps(jwk).encode(), urlsafe=True, pad=False - ) + did = "did:jwk:" + bytes_to_b64(json.dumps(jwk).encode(), urlsafe=True, pad=False) did_info = DIDInfo( did=did, @@ -942,8 +1059,10 @@ async def register(app: web.Application): ), web.post("/oid4vci/exchange/create", exchange_create), web.delete("/oid4vci/exchange/records/{exchange_id}", exchange_delete), + web.post("/oid4vci/credential-supported/create", supported_credential_create), web.post( - "/oid4vci/credential-supported/create", supported_credential_create + "/oid4vci/credential-supported/create/jwt", + supported_credential_create_jwt, ), web.get( "/oid4vci/credential-supported/records", @@ -973,14 +1092,14 @@ def post_process_routes(app: web.Application): app._state["swagger_dict"]["tags"].append( { "name": "oid4vci", - "description": "OpenID4VCI", + "description": "OpenID for VC Issuance", "externalDocs": {"description": "Specification", "url": VCI_SPEC_URI}, } ) app._state["swagger_dict"]["tags"].append( { "name": "oid4vp", - "description": "OpenID4VP", + "description": "OpenID for VP", "externalDocs": {"description": "Specification", "url": VP_SPEC_URI}, } ) diff --git a/oid4vc/sd_jwt_vc/routes.py b/oid4vc/sd_jwt_vc/routes.py new file mode 100644 index 000000000..ed5d4484c --- /dev/null +++ b/oid4vc/sd_jwt_vc/routes.py @@ -0,0 +1,191 @@ +"""SD-JWT VC extra routes.""" + +import logging +from typing import Any, Dict +from textwrap import dedent + +from aiohttp import web +from aiohttp_apispec import ( + docs, + request_schema, + response_schema, +) +from aries_cloudagent.admin.decorators.auth import tenant_authentication +from aries_cloudagent.admin.request_context import AdminRequestContext +from aries_cloudagent.messaging.models.openapi import OpenAPISchema +from marshmallow import fields + + +from oid4vc.cred_processor import CredProcessors + +from oid4vc.models.supported_cred import SupportedCredential, SupportedCredentialSchema + + +LOGGER = logging.getLogger(__name__) + + +class SdJwtSupportedCredCreateReq(OpenAPISchema): + """Schema for SdJwtSupportedCredCreateReq.""" + + format = fields.Str(required=True, metadata={"example": "jwt_vc_json"}) + identifier = fields.Str( + data_key="id", required=True, metadata={"example": "UniversityDegreeCredential"} + ) + cryptographic_binding_methods_supported = fields.List( + fields.Str(), metadata={"example": ["did"]} + ) + cryptographic_suites_supported = fields.List( + fields.Str(), metadata={"example": ["ES256K"]} + ) + display = fields.List( + fields.Dict(), + metadata={ + "example": [ + { + "name": "University Credential", + "locale": "en-US", + "logo": { + "url": "https://w3c-ccg.github.io/vc-ed/plugfest-1-2022/images/JFF_LogoLockup.png", + "alt_text": "a square logo of a university", + }, + "background_color": "#12107c", + "text_color": "#FFFFFF", + } + ] + }, + ) + vct = fields.Str( + required=True, + metadata={ + "description": ( + "String designating the type of a Credential. This MAY be a " + "URI but it can also be an arbitrary string value." + ), + "example": "https://example.com/id-card", + }, + ) + order = fields.List( + fields.Str, + required=False, + metadata={ + "description": ( + "The order in which claims should be displayed. This is not well defined " + "by the spec right now. Best to omit for now." + ), + }, + ) + claims = fields.Dict( + keys=fields.Str, + required=False, + metadata={ + "description": "Display information about claims.", + "example": { + "given_name": { + "display": [ + {"name": "Given Name", "locale": "en-US"}, + {"name": "Vorname", "locale": "de-DE"}, + ] + }, + "family_name": { + "display": [ + {"name": "Surname", "locale": "en-US"}, + {"name": "Nachname", "locale": "de-DE"}, + ] + }, + "email": {}, + "phone_number": {}, + "address": { + "street_address": {}, + "locality": {}, + "region": {}, + "country": {}, + }, + "birthdate": {}, + "is_over_18": {}, + "is_over_21": {}, + "is_over_65": {}, + }, + }, + ) + sd_list = fields.List( + fields.Str, + required=False, + metadata={ + "description": "List of JSON pointers to selectively disclosable attributes.", + "example": [ + "/given_name", + "/family_name", + "/email", + "/phone_number", + "/address", + "/is_over_18", + "/is_over_21", + "/is_over_65", + ] + }, + ) + + +@docs( + tags=["oid4vci"], + summary="Register a configuration for a supported SD-JWT VC credential", + description=dedent(""" + This endpoint feeds into the Credential Issuer Metadata reported by the Issuer to its clients. + + See the SD-JWT VC profile for more details on these properties: + https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0-ID1.html#name-credential-issuer-metadata-6 + """), # noqa +) +@request_schema(SdJwtSupportedCredCreateReq()) +@response_schema(SupportedCredentialSchema()) +@tenant_authentication +async def supported_credential_create(request: web.Request): + """Request handler for creating a credential supported record.""" + context = request["context"] + assert isinstance(context, AdminRequestContext) + profile = context.profile + + body: Dict[str, Any] = await request.json() + LOGGER.info(f"body: {body}") + body["identifier"] = body.pop("id") + format_data = {} + format_data["vct"] = body.pop("vct") + format_data["claims"] = body.pop("claims", None) + format_data["order"] = body.pop("order", None) + vc_additional_data = {} + vc_additional_data["sd_list"] = body.pop("sd_list", None) + + record = SupportedCredential( + **body, + format_data=format_data, + vc_additional_data=vc_additional_data, + ) + + registered_processors = context.inject(CredProcessors) + if record.format not in registered_processors.issuers: + raise web.HTTPBadRequest( + reason=f"Format {record.format} is not supported by" + " currently registered processors" + ) + + processor = registered_processors.issuer_for_format(record.format) + try: + processor.validate_supported_credential(record) + except ValueError as err: + raise web.HTTPBadRequest(reason=str(err)) from err + + async with profile.session() as session: + await record.save(session, reason="Save credential supported record.") + + return web.json_response(record.serialize()) + + +async def register(app: web.Application): + """Register routes.""" + app.add_routes( + [ + web.post( + "/oid4vci/credential-supported/create/sd-jwt", supported_credential_create + ), + ] + )