Skip to content

refactor(api): route both auth API layers through one implementation per operation - #1034

Merged
swolfand merged 2 commits into
mike/auth-facade-primaryfrom
mike/auth-facade-delegation
Oct 5, 2026
Merged

swolfand merged 2 commits into
mike/auth-facade-primaryfrom
mike/auth-facade-delegation

Conversation

@swolfand

@swolfand swolfand commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Why

Architecture review finding #9: "Two public auth APIs that drift apart." About 23 operations have two to seven public entry points each, and each entry point built its own request. #1026 fixed the drift bugs the review found. This PR removes the cause: for each operation, every entry point now calls one implementation.

Stack: #1026 (drift fixes) → this PR → #1035 (facade gaps for the prebuilt UI).

Scope

  • New auth/AuthFlows.kt holds the canonical implementations: createSignIn, authenticateSignInWithRedirect, createSignUp and authenticateSignUpWithRedirect.
    • Each Clerk.auth method now builds the same typed params that the older entry point accepts, then calls one of these. The typed params are SignIn.CreateParams.Strategy, *.AuthenticateWithRedirectParams and SignUp.CreateParams.
    • SignIn.create, SignUp.create and both authenticateWithRedirect methods call the same functions. Request shape, locale and AuthEvent.Error reporting now live in one place.
  • Step verbs delegate to the general entry point:
    • verifyCode, verifyWithPassword and verifyWithPasskey → attemptFirstFactor
    • verifyMfaCode → attemptSecondFactor
    • reload → get
    • SignUp.verifyCode → attemptVerification
    • SignUp.update {} → update(SignUpUpdateParams)
    • sendMfaPhoneCode / sendMfaEmailCode and prepareSecondFactor(ids) → prepareSecondFactor(strategy)
  • Two behaviour fixes came out of the delegation. Each has a test that fails before the change:
    • prepareSecondFactor(emailAddressId = …) prepared the phone factor whenever the sign-in also supported phone. The prebuilt UI hits this when a user with both phone and email MFA picks email. An explicit id now picks its channel.
    • sendMfaPhoneCode / sendMfaEmailCode skipped the needs_second_factor / needs_client_trust status check that prepareSecondFactor makes.
  • No deprecations in this stack. The prebuilt UI still calls the old layer, so deprecating it now would flood the build with warnings. Deprecation with ReplaceWith follows once the UI migration PR lands, and removal in the next major.

Operation × entry-point matrix

Legend:

  • Canonical is the public API we will steer people to.
  • → shows what the other entry point calls after this stack.
  • "≡" means the request is identical. AuthLayerParityTest pins rows 1–4, 16, 18, 22, 23, 27 and 29 against literal request maps, and rows 11, 12, 25 and 26 against the SSOService call. The other rows were checked by reading the code.
  • Rows marked (feat(api): fill the Clerk.auth gaps the prebuilt UI needs #1035) gain their facade method in the next PR.
# Operation Canonical Other entry points → now calls Notes
1 Start sign-in with identifier Clerk.auth.signIn { email / phone / username / identifier } SignIn.create(Strategy.Identifier) → createSignIn ≡; SignIn.create(Map) raw (no locale added) identifier = added in #1035
2 Password sign-in Clerk.auth.signInWithPassword {} SignIn.create(Strategy.Password) → createSignIn ≡; SignIn.create(Identifier(strategy = "password", password = …)) → createSignIn
3 OTP sign-in (create + send) Clerk.auth.signInWithOtp { email / phone } SignIn.create(Strategy.EmailCode / PhoneCode) → createSignIn ≡
4 Ticket sign-in Clerk.auth.signInWithTicket SignIn.create(Strategy.Ticket) → createSignIn ≡
5 Passkey sign-in Clerk.auth.signInWithPasskey(prefer…) SignIn.create(Strategy.Passkey) → facade; SignIn.create(Passkey, prefer…) → facade (#1035); SignIn.authenticateWithGoogleCredential([PASSKEY]) same service call authenticateWithGoogleCredential stays the general multi-credential entry point
6 Passkey on an existing sign-in (incl. MFA) signIn.authenticateWithPasskey() prepareSecondFactor(Passkey) + attemptSecondFactor(Passkey) are its steps single entry point
7 Biometric sign-in Clerk.auth.signInWithBiometrics SignIn.create(Strategy.BiometricCredential) → createSignIn ≡
8 Restore-credential sign-in Clerk.auth.signInWithRestoreCredential n/a single entry point
9 Google One Tap sign-in Clerk.auth.signInWithGoogleOneTap(transferable) (#1035) SignIn.authenticateWithGoogleOneTap → facade (#1035); Clerk.auth.signInWithIdToken { GOOGLE } takes your own token
10 Google One Tap, sign-up first SignUp.authenticateWithGoogleOneTap() Clerk.auth.signUpWithGoogleOneTap() → signInWithGoogleOneTap(true): sign-in first, deliberately (#665) Not identical, so not delegated. Both can end in sign-in or sign-up.
11 OAuth sign-in Clerk.auth.signInWithOAuth(provider, transferable, redirectUrl) SignIn.authenticateWithRedirect(OAuth) → authenticateSignInWithRedirect ≡ transferable / redirectUrl added in #1035. The old params also carry identifier, emailAddress and legalAccepted.
12 Enterprise SSO sign-in Clerk.auth.signInWithEnterpriseSso { email } SignIn.authenticateWithRedirect(EnterpriseSSO) → authenticateSignInWithRedirect ≡
13 OAuth / Enterprise SSO on an existing sign-in signIn.authenticateWithOAuth(…) / signIn.authenticateWithEnterpriseSso(…) (#1035) prepareFirstFactor(OAuth / EnterpriseSSO) + authenticateWithPreparedRedirect are its steps
14 Send first-factor code signIn.sendEmailCode(id) / sendPhoneCode(id) signIn.sendCode { email / phone } resolves the factor, then → prepareFirstFactor (#1026); prepareFirstFactor(EmailCode / PhoneCode)
15 Send email link (sign-in) signIn.sendEmailLink(id) prepareFirstFactor(EmailLink) needs a caller-made PKCE pair; Clerk.auth.startEmailLinkSignIn(email) creates and sends PKCE storage keeps these separate
16 Verify first factor signIn.verifyCode(code) / verifyWithPassword / verifyWithPasskey → attemptFirstFactor ≡ verifyCode infers the strategy from firstFactorVerification
17 Send MFA code signIn.sendMfaPhoneCode(id) / sendMfaEmailCode(id) → prepareSecondFactor(strategy); prepareSecondFactor(phoneNumberId, emailAddressId) → same channel fix and status check in this PR
18 Verify MFA signIn.verifyMfaCode(code, type) → attemptSecondFactor ≡
19 Send reset-password code signIn.sendResetPasswordEmailCode(id) / PhoneCode(id) (#1035) sendResetPasswordCode { email / phone } resolves the factor, then → those; prepareFirstFactor(ResetPassword*); SignIn.create(Strategy.ResetPassword*) → createSignIn
20 Reset password signIn.resetPassword(…) n/a single entry point
21 Reload sign-in signIn.reload() signIn.get() (reload → get ≡)
22 Create sign-up Clerk.auth.signUp {} SignUp.create(Standard) → createSignUp ≡; SignUp.create(Map) raw
23 Ticket sign-up Clerk.auth.signUpWithTicket SignUp.create(Ticket) → createSignUp ≡
24 ID-token sign-up Clerk.auth.signUpWithIdToken(token, GOOGLE) {} SignUp.create(GoogleOneTap(token)) → createSignUp (no name fields)
25 OAuth sign-up Clerk.auth.signUpWithOAuth(provider, redirectUrl, unsafeMetadata) SignUp.authenticateWithRedirect(OAuth) → authenticateSignUpWithRedirect ≡ redirectUrl / unsafeMetadata added in #1035
26 Enterprise SSO sign-up Clerk.auth.signUpWithEnterpriseSso { email } SignUp.authenticateWithRedirect(EnterpriseSSO) → authenticateSignUpWithRedirect ≡ was running sign-in until #1026
27 Update sign-up signUp.update {} → update(SignUpUpdateParams) ≡ SignUpBuilder.unsafeMetadata is still ignored here because SignUpUpdateParams has no field for it. Follow-up.
28 Send sign-up verification signUp.sendEmailCode() / sendPhoneCode() / sendEmailLink() signUp.sendCode {} → those; prepareVerification(…)
29 Verify sign-up signUp.verifyCode(code, type) → attemptVerification ≡
30 Sign out / set active / token / revoke Clerk.auth.signOut / setActive / getToken / revokeSession session.fetchToken, session.revoke() getToken and revokeSession do not emit AuthEvent.Error. Token fetches are frequent, and their failures are not auth-flow errors.
31 Callbacks / hosted auth / magic link Clerk.auth.handle(uri), startHostedAuth, startEmailLinkSignIn / handleMagicLinkDeepLink / completeMagicLink Clerk.auth.nativeMagicLink.* Magic link uses NativeMagicLinkError, not ClerkErrorResponse

Prebuilt UI call sites → facade equivalent

Every old-layer call in source/ui now has a facade equivalent (after #1035). The UI migration PR will switch these over once the UI ViewModel refactor (item 10) lands:

UI call site Facade equivalent
AuthStartViewModel: SignIn.create(Passkey, preferImmediatelyAvailableCredentials = true) Clerk.auth.signInWithPasskey(preferImmediatelyAvailableCredentials = true) (#1035)
AuthStartViewModel: SignIn.create(Identifier(identifier)) Clerk.auth.signIn { identifier = … } (#1035)
AuthStartViewModel: SignUp.create(Standard(email/phone/username, unsafeMetadata)) Clerk.auth.signUp { … }
AuthStartViewModel: SignIn.authenticateWithGoogleOneTap(transferable) Clerk.auth.signInWithGoogleOneTap(transferable) (#1035)
AuthStartViewModel: SignIn.authenticateWithRedirect(OAuth(provider), transferable) Clerk.auth.signInWithOAuth(provider, transferable) (#1035)
AuthStartViewModel: SignUp.authenticateWithRedirect(OAuth(provider, unsafeMetadata)) Clerk.auth.signUpWithOAuth(provider, unsafeMetadata = …) (#1035)
AuthStartViewModel: prepareFirstFactor(EnterpriseSSO()) + authenticateWithPreparedRedirect signIn.authenticateWithEnterpriseSso(transferable) (#1035)
SignInRedirect: prepareFirstFactor(OAuth) + authenticateWithPreparedRedirect signIn.authenticateWithOAuth(provider, transferable) (#1035)
SignInPrepareHandler: prepareFirstFactor(EmailCode / PhoneCode(id)) signIn.sendEmailCode(id) / sendPhoneCode(id)
SignInPrepareHandler: prepareFirstFactor(ResetPasswordEmailCode / PhoneCode(id)) signIn.sendResetPasswordEmailCode(id) / PhoneCode(id) (#1035)
SignInPrepareHandler: prepareSecondFactor(phoneNumberId) / (emailAddressId = …) signIn.sendMfaPhoneCode(id) / sendMfaEmailCode(id)
SignInAttemptHandler, SetPasswordViewModel: attemptFirstFactor(EmailCode / PhoneCode / ResetPassword* / Password) signIn.verifyCode(code) / verifyWithPassword(pw)
SignInAttemptHandler, BackupCodeViewModel: attemptSecondFactor(TOTP / PhoneCode / EmailCode / BackupCode) signIn.verifyMfaCode(code, MfaType.*)
PasskeyViewModel: SignIn.authenticateWithGoogleCredential([PASSKEY]) Clerk.auth.signInWithPasskey()
PasskeyViewModel: signIn.authenticateWithPasskey() unchanged (already canonical)
ResetPasswordViewModel: signIn.resetPassword(…) unchanged (single entry point)
SignUpCodeViewModel: prepareVerification(EmailCode / PhoneCode), attemptVerification(…) signUp.sendEmailCode() / sendPhoneCode(), signUp.verifyCode(code, type)
SignUpEmailLinkViewModel: prepareVerification(EmailLink()) signUp.sendEmailLink()
CollectFieldViewModel, CompleteProfileViewModel: update(SignUpUpdateParams.Standard) signUp.update { … }

Blast Radius

Testing

  • AuthLayerParityTest (new) pins that each pair of entry points sends Clerk the same, literal request. These pass before and after: they guard against future drift.
  • SignInPrepareSecondFactorTest (new, 4 cases). Two cases failed against fix(api): stop the two auth API layers drifting on enterprise sign-up, sendCode and error events #1026's sources (stashed and run): the email-channel fix and the sendMfaPhoneCode status check.
  • ./gradlew :source:api:spotlessApply :source:api:detekt :source:api:testDebugUnitTest: all pass.
  • ./gradlew :source:ui:testDebugUnitTest: all pass. The prebuilt UI calls the old layer and is unchanged.

Review follow-ups

The "fail second-factor sends without throwing and pin parity requests" commit addresses review feedback:

  • sendMfaPhoneCode / sendMfaEmailCode checked status only after resolving the factor id, so on a needs_first_factor sign-in (where supported_second_factors is null) they threw IllegalStateException. They now check status first and, like the no-arg prepareSecondFactor(), return a second_factor_strategy_not_supported failure instead of throwing when no factor is available.
  • prepareSecondFactor(phoneNumberId, emailAddressId) KDoc documents the precedence: explicit phone, explicit email, then supported factors.
  • AuthLayerParityTest asserts literal request maps. Both layers share one implementation, so comparing them with each other alone could no longer catch drift.
  • The Map overload of Auth.createSignUp is renamed postSignUp (mirroring postSignIn), so it isn't mistaken for the locale-free SignUp.create(Map).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Second-factor preparation now selects the requested verification method when available, otherwise chooses a supported method, and returns a clear failure when none is available.
    • Requests to prepare a second factor are rejected when sign-in is not at the required stage. Phone and email code options follow the same checks.
    • Authentication requests now behave consistently across sign-in and sign-up entry points, including verification, updates, and OAuth or enterprise redirects.

@coderabbitai

coderabbitai Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: clerk/clerk-android/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Team
  • Run ID: 669a0c92-89cb-4b35-80e0-af2eac8976c2
📥 Commits

Reviewing files that changed from the base of the PR and between 1c07b53 and 759e61d.

📒 Files selected for processing (1)
  • source/api/src/main/kotlin/com/clerk/api/signup/SignUpExtensions.kt

Included review availability: This review used your included allowance. 5 included reviews remain after this review. Your included PR review attempts over the past 7 days set your current allowance at 8 reviews per hour.


📝 Walkthrough

Walkthrough

The change centralizes sign-in and sign-up creation and redirect operations in shared Auth flows. SignIn and SignUp delegate related operations to shared methods. Second-factor preparation validates sign-in status and selects a phone or email channel.

Changes

Authentication flow consolidation

Layer / File(s) Summary
Shared creation and redirect flows
source/api/src/main/kotlin/com/clerk/api/auth/*
AuthFlows handles typed sign-in and sign-up creation and redirects. Auth passes typed strategy parameters to those shared flows.
SignIn and SignUp operation delegation
source/api/src/main/kotlin/com/clerk/api/signin/SignIn.kt, source/api/src/main/kotlin/com/clerk/api/signin/SignInExtensions.kt, source/api/src/main/kotlin/com/clerk/api/signup/*, source/api/src/test/java/com/clerk/api/auth/AuthLayerParityTest.kt
SignIn and SignUp delegate creation, redirect, verification, update, and reload operations. Parity tests compare requests and redirect arguments across the API layers.
Second-factor selection and validation
source/api/src/main/kotlin/com/clerk/api/signin/SignIn.kt, source/api/src/main/kotlin/com/clerk/api/signin/SignInPasskeyExtensions.kt, source/api/src/test/java/com/clerk/api/signin/SignInPrepareSecondFactorTest.kt
Second-factor preparation checks sign-in status and selects an explicit or supported phone or email factor. Tests cover channel selection and invalid status.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Refactor

Merge Risk: ⚪ Minimal · up to 759e6

No actionable issue is established for this change; the sign-up update payload remains an unverified compatibility question.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 60 functions across 9 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the shared-implementation refactor across both auth API layers.
Description check ✅ Passed The description explains why the change is needed, details its scope and behavior fixes, and reports testing. It does not use the template headings “What & why” or “What to focus on,” and it omits “Sc…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

…per operation

Clerk.auth methods now build the same typed params the SignIn/SignUp entry
points take (SignIn.CreateParams.Strategy, AuthenticateWithRedirectParams,
SignUp.CreateParams) and call a shared internal implementation in AuthFlows.kt.
SignIn.create, SignUp.create and both authenticateWithRedirect methods call the
same implementations, so request shape, locale and error reporting live in one
place.

Step verbs delegate to the general entry point: verifyCode, verifyWithPassword
and verifyWithPasskey go to attemptFirstFactor; verifyMfaCode to
attemptSecondFactor; reload to get; SignUp.verifyCode to attemptVerification;
SignUp.update {} to update(SignUpUpdateParams).

Two behaviour fixes fall out of the delegation:
- prepareSecondFactor(emailAddressId = ...) prepared the phone factor whenever
  one existed. An explicit id now picks its channel.
- sendMfaPhoneCode / sendMfaEmailCode skipped the status check that
  prepareSecondFactor makes. They now delegate to it.
…quests

sendMfaPhoneCode and sendMfaEmailCode resolved the factor id before the
status check, so on a sign-in still in needs_first_factor they threw
IllegalStateException instead of returning sign_in_status_invalid. They,
and the no-arg prepareSecondFactor, now check status first and return a
second_factor_strategy_not_supported failure when no factor is available.

Document prepareSecondFactor's id precedence, assert AuthLayerParityTest
requests against literal maps now that both layers share one
implementation, and rename the Map overload of Auth.createSignUp to
postSignUp so it is not mistaken for the locale-free SignUp.create(Map).
@swolfand
swolfand force-pushed the mike/auth-facade-delegation branch from 1c07b53 to 759e61d Compare October 5, 2026 22:00
@swolfand
swolfand merged commit 6f812d0 into main Oct 5, 2026
16 checks passed
@swolfand
swolfand deleted the mike/auth-facade-delegation branch October 5, 2026 22:19
swolfand added a commit that referenced this pull request Oct 5, 2026
* refactor(ui): move the prebuilt UI onto the Clerk.auth facade

Swap every old-layer auth call in source/ui for its Clerk.auth / step-verb
equivalent from the #1034 matrix. Requests are unchanged. First-factor code
attempts keep attemptFirstFactor because signIn.verifyCode infers the
strategy instead of taking the one the UI chose.

* test(ui): pin the MFA type and sign-up code channel the prebuilt UI sends

SignInAttemptHandlerTest verified verifyMfaCode(any(), any()), so a
handler sending the wrong MfaType still passed. It now verifies the
exact code and type. SignUpCodeViewModel's email/phone mapping had no
test at all.
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