diff --git a/.changeset/expo-android-restore-credentials.md b/.changeset/expo-android-restore-credentials.md
new file mode 100644
index 00000000000..2fa7c051379
--- /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. On Android, when a signed-in session on the device ends, including a sign-out from JavaScript, the device's restore credential is deleted.
+
+```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 b72c1801e40..ddc69bd0885 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 27055ffdaf4..297fd64d963 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
@@ -14,6 +14,7 @@ import com.clerk.api.FrameworkIntegrationApi
import com.clerk.api.network.model.client.Client
import com.clerk.api.network.model.error.firstMessage
import com.clerk.api.network.serialization.ClerkResult
+import com.clerk.api.session.Session
import com.clerk.api.ui.ClerkColors
import com.clerk.api.ui.ClerkDesign
import com.clerk.api.ui.ClerkTheme
@@ -42,6 +43,17 @@ private fun debugLog(tag: String, message: String) {
}
}
+internal fun signedInSessionEnded(previous: Client?, current: Client?): Boolean {
+ if (current == null) return false
+ val remaining = current.signedInSessionIds()
+ return previous?.signedInSessionIds().orEmpty().any { it !in remaining }
+}
+
+private fun Client.signedInSessionIds(): Set =
+ sessions
+ .filter { it.status == Session.SessionStatus.ACTIVE || it.status == Session.SessionStatus.PENDING }
+ .mapTo(mutableSetOf()) { it.id }
+
class ClerkExpoModule : Module() {
private val coroutineScope = CoroutineScope(Dispatchers.Main)
private var authFlowStateObserverJob: Job? = null
@@ -211,8 +223,13 @@ class ClerkExpoModule : Module() {
}
clientInvalidationObserverJob = coroutineScope.launch {
+ var previousClient: Client? = null
Clerk.clientFlow.collect { client ->
clientInvalidationTracker.observe(clientFingerprint(client))
+ if (signedInSessionEnded(previousClient, client)) {
+ launch { clearRestoreCredential() }
+ }
+ previousClient = client
}
}
}
@@ -290,6 +307,12 @@ class ClerkExpoModule : Module() {
}
}
+ private suspend fun clearRestoreCredential() {
+ if (Clerk.restoreCredentials.clear() is ClerkResult.Failure) {
+ debugLog(TAG, "Restore credential cleanup after a session ended 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/SignedInSessionEndedTest.kt b/packages/expo-native-components/android/src/test/java/expo/modules/clerk/SignedInSessionEndedTest.kt
new file mode 100644
index 00000000000..30cdfd200b1
--- /dev/null
+++ b/packages/expo-native-components/android/src/test/java/expo/modules/clerk/SignedInSessionEndedTest.kt
@@ -0,0 +1,36 @@
+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 SignedInSessionEndedTest {
+ private val signedOut = Client(id = "client_1")
+ private val one = client(session("sess_1"))
+ private val two = client(session("sess_1"), session("sess_2"))
+
+ @Test
+ fun `losing a signed-in session ends it`() {
+ assertTrue(signedInSessionEnded(one, signedOut))
+ assertTrue(signedInSessionEnded(two, one))
+ assertTrue(signedInSessionEnded(one, client(session("sess_1", Session.SessionStatus.ENDED))))
+ assertTrue(signedInSessionEnded(client(session("sess_1", Session.SessionStatus.PENDING)), signedOut))
+ }
+
+ @Test
+ fun `keeping, adding or not yet knowing the sessions ends nothing`() {
+ assertFalse(signedInSessionEnded(one, one))
+ assertFalse(signedInSessionEnded(one, two))
+ assertFalse(signedInSessionEnded(signedOut, one))
+ assertFalse(signedInSessionEnded(null, signedOut))
+ assertFalse(signedInSessionEnded(one, null))
+ assertFalse(signedInSessionEnded(client(session("sess_1", Session.SessionStatus.PENDING)), one))
+ }
+
+ private fun client(vararg sessions: Session) = Client(id = "client_1", sessions = sessions.toList())
+
+ private fun session(id: String, status: Session.SessionStatus = Session.SessionStatus.ACTIVE) =
+ Session(id = id, status = status, 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..9f6ddb794b6 100644
--- a/packages/expo-native-components/src/AuthView.types.ts
+++ b/packages/expo-native-components/src/AuthView.types.ts
@@ -58,6 +58,31 @@ 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".
+ *
+ * When a signed-in session on the device ends, whether from JavaScript or native code, the
+ * device's restore credential is deleted.
+ *
+ * Requires passkeys to be enabled as a first factor for your Clerk instance.
+ *
+ * @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..7df1fdaa1a1 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