Skip to content

Commit 63865de

Browse files
gispadaChrisMattew
andauthored
feat: [SIW-2159] Handle presentation errors (#223)
Co-authored-by: ChrisMattew <[email protected]>
1 parent 87e7383 commit 63865de

16 files changed

+713
-92
lines changed

example/src/screens/PresentationScreen.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,27 @@ export const PresentationScreen = () => {
4242
isPresent: !!presentationDetails.redirectUri,
4343
successMessage: "OK",
4444
},
45+
{
46+
title: "PID Remote Cross-Device (Refuse)",
47+
onPress: () =>
48+
navigation.navigate("QrScanner", {
49+
presentationBehavior: "refusalState",
50+
}),
51+
isLoading: refusalPresentationState.isLoading,
52+
hasError: refusalPresentationState.hasError,
53+
isDone: refusalPresentationState.isDone,
54+
icon: "qrCode",
55+
},
4556
],
4657
[
4758
navigation,
4859
acceptancePresentationState.hasError,
4960
acceptancePresentationState.isDone,
5061
acceptancePresentationState.isLoading,
5162
presentationDetails.redirectUri,
63+
refusalPresentationState.hasError,
64+
refusalPresentationState.isDone,
65+
refusalPresentationState.isLoading,
5266
]
5367
);
5468

example/src/thunks/presentation.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ export const remoteCrossDevicePresentationThunk = createAppAsyncThunk<
9999
.map((c) => [c.keyTag, c.credential]),
100100
] as [string, string][];
101101

102+
if (requestObject.dcql_query && args.allowed === "refusalState") {
103+
return processRefusedPresentation(requestObject);
104+
}
105+
102106
if (requestObject.dcql_query) {
103107
return processPresentation(requestObject, rpConf, credentialsSdJwt);
104108
}
@@ -203,3 +207,16 @@ const processPresentation: ProcessPresentation = async (
203207
requestedClaims: credentialsToPresent.flatMap((c) => c.requestedClaims),
204208
};
205209
};
210+
211+
// Mock an error in the presentation flow
212+
const processRefusedPresentation = async (requestObject: RequestObject) => {
213+
const authResponse =
214+
await Credential.Presentation.sendAuthorizationErrorResponse(
215+
requestObject,
216+
{
217+
error: "invalid_request_object",
218+
errorDescription: "Mock error during request object validation",
219+
}
220+
);
221+
return { authResponse, requestObject, requestedClaims: [] };
222+
};

src/credential/presentation/03-get-request-object.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { RelyingPartyResponseError } from "../../utils/errors";
12
import { hasStatusOrThrow } from "../../utils/misc";
23
import { RequestObjectWalletCapabilities } from "./types";
34

@@ -40,7 +41,7 @@ export const getRequestObject: GetRequestObject = async (
4041
},
4142
body: formUrlEncodedBody.toString(),
4243
})
43-
.then(hasStatusOrThrow(200))
44+
.then(hasStatusOrThrow(200, RelyingPartyResponseError))
4445
.then((res) => res.text());
4546

4647
return {
@@ -51,7 +52,7 @@ export const getRequestObject: GetRequestObject = async (
5152
const requestObjectEncodedJwt = await appFetch(requestUri, {
5253
method: "GET",
5354
})
54-
.then(hasStatusOrThrow(200))
55+
.then(hasStatusOrThrow(200, RelyingPartyResponseError))
5556
.then((res) => res.text());
5657

5758
return {
Lines changed: 73 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { decode as decodeJwt, verify } from "@pagopa/io-react-native-jwt";
22
import type { RelyingPartyEntityConfiguration } from "../../trust";
3-
import { UnverifiedEntityError } from "./errors";
3+
import { InvalidRequestObjectError } from "./errors";
44
import { RequestObject } from "./types";
55
import { getJwksFromConfig } from "./04-retrieve-rp-jwks";
66

@@ -15,39 +15,38 @@ export type VerifyRequestObject = (
1515
) => Promise<{ requestObject: RequestObject }>;
1616

1717
/**
18-
* Function to verify the Request Object's signature and the client ID.
18+
* Function to verify the Request Object's validity, from the signature to the required properties.
1919
* @param requestObjectEncodedJwt The Request Object in JWT format
2020
* @param context.clientId The client ID to verify
2121
* @param context.rpConf The Entity Configuration of the Relying Party
2222
* @param context.state Optional state
2323
* @returns The verified Request Object
24+
* @throws {InvalidRequestObjectError} if the Request Object cannot be validated
2425
*/
2526
export const verifyRequestObject: VerifyRequestObject = async (
2627
requestObjectEncodedJwt,
2728
{ clientId, rpConf, rpSubject, state }
2829
) => {
2930
const requestObjectJwt = decodeJwt(requestObjectEncodedJwt);
30-
const { keys } = getJwksFromConfig(rpConf);
3131

32-
// Verify token signature to ensure the request object is authentic
33-
const pubKey = keys?.find(
34-
({ kid }) => kid === requestObjectJwt.protectedHeader.kid
35-
);
32+
const pubKey = getSigPublicKey(rpConf, requestObjectJwt.protectedHeader.kid);
3633

37-
if (!pubKey) {
38-
throw new UnverifiedEntityError("Request Object signature verification!");
34+
try {
35+
// Standard claims are verified within `verify`
36+
await verify(requestObjectEncodedJwt, pubKey, { issuer: clientId });
37+
} catch (_) {
38+
throw new InvalidRequestObjectError(
39+
"The Request Object signature verification failed"
40+
);
3941
}
4042

41-
// Standard claims are verified within `verify`
42-
await verify(requestObjectEncodedJwt, pubKey, { issuer: clientId });
43-
44-
const requestObject = RequestObject.parse(requestObjectJwt.payload);
43+
const requestObject = validateRequestObjectShape(requestObjectJwt.payload);
4544

4645
const isClientIdMatch =
4746
clientId === requestObject.client_id && clientId === rpSubject;
4847

4948
if (!isClientIdMatch) {
50-
throw new UnverifiedEntityError(
49+
throw new InvalidRequestObjectError(
5150
"Client ID does not match Request Object or Entity Configuration"
5251
);
5352
}
@@ -56,8 +55,67 @@ export const verifyRequestObject: VerifyRequestObject = async (
5655
state && requestObject.state ? state === requestObject.state : true;
5756

5857
if (!isStateMatch) {
59-
throw new UnverifiedEntityError("State does not match Request Object");
58+
throw new InvalidRequestObjectError(
59+
"The provided state does not match the Request Object's"
60+
);
6061
}
6162

6263
return { requestObject };
6364
};
65+
66+
/**
67+
* Validate the shape of the Request Object to ensure all required properties are present and are of the expected type.
68+
*
69+
* @param payload The Request Object to validate
70+
* @returns A valid Request Object
71+
* @throws {InvalidRequestObjectError} when the Request Object cannot be parsed
72+
*/
73+
const validateRequestObjectShape = (payload: unknown): RequestObject => {
74+
const requestObjectParse = RequestObject.safeParse(payload);
75+
76+
if (requestObjectParse.success) {
77+
return requestObjectParse.data;
78+
}
79+
80+
throw new InvalidRequestObjectError(
81+
"The Request Object cannot be parsed successfully",
82+
formatFlattenedZodErrors(requestObjectParse.error.flatten())
83+
);
84+
};
85+
86+
/**
87+
* Get the public key to verify the Request Object's signature from the Relying Party's EC.
88+
*
89+
* @param rpConf The Relying Party's EC
90+
* @param kid The identifier of the key to find
91+
* @returns The corresponding public key to verify the signature
92+
* @throws {InvalidRequestObjectError} when the key cannot be found
93+
*/
94+
const getSigPublicKey = (
95+
rpConf: RelyingPartyEntityConfiguration["payload"]["metadata"],
96+
kid: string | undefined
97+
) => {
98+
try {
99+
const { keys } = getJwksFromConfig(rpConf);
100+
101+
const pubKey = keys.find((k) => k.kid === kid);
102+
103+
if (!pubKey) throw new Error();
104+
105+
return pubKey;
106+
} catch (_) {
107+
throw new InvalidRequestObjectError(
108+
`The public key for signature verification (${kid}) cannot be found in the Entity Configuration`
109+
);
110+
}
111+
};
112+
113+
/**
114+
* Utility to format flattened Zod errors into a simplified string `key1: key1_error, key2: key2_error`
115+
*/
116+
const formatFlattenedZodErrors = (
117+
errors: Zod.typeToFlattenedError<RequestObject>
118+
): string =>
119+
Object.entries(errors.fieldErrors)
120+
.map(([key, error]) => `${key}: ${error[0]}`)
121+
.join(", ");

src/credential/presentation/07-evaluate-dcql-query.ts

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
1-
import {
2-
DcqlQuery,
3-
DcqlError,
4-
DcqlCredentialSetError,
5-
DcqlQueryResult,
6-
} from "dcql";
1+
import { DcqlQuery, DcqlError, DcqlQueryResult } from "dcql";
72
import { isValiError } from "valibot";
83
import { decode, prepareVpToken } from "../../sd-jwt";
94
import type { Disclosure } from "../../sd-jwt/types";
10-
import { ValidationFailed } from "../../utils/errors";
115
import { createCryptoContextFor } from "../../utils/crypto";
126
import type { RemotePresentation } from "./types";
137
import { CredentialsNotFoundError, type NotFoundDetail } from "./errors";
@@ -167,20 +161,16 @@ export const evaluateDcqlQuery: EvaluateDcqlQuery = (
167161
};
168162
});
169163
} catch (error) {
170-
// Invalid DCQL query structure
164+
// Invalid DCQL query structure. Remap to `DcqlError` for consistency.
171165
if (isValiError(error)) {
172-
throw new ValidationFailed({
173-
message: "Invalid DCQL query",
174-
reason: error.issues.map((issue) => issue.message).join(", "),
166+
throw new DcqlError({
167+
message: "Failed to parse the provided DCQL query",
168+
code: "PARSE_ERROR",
169+
cause: error.issues,
175170
});
176171
}
177172

178-
if (error instanceof DcqlError) {
179-
// TODO [SIW-2110]: handle invalid DQCL query or let the error propagate
180-
}
181-
if (error instanceof DcqlCredentialSetError) {
182-
// TODO [SIW-2110]: handle missing credentials or let the error propagate
183-
}
173+
// Let other errors propagate so they can be caught with `err instanceof DcqlError`
184174
throw error;
185175
}
186176
};

src/credential/presentation/08-send-authorization-response.ts

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,18 @@ import { hasStatusOrThrow, type Out } from "../../utils/misc";
77
import {
88
type RemotePresentation,
99
DirectAuthorizationBodyPayload,
10+
ErrorResponse,
1011
type LegacyRemotePresentation,
11-
LegacyDirectAuthorizationBodyPayload,
1212
} from "./types";
1313
import * as z from "zod";
1414
import type { JWK } from "../../utils/jwk";
1515
import type { RelyingPartyEntityConfiguration } from "../../trust";
16+
import {
17+
RelyingPartyResponseError,
18+
ResponseErrorBuilder,
19+
UnexpectedStatusCodeError,
20+
RelyingPartyResponseErrorCodes,
21+
} from "../../utils/errors";
1622

1723
export type AuthorizationResponse = z.infer<typeof AuthorizationResponse>;
1824
export const AuthorizationResponse = z.object({
@@ -61,7 +67,7 @@ export const choosePublicKeyToEncrypt = (
6167
export const buildDirectPostJwtBody = async (
6268
requestObject: Out<VerifyRequestObject>["requestObject"],
6369
rpConf: RelyingPartyEntityConfiguration["payload"]["metadata"],
64-
payload: DirectAuthorizationBodyPayload | LegacyDirectAuthorizationBodyPayload
70+
payload: DirectAuthorizationBodyPayload
6571
): Promise<string> => {
6672
type Jwe = ConstructorParameters<typeof EncryptJwe>[1];
6773

@@ -98,6 +104,34 @@ export const buildDirectPostJwtBody = async (
98104
return formBody.toString();
99105
};
100106

107+
/**
108+
* Builds a URL-encoded form body for a direct POST response without encryption.
109+
*
110+
* @param requestObject - Contains state, nonce, and other relevant info.
111+
* @param payload - Object that contains either the VP token to encrypt and the stringified mapping of the credential disclosures or the error code
112+
* @returns A URL-encoded string suitable for an `application/x-www-form-urlencoded` POST body.
113+
*/
114+
export const buildDirectPostBody = async (
115+
requestObject: Out<VerifyRequestObject>["requestObject"],
116+
payload: DirectAuthorizationBodyPayload
117+
): Promise<string> => {
118+
const formUrlEncodedBody = new URLSearchParams({
119+
...(requestObject.state && { state: requestObject.state }),
120+
...Object.entries(payload).reduce(
121+
(acc, [key, value]) => ({
122+
...acc,
123+
[key]:
124+
Array.isArray(value) || typeof value === "object"
125+
? JSON.stringify(value)
126+
: value,
127+
}),
128+
{} as Record<string, string>
129+
),
130+
});
131+
132+
return formUrlEncodedBody.toString();
133+
};
134+
101135
/**
102136
* Type definition for the function that sends the authorization response
103137
* to the Relying Party, completing the presentation flow.
@@ -218,5 +252,78 @@ export const sendAuthorizationResponse: SendAuthorizationResponse = async (
218252
})
219253
.then(hasStatusOrThrow(200))
220254
.then((res) => res.json())
221-
.then(AuthorizationResponse.parse);
255+
.then(AuthorizationResponse.parse)
256+
.catch(handleAuthorizationResponseError);
257+
};
258+
259+
/**
260+
* Type definition for the function that sends the authorization response
261+
* to the Relying Party, completing the presentation flow.
262+
*/
263+
export type SendAuthorizationErrorResponse = (
264+
requestObject: Out<VerifyRequestObject>["requestObject"],
265+
error: { error: ErrorResponse; errorDescription: string },
266+
context?: {
267+
appFetch?: GlobalFetch["fetch"];
268+
}
269+
) => Promise<AuthorizationResponse>;
270+
271+
/**
272+
* Sends the authorization error response to the Relying Party (RP) using the specified `response_mode`.
273+
* This function completes the presentation flow in an OpenID 4 Verifiable Presentations scenario.
274+
*
275+
* @param requestObject - The request details, including presentation requirements.
276+
* @param error - The response error value, with description
277+
* @param context - Contains optional custom fetch implementation.
278+
* @returns Parsed and validated authorization response from the Relying Party.
279+
*/
280+
export const sendAuthorizationErrorResponse: SendAuthorizationErrorResponse =
281+
async (
282+
requestObject,
283+
{ error, errorDescription },
284+
{ appFetch = fetch } = {}
285+
): Promise<AuthorizationResponse> => {
286+
const requestBody = await buildDirectPostBody(requestObject, {
287+
error,
288+
error_description: errorDescription,
289+
});
290+
291+
return await appFetch(requestObject.response_uri, {
292+
method: "POST",
293+
headers: {
294+
"Content-Type": "application/x-www-form-urlencoded",
295+
},
296+
body: requestBody,
297+
})
298+
.then(hasStatusOrThrow(200, RelyingPartyResponseError))
299+
.then((res) => res.json())
300+
.then(AuthorizationResponse.parse);
301+
};
302+
303+
/**
304+
* Handle the the presentation error by mapping it to a custom exception.
305+
* If the error is not an instance of {@link UnexpectedStatusCodeError}, it is thrown as is.
306+
* @param e - The error to be handled
307+
* @throws {RelyingPartyResponseError} with a specific code for more context
308+
*/
309+
const handleAuthorizationResponseError = (e: unknown) => {
310+
if (!(e instanceof UnexpectedStatusCodeError)) {
311+
throw e;
312+
}
313+
314+
throw new ResponseErrorBuilder(RelyingPartyResponseError)
315+
.handle(400, {
316+
code: RelyingPartyResponseErrorCodes.InvalidAuthorizationResponse,
317+
message:
318+
"The Authorization Response contains invalid parameters or it is malformed",
319+
})
320+
.handle(403, {
321+
code: RelyingPartyResponseErrorCodes.InvalidAuthorizationResponse,
322+
message: "The Authorization Response was forbidden",
323+
})
324+
.handle("*", {
325+
code: RelyingPartyResponseErrorCodes.RelyingPartyGenericError,
326+
message: "Unable to successfully send the Authorization Response",
327+
})
328+
.buildFrom(e);
222329
};

0 commit comments

Comments
 (0)