Skip to content

feat: add trusted-device (biometric) sign-in - #794

Draft
swolfand wants to merge 5 commits into
mainfrom
sam/mobile-587-add-biometrics-to-android
Draft

feat: add trusted-device (biometric) sign-in#794
swolfand wants to merge 5 commits into
mainfrom
sam/mobile-587-add-biometrics-to-android

Conversation

@swolfand

@swolfand swolfand commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

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 a trusted_device sign-in attempt, signs the returned challenge with the local key behind a BiometricPrompt (CryptoObject-bound, per-use auth), and completes the first factor. The private key never leaves the device.

What's included

clerk-android-api

  • New com.clerk.api.trusteddevice package: TrustedDevice, TrustedDeviceChallenge, TrustedDeviceAvailability, TrustedDevicePolicy models; Keystore key manager; encrypted local credential metadata store (on StorageHelper); and the TrustedDevices domain exposed as Clerk.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/validate
  • Public entry points: Clerk.auth.signInWithTrustedDevice() and SignIn.CreateParams.Strategy.TrustedDevice
  • Plumbing: trusted_device strategy constant, Verification.trustedDeviceChallenge, Factor.trustedDeviceId, per-error meta on the Error model, native_settings on auth_config with public Clerk getters, new USE_BIOMETRIC permission, and the androidx.biometric dependency (capability checks only — the prompt itself uses the framework API so Compose-only hosts don't need FragmentActivity)

clerk-android-ui

  • Auth start: "Continue with biometrics" button (shown when the feature is enabled, a validated local credential exists, and mode isn't sign-up), with "Last used" badge support via a new LastUsedAuth.TrustedDevice case
  • Post-auth enrollment prompt: new AuthDestination.TrustedDeviceEnrollment screen offered after sign-in/sign-up per the dashboard prompt settings, with per-user "seen" persistence (sign-in flow only, matching iOS)
  • User profile → Security: "Biometric sign-in" toggle section that enrolls/revokes the device credential
  • Account deletion forgets local trusted-device credentials

Policy mapping (iOS → Android)

iOS policy Android key spec
biometryCurrentSet BIOMETRIC_STRONG, invalidated by new biometric enrollment
biometryAny BIOMETRIC_STRONG, survives enrollment changes
biometryOrDevicePasscode (default) BIOMETRIC_STRONG | DEVICE_CREDENTIAL on API 30+; biometric-only on API 28–29

Deliberate deviations from the iOS PR

  • No installation marker (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.
  • Enrollment prompt only fires when no session tasks are pending — Android's reactive session-task router would immediately push task screens over it, so iOS's "enrollment before tasks" ordering isn't reproducible here.
  • Generic "biometrics" wording and fingerprint icon instead of Face ID / Touch ID display names (Android can't reliably distinguish modalities).
  • Devices below API 28 report UNSUPPORTED_PLATFORM (framework BiometricPrompt requirement; minSdk is 24).

Testing

  • Unit tests: model serialization (incl. native_settings present/absent), Retrofit path/field contracts for all five endpoints, local credential store round-trips + malformed-record resilience, LastUsedAuth trusted-device cases
  • Paparazzi snapshots recorded for the security-screen toggle section (light/dark)
  • spotlessCheck, detekt (no new issues), :source:api:test, :source:ui:testDebug all pass

Reviewer notes / open items

  • Needs on-device verification against an instance with the trusted-device feature flag enabled — the biometric prompt, FAPI contract, and response envelopes couldn't be exercised in unit tests. In particular, the GET me/trusted_devices list decode follows the SDK's existing plain-array convention for List<T> responses; worth confirming FAPI doesn't wrap it in the {response, client} envelope for this endpoint.
  • New strings are English-only (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

  • Introduces a new TrustedDevices subsystem (Clerk.trustedDevices) that manages the full lifecycle of biometric-gated device credentials: enrollment, sign-in, revocation, local credential storage, and server validation.
  • Adds Android Keystore–backed EC P-256 key generation and biometric prompt signing via DefaultTrustedDeviceKeyManager, with DER-to-raw signature conversion and per-policy authenticator gating.
  • Extends the auth start screen with a "Continue with biometrics" button and post-auth enrollment prompt (TrustedDeviceEnrollmentView) that routes users through biometric enrollment after sign-in or sign-up.
  • Adds a trusted-device toggle to the User Profile security screen, and cleans up local credentials on account deletion.
  • Introduces Clerk.isAuthFlowCompleteFlow to track when SDK-owned post-auth steps (e.g. enrollment prompt) are complete, replacing manual session/task tracking in WorkbenchAuthGate and the prebuilt UI sample.
  • Risk: biometric key creation and signing require API 28+; device credential fallback for key operations requires API 30+.

🖇️ 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.

swolfand added 5 commits July 9, 2026 11:09
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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant