Skip to content
Draft
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/fresh-coins-punch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@openid4vc/openid4vci": minor
---

feat: update draft 16 to v1.0. This only introduces one breaking change requiring a transaction_id in the deferred credential response. For this reason we replaced the Draft16 support with V1. All other drafts are still supported.
11 changes: 9 additions & 2 deletions packages/oauth2/src/access-token/parse-access-token-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import {
type RefreshTokenGrantIdentifier,
refreshTokenGrantIdentifier,
} from '../z-grant-type'
import { type AccessTokenRequest, zAccessTokenRequest } from './z-access-token'
import {
type AccessTokenRequest,
zAccessTokenRequest,
zAccessTokenRequestParsedUriParamsToJson,
} from './z-access-token'

export interface ParsedAccessTokenPreAuthorizedCodeRequestGrant {
grantType: PreAuthorizedCodeGrantIdentifier
Expand Down Expand Up @@ -78,7 +82,10 @@ export interface ParseAccessTokenRequestOptions {
* that can be returned to the client.
*/
export function parseAccessTokenRequest(options: ParseAccessTokenRequestOptions): ParseAccessTokenRequestResult {
const parsedAccessTokenRequest = zAccessTokenRequest.safeParse(options.accessTokenRequest)
const parsedAccessTokenRequest = zAccessTokenRequestParsedUriParamsToJson
.pipe(zAccessTokenRequest)
.safeParse(options.accessTokenRequest)

if (!parsedAccessTokenRequest.success) {
throw new Oauth2ServerErrorResponseError({
error: Oauth2ErrorCodes.InvalidRequest,
Expand Down
5 changes: 5 additions & 0 deletions packages/oauth2/src/access-token/z-access-token-jwt.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { zInteger } from '@openid4vc/utils'
import z from 'zod'
import { zJwtHeader, zJwtPayload } from '../common/jwt/z-jwt'
import { zAuthorizationDetailsEntryBase } from '../common/z-authorization-details'

export const zAccessTokenProfileJwtHeader = z
.object({
Expand All @@ -25,6 +26,10 @@ export const zAccessTokenProfileJwtPayload = z

// SHOULD be included in the authorization request contained it
scope: z.optional(z.string()),

// Authorization Details may be added to jwt access token
// to allow a resource server to understand the authorizations
authorization_details: z.array(zAuthorizationDetailsEntryBase).optional(),
})
.loose()

Expand Down
22 changes: 10 additions & 12 deletions packages/oauth2/src/access-token/z-access-token.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { zHttpsUrl } from '@openid4vc/utils'
import { zHttpsUrl, zStringToJson } from '@openid4vc/utils'
import z from 'zod'
import { zAuthorizationDetailsEntryBase } from '../common/z-authorization-details'
import { zOauth2ErrorResponse } from '../common/z-oauth2-error'
import {
zAuthorizationCodeGrantIdentifier,
Expand Down Expand Up @@ -30,6 +31,8 @@ export const zAccessTokenRequest = z.intersection(
// string makes the previous ones unnecessary, but it does help with error messages
z.string(),
]),

authorization_details: z.array(zAuthorizationDetailsEntryBase).optional(),
})
.loose(),
z
Expand All @@ -51,6 +54,11 @@ export const zAccessTokenRequest = z.intersection(
)
export type AccessTokenRequest = z.infer<typeof zAccessTokenRequest>

// We need to parse serialized JSON to an JSON object.
export const zAccessTokenRequestParsedUriParamsToJson = z.looseObject({
authorization_details: zStringToJson.optional(),
})

export const zAccessTokenResponse = z
.object({
access_token: z.string(),
Expand All @@ -66,17 +74,7 @@ export const zAccessTokenResponse = z
c_nonce: z.optional(z.string()),
c_nonce_expires_in: z.optional(z.number().int()),

// TODO: add additional params
authorization_details: z
.array(
z
.object({
// required when type is openid_credential (so we probably need a discriminator)
// credential_identifiers: z.array(z.string()),
})
.loose()
)
.optional(),
authorization_details: z.array(zAuthorizationDetailsEntryBase).optional(),
})
.loose()

Expand Down
5 changes: 5 additions & 0 deletions packages/oauth2/src/access-token/z-token-introspection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { zInteger } from '@openid4vc/utils'
import z from 'zod'
import { zJwtConfirmationPayload } from '../common/jwt/z-jwt'
import { zAuthorizationDetailsEntryBase } from '../common/z-authorization-details'

export const zTokenIntrospectionRequest = z
.object({
Expand Down Expand Up @@ -30,6 +31,10 @@ export const zTokenIntrospectionResponse = z
jti: z.optional(z.string()),

cnf: z.optional(zJwtConfirmationPayload),

// Authorization Details may be added to introspection response
// to allow a resource server to understand the authorizations
authorization_details: z.array(zAuthorizationDetailsEntryBase).optional(),
})
.loose()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type ParseAuthorizationRequestResult,
parseAuthorizationRequest,
} from '../authorization-request/parse-authorization-request'
import { zAuthorizationRequestParsedUriParamsToJson } from '../authorization-request/z-authorization-request'
import type { RequestLike } from '../common/z-common'
import { Oauth2ErrorCodes } from '../common/z-oauth2-error'
import { Oauth2ServerErrorResponseError } from '../error/Oauth2ServerErrorResponseError'
Expand All @@ -26,9 +27,11 @@ export interface ParseAuthorizationChallengeRequestResult extends ParseAuthoriza
export function parseAuthorizationChallengeRequest(
options: ParseAuthorizationChallengeRequestOptions
): ParseAuthorizationChallengeRequestResult {
const parsedAuthorizationChallengeRequest = zAuthorizationChallengeRequest.safeParse(
options.authorizationChallengeRequest
)
// First ensure we correctly transform the serialized entries to JSON
const parsedAuthorizationChallengeRequest = zAuthorizationRequestParsedUriParamsToJson
.pipe(zAuthorizationChallengeRequest)
.safeParse(options.authorizationChallengeRequest)

if (!parsedAuthorizationChallengeRequest.success) {
throw new Oauth2ServerErrorResponseError({
error: Oauth2ErrorCodes.InvalidRequest,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import type { RequestLike } from '../common/z-common'
import { Oauth2ErrorCodes } from '../common/z-oauth2-error'
import { Oauth2ServerErrorResponseError } from '../error/Oauth2ServerErrorResponseError'
import { type ParseAuthorizationRequestResult, parseAuthorizationRequest } from './parse-authorization-request'
import { type AuthorizationRequest, zAuthorizationRequest } from './z-authorization-request'
import {
type AuthorizationRequest,
zAuthorizationRequest,
zAuthorizationRequestParsedUriParamsToJson,
} from './z-authorization-request'

export interface ParsePushedAuthorizationRequestOptions {
request: RequestLike
Expand All @@ -21,7 +25,10 @@ export interface ParsePushedAuthorizationRequestResult extends ParseAuthorizatio
export function parsePushedAuthorizationRequest(
options: ParsePushedAuthorizationRequestOptions
): ParsePushedAuthorizationRequestResult {
const parsedAuthorizationRequest = zAuthorizationRequest.safeParse(options.authorizationRequest)
const parsedAuthorizationRequest = zAuthorizationRequestParsedUriParamsToJson
.pipe(zAuthorizationRequest)
.safeParse(options.authorizationRequest)

if (!parsedAuthorizationRequest.success) {
throw new Oauth2ServerErrorResponseError({
error: Oauth2ErrorCodes.InvalidRequest,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { zHttpsUrl } from '@openid4vc/utils'
import { zHttpsUrl, zStringToJson } from '@openid4vc/utils'
import z from 'zod'
import { zAuthorizationDetailsEntryBase } from '../common/z-authorization-details'
import { zOauth2ErrorResponse } from '../common/z-oauth2-error'

// TODO: should create different request validations for different
Expand All @@ -13,6 +14,7 @@ export const zAuthorizationRequest = z
redirect_uri: z.url().optional(),
resource: z.optional(zHttpsUrl),
scope: z.optional(z.string()),
authorization_details: z.array(zAuthorizationDetailsEntryBase).optional(),

// DPoP jwk thumbprint
dpop_jkt: z.optional(z.base64url()),
Expand All @@ -23,6 +25,11 @@ export const zAuthorizationRequest = z
.loose()
export type AuthorizationRequest = z.infer<typeof zAuthorizationRequest>

// We need to parse serialized JSON to an JSON object.
export const zAuthorizationRequestParsedUriParamsToJson = z.looseObject({
authorization_details: zStringToJson.optional(),
})

export const zPushedAuthorizationRequest = z
.object({
request_uri: z.string(),
Expand Down
11 changes: 11 additions & 0 deletions packages/oauth2/src/common/z-authorization-details.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import z from 'zod'

export const zAuthorizationDetailsEntryBase = z.object({
type: z.string(),

locations: z.array(z.string()).optional(),
actions: z.array(z.string()).optional(),
datatypes: z.array(z.string()).optional(),
identifier: z.string().optional(),
privileges: z.array(z.string()).optional(),
})
3 changes: 3 additions & 0 deletions packages/oauth2/src/common/z-oauth2-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export enum Oauth2ErrorCodes {
InvalidRequestUriMethod = 'invalid_request_uri_method',
InvalidTransactionData = 'invalid_transaction_data',
WalletUnavailable = 'wallet_unavailable',

// Rich Authorization Requests
InvalidAuthorizationDetails = 'invalid_authorization_details',
}

export const zOauth2ErrorResponse = z
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ export const zAuthorizationServerMetadata = z

// Attestation Based Client Auth (draft 5)
client_attestation_pop_nonce_required: z.boolean().optional(),

// RFC9396 - Rich Autorization Requests
authorization_details_types_supported: z.array(z.string()).optional(),
})
.loose()
.refine(
Expand Down
2 changes: 1 addition & 1 deletion packages/openid4vci/src/Openid4vciClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ export class Openid4vciClient {

if (
issuerMetadata.originalDraftVersion === Openid4vciDraftVersion.Draft15 ||
issuerMetadata.originalDraftVersion === Openid4vciDraftVersion.Draft16
issuerMetadata.originalDraftVersion === Openid4vciDraftVersion.V1
) {
credentialResponse = await retrieveCredentialsWithCredentialConfigurationId({
accessToken,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,25 @@ export function createCredentialResponse(options: CreateCredentialResponseOption
} satisfies CredentialResponse)
}

export type CreateDeferredCredentialResponseOptions = {
credentials?: DeferredCredentialResponse['credentials']
export type CreateDeferredCredentialResponseOptions = (
| {
credentials: DeferredCredentialResponse['credentials']
notificationId?: string

interval?: number

notificationId?: string
transactionId?: never
interval?: never
}
| {
/**
* The `transaction_id` that was included in the
*/
transactionId: string
interval: number

credentials?: never
notificationId?: never
}
) & {
/**
* Additional payload to include in the deferred credential response
*/
Expand All @@ -62,7 +74,10 @@ export function createDeferredCredentialResponse(options: CreateDeferredCredenti
return parseWithErrorHandling(zDeferredCredentialResponse, {
credentials: options.credentials,
notification_id: options.notificationId,

transaction_id: options.transactionId,
interval: options.interval,

...options.additionalPayload,
} satisfies DeferredCredentialResponse)
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
zMsoMdocCredentialIssuerMetadataDraft14,
zSdJwtDcCredentialIssuerMetadata,
} from '../formats/credential'
import { zLegacySdJwtVcCredentialIssuerMetadataDraft16 } from '../formats/credential/sd-jwt-vc/z-sd-jwt-vc'
import { zLegacySdJwtVcCredentialIssuerMetadataV1 } from '../formats/credential/sd-jwt-vc/z-sd-jwt-vc'
import { zSdJwtW3VcCredentialIssuerMetadata } from '../formats/credential/w3c-vc/z-w3c-sd-jwt-vc'
import { getCredentialConfigurationSupportedById } from '../metadata/credential-issuer/credential-issuer-metadata'
import type { IssuerMetadataResult } from '../metadata/fetch-issuer-metadata'
Expand All @@ -40,7 +40,7 @@ export function getCredentialRequestFormatPayloadForCredentialConfigurationId(
)

if (
zIs(zLegacySdJwtVcCredentialIssuerMetadataDraft16, credentialConfiguration) ||
zIs(zLegacySdJwtVcCredentialIssuerMetadataV1, credentialConfiguration) ||
zIs(zLegacySdJwtVcCredentialIssuerMetadataDraft14, credentialConfiguration)
) {
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export async function retrieveCredentialsWithCredentialConfigurationId(
) {
if (
options.issuerMetadata.originalDraftVersion !== Openid4vciDraftVersion.Draft15 &&
options.issuerMetadata.originalDraftVersion !== Openid4vciDraftVersion.Draft16
options.issuerMetadata.originalDraftVersion !== Openid4vciDraftVersion.V1
) {
throw new Openid4vciError(
'Requesting credentials based on credential configuration ID is not supported in OpenID4VCI below draft 15. Make sure to provide the format and format specific claims in the request.'
Expand Down Expand Up @@ -119,10 +119,10 @@ export interface RetrieveCredentialsWithFormatOptions extends RetrieveCredential
export async function retrieveCredentialsWithFormat(options: RetrieveCredentialsWithFormatOptions) {
if (
options.issuerMetadata.originalDraftVersion === Openid4vciDraftVersion.Draft15 ||
options.issuerMetadata.originalDraftVersion === Openid4vciDraftVersion.Draft16
options.issuerMetadata.originalDraftVersion === Openid4vciDraftVersion.V1
) {
throw new Openid4vciError(
'Requesting credentials based on format is not supported in OpenID4VCI draft 15. Provide the credential configuration id directly in the request.'
'Requesting credentials based on format is not supported on OpenID4VCI above draft 15. Provide the credential configuration id directly in the request.'
)
}

Expand Down Expand Up @@ -331,7 +331,11 @@ export async function retrieveDeferredCredentials(

// Try to parse the credential response
const deferredCredentialResponseResult = isResponseContentType(ContentType.Json, resourceResponse.response)
? zDeferredCredentialResponse.safeParse(await resourceResponse.response.clone().json())
? zDeferredCredentialResponse
.refine((response) => response.credentials || response.transaction_id === options.transactionId, {
error: `Transaction id in deferred credential response does not match transaction id in deferred credential request '${options.transactionId}'`,
})
.safeParse(await resourceResponse.response.clone().json())
: undefined
if (!deferredCredentialResponseResult?.success) {
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,16 @@ const zBaseCredentialResponse = z
z.array(zCredentialEncoding),
])
.optional(),
interval: z.number().int().positive().optional(),
notification_id: z.string().optional(),

transaction_id: z.string().optional(),
interval: z.number().int().positive().optional(),
})
.loose()

export const zCredentialResponse = zBaseCredentialResponse
.extend({
credential: z.optional(zCredentialEncoding),
transaction_id: z.string().optional(),

c_nonce: z.string().optional(),
c_nonce_expires_in: z.number().int().optional(),
Expand Down Expand Up @@ -65,14 +66,29 @@ export const zCredentialErrorResponse = z

export type CredentialErrorResponse = z.infer<typeof zCredentialErrorResponse>

export const zDeferredCredentialResponse = zBaseCredentialResponse.refine(
(value) => {
const { credentials, interval } = value
return [credentials, interval].filter((i) => i !== undefined).length === 1
},
{
message: `Exactly one of 'credentials' or 'interval' MUST be defined.`,
export const zDeferredCredentialResponse = zBaseCredentialResponse.superRefine((value, ctx) => {
const { credentials, transaction_id, interval, notification_id } = value

if ([credentials, transaction_id].filter((i) => i !== undefined).length !== 1) {
ctx.addIssue({
code: 'custom',
message: `Exactly one of 'credentials', or 'transaction_id' MUST be defined.`,
})
}

if (transaction_id && !interval) {
ctx.addIssue({
code: 'custom',
message: `'interval' MUST be defined when 'transaction_id' is defined.`,
})
}

if (notification_id && credentials) {
ctx.addIssue({
code: 'custom',
message: `'notification_id' MUST NOT be defined when 'credentials' is not defined.`,
})
}
)
})

export type DeferredCredentialResponse = z.infer<typeof zDeferredCredentialResponse>
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export type LegacySdJwtVcFormatIdentifier = z.infer<typeof zLegacySdJwtVcFormatI
* of the OpenID for Verifiable Presentations specification. Please update your
* implementations accordingly.
*/
export const zLegacySdJwtVcCredentialIssuerMetadataDraft16 = zCredentialConfigurationSupportedCommon.extend({
export const zLegacySdJwtVcCredentialIssuerMetadataV1 = zCredentialConfigurationSupportedCommon.extend({
vct: z.string(),
format: zLegacySdJwtVcFormatIdentifier,
order: z.optional(z.array(z.string())),
Expand Down
Loading