Skip to content

Commit

Permalink
feat: Added OpenID Federation for issuer metadata resolvement
Browse files Browse the repository at this point in the history
Signed-off-by: Tom Lanser <[email protected]>
  • Loading branch information
Tommylans committed Dec 5, 2024
1 parent 379a9c9 commit 647f274
Show file tree
Hide file tree
Showing 8 changed files with 291 additions and 21 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/oid4vci/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 2 additions & 2 deletions packages/oid4vci/src/Oid4vciClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export interface Oid4vciClientOptions {
/**
* Callbacks required for the oid4vc client
*/
callbacks: Omit<CallbackContext, 'verifyJwt' | 'clientAuthentication'>
callbacks: Omit<CallbackContext, 'clientAuthentication'>
}

export class Oid4vciClient {
Expand Down Expand Up @@ -76,7 +76,7 @@ export class Oid4vciClient {

public async resolveIssuerMetadata(credentialIssuer: string): Promise<IssuerMetadataResult> {
return resolveIssuerMetadata(credentialIssuer, {
fetch: this.options.callbacks.fetch,
callbackContext: this.options.callbacks,
})
}

Expand Down
193 changes: 191 additions & 2 deletions packages/oid4vci/src/__tests__/Oid4vciClient.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
),
Expand Down Expand Up @@ -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)
),
Expand Down Expand Up @@ -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 })
),
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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)
})
})
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -13,20 +16,72 @@ import {

const wellKnownCredentialIssuerSuffix = '.well-known/openid-credential-issuer'

type FetchCredentialIssuerMetadataOptions = {
callbackContext: Pick<CallbackContext, 'fetch' | 'verifyJwt' | 'signJwt'>
}

/**
* @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<typeof vCredentialIssuerMetadataWithDraftVersion> | 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}'.`
)
}

Expand Down
Loading

0 comments on commit 647f274

Please sign in to comment.