forked from Expensify/App
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathindex.ts
More file actions
128 lines (109 loc) · 6.58 KB
/
Copy pathindex.ts
File metadata and controls
128 lines (109 loc) · 6.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
import type {CreateCredentialParams, CreateCredentialResult} from '@components/MultifactorAuthentication/biometrics/shared/types';
import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs';
import {getErrorMessage} from '@libs/ErrorUtils';
import {
arrayBufferToBase64URL,
buildPublicKeyCredentialCreationOptions,
createPasskeyCredential,
decodeWebAuthnError,
extractAAGUID,
isSupportedTransport,
isWebAuthnSupported,
} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn';
import {createLocalMFAError} from '@libs/MultifactorAuthentication/shared/MFAResult';
import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce';
import {addLocalPasskeyCredential, getPasskeyOnyxKey, reconcileLocalPasskeysWithBackend} from '@userActions/Passkey';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {mfaCredentialIDsSelector} from '@selectors/Account';
/**
* Platform-resolved biometric operations for the MFA machine's pre-screen checks and the
* credential-creation ceremony. These functions read no React state, so the machine actors and
* other non-React callers can import them directly.
*/
/** The authentication method this platform verifies with. Web verifies with passkeys. */
const deviceVerificationType = CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS;
/** The failure reason to report when this platform cannot run the verification method. */
const deviceCheckFailureReason = CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.AUTHENTICATION_TYPE_NOT_SUPPORTED;
/** Resolves to whether this browser can perform the passkey ceremony. */
async function doesDeviceSupportAuthenticationMethod(): Promise<boolean> {
return isWebAuthnSupported();
}
/**
* Resolves to whether the account has a local passkey the server also knows, meaning it can skip registration.
*
* This is the canonical non-React implementation. The legacy `usePasskeys` hook intentionally
* performs the same comparison using its reactive Onyx values. Keep both implementations aligned
* until the hook is removed.
*/
async function areLocalCredentialsKnownToServer(accountID: number, signal?: AbortSignal): Promise<boolean> {
const [account, localPasskeyCredentials] = await Promise.all([readOnyxValueOnce(ONYXKEYS.ACCOUNT, signal), readOnyxValueOnce(getPasskeyOnyxKey(String(accountID)), signal)]);
const serverKnownCredentialIDs = new Set(mfaCredentialIDsSelector(account) ?? []);
return (localPasskeyCredentials ?? []).some((credential) => serverKnownCredentialIDs.has(credential.id));
}
/** Runs the platform passkey ceremony and persists the resulting credential locally. */
async function createCredential(params: CreateCredentialParams): Promise<CreateCredentialResult> {
const {accountID, registrationChallenge, signal} = params;
const userId = String(accountID);
const [account, localPasskeyCredentials] = await Promise.all([readOnyxValueOnce(ONYXKEYS.ACCOUNT, signal), readOnyxValueOnce(getPasskeyOnyxKey(userId), signal)]);
const backendCredentials = (mfaCredentialIDsSelector(account) ?? []).map((id) => ({id, type: CONST.PASSKEY_CREDENTIAL_TYPE}));
const reconciledExisting = reconcileLocalPasskeysWithBackend({userId, backendCredentials, localCredentials: localPasskeyCredentials ?? null});
const publicKeyOptions = buildPublicKeyCredentialCreationOptions(registrationChallenge, reconciledExisting);
let credential: PublicKeyCredential;
try {
// Cancelling the flow (CLOSE_MODAL) aborts `signal`, which closes the passkey dialog — the
// rejection below then gets handled like any other refusal.
credential = await createPasskeyCredential(publicKeyOptions, signal);
} catch (error) {
return {success: false, error: decodeWebAuthnError(error)};
}
if (!(credential.response instanceof AuthenticatorAttestationResponse)) {
return {
success: false,
error: createLocalMFAError(
CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.WEBAUTHN.UNEXPECTED_RESPONSE,
'Registration credential response is not AuthenticatorAttestationResponse',
),
};
}
const attestationResponse = credential.response;
const credentialId = arrayBufferToBase64URL(credential.rawId);
const clientDataJSON = arrayBufferToBase64URL(attestationResponse.clientDataJSON);
const attestationObject = arrayBufferToBase64URL(attestationResponse.attestationObject);
const transports = attestationResponse.getTransports?.().filter(isSupportedTransport);
// getAuthenticatorData() is a WebAuthn Level 2 method — not available in older browsers.
// NOTE: A value of "00000000-0000-0000-0000-000000000000" is expected for Apple iCloud Keychain
const aaguid = attestationResponse.getAuthenticatorData ? extractAAGUID(attestationResponse.getAuthenticatorData()) : undefined;
// Not every browser honors `signal` on create(), so the ceremony can still succeed after the flow
// was cancelled. Don't persist or register a credential nobody asked for anymore — the passkey
// itself is already on the device either way, that part can't be undone.
if (signal?.aborted) {
return {success: false, error: createLocalMFAError(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED, 'MFA flow canceled before the credential could be persisted')};
}
try {
// Reconciled list, not the stale pre-reconciliation read — reconciliation may have already
// dropped a duplicate id, and checking the stale list would throw for a credential that's
// no longer there.
await addLocalPasskeyCredential({
userId,
credential: {id: credentialId, type: CONST.PASSKEY_CREDENTIAL_TYPE, transports, aaguid},
existingCredentials: reconciledExisting,
});
} catch (error) {
// A failed local write shouldn't throw away a ceremony that already succeeded — the backend
// call below is what matters, and the server stays the source of truth for
// `areLocalCredentialsKnownToServer`.
addMFABreadcrumb('Failed to persist local passkey credential', {message: getErrorMessage(error)}, 'error');
}
return {
success: true,
keyInfo: {
rawId: credentialId,
type: CONST.PASSKEY_CREDENTIAL_TYPE,
transports,
aaguid,
response: {clientDataJSON, attestationObject},
},
};
}
export {areLocalCredentialsKnownToServer, createCredential, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod};