diff --git a/.changeset/sign-in-future-client-trust-state.md b/.changeset/sign-in-future-client-trust-state.md new file mode 100644 index 00000000000..c51d62c4264 --- /dev/null +++ b/.changeset/sign-in-future-client-trust-state.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/react': minor +--- + +Add `clientTrustState` to `SignInFuture`, so custom sign-in flows can read whether Clerk trusts the user's device (`'new'`, `'known'`, or `'pending'`). Clerk returns it once the first factor, such as the password, is verified. It's `undefined` before then, and when Device Trust is off. diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 83caee52941..b333101d779 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -857,6 +857,7 @@ export class SignIn extends BaseResource implements SignInResource { created_session_id: this.createdSessionId, user_data: this.userData.__internal_toSnapshot(), timezone: this.timezone, + client_trust_state: this.clientTrustState, protect_check: this.protectCheck ? { status: this.protectCheck.status, @@ -1004,6 +1005,10 @@ class SignInFuture implements SignInFutureResource { return this.#resource.secondFactorVerification; } + get clientTrustState(): ClientTrustState | undefined { + return this.#resource.clientTrustState ?? (this.#resource.status === 'needs_client_trust' ? 'new' : undefined); + } + get protectCheck() { return this.#resource.protectCheck; } 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 53a50f0baa0..067b12d7919 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -1427,6 +1427,78 @@ describe('SignIn', () => { }); }); + describe('clientTrustState', () => { + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it.each([ + { reported: 'new', status: 'needs_second_factor' }, + { reported: 'known', status: 'needs_first_factor' }, + { reported: 'pending', status: 'needs_first_factor' }, + ])('exposes $reported when FAPI reports it', async ({ reported, status }) => { + BaseResource._fetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status, client_trust_state: reported }, + }); + + const signIn = new SignIn(); + await signIn.__internal_future.password({ identifier: 'user@example.com', password: 'password123' }); + + expect(signIn.__internal_future.clientTrustState).toBe(reported); + }); + + it('is undefined when FAPI does not report it', async () => { + BaseResource._fetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status: 'needs_first_factor' }, + }); + + const signIn = new SignIn(); + await signIn.__internal_future.password({ identifier: 'user@example.com', password: 'password123' }); + + expect(signIn.__internal_future.clientTrustState).toBeUndefined(); + }); + + it('is new while the status is needs_client_trust and FAPI omits the field', async () => { + BaseResource._fetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status: 'needs_client_trust' }, + }); + + const signIn = new SignIn(); + await signIn.__internal_future.password({ identifier: 'user@example.com', password: 'password123' }); + + expect(signIn.__internal_future.clientTrustState).toBe('new'); + expect(signIn.clientTrustState).toBeUndefined(); + }); + + it('prefers the value FAPI reports over the needs_client_trust status', async () => { + BaseResource._fetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status: 'needs_client_trust', client_trust_state: 'pending' }, + }); + + const signIn = new SignIn(); + await signIn.__internal_future.password({ identifier: 'user@example.com', password: 'password123' }); + + expect(signIn.__internal_future.clientTrustState).toBe('pending'); + }); + + it('survives a snapshot round trip', () => { + const signIn = new SignIn({ + id: 'signin_123', + status: 'needs_second_factor', + client_trust_state: 'new', + } as any); + + const restored = new SignIn(signIn.__internal_toSnapshot()); + + expect(restored.__internal_future.clientTrustState).toBe('new'); + }); + }); + describe('password', () => { afterEach(() => { vi.clearAllMocks(); diff --git a/packages/react/src/__tests__/stateProxy.test.ts b/packages/react/src/__tests__/stateProxy.test.ts index e1a8586a0d9..6aea8e546ee 100644 --- a/packages/react/src/__tests__/stateProxy.test.ts +++ b/packages/react/src/__tests__/stateProxy.test.ts @@ -25,6 +25,23 @@ describe('StateProxy', () => { }, ); + it('exposes signIn clientTrustState after loading and follows the active attempt', () => { + const clientAttempt = { clientTrustState: 'known' }; + let stateAttempt: { clientTrustState: string } | null = { clientTrustState: 'new' }; + const isomorphicClerk = { + loaded: false, + client: { signIn: { __internal_future: clientAttempt } }, + __internal_state: { signInSignal: () => ({ signIn: stateAttempt }) }, + }; + const signIn = new StateProxy(isomorphicClerk as any).signInSignal().signIn as SignInFutureResource; + + expect(signIn.clientTrustState).toBeUndefined(); + isomorphicClerk.loaded = true; + expect(signIn.clientTrustState).toBe('new'); + stateAttempt = null; + expect(signIn.clientTrustState).toBe('known'); + }); + it('preserves a completed sign-in across chained calls when the client clears its sign-in attempt', async () => { const emptySignIn = { status: 'needs_identifier', diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index e9447eb3d75..abaf02b3e47 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -183,6 +183,9 @@ export class StateProxy implements State { }, }); }, + get clientTrustState() { + return gateProperty(target, 'clientTrustState', undefined); + }, get identifier() { return gateProperty(target, 'identifier', null); }, diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index 079e414e242..bea06126263 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -1,5 +1,6 @@ import type { ClerkError } from '../errors/clerkError'; import type { SetActiveNavigate } from './clerk'; +import type { ClientTrustState } from './json'; import type { PhoneCodeChannel } from './phoneCodeChannel'; import type { SignInFirstFactor, SignInSecondFactor, SignInStatus, UserData } from './signInCommon'; import type { ProtectCheckResource } from './signUpCommon'; @@ -402,6 +403,17 @@ export interface SignInFutureResource { */ readonly secondFactorVerification: VerificationResource; + /** + * The [Device Trust](https://clerk.com/docs/guides/protect/device-trust) state of the device the user is signing in from, as of the latest response. Clerk returns it once the first factor, such as the password, is verified, so entering an identifier doesn't reveal whether that account has signed in on this device. + * + * `undefined` when Device Trust is off, or before the first factor is verified. + */ + readonly clientTrustState?: ClientTrustState; + /** * The authentication identifier value for the current sign-in. `null` if the `strategy` is `'oauth_'` or `'enterprise_sso'`. */ diff --git a/packages/ui/src/components/SignIn/__tests__/SignInClientTrust.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInClientTrust.test.tsx index 029f51e9619..049f07d8d9b 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInClientTrust.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInClientTrust.test.tsx @@ -1,5 +1,5 @@ import type { SignInResource } from '@clerk/shared/types'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render, screen } from '@/test/utils'; @@ -32,4 +32,58 @@ describe('SignInClientTrust', () => { expect(screen.getByText('Use another method')).toBeInTheDocument(); }); }); + + describe('New device notice', () => { + const NOTICE = /You're signing in from a new device/i; + const cases = [ + ['with client_trust_state', 'new'], + ['without client_trust_state', undefined], + ] as const; + + it.each(cases)('renders the notice once on the email_code factor %s', async (_, clientTrustState) => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignInClientTrust({ supportPhoneCode: false, supportEmailCode: true, clientTrustState }); + }); + + fixtures.signIn.prepareSecondFactor.mockResolvedValueOnce({} as SignInResource); + render(, { wrapper }); + + expect(screen.getAllByText(NOTICE)).toHaveLength(1); + expect(screen.getAllByText('Check your email').length).toBeGreaterThan(0); + expect(screen.getAllByTestId('otp-input-segment')).toHaveLength(6); + }); + + it.each(cases)('renders the notice once on the phone_code factor %s', async (_, clientTrustState) => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignInClientTrust({ supportPhoneCode: true, clientTrustState }); + }); + + fixtures.signIn.prepareSecondFactor.mockResolvedValueOnce({} as SignInResource); + render(, { wrapper }); + + expect(screen.getAllByText(NOTICE)).toHaveLength(1); + expect(screen.getAllByText('Check your phone').length).toBeGreaterThan(0); + expect(screen.getAllByTestId('otp-input-segment')).toHaveLength(6); + }); + + it.each(cases)('renders the notice once on the email_link factor %s', async (_, clientTrustState) => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignInClientTrust({ supportPhoneCode: false, supportEmailLink: true, clientTrustState }); + }); + + fixtures.signIn.prepareSecondFactor.mockResolvedValueOnce({} as SignInResource); + fixtures.signIn.createEmailLinkFlow.mockImplementation( + () => + ({ + startEmailLinkFlow: vi.fn(() => new Promise(() => {})), + cancelEmailLinkFlow: vi.fn(), + }) as any, + ); + render(, { wrapper }); + + expect(screen.getAllByText(NOTICE)).toHaveLength(1); + expect(screen.getAllByText('Check your email').length).toBeGreaterThan(0); + expect(screen.getByText('Use the verification link sent to your email')).toBeInTheDocument(); + }); + }); }); diff --git a/packages/ui/src/components/SignIn/__tests__/SignInFactorTwo.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInFactorTwo.test.tsx index f5b04763a77..e0678247e9d 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInFactorTwo.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorTwo.test.tsx @@ -94,6 +94,24 @@ describe('SignInFactorTwo', () => { }); }); + describe('New device notice with a second factor attribute', () => { + it.each([ + ['with client_trust_state', 'new'], + ['without client_trust_state', undefined], + ] as const)('does not render the notice %s', async (_, clientTrustState) => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withPhoneNumber({ used_for_second_factor: true, second_factors: ['phone_code'] }); + f.startSignInFactorTwo({ supportPhoneCode: true, clientTrustState }); + }); + + fixtures.signIn.prepareSecondFactor.mockResolvedValueOnce({} as SignInResource); + render(, { wrapper }); + + expect(screen.getAllByTestId('otp-input-segment')).toHaveLength(6); + expect(screen.queryByText(/You're signing in from a new device/i)).not.toBeInTheDocument(); + }); + }); + describe('Selected Second Factor Method', () => { describe('Phone Code', () => { it('renders the correct screen with the text "Check your phone"', async () => { diff --git a/packages/ui/src/test/fixture-helpers.ts b/packages/ui/src/test/fixture-helpers.ts index d00ec09826c..1275834b4a8 100644 --- a/packages/ui/src/test/fixture-helpers.ts +++ b/packages/ui/src/test/fixture-helpers.ts @@ -131,6 +131,8 @@ const createSignInFixtureHelpers = (baseClient: ClientJSON) => { supportPasskey?: boolean; supportResetPasswordEmail?: boolean; supportResetPasswordPhone?: boolean; + supportEmailLink?: boolean; + clientTrustState?: SignInJSON['client_trust_state']; }; const startSignInWithEmailAddress = (params?: SignInWithEmailAddressParams) => { @@ -225,15 +227,18 @@ const createSignInFixtureHelpers = (baseClient: ClientJSON) => { identifier = '+30 691 1111111', supportPhoneCode = true, supportEmailCode, + supportEmailLink, supportTotp, supportBackupCode, supportPasskey, supportResetPasswordEmail, supportResetPasswordPhone, + clientTrustState, } = params || {}; baseClient.sign_in = { status, identifier, + ...(clientTrustState ? { client_trust_state: clientTrustState } : {}), ...(supportResetPasswordEmail ? { first_factor_verification: { @@ -254,6 +259,9 @@ const createSignInFixtureHelpers = (baseClient: ClientJSON) => { supported_second_factors: [ ...(supportPhoneCode ? [{ strategy: 'phone_code', safe_identifier: identifier || 'n*****@clerk.com' }] : []), ...(supportEmailCode ? [{ strategy: 'email_code', safe_identifier: 'n*****@clerk.com' }] : []), + ...(supportEmailLink + ? [{ strategy: 'email_link', safe_identifier: 'n*****@clerk.com', email_address_id: 'idn_123' }] + : []), ...(supportTotp ? [{ strategy: 'totp', safe_identifier: identifier || 'n*****@clerk.com' }] : []), ...(supportBackupCode ? [{ strategy: 'backup_code', safe_identifier: identifier || 'n*****@clerk.com' }] : []), ...(supportPasskey ? [{ strategy: 'passkey' }] : []),