Skip to content

Commit e4b2942

Browse files
committed
Merge remote-tracking branch 'origin/feature/SSISDK-73_cnonce_fix' into feature/DIIPv4
2 parents d993974 + 27f608d commit e4b2942

7 files changed

Lines changed: 192 additions & 96 deletions

File tree

packages/issuer-rest/lib/__tests__/nonceEndpoint.spec.ts

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ describe('Nonce Endpoint', () => {
7676
await new Promise((resolve) => setTimeout((v: void) => resolve(v), 500))
7777
})
7878

79-
it('should return fresh c_nonce without authorization', async () => {
79+
it('should return fresh c_nonce', async () => {
8080
const res = await requests(app).post('/nonce').send()
8181

8282
expect(res.statusCode).toEqual(200)
@@ -101,19 +101,6 @@ describe('Nonce Endpoint', () => {
101101
expect(storedNonce?.expiresAt).toBeGreaterThan(Math.floor(Date.now() / 1000))
102102
})
103103

104-
it('should return error with invalid access token', async () => {
105-
const res = await requests(app)
106-
.post('/nonce')
107-
.set('Authorization', 'Bearer invalid-token')
108-
.send()
109-
110-
expect(res.statusCode).toEqual(400)
111-
const actual = JSON.parse(res.text)
112-
expect(actual).toEqual({
113-
error: 'invalid_token'
114-
})
115-
})
116-
117104
it('should work when nonce endpoint is disabled', async () => {
118105
const disabledVcIssuer = new VcIssuer(
119106
{

packages/issuer-rest/lib/oid4vci-api-functions.ts

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { epochTime, uuidv4 } from '@sphereon/oid4vc-common'
1+
import { uuidv4 } from '@sphereon/oid4vc-common'
22
import {
33
ACCESS_TOKEN_ISSUER_REQUIRED_ERROR,
44
AccessTokenRequest,
@@ -434,46 +434,18 @@ export function nonceEndpoint(router: Router, issuer: VcIssuer, opts: INonceEndp
434434

435435
router.post(path, async (request: Request, response: Response) => {
436436
try {
437-
let preAuthorizedCode: string | undefined
438-
let issuerState: string | undefined
439-
440-
// Verify access token if present (optional per spec)
441-
// If not present, the nonce will be unbound to any session
442-
if (request.header('Authorization')) {
443-
try {
444-
const jwt = extractBearerToken(request.header('Authorization'))
445-
const jwtResult = await validateJWT(jwt, {
446-
accessTokenVerificationCallback: issuer.jwtVerifyCallback
447-
})
448-
449-
// Extract session info from access token
450-
const accessToken = jwtResult.jwt.payload as AccessTokenRequest
451-
preAuthorizedCode = accessToken['pre-authorized_code']
452-
} catch (e) {
453-
LOG.warning(e)
454-
return sendErrorResponse(response, 400, {
455-
error: 'invalid_token'
456-
})
457-
}
458-
}
459-
460437
const cNonce = uuidv4()
461438
const cNonceExpiresIn = issuer.cNonceExpiresIn || 300
462439

463-
const createdAt = epochTime()
440+
const createdAt = +Date.now()
441+
const expiresAt = createdAt + Math.abs(cNonceExpiresIn) * 1000
442+
464443

465444
// Create nonce state - only include session identifiers if available
466445
const cNonceState: any = {
467446
cNonce,
468-
createdAt: createdAt,
469-
expiresAt: createdAt + cNonceExpiresIn
470-
}
471-
472-
if (preAuthorizedCode) {
473-
cNonceState.preAuthorizedCode = preAuthorizedCode
474-
}
475-
if (issuerState) {
476-
cNonceState.issuerState = issuerState
447+
createdAt,
448+
expiresAt
477449
}
478450

479451
await issuer.cNonces.set(cNonce, cNonceState)

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
};

0 commit comments

Comments
 (0)