Repository navigation
feat(expo-native-components): opt-in Restore Credentials for the Android AuthView #10233
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
bcbfe7c
b666673
d863e43
a8365c1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. 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 | ||
| <AuthView enableRestoreCredentials /> | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor edge case. When JS calls I think this is rare on mobile because JS and native normally share one client, but I haven't confirmed it. If it's expected, it might be worth a test case so the behavior is pinned down. Generated by Claude Code |
||
| if (current == null) return false | ||
| val remaining = current.signedInSessionIds() | ||
| return previous?.signedInSessionIds().orEmpty().any { it !in remaining } | ||
| } | ||
|
|
||
| private fun Client.signedInSessionIds(): Set<String> = | ||
| 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)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This fires on any loss of an active or pending session, including a session that expires or is revoked from the dashboard. In clerk-android 1.3.0, native only clears the key on an explicit sign-out ( That difference affects the case Restore Credentials exists for. Picture a user whose session timed out and who moves to a new phone before signing back in on the old one. Their key would still have restored them on the new phone. With this watcher, opening the app once after expiry deletes the key on this device and in the cloud backup. I got this from reading the code and haven't run it on a device. Is the broader behavior intended? If not, one option is to clear only when the ended session's status is Generated by Claude Code |
||
| launch { clearRestoreCredential() } | ||
| } | ||
| previousClient = client | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: set -eu
printf '%s\n' '--- diff ---'
git diff --no-ext-diff --unified=40 7beb0b09bac1094053c58f5be9ec592f1551e384 a8365c1e3619efbdb9671cffb98c8d67c13ca1b7 -- packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt packages/expo-native-components/android/build.gradle
printf '%s\n' '--- module relevant source ---'
nl -ba packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt | sed -n '150,270p'
printf '%s\n' '--- dependency declarations ---'
nl -ba packages/expo-native-components/android/build.gradle | sed -n '1,180p'Repository: clerk/javascript Length of output: 24102 🏁 Script executed: set -eu
printf '%s\n' '--- version ---'
nl -ba gradle.properties | sed -n '1,30p'
printf '%s\n' '--- AuthView restore behavior ---'
nl -ba source/ui/src/main/java/com/clerk/ui/auth/AuthView.kt | sed -n '90,245p'
printf '%s\n' '--- Clerk switchConfiguration and clientFlow ---'
rg -n -F -- 'switchConfiguration' source/api source/ui
rg -n -F -- 'clientFlow' source/api source/uiRepository: clerk/clerk-android Length of output: 15885 🏁 Script executed: set -eu
printf '%s\n' '--- switchConfiguration implementation ---'
nl -ba source/api/src/main/kotlin/com/clerk/api/Clerk.kt | sed -n '880,970p'
printf '%s\n' '--- configuration reset implementation ---'
rg -n -F -- 'fun switchConfiguration' source/api/src/main/kotlin
rg -n -F -- 'reset' source/api/src/main/kotlin/com/clerk/api/configuration source/api/src/main/kotlin/com/clerk/api/state source/api/src/main/kotlin/com/clerk/api/Clerk.kt | head -80
printf '%s\n' '--- restore credential APIs and clear implementation ---'
rg -n -F -- 'restoreCredentials' source/api/src/main/kotlin source/ui/src/main
rg -n -F -- 'class RestoreCredential' source/api/src/main/kotlin source/ui/src/main
rg -n -F -- 'clear()' source/api/src/main/kotlin/com/clerk/api source/ui/src/main | head -80
printf '%s\n' '--- configuration switch test ---'
nl -ba source/api/src/test/java/com/clerk/api/sdk/ClerkConfigurationSwitchTest.kt | sed -n '70,145p'Repository: clerk/clerk-android Length of output: 15296 🏁 Script executed: set -eu
printf '%s\n' '--- restore credentials public API ---'
nl -ba source/api/src/main/kotlin/com/clerk/api/restorecredentials/RestoreCredentials.kt | sed -n '1,205p'
printf '%s\n' '--- restore credential manager and store ---'
nl -ba source/api/src/main/kotlin/com/clerk/api/restorecredentials/RestoreCredentialManager.kt | sed -n '1,180p'
nl -ba source/api/src/main/kotlin/com/clerk/api/restorecredentials/RestoreCredentialStore.kt | sed -n '1,160p'Repository: clerk/clerk-android Length of output: 15467 Clear restore credentials before switching configurations.
Await cleanup before resetting the SDK: 🐛 Suggested fix activePublishableKey != pubKey || activeProxyUrl != normalizedProxyUrl -> {
+ clearRestoreCredential()
Clerk.switchConfiguration(context, pubKey, clerkConfigurationOptions(normalizedProxyUrl))
true
}🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
| } | ||
|
|
@@ -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) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
Comment on lines
+77
to
+78
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win Document the cleanup failure mode. The PR objective states that failed credential clears are logged and not retried. A failed clear can leave the restore credential present, but this text states that deletion always succeeds. Document deletion as best effort, or add a retry guarantee before using unconditional wording. Update the matching changeset text if needed. 🤖 Prompt for AI Agents
Comment on lines
+77
to
+78
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Because this sits under Generated by Claude Code |
||
| * | ||
| * 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. | ||
| * | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: clerk/javascript
Length of output: 547
🏁 Script executed:
Repository: clerk/javascript
Length of output: 2926
Add the prop to the public Expo reference.
The repository PR checklist requires applicable documentation updates.
enableRestoreCredentialsis a new publicAuthViewprop, but the Expo reference does not document it. Add its Android-only behavior toclerk-docsbefore release.🧰 Tools
🪛 markdownlint-cli2 (0.23.3)
[warning] 5-5: First line in a file should be a top-level heading
(MD041, first-line-heading, first-line-h1)
🤖 Prompt for AI Agents