Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/chatty-points-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@openid4vc/openid4vci": patch
---

feat(openid4vc): support the new issuer metadata url
Original file line number Diff line number Diff line change
Expand Up @@ -14,76 +14,54 @@ export async function fetchAuthorizationServerMetadata(
issuer: string,
fetch?: Fetch
): Promise<AuthorizationServerMetadata | null> {
const openIdConfigurationWellKnownMetadataUrl = joinUriParts(issuer, [wellKnownOpenIdConfigurationServerSuffix])

const parsedIssuerUrl = new URL(issuer)

const openIdConfigurationWellKnownMetadataUrl = joinUriParts(issuer, [wellKnownOpenIdConfigurationServerSuffix])
const authorizationServerWellKnownMetadataUrl = joinUriParts(parsedIssuerUrl.origin, [
wellKnownAuthorizationServerSuffix,
parsedIssuerUrl.pathname,
])

// First try oauth-authorization-server
const authorizationServerResult = await fetchWellKnownMetadata(
authorizationServerWellKnownMetadataUrl,
zAuthorizationServerMetadata,
fetch
)

if (authorizationServerResult) {
if (authorizationServerResult.issuer !== issuer) {
// issuer param MUST match
throw new Oauth2Error(
`The 'issuer' parameter '${authorizationServerResult.issuer}' in the well known authorization server metadata at '${authorizationServerWellKnownMetadataUrl}' does not match the provided issuer '${issuer}'.`
)
}

return authorizationServerResult
}

// NOTE: there is a difference in how to construct well-known OAuth2 and well-known openid
// url. For OAuth you place `.well-known/oauth-authorization-server` between the origin and
// the path. Historically we used the same method as OpenID (which a lot of servers seems to
// host as well), and thus we use this as a last fallback if it's different for now (in case of subpath).
const nonCompliantAuthorizationServerWellKnownMetadataUrl = joinUriParts(issuer, [wellKnownAuthorizationServerSuffix])

const alternativeAuthorizationServerResult =
nonCompliantAuthorizationServerWellKnownMetadataUrl !== authorizationServerWellKnownMetadataUrl
? await fetchWellKnownMetadata(
nonCompliantAuthorizationServerWellKnownMetadataUrl,
zAuthorizationServerMetadata,
fetch
)
: undefined

if (alternativeAuthorizationServerResult) {
if (alternativeAuthorizationServerResult.issuer !== issuer) {
// issuer param MUST match
throw new Oauth2Error(
`The 'issuer' parameter '${alternativeAuthorizationServerResult.issuer}' in the well known authorization server metadata at '${nonCompliantAuthorizationServerWellKnownMetadataUrl}' does not match the provided issuer '${issuer}'.`
)
}

return alternativeAuthorizationServerResult
}

const openIdConfigurationResult = await fetchWellKnownMetadata(
openIdConfigurationWellKnownMetadataUrl,
// First try oauth-authorization-server
let authorizationServerResult = await fetchWellKnownMetadata(
authorizationServerWellKnownMetadataUrl,
zAuthorizationServerMetadata,
fetch
)

// issuer param MUST match
if (openIdConfigurationResult) {
if (openIdConfigurationResult.issuer !== issuer) {
throw new Oauth2Error(
`The 'issuer' parameter '${openIdConfigurationResult.issuer}' in the well known openid configuration metadata at '${openIdConfigurationWellKnownMetadataUrl}' does not match the provided issuer '${issuer}'.`
)
}
return openIdConfigurationResult
if (
!authorizationServerResult &&
nonCompliantAuthorizationServerWellKnownMetadataUrl !== authorizationServerWellKnownMetadataUrl
) {
authorizationServerResult = await fetchWellKnownMetadata(
nonCompliantAuthorizationServerWellKnownMetadataUrl,
zAuthorizationServerMetadata,
fetch
)
}

if (!authorizationServerResult) {
authorizationServerResult = await fetchWellKnownMetadata(
openIdConfigurationWellKnownMetadataUrl,
zAuthorizationServerMetadata,
fetch
)
}

if (authorizationServerResult && authorizationServerResult.issuer !== issuer) {
// issuer param MUST match
throw new Oauth2Error(
`The 'issuer' parameter '${authorizationServerResult.issuer}' in the well known authorization server metadata at '${authorizationServerWellKnownMetadataUrl}' does not match the provided issuer '${issuer}'.`
)
}

return null
return authorizationServerResult
}

export function getAuthorizationServerMetadataFromList(
Expand Down
192 changes: 191 additions & 1 deletion packages/openid4vci/src/__tests__/Openid4vciClient.test.mts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { clientAuthenticationAnonymous, decodeJwt, preAuthorizedCodeGrantIdentifier } from '@openid4vc/oauth2'
import {
type AuthorizationServerMetadata,
clientAuthenticationAnonymous,
decodeJwt,
preAuthorizedCodeGrantIdentifier,
} from '@openid4vc/oauth2'
import { parseWithErrorHandling } from '@openid4vc/utils'
import { HttpResponse, http } from 'msw'
import { setupServer } from 'msw/node'
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'
import { zAuthorizationChallengeRequest } from '../../../oauth2/src/authorization-challenge/z-authorization-challenge.js'
import { callbacks, getSignJwtCallback, parseXwwwFormUrlEncoded } from '../../../oauth2/tests/util.mjs'
import { Openid4vciDraftVersion } from '../index.js'
import { extractScopesForCredentialConfigurationIds } from '../metadata/credential-issuer/credential-configurations.js'
import type { CredentialIssuerMetadata } from '../metadata/credential-issuer/z-credential-issuer-metadata.js'
import { AuthorizationFlow, Openid4vciClient } from '../Openid4vciClient.js'
import { bdrDraft13 } from './__fixtures__/bdr.js'
import { paradymDraft11, paradymDraft13 } from './__fixtures__/paradym.js'
Expand Down Expand Up @@ -684,4 +691,187 @@ describe('Openid4vciClient', () => {
})
expect(credentialResponse.credentialResponse).toStrictEqual(presentationDuringIssuance.credentialResponse)
})

test('correctly fetches issuer and authorization metadata', async () => {
const issuerMetadata = {
credential_issuer: 'https://example.com/issuer-id',
authorization_servers: ['https://example.com/issuer-id'],
credential_endpoint: 'https://example.com/issuer-id/credential',
credential_configurations_supported: {},
} satisfies CredentialIssuerMetadata

const authorizationMetadata = {
issuer: 'https://example.com/issuer-id',
token_endpoint: 'https://example.com/issuer-id/token',
} satisfies AuthorizationServerMetadata

server.resetHandlers(
// New/correct syntax for issuer metadata
http.get('https://example.com/.well-known/openid-credential-issuer/issuer-id', () =>
HttpResponse.json(issuerMetadata)
),
// Correct syntax for authorization server metadata
http.get('https://example.com/.well-known/oauth-authorization-server/issuer-id', () =>
HttpResponse.json(authorizationMetadata)
)
)

const client = new Openid4vciClient({
callbacks: {
...callbacks,
fetch,
signJwt: () => {
throw new Error('Not implemented')
},
},
})

const resolvedIssuerMetadata = await client.resolveIssuerMetadata(issuerMetadata.credential_issuer)
expect(resolvedIssuerMetadata).toStrictEqual({
originalDraftVersion: Openid4vciDraftVersion.Draft14,
credentialIssuer: issuerMetadata,
authorizationServers: [authorizationMetadata],
})

server.resetHandlers(
// New/correct syntax for issuer metadata
http.get('https://example.com/.well-known/openid-credential-issuer/issuer-id', () =>
HttpResponse.text(undefined, { status: 404 })
),
// Correct syntax for authorization server metadata
http.get('https://example.com/.well-known/oauth-authorization-server/issuer-id', () =>
HttpResponse.json(undefined, { status: 404 })
),

// http.get('https://example.com/.well-known/openid-configuration', () =>
// HttpResponse.text(undefined, { status: 404 })
// ),

// Old syntax for issuer metadata
http.get('https://example.com/issuer-id/.well-known/openid-credential-issuer', () =>
HttpResponse.json(issuerMetadata)
),
// incorrect/lgeacy syntax for authorization server metadata
http.get('https://example.com/issuer-id/.well-known/oauth-authorization-server', () =>
HttpResponse.json(authorizationMetadata)
)
)

const resolvedIssuerMetadata2 = await client.resolveIssuerMetadata(issuerMetadata.credential_issuer)
expect(resolvedIssuerMetadata2).toStrictEqual({
originalDraftVersion: Openid4vciDraftVersion.Draft14,
credentialIssuer: issuerMetadata,
authorizationServers: [authorizationMetadata],
})

server.resetHandlers(
// New/correct syntax for issuer metadata
http.get('https://example.com/.well-known/openid-credential-issuer/issuer-id', () =>
HttpResponse.text(undefined, { status: 404 })
),
// Correct syntax for authorization server metadata
http.get('https://example.com/.well-known/oauth-authorization-server/issuer-id', () =>
HttpResponse.json(undefined, { status: 404 })
),

// Old syntax for issuer metadata
http.get('https://example.com/issuer-id/.well-known/openid-credential-issuer', () =>
HttpResponse.json(issuerMetadata)
),
// incorrect/lgeacy syntax for authorization server metadata
http.get('https://example.com/issuer-id/.well-known/oauth-authorization-server', () =>
HttpResponse.json(undefined, { status: 404 })
),

// Old openid syntax for authorization server metadata
http.get('https://example.com/issuer-id/.well-known/openid-configuration', () =>
HttpResponse.json(authorizationMetadata)
)
)

const resolvedIssuerMetadata3 = await client.resolveIssuerMetadata(issuerMetadata.credential_issuer)
expect(resolvedIssuerMetadata3).toStrictEqual({
originalDraftVersion: Openid4vciDraftVersion.Draft14,
credentialIssuer: issuerMetadata,
authorizationServers: [authorizationMetadata],
})

// Fallback to the issuer metadata if no authorization server metadata found
server.resetHandlers(
// New/correct syntax for issuer metadata
http.get('https://example.com/.well-known/openid-credential-issuer/issuer-id', () =>
HttpResponse.text(undefined, { status: 404 })
),
// Correct syntax for authorization server metadata
http.get('https://example.com/.well-known/oauth-authorization-server/issuer-id', () =>
HttpResponse.json(undefined, { status: 404 })
),

// Old syntax for issuer metadata
http.get('https://example.com/issuer-id/.well-known/openid-credential-issuer', () =>
HttpResponse.json({ ...issuerMetadata, token_endpoint: 'https://example.com/issuer-id/token' })
),
// incorrect/lgeacy syntax for authorization server metadata
http.get('https://example.com/issuer-id/.well-known/oauth-authorization-server', () =>
HttpResponse.json(undefined, { status: 404 })
),

// Old openid syntax for authorization server metadata
http.get('https://example.com/issuer-id/.well-known/openid-configuration', () =>
HttpResponse.json(undefined, { status: 404 })
)
)

const resolvedIssuerMetadata4 = await client.resolveIssuerMetadata(issuerMetadata.credential_issuer)
expect(resolvedIssuerMetadata4).toStrictEqual({
originalDraftVersion: Openid4vciDraftVersion.Draft14,
credentialIssuer: { ...issuerMetadata, token_endpoint: 'https://example.com/issuer-id/token' },
authorizationServers: [
{
issuer: 'https://example.com/issuer-id',
token_endpoint: 'https://example.com/issuer-id/token',
},
],
})

server.resetHandlers(
// New/correct syntax for issuer metadata
http.get('https://example.com/.well-known/openid-credential-issuer/issuer-id', () =>
HttpResponse.text(undefined, { status: 404 })
),
// Old syntax for issuer metadata
http.get('https://example.com/issuer-id/.well-known/openid-credential-issuer', () =>
HttpResponse.json(undefined, { status: 404 })
)
)

await expect(client.resolveIssuerMetadata(issuerMetadata.credential_issuer)).rejects.toThrow(
"Well known credential issuer metadata for issuer 'https://example.com/issuer-id' not found."
)

server.resetHandlers(
// New/correct syntax for issuer metadata
http.get('https://example.com/.well-known/openid-credential-issuer/issuer-id', () =>
HttpResponse.json(issuerMetadata)
),
// Correct syntax for authorization server metadata
http.get('https://example.com/.well-known/oauth-authorization-server/issuer-id', () =>
HttpResponse.json(undefined, { status: 404 })
),

// incorrect/lgeacy syntax for authorization server metadata
http.get('https://example.com/issuer-id/.well-known/oauth-authorization-server', () =>
HttpResponse.json(undefined, { status: 404 })
),

// Old openid syntax for authorization server metadata
http.get('https://example.com/issuer-id/.well-known/openid-configuration', () =>
HttpResponse.json(undefined, { status: 404 })
)
)

await expect(client.resolveIssuerMetadata(issuerMetadata.credential_issuer)).rejects.toThrow(
"Well known authorization server metadata for authorization server 'https://example.com/issuer-id' not found, and could also not extract required values from the credential issuer metadata as a fallback."
)
})
})
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { fetchWellKnownMetadata, Oauth2Error } from '@openid4vc/oauth2'
import { type Fetch, joinUriParts } from '@openid4vc/utils'
import { type Fetch, joinUriParts, URL } from '@openid4vc/utils'
import type { CredentialFormatIdentifier } from '../../formats/credential'
import type { Openid4vciDraftVersion } from '../../version'
import {
Expand All @@ -24,8 +24,20 @@ export async function fetchCredentialIssuerMetadata(
credentialIssuerMetadata: CredentialIssuerMetadata
originalDraftVersion: Openid4vciDraftVersion
} | null> {
const wellKnownMetadataUrl = joinUriParts(credentialIssuer, [wellKnownCredentialIssuerSuffix])
const result = await fetchWellKnownMetadata(wellKnownMetadataUrl, zCredentialIssuerMetadataWithDraftVersion, fetch)
const parsedIssuerUrl = new URL(credentialIssuer)

const legacyWellKnownMetadataUrl = joinUriParts(credentialIssuer, [wellKnownCredentialIssuerSuffix])
const wellKnownMetadataUrl = joinUriParts(parsedIssuerUrl.origin, [
wellKnownCredentialIssuerSuffix,
parsedIssuerUrl.pathname,
])

let result = await fetchWellKnownMetadata(wellKnownMetadataUrl, zCredentialIssuerMetadataWithDraftVersion, fetch)
// If the metadata is not available at the new URL, fetch it at the legacy URL
// The legacy url is the same if no subpath is used by the issuer
if (!result && legacyWellKnownMetadataUrl !== wellKnownMetadataUrl) {
result = await fetchWellKnownMetadata(legacyWellKnownMetadataUrl, zCredentialIssuerMetadataWithDraftVersion, fetch)
}

// credential issuer param MUST match
if (result && result.credentialIssuerMetadata.credential_issuer !== credentialIssuer) {
Expand Down