Skip to content

Commit df7e58d

Browse files
committed
🥅 app: fingerprint passkey errors by message, drop expected ones
1 parent 48a97e6 commit df7e58d

6 files changed

Lines changed: 127 additions & 47 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@exactly/mobile": patch
3+
---
4+
5+
🥅 fingerprint passkey errors by message, drop expected ones

‎src/app/_layout.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import en from "../i18n/en.json";
4141
import es from "../i18n/es.json";
4242
import e2e from "../utils/e2e";
4343
import queryClient, { persistOptions } from "../utils/queryClient";
44-
import reportError from "../utils/reportError";
44+
import reportError, { classifyError } from "../utils/reportError";
4545
import exaConfig from "../utils/wagmi/exa";
4646
import ownerConfig from "../utils/wagmi/owner";
4747

@@ -112,6 +112,14 @@ init({
112112
enableUserInteractionTracing: true,
113113
integrations: [routingInstrumentation, userFeedback, ...(__DEV__ || e2e ? [] : [mobileReplayIntegration()])],
114114
_experiments: __DEV__ || e2e ? undefined : { replaysOnErrorSampleRate: 1, replaysSessionSampleRate: 0.01 },
115+
beforeSend: (event, hint) => {
116+
const value = event.exception?.values?.[0]?.value;
117+
const source = hint.originalException ?? value ?? event.message;
118+
const { expected, fingerprint } = classifyError(source);
119+
if (expected) return null;
120+
event.fingerprint ??= fingerprint;
121+
return event;
122+
},
115123
spotlight: __DEV__ || !!e2e,
116124
});
117125
const useServerFonts = typeof window === "undefined" ? useFonts : () => undefined;

‎src/utils/accountClient.ts‎

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import e2e from "./e2e";
6161
import { login } from "./onesignal";
6262
import publicClient from "./publicClient";
6363
import queryClient, { type AuthMethod } from "./queryClient";
64+
import { isPasskeyCancelled } from "./reportError";
6465
import ownerConfig from "./wagmi/owner";
6566

6667
import type { Credential } from "@exactly/common/validation";
@@ -106,17 +107,7 @@ export default async function createAccountClient({ credentialId, factory, x, y
106107
if (s > P256_N / 2n) s = P256_N - s; // pass malleability guard
107108
return webauthn({ authenticatorData, clientDataJSON, challengeIndex, typeIndex, r, s });
108109
} catch (error: unknown) {
109-
if (
110-
error instanceof Error &&
111-
(error.message ===
112-
"The operation couldn’t be completed. (com.apple.AuthenticationServices.AuthorizationError error 1001.)" ||
113-
error.message ===
114-
"The operation couldn’t be completed. (com.apple.AuthenticationServices.AuthorizationError error 1004.)" ||
115-
error.message === "The operation couldn’t be completed. Device must be unlocked to perform request." ||
116-
error.message === "UserCancelled")
117-
) {
118-
return "0x";
119-
}
110+
if (isPasskeyCancelled(error)) return "0x";
120111
throw error;
121112
}
122113
},

‎src/utils/reportError.ts‎

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,113 @@ import { captureException } from "@sentry/react-native";
22

33
export default function reportError(error: unknown, hint?: Parameters<typeof captureException>[1]) {
44
console.error(error); // eslint-disable-line no-console
5+
const classification = classify(parseError(error));
6+
if (classification.expected) return;
57
try {
6-
return captureException(error, hint);
8+
if (hint) return captureException(error, hint);
9+
return captureException(
10+
error,
11+
classification.fingerprint ? { fingerprint: classification.fingerprint } : undefined,
12+
);
713
} catch (sentryError) {
814
console.error(sentryError); // eslint-disable-line no-console
915
}
1016
}
17+
18+
const passkeyCancelledMessages = new Set([
19+
"The operation couldn’t be completed. (com.apple.AuthenticationServices.AuthorizationError error 1001.)",
20+
"The operation couldn’t be completed. (com.apple.AuthenticationServices.AuthorizationError error 1004.)",
21+
"The operation couldn’t be completed. Device must be unlocked to perform request.",
22+
"UserCancelled",
23+
]);
24+
const passkeyExpectedMessages = new Set([
25+
...passkeyCancelledMessages,
26+
"The operation couldn’t be completed. Stolen Device Protection is enabled and biometry is required.",
27+
]);
28+
const authPrefixes = ["androidx.credentials.exceptions.domerrors.NotAllowedError"];
29+
type ParsedError = ReturnType<typeof parseError>;
30+
31+
export function isPasskeyExpected(error: unknown) {
32+
return classify(parseError(error)).passkeyExpected;
33+
}
34+
35+
export function isPasskeyCancelled(error: unknown) {
36+
return classify(parseError(error)).passkeyCancelled;
37+
}
38+
39+
export function isAuthExpected(error: unknown) {
40+
return classify(parseError(error)).authExpected;
41+
}
42+
43+
export function isExpected(error: unknown) {
44+
return classify(parseError(error)).expected;
45+
}
46+
47+
export function fingerprint(error: unknown) {
48+
return classify(parseError(error)).fingerprint;
49+
}
50+
51+
export function classifyError(error: unknown) {
52+
return classify(parseError(error));
53+
}
54+
55+
function parseError(error: unknown) {
56+
const code =
57+
typeof error === "object" &&
58+
error !== null &&
59+
"code" in error &&
60+
typeof error.code === "string" &&
61+
error.code.length > 0
62+
? error.code
63+
: undefined;
64+
const name =
65+
typeof error === "object" &&
66+
error !== null &&
67+
"name" in error &&
68+
typeof error.name === "string" &&
69+
error.name.length > 0
70+
? error.name
71+
: undefined;
72+
const message =
73+
error instanceof Error
74+
? normalizeMessage(error.message)
75+
: typeof error === "string"
76+
? normalizeMessage(error)
77+
: typeof error === "object" &&
78+
error !== null &&
79+
"message" in error &&
80+
typeof error.message === "string" &&
81+
error.message.length > 0
82+
? normalizeMessage(error.message)
83+
: undefined;
84+
return { code, name, message };
85+
}
86+
87+
function classify({ code, message }: ParsedError) {
88+
const passkeyCancelled = message !== undefined && passkeyCancelledMessages.has(message);
89+
const passkeyExpected =
90+
passkeyCancelled ||
91+
(message !== undefined &&
92+
(passkeyExpectedMessages.has(message) ||
93+
message.includes("There is already a pending passkey request") ||
94+
authPrefixes.some((prefix) => message.startsWith(prefix))));
95+
const authExpected = passkeyExpected || message === "invalid operation";
96+
const expected = authExpected;
97+
const fingerprintMessage =
98+
message?.startsWith("Calling the 'get' function has failed") ||
99+
message?.startsWith("The operation couldn’t be completed.")
100+
? message
101+
: undefined;
102+
const value =
103+
code !== undefined && code !== "ERR_UNKNOWN"
104+
? ["{{ default }}", code]
105+
: fingerprintMessage === undefined
106+
? undefined
107+
: ["{{ default }}", fingerprintMessage];
108+
return { passkeyExpected, passkeyCancelled, authExpected, expected, fingerprint: value };
109+
}
110+
111+
function normalizeMessage(message: string) {
112+
const value = message.startsWith("Error: ") ? message.slice("Error: ".length) : message;
113+
return value.trim();
114+
}

‎src/utils/server.ts‎

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Credential } from "@exactly/common/validation";
1414
import { login as loginIntercom, logout as logoutIntercom } from "./intercom";
1515
import { decrypt, decryptPIN, encryptPIN, session } from "./panda";
1616
import queryClient, { APIError, type AuthMethod } from "./queryClient";
17+
import { isPasskeyExpected } from "./reportError";
1718
import ownerConfig from "./wagmi/owner";
1819

1920
import type { ExaAPI } from "@exactly/server/api"; // eslint-disable-line @nx/enforce-module-boundaries
@@ -61,19 +62,7 @@ queryClient.setQueryDefaults<number | undefined>(["auth"], {
6162
return parse(Auth, expires);
6263
},
6364
meta: {
64-
suppressError: (error) =>
65-
error instanceof ValiError ||
66-
(error instanceof Error &&
67-
(error.name === "NotAllowedError" ||
68-
error.message === "UnknownError" ||
69-
error.message ===
70-
"The operation couldn’t be completed. (com.apple.AuthenticationServices.AuthorizationError error 1001.)" ||
71-
error.message ===
72-
"The operation couldn’t be completed. (com.apple.AuthenticationServices.AuthorizationError error 1004.)" ||
73-
error.message === "The operation couldn’t be completed. Device must be unlocked to perform request." ||
74-
error.message === "UserCancelled" ||
75-
error.message.startsWith("androidx.credentials.exceptions.domerrors.NotAllowedError") ||
76-
("code" in error && error.code === "ERR_UNKNOWN"))),
65+
suppressError: (error) => error instanceof ValiError || isPasskeyExpected(error),
7766
},
7867
});
7968

‎src/utils/useAuth.ts‎

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import chain from "@exactly/common/generated/chain";
1212

1313
import alchemyConnector from "./alchemyConnector";
1414
import queryClient, { type AuthMethod } from "./queryClient";
15-
import reportError from "./reportError";
15+
import reportError, { isAuthExpected } from "./reportError";
1616
import { APIError, createCredential, getCredential } from "./server";
1717
import ownerConfig, { getConnector as getOwnerConnector } from "./wagmi/owner";
1818

@@ -49,19 +49,7 @@ function handleError(
4949
onDomainError: () => void,
5050
t: TFunction,
5151
) {
52-
if (
53-
(error instanceof Error &&
54-
(error.message ===
55-
"The operation couldn’t be completed. (com.apple.AuthenticationServices.AuthorizationError error 1001.)" ||
56-
error.message ===
57-
"The operation couldn’t be completed. (com.apple.AuthenticationServices.AuthorizationError error 1004.)" ||
58-
error.message === "The operation couldn’t be completed. Device must be unlocked to perform request." ||
59-
error.message === "UserCancelled" ||
60-
error.message.startsWith("androidx.credentials.exceptions.domerrors.NotAllowedError") ||
61-
error.message === "invalid operation" ||
62-
error.name === "NotAllowedError")) ||
63-
error instanceof UserRejectedRequestError
64-
) {
52+
if (isAuthExpected(error) || error instanceof UserRejectedRequestError) {
6553
queryClient.setQueryData(["method"], undefined);
6654
toast.show(t("Authentication cancelled"), {
6755
native: true,
@@ -70,7 +58,7 @@ function handleError(
7058
});
7159
return;
7260
}
73-
if (error instanceof Error && "code" in error && (error as Error & { code: string }).code === "ERR_BIOMETRIC") {
61+
if (error instanceof Error && "code" in error && error.code === "ERR_BIOMETRIC") {
7462
queryClient.setQueryData(["method"], undefined);
7563
toast.show(t("Biometrics must be enabled to use passkeys. Please enable biometrics in your device settings"), {
7664
native: true,
@@ -93,10 +81,5 @@ function handleError(
9381
) {
9482
onDomainError();
9583
}
96-
reportError(
97-
error,
98-
error instanceof Error && !(error instanceof APIError) && "code" in error
99-
? { fingerprint: ["{{ default }}", (error as Error & { code: string }).code] }
100-
: undefined,
101-
);
84+
reportError(error);
10285
}

0 commit comments

Comments
 (0)