Skip to content

Commit 27f608d

Browse files
authored
Merge pull request #209 from Sphereon-Opensource/feature/SSISDK-73_dcql-array-fix
feature/SSISDK-73_dcql-array-fix
2 parents 34b969f + 4dd7112 commit 27f608d

5 files changed

Lines changed: 185 additions & 48 deletions

File tree

packages/siop-oid4vp/lib/authorization-response/OpenID4VP.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,26 @@ export const extractDcqlPresentationFromDcqlVpToken = (
104104
opts?: { hasher?: HasherSync },
105105
): PresentationSubmission => {
106106
return Object.fromEntries(
107-
Object.entries(DcqlPresentation.parse(vpToken)).map(([credentialQueryId, vp]) => [
107+
Object.entries(DcqlPresentation.parse(vpToken)).map(([credentialQueryId, vp]) => {
108+
let singleVp: W3CVerifiablePresentation | CompactSdJwtVc | string
109+
110+
if (Array.isArray(vp)) {
111+
if (vp.length === 0) {
112+
throw new Error(`DCQL query '${credentialQueryId}' has empty array of presentations`)
113+
}
114+
if (vp.length > 1) {
115+
throw new Error(`DCQL query '${credentialQueryId}' has multiple presentations (${vp.length}), but only one is supported atm`)
116+
}
117+
singleVp = vp[0]
118+
} else {
119+
singleVp = vp
120+
}
121+
122+
return [
108123
credentialQueryId,
109-
CredentialMapper.toWrappedVerifiablePresentation(vp as W3CVerifiablePresentation | CompactSdJwtVc | string, {hasher: opts?.hasher}),
110-
]),
124+
CredentialMapper.toWrappedVerifiablePresentation(singleVp as W3CVerifiablePresentation | CompactSdJwtVc | string, {hasher: opts?.hasher}),
125+
]
126+
}),
111127
)
112128
}
113129

packages/siop-oid4vp/lib/authorization-response/Payload.ts

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,85 @@ import { AuthorizationRequest } from '../authorization-request'
33
import { IDToken } from '../id-token'
44
import { RequestObject } from '../request-object'
55
import { 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'
716
import { 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+
const sortedKeys = Object.keys(value).sort((a, b) => Number(a) - Number(b))
48+
presentationsArray = sortedKeys.map(key => value[key])
49+
} else {
50+
presentationsArray = [value]
51+
}
52+
53+
if (presentationsArray.length === 0) {
54+
throw new Error(
55+
`DCQL presentations for credential query '${credentialQueryId}' cannot be empty`
56+
)
57+
}
58+
59+
return presentationsArray as NonEmptyArray<DcqlPresentationEntry>
60+
}
61+
62+
/**
63+
* Converts a DCQL presentation input (which may have mixed formats) to the canonical
64+
* format where all credential queries map to non-empty arrays of presentations.
65+
*
66+
* This ensures consistent handling of:
67+
* - Single presentations: { "PID": "eyJ..." } -> { "PID": ["eyJ..."] }
68+
* - Array presentations: { "PID": ["eyJ..."] } -> { "PID": ["eyJ..."] }
69+
* - Array-like objects: { "PID": {"0": "eyJ..."} } -> { "PID": ["eyJ..."] }
70+
*/
71+
const toCanonicalDcqlPresentation = (input: DcqlVpTokenInput): DcqlVpToken => {
72+
return Object.fromEntries(
73+
Object.entries(input).map(([credentialQueryId, value]) => {
74+
const presentationsArray = normalizeToArray(credentialQueryId, value)
75+
return [credentialQueryId, presentationsArray]
76+
})
77+
) as DcqlVpToken
78+
}
79+
80+
981
export const createResponsePayload = async (
1082
authorizationRequest: AuthorizationRequest,
1183
responseOpts: AuthorizationResponseOpts,
12-
idTokenPayload?: IDTokenPayload,
84+
idTokenPayload?: IDTokenPayload
1385
): Promise<AuthorizationResponsePayload | undefined> => {
1486
assertValidResponseOpts(responseOpts)
1587
if (!authorizationRequest) {
@@ -20,15 +92,21 @@ export const createResponsePayload = async (
2092
const state: string | undefined = authorizationRequest.getMergedProperty('state')
2193

2294
const responsePayload: AuthorizationResponsePayload = {
23-
...(responseOpts.accessToken && { access_token: responseOpts.accessToken, expires_in: responseOpts.expiresIn || 3600 }),
95+
...(responseOpts.accessToken && {
96+
access_token: responseOpts.accessToken,
97+
expires_in: responseOpts.expiresIn || 3600
98+
}),
2499
...(responseOpts.tokenType && { token_type: responseOpts.tokenType }),
25100
...(responseOpts.refreshToken && { refresh_token: responseOpts.refreshToken }),
26101
...(responseOpts.isFirstParty && { is_first_party: responseOpts.isFirstParty }),
27-
state,
102+
state
28103
}
29104

30105
if (responseOpts.dcqlResponse?.dcqlPresentation) {
31-
responsePayload.vp_token = DcqlPresentation.encode(responseOpts.dcqlResponse.dcqlPresentation as DcqlPresentation)
106+
const canonicalPresentation = toCanonicalDcqlPresentation(
107+
responseOpts.dcqlResponse.dcqlPresentation
108+
)
109+
responsePayload.vp_token = DcqlPresentation.encode(canonicalPresentation)
32110
}
33111

34112
if (idTokenPayload) {
@@ -46,7 +124,7 @@ export const createResponsePayload = async (
46124
*/
47125
export const mergeOAuth2AndOpenIdInRequestPayload = async (
48126
payload: AuthorizationRequestPayload,
49-
requestObject?: RequestObject,
127+
requestObject?: RequestObject
50128
): Promise<AuthorizationRequestPayload> => {
51129
const payloadCopy = JSON.parse(JSON.stringify(payload))
52130

packages/siop-oid4vp/lib/authorization-response/types.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,22 @@ import {
66
HasherSync,
77
MdocOid4vpIssuerSigned,
88
PresentationSubmission,
9-
W3CVerifiablePresentation,
9+
W3CVerifiablePresentation
1010
} from '@sphereon/ssi-types'
1111
import { DcqlQuery } from 'dcql'
1212
import { AuthorizationResponse } from './AuthorizationResponse'
1313
import {
1414
CreateJwtCallback,
15+
DcqlVpTokenInput,
16+
ResponseIss,
1517
ResponseMode,
1618
ResponseRegistrationOpts,
1719
ResponseType,
1820
ResponseURIType,
1921
SupportedVersion,
2022
VerifiablePresentationWithFormat,
2123
Verification,
22-
VerifyJwtCallback,
23-
ResponseIss
24+
VerifyJwtCallback
2425
} from '../types'
2526

2627
export interface AuthorizationResponseOpts {
@@ -42,7 +43,7 @@ export interface AuthorizationResponseOpts {
4243
}
4344

4445
export interface DcqlResponseOpts {
45-
dcqlPresentation: Record<string, string | Record<string, unknown> | Array<string | Record<string, unknown>>>
46+
dcqlPresentation: DcqlVpTokenInput
4647
}
4748

4849
export interface DcqlQueryPayloadOpts {

packages/siop-oid4vp/lib/schemas/AuthorizationResponseOpts.schema.ts

Lines changed: 64 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -874,38 +874,76 @@ export const AuthorizationResponseOptsSchemaObj = {
874874
"type": "object",
875875
"properties": {
876876
"dcqlPresentation": {
877-
"type": "object",
878-
"additionalProperties": {
879-
"anyOf": [
880-
{
881-
"type": "string"
882-
},
883-
{
884-
"type": "object",
885-
"additionalProperties": {}
886-
},
887-
{
888-
"type": "array",
889-
"items": {
890-
"anyOf": [
891-
{
892-
"type": "string"
893-
},
894-
{
895-
"type": "object",
896-
"additionalProperties": {}
897-
}
898-
]
899-
}
900-
}
901-
]
902-
}
877+
"$ref": "#/definitions/DcqlVpTokenInput"
903878
}
904879
},
905880
"required": [
906881
"dcqlPresentation"
907882
],
908883
"additionalProperties": false
884+
},
885+
"DcqlVpTokenInput": {
886+
"type": "object",
887+
"additionalProperties": {
888+
"anyOf": [
889+
{
890+
"$ref": "#/definitions/DcqlPresentationEntry"
891+
},
892+
{
893+
"type": "array",
894+
"items": {
895+
"$ref": "#/definitions/DcqlPresentationEntry"
896+
}
897+
},
898+
{
899+
"type": "object",
900+
"additionalProperties": {
901+
"$ref": "#/definitions/DcqlPresentationEntry"
902+
}
903+
}
904+
]
905+
}
906+
},
907+
"DcqlPresentationEntry": {
908+
"anyOf": [
909+
{
910+
"type": "string"
911+
},
912+
{
913+
"type": "object",
914+
"additionalProperties": {
915+
"$ref": "#/definitions/Json"
916+
}
917+
}
918+
]
919+
},
920+
"Json": {
921+
"anyOf": [
922+
{
923+
"type": "string"
924+
},
925+
{
926+
"type": "number"
927+
},
928+
{
929+
"type": "boolean"
930+
},
931+
{
932+
"type": "null"
933+
},
934+
{
935+
"type": "object",
936+
"additionalProperties": {
937+
"$ref": "#/definitions/Json"
938+
}
939+
},
940+
{
941+
"type": "array",
942+
"items": {
943+
"$ref": "#/definitions/Json"
944+
}
945+
}
946+
]
909947
}
910948
}
911949
};

packages/siop-oid4vp/lib/types/SIOP.types.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@ import { JarmClientMetadata } from '@sphereon/jarm'
22
import { DynamicRegistrationClientMetadata, SigningAlgo } from '@sphereon/oid4vc-common'
33
import {
44
AdditionalClaims,
5-
CompactSdJwtVc,
65
Format,
7-
MdocOid4vpMdocVpToken,
86
W3CVerifiableCredential,
97
W3CVerifiablePresentation,
108
WrappedVerifiablePresentation
@@ -37,9 +35,12 @@ import {
3735
CreateAuthorizationResponsePayloadSchema,
3836
CreateAuthorizationResponseSchema,
3937
QRCodeOptsPayloadSchema,
40-
QRCodeOptsSchema, RequestErrorPayloadSchema, RequestErrorSchema,
38+
QRCodeOptsSchema,
39+
RequestErrorPayloadSchema,
40+
RequestErrorSchema,
4141
VerifiedDataOptsSchema
4242
} from '../schemas'
43+
import { Json } from './Json.types'
4344

4445
export const DEFAULT_EXPIRATION_TIME = 10 * 60
4546

@@ -178,6 +179,14 @@ export interface IDTokenPayload extends JWTPayload {
178179
}
179180
}
180181

182+
export type NonEmptyArray<T> = [T, ...T[]]
183+
export type DcqlPresentationEntry = string | Record<string, Json>
184+
export type DcqlVpToken = Record<string, NonEmptyArray<DcqlPresentationEntry>>
185+
export type DcqlVpTokenInput = Record<
186+
string,
187+
DcqlPresentationEntry | DcqlPresentationEntry[] | Record<string, DcqlPresentationEntry>
188+
>
189+
181190
export type EncodedDcqlPresentationVpToken = string
182191

183192
export interface AuthorizationResponsePayload {
@@ -187,12 +196,7 @@ export interface AuthorizationResponsePayload {
187196
expires_in?: number
188197
state?: string
189198
id_token?: string
190-
vp_token?:
191-
| Array<W3CVerifiablePresentation | CompactSdJwtVc | MdocOid4vpMdocVpToken>
192-
| W3CVerifiablePresentation
193-
| CompactSdJwtVc
194-
| MdocOid4vpMdocVpToken
195-
| EncodedDcqlPresentationVpToken
199+
vp_token?: EncodedDcqlPresentationVpToken
196200
is_first_party?: boolean
197201
// eslint-disable-next-line @typescript-eslint/no-explicit-any
198202
[x: string]: any

0 commit comments

Comments
 (0)