feat: add trusted-device (biometric) sign-in - #794
Draft
swolfand wants to merge 5 commits into
Draft
Conversation
Port of clerk-ios#486 to Android. Adds biometric trusted-device sign-in end to end. API module: - TrustedDevice, TrustedDeviceChallenge, TrustedDeviceValidation, TrustedDeviceAvailability, and TrustedDevicePolicy models - TrustedDeviceApi for me/trusted_devices (list/prepare/attempt/revoke) and client/trusted_devices/validate - Android Keystore-backed key manager: biometric-gated EC P-256 keys, ES256 JWK export, challenge signing via BiometricPrompt CryptoObject (API 28+) - Encrypted local credential metadata store on StorageHelper - TrustedDevices domain (Clerk.trustedDevices) with enroll, signIn, revoke, availability, server reconciliation, and stale-credential cleanup; Clerk.auth.signInWithTrustedDevice and SignIn.CreateParams.Strategy.TrustedDevice entry points - trusted_device strategy, Verification.trustedDeviceChallenge, Factor.trustedDeviceId, per-error meta, and native_settings on auth_config with public Clerk getters UI module: - "Continue with biometrics" button on AuthStartView with last-used badge support and server-validated availability - Post-auth trusted-device enrollment prompt routed before dismissal, gated by dashboard prompt settings and per-user seen state - Biometric sign-in toggle section on the user-profile security screen - Local credential cleanup after account deletion Keys never leave the device; the server stores only the public key and verifies signed challenges. Unlike iOS, no installation marker is needed because Keystore keys do not survive uninstall, and stale restored metadata self-heals via the missing-key cleanup path.
The prebuilt-ui sample swapped AuthView out the moment userFlow emitted a signed-in user with no pending session task, unmounting the flow before the trusted-device enrollment prompt could render. Session task screens only survived because pendingTaskKey kept the condition false; the enrollment prompt has no server-side task key. - prebuilt-ui: gate on Clerk.isInitialized and keep AuthView visible until onAuthComplete fires instead of switching on raw session state - AuthView: document that hosts must wait for onAuthComplete or post-auth steps are skipped - TrustedDeviceEnrollmentPrompt: log each skip reason so gating issues (e.g. dashboard flags off, biometrics not enrolled) are visible in logcat
WorkbenchAuthGate reset isAuthFlowActive whenever the user became non-null, unmounting AuthView before the trusted-device enrollment prompt could render. Only flip it on via state changes and let AuthView's onAuthComplete turn it off, matching the prebuilt-ui sample.
Track non-dismissible authentication flows at the SDK level so active sessions do not hide pending session tasks or trusted-device enrollment. Update root auth gates and add regression coverage for client-sync ordering and flow lifecycle.
Android Keystore produces DER-encoded ECDSA signatures, while the trusted-device API expects the fixed-width R || S form. Convert and validate signatures before Base64URL encoding and cover malformed inputs.
Comment on lines
+1125
to
+1128
| private fun resetAuthFlowState() { | ||
| pendingAuthFlowCompletion = null | ||
| setAuthFlowPending(false) | ||
| } |
There was a problem hiding this comment.
🟠 High api/Clerk.kt:1125
Clerk.reset() calls resetAuthFlowState() which clears the pending flags but leaves authFlowRegistrationId set. After reinitialization, a completed sign-in still flows through holdAuthFlowCompletion, which sees the stale registration ID and sets isAuthFlowComplete to false, blocking the UI from transitioning to authenticated content until the old AuthFlowRegistration is closed. Clear authFlowRegistrationId in resetAuthFlowState() so the stale registration can no longer hold the flow pending.
Suggested change
| private fun resetAuthFlowState() { | |
| pendingAuthFlowCompletion = null | |
| setAuthFlowPending(false) | |
| } | |
| private fun resetAuthFlowState() { | |
| authFlowRegistrationId = null | |
| pendingAuthFlowCompletion = null | |
| setAuthFlowPending(false) | |
| } |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @source/api/src/main/kotlin/com/clerk/api/Clerk.kt around lines 1125-1128:
`Clerk.reset()` calls `resetAuthFlowState()` which clears the pending flags but leaves `authFlowRegistrationId` set. After reinitialization, a completed sign-in still flows through `holdAuthFlowCompletion`, which sees the stale registration ID and sets `isAuthFlowComplete` to `false`, blocking the UI from transitioning to authenticated content until the old `AuthFlowRegistration` is closed. Clear `authFlowRegistrationId` in `resetAuthFlowState()` so the stale registration can no longer hold the flow pending.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Port of clerk-ios#486 to Android. Adds trusted-device (biometric) sign-in: users can enroll their device while signed in, then sign back in with the system biometric prompt instead of their regular first factor.
Fixes MOBILE-587.
How it works
Enrollment generates a biometric-gated EC P-256 key in the Android Keystore and registers its public key (ES256 JWK) with Clerk via
POST me/trusted_devices/prepare→ sign challenge →POST me/trusted_devices/attempt. Sign-in creates atrusted_devicesign-in attempt, signs the returned challenge with the local key behind aBiometricPrompt(CryptoObject-bound, per-use auth), and completes the first factor. The private key never leaves the device.What's included
clerk-android-apicom.clerk.api.trusteddevicepackage:TrustedDevice,TrustedDeviceChallenge,TrustedDeviceAvailability,TrustedDevicePolicymodels; Keystore key manager; encrypted local credential metadata store (onStorageHelper); and theTrustedDevicesdomain exposed asClerk.trustedDevices(enroll,signIn,revoke,list,availability, server reconciliation, stale-credential cleanup)TrustedDeviceApi:GET/POST me/trusted_devices(+/prepare,/attempt),DELETE me/trusted_devices/{id},POST client/trusted_devices/validateClerk.auth.signInWithTrustedDevice()andSignIn.CreateParams.Strategy.TrustedDevicetrusted_devicestrategy constant,Verification.trustedDeviceChallenge,Factor.trustedDeviceId, per-errormetaon theErrormodel,native_settingsonauth_configwith publicClerkgetters, newUSE_BIOMETRICpermission, and theandroidx.biometricdependency (capability checks only — the prompt itself uses the framework API so Compose-only hosts don't needFragmentActivity)clerk-android-uiLastUsedAuth.TrustedDevicecaseAuthDestination.TrustedDeviceEnrollmentscreen offered after sign-in/sign-up per the dashboard prompt settings, with per-user "seen" persistence (sign-in flow only, matching iOS)Policy mapping (iOS → Android)
biometryCurrentSetBIOMETRIC_STRONG, invalidated by new biometric enrollmentbiometryAnyBIOMETRIC_STRONG, survives enrollment changesbiometryOrDevicePasscode(default)BIOMETRIC_STRONG | DEVICE_CREDENTIALon API 30+; biometric-only on API 28–29Deliberate deviations from the iOS PR
Clerk+Installation.swift): iOS needs it because Keychain items survive uninstall; Android Keystore keys don't. Stale metadata restored via auto-backup self-heals through the missing-key cleanup path.UNSUPPORTED_PLATFORM(frameworkBiometricPromptrequirement; minSdk is 24).Testing
native_settingspresent/absent), Retrofit path/field contracts for all five endpoints, local credential store round-trips + malformed-record resilience,LastUsedAuthtrusted-device casesspotlessCheck,detekt(no new issues),:source:api:test,:source:ui:testDebugall passReviewer notes / open items
GET me/trusted_deviceslist decode follows the SDK's existing plain-array convention forList<T>responses; worth confirming FAPI doesn't wrap it in the{response, client}envelope for this endpoint.tools:ignore="MissingTranslation"); the 14 locale files need translations as a follow-up.🤖 Generated with Claude Code
Note
Add biometric trusted-device sign-in and enrollment to Android
TrustedDevicessubsystem (Clerk.trustedDevices) that manages the full lifecycle of biometric-gated device credentials: enrollment, sign-in, revocation, local credential storage, and server validation.DefaultTrustedDeviceKeyManager, with DER-to-raw signature conversion and per-policy authenticator gating.TrustedDeviceEnrollmentView) that routes users through biometric enrollment after sign-in or sign-up.Clerk.isAuthFlowCompleteFlowto track when SDK-owned post-auth steps (e.g. enrollment prompt) are complete, replacing manual session/task tracking inWorkbenchAuthGateand the prebuilt UI sample.🖇️ Linked Issues
Implements MOBILE-587 — Add Biometrics to Android.
📊 Macroscope summarized 65190ad. 7 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted
🗂️ Filtered Issues
No issues evaluated.