diff --git a/.dev/compose.openid4vc.yml b/.dev/compose.openid4vc.yml index 790065d90..73906b76c 100644 --- a/.dev/compose.openid4vc.yml +++ b/.dev/compose.openid4vc.yml @@ -2,7 +2,7 @@ name: runtime-oid4vc-tests services: oid4vc-service: - image: ghcr.io/js-soft/openid4vc-service:1.2.0@sha256:653358212651a992d211a187a0d405f56ae50b05d6c95bbdc37e1647fd8e6c33 + image: ghcr.io/js-soft/openid4vc-service:1.3.0@sha256:0ec8d7e1479d168c4760cc0fb3f7d9273985bc2d74759f09cd39e0b50fa45f6b ports: - "9000:9000" platform: linux/amd64 diff --git a/package-lock.json b/package-lock.json index b6df64187..dcb3a8183 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13060,9 +13060,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -16220,7 +16220,7 @@ "json-stringify-safe": "^5.0.1", "lodash": "^4.17.21", "luxon": "^3.7.2", - "qs": "^6.14.0", + "qs": "^6.14.1", "reflect-metadata": "^0.2.2", "ts-simple-nameof": "^1.3.3" }, diff --git a/packages/consumption/src/modules/openid4vc/OpenId4VcController.ts b/packages/consumption/src/modules/openid4vc/OpenId4VcController.ts index 618ab3b40..798471b83 100644 --- a/packages/consumption/src/modules/openid4vc/OpenId4VcController.ts +++ b/packages/consumption/src/modules/openid4vc/OpenId4VcController.ts @@ -124,4 +124,8 @@ export class OpenId4VcController extends ConsumptionBaseController { return { status: serverResponse.status, message: serverResponse.body }; } + + public async createDefaultPresentation(credential: VerifiableCredential): Promise { + return await this.holder.createDefaultPresentation(credential); + } } diff --git a/packages/consumption/src/modules/openid4vc/local/Holder.ts b/packages/consumption/src/modules/openid4vc/local/Holder.ts index b13639f8c..18a780c69 100644 --- a/packages/consumption/src/modules/openid4vc/local/Holder.ts +++ b/packages/consumption/src/modules/openid4vc/local/Holder.ts @@ -1,5 +1,19 @@ -import { BaseRecord, ClaimFormat, DidJwk, DidKey, InjectionSymbols, JwkDidCreateOptions, KeyDidCreateOptions, Kms, MdocRecord, SdJwtVcRecord, X509Module } from "@credo-ts/core"; +import { + BaseRecord, + ClaimFormat, + DidJwk, + DidKey, + InjectionSymbols, + JwkDidCreateOptions, + KeyDidCreateOptions, + Kms, + MdocRecord, + SdJwtVcApi, + SdJwtVcRecord, + X509Module +} from "@credo-ts/core"; import { OpenId4VciCredentialResponse, OpenId4VcModule, type OpenId4VciResolvedCredentialOffer, type OpenId4VpResolvedAuthorizationRequest } from "@credo-ts/openid4vc"; +import { VerifiableCredential } from "@nmshd/content"; import { AccountController } from "@nmshd/transport"; import { AttributesController, OwnIdentityAttribute } from "../../attributes"; import { BaseAgent } from "./BaseAgent"; @@ -196,6 +210,27 @@ export class Holder extends BaseAgent> return submissionResult.serverResponse; } + // hacky solution because credo doesn't support credentials without key binding + // TODO: use credentials without key binding once supported + public async createDefaultPresentation(credential: VerifiableCredential): Promise { + if (credential.type !== ClaimFormat.SdJwtDc) throw new Error("Only SD-JWT credentials have been tested so far with default presentation"); + + const sdJwtVcApi = this.agent.dependencyManager.resolve(SdJwtVcApi); + const presentation = await sdJwtVcApi.present({ + sdJwtVc: sdJwtVcApi.fromCompact(credential.value as string), + verifierMetadata: { + audience: "defaultPresentationAudience", + issuedAt: Date.now() / 1000, + nonce: "defaultPresentationNonce" + } + }); + + return VerifiableCredential.from({ + ...credential, + value: presentation + }); + } + public async exit(): Promise { await this.shutdown(); } diff --git a/packages/content/src/attributes/types/VerifiableCredential.ts b/packages/content/src/attributes/types/VerifiableCredential.ts index 20b87060f..dee12cb09 100644 --- a/packages/content/src/attributes/types/VerifiableCredential.ts +++ b/packages/content/src/attributes/types/VerifiableCredential.ts @@ -17,7 +17,7 @@ export interface IVerifiableCredential extends IAbstractAttributeValue { } @type("VerifiableCredential") -export class VerifiableCredential extends AbstractAttributeValue { +export class VerifiableCredential extends AbstractAttributeValue implements IVerifiableCredential { @serialize({ any: true }) @validate({ customValidator: validateValue }) public value: string | Record; diff --git a/packages/runtime/src/extensibility/facades/consumption/OpenId4VcFacade.ts b/packages/runtime/src/extensibility/facades/consumption/OpenId4VcFacade.ts index 17ff55885..00b65dc75 100644 --- a/packages/runtime/src/extensibility/facades/consumption/OpenId4VcFacade.ts +++ b/packages/runtime/src/extensibility/facades/consumption/OpenId4VcFacade.ts @@ -1,10 +1,12 @@ import { Result } from "@js-soft/ts-utils"; -import { LocalAttributeDTO } from "@nmshd/runtime-types"; +import { LocalAttributeDTO, TokenDTO } from "@nmshd/runtime-types"; import { Inject } from "@nmshd/typescript-ioc"; import { AcceptAuthorizationRequestRequest, AcceptAuthorizationRequestResponse, AcceptAuthorizationRequestUseCase, + CreatePresentationTokenRequest, + CreatePresentationTokenUseCase, RequestCredentialsRequest, RequestCredentialsResponse, RequestCredentialsUseCase, @@ -24,7 +26,8 @@ export class OpenId4VcFacade { @Inject private readonly requestCredentialsUseCase: RequestCredentialsUseCase, @Inject private readonly storeCredentialsUseCase: StoreCredentialsUseCase, @Inject private readonly resolveAuthorizationRequestUseCase: ResolveAuthorizationRequestUseCase, - @Inject private readonly acceptAuthorizationRequestUseCase: AcceptAuthorizationRequestUseCase + @Inject private readonly acceptAuthorizationRequestUseCase: AcceptAuthorizationRequestUseCase, + @Inject private readonly createPresentationTokenUseCase: CreatePresentationTokenUseCase ) {} public async resolveCredentialOffer(request: ResolveCredentialOfferRequest): Promise> { @@ -46,4 +49,8 @@ export class OpenId4VcFacade { public async acceptAuthorizationRequest(request: AcceptAuthorizationRequestRequest): Promise> { return await this.acceptAuthorizationRequestUseCase.execute(request); } + + public async createPresentationToken(request: CreatePresentationTokenRequest): Promise> { + return await this.createPresentationTokenUseCase.execute(request); + } } diff --git a/packages/runtime/src/useCases/common/Schemas.ts b/packages/runtime/src/useCases/common/Schemas.ts index 161e90d48..c5b9ffd37 100644 --- a/packages/runtime/src/useCases/common/Schemas.ts +++ b/packages/runtime/src/useCases/common/Schemas.ts @@ -17219,6 +17219,25 @@ export const AcceptAuthorizationRequestRequest: any = { } } +export const CreatePresentationTokenRequest: any = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/CreatePresentationTokenRequest", + "definitions": { + "CreatePresentationTokenRequest": { + "type": "object", + "properties": { + "attributeId": { + "type": "string" + } + }, + "required": [ + "attributeId" + ], + "additionalProperties": false + } + } +} + export const RequestCredentialsRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", "$ref": "#/definitions/RequestCredentialsRequest", diff --git a/packages/runtime/src/useCases/consumption/openid4vc/CreatePresentationToken.ts b/packages/runtime/src/useCases/consumption/openid4vc/CreatePresentationToken.ts new file mode 100644 index 000000000..4e5ad14cf --- /dev/null +++ b/packages/runtime/src/useCases/consumption/openid4vc/CreatePresentationToken.ts @@ -0,0 +1,45 @@ +import { Result } from "@js-soft/ts-utils"; +import { AttributesController, OpenId4VcController } from "@nmshd/consumption"; +import { VerifiableCredential } from "@nmshd/content"; +import { CoreDate, CoreId } from "@nmshd/core-types"; +import { TokenDTO } from "@nmshd/runtime-types"; +import { TokenController } from "@nmshd/transport"; +import { Inject } from "@nmshd/typescript-ioc"; +import { RuntimeErrors, SchemaRepository, SchemaValidator, UseCase } from "../../common"; +import { TokenMapper } from "../../transport/tokens/TokenMapper"; + +export interface CreatePresentationTokenRequest { + attributeId: string; +} + +class Validator extends SchemaValidator { + public constructor(@Inject schemaRepository: SchemaRepository) { + super(schemaRepository.getSchema("CreatePresentationTokenRequest")); + } +} + +export class CreatePresentationTokenUseCase extends UseCase { + public constructor( + @Inject private readonly openId4VcController: OpenId4VcController, + @Inject private readonly attributesController: AttributesController, + @Inject private readonly tokenController: TokenController, + @Inject validator: Validator + ) { + super(validator); + } + + protected override async executeInternal(request: CreatePresentationTokenRequest): Promise> { + const attribute = await this.attributesController.getLocalAttribute(CoreId.from(request.attributeId)); + if (!(attribute?.content.value instanceof VerifiableCredential)) return Result.fail(RuntimeErrors.general.recordNotFound("Attribute with Verifiable Credential")); + + const presentation = await this.openId4VcController.createDefaultPresentation(attribute.content.value); + + const token = await this.tokenController.sendToken({ + content: presentation.toJSON(), + expiresAt: CoreDate.utc().add({ minutes: 1 }), + ephemeral: true + }); + + return Result.ok(TokenMapper.toTokenDTO(token, true)); + } +} diff --git a/packages/runtime/src/useCases/consumption/openid4vc/index.ts b/packages/runtime/src/useCases/consumption/openid4vc/index.ts index d035ced3e..024905d25 100644 --- a/packages/runtime/src/useCases/consumption/openid4vc/index.ts +++ b/packages/runtime/src/useCases/consumption/openid4vc/index.ts @@ -1,4 +1,5 @@ export * from "./AcceptAuthorizationRequest"; +export * from "./CreatePresentationToken"; export * from "./RequestCredentials"; export * from "./ResolveAuthorizationRequest"; export * from "./ResolveCredentialOffer"; diff --git a/packages/runtime/test/consumption/openid4vc.test.ts b/packages/runtime/test/consumption/openid4vc.test.ts index 83166975f..ecbe82511 100644 --- a/packages/runtime/test/consumption/openid4vc.test.ts +++ b/packages/runtime/test/consumption/openid4vc.test.ts @@ -57,6 +57,8 @@ describe("custom openid4vc service", () => { let credentialOfferUrl: string; describe("sd-jwt", () => { + let attributeId: string; + test("should process a given sd-jwt credential offer", async () => { const response = await axiosInstance.post("/issuance/credentialOffers", { credentialConfigurationIds: ["EmployeeIdCard-sdjwt"] @@ -81,7 +83,8 @@ describe("custom openid4vc service", () => { }); const storeResult = await runtimeServices1.consumption.openId4Vc.storeCredentials({ credentialResponses: credentialResponseResult.value.credentialResponses }); expect(storeResult).toBeSuccessful(); - expect(typeof storeResult.value.id).toBe("string"); + attributeId = storeResult.value.id; + expect(typeof attributeId).toBe("string"); const credential = storeResult.value.content.value as VerifiableCredentialJSON; expect(credential.displayInformation?.[0].logo).toBeDefined(); @@ -256,6 +259,25 @@ describe("custom openid4vc service", () => { expect(acceptanceResult).toBeSuccessful(); expect(acceptanceResult.value.length).toBeGreaterThan(0); }); + + test("presentation with token", async () => { + const token = (await runtimeServices1.consumption.openId4Vc.createPresentationToken({ attributeId })).value; + + const loadedToken = (await runtimeServices1.anonymous.tokens.loadPeerToken({ reference: token.reference.url })).value; + + const credential = ((await runtimeServices1.consumption.attributes.getAttribute({ id: attributeId })).value.content.value as VerifiableCredentialJSON).value; + const presentation = (loadedToken.content as VerifiableCredentialJSON).value; + expect(presentation.substring(0, credential.length)).toBe(credential); + expect(presentation.substring(credential.length, credential.length + 2)).toBe("ey"); + + const verificationResult = await axiosInstance.post("/presentation/verify", { + credential: presentation, + format: "dc+sd-jwt", + nonce: "defaultPresentationNonce", + audience: "defaultPresentationAudience" + }); + expect(verificationResult.data.result.verified).toBe(true); + }); }); describe("mdoc", () => { @@ -324,7 +346,8 @@ describe("custom openid4vc service", () => { } ] }, - version: "v1.draft21" + version: "v1.draft21", + encryptResponse: true }); expect(response.status).toBe(200); const responseData = await response.data; diff --git a/packages/transport/package.json b/packages/transport/package.json index 174493cf8..cbec82fed 100644 --- a/packages/transport/package.json +++ b/packages/transport/package.json @@ -77,7 +77,7 @@ "json-stringify-safe": "^5.0.1", "lodash": "^4.17.21", "luxon": "^3.7.2", - "qs": "^6.14.0", + "qs": "^6.14.1", "reflect-metadata": "^0.2.2", "ts-simple-nameof": "^1.3.3" },