|
1 | 1 | import type {CreateCredentialParams, CreateCredentialResult} from '@components/MultifactorAuthentication/biometrics/shared/types'; |
| 2 | +import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs'; |
2 | 3 |
|
3 | | -import {isWebAuthnSupported} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; |
| 4 | +import {getErrorMessage} from '@libs/ErrorUtils'; |
| 5 | +import { |
| 6 | + arrayBufferToBase64URL, |
| 7 | + buildPublicKeyCredentialCreationOptions, |
| 8 | + createPasskeyCredential, |
| 9 | + decodeWebAuthnError, |
| 10 | + extractAAGUID, |
| 11 | + isSupportedTransport, |
| 12 | + isWebAuthnSupported, |
| 13 | +} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; |
| 14 | +import {createLocalMFAError} from '@libs/MultifactorAuthentication/shared/MFAResult'; |
4 | 15 | import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce'; |
5 | 16 |
|
6 | | -import {getPasskeyOnyxKey} from '@userActions/Passkey'; |
| 17 | +import {addLocalPasskeyCredential, getPasskeyOnyxKey, reconcileLocalPasskeysWithBackend} from '@userActions/Passkey'; |
7 | 18 |
|
8 | 19 | import CONST from '@src/CONST'; |
9 | 20 | import ONYXKEYS from '@src/ONYXKEYS'; |
10 | 21 |
|
11 | 22 | import {mfaCredentialIDsSelector} from '@selectors/Account'; |
12 | 23 |
|
13 | 24 | /** |
14 | | - * Platform-resolved biometric operations for the MFA machine's pre-screen checks and credential |
15 | | - * creation ceremony. These functions read no React state, so the machine actors and other |
16 | | - * non-React callers can import them directly. |
| 25 | + * Platform-resolved biometric operations for the MFA machine's pre-screen checks and the |
| 26 | + * credential-creation ceremony. These functions read no React state, so the machine actors and |
| 27 | + * other non-React callers can import them directly. |
17 | 28 | */ |
18 | 29 |
|
19 | 30 | /** The authentication method this platform verifies with. Web verifies with passkeys. */ |
@@ -42,7 +53,76 @@ async function areLocalCredentialsKnownToServer(accountID: number, signal?: Abor |
42 | 53 |
|
43 | 54 | /** Runs the platform passkey ceremony and persists the resulting credential locally. */ |
44 | 55 | async function createCredential(params: CreateCredentialParams): Promise<CreateCredentialResult> { |
45 | | - throw new Error('Not implemented'); |
| 56 | + const {accountID, registrationChallenge, signal} = params; |
| 57 | + const userId = String(accountID); |
| 58 | + const [account, localPasskeyCredentials] = await Promise.all([readOnyxValueOnce(ONYXKEYS.ACCOUNT, signal), readOnyxValueOnce(getPasskeyOnyxKey(userId), signal)]); |
| 59 | + |
| 60 | + const backendCredentials = (mfaCredentialIDsSelector(account) ?? []).map((id) => ({id, type: CONST.PASSKEY_CREDENTIAL_TYPE})); |
| 61 | + const reconciledExisting = reconcileLocalPasskeysWithBackend({userId, backendCredentials, localCredentials: localPasskeyCredentials ?? null}); |
| 62 | + const publicKeyOptions = buildPublicKeyCredentialCreationOptions(registrationChallenge, reconciledExisting); |
| 63 | + |
| 64 | + let credential: PublicKeyCredential; |
| 65 | + try { |
| 66 | + // Cancelling the flow (CLOSE_MODAL) aborts `signal`, which closes the passkey dialog — the |
| 67 | + // rejection below then gets handled like any other refusal. |
| 68 | + credential = await createPasskeyCredential(publicKeyOptions, signal); |
| 69 | + } catch (error) { |
| 70 | + return {success: false, error: decodeWebAuthnError(error)}; |
| 71 | + } |
| 72 | + |
| 73 | + if (!(credential.response instanceof AuthenticatorAttestationResponse)) { |
| 74 | + return { |
| 75 | + success: false, |
| 76 | + error: createLocalMFAError( |
| 77 | + CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.WEBAUTHN.UNEXPECTED_RESPONSE, |
| 78 | + 'Registration credential response is not AuthenticatorAttestationResponse', |
| 79 | + ), |
| 80 | + }; |
| 81 | + } |
| 82 | + const attestationResponse = credential.response; |
| 83 | + const credentialId = arrayBufferToBase64URL(credential.rawId); |
| 84 | + const clientDataJSON = arrayBufferToBase64URL(attestationResponse.clientDataJSON); |
| 85 | + const attestationObject = arrayBufferToBase64URL(attestationResponse.attestationObject); |
| 86 | + |
| 87 | + const transports = attestationResponse.getTransports?.().filter(isSupportedTransport); |
| 88 | + |
| 89 | + // getAuthenticatorData() is a WebAuthn Level 2 method — not available in older browsers. |
| 90 | + // NOTE: A value of "00000000-0000-0000-0000-000000000000" is expected for Apple iCloud Keychain |
| 91 | + const aaguid = attestationResponse.getAuthenticatorData ? extractAAGUID(attestationResponse.getAuthenticatorData()) : undefined; |
| 92 | + |
| 93 | + // Not every browser honors `signal` on create(), so the ceremony can still succeed after the flow |
| 94 | + // was cancelled. Don't persist or register a credential nobody asked for anymore — the passkey |
| 95 | + // itself is already on the device either way, that part can't be undone. |
| 96 | + if (signal?.aborted) { |
| 97 | + return {success: false, error: createLocalMFAError(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED, 'MFA flow canceled before the credential could be persisted')}; |
| 98 | + } |
| 99 | + |
| 100 | + try { |
| 101 | + // Reconciled list, not the stale pre-reconciliation read — reconciliation may have already |
| 102 | + // dropped a duplicate id, and checking the stale list would throw for a credential that's |
| 103 | + // no longer there. |
| 104 | + await addLocalPasskeyCredential({ |
| 105 | + userId, |
| 106 | + credential: {id: credentialId, type: CONST.PASSKEY_CREDENTIAL_TYPE, transports, aaguid}, |
| 107 | + existingCredentials: reconciledExisting, |
| 108 | + }); |
| 109 | + } catch (error) { |
| 110 | + // A failed local write shouldn't throw away a ceremony that already succeeded — the backend |
| 111 | + // call below is what matters, and the server stays the source of truth for |
| 112 | + // `areLocalCredentialsKnownToServer`. |
| 113 | + addMFABreadcrumb('Failed to persist local passkey credential', {message: getErrorMessage(error)}, 'error'); |
| 114 | + } |
| 115 | + |
| 116 | + return { |
| 117 | + success: true, |
| 118 | + keyInfo: { |
| 119 | + rawId: credentialId, |
| 120 | + type: CONST.PASSKEY_CREDENTIAL_TYPE, |
| 121 | + transports, |
| 122 | + aaguid, |
| 123 | + response: {clientDataJSON, attestationObject}, |
| 124 | + }, |
| 125 | + }; |
46 | 126 | } |
47 | 127 |
|
48 | 128 | export {areLocalCredentialsKnownToServer, createCredential, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod}; |
0 commit comments