@@ -3,13 +3,88 @@ import { AuthorizationRequest } from '../authorization-request'
33import { IDToken } from '../id-token'
44import { RequestObject } from '../request-object'
55import { assertValidResponseOpts } from './Opts'
6- import { AuthorizationRequestPayload , AuthorizationResponsePayload , IDTokenPayload , SIOPErrors } from '../types'
6+ import {
7+ AuthorizationRequestPayload ,
8+ AuthorizationResponsePayload ,
9+ DcqlPresentationEntry ,
10+ DcqlVpToken ,
11+ DcqlVpTokenInput ,
12+ IDTokenPayload ,
13+ NonEmptyArray ,
14+ SIOPErrors
15+ } from '../types'
716import { AuthorizationResponseOpts } from './types'
817
18+
19+ /**
20+ * Checks if an object is array-like (has only numeric string keys: "0", "1", "2", etc.)
21+ * This handles objects that were serialized arrays: { "0": val1, "1": val2 }
22+ */
23+ const isArrayLikeObject = ( value : unknown ) : value is Record < string , DcqlPresentationEntry > => {
24+ if ( ! value || typeof value !== 'object' || Array . isArray ( value ) ) {
25+ return false
26+ }
27+ const keys = Object . keys ( value )
28+ return keys . length > 0 && keys . every ( key => / ^ \d + $ / . test ( key ) )
29+ }
30+
31+ /**
32+ * Normalizes a credential query value to an array.
33+ * Handles three input formats:
34+ * 1. Single value: "credential" -> ["credential"]
35+ * 2. Array: ["cred1", "cred2"] -> ["cred1", "cred2"]
36+ * 3. Array-like object: {"0": "cred1", "1": "cred2"} -> ["cred1", "cred2"]
37+ */
38+ const normalizeToArray = (
39+ credentialQueryId : string ,
40+ value : DcqlPresentationEntry | DcqlPresentationEntry [ ] | Record < string , DcqlPresentationEntry >
41+ ) : NonEmptyArray < DcqlPresentationEntry > => {
42+ let presentationsArray : DcqlPresentationEntry [ ]
43+
44+ if ( Array . isArray ( value ) ) {
45+ presentationsArray = value
46+ } else if ( isArrayLikeObject ( value ) ) {
47+ // Convert array-like object to array by sorting keys numerically
48+ const sortedKeys = Object . keys ( value ) . sort ( ( a , b ) => Number ( a ) - Number ( b ) )
49+ presentationsArray = sortedKeys . map ( key => value [ key ] )
50+ } else {
51+ // Single value, wrap in array
52+ presentationsArray = [ value ]
53+ }
54+
55+ if ( presentationsArray . length === 0 ) {
56+ throw new Error (
57+ `DCQL presentations for credential query '${ credentialQueryId } ' cannot be empty`
58+ )
59+ }
60+
61+ // Cast to NonEmptyArray since we verified length > 0
62+ return presentationsArray as NonEmptyArray < DcqlPresentationEntry >
63+ }
64+
65+ /**
66+ * Converts a DCQL presentation input (which may have mixed formats) to the canonical
67+ * format where all credential queries map to non-empty arrays of presentations.
68+ *
69+ * This ensures consistent handling of:
70+ * - Single presentations: { "PID": "eyJ..." } -> { "PID": ["eyJ..."] }
71+ * - Array presentations: { "PID": ["eyJ..."] } -> { "PID": ["eyJ..."] }
72+ * - Array-like objects: { "PID": {"0": "eyJ..."} } -> { "PID": ["eyJ..."] }
73+ */
74+ const toCanonicalDcqlPresentation = ( input : DcqlVpTokenInput ) : DcqlVpToken => {
75+ return Object . fromEntries (
76+ Object . entries ( input ) . map ( ( [ credentialQueryId , value ] ) => {
77+ const presentationsArray = normalizeToArray ( credentialQueryId , value )
78+ return [ credentialQueryId , presentationsArray ]
79+ } )
80+ ) as DcqlVpToken
81+ }
82+
83+
984export const createResponsePayload = async (
1085 authorizationRequest : AuthorizationRequest ,
1186 responseOpts : AuthorizationResponseOpts ,
12- idTokenPayload ?: IDTokenPayload ,
87+ idTokenPayload ?: IDTokenPayload
1388) : Promise < AuthorizationResponsePayload | undefined > => {
1489 assertValidResponseOpts ( responseOpts )
1590 if ( ! authorizationRequest ) {
@@ -20,15 +95,21 @@ export const createResponsePayload = async (
2095 const state : string | undefined = authorizationRequest . getMergedProperty ( 'state' )
2196
2297 const responsePayload : AuthorizationResponsePayload = {
23- ...( responseOpts . accessToken && { access_token : responseOpts . accessToken , expires_in : responseOpts . expiresIn || 3600 } ) ,
98+ ...( responseOpts . accessToken && {
99+ access_token : responseOpts . accessToken ,
100+ expires_in : responseOpts . expiresIn || 3600
101+ } ) ,
24102 ...( responseOpts . tokenType && { token_type : responseOpts . tokenType } ) ,
25103 ...( responseOpts . refreshToken && { refresh_token : responseOpts . refreshToken } ) ,
26104 ...( responseOpts . isFirstParty && { is_first_party : responseOpts . isFirstParty } ) ,
27- state,
105+ state
28106 }
29107
30108 if ( responseOpts . dcqlResponse ?. dcqlPresentation ) {
31- responsePayload . vp_token = DcqlPresentation . encode ( responseOpts . dcqlResponse . dcqlPresentation as DcqlPresentation )
109+ const canonicalPresentation = toCanonicalDcqlPresentation (
110+ responseOpts . dcqlResponse . dcqlPresentation
111+ )
112+ responsePayload . vp_token = DcqlPresentation . encode ( canonicalPresentation )
32113 }
33114
34115 if ( idTokenPayload ) {
@@ -46,7 +127,7 @@ export const createResponsePayload = async (
46127 */
47128export const mergeOAuth2AndOpenIdInRequestPayload = async (
48129 payload : AuthorizationRequestPayload ,
49- requestObject ?: RequestObject ,
130+ requestObject ?: RequestObject
50131) : Promise < AuthorizationRequestPayload > => {
51132 const payloadCopy = JSON . parse ( JSON . stringify ( payload ) )
52133
0 commit comments