Skip to content

Commit b3c0e6f

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

6 files changed

Lines changed: 126 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: 8 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, { fingerprint, isExpected } from "../utils/reportError";
4545
import exaConfig from "../utils/wagmi/exa";
4646
import ownerConfig from "../utils/wagmi/owner";
4747

@@ -112,6 +112,13 @@ 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+
if (isExpected(source)) return null;
119+
event.fingerprint ??= fingerprint(source);
120+
return event;
121+
},
115122
spotlight: __DEV__ || !!e2e,
116123
});
117124
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+
const networkMessages = new Set([
30+
"Unknown error: A TLS error caused the secure connection to fail.",
31+
"Unknown error: The Internet connection appears to be offline.",
32+
"Unknown error: The request timed out.",
33+
]);
34+
type ParsedError = ReturnType<typeof parseError>;
35+
36+
export function isPasskeyExpected(error: unknown) {
37+
return classify(parseError(error)).passkeyExpected;
38+
}
39+
40+
export function isPasskeyCancelled(error: unknown) {
41+
return classify(parseError(error)).passkeyCancelled;
42+
}
43+
44+
export function isAuthExpected(error: unknown) {
45+
return classify(parseError(error)).authExpected;
46+
}
47+
48+
export function isExpected(error: unknown) {
49+
return classify(parseError(error)).expected;
50+
}
51+
52+
export function fingerprint(error: unknown) {
53+
return classify(parseError(error)).fingerprint;
54+
}
55+
56+
function parseError(error: unknown) {
57+
const code =
58+
typeof error === "object" &&
59+
error !== null &&
60+
"code" in error &&
61+
typeof error.code === "string" &&
62+
error.code.length > 0
63+
? error.code
64+
: undefined;
65+
const name =
66+
typeof error === "object" &&
67+
error !== null &&
68+
"name" in error &&
69+
typeof error.name === "string" &&
70+
error.name.length > 0
71+
? error.name
72+
: undefined;
73+
const message =
74+
error instanceof Error
75+
? normalizeMessage(error.message)
76+
: typeof error === "string"
77+
? normalizeMessage(error)
78+
: undefined;
79+
return { code, name, message };
80+
}
81+
82+
function classify({ code, message, name }: ParsedError) {
83+
const passkeyCancelled = message !== undefined && passkeyCancelledMessages.has(message);
84+
const unknown = message === "UnknownError" || message?.startsWith("Unknown error:") === true;
85+
const passkeyUnknown = code === "ERR_UNKNOWN" && (unknown || name === "NotAllowedError");
86+
const passkeyExpected =
87+
passkeyCancelled ||
88+
passkeyUnknown ||
89+
name === "NotAllowedError" ||
90+
(message !== undefined &&
91+
(passkeyExpectedMessages.has(message) ||
92+
message.includes("There is already a pending passkey request") ||
93+
authPrefixes.some((prefix) => message.startsWith(prefix))));
94+
const authExpected = passkeyExpected || message === "invalid operation";
95+
const expected = authExpected || (message !== undefined && networkMessages.has(message));
96+
const fingerprintMessage =
97+
message?.startsWith("Calling the 'get' function has failed") ||
98+
message?.startsWith("The operation couldn’t be completed.") ||
99+
(passkeyUnknown && unknown)
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)