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. + *