Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/expo-android-restore-credentials.md
Original file line number Diff line number Diff line change
@@ -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
<AuthView enableRestoreCredentials />
```
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -121,6 +122,7 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo
},
mode = authMode(mode),
isDismissible = isDismissible,
enableRestoreCredentials = enableRestoreCredentials,
onDismiss = ::sendDismissEvent,
onAuthComplete = {
sendDismissEvent()
Expand Down Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Any?> {
Expand Down Expand Up @@ -961,6 +964,7 @@ class ClerkExpoModule : Module() {
}
is ClerkResult.Success -> {
val newClientState = clientStateSnapshot()
clearRestoreCredentialAfterSignOut(previousClientState, newClientState)
emitSyncedClientChanged(
sourceId,
ClientStateChanges(
Expand All @@ -976,6 +980,7 @@ class ClerkExpoModule : Module() {
}

val newClientState = clientStateSnapshot()
clearRestoreCredentialAfterSignOut(previousClientState, newClientState)
emitSyncedClientChanged(
sourceId,
ClientStateChanges(
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 3 additions & 1 deletion packages/expo-native-components/src/AuthView.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -45,6 +45,7 @@ export function AuthView({
mode = 'signInOrUp',
isDismissible = true,
logoMaxHeight,
enableRestoreCredentials = false,
onDismiss,
onHostBack,
}: AuthViewProps) {
Expand Down Expand Up @@ -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}
Expand Down
23 changes: 23 additions & 0 deletions packages/expo-native-components/src/AuthView.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
25 changes: 24 additions & 1 deletion packages/expo-native-components/src/__tests__/AuthView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { AuthView } from '../AuthView';

const mocks = vi.hoisted(() => {
return {
NativeClerkAuthView: vi.fn(() => null),
NativeClerkAuthView: vi.fn((_props: Record<string, any>) => null),
platform: { OS: 'android' },
};
});

Expand All @@ -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),
};
Expand All @@ -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', () => {
Expand All @@ -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(<AuthView />);

expect(mocks.NativeClerkAuthView.mock.calls[0]?.[0]).toMatchObject({ enableRestoreCredentials: false });
});

test('passes enableRestoreCredentials to the native auth view on Android', () => {
render(<AuthView enableRestoreCredentials />);

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(<AuthView enableRestoreCredentials />);

expect(mocks.NativeClerkAuthView.mock.calls[0]?.[0].enableRestoreCredentials).toBeUndefined();
});

test('calls onDismiss when the native auth view emits dismissed', () => {
const onDismiss = vi.fn();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface NativeProps extends ViewProps {
mode?: string;
isDismissible?: boolean;
logoMaxHeight?: number;
enableRestoreCredentials?: boolean;
hostBackButton?: boolean;
onAuthEvent?: (event: NativeSyntheticEvent<AuthEvent>) => void;
onHostBack?: (event: NativeSyntheticEvent<object>) => void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ interface NativeProps extends ViewProps {
mode?: string;
isDismissible?: boolean;
logoMaxHeight?: number;
enableRestoreCredentials?: boolean;
hostBackButton?: boolean;
onAuthEvent?: (event: NativeSyntheticEvent<AuthEvent>) => void;
onHostBack?: (event: NativeSyntheticEvent<object>) => void;
Expand Down
Loading