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) => 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;