diff --git a/.changeset/passkey-second-factor.md b/.changeset/passkey-second-factor.md new file mode 100644 index 00000000000..902844d9ef9 --- /dev/null +++ b/.changeset/passkey-second-factor.md @@ -0,0 +1,25 @@ +--- +'@clerk/shared': minor +'@clerk/clerk-js': minor +'@clerk/ui': minor +'@clerk/localizations': minor +'@clerk/react': minor +--- + +Support passkeys as a second factor during sign-in and session reverification. + +When a user has a passkey, the prebuilt `` and `` +components preselect it as the second factor, as they already do for the first +factor. Other methods stay available under "Use another method". + +To use it, your instance must allow passkeys to satisfy the second factor. This +is on by default for instances created after 2026-07-08, and can be turned on +in the Dashboard for older ones. Web apps also need to opt in to the passkey +second factor update in the Dashboard. + +If you build a custom flow, `supportedSecondFactors` can now include `passkey`. +Call `signIn.authenticateWithPasskey()` to complete it. + +Passkeys are no longer preselected, for either factor, where the SDK reports +that WebAuthn is unavailable (for example, Electron apps without passkey +support enabled). diff --git a/integration/configs/with-passkeys.js b/integration/configs/with-passkeys.js index 056a488a030..07971f84059 100644 --- a/integration/configs/with-passkeys.js +++ b/integration/configs/with-passkeys.js @@ -16,6 +16,12 @@ export default defineConfig({ }, auth_passkey: { used_for_sign_in: true, + satisfies_second_factor: true, + }, + auth_multi_factor: { + authenticator_app: { + enabled: true, + }, }, }, }); diff --git a/integration/tests/passkeys.test.ts b/integration/tests/passkeys.test.ts index 4ad96a6905e..f079940e8f2 100644 --- a/integration/tests/passkeys.test.ts +++ b/integration/tests/passkeys.test.ts @@ -1,4 +1,4 @@ -import type { BrowserContext } from '@playwright/test'; +import type { BrowserContext, Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import type { Application } from '../models/application'; @@ -14,6 +14,9 @@ type SavedCredential = { publicKey: string; }; +// Any valid base32 secret works — no test ever generates a code from it. +const TOTP_SECRET = 'JBSWY3DPEHPK3PXP'; + // clerk-js gates WebAuthn behind isValidBrowser(), which bails when // navigator.webdriver is true, so mask it before installing the virtual authenticator. const installVirtualAuthenticator = async (context: BrowserContext) => { @@ -23,18 +26,26 @@ const installVirtualAuthenticator = async (context: BrowserContext) => { await context.credentials.install(); }; +let app: Application; + +test.beforeAll(async () => { + app = await appConfigs.next.appRouter.commit(); + await app.setup(); + await app.withEnv(appConfigs.envs.withPasskeys); + await app.dev(); +}); + +test.afterAll(async () => { + await app.teardown(); +}); + test.describe('passkeys @generic', () => { test.describe.configure({ mode: 'serial' }); - let app: Application; let fakeUser: FakeUser; let savedCredential: SavedCredential; test.beforeAll(async () => { - app = await appConfigs.next.appRouter.commit(); - await app.setup(); - await app.withEnv(appConfigs.envs.withPasskeys); - await app.dev(); const u = await createTestUtils({ app }); fakeUser = u.services.users.createFakeUser(test, { fictionalEmail: true, withPassword: true }); await u.services.users.createBapiUser(fakeUser); @@ -42,7 +53,6 @@ test.describe('passkeys @generic', () => { test.afterAll(async () => { await fakeUser.deleteIfExists(); - await app.teardown(); }); test('registers a passkey through UserProfile', async ({ page, context }) => { @@ -108,3 +118,91 @@ test.describe('passkeys @generic', () => { await u.po.expect.toBeSignedIn(); }); }); + +test.describe('passkeys as a second factor @generic', () => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + let userId: string; + let savedCredential: SavedCredential; + + test.beforeAll(async () => { + const u = await createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(test, { fictionalEmail: true, withPassword: true }); + const user = await u.services.users.createBapiUser(fakeUser); + userId = user.id; + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + }); + + const signInToSecondFactor = async (page: Page, context: BrowserContext) => { + const u = await createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: fakeUser.email!, + password: fakeUser.password, + waitForSession: false, + }); + await u.page.waitForURL(/\/sign-in\/factor-two/); + return u; + }; + + test('registers a passkey, then enrolls the user in two-factor', async ({ page, context }) => { + await installVirtualAuthenticator(context); + + const u = await createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email!, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.po.userProfile.goTo(); + await u.po.userProfile.switchToSecurityTab(); + await u.page.getByRole('button', { name: /add a passkey/i }).click(); + + await expect(u.page.locator('.cl-profileSectionItem__passkeys')).toBeVisible(); + + const credentials = await context.credentials.get(); + expect(credentials).toHaveLength(1); + savedCredential = credentials[0]; + + // A passkey on its own never creates a 2FA requirement, so enroll TOTP to park + // later sign-ins at needs_second_factor. It has to happen after the passkey is + // registered, since the remaining tests never enter a TOTP code. + await u.services.clerk.users.updateUser(userId, { totpSecret: TOTP_SECRET }); + }); + + test('offers the passkey as the starting second factor', async ({ page, context }) => { + await installVirtualAuthenticator(context); + + const u = await signInToSecondFactor(page, context); + + // Passkey outranks the enrolled authenticator app once the backend advertises it. + await expect(u.page.getByText('Use your passkey')).toBeVisible(); + // Seed only now: the start page's conditional-UI autofill would otherwise + // answer with this credential and verify it as the first factor instead. + await context.credentials.create(savedCredential.rpId, savedCredential); + await u.po.signIn.continue(); + + await u.po.expect.toBeSignedIn(); + }); + + test('lists the passkey under "Use another method"', async ({ page, context }) => { + await installVirtualAuthenticator(context); + + const u = await signInToSecondFactor(page, context); + + await u.po.signIn.getUseAnotherMethodLink().click(); + + const passkeyButton = u.page.getByRole('button', { name: /sign in with your passkey/i }); + await expect(passkeyButton).toBeVisible(); + await expect(u.page.getByRole('button', { name: /use your authenticator app/i })).toBeVisible(); + + await context.credentials.create(savedCredential.rpId, savedCredential); + await passkeyButton.click(); + await u.po.signIn.continue(); + + await u.po.expect.toBeSignedIn(); + }); +}); diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index ce853a560ba..1823d6b7b5b 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -3,8 +3,8 @@ { "path": "./dist/clerk.js", "maxSize": "556KB" }, { "path": "./dist/clerk.browser.js", "maxSize": "83KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "126KB" }, - { "path": "./dist/clerk.no-rhc.js", "maxSize": "322.25KB" }, - { "path": "./dist/clerk.native.js", "maxSize": "82KB" }, + { "path": "./dist/clerk.no-rhc.js", "maxSize": "322.75KB" }, + { "path": "./dist/clerk.native.js", "maxSize": "82.5KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, { "path": "./dist/coinbase*.js", "maxSize": "36KB" }, { "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" }, diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index dd80e84be0b..e9078a363e3 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -37,6 +37,7 @@ import type { SessionVerifyCreateParams, SessionVerifyPrepareFirstFactorParams, SessionVerifyPrepareSecondFactorParams, + SessionVerifyWithPasskeyParams, TokenResource, UserResource, } from '@clerk/shared/types'; @@ -47,7 +48,7 @@ import { debugLogger } from '@/utils/debug'; import { getTabState, isTabFocused } from '@/utils/isTabFocused'; import { TokenId } from '@/utils/tokenId'; -import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../errors'; +import { clerkInvalidStrategy, clerkInvalidVerificationLevel, clerkMissingWebAuthnPublicKeyOptions } from '../errors'; import { eventBus, events } from '../events'; import type { FapiResponseJSON } from '../fapiClient'; import { SessionTokenCache } from '../tokenCache'; @@ -324,10 +325,29 @@ export class Session extends BaseResource implements SessionResource { return new SessionVerification(json); }; - verifyWithPasskey = async (): Promise => { - const prepareResponse = await this.prepareFirstFactorVerification({ strategy: 'passkey' }); + /** + * Initiates a reverification flow using passkeys. + * + * By default the passkey verifies the first factor. Pass `{ level: 'second_factor' }` to satisfy + * the second factor of an in-progress multi-factor reverification instead. Any other `level` + * value is rejected. + * + * Throws a `ClerkWebAuthnError` when WebAuthn is unsupported or the passkey ceremony fails. + * @returns A `SessionVerification` instance with its status and supported factors. + */ + verifyWithPasskey = async (params?: SessionVerifyWithPasskeyParams): Promise => { + const level = params?.level ?? 'first_factor'; + if (level !== 'first_factor' && level !== 'second_factor') { + clerkInvalidVerificationLevel('Session.verifyWithPasskey', level); + } + + const prepareResponse = + level === 'second_factor' + ? await this.prepareSecondFactorVerification({ strategy: 'passkey' }) + : await this.prepareFirstFactorVerification({ strategy: 'passkey' }); - const { nonce = null } = prepareResponse.firstFactorVerification; + const { nonce = null } = + level === 'second_factor' ? prepareResponse.secondFactorVerification : prepareResponse.firstFactorVerification; /** * The UI should always prevent from this method being called if WebAuthn is not supported. @@ -357,6 +377,13 @@ export class Session extends BaseResource implements SessionResource { throw error; } + if (level === 'second_factor') { + return this.attemptSecondFactorVerification({ + strategy: 'passkey', + publicKeyCredential, + }); + } + return this.attemptFirstFactorVerification({ strategy: 'passkey', publicKeyCredential, @@ -380,11 +407,23 @@ export class Session extends BaseResource implements SessionResource { attemptSecondFactorVerification = async ( attemptFactor: SessionVerifyAttemptSecondFactorParams, ): Promise => { + let config; + switch (attemptFactor.strategy) { + case 'passkey': { + config = { + publicKeyCredential: JSON.stringify(serializePublicKeyCredentialAssertion(attemptFactor.publicKeyCredential)), + }; + break; + } + default: + config = { ...attemptFactor }; + } + const json = ( await BaseResource._fetch({ method: 'POST', path: `/client/sessions/${this.id}/verify/attempt_second_factor`, - body: attemptFactor as any, + body: { ...config, strategy: attemptFactor.strategy } as any, }) )?.response as unknown as SessionVerificationJSON; diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 94a4916c196..7b12a95f3c4 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -402,8 +402,19 @@ export class SignIn extends BaseResource implements SignInResource { attemptSecondFactor = (params: AttemptSecondFactorParams): Promise => { debugLogger.debug('SignIn.attemptSecondFactor', { id: this.id, strategy: params.strategy }); + let config; + switch (params.strategy) { + case 'passkey': + config = { + publicKeyCredential: JSON.stringify(serializePublicKeyCredentialAssertion(params.publicKeyCredential)), + }; + break; + default: + config = { ...params }; + } + return this._basePost({ - body: params, + body: { ...config, strategy: params.strategy }, action: 'attempt_second_factor', }); }; @@ -622,6 +633,25 @@ export class SignIn extends BaseResource implements SignInResource { }); }; + /** + * Authenticates the sign-in with a passkey. + * + * When called without `params.flow` while the sign-in status is `needs_second_factor` (or + * `needs_client_trust`) and the sign-in offers `passkey` among its `supportedSecondFactors`, the + * passkey acts as the second factor: the in-progress sign-in is reused via the discrete + * prepare/attempt second-factor flow. + * + * Otherwise the passkey verifies the first factor, with `params.flow` selecting how the ceremony + * starts: `'autofill'`/`'discoverable'` create a new sign-in and identify the user from the + * passkey itself, discarding any in-progress sign-in, while the default requires a sign-in + * created beforehand. A sign-in parked at a second-factor status that does NOT offer passkey (the + * backend advertises it only when the instance allows passkeys to satisfy the second factor, the + * user has one registered, and the client version supports it) also takes this path, matching + * clients that predate passkey second factors: the ceremony starts over instead of failing. + * + * Throws a `ClerkWebAuthnError` when WebAuthn is unsupported or the passkey ceremony fails. + * @returns The updated `SignIn` resource. + */ public authenticateWithPasskey = async (params?: AuthenticateWithPasskeyParams): Promise => { const { flow } = params || {}; @@ -641,6 +671,33 @@ export class SignIn extends BaseResource implements SignInResource { }); } + const isSecondFactor = this.status === 'needs_second_factor' || this.status === 'needs_client_trust'; + const hasPasskeySecondFactor = (this.supportedSecondFactors || []).some(f => f.strategy === 'passkey'); + if (!flow && isSecondFactor && hasPasskeySecondFactor) { + await this.prepareSecondFactor({ strategy: 'passkey' }); + + const { nonce: secondFactorNonce } = this.secondFactorVerification; + const secondFactorPublicKeyOptions = secondFactorNonce + ? convertJSONToPublicKeyRequestOptions(JSON.parse(secondFactorNonce)) + : null; + if (!secondFactorPublicKeyOptions) { + clerkMissingWebAuthnPublicKeyOptions('get'); + } + + const { publicKeyCredential, error } = await webAuthnGetCredential({ + publicKeyOptions: secondFactorPublicKeyOptions, + conditionalUI: false, + }); + if (!publicKeyCredential) { + throw error; + } + + return this.attemptSecondFactor({ + publicKeyCredential, + strategy: 'passkey', + }); + } + if (flow === 'autofill' || flow === 'discoverable') { // @ts-ignore As this is experimental we want to support it at runtime, but not at the type level await this.create({ strategy: 'passkey' }); @@ -864,6 +921,7 @@ class SignInFuture implements SignInFutureResource { verifyEmailCode: this.verifyMFAEmailCode.bind(this), verifyTOTP: this.verifyTOTP.bind(this), verifyBackupCode: this.verifyBackupCode.bind(this), + passkey: this.verifyMFAPasskey.bind(this), }; #canBeDiscarded = false; @@ -1643,6 +1701,56 @@ class SignInFuture implements SignInFutureResource { }); } + async verifyMFAPasskey(): Promise<{ error: ClerkError | null }> { + /** + * The UI should always prevent from this method being called if WebAuthn is not supported. + * As a precaution we need to check if WebAuthn is supported. + */ + const isWebAuthnSupported = SignIn.clerk.__internal_isWebAuthnSupported || isWebAuthnSupportedOnWindow; + const webAuthnGetCredential = SignIn.clerk.__internal_getPublicCredentials || webAuthnGetCredentialOnWindow; + + if (!isWebAuthnSupported()) { + throw new ClerkWebAuthnError('Passkeys are not supported', { + code: 'passkey_not_supported', + }); + } + + return runAsyncResourceTask(this.#resource, async () => { + const passkeyFactor = this.#resource.supportedSecondFactors?.find(f => f.strategy === 'passkey'); + if (!passkeyFactor) { + throw new ClerkRuntimeError('Passkey factor not found', { code: 'factor_not_found' }); + } + + await this.#resource.__internal_basePost({ + body: { strategy: 'passkey' }, + action: 'prepare_second_factor', + coalesce: true, + }); + + const { nonce } = this.#resource.secondFactorVerification; + const publicKeyOptions = nonce ? convertJSONToPublicKeyRequestOptions(JSON.parse(nonce)) : null; + if (!publicKeyOptions) { + throw new ClerkRuntimeError('Missing public key options', { code: 'missing_public_key_options' }); + } + + const { publicKeyCredential, error } = await webAuthnGetCredential({ + publicKeyOptions, + conditionalUI: false, + }); + if (!publicKeyCredential) { + throw new ClerkWebAuthnError(error.message, { code: 'passkey_retrieval_failed' }); + } + + await this.#resource.__internal_basePost({ + body: { + publicKeyCredential: JSON.stringify(serializePublicKeyCredentialAssertion(publicKeyCredential)), + strategy: 'passkey', + }, + action: 'attempt_second_factor', + }); + }); + } + async ticket(params?: SignInFutureTicketParams): Promise<{ error: ClerkError | null }> { const ticket = params?.ticket ?? getClerkQueryParam('__clerk_ticket'); return this.create({ strategy: 'ticket', ticket: ticket ?? undefined }); diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 1c57c44e7c3..e733406e3c9 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -896,6 +896,137 @@ describe('Session', () => { }); }); + describe('verifyWithPasskey()', () => { + const mockPublicKeyCredential = { + id: 'credential_123', + rawId: new ArrayBuffer(32), + response: { + authenticatorData: new ArrayBuffer(37), + clientDataJSON: new ArrayBuffer(121), + signature: new ArrayBuffer(64), + userHandle: null, + }, + type: 'public-key', + }; + + const sessionJSON = { + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON; + + let originalFetch: typeof BaseResource._fetch; + + beforeEach(() => { + originalFetch = BaseResource._fetch; + BaseResource.clerk = clerkMock({ + __internal_isWebAuthnSupported: vi.fn().mockReturnValue(true), + __internal_getPublicCredentials: vi.fn().mockResolvedValue({ + publicKeyCredential: mockPublicKeyCredential, + error: null, + }), + } as any); + }); + + afterEach(() => { + BaseResource._fetch = originalFetch; + BaseResource.clerk = null as any; + }); + + it('runs the first-factor verification flow by default', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + response: { + id: 'sv_123', + first_factor_verification: { nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }) }, + }, + }) + .mockResolvedValueOnce({ + response: { id: 'sv_123', status: 'complete' }, + }); + BaseResource._fetch = mockFetch; + + const session = new Session(sessionJSON); + await session.verifyWithPasskey(); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + method: 'POST', + path: '/client/sessions/session_1/verify/prepare_first_factor', + body: expect.objectContaining({ strategy: 'passkey' }), + }), + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: 'POST', + path: '/client/sessions/session_1/verify/attempt_first_factor', + body: expect.objectContaining({ + strategy: 'passkey', + publicKeyCredential: expect.any(String), + }), + }), + ); + }); + + it('runs the second-factor verification flow when level is second_factor', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + response: { + id: 'sv_123', + second_factor_verification: { nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }) }, + }, + }) + .mockResolvedValueOnce({ + response: { id: 'sv_123', status: 'complete' }, + }); + BaseResource._fetch = mockFetch; + + const session = new Session(sessionJSON); + await session.verifyWithPasskey({ level: 'second_factor' }); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + method: 'POST', + path: '/client/sessions/session_1/verify/prepare_second_factor', + body: expect.objectContaining({ strategy: 'passkey' }), + }), + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: 'POST', + path: '/client/sessions/session_1/verify/attempt_second_factor', + body: expect.objectContaining({ + strategy: 'passkey', + publicKeyCredential: expect.any(String), + }), + }), + ); + }); + + it('rejects invalid verification levels without calling the API', async () => { + const mockFetch = vi.fn(); + BaseResource._fetch = mockFetch; + + const session = new Session(sessionJSON); + await expect(session.verifyWithPasskey({ level: 'third_factor' as any })).rejects.toThrow( + 'not a valid verification level', + ); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + describe('touch()', () => { let dispatchSpy: ReturnType; diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts index 7942dfcc7ac..d8c3abe7fb4 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -763,6 +763,215 @@ describe('SignIn', () => { }); }); + describe('authenticateWithPasskey', () => { + const mockPublicKeyCredential = { + id: 'credential_123', + rawId: new ArrayBuffer(32), + response: { + authenticatorData: new ArrayBuffer(37), + clientDataJSON: new ArrayBuffer(121), + signature: new ArrayBuffer(64), + userHandle: null, + }, + type: 'public-key', + }; + + const originalFetch = BaseResource._fetch; + const originalClerk = SignIn.clerk; + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + BaseResource._fetch = originalFetch; + SignIn.clerk = originalClerk; + }); + + it('prepares and attempts the second factor when the sign-in needs a second factor', async () => { + const mockIsWebAuthnSupported = vi.fn().mockReturnValue(true); + const mockWebAuthnGetCredential = vi.fn().mockResolvedValue({ + publicKeyCredential: mockPublicKeyCredential, + error: null, + }); + + SignIn.clerk = { + __internal_isWebAuthnSupported: mockIsWebAuthnSupported, + __internal_getPublicCredentials: mockWebAuthnGetCredential, + } as any; + + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_123', + status: 'needs_second_factor', + supported_second_factors: [{ strategy: 'passkey' }], + second_factor_verification: { + nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }), + }, + }, + }) + .mockResolvedValueOnce({ + client: null, + response: { id: 'signin_123', status: 'complete' }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ + id: 'signin_123', + status: 'needs_second_factor', + supported_second_factors: [{ strategy: 'passkey' }, { strategy: 'totp' }], + } as any); + await signIn.authenticateWithPasskey(); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + method: 'POST', + path: '/client/sign_ins/signin_123/prepare_second_factor', + body: expect.objectContaining({ strategy: 'passkey' }), + }), + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: 'POST', + path: '/client/sign_ins/signin_123/attempt_second_factor', + body: expect.objectContaining({ + strategy: 'passkey', + publicKeyCredential: expect.any(String), + }), + }), + ); + }); + + it.each(['autofill', 'discoverable'] as const)( + 'creates a new sign-in for the %s flow even when the sign-in needs a passkey second factor', + async flow => { + const mockIsWebAuthnSupported = vi.fn().mockReturnValue(true); + const mockIsWebAuthnAutofillSupported = vi.fn().mockResolvedValue(true); + const mockWebAuthnGetCredential = vi.fn().mockResolvedValue({ + publicKeyCredential: mockPublicKeyCredential, + error: null, + }); + + SignIn.clerk = { + __internal_isWebAuthnSupported: mockIsWebAuthnSupported, + __internal_isWebAuthnAutofillSupported: mockIsWebAuthnAutofillSupported, + __internal_getPublicCredentials: mockWebAuthnGetCredential, + } as any; + + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_456', + status: 'needs_first_factor', + first_factor_verification: { + nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }), + }, + }, + }) + .mockResolvedValueOnce({ + client: null, + response: { id: 'signin_456', status: 'complete' }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ + id: 'signin_123', + status: 'needs_second_factor', + supported_second_factors: [{ strategy: 'passkey' }], + } as any); + await signIn.authenticateWithPasskey({ flow }); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + method: 'POST', + path: '/client/sign_ins', + body: expect.objectContaining({ strategy: 'passkey' }), + }), + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + path: '/client/sign_ins/signin_456/attempt_first_factor', + }), + ); + expect(mockWebAuthnGetCredential).toHaveBeenCalledWith( + expect.objectContaining({ conditionalUI: flow === 'autofill' }), + ); + }, + ); + + it('falls through to the first-factor flow when the sign-in needs a second factor but does not offer passkey', async () => { + const mockIsWebAuthnSupported = vi.fn().mockReturnValue(true); + const mockWebAuthnGetCredential = vi.fn().mockResolvedValue({ + publicKeyCredential: mockPublicKeyCredential, + error: null, + }); + + SignIn.clerk = { + __internal_isWebAuthnSupported: mockIsWebAuthnSupported, + __internal_getPublicCredentials: mockWebAuthnGetCredential, + } as any; + + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_456', + status: 'needs_first_factor', + first_factor_verification: { + nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }), + }, + }, + }) + .mockResolvedValueOnce({ + client: null, + response: { id: 'signin_456', status: 'complete' }, + }); + BaseResource._fetch = mockFetch; + + // A stale in-progress sign-in parked at needs_second_factor: e.g. the + // backend didn't advertise passkey (instance toggle off, no registered + // passkey, or an older-client version gate) while the start page still + // offers browser-side passkey discovery. + const signIn = new SignIn({ + id: 'signin_123', + status: 'needs_second_factor', + supported_second_factors: [{ strategy: 'totp' }], + } as any); + await signIn.authenticateWithPasskey({ flow: 'discoverable' }); + + // The in-progress sign-in is discarded: a fresh sign-in is created and + // the passkey verifies its first factor, as on clients that predate + // passkey second factors. + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + method: 'POST', + path: '/client/sign_ins', + body: expect.objectContaining({ strategy: 'passkey' }), + }), + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: 'POST', + path: '/client/sign_ins/signin_456/attempt_first_factor', + body: expect.objectContaining({ + strategy: 'passkey', + publicKeyCredential: expect.any(String), + }), + }), + ); + }); + }); + describe('trusted_device strategy', () => { afterEach(() => { vi.clearAllMocks(); @@ -2462,6 +2671,106 @@ describe('SignIn', () => { }); }); + describe('mfa.passkey', () => { + const originalFetch = BaseResource._fetch; + const originalClerk = SignIn.clerk; + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + BaseResource._fetch = originalFetch; + SignIn.clerk = originalClerk; + }); + + it('prepares and attempts the passkey second factor', async () => { + const mockIsWebAuthnSupported = vi.fn().mockReturnValue(true); + const mockWebAuthnGetCredential = vi.fn().mockResolvedValue({ + publicKeyCredential: { + id: 'credential_123', + rawId: new ArrayBuffer(32), + response: { + authenticatorData: new ArrayBuffer(37), + clientDataJSON: new ArrayBuffer(121), + signature: new ArrayBuffer(64), + userHandle: null, + }, + type: 'public-key', + }, + error: null, + }); + + SignIn.clerk = { + __internal_isWebAuthnSupported: mockIsWebAuthnSupported, + __internal_getPublicCredentials: mockWebAuthnGetCredential, + } as any; + + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_123', + status: 'needs_second_factor', + supported_second_factors: [{ strategy: 'passkey' }], + second_factor_verification: { + nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }), + }, + }, + }) + .mockResolvedValueOnce({ + client: null, + response: { id: 'signin_123', status: 'complete' }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ + id: 'signin_123', + status: 'needs_second_factor', + supported_second_factors: [{ strategy: 'passkey' }], + } as any); + await signIn.__internal_future.mfa.passkey(); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + method: 'POST', + path: '/client/sign_ins/signin_123/prepare_second_factor', + body: { strategy: 'passkey' }, + }), + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: 'POST', + path: '/client/sign_ins/signin_123/attempt_second_factor', + body: expect.objectContaining({ + strategy: 'passkey', + publicKeyCredential: expect.any(String), + }), + }), + ); + }); + + it('returns error when passkey second factor is not available', async () => { + const mockIsWebAuthnSupported = vi.fn().mockReturnValue(true); + + SignIn.clerk = { + __internal_isWebAuthnSupported: mockIsWebAuthnSupported, + } as any; + + const signIn = new SignIn({ + id: 'signin_123', + status: 'needs_second_factor', + supported_second_factors: [{ strategy: 'totp' }], + } as any); + + const result = await signIn.__internal_future.mfa.passkey(); + + expect(result.error).toBeTruthy(); + expect(result.error?.code).toBe('factor_not_found'); + }); + }); + describe('web3', () => { afterEach(() => { vi.clearAllMocks(); diff --git a/packages/localizations/src/ar-SA.ts b/packages/localizations/src/ar-SA.ts index cc7882eab1a..c707911a13b 100644 --- a/packages/localizations/src/ar-SA.ts +++ b/packages/localizations/src/ar-SA.ts @@ -1625,6 +1625,11 @@ export const arSA: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: undefined, subtitle: undefined, @@ -1775,6 +1780,10 @@ export const arSA: LocalizationResource = { 'يؤدي استخدام مفتاح المرور الخاص بك إلى تأكيد هويتك. جهازك الخاص قد يقوم بسؤالك عن بصمة الإصبع, او معرف الوجة او كلمة مرور قفل الشاشة', title: 'إستخدم مفتاح المرور', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'أستعمل طريقة أخرى', subtitle: 'للمتابعة إلى {{applicationName}}', diff --git a/packages/localizations/src/be-BY.ts b/packages/localizations/src/be-BY.ts index c95417dc549..db7b9ca5650 100644 --- a/packages/localizations/src/be-BY.ts +++ b/packages/localizations/src/be-BY.ts @@ -1632,6 +1632,11 @@ export const beBY: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Вярнуцца да ўводу пароля', subtitle: 'Калі вы памятаеце свой пароль, вы можаце ўвесці яго для завершэння верыфікацыі.', @@ -1783,6 +1788,10 @@ export const beBY: LocalizationResource = { subtitle: 'Выкарыстоўвайце паскей для бяспечнага ўваходу.', title: 'Увядзіце паскей', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Выкарыстаць іншы метад', subtitle: 'каб працягнуць працу ў "{{applicationName}}"', diff --git a/packages/localizations/src/bg-BG.ts b/packages/localizations/src/bg-BG.ts index a2e222547eb..fc88d5a762e 100644 --- a/packages/localizations/src/bg-BG.ts +++ b/packages/localizations/src/bg-BG.ts @@ -1629,6 +1629,11 @@ export const bgBG: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Use another method', subtitle: 'Enter your current password to continue using "{{applicationName}}"', @@ -1779,6 +1784,10 @@ export const bgBG: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Използвайте друг метод', subtitle: 'Въведете паролата, свързана с вашия акаунт', diff --git a/packages/localizations/src/bn-IN.ts b/packages/localizations/src/bn-IN.ts index 2cb0e9dec42..7385f976b99 100644 --- a/packages/localizations/src/bn-IN.ts +++ b/packages/localizations/src/bn-IN.ts @@ -1637,6 +1637,11 @@ export const bnIN: LocalizationResource = { 'আপনার পাসকি ব্যবহার করে আপনার পরিচয় নিশ্চিত করা হয়। আপনার ডিভাইস আপনার আঙ্গুলের ছাপ, মুখ বা স্ক্রিন লক চাইতে পারে।', title: 'আপনার পাসকি ব্যবহার করুন', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'অন্য পদ্ধতি ব্যবহার করুন', subtitle: 'চালিয়ে যেতে আপনার বর্তমান পাসওয়ার্ড লিখুন', @@ -1788,6 +1793,10 @@ export const bnIN: LocalizationResource = { 'আপনার পাসকি ব্যবহার করলে নিশ্চিত হয় যে এটি আপনি। আপনার ডিভাইস আপনার আঙ্গুলের ছাপ, মুখ বা স্ক্রিন লক চাইতে পারে।', title: 'আপনার পাসকি ব্যবহার করুন', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'অন্য পদ্ধতি ব্যবহার করুন', subtitle: 'আপনার অ্যাকাউন্টের সাথে যুক্ত পাসওয়ার্ড লিখুন', diff --git a/packages/localizations/src/ca-ES.ts b/packages/localizations/src/ca-ES.ts index fd6720cff57..9225a3c42c5 100644 --- a/packages/localizations/src/ca-ES.ts +++ b/packages/localizations/src/ca-ES.ts @@ -1637,6 +1637,11 @@ export const caES: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Has oblidat la contrasenya? Recupera-la aquí.', subtitle: 'Utilitza la teva contrasenya per verificar la teva identitat.', @@ -1787,6 +1792,10 @@ export const caES: LocalizationResource = { subtitle: "Utilitza la teva clau d'accés per continuar amb l'autenticació.", title: "Clau d'accés", }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Utilitza un altre mètode', subtitle: 'Introdueix la contrasenya associada al teu compte', diff --git a/packages/localizations/src/cs-CZ.ts b/packages/localizations/src/cs-CZ.ts index e025627f5bc..cc16a2a3d70 100644 --- a/packages/localizations/src/cs-CZ.ts +++ b/packages/localizations/src/cs-CZ.ts @@ -1635,6 +1635,11 @@ export const csCZ: LocalizationResource = { 'Použití vašeho přístupového klíče potvrzuje vaši identitu. Vaše zařízení může požádat o otisk prstu, obličej nebo zámek obrazovky.', title: 'Použít váš přístupový klíč', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Použít jinou metodu', subtitle: 'Zadejte své aktuální heslo pro pokračování', @@ -1787,6 +1792,10 @@ export const csCZ: LocalizationResource = { 'Použití vašeho přístupového klíče potvrzuje, že jste to vy. Vaše zařízení může požádat o otisk prstu, obličej nebo zámek obrazovky.', title: 'Použít váš přístupový klíč', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Použít jinou metodu', subtitle: 'Zadejte heslo spojené s vaším účtem', diff --git a/packages/localizations/src/da-DK.ts b/packages/localizations/src/da-DK.ts index 6e0d81a20b2..362723d2869 100644 --- a/packages/localizations/src/da-DK.ts +++ b/packages/localizations/src/da-DK.ts @@ -1627,6 +1627,11 @@ export const daDK: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: undefined, subtitle: undefined, @@ -1777,6 +1782,10 @@ export const daDK: LocalizationResource = { subtitle: 'Brug adgangsnøgle til at logge ind.', title: 'Adgangsnøgle', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Brug en anden metode', subtitle: 'Fortsæt til {{applicationName}}', diff --git a/packages/localizations/src/de-DE.ts b/packages/localizations/src/de-DE.ts index 4bc781d2353..52ecf5ecb8b 100644 --- a/packages/localizations/src/de-DE.ts +++ b/packages/localizations/src/de-DE.ts @@ -1644,6 +1644,11 @@ export const deDE: LocalizationResource = { 'Die Verwendung Ihres Passkeys bestätigt Ihre Identität. Ihr Gerät kann nach Ihrem Fingerabdruck, Gesicht oder Bildschirmsperre fragen.', title: 'Verwenden Sie Ihren Passkey', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Passwort zurücksetzen', subtitle: 'Geben Sie Ihr Passwort ein, um fortzufahren.', @@ -1795,6 +1800,10 @@ export const deDE: LocalizationResource = { 'Die Verwendung Ihres Passkeys bestätigt, dass Sie es sind. Ihr Gerät kann nach Ihrem Fingerabdruck, Ihrem Gesicht oder der Bildschirmsperre fragen.', title: 'Verwenden Sie Ihren Passkey', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Verwenden Sie eine andere Methode', subtitle: 'weiter zu {{applicationName}}', diff --git a/packages/localizations/src/el-GR.ts b/packages/localizations/src/el-GR.ts index 8bf87a2b91f..5013b35ca5b 100644 --- a/packages/localizations/src/el-GR.ts +++ b/packages/localizations/src/el-GR.ts @@ -1635,6 +1635,11 @@ export const elGR: LocalizationResource = { 'Η χρήση του passkey σας επαληθεύει ότι είστε εσείς. Η συσκευή σας μπορεί να ζητήσει το δακτυλικό σας αποτύπωμα, την αναγνώριση προσώπου ή το PIN οθόνης.', title: 'Χρησιμοποιήστε το passkey σας', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Χρησιμοποιήστε άλλη μέθοδο', subtitle: 'για να συνεχίσετε', @@ -1788,6 +1793,10 @@ export const elGR: LocalizationResource = { 'Η χρήση του passkey σας επιβεβαιώνει την ταυτότητά σας. Η συσκευή σας μπορεί να ζητήσει δακτυλικό αποτύπωμα, αναγνώριση προσώπου ή κλείδωμα οθόνης.', title: 'Χρησιμοποιήστε το passkey σας', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Χρήση άλλης μεθόδου', subtitle: 'για να συνεχίσετε στο {{applicationName}}', diff --git a/packages/localizations/src/en-GB.ts b/packages/localizations/src/en-GB.ts index 0dfddf53585..a038681c897 100644 --- a/packages/localizations/src/en-GB.ts +++ b/packages/localizations/src/en-GB.ts @@ -1628,6 +1628,11 @@ export const enGB: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Use another method', subtitle: 'Enter your current password to continue', @@ -1779,6 +1784,10 @@ export const enGB: LocalizationResource = { subtitle: "Using your passkey confirms it's you. Your device may ask for your fingerprint, face or screen lock.", title: 'Use your passkey', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Use another method', subtitle: 'Enter the password associated with your account', diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 7e862ea758e..b307ffe16d6 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -1675,6 +1675,12 @@ export const enUS: LocalizationResource = { 'Using your passkey confirms your identity. Your device may ask for your fingerprint, face, or screen lock.', title: 'Use your passkey', }, + passkeyMfa: { + blockButton__passkey: 'Use your passkey', + subtitle: + 'Using your passkey confirms your identity. Your device may ask for your fingerprint, face, or screen lock.', + title: 'Verification required', + }, password: { actionLink: 'Use another method', subtitle: 'Enter your current password to continue', @@ -1826,6 +1832,10 @@ export const enUS: LocalizationResource = { subtitle: "Using your passkey confirms it's you. Your device may ask for your fingerprint, face or screen lock.", title: 'Use your passkey', }, + passkeyMfa: { + subtitle: "Using your passkey confirms it's you. Your device may ask for your fingerprint, face or screen lock.", + title: 'Use your passkey', + }, password: { actionLink: 'Use another method', subtitle: 'Enter the password associated with your account', diff --git a/packages/localizations/src/es-CR.ts b/packages/localizations/src/es-CR.ts index 0db7fbb5555..ed06e9d92f1 100644 --- a/packages/localizations/src/es-CR.ts +++ b/packages/localizations/src/es-CR.ts @@ -1633,6 +1633,11 @@ export const esCR: LocalizationResource = { 'Utilizar tu llave de acceso confirma que eres tú. Tu dispositivo puede solicitar tu huella dactilar, rostro o pantalla de bloqueo.', title: 'Utiliza tu llave de acceso', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Utiliza otro método', subtitle: 'Ingresa tu contraseña actual para continuar', @@ -1785,6 +1790,10 @@ export const esCR: LocalizationResource = { 'Usando tu llave de acceso confirmas que eres tú. Tu dispositivo puede pedirte la huella dactilar, el rostro o el bloqueo de pantalla.', title: 'Usa tu llave de acceso', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Utiliza otro método', subtitle: 'para continuar con {{applicationName}}', diff --git a/packages/localizations/src/es-ES.ts b/packages/localizations/src/es-ES.ts index c8f7d6d7b89..cff65d2b6a5 100644 --- a/packages/localizations/src/es-ES.ts +++ b/packages/localizations/src/es-ES.ts @@ -1638,6 +1638,11 @@ export const esES: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: '¿Olvidaste tu contraseña? Recupérala aquí.', subtitle: 'Usa tu contraseña para verificar tu identidad.', @@ -1788,6 +1793,10 @@ export const esES: LocalizationResource = { subtitle: 'Use su clave de acceso para continuar con la autenticación.', title: 'Clave de acceso', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Usa otro método', subtitle: 'para continuar a {{applicationName}}', diff --git a/packages/localizations/src/es-MX.ts b/packages/localizations/src/es-MX.ts index 713bc34b6c9..2e6d7e4256d 100644 --- a/packages/localizations/src/es-MX.ts +++ b/packages/localizations/src/es-MX.ts @@ -1634,6 +1634,11 @@ export const esMX: LocalizationResource = { 'Utilizar tu llave de acceso confirma que eres tú. Tu dispositivo puede solicitar tu huella dactilar, rostro o pantalla de bloqueo.', title: 'Utiliza tu llave de acceso', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Utiliza otro método', subtitle: 'Ingresa tu contraseña actual para continuar', @@ -1786,6 +1791,10 @@ export const esMX: LocalizationResource = { 'Usando tu llave de acceso confirmas que eres tú. Tu dispositivo puede pedirte la huella dactilar, el rostro o el bloqueo de pantalla.', title: 'Usa tu llave de acceso', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Utiliza otro método', subtitle: 'para continuar con {{applicationName}}', diff --git a/packages/localizations/src/es-UY.ts b/packages/localizations/src/es-UY.ts index 3e72c1ee734..395d82d04e4 100644 --- a/packages/localizations/src/es-UY.ts +++ b/packages/localizations/src/es-UY.ts @@ -1632,6 +1632,11 @@ export const esUY: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Usar otro método', subtitle: 'Ingresá tu contraseña para continuar', @@ -1784,6 +1789,10 @@ export const esUY: LocalizationResource = { 'Usar tu clave de acceso confirma que sos vos. Tu dispositivo puede solicitar tu huella, rostro o bloqueo de pantalla.', title: 'Usar tu clave de acceso', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Usar otro método', subtitle: 'Ingresá la contraseña asociada a tu cuenta', diff --git a/packages/localizations/src/fa-IR.ts b/packages/localizations/src/fa-IR.ts index a255fe5baf8..38ce6f62bba 100644 --- a/packages/localizations/src/fa-IR.ts +++ b/packages/localizations/src/fa-IR.ts @@ -1637,6 +1637,11 @@ export const faIR: LocalizationResource = { 'استفاده از کلید عبور، هویت شما را تأیید می‌کند. ممکن است دستگاه شما از شما اثر انگشت، چهره یا قفل صفحه را درخواست کند.', title: 'از کلید عبور خود استفاده کنید', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'استفاده از روش دیگر', subtitle: 'برای ادامه، رمز عبور فعلی خود را وارد کنید', @@ -1789,6 +1794,10 @@ export const faIR: LocalizationResource = { 'ستفاده از کلید عبور، هویت شما را تأیید می‌کند. ممکن است دستگاه از شما اثر انگشت، چهره یا قفل صفحه را درخواست کند.', title: 'از کلید عبور خود استفاده کنید', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'استفاده از روش دیگر', subtitle: 'رمز عبور مرتبط با حساب کاربری خود را وارد کنید', diff --git a/packages/localizations/src/fi-FI.ts b/packages/localizations/src/fi-FI.ts index 7f2d2139608..5e68744f92e 100644 --- a/packages/localizations/src/fi-FI.ts +++ b/packages/localizations/src/fi-FI.ts @@ -1639,6 +1639,11 @@ export const fiFI: LocalizationResource = { 'Pääsyavaimen käyttö vahvistaa henkilöllisyytesi. Laitteesi saattaa pyytää sormenjälkeä, kasvoja tai näytön lukitusta.', title: 'Käytä pääsyavaintasi', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Käytä toista menetelmää', subtitle: 'Syötä nykyinen salasanasi jatkaaksesi', @@ -1790,6 +1795,10 @@ export const fiFI: LocalizationResource = { 'Käyttämällä pääsyavaintasi vahvistat, että olet se joka väität olevasi. Laite voi pyytää sormenjälkeäsi, kasvojasi tai näytön lukitusta.', title: 'Käytä pääsyavaintasi', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Käytä toista tapaa', subtitle: 'Syötä tilisi salasana', diff --git a/packages/localizations/src/fr-FR.ts b/packages/localizations/src/fr-FR.ts index 0c7d92c392e..b10aa5e3a60 100644 --- a/packages/localizations/src/fr-FR.ts +++ b/packages/localizations/src/fr-FR.ts @@ -1645,6 +1645,11 @@ export const frFR: LocalizationResource = { "L'utilisation de votre clé de sécurité confirme votre identité. Votre appareil peut vous demander votre empreinte digitale, votre visage ou votre code de sécurité.", title: 'Utiliser votre clé de sécurité', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Réinitialiser le mot de passe', subtitle: 'Entrez votre mot de passe pour continuer.', @@ -1795,6 +1800,10 @@ export const frFR: LocalizationResource = { subtitle: 'Utilisez une clé de sécurité pour continuer.', title: 'Clé de sécurité', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Utiliser une autre méthode', subtitle: 'pour continuer vers {{applicationName}}', diff --git a/packages/localizations/src/he-IL.ts b/packages/localizations/src/he-IL.ts index a27db252446..76d4e71749c 100644 --- a/packages/localizations/src/he-IL.ts +++ b/packages/localizations/src/he-IL.ts @@ -1622,6 +1622,11 @@ export const heIL: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'השתמש בשיטה אחרת', subtitle: 'הכנס את הסיסמה המקושרת עם חשבונך', @@ -1770,6 +1775,10 @@ export const heIL: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'השתמש בשיטה אחרת', subtitle: 'להמשיך אל {{applicationName}}', diff --git a/packages/localizations/src/hi-IN.ts b/packages/localizations/src/hi-IN.ts index 78251d7e485..a15280fb922 100644 --- a/packages/localizations/src/hi-IN.ts +++ b/packages/localizations/src/hi-IN.ts @@ -1637,6 +1637,11 @@ export const hiIN: LocalizationResource = { 'अपनी पासकी का उपयोग करके आपकी पहचान की पुष्टि होती है। आपका डिवाइस आपके फिंगरप्रिंट, चेहरे या स्क्रीन लॉक के लिए पूछ सकता है।', title: 'अपनी पासकी का उपयोग करें', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'दूसरी विधि का उपयोग करें', subtitle: 'जारी रखने के लिए अपना वर्तमान पासवर्ड दर्ज करें', @@ -1788,6 +1793,10 @@ export const hiIN: LocalizationResource = { 'अपनी पासकी का उपयोग करके पुष्टि होती है कि यह आप ही हैं। आपका डिवाइस आपके फिंगरप्रिंट, चेहरे या स्क्रीन लॉक के लिए पूछ सकता है।', title: 'अपनी पासकी का उपयोग करें', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'दूसरी विधि का उपयोग करें', subtitle: 'अपने खाते से जुड़ा पासवर्ड दर्ज करें', diff --git a/packages/localizations/src/hr-HR.ts b/packages/localizations/src/hr-HR.ts index d0976af81e5..898e1668daf 100644 --- a/packages/localizations/src/hr-HR.ts +++ b/packages/localizations/src/hr-HR.ts @@ -1638,6 +1638,11 @@ export const hrHR: LocalizationResource = { 'Korištenje vašeg pristupnog ključa potvrđuje vaš identitet. Vaš uređaj može tražiti otisak prsta, prepoznavanje lica ili zaključavanje zaslona.', title: 'Koristite svoj pristupni ključ', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Koristite drugu metodu', subtitle: 'Unesite svoju trenutnu lozinku za nastavak', @@ -1790,6 +1795,10 @@ export const hrHR: LocalizationResource = { 'Korištenje vašeg pristupnog ključa potvrđuje da ste to vi. Vaš uređaj može tražiti otisak prsta, prepoznavanje lica ili zaključavanje zaslona.', title: 'Koristite svoj pristupni ključ', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Koristite drugu metodu', subtitle: 'Unesite lozinku povezanu s vašim računom', diff --git a/packages/localizations/src/hu-HU.ts b/packages/localizations/src/hu-HU.ts index eb5592c2763..ba75bd80414 100644 --- a/packages/localizations/src/hu-HU.ts +++ b/packages/localizations/src/hu-HU.ts @@ -1640,6 +1640,11 @@ export const huHU: LocalizationResource = { 'A passkey használata megerősíti a személyazonosságodat. Az eszközöd kérheti az ujjlenyomatodat, arcodat vagy a képernyőzáradat.', title: 'Passkey használata', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Másik módszer használata', subtitle: 'Add meg a jelenlegi jelszavadat a folytatáshoz', @@ -1792,6 +1797,10 @@ export const huHU: LocalizationResource = { 'A Passkey-d használata megerősíti, hogy te vagy az. Az eszközöd kérheti az ujjlenyomatod, arcod vagy a képernyőzárad.', title: 'Használd a passkeydet', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Másik mód használata', subtitle: 'Írd be a fiókhoz tartozó jelszavad', diff --git a/packages/localizations/src/id-ID.ts b/packages/localizations/src/id-ID.ts index e17e483497b..7b35077a8b8 100644 --- a/packages/localizations/src/id-ID.ts +++ b/packages/localizations/src/id-ID.ts @@ -1631,6 +1631,11 @@ export const idID: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Gunakan metode lain', subtitle: 'Masukkan kata sandi Anda untuk melanjutkan', @@ -1783,6 +1788,10 @@ export const idID: LocalizationResource = { 'Menggunakan passkey mengonfirmasi bahwa ini adalah Anda. Perangkat Anda mungkin meminta sidik jari, wajah, atau kunci layar.', title: 'Gunakan passkey Anda', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Gunakan metode lain', subtitle: 'Masukkan kata sandi yang terkait dengan akun Anda', diff --git a/packages/localizations/src/is-IS.ts b/packages/localizations/src/is-IS.ts index f468b8838d9..f015eed4f00 100644 --- a/packages/localizations/src/is-IS.ts +++ b/packages/localizations/src/is-IS.ts @@ -1639,6 +1639,11 @@ export const isIS: LocalizationResource = { 'Að nota lykilinn þinn staðfestir auðkenni þitt. Tækið þitt gæti beðið um fingrafar, andlit eða skjálás.', title: 'Nota lykilinn þinn', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Nota aðra aðferð', subtitle: 'Sláðu inn núverandi lykilorð til að halda áfram', @@ -1791,6 +1796,10 @@ export const isIS: LocalizationResource = { 'Að nota lykilinn þinn staðfestir að þú ert það. Tækið þitt gæti beðið um fingrafar, andlit eða skjálás.', title: 'Nota lykilinn þinn', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Nota aðra aðferð', subtitle: 'Sláðu inn lykilorðið sem tengist reikningnum þínum', diff --git a/packages/localizations/src/it-IT.ts b/packages/localizations/src/it-IT.ts index ac1f77d29c5..4044db1665a 100644 --- a/packages/localizations/src/it-IT.ts +++ b/packages/localizations/src/it-IT.ts @@ -1637,6 +1637,11 @@ export const itIT: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Reimposta la password', subtitle: 'Inserisci la tua password per continuare.', @@ -1787,6 +1792,10 @@ export const itIT: LocalizationResource = { subtitle: 'Usa una passkey per un accesso più sicuro e rapido.', title: 'Autenticazione tramite passkey', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Usa un altro metodo', subtitle: 'per continuare su {{applicationName}}', diff --git a/packages/localizations/src/ja-JP.ts b/packages/localizations/src/ja-JP.ts index 1fcea5854dc..e0298371964 100644 --- a/packages/localizations/src/ja-JP.ts +++ b/packages/localizations/src/ja-JP.ts @@ -1638,6 +1638,11 @@ export const jaJP: LocalizationResource = { 'パスキーを使用すると、ご本人であることを確認できます。デバイスから指紋認証、顔認証、または画面ロックの解除を求められる場合があります。', title: 'パスキーを使用する', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: '別の方法を使用', subtitle: '続行するには現在のパスワードを入力してください', @@ -1789,6 +1794,10 @@ export const jaJP: LocalizationResource = { 'パスキーを使用すると、ご本人であることを確認できます。デバイスから指紋認証、顔認証、または画面ロックの解除を求められる場合があります。', title: 'パスキーを使用する', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: '別の方法を使用', subtitle: 'アカウントに関連付けられたパスワードを入力してください', diff --git a/packages/localizations/src/kk-KZ.ts b/packages/localizations/src/kk-KZ.ts index 70735f967e9..8a51cb18eca 100644 --- a/packages/localizations/src/kk-KZ.ts +++ b/packages/localizations/src/kk-KZ.ts @@ -1621,6 +1621,11 @@ export const kkKZ: LocalizationResource = { 'Құпия кілт арқылы сіздің тұлғаңыз расталады. Құрылғыңыз саусақ ізі, бет бейнесі немесе экран құлыптамасын сұрауы мүмкін.', title: 'Құпия кілт қолдану', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Басқа әдісті қолдану', subtitle: 'Жалғастыру үшін құпия сөзді енгізіңіз', @@ -1771,6 +1776,10 @@ export const kkKZ: LocalizationResource = { 'Құпия кілт арқылы тұлғаңыз расталады. Құрылғыңыз саусақ ізі, бет бейнесі немесе экран құлыптамасын сұрауы мүмкін.', title: 'Құпия кілт қолдану', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Басқа әдісті қолдану', subtitle: 'Есептік жазбаңыздың құпия сөзін енгізіңіз', diff --git a/packages/localizations/src/ko-KR.ts b/packages/localizations/src/ko-KR.ts index 78896999f98..abec99a1dff 100644 --- a/packages/localizations/src/ko-KR.ts +++ b/packages/localizations/src/ko-KR.ts @@ -1626,6 +1626,11 @@ export const koKR: LocalizationResource = { subtitle: '패스키로 신원을 확인해요. 기기에서 지문, 얼굴 또는 화면 잠금을 요청할 수 있어요.', title: '패스키 사용', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: '다른 방법 사용하기', subtitle: '계속하려면 현재 비밀번호를 입력하세요', @@ -1774,6 +1779,10 @@ export const koKR: LocalizationResource = { subtitle: '패스키로 로그인하면 본인 확인이 돼요. 기기에서 지문, 얼굴 또는 화면 잠금을 요청할 수 있어요.', title: '패스키 사용', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: '다른 방법 사용하기', subtitle: '계정에 등록된 비밀번호를 입력해 주세요', diff --git a/packages/localizations/src/mn-MN.ts b/packages/localizations/src/mn-MN.ts index c1e2bed88b7..64c73cef2a1 100644 --- a/packages/localizations/src/mn-MN.ts +++ b/packages/localizations/src/mn-MN.ts @@ -1630,6 +1630,11 @@ export const mnMN: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: undefined, subtitle: undefined, @@ -1781,6 +1786,10 @@ export const mnMN: LocalizationResource = { 'Passkey-ээ ашигласнаар таныг мөн болохыг баталгаажуулна. Таны төхөөрөмж хурууны хээ, нүүр эсвэл дэлгэцийн түгжээг асууж магадгүй.', title: 'Passkey ашиглана уу', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Өөр аргыг ашигла', subtitle: 'Бүртгэлтэй холбоотой нууц үгээ оруулна уу', diff --git a/packages/localizations/src/ms-MY.ts b/packages/localizations/src/ms-MY.ts index 016bc8c0743..99c926f3155 100644 --- a/packages/localizations/src/ms-MY.ts +++ b/packages/localizations/src/ms-MY.ts @@ -1641,6 +1641,11 @@ export const msMY: LocalizationResource = { 'Menggunakan kunci pas anda mengesahkan identiti anda. Peranti anda mungkin meminta cap jari, wajah, atau kunci skrin anda.', title: 'Gunakan kunci pas anda', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Gunakan kaedah lain', subtitle: 'Masukkan kata laluan semasa anda untuk meneruskan', @@ -1793,6 +1798,10 @@ export const msMY: LocalizationResource = { 'Menggunakan kunci pas anda mengesahkan bahawa itu adalah anda. Peranti anda mungkin meminta cap jari, wajah atau kunci skrin anda.', title: 'Gunakan kunci pas anda', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Gunakan kaedah lain', subtitle: 'Masukkan kata laluan yang berkaitan dengan akaun anda', diff --git a/packages/localizations/src/nb-NO.ts b/packages/localizations/src/nb-NO.ts index 384b98fdd7b..0ebbf9b3315 100644 --- a/packages/localizations/src/nb-NO.ts +++ b/packages/localizations/src/nb-NO.ts @@ -1640,6 +1640,11 @@ export const nbNO: LocalizationResource = { 'Bruk av passnøkkelen bekrefter identiteten din. Enheten din kan be om fingeravtrykk, ansiktsgjenkjenning eller skjermlås.', title: 'Bruk passnøkkelen din', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Bruk en annen metode', subtitle: 'Skriv inn ditt nåværende passord for å fortsette', @@ -1791,6 +1796,10 @@ export const nbNO: LocalizationResource = { 'Bruk av passnøkkelen bekrefter at det er deg. Enheten din kan be om fingeravtrykk, ansiktsgjenkjenning eller skjermlås.', title: 'Bruk passnøkkelen din', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Bruk en annen metode', subtitle: 'for å fortsette til {{applicationName}}', diff --git a/packages/localizations/src/nl-BE.ts b/packages/localizations/src/nl-BE.ts index 1bf581ef397..8ceaa13c70a 100644 --- a/packages/localizations/src/nl-BE.ts +++ b/packages/localizations/src/nl-BE.ts @@ -1631,6 +1631,11 @@ export const nlBE: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Gebruik een andere methode', subtitle: 'Voer het wachtwoord in dat bij je account hoort', @@ -1780,6 +1785,10 @@ export const nlBE: LocalizationResource = { subtitle: 'Gebruik je toegangssleutel voor authenticatie.', title: 'Authenticatie met toegangssleutel', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Gebruik een andere methode', subtitle: 'om door te gaan naar {{applicationName}}', diff --git a/packages/localizations/src/nl-NL.ts b/packages/localizations/src/nl-NL.ts index 2349d69abe6..762a4f68899 100644 --- a/packages/localizations/src/nl-NL.ts +++ b/packages/localizations/src/nl-NL.ts @@ -1631,6 +1631,11 @@ export const nlNL: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Gebruik een andere methode', subtitle: 'Voer het wachtwoord in dat bij je account hoort', @@ -1780,6 +1785,10 @@ export const nlNL: LocalizationResource = { subtitle: 'Gebruik je toegangssleutel voor authenticatie.', title: 'Authenticatie met toegangssleutel', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Gebruik een andere methode', subtitle: 'om door te gaan naar {{applicationName}}', diff --git a/packages/localizations/src/pl-PL.ts b/packages/localizations/src/pl-PL.ts index c6f75646e70..19eed5d20d3 100644 --- a/packages/localizations/src/pl-PL.ts +++ b/packages/localizations/src/pl-PL.ts @@ -1629,6 +1629,11 @@ export const plPL: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Użyj innej metody', subtitle: 'Wprowadź hasło, aby kontynuować', @@ -1781,6 +1786,10 @@ export const plPL: LocalizationResource = { 'Użycie klucza dostępu potwierdza, że to Ty. Urządzenie może poprosić o twój odcisk palca, twarz lub blokadę ekranu.', title: 'Użyj swojego klucza dostępowego', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Użyj innego sposobu', subtitle: 'aby kontynuować w {{applicationName}}', diff --git a/packages/localizations/src/pt-BR.ts b/packages/localizations/src/pt-BR.ts index 7138b2f1e6f..0b265682603 100644 --- a/packages/localizations/src/pt-BR.ts +++ b/packages/localizations/src/pt-BR.ts @@ -1639,6 +1639,11 @@ export const ptBR: LocalizationResource = { 'Usar sua chave de acesso confirma a sua identidade. Seu dispositivo pode solicitar sua impressão digital, reconhecimento facial ou PIN.', title: 'Use sua chave de acesso.', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Usar outro método', subtitle: 'Digite sua senha para continuar.', @@ -1790,6 +1795,10 @@ export const ptBR: LocalizationResource = { 'Usar sua chave de acesso confirma a sua identidade. Seu dispositivo pode solicitar sua impressão digital, reconhecimento facial ou PIN.', title: 'Use sua chave de acesso.', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Utilize outro método', subtitle: 'para continuar em {{applicationName}}', diff --git a/packages/localizations/src/pt-PT.ts b/packages/localizations/src/pt-PT.ts index f0659aa1e63..72e81ceabb0 100644 --- a/packages/localizations/src/pt-PT.ts +++ b/packages/localizations/src/pt-PT.ts @@ -1640,6 +1640,11 @@ export const ptPT: LocalizationResource = { 'A utilização da sua chave de acesso confirma a sua identidade. O dispositivo pode pedir a sua impressão digital, rosto ou bloqueio de ecrã.', title: 'Utilizar a sua chave de acesso', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Utilizar outro método', subtitle: 'Introduza a sua palavra-passe atual para continuar', @@ -1790,6 +1795,10 @@ export const ptPT: LocalizationResource = { subtitle: 'Utilize a sua chave de acesso para autenticação.', title: 'Autenticação com Chave de Acesso', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Utilize outro método', subtitle: 'para continuar em {{applicationName}}', diff --git a/packages/localizations/src/ro-RO.ts b/packages/localizations/src/ro-RO.ts index e9000c9055a..d8515f1b2ae 100644 --- a/packages/localizations/src/ro-RO.ts +++ b/packages/localizations/src/ro-RO.ts @@ -1640,6 +1640,11 @@ export const roRO: LocalizationResource = { 'Folosirea cheii de acces confirmă identitatea ta. Dispozitivul îți poate cere amprenta, fața sau codul de ecran.', title: 'Folosește cheia de acces', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Folosește altă metodă', subtitle: 'Introdu parola curentă pentru a continua', @@ -1792,6 +1797,10 @@ export const roRO: LocalizationResource = { 'Folosirea cheii de acces confirmă că ești tu. Dispozitivul îți poate cere amprenta, fața sau blocarea ecranului.', title: 'Folosește cheia de acces', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Folosește altă metodă', subtitle: 'Introdu parola asociată contului tău', diff --git a/packages/localizations/src/ru-RU.ts b/packages/localizations/src/ru-RU.ts index 73ba47d5a48..3af75fb03eb 100644 --- a/packages/localizations/src/ru-RU.ts +++ b/packages/localizations/src/ru-RU.ts @@ -1636,6 +1636,11 @@ export const ruRU: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Используйте другой метод', subtitle: 'Введите пароль, связанный с вашей учетной записью', @@ -1788,6 +1793,10 @@ export const ruRU: LocalizationResource = { 'Использование вашего ключа доступа подтверждает вашу личность. Ваше устройство может запросить ваш отпечаток пальца, лицо или блокировку экрана.', title: 'Используйте ваш ключ доступа', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Использовать другой метод', subtitle: 'чтобы продолжить работу в "{{applicationName}}"', diff --git a/packages/localizations/src/sk-SK.ts b/packages/localizations/src/sk-SK.ts index 025e874ff30..1dcc2f679d6 100644 --- a/packages/localizations/src/sk-SK.ts +++ b/packages/localizations/src/sk-SK.ts @@ -1630,6 +1630,11 @@ export const skSK: LocalizationResource = { 'Použitie passkey potvrdí vašu identitu. Vaše zariadenie vás môže vyzvať na potvrdenie odtlačkom, tvárou alebo kódom.', title: 'Použiť Passkey', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Použiť inú metódu', subtitle: 'Pre pokračovanie vložte svoje aktuálne heslo', @@ -1781,6 +1786,10 @@ export const skSK: LocalizationResource = { 'Použitie passkey potvrdí vašu identitu. Vaše zariadenie vás môže vyzvať na potvrdenie odtlačkom, tvárou alebo kódom.', title: 'Prihlásiť sa pomocou Passkey', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Použiť inú metódu', subtitle: 'pre pokračovanie do {{applicationName}}', diff --git a/packages/localizations/src/sr-RS.ts b/packages/localizations/src/sr-RS.ts index 14e8a094adf..77c45b2f883 100644 --- a/packages/localizations/src/sr-RS.ts +++ b/packages/localizations/src/sr-RS.ts @@ -1627,6 +1627,11 @@ export const srRS: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: undefined, subtitle: undefined, @@ -1778,6 +1783,10 @@ export const srRS: LocalizationResource = { 'Korišćenje tvojeg ključa za prolaz potvrđuje da si to ti. Tvoj uređaj može zatražiti otisak prsta, lice ili ekran zaključavanja.', title: 'Koristi svoj ključ za prolaz', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Koristi drugu metodu', subtitle: 'Unesi lozinku koja je povezana sa tvojim nalogom', diff --git a/packages/localizations/src/sv-SE.ts b/packages/localizations/src/sv-SE.ts index f411009e4f3..cec06c14be5 100644 --- a/packages/localizations/src/sv-SE.ts +++ b/packages/localizations/src/sv-SE.ts @@ -1629,6 +1629,11 @@ export const svSE: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Använd en annan metod', subtitle: 'Ange lösenordet som är kopplat till ditt konto', @@ -1781,6 +1786,10 @@ export const svSE: LocalizationResource = { 'Att använda din passkey bekräftar att det är du. Din enhet kan be om ditt fingeravtryck, ansikte eller skärmlås.', title: 'Använd din passkey', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Använd en annan metod', subtitle: 'för att fortsätta till {{applicationName}}', diff --git a/packages/localizations/src/ta-IN.ts b/packages/localizations/src/ta-IN.ts index 076f86f9873..5dbb4287d9c 100644 --- a/packages/localizations/src/ta-IN.ts +++ b/packages/localizations/src/ta-IN.ts @@ -1643,6 +1643,11 @@ export const taIN: LocalizationResource = { 'உங்கள் பாஸ்கீயைப் பயன்படுத்துவது உங்கள் அடையாளத்தை உறுதிப்படுத்துகிறது. உங்கள் சாதனம் உங்கள் கைரேகை, முகம் அல்லது திரை பூட்டைக் கேட்கலாம்.', title: 'உங்கள் பாஸ்கீயைப் பயன்படுத்தவும்', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'வேறு முறையைப் பயன்படுத்துங்கள்', subtitle: 'தொடர உங்கள் தற்போதைய கடவுச்சொல்லை உள்ளிடவும்', @@ -1794,6 +1799,10 @@ export const taIN: LocalizationResource = { 'உங்கள் பாஸ்கீயைப் பயன்படுத்துவது நீங்கள் தான் என்பதை உறுதிப்படுத்துகிறது. உங்கள் சாதனம் உங்கள் கைரேகை, முகம் அல்லது திரை பூட்டைக் கேட்கலாம்.', title: 'உங்கள் பாஸ்கீயைப் பயன்படுத்தவும்', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'வேறு முறையைப் பயன்படுத்துங்கள்', subtitle: 'உங்கள் கணக்குடன் தொடர்புடைய கடவுச்சொல்லை உள்ளிடவும்', diff --git a/packages/localizations/src/te-IN.ts b/packages/localizations/src/te-IN.ts index 8a4c8d6f7a2..341a072a8c1 100644 --- a/packages/localizations/src/te-IN.ts +++ b/packages/localizations/src/te-IN.ts @@ -1640,6 +1640,11 @@ export const teIN: LocalizationResource = { 'మీ పాస్‌కీని ఉపయోగించడం మీ గుర్తింపును నిర్ధారిస్తుంది. మీ పరికరం మీ వేలిముద్ర, ముఖం లేదా స్క్రీన్ లాక్ కోసం అడగవచ్చు.', title: 'మీ పాస్‌కీని ఉపయోగించండి', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'మరొక పద్ధతిని ఉపయోగించండి', subtitle: 'కొనసాగించడానికి మీ ప్రస్తుత పాస్‌వర్డ్‌ను నమోదు చేయండి', @@ -1791,6 +1796,10 @@ export const teIN: LocalizationResource = { 'మీ పాస్‌కీని ఉపయోగించడం అది మీరని నిర్ధారిస్తుంది. మీ పరికరం మీ వేలిముద్ర, ముఖం లేదా స్క్రీన్ లాక్ కోసం అడగవచ్చు.', title: 'మీ పాస్‌కీని ఉపయోగించండి', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'మరొక పద్ధతిని ఉపయోగించండి', subtitle: 'మీ ఖాతాతో సంబంధం ఉన్న పాస్‌వర్డ్‌ను నమోదు చేయండి', diff --git a/packages/localizations/src/th-TH.ts b/packages/localizations/src/th-TH.ts index eebb4436919..51dcfa5b0e9 100644 --- a/packages/localizations/src/th-TH.ts +++ b/packages/localizations/src/th-TH.ts @@ -1629,6 +1629,11 @@ export const thTH: LocalizationResource = { subtitle: 'การใช้พาสคีย์ของคุณยืนยันตัวตนของคุณ อุปกรณ์ของคุณอาจขอลายนิ้วมือ ใบหน้า หรือการล็อคหน้าจอ', title: 'ใช้พาสคีย์ของคุณ', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'ใช้วิธีอื่น', subtitle: 'ใส่รหัสผ่านปัจจุบันของคุณเพื่อดำเนินการต่อ', @@ -1779,6 +1784,10 @@ export const thTH: LocalizationResource = { subtitle: 'การใช้พาสคีย์ของคุณยืนยันว่าเป็นคุณ อุปกรณ์ของคุณอาจขอลายนิ้วมือ ใบหน้า หรือการล็อคหน้าจอ', title: 'ใช้พาสคีย์ของคุณ', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'ใช้วิธีอื่น', subtitle: 'ใส่รหัสผ่านที่เชื่อมโยงกับบัญชีของคุณ', diff --git a/packages/localizations/src/tr-TR.ts b/packages/localizations/src/tr-TR.ts index e840c7c2b2c..50d01c57240 100644 --- a/packages/localizations/src/tr-TR.ts +++ b/packages/localizations/src/tr-TR.ts @@ -1628,6 +1628,11 @@ export const trTR: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Şifremi unuttum', subtitle: 'Şifrenizi girerek devam edebilirsiniz.', @@ -1780,6 +1785,10 @@ export const trTR: LocalizationResource = { 'Geçiş anahtarınızı kullanarak siz olduğunuzu onaylayın. Cihazınız parmak izinizi, yüzünüzü veya ekran kilidinizi isteyebilir.', title: 'Geçiş anahtarınızı kullanın', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Başka bir yöntem kullan', subtitle: '{{applicationName}} ile devam etmek için', diff --git a/packages/localizations/src/uk-UA.ts b/packages/localizations/src/uk-UA.ts index bd77cc5dcd0..9125e2b878c 100644 --- a/packages/localizations/src/uk-UA.ts +++ b/packages/localizations/src/uk-UA.ts @@ -1627,6 +1627,11 @@ export const ukUA: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: undefined, subtitle: undefined, @@ -1777,6 +1782,10 @@ export const ukUA: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Використати інший метод', subtitle: 'щоб продовжити роботу в "{{applicationName}}"', diff --git a/packages/localizations/src/vi-VN.ts b/packages/localizations/src/vi-VN.ts index 35e6662dc65..1b5a87abe44 100644 --- a/packages/localizations/src/vi-VN.ts +++ b/packages/localizations/src/vi-VN.ts @@ -1637,6 +1637,11 @@ export const viVN: LocalizationResource = { 'Sử dụng mã passkey xác minh danh tính của bạn. Thiết bị của bạn có thể yêu cầu vân tay, khuôn mặt hoặc khóa màn hình.', title: 'Sử dụng mã passkey', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Sử dụng phương thức khác', subtitle: 'Nhập mật khẩu hiện tại để tiếp tục', @@ -1788,6 +1793,10 @@ export const viVN: LocalizationResource = { 'Sử dụng mã passkey xác minh danh tính của bạn. Thiết bị của bạn có thể yêu cầu vân tay, khuôn mặt hoặc khóa màn hình.', title: 'Sử dụng mã passkey', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: 'Sử dụng phương thức khác', subtitle: 'Nhập mật khẩu được liên kết với tài khoản của bạn', diff --git a/packages/localizations/src/zh-CN.ts b/packages/localizations/src/zh-CN.ts index 7ea16a28b01..3e65172457b 100644 --- a/packages/localizations/src/zh-CN.ts +++ b/packages/localizations/src/zh-CN.ts @@ -1618,6 +1618,11 @@ export const zhCN: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: undefined, subtitle: undefined, @@ -1766,6 +1771,10 @@ export const zhCN: LocalizationResource = { subtitle: undefined, title: undefined, }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: '使用其他方法', subtitle: '继续使用 {{applicationName}}', diff --git a/packages/localizations/src/zh-TW.ts b/packages/localizations/src/zh-TW.ts index ee115cb297f..3d905a96077 100644 --- a/packages/localizations/src/zh-TW.ts +++ b/packages/localizations/src/zh-TW.ts @@ -1621,6 +1621,11 @@ export const zhTW: LocalizationResource = { subtitle: '使用您的金鑰確認您的身分。您的裝置可能會要求您的指紋、臉部或螢幕鎖。', title: '使用您的金鑰', }, + passkeyMfa: { + blockButton__passkey: undefined, + subtitle: undefined, + title: undefined, + }, password: { actionLink: '使用其他方式', subtitle: '請輸入您的密碼以繼續', @@ -1769,6 +1774,10 @@ export const zhTW: LocalizationResource = { subtitle: '使用您的金鑰確認您的身分。您的裝置可能會要求您的指紋、臉部或螢幕鎖。', title: '使用您的金鑰', }, + passkeyMfa: { + subtitle: undefined, + title: undefined, + }, password: { actionLink: '使用其他方式', subtitle: '以繼續前往 {{applicationName}}', diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index d8bb23c6423..e9447eb3d75 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -252,6 +252,7 @@ export class StateProxy implements State { 'verifyEmailCode', 'verifyTOTP', 'verifyBackupCode', + 'passkey', ] as const), ticket: this.gateMethod(target, 'ticket'), passkey: this.gateMethod(target, 'passkey'), diff --git a/packages/shared/src/internal/clerk-js/constants.ts b/packages/shared/src/internal/clerk-js/constants.ts index b87fbaa578f..8752e064a5b 100644 --- a/packages/shared/src/internal/clerk-js/constants.ts +++ b/packages/shared/src/internal/clerk-js/constants.ts @@ -67,7 +67,7 @@ export const SIGN_UP_MODES = { } satisfies Record; // This is the currently supported version of the Frontend API -export const SUPPORTED_FAPI_VERSION = '2026-05-12'; +export const SUPPORTED_FAPI_VERSION = '2026-08-20'; export const CAPTCHA_ELEMENT_ID = 'clerk-captcha'; export const CAPTCHA_INVISIBLE_CLASSNAME = 'clerk-invisible-captcha'; diff --git a/packages/shared/src/internal/clerk-js/errors.ts b/packages/shared/src/internal/clerk-js/errors.ts index 77345b56d3e..8242f06d8bb 100644 --- a/packages/shared/src/internal/clerk-js/errors.ts +++ b/packages/shared/src/internal/clerk-js/errors.ts @@ -102,6 +102,15 @@ export function clerkInvalidStrategy(functionaName: string, strategy: string): n throw new Error(`${errorPrefix} Strategy "${strategy}" is not a valid strategy for ${functionaName}.`); } +/** + * + */ +export function clerkInvalidVerificationLevel(functionName: string, level: string): never { + throw new Error( + `${errorPrefix} "${level}" is not a valid verification level for ${functionName}. Use 'first_factor' or 'second_factor'.`, + ); +} + /** * */ diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 78e90a86b4f..446d328a8e5 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -588,6 +588,10 @@ export type __internal_LocalizationResource = { title: LocalizationValue; subtitle: LocalizationValue; }; + passkeyMfa: { + title: LocalizationValue; + subtitle: LocalizationValue; + }; alternativeMethods: { title: LocalizationValue; subtitle: LocalizationValue; @@ -682,6 +686,11 @@ export type __internal_LocalizationResource = { subtitle: LocalizationValue; blockButton__passkey: LocalizationValue; }; + passkeyMfa: { + title: LocalizationValue; + subtitle: LocalizationValue; + blockButton__passkey: LocalizationValue; + }; alternativeMethods: { title: LocalizationValue; subtitle: LocalizationValue; diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index a492a586be0..77362fb96d2 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -345,9 +345,15 @@ export interface SessionResource extends ClerkResource { ) => Promise; /** * Initiates a verification flow using passkeys. + * + * By default the passkey verifies the first factor. Pass + * `{ level: 'second_factor' }` to satisfy the second factor of an + * in-progress multi-factor reverification instead. Any other `level` + * value is rejected. Throws a `ClerkWebAuthnError` when WebAuthn is + * unsupported or the passkey ceremony fails. * @returns A [`SessionVerification`](https://clerk.com/docs/reference/types/session-verification) instance with its status and supported factors. */ - verifyWithPasskey: () => Promise; + verifyWithPasskey: (params?: SessionVerifyWithPasskeyParams) => Promise; __internal_toSnapshot: () => SessionJSONSnapshot; __internal_touch: (params?: SessionTouchParams) => Promise; } @@ -552,6 +558,7 @@ export type SessionVerifyAttemptFirstFactorParams = export type SessionVerifyPrepareSecondFactorParams = | PhoneCodeSecondFactorConfig + | PassKeyConfig /** * @experimental */ @@ -560,7 +567,17 @@ export type SessionVerifyAttemptSecondFactorParams = | PhoneCodeAttempt | TOTPAttempt | BackupCodeAttempt + | PasskeyAttempt /** * @experimental */ | TrustedDeviceAttempt; + +export type SessionVerifyWithPasskeyParams = { + /** + * Which factor of the reverification the passkey should verify. Defaults + * to `'first_factor'`; pass `'second_factor'` to satisfy the second + * factor of an in-progress multi-factor reverification. + */ + level?: 'first_factor' | 'second_factor'; +}; diff --git a/packages/shared/src/types/sessionVerification.ts b/packages/shared/src/types/sessionVerification.ts index 61af637ce2b..bcbd347d5fd 100644 --- a/packages/shared/src/types/sessionVerification.ts +++ b/packages/shared/src/types/sessionVerification.ts @@ -59,4 +59,4 @@ export type SessionVerificationFirstFactor = * @experimental */ | EnterpriseSSOFactor; -export type SessionVerificationSecondFactor = PhoneCodeFactor | TOTPFactor | BackupCodeFactor; +export type SessionVerificationSecondFactor = PhoneCodeFactor | TOTPFactor | BackupCodeFactor | PasskeyFactor; diff --git a/packages/shared/src/types/signInCommon.ts b/packages/shared/src/types/signInCommon.ts index 922686321bc..46c8f8133ab 100644 --- a/packages/shared/src/types/signInCommon.ts +++ b/packages/shared/src/types/signInCommon.ts @@ -86,7 +86,13 @@ export type SignInFirstFactor = | OauthFactor | EnterpriseSSOFactor; -export type SignInSecondFactor = PhoneCodeFactor | TOTPFactor | BackupCodeFactor | EmailCodeFactor | EmailLinkFactor; +export type SignInSecondFactor = + | PhoneCodeFactor + | TOTPFactor + | BackupCodeFactor + | EmailCodeFactor + | EmailLinkFactor + | PasskeyFactor; export interface UserData { firstName?: string; @@ -118,9 +124,18 @@ export type AttemptFirstFactorParams = | ResetPasswordEmailCodeAttempt | TrustedDeviceAttempt; -export type PrepareSecondFactorParams = PhoneCodeSecondFactorConfig | EmailCodeSecondFactorConfig | EmailLinkConfig; +export type PrepareSecondFactorParams = + | PhoneCodeSecondFactorConfig + | EmailCodeSecondFactorConfig + | EmailLinkConfig + | PassKeyConfig; -export type AttemptSecondFactorParams = PhoneCodeAttempt | TOTPAttempt | BackupCodeAttempt | EmailCodeAttempt; +export type AttemptSecondFactorParams = + | PhoneCodeAttempt + | TOTPAttempt + | BackupCodeAttempt + | EmailCodeAttempt + | PasskeyAttempt; export type SignInCreateParams = ( | { @@ -191,6 +206,16 @@ export type ResetPasswordParams = { }; export type AuthenticateWithPasskeyParams = { + /** + * The passkey flow to use when the passkey is the FIRST factor: + * `'autofill'` (conditional UI) or `'discoverable'` both create a new + * sign-in and identify the user from the passkey itself, discarding any + * in-progress sign-in. + * + * Omit it to complete the second factor of a sign-in in the + * `needs_second_factor` (or `needs_client_trust`) status that offers + * `passkey` among its supported second factors. + */ flow?: 'autofill' | 'discoverable'; }; diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index fe1ff3d48eb..65c2d6354d5 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -362,7 +362,7 @@ export interface SignInFutureResource { *
  • `'needs_client_trust'` - The user is signing in from a new device and must complete a [second factor verification](!second-factor-verification) to establish [Device Trust](https://clerk.com/docs/guides/secure/device-trust). See the [Device Trust custom flow guide](https://clerk.com/docs/guides/development/custom-flows/authentication/device-trust) for more information.
  • *
  • `'needs_identifier'` - The user's identifier (e.g., email address, phone number, username) hasn't been provided.
  • *
  • `'needs_first_factor'` - One of the following [first factor verification](!first-factor-verification) strategies is missing: `'email_link'`, `'email_code'`, `passkey`, `password`, `'phone_code'`, `'web3_base_signature'`, `'web3_metamask_signature'`, `'web3_coinbase_wallet_signature'`, `'web3_okx_wallet_signature'`, `'web3_solana_signature'`, [`OAuthStrategy`](https://clerk.com/docs/reference/types/sso#oauthstrategy), or `'enterprise_sso'`.
  • - *
  • `'needs_second_factor'` - One of the following [second factor verification](!second-factor-verification) strategies is missing: `'phone_code'`, `'totp'`, `'backup_code'`, `'email_code'`, or `'email_link'`.
  • + *
  • `'needs_second_factor'` - One of the following [second factor verification](!second-factor-verification) strategies is missing: `'phone_code'`, `'totp'`, `'backup_code'`, `'email_code'`, `'email_link'`, or `'passkey'`.
  • *
  • `'needs_new_password'` - The user needs to set a new password. See the [dedicated custom flow](/docs/guides/development/custom-flows/authentication/forgot-password) guide for more information.
  • *
  • `'needs_protect_check'` - A Clerk Protect challenge must be resolved before the sign-in can continue. This status is only returned when Protect mid-flow challenges are explicitly enabled for the instance; upgrading the SDK alone does not enable it. When Clerk's UI is loaded, the sign-in methods resolve the challenge in a modal before they return. Otherwise, run the challenge described by `protectCheck` and resolve it via `submitProtectCheck()`. When `sso()` redirects to the identity provider, the challenge runs on the way back through `` instead. The pre-built components handle this automatically.
  • * @@ -585,6 +585,14 @@ export interface SignInFutureResource { * Verifies a backup code to sign in with as a second factor. */ verifyBackupCode: (params: SignInFutureBackupCodeVerifyParams) => Promise<{ error: ClerkError | null }>; + + /** + * Uses a passkey to sign in with as a second factor. Only available when + * the instance allows passkeys to satisfy the second factor and the user + * has a registered passkey (`passkey` appears in + * `supportedSecondFactors`). + */ + passkey: () => Promise<{ error: ClerkError | null }>; }; /** diff --git a/packages/ui/src/components/SignIn/SignInClientTrust.tsx b/packages/ui/src/components/SignIn/SignInClientTrust.tsx index aa14fd77fb1..6a44b0d46de 100644 --- a/packages/ui/src/components/SignIn/SignInClientTrust.tsx +++ b/packages/ui/src/components/SignIn/SignInClientTrust.tsx @@ -3,11 +3,14 @@ import { LoadingCard } from '@/ui/elements/LoadingCard'; import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common'; import { useCoreSignIn } from '../../contexts'; +import { useIsWebAuthnSupported } from '../../hooks'; import { SignInFactorTwoAlternativeMethods } from './SignInFactorTwoAlternativeMethods'; import { SignInFactorTwoEmailCodeCard } from './SignInFactorTwoEmailCodeCard'; import { SignInFactorTwoEmailLinkCard } from './SignInFactorTwoEmailLinkCard'; +import { SignInFactorTwoPasskeyCard } from './SignInFactorTwoPasskeyCard'; import { SignInFactorTwoPhoneCodeCard } from './SignInFactorTwoPhoneCodeCard'; import { useSecondFactorSelection } from './useSecondFactorSelection'; +import { isOfferableSecondFactor } from './utils'; function SignInClientTrustInternal(): JSX.Element { const signIn = useCoreSignIn(); @@ -19,8 +22,11 @@ function SignInClientTrustInternal(): JSX.Element { showAllStrategies, toggleAllStrategies, } = useSecondFactorSelection(signIn.supportedSecondFactors); + const webAuthnSupported = useIsWebAuthnSupported(); const onShowAlternativeMethodsClicked = - signIn.supportedSecondFactors && signIn.supportedSecondFactors.length > 1 ? toggleAllStrategies : undefined; + (signIn.supportedSecondFactors?.filter(f => isOfferableSecondFactor(f, webAuthnSupported)).length ?? 0) > 1 + ? toggleAllStrategies + : undefined; if (!currentFactor) { return ; @@ -66,6 +72,8 @@ function SignInClientTrustInternal(): JSX.Element { onShowAlternativeMethodsClicked={onShowAlternativeMethodsClicked} /> ); + case 'passkey': + return ; default: return ; } diff --git a/packages/ui/src/components/SignIn/SignInFactorOne.tsx b/packages/ui/src/components/SignIn/SignInFactorOne.tsx index a2dd6ee994b..a6d25b6f7d3 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOne.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOne.tsx @@ -8,6 +8,7 @@ import { LoadingCard } from '@/ui/elements/LoadingCard'; import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common'; import { useCoreSignIn, useEnvironment } from '../../contexts'; +import { useIsWebAuthnSupported } from '../../hooks'; import { useAlternativeStrategies } from '../../hooks/useAlternativeStrategies'; import { localizationKeys } from '../../localization'; import { useRouter } from '../../router'; @@ -79,6 +80,7 @@ function SignInFactorOneInternal(): JSX.Element { const { __internal_setActiveInProgress } = useClerk(); const signIn = useCoreSignIn(); const { preferredSignInStrategy } = useEnvironment().displayConfig; + const webAuthnSupported = useIsWebAuthnSupported(); const availableFactors = signIn.supportedFirstFactors; const router = useRouter(); const card = useCardState(); @@ -89,7 +91,12 @@ function SignInFactorOneInternal(): JSX.Element { currentFactor: SignInFactor | undefined | null; prevCurrentFactor: SignInFactor | undefined | null; }>(() => { - const factor = determineStartingSignInFactor(availableFactors, signIn.identifier, preferredSignInStrategy); + const factor = determineStartingSignInFactor( + availableFactors, + signIn.identifier, + preferredSignInStrategy, + webAuthnSupported, + ); if ( factor?.strategy === 'phone_code' && !!firstFactorVerification.channel && diff --git a/packages/ui/src/components/SignIn/SignInFactorTwo.tsx b/packages/ui/src/components/SignIn/SignInFactorTwo.tsx index 1af218d771c..7d5efe22944 100644 --- a/packages/ui/src/components/SignIn/SignInFactorTwo.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorTwo.tsx @@ -6,14 +6,17 @@ import { LoadingCard } from '@/ui/elements/LoadingCard'; import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common'; import { useCoreSignIn, useSignInContext } from '../../contexts'; +import { useIsWebAuthnSupported } from '../../hooks'; import { useRouter } from '../../router'; import { SignInFactorTwoAlternativeMethods } from './SignInFactorTwoAlternativeMethods'; import { SignInFactorTwoBackupCodeCard } from './SignInFactorTwoBackupCodeCard'; import { SignInFactorTwoEmailCodeCard } from './SignInFactorTwoEmailCodeCard'; import { SignInFactorTwoEmailLinkCard } from './SignInFactorTwoEmailLinkCard'; +import { SignInFactorTwoPasskeyCard } from './SignInFactorTwoPasskeyCard'; import { SignInFactorTwoPhoneCodeCard } from './SignInFactorTwoPhoneCodeCard'; import { SignInFactorTwoTOTPCard } from './SignInFactorTwoTOTPCard'; import { useSecondFactorSelection } from './useSecondFactorSelection'; +import { isOfferableSecondFactor } from './utils'; function SignInFactorTwoInternal(): JSX.Element { const clerk = useClerk(); @@ -28,8 +31,11 @@ function SignInFactorTwoInternal(): JSX.Element { showAllStrategies, toggleAllStrategies, } = useSecondFactorSelection(signIn.supportedSecondFactors); + const webAuthnSupported = useIsWebAuthnSupported(); const onShowAlternativeMethodsClicked = - signIn.supportedSecondFactors && signIn.supportedSecondFactors.length > 1 ? toggleAllStrategies : undefined; + (signIn.supportedSecondFactors?.filter(f => isOfferableSecondFactor(f, webAuthnSupported)).length ?? 0) > 1 + ? toggleAllStrategies + : undefined; React.useEffect(() => { if (clerk.__internal_setActiveInProgress) { @@ -85,6 +91,8 @@ function SignInFactorTwoInternal(): JSX.Element { ); case 'backup_code': return ; + case 'passkey': + return ; case 'email_code': return ( @@ -56,17 +59,20 @@ const AlternativeMethodsList = (props: AlternativeMethodsProps & { onHavingTroub > {supportedSecondFactors && - supportedSecondFactors.sort(backupCodePrefFactorComparator).map((factor, i) => ( - onFactorSelected(factor)} - /> - ))} + supportedSecondFactors + .filter(factor => isOfferableSecondFactor(factor, webAuthnSupported)) + .sort(backupCodePrefFactorComparator) + .map((factor, i) => ( + onFactorSelected(factor)} + /> + ))} {onBackLinkClick && ( @@ -111,6 +117,8 @@ export function getButtonLabel(factor: SignInSecondFactor): LocalizationKey { return localizationKeys('signIn.alternativeMethods.blockButton__emailLink', { identifier: formatSafeIdentifier(factor.safeIdentifier) || '', }); + case 'passkey': + return localizationKeys('signIn.alternativeMethods.blockButton__passkey'); default: ((_: never) => _)(factor); throw new Error('Invalid sign in strategy'); diff --git a/packages/ui/src/components/SignIn/SignInFactorTwoPasskeyCard.tsx b/packages/ui/src/components/SignIn/SignInFactorTwoPasskeyCard.tsx new file mode 100644 index 00000000000..11d2d223422 --- /dev/null +++ b/packages/ui/src/components/SignIn/SignInFactorTwoPasskeyCard.tsx @@ -0,0 +1,59 @@ +import React from 'react'; + +import { Card } from '@/ui/elements/Card'; +import { useCardState } from '@/ui/elements/contexts'; +import { Form } from '@/ui/elements/Form'; +import { Header } from '@/ui/elements/Header'; + +import { descriptors, Flex, localizationKeys } from '../../customizables'; +import { useHandleAuthenticateWithPasskey } from './shared'; + +type SignInFactorTwoPasskeyCardProps = { + onShowAlternativeMethodsClicked?: React.MouseEventHandler; +}; + +export const SignInFactorTwoPasskeyCard = (props: SignInFactorTwoPasskeyCardProps) => { + const { onShowAlternativeMethodsClicked } = props; + const card = useCardState(); + // A successful attempt completes the sign-in, which the hook handles via + // setActive; the sign-in can't come back as needs_second_factor. + const authenticateWithPasskey = useHandleAuthenticateWithPasskey(() => Promise.resolve()); + + const handleSubmit: React.FormEventHandler = e => { + e.preventDefault(); + return authenticateWithPasskey(); + }; + + return ( + + + + + + + {card.error} + + + + + + {onShowAlternativeMethodsClicked && ( + + )} + + + + + + ); +}; diff --git a/packages/ui/src/components/SignIn/__tests__/SignInFactorTwo.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInFactorTwo.test.tsx index 287e934288c..f5b04763a77 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInFactorTwo.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorTwo.test.tsx @@ -3,7 +3,7 @@ import type { SignInResource } from '@clerk/shared/types'; import { describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; -import { render, screen, waitFor } from '@/test/utils'; +import { mockWebAuthn, render, screen, waitFor } from '@/test/utils'; import { SignInFactorTwo } from '../SignInFactorTwo'; @@ -404,6 +404,78 @@ describe('SignInFactorTwo', () => { }); }); }); + + describe('Passkey', () => { + it('falls back to the enrolled code factors when webauthn is not supported', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withPassword(); + f.startSignInFactorTwo({ + supportPhoneCode: false, + supportTotp: true, + supportPasskey: true, + }); + }); + fixtures.signIn.prepareSecondFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + render(, { wrapper }); + + expect(screen.getAllByTestId('otp-input-segment').length).toBe(6); + expect(screen.queryByText('Use your passkey')).not.toBeInTheDocument(); + }); + + mockWebAuthn(() => { + it('is the starting factor ahead of the enrolled code factors', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withPassword(); + f.startSignInFactorTwo({ + supportPhoneCode: true, + supportTotp: true, + supportPasskey: true, + }); + }); + fixtures.signIn.prepareSecondFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + render(, { wrapper }); + + await screen.findByText('Use your passkey'); + expect(screen.queryAllByTestId('otp-input-segment').length).toBe(0); + }); + + it('renders the passkey card when passkey is the only second factor', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withPassword(); + f.startSignInFactorTwo({ + supportPhoneCode: false, + supportPasskey: true, + }); + }); + render(, { wrapper }); + + await screen.findByText('Use your passkey'); + await screen.findByText( + "Using your passkey confirms it's you. Your device may ask for your fingerprint, face or screen lock.", + ); + }); + + it('calls authenticateWithPasskey when clicking continue', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withPassword(); + f.startSignInFactorTwo({ + supportPhoneCode: false, + supportPasskey: true, + }); + }); + fixtures.signIn.authenticateWithPasskey.mockResolvedValue({ status: 'complete' } as SignInResource); + const { userEvent } = render(, { wrapper }); + + await userEvent.click(screen.getByText('Continue')); + + expect(fixtures.signIn.authenticateWithPasskey).toHaveBeenCalled(); + }); + }); + }); }); describe('Use another method', () => { @@ -485,6 +557,44 @@ describe('SignInFactorTwo', () => { expect(await screen.findByText(/Authenticator/i)).toBeInTheDocument(); }); + it('skips the passkey method when webauthn is not supported', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withPassword(); + f.startSignInFactorTwo({ + supportPhoneCode: true, + supportTotp: true, + supportPasskey: true, + }); + }); + + fixtures.signIn.prepareSecondFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + const { userEvent } = render(, { wrapper }); + await userEvent.click(screen.getByText('Use another method')); + expect(await screen.findByText(/Send SMS code to \+/i)).toBeInTheDocument(); + expect(screen.queryByText('Sign in with your passkey')).not.toBeInTheDocument(); + }); + + mockWebAuthn(() => { + it('lists the passkey method and shows the passkey card when clicking it', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withPassword(); + f.startSignInFactorTwo({ + supportPhoneCode: true, + supportTotp: true, + supportPasskey: true, + }); + }); + + fixtures.signIn.prepareSecondFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + const { userEvent } = render(, { wrapper }); + await userEvent.click(screen.getByText('Use another method')); + await userEvent.click(await screen.findByText('Sign in with your passkey')); + expect(await screen.findByText('Use your passkey')).toBeInTheDocument(); + }); + }); + it('shows the SMS code input when clicking the Phone code method', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withEmailAddress(); diff --git a/packages/ui/src/components/SignIn/__tests__/utils.test.ts b/packages/ui/src/components/SignIn/__tests__/utils.test.ts index bde4ce8afcb..eb227c249be 100644 --- a/packages/ui/src/components/SignIn/__tests__/utils.test.ts +++ b/packages/ui/src/components/SignIn/__tests__/utils.test.ts @@ -1,4 +1,4 @@ -import type { SignInResource } from '@clerk/shared/types'; +import type { SignInFactor, SignInResource } from '@clerk/shared/types'; import { describe, expect, it } from 'vitest'; import type { FormControlState } from '@/ui/utils/useFormControl'; @@ -6,8 +6,10 @@ import type { FormControlState } from '@/ui/utils/useFormControl'; import { determineSalutation, determineStartingSignInFactor, + determineStartingSignInSecondFactor, getPreferredAlternativePhoneChannel, getPreferredAlternativePhoneChannelForCombinedFlow, + isOfferableSecondFactor, } from '../utils'; describe('determineStrategy(signIn, displayConfig)', () => { @@ -20,9 +22,11 @@ describe('determineStrategy(signIn, displayConfig)', () => { }, ], } as unknown as SignInResource; - expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'password')).toEqual({ - strategy: 'password', - }); + expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'password', false)).toEqual( + { + strategy: 'password', + }, + ); }); it('selects based on user input in the previous step if password is not available', () => { @@ -39,10 +43,12 @@ describe('determineStrategy(signIn, displayConfig)', () => { }, ], } as unknown as SignInResource; - expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'password')).toEqual({ - strategy: 'phone_code', - safeIdentifier: 'jdoe@example.com', - }); + expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'password', false)).toEqual( + { + strategy: 'phone_code', + safeIdentifier: 'jdoe@example.com', + }, + ); }); it('selects by prioritizing email_code if all the above fail', () => { @@ -59,10 +65,12 @@ describe('determineStrategy(signIn, displayConfig)', () => { }, ], } as unknown as SignInResource; - expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'password')).toEqual({ - strategy: 'email_code', - safeIdentifier: 'ccoe@example.com', - }); + expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'password', false)).toEqual( + { + strategy: 'email_code', + safeIdentifier: 'ccoe@example.com', + }, + ); }); it('selects phone_code if all the above fail', () => { @@ -75,10 +83,12 @@ describe('determineStrategy(signIn, displayConfig)', () => { }, ], } as unknown as SignInResource; - expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'password')).toEqual({ - strategy: 'phone_code', - safeIdentifier: 'jdoe@example.com', - }); + expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'password', false)).toEqual( + { + strategy: 'phone_code', + safeIdentifier: 'jdoe@example.com', + }, + ); }); it('returns null if every other scenario', () => { @@ -86,7 +96,9 @@ describe('determineStrategy(signIn, displayConfig)', () => { identifier: undefined, supportedFirstFactors: [], } as unknown as SignInResource; - expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'password')).toBeNull(); + expect( + determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'password', false), + ).toBeNull(); }); }); @@ -108,7 +120,7 @@ describe('determineStrategy(signIn, displayConfig)', () => { }, ], } as unknown as SignInResource; - expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'otp')).toEqual({ + expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'otp', false)).toEqual({ strategy: 'phone_code', safeIdentifier: 'jdoe@example.com', }); @@ -131,7 +143,7 @@ describe('determineStrategy(signIn, displayConfig)', () => { }, ], } as unknown as SignInResource; - expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'otp')).toEqual({ + expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'otp', false)).toEqual({ strategy: 'email_code', safeIdentifier: 'ccoe@example.com', }); @@ -150,7 +162,7 @@ describe('determineStrategy(signIn, displayConfig)', () => { }, ], } as unknown as SignInResource; - expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'otp')).toEqual({ + expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'otp', false)).toEqual({ strategy: 'phone_code', safeIdentifier: 'jdoe@example.com', }); @@ -164,7 +176,7 @@ describe('determineStrategy(signIn, displayConfig)', () => { }, ], } as unknown as SignInResource; - expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'otp')).toEqual({ + expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'otp', false)).toEqual({ strategy: 'password', }); }); @@ -174,7 +186,7 @@ describe('determineStrategy(signIn, displayConfig)', () => { identifier: undefined, supportedFirstFactors: [], } as unknown as SignInResource; - expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'otp')).toBeNull(); + expect(determineStartingSignInFactor(signIn.supportedFirstFactors, signIn.identifier, 'otp', false)).toBeNull(); }); }); @@ -311,3 +323,29 @@ describe('getPreferredAlternativePhoneChannelForCombinedFlow', () => { expect(result).toBeNull(); }); }); + +describe('determineStartingSignInSecondFactor', () => { + const secondFactors = [{ strategy: 'totp' }, { strategy: 'passkey' }] as SignInFactor[]; + + it('prefers passkey when WebAuthn is supported', () => { + expect(determineStartingSignInSecondFactor(secondFactors, true)).toEqual({ strategy: 'passkey' }); + }); + + it('falls back to the next factor when WebAuthn is not supported', () => { + expect(determineStartingSignInSecondFactor(secondFactors, false)).toEqual({ strategy: 'totp' }); + }); + + it('falls back to the passkey when it is the only factor, even without WebAuthn support', () => { + expect(determineStartingSignInSecondFactor([{ strategy: 'passkey' }] as SignInFactor[], false)).toEqual({ + strategy: 'passkey', + }); + }); +}); + +describe('isOfferableSecondFactor', () => { + it('offers passkey only when WebAuthn is supported', () => { + expect(isOfferableSecondFactor({ strategy: 'passkey' }, true)).toBe(true); + expect(isOfferableSecondFactor({ strategy: 'passkey' }, false)).toBe(false); + expect(isOfferableSecondFactor({ strategy: 'totp' }, false)).toBe(true); + }); +}); diff --git a/packages/ui/src/components/SignIn/shared.ts b/packages/ui/src/components/SignIn/shared.ts index 634128e86ee..16ae1656b44 100644 --- a/packages/ui/src/components/SignIn/shared.ts +++ b/packages/ui/src/components/SignIn/shared.ts @@ -12,6 +12,7 @@ import { useCoreSignIn, useSignInContext } from '../../contexts'; import { useSupportEmail } from '../../hooks/useSupportEmail'; import { useRouter } from '../../router'; import { navigateOnSignInProtectGate } from './handleProtectCheck'; +import { isResetPasswordStrategy } from './utils'; /** Search param set when navigating from the start page "Forgot password?" action. */ export const SIGN_IN_RESET_PASSWORD_INTENT_PARAM = '__clerk_reset_password'; @@ -51,6 +52,20 @@ function useHandleAuthenticateWithPasskey( } switch (res.status) { case 'complete': + // A passkey can complete the SECOND factor of a reset-password + // sign-in (reset password -> needs_second_factor -> passkey); that + // flow ends on the reset-password success screen, mirroring the + // other factor-two cards. Unreachable from factor-one usages of + // this hook, where the first-factor strategy is the passkey itself. + if ( + isResetPasswordStrategy(res.firstFactorVerification?.strategy) && + res.firstFactorVerification?.status === 'verified' && + res.createdSessionId + ) { + const queryParams = new URLSearchParams(); + queryParams.set('createdSessionId', res.createdSessionId); + return navigate(`../reset-password-success?${queryParams.toString()}`); + } return setActive({ session: res.createdSessionId, navigate: async ({ session, decorateUrl }) => { diff --git a/packages/ui/src/components/SignIn/useSecondFactorSelection.ts b/packages/ui/src/components/SignIn/useSecondFactorSelection.ts index 5e993d1759a..c658b0f34df 100644 --- a/packages/ui/src/components/SignIn/useSecondFactorSelection.ts +++ b/packages/ui/src/components/SignIn/useSecondFactorSelection.ts @@ -1,6 +1,7 @@ import type { SignInFactor } from '@clerk/shared/types'; import React from 'react'; +import { useIsWebAuthnSupported } from '../../hooks'; import { determineStartingSignInSecondFactor } from './utils'; const secondFactorKey = (factor: SignInFactor | null | undefined) => { @@ -15,9 +16,10 @@ const secondFactorKey = (factor: SignInFactor | null | undefined) => { }; export function useSecondFactorSelection(availableFactors: T[] | null) { + const webAuthnSupported = useIsWebAuthnSupported(); const lastPreparedFactorKeyRef = React.useRef(''); const [currentFactor, setCurrentFactor] = React.useState( - () => determineStartingSignInSecondFactor(availableFactors) as T | null, + () => determineStartingSignInSecondFactor(availableFactors, webAuthnSupported) as T | null, ); const [showAllStrategies, setShowAllStrategies] = React.useState(!currentFactor); diff --git a/packages/ui/src/components/SignIn/utils.ts b/packages/ui/src/components/SignIn/utils.ts index 697bd08adea..c57bee8327a 100644 --- a/packages/ui/src/components/SignIn/utils.ts +++ b/packages/ui/src/components/SignIn/utils.ts @@ -6,7 +6,6 @@ import type { SignInStrategy, } from '@clerk/shared/types'; import { titleize } from '@clerk/shared/underscore'; -import { isWebAuthnSupported } from '@clerk/shared/webauthn'; import { isEmail } from '@/ui/utils/emailUtils'; import { getPreferredPhoneCodeChannelByCountry } from '@/ui/utils/phoneUtils'; @@ -19,8 +18,12 @@ const factorForIdentifier = (i: string | null) => (f: SignInFactor) => { return 'safeIdentifier' in f && f.safeIdentifier === i; }; -function findPasskeyStrategy(factors: SignInFactor[]): SignInFactor | null { - if (isWebAuthnSupported()) { +export function isOfferableSecondFactor(factor: { strategy: SignInStrategy }, webAuthnSupported: boolean): boolean { + return factor.strategy !== 'passkey' || webAuthnSupported; +} + +function findPasskeyStrategy(factors: SignInFactor[], webAuthnSupported: boolean): SignInFactor | null { + if (webAuthnSupported) { // @ts-ignore const passkeyFactor = factors.find(({ strategy }) => strategy === 'passkey'); @@ -34,8 +37,9 @@ function findPasskeyStrategy(factors: SignInFactor[]): SignInFactor | null { function determineStrategyWhenPasswordIsPreferred( factors: SignInFactor[], identifier: string | null, + webAuthnSupported: boolean, ): SignInFactor | null { - const passkeyFactor = findPasskeyStrategy(factors); + const passkeyFactor = findPasskeyStrategy(factors, webAuthnSupported); if (passkeyFactor) { return passkeyFactor; } @@ -46,8 +50,12 @@ function determineStrategyWhenPasswordIsPreferred( return factors.find(factorForIdentifier(identifier)) || selected || null; } -function determineStrategyWhenOTPIsPreferred(factors: SignInFactor[], identifier: string | null): SignInFactor | null { - const passkeyFactor = findPasskeyStrategy(factors); +function determineStrategyWhenOTPIsPreferred( + factors: SignInFactor[], + identifier: string | null, + webAuthnSupported: boolean, +): SignInFactor | null { + const passkeyFactor = findPasskeyStrategy(factors, webAuthnSupported); if (passkeyFactor) { return passkeyFactor; } @@ -69,14 +77,15 @@ export function determineStartingSignInFactor( firstFactors: SignInFactor[] | null, identifier: string | null, preferredSignInStrategy: PreferredSignInStrategy, + webAuthnSupported: boolean, ): SignInFactor | null | undefined { if (!firstFactors || firstFactors.length === 0) { return null; } return preferredSignInStrategy === PREFERRED_SIGN_IN_STRATEGIES.Password - ? determineStrategyWhenPasswordIsPreferred(firstFactors, identifier) - : determineStrategyWhenOTPIsPreferred(firstFactors, identifier); + ? determineStrategyWhenPasswordIsPreferred(firstFactors, identifier, webAuthnSupported) + : determineStrategyWhenOTPIsPreferred(firstFactors, identifier, webAuthnSupported); } export function determineSalutation(signIn: Partial): string { @@ -96,12 +105,22 @@ export function factorHasLocalStrategy(factor: SignInFactor | undefined | null): return localStrategies.includes(factor.strategy); } -// The priority of second factors is: TOTP -> Phone code -> any other factor -export function determineStartingSignInSecondFactor(secondFactors: SignInFactor[] | null): SignInFactor | null { +// The priority of second factors is: Passkey -> TOTP -> Phone code -> any other factor. +// Passkey is only offered where WebAuthn is supported — without it the passkey +// card is a dead end, so fall through to the enrolled code-based factors instead. +export function determineStartingSignInSecondFactor( + secondFactors: SignInFactor[] | null, + webAuthnSupported: boolean, +): SignInFactor | null { if (!secondFactors || secondFactors.length === 0) { return null; } + const passkeyFactor = findPasskeyStrategy(secondFactors, webAuthnSupported); + if (passkeyFactor) { + return passkeyFactor; + } + const totpFactor = secondFactors.find(f => f.strategy === 'totp'); if (totpFactor) { return totpFactor; @@ -112,7 +131,7 @@ export function determineStartingSignInSecondFactor(secondFactors: SignInFactor[ return phoneCodeFactor; } - return secondFactors[0]; + return secondFactors.find(f => f.strategy !== 'passkey') ?? secondFactors[0]; } const resetPasswordStrategies: SignInStrategy[] = ['reset_password_phone_code', 'reset_password_email_code']; diff --git a/packages/ui/src/components/UserVerification/UVFactorTwoAlternativeMethods.tsx b/packages/ui/src/components/UserVerification/UVFactorTwoAlternativeMethods.tsx index c3041271e07..99869ca51e6 100644 --- a/packages/ui/src/components/UserVerification/UVFactorTwoAlternativeMethods.tsx +++ b/packages/ui/src/components/UserVerification/UVFactorTwoAlternativeMethods.tsx @@ -102,6 +102,8 @@ export function getButtonLabel(factor: SessionVerificationSecondFactor): Localiz return localizationKeys('reverification.alternativeMethods.blockButton__totp'); case 'backup_code': return localizationKeys('reverification.alternativeMethods.blockButton__backupCode'); + case 'passkey': + return localizationKeys('reverification.alternativeMethods.blockButton__passkey'); default: throw new Error(`Invalid verification strategy: "${(factor as any).strategy}"`); } diff --git a/packages/ui/src/components/UserVerification/UVFactorTwoPasskeyCard.tsx b/packages/ui/src/components/UserVerification/UVFactorTwoPasskeyCard.tsx new file mode 100644 index 00000000000..10c61c154f7 --- /dev/null +++ b/packages/ui/src/components/UserVerification/UVFactorTwoPasskeyCard.tsx @@ -0,0 +1,97 @@ +import { isClerkRuntimeError } from '@clerk/shared/error'; +import { __internal_WebAuthnAbortService } from '@clerk/shared/internal/clerk-js/passkeys'; +import { useSession } from '@clerk/shared/react'; +import React from 'react'; + +import { Card } from '@/ui/elements/Card'; +import { useCardState } from '@/ui/elements/contexts'; +import { Form } from '@/ui/elements/Form'; +import { Header } from '@/ui/elements/Header'; +import { handleError } from '@/ui/utils/errorHandler'; + +import { Button, Col, descriptors, localizationKeys } from '../../customizables'; +import { useAfterVerification } from './use-after-verification'; + +type UVFactorTwoPasskeyCardProps = { + onShowAlternativeMethodsClicked?: React.MouseEventHandler; + showAlternativeMethods?: boolean; +}; + +export const UVFactorTwoPasskeyCard = (props: UVFactorTwoPasskeyCardProps) => { + const { onShowAlternativeMethodsClicked, showAlternativeMethods } = props; + const { session } = useSession(); + const { handleVerificationResponse } = useAfterVerification(); + + const card = useCardState(); + + React.useEffect(() => { + return () => { + __internal_WebAuthnAbortService.abort(); + }; + }, []); + + const handlePasskeysAttempt = () => { + // A second click while the WebAuthn ceremony is in flight would abort it + // and surface a passkey_operation_aborted error. + if (card.isLoading) { + return; + } + + card.setLoading(); + session + ?.verifyWithPasskey({ level: 'second_factor' }) + .then(response => { + return handleVerificationResponse(response); + }) + .catch(err => { + if (isClerkRuntimeError(err) && err.code === 'passkey_operation_aborted') { + return; + } + handleError(err, [], card.setError); + }) + .finally(() => card.setIdle()); + + return; + }; + + return ( + + + + + + + {card.error} + + + +