From bcbfe7c07796016c7b9135af630524b88b2e8f7f Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 14 Oct 2026 10:26:16 -0700 Subject: [PATCH 1/2] feat(expo-native-components): opt-in Restore Credentials for the Android AuthView --- .../expo-android-restore-credentials.md | 9 +++++++ .../expo/modules/clerk/ClerkAuthViewModule.kt | 6 +++++ .../expo-native-components/src/AuthView.tsx | 4 ++- .../src/AuthView.types.ts | 17 +++++++++++++ .../src/__tests__/AuthView.test.tsx | 25 ++++++++++++++++++- .../src/specs/NativeClerkAuthView.android.ts | 1 + .../src/specs/NativeClerkAuthView.ts | 1 + 7 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 .changeset/expo-android-restore-credentials.md diff --git a/.changeset/expo-android-restore-credentials.md b/.changeset/expo-android-restore-credentials.md new file mode 100644 index 00000000000..c1ec6210795 --- /dev/null +++ b/.changeset/expo-android-restore-credentials.md @@ -0,0 +1,9 @@ +--- +'@clerk/expo-native-components': minor +--- + +Add an `enableRestoreCredentials` prop to `AuthView` that turns on [Restore Credentials](https://developer.android.com/identity/sign-in/restore-credentials) on Android, so users stay signed in when they move to a new device. It is off by default and ignored on iOS. When it is on, a signed-out `AuthView` silently signs the user in with a restore credential transferred from their old device, and after a sign-in or sign-up it creates a restore credential in the background. Passkeys must be enabled for your Clerk instance. + +```tsx + +``` diff --git a/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt index 8d5d9e49506..5578ec148bf 100644 --- a/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt +++ b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt @@ -46,6 +46,7 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo var logoView: View? = null private set var hostBackButton: Boolean = false + var enableRestoreCredentials: Boolean = false private var logoWidth = 0 private var logoHeight = 0 @@ -121,6 +122,7 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo }, mode = authMode(mode), isDismissible = isDismissible, + enableRestoreCredentials = enableRestoreCredentials, onDismiss = ::sendDismissEvent, onAuthComplete = { sendDismissEvent() @@ -233,6 +235,10 @@ class ClerkAuthViewModule : Module() { view.hostBackButton = hostBackButton } + Prop("enableRestoreCredentials") { view: ClerkAuthNativeView, enableRestoreCredentials: Boolean? -> + view.enableRestoreCredentials = enableRestoreCredentials ?: false + } + OnViewDidUpdateProps { view: ClerkAuthNativeView -> view.setupView() } diff --git a/packages/expo-native-components/src/AuthView.tsx b/packages/expo-native-components/src/AuthView.tsx index 30e4e374d57..d56f6b708df 100644 --- a/packages/expo-native-components/src/AuthView.tsx +++ b/packages/expo-native-components/src/AuthView.tsx @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import type { NativeSyntheticEvent } from 'react-native'; -import { Text, View } from 'react-native'; +import { Platform, Text, View } from 'react-native'; import type { AuthViewProps } from './AuthView.types'; import NativeClerkAuthView from './specs/NativeClerkAuthView'; @@ -45,6 +45,7 @@ export function AuthView({ mode = 'signInOrUp', isDismissible = true, logoMaxHeight, + enableRestoreCredentials = false, onDismiss, onHostBack, }: AuthViewProps) { @@ -75,6 +76,7 @@ export function AuthView({ mode={mode} isDismissible={isDismissible} logoMaxHeight={logoMaxHeight} + enableRestoreCredentials={Platform.OS === 'android' ? enableRestoreCredentials : undefined} hostBackButton={!!onHostBack} onAuthEvent={handleAuthEvent} onHostBack={onHostBack ? () => onHostBack() : undefined} diff --git a/packages/expo-native-components/src/AuthView.types.ts b/packages/expo-native-components/src/AuthView.types.ts index 90c7b263c1d..5807e3a8205 100644 --- a/packages/expo-native-components/src/AuthView.types.ts +++ b/packages/expo-native-components/src/AuthView.types.ts @@ -58,6 +58,23 @@ export interface AuthViewProps extends EmbeddedNavigationProps { */ logoMaxHeight?: number; + /** + * Turns on [Restore Credentials](https://developer.android.com/identity/sign-in/restore-credentials) + * so users stay signed in when they move to a new Android device. Android only and opt-in; + * ignored on iOS. + * + * When `true`, a signed-out `AuthView` first tries to sign in silently with a restore credential + * that Android device setup transferred from the user's old device, and falls back to the usual + * sign-in UI if there is none. After a sign-in or sign-up, it creates a restore credential in the + * background, at most once per user on each device. + * + * Requires passkeys to be enabled for your Clerk instance, and a version of clerk-android that + * includes [clerk/clerk-android#1097](https://github.com/clerk/clerk-android/pull/1097). + * + * @default false + */ + enableRestoreCredentials?: boolean; + /** * Called when the native authentication view requests dismissal. * diff --git a/packages/expo-native-components/src/__tests__/AuthView.test.tsx b/packages/expo-native-components/src/__tests__/AuthView.test.tsx index 528e251fb2e..be4ffb660b9 100644 --- a/packages/expo-native-components/src/__tests__/AuthView.test.tsx +++ b/packages/expo-native-components/src/__tests__/AuthView.test.tsx @@ -6,7 +6,8 @@ import { AuthView } from '../AuthView'; const mocks = vi.hoisted(() => { return { - NativeClerkAuthView: vi.fn(() => null), + NativeClerkAuthView: vi.fn((_props: Record) => null), + platform: { OS: 'android' }, }; }); @@ -24,6 +25,7 @@ vi.mock('../utils/native-module', () => { vi.mock('react-native', () => { return { + Platform: mocks.platform, Text: ({ children }: { children?: React.ReactNode }) => React.createElement('span', null, children), View: ({ children }: { children?: React.ReactNode }) => React.createElement('div', null, children), }; @@ -32,6 +34,7 @@ vi.mock('react-native', () => { describe('AuthView', () => { beforeEach(() => { mocks.NativeClerkAuthView.mockClear(); + mocks.platform.OS = 'android'; }); test('passes logoMaxHeight to the native auth view', () => { @@ -40,6 +43,26 @@ describe('AuthView', () => { expect(mocks.NativeClerkAuthView.mock.calls[0]?.[0]).toMatchObject({ logoMaxHeight: 64 }); }); + test('turns off restore credentials by default on Android', () => { + render(); + + expect(mocks.NativeClerkAuthView.mock.calls[0]?.[0]).toMatchObject({ enableRestoreCredentials: false }); + }); + + test('passes enableRestoreCredentials to the native auth view on Android', () => { + render(); + + expect(mocks.NativeClerkAuthView.mock.calls[0]?.[0]).toMatchObject({ enableRestoreCredentials: true }); + }); + + test('does not pass enableRestoreCredentials to the native auth view on iOS', () => { + mocks.platform.OS = 'ios'; + + render(); + + expect(mocks.NativeClerkAuthView.mock.calls[0]?.[0].enableRestoreCredentials).toBeUndefined(); + }); + test('calls onDismiss when the native auth view emits dismissed', () => { const onDismiss = vi.fn(); diff --git a/packages/expo-native-components/src/specs/NativeClerkAuthView.android.ts b/packages/expo-native-components/src/specs/NativeClerkAuthView.android.ts index 43c0ec2a435..7ecc232b310 100644 --- a/packages/expo-native-components/src/specs/NativeClerkAuthView.android.ts +++ b/packages/expo-native-components/src/specs/NativeClerkAuthView.android.ts @@ -7,6 +7,7 @@ interface NativeProps extends ViewProps { mode?: string; isDismissible?: boolean; logoMaxHeight?: number; + enableRestoreCredentials?: boolean; hostBackButton?: boolean; onAuthEvent?: (event: NativeSyntheticEvent) => void; onHostBack?: (event: NativeSyntheticEvent) => void; diff --git a/packages/expo-native-components/src/specs/NativeClerkAuthView.ts b/packages/expo-native-components/src/specs/NativeClerkAuthView.ts index 9a0de17dc62..5f0d63131ab 100644 --- a/packages/expo-native-components/src/specs/NativeClerkAuthView.ts +++ b/packages/expo-native-components/src/specs/NativeClerkAuthView.ts @@ -8,6 +8,7 @@ interface NativeProps extends ViewProps { mode?: string; isDismissible?: boolean; logoMaxHeight?: number; + enableRestoreCredentials?: boolean; hostBackButton?: boolean; onAuthEvent?: (event: NativeSyntheticEvent) => void; onHostBack?: (event: NativeSyntheticEvent) => void; From b6666739825293d66b201b20e59dc27959656a2c Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 14 Oct 2026 12:39:09 -0700 Subject: [PATCH 2/2] fix(expo-native-components): describe first-launch restore and clear the restore key on JS sign-out Match the final clerk/clerk-android#1097 behaviour in the AuthView TSDoc and the changeset: restore is tried only on the first launch after install or restore, the device's restore key belongs to the most recent user and replaces that user's previous one, and passkeys must be a first factor. When a JS-originated client sync takes the client from having sessions to none, call Clerk.restoreCredentials.clear() so a JS sign-out deletes the device's restore key like a native sign-out does. --- .../expo-android-restore-credentials.md | 2 +- .../expo/modules/clerk/ClerkExpoModule.kt | 15 ++++++++++ .../expo/modules/clerk/ClientSignedOutTest.kt | 29 +++++++++++++++++++ .../src/AuthView.types.ts | 18 ++++++++---- 4 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClientSignedOutTest.kt diff --git a/.changeset/expo-android-restore-credentials.md b/.changeset/expo-android-restore-credentials.md index c1ec6210795..aaf3a9714f8 100644 --- a/.changeset/expo-android-restore-credentials.md +++ b/.changeset/expo-android-restore-credentials.md @@ -2,7 +2,7 @@ '@clerk/expo-native-components': minor --- -Add an `enableRestoreCredentials` prop to `AuthView` that turns on [Restore Credentials](https://developer.android.com/identity/sign-in/restore-credentials) on Android, so users stay signed in when they move to a new device. It is off by default and ignored on iOS. When it is on, a signed-out `AuthView` silently signs the user in with a restore credential transferred from their old device, and after a sign-in or sign-up it creates a restore credential in the background. Passkeys must be enabled for your Clerk instance. +Add an `enableRestoreCredentials` prop to `AuthView` that turns on [Restore Credentials](https://developer.android.com/identity/sign-in/restore-credentials) on Android, so users stay signed in when they move to a new device. It is off by default and ignored on iOS. When it is on, on the first launch after the app is installed or restored onto a device, a signed-out `AuthView` silently signs the user in with the restore credential transferred from their old device. Later launches don't try again. After a sign-in or sign-up, it creates a restore credential for the signed-in user in the background, replacing the one this device created for that user before; a device holds one restore credential, for the user who most recently signed in. Passkeys must be enabled as a first factor for your Clerk instance. Signing out from JavaScript now also deletes the device's restore credential. ```tsx diff --git a/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index c329174372d..338376bef62 100644 --- a/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -53,6 +53,9 @@ private fun debugLog(tag: String, message: String) { } } +internal fun clientSignedOut(previous: Client?, current: Client?): Boolean = + previous?.sessions.orEmpty().isNotEmpty() && current?.sessions.orEmpty().isEmpty() + internal fun biometricCredentialAvailabilityPayload( availability: BiometricCredentialAvailability ): Map { @@ -961,6 +964,7 @@ class ClerkExpoModule : Module() { } is ClerkResult.Success -> { val newClientState = clientStateSnapshot() + clearRestoreCredentialAfterSignOut(previousClientState, newClientState) emitSyncedClientChanged( sourceId, ClientStateChanges( @@ -976,6 +980,7 @@ class ClerkExpoModule : Module() { } val newClientState = clientStateSnapshot() + clearRestoreCredentialAfterSignOut(previousClientState, newClientState) emitSyncedClientChanged( sourceId, ClientStateChanges( @@ -993,6 +998,16 @@ class ClerkExpoModule : Module() { } } + private suspend fun clearRestoreCredentialAfterSignOut( + previous: ClientStateSnapshot, + current: ClientStateSnapshot + ) { + if (!clientSignedOut(previous.client, current.client)) return + if (Clerk.restoreCredentials.clear() is ClerkResult.Failure) { + debugLog(TAG, "syncClientStateFromJs - restore credential cleanup did not complete") + } + } + // MARK: - Theme Loading private fun loadThemeFromAssets(context: Context) { diff --git a/packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClientSignedOutTest.kt b/packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClientSignedOutTest.kt new file mode 100644 index 00000000000..f4280ff7aba --- /dev/null +++ b/packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClientSignedOutTest.kt @@ -0,0 +1,29 @@ +package expo.modules.clerk + +import com.clerk.api.network.model.client.Client +import com.clerk.api.session.Session +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ClientSignedOutTest { + private val signedIn = Client(id = "client_1", sessions = listOf(session("sess_1"))) + private val signedOut = Client(id = "client_1") + + @Test + fun `a client that loses its last session signed out`() { + assertTrue(clientSignedOut(signedIn, signedOut)) + assertTrue(clientSignedOut(signedIn, null)) + } + + @Test + fun `a client that keeps a session or never had one did not sign out`() { + assertFalse(clientSignedOut(signedIn, signedIn)) + assertFalse(clientSignedOut(signedOut, signedOut)) + assertFalse(clientSignedOut(null, signedOut)) + assertFalse(clientSignedOut(signedOut, signedIn)) + } + + private fun session(id: String) = + Session(id = id, expireAt = 0, lastActiveAt = 0, createdAt = 0, updatedAt = 0) +} diff --git a/packages/expo-native-components/src/AuthView.types.ts b/packages/expo-native-components/src/AuthView.types.ts index 5807e3a8205..fe970a2f0bc 100644 --- a/packages/expo-native-components/src/AuthView.types.ts +++ b/packages/expo-native-components/src/AuthView.types.ts @@ -63,13 +63,19 @@ export interface AuthViewProps extends EmbeddedNavigationProps { * so users stay signed in when they move to a new Android device. Android only and opt-in; * ignored on iOS. * - * When `true`, a signed-out `AuthView` first tries to sign in silently with a restore credential - * that Android device setup transferred from the user's old device, and falls back to the usual - * sign-in UI if there is none. After a sign-in or sign-up, it creates a restore credential in the - * background, at most once per user on each device. + * When `true`, on the first launch after the app is installed or restored onto a device, a + * signed-out `AuthView` tries to sign in silently with the restore credential that Android device + * setup transferred from the user's old device, and falls back to the usual sign-in UI if there is + * none. Later launches don't try again, so a user whose session ended is not signed back in + * silently. * - * Requires passkeys to be enabled for your Clerk instance, and a version of clerk-android that - * includes [clerk/clerk-android#1097](https://github.com/clerk/clerk-android/pull/1097). + * After a sign-in or sign-up, `AuthView` creates a restore credential for the signed-in user in + * the background, replacing the one this device created for that user before. A device holds one + * restore credential, which belongs to the user who most recently signed in. Restore credentials + * are passkeys named "Android restore key". + * + * Requires passkeys to be enabled as a first factor for your Clerk instance, and a version of + * clerk-android that includes [clerk/clerk-android#1097](https://github.com/clerk/clerk-android/pull/1097). * * @default false */