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