Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ade9a1d
chore(ci): merge main into develop (next)
github-actions[bot] Jan 30, 2026
2e42a2c
chore: improve error handling for missing URI scheme in `decodeUriAsJ…
nklomp Feb 1, 2026
0c564fa
Merge remote-tracking branch 'origin/main' into develop
nklomp Feb 1, 2026
4bd6104
chore: Allow auth also in oid4vci-api-functions
sanderPostma Feb 4, 2026
7ddfcb2
chore: Allow auth also in oid4vci-api-functions
sanderPostma Feb 4, 2026
e47fdc5
chore: Allow auth also in oid4vci-api-functions
sanderPostma Feb 4, 2026
2b80d82
chore: skip old tests that keep failing
sanderPostma Feb 4, 2026
2c3badd
chore: skip old tests that keep failing
sanderPostma Feb 4, 2026
4654cf4
feat: add support for 1.0 final for both oid4vp (was already mostly t…
nklomp Mar 31, 2026
e203406
chore: fixes for oid4vc 1.0
nklomp Mar 31, 2026
4602504
chore: fixes for oid4vc 1.0
nklomp Mar 31, 2026
9ecdd16
chore: fixes for oid4vc 1.0
nklomp Mar 31, 2026
304a4f8
chore: fixes for oid4vc 1.0
nklomp Mar 31, 2026
8b44f59
chore: fixes for oid4vc 1.0
nklomp Mar 31, 2026
f554e62
chore: fixes for oid4vc 1.0
nklomp Apr 1, 2026
25c13e0
feat: cwt support for ProofOfPossessionBuilder
nklomp Apr 1, 2026
5c1ea03
feat: cwt support for ProofOfPossessionBuilder
nklomp Apr 1, 2026
dfe2eb8
feat: cwt support for ProofOfPossessionBuilder
nklomp Apr 1, 2026
83971e7
chore: update jwt handling and client assertion logic per RFC 7521/75…
nklomp Apr 1, 2026
3418039
chore: fix credential handling for mso_mdoc and improve error messagi…
nklomp Jun 4, 2026
28ece9f
chore: test fix
nklomp Jun 4, 2026
c08ad8b
Merge pull request #219 from Sphereon-Opensource/feature/oid4vci-1.0
nklomp Jun 4, 2026
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
10 changes: 3 additions & 7 deletions lerna.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
{
"packages": [
"packages/*"
],
"packages": ["packages/*"],
"version": "0.20.1",
"npmClient": "pnpm",
"workspaces": [
"packages/*"
]
}
"workspaces": ["packages/*"]
}
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ describe('issuerCallback', () => {
const nonces = new MemoryStates<CNonceState>()
await nonces.set('test_value', { cNonce: 'test_value', createdAt: +new Date() })
vcIssuer = new VcIssuerBuilder()
.withVersion(OpenId4VCIVersion.VER_1_0_15)
.withAuthorizationServers('https://authorization-server')
.withCredentialEndpoint('https://credential-endpoint')
.withCredentialIssuer(IDENTIPROOF_ISSUER_URL)
Expand Down Expand Up @@ -251,6 +252,7 @@ describe('issuerCallback', () => {

it('Should pass requesting a verifiable credential using the client', async () => {
const credReqClient = (await CredentialRequestClientBuilderV1_0_15.fromURI({ uri: INITIATION_TEST_URI }))
.withVersion(OpenId4VCIVersion.VER_1_0_15)
.withCredentialEndpoint('https://oidc4vci.demo.spruceid.com/credential')
.withCredentialConfigurationId('VeriCred')
.withCredentialEndpointFromMetadata({
Expand Down
7 changes: 1 addition & 6 deletions packages/callback-example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,7 @@
"engines": {
"node": ">=20.6"
},
"files": [
"src",
"dist",
"README.md",
"LICENSE.md"
],
"files": ["src", "dist", "README.md", "LICENSE.md"],
"keywords": [
"Sphereon",
"Verifiable Credentials",
Expand Down
16 changes: 13 additions & 3 deletions packages/client/lib/AccessTokenClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ export class AccessTokenClient {
}
const credentialIssuer = opts.credentialIssuer ?? credentialOfferRequest?.credential_offer?.credential_issuer ?? opts.metadata?.issuer
await createJwtBearerClientAssertion(request, { ...opts, credentialIssuer })
// Per RFC 7521, client_id is not needed when client_assertion conveys the client identity
if (request.client_assertion) {
delete request.client_id
}

// Prefer AUTHORIZATION_CODE over PRE_AUTHORIZED_CODE_FLOW
if (!credentialOfferRequest || credentialOfferRequest.supportedFlows.includes(AuthzFlowType.AUTHORIZATION_CODE_FLOW)) {
Expand All @@ -147,8 +151,12 @@ export class AccessTokenClient {

if (credentialOfferRequest?.supportedFlows.includes(AuthzFlowType.PRE_AUTHORIZED_CODE_FLOW)) {
this.assertAlphanumericPin(opts.pinMetadata, pin)
request.user_pin = pin
request.tx_code = pin
// OID4VCI 1.0 uses tx_code, older drafts used user_pin
if (opts.pinMetadata?.txCode) {
request.tx_code = pin
} else {
request.user_pin = pin
}

request.grant_type = GrantTypes.PRE_AUTHORIZED_CODE
// we actually know it is there because of the isPreAuthCode call
Expand Down Expand Up @@ -259,7 +267,9 @@ export class AccessTokenClient {
accessTokenRequest: AccessTokenRequest,
opts?: { headers?: Record<string, string> },
): Promise<OpenIDResponse<AccessTokenResponse, DPoPResponseParams>> {
return await formPost(requestTokenURL, convertJsonToURI(accessTokenRequest, { mode: JsonURIMode.X_FORM_WWW_URLENCODED }), {
const body = convertJsonToURI(accessTokenRequest, { mode: JsonURIMode.X_FORM_WWW_URLENCODED })
LOG.info(`Token request to ${requestTokenURL}: ${body}`)
return await formPost(requestTokenURL, body, {
customHeaders: opts?.headers ? opts.headers : undefined,
})
}
Expand Down
48 changes: 23 additions & 25 deletions packages/client/lib/AuthorizationCodeClient.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import {
AuthorizationChallengeCodeResponse,
AuthorizationChallengeRequestOpts,
AuthorizationDetailsV1_0_15,
AuthorizationRequestOpts,
CodeChallengeMethod,
CommonAuthorizationChallengeRequest,
convertJsonToURI,
CreateRequestObjectMode,
CredentialConfigurationSupportedV1_0_15,
CredentialConfigurationSupported,
CredentialDefinitionJwtVcJsonLdAndLdpVcV1_0_15,
CredentialDefinitionJwtVcJsonV1_0_15,
CredentialOfferPayloadV1_0_15,
AuthorizationDetails,
CredentialOfferPayload,
CredentialOfferRequestWithBaseUrl,
CreateRequestObjectMode,
determineSpecVersionFromOffer,
EndpointMetadata,
EndpointMetadataResultV1_0_15,
EndpointMetadataResult,
formPost,
IssuerOpts,
isW3cCredentialSupported,
Expand Down Expand Up @@ -72,17 +72,17 @@ export async function createSignedAuthRequestWhenNeeded(
const pop = await ProofOfPossessionBuilder.fromJwt({
jwt,
callbacks: opts.signCallbacks,
version: OpenId4VCIVersion.VER_1_0_15,
version: OpenId4VCIVersion.VER_1_0,
mode: 'JWT',
}).build()
requestObject['request'] = pop.jwt
}
}

function filterSupportedCredentials(
credentialOffer: CredentialOfferPayloadV1_0_15,
credentialsSupported?: Record<string, CredentialConfigurationSupportedV1_0_15>,
): (CredentialConfigurationSupportedV1_0_15 & {
credentialOffer: CredentialOfferPayload,
credentialsSupported?: Record<string, CredentialConfigurationSupported>,
): (CredentialConfigurationSupported & {
configuration_id: string
})[] {
if (!credentialOffer.credential_configuration_ids || !credentialsSupported) {
Expand All @@ -105,10 +105,10 @@ export const createAuthorizationRequestUrl = async ({
version,
}: {
pkce: PKCEOpts
endpointMetadata: EndpointMetadataResultV1_0_15
endpointMetadata: EndpointMetadataResult
authorizationRequest: AuthorizationRequestOpts
credentialOffer?: CredentialOfferRequestWithBaseUrl
credentialConfigurationSupported?: Record<string, CredentialConfigurationSupportedV1_0_15>
credentialConfigurationSupported?: Record<string, CredentialConfigurationSupported>
clientId?: string
version?: OpenId4VCIVersion
}): Promise<string> => {
Expand Down Expand Up @@ -152,11 +152,9 @@ export const createAuthorizationRequestUrl = async ({
if ('credentials' in credentialOffer.credential_offer) {
throw new Error('CredentialOffer format is wrong.')
}
const ver = version ?? determineSpecVersionFromOffer(credentialOffer.credential_offer) ?? OpenId4VCIVersion.VER_1_0_15
const ver = version ?? determineSpecVersionFromOffer(credentialOffer.credential_offer) ?? OpenId4VCIVersion.VER_1_0
const creds =
ver === OpenId4VCIVersion.VER_1_0_15
? filterSupportedCredentials(credentialOffer.credential_offer as CredentialOfferPayloadV1_0_15, credentialConfigurationSupported)
: []
ver >= OpenId4VCIVersion.VER_1_0_15 ? filterSupportedCredentials(credentialOffer.credential_offer, credentialConfigurationSupported) : []

authorizationDetails = creds.flatMap((cred) => {
const locations = [credentialOffer?.credential_offer.credential_issuer ?? endpointMetadata.issuer]
Expand Down Expand Up @@ -194,9 +192,9 @@ export const createAuthorizationRequestUrl = async ({
...(credential_definition && { credential_definition }),
...(credential_configuration_id && { credential_configuration_id }),
...(format && { format }),
...(vct && { vct, claims: cred.claims ? removeDisplayAndValueTypes(cred.claims) : undefined }),
...(doctype && { doctype, claims: cred.claims ? removeDisplayAndValueTypes(cred.claims) : undefined }),
} as AuthorizationDetailsV1_0_15
...(vct && { vct, claims: (cred as any).claims ? removeDisplayAndValueTypes((cred as any).claims) : undefined }),
...(doctype && { doctype, claims: (cred as any).claims ? removeDisplayAndValueTypes((cred as any).claims) : undefined }),
} as AuthorizationDetails
})
if (!authorizationDetails || authorizationDetails.length === 0) {
throw Error(`Could not create authorization details from credential offer. Please pass in explicit details`)
Expand All @@ -205,8 +203,8 @@ export const createAuthorizationRequestUrl = async ({
// v15: authorization endpoint is under Authorization Server metadata (or duplicated in CI metadata)
const authorizationEndpoint =
(endpointMetadata as any).authorization_endpoint ??
(endpointMetadata as EndpointMetadataResultV1_0_15).authorizationServerMetadata?.authorization_endpoint ??
(endpointMetadata as EndpointMetadataResultV1_0_15).credentialIssuerMetadata?.authorization_endpoint
(endpointMetadata as EndpointMetadataResult).authorizationServerMetadata?.authorization_endpoint ??
(endpointMetadata as EndpointMetadataResult).credentialIssuerMetadata?.authorization_endpoint

if (!authorizationEndpoint) {
throw Error('Server metadata does not contain authorization endpoint')
Expand Down Expand Up @@ -292,9 +290,9 @@ const hasCredentialDefinition = (
Array.isArray(cred.credential_definition.type)

const handleAuthorizationDetails = (
endpointMetadata: EndpointMetadataResultV1_0_15,
authorizationDetails?: AuthorizationDetailsV1_0_15 | AuthorizationDetailsV1_0_15[],
): AuthorizationDetailsV1_0_15 | AuthorizationDetailsV1_0_15[] | undefined => {
endpointMetadata: EndpointMetadataResult,
authorizationDetails?: AuthorizationDetails | AuthorizationDetails[],
): AuthorizationDetails | AuthorizationDetails[] | undefined => {
if (authorizationDetails) {
if (typeof authorizationDetails === 'string') {
// backwards compat for older versions of the lib
Expand All @@ -311,14 +309,14 @@ const handleAuthorizationDetails = (
return authorizationDetails
}

const handleLocations = (endpointMetadata: EndpointMetadataResultV1_0_15, authorizationDetails: AuthorizationDetailsV1_0_15) => {
const handleLocations = (endpointMetadata: EndpointMetadataResult, authorizationDetails: AuthorizationDetails) => {
if (typeof authorizationDetails === 'string') {
// backwards compat for older versions of the lib
return authorizationDetails
}

// v15 signal: CI metadata lists external Authorization Server(s)
const ciMeta = (endpointMetadata as EndpointMetadataResultV1_0_15).credentialIssuerMetadata as any
const ciMeta = (endpointMetadata as EndpointMetadataResult).credentialIssuerMetadata as any
const hasAuthorizationServers = Array.isArray(ciMeta?.authorization_servers) && ciMeta.authorization_servers.length > 0

// v13/v11 fallback: some older metadata exposed authorization_endpoint at top level
Expand Down
79 changes: 64 additions & 15 deletions packages/client/lib/CredentialRequestClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { createDPoP, CreateDPoPClientOpts, getCreateDPoPOptions } from '@sphereo
import {
acquireDeferredCredential,
AuthorizationDetailsV1_0_15,
AuthorizationDetailsV1_0,
CredentialRequest,
CredentialRequestV1_0_15,
CredentialRequestV1_0,
CredentialResponse,
DPoPResponseParams,
ExperimentalSubjectIssuance,
Expand All @@ -20,6 +22,7 @@ import {
import { CredentialFormat, Loggers } from '@sphereon/ssi-types'

import { CredentialRequestClientBuilderV1_0_15 } from './CredentialRequestClientBuilderV1_0_15'
import { CredentialRequestClientBuilderV1_0 } from './CredentialRequestClientBuilderV1_0'
import { ProofOfPossessionBuilder } from './ProofOfPossessionBuilder'
import { shouldRetryResourceRequestWithDPoPNonce } from './functions/dpopUtil'

Expand All @@ -32,14 +35,15 @@ export interface CredentialRequestOpts {
notificationEndpoint?: string
deferredCredentialEndpoint?: string
credentialTypes?: string[]
credentialIdentifier?: string
credentialIdentifier?: string // d15: singular
credentialIdentifiers?: string[] // 1.0 final: array
credentialConfigurationId?: string
proof: ProofOfPossession
token: string
version: OpenId4VCIVersion
subjectIssuance?: ExperimentalSubjectIssuance
issuerState?: string
authorizationDetails?: AuthorizationDetailsV1_0_15[]
authorizationDetails?: (AuthorizationDetailsV1_0_15 | AuthorizationDetailsV1_0)[]
}

export type CreateCredentialRequestOpts = {
Expand Down Expand Up @@ -134,7 +138,7 @@ export class CredentialRequestClient {
return this.credentialRequestOpts.deferredCredentialEndpoint
}

public constructor(builder: CredentialRequestClientBuilderV1_0_15) {
public constructor(builder: CredentialRequestClientBuilderV1_0_15 | CredentialRequestClientBuilderV1_0) {
this._credentialRequestOpts = { ...builder }
}

Expand Down Expand Up @@ -304,41 +308,87 @@ export class CredentialRequestClient {
})
}

public async createCredentialRequestWithoutProof(opts: CreateCredentialRequestOpts): Promise<CredentialRequestV1_0_15> {
public async createCredentialRequestWithoutProof(opts: CreateCredentialRequestOpts): Promise<CredentialRequestV1_0_15 | CredentialRequestV1_0> {
return await this.createCredentialRequestImpl(opts)
}

public async createCredentialRequest(
opts: CreateCredentialRequestOpts & {
proofInput: ProofOfPossessionBuilder | ProofOfPossession
},
): Promise<CredentialRequestV1_0_15> {
): Promise<CredentialRequestV1_0_15 | CredentialRequestV1_0> {
return await this.createCredentialRequestImpl(opts)
}

private async createCredentialRequestImpl(
opts: CreateCredentialRequestOpts & {
proofInput?: ProofOfPossessionBuilder | ProofOfPossession
},
): Promise<CredentialRequestV1_0_15> {
): Promise<CredentialRequestV1_0_15 | CredentialRequestV1_0> {
const { proofInput, credentialIdentifier, credentialConfigurationId } = opts
let proof: ProofOfPossession | undefined = undefined
if (proofInput) {
proof = await buildProof(proofInput, opts)
}

// For v15, handle authorization details from token response
if (this.version() >= OpenId4VCIVersion.VER_1_0_15) {
const authDetail = findAuthorizationDetail(this.credentialRequestOpts.authorizationDetails, credentialConfigurationId ?? credentialIdentifier)
const issuer_state = this.credentialRequestOpts.issuerState
const commonBody = {
...(issuer_state && { issuer_state }),
...(proof && { proof }),
...opts.subjectIssuance,
}

// 1.0 final: credential_configuration_id is REQUIRED, credential_identifiers is OPTIONAL array
if (this.version() >= OpenId4VCIVersion.VER_1_0) {
const authDetail = findAuthorizationDetail(
this.credentialRequestOpts.authorizationDetails as AuthorizationDetailsV1_0_15[],
credentialConfigurationId ?? credentialIdentifier,
)
const authDetailObj = authDetail && typeof authDetail === 'object' ? (authDetail as any) : null

const issuer_state = this.credentialRequestOpts.issuerState
const configId =
credentialConfigurationId ?? authDetailObj?.credential_configuration_id ?? this._credentialRequestOpts.credentialConfigurationId

const commonBody = {
if (!configId) {
return Promise.reject(Error('credential_configuration_id is required for 1.0 final credential request'))
}

// Build credential_identifiers array from various sources
const identifiers: string[] | undefined =
this._credentialRequestOpts.credentialIdentifiers ??
(authDetailObj?.credential_identifiers && authDetailObj.credential_identifiers.length > 0
? authDetailObj.credential_identifiers
: credentialIdentifier
? [credentialIdentifier]
: undefined)

// OID4VCI 1.0 uses 'proofs' (plural) instead of 'proof' (singular)
// See https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-proof-types
let proofsBody: Record<string, unknown> = {}
if (proof) {
if (proof.proof_type === 'cwt' && 'cwt' in proof) {
proofsBody = { proofs: { cwt: [proof.cwt] } }
} else if ('jwt' in proof) {
proofsBody = { proofs: { jwt: [proof.jwt] } }
}
}

const request: CredentialRequestV1_0 = {
credential_configuration_id: configId,
...(identifiers && identifiers.length > 0 && { credential_identifiers: identifiers }),
...(issuer_state && { issuer_state }),
...(proof && { proof }),
...proofsBody,
...opts.subjectIssuance,
}
} as CredentialRequestV1_0
return request
}

// Draft 15: credential_identifier (singular) OR credential_configuration_id
if (this.version() >= OpenId4VCIVersion.VER_1_0_15) {
const authDetail = findAuthorizationDetail(
this.credentialRequestOpts.authorizationDetails as AuthorizationDetailsV1_0_15[],
credentialConfigurationId ?? credentialIdentifier,
)
const authDetailObj = authDetail && typeof authDetail === 'object' ? (authDetail as any) : null

if (authDetailObj?.credential_identifier) {
Expand Down Expand Up @@ -374,11 +424,10 @@ export class CredentialRequestClient {
return Promise.reject(Error('No credential_identifier or credential_configuration_id available for v1.0-15 request'))
}

// Since you only support V15+, this should never execute
throw new Error(`Unsupported version: ${this.version()}`)
}

private version(): OpenId4VCIVersion {
return this.credentialRequestOpts?.version ?? OpenId4VCIVersion.VER_1_0_15
return this.credentialRequestOpts?.version ?? OpenId4VCIVersion.VER_1_0
}
}
Loading
Loading