11import type { OpenId4VcIssuanceSessionsCreateOffer } from '../types/issuer.types'
22import type { Request as Req } from 'express'
33
4- import { type OpenId4VcIssuanceSessionState } from '@credo-ts/openid4vc '
5- import { OpenId4VcIssuanceSessionRepository } from '@credo-ts/openid4vc'
4+ import { CREDENTIALS_CONTEXT_V1_URL , CREDENTIALS_CONTEXT_V2_URL } from '@credo-ts/core '
5+ import { OpenId4VcIssuanceSessionRepository , type OpenId4VcIssuanceSessionState } from '@credo-ts/openid4vc'
66
77import { CredentialFormat , SignerMethod } from '../../../enums/enum'
88import { BadRequestError , NotFoundError } from '../../../errors/errors'
@@ -24,18 +24,27 @@ class IssuanceSessionsService {
2424 credentials . map ( async ( cred ) => {
2525 const supported = issuer . credentialConfigurationsSupported [ cred . credentialSupportedId ]
2626
27- this . validateCredentialConfig ( cred , supported )
27+ const format = cred . format as unknown as CredentialFormat
28+ const isJsonLdFormat = format === CredentialFormat . JwtVcJsonLd || format === CredentialFormat . LdpVc
29+ const effectiveVersion = options . version === 'v2.0' && isJsonLdFormat ? 'v2.0' : undefined
30+
31+ this . validateCredentialConfig ( cred , supported , effectiveVersion )
2832
2933 const statusBlock = await this . processStatusList ( cred , options , agentReq , offerStatusInfo )
3034
3135 const currentVct = cred . payload && 'vct' in cred . payload ? cred . payload . vct : undefined
32- return {
33- ...cred ,
34- payload : {
36+ const transformedPayload = this . transformPayloadForVersion (
37+ {
3538 ...cred . payload ,
3639 vct : currentVct ?? ( typeof supported . vct === 'string' ? supported . vct : undefined ) ,
3740 ...( statusBlock ? { status : statusBlock } : { } ) ,
3841 } ,
42+ effectiveVersion ,
43+ )
44+
45+ return {
46+ ...cred ,
47+ payload : transformedPayload ,
3948 }
4049 } ) ,
4150 )
@@ -53,56 +62,52 @@ class IssuanceSessionsService {
5362 if ( ! issuerModule ) {
5463 throw new Error ( 'OID4VC issuer module not initialized' )
5564 }
56- const preAuthorizedCodeFlowConfig = this . resolvePreAuthorizedCodeFlowConfig ( options . preAuthorizedCodeFlowConfig )
57-
5865 const { credentialOffer, issuanceSession } = await issuerModule . createCredentialOffer ( {
5966 issuerId : publicIssuerId ,
6067 issuanceMetadata : options . issuanceMetadata ,
6168 credentialConfigurationIds : credentials . map ( ( c ) => c . credentialSupportedId ) ,
62- preAuthorizedCodeFlowConfig,
69+ preAuthorizedCodeFlowConfig : options . preAuthorizedCodeFlowConfig ,
6370 authorizationCodeFlowConfig : options . authorizationCodeFlowConfig ,
71+ version : 'v1' ,
6472 } )
6573
6674 return { credentialOffer, issuanceSession }
6775 }
6876
69- private resolvePreAuthorizedCodeFlowConfig (
70- config : OpenId4VcIssuanceSessionsCreateOffer [ 'preAuthorizedCodeFlowConfig' ] ,
71- ) {
72- if ( ! config ) return undefined
73-
74- const hasTxCode = config . txCode != null
75- const hasAuthServerUrl = config . authorizationServerUrl != null
76-
77- if ( hasTxCode !== hasAuthServerUrl ) {
78- throw new BadRequestError (
79- 'Both txCode and authorizationServerUrl must be provided together for normal flow, or both must be omitted for no-auth flow' ,
77+ private validateCredentialConfig ( cred : any , supported : any , version ?: string ) {
78+ if ( ! supported ) {
79+ throw new Error ( `CredentialSupportedId '${ cred . credentialSupportedId } ' is not supported by issuer` )
80+ }
81+ if ( supported . format !== cred . format ) {
82+ throw new Error (
83+ `Format mismatch for '${ cred . credentialSupportedId } ': expected '${ supported . format } ', got '${ cred . format } '` ,
8084 )
8185 }
8286
83- if ( ! hasTxCode ) return { }
87+ const isW3cFormat =
88+ cred . format === CredentialFormat . JwtVcJson ||
89+ cred . format === CredentialFormat . JwtVcJsonLd ||
90+ cred . format === CredentialFormat . LdpVc
8491
85- if ( Object . keys ( config . txCode ! ) . length === 0 ) {
86- throw new BadRequestError ( 'txCode must not be an empty object when provided' )
92+ if ( isW3cFormat && ! cred . payload ?. credentialSubject ) {
93+ throw new BadRequestError (
94+ `Credential payload for '${ cred . credentialSupportedId } ' must contain 'credentialSubject'` ,
95+ )
8796 }
8897
89- if ( config . authorizationServerUrl ! . trim ( ) === '' ) {
90- throw new BadRequestError ( 'authorizationServerUrl must not be an empty string when provided' )
98+ if (
99+ version === 'v2.0' &&
100+ cred . payload ?. issuer &&
101+ typeof cred . payload . issuer === 'object' &&
102+ ! cred . payload . issuer . id
103+ ) {
104+ throw new BadRequestError ( `Issuer object for '${ cred . credentialSupportedId } ' must contain 'id' property` )
91105 }
92106
93- return { txCode : config . txCode , authorizationServerUrl : config . authorizationServerUrl }
107+ this . validateSignerOptions ( cred )
94108 }
95109
96- private validateCredentialConfig ( cred : any , supported : any ) {
97- if ( ! supported ) {
98- throw new Error ( `CredentialSupportedId '${ cred . credentialSupportedId } ' is not supported by issuer` )
99- }
100- if ( supported . format !== cred . format ) {
101- throw new Error (
102- `Format mismatch for '${ cred . credentialSupportedId } ': expected '${ supported . format } ', got '${ cred . format } '` ,
103- )
104- }
105-
110+ private validateSignerOptions ( cred : any ) {
106111 if ( ! cred . signerOptions ?. method ) {
107112 throw new BadRequestError (
108113 `signerOptions must be provided and allowed methods are ${ Object . values ( SignerMethod ) . join ( ', ' ) } ` ,
@@ -122,6 +127,82 @@ class IssuanceSessionsService {
122127 }
123128 }
124129
130+ private transformPayloadForVersion ( payload : any , version : 'v1.1' | 'v2.0' | undefined ) {
131+ if ( version !== 'v2.0' ) {
132+ return payload
133+ }
134+
135+ const transformed = { ...payload }
136+
137+ // Rule: issuanceDate -> validFrom
138+ if ( transformed . issuanceDate && ! transformed . validFrom ) {
139+ transformed . validFrom = transformed . issuanceDate
140+ }
141+
142+ // Rule: expirationDate -> validUntil
143+ if ( transformed . expirationDate && ! transformed . validUntil ) {
144+ transformed . validUntil = transformed . expirationDate
145+ delete transformed . expirationDate
146+ }
147+
148+ // Normalize dates to ISO format
149+ if ( transformed . validFrom ) transformed . validFrom = this . formatDate ( transformed . validFrom )
150+ if ( transformed . validUntil ) transformed . validUntil = this . formatDate ( transformed . validUntil )
151+
152+ // Rule: issuer string -> object (standardizing for v2.0 if it is a DID)
153+ if ( typeof transformed . issuer === 'string' && transformed . issuer . startsWith ( 'did:' ) ) {
154+ transformed . issuer = { id : transformed . issuer }
155+ }
156+
157+ this . updateContextForVersion ( transformed , version )
158+
159+ return transformed
160+ }
161+
162+ private formatDate ( date : any ) : any {
163+ if ( ! date ) return undefined
164+ if ( date instanceof Date ) return date . toISOString ( )
165+ if ( typeof date === 'string' ) {
166+ try {
167+ const d = new Date ( date )
168+ if ( Number . isNaN ( d . getTime ( ) ) ) return date
169+ return d . toISOString ( )
170+ } catch {
171+ return date
172+ }
173+ }
174+ return date
175+ }
176+
177+ private updateContextForVersion ( transformed : any , version : 'v1.1' | 'v2.0' | undefined ) {
178+ const v1Context = CREDENTIALS_CONTEXT_V1_URL
179+ const v2Context = CREDENTIALS_CONTEXT_V2_URL
180+
181+ if ( version === 'v2.0' ) {
182+ let currentCtx : any [ ] = [ ]
183+ if ( Array . isArray ( transformed [ '@context' ] ) ) {
184+ currentCtx = transformed [ '@context' ]
185+ } else if ( typeof transformed [ '@context' ] === 'string' ) {
186+ currentCtx = [ transformed [ '@context' ] ]
187+ }
188+
189+ const ctxSet = new Set ( currentCtx )
190+ ctxSet . delete ( v1Context )
191+ ctxSet . delete ( v2Context )
192+ // W3C V2.0 requires the V2 context to be the very first element.
193+ transformed [ '@context' ] = [ v2Context , v1Context , ...Array . from ( ctxSet ) ]
194+ } else if ( ! transformed [ '@context' ] ) {
195+ // W3C V1.1 / Default behavior
196+ transformed [ '@context' ] = [ v1Context ]
197+ } else if ( Array . isArray ( transformed [ '@context' ] ) ) {
198+ const ctxSet = new Set ( transformed [ '@context' ] )
199+ ctxSet . delete ( v1Context )
200+ transformed [ '@context' ] = [ v1Context , ...Array . from ( ctxSet ) ]
201+ } else if ( typeof transformed [ '@context' ] === 'string' ) {
202+ transformed [ '@context' ] = [ v1Context , transformed [ '@context' ] ]
203+ }
204+ }
205+
125206 private async processStatusList (
126207 cred : any ,
127208 options : OpenId4VcIssuanceSessionsCreateOffer ,
0 commit comments