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: {}