diff --git a/.changeset/expo-android-restore-credentials.md b/.changeset/expo-android-restore-credentials.md
new file mode 100644
index 00000000000..aaf3a9714f8
--- /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, 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/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/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.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..fe970a2f0bc 100644
--- a/packages/expo-native-components/src/AuthView.types.ts
+++ b/packages/expo-native-components/src/AuthView.types.ts
@@ -58,6 +58,29 @@ 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`, 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.
+ *
+ * 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
+ */
+ 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