From b2d6251188b7e2694e7d8e398dca6227f34b901b Mon Sep 17 00:00:00 2001 From: Sam Wolfand Date: Mon, 2 Mar 2026 19:05:27 -0800 Subject: [PATCH 01/17] feat(auth): add native email-link flows for AuthView Implement native PKCE email-link support across Auth API and AuthView for sign-in and sign-up. Prioritize email_link when available, complete native deep links through the magic-link endpoint, and surface backend error codes like too_many_requests in UI flows. Also harden redirect handling and callback parsing for non-default proxy ports, add safe URI logging, and include regression tests for native magic-link completion, factor routing, and sign-up verification strategies. --- .../main/kotlin/com/clerk/api/Constants.kt | 1 + .../main/kotlin/com/clerk/api/auth/Auth.kt | 172 +++++-- .../kotlin/com/clerk/api/log/SafeUriLog.kt | 55 +++ .../NativeMagicLinkCompletionRunner.kt | 78 +++ .../api/magiclink/NativeMagicLinkManager.kt | 444 ++++++++++++++++++ .../com/clerk/api/magiclink/PkceUtil.kt | 34 ++ .../kotlin/com/clerk/api/network/ApiParams.kt | 4 + .../kotlin/com/clerk/api/network/ApiPaths.kt | 6 + .../kotlin/com/clerk/api/network/ClerkApi.kt | 6 + .../com/clerk/api/network/api/MagicLinkApi.kt | 17 + .../com/clerk/api/network/api/SignUpApi.kt | 2 +- .../magiclink/NativeMagicLinkRequests.kt | 44 ++ .../ClerkApiResultConverterFactory.kt | 7 +- .../kotlin/com/clerk/api/signin/SignIn.kt | 51 +- .../com/clerk/api/signin/SignInExtensions.kt | 52 +- .../kotlin/com/clerk/api/signup/SignUp.kt | 91 +++- .../com/clerk/api/signup/SignUpExtensions.kt | 8 +- .../clerk/api/sso/RedirectConfiguration.kt | 17 + .../com/clerk/api/sso/SSOManagerActivity.kt | 23 + .../java/com/clerk/api/log/SafeUriLogTest.kt | 39 ++ .../magiclink/MagicLinkDeepLinkParserTest.kt | 47 ++ .../NativeMagicLinkErrorMappingTest.kt | 45 ++ .../magiclink/NativeMagicLinkServiceTest.kt | 234 +++++++++ .../com/clerk/api/magiclink/PkceUtilTest.kt | 37 ++ .../clerk/api/signin/SignInExtensionsTest.kt | 115 +++++ .../SignUpEmailVerificationStrategyTest.kt | 120 +++++ .../api/sso/RedirectConfigurationTest.kt | 22 + .../clerk/api/sso/SSOManagerActivityTest.kt | 18 + .../java/com/clerk/ui/auth/AuthStartView.kt | 6 + .../main/java/com/clerk/ui/auth/AuthState.kt | 23 +- .../main/java/com/clerk/ui/auth/AuthView.kt | 6 + .../com/clerk/ui/core/common/StrategyKeys.kt | 1 + .../clerk/ui/signin/SignInFactorOneView.kt | 75 ++- .../signin/code/SignInFactorCodeViewModel.kt | 44 ++ .../ui/signin/code/SignInPrepareHandler.kt | 97 +++- .../emaillink/SignInFactorOneEmailLinkView.kt | 105 +++++ .../SignInFactorOneEmailLinkViewModel.kt | 62 +++ .../ui/signup/code/SignUpCodeViewModel.kt | 9 + .../signup/emaillink/SignUpEmailLinkView.kt | 90 ++++ .../emaillink/SignUpEmailLinkViewModel.kt | 64 +++ .../java/com/clerk/ui/util/TextIconHelper.kt | 9 + .../ui/auth/AuthStateSignUpRoutingTest.kt | 78 +++ .../ui/signin/SignInFactorOneViewTest.kt | 66 +++ .../code/SignInFactorCodeViewModelTest.kt | 98 ++++ .../signin/code/SignInPrepareHandlerTest.kt | 48 +- .../SignInFactorOneEmailLinkViewModelTest.kt | 71 +++ .../java/com/clerk/workbench/UiActivity2.kt | 10 +- 47 files changed, 2674 insertions(+), 77 deletions(-) create mode 100644 source/api/src/main/kotlin/com/clerk/api/log/SafeUriLog.kt create mode 100644 source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkCompletionRunner.kt create mode 100644 source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkManager.kt create mode 100644 source/api/src/main/kotlin/com/clerk/api/magiclink/PkceUtil.kt create mode 100644 source/api/src/main/kotlin/com/clerk/api/network/api/MagicLinkApi.kt create mode 100644 source/api/src/main/kotlin/com/clerk/api/network/model/magiclink/NativeMagicLinkRequests.kt create mode 100644 source/api/src/test/java/com/clerk/api/log/SafeUriLogTest.kt create mode 100644 source/api/src/test/java/com/clerk/api/magiclink/MagicLinkDeepLinkParserTest.kt create mode 100644 source/api/src/test/java/com/clerk/api/magiclink/NativeMagicLinkErrorMappingTest.kt create mode 100644 source/api/src/test/java/com/clerk/api/magiclink/NativeMagicLinkServiceTest.kt create mode 100644 source/api/src/test/java/com/clerk/api/magiclink/PkceUtilTest.kt create mode 100644 source/api/src/test/java/com/clerk/api/signin/SignInExtensionsTest.kt create mode 100644 source/api/src/test/java/com/clerk/api/signup/SignUpEmailVerificationStrategyTest.kt create mode 100644 source/ui/src/main/java/com/clerk/ui/signin/emaillink/SignInFactorOneEmailLinkView.kt create mode 100644 source/ui/src/main/java/com/clerk/ui/signin/emaillink/SignInFactorOneEmailLinkViewModel.kt create mode 100644 source/ui/src/main/java/com/clerk/ui/signup/emaillink/SignUpEmailLinkView.kt create mode 100644 source/ui/src/main/java/com/clerk/ui/signup/emaillink/SignUpEmailLinkViewModel.kt create mode 100644 source/ui/src/test/java/com/clerk/ui/auth/AuthStateSignUpRoutingTest.kt create mode 100644 source/ui/src/test/java/com/clerk/ui/signin/SignInFactorOneViewTest.kt create mode 100644 source/ui/src/test/java/com/clerk/ui/signin/emaillink/SignInFactorOneEmailLinkViewModelTest.kt diff --git a/source/api/src/main/kotlin/com/clerk/api/Constants.kt b/source/api/src/main/kotlin/com/clerk/api/Constants.kt index 0db66b74f..f68a5bc99 100644 --- a/source/api/src/main/kotlin/com/clerk/api/Constants.kt +++ b/source/api/src/main/kotlin/com/clerk/api/Constants.kt @@ -9,6 +9,7 @@ object Constants { object Strategy { const val PHONE_CODE = "phone_code" const val EMAIL_CODE = "email_code" + const val EMAIL_LINK = "email_link" const val TOTP = "totp" const val BACKUP_CODE = "backup_code" const val PASSWORD = "password" diff --git a/source/api/src/main/kotlin/com/clerk/api/auth/Auth.kt b/source/api/src/main/kotlin/com/clerk/api/auth/Auth.kt index ba2942509..3be070754 100644 --- a/source/api/src/main/kotlin/com/clerk/api/auth/Auth.kt +++ b/source/api/src/main/kotlin/com/clerk/api/auth/Auth.kt @@ -14,8 +14,12 @@ import com.clerk.api.auth.builders.SignUpBuilder import com.clerk.api.auth.builders.SignUpWithIdTokenBuilder import com.clerk.api.auth.types.IdTokenProvider import com.clerk.api.log.ClerkLog +import com.clerk.api.magiclink.NativeMagicLinkError +import com.clerk.api.magiclink.NativeMagicLinkManager +import com.clerk.api.magiclink.NativeMagicLinkService import com.clerk.api.network.ClerkApi import com.clerk.api.network.model.error.ClerkErrorResponse +import com.clerk.api.network.model.error.Error import com.clerk.api.network.serialization.ClerkResult import com.clerk.api.network.serialization.errorMessage import com.clerk.api.network.serialization.onFailure @@ -126,6 +130,10 @@ class Auth internal constructor() { val currentSignUp: SignUp? get() = if (Clerk.clientInitialized) Clerk.client.signUp else null + /** Native magic-link manager for PKCE-bound email link flows. */ + val nativeMagicLink: NativeMagicLinkManager + get() = NativeMagicLinkService + // endregion // region Sign In @@ -219,42 +227,100 @@ class Auth internal constructor() { builder.validate() val identifier = builder.email ?: builder.phone!! - val strategy = if (builder.email != null) EMAIL_CODE else PHONE_CODE - - val params = - mapOf( - "identifier" to identifier, - "strategy" to strategy, - "locale" to Clerk.locale.value.orEmpty(), - ) - - val result = - when (val createResult = ClerkApi.signIn.createSignIn(params)) { - is ClerkResult.Failure -> createResult - is ClerkResult.Success -> { - val signIn = createResult.value - // Prepare first factor to send the code - val prepareParams = - if (builder.email != null) { - SignIn.PrepareFirstFactorParams.EmailCode( - emailAddressId = - signIn.supportedFirstFactors?.find { it.strategy == EMAIL_CODE }?.emailAddressId - ?: "" - ) - } else { - SignIn.PrepareFirstFactorParams.PhoneCode( - phoneNumberId = - signIn.supportedFirstFactors?.find { it.strategy == PHONE_CODE }?.phoneNumberId - ?: "" - ) - } - ClerkApi.signIn.prepareSignInFirstFactor(signIn.id, prepareParams.toMap()) - } + val isEmailFlow = builder.email != null + val nativeEmailLinkResult = + if (isEmailFlow) { + signInWithNativeEmailLink(identifier) + } else { + null } + val result = nativeEmailLinkResult ?: createAndPrepareOtpSignIn(identifier, isEmailFlow) result.onFailure { emitAuthError(it) } return result } + private suspend fun signInWithNativeEmailLink( + identifier: String + ): ClerkResult { + return when (val nativeResult = nativeMagicLink.startEmailLinkSignIn(identifier)) { + is ClerkResult.Success -> ClerkResult.success(nativeResult.value) + is ClerkResult.Failure -> + ClerkResult.apiFailure( + ClerkErrorResponse( + errors = + listOf( + Error( + message = "is invalid", + longMessage = "Native email-link sign-in failed", + code = nativeResult.error?.reasonCode, + ) + ) + ) + ) + } + } + + private suspend fun createAndPrepareOtpSignIn( + identifier: String, + isEmailFlow: Boolean, + ): ClerkResult { + val params = mapOf("identifier" to identifier, "locale" to Clerk.locale.value.orEmpty()) + return when (val createResult = ClerkApi.signIn.createSignIn(params)) { + is ClerkResult.Failure -> createResult + is ClerkResult.Success -> prepareOtpFirstFactor(createResult.value, isEmailFlow) + } + } + + private suspend fun prepareOtpFirstFactor( + signIn: SignIn, + isEmailFlow: Boolean, + ): ClerkResult { + return if (isEmailFlow) { + val emailAddressId = + signIn.supportedFirstFactors + ?.find { it.strategy == EMAIL_CODE && it.emailAddressId != null } + ?.emailAddressId + if (emailAddressId == null) { + unsupportedFirstFactorError(EMAIL_CODE) + } else { + ClerkApi.signIn.prepareSignInFirstFactor( + signIn.id, + SignIn.PrepareFirstFactorParams.EmailCode(emailAddressId = emailAddressId).toMap(), + ) + } + } else { + val phoneNumberId = + signIn.supportedFirstFactors + ?.find { it.strategy == PHONE_CODE && it.phoneNumberId != null } + ?.phoneNumberId + if (phoneNumberId == null) { + unsupportedFirstFactorError(PHONE_CODE) + } else { + ClerkApi.signIn.prepareSignInFirstFactor( + signIn.id, + SignIn.PrepareFirstFactorParams.PhoneCode(phoneNumberId = phoneNumberId).toMap(), + ) + } + } + } + + private fun unsupportedFirstFactorError( + strategy: String + ): ClerkResult.Failure { + return ClerkResult.apiFailure( + ClerkErrorResponse( + errors = + listOf( + Error( + message = "is invalid", + longMessage = "$strategy is not supported for this sign-in attempt", + code = "first_factor_strategy_not_supported", + ) + ) + ) + ) + } + /** * Signs in with OAuth provider. * @@ -383,6 +449,29 @@ class Auth internal constructor() { return result } + /** + * Starts a native email-link sign-in flow secured by PKCE. + * + * The flow sends only a code challenge to Clerk and expects completion through a deep-link + * callback carrying `flow_id` and `approval_token`. + */ + suspend fun startEmailLinkSignIn(email: String): ClerkResult { + return nativeMagicLink.startEmailLinkSignIn(email) + } + + /** Handles a native magic-link deep-link callback and completes sign-in using a ticket. */ + suspend fun handleMagicLinkDeepLink(uri: Uri): ClerkResult { + return nativeMagicLink.handleMagicLinkDeepLink(uri) + } + + /** Completes a pending native magic-link flow using callback values from the deep link. */ + suspend fun completeMagicLink( + flowId: String, + approvalToken: String, + ): ClerkResult { + return nativeMagicLink.complete(flowId, approvalToken) + } + // endregion // region Sign Up @@ -640,10 +729,10 @@ class Auth internal constructor() { // region Deep Link Handling /** - * Handles OAuth/SSO deep link callbacks. + * Handles OAuth/SSO and native magic-link deep link callbacks. * - * Call this method from your Activity when receiving a deep link callback from an OAuth or SSO - * provider. + * Call this method from your Activity when receiving a deep link callback from Clerk + * authentication flows. * * @param uri The deep link URI received from the callback. * @return true if the URI was handled, false otherwise. @@ -655,15 +744,18 @@ class Auth internal constructor() { * ``` */ fun handle(uri: Uri?): Boolean { - // Check if this is a Clerk OAuth callback - val isClerkCallback = uri?.scheme?.startsWith("clerk") == true + val callbackUri = uri ?: return false + val handledByMagicLink = NativeMagicLinkService.canHandle(callbackUri) + if (handledByMagicLink) { + kotlinx.coroutines.runBlocking { NativeMagicLinkService.handleMagicLinkDeepLink(callbackUri) } + } - if (isClerkCallback) { - // Let the SSO service handle the callback - kotlinx.coroutines.runBlocking { SSOService.completeAuthenticateWithRedirect(uri) } + val isClerkCallback = callbackUri.scheme?.startsWith("clerk") == true + if (!handledByMagicLink && isClerkCallback) { + kotlinx.coroutines.runBlocking { SSOService.completeAuthenticateWithRedirect(callbackUri) } } - return isClerkCallback + return handledByMagicLink || isClerkCallback } // endregion diff --git a/source/api/src/main/kotlin/com/clerk/api/log/SafeUriLog.kt b/source/api/src/main/kotlin/com/clerk/api/log/SafeUriLog.kt new file mode 100644 index 000000000..bc9bf281e --- /dev/null +++ b/source/api/src/main/kotlin/com/clerk/api/log/SafeUriLog.kt @@ -0,0 +1,55 @@ +package com.clerk.api.log + +import android.net.Uri + +internal object SafeUriLog { + fun describe(uri: Uri?): String { + if (uri == null) return "uri=null" + + val queryKeys = queryParamKeys(uri) + val fragmentKeys = fragmentParamKeys(uri.encodedFragment) + val allKeys = (queryKeys + fragmentKeys).sorted() + + val port = if (uri.port == -1) "-" else uri.port.toString() + val path = uri.path ?: "-" + val scheme = uri.scheme ?: "-" + val host = uri.host ?: "-" + + return buildString { + append("scheme=$scheme") + append(", host=$host") + append(", port=$port") + append(", path=$path") + append(", query_keys=") + append(queryKeys.sorted()) + append(", fragment_keys=") + append(fragmentKeys.sorted()) + append(", has_flow_id=") + append("flow_id" in allKeys) + append(", has_approval_token=") + append("approval_token" in allKeys) + append(", has_token=") + append("token" in allKeys) + append(", has_rotating_token_nonce=") + append("rotating_token_nonce" in allKeys) + } + } + + private fun queryParamKeys(uri: Uri): Set = + runCatching { uri.queryParameterNames.map { it.trim() }.filter { it.isNotEmpty() }.toSet() } + .getOrDefault(emptySet()) + + private fun fragmentParamKeys(fragment: String?): Set { + if (fragment.isNullOrBlank()) return emptySet() + + return fragment + .split("&") + .mapNotNull { entry -> + val separator = entry.indexOf("=") + val rawKey = if (separator >= 0) entry.substring(0, separator) else entry + val key = Uri.decode(rawKey).trim() + key.takeIf { it.isNotEmpty() } + } + .toSet() + } +} diff --git a/source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkCompletionRunner.kt b/source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkCompletionRunner.kt new file mode 100644 index 000000000..37773a33d --- /dev/null +++ b/source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkCompletionRunner.kt @@ -0,0 +1,78 @@ +package com.clerk.api.magiclink + +import com.clerk.api.Clerk +import com.clerk.api.network.ClerkApi +import com.clerk.api.network.model.error.ClerkErrorResponse +import com.clerk.api.network.model.magiclink.NativeMagicLinkCompleteRequest +import com.clerk.api.network.serialization.ClerkResult +import com.clerk.api.signin.SignIn + +internal class NativeMagicLinkCompletionRunner( + private val attestationProvider: NativeMagicLinkAttestationProvider?, + private val clearPendingFlow: suspend () -> Unit, + private val activateCreatedSession: suspend (SignIn) -> ClerkResult, + private val refreshClientState: suspend () -> Unit, +) { + suspend fun complete( + flowId: String, + approvalToken: String, + pending: PendingNativeMagicLinkFlow, + ): ClerkResult { + val completeRequest = + NativeMagicLinkCompleteRequest( + flowId = flowId, + approvalToken = approvalToken, + codeVerifier = pending.codeVerifier, + attestation = attestationProvider?.attestation(), + ) + + return when (val completeResult = ClerkApi.magicLink.complete(completeRequest.toFields())) { + is ClerkResult.Failure -> handleCompleteApiFailure(completeResult) + is ClerkResult.Success -> completeFromTicket(completeResult.value.ticket) + } + } + + private suspend fun handleCompleteApiFailure( + completeResult: ClerkResult.Failure + ): ClerkResult.Failure { + val mapped = completeResult.toNativeMagicLinkError(NativeMagicLinkReason.COMPLETE_FAILED) + if (mapped.reasonCode in TERMINAL_REASON_CODES) { + clearPendingFlow() + } + NativeMagicLinkLogger.completeFailure(mapped.reasonCode) + return ClerkResult.apiFailure(mapped) + } + + private suspend fun completeFromTicket( + ticket: String + ): ClerkResult { + return when (val ticketSignInResult = Clerk.auth.signInWithTicket(ticket)) { + is ClerkResult.Failure -> { + clearPendingFlow() + val mapped = + ticketSignInResult.toNativeMagicLinkError(NativeMagicLinkReason.TICKET_SIGN_IN_FAILED) + NativeMagicLinkLogger.completeFailure(mapped.reasonCode) + ClerkResult.apiFailure(mapped) + } + is ClerkResult.Success -> completeAfterTicketSignIn(ticketSignInResult.value) + } + } + + private suspend fun completeAfterTicketSignIn( + signIn: SignIn + ): ClerkResult { + val activationResult = activateCreatedSession(signIn) + return if (activationResult is ClerkResult.Failure) { + clearPendingFlow() + val reasonCode = + activationResult.error?.reasonCode ?: NativeMagicLinkReason.SESSION_ACTIVATION_FAILED.code + NativeMagicLinkLogger.completeFailure(reasonCode) + ClerkResult.apiFailure(activationResult.error) + } else { + clearPendingFlow() + refreshClientState() + NativeMagicLinkLogger.completeSuccess() + ClerkResult.success(signIn) + } + } +} diff --git a/source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkManager.kt b/source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkManager.kt new file mode 100644 index 000000000..8ba3292d5 --- /dev/null +++ b/source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkManager.kt @@ -0,0 +1,444 @@ +package com.clerk.api.magiclink + +import android.net.Uri +import com.clerk.api.Clerk +import com.clerk.api.Constants.Strategy.EMAIL_LINK +import com.clerk.api.log.ClerkLog +import com.clerk.api.log.SafeUriLog +import com.clerk.api.network.ApiParams +import com.clerk.api.network.ClerkApi +import com.clerk.api.network.model.client.Client +import com.clerk.api.network.model.error.ClerkErrorResponse +import com.clerk.api.network.model.error.Error +import com.clerk.api.network.model.magiclink.NativeMagicLinkPrepareRequest +import com.clerk.api.network.serialization.ClerkResult +import com.clerk.api.signin.SignIn +import com.clerk.api.signup.SignUp +import com.clerk.api.sso.RedirectConfiguration +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +public interface NativeMagicLinkManager { + public var attestationProvider: NativeMagicLinkAttestationProvider? + + public suspend fun startEmailLinkSignIn(email: String): ClerkResult + + public suspend fun handleMagicLinkDeepLink(uri: Uri): ClerkResult + + public suspend fun complete( + flowId: String, + approvalToken: String, + ): ClerkResult +} + +internal object NativeMagicLinkService : NativeMagicLinkManager { + private val mutex = Mutex() + private val pendingFlowStore: PendingNativeMagicLinkStore = InMemoryPendingNativeMagicLinkStore() + + override var attestationProvider: NativeMagicLinkAttestationProvider? = null + + override suspend fun startEmailLinkSignIn( + email: String + ): ClerkResult { + NativeMagicLinkLogger.start() + val identifier = email.trim() + val invalidIdentifierFailure = + if (identifier.isEmpty()) { + ClerkResult.apiFailure( + NativeMagicLinkError(reasonCode = NativeMagicLinkReason.INVALID_IDENTIFIER.code) + ) + } else { + null + } + + return invalidIdentifierFailure ?: createAndPrepareEmailLinkSignIn(identifier) + } + + private suspend fun createAndPrepareEmailLinkSignIn( + identifier: String + ): ClerkResult { + val signInResult = + ClerkApi.signIn.createSignIn( + mapOf("identifier" to identifier, "locale" to Clerk.locale.value.orEmpty()) + ) + + return when (signInResult) { + is ClerkResult.Failure -> + ClerkResult.apiFailure( + signInResult.toNativeMagicLinkError(NativeMagicLinkReason.START_FAILED) + ) + is ClerkResult.Success -> prepareEmailLinkSignIn(signInResult.value) + } + } + + private suspend fun prepareEmailLinkSignIn( + signIn: SignIn + ): ClerkResult { + val emailAddressId = signIn.emailLinkAddressId() + val redirectUri = resolveNativeEmailLinkRedirectUri() + val missingReason = + when { + emailAddressId == null -> NativeMagicLinkReason.EMAIL_LINK_NOT_SUPPORTED + redirectUri == null -> NativeMagicLinkReason.START_FAILED + else -> null + } + + return if (missingReason != null) { + ClerkResult.apiFailure(NativeMagicLinkError(reasonCode = missingReason.code)) + } else { + val requiredEmailAddressId = checkNotNull(emailAddressId) + val requiredRedirectUri = checkNotNull(redirectUri) + val pkcePair = PkceUtil.generatePair() + val prepareRequest = + NativeMagicLinkPrepareRequest( + emailAddressId = requiredEmailAddressId, + redirectUri = requiredRedirectUri, + codeChallenge = pkcePair.challenge, + ) + when ( + val prepareResult = + ClerkApi.signIn.prepareSignInFirstFactor(signIn.id, prepareRequest.toFields()) + ) { + is ClerkResult.Failure -> + ClerkResult.apiFailure( + prepareResult.toNativeMagicLinkError(NativeMagicLinkReason.PREPARE_FAILED) + ) + is ClerkResult.Success -> { + mutex.withLock { + pendingFlowStore.save( + PendingNativeMagicLinkFlow( + codeVerifier = pkcePair.verifier, + expiresAtEpochMs = currentTimeMillis() + PENDING_FLOW_TTL_MS, + ) + ) + } + ClerkResult.success(prepareResult.value) + } + } + } + } + + internal suspend fun prepareSignUpEmailLink( + signUpId: String, + strategy: SignUp.PrepareVerificationParams.Strategy.EmailLink, + ): ClerkResult { + val applicationId = Clerk.applicationId + val redirectUri = + strategy.redirectUri + ?: strategy.redirectUrl + ?: if (applicationId.isNullOrBlank()) null + else { + RedirectConfiguration.emailLinkRedirectUrl( + applicationId = applicationId, + proxyUrl = Clerk.proxyUrl, + ) + } + + if (redirectUri.isNullOrBlank()) { + return ClerkResult.apiFailure( + ClerkErrorResponse( + errors = + listOf( + Error( + message = "is invalid", + longMessage = "redirect_uri is required for native email-link verification", + code = "native_redirect_uri_required", + ) + ) + ) + ) + } + + val pkcePair = PkceUtil.generatePair() + val fields = + mapOf( + ApiParams.STRATEGY to EMAIL_LINK, + ApiParams.REDIRECT_URI to redirectUri, + ApiParams.CODE_CHALLENGE to pkcePair.challenge, + ApiParams.CODE_CHALLENGE_METHOD to NativeMagicLinkPrepareRequest.PKCE_METHOD_S256, + ) + + return when (val prepareResult = ClerkApi.signUp.prepareSignUpVerification(signUpId, fields)) { + is ClerkResult.Failure -> prepareResult + is ClerkResult.Success -> { + mutex.withLock { + pendingFlowStore.save( + PendingNativeMagicLinkFlow( + codeVerifier = pkcePair.verifier, + expiresAtEpochMs = currentTimeMillis() + PENDING_FLOW_TTL_MS, + ) + ) + } + prepareResult + } + } + } + + override suspend fun handleMagicLinkDeepLink( + uri: Uri + ): ClerkResult { + NativeMagicLinkLogger.deepLinkReceived(uri) + val parsed = parseMagicLinkCallback(uri) + return when (parsed) { + is ClerkResult.Failure -> { + val reason = parsed.error?.reasonCode ?: NativeMagicLinkReason.COMPLETE_FAILED.code + NativeMagicLinkLogger.completeFailure(reason) + parsed + } + is ClerkResult.Success -> complete(parsed.value.flowId, parsed.value.approvalToken) + } + } + + override suspend fun complete( + flowId: String, + approvalToken: String, + ): ClerkResult { + val pending = + mutex.withLock { + val flow = pendingFlowStore.load() + if (flow != null && flow.expiresAtEpochMs <= currentTimeMillis()) { + pendingFlowStore.clear() + null + } else { + flow + } + } + + val clearPendingFlow: suspend () -> Unit = { mutex.withLock { pendingFlowStore.clear() } } + val completionRunner = + NativeMagicLinkCompletionRunner( + attestationProvider = attestationProvider, + clearPendingFlow = clearPendingFlow, + activateCreatedSession = { signIn -> activateCreatedSession(signIn) }, + refreshClientState = { refreshClientState() }, + ) + return if (pending == null) { + nativeMagicLinkFailure(NativeMagicLinkReason.NO_PENDING_FLOW) + } else { + completionRunner.complete(flowId = flowId, approvalToken = approvalToken, pending = pending) + } + } + + internal fun canHandle(uri: Uri?): Boolean { + return uri?.let { + queryOrFragmentParam(it, "flow_id") != null || + queryOrFragmentParam(it, "approval_token") != null + } ?: false + } + + internal fun resetForTests() { + pendingFlowStore.clear() + attestationProvider = null + } + + private suspend fun refreshClientState() { + when (val clientResult = Client.get()) { + is ClerkResult.Success -> Clerk.updateClient(clientResult.value) + is ClerkResult.Failure -> ClerkLog.w("event=native_magic_link_client_refresh_failure") + } + } + + private suspend fun activateCreatedSession( + signIn: SignIn + ): ClerkResult { + val createdSessionId = signIn.createdSessionId ?: return ClerkResult.success(Unit) + return when (val activationResult = Clerk.auth.setActive(createdSessionId)) { + is ClerkResult.Success -> ClerkResult.success(Unit) + is ClerkResult.Failure -> { + refreshClientState() + val isAlreadyActive = + runCatching { Clerk.client.lastActiveSessionId }.getOrNull() == createdSessionId + if (isAlreadyActive) { + ClerkResult.success(Unit) + } else { + ClerkResult.apiFailure( + activationResult.toNativeMagicLinkError(NativeMagicLinkReason.SESSION_ACTIVATION_FAILED) + ) + } + } + } + } + + private const val PENDING_FLOW_TTL_MS = 10 * 60 * 1000L +} + +public fun interface NativeMagicLinkAttestationProvider { + suspend fun attestation(): String? +} + +internal data class PendingNativeMagicLinkFlow( + val codeVerifier: String, + val expiresAtEpochMs: Long, +) + +internal interface PendingNativeMagicLinkStore { + fun save(flow: PendingNativeMagicLinkFlow) + + fun load(): PendingNativeMagicLinkFlow? + + fun clear() +} + +internal class InMemoryPendingNativeMagicLinkStore : PendingNativeMagicLinkStore { + @Volatile private var flow: PendingNativeMagicLinkFlow? = null + + override fun save(flow: PendingNativeMagicLinkFlow) { + this.flow = flow + } + + override fun load(): PendingNativeMagicLinkFlow? = flow + + override fun clear() { + flow = null + } +} + +internal data class ParsedMagicLinkDeepLink(val flowId: String, val approvalToken: String) + +internal fun parseMagicLinkCallback( + uri: Uri +): ClerkResult { + val flowId = queryOrFragmentParam(uri, "flow_id") + val approvalToken = queryOrFragmentParam(uri, "approval_token") + + return when { + flowId.isNullOrBlank() -> + ClerkResult.apiFailure( + NativeMagicLinkError(reasonCode = NativeMagicLinkReason.MISSING_FLOW_ID.code) + ) + approvalToken.isNullOrBlank() -> + ClerkResult.apiFailure( + NativeMagicLinkError(reasonCode = NativeMagicLinkReason.MISSING_APPROVAL_TOKEN.code) + ) + else -> + ClerkResult.success(ParsedMagicLinkDeepLink(flowId = flowId, approvalToken = approvalToken)) + } +} + +internal fun queryOrFragmentParam(uri: Uri, key: String): String? { + val queryValue = uri.getQueryParameter(key)?.takeIf { it.isNotBlank() } + val fragmentValue = + uri.encodedFragment + ?.split("&") + .orEmpty() + .asSequence() + .mapNotNull { entry -> + val index = entry.indexOf('=') + val rawKey = if (index >= 0) entry.substring(0, index) else entry + val rawValue = if (index >= 0) entry.substring(index + 1) else "" + val decodedKey = Uri.decode(rawKey) + if (decodedKey == key) Uri.decode(rawValue) else null + } + .firstOrNull() + ?.takeIf { it.isNotBlank() } + + return queryValue ?: fragmentValue +} + +private fun SignIn.emailLinkAddressId(): String? { + return supportedFirstFactors + ?.firstOrNull { it.strategy == EMAIL_LINK && it.emailAddressId != null } + ?.emailAddressId +} + +private fun resolveNativeEmailLinkRedirectUri(): String? { + val applicationId = Clerk.applicationId + return if (applicationId.isNullOrBlank()) { + null + } else { + RedirectConfiguration.emailLinkRedirectUrl( + applicationId = applicationId, + proxyUrl = Clerk.proxyUrl, + ) + } +} + +private fun nativeMagicLinkFailure( + reason: NativeMagicLinkReason +): ClerkResult.Failure { + NativeMagicLinkLogger.completeFailure(reason.code) + return ClerkResult.apiFailure(NativeMagicLinkError(reasonCode = reason.code)) +} + +private fun currentTimeMillis(): Long = System.currentTimeMillis() + +public class NativeMagicLinkError( + public val reasonCode: String, + public val message: String? = null, +) + +internal enum class NativeMagicLinkReason(val code: String) { + APPROVAL_TOKEN_CONSUMED("approval_token_consumed"), + APPROVAL_TOKEN_EXPIRED("approval_token_expired"), + APPROVAL_TOKEN_INVALID("approval_token_invalid"), + PKCE_VERIFICATION_FAILED("pkce_verification_failed"), + FLOW_NOT_APPROVED("flow_not_approved"), + MISSING_FLOW_ID("missing_flow_id"), + MISSING_APPROVAL_TOKEN("missing_approval_token"), + INVALID_IDENTIFIER("invalid_identifier"), + EMAIL_LINK_NOT_SUPPORTED("email_link_not_supported"), + NATIVE_API_DISABLED_FOR_INSTANCE("native_api_disabled_for_instance"), + NATIVE_API_DISABLED("native_api_disabled"), + NO_PENDING_FLOW("no_pending_flow"), + SESSION_ACTIVATION_FAILED("native_magic_link_session_activation_failed"), + START_FAILED("native_magic_link_start_failed"), + PREPARE_FAILED("native_magic_link_prepare_failed"), + COMPLETE_FAILED("native_magic_link_complete_failed"), + TICKET_SIGN_IN_FAILED("native_magic_link_ticket_sign_in_failed"), +} + +internal val TERMINAL_REASON_CODES = + setOf( + NativeMagicLinkReason.APPROVAL_TOKEN_CONSUMED.code, + NativeMagicLinkReason.APPROVAL_TOKEN_EXPIRED.code, + NativeMagicLinkReason.APPROVAL_TOKEN_INVALID.code, + NativeMagicLinkReason.PKCE_VERIFICATION_FAILED.code, + NativeMagicLinkReason.FLOW_NOT_APPROVED.code, + ) + +internal fun ClerkResult.Failure.toNativeMagicLinkError( + fallbackReason: NativeMagicLinkReason +): NativeMagicLinkError { + val firstError = error?.errors?.firstOrNull() + val backendCode = firstError?.code?.takeIf { it.isNotBlank() } + val normalizedCode = backendCode ?: fallbackReason.code + val mapped = + when (normalizedCode) { + NativeMagicLinkReason.APPROVAL_TOKEN_CONSUMED.code -> + NativeMagicLinkReason.APPROVAL_TOKEN_CONSUMED + NativeMagicLinkReason.APPROVAL_TOKEN_EXPIRED.code -> + NativeMagicLinkReason.APPROVAL_TOKEN_EXPIRED + NativeMagicLinkReason.APPROVAL_TOKEN_INVALID.code -> + NativeMagicLinkReason.APPROVAL_TOKEN_INVALID + NativeMagicLinkReason.PKCE_VERIFICATION_FAILED.code -> + NativeMagicLinkReason.PKCE_VERIFICATION_FAILED + NativeMagicLinkReason.FLOW_NOT_APPROVED.code -> NativeMagicLinkReason.FLOW_NOT_APPROVED + NativeMagicLinkReason.NATIVE_API_DISABLED_FOR_INSTANCE.code -> + NativeMagicLinkReason.NATIVE_API_DISABLED_FOR_INSTANCE + NativeMagicLinkReason.NATIVE_API_DISABLED.code -> NativeMagicLinkReason.NATIVE_API_DISABLED + else -> null + } + val reasonCode = mapped?.code ?: normalizedCode + return NativeMagicLinkError( + reasonCode = reasonCode, + message = firstError?.longMessage ?: firstError?.message, + ) +} + +internal object NativeMagicLinkLogger { + fun start() { + ClerkLog.i("event=native_magic_link_start") + } + + fun deepLinkReceived(uri: Uri) { + ClerkLog.i("event=native_magic_link_deeplink_received uri_shape={${SafeUriLog.describe(uri)}}") + } + + fun completeSuccess() { + ClerkLog.i("event=native_magic_link_complete_success") + } + + fun completeFailure(reasonCode: String) { + ClerkLog.w("event=native_magic_link_complete_failure reason_code=$reasonCode") + } +} diff --git a/source/api/src/main/kotlin/com/clerk/api/magiclink/PkceUtil.kt b/source/api/src/main/kotlin/com/clerk/api/magiclink/PkceUtil.kt new file mode 100644 index 000000000..4a27fd874 --- /dev/null +++ b/source/api/src/main/kotlin/com/clerk/api/magiclink/PkceUtil.kt @@ -0,0 +1,34 @@ +package com.clerk.api.magiclink + +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 + +internal object PkceUtil { + private val secureRandom = SecureRandom() + private val urlEncoder = Base64.getUrlEncoder().withoutPadding() + + fun generateCodeVerifier(bytes: Int = DEFAULT_VERIFIER_BYTES): String { + require(bytes > 0) { "bytes must be positive" } + val random = ByteArray(bytes) + secureRandom.nextBytes(random) + return urlEncoder.encodeToString(random) + } + + fun createS256CodeChallenge(codeVerifier: String): String { + require(codeVerifier.isNotBlank()) { "codeVerifier must not be blank" } + val digest = + MessageDigest.getInstance(SHA_256).digest(codeVerifier.toByteArray(Charsets.US_ASCII)) + return urlEncoder.encodeToString(digest) + } + + fun generatePair(): PkcePair { + val verifier = generateCodeVerifier() + return PkcePair(verifier = verifier, challenge = createS256CodeChallenge(verifier)) + } + + private const val SHA_256 = "SHA-256" + private const val DEFAULT_VERIFIER_BYTES = 32 +} + +internal data class PkcePair(val verifier: String, val challenge: String) diff --git a/source/api/src/main/kotlin/com/clerk/api/network/ApiParams.kt b/source/api/src/main/kotlin/com/clerk/api/network/ApiParams.kt index 2cc147e55..2bcd4f13f 100644 --- a/source/api/src/main/kotlin/com/clerk/api/network/ApiParams.kt +++ b/source/api/src/main/kotlin/com/clerk/api/network/ApiParams.kt @@ -24,6 +24,10 @@ internal object ApiParams { // Request parameters internal const val STRATEGY = "strategy" internal const val CODE = "code" + internal const val REDIRECT_URL = "redirect_url" + internal const val REDIRECT_URI = "redirect_uri" + internal const val CODE_CHALLENGE = "code_challenge" + internal const val CODE_CHALLENGE_METHOD = "code_challenge_method" internal const val CLERK_SESSION_ID = "_clerk_session_id" internal const val ROLE = "role" diff --git a/source/api/src/main/kotlin/com/clerk/api/network/ApiPaths.kt b/source/api/src/main/kotlin/com/clerk/api/network/ApiPaths.kt index a2ce910ed..d1249b244 100644 --- a/source/api/src/main/kotlin/com/clerk/api/network/ApiPaths.kt +++ b/source/api/src/main/kotlin/com/clerk/api/network/ApiPaths.kt @@ -42,6 +42,12 @@ internal object ApiPaths { internal const val RESET_PASSWORD = "${WITH_ID}/reset_password" } + /** Magic link endpoints */ + internal object MagicLinks { + internal const val BASE = "${Client.BASE}/magic_links" + internal const val COMPLETE = "${BASE}/complete" + } + /** Sign-up endpoints */ internal object SignUp { internal const val BASE = "${Client.BASE}/sign_ups" diff --git a/source/api/src/main/kotlin/com/clerk/api/network/ClerkApi.kt b/source/api/src/main/kotlin/com/clerk/api/network/ClerkApi.kt index ca6c35350..8677daf7d 100644 --- a/source/api/src/main/kotlin/com/clerk/api/network/ClerkApi.kt +++ b/source/api/src/main/kotlin/com/clerk/api/network/ClerkApi.kt @@ -5,6 +5,7 @@ import com.clerk.api.Clerk import com.clerk.api.network.api.ClientApi import com.clerk.api.network.api.DeviceAttestationApi import com.clerk.api.network.api.EnvironmentApi +import com.clerk.api.network.api.MagicLinkApi import com.clerk.api.network.api.OrganizationApi import com.clerk.api.network.api.SessionApi import com.clerk.api.network.api.SignInApi @@ -69,6 +70,10 @@ internal object ClerkApi { val organization: OrganizationApi get() = _organization ?: error("ClerkApi is not configured.") + private var _magicLink: MagicLinkApi? = null + val magicLink: MagicLinkApi + get() = _magicLink ?: error("ClerkApi is not configured.") + // Exposed for internal testing/verification internal var configuredBaseUrl: String? = null private set @@ -89,6 +94,7 @@ internal object ClerkApi { _user = retrofit.create(UserApi::class.java) _deviceAttestation = retrofit.create(DeviceAttestationApi::class.java) _organization = retrofit.create(OrganizationApi::class.java) + _magicLink = retrofit.create(MagicLinkApi::class.java) } /** Builds and configures the Retrofit instance. */ diff --git a/source/api/src/main/kotlin/com/clerk/api/network/api/MagicLinkApi.kt b/source/api/src/main/kotlin/com/clerk/api/network/api/MagicLinkApi.kt new file mode 100644 index 000000000..7055f3c73 --- /dev/null +++ b/source/api/src/main/kotlin/com/clerk/api/network/api/MagicLinkApi.kt @@ -0,0 +1,17 @@ +package com.clerk.api.network.api + +import com.clerk.api.network.ApiPaths +import com.clerk.api.network.model.error.ClerkErrorResponse +import com.clerk.api.network.model.magiclink.NativeMagicLinkCompleteResponse +import com.clerk.api.network.serialization.ClerkResult +import retrofit2.http.FieldMap +import retrofit2.http.FormUrlEncoded +import retrofit2.http.POST + +internal interface MagicLinkApi { + @FormUrlEncoded + @POST(ApiPaths.Client.MagicLinks.COMPLETE) + suspend fun complete( + @FieldMap fields: Map + ): ClerkResult +} diff --git a/source/api/src/main/kotlin/com/clerk/api/network/api/SignUpApi.kt b/source/api/src/main/kotlin/com/clerk/api/network/api/SignUpApi.kt index 9e4cf0965..4c575f91f 100644 --- a/source/api/src/main/kotlin/com/clerk/api/network/api/SignUpApi.kt +++ b/source/api/src/main/kotlin/com/clerk/api/network/api/SignUpApi.kt @@ -33,7 +33,7 @@ internal interface SignUpApi { @POST(ApiPaths.Client.SignUp.PREPARE_VERIFICATION) suspend fun prepareSignUpVerification( @Path(ApiParams.ID) signUpId: String, - @Field(ApiParams.STRATEGY) strategy: String, + @FieldMap fields: Map, ): ClerkResult /** @see [com.clerk.api.signup.attemptVerification] */ diff --git a/source/api/src/main/kotlin/com/clerk/api/network/model/magiclink/NativeMagicLinkRequests.kt b/source/api/src/main/kotlin/com/clerk/api/network/model/magiclink/NativeMagicLinkRequests.kt new file mode 100644 index 000000000..d46204455 --- /dev/null +++ b/source/api/src/main/kotlin/com/clerk/api/network/model/magiclink/NativeMagicLinkRequests.kt @@ -0,0 +1,44 @@ +package com.clerk.api.network.model.magiclink + +import com.clerk.api.Constants.Strategy.EMAIL_LINK +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class NativeMagicLinkPrepareRequest( + val strategy: String = EMAIL_LINK, + @SerialName("email_address_id") val emailAddressId: String, + @SerialName("redirect_uri") val redirectUri: String, + @SerialName("code_challenge") val codeChallenge: String, + @SerialName("code_challenge_method") val codeChallengeMethod: String = PKCE_METHOD_S256, +) { + fun toFields(): Map = + mapOf( + "strategy" to strategy, + "email_address_id" to emailAddressId, + "redirect_uri" to redirectUri, + "code_challenge" to codeChallenge, + "code_challenge_method" to codeChallengeMethod, + ) + + companion object { + const val PKCE_METHOD_S256 = "S256" + } +} + +@Serializable +internal data class NativeMagicLinkCompleteRequest( + @SerialName("flow_id") val flowId: String, + @SerialName("approval_token") val approvalToken: String, + @SerialName("code_verifier") val codeVerifier: String, + @SerialName("attestation") val attestation: String? = null, +) { + fun toFields(): Map = buildMap { + put("flow_id", flowId) + put("approval_token", approvalToken) + put("code_verifier", codeVerifier) + attestation?.let { put("attestation", it) } + } +} + +@Serializable internal data class NativeMagicLinkCompleteResponse(val ticket: String) diff --git a/source/api/src/main/kotlin/com/clerk/api/network/serialization/ClerkApiResultConverterFactory.kt b/source/api/src/main/kotlin/com/clerk/api/network/serialization/ClerkApiResultConverterFactory.kt index 046be8672..61ab338ec 100644 --- a/source/api/src/main/kotlin/com/clerk/api/network/serialization/ClerkApiResultConverterFactory.kt +++ b/source/api/src/main/kotlin/com/clerk/api/network/serialization/ClerkApiResultConverterFactory.kt @@ -2,6 +2,7 @@ package com.clerk.api.network.serialization import com.clerk.api.log.ClerkLog import com.clerk.api.network.model.environment.Environment +import com.clerk.api.network.model.magiclink.NativeMagicLinkCompleteResponse import com.clerk.api.network.model.response.ClientPiggybackedResponse import com.clerk.api.network.model.token.TokenResource import java.lang.reflect.ParameterizedType @@ -104,5 +105,9 @@ internal object ClerkApiResultConverterFactory : Converter.Factory() { } internal fun getExcludedTypeNames(): List { - return listOf(Environment::class.qualifiedName ?: "", TokenResource::class.qualifiedName ?: "") + return listOf( + Environment::class.qualifiedName ?: "", + TokenResource::class.qualifiedName ?: "", + NativeMagicLinkCompleteResponse::class.qualifiedName ?: "", + ) } diff --git a/source/api/src/main/kotlin/com/clerk/api/signin/SignIn.kt b/source/api/src/main/kotlin/com/clerk/api/signin/SignIn.kt index c54ea3c76..29805e05e 100644 --- a/source/api/src/main/kotlin/com/clerk/api/signin/SignIn.kt +++ b/source/api/src/main/kotlin/com/clerk/api/signin/SignIn.kt @@ -3,6 +3,7 @@ package com.clerk.api.signin import com.clerk.api.Clerk import com.clerk.api.Constants.Strategy.BACKUP_CODE import com.clerk.api.Constants.Strategy.EMAIL_CODE +import com.clerk.api.Constants.Strategy.EMAIL_LINK import com.clerk.api.Constants.Strategy.ENTERPRISE_SSO import com.clerk.api.Constants.Strategy.PASSKEY import com.clerk.api.Constants.Strategy.PASSWORD @@ -14,6 +15,7 @@ import com.clerk.api.Constants.Strategy.TOTP as STRATEGY_TOTP import com.clerk.api.Constants.Strategy.TRANSFER import com.clerk.api.network.ClerkApi import com.clerk.api.network.model.error.ClerkErrorResponse +import com.clerk.api.network.model.error.Error import com.clerk.api.network.model.factor.Factor import com.clerk.api.network.model.verification.Verification import com.clerk.api.network.serialization.ClerkResult @@ -405,6 +407,16 @@ data class SignIn( override val strategy: String = PHONE_CODE, ) : PrepareFirstFactorParams + @AutoMap + @Serializable + data class EmailLink( + @SerialName("email_address_id") val emailAddressId: String, + @SerialName("redirect_uri") val redirectUri: String, + @SerialName("code_challenge") val codeChallenge: String, + @SerialName("code_challenge_method") val codeChallengeMethod: String = "S256", + override val strategy: String = EMAIL_LINK, + ) : PrepareFirstFactorParams + @AutoMap @Serializable data class ResetPasswordEmailCode( @@ -727,7 +739,26 @@ data class SignIn( suspend fun SignIn.prepareFirstFactor( params: SignIn.PrepareFirstFactorParams ): ClerkResult { - return ClerkApi.signIn.prepareSignInFirstFactor(this.id, params.toMap()) + val isResetPasswordStrategy = + params.strategy == RESET_PASSWORD_EMAIL_CODE || params.strategy == RESET_PASSWORD_PHONE_CODE + + val supportedFirstFactorStrategies = supportedFirstFactors?.map { it.strategy }.orEmpty() + val validationError = + when { + !isResetPasswordStrategy && status != SignIn.Status.NEEDS_FIRST_FACTOR -> + invalidPrepareState( + code = "sign_in_status_invalid", + longMessage = "Cannot prepare first factor while sign-in status is ${status.name}", + ) + !isResetPasswordStrategy && params.strategy !in supportedFirstFactorStrategies -> + invalidPrepareState( + code = "first_factor_strategy_not_supported", + longMessage = "${params.strategy} is not supported for this sign-in attempt", + ) + else -> null + } + + return validationError ?: ClerkApi.signIn.prepareSignInFirstFactor(this.id, params.toMap()) } /** @@ -784,6 +815,13 @@ suspend fun SignIn.prepareSecondFactor( phoneNumberId: String? = null, emailAddressId: String? = null, ): ClerkResult { + if (status != SignIn.Status.NEEDS_SECOND_FACTOR && status != SignIn.Status.NEEDS_CLIENT_TRUST) { + return invalidPrepareState( + code = "sign_in_status_invalid", + longMessage = "Cannot prepare second factor while sign-in status is ${status.name}", + ) + } + val strategy = when { supportedSecondFactors?.any { it.strategy == SignIn.PrepareSecondFactorParams.PHONE_CODE } == @@ -811,6 +849,17 @@ suspend fun SignIn.prepareSecondFactor( return ClerkApi.signIn.prepareSecondFactor(id = id, params = params.toMap()) } +private fun invalidPrepareState( + code: String, + longMessage: String, +): ClerkResult.Failure { + return ClerkResult.apiFailure( + ClerkErrorResponse( + errors = listOf(Error(message = "is invalid", longMessage = longMessage, code = code)) + ) + ) +} + /** * Sends a verification code to the user's phone number for MFA (second factor) authentication. * diff --git a/source/api/src/main/kotlin/com/clerk/api/signin/SignInExtensions.kt b/source/api/src/main/kotlin/com/clerk/api/signin/SignInExtensions.kt index b23a781c7..a4ee02437 100644 --- a/source/api/src/main/kotlin/com/clerk/api/signin/SignInExtensions.kt +++ b/source/api/src/main/kotlin/com/clerk/api/signin/SignInExtensions.kt @@ -4,6 +4,7 @@ package com.clerk.api.signin import com.clerk.api.Clerk import com.clerk.api.Constants.Strategy.EMAIL_CODE +import com.clerk.api.Constants.Strategy.EMAIL_LINK import com.clerk.api.Constants.Strategy.PHONE_CODE import com.clerk.api.Constants.Strategy.RESET_PASSWORD_EMAIL_CODE import com.clerk.api.Constants.Strategy.RESET_PASSWORD_PHONE_CODE @@ -77,11 +78,15 @@ fun SignIn.alternativeSecondFactors(factor: Factor): List { * is found. */ val SignIn.startingFirstFactor: Factor? - get() = - when (Clerk.environment.displayConfig?.preferredSignInStrategy) { + get() { + preparedFirstFactor?.let { + return it + } + return when (Clerk.environment.displayConfig?.preferredSignInStrategy) { PreferredSignInStrategy.PASSWORD -> this.factorWhenPasswordIsPreferred else -> this.factorWhenOtpIsPreferred } + } val SignIn.startingSecondFactor: Factor? get() { @@ -100,9 +105,15 @@ val SignIn.startingSecondFactor: Factor? private val SignIn.factorWhenPasswordIsPreferred: Factor? get() { - // email links are not supported on iOS (keeping the same exclusion here) - val availableFirstFactors = - supportedFirstFactors?.filter { it.strategy != "email_link" } ?: return null + val availableFirstFactors = supportedFirstFactors ?: return null + + if (isEmailIdentifier) { + availableFirstFactors + .firstOrNull { it.strategy == EMAIL_LINK } + ?.let { + return it + } + } // Prefer passkey availableFirstFactors @@ -126,9 +137,15 @@ private val SignIn.factorWhenPasswordIsPreferred: Factor? private val SignIn.factorWhenOtpIsPreferred: Factor? get() { - // email links are not supported on iOS (keeping the same exclusion here) - val availableFirstFactors = - supportedFirstFactors?.filter { it.strategy != "email_link" } ?: return null + val availableFirstFactors = supportedFirstFactors ?: return null + + if (isEmailIdentifier) { + availableFirstFactors + .firstOrNull { it.strategy == EMAIL_LINK } + ?.let { + return it + } + } // Prefer passkey availableFirstFactors @@ -142,6 +159,25 @@ private val SignIn.factorWhenOtpIsPreferred: Factor? return sorted.firstOrNull { it.safeIdentifier == identifier } ?: sorted.firstOrNull() } +private val SignIn.isEmailIdentifier: Boolean + get() { + if (identifier?.contains("@") == true) return true + return supportedFirstFactors?.any { + (it.strategy == EMAIL_LINK || it.strategy == EMAIL_CODE) && + it.safeIdentifier?.contains("@") == true + } == true + } + +private val SignIn.preparedFirstFactor: Factor? + get() { + val preparedStrategy = firstFactorVerification?.strategy ?: return null + val availableFirstFactors = supportedFirstFactors ?: return null + + return availableFirstFactors.firstOrNull { + it.strategy == preparedStrategy && it.safeIdentifier == identifier + } ?: availableFirstFactors.firstOrNull { it.strategy == preparedStrategy } + } + // endregion // region Auth Namespace Extension Functions diff --git a/source/api/src/main/kotlin/com/clerk/api/signup/SignUp.kt b/source/api/src/main/kotlin/com/clerk/api/signup/SignUp.kt index ac7961db8..5d8141ef6 100644 --- a/source/api/src/main/kotlin/com/clerk/api/signup/SignUp.kt +++ b/source/api/src/main/kotlin/com/clerk/api/signup/SignUp.kt @@ -5,6 +5,9 @@ package com.clerk.api.signup import com.clerk.api.Clerk import com.clerk.api.Constants.Strategy as AuthStrategy import com.clerk.api.extensions.sortedByPriority +import com.clerk.api.magiclink.NativeMagicLinkService +import com.clerk.api.magiclink.PkceUtil +import com.clerk.api.network.ApiParams import com.clerk.api.network.ClerkApi import com.clerk.api.network.model.error.ClerkErrorResponse import com.clerk.api.network.model.verification.Verification @@ -338,6 +341,23 @@ data class SignUp( */ data class EmailCode(override val strategy: String = AuthStrategy.EMAIL_CODE) : PrepareVerificationParams.Strategy + + /** + * Send an email with a verification link. + * + * @property redirectUrl Legacy callback URL field. When [redirectUri] is not provided this + * value is used as the native callback URI. + * @property redirectUri Callback URI used for native email-link completion. + * @property codeChallenge PKCE code challenge for native email-link verification. + * @property codeChallengeMethod PKCE method. Native flows require `S256`. + */ + data class EmailLink( + override val strategy: String = AuthStrategy.EMAIL_LINK, + @SerialName("redirect_url") val redirectUrl: String? = null, + @SerialName("redirect_uri") val redirectUri: String? = null, + @SerialName("code_challenge") val codeChallenge: String? = null, + @SerialName("code_challenge_method") val codeChallengeMethod: String = PKCE_METHOD_S256, + ) : PrepareVerificationParams.Strategy } } @@ -550,7 +570,10 @@ suspend fun SignUp.update( suspend fun SignUp.prepareVerification( prepareVerification: SignUp.PrepareVerificationParams.Strategy ): ClerkResult { - return ClerkApi.signUp.prepareSignUpVerification(this.id, prepareVerification.strategy) + if (prepareVerification is SignUp.PrepareVerificationParams.Strategy.EmailLink) { + return NativeMagicLinkService.prepareSignUpEmailLink(this.id, prepareVerification) + } + return ClerkApi.signUp.prepareSignUpVerification(this.id, fields = prepareVerification.toFields()) } /** @@ -579,6 +602,16 @@ suspend fun SignUp.sendEmailCode(): ClerkResult { return prepareVerification(SignUp.PrepareVerificationParams.Strategy.EmailCode()) } +/** + * Sends a verification link to the email address associated with this sign-up. + * + * @return A [ClerkResult] containing the updated [SignUp] object on success, or a + * [ClerkErrorResponse] on failure. + */ +suspend fun SignUp.sendEmailLink(): ClerkResult { + return prepareVerification(SignUp.PrepareVerificationParams.Strategy.EmailLink()) +} + /** * Attempts to complete the verification process. * @@ -607,3 +640,59 @@ suspend fun SignUp.attemptVerification( * @return An [OAuthResult] containing this [SignUp] object. */ internal fun SignUp.toOAuthResult() = OAuthResult(signUp = this) + +private const val EMAIL_ADDRESS = "email_address" + +val SignUp.emailVerificationStrategy: String + get() { + val activeStrategy = verifications[EMAIL_ADDRESS]?.strategy + if (!activeStrategy.isNullOrBlank()) return activeStrategy + + val configuredStrategies = + runCatching { + Clerk.environment.userSettings.attributes[EMAIL_ADDRESS]?.verifications.orEmpty() + } + .getOrDefault(emptyList()) + + return when { + configuredStrategies.contains(AuthStrategy.EMAIL_LINK) -> AuthStrategy.EMAIL_LINK + configuredStrategies.contains(AuthStrategy.EMAIL_CODE) -> AuthStrategy.EMAIL_CODE + else -> AuthStrategy.EMAIL_CODE + } + } + +val SignUp.isEmailLinkVerificationSupported: Boolean + get() = emailVerificationStrategy == AuthStrategy.EMAIL_LINK + +private fun SignUp.PrepareVerificationParams.Strategy.toFields(): Map { + val strategyFields = mutableMapOf(ApiParams.STRATEGY to strategy) + + if (this is SignUp.PrepareVerificationParams.Strategy.EmailLink) { + val resolvedRedirectUri = + redirectUri + ?: redirectUrl + ?: runCatching { + val applicationId = Clerk.applicationId + if (applicationId.isNullOrBlank()) { + null + } else { + RedirectConfiguration.emailLinkRedirectUrl( + applicationId = applicationId, + proxyUrl = Clerk.proxyUrl, + ) + } + } + .getOrNull() + if (!resolvedRedirectUri.isNullOrBlank()) { + strategyFields[ApiParams.REDIRECT_URI] = resolvedRedirectUri + } + + strategyFields[ApiParams.CODE_CHALLENGE] = codeChallenge ?: PkceUtil.generatePair().challenge + strategyFields[ApiParams.CODE_CHALLENGE_METHOD] = + if (codeChallengeMethod == PKCE_METHOD_S256) codeChallengeMethod else PKCE_METHOD_S256 + } + + return strategyFields +} + +private const val PKCE_METHOD_S256 = "S256" diff --git a/source/api/src/main/kotlin/com/clerk/api/signup/SignUpExtensions.kt b/source/api/src/main/kotlin/com/clerk/api/signup/SignUpExtensions.kt index a46e390ae..b9a207b61 100644 --- a/source/api/src/main/kotlin/com/clerk/api/signup/SignUpExtensions.kt +++ b/source/api/src/main/kotlin/com/clerk/api/signup/SignUpExtensions.kt @@ -32,12 +32,16 @@ suspend fun SignUp.sendCode( val strategy = if (builder.email != null) { - SignUp.PrepareVerificationParams.Strategy.EmailCode() + if (isEmailLinkVerificationSupported) { + SignUp.PrepareVerificationParams.Strategy.EmailLink() + } else { + SignUp.PrepareVerificationParams.Strategy.EmailCode() + } } else { SignUp.PrepareVerificationParams.Strategy.PhoneCode() } - return ClerkApi.signUp.prepareSignUpVerification(this.id, strategy.strategy) + return prepareVerification(strategy) } /** diff --git a/source/api/src/main/kotlin/com/clerk/api/sso/RedirectConfiguration.kt b/source/api/src/main/kotlin/com/clerk/api/sso/RedirectConfiguration.kt index 04e33522e..df62b49d0 100644 --- a/source/api/src/main/kotlin/com/clerk/api/sso/RedirectConfiguration.kt +++ b/source/api/src/main/kotlin/com/clerk/api/sso/RedirectConfiguration.kt @@ -1,6 +1,7 @@ package com.clerk.api.sso import com.clerk.api.Clerk +import java.net.URL /** * Internal configuration object for OAuth redirect URLs. @@ -15,6 +16,7 @@ internal object RedirectConfiguration { private const val SCHEME = "clerk" private const val DEFAULT_HOST_SUFFIX = "callback" private const val LEGACY_HOST_SUFFIX = "oauth" + private const val DEFAULT_HTTPS_PORT = 443 /** * The default redirect URL used for OAuth authentication flows. @@ -35,7 +37,22 @@ internal object RedirectConfiguration { val LEGACY_REDIRECT_URL: String get() = buildRedirectUrl(LEGACY_HOST_SUFFIX) + internal fun emailLinkRedirectUrl( + applicationId: String, + proxyUrl: String? = Clerk.proxyUrl, + ): String { + val portSuffix = resolveNonDefaultHttpsPort(proxyUrl) + return "$SCHEME://$applicationId.$LEGACY_HOST_SUFFIX$portSuffix" + } + private fun buildRedirectUrl(hostSuffix: String): String { return "$SCHEME://${Clerk.applicationId}.$hostSuffix" } + + private fun resolveNonDefaultHttpsPort(proxyUrl: String?): String { + val parsedPort = + proxyUrl?.takeUnless { it.isBlank() }?.let { runCatching { URL(it) }.getOrNull() }?.port + val nonDefaultPort = parsedPort?.takeIf { it > 0 && it != DEFAULT_HTTPS_PORT } + return nonDefaultPort?.let { ":$it" }.orEmpty() + } } diff --git a/source/api/src/main/kotlin/com/clerk/api/sso/SSOManagerActivity.kt b/source/api/src/main/kotlin/com/clerk/api/sso/SSOManagerActivity.kt index 14cd8187c..c2e1b15b2 100644 --- a/source/api/src/main/kotlin/com/clerk/api/sso/SSOManagerActivity.kt +++ b/source/api/src/main/kotlin/com/clerk/api/sso/SSOManagerActivity.kt @@ -11,6 +11,7 @@ import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import com.clerk.api.Constants.Storage.KEY_AUTHORIZATION_STARTED import com.clerk.api.log.ClerkLog +import com.clerk.api.magiclink.NativeMagicLinkService import kotlinx.coroutines.launch /** @@ -42,6 +43,17 @@ internal class SSOManagerActivity : AppCompatActivity() { override fun onResume() { super.onResume() + val callbackUri = intent.data?.takeIf(::isCallbackUri) + if (callbackUri != null) { + if (!completionStarted) { + completionStarted = true + authorizationStarted = true + intent = Intent(intent).apply { data = null } + authorizationComplete(callbackUri) + } + return + } + // on first run, launch the intent to start the OAuth/SSO flow in the browser if (!authorizationStarted) { try { @@ -109,6 +121,11 @@ internal class SSOManagerActivity : AppCompatActivity() { try { // Mark the Activity result as success so callers don't observe RESULT_CANCELED setResult(RESULT_OK, Intent()) + if (NativeMagicLinkService.canHandle(uri)) { + ClerkLog.d("authorizationComplete called with native magic link redirect: $uri") + NativeMagicLinkService.handleMagicLinkDeepLink(uri) + return@launch + } if (SSOService.hasPendingExternalAccountConnection()) { ClerkLog.d("authorizationComplete called with external connection") SSOService.completeExternalConnection() @@ -123,6 +140,12 @@ internal class SSOManagerActivity : AppCompatActivity() { } } + private fun isCallbackUri(uri: Uri): Boolean { + return uri.scheme?.startsWith("clerk") == true || + NativeMagicLinkService.canHandle(uri) || + uri.getQueryParameter("rotating_token_nonce") != null + } + /** Handles authentication cancellation by the user. */ private fun authorizationCanceled() { val response = Intent() diff --git a/source/api/src/test/java/com/clerk/api/log/SafeUriLogTest.kt b/source/api/src/test/java/com/clerk/api/log/SafeUriLogTest.kt new file mode 100644 index 000000000..e8007ce93 --- /dev/null +++ b/source/api/src/test/java/com/clerk/api/log/SafeUriLogTest.kt @@ -0,0 +1,39 @@ +package com.clerk.api.log + +import android.net.Uri +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SafeUriLogTest { + + @Test + fun `describe includes URI shape but redacts parameter values`() { + val uri = + Uri.parse( + "https://example.com/v1/verify?token=secret-token&flow_id=flow_123&approval_token=approval_123" + ) + + val description = SafeUriLog.describe(uri) + + assertTrue(description.contains("scheme=https")) + assertTrue(description.contains("host=example.com")) + assertTrue(description.contains("query_keys=[approval_token, flow_id, token]")) + assertFalse(description.contains("secret-token")) + assertFalse(description.contains("approval_123")) + } + + @Test + fun `describe parses fragment keys`() { + val uri = Uri.parse("clerk://callback#flow_id=flow_123&approval_token=approval_123") + + val description = SafeUriLog.describe(uri) + + assertTrue(description.contains("fragment_keys=[approval_token, flow_id]")) + assertFalse(description.contains("flow_123")) + assertFalse(description.contains("approval_123")) + } +} diff --git a/source/api/src/test/java/com/clerk/api/magiclink/MagicLinkDeepLinkParserTest.kt b/source/api/src/test/java/com/clerk/api/magiclink/MagicLinkDeepLinkParserTest.kt new file mode 100644 index 000000000..e1726d283 --- /dev/null +++ b/source/api/src/test/java/com/clerk/api/magiclink/MagicLinkDeepLinkParserTest.kt @@ -0,0 +1,47 @@ +package com.clerk.api.magiclink + +import android.net.Uri +import com.clerk.api.network.serialization.ClerkResult +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class MagicLinkDeepLinkParserTest { + @Test + fun `parses flow_id and approval_token from query`() { + val uri = Uri.parse("clerk://callback?flow_id=flow_123&approval_token=approval_123") + + val result = parseMagicLinkCallback(uri) + + assertTrue(result is ClerkResult.Success) + val value = (result as ClerkResult.Success).value + assertEquals("flow_123", value.flowId) + assertEquals("approval_123", value.approvalToken) + } + + @Test + fun `parses flow_id and approval_token from fragment`() { + val uri = Uri.parse("clerk://callback#flow_id=flow_123&approval_token=approval_123") + + val result = parseMagicLinkCallback(uri) + + assertTrue(result is ClerkResult.Success) + val value = (result as ClerkResult.Success).value + assertEquals("flow_123", value.flowId) + assertEquals("approval_123", value.approvalToken) + } + + @Test + fun `missing flow_id returns deterministic reason code`() { + val uri = Uri.parse("clerk://callback?approval_token=approval_123") + + val result = parseMagicLinkCallback(uri) + + assertTrue(result is ClerkResult.Failure) + val reason = (result as ClerkResult.Failure).error?.reasonCode + assertEquals(NativeMagicLinkReason.MISSING_FLOW_ID.code, reason) + } +} diff --git a/source/api/src/test/java/com/clerk/api/magiclink/NativeMagicLinkErrorMappingTest.kt b/source/api/src/test/java/com/clerk/api/magiclink/NativeMagicLinkErrorMappingTest.kt new file mode 100644 index 000000000..8ad070476 --- /dev/null +++ b/source/api/src/test/java/com/clerk/api/magiclink/NativeMagicLinkErrorMappingTest.kt @@ -0,0 +1,45 @@ +package com.clerk.api.magiclink + +import com.clerk.api.network.model.error.ClerkErrorResponse +import com.clerk.api.network.model.error.Error +import com.clerk.api.network.serialization.ClerkResult +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class NativeMagicLinkErrorMappingTest { + @Test + fun `maps known backend error codes deterministically`() { + assertMapped("approval_token_consumed", NativeMagicLinkReason.APPROVAL_TOKEN_CONSUMED) + assertMapped("approval_token_expired", NativeMagicLinkReason.APPROVAL_TOKEN_EXPIRED) + assertMapped("approval_token_invalid", NativeMagicLinkReason.APPROVAL_TOKEN_INVALID) + assertMapped("pkce_verification_failed", NativeMagicLinkReason.PKCE_VERIFICATION_FAILED) + assertMapped("flow_not_approved", NativeMagicLinkReason.FLOW_NOT_APPROVED) + } + + @Test + fun `unknown backend errors preserve backend error code`() { + val mapped = + failure(code = "too_many_requests", longMessage = "Too many requests, retry later") + .toNativeMagicLinkError(NativeMagicLinkReason.COMPLETE_FAILED) + + assertEquals("too_many_requests", mapped.reasonCode) + assertEquals("Too many requests, retry later", mapped.message) + } + + private fun assertMapped(apiCode: String, expected: NativeMagicLinkReason) { + val mapped = failure(apiCode).toNativeMagicLinkError(NativeMagicLinkReason.COMPLETE_FAILED) + assertEquals(expected.code, mapped.reasonCode) + } + + private fun failure( + code: String, + longMessage: String? = null, + ): ClerkResult.Failure { + val errorResponse = + ClerkErrorResponse(errors = listOf(Error(code = code, longMessage = longMessage))) + return ClerkResult.apiFailure(errorResponse) + } +} diff --git a/source/api/src/test/java/com/clerk/api/magiclink/NativeMagicLinkServiceTest.kt b/source/api/src/test/java/com/clerk/api/magiclink/NativeMagicLinkServiceTest.kt new file mode 100644 index 000000000..d3c050bc5 --- /dev/null +++ b/source/api/src/test/java/com/clerk/api/magiclink/NativeMagicLinkServiceTest.kt @@ -0,0 +1,234 @@ +package com.clerk.api.magiclink + +import android.net.Uri +import com.clerk.api.Clerk +import com.clerk.api.auth.Auth +import com.clerk.api.network.ClerkApi +import com.clerk.api.network.api.ClientApi +import com.clerk.api.network.api.MagicLinkApi +import com.clerk.api.network.api.SignInApi +import com.clerk.api.network.api.SignUpApi +import com.clerk.api.network.model.client.Client +import com.clerk.api.network.model.error.ClerkErrorResponse +import com.clerk.api.network.model.error.Error +import com.clerk.api.network.model.factor.Factor +import com.clerk.api.network.model.magiclink.NativeMagicLinkCompleteResponse +import com.clerk.api.network.serialization.ClerkResult +import com.clerk.api.session.Session +import com.clerk.api.signin.SignIn +import com.clerk.api.signup.SignUp +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.runs +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class NativeMagicLinkServiceTest { + private lateinit var signInApi: SignInApi + private lateinit var signUpApi: SignUpApi + private lateinit var magicLinkApi: MagicLinkApi + private lateinit var clientApi: ClientApi + private lateinit var auth: Auth + + @Before + fun setup() { + signInApi = mockk(relaxed = true) + signUpApi = mockk(relaxed = true) + magicLinkApi = mockk(relaxed = true) + clientApi = mockk(relaxed = true) + auth = mockk(relaxed = true) + + mockkObject(ClerkApi) + mockkObject(Clerk) + + every { ClerkApi.signIn } returns signInApi + every { ClerkApi.signUp } returns signUpApi + every { ClerkApi.magicLink } returns magicLinkApi + every { ClerkApi.client } returns clientApi + every { Clerk.auth } returns auth + every { Clerk.locale } returns MutableStateFlow("en") + every { Clerk.applicationId } returns "com.clerk.test" + every { Clerk.proxyUrl } returns null + every { Clerk.updateClient(any()) } just runs + + NativeMagicLinkService.resetForTests() + } + + @After + fun tearDown() { + NativeMagicLinkService.resetForTests() + unmockkAll() + } + + @Test + fun `end-to-end native magic link flow completes with ticket sign-in`() = runTest { + val initialSignIn = + SignIn( + id = "sign_in_123", + supportedFirstFactors = + listOf(Factor(strategy = "email_link", emailAddressId = "email_123")), + ) + val preparedSignIn = initialSignIn.copy(status = SignIn.Status.NEEDS_FIRST_FACTOR) + val completedSignIn = + initialSignIn.copy(status = SignIn.Status.COMPLETE, createdSessionId = "sess_123") + val refreshedClient = mockk(relaxed = true) + val activatedSession = mockk(relaxed = true) + + coEvery { signInApi.createSignIn(any()) } returns ClerkResult.success(initialSignIn) + coEvery { signInApi.prepareSignInFirstFactor(any(), any()) } returns + ClerkResult.success(preparedSignIn) + coEvery { magicLinkApi.complete(any()) } returns + ClerkResult.success(NativeMagicLinkCompleteResponse(ticket = "ticket_123")) + coEvery { auth.signInWithTicket("ticket_123") } returns ClerkResult.success(completedSignIn) + coEvery { auth.setActive("sess_123", null) } returns ClerkResult.success(activatedSession) + coEvery { clientApi.get() } returns ClerkResult.success(refreshedClient) + + val startResult = NativeMagicLinkService.startEmailLinkSignIn("user@example.com") + assertTrue(startResult is ClerkResult.Success) + + val callbackUri = + Uri.parse("clerk://com.clerk.test.oauth?flow_id=flow_123&approval_token=approval_123") + val completeResult = NativeMagicLinkService.handleMagicLinkDeepLink(callbackUri) + assertTrue(completeResult is ClerkResult.Success) + assertEquals(SignIn.Status.COMPLETE, (completeResult as ClerkResult.Success).value.status) + + coVerify(exactly = 1) { + signInApi.prepareSignInFirstFactor( + "sign_in_123", + match { + it["strategy"] == "email_link" && + it["email_address_id"] == "email_123" && + it["code_challenge_method"] == "S256" && + !it.containsKey("native_flow") && + !it.containsKey("code_verifier") + }, + ) + } + coVerify(exactly = 1) { + magicLinkApi.complete( + match { + it["flow_id"] == "flow_123" && + it["approval_token"] == "approval_123" && + it["code_verifier"]?.isNotBlank() == true + } + ) + } + coVerify(exactly = 1) { auth.signInWithTicket("ticket_123") } + coVerify(exactly = 1) { auth.setActive("sess_123", null) } + coVerify(exactly = 0) { signInApi.fetchSignIn(any(), any()) } + } + + @Test + fun `sign-up native email-link prepare stores PKCE verifier for callback completion`() = runTest { + val preparedSignUp = mockk(relaxed = true) + val completedSignIn = + SignIn(id = "sign_in_123", status = SignIn.Status.COMPLETE, createdSessionId = "sess_456") + val refreshedClient = mockk(relaxed = true) + val activatedSession = mockk(relaxed = true) + val strategy = + SignUp.PrepareVerificationParams.Strategy.EmailLink( + redirectUri = "clerk://com.clerk.test.oauth" + ) + + coEvery { signUpApi.prepareSignUpVerification("sign_up_123", any()) } returns + ClerkResult.success(preparedSignUp) + coEvery { magicLinkApi.complete(any()) } returns + ClerkResult.success(NativeMagicLinkCompleteResponse(ticket = "ticket_signup")) + coEvery { auth.signInWithTicket("ticket_signup") } returns ClerkResult.success(completedSignIn) + coEvery { auth.setActive("sess_456", null) } returns ClerkResult.success(activatedSession) + coEvery { clientApi.get() } returns ClerkResult.success(refreshedClient) + + val prepareResult = + NativeMagicLinkService.prepareSignUpEmailLink(signUpId = "sign_up_123", strategy = strategy) + assertTrue(prepareResult is ClerkResult.Success) + + val callbackUri = + Uri.parse("clerk://com.clerk.test.oauth?flow_id=sua_123&approval_token=approval_123") + val completeResult = NativeMagicLinkService.handleMagicLinkDeepLink(callbackUri) + assertTrue(completeResult is ClerkResult.Success) + + coVerify(exactly = 1) { + signUpApi.prepareSignUpVerification( + "sign_up_123", + match { + it["strategy"] == "email_link" && + it["redirect_uri"] == "clerk://com.clerk.test.oauth" && + it["code_challenge_method"] == "S256" && + it["code_challenge"]?.isNotBlank() == true + }, + ) + } + coVerify(exactly = 1) { + magicLinkApi.complete( + match { + it["flow_id"] == "sua_123" && + it["approval_token"] == "approval_123" && + it["code_verifier"]?.isNotBlank() == true + } + ) + } + coVerify(exactly = 1) { auth.setActive("sess_456", null) } + } + + @Test + fun `complete returns activation failure when created session cannot be activated`() = runTest { + val initialSignIn = + SignIn( + id = "sign_in_123", + supportedFirstFactors = + listOf(Factor(strategy = "email_link", emailAddressId = "email_123")), + ) + val preparedSignIn = initialSignIn.copy(status = SignIn.Status.NEEDS_FIRST_FACTOR) + val completedSignIn = + initialSignIn.copy(status = SignIn.Status.COMPLETE, createdSessionId = "sess_unactivated") + val refreshedClient = mockk(relaxed = true) + val apiError = + ClerkErrorResponse( + errors = + listOf( + Error( + message = "is invalid", + longMessage = "Could not activate session", + code = "session_cannot_be_activated", + ) + ) + ) + + coEvery { signInApi.createSignIn(any()) } returns ClerkResult.success(initialSignIn) + coEvery { signInApi.prepareSignInFirstFactor(any(), any()) } returns + ClerkResult.success(preparedSignIn) + coEvery { magicLinkApi.complete(any()) } returns + ClerkResult.success(NativeMagicLinkCompleteResponse(ticket = "ticket_123")) + coEvery { auth.signInWithTicket("ticket_123") } returns ClerkResult.success(completedSignIn) + coEvery { auth.setActive("sess_unactivated", null) } returns ClerkResult.apiFailure(apiError) + coEvery { clientApi.get() } returns ClerkResult.success(refreshedClient) + + val startResult = NativeMagicLinkService.startEmailLinkSignIn("user@example.com") + assertTrue(startResult is ClerkResult.Success) + + val callbackUri = + Uri.parse("clerk://com.clerk.test.oauth?flow_id=flow_123&approval_token=approval_123") + val completeResult = NativeMagicLinkService.handleMagicLinkDeepLink(callbackUri) + + assertTrue(completeResult is ClerkResult.Failure) + assertEquals( + "session_cannot_be_activated", + (completeResult as ClerkResult.Failure).error?.reasonCode, + ) + assertEquals("Could not activate session", completeResult.error?.message) + coVerify(exactly = 1) { auth.setActive("sess_unactivated", null) } + } +} diff --git a/source/api/src/test/java/com/clerk/api/magiclink/PkceUtilTest.kt b/source/api/src/test/java/com/clerk/api/magiclink/PkceUtilTest.kt new file mode 100644 index 000000000..5fd374b87 --- /dev/null +++ b/source/api/src/test/java/com/clerk/api/magiclink/PkceUtilTest.kt @@ -0,0 +1,37 @@ +package com.clerk.api.magiclink + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PkceUtilTest { + @Test + fun `generatePair returns verifier and challenge in base64url format`() { + val pair = PkceUtil.generatePair() + + assertTrue(pair.verifier.length in 43..128) + assertFalse(pair.verifier.contains("=")) + assertTrue(BASE64_URL_REGEX.matches(pair.verifier)) + + assertFalse(pair.challenge.contains("=")) + assertTrue(BASE64_URL_REGEX.matches(pair.challenge)) + } + + @Test + fun `challenge matches verifier using S256`() { + val verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + val expectedChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + + val challenge = PkceUtil.createS256CodeChallenge(verifier) + + assertEquals(expectedChallenge, challenge) + } + + private companion object { + private val BASE64_URL_REGEX = Regex("^[A-Za-z0-9_-]+$") + } +} diff --git a/source/api/src/test/java/com/clerk/api/signin/SignInExtensionsTest.kt b/source/api/src/test/java/com/clerk/api/signin/SignInExtensionsTest.kt new file mode 100644 index 000000000..6319e863c --- /dev/null +++ b/source/api/src/test/java/com/clerk/api/signin/SignInExtensionsTest.kt @@ -0,0 +1,115 @@ +package com.clerk.api.signin + +import com.clerk.api.Clerk +import com.clerk.api.network.model.environment.DisplayConfig +import com.clerk.api.network.model.environment.Environment +import com.clerk.api.network.model.environment.PreferredSignInStrategy +import com.clerk.api.network.model.factor.Factor +import com.clerk.api.network.model.verification.Verification +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SignInExtensionsTest { + private lateinit var environment: Environment + private lateinit var displayConfig: DisplayConfig + + @Before + fun setup() { + environment = mockk(relaxed = true) + displayConfig = mockk(relaxed = true) + every { environment.displayConfig } returns displayConfig + Clerk.environment = environment + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `startingFirstFactor prefers email_link for email identifier when password is preferred`() { + every { displayConfig.preferredSignInStrategy } returns PreferredSignInStrategy.PASSWORD + val signIn = + SignIn( + id = "sign_in_123", + identifier = "user@example.com", + supportedFirstFactors = + listOf( + Factor(strategy = "password", safeIdentifier = "user@example.com"), + Factor(strategy = "email_code", emailAddressId = "email_123"), + Factor(strategy = "email_link", emailAddressId = "email_123"), + ), + ) + + val factor = signIn.startingFirstFactor + + assertEquals("email_link", factor?.strategy) + } + + @Test + fun `startingFirstFactor prefers email_link for email identifier when otp is preferred`() { + every { displayConfig.preferredSignInStrategy } returns PreferredSignInStrategy.OTP + val signIn = + SignIn( + id = "sign_in_123", + identifier = "user@example.com", + supportedFirstFactors = + listOf( + Factor(strategy = "email_code", emailAddressId = "email_123"), + Factor(strategy = "email_link", emailAddressId = "email_123"), + ), + ) + + val factor = signIn.startingFirstFactor + + assertEquals("email_link", factor?.strategy) + } + + @Test + fun `startingFirstFactor does not force email_link for non-email identifiers`() { + every { displayConfig.preferredSignInStrategy } returns PreferredSignInStrategy.OTP + val signIn = + SignIn( + id = "sign_in_123", + identifier = "username_123", + supportedFirstFactors = + listOf( + Factor(strategy = "passkey"), + Factor(strategy = "email_link", emailAddressId = "email_123"), + ), + ) + + val factor = signIn.startingFirstFactor + + assertEquals("passkey", factor?.strategy) + } + + @Test + fun `startingFirstFactor returns prepared email_code when verification is already prepared`() { + every { displayConfig.preferredSignInStrategy } returns PreferredSignInStrategy.OTP + val signIn = + SignIn( + id = "sign_in_123", + identifier = "user@example.com", + supportedFirstFactors = + listOf( + Factor(strategy = "email_code", emailAddressId = "email_123"), + Factor(strategy = "email_link", emailAddressId = "email_123"), + ), + firstFactorVerification = + Verification(status = Verification.Status.UNVERIFIED, strategy = "email_code"), + ) + + val factor = signIn.startingFirstFactor + + assertEquals("email_code", factor?.strategy) + } +} diff --git a/source/api/src/test/java/com/clerk/api/signup/SignUpEmailVerificationStrategyTest.kt b/source/api/src/test/java/com/clerk/api/signup/SignUpEmailVerificationStrategyTest.kt new file mode 100644 index 000000000..61e88a2f1 --- /dev/null +++ b/source/api/src/test/java/com/clerk/api/signup/SignUpEmailVerificationStrategyTest.kt @@ -0,0 +1,120 @@ +package com.clerk.api.signup + +import com.clerk.api.Clerk +import com.clerk.api.Constants +import com.clerk.api.network.ClerkApi +import com.clerk.api.network.api.SignUpApi +import com.clerk.api.network.model.environment.Environment +import com.clerk.api.network.model.environment.UserSettings +import com.clerk.api.network.model.verification.Verification +import com.clerk.api.network.serialization.ClerkResult +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkAll +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SignUpEmailVerificationStrategyTest { + private val mockSignUpApi = mockk(relaxed = true) + private val environment = mockk(relaxed = true) + private val userSettings = mockk(relaxed = true) + + @Before + fun setUp() { + mockkObject(ClerkApi) + every { ClerkApi.signUp } returns mockSignUpApi + every { environment.userSettings } returns userSettings + Clerk.environment = environment + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun emailVerificationStrategyUsesActiveVerificationStrategyWhenPresent() { + every { userSettings.attributes } returns emptyMap() + val signUp = + signUp( + verifications = + mapOf( + "email_address" to + Verification( + status = Verification.Status.UNVERIFIED, + strategy = Constants.Strategy.EMAIL_LINK, + ) + ) + ) + + assertEquals(Constants.Strategy.EMAIL_LINK, signUp.emailVerificationStrategy) + } + + @Test + fun emailVerificationStrategyFallsBackToEnvironmentStrategiesWhenNoActiveStrategyExists() { + every { userSettings.attributes } returns + mapOf( + "email_address" to + UserSettings.AttributesConfig( + enabled = true, + required = true, + usedForFirstFactor = true, + firstFactors = listOf(Constants.Strategy.EMAIL_CODE, Constants.Strategy.EMAIL_LINK), + usedForSecondFactor = false, + secondFactors = emptyList(), + verifications = listOf(Constants.Strategy.EMAIL_LINK), + verifyAtSignUp = true, + ) + ) + val signUp = signUp() + + assertEquals(Constants.Strategy.EMAIL_LINK, signUp.emailVerificationStrategy) + assertTrue(signUp.isEmailLinkVerificationSupported) + } + + @Test + fun prepareVerificationMapsNativeEmailLinkPkceFields() { + val fieldsSlot = slot>() + val signUp = signUp() + + coEvery { mockSignUpApi.prepareSignUpVerification(signUp.id, capture(fieldsSlot)) } returns + ClerkResult.success(signUp) + + runBlocking { + signUp.prepareVerification( + SignUp.PrepareVerificationParams.Strategy.EmailLink(redirectUrl = "app://clerk-callback") + ) + } + + coVerify(exactly = 1) { mockSignUpApi.prepareSignUpVerification(signUp.id, any()) } + assertEquals(Constants.Strategy.EMAIL_LINK, fieldsSlot.captured["strategy"]) + assertEquals("app://clerk-callback", fieldsSlot.captured["redirect_uri"]) + assertEquals("S256", fieldsSlot.captured["code_challenge_method"]) + assertTrue(fieldsSlot.captured["code_challenge"]?.isNotBlank() == true) + } + + private fun signUp(verifications: Map = emptyMap()): SignUp { + return SignUp( + id = "sign_up_123", + status = SignUp.Status.MISSING_REQUIREMENTS, + requiredFields = listOf("email_address"), + optionalFields = emptyList(), + missingFields = emptyList(), + unverifiedFields = listOf("email_address"), + verifications = verifications, + emailAddress = "sam@clerk.dev", + passwordEnabled = false, + ) + } +} diff --git a/source/api/src/test/java/com/clerk/api/sso/RedirectConfigurationTest.kt b/source/api/src/test/java/com/clerk/api/sso/RedirectConfigurationTest.kt index e99624b1b..5b8f77dbd 100644 --- a/source/api/src/test/java/com/clerk/api/sso/RedirectConfigurationTest.kt +++ b/source/api/src/test/java/com/clerk/api/sso/RedirectConfigurationTest.kt @@ -42,4 +42,26 @@ class RedirectConfigurationTest { assertEquals("clerk://null.callback", RedirectConfiguration.DEFAULT_REDIRECT_URL) } + + @Test + fun emailLinkRedirectUrl_usesProxyPortWhenConfigured() { + assertEquals( + "clerk://com.clerk.workbench.oauth:8443", + RedirectConfiguration.emailLinkRedirectUrl( + applicationId = "com.clerk.workbench", + proxyUrl = "https://rapid-earwig-10.clerk.accounts.lclclerk.com:8443", + ), + ) + } + + @Test + fun emailLinkRedirectUrl_omitsStandardHttpsPort() { + assertEquals( + "clerk://com.clerk.workbench.oauth", + RedirectConfiguration.emailLinkRedirectUrl( + applicationId = "com.clerk.workbench", + proxyUrl = "https://rapid-earwig-10.clerk.accounts.lclclerk.com:443", + ), + ) + } } diff --git a/source/api/src/test/java/com/clerk/api/sso/SSOManagerActivityTest.kt b/source/api/src/test/java/com/clerk/api/sso/SSOManagerActivityTest.kt index 5c60e4593..1b6ab0e71 100644 --- a/source/api/src/test/java/com/clerk/api/sso/SSOManagerActivityTest.kt +++ b/source/api/src/test/java/com/clerk/api/sso/SSOManagerActivityTest.kt @@ -48,6 +48,24 @@ class SSOManagerActivityTest { assertEquals(Activity.RESULT_OK, shadow.resultCode) } + @Test + fun callbackIntent_completesWithoutAuthorizationStarted() { + mockkObject(SSOService) + every { SSOService.hasPendingExternalAccountConnection() } returns false + coJustRun { SSOService.completeAuthenticateWithRedirect(any()) } + + val app = ApplicationProvider.getApplicationContext() + val responseUri = Uri.parse("clerk://callback?rotating_token_nonce=abc") + val intent = SSOManagerActivity.createResponseHandlingIntent(app, responseUri) + + val controller = Robolectric.buildActivity(SSOManagerActivity::class.java, intent) + val activity = controller.create().resume().get() + + coVerify(exactly = 1) { SSOService.completeAuthenticateWithRedirect(any()) } + val shadow = Shadows.shadowOf(activity) + assertEquals(Activity.RESULT_OK, shadow.resultCode) + } + @Test fun authorizationCanceled_setsResultCanceled_whenNoData() { val app = ApplicationProvider.getApplicationContext() diff --git a/source/ui/src/main/java/com/clerk/ui/auth/AuthStartView.kt b/source/ui/src/main/java/com/clerk/ui/auth/AuthStartView.kt index 1538badf0..a96e64cad 100644 --- a/source/ui/src/main/java/com/clerk/ui/auth/AuthStartView.kt +++ b/source/ui/src/main/java/com/clerk/ui/auth/AuthStartView.kt @@ -174,6 +174,12 @@ internal fun AuthStartViewImpl( authStartIdentifier = authState.authStartIdentifier, ) } + authState.lastSubmittedIdentifier = + if (phoneActive && authViewHelper.phoneNumberIsEnabled) { + authState.authStartPhoneNumber + } else { + authState.authStartIdentifier + } authStartViewModel.startAuth( authMode = authState.mode, isPhoneNumberFieldActive = phoneActive, diff --git a/source/ui/src/main/java/com/clerk/ui/auth/AuthState.kt b/source/ui/src/main/java/com/clerk/ui/auth/AuthState.kt index 5a3b19546..0463a5759 100644 --- a/source/ui/src/main/java/com/clerk/ui/auth/AuthState.kt +++ b/source/ui/src/main/java/com/clerk/ui/auth/AuthState.kt @@ -17,6 +17,7 @@ import com.clerk.api.signin.SignIn import com.clerk.api.signin.startingFirstFactor import com.clerk.api.signin.startingSecondFactor import com.clerk.api.signup.SignUp +import com.clerk.api.signup.emailVerificationStrategy import com.clerk.api.signup.firstFieldToCollect import com.clerk.api.signup.firstFieldToVerify import com.clerk.ui.core.common.NavigableState @@ -73,6 +74,8 @@ internal class AuthState( persistStoredValue(AUTH_START_PHONE_NUMBER_STORAGE_KEY, value) } + var lastSubmittedIdentifier by mutableStateOf(null) + // Sign In var signInPassword by mutableStateOf("") var signInNewPassword by mutableStateOf("") @@ -174,8 +177,16 @@ internal class AuthState( } private fun routeToFirstFactorOrHelp(signIn: SignIn) { - signIn.startingFirstFactor?.let { backStack.add(AuthDestination.SignInFactorOne(factor = it)) } - ?: backStack.add(AuthDestination.SignInGetHelp) + val resolvedSignIn = + if (signIn.identifier.isNullOrBlank() && !lastSubmittedIdentifier.isNullOrBlank()) { + signIn.copy(identifier = lastSubmittedIdentifier) + } else { + signIn + } + + resolvedSignIn.startingFirstFactor?.let { + backStack.add(AuthDestination.SignInFactorOne(factor = it)) + } ?: backStack.add(AuthDestination.SignInGetHelp) } private fun routeToSecondFactorOrHelp(signIn: SignIn) { @@ -223,7 +234,13 @@ internal class AuthState( EMAIL_ADDRESS -> { val emailAddress = signUp.emailAddress if (emailAddress != null) { - backStack.add(AuthDestination.SignUpCode(field = SignUpCodeField.Email(emailAddress))) + val destination = + if (signUp.emailVerificationStrategy == Constants.Strategy.EMAIL_LINK) { + AuthDestination.SignUpEmailLink(emailAddress = emailAddress) + } else { + AuthDestination.SignUpCode(field = SignUpCodeField.Email(emailAddress)) + } + backStack.add(destination) } else { resetToRoot() } diff --git a/source/ui/src/main/java/com/clerk/ui/auth/AuthView.kt b/source/ui/src/main/java/com/clerk/ui/auth/AuthView.kt index cf6a1e793..f4174cc90 100644 --- a/source/ui/src/main/java/com/clerk/ui/auth/AuthView.kt +++ b/source/ui/src/main/java/com/clerk/ui/auth/AuthView.kt @@ -41,6 +41,7 @@ import com.clerk.ui.signup.code.SignUpCodeView import com.clerk.ui.signup.collectfield.CollectField import com.clerk.ui.signup.collectfield.SignUpCollectFieldView import com.clerk.ui.signup.completeprofile.SignUpCompleteProfileView +import com.clerk.ui.signup.emaillink.SignUpEmailLinkView import com.clerk.ui.theme.ClerkThemeOverrideProvider import kotlinx.serialization.Serializable @@ -173,6 +174,9 @@ private fun authEntryProvider(backStack: NavBackStack, onAuthComplete: ( entry { SignUpCodeView(field = it.field, onAuthComplete = onAuthComplete) } + entry { + SignUpEmailLinkView(emailAddress = it.emailAddress, onAuthComplete = onAuthComplete) + } entry { SignUpCompleteProfileView(onAuthComplete = onAuthComplete) } @@ -242,5 +246,7 @@ internal object AuthDestination { @Serializable data class SignUpCode(val field: SignUpCodeField) : NavKey + @Serializable data class SignUpEmailLink(val emailAddress: String) : NavKey + @Serializable data class SignUpCompleteProfile(val progress: Int) : NavKey } diff --git a/source/ui/src/main/java/com/clerk/ui/core/common/StrategyKeys.kt b/source/ui/src/main/java/com/clerk/ui/core/common/StrategyKeys.kt index dc2843ef8..7b34e8c14 100644 --- a/source/ui/src/main/java/com/clerk/ui/core/common/StrategyKeys.kt +++ b/source/ui/src/main/java/com/clerk/ui/core/common/StrategyKeys.kt @@ -7,6 +7,7 @@ internal object StrategyKeys { internal const val PASSWORD = "password" internal const val PHONE_CODE = "phone_code" internal const val EMAIL_CODE = "email_code" + internal const val EMAIL_LINK = "email_link" internal const val TOTP = "totp" internal const val BACKUP_CODE = "backup_code" diff --git a/source/ui/src/main/java/com/clerk/ui/signin/SignInFactorOneView.kt b/source/ui/src/main/java/com/clerk/ui/signin/SignInFactorOneView.kt index f7988a078..43979da99 100644 --- a/source/ui/src/main/java/com/clerk/ui/signin/SignInFactorOneView.kt +++ b/source/ui/src/main/java/com/clerk/ui/signin/SignInFactorOneView.kt @@ -2,11 +2,15 @@ package com.clerk.ui.signin import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.clerk.api.Clerk import com.clerk.api.network.model.factor.Factor +import com.clerk.api.signin.SignIn +import com.clerk.api.signin.startingFirstFactor import com.clerk.api.ui.ClerkTheme import com.clerk.ui.auth.PreviewAuthStateProvider import com.clerk.ui.core.common.StrategyKeys import com.clerk.ui.signin.code.SignInFactorCodeView +import com.clerk.ui.signin.emaillink.SignInFactorOneEmailLinkView import com.clerk.ui.signin.help.SignInGetHelpView import com.clerk.ui.signin.passkey.SignInFactorOnePasskeyView import com.clerk.ui.signin.password.set.SignInFactorOnePasswordView @@ -19,24 +23,87 @@ fun SignInFactorOneView( clerkTheme: ClerkTheme? = null, onAuthComplete: () -> Unit, ) { + val effectiveFactor = resolveFirstFactor(factor) ClerkThemeOverrideProvider(clerkTheme) { ClerkMaterialTheme { - when (factor.strategy) { + when (effectiveFactor.strategy) { StrategyKeys.PASSKEY -> - SignInFactorOnePasskeyView(factor = factor, onAuthComplete = onAuthComplete) + SignInFactorOnePasskeyView(factor = effectiveFactor, onAuthComplete = onAuthComplete) StrategyKeys.PASSWORD -> - SignInFactorOnePasswordView(factor = factor, onAuthComplete = onAuthComplete) + SignInFactorOnePasswordView(factor = effectiveFactor, onAuthComplete = onAuthComplete) + StrategyKeys.EMAIL_LINK -> + SignInFactorOneEmailLinkView(factor = effectiveFactor, onAuthComplete = onAuthComplete) StrategyKeys.EMAIL_CODE, StrategyKeys.PHONE_CODE, StrategyKeys.RESET_PASSWORD_PHONE_CODE, StrategyKeys.RESET_PASSWORD_EMAIL_CODE -> - SignInFactorCodeView(factor = factor, onAuthComplete = onAuthComplete) + SignInFactorCodeView(factor = effectiveFactor, onAuthComplete = onAuthComplete) else -> SignInGetHelpView() } } } } +internal fun resolveFirstFactor(fallback: Factor): Factor { + val currentSignIn = Clerk.auth.currentSignIn + val supportedFactors = currentSignIn?.supportedFirstFactors.orEmpty() + val hasSignInContext = currentSignIn != null && supportedFactors.isNotEmpty() + + val preparedFactor = + if (hasSignInContext) { + supportedFactors.factorForStrategy(currentSignIn.firstFactorVerification?.strategy) + } else { + null + } + val emailLinkFactor = + if (hasSignInContext) { + currentSignIn.preferredEmailLinkFactor(fallback, supportedFactors) + } else { + null + } + val fallbackIsSupported = hasSignInContext && supportedFactors.hasStrategy(fallback.strategy) + + return if (!hasSignInContext) { + fallback + } else { + preparedFactor + ?: emailLinkFactor + ?: if (fallbackIsSupported) fallback else currentSignIn.startingFirstFactor ?: fallback + } +} + +private fun List.factorForStrategy(strategy: String?): Factor? { + val preparedStrategy = strategy?.takeIf { it.isNotBlank() } ?: return null + return firstOrNull { it.strategy == preparedStrategy } +} + +private fun List.hasStrategy(strategy: String): Boolean { + return any { it.strategy == strategy } +} + +private fun SignIn.preferredEmailLinkFactor( + fallback: Factor, + supportedFactors: List, +): Factor? { + return if (isEmailIdentifierSignIn(fallback, supportedFactors)) { + supportedFactors.firstOrNull { it.strategy == StrategyKeys.EMAIL_LINK } + } else { + null + } +} + +private fun SignIn.isEmailIdentifierSignIn( + fallback: Factor, + supportedFactors: List, +): Boolean { + return (fallback.strategy == StrategyKeys.EMAIL_CODE && fallback.emailAddressId != null) || + identifier?.contains("@") == true || + supportedFactors.any { + (it.strategy == StrategyKeys.EMAIL_LINK || it.strategy == StrategyKeys.EMAIL_CODE) && + it.safeIdentifier?.contains("@") == true + } +} + @PreviewLightDark @Composable private fun PreviewSignInComponent() { diff --git a/source/ui/src/main/java/com/clerk/ui/signin/code/SignInFactorCodeViewModel.kt b/source/ui/src/main/java/com/clerk/ui/signin/code/SignInFactorCodeViewModel.kt index 47af1e377..ad5e00c34 100644 --- a/source/ui/src/main/java/com/clerk/ui/signin/code/SignInFactorCodeViewModel.kt +++ b/source/ui/src/main/java/com/clerk/ui/signin/code/SignInFactorCodeViewModel.kt @@ -30,6 +30,11 @@ internal class SignInFactorCodeViewModel( guardSignIn(_state) { inProgressSignIn -> viewModelScope.launch(Dispatchers.IO) { + if (shouldRerouteForUnsupportedFactor(inProgressSignIn, factor, isSecondFactor)) { + _state.value = AuthenticationViewState.Success.SignIn(inProgressSignIn) + return@launch + } + when (factor.strategy) { StrategyKeys.EMAIL_CODE -> { prepareHandler.prepareForEmailCode(inProgressSignIn, factor, isSecondFactor) { @@ -129,4 +134,43 @@ internal class SignInFactorCodeViewModel( fun resetVerificationState() { _verificationUiState.value = VerificationUiState.Idle } + + private fun shouldRerouteForUnsupportedFactor( + signIn: SignIn, + factor: Factor, + isSecondFactor: Boolean, + ): Boolean { + val supportedFirstFactors = signIn.supportedFirstFactors.orEmpty() + val prefersEmailLinkOverEmailCode = + factor.strategy == StrategyKeys.EMAIL_CODE && + signIn.firstFactorVerification?.strategy != StrategyKeys.EMAIL_CODE && + signIn.shouldPreferEmailLink( + supportedFirstFactors = supportedFirstFactors, + fallbackFactor = factor, + ) + + return if (isSecondFactor) { + signIn.supportedSecondFactors?.none { it.strategy == factor.strategy } == true + } else { + supportedFirstFactors.none { it.strategy == factor.strategy } || prefersEmailLinkOverEmailCode + } + } + + private fun SignIn.shouldPreferEmailLink( + supportedFirstFactors: List, + fallbackFactor: Factor, + ): Boolean { + val hasEmailLink = supportedFirstFactors.any { it.strategy == StrategyKeys.EMAIL_LINK } + if (!hasEmailLink) return false + + val isEmailIdentifier = + fallbackFactor.emailAddressId != null || + fallbackFactor.safeIdentifier?.contains("@") == true || + identifier?.contains("@") == true || + supportedFirstFactors.any { + (it.strategy == StrategyKeys.EMAIL_LINK || it.strategy == StrategyKeys.EMAIL_CODE) && + it.safeIdentifier?.contains("@") == true + } + return isEmailIdentifier + } } diff --git a/source/ui/src/main/java/com/clerk/ui/signin/code/SignInPrepareHandler.kt b/source/ui/src/main/java/com/clerk/ui/signin/code/SignInPrepareHandler.kt index 26b182dd1..cc39c6231 100644 --- a/source/ui/src/main/java/com/clerk/ui/signin/code/SignInPrepareHandler.kt +++ b/source/ui/src/main/java/com/clerk/ui/signin/code/SignInPrepareHandler.kt @@ -8,6 +8,7 @@ import com.clerk.api.network.serialization.onSuccess import com.clerk.api.signin.SignIn import com.clerk.api.signin.prepareFirstFactor import com.clerk.api.signin.prepareSecondFactor +import com.clerk.ui.core.common.StrategyKeys internal class SignInPrepareHandler { @@ -66,10 +67,65 @@ internal class SignInPrepareHandler { } if (isSecondFactor) { + prepareSecondFactorPhoneCode(inProgressSignIn, phoneNumberId, onError) + } else { + prepareFirstFactorPhoneCode(inProgressSignIn, phoneNumberId, onError) + } + } + + internal suspend fun prepareForEmailCode( + inProgressSignIn: SignIn, + factor: Factor, + isSecondFactor: Boolean, + onError: (String) -> Unit, + ) { + val emailAddressId = factor.emailAddressId + if (emailAddressId == null) { + ClerkLog.e("Error preparing for email code: emailAddressId is null") + return + } + + if (isSecondFactor) { + prepareSecondFactorEmailCode(inProgressSignIn, emailAddressId, onError) + } else { + prepareFirstFactorEmailCode(inProgressSignIn, emailAddressId, onError) + } + } + + private suspend fun prepareSecondFactorPhoneCode( + inProgressSignIn: SignIn, + phoneNumberId: String, + onError: (String) -> Unit, + ) { + val isSupported = + isFactorStrategySupported( + factors = inProgressSignIn.supportedSecondFactors, + strategy = SignIn.PrepareSecondFactorParams.PHONE_CODE, + ) + if (!isSupported) { + ClerkLog.e("Error preparing for phone code: strategy no longer supported for second factor") + onError("Selected sign-in method is no longer available.") + } else { inProgressSignIn .prepareSecondFactor(phoneNumberId) .onSuccess { ClerkLog.v("Successfully prepared second factor for phone code") } .onFailure { ClerkLog.e("Error preparing second factor for phone code: $it") } + } + } + + private suspend fun prepareFirstFactorPhoneCode( + inProgressSignIn: SignIn, + phoneNumberId: String, + onError: (String) -> Unit, + ) { + val isSupported = + isFactorStrategySupported( + factors = inProgressSignIn.supportedFirstFactors, + strategy = StrategyKeys.PHONE_CODE, + ) + if (!isSupported) { + ClerkLog.e("Error preparing for phone code: strategy no longer supported for first factor") + onError("Selected sign-in method is no longer available.") } else { inProgressSignIn .prepareFirstFactor( @@ -82,23 +138,40 @@ internal class SignInPrepareHandler { } } - internal suspend fun prepareForEmailCode( + private suspend fun prepareSecondFactorEmailCode( inProgressSignIn: SignIn, - factor: Factor, - isSecondFactor: Boolean, + emailAddressId: String, onError: (String) -> Unit, ) { - val emailAddressId = factor.emailAddressId - if (emailAddressId == null) { - ClerkLog.e("Error preparing for email code: emailAddressId is null") - return - } - - if (isSecondFactor) { + val isSupported = + isFactorStrategySupported( + factors = inProgressSignIn.supportedSecondFactors, + strategy = SignIn.PrepareSecondFactorParams.EMAIL_CODE, + ) + if (!isSupported) { + ClerkLog.e("Error preparing for email code: strategy no longer supported for second factor") + onError("Selected sign-in method is no longer available.") + } else { inProgressSignIn .prepareSecondFactor(emailAddressId = emailAddressId) .onSuccess { ClerkLog.v("Successfully prepared second factor for email code") } .onFailure { ClerkLog.e("Error preparing second factor for email code: $it") } + } + } + + private suspend fun prepareFirstFactorEmailCode( + inProgressSignIn: SignIn, + emailAddressId: String, + onError: (String) -> Unit, + ) { + val isSupported = + isFactorStrategySupported( + factors = inProgressSignIn.supportedFirstFactors, + strategy = StrategyKeys.EMAIL_CODE, + ) + if (!isSupported) { + ClerkLog.e("Error preparing for email code: strategy no longer supported for first factor") + onError("Selected sign-in method is no longer available.") } else { inProgressSignIn .prepareFirstFactor( @@ -111,4 +184,8 @@ internal class SignInPrepareHandler { } } } + + private fun isFactorStrategySupported(factors: List?, strategy: String): Boolean { + return factors.isNullOrEmpty() || factors.any { it.strategy == strategy } + } } diff --git a/source/ui/src/main/java/com/clerk/ui/signin/emaillink/SignInFactorOneEmailLinkView.kt b/source/ui/src/main/java/com/clerk/ui/signin/emaillink/SignInFactorOneEmailLinkView.kt new file mode 100644 index 000000000..df5d45ca3 --- /dev/null +++ b/source/ui/src/main/java/com/clerk/ui/signin/emaillink/SignInFactorOneEmailLinkView.kt @@ -0,0 +1,105 @@ +package com.clerk.ui.signin.emaillink + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.clerk.api.Clerk +import com.clerk.api.network.model.factor.Factor +import com.clerk.api.ui.ClerkTheme +import com.clerk.ui.R +import com.clerk.ui.auth.AuthDestination +import com.clerk.ui.auth.AuthStateEffects +import com.clerk.ui.auth.AuthenticationViewState +import com.clerk.ui.auth.PreviewAuthStateProvider +import com.clerk.ui.core.button.standard.ClerkButton +import com.clerk.ui.core.button.standard.ClerkTextButton +import com.clerk.ui.core.common.StrategyKeys +import com.clerk.ui.core.composition.LocalAuthState +import com.clerk.ui.core.scaffold.ClerkThemedAuthScaffold +import com.clerk.ui.core.spacers.Spacers +import com.clerk.ui.theme.ClerkThemeOverrideProvider + +@Composable +fun SignInFactorOneEmailLinkView( + factor: Factor, + modifier: Modifier = Modifier, + clerkTheme: ClerkTheme? = null, + onAuthComplete: () -> Unit, +) { + ClerkThemeOverrideProvider(clerkTheme) { + SignInFactorOneEmailLinkViewImpl( + factor = factor, + modifier = modifier, + onAuthComplete = onAuthComplete, + ) + } +} + +@Composable +private fun SignInFactorOneEmailLinkViewImpl( + factor: Factor, + modifier: Modifier = Modifier, + viewModel: SignInFactorOneEmailLinkViewModel = viewModel(), + onAuthComplete: () -> Unit, +) { + val authState = LocalAuthState.current + val snackbarHostState = remember { SnackbarHostState() } + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { viewModel.sendLink() } + + AuthStateEffects( + authState = authState, + state = state, + snackbarHostState = snackbarHostState, + onAuthComplete = onAuthComplete, + onReset = viewModel::resetState, + ) + + ClerkThemedAuthScaffold( + modifier = modifier, + onBackPressed = authState::navigateBack, + title = stringResource(R.string.check_your_email), + subtitle = + Clerk.applicationName?.let { stringResource(R.string.to_continue_to, it) } + ?: stringResource(R.string.to_continue), + identifier = factor.safeIdentifier, + onClickIdentifier = { authState.clearBackStack() }, + snackbarHostState = snackbarHostState, + ) { + ClerkButton( + text = stringResource(R.string.resend), + onClick = { viewModel.sendLink() }, + modifier = Modifier.fillMaxWidth(), + isLoading = state is AuthenticationViewState.Loading, + ) + Spacers.Vertical.Spacer24() + ClerkTextButton( + text = stringResource(R.string.use_another_method), + onClick = { + authState.navigateTo( + AuthDestination.SignInFactorOneUseAnotherMethod(currentFactor = factor) + ) + }, + ) + } +} + +@PreviewLightDark +@Composable +private fun PreviewSignInFactorOneEmailLinkView() { + PreviewAuthStateProvider { + SignInFactorOneEmailLinkView( + factor = Factor(strategy = StrategyKeys.EMAIL_LINK, safeIdentifier = "sam@clerk.dev"), + onAuthComplete = {}, + ) + } +} diff --git a/source/ui/src/main/java/com/clerk/ui/signin/emaillink/SignInFactorOneEmailLinkViewModel.kt b/source/ui/src/main/java/com/clerk/ui/signin/emaillink/SignInFactorOneEmailLinkViewModel.kt new file mode 100644 index 000000000..5b49729c9 --- /dev/null +++ b/source/ui/src/main/java/com/clerk/ui/signin/emaillink/SignInFactorOneEmailLinkViewModel.kt @@ -0,0 +1,62 @@ +package com.clerk.ui.signin.emaillink + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.clerk.api.Clerk +import com.clerk.api.auth.AuthEvent +import com.clerk.api.network.serialization.onFailure +import com.clerk.api.network.serialization.onSuccess +import com.clerk.ui.auth.AuthenticationViewState +import com.clerk.ui.auth.guardSignIn +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +internal class SignInFactorOneEmailLinkViewModel : ViewModel() { + private val _state: MutableStateFlow = + MutableStateFlow(AuthenticationViewState.Idle) + val state = _state.asStateFlow() + + init { + viewModelScope.launch { + Clerk.auth.events.collectLatest { event -> + if (event is AuthEvent.SignInCompleted) { + _state.value = AuthenticationViewState.Success.SignIn(event.signIn) + } + } + } + } + + fun sendLink() { + _state.value = AuthenticationViewState.Loading + + guardSignIn(_state) { inProgressSignIn -> + val identifier = inProgressSignIn.identifier + if (identifier.isNullOrBlank() || !identifier.isEmailAddress()) { + _state.value = AuthenticationViewState.Error(null) + return@guardSignIn + } + + viewModelScope.launch(Dispatchers.IO) { + Clerk.auth + .startEmailLinkSignIn(identifier) + .onSuccess { _state.value = AuthenticationViewState.Idle } + .onFailure { failure -> + _state.value = AuthenticationViewState.Error(failure.error?.message) + } + } + } + } + + fun resetState() { + if (_state.value !is AuthenticationViewState.Success.SignIn) { + _state.value = AuthenticationViewState.Idle + } + } +} + +private val emailRegex = Regex("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$") + +private fun String.isEmailAddress(): Boolean = emailRegex.matches(this) diff --git a/source/ui/src/main/java/com/clerk/ui/signup/code/SignUpCodeViewModel.kt b/source/ui/src/main/java/com/clerk/ui/signup/code/SignUpCodeViewModel.kt index 58cfbeaf3..0e932d6f5 100644 --- a/source/ui/src/main/java/com/clerk/ui/signup/code/SignUpCodeViewModel.kt +++ b/source/ui/src/main/java/com/clerk/ui/signup/code/SignUpCodeViewModel.kt @@ -3,11 +3,13 @@ package com.clerk.ui.signup.code import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.clerk.api.Clerk +import com.clerk.api.Constants import com.clerk.api.network.serialization.errorMessage import com.clerk.api.network.serialization.onFailure import com.clerk.api.network.serialization.onSuccess import com.clerk.api.signup.SignUp import com.clerk.api.signup.attemptVerification +import com.clerk.api.signup.emailVerificationStrategy import com.clerk.api.signup.prepareVerification import com.clerk.ui.auth.AuthenticationViewState import com.clerk.ui.auth.VerificationUiState @@ -25,6 +27,13 @@ internal class SignUpCodeViewModel : ViewModel() { fun prepare(field: SignUpCodeField) { val signUp = Clerk.client.signUp ?: return + if ( + field is SignUpCodeField.Email && + signUp.emailVerificationStrategy == Constants.Strategy.EMAIL_LINK + ) { + _state.value = AuthenticationViewState.Success.SignUp(signUp) + return + } viewModelScope.launch { val signUp = when (field) { diff --git a/source/ui/src/main/java/com/clerk/ui/signup/emaillink/SignUpEmailLinkView.kt b/source/ui/src/main/java/com/clerk/ui/signup/emaillink/SignUpEmailLinkView.kt new file mode 100644 index 000000000..308bbda3b --- /dev/null +++ b/source/ui/src/main/java/com/clerk/ui/signup/emaillink/SignUpEmailLinkView.kt @@ -0,0 +1,90 @@ +package com.clerk.ui.signup.emaillink + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.clerk.api.Clerk +import com.clerk.api.ui.ClerkTheme +import com.clerk.ui.R +import com.clerk.ui.auth.AuthStateEffects +import com.clerk.ui.auth.AuthenticationViewState +import com.clerk.ui.auth.PreviewAuthStateProvider +import com.clerk.ui.core.button.standard.ClerkButton +import com.clerk.ui.core.composition.LocalAuthState +import com.clerk.ui.core.scaffold.ClerkThemedAuthScaffold +import com.clerk.ui.core.spacers.Spacers +import com.clerk.ui.theme.ClerkThemeOverrideProvider + +@Composable +fun SignUpEmailLinkView( + emailAddress: String, + modifier: Modifier = Modifier, + clerkTheme: ClerkTheme? = null, + onAuthComplete: () -> Unit, +) { + ClerkThemeOverrideProvider(clerkTheme) { + SignUpEmailLinkViewImpl( + emailAddress = emailAddress, + modifier = modifier, + onAuthComplete = onAuthComplete, + ) + } +} + +@Composable +private fun SignUpEmailLinkViewImpl( + emailAddress: String, + modifier: Modifier = Modifier, + viewModel: SignUpEmailLinkViewModel = viewModel(), + onAuthComplete: () -> Unit, +) { + val authState = LocalAuthState.current + val snackbarHostState = remember { SnackbarHostState() } + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { viewModel.sendLink() } + + AuthStateEffects( + authState = authState, + state = state, + snackbarHostState = snackbarHostState, + onAuthComplete = onAuthComplete, + onReset = viewModel::resetState, + ) + + ClerkThemedAuthScaffold( + modifier = modifier, + title = stringResource(R.string.check_your_email), + subtitle = + Clerk.applicationName?.let { stringResource(R.string.to_continue_to, it) } + ?: stringResource(R.string.to_continue), + identifier = emailAddress, + onClickIdentifier = authState::clearBackStack, + hasLogo = false, + snackbarHostState = snackbarHostState, + ) { + ClerkButton( + text = stringResource(R.string.resend), + onClick = viewModel::sendLink, + modifier = Modifier.fillMaxWidth(), + isLoading = state is AuthenticationViewState.Loading, + ) + Spacers.Vertical.Spacer24() + } +} + +@PreviewLightDark +@Composable +private fun PreviewSignUpEmailLinkView() { + PreviewAuthStateProvider { + SignUpEmailLinkView(emailAddress = "sam@clerk.dev", onAuthComplete = {}) + } +} diff --git a/source/ui/src/main/java/com/clerk/ui/signup/emaillink/SignUpEmailLinkViewModel.kt b/source/ui/src/main/java/com/clerk/ui/signup/emaillink/SignUpEmailLinkViewModel.kt new file mode 100644 index 000000000..1d045aa65 --- /dev/null +++ b/source/ui/src/main/java/com/clerk/ui/signup/emaillink/SignUpEmailLinkViewModel.kt @@ -0,0 +1,64 @@ +package com.clerk.ui.signup.emaillink + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.clerk.api.Clerk +import com.clerk.api.auth.AuthEvent +import com.clerk.api.network.serialization.errorMessage +import com.clerk.api.network.serialization.onFailure +import com.clerk.api.network.serialization.onSuccess +import com.clerk.api.signup.SignUp +import com.clerk.api.signup.prepareVerification +import com.clerk.ui.auth.AuthenticationViewState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +internal class SignUpEmailLinkViewModel : ViewModel() { + private val _state = MutableStateFlow(AuthenticationViewState.Idle) + val state = _state.asStateFlow() + + init { + viewModelScope.launch { + Clerk.auth.events.collectLatest { event -> + when (event) { + is AuthEvent.SignUpCompleted -> { + _state.value = AuthenticationViewState.Success.SignUp(event.signUp) + } + is AuthEvent.SignInCompleted -> { + _state.value = AuthenticationViewState.Success.SignIn(event.signIn) + } + else -> Unit + } + } + } + } + + fun sendLink() { + _state.value = AuthenticationViewState.Loading + + val signUp = Clerk.client.signUp + if (signUp == null || signUp.emailAddress.isNullOrBlank()) { + _state.value = AuthenticationViewState.Error(null) + return + } + + viewModelScope.launch(Dispatchers.IO) { + signUp + .prepareVerification(SignUp.PrepareVerificationParams.Strategy.EmailLink()) + .onSuccess { _state.value = AuthenticationViewState.Idle } + .onFailure { failure -> _state.value = AuthenticationViewState.Error(failure.errorMessage) } + } + } + + fun resetState() { + if ( + _state.value !is AuthenticationViewState.Success.SignUp && + _state.value !is AuthenticationViewState.Success.SignIn + ) { + _state.value = AuthenticationViewState.Idle + } + } +} diff --git a/source/ui/src/main/java/com/clerk/ui/util/TextIconHelper.kt b/source/ui/src/main/java/com/clerk/ui/util/TextIconHelper.kt index 792bcf924..7dbdb5a9b 100644 --- a/source/ui/src/main/java/com/clerk/ui/util/TextIconHelper.kt +++ b/source/ui/src/main/java/com/clerk/ui/util/TextIconHelper.kt @@ -34,6 +34,14 @@ internal class TextIconHelper { context.getString(R.string.email_code_to_email, safeIdentifier) } } + StrategyKeys.EMAIL_LINK -> { + val safeIdentifier = factor.safeIdentifier + if (safeIdentifier.isNullOrBlank()) { + context.getString(R.string.send_email_to, context.getString(R.string.email_address)) + } else { + context.getString(R.string.send_email_to, safeIdentifier) + } + } StrategyKeys.PASSKEY -> context.getString(R.string.sign_in_with_your_passkey) StrategyKeys.PASSWORD -> context.getString(R.string.sign_in_with_your_password) StrategyKeys.TOTP -> context.getString(R.string.use_your_authenticator_app) @@ -52,6 +60,7 @@ internal class TextIconHelper { return when (factor.strategy) { StrategyKeys.PHONE_CODE -> R.drawable.ic_sms StrategyKeys.EMAIL_CODE -> R.drawable.ic_email + StrategyKeys.EMAIL_LINK -> R.drawable.ic_email StrategyKeys.PASSKEY -> R.drawable.ic_fingerprint StrategyKeys.PASSWORD -> R.drawable.ic_lock else -> null diff --git a/source/ui/src/test/java/com/clerk/ui/auth/AuthStateSignUpRoutingTest.kt b/source/ui/src/test/java/com/clerk/ui/auth/AuthStateSignUpRoutingTest.kt new file mode 100644 index 000000000..9dcae4fc4 --- /dev/null +++ b/source/ui/src/test/java/com/clerk/ui/auth/AuthStateSignUpRoutingTest.kt @@ -0,0 +1,78 @@ +package com.clerk.ui.auth + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.clerk.api.Constants +import com.clerk.api.network.model.verification.Verification +import com.clerk.api.signup.SignUp +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test + +class AuthStateSignUpRoutingTest { + + private val backStack = mockk>(relaxed = true) + private val authState = AuthState(mode = AuthMode.SignInOrUp, backStack = backStack) + + @Test + fun setToStepForStatusRoutesToSignUpEmailLinkWhenEmailVerificationStrategyIsEmailLink() { + val signUp = + signUp( + verifications = + mapOf( + "email_address" to + Verification( + status = Verification.Status.UNVERIFIED, + strategy = Constants.Strategy.EMAIL_LINK, + ) + ) + ) + + authState.setToStepForStatus(signUp) {} + + verify(exactly = 1) { + backStack.add(AuthDestination.SignUpEmailLink(emailAddress = "sam@clerk.dev")) + } + } + + @Test + fun setToStepForStatusRoutesToSignUpCodeWhenEmailVerificationStrategyIsEmailCode() { + val signUp = + signUp( + verifications = + mapOf( + "email_address" to + Verification( + status = Verification.Status.UNVERIFIED, + strategy = Constants.Strategy.EMAIL_CODE, + ) + ) + ) + + authState.setToStepForStatus(signUp) {} + + verify(exactly = 1) { + backStack.add( + AuthDestination.SignUpCode( + field = com.clerk.ui.signup.code.SignUpCodeField.Email("sam@clerk.dev") + ) + ) + } + } + + private fun signUp(verifications: Map): SignUp { + every { backStack.add(any()) } returns true + return SignUp( + id = "sign_up_123", + status = SignUp.Status.MISSING_REQUIREMENTS, + requiredFields = listOf("email_address"), + optionalFields = emptyList(), + missingFields = emptyList(), + unverifiedFields = listOf("email_address"), + verifications = verifications, + emailAddress = "sam@clerk.dev", + passwordEnabled = false, + ) + } +} diff --git a/source/ui/src/test/java/com/clerk/ui/signin/SignInFactorOneViewTest.kt b/source/ui/src/test/java/com/clerk/ui/signin/SignInFactorOneViewTest.kt new file mode 100644 index 000000000..2bf88eb95 --- /dev/null +++ b/source/ui/src/test/java/com/clerk/ui/signin/SignInFactorOneViewTest.kt @@ -0,0 +1,66 @@ +package com.clerk.ui.signin + +import com.clerk.api.Clerk +import com.clerk.api.auth.Auth +import com.clerk.api.network.model.factor.Factor +import com.clerk.api.signin.SignIn +import com.clerk.ui.core.common.StrategyKeys +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class SignInFactorOneViewTest { + + private val mockAuth = mockk(relaxed = true) + + @Before + fun setUp() { + mockkObject(Clerk) + every { Clerk.auth } returns mockAuth + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun resolveFirstFactorShouldPreferEmailLinkWhenFallbackEmailCodeHasEmailAddressId() { + every { mockAuth.currentSignIn } returns + SignIn( + id = "sign_in_123", + identifier = null, + supportedFirstFactors = + listOf( + Factor(strategy = StrategyKeys.EMAIL_CODE, emailAddressId = "email_123"), + Factor(strategy = StrategyKeys.EMAIL_LINK, emailAddressId = "email_123"), + ), + ) + + val resolved = + resolveFirstFactor(Factor(strategy = StrategyKeys.EMAIL_CODE, emailAddressId = "email_123")) + + assertEquals(StrategyKeys.EMAIL_LINK, resolved.strategy) + } + + @Test + fun resolveFirstFactorShouldKeepFallbackWhenEmailLinkIsNotSupported() { + every { mockAuth.currentSignIn } returns + SignIn( + id = "sign_in_123", + identifier = null, + supportedFirstFactors = + listOf(Factor(strategy = StrategyKeys.EMAIL_CODE, emailAddressId = "email_123")), + ) + + val fallback = Factor(strategy = StrategyKeys.EMAIL_CODE, emailAddressId = "email_123") + val resolved = resolveFirstFactor(fallback) + + assertEquals(fallback, resolved) + } +} diff --git a/source/ui/src/test/java/com/clerk/ui/signin/code/SignInFactorCodeViewModelTest.kt b/source/ui/src/test/java/com/clerk/ui/signin/code/SignInFactorCodeViewModelTest.kt index 0c58d708f..946fb34f3 100644 --- a/source/ui/src/test/java/com/clerk/ui/signin/code/SignInFactorCodeViewModelTest.kt +++ b/source/ui/src/test/java/com/clerk/ui/signin/code/SignInFactorCodeViewModelTest.kt @@ -6,6 +6,7 @@ import app.cash.turbine.test import com.clerk.api.Clerk import com.clerk.api.auth.Auth import com.clerk.api.network.model.factor.Factor +import com.clerk.api.network.model.verification.Verification import com.clerk.api.signin.SignIn import com.clerk.ui.auth.AuthenticationViewState import com.clerk.ui.core.common.StrategyKeys @@ -50,6 +51,18 @@ class SignInFactorCodeViewModelTest { mockkObject(Clerk) every { Clerk.auth } returns mockAuth every { mockAuth.currentSignIn } returns mockSignIn + every { mockSignIn.identifier } returns "user@example.com" + every { mockSignIn.supportedFirstFactors } returns + listOf( + Factor(strategy = StrategyKeys.EMAIL_CODE, emailAddressId = "email_123"), + Factor(strategy = StrategyKeys.PHONE_CODE, phoneNumberId = "phone_456"), + ) + every { mockSignIn.supportedSecondFactors } returns + listOf( + Factor(strategy = StrategyKeys.EMAIL_CODE, emailAddressId = "email_123"), + Factor(strategy = StrategyKeys.PHONE_CODE, phoneNumberId = "phone_456"), + Factor(strategy = StrategyKeys.TOTP), + ) viewModel = SignInFactorCodeViewModel( @@ -332,4 +345,89 @@ class SignInFactorCodeViewModelTest { assertEquals(AuthenticationViewState.Loading, awaitItem()) } } + + @Test + fun prepareShouldRerouteToFirstFactorWhenEmailLinkIsAvailableForEmailIdentifier() = runTest { + every { mockSignIn.identifier } returns "sam@clerk.dev" + every { mockSignIn.supportedFirstFactors } returns + listOf( + Factor(strategy = StrategyKeys.EMAIL_CODE, emailAddressId = "email_123"), + Factor(strategy = StrategyKeys.EMAIL_LINK, emailAddressId = "email_123"), + ) + val factor = Factor(strategy = StrategyKeys.EMAIL_CODE, emailAddressId = "email_123") + + viewModel.state.test { + assertEquals(AuthenticationViewState.Idle, awaitItem()) + + viewModel.prepare(factor, isSecondFactor = false) + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(AuthenticationViewState.Loading, awaitItem()) + assertEquals(AuthenticationViewState.Success.SignIn(mockSignIn), awaitItem()) + } + + coVerify(exactly = 0) { mockPrepareHandler.prepareForEmailCode(any(), any(), any(), any()) } + } + + @Test + fun prepareShouldRerouteToFirstFactorWhenIdentifierIsMissingButEmailFactorExists() = runTest { + every { mockSignIn.identifier } returns null + every { mockSignIn.supportedFirstFactors } returns + listOf( + Factor(strategy = StrategyKeys.EMAIL_CODE, emailAddressId = "email_123"), + Factor(strategy = StrategyKeys.EMAIL_LINK, emailAddressId = "email_123"), + ) + val factor = Factor(strategy = StrategyKeys.EMAIL_CODE, emailAddressId = "email_123") + + viewModel.state.test { + assertEquals(AuthenticationViewState.Idle, awaitItem()) + + viewModel.prepare(factor, isSecondFactor = false) + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(AuthenticationViewState.Loading, awaitItem()) + assertEquals(AuthenticationViewState.Success.SignIn(mockSignIn), awaitItem()) + } + + coVerify(exactly = 0) { mockPrepareHandler.prepareForEmailCode(any(), any(), any(), any()) } + } + + @Test + fun prepareShouldNotRerouteWhenEmailCodeIsAlreadyPrepared() = runTest { + every { mockSignIn.identifier } returns "sam@clerk.dev" + every { mockSignIn.firstFactorVerification } returns + Verification(status = Verification.Status.UNVERIFIED, strategy = StrategyKeys.EMAIL_CODE) + every { mockSignIn.supportedFirstFactors } returns + listOf( + Factor(strategy = StrategyKeys.EMAIL_CODE, emailAddressId = "email_123"), + Factor(strategy = StrategyKeys.EMAIL_LINK, emailAddressId = "email_123"), + ) + val factor = Factor(strategy = StrategyKeys.EMAIL_CODE, emailAddressId = "email_123") + + viewModel.prepare(factor, isSecondFactor = false) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 1) { + mockPrepareHandler.prepareForEmailCode(mockSignIn, factor, false, any()) + } + } + + @Test + fun prepareShouldRerouteWhenRequestedFactorIsNoLongerSupported() = runTest { + every { mockSignIn.identifier } returns null + every { mockSignIn.supportedFirstFactors } returns listOf(Factor(strategy = "ticket")) + val factor = Factor(strategy = StrategyKeys.EMAIL_CODE, emailAddressId = "email_123") + + viewModel.state.test { + assertEquals(AuthenticationViewState.Idle, awaitItem()) + + viewModel.prepare(factor, isSecondFactor = false) + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(AuthenticationViewState.Loading, awaitItem()) + assertEquals(AuthenticationViewState.Success.SignIn(mockSignIn), awaitItem()) + } + + coVerify(exactly = 0) { mockPrepareHandler.prepareForEmailCode(any(), any(), any(), any()) } + } } diff --git a/source/ui/src/test/java/com/clerk/ui/signin/code/SignInPrepareHandlerTest.kt b/source/ui/src/test/java/com/clerk/ui/signin/code/SignInPrepareHandlerTest.kt index 6a94b53ae..979ddd6c2 100644 --- a/source/ui/src/test/java/com/clerk/ui/signin/code/SignInPrepareHandlerTest.kt +++ b/source/ui/src/test/java/com/clerk/ui/signin/code/SignInPrepareHandlerTest.kt @@ -222,7 +222,12 @@ class SignInPrepareHandlerTest { } returns failureResult var capturedMessage: String? = null - handler.prepareForEmailCode(mockSignIn, factor, isSecondFactor = false, onError = { capturedMessage = it }) + handler.prepareForEmailCode( + mockSignIn, + factor, + isSecondFactor = false, + onError = { capturedMessage = it }, + ) assert(capturedMessage == "Long message") } @@ -304,7 +309,12 @@ class SignInPrepareHandlerTest { val factor = Factor(strategy = "email_code", emailAddressId = null) var called = false - handler.prepareForEmailCode(mockSignIn, factor, isSecondFactor = false, onError = { called = true }) + handler.prepareForEmailCode( + mockSignIn, + factor, + isSecondFactor = false, + onError = { called = true }, + ) coVerify(exactly = 0) { mockSignIn.prepareFirstFactor(any()) } assert(!called) @@ -325,4 +335,38 @@ class SignInPrepareHandlerTest { coVerify(exactly = 0) { mockSignIn.prepareFirstFactor(any()) } assert(!called) } + + @Test + fun prepareForEmailCodeShouldSkipApiCallWhenFirstFactorIsNotSupported() = runTest { + every { mockSignIn.supportedFirstFactors } returns listOf(Factor(strategy = "ticket")) + val factor = Factor(strategy = "email_code", emailAddressId = "email_123") + var capturedMessage: String? = null + + handler.prepareForEmailCode( + inProgressSignIn = mockSignIn, + factor = factor, + isSecondFactor = false, + onError = { capturedMessage = it }, + ) + + coVerify(exactly = 0) { mockSignIn.prepareFirstFactor(any()) } + assert(capturedMessage == "Selected sign-in method is no longer available.") + } + + @Test + fun prepareForEmailCodeShouldSkipApiCallWhenSecondFactorIsNotSupported() = runTest { + every { mockSignIn.supportedSecondFactors } returns listOf(Factor(strategy = "totp")) + val factor = Factor(strategy = "email_code", emailAddressId = "email_123") + var capturedMessage: String? = null + + handler.prepareForEmailCode( + inProgressSignIn = mockSignIn, + factor = factor, + isSecondFactor = true, + onError = { capturedMessage = it }, + ) + + coVerify(exactly = 0) { mockSignIn.prepareSecondFactor(emailAddressId = any()) } + assert(capturedMessage == "Selected sign-in method is no longer available.") + } } diff --git a/source/ui/src/test/java/com/clerk/ui/signin/emaillink/SignInFactorOneEmailLinkViewModelTest.kt b/source/ui/src/test/java/com/clerk/ui/signin/emaillink/SignInFactorOneEmailLinkViewModelTest.kt new file mode 100644 index 000000000..4fa209ab1 --- /dev/null +++ b/source/ui/src/test/java/com/clerk/ui/signin/emaillink/SignInFactorOneEmailLinkViewModelTest.kt @@ -0,0 +1,71 @@ +package com.clerk.ui.signin.emaillink + +import com.clerk.api.Clerk +import com.clerk.api.auth.Auth +import com.clerk.api.auth.AuthEvent +import com.clerk.api.magiclink.NativeMagicLinkError +import com.clerk.api.network.serialization.ClerkResult +import com.clerk.api.signin.SignIn +import com.clerk.ui.auth.AuthenticationViewState +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class SignInFactorOneEmailLinkViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private val auth = mockk(relaxed = true) + private val events = MutableSharedFlow(extraBufferCapacity = 1) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockkObject(Clerk) + every { Clerk.auth } returns auth + every { auth.events } returns events + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `sendLink surfaces backend long message for rate limits`() = runTest { + every { auth.currentSignIn } returns SignIn(id = "sia_123", identifier = "sam@clerk.dev") + coEvery { auth.startEmailLinkSignIn("sam@clerk.dev") } returns + ClerkResult.apiFailure( + NativeMagicLinkError( + reasonCode = "too_many_requests", + message = "Too many requests, retry later", + ) + ) + + val viewModel = SignInFactorOneEmailLinkViewModel() + viewModel.sendLink() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals( + AuthenticationViewState.Error("Too many requests, retry later"), + viewModel.state.value, + ) + } +} diff --git a/workbench/src/main/java/com/clerk/workbench/UiActivity2.kt b/workbench/src/main/java/com/clerk/workbench/UiActivity2.kt index 902bfd2c4..3f2ea9d9b 100644 --- a/workbench/src/main/java/com/clerk/workbench/UiActivity2.kt +++ b/workbench/src/main/java/com/clerk/workbench/UiActivity2.kt @@ -8,6 +8,7 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -33,16 +34,17 @@ class UiActivity2 : ComponentActivity() { ) setContent { WorkbenchTheme { + val isInitialized by Clerk.isInitialized.collectAsStateWithLifecycle() val session by Clerk.sessionFlow.collectAsStateWithLifecycle() val user by Clerk.userFlow.collectAsStateWithLifecycle() Box( modifier = Modifier.fillMaxSize().background(color = Color(0xFFF9F9F9)), contentAlignment = Alignment.Center, ) { - if (user == null || session?.pendingTaskKey != null) { - AuthView(persistIdentifiers = false) - } else { - UserButton() + when { + !isInitialized -> CircularProgressIndicator() + user == null || session?.pendingTaskKey != null -> AuthView(persistIdentifiers = false) + else -> UserButton() } } } From 159d82c2577364456545f658a05e7d2be3df483f Mon Sep 17 00:00:00 2001 From: Sam Wolfand Date: Wed, 4 Mar 2026 14:49:18 -0800 Subject: [PATCH 02/17] feat(ui): improve email-link check-email screens Add a native 'Open email app' action for sign-in and sign-up email-link screens and preserve a non-compose fallback when no email client is available. Update the check-email layout to match the intended hierarchy with secondary primary action, text actions for resend/use another method, and sign-up back navigation parity. --- .../emaillink/SignInFactorOneEmailLinkView.kt | 56 ++++++++++++++++--- .../signup/emaillink/SignUpEmailLinkView.kt | 45 +++++++++++++-- .../com/clerk/ui/util/EmailAppLauncher.kt | 31 ++++++++++ source/ui/src/main/res/values/strings.xml | 1 + 4 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 source/ui/src/main/java/com/clerk/ui/util/EmailAppLauncher.kt diff --git a/source/ui/src/main/java/com/clerk/ui/signin/emaillink/SignInFactorOneEmailLinkView.kt b/source/ui/src/main/java/com/clerk/ui/signin/emaillink/SignInFactorOneEmailLinkView.kt index df5d45ca3..5c08839a8 100644 --- a/source/ui/src/main/java/com/clerk/ui/signin/emaillink/SignInFactorOneEmailLinkView.kt +++ b/source/ui/src/main/java/com/clerk/ui/signin/emaillink/SignInFactorOneEmailLinkView.kt @@ -1,12 +1,18 @@ package com.clerk.ui.signin.emaillink +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -17,15 +23,19 @@ import com.clerk.api.ui.ClerkTheme import com.clerk.ui.R import com.clerk.ui.auth.AuthDestination import com.clerk.ui.auth.AuthStateEffects -import com.clerk.ui.auth.AuthenticationViewState import com.clerk.ui.auth.PreviewAuthStateProvider import com.clerk.ui.core.button.standard.ClerkButton +import com.clerk.ui.core.button.standard.ClerkButtonConfiguration +import com.clerk.ui.core.button.standard.ClerkButtonDefaults import com.clerk.ui.core.button.standard.ClerkTextButton import com.clerk.ui.core.common.StrategyKeys import com.clerk.ui.core.composition.LocalAuthState +import com.clerk.ui.core.dimens.dp8 import com.clerk.ui.core.scaffold.ClerkThemedAuthScaffold import com.clerk.ui.core.spacers.Spacers import com.clerk.ui.theme.ClerkThemeOverrideProvider +import com.clerk.ui.util.EmailAppLauncher +import kotlinx.coroutines.launch @Composable fun SignInFactorOneEmailLinkView( @@ -51,7 +61,9 @@ private fun SignInFactorOneEmailLinkViewImpl( onAuthComplete: () -> Unit, ) { val authState = LocalAuthState.current + val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() val state by viewModel.state.collectAsStateWithLifecycle() LaunchedEffect(Unit) { viewModel.sendLink() } @@ -76,15 +88,28 @@ private fun SignInFactorOneEmailLinkViewImpl( snackbarHostState = snackbarHostState, ) { ClerkButton( - text = stringResource(R.string.resend), - onClick = { viewModel.sendLink() }, + text = stringResource(R.string.open_email_app), + onClick = { + if (!EmailAppLauncher.open(context)) { + coroutineScope.launch { + snackbarHostState.showSnackbar( + message = context.getString(R.string.no_email_clients_installed_on_device), + duration = SnackbarDuration.Short, + ) + } + } + }, modifier = Modifier.fillMaxWidth(), - isLoading = state is AuthenticationViewState.Loading, + configuration = + ClerkButtonDefaults.configuration( + style = ClerkButtonConfiguration.ButtonStyle.Secondary, + emphasis = ClerkButtonConfiguration.Emphasis.High, + ), ) Spacers.Vertical.Spacer24() - ClerkTextButton( - text = stringResource(R.string.use_another_method), - onClick = { + SignInEmailLinkSecondaryActions( + onResendClick = viewModel::sendLink, + onUseAnotherMethodClick = { authState.navigateTo( AuthDestination.SignInFactorOneUseAnotherMethod(currentFactor = factor) ) @@ -93,6 +118,23 @@ private fun SignInFactorOneEmailLinkViewImpl( } } +@Composable +private fun SignInEmailLinkSecondaryActions( + onResendClick: () -> Unit, + onUseAnotherMethodClick: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = dp8), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + ClerkTextButton(text = stringResource(R.string.resend), onClick = onResendClick) + ClerkTextButton( + text = stringResource(R.string.use_another_method), + onClick = onUseAnotherMethodClick, + ) + } +} + @PreviewLightDark @Composable private fun PreviewSignInFactorOneEmailLinkView() { diff --git a/source/ui/src/main/java/com/clerk/ui/signup/emaillink/SignUpEmailLinkView.kt b/source/ui/src/main/java/com/clerk/ui/signup/emaillink/SignUpEmailLinkView.kt index 308bbda3b..27e089e6a 100644 --- a/source/ui/src/main/java/com/clerk/ui/signup/emaillink/SignUpEmailLinkView.kt +++ b/source/ui/src/main/java/com/clerk/ui/signup/emaillink/SignUpEmailLinkView.kt @@ -1,12 +1,18 @@ package com.clerk.ui.signup.emaillink +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -15,13 +21,18 @@ import com.clerk.api.Clerk import com.clerk.api.ui.ClerkTheme import com.clerk.ui.R import com.clerk.ui.auth.AuthStateEffects -import com.clerk.ui.auth.AuthenticationViewState import com.clerk.ui.auth.PreviewAuthStateProvider import com.clerk.ui.core.button.standard.ClerkButton +import com.clerk.ui.core.button.standard.ClerkButtonConfiguration +import com.clerk.ui.core.button.standard.ClerkButtonDefaults +import com.clerk.ui.core.button.standard.ClerkTextButton import com.clerk.ui.core.composition.LocalAuthState +import com.clerk.ui.core.dimens.dp8 import com.clerk.ui.core.scaffold.ClerkThemedAuthScaffold import com.clerk.ui.core.spacers.Spacers import com.clerk.ui.theme.ClerkThemeOverrideProvider +import com.clerk.ui.util.EmailAppLauncher +import kotlinx.coroutines.launch @Composable fun SignUpEmailLinkView( @@ -47,7 +58,9 @@ private fun SignUpEmailLinkViewImpl( onAuthComplete: () -> Unit, ) { val authState = LocalAuthState.current + val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() val state by viewModel.state.collectAsStateWithLifecycle() LaunchedEffect(Unit) { viewModel.sendLink() } @@ -62,6 +75,7 @@ private fun SignUpEmailLinkViewImpl( ClerkThemedAuthScaffold( modifier = modifier, + onBackPressed = authState::navigateBack, title = stringResource(R.string.check_your_email), subtitle = Clerk.applicationName?.let { stringResource(R.string.to_continue_to, it) } @@ -72,12 +86,35 @@ private fun SignUpEmailLinkViewImpl( snackbarHostState = snackbarHostState, ) { ClerkButton( - text = stringResource(R.string.resend), - onClick = viewModel::sendLink, + text = stringResource(R.string.open_email_app), + onClick = { + if (!EmailAppLauncher.open(context)) { + coroutineScope.launch { + snackbarHostState.showSnackbar( + message = context.getString(R.string.no_email_clients_installed_on_device), + duration = SnackbarDuration.Short, + ) + } + } + }, modifier = Modifier.fillMaxWidth(), - isLoading = state is AuthenticationViewState.Loading, + configuration = + ClerkButtonDefaults.configuration( + style = ClerkButtonConfiguration.ButtonStyle.Secondary, + emphasis = ClerkButtonConfiguration.Emphasis.High, + ), ) Spacers.Vertical.Spacer24() + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = dp8), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + ClerkTextButton(text = stringResource(R.string.resend), onClick = viewModel::sendLink) + ClerkTextButton( + text = stringResource(R.string.use_another_method), + onClick = authState::navigateBack, + ) + } } } diff --git a/source/ui/src/main/java/com/clerk/ui/util/EmailAppLauncher.kt b/source/ui/src/main/java/com/clerk/ui/util/EmailAppLauncher.kt new file mode 100644 index 000000000..92870f7c7 --- /dev/null +++ b/source/ui/src/main/java/com/clerk/ui/util/EmailAppLauncher.kt @@ -0,0 +1,31 @@ +package com.clerk.ui.util + +import android.content.Context +import android.content.Intent + +internal object EmailAppLauncher { + fun open(context: Context): Boolean { + val packageManager = context.packageManager + val appIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_APP_EMAIL) } + + val explicitLaunchIntent = + packageManager.queryIntentActivities(appIntent, 0).firstNotNullOfOrNull { resolveInfo -> + packageManager.getLaunchIntentForPackage(resolveInfo.activityInfo.packageName) + } + + if (explicitLaunchIntent != null && context.tryStartActivity(explicitLaunchIntent)) { + return true + } + + return context.tryStartActivity(appIntent) + } + + private fun Context.tryStartActivity(intent: Intent): Boolean { + val launchIntent = Intent(intent).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + return runCatching { + startActivity(launchIntent) + true + } + .getOrElse { false } + } +} diff --git a/source/ui/src/main/res/values/strings.xml b/source/ui/src/main/res/values/strings.xml index 41648b83a..5dd1075aa 100644 --- a/source/ui/src/main/res/values/strings.xml +++ b/source/ui/src/main/res/values/strings.xml @@ -28,6 +28,7 @@ Get help If you have trouble signing into your account, email us and we will work with you to restore access as soon as possible. Email support + Open email app support@clerk.com No email clients installed on device. Set new password From 6d3909123342aec9fccbda2c4096801519d05203 Mon Sep 17 00:00:00 2001 From: Sam Wolfand Date: Wed, 4 Mar 2026 14:51:09 -0800 Subject: [PATCH 03/17] fix(ui): hide instance logo when logo URL is missing Treat logo visibility as the combination of instance logo support and a non-blank logo URL in both auth and profile scaffolds. Add regression tests for logo visibility decisions so instances without branding assets no longer render an empty logo slot. --- .../core/scaffold/ClerkThemedAuthScaffold.kt | 4 ++- .../scaffold/ClerkThemedProfileScaffold.kt | 5 ++- .../clerk/ui/core/scaffold/LogoVisibility.kt | 5 +++ .../ui/core/scaffold/LogoVisibilityTest.kt | 32 +++++++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 source/ui/src/main/java/com/clerk/ui/core/scaffold/LogoVisibility.kt create mode 100644 source/ui/src/test/java/com/clerk/ui/core/scaffold/LogoVisibilityTest.kt diff --git a/source/ui/src/main/java/com/clerk/ui/core/scaffold/ClerkThemedAuthScaffold.kt b/source/ui/src/main/java/com/clerk/ui/core/scaffold/ClerkThemedAuthScaffold.kt index b8b8ae9e3..55c12f156 100644 --- a/source/ui/src/main/java/com/clerk/ui/core/scaffold/ClerkThemedAuthScaffold.kt +++ b/source/ui/src/main/java/com/clerk/ui/core/scaffold/ClerkThemedAuthScaffold.kt @@ -83,6 +83,8 @@ internal fun ClerkThemedAuthScaffold( ) { val user = Clerk.userFlow.collectAsStateWithLifecycle().value val session = Clerk.sessionFlow.collectAsStateWithLifecycle().value + val shouldShowLogo = + shouldShowInstanceLogo(hasLogo = hasLogo, organizationLogoUrl = Clerk.organizationLogoUrl) var showSignedInAccountSheet by remember { mutableStateOf(false) } val displayName = user.displayName() val displayIdentifier = session?.publicUserData?.identifier ?: user?.username.orEmpty() @@ -109,7 +111,7 @@ internal fun ClerkThemedAuthScaffold( ClerkTopAppBar( backgroundColor = ClerkMaterialTheme.colors.background, onBackPressed = onBackPressed, - hasLogo = hasLogo, + hasLogo = shouldShowLogo, hasBackButton = hasBackButton, trailingContent = trailingContent, ) diff --git a/source/ui/src/main/java/com/clerk/ui/core/scaffold/ClerkThemedProfileScaffold.kt b/source/ui/src/main/java/com/clerk/ui/core/scaffold/ClerkThemedProfileScaffold.kt index dea8ce898..08b56432c 100644 --- a/source/ui/src/main/java/com/clerk/ui/core/scaffold/ClerkThemedProfileScaffold.kt +++ b/source/ui/src/main/java/com/clerk/ui/core/scaffold/ClerkThemedProfileScaffold.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.Dp +import com.clerk.api.Clerk import com.clerk.api.ui.ClerkTheme import com.clerk.ui.core.appbar.ClerkTopAppBar import com.clerk.ui.core.dimens.dp18 @@ -40,6 +41,8 @@ internal fun ClerkThemedProfileScaffold( content: @Composable ColumnScope.() -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } + val shouldShowLogo = + shouldShowInstanceLogo(hasLogo = hasLogo, organizationLogoUrl = Clerk.organizationLogoUrl) LaunchedEffect(errorMessage) { if (errorMessage != null) snackbarHostState.showSnackbar(errorMessage) } @@ -51,7 +54,7 @@ internal fun ClerkThemedProfileScaffold( topBar = { ClerkTopAppBar( backgroundColor = ClerkMaterialTheme.colors.background, - hasLogo = hasLogo, + hasLogo = shouldShowLogo, hasBackButton = hasBackButton, title = title, onBackPressed = onBackPressed, diff --git a/source/ui/src/main/java/com/clerk/ui/core/scaffold/LogoVisibility.kt b/source/ui/src/main/java/com/clerk/ui/core/scaffold/LogoVisibility.kt new file mode 100644 index 000000000..f4c2790fa --- /dev/null +++ b/source/ui/src/main/java/com/clerk/ui/core/scaffold/LogoVisibility.kt @@ -0,0 +1,5 @@ +package com.clerk.ui.core.scaffold + +internal fun shouldShowInstanceLogo(hasLogo: Boolean, organizationLogoUrl: String?): Boolean { + return hasLogo && !organizationLogoUrl.isNullOrBlank() +} diff --git a/source/ui/src/test/java/com/clerk/ui/core/scaffold/LogoVisibilityTest.kt b/source/ui/src/test/java/com/clerk/ui/core/scaffold/LogoVisibilityTest.kt new file mode 100644 index 000000000..d4b8deb02 --- /dev/null +++ b/source/ui/src/test/java/com/clerk/ui/core/scaffold/LogoVisibilityTest.kt @@ -0,0 +1,32 @@ +package com.clerk.ui.core.scaffold + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LogoVisibilityTest { + + @Test + fun shouldShowInstanceLogoReturnsFalseWhenHasLogoIsFalse() { + assertFalse( + shouldShowInstanceLogo(hasLogo = false, organizationLogoUrl = "https://example.com/logo.png") + ) + } + + @Test + fun shouldShowInstanceLogoReturnsFalseWhenLogoUrlIsNull() { + assertFalse(shouldShowInstanceLogo(hasLogo = true, organizationLogoUrl = null)) + } + + @Test + fun shouldShowInstanceLogoReturnsFalseWhenLogoUrlIsBlank() { + assertFalse(shouldShowInstanceLogo(hasLogo = true, organizationLogoUrl = " ")) + } + + @Test + fun shouldShowInstanceLogoReturnsTrueWhenHasLogoAndUrlArePresent() { + assertTrue( + shouldShowInstanceLogo(hasLogo = true, organizationLogoUrl = "https://example.com/logo.png") + ) + } +} From 57957bb793293b4b7ded75c8662883618971d104 Mon Sep 17 00:00:00 2001 From: Sam Wolfand Date: Wed, 4 Mar 2026 14:52:36 -0800 Subject: [PATCH 04/17] fix(android): restore user button visibility after native email-link Make user-button visibility session-driven in pending flows, add avatar fallbacks for null/error image loads, and align workbench auth surfaces with session state to avoid blank signed-in screens. Use a transparent bridge theme for auth callback activities so deep-link completion no longer presents a black transition screen. --- source/api/src/main/AndroidManifest.xml | 4 +- source/api/src/main/res/values/themes.xml | 9 ++ .../com/clerk/ui/userbutton/UserButton.kt | 126 +++++++++++++----- .../ui/userbutton/UserButtonBehaviorTest.kt | 24 +++- .../main/java/com/clerk/workbench/HomeView.kt | 4 +- .../java/com/clerk/workbench/UiActivity2.kt | 30 ++++- 6 files changed, 150 insertions(+), 47 deletions(-) create mode 100644 source/api/src/main/res/values/themes.xml diff --git a/source/api/src/main/AndroidManifest.xml b/source/api/src/main/AndroidManifest.xml index 6a83cc05d..68a6377c8 100644 --- a/source/api/src/main/AndroidManifest.xml +++ b/source/api/src/main/AndroidManifest.xml @@ -11,11 +11,11 @@ android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboard|keyboardHidden" android:exported="false" android:launchMode="singleTask" - android:theme="@style/Theme.AppCompat.DayNight.NoActionBar" /> + android:theme="@style/Theme.Clerk.AuthBridge" /> + android:theme="@style/Theme.Clerk.AuthBridge"> diff --git a/source/api/src/main/res/values/themes.xml b/source/api/src/main/res/values/themes.xml new file mode 100644 index 000000000..e8120eac0 --- /dev/null +++ b/source/api/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + diff --git a/source/ui/src/main/java/com/clerk/ui/userbutton/UserButton.kt b/source/ui/src/main/java/com/clerk/ui/userbutton/UserButton.kt index c8799bd48..4334a2733 100644 --- a/source/ui/src/main/java/com/clerk/ui/userbutton/UserButton.kt +++ b/source/ui/src/main/java/com/clerk/ui/userbutton/UserButton.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -31,6 +32,7 @@ import coil3.request.crossfade import com.clerk.api.Clerk import com.clerk.api.session.requiresForcedMfa import com.clerk.api.ui.ClerkTheme +import com.clerk.api.user.User import com.clerk.telemetry.TelemetryEvents import com.clerk.ui.R import com.clerk.ui.auth.AuthView @@ -66,14 +68,22 @@ fun UserButton( TelemetryProvider { val session by Clerk.sessionFlow.collectAsStateWithLifecycle() val sessionUser by Clerk.userFlow.collectAsStateWithLifecycle() + val resolved = + resolveUserButtonState( + sessionExists = session != null || Clerk.session != null, + sessionUser = sessionUser, + activeUser = Clerk.activeUser ?: Clerk.user, + treatPendingAsSignedOut = treatPendingAsSignedOut, + ) val requiresForcedMfa = session?.requiresForcedMfa == true - val user = if (treatPendingAsSignedOut) Clerk.activeUser else sessionUser + val user = resolved.user + val shouldShowButton = resolved.shouldShowButton val telemetry = LocalTelemetryCollector.current var showProfile by rememberSaveable { mutableStateOf(false) } var showAuth by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(user?.id) { - if (user != null) telemetry.record(TelemetryEvents.viewDidAppear("UserButton")) + LaunchedEffect(shouldShowButton, user?.id) { + if (shouldShowButton) telemetry.record(TelemetryEvents.viewDidAppear("UserButton")) } LaunchedEffect(requiresForcedMfa, showAuth) { @@ -82,27 +92,17 @@ fun UserButton( } } - if ( - shouldShowUserButton( - sessionUser != null, - Clerk.activeUser != null, - treatPendingAsSignedOut, - ) && user != null - ) { + if (shouldShowButton) { UserButtonContent( - imageUrl = user.imageUrl, + imageUrl = user?.imageUrl, onClick = { - when ( - userButtonClickAction( - requiresForcedMfa = requiresForcedMfa, - routeToAuthWhenForcedMfa = routeToAuthWhenForcedMfa, - ) - ) { - UserButtonClickAction.OPEN_PROFILE -> showProfile = true - UserButtonClickAction.ROUTE_TO_AUTH -> { - onRequiresForcedMfaClick?.invoke() ?: run { showAuth = true } - } - } + handleUserButtonClick( + requiresForcedMfa = requiresForcedMfa, + routeToAuthWhenForcedMfa = routeToAuthWhenForcedMfa, + onRequiresForcedMfaClick = onRequiresForcedMfaClick, + onOpenProfile = { showProfile = true }, + onOpenAuth = { showAuth = true }, + ) }, ) if (showProfile) { @@ -116,10 +116,54 @@ fun UserButton( } } +private data class ResolvedUserButtonState(val user: User?, val shouldShowButton: Boolean) + +private fun resolveUserButtonState( + sessionExists: Boolean, + sessionUser: User?, + activeUser: User?, + treatPendingAsSignedOut: Boolean, +): ResolvedUserButtonState { + val user = + if (treatPendingAsSignedOut) { + activeUser + } else { + sessionUser ?: activeUser + } + + val shouldShowButton = + shouldShowUserButton( + hasSession = sessionExists, + hasActiveUser = activeUser != null, + treatPendingAsSignedOut = treatPendingAsSignedOut, + ) + + return ResolvedUserButtonState(user = user, shouldShowButton = shouldShowButton) +} + +private fun handleUserButtonClick( + requiresForcedMfa: Boolean, + routeToAuthWhenForcedMfa: Boolean, + onRequiresForcedMfaClick: (() -> Unit)?, + onOpenProfile: () -> Unit, + onOpenAuth: () -> Unit, +) { + when ( + userButtonClickAction( + requiresForcedMfa = requiresForcedMfa, + routeToAuthWhenForcedMfa = routeToAuthWhenForcedMfa, + ) + ) { + UserButtonClickAction.OPEN_PROFILE -> onOpenProfile() + UserButtonClickAction.ROUTE_TO_AUTH -> onRequiresForcedMfaClick?.invoke() ?: onOpenAuth() + } +} + @SuppressLint("LocalContextGetResourceValueCall") @Composable private fun UserButtonContent(imageUrl: String?, onClick: () -> Unit) { val context = LocalContext.current + val profilePainter = painterResource(id = R.drawable.ic_profile) IconButton(onClick = onClick) { Box( modifier = @@ -128,17 +172,25 @@ private fun UserButtonContent(imageUrl: String?, onClick: () -> Unit) { }, contentAlignment = Alignment.Center, ) { - val model = ImageRequest.Builder(LocalContext.current).data(imageUrl).crossfade(true).build() - AsyncImage( - modifier = Modifier.matchParentSize().clip(CircleShape), - model = model, - contentDescription = stringResource(R.string.user_avatar), - contentScale = ContentScale.Crop, - fallback = painterResource(id = R.drawable.ic_profile), - onError = { /* fall through to placeholder below */ }, - ) - if (imageUrl?.isBlank() == true) { - Icon(painterResource(id = R.drawable.ic_profile), null, Modifier.matchParentSize()) + if (imageUrl.isNullOrBlank()) { + Icon( + painter = profilePainter, + contentDescription = stringResource(R.string.user_avatar), + modifier = Modifier.matchParentSize(), + tint = Color.Unspecified, + ) + } else { + val model = + ImageRequest.Builder(LocalContext.current).data(imageUrl).crossfade(true).build() + AsyncImage( + modifier = Modifier.matchParentSize().clip(CircleShape), + model = model, + contentDescription = stringResource(R.string.user_avatar), + contentScale = ContentScale.Crop, + placeholder = profilePainter, + fallback = profilePainter, + error = profilePainter, + ) } } } @@ -181,9 +233,13 @@ internal fun userButtonClickAction( } internal fun shouldShowUserButton( - hasSessionUser: Boolean, + hasSession: Boolean, hasActiveUser: Boolean, treatPendingAsSignedOut: Boolean, ): Boolean { - return if (treatPendingAsSignedOut) hasActiveUser else hasSessionUser + return if (treatPendingAsSignedOut) { + hasActiveUser + } else { + hasSession + } } diff --git a/source/ui/src/test/java/com/clerk/ui/userbutton/UserButtonBehaviorTest.kt b/source/ui/src/test/java/com/clerk/ui/userbutton/UserButtonBehaviorTest.kt index 7b79a7aa8..c30605b72 100644 --- a/source/ui/src/test/java/com/clerk/ui/userbutton/UserButtonBehaviorTest.kt +++ b/source/ui/src/test/java/com/clerk/ui/userbutton/UserButtonBehaviorTest.kt @@ -29,21 +29,39 @@ class UserButtonBehaviorTest { } @Test - fun `shouldShowUserButton uses session user when pending sessions are allowed`() { + fun `shouldShowUserButton shows when session exists and pending sessions are allowed`() { assertTrue( shouldShowUserButton( - hasSessionUser = true, + hasSession = true, hasActiveUser = false, treatPendingAsSignedOut = false, ) ) } + @Test + fun `shouldShowUserButton hides when no session exists and pending sessions are allowed`() { + assertFalse( + shouldShowUserButton( + hasSession = false, + hasActiveUser = true, + treatPendingAsSignedOut = false, + ) + ) + } + + @Test + fun `shouldShowUserButton shows when active user exists and pending is treated as signed out`() { + assertTrue( + shouldShowUserButton(hasSession = false, hasActiveUser = true, treatPendingAsSignedOut = true) + ) + } + @Test fun `shouldShowUserButton hides when only session user exists and pending is treated as signed out`() { assertFalse( shouldShowUserButton( - hasSessionUser = true, + hasSession = false, hasActiveUser = false, treatPendingAsSignedOut = true, ) diff --git a/workbench/src/main/java/com/clerk/workbench/HomeView.kt b/workbench/src/main/java/com/clerk/workbench/HomeView.kt index db30bf0fb..4a5ac3dea 100644 --- a/workbench/src/main/java/com/clerk/workbench/HomeView.kt +++ b/workbench/src/main/java/com/clerk/workbench/HomeView.kt @@ -17,13 +17,13 @@ import com.clerk.ui.userbutton.UserButton @Composable fun UserProfileTopBar() { val session by Clerk.sessionFlow.collectAsStateWithLifecycle() - val user by Clerk.userFlow.collectAsStateWithLifecycle() + val currentSession = session Scaffold( topBar = { TopAppBar( title = { Text("Home screen") }, actions = { - if (user != null && session?.pendingTaskKey == null) { + if (currentSession != null && currentSession.pendingTaskKey == null) { UserButton() } }, diff --git a/workbench/src/main/java/com/clerk/workbench/UiActivity2.kt b/workbench/src/main/java/com/clerk/workbench/UiActivity2.kt index 3f2ea9d9b..04ed1875b 100644 --- a/workbench/src/main/java/com/clerk/workbench/UiActivity2.kt +++ b/workbench/src/main/java/com/clerk/workbench/UiActivity2.kt @@ -7,15 +7,19 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.clerk.api.Clerk import com.clerk.api.session.pendingTaskKey @@ -36,15 +40,31 @@ class UiActivity2 : ComponentActivity() { WorkbenchTheme { val isInitialized by Clerk.isInitialized.collectAsStateWithLifecycle() val session by Clerk.sessionFlow.collectAsStateWithLifecycle() - val user by Clerk.userFlow.collectAsStateWithLifecycle() + val hasPendingTask = session?.pendingTaskKey != null Box( - modifier = Modifier.fillMaxSize().background(color = Color(0xFFF9F9F9)), + modifier = + Modifier.fillMaxSize().background(color = MaterialTheme.colorScheme.background), contentAlignment = Alignment.Center, ) { when { !isInitialized -> CircularProgressIndicator() - user == null || session?.pendingTaskKey != null -> AuthView(persistIdentifiers = false) - else -> UserButton() + session == null || hasPendingTask -> + AuthView(modifier = Modifier.fillMaxSize(), persistIdentifiers = false) + else -> + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Signed in", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleMedium, + ) + Box(modifier = Modifier.height(12.dp)) + UserButton() + } + } } } } From 90ec67194a009f0f6386fcfdf63dbdbcfcb129e5 Mon Sep 17 00:00:00 2001 From: Sam Wolfand Date: Wed, 4 Mar 2026 17:29:08 -0800 Subject: [PATCH 05/17] refactor(telemetry): inject runtime providers into compose telemetry env Replace direct Clerk singleton reads in ClerkTelemetryEnvironment with injected provider lambdas and wire them from CompositionLocals. This keeps telemetry event emission behavior intact while reducing static coupling in telemetry setup. --- .../telemetry/ClerkTelemetryEnvironment.kt | 25 +++++++++---------- .../com/clerk/telemetry/TelemetryCollector.kt | 5 +--- .../ui/core/composition/CompositionLocals.kt | 11 +++++++- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/source/telemetry/src/androidMain/kotlin/com/clerk/telemetry/ClerkTelemetryEnvironment.kt b/source/telemetry/src/androidMain/kotlin/com/clerk/telemetry/ClerkTelemetryEnvironment.kt index 2af40081f..fe239e38a 100644 --- a/source/telemetry/src/androidMain/kotlin/com/clerk/telemetry/ClerkTelemetryEnvironment.kt +++ b/source/telemetry/src/androidMain/kotlin/com/clerk/telemetry/ClerkTelemetryEnvironment.kt @@ -1,33 +1,32 @@ package com.clerk.telemetry -import com.clerk.api.Clerk - private const val CLERK_ANDROID = "clerk-android" -/** - * An implementation of [TelemetryEnvironment] that retrieves configuration and state directly from - * the main [Clerk] singleton. This class acts as a bridge between the telemetry system and the core - * Clerk SDK's settings. - */ -class ClerkTelemetryEnvironment : TelemetryEnvironment { +/** A [TelemetryEnvironment] implementation that reads values from injected providers. */ +class ClerkTelemetryEnvironment( + override val sdkVersion: String, + private val instanceTypeProvider: suspend () -> String, + private val telemetryEnabledProvider: suspend () -> Boolean, + private val debugModeEnabledProvider: suspend () -> Boolean, + private val publishableKeyProvider: suspend () -> String?, +) : TelemetryEnvironment { override val sdkName: String = CLERK_ANDROID - override val sdkVersion: String = Clerk.version override suspend fun instanceTypeString(): String { - return Clerk.instanceEnvironmentType.name + return instanceTypeProvider() } override suspend fun isTelemetryEnabled(): Boolean { - return Clerk.telemetryEnabled + return telemetryEnabledProvider() } override suspend fun isDebugModeEnabled(): Boolean { - return Clerk.debugMode + return debugModeEnabledProvider() } override suspend fun publishableKey(): String? { - val key = Clerk.publishableKey + val key = publishableKeyProvider() return key.takeIf { !it.isNullOrEmpty() } } } diff --git a/source/telemetry/src/commonMain/kotlin/com/clerk/telemetry/TelemetryCollector.kt b/source/telemetry/src/commonMain/kotlin/com/clerk/telemetry/TelemetryCollector.kt index 701cdb95e..321caf550 100644 --- a/source/telemetry/src/commonMain/kotlin/com/clerk/telemetry/TelemetryCollector.kt +++ b/source/telemetry/src/commonMain/kotlin/com/clerk/telemetry/TelemetryCollector.kt @@ -1,6 +1,5 @@ package com.clerk.telemetry -import com.clerk.api.log.ClerkLog import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.post @@ -172,9 +171,7 @@ class TelemetryCollector( setBody(envelope) } .body() - } catch (e: Exception) { - ClerkLog.e("${e.message}") - } + } catch (_: Exception) {} } private companion object { diff --git a/source/ui/src/main/java/com/clerk/ui/core/composition/CompositionLocals.kt b/source/ui/src/main/java/com/clerk/ui/core/composition/CompositionLocals.kt index 6fcc97556..bb05f4a72 100644 --- a/source/ui/src/main/java/com/clerk/ui/core/composition/CompositionLocals.kt +++ b/source/ui/src/main/java/com/clerk/ui/core/composition/CompositionLocals.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.platform.LocalContext import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey +import com.clerk.api.Clerk import com.clerk.telemetry.ClerkTelemetryEnvironment import com.clerk.telemetry.TelemetryCollector import com.clerk.telemetry.TelemetryModule @@ -27,7 +28,15 @@ internal val LocalTelemetryCollector = private fun rememberTelemetryCollector(): TelemetryCollector { val context = LocalContext.current.applicationContext - val environment = remember { ClerkTelemetryEnvironment() } + val environment = remember { + ClerkTelemetryEnvironment( + sdkVersion = Clerk.version, + instanceTypeProvider = { Clerk.instanceEnvironmentType.name }, + telemetryEnabledProvider = { Clerk.telemetryEnabled }, + debugModeEnabledProvider = { Clerk.debugMode }, + publishableKeyProvider = { Clerk.publishableKey }, + ) + } return remember { TelemetryModule.createCollector(context = context, environment = environment) } } From 90afd248fb20cd555fe2f2fd52a7d3d5bd4720d3 Mon Sep 17 00:00:00 2001 From: Sam Wolfand Date: Thu, 5 Mar 2026 11:20:10 -0800 Subject: [PATCH 06/17] wip --- workbench/src/debug/AndroidManifest.xml | 7 +++++++ .../xml/workbench_debug_network_security_config.xml | 9 +++++++++ .../java/com/clerk/workbench/WorkbenchApplication.kt | 10 +++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 workbench/src/debug/AndroidManifest.xml create mode 100644 workbench/src/debug/res/xml/workbench_debug_network_security_config.xml diff --git a/workbench/src/debug/AndroidManifest.xml b/workbench/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..86cc22238 --- /dev/null +++ b/workbench/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/workbench/src/debug/res/xml/workbench_debug_network_security_config.xml b/workbench/src/debug/res/xml/workbench_debug_network_security_config.xml new file mode 100644 index 000000000..931ccdce7 --- /dev/null +++ b/workbench/src/debug/res/xml/workbench_debug_network_security_config.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/workbench/src/main/java/com/clerk/workbench/WorkbenchApplication.kt b/workbench/src/main/java/com/clerk/workbench/WorkbenchApplication.kt index 76acfa757..cf2f62bd7 100644 --- a/workbench/src/main/java/com/clerk/workbench/WorkbenchApplication.kt +++ b/workbench/src/main/java/com/clerk/workbench/WorkbenchApplication.kt @@ -12,7 +12,15 @@ class WorkbenchApplication : Application() { val publicKey = StorageHelper.loadValue(StorageKey.PUBLIC_KEY) publicKey?.let { key -> - Clerk.initialize(this, key, options = ClerkConfigurationOptions(enableDebugMode = true)) + Clerk.initialize( + this, + key, + options = + ClerkConfigurationOptions( + enableDebugMode = true, + proxyUrl = "https://rapid-earwig-10.clerk.accounts.lclclerk.com:8443", + ), + ) } } } From b6aa9f11afa2b45cc216aa1945c46f94d92a3734 Mon Sep 17 00:00:00 2001 From: Sam Wolfand Date: Tue, 10 Mar 2026 17:31:57 -0700 Subject: [PATCH 07/17] codex: fix CI failure on PR #563 --- .../main/kotlin/com/clerk/api/auth/Auth.kt | 42 ++------ .../api/magiclink/NativeMagicLinkManager.kt | 95 ++++++++++++------- .../com/clerk/api/sso/SSOManagerActivity.kt | 5 +- .../com/clerk/api/storage/StorageHelper.kt | 3 +- .../java/com/clerk/api/auth/AuthHandleTest.kt | 90 ++++++++++++++++++ .../java/com/clerk/api/auth/AuthOtpTest.kt | 82 ++++++++++++++++ .../magiclink/NativeMagicLinkServiceTest.kt | 6 ++ ...rsistentPendingNativeMagicLinkStoreTest.kt | 54 +++++++++++ 8 files changed, 309 insertions(+), 68 deletions(-) create mode 100644 source/api/src/test/java/com/clerk/api/auth/AuthHandleTest.kt create mode 100644 source/api/src/test/java/com/clerk/api/auth/AuthOtpTest.kt create mode 100644 source/api/src/test/java/com/clerk/api/magiclink/PersistentPendingNativeMagicLinkStoreTest.kt diff --git a/source/api/src/main/kotlin/com/clerk/api/auth/Auth.kt b/source/api/src/main/kotlin/com/clerk/api/auth/Auth.kt index 3be070754..c83ffcfdb 100644 --- a/source/api/src/main/kotlin/com/clerk/api/auth/Auth.kt +++ b/source/api/src/main/kotlin/com/clerk/api/auth/Auth.kt @@ -17,6 +17,7 @@ import com.clerk.api.log.ClerkLog import com.clerk.api.magiclink.NativeMagicLinkError import com.clerk.api.magiclink.NativeMagicLinkManager import com.clerk.api.magiclink.NativeMagicLinkService +import com.clerk.api.magiclink.canHandleNativeMagicLink import com.clerk.api.network.ClerkApi import com.clerk.api.network.model.error.ClerkErrorResponse import com.clerk.api.network.model.error.Error @@ -228,38 +229,11 @@ class Auth internal constructor() { val identifier = builder.email ?: builder.phone!! val isEmailFlow = builder.email != null - val nativeEmailLinkResult = - if (isEmailFlow) { - signInWithNativeEmailLink(identifier) - } else { - null - } - val result = nativeEmailLinkResult ?: createAndPrepareOtpSignIn(identifier, isEmailFlow) + val result = createAndPrepareOtpSignIn(identifier, isEmailFlow) result.onFailure { emitAuthError(it) } return result } - private suspend fun signInWithNativeEmailLink( - identifier: String - ): ClerkResult { - return when (val nativeResult = nativeMagicLink.startEmailLinkSignIn(identifier)) { - is ClerkResult.Success -> ClerkResult.success(nativeResult.value) - is ClerkResult.Failure -> - ClerkResult.apiFailure( - ClerkErrorResponse( - errors = - listOf( - Error( - message = "is invalid", - longMessage = "Native email-link sign-in failed", - code = nativeResult.error?.reasonCode, - ) - ) - ) - ) - } - } - private suspend fun createAndPrepareOtpSignIn( identifier: String, isEmailFlow: Boolean, @@ -740,19 +714,21 @@ class Auth internal constructor() { * ### Example usage: * ```kotlin * // In your Activity's onCreate or onNewIntent - * clerk.auth.handle(intent.data) + * lifecycleScope.launch { + * clerk.auth.handle(intent.data) + * } * ``` */ - fun handle(uri: Uri?): Boolean { + suspend fun handle(uri: Uri?): Boolean { val callbackUri = uri ?: return false - val handledByMagicLink = NativeMagicLinkService.canHandle(callbackUri) + val handledByMagicLink = canHandleNativeMagicLink(callbackUri) if (handledByMagicLink) { - kotlinx.coroutines.runBlocking { NativeMagicLinkService.handleMagicLinkDeepLink(callbackUri) } + NativeMagicLinkService.handleMagicLinkDeepLink(callbackUri) } val isClerkCallback = callbackUri.scheme?.startsWith("clerk") == true if (!handledByMagicLink && isClerkCallback) { - kotlinx.coroutines.runBlocking { SSOService.completeAuthenticateWithRedirect(callbackUri) } + SSOService.completeAuthenticateWithRedirect(callbackUri) } return handledByMagicLink || isClerkCallback diff --git a/source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkManager.kt b/source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkManager.kt index 8ba3292d5..6344d1eab 100644 --- a/source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkManager.kt +++ b/source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkManager.kt @@ -15,8 +15,15 @@ import com.clerk.api.network.serialization.ClerkResult import com.clerk.api.signin.SignIn import com.clerk.api.signup.SignUp import com.clerk.api.sso.RedirectConfiguration +import com.clerk.api.storage.StorageHelper +import com.clerk.api.storage.StorageKey import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +private const val PENDING_FLOW_TTL_MS = 10 * 60 * 1000L public interface NativeMagicLinkManager { public var attestationProvider: NativeMagicLinkAttestationProvider? @@ -33,7 +40,8 @@ public interface NativeMagicLinkManager { internal object NativeMagicLinkService : NativeMagicLinkManager { private val mutex = Mutex() - private val pendingFlowStore: PendingNativeMagicLinkStore = InMemoryPendingNativeMagicLinkStore() + private val pendingFlowStore: PendingNativeMagicLinkStore = + PersistentPendingNativeMagicLinkStore() override var attestationProvider: NativeMagicLinkAttestationProvider? = null @@ -104,14 +112,9 @@ internal object NativeMagicLinkService : NativeMagicLinkManager { prepareResult.toNativeMagicLinkError(NativeMagicLinkReason.PREPARE_FAILED) ) is ClerkResult.Success -> { - mutex.withLock { - pendingFlowStore.save( - PendingNativeMagicLinkFlow( - codeVerifier = pkcePair.verifier, - expiresAtEpochMs = currentTimeMillis() + PENDING_FLOW_TTL_MS, - ) - ) - } + persistPendingFlow( + createPendingFlow(pkcePair.verifier, PendingNativeMagicLinkState.SIGN_IN) + ) ClerkResult.success(prepareResult.value) } } @@ -161,14 +164,9 @@ internal object NativeMagicLinkService : NativeMagicLinkManager { return when (val prepareResult = ClerkApi.signUp.prepareSignUpVerification(signUpId, fields)) { is ClerkResult.Failure -> prepareResult is ClerkResult.Success -> { - mutex.withLock { - pendingFlowStore.save( - PendingNativeMagicLinkFlow( - codeVerifier = pkcePair.verifier, - expiresAtEpochMs = currentTimeMillis() + PENDING_FLOW_TTL_MS, - ) - ) - } + persistPendingFlow( + createPendingFlow(pkcePair.verifier, PendingNativeMagicLinkState.SIGN_UP) + ) prepareResult } } @@ -219,18 +217,15 @@ internal object NativeMagicLinkService : NativeMagicLinkManager { } } - internal fun canHandle(uri: Uri?): Boolean { - return uri?.let { - queryOrFragmentParam(it, "flow_id") != null || - queryOrFragmentParam(it, "approval_token") != null - } ?: false - } - internal fun resetForTests() { pendingFlowStore.clear() attestationProvider = null } + private suspend fun persistPendingFlow(flow: PendingNativeMagicLinkFlow) { + mutex.withLock { pendingFlowStore.save(flow) } + } + private suspend fun refreshClientState() { when (val clientResult = Client.get()) { is ClerkResult.Success -> Clerk.updateClient(clientResult.value) @@ -258,19 +253,27 @@ internal object NativeMagicLinkService : NativeMagicLinkManager { } } } - - private const val PENDING_FLOW_TTL_MS = 10 * 60 * 1000L } public fun interface NativeMagicLinkAttestationProvider { suspend fun attestation(): String? } +@Serializable internal data class PendingNativeMagicLinkFlow( val codeVerifier: String, + val state: PendingNativeMagicLinkState, + val createdAtEpochMs: Long, val expiresAtEpochMs: Long, + val flowId: String? = null, ) +@Serializable +internal enum class PendingNativeMagicLinkState { + SIGN_IN, + SIGN_UP, +} + internal interface PendingNativeMagicLinkStore { fun save(flow: PendingNativeMagicLinkFlow) @@ -279,22 +282,50 @@ internal interface PendingNativeMagicLinkStore { fun clear() } -internal class InMemoryPendingNativeMagicLinkStore : PendingNativeMagicLinkStore { - @Volatile private var flow: PendingNativeMagicLinkFlow? = null - +internal class PersistentPendingNativeMagicLinkStore( + private val json: Json = Json { ignoreUnknownKeys = true } +) : PendingNativeMagicLinkStore { override fun save(flow: PendingNativeMagicLinkFlow) { - this.flow = flow + StorageHelper.saveValue(StorageKey.PENDING_NATIVE_MAGIC_LINK_FLOW, json.encodeToString(flow)) } - override fun load(): PendingNativeMagicLinkFlow? = flow + override fun load(): PendingNativeMagicLinkFlow? { + val encoded = StorageHelper.loadValue(StorageKey.PENDING_NATIVE_MAGIC_LINK_FLOW) ?: return null + return runCatching { json.decodeFromString(encoded) } + .getOrElse { error -> + ClerkLog.w("event=native_magic_link_pending_flow_decode_failure message=${error.message}") + clear() + null + } + } override fun clear() { - flow = null + StorageHelper.deleteValue(StorageKey.PENDING_NATIVE_MAGIC_LINK_FLOW) } } internal data class ParsedMagicLinkDeepLink(val flowId: String, val approvalToken: String) +private fun createPendingFlow( + codeVerifier: String, + state: PendingNativeMagicLinkState, +): PendingNativeMagicLinkFlow { + val createdAtEpochMs = currentTimeMillis() + return PendingNativeMagicLinkFlow( + codeVerifier = codeVerifier, + state = state, + createdAtEpochMs = createdAtEpochMs, + expiresAtEpochMs = createdAtEpochMs + PENDING_FLOW_TTL_MS, + ) +} + +internal fun canHandleNativeMagicLink(uri: Uri?): Boolean { + return uri?.let { + queryOrFragmentParam(it, "flow_id") != null || + queryOrFragmentParam(it, "approval_token") != null + } ?: false +} + internal fun parseMagicLinkCallback( uri: Uri ): ClerkResult { diff --git a/source/api/src/main/kotlin/com/clerk/api/sso/SSOManagerActivity.kt b/source/api/src/main/kotlin/com/clerk/api/sso/SSOManagerActivity.kt index c2e1b15b2..3f3f37ad1 100644 --- a/source/api/src/main/kotlin/com/clerk/api/sso/SSOManagerActivity.kt +++ b/source/api/src/main/kotlin/com/clerk/api/sso/SSOManagerActivity.kt @@ -12,6 +12,7 @@ import androidx.lifecycle.lifecycleScope import com.clerk.api.Constants.Storage.KEY_AUTHORIZATION_STARTED import com.clerk.api.log.ClerkLog import com.clerk.api.magiclink.NativeMagicLinkService +import com.clerk.api.magiclink.canHandleNativeMagicLink import kotlinx.coroutines.launch /** @@ -121,7 +122,7 @@ internal class SSOManagerActivity : AppCompatActivity() { try { // Mark the Activity result as success so callers don't observe RESULT_CANCELED setResult(RESULT_OK, Intent()) - if (NativeMagicLinkService.canHandle(uri)) { + if (canHandleNativeMagicLink(uri)) { ClerkLog.d("authorizationComplete called with native magic link redirect: $uri") NativeMagicLinkService.handleMagicLinkDeepLink(uri) return@launch @@ -142,7 +143,7 @@ internal class SSOManagerActivity : AppCompatActivity() { private fun isCallbackUri(uri: Uri): Boolean { return uri.scheme?.startsWith("clerk") == true || - NativeMagicLinkService.canHandle(uri) || + canHandleNativeMagicLink(uri) || uri.getQueryParameter("rotating_token_nonce") != null } diff --git a/source/api/src/main/kotlin/com/clerk/api/storage/StorageHelper.kt b/source/api/src/main/kotlin/com/clerk/api/storage/StorageHelper.kt index 909c6f21f..4f1afdafd 100644 --- a/source/api/src/main/kotlin/com/clerk/api/storage/StorageHelper.kt +++ b/source/api/src/main/kotlin/com/clerk/api/storage/StorageHelper.kt @@ -59,7 +59,7 @@ internal object StorageHelper { ) return } - prefs.edit { remove(key.name) } + prefs.edit(commit = true) { remove(key.name) } } /** @@ -86,4 +86,5 @@ internal object StorageHelper { internal enum class StorageKey { DEVICE_TOKEN, DEVICE_ID, + PENDING_NATIVE_MAGIC_LINK_FLOW, } diff --git a/source/api/src/test/java/com/clerk/api/auth/AuthHandleTest.kt b/source/api/src/test/java/com/clerk/api/auth/AuthHandleTest.kt new file mode 100644 index 000000000..1f6a17312 --- /dev/null +++ b/source/api/src/test/java/com/clerk/api/auth/AuthHandleTest.kt @@ -0,0 +1,90 @@ +package com.clerk.api.auth + +import android.net.Uri +import com.clerk.api.magiclink.NativeMagicLinkService +import com.clerk.api.magiclink.canHandleNativeMagicLink +import com.clerk.api.network.serialization.ClerkResult +import com.clerk.api.signin.SignIn +import com.clerk.api.sso.SSOService +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class AuthHandleTest { + private lateinit var auth: Auth + + @Before + fun setup() { + auth = Auth() + mockkObject(NativeMagicLinkService) + mockkStatic(::canHandleNativeMagicLink) + mockkObject(SSOService) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `handle completes native magic link callback without SSO fallback`() = runTest { + val callbackUri = mockk(relaxed = true) + + every { canHandleNativeMagicLink(callbackUri) } returns true + coEvery { NativeMagicLinkService.handleMagicLinkDeepLink(callbackUri) } returns + ClerkResult.success(mockk(relaxed = true)) + + val handled = auth.handle(callbackUri) + + assertTrue(handled) + coVerify(exactly = 1) { NativeMagicLinkService.handleMagicLinkDeepLink(callbackUri) } + coVerify(exactly = 0) { SSOService.completeAuthenticateWithRedirect(any()) } + } + + @Test + fun `handle completes SSO callback when URI is Clerk scheme and not magic link`() = runTest { + val callbackUri = mockk(relaxed = true) + + every { canHandleNativeMagicLink(callbackUri) } returns false + every { callbackUri.scheme } returns "clerk" + coJustRun { SSOService.completeAuthenticateWithRedirect(callbackUri) } + + val handled = auth.handle(callbackUri) + + assertTrue(handled) + coVerify(exactly = 0) { NativeMagicLinkService.handleMagicLinkDeepLink(any()) } + coVerify(exactly = 1) { SSOService.completeAuthenticateWithRedirect(callbackUri) } + } + + @Test + fun `handle returns false for non Clerk URIs`() = runTest { + val callbackUri = mockk(relaxed = true) + + every { canHandleNativeMagicLink(callbackUri) } returns false + every { callbackUri.scheme } returns "https" + + val handled = auth.handle(callbackUri) + + assertFalse(handled) + coVerify(exactly = 0) { NativeMagicLinkService.handleMagicLinkDeepLink(any()) } + coVerify(exactly = 0) { SSOService.completeAuthenticateWithRedirect(any()) } + } + + @Test + fun `handle returns false for null URI`() = runTest { + assertFalse(auth.handle(null)) + coVerify(exactly = 0) { NativeMagicLinkService.handleMagicLinkDeepLink(any()) } + coVerify(exactly = 0) { SSOService.completeAuthenticateWithRedirect(any()) } + } +} diff --git a/source/api/src/test/java/com/clerk/api/auth/AuthOtpTest.kt b/source/api/src/test/java/com/clerk/api/auth/AuthOtpTest.kt new file mode 100644 index 000000000..9aaa8d8fb --- /dev/null +++ b/source/api/src/test/java/com/clerk/api/auth/AuthOtpTest.kt @@ -0,0 +1,82 @@ +package com.clerk.api.auth + +import com.clerk.api.Clerk +import com.clerk.api.network.ClerkApi +import com.clerk.api.network.api.SignInApi +import com.clerk.api.network.model.factor.Factor +import com.clerk.api.network.model.verification.Verification +import com.clerk.api.network.serialization.ClerkResult +import com.clerk.api.signin.SignIn +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class AuthOtpTest { + private lateinit var auth: Auth + private lateinit var signInApi: SignInApi + + @Before + fun setup() { + auth = Auth() + signInApi = mockk(relaxed = true) + + mockkObject(ClerkApi) + mockkObject(Clerk) + + every { ClerkApi.signIn } returns signInApi + every { Clerk.locale } returns MutableStateFlow("en") + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `signInWithOtp for email prepares email code instead of native email link`() = runTest { + val createdSignIn = + SignIn( + id = "sign_in_123", + supportedFirstFactors = + listOf(Factor(strategy = "email_code", emailAddressId = "email_123")), + ) + val preparedSignIn = + createdSignIn.copy( + firstFactorVerification = + Verification(strategy = "email_code", status = Verification.Status.UNVERIFIED) + ) + + coEvery { signInApi.createSignIn(any()) } returns ClerkResult.success(createdSignIn) + coEvery { signInApi.prepareSignInFirstFactor(any(), any()) } returns + ClerkResult.success(preparedSignIn) + + val result = auth.signInWithOtp { email = "user@example.com" } + + assertTrue(result is ClerkResult.Success) + coVerify(exactly = 1) { + signInApi.createSignIn( + match { it["identifier"] == "user@example.com" && it["locale"] == "en" } + ) + } + coVerify(exactly = 1) { + signInApi.prepareSignInFirstFactor( + "sign_in_123", + match { + it["strategy"] == "email_code" && + it["email_address_id"] == "email_123" && + !it.containsKey("redirect_uri") && + !it.containsKey("code_challenge") + }, + ) + } + } +} diff --git a/source/api/src/test/java/com/clerk/api/magiclink/NativeMagicLinkServiceTest.kt b/source/api/src/test/java/com/clerk/api/magiclink/NativeMagicLinkServiceTest.kt index d3c050bc5..61c15edeb 100644 --- a/source/api/src/test/java/com/clerk/api/magiclink/NativeMagicLinkServiceTest.kt +++ b/source/api/src/test/java/com/clerk/api/magiclink/NativeMagicLinkServiceTest.kt @@ -17,6 +17,7 @@ import com.clerk.api.network.serialization.ClerkResult import com.clerk.api.session.Session import com.clerk.api.signin.SignIn import com.clerk.api.signup.SignUp +import com.clerk.api.storage.StorageHelper import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -34,6 +35,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment @RunWith(RobolectricTestRunner::class) class NativeMagicLinkServiceTest { @@ -45,6 +47,9 @@ class NativeMagicLinkServiceTest { @Before fun setup() { + StorageHelper.initialize(RuntimeEnvironment.getApplication()) + StorageHelper.reset(RuntimeEnvironment.getApplication()) + signInApi = mockk(relaxed = true) signUpApi = mockk(relaxed = true) magicLinkApi = mockk(relaxed = true) @@ -70,6 +75,7 @@ class NativeMagicLinkServiceTest { @After fun tearDown() { NativeMagicLinkService.resetForTests() + StorageHelper.reset(RuntimeEnvironment.getApplication()) unmockkAll() } diff --git a/source/api/src/test/java/com/clerk/api/magiclink/PersistentPendingNativeMagicLinkStoreTest.kt b/source/api/src/test/java/com/clerk/api/magiclink/PersistentPendingNativeMagicLinkStoreTest.kt new file mode 100644 index 000000000..b5570bc22 --- /dev/null +++ b/source/api/src/test/java/com/clerk/api/magiclink/PersistentPendingNativeMagicLinkStoreTest.kt @@ -0,0 +1,54 @@ +package com.clerk.api.magiclink + +import com.clerk.api.storage.StorageHelper +import com.clerk.api.storage.StorageKey +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class PersistentPendingNativeMagicLinkStoreTest { + @Before + fun setup() { + StorageHelper.initialize(RuntimeEnvironment.getApplication()) + StorageHelper.reset(RuntimeEnvironment.getApplication()) + } + + @After + fun tearDown() { + StorageHelper.reset(RuntimeEnvironment.getApplication()) + } + + @Test + fun `save persists flow across store instances`() { + val flow = + PendingNativeMagicLinkFlow( + codeVerifier = "verifier_123", + state = PendingNativeMagicLinkState.SIGN_IN, + createdAtEpochMs = 100L, + expiresAtEpochMs = 200L, + flowId = "flow_123", + ) + + PersistentPendingNativeMagicLinkStore().save(flow) + + val restored = PersistentPendingNativeMagicLinkStore().load() + + assertEquals(flow, restored) + } + + @Test + fun `load clears corrupted payload`() { + StorageHelper.saveValue(StorageKey.PENDING_NATIVE_MAGIC_LINK_FLOW, "{bad json") + + val restored = PersistentPendingNativeMagicLinkStore().load() + + assertNull(restored) + assertNull(StorageHelper.loadValue(StorageKey.PENDING_NATIVE_MAGIC_LINK_FLOW)) + } +} From 32b1873070291cc490e4379d3493dd64b8f634ed Mon Sep 17 00:00:00 2001 From: Sam Wolfand Date: Tue, 10 Mar 2026 17:36:49 -0700 Subject: [PATCH 08/17] codex: fix integration test collision on PR #563 --- .../clerk/api/integration/AuthIntegrationTests.kt | 1 - .../api/integration/IntegrationTestHelpers.kt | 15 +++++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/source/api/src/test/java/com/clerk/api/integration/AuthIntegrationTests.kt b/source/api/src/test/java/com/clerk/api/integration/AuthIntegrationTests.kt index e9d429a81..27374e789 100644 --- a/source/api/src/test/java/com/clerk/api/integration/AuthIntegrationTests.kt +++ b/source/api/src/test/java/com/clerk/api/integration/AuthIntegrationTests.kt @@ -29,7 +29,6 @@ import org.robolectric.RobolectricTestRunner */ @RunWith(RobolectricTestRunner::class) class AuthIntegrationTests { - @Test fun `sign up and sign in with email codes`(): Unit = runBlocking { val pk = requirePublishableKey() diff --git a/source/api/src/test/java/com/clerk/api/integration/IntegrationTestHelpers.kt b/source/api/src/test/java/com/clerk/api/integration/IntegrationTestHelpers.kt index c139abc94..7f747701f 100644 --- a/source/api/src/test/java/com/clerk/api/integration/IntegrationTestHelpers.kt +++ b/source/api/src/test/java/com/clerk/api/integration/IntegrationTestHelpers.kt @@ -3,7 +3,6 @@ package com.clerk.api.integration import com.clerk.api.Clerk import java.io.File import java.util.UUID -import kotlin.random.Random import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.Json @@ -63,11 +62,11 @@ suspend fun initializeClerkAndWait(publishableKey: String, timeoutMs: Long = INI fun generateTestEmail(): String = "test+clerk_test_${UUID.randomUUID()}@example.com" fun generateTestPhone(): String { - val areaCode = buildString { - append(Random.nextInt(from = 2, until = 10)) - append(Random.nextInt(from = 0, until = 10)) - append(Random.nextInt(from = 0, until = 10)) - } - val suffix = Random.nextInt(from = 0, until = 100).toString().padStart(length = 2, padChar = '0') - return "+1${areaCode}55501${suffix}" + val seed = UUID.randomUUID() + val areaSeed = (seed.mostSignificantBits and Long.MAX_VALUE).toInt() + val firstDigit = 2 + (areaSeed % 8) + val secondDigit = (areaSeed / 8) % 10 + val thirdDigit = (areaSeed / 80) % 10 + val suffix = ((seed.leastSignificantBits and 0x7FFF).toInt()) % 100 + return "+1${firstDigit}${secondDigit}${thirdDigit}55501%02d".format(suffix) } From 37968747db80ee605d48ea074cb6fa88a5370426 Mon Sep 17 00:00:00 2001 From: Sam Wolfand Date: Fri, 27 Mar 2026 20:05:27 -0700 Subject: [PATCH 09/17] fix(api): track flow ID in native magic link to reject stale callbacks Store the sign-in/sign-up flow ID alongside the PKCE verifier in PendingNativeMagicLinkFlow. On completion, validate that the callback's flow ID matches the pending flow; mismatches return a new FLOW_ID_MISMATCH error without consuming the pending state, so the original flow can still complete. --- .../api/magiclink/NativeMagicLinkManager.kt | 49 +++++++++++++--- .../magiclink/NativeMagicLinkServiceTest.kt | 58 +++++++++++++++++-- 2 files changed, 93 insertions(+), 14 deletions(-) diff --git a/source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkManager.kt b/source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkManager.kt index 6344d1eab..d5585409e 100644 --- a/source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkManager.kt +++ b/source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkManager.kt @@ -113,7 +113,11 @@ internal object NativeMagicLinkService : NativeMagicLinkManager { ) is ClerkResult.Success -> { persistPendingFlow( - createPendingFlow(pkcePair.verifier, PendingNativeMagicLinkState.SIGN_IN) + createPendingFlow( + codeVerifier = pkcePair.verifier, + state = PendingNativeMagicLinkState.SIGN_IN, + flowId = signIn.id, + ) ) ClerkResult.success(prepareResult.value) } @@ -165,7 +169,11 @@ internal object NativeMagicLinkService : NativeMagicLinkManager { is ClerkResult.Failure -> prepareResult is ClerkResult.Success -> { persistPendingFlow( - createPendingFlow(pkcePair.verifier, PendingNativeMagicLinkState.SIGN_UP) + createPendingFlow( + codeVerifier = pkcePair.verifier, + state = PendingNativeMagicLinkState.SIGN_UP, + flowId = signUpId, + ) ) prepareResult } @@ -191,14 +199,16 @@ internal object NativeMagicLinkService : NativeMagicLinkManager { flowId: String, approvalToken: String, ): ClerkResult { - val pending = + val pendingState = mutex.withLock { val flow = pendingFlowStore.load() if (flow != null && flow.expiresAtEpochMs <= currentTimeMillis()) { pendingFlowStore.clear() - null + PendingFlowLookup.None + } else if (flow != null && flow.flowId != null && flow.flowId != flowId) { + PendingFlowLookup.Mismatched(flow.flowId) } else { - flow + flow?.let(PendingFlowLookup::Found) ?: PendingFlowLookup.None } } @@ -210,10 +220,20 @@ internal object NativeMagicLinkService : NativeMagicLinkManager { activateCreatedSession = { signIn -> activateCreatedSession(signIn) }, refreshClientState = { refreshClientState() }, ) - return if (pending == null) { - nativeMagicLinkFailure(NativeMagicLinkReason.NO_PENDING_FLOW) - } else { - completionRunner.complete(flowId = flowId, approvalToken = approvalToken, pending = pending) + return when (pendingState) { + PendingFlowLookup.None -> nativeMagicLinkFailure(NativeMagicLinkReason.NO_PENDING_FLOW) + is PendingFlowLookup.Mismatched -> { + ClerkLog.w( + "event=native_magic_link_flow_id_mismatch expected=${pendingState.expectedFlowId} actual=$flowId" + ) + nativeMagicLinkFailure(NativeMagicLinkReason.FLOW_ID_MISMATCH) + } + is PendingFlowLookup.Found -> + completionRunner.complete( + flowId = flowId, + approvalToken = approvalToken, + pending = pendingState.flow, + ) } } @@ -306,9 +326,18 @@ internal class PersistentPendingNativeMagicLinkStore( internal data class ParsedMagicLinkDeepLink(val flowId: String, val approvalToken: String) +private sealed interface PendingFlowLookup { + data object None : PendingFlowLookup + + data class Found(val flow: PendingNativeMagicLinkFlow) : PendingFlowLookup + + data class Mismatched(val expectedFlowId: String?) : PendingFlowLookup +} + private fun createPendingFlow( codeVerifier: String, state: PendingNativeMagicLinkState, + flowId: String, ): PendingNativeMagicLinkFlow { val createdAtEpochMs = currentTimeMillis() return PendingNativeMagicLinkFlow( @@ -316,6 +345,7 @@ private fun createPendingFlow( state = state, createdAtEpochMs = createdAtEpochMs, expiresAtEpochMs = createdAtEpochMs + PENDING_FLOW_TTL_MS, + flowId = flowId, ) } @@ -411,6 +441,7 @@ internal enum class NativeMagicLinkReason(val code: String) { NATIVE_API_DISABLED_FOR_INSTANCE("native_api_disabled_for_instance"), NATIVE_API_DISABLED("native_api_disabled"), NO_PENDING_FLOW("no_pending_flow"), + FLOW_ID_MISMATCH("native_magic_link_flow_id_mismatch"), SESSION_ACTIVATION_FAILED("native_magic_link_session_activation_failed"), START_FAILED("native_magic_link_start_failed"), PREPARE_FAILED("native_magic_link_prepare_failed"), diff --git a/source/api/src/test/java/com/clerk/api/magiclink/NativeMagicLinkServiceTest.kt b/source/api/src/test/java/com/clerk/api/magiclink/NativeMagicLinkServiceTest.kt index 61c15edeb..20ba151c0 100644 --- a/source/api/src/test/java/com/clerk/api/magiclink/NativeMagicLinkServiceTest.kt +++ b/source/api/src/test/java/com/clerk/api/magiclink/NativeMagicLinkServiceTest.kt @@ -106,7 +106,7 @@ class NativeMagicLinkServiceTest { assertTrue(startResult is ClerkResult.Success) val callbackUri = - Uri.parse("clerk://com.clerk.test.oauth?flow_id=flow_123&approval_token=approval_123") + Uri.parse("clerk://com.clerk.test.oauth?flow_id=sign_in_123&approval_token=approval_123") val completeResult = NativeMagicLinkService.handleMagicLinkDeepLink(callbackUri) assertTrue(completeResult is ClerkResult.Success) assertEquals(SignIn.Status.COMPLETE, (completeResult as ClerkResult.Success).value.status) @@ -126,7 +126,7 @@ class NativeMagicLinkServiceTest { coVerify(exactly = 1) { magicLinkApi.complete( match { - it["flow_id"] == "flow_123" && + it["flow_id"] == "sign_in_123" && it["approval_token"] == "approval_123" && it["code_verifier"]?.isNotBlank() == true } @@ -162,7 +162,7 @@ class NativeMagicLinkServiceTest { assertTrue(prepareResult is ClerkResult.Success) val callbackUri = - Uri.parse("clerk://com.clerk.test.oauth?flow_id=sua_123&approval_token=approval_123") + Uri.parse("clerk://com.clerk.test.oauth?flow_id=sign_up_123&approval_token=approval_123") val completeResult = NativeMagicLinkService.handleMagicLinkDeepLink(callbackUri) assertTrue(completeResult is ClerkResult.Success) @@ -180,7 +180,7 @@ class NativeMagicLinkServiceTest { coVerify(exactly = 1) { magicLinkApi.complete( match { - it["flow_id"] == "sua_123" && + it["flow_id"] == "sign_up_123" && it["approval_token"] == "approval_123" && it["code_verifier"]?.isNotBlank() == true } @@ -189,6 +189,54 @@ class NativeMagicLinkServiceTest { coVerify(exactly = 1) { auth.setActive("sess_456", null) } } + @Test + fun `complete rejects callback for different pending flow id`() = runTest { + val initialSignIn = + SignIn( + id = "sign_in_123", + supportedFirstFactors = + listOf(Factor(strategy = "email_link", emailAddressId = "email_123")), + ) + + coEvery { signInApi.createSignIn(any()) } returns ClerkResult.success(initialSignIn) + coEvery { signInApi.prepareSignInFirstFactor(any(), any()) } returns + ClerkResult.success(initialSignIn) + + val startResult = NativeMagicLinkService.startEmailLinkSignIn("user@example.com") + assertTrue(startResult is ClerkResult.Success) + + val completeResult = NativeMagicLinkService.complete("sign_in_other", "approval_123") + + assertTrue(completeResult is ClerkResult.Failure) + assertEquals( + NativeMagicLinkReason.FLOW_ID_MISMATCH.code, + (completeResult as ClerkResult.Failure).error?.reasonCode, + ) + coVerify(exactly = 0) { magicLinkApi.complete(any()) } + } + + @Test + fun `complete keeps pending flow when callback flow id does not match`() = runTest { + val initialSignIn = + SignIn( + id = "sign_in_123", + supportedFirstFactors = + listOf(Factor(strategy = "email_link", emailAddressId = "email_123")), + ) + + coEvery { signInApi.createSignIn(any()) } returns ClerkResult.success(initialSignIn) + coEvery { signInApi.prepareSignInFirstFactor(any(), any()) } returns + ClerkResult.success(initialSignIn) + + NativeMagicLinkService.startEmailLinkSignIn("user@example.com") + + NativeMagicLinkService.complete("sign_in_other", "approval_123") + + val persisted = PersistentPendingNativeMagicLinkStore().load() + assertEquals("sign_in_123", persisted?.flowId) + assertTrue(persisted?.codeVerifier?.isNotBlank() == true) + } + @Test fun `complete returns activation failure when created session cannot be activated`() = runTest { val initialSignIn = @@ -226,7 +274,7 @@ class NativeMagicLinkServiceTest { assertTrue(startResult is ClerkResult.Success) val callbackUri = - Uri.parse("clerk://com.clerk.test.oauth?flow_id=flow_123&approval_token=approval_123") + Uri.parse("clerk://com.clerk.test.oauth?flow_id=sign_in_123&approval_token=approval_123") val completeResult = NativeMagicLinkService.handleMagicLinkDeepLink(callbackUri) assertTrue(completeResult is ClerkResult.Failure) From 68ae5848d0bbdd0f4b123a0ce3e3b0690a21a351 Mon Sep 17 00:00:00 2001 From: Sam Wolfand Date: Fri, 27 Mar 2026 21:27:40 -0700 Subject: [PATCH 10/17] fix(api): persist SSO callback URI and propagate magic link failures Store the callback URI in a field (and in savedInstanceState) so it survives configuration changes and process-death restores. Set RESULT_CANCELED when magic link handling or SSO completion fails so callers can distinguish success from failure. Also fixes test isolation in SignUpEmailVerificationStrategyTest by saving/restoring Clerk.environment. --- .../com/clerk/api/sso/SSOManagerActivity.kt | 30 ++++++++++++---- .../SignUpEmailVerificationStrategyTest.kt | 3 ++ .../clerk/api/sso/SSOManagerActivityTest.kt | 36 +++++++++++++++++-- 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/source/api/src/main/kotlin/com/clerk/api/sso/SSOManagerActivity.kt b/source/api/src/main/kotlin/com/clerk/api/sso/SSOManagerActivity.kt index 3f3f37ad1..a40b9bf11 100644 --- a/source/api/src/main/kotlin/com/clerk/api/sso/SSOManagerActivity.kt +++ b/source/api/src/main/kotlin/com/clerk/api/sso/SSOManagerActivity.kt @@ -32,6 +32,7 @@ internal class SSOManagerActivity : AppCompatActivity() { private var authorizationStarted = false private var completionStarted = false private lateinit var desiredUri: Uri + private var pendingCallbackUri: Uri? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -44,11 +45,12 @@ internal class SSOManagerActivity : AppCompatActivity() { override fun onResume() { super.onResume() - val callbackUri = intent.data?.takeIf(::isCallbackUri) + val callbackUri = pendingCallbackUri ?: intent.data?.takeIf(::isCallbackUri) if (callbackUri != null) { if (!completionStarted) { completionStarted = true authorizationStarted = true + pendingCallbackUri = callbackUri intent = Intent(intent).apply { data = null } authorizationComplete(callbackUri) } @@ -76,7 +78,7 @@ internal class SSOManagerActivity : AppCompatActivity() { intent.data?.let { if (!completionStarted) { completionStarted = true - // Clear the intent's data to avoid re-triggering completion on subsequent resumes + pendingCallbackUri = it intent = Intent(intent).apply { data = null } authorizationComplete(it) } @@ -97,6 +99,10 @@ internal class SSOManagerActivity : AppCompatActivity() { super.onSaveInstanceState(outState) outState.putBoolean(KEY_AUTHORIZATION_STARTED, authorizationStarted) outState.putBoolean(KEY_COMPLETION_STARTED, completionStarted) + outState.putString(KEY_PENDING_CALLBACK_URI, pendingCallbackUri?.toString()) + if (::desiredUri.isInitialized) { + outState.putString(URI_KEY, desiredUri.toString()) + } } /** @@ -110,6 +116,7 @@ internal class SSOManagerActivity : AppCompatActivity() { authorizationStarted = state.getBoolean(KEY_AUTHORIZATION_STARTED, false) completionStarted = state.getBoolean(KEY_COMPLETION_STARTED, false) state.getString(URI_KEY)?.let { desiredUri = it.toUri() } + pendingCallbackUri = state.getString(KEY_PENDING_CALLBACK_URI)?.toUri() } /** @@ -120,11 +127,17 @@ internal class SSOManagerActivity : AppCompatActivity() { private fun authorizationComplete(uri: Uri) { lifecycleScope.launch { try { - // Mark the Activity result as success so callers don't observe RESULT_CANCELED - setResult(RESULT_OK, Intent()) if (canHandleNativeMagicLink(uri)) { ClerkLog.d("authorizationComplete called with native magic link redirect: $uri") - NativeMagicLinkService.handleMagicLinkDeepLink(uri) + when (NativeMagicLinkService.handleMagicLinkDeepLink(uri)) { + is com.clerk.api.network.serialization.ClerkResult.Success -> { + pendingCallbackUri = null + setResult(RESULT_OK, Intent()) + } + is com.clerk.api.network.serialization.ClerkResult.Failure -> { + setResult(RESULT_CANCELED, Intent()) + } + } return@launch } if (SSOService.hasPendingExternalAccountConnection()) { @@ -134,8 +147,12 @@ internal class SSOManagerActivity : AppCompatActivity() { ClerkLog.d("authorizationComplete called with redirect: $uri") SSOService.completeAuthenticateWithRedirect(uri) } + pendingCallbackUri = null + setResult(RESULT_OK, Intent()) + } catch (t: Throwable) { + ClerkLog.e("authorizationComplete failed: ${t.message}") + setResult(RESULT_CANCELED, Intent()) } finally { - // Finish only after the completion call returns so coroutines aren't cancelled early finish() } } @@ -192,5 +209,6 @@ internal class SSOManagerActivity : AppCompatActivity() { internal const val URI_KEY = "uri" internal const val KEY_COMPLETION_STARTED = "completion_started" + internal const val KEY_PENDING_CALLBACK_URI = "pending_callback_uri" } } diff --git a/source/api/src/test/java/com/clerk/api/signup/SignUpEmailVerificationStrategyTest.kt b/source/api/src/test/java/com/clerk/api/signup/SignUpEmailVerificationStrategyTest.kt index 61e88a2f1..f1549b125 100644 --- a/source/api/src/test/java/com/clerk/api/signup/SignUpEmailVerificationStrategyTest.kt +++ b/source/api/src/test/java/com/clerk/api/signup/SignUpEmailVerificationStrategyTest.kt @@ -29,17 +29,20 @@ class SignUpEmailVerificationStrategyTest { private val mockSignUpApi = mockk(relaxed = true) private val environment = mockk(relaxed = true) private val userSettings = mockk(relaxed = true) + private var previousEnvironment: Environment? = null @Before fun setUp() { mockkObject(ClerkApi) every { ClerkApi.signUp } returns mockSignUpApi every { environment.userSettings } returns userSettings + previousEnvironment = runCatching { Clerk.environment }.getOrNull() Clerk.environment = environment } @After fun tearDown() { + previousEnvironment?.let { Clerk.environment = it } unmockkAll() } diff --git a/source/api/src/test/java/com/clerk/api/sso/SSOManagerActivityTest.kt b/source/api/src/test/java/com/clerk/api/sso/SSOManagerActivityTest.kt index 1b6ab0e71..68a67e001 100644 --- a/source/api/src/test/java/com/clerk/api/sso/SSOManagerActivityTest.kt +++ b/source/api/src/test/java/com/clerk/api/sso/SSOManagerActivityTest.kt @@ -4,12 +4,15 @@ import android.app.Activity import android.app.Application import android.net.Uri import androidx.test.core.app.ApplicationProvider +import com.clerk.api.magiclink.NativeMagicLinkService import io.mockk.coEvery import io.mockk.coJustRun import io.mockk.coVerify import io.mockk.every import io.mockk.mockkObject +import io.mockk.unmockkAll import kotlinx.coroutines.CompletableDeferred +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test @@ -21,6 +24,11 @@ import org.robolectric.Shadows @RunWith(RobolectricTestRunner::class) class SSOManagerActivityTest { + @After + fun tearDown() { + unmockkAll() + } + @Before fun setup() { // Ensure AppCompat theme for AppCompatActivity @@ -122,8 +130,7 @@ class SSOManagerActivityTest { val activity = controller.create().resume().get() val shadow = Shadows.shadowOf(activity) - // Result is set to OK before the service call - assertEquals(Activity.RESULT_OK, shadow.resultCode) + assertEquals(Activity.RESULT_CANCELED, shadow.resultCode) } finally { Thread.setDefaultUncaughtExceptionHandler(originalHandler) } @@ -183,4 +190,29 @@ class SSOManagerActivityTest { // Release the gate so activity can finish gate.complete(Unit) } + + @Test + fun nativeMagicLinkFailure_setsResultCanceled() { + mockkObject(NativeMagicLinkService) + coEvery { NativeMagicLinkService.handleMagicLinkDeepLink(any()) } returns + com.clerk.api.network.serialization.ClerkResult.apiFailure( + com.clerk.api.magiclink.NativeMagicLinkError( + reasonCode = "native_magic_link_complete_failed" + ) + ) + + val app = ApplicationProvider.getApplicationContext() + val responseUri = + Uri.parse("clerk://com.clerk.test.oauth?flow_id=flow_123&approval_token=approval_123") + val intent = + SSOManagerActivity.createResponseHandlingIntent(app, responseUri).apply { + putExtra(com.clerk.api.Constants.Storage.KEY_AUTHORIZATION_STARTED, true) + } + + val controller = Robolectric.buildActivity(SSOManagerActivity::class.java, intent) + val activity = controller.create().resume().get() + + val shadow = Shadows.shadowOf(activity) + assertEquals(Activity.RESULT_CANCELED, shadow.resultCode) + } } From d1f171ceb43784e248ec9ca64a58a16fda2cfb54 Mon Sep 17 00:00:00 2001 From: Sam Wolfand Date: Fri, 27 Mar 2026 21:28:14 -0700 Subject: [PATCH 11/17] fix(ui): prefer email-link factor and match factors by identifier Swap resolution order so email-link takes priority over a prepared email-code factor, preventing the code screen from appearing when an email-link flow is available. Factor matching now compares identifiers (emailAddressId, phoneNumberId, etc.) in addition to strategy, so a factor with a different identifier is correctly treated as unsupported. --- .../clerk/ui/signin/SignInFactorOneView.kt | 4 ++-- .../signin/code/SignInFactorCodeViewModel.kt | 18 ++++++++++++-- .../ui/signin/SignInFactorOneViewTest.kt | 24 +++++++++++++++++++ .../code/SignInFactorCodeViewModelTest.kt | 20 ++++++++++++++++ 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/source/ui/src/main/java/com/clerk/ui/signin/SignInFactorOneView.kt b/source/ui/src/main/java/com/clerk/ui/signin/SignInFactorOneView.kt index 43979da99..2ca890bda 100644 --- a/source/ui/src/main/java/com/clerk/ui/signin/SignInFactorOneView.kt +++ b/source/ui/src/main/java/com/clerk/ui/signin/SignInFactorOneView.kt @@ -66,8 +66,8 @@ internal fun resolveFirstFactor(fallback: Factor): Factor { return if (!hasSignInContext) { fallback } else { - preparedFactor - ?: emailLinkFactor + emailLinkFactor + ?: preparedFactor ?: if (fallbackIsSupported) fallback else currentSignIn.startingFirstFactor ?: fallback } } diff --git a/source/ui/src/main/java/com/clerk/ui/signin/code/SignInFactorCodeViewModel.kt b/source/ui/src/main/java/com/clerk/ui/signin/code/SignInFactorCodeViewModel.kt index ad5e00c34..056787f48 100644 --- a/source/ui/src/main/java/com/clerk/ui/signin/code/SignInFactorCodeViewModel.kt +++ b/source/ui/src/main/java/com/clerk/ui/signin/code/SignInFactorCodeViewModel.kt @@ -150,9 +150,9 @@ internal class SignInFactorCodeViewModel( ) return if (isSecondFactor) { - signIn.supportedSecondFactors?.none { it.strategy == factor.strategy } == true + signIn.supportedSecondFactors?.none { it.matches(factor) } == true } else { - supportedFirstFactors.none { it.strategy == factor.strategy } || prefersEmailLinkOverEmailCode + supportedFirstFactors.none { it.matches(factor) } || prefersEmailLinkOverEmailCode } } @@ -173,4 +173,18 @@ internal class SignInFactorCodeViewModel( } return isEmailIdentifier } + + private fun Factor.matches(other: Factor): Boolean { + if (strategy != other.strategy) return false + + return when { + emailAddressId != null || other.emailAddressId != null -> + emailAddressId == other.emailAddressId + phoneNumberId != null || other.phoneNumberId != null -> phoneNumberId == other.phoneNumberId + web3WalletId != null || other.web3WalletId != null -> web3WalletId == other.web3WalletId + safeIdentifier != null || other.safeIdentifier != null -> + safeIdentifier == other.safeIdentifier + else -> true + } + } } diff --git a/source/ui/src/test/java/com/clerk/ui/signin/SignInFactorOneViewTest.kt b/source/ui/src/test/java/com/clerk/ui/signin/SignInFactorOneViewTest.kt index 2bf88eb95..ce5dea2bd 100644 --- a/source/ui/src/test/java/com/clerk/ui/signin/SignInFactorOneViewTest.kt +++ b/source/ui/src/test/java/com/clerk/ui/signin/SignInFactorOneViewTest.kt @@ -63,4 +63,28 @@ class SignInFactorOneViewTest { assertEquals(fallback, resolved) } + + @Test + fun resolveFirstFactorShouldPreferEmailLinkOverPreparedEmailCodeForEmailIdentifier() { + every { mockAuth.currentSignIn } returns + SignIn( + id = "sign_in_123", + identifier = "sam@clerk.dev", + supportedFirstFactors = + listOf( + Factor(strategy = StrategyKeys.EMAIL_CODE, emailAddressId = "email_123"), + Factor(strategy = StrategyKeys.EMAIL_LINK, emailAddressId = "email_123"), + ), + firstFactorVerification = + com.clerk.api.network.model.verification.Verification( + status = com.clerk.api.network.model.verification.Verification.Status.UNVERIFIED, + strategy = StrategyKeys.EMAIL_CODE, + ), + ) + + val resolved = + resolveFirstFactor(Factor(strategy = StrategyKeys.EMAIL_CODE, emailAddressId = "email_123")) + + assertEquals(StrategyKeys.EMAIL_LINK, resolved.strategy) + } } diff --git a/source/ui/src/test/java/com/clerk/ui/signin/code/SignInFactorCodeViewModelTest.kt b/source/ui/src/test/java/com/clerk/ui/signin/code/SignInFactorCodeViewModelTest.kt index 946fb34f3..59fe02af0 100644 --- a/source/ui/src/test/java/com/clerk/ui/signin/code/SignInFactorCodeViewModelTest.kt +++ b/source/ui/src/test/java/com/clerk/ui/signin/code/SignInFactorCodeViewModelTest.kt @@ -430,4 +430,24 @@ class SignInFactorCodeViewModelTest { coVerify(exactly = 0) { mockPrepareHandler.prepareForEmailCode(any(), any(), any(), any()) } } + + @Test + fun prepareShouldRerouteWhenMatchingStrategyHasDifferentIdentifier() = runTest { + every { mockSignIn.identifier } returns null + every { mockSignIn.supportedFirstFactors } returns + listOf(Factor(strategy = StrategyKeys.EMAIL_CODE, emailAddressId = "email_456")) + val factor = Factor(strategy = StrategyKeys.EMAIL_CODE, emailAddressId = "email_123") + + viewModel.state.test { + assertEquals(AuthenticationViewState.Idle, awaitItem()) + + viewModel.prepare(factor, isSecondFactor = false) + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(AuthenticationViewState.Loading, awaitItem()) + assertEquals(AuthenticationViewState.Success.SignIn(mockSignIn), awaitItem()) + } + + coVerify(exactly = 0) { mockPrepareHandler.prepareForEmailCode(any(), any(), any(), any()) } + } } From 297eb8c73fd50f8da7222b1cc20279db9a5dab28 Mon Sep 17 00:00:00 2001 From: Sam Wolfand Date: Fri, 27 Mar 2026 21:28:27 -0700 Subject: [PATCH 12/17] fix(ui): propagate second-factor preparation errors to UI Second-factor prepare failures for phone-code and email-code were silently logged. Now they call onError with the error message so the UI can display a meaningful error to the user. --- .../ui/signin/code/SignInPrepareHandler.kt | 10 ++++- .../signin/code/SignInPrepareHandlerTest.kt | 38 ++++++++++++++++--- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/source/ui/src/main/java/com/clerk/ui/signin/code/SignInPrepareHandler.kt b/source/ui/src/main/java/com/clerk/ui/signin/code/SignInPrepareHandler.kt index cc39c6231..80eb82676 100644 --- a/source/ui/src/main/java/com/clerk/ui/signin/code/SignInPrepareHandler.kt +++ b/source/ui/src/main/java/com/clerk/ui/signin/code/SignInPrepareHandler.kt @@ -109,7 +109,10 @@ internal class SignInPrepareHandler { inProgressSignIn .prepareSecondFactor(phoneNumberId) .onSuccess { ClerkLog.v("Successfully prepared second factor for phone code") } - .onFailure { ClerkLog.e("Error preparing second factor for phone code: $it") } + .onFailure { + onError(it.errorMessage) + ClerkLog.e("Error preparing second factor for phone code: $it") + } } } @@ -155,7 +158,10 @@ internal class SignInPrepareHandler { inProgressSignIn .prepareSecondFactor(emailAddressId = emailAddressId) .onSuccess { ClerkLog.v("Successfully prepared second factor for email code") } - .onFailure { ClerkLog.e("Error preparing second factor for email code: $it") } + .onFailure { + onError(it.errorMessage) + ClerkLog.e("Error preparing second factor for email code: $it") + } } } diff --git a/source/ui/src/test/java/com/clerk/ui/signin/code/SignInPrepareHandlerTest.kt b/source/ui/src/test/java/com/clerk/ui/signin/code/SignInPrepareHandlerTest.kt index 979ddd6c2..7c12ee94c 100644 --- a/source/ui/src/test/java/com/clerk/ui/signin/code/SignInPrepareHandlerTest.kt +++ b/source/ui/src/test/java/com/clerk/ui/signin/code/SignInPrepareHandlerTest.kt @@ -82,15 +82,28 @@ class SignInPrepareHandlerTest { @Test fun prepareForEmailCodeAsSecondFactorShouldHandleFailureGracefully() = runTest { val factor = Factor(strategy = "email_code", emailAddressId = "email_123") - val errorResponse = mockk() + val errorResponse = + ClerkErrorResponse( + errors = + listOf( + ClerkApiError(message = "Short", longMessage = "Email second factor failed", code = "x") + ), + clerkTraceId = null, + ) val failureResult = ClerkResult.apiFailure(errorResponse) + var capturedMessage: String? = null coEvery { mockSignIn.prepareSecondFactor(emailAddressId = "email_123") } returns failureResult - // This should not throw an exception - the handler logs but doesn't propagate errors - handler.prepareForEmailCode(mockSignIn, factor, isSecondFactor = true, onError = {}) + handler.prepareForEmailCode( + mockSignIn, + factor, + isSecondFactor = true, + onError = { capturedMessage = it }, + ) coVerify { mockSignIn.prepareSecondFactor(emailAddressId = "email_123") } + assert(capturedMessage == "Email second factor failed") } @Test @@ -128,15 +141,28 @@ class SignInPrepareHandlerTest { @Test fun prepareForPhoneCodeAsSecondFactorShouldHandleFailureGracefully() = runTest { val factor = Factor(strategy = "phone_code", phoneNumberId = "phone_789") - val errorResponse = mockk() + val errorResponse = + ClerkErrorResponse( + errors = + listOf( + ClerkApiError(message = "Short", longMessage = "Phone second factor failed", code = "x") + ), + clerkTraceId = null, + ) val failureResult = ClerkResult.apiFailure(errorResponse) + var capturedMessage: String? = null coEvery { mockSignIn.prepareSecondFactor("phone_789") } returns failureResult - // This should not throw an exception - the handler logs but doesn't propagate errors - handler.prepareForPhoneCode(mockSignIn, factor, isSecondFactor = true, onError = {}) + handler.prepareForPhoneCode( + mockSignIn, + factor, + isSecondFactor = true, + onError = { capturedMessage = it }, + ) coVerify { mockSignIn.prepareSecondFactor("phone_789") } + assert(capturedMessage == "Phone second factor failed") } @Test From 78062bb942c4e7aa1155dada3a66824ba1f15895 Mon Sep 17 00:00:00 2001 From: Sam Wolfand Date: Fri, 27 Mar 2026 21:28:40 -0700 Subject: [PATCH 13/17] fix(ui): resolve user button session state more robustly Use an effectiveSession that falls back to Clerk.session when the StateFlow hasn't emitted yet, and derive the displayed user from the active session rather than relying on separate Clerk.activeUser state. This prevents the button from flickering or disappearing when session state is still propagating after sign-in. --- .../main/java/com/clerk/ui/userbutton/UserButton.kt | 13 +++++++++---- .../clerk/ui/userbutton/UserButtonBehaviorTest.kt | 6 +----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/source/ui/src/main/java/com/clerk/ui/userbutton/UserButton.kt b/source/ui/src/main/java/com/clerk/ui/userbutton/UserButton.kt index 4334a2733..22fad977b 100644 --- a/source/ui/src/main/java/com/clerk/ui/userbutton/UserButton.kt +++ b/source/ui/src/main/java/com/clerk/ui/userbutton/UserButton.kt @@ -30,6 +30,7 @@ import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade import com.clerk.api.Clerk +import com.clerk.api.session.Session import com.clerk.api.session.requiresForcedMfa import com.clerk.api.ui.ClerkTheme import com.clerk.api.user.User @@ -68,14 +69,18 @@ fun UserButton( TelemetryProvider { val session by Clerk.sessionFlow.collectAsStateWithLifecycle() val sessionUser by Clerk.userFlow.collectAsStateWithLifecycle() + val effectiveSession = session ?: Clerk.session val resolved = resolveUserButtonState( - sessionExists = session != null || Clerk.session != null, - sessionUser = sessionUser, - activeUser = Clerk.activeUser ?: Clerk.user, + sessionExists = effectiveSession != null, + sessionUser = sessionUser ?: effectiveSession?.user, + activeUser = + effectiveSession?.takeIf { it.status == Session.SessionStatus.ACTIVE }?.user + ?: Clerk.activeUser + ?: Clerk.user, treatPendingAsSignedOut = treatPendingAsSignedOut, ) - val requiresForcedMfa = session?.requiresForcedMfa == true + val requiresForcedMfa = effectiveSession?.requiresForcedMfa == true val user = resolved.user val shouldShowButton = resolved.shouldShowButton val telemetry = LocalTelemetryCollector.current diff --git a/source/ui/src/test/java/com/clerk/ui/userbutton/UserButtonBehaviorTest.kt b/source/ui/src/test/java/com/clerk/ui/userbutton/UserButtonBehaviorTest.kt index c30605b72..7ce99df61 100644 --- a/source/ui/src/test/java/com/clerk/ui/userbutton/UserButtonBehaviorTest.kt +++ b/source/ui/src/test/java/com/clerk/ui/userbutton/UserButtonBehaviorTest.kt @@ -60,11 +60,7 @@ class UserButtonBehaviorTest { @Test fun `shouldShowUserButton hides when only session user exists and pending is treated as signed out`() { assertFalse( - shouldShowUserButton( - hasSession = false, - hasActiveUser = false, - treatPendingAsSignedOut = true, - ) + shouldShowUserButton(hasSession = true, hasActiveUser = false, treatPendingAsSignedOut = true) ) } } From 4d380e8f7a62cbd81eab593a889d5f142e131587 Mon Sep 17 00:00:00 2001 From: Sam Wolfand Date: Tue, 31 Mar 2026 17:20:35 -0700 Subject: [PATCH 14/17] fix(android): use saved workbench config for native magic links Make the workbench initialize Clerk from the saved publishable key and optional proxy URL instead of a baked-in local instance. This keeps native email-link flows on the same backend the user configured in settings. Also align native email-link redirects with the callback host and enrich magic-link logs with safe runtime context so approval-token failures can be diagnosed without exposing tokens. --- .../api/magiclink/NativeMagicLinkManager.kt | 55 +++++++++++++++++-- .../clerk/api/sso/RedirectConfiguration.kt | 2 +- .../java/com/clerk/workbench/MainActivity.kt | 40 +++++++++++--- .../com/clerk/workbench/PreferencesManager.kt | 3 +- .../clerk/workbench/WorkbenchApplication.kt | 3 +- 5 files changed, 87 insertions(+), 16 deletions(-) diff --git a/source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkManager.kt b/source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkManager.kt index d5585409e..b9f35708e 100644 --- a/source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkManager.kt +++ b/source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkManager.kt @@ -17,6 +17,7 @@ import com.clerk.api.signup.SignUp import com.clerk.api.sso.RedirectConfiguration import com.clerk.api.storage.StorageHelper import com.clerk.api.storage.StorageKey +import java.net.URL import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.Serializable @@ -489,18 +490,64 @@ internal fun ClerkResult.Failure.toNativeMagicLinkError( internal object NativeMagicLinkLogger { fun start() { - ClerkLog.i("event=native_magic_link_start") + ClerkLog.i("event=native_magic_link_start context={${runtimeContext()}}") } fun deepLinkReceived(uri: Uri) { - ClerkLog.i("event=native_magic_link_deeplink_received uri_shape={${SafeUriLog.describe(uri)}}") + ClerkLog.i( + "event=native_magic_link_deeplink_received uri_shape={${SafeUriLog.describe(uri)}} context={${runtimeContext()}}" + ) } fun completeSuccess() { - ClerkLog.i("event=native_magic_link_complete_success") + ClerkLog.i("event=native_magic_link_complete_success context={${runtimeContext()}}") } fun completeFailure(reasonCode: String) { - ClerkLog.w("event=native_magic_link_complete_failure reason_code=$reasonCode") + ClerkLog.w( + "event=native_magic_link_complete_failure reason_code=$reasonCode context={${runtimeContext()}}" + ) + } + + private fun runtimeContext(): String { + val baseUrl = runCatching { Clerk.baseUrl }.getOrNull() + val proxyUrl = Clerk.proxyUrl + val redirectUri = + Clerk.applicationId?.let { + RedirectConfiguration.emailLinkRedirectUrl(applicationId = it, proxyUrl = proxyUrl) + } + + return buildString { + append("app_id=") + append(Clerk.applicationId ?: "-") + append(", base=") + append(describeUrl(baseUrl)) + append(", proxy=") + append(describeUrl(proxyUrl)) + append(", redirect=") + append(redirectUri ?: "-") + } + } + + private fun describeUrl(value: String?): String { + return if (value.isNullOrBlank()) { + "-" + } else { + val parsed = runCatching { URL(value) }.getOrNull() + if (parsed == null) { + value + } else { + val port = parsed.port.takeIf { it > 0 } ?: parsed.defaultPort + buildString { + append(parsed.protocol) + append("://") + append(parsed.host) + if (port > 0) { + append(":") + append(port) + } + } + } + } } } diff --git a/source/api/src/main/kotlin/com/clerk/api/sso/RedirectConfiguration.kt b/source/api/src/main/kotlin/com/clerk/api/sso/RedirectConfiguration.kt index df62b49d0..554afbcf0 100644 --- a/source/api/src/main/kotlin/com/clerk/api/sso/RedirectConfiguration.kt +++ b/source/api/src/main/kotlin/com/clerk/api/sso/RedirectConfiguration.kt @@ -42,7 +42,7 @@ internal object RedirectConfiguration { proxyUrl: String? = Clerk.proxyUrl, ): String { val portSuffix = resolveNonDefaultHttpsPort(proxyUrl) - return "$SCHEME://$applicationId.$LEGACY_HOST_SUFFIX$portSuffix" + return "$SCHEME://$applicationId.$DEFAULT_HOST_SUFFIX$portSuffix" } private fun buildRedirectUrl(hostSuffix: String): String { diff --git a/workbench/src/main/java/com/clerk/workbench/MainActivity.kt b/workbench/src/main/java/com/clerk/workbench/MainActivity.kt index e2a106ddf..45a02433c 100644 --- a/workbench/src/main/java/com/clerk/workbench/MainActivity.kt +++ b/workbench/src/main/java/com/clerk/workbench/MainActivity.kt @@ -54,11 +54,19 @@ class MainActivity : ComponentActivity() { val context = LocalContext.current WorkbenchTheme { MainContent( - onSave = { - StorageHelper.saveValue(StorageKey.PUBLIC_KEY, it) + onSave = { publishableKey, proxyUrl -> + StorageHelper.saveValue(StorageKey.PUBLIC_KEY, publishableKey) + if (proxyUrl.isBlank()) { + StorageHelper.deleteValue(StorageKey.PROXY_URL) + } else { + StorageHelper.saveValue(StorageKey.PROXY_URL, proxyUrl) + } ProcessPhoenix.triggerRebirth(context) }, - onClear = { StorageHelper.deleteValue(StorageKey.PUBLIC_KEY) }, + onClear = { + StorageHelper.deleteValue(StorageKey.PUBLIC_KEY) + StorageHelper.deleteValue(StorageKey.PROXY_URL) + }, onClickFirstItem = { context.startActivity(Intent(context, UiActivity1::class.java)) }, onClickSecondItem = { context.startActivity(Intent(context, UiActivity2::class.java)) }, ) @@ -71,7 +79,7 @@ class MainActivity : ComponentActivity() { @Composable private fun MainContent( onClear: () -> Unit, - onSave: (String) -> Unit, + onSave: (String, String) -> Unit, onClickFirstItem: () -> Unit, onClickSecondItem: () -> Unit, ) { @@ -107,9 +115,9 @@ private fun MainContent( if (showBottomSheet) { ModalBottomSheet(onDismissRequest = { showBottomSheet = false }) { SettingsBottomSheetContent( - onSave = { publishableKey -> + onSave = { publishableKey, proxyUrl -> showBottomSheet = false - onSave(publishableKey) + onSave(publishableKey, proxyUrl) }, onClear = onClear, ) @@ -195,9 +203,11 @@ private fun ClickableTestItem(text: String, onClick: () -> Unit) { } @Composable -private fun SettingsBottomSheetContent(onSave: (String) -> Unit, onClear: () -> Unit) { +private fun SettingsBottomSheetContent(onSave: (String, String) -> Unit, onClear: () -> Unit) { val publicKey = StorageHelper.loadValue(StorageKey.PUBLIC_KEY) ?: "" + val savedProxyUrl = StorageHelper.loadValue(StorageKey.PROXY_URL) ?: "" var publishableKey by remember { mutableStateOf(publicKey) } + var proxyUrl by remember { mutableStateOf(savedProxyUrl) } Column( modifier = @@ -227,13 +237,23 @@ private fun SettingsBottomSheetContent(onSave: (String) -> Unit, onClear: () -> singleLine = true, ) Spacer(modifier = Modifier.height(Spacing.medium)) - Button(onClick = { onSave(publishableKey) }, modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = proxyUrl, + onValueChange = { proxyUrl = it }, + label = { Text(WorkbenchConstants.PROXY_URL_LABEL) }, + placeholder = { Text(WorkbenchConstants.PROXY_URL_PLACEHOLDER) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Spacer(modifier = Modifier.height(Spacing.medium)) + Button(onClick = { onSave(publishableKey, proxyUrl) }, modifier = Modifier.fillMaxWidth()) { Text(WorkbenchConstants.SAVE_BUTTON_TEXT) } Button( modifier = Modifier.fillMaxWidth(), onClick = { publishableKey = "" + proxyUrl = "" onClear() }, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), @@ -248,9 +268,11 @@ private object WorkbenchConstants { const val APP_TITLE = "Clerk Workbench" const val INSTRUCTIONS_TITLE = "Instructions:" const val SETTINGS_TITLE = "Settings" - const val SETTINGS_DESCRIPTION = "Please enter your publishable key" + const val SETTINGS_DESCRIPTION = "Please enter your publishable key and optional proxy URL" const val PUBLISHABLE_KEY_LABEL = "Publishable Key" const val PUBLISHABLE_KEY_PLACEHOLDER = "Enter publishable key" + const val PROXY_URL_LABEL = "Proxy URL" + const val PROXY_URL_PLACEHOLDER = "Enter proxy URL for local/dev instances" const val SAVE_BUTTON_TEXT = "Save" val instructionSteps = diff --git a/workbench/src/main/java/com/clerk/workbench/PreferencesManager.kt b/workbench/src/main/java/com/clerk/workbench/PreferencesManager.kt index 05fc92106..21182085d 100644 --- a/workbench/src/main/java/com/clerk/workbench/PreferencesManager.kt +++ b/workbench/src/main/java/com/clerk/workbench/PreferencesManager.kt @@ -41,5 +41,6 @@ internal object StorageHelper { } internal enum class StorageKey { - PUBLIC_KEY + PUBLIC_KEY, + PROXY_URL, } diff --git a/workbench/src/main/java/com/clerk/workbench/WorkbenchApplication.kt b/workbench/src/main/java/com/clerk/workbench/WorkbenchApplication.kt index cf2f62bd7..166913cfb 100644 --- a/workbench/src/main/java/com/clerk/workbench/WorkbenchApplication.kt +++ b/workbench/src/main/java/com/clerk/workbench/WorkbenchApplication.kt @@ -11,6 +11,7 @@ class WorkbenchApplication : Application() { StorageHelper.initialize(this) val publicKey = StorageHelper.loadValue(StorageKey.PUBLIC_KEY) + val proxyUrl = StorageHelper.loadValue(StorageKey.PROXY_URL) publicKey?.let { key -> Clerk.initialize( this, @@ -18,7 +19,7 @@ class WorkbenchApplication : Application() { options = ClerkConfigurationOptions( enableDebugMode = true, - proxyUrl = "https://rapid-earwig-10.clerk.accounts.lclclerk.com:8443", + proxyUrl = proxyUrl?.takeIf(String::isNotBlank), ), ) } From a71f1142991a35b6a7b8eaa96c65b12fe3468ee7 Mon Sep 17 00:00:00 2001 From: Sam Wolfand Date: Tue, 31 Mar 2026 17:24:15 -0700 Subject: [PATCH 15/17] fix(ui): expose accurate identifier autofill hints Stop marking the auth identifier field as email-only in all cases. When both email and username are enabled, advertise both content types so Compose semantics match the actual accepted identifiers. Add focused helper tests for the email-only, username-only, and mixed identifier cases. --- .../java/com/clerk/ui/auth/AuthStartView.kt | 2 +- .../com/clerk/ui/auth/AuthStartViewHelper.kt | 9 ++++++ .../clerk/ui/auth/AuthStartViewHelperTest.kt | 29 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/source/ui/src/main/java/com/clerk/ui/auth/AuthStartView.kt b/source/ui/src/main/java/com/clerk/ui/auth/AuthStartView.kt index a96e64cad..80f5c1145 100644 --- a/source/ui/src/main/java/com/clerk/ui/auth/AuthStartView.kt +++ b/source/ui/src/main/java/com/clerk/ui/auth/AuthStartView.kt @@ -251,7 +251,7 @@ private fun AuthInputField( } else { LastUsedAuthBadgeOverlay(isVisible = showEmailUsernameBadge) { ClerkTextField( - inputContentType = ContentType.EmailAddress, + inputContentType = authViewHelper.identifierContentType(), value = authStartIdentifier, onValueChange = onIdentifierChange, label = authViewHelper.emailOrUsernamePlaceholder(), diff --git a/source/ui/src/main/java/com/clerk/ui/auth/AuthStartViewHelper.kt b/source/ui/src/main/java/com/clerk/ui/auth/AuthStartViewHelper.kt index 617bba401..b54379428 100644 --- a/source/ui/src/main/java/com/clerk/ui/auth/AuthStartViewHelper.kt +++ b/source/ui/src/main/java/com/clerk/ui/auth/AuthStartViewHelper.kt @@ -3,6 +3,7 @@ package com.clerk.ui.auth import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import com.clerk.api.Clerk @@ -82,6 +83,14 @@ internal class AuthStartViewHelper { } } + fun identifierContentType(): ContentType { + return when { + emailIsEnabled && usernameIsEnabled -> ContentType.EmailAddress + ContentType.Username + emailIsEnabled -> ContentType.EmailAddress + else -> ContentType.Username + } + } + fun continueIsDisabled( isPhoneNumberFieldActive: Boolean, identifier: String, diff --git a/source/ui/src/test/java/com/clerk/ui/auth/AuthStartViewHelperTest.kt b/source/ui/src/test/java/com/clerk/ui/auth/AuthStartViewHelperTest.kt index 2c3e18c7b..20669d1c2 100644 --- a/source/ui/src/test/java/com/clerk/ui/auth/AuthStartViewHelperTest.kt +++ b/source/ui/src/test/java/com/clerk/ui/auth/AuthStartViewHelperTest.kt @@ -1,6 +1,8 @@ package com.clerk.ui.auth +import androidx.compose.ui.autofill.ContentType import org.junit.Assert.assertFalse +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -55,4 +57,31 @@ class AuthStartViewHelperTest { assertFalse(result) } + + @Test + fun identifierContentTypeReturnsEmailAddressWhenOnlyEmailIsEnabled() { + val helper = AuthStartViewHelper() + helper.setTestValues(enabledFirstFactorAttributes = listOf("email_address")) + + assertEquals(ContentType.EmailAddress, helper.identifierContentType()) + } + + @Test + fun identifierContentTypeReturnsUsernameWhenOnlyUsernameIsEnabled() { + val helper = AuthStartViewHelper() + helper.setTestValues(enabledFirstFactorAttributes = listOf("username")) + + assertEquals(ContentType.Username, helper.identifierContentType()) + } + + @Test + fun identifierContentTypeReturnsEmailAndUsernameWhenBothAreEnabled() { + val helper = AuthStartViewHelper() + helper.setTestValues(enabledFirstFactorAttributes = listOf("email_address", "username")) + + val contentType = helper.identifierContentType() + + assertFalse(contentType == ContentType.EmailAddress) + assertFalse(contentType == ContentType.Username) + } } From dd2653ac5d559f8fc763b7ef244b3a5279a24024 Mon Sep 17 00:00:00 2001 From: Sam Wolfand Date: Tue, 31 Mar 2026 17:24:29 -0700 Subject: [PATCH 16/17] fix(ui): collect sign-up fields before email link verification Prioritize required field collection over verification when a sign-up still has missing fields. This prevents the auth flow from auto-starting a native email link before required inputs like password have been collected. Add a routing regression test that asserts sign-up email-link flows stay on field collection until missing requirements are satisfied. --- .../main/java/com/clerk/ui/auth/AuthState.kt | 8 ++- .../ui/auth/AuthStateSignUpRoutingTest.kt | 62 +++++++++++++++++-- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/source/ui/src/main/java/com/clerk/ui/auth/AuthState.kt b/source/ui/src/main/java/com/clerk/ui/auth/AuthState.kt index 0463a5759..43c58871d 100644 --- a/source/ui/src/main/java/com/clerk/ui/auth/AuthState.kt +++ b/source/ui/src/main/java/com/clerk/ui/auth/AuthState.kt @@ -221,11 +221,15 @@ internal class AuthState( } private fun handleMissingRequirements(signUp: SignUp) { + val firstFieldToCollect = signUp.firstFieldToCollect + if (firstFieldToCollect != null) { + handleFieldCollection(signUp) + return + } + val firstFieldToVerify = signUp.firstFieldToVerify if (firstFieldToVerify != null) { handleFieldVerification(signUp, firstFieldToVerify) - } else { - handleFieldCollection(signUp) } } diff --git a/source/ui/src/test/java/com/clerk/ui/auth/AuthStateSignUpRoutingTest.kt b/source/ui/src/test/java/com/clerk/ui/auth/AuthStateSignUpRoutingTest.kt index 9dcae4fc4..1ab60dc58 100644 --- a/source/ui/src/test/java/com/clerk/ui/auth/AuthStateSignUpRoutingTest.kt +++ b/source/ui/src/test/java/com/clerk/ui/auth/AuthStateSignUpRoutingTest.kt @@ -1,19 +1,39 @@ package com.clerk.ui.auth +import android.content.Context import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey +import androidx.test.core.app.ApplicationProvider import com.clerk.api.Constants import com.clerk.api.network.model.verification.Verification import com.clerk.api.signup.SignUp +import com.clerk.ui.signup.collectfield.CollectField import io.mockk.every import io.mockk.mockk import io.mockk.verify +import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) class AuthStateSignUpRoutingTest { + private lateinit var context: Context private val backStack = mockk>(relaxed = true) - private val authState = AuthState(mode = AuthMode.SignInOrUp, backStack = backStack) + private lateinit var authState: AuthState + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + preferences().edit().clear().commit() + authState = + AuthState( + mode = AuthMode.SignInOrUp, + backStack = backStack, + sharedPreferences = preferences(), + ) + } @Test fun setToStepForStatusRoutesToSignUpEmailLinkWhenEmailVerificationStrategyIsEmailLink() { @@ -61,18 +81,52 @@ class AuthStateSignUpRoutingTest { } } - private fun signUp(verifications: Map): SignUp { + @Test + fun setToStepForStatusCollectsRequiredFieldsBeforeStartingEmailLinkVerification() { + val signUp = + signUp( + verifications = + mapOf( + "email_address" to + Verification( + status = Verification.Status.UNVERIFIED, + strategy = Constants.Strategy.EMAIL_LINK, + ) + ), + missingFields = listOf("password"), + ) + + authState.setToStepForStatus(signUp) {} + + verify(exactly = 1) { + backStack.add(AuthDestination.SignUpCollectField(CollectField.Password)) + } + verify(exactly = 0) { + backStack.add(AuthDestination.SignUpEmailLink(emailAddress = "sam@clerk.dev")) + } + } + + private fun signUp( + verifications: Map, + missingFields: List = emptyList(), + ): SignUp { every { backStack.add(any()) } returns true return SignUp( id = "sign_up_123", status = SignUp.Status.MISSING_REQUIREMENTS, - requiredFields = listOf("email_address"), + requiredFields = listOf("email_address", "password"), optionalFields = emptyList(), - missingFields = emptyList(), + missingFields = missingFields, unverifiedFields = listOf("email_address"), verifications = verifications, emailAddress = "sam@clerk.dev", passwordEnabled = false, ) } + + private fun preferences() = + context.getSharedPreferences( + Constants.Storage.CLERK_PREFERENCES_FILE_NAME, + Context.MODE_PRIVATE, + ) } From 71a2c1db8d8099c315fc13f5164eb4aa2f4a3348 Mon Sep 17 00:00:00 2001 From: Sam Wolfand Date: Tue, 31 Mar 2026 17:54:51 -0700 Subject: [PATCH 17/17] codex: fix CI failure on PR #563 --- .../test/java/com/clerk/api/sso/ExternalAccountServiceTest.kt | 2 ++ .../test/java/com/clerk/api/sso/RedirectConfigurationTest.kt | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/source/api/src/test/java/com/clerk/api/sso/ExternalAccountServiceTest.kt b/source/api/src/test/java/com/clerk/api/sso/ExternalAccountServiceTest.kt index 33cc5d411..bf4681f26 100644 --- a/source/api/src/test/java/com/clerk/api/sso/ExternalAccountServiceTest.kt +++ b/source/api/src/test/java/com/clerk/api/sso/ExternalAccountServiceTest.kt @@ -52,6 +52,7 @@ class ExternalAccountServiceTest { @Before fun setup() { Dispatchers.setMain(testDispatcher) + ExternalAccountService.cancelPendingExternalAccountConnection() // Mock objects mockContext = mockk(relaxed = true) @@ -89,6 +90,7 @@ class ExternalAccountServiceTest { @OptIn(ExperimentalCoroutinesApi::class) @After fun tearDown() { + ExternalAccountService.cancelPendingExternalAccountConnection() Dispatchers.resetMain() unmockkAll() } diff --git a/source/api/src/test/java/com/clerk/api/sso/RedirectConfigurationTest.kt b/source/api/src/test/java/com/clerk/api/sso/RedirectConfigurationTest.kt index 5b8f77dbd..45fc96abd 100644 --- a/source/api/src/test/java/com/clerk/api/sso/RedirectConfigurationTest.kt +++ b/source/api/src/test/java/com/clerk/api/sso/RedirectConfigurationTest.kt @@ -46,7 +46,7 @@ class RedirectConfigurationTest { @Test fun emailLinkRedirectUrl_usesProxyPortWhenConfigured() { assertEquals( - "clerk://com.clerk.workbench.oauth:8443", + "clerk://com.clerk.workbench.callback:8443", RedirectConfiguration.emailLinkRedirectUrl( applicationId = "com.clerk.workbench", proxyUrl = "https://rapid-earwig-10.clerk.accounts.lclclerk.com:8443", @@ -57,7 +57,7 @@ class RedirectConfigurationTest { @Test fun emailLinkRedirectUrl_omitsStandardHttpsPort() { assertEquals( - "clerk://com.clerk.workbench.oauth", + "clerk://com.clerk.workbench.callback", RedirectConfiguration.emailLinkRedirectUrl( applicationId = "com.clerk.workbench", proxyUrl = "https://rapid-earwig-10.clerk.accounts.lclclerk.com:443",