Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/sign-in-future-client-trust-state.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down
72 changes: 72 additions & 0 deletions packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
17 changes: 17 additions & 0 deletions packages/react/src/__tests__/stateProxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions packages/react/src/stateProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,9 @@ export class StateProxy implements State {
},
});
},
get clientTrustState() {
return gateProperty(target, 'clientTrustState', undefined);
},
get identifier() {
return gateProperty(target, 'identifier', null);
},
Expand Down
12 changes: 12 additions & 0 deletions packages/shared/src/types/signInFuture.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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.
* <ul>
* <li>`'new'` - Clerk doesn't trust this device for the user yet, or can't confirm that it does. While `status` is `'needs_client_trust'`, this is `'new'`.</li>
* <li>`'known'` - Clerk trusts this device for the user. Completing a sign-in makes the device trusted in most cases, so a `'complete'` sign-in usually reads `'known'`.</li>
* <li>`'pending'` - Clerk can't tell yet.</li>
* </ul>
* `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_<provider>'` or `'enterprise_sso'`.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(<SignInClientTrust />, { 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(<SignInClientTrust />, { 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<SignInResource>(() => {})),
cancelEmailLinkFlow: vi.fn(),
}) as any,
);
render(<SignInClientTrust />, { 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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(<SignInFactorTwo />, { 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 () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/ui/src/test/fixture-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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: {
Expand All @@ -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' }] : []),
Expand Down
Loading