From 647f274574921fc40d290ac6ff3e3f62857e90c8 Mon Sep 17 00:00:00 2001 From: Tom Lanser Date: Thu, 5 Dec 2024 18:18:30 +0100 Subject: [PATCH 1/2] feat: Added OpenID Federation for issuer metadata resolvement Signed-off-by: Tom Lanser --- package.json | 1 + packages/oid4vci/package.json | 1 + packages/oid4vci/src/Oid4vciClient.ts | 4 +- .../src/__tests__/Oid4vciClient.test.ts | 193 +++++++++++++++++- .../credential-issuer-metadata.ts | 67 +++++- .../src/metadata/fetch-issuer-metadata.ts | 24 ++- packages/oid4vci/tests/full-flow.test.ts | 6 + pnpm-lock.yaml | 16 ++ 8 files changed, 291 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 9bc8471..76dcde9 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "types:check": "tsc --noEmit", "style:check": "biome check --unsafe", "style:fix": "biome check --write --unsafe", + "validate": "pnpm types:check && pnpm style:check", "build": "pnpm -r build", "test": "vitest", "release": "pnpm build && pnpm changeset publish --no-git-tag", diff --git a/packages/oid4vci/package.json b/packages/oid4vci/package.json index 5652779..e03610e 100644 --- a/packages/oid4vci/package.json +++ b/packages/oid4vci/package.json @@ -23,6 +23,7 @@ "dependencies": { "@animo-id/oauth2": "workspace:*", "@animo-id/oauth2-utils": "workspace:*", + "@openid-federation/core": "0.1.1-alpha.18", "valibot": "^0.42.1" }, "devDependencies": { diff --git a/packages/oid4vci/src/Oid4vciClient.ts b/packages/oid4vci/src/Oid4vciClient.ts index c80cb79..0613913 100644 --- a/packages/oid4vci/src/Oid4vciClient.ts +++ b/packages/oid4vci/src/Oid4vciClient.ts @@ -46,7 +46,7 @@ export interface Oid4vciClientOptions { /** * Callbacks required for the oid4vc client */ - callbacks: Omit + callbacks: Omit } export class Oid4vciClient { @@ -76,7 +76,7 @@ export class Oid4vciClient { public async resolveIssuerMetadata(credentialIssuer: string): Promise { return resolveIssuerMetadata(credentialIssuer, { - fetch: this.options.callbacks.fetch, + callbackContext: this.options.callbacks, }) } diff --git a/packages/oid4vci/src/__tests__/Oid4vciClient.test.ts b/packages/oid4vci/src/__tests__/Oid4vciClient.test.ts index a823dca..4306b81 100644 --- a/packages/oid4vci/src/__tests__/Oid4vciClient.test.ts +++ b/packages/oid4vci/src/__tests__/Oid4vciClient.test.ts @@ -1,5 +1,12 @@ -import { decodeJwt, preAuthorizedCodeGrantIdentifier } from '@animo-id/oauth2' -import { parseWithErrorHandling } from '@animo-id/oauth2-utils' +import { + HashAlgorithm, + type Jwk, + calculateJwkThumbprint, + decodeJwt, + preAuthorizedCodeGrantIdentifier, +} from '@animo-id/oauth2' +import { decodeBase64, encodeToUtf8String, parseWithErrorHandling } from '@animo-id/oauth2-utils' +import { createEntityConfiguration } from '@openid-federation/core' import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest' @@ -31,6 +38,9 @@ describe('Oid4vciClient', () => { http.get(paradymDraft13.credentialOfferUri.replace('?raw=true', ''), () => HttpResponse.json(paradymDraft13.credentialOfferObject) ), + http.get(`${paradymDraft13.credentialOfferObject.credential_issuer}/.well-known/openid-federation`, () => + HttpResponse.text(undefined, { status: 404 }) + ), http.get(`${paradymDraft13.credentialOfferObject.credential_issuer}/.well-known/openid-credential-issuer`, () => HttpResponse.json(paradymDraft13.credentialIssuerMetadata) ), @@ -132,6 +142,9 @@ describe('Oid4vciClient', () => { http.get(paradymDraft11.credentialOfferUri.replace('?raw=true', ''), () => HttpResponse.json(paradymDraft11.credentialOfferObject) ), + http.get(`${paradymDraft11.credentialOfferObject.credential_issuer}/.well-known/openid-federation`, () => + HttpResponse.text(undefined, { status: 404 }) + ), http.get(`${paradymDraft11.credentialOfferObject.credential_issuer}/.well-known/openid-credential-issuer`, () => HttpResponse.json(paradymDraft11.credentialIssuerMetadata) ), @@ -291,6 +304,9 @@ describe('Oid4vciClient', () => { http.get(`${bdrDraft13.credentialOfferObject.credential_issuer}/.well-known/openid-credential-issuer`, () => HttpResponse.json(bdrDraft13.credentialIssuerMetadata) ), + http.get(`${bdrDraft13.credentialOfferObject.credential_issuer}/.well-known/openid-federation`, () => + HttpResponse.text(undefined, { status: 404 }) + ), http.get(`${bdrDraft13.credentialOfferObject.credential_issuer}/.well-known/openid-configuration`, () => HttpResponse.text(undefined, { status: 404 }) ), @@ -501,6 +517,10 @@ describe('Oid4vciClient', () => { `${presentationDuringIssuance.credentialOfferObject.credential_issuer}/.well-known/openid-credential-issuer`, () => HttpResponse.json(presentationDuringIssuance.credentialIssuerMetadata) ), + http.get( + `${presentationDuringIssuance.credentialOfferObject.credential_issuer}/.well-known/openid-federation`, + () => HttpResponse.text(undefined, { status: 404 }) + ), http.get( `${presentationDuringIssuance.credentialOfferObject.credential_issuer}/.well-known/openid-configuration`, () => HttpResponse.text(undefined, { status: 404 }) @@ -663,4 +683,173 @@ describe('Oid4vciClient', () => { }) expect(credentialResponse.credentialResponse).toStrictEqual(presentationDuringIssuance.credentialResponse) }) + + test('receive a credential from Paradym using federation', async () => { + const { d, ...publicKeyJwk } = paradymDraft13.holderPrivateKeyJwk + const jwtSigner = getSignJwtCallback([paradymDraft13.holderPrivateKeyJwk]) + + const entityConfigurationJwt = await createEntityConfiguration({ + signJwtCallback: async ({ jwk, toBeSigned }) => { + const toBeSignedString = encodeToUtf8String(toBeSigned) + const [header, payload] = toBeSignedString.split('.') + + const headerObject = JSON.parse(encodeToUtf8String(decodeBase64(header))) + const payloadObject = JSON.parse(encodeToUtf8String(decodeBase64(payload))) + + const signed = await jwtSigner( + { publicJwk: jwk as Jwk, alg: 'ES256', method: 'jwk' }, + { + header: headerObject, + payload: payloadObject, + } + ) + + const [, , signature] = signed.jwt.split('.') + + return decodeBase64(signature) + }, + header: { + kid: await calculateJwkThumbprint({ + hashAlgorithm: HashAlgorithm.Sha256, + hashCallback: callbacks.hash, + jwk: paradymDraft13.holderPrivateKeyJwk, + }), + alg: 'ES256', + typ: 'entity-statement+jwt', + }, + claims: { + exp: new Date(new Date().getTime() + 1000 * 60 * 60 * 24).getTime(), + iat: new Date().getTime(), + sub: paradymDraft13.credentialIssuerMetadata.credential_issuer, + iss: paradymDraft13.credentialIssuerMetadata.credential_issuer, + jwks: { + keys: [ + { + ...publicKeyJwk, + alg: 'ES256', + kid: await calculateJwkThumbprint({ + hashAlgorithm: HashAlgorithm.Sha256, + hashCallback: callbacks.hash, + jwk: paradymDraft13.holderPrivateKeyJwk, + }), + }, + ], + }, + metadata: { + openid_provider: { + ...paradymDraft13.credentialIssuerMetadata, + client_registration_types_supported: ['automatic'], + }, + }, + }, + }) + + server.resetHandlers( + http.get(paradymDraft13.credentialOfferUri.replace('?raw=true', ''), () => + HttpResponse.json(paradymDraft13.credentialOfferObject) + ), + http.get(`${paradymDraft13.credentialOfferObject.credential_issuer}/.well-known/openid-federation`, () => + HttpResponse.text(entityConfigurationJwt, { + headers: { + 'Content-Type': 'application/entity-statement+jwt', + }, + }) + ), + http.get(`${paradymDraft13.credentialOfferObject.credential_issuer}/.well-known/openid-credential-issuer`, () => + HttpResponse.json(paradymDraft13.credentialIssuerMetadata) + ), + http.get(`${paradymDraft13.credentialOfferObject.credential_issuer}/.well-known/openid-configuration`, () => + HttpResponse.text(undefined, { status: 404 }) + ), + http.get(`${paradymDraft13.credentialOfferObject.credential_issuer}/.well-known/oauth-authorization-server`, () => + HttpResponse.text(undefined, { status: 404 }) + ), + http.post(paradymDraft13.credentialIssuerMetadata.token_endpoint, async ({ request }) => { + expect(parseXwwwFormUrlEncoded(await request.text())).toEqual({ + 'pre-authorized_code': '1130293840889780123292078', + grant_type: preAuthorizedCodeGrantIdentifier, + resource: credentialOffer.credential_issuer, + }) + + return HttpResponse.json(paradymDraft13.accessTokenResponse) + }), + http.post(paradymDraft13.credentialIssuerMetadata.credential_endpoint, async ({ request }) => { + expect(await request.json()).toEqual({ + format: 'vc+sd-jwt', + vct: 'https://metadata.paradym.id/types/6fTEgFULv2-EmployeeBadge', + proof: { + proof_type: 'jwt', + jwt: expect.any(String), + }, + }) + return HttpResponse.json(paradymDraft13.credentialResponse) + }) + ) + + const client = new Oid4vciClient({ + callbacks: { + ...callbacks, + fetch, + signJwt: jwtSigner, + }, + }) + + const credentialOffer = await client.resolveCredentialOffer(paradymDraft13.credentialOffer) + expect(credentialOffer).toStrictEqual(paradymDraft13.credentialOfferObject) + + const issuerMetadata = await client.resolveIssuerMetadata(credentialOffer.credential_issuer) + expect(issuerMetadata.credentialIssuer).toStrictEqual({ + ...paradymDraft13.credentialIssuerMetadata, + client_registration_types_supported: ['automatic'], + }) + + const { accessTokenResponse, authorizationServer } = await client.retrievePreAuthorizedCodeAccessTokenFromOffer({ + credentialOffer, + issuerMetadata, + }) + expect(accessTokenResponse).toStrictEqual(paradymDraft13.accessTokenResponse) + expect(authorizationServer).toStrictEqual(paradymDraft13.credentialIssuerMetadata.credential_issuer) + + const encodedJwk = Buffer.from(JSON.stringify(publicKeyJwk)).toString('base64url') + const didUrl = `did:jwk:${encodedJwk}#0` + + const { jwt: proofJwt } = await client.createCredentialRequestJwtProof({ + issuerMetadata, + signer: { + alg: 'ES256', + method: 'did', + didUrl, + }, + issuedAt: new Date('2024-10-10'), + credentialConfigurationId: credentialOffer.credential_configuration_ids[0], + nonce: accessTokenResponse.c_nonce, + }) + expect(proofJwt).toMatch( + 'eyJhbGciOiJFUzI1NiIsInR5cCI6Im9wZW5pZDR2Y2ktcHJvb2Yrand0Iiwia2lkIjoiZGlkOmp3azpleUpyZEhraU9pSkZReUlzSW5naU9pSkJSVmh3U0hreE1FZG9kRmRvYkZaUVRtMXlSbk5pZVhSZmQwUnpVVjgzY1ROa2FrNXVjbWg2YWw4MElpd2llU0k2SWtSSFZFRkRUMEZCYmxGVVpYQmhSRFF3WjNsSE9WcHNMVzlFYUU5c2RqTlZRbXhVZEhoSlpYSTFaVzhpTENKamNuWWlPaUpRTFRJMU5pSjkjMCJ9.eyJhdWQiOiJodHRwczovL2FnZW50LnBhcmFkeW0uaWQvb2lkNHZjaS9kcmFmdC0xMy1pc3N1ZXIiLCJpYXQiOjE3Mjg1MTg0MDAsIm5vbmNlIjoiNDYzMjUzOTE3MDk0ODY5MTcyMDc4MzEwIn0.' + ) + expect(decodeJwt({ jwt: proofJwt })).toStrictEqual({ + header: { + alg: 'ES256', + kid: 'did:jwk:eyJrdHkiOiJFQyIsIngiOiJBRVhwSHkxMEdodFdobFZQTm1yRnNieXRfd0RzUV83cTNkak5ucmh6al80IiwieSI6IkRHVEFDT0FBblFUZXBhRDQwZ3lHOVpsLW9EaE9sdjNVQmxUdHhJZXI1ZW8iLCJjcnYiOiJQLTI1NiJ9#0', + typ: 'openid4vci-proof+jwt', + }, + payload: { + aud: 'https://agent.paradym.id/oid4vci/draft-13-issuer', + iat: 1728518400, + nonce: '463253917094869172078310', + }, + signature: expect.any(String), + }) + + const credentialResponse = await client.retrieveCredentials({ + accessToken: accessTokenResponse.access_token, + credentialConfigurationId: credentialOffer.credential_configuration_ids[0], + issuerMetadata, + proof: { + proof_type: 'jwt', + jwt: proofJwt, + }, + }) + expect(credentialResponse.credentialResponse).toStrictEqual(paradymDraft13.credentialResponse) + }) }) diff --git a/packages/oid4vci/src/metadata/credential-issuer/credential-issuer-metadata.ts b/packages/oid4vci/src/metadata/credential-issuer/credential-issuer-metadata.ts index ee10c90..97692fc 100644 --- a/packages/oid4vci/src/metadata/credential-issuer/credential-issuer-metadata.ts +++ b/packages/oid4vci/src/metadata/credential-issuer/credential-issuer-metadata.ts @@ -1,5 +1,8 @@ -import { Oauth2Error, fetchWellKnownMetadata } from '@animo-id/oauth2' -import { type Fetch, joinUriParts } from '@animo-id/oauth2-utils' +import { type CallbackContext, type Jwk, Oauth2Error, fetchWellKnownMetadata } from '@animo-id/oauth2' +import { joinUriParts } from '@animo-id/oauth2-utils' +import { fetchEntityConfiguration } from '@openid-federation/core' +import * as v from 'valibot' +import type { JwtHeader } from '../../../../oauth2/src/common/jwt/v-jwt' import type { CredentialFormatIdentifier } from '../../formats/credential' import type { Oid4vciDraftVersion } from '../../version' import { @@ -13,20 +16,72 @@ import { const wellKnownCredentialIssuerSuffix = '.well-known/openid-credential-issuer' +type FetchCredentialIssuerMetadataOptions = { + callbackContext: Pick +} + /** * @inheritdoc {@link fetchWellKnownMetadata} */ export async function fetchCredentialIssuerMetadata( credentialIssuer: string, - fetch?: Fetch + options: FetchCredentialIssuerMetadataOptions ): Promise<{ credentialIssuerMetadata: CredentialIssuerMetadata; originalDraftVersion: Oid4vciDraftVersion } | null> { - const wellKnownMetadataUrl = joinUriParts(credentialIssuer, [wellKnownCredentialIssuerSuffix]) - const result = await fetchWellKnownMetadata(wellKnownMetadataUrl, vCredentialIssuerMetadataWithDraftVersion, fetch) + // TODO: What should we do when it has the property trust_chain? + + let result: v.InferOutput | null = null + + const entityConfiguration = await fetchEntityConfiguration({ + entityId: credentialIssuer, + fetchCallback: options.callbackContext.fetch, + verifyJwtCallback: async ({ jwt, header, claims, jwk }) => { + if (!jwk.alg) throw new Oauth2Error('JWK alg is required.') + if (!header.alg || typeof header.alg !== 'string') throw new Oauth2Error('header alg is required.') + + const { verified } = await options.callbackContext.verifyJwt( + { + alg: jwk.alg, + method: 'jwk', + publicJwk: jwk as Jwk, // TODO: Check why this type is not correct + }, + { + header: header as JwtHeader, + payload: claims, + compact: jwt, + } + ) + return verified + }, + }).catch((error) => { + // TODO: Not really sure what we want to do with the error. I think most of the times it will be a 404. + return null + }) + + if (entityConfiguration) { + const credentialIssuerMetadata = await v.safeParseAsync( + vCredentialIssuerMetadataWithDraftVersion, + entityConfiguration.metadata?.openid_provider + ) + + if (credentialIssuerMetadata.success) { + result = credentialIssuerMetadata.output + } + } + + // When the result isn't set yet we continue with the well known credential issuer metadata + if (!result) { + const wellKnownMetadataUrl = joinUriParts(credentialIssuer, [wellKnownCredentialIssuerSuffix]) + result = await fetchWellKnownMetadata( + wellKnownMetadataUrl, + vCredentialIssuerMetadataWithDraftVersion, + options.callbackContext.fetch + ) + } // credential issuer param MUST match if (result && result.credentialIssuerMetadata.credential_issuer !== credentialIssuer) { throw new Oauth2Error( - `The 'credential_issuer' parameter '${result.credentialIssuerMetadata.credential_issuer}' in the well known credential issuer metadata at '${wellKnownMetadataUrl}' does not match the provided credential issuer '${credentialIssuer}'.` + `The 'credential_issuer' parameter '${result.credentialIssuerMetadata.credential_issuer}' in the credential issuer metadata does not match the provided credential issuer '${credentialIssuer}'.` ) } diff --git a/packages/oid4vci/src/metadata/fetch-issuer-metadata.ts b/packages/oid4vci/src/metadata/fetch-issuer-metadata.ts index 2425267..7801991 100644 --- a/packages/oid4vci/src/metadata/fetch-issuer-metadata.ts +++ b/packages/oid4vci/src/metadata/fetch-issuer-metadata.ts @@ -1,10 +1,10 @@ import { type AuthorizationServerMetadata, + type CallbackContext, Oauth2Error, fetchAuthorizationServerMetadata, vAuthorizationServerMetadata, } from '@animo-id/oauth2' -import type { Fetch } from '@animo-id/oauth2-utils' import { parseWithErrorHandling } from '@animo-id/oauth2-utils' import type { Oid4vciDraftVersion } from '../version' import { fetchCredentialIssuerMetadata } from './credential-issuer/credential-issuer-metadata' @@ -27,10 +27,7 @@ export interface ResolveIssuerMetadataOptions { */ allowAuthorizationMetadataFromCredentialIssuerMetadata?: boolean - /** - * Custom fetch implementation to use - */ - fetch?: Fetch + callbackContext: Pick } export interface IssuerMetadataResult { @@ -41,12 +38,14 @@ export interface IssuerMetadataResult { export async function resolveIssuerMetadata( credentialIssuer: string, - options?: ResolveIssuerMetadataOptions + options: ResolveIssuerMetadataOptions ): Promise { const allowAuthorizationMetadataFromCredentialIssuerMetadata = options?.allowAuthorizationMetadataFromCredentialIssuerMetadata ?? true - const credentialIssuerMetadataWithDraftVersion = await fetchCredentialIssuerMetadata(credentialIssuer, options?.fetch) + const credentialIssuerMetadataWithDraftVersion = await fetchCredentialIssuerMetadata(credentialIssuer, { + callbackContext: options.callbackContext, + }) if (!credentialIssuerMetadataWithDraftVersion) { throw new Oauth2Error(`Well known credential issuer metadata for issuer '${credentialIssuer}' not found.`) } @@ -56,7 +55,7 @@ export async function resolveIssuerMetadata( // If no authoriation servers are defined, use the credential issuer as the authorization server const authorizationServers = credentialIssuerMetadata.authorization_servers ?? [credentialIssuer] - const authoriationServersMetadata: AuthorizationServerMetadata[] = [] + const authorizationServersMetadata: AuthorizationServerMetadata[] = [] for (const authorizationServer of authorizationServers) { if ( options?.restrictToAuthorizationServers && @@ -65,7 +64,10 @@ export async function resolveIssuerMetadata( continue } - let authorizationServerMetadata = await fetchAuthorizationServerMetadata(authorizationServer, options?.fetch) + let authorizationServerMetadata = await fetchAuthorizationServerMetadata( + authorizationServer, + options.callbackContext.fetch + ) if ( !authorizationServerMetadata && authorizationServer === credentialIssuer && @@ -87,12 +89,12 @@ export async function resolveIssuerMetadata( ) } - authoriationServersMetadata.push(authorizationServerMetadata) + authorizationServersMetadata.push(authorizationServerMetadata) } return { originalDraftVersion, credentialIssuer: credentialIssuerMetadata, - authorizationServers: authoriationServersMetadata, + authorizationServers: authorizationServersMetadata, } } diff --git a/packages/oid4vci/tests/full-flow.test.ts b/packages/oid4vci/tests/full-flow.test.ts index c197c40..b8bb971 100644 --- a/packages/oid4vci/tests/full-flow.test.ts +++ b/packages/oid4vci/tests/full-flow.test.ts @@ -149,6 +149,9 @@ describe('Full E2E test', () => { http.get('https://oid4vc-ts-issuer.com/offers/1f9f284a-3b37-4d92-adb4-6339c9b7ca68', () => HttpResponse.json(createdCredentialOffer.credentialOfferObject) ), + http.get(`${credentialIssuerMetadata.credential_issuer}/.well-known/openid-federation`, () => + HttpResponse.text(undefined, { status: 404 }) + ), http.get(`${credentialIssuerMetadata.credential_issuer}/.well-known/openid-credential-issuer`, () => HttpResponse.json(credentialIssuerMetadata) ), @@ -441,6 +444,9 @@ describe('Full E2E test', () => { http.get(`${credentialIssuerMetadata.credential_issuer}/.well-known/openid-credential-issuer`, () => HttpResponse.json(credentialIssuerMetadata) ), + http.get(`${credentialIssuerMetadata.credential_issuer}/.well-known/openid-federation`, () => + HttpResponse.text(undefined, { status: 404 }) + ), http.get(`${authorizationServerMetadata.issuer}/.well-known/openid-configuration`, () => HttpResponse.text(undefined, { status: 404 }) ), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65d28d0..d9041a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: '@animo-id/oauth2-utils': specifier: workspace:* version: link:../utils + '@openid-federation/core': + specifier: 0.1.1-alpha.18 + version: 0.1.1-alpha.18 valibot: specifier: ^0.42.1 version: 0.42.1(typescript@5.6.3) @@ -543,6 +546,9 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@openid-federation/core@0.1.1-alpha.18': + resolution: {integrity: sha512-KtsC6peYIhxuMl7J2srttEzp8XTP8btBHKOshak1J/5R2PZvVawobiS51KeAcyZNTTpDtGrt9j5NQh0tpuJH2g==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1555,6 +1561,9 @@ packages: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} + zod@3.23.8: + resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + snapshots: '@babel/runtime@7.25.7': @@ -1990,6 +1999,11 @@ snapshots: '@open-draft/until@2.1.0': {} + '@openid-federation/core@0.1.1-alpha.18': + dependencies: + buffer: 6.0.3 + zod: 3.23.8 + '@pkgjs/parseargs@0.11.0': optional: true @@ -2925,3 +2939,5 @@ snapshots: yargs-parser: 21.1.1 yoctocolors-cjs@2.1.2: {} + + zod@3.23.8: {} From 2d13142f0cdb0e6c9d074b23b58e2628f41fa541 Mon Sep 17 00:00:00 2001 From: Tom Lanser Date: Thu, 5 Dec 2024 19:14:20 +0100 Subject: [PATCH 2/2] chore: cleanup and small bugfix Signed-off-by: Tom Lanser --- .../src/__tests__/Oid4vciClient.test.ts | 84 +++++----- .../src/__tests__/__fixtures__/paradym.ts | 150 ++++++++++++++++++ .../proof-type/jwt/v-jwt-proof-type.ts | 5 +- .../credential-issuer-metadata.ts | 4 +- 4 files changed, 199 insertions(+), 44 deletions(-) diff --git a/packages/oid4vci/src/__tests__/Oid4vciClient.test.ts b/packages/oid4vci/src/__tests__/Oid4vciClient.test.ts index 4306b81..cf7910d 100644 --- a/packages/oid4vci/src/__tests__/Oid4vciClient.test.ts +++ b/packages/oid4vci/src/__tests__/Oid4vciClient.test.ts @@ -15,7 +15,7 @@ import { callbacks, getSignJwtCallback, parseXwwwFormUrlEncoded } from '../../.. import { AuthorizationFlow, Oid4vciClient } from '../Oid4vciClient' import { extractScopesForCredentialConfigurationIds } from '../metadata/credential-issuer/credential-configurations' import { bdrDraft13 } from './__fixtures__/bdr' -import { paradymDraft11, paradymDraft13 } from './__fixtures__/paradym' +import { paradymDraft11, paradymDraft13, paradymDraft13Federation } from './__fixtures__/paradym' import { presentationDuringIssuance } from './__fixtures__/presentationDuringIssuance' const server = setupServer() @@ -685,8 +685,13 @@ describe('Oid4vciClient', () => { }) test('receive a credential from Paradym using federation', async () => { - const { d, ...publicKeyJwk } = paradymDraft13.holderPrivateKeyJwk - const jwtSigner = getSignJwtCallback([paradymDraft13.holderPrivateKeyJwk]) + const { d, ...publicKeyJwk } = paradymDraft13Federation.holderPrivateKeyJwk + const jwtSigner = getSignJwtCallback([paradymDraft13Federation.holderPrivateKeyJwk]) + const jwkKid = await calculateJwkThumbprint({ + hashAlgorithm: HashAlgorithm.Sha256, + hashCallback: callbacks.hash, + jwk: paradymDraft13Federation.holderPrivateKeyJwk, + }) const entityConfigurationJwt = await createEntityConfiguration({ signJwtCallback: async ({ jwk, toBeSigned }) => { @@ -709,35 +714,28 @@ describe('Oid4vciClient', () => { return decodeBase64(signature) }, header: { - kid: await calculateJwkThumbprint({ - hashAlgorithm: HashAlgorithm.Sha256, - hashCallback: callbacks.hash, - jwk: paradymDraft13.holderPrivateKeyJwk, - }), + kid: jwkKid, alg: 'ES256', typ: 'entity-statement+jwt', }, claims: { exp: new Date(new Date().getTime() + 1000 * 60 * 60 * 24).getTime(), iat: new Date().getTime(), - sub: paradymDraft13.credentialIssuerMetadata.credential_issuer, - iss: paradymDraft13.credentialIssuerMetadata.credential_issuer, + sub: paradymDraft13Federation.credentialIssuerMetadata.credential_issuer, + iss: paradymDraft13Federation.credentialIssuerMetadata.credential_issuer, jwks: { keys: [ { ...publicKeyJwk, alg: 'ES256', - kid: await calculateJwkThumbprint({ - hashAlgorithm: HashAlgorithm.Sha256, - hashCallback: callbacks.hash, - jwk: paradymDraft13.holderPrivateKeyJwk, - }), + kid: jwkKid, }, ], }, metadata: { + federation_entity: paradymDraft13Federation.federationEntity, openid_provider: { - ...paradymDraft13.credentialIssuerMetadata, + ...paradymDraft13Federation.credentialIssuerMetadata, client_registration_types_supported: ['automatic'], }, }, @@ -745,35 +743,40 @@ describe('Oid4vciClient', () => { }) server.resetHandlers( - http.get(paradymDraft13.credentialOfferUri.replace('?raw=true', ''), () => - HttpResponse.json(paradymDraft13.credentialOfferObject) + http.get(paradymDraft13Federation.credentialOfferUri.replace('?raw=true', ''), () => + HttpResponse.json(paradymDraft13Federation.credentialOfferObject) ), - http.get(`${paradymDraft13.credentialOfferObject.credential_issuer}/.well-known/openid-federation`, () => - HttpResponse.text(entityConfigurationJwt, { - headers: { - 'Content-Type': 'application/entity-statement+jwt', - }, - }) + http.get( + `${paradymDraft13Federation.credentialOfferObject.credential_issuer}/.well-known/openid-federation`, + () => + HttpResponse.text(entityConfigurationJwt, { + headers: { + 'Content-Type': 'application/entity-statement+jwt', + }, + }) ), - http.get(`${paradymDraft13.credentialOfferObject.credential_issuer}/.well-known/openid-credential-issuer`, () => - HttpResponse.json(paradymDraft13.credentialIssuerMetadata) + http.get( + `${paradymDraft13Federation.credentialOfferObject.credential_issuer}/.well-known/openid-credential-issuer`, + () => HttpResponse.json(paradymDraft13Federation.credentialIssuerMetadata) ), - http.get(`${paradymDraft13.credentialOfferObject.credential_issuer}/.well-known/openid-configuration`, () => - HttpResponse.text(undefined, { status: 404 }) + http.get( + `${paradymDraft13Federation.credentialOfferObject.credential_issuer}/.well-known/openid-configuration`, + () => HttpResponse.text(undefined, { status: 404 }) ), - http.get(`${paradymDraft13.credentialOfferObject.credential_issuer}/.well-known/oauth-authorization-server`, () => - HttpResponse.text(undefined, { status: 404 }) + http.get( + `${paradymDraft13Federation.credentialOfferObject.credential_issuer}/.well-known/oauth-authorization-server`, + () => HttpResponse.text(undefined, { status: 404 }) ), - http.post(paradymDraft13.credentialIssuerMetadata.token_endpoint, async ({ request }) => { + http.post(paradymDraft13Federation.credentialIssuerMetadata.token_endpoint, async ({ request }) => { expect(parseXwwwFormUrlEncoded(await request.text())).toEqual({ 'pre-authorized_code': '1130293840889780123292078', grant_type: preAuthorizedCodeGrantIdentifier, resource: credentialOffer.credential_issuer, }) - return HttpResponse.json(paradymDraft13.accessTokenResponse) + return HttpResponse.json(paradymDraft13Federation.accessTokenResponse) }), - http.post(paradymDraft13.credentialIssuerMetadata.credential_endpoint, async ({ request }) => { + http.post(paradymDraft13Federation.credentialIssuerMetadata.credential_endpoint, async ({ request }) => { expect(await request.json()).toEqual({ format: 'vc+sd-jwt', vct: 'https://metadata.paradym.id/types/6fTEgFULv2-EmployeeBadge', @@ -782,7 +785,7 @@ describe('Oid4vciClient', () => { jwt: expect.any(String), }, }) - return HttpResponse.json(paradymDraft13.credentialResponse) + return HttpResponse.json(paradymDraft13Federation.credentialResponse) }) ) @@ -794,12 +797,12 @@ describe('Oid4vciClient', () => { }, }) - const credentialOffer = await client.resolveCredentialOffer(paradymDraft13.credentialOffer) - expect(credentialOffer).toStrictEqual(paradymDraft13.credentialOfferObject) + const credentialOffer = await client.resolveCredentialOffer(paradymDraft13Federation.credentialOffer) + expect(credentialOffer).toStrictEqual(paradymDraft13Federation.credentialOfferObject) const issuerMetadata = await client.resolveIssuerMetadata(credentialOffer.credential_issuer) expect(issuerMetadata.credentialIssuer).toStrictEqual({ - ...paradymDraft13.credentialIssuerMetadata, + ...paradymDraft13Federation.credentialIssuerMetadata, client_registration_types_supported: ['automatic'], }) @@ -807,12 +810,13 @@ describe('Oid4vciClient', () => { credentialOffer, issuerMetadata, }) - expect(accessTokenResponse).toStrictEqual(paradymDraft13.accessTokenResponse) - expect(authorizationServer).toStrictEqual(paradymDraft13.credentialIssuerMetadata.credential_issuer) + expect(accessTokenResponse).toStrictEqual(paradymDraft13Federation.accessTokenResponse) + expect(authorizationServer).toStrictEqual(paradymDraft13Federation.credentialIssuerMetadata.credential_issuer) const encodedJwk = Buffer.from(JSON.stringify(publicKeyJwk)).toString('base64url') const didUrl = `did:jwk:${encodedJwk}#0` + // This is the holder side that will create a proof of possession of the private key const { jwt: proofJwt } = await client.createCredentialRequestJwtProof({ issuerMetadata, signer: { @@ -850,6 +854,6 @@ describe('Oid4vciClient', () => { jwt: proofJwt, }, }) - expect(credentialResponse.credentialResponse).toStrictEqual(paradymDraft13.credentialResponse) + expect(credentialResponse.credentialResponse).toStrictEqual(paradymDraft13Federation.credentialResponse) }) }) diff --git a/packages/oid4vci/src/__tests__/__fixtures__/paradym.ts b/packages/oid4vci/src/__tests__/__fixtures__/paradym.ts index 30a5934..187f8bf 100644 --- a/packages/oid4vci/src/__tests__/__fixtures__/paradym.ts +++ b/packages/oid4vci/src/__tests__/__fixtures__/paradym.ts @@ -142,6 +142,156 @@ export const paradymDraft13 = { }, } +export const paradymDraft13Federation = { + credentialOffer: + 'https://paradym.id/invitation?credential_offer_uri=https%3A%2F%2Fparadym.id%2Finvitation%2Fdraft-13-issuer%2Foffers%2Fb99db8f1-4fa2-4b27-8dc7-ecf81478eb9b%3Fraw%3Dtrue', + credentialOfferUri: + 'https://paradym.id/invitation/draft-13-issuer/offers/b99db8f1-4fa2-4b27-8dc7-ecf81478eb9b?raw=true', + credentialOfferObject: { + grants: { + 'urn:ietf:params:oauth:grant-type:pre-authorized_code': { + 'pre-authorized_code': '1130293840889780123292078', + user_pin_required: false, + }, + }, + credential_configuration_ids: ['clvi9a5od00127pap4obzoeuf'], + credential_issuer: 'https://agent.paradym.id/oid4vci/draft-13-issuer', + }, + authorizationServerMetadata: null, + credentialIssuerMetadata: { + credential_issuer: 'https://agent.paradym.id/oid4vci/draft-13-issuer', + token_endpoint: 'https://agent.paradym.id/oid4vci/draft-13-issuer/token', + credential_endpoint: 'https://agent.paradym.id/oid4vci/draft-13-issuer/credential', + credentials_supported: [ + { + format: 'vc+sd-jwt', + vct: 'https://metadata.paradym.id/types/iuoQGyxlww-ParadymContributor', + id: 'clv2gbawu000tfkrk5l067h1h', + cryptographic_binding_methods_supported: ['did:key', 'did:jwk', 'did:web'], + cryptographic_suites_supported: ['EdDSA', 'ES256'], + display: [ + { + name: 'Paradym Contributor', + description: 'Contributed to the Paradym Release', + background_color: '#5535ed', + background_image: {}, + text_color: '#ffffff', + }, + ], + }, + { + format: 'vc+sd-jwt', + vct: 'https://metadata.paradym.id/types/6fTEgFULv2-EmployeeBadge', + id: 'clvi9a5od00127pap4obzoeuf', + cryptographic_binding_methods_supported: ['did:key', 'did:jwk', 'did:web'], + cryptographic_suites_supported: ['EdDSA', 'ES256'], + display: [ + { + name: 'Employee Badge', + description: 'Credential for employee badge', + background_color: '#000000', + background_image: { url: 'https://github.com/animo.png' }, + text_color: '#ffffff', + }, + ], + }, + { + format: 'vc+sd-jwt', + vct: 'https://metadata.paradym.id/types/ULaVABcapZ-Heyo', + id: 'clx4z0auo00a6f0sibkutdqor', + cryptographic_binding_methods_supported: ['did:key', 'did:jwk', 'did:web'], + cryptographic_suites_supported: ['EdDSA', 'ES256'], + display: [ + { + name: 'Direct issuance revocation', + background_color: '#000000', + background_image: {}, + text_color: '#ffffff', + }, + ], + }, + ], + credential_configurations_supported: { + clv2gbawu000tfkrk5l067h1h: { + cryptographic_binding_methods_supported: ['did:key', 'did:jwk', 'did:web'], + credential_signing_alg_values_supported: ['EdDSA', 'ES256'], + proof_types_supported: { jwt: { proof_signing_alg_values_supported: ['EdDSA', 'ES256'] } }, + display: [ + { + name: 'Paradym Contributor', + description: 'Contributed to the Paradym Release', + background_color: '#5535ed', + background_image: {}, + text_color: '#ffffff', + }, + ], + format: 'vc+sd-jwt', + vct: 'https://metadata.paradym.id/types/iuoQGyxlww-ParadymContributor', + }, + clvi9a5od00127pap4obzoeuf: { + cryptographic_binding_methods_supported: ['did:key', 'did:jwk', 'did:web'], + credential_signing_alg_values_supported: ['EdDSA', 'ES256'], + proof_types_supported: { jwt: { proof_signing_alg_values_supported: ['EdDSA', 'ES256'] } }, + display: [ + { + name: 'Employee Badge', + description: 'Credential for employee badge', + background_color: '#000000', + background_image: { url: 'https://github.com/animo.png' }, + text_color: '#ffffff', + }, + ], + format: 'vc+sd-jwt', + vct: 'https://metadata.paradym.id/types/6fTEgFULv2-EmployeeBadge', + }, + clx4z0auo00a6f0sibkutdqor: { + cryptographic_binding_methods_supported: ['did:key', 'did:jwk', 'did:web'], + credential_signing_alg_values_supported: ['EdDSA', 'ES256'], + proof_types_supported: { jwt: { proof_signing_alg_values_supported: ['EdDSA', 'ES256'] } }, + display: [ + { + name: 'Direct issuance revocation', + background_color: '#000000', + background_image: {}, + text_color: '#ffffff', + }, + ], + format: 'vc+sd-jwt', + vct: 'https://metadata.paradym.id/types/ULaVABcapZ-Heyo', + }, + }, + display: [{ name: 'Animo', logo: { url: 'https://github.com/animo.png', alt_text: 'Logo of Animo Solutions' } }], + }, + federationEntity: { + organization_name: 'Animo', + logo_uri: 'https://github.com/animo.png', + homepage_uri: 'https://animo.id', + contacts: ['support@animo.id'], + }, + accessTokenResponse: { + access_token: + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImp3ayI6eyJrdHkiOiJPS1AiLCJjcnYiOiJFZDI1NTE5IiwieCI6IjhXWGU4UFBEVTRrTnpIbjJuZ0FTUmFsUmxiZHF6NEJjYkU3X0RTUTN0aUkifX0.eyJwcmVBdXRob3JpemVkQ29kZSI6IjExMzAyOTM4NDA4ODk3ODAxMjMyOTIwNzgiLCJ0b2tlbl90eXBlIjoiQmVhcmVyIiwiaXNzIjoiaHR0cHM6Ly9hZ2VudC5wYXJhZHltLmlkL29pZDR2Y2kvOWI2ZGY1YmMtNTk2NS00YWVjLWEzOWEtMDNjYjNiMjc4NmI1IiwiZXhwIjoxNzI5NTY3ODk2LCJpYXQiOjE3Mjk1Njc3MTZ9.iBeLzXv7Z6kwGpgrT-5XoCyWOkl4FMDixVMqPbdSkLYq8eqU-iJWSWPsoqGnNhZ8B2H6zaEYKpxbZhdvSauSBg', + token_type: 'bearer', + expires_in: 180, + c_nonce: '463253917094869172078310', + c_nonce_expires_in: 300, + authorization_pending: false, + }, + credentialResponse: { + credential: + 'eyJ0eXAiOiJ2YytzZC1qd3QiLCJhbGciOiJFZERTQSIsImtpZCI6IiN6Nk1rdWFiek4xdzRxcnlINENMY1pRSmllaEpRd3VpM3V3RVloSjEzd01qWG5RTnQifQ.eyJkZXBhcnRtZW50IjoiU3RyaW5nIHZhbHVlIiwidmN0IjoiaHR0cHM6Ly9tZXRhZGF0YS5wYXJhZHltLmlkL3R5cGVzLzZmVEVnRlVMdjItRW1wbG95ZWVCYWRnZSIsImNuZiI6eyJraWQiOiJkaWQ6andrOmV5SnJkSGtpT2lKRlF5SXNJbmdpT2lKQlJWaHdTSGt4TUVkb2RGZG9iRlpRVG0xeVJuTmllWFJmZDBSelVWODNjVE5rYWs1dWNtaDZhbDgwSWl3aWVTSTZJa1JIVkVGRFQwRkJibEZVWlhCaFJEUXdaM2xIT1Zwc0xXOUVhRTlzZGpOVlFteFVkSGhKWlhJMVpXOGlMQ0pqY25ZaU9pSlFMVEkxTmlKOSMwIn0sImlzcyI6ImRpZDp3ZWI6bWV0YWRhdGEucGFyYWR5bS5pZDowYzIwNzI3Ny02NjU2LTQ2MzItOWQyOC03MGYzNGRkZTllYTIiLCJpYXQiOjE3Mjk1Njc3MTcsIl9zZCI6WyJHY2NVSjNGdW1pb3I0bDdqZnpXSmFvUjBTSzNaQU5penQ4ZW1HOTBVLWQ0IiwiVWhCUEp5VWdmTFFnX1JUUnJLM2xNN25ldWhZT3JlYVNBNTlVNTJQUnNZUSIsInYtTHhvdDhWczBzWi1VMGRZZ0dZMFppem9BYVc4eWt3NC1kaVhDODhIeEkiLCJ2Y1pwZW5nUkhsV2hEUUhhczVvOW1TNUM3VDRFUU92ekVWcC05MHF2OHVVIl0sIl9zZF9hbGciOiJzaGEtMjU2In0.sceont7otHdLLwAMNQkkppakbZiX0YVWgxTejugsZUkaOFtIYVM8pNLZTs-Oi_AIRvwIZ6uuuMK8TOmp6QSfAg~WyI2NDU1NTI0ODUwOTEwNzMzMDk3MzI1NDUiLCJpc19hZG1pbiIsdHJ1ZV0~WyIxMDc0NzQzNzg3OTY1NzI0ODkxNDkwMDciLCJsYXN0X25hbWUiLCJTdHJpbmcgdmFsdWUiXQ~WyI2OTg2MDU4NTY2NDc3MjQwNTE0MTI3MTMiLCJmaXJzdF9uYW1lIiwiU3RyaW5nIHZhbHVlIl0~WyIyODIyODI0Nzg0ODQ4MTYxNjA4NDM0NTkiLCJlbXBsb3llZV9pZCIsIlN0cmluZyB2YWx1ZSJd~', + c_nonce: '1f476b83-00fc-44e6-8cfa-c52c5df12d08', + c_nonce_expires_in: 300, + }, + holderPrivateKeyJwk: { + kty: 'EC', + x: 'AEXpHy10GhtWhlVPNmrFsbyt_wDsQ_7q3djNnrhzj_4', + y: 'DGTACOAAnQTepaD40gyG9Zl-oDhOlv3UBlTtxIer5eo', + crv: 'P-256', + d: 'C75pQj72AAl6SCsBW8AKTKxqLGk2Fw7NutIpWZ-xjvE', + }, +} + export const paradymDraft11 = { credentialOffer: 'https://paradym.id/invitation?credential_offer_uri=https%3A%2F%2Fparadym.id%2Finvitation%2Fdraft-11-issuer%2Foffers%2Fb99db8f1-4fa2-4b27-8dc7-ecf81478eb9b%3Fraw%3Dtrue', diff --git a/packages/oid4vci/src/formats/proof-type/jwt/v-jwt-proof-type.ts b/packages/oid4vci/src/formats/proof-type/jwt/v-jwt-proof-type.ts index 82d931e..6a235da 100644 --- a/packages/oid4vci/src/formats/proof-type/jwt/v-jwt-proof-type.ts +++ b/packages/oid4vci/src/formats/proof-type/jwt/v-jwt-proof-type.ts @@ -22,7 +22,10 @@ export const vCredentialRequestJwtProofTypeHeader = v.pipe( ({ kid, jwk }) => jwk === undefined || kid === undefined, `Both 'jwk' and 'kid' are defined. Only one is allowed` ), - v.check(({ trust_chain, kid }) => !trust_chain || !kid, `When 'trust_chain' is provided, 'kid' is required`) + v.check( + ({ trust_chain, kid }) => !trust_chain || kid !== undefined, + `When 'trust_chain' is provided, 'kid' is required` + ) ) export type CredentialRequestJwtProofTypeHeader = v.InferOutput diff --git a/packages/oid4vci/src/metadata/credential-issuer/credential-issuer-metadata.ts b/packages/oid4vci/src/metadata/credential-issuer/credential-issuer-metadata.ts index 97692fc..7abb837 100644 --- a/packages/oid4vci/src/metadata/credential-issuer/credential-issuer-metadata.ts +++ b/packages/oid4vci/src/metadata/credential-issuer/credential-issuer-metadata.ts @@ -27,8 +27,6 @@ export async function fetchCredentialIssuerMetadata( credentialIssuer: string, options: FetchCredentialIssuerMetadataOptions ): Promise<{ credentialIssuerMetadata: CredentialIssuerMetadata; originalDraftVersion: Oid4vciDraftVersion } | null> { - // TODO: What should we do when it has the property trust_chain? - let result: v.InferOutput | null = null const entityConfiguration = await fetchEntityConfiguration({ @@ -42,7 +40,7 @@ export async function fetchCredentialIssuerMetadata( { alg: jwk.alg, method: 'jwk', - publicJwk: jwk as Jwk, // TODO: Check why this type is not correct + publicJwk: jwk as Jwk, }, { header: header as JwtHeader,