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/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..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 @@ -14,8 +14,13 @@ 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.magiclink.canHandleNativeMagicLink 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 +131,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,40 +228,71 @@ class Auth internal constructor() { builder.validate() val identifier = builder.email ?: builder.phone!! - val strategy = if (builder.email != null) EMAIL_CODE else PHONE_CODE + val isEmailFlow = builder.email != null + val result = createAndPrepareOtpSignIn(identifier, isEmailFlow) + result.onFailure { emitAuthError(it) } + return result + } - val params = - mapOf( - "identifier" to identifier, - "strategy" to strategy, - "locale" to Clerk.locale.value.orEmpty(), - ) + 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) + } + } - 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()) - } + 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(), + ) } - result.onFailure { emitAuthError(it) } - return result + } 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", + ) + ) + ) + ) } /** @@ -383,6 +423,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 +703,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. @@ -651,19 +714,24 @@ 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 { - // Check if this is a Clerk OAuth callback - val isClerkCallback = uri?.scheme?.startsWith("clerk") == true + suspend fun handle(uri: Uri?): Boolean { + val callbackUri = uri ?: return false + val handledByMagicLink = canHandleNativeMagicLink(callbackUri) + if (handledByMagicLink) { + 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) { + 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..b9f35708e --- /dev/null +++ b/source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkManager.kt @@ -0,0 +1,553 @@ +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 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 +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? + + 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 = + PersistentPendingNativeMagicLinkStore() + + 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 -> { + persistPendingFlow( + createPendingFlow( + codeVerifier = pkcePair.verifier, + state = PendingNativeMagicLinkState.SIGN_IN, + flowId = signIn.id, + ) + ) + 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 -> { + persistPendingFlow( + createPendingFlow( + codeVerifier = pkcePair.verifier, + state = PendingNativeMagicLinkState.SIGN_UP, + flowId = signUpId, + ) + ) + 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 pendingState = + mutex.withLock { + val flow = pendingFlowStore.load() + if (flow != null && flow.expiresAtEpochMs <= currentTimeMillis()) { + pendingFlowStore.clear() + PendingFlowLookup.None + } else if (flow != null && flow.flowId != null && flow.flowId != flowId) { + PendingFlowLookup.Mismatched(flow.flowId) + } else { + flow?.let(PendingFlowLookup::Found) ?: PendingFlowLookup.None + } + } + + val clearPendingFlow: suspend () -> Unit = { mutex.withLock { pendingFlowStore.clear() } } + val completionRunner = + NativeMagicLinkCompletionRunner( + attestationProvider = attestationProvider, + clearPendingFlow = clearPendingFlow, + activateCreatedSession = { signIn -> activateCreatedSession(signIn) }, + refreshClientState = { refreshClientState() }, + ) + 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, + ) + } + } + + 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) + 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) + ) + } + } + } + } +} + +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) + + fun load(): PendingNativeMagicLinkFlow? + + fun clear() +} + +internal class PersistentPendingNativeMagicLinkStore( + private val json: Json = Json { ignoreUnknownKeys = true } +) : PendingNativeMagicLinkStore { + override fun save(flow: PendingNativeMagicLinkFlow) { + StorageHelper.saveValue(StorageKey.PENDING_NATIVE_MAGIC_LINK_FLOW, json.encodeToString(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() { + StorageHelper.deleteValue(StorageKey.PENDING_NATIVE_MAGIC_LINK_FLOW) + } +} + +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( + codeVerifier = codeVerifier, + state = state, + createdAtEpochMs = createdAtEpochMs, + expiresAtEpochMs = createdAtEpochMs + PENDING_FLOW_TTL_MS, + flowId = flowId, + ) +} + +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 { + 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"), + 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"), + 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 context={${runtimeContext()}}") + } + + fun deepLinkReceived(uri: 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 context={${runtimeContext()}}") + } + + fun completeFailure(reasonCode: String) { + 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/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..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 @@ -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.$DEFAULT_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..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 @@ -11,6 +11,8 @@ 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 com.clerk.api.magiclink.canHandleNativeMagicLink import kotlinx.coroutines.launch /** @@ -30,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) @@ -42,6 +45,18 @@ internal class SSOManagerActivity : AppCompatActivity() { override fun onResume() { super.onResume() + 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) + } + return + } + // on first run, launch the intent to start the OAuth/SSO flow in the browser if (!authorizationStarted) { try { @@ -63,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) } @@ -84,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()) + } } /** @@ -97,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() } /** @@ -107,8 +127,19 @@ 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") + 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()) { ClerkLog.d("authorizationComplete called with external connection") SSOService.completeExternalConnection() @@ -116,13 +147,23 @@ 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() } } } + private fun isCallbackUri(uri: Uri): Boolean { + return uri.scheme?.startsWith("clerk") == true || + canHandleNativeMagicLink(uri) || + uri.getQueryParameter("rotating_token_nonce") != null + } + /** Handles authentication cancellation by the user. */ private fun authorizationCanceled() { val response = Intent() @@ -168,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/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/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/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/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) } 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..20ba151c0 --- /dev/null +++ b/source/api/src/test/java/com/clerk/api/magiclink/NativeMagicLinkServiceTest.kt @@ -0,0 +1,288 @@ +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 com.clerk.api.storage.StorageHelper +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 +import org.robolectric.RuntimeEnvironment + +@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() { + StorageHelper.initialize(RuntimeEnvironment.getApplication()) + StorageHelper.reset(RuntimeEnvironment.getApplication()) + + 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() + StorageHelper.reset(RuntimeEnvironment.getApplication()) + 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=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) + + 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"] == "sign_in_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=sign_up_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"] == "sign_up_123" && + it["approval_token"] == "approval_123" && + it["code_verifier"]?.isNotBlank() == true + } + ) + } + 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 = + 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=sign_in_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/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)) + } +} 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..f1549b125 --- /dev/null +++ b/source/api/src/test/java/com/clerk/api/signup/SignUpEmailVerificationStrategyTest.kt @@ -0,0 +1,123 @@ +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) + 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() + } + + @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/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 e99624b1b..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 @@ -42,4 +42,26 @@ class RedirectConfigurationTest { assertEquals("clerk://null.callback", RedirectConfiguration.DEFAULT_REDIRECT_URL) } + + @Test + fun emailLinkRedirectUrl_usesProxyPortWhenConfigured() { + assertEquals( + "clerk://com.clerk.workbench.callback: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.callback", + 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..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 @@ -48,6 +56,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() @@ -104,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) } @@ -165,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) + } } 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/auth/AuthStartView.kt b/source/ui/src/main/java/com/clerk/ui/auth/AuthStartView.kt index 1538badf0..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 @@ -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, @@ -245,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/main/java/com/clerk/ui/auth/AuthState.kt b/source/ui/src/main/java/com/clerk/ui/auth/AuthState.kt index 5a3b19546..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 @@ -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) { @@ -210,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) } } @@ -223,7 +238,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/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) } } 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/main/java/com/clerk/ui/signin/SignInFactorOneView.kt b/source/ui/src/main/java/com/clerk/ui/signin/SignInFactorOneView.kt index f7988a078..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 @@ -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 { + emailLinkFactor + ?: preparedFactor + ?: 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..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 @@ -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,57 @@ 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.matches(factor) } == true + } else { + supportedFirstFactors.none { it.matches(factor) } || 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 + } + + 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/main/java/com/clerk/ui/signin/code/SignInPrepareHandler.kt b/source/ui/src/main/java/com/clerk/ui/signin/code/SignInPrepareHandler.kt index 26b182dd1..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 @@ -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,68 @@ 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") } + .onFailure { + onError(it.errorMessage) + 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 +141,43 @@ 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") } + .onFailure { + onError(it.errorMessage) + 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 +190,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..5c08839a8 --- /dev/null +++ b/source/ui/src/main/java/com/clerk/ui/signin/emaillink/SignInFactorOneEmailLinkView.kt @@ -0,0 +1,147 @@ +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 +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.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( + 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 context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + 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.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(), + configuration = + ClerkButtonDefaults.configuration( + style = ClerkButtonConfiguration.ButtonStyle.Secondary, + emphasis = ClerkButtonConfiguration.Emphasis.High, + ), + ) + Spacers.Vertical.Spacer24() + SignInEmailLinkSecondaryActions( + onResendClick = viewModel::sendLink, + onUseAnotherMethodClick = { + authState.navigateTo( + AuthDestination.SignInFactorOneUseAnotherMethod(currentFactor = factor) + ) + }, + ) + } +} + +@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() { + 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..27e089e6a --- /dev/null +++ b/source/ui/src/main/java/com/clerk/ui/signup/emaillink/SignUpEmailLinkView.kt @@ -0,0 +1,127 @@ +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 +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.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( + 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 context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + 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 = emailAddress, + onClickIdentifier = authState::clearBackStack, + hasLogo = false, + snackbarHostState = snackbarHostState, + ) { + ClerkButton( + 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(), + 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, + ) + } + } +} + +@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/userbutton/UserButton.kt b/source/ui/src/main/java/com/clerk/ui/userbutton/UserButton.kt index c8799bd48..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 @@ -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 @@ -29,8 +30,10 @@ 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 import com.clerk.telemetry.TelemetryEvents import com.clerk.ui.R import com.clerk.ui.auth.AuthView @@ -66,14 +69,26 @@ fun UserButton( TelemetryProvider { val session by Clerk.sessionFlow.collectAsStateWithLifecycle() val sessionUser by Clerk.userFlow.collectAsStateWithLifecycle() - val requiresForcedMfa = session?.requiresForcedMfa == true - val user = if (treatPendingAsSignedOut) Clerk.activeUser else sessionUser + val effectiveSession = session ?: Clerk.session + val resolved = + resolveUserButtonState( + sessionExists = effectiveSession != null, + sessionUser = sessionUser ?: effectiveSession?.user, + activeUser = + effectiveSession?.takeIf { it.status == Session.SessionStatus.ACTIVE }?.user + ?: Clerk.activeUser + ?: Clerk.user, + treatPendingAsSignedOut = treatPendingAsSignedOut, + ) + val requiresForcedMfa = effectiveSession?.requiresForcedMfa == true + 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 +97,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 +121,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 +177,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 +238,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/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/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/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 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) + } } 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..1ab60dc58 --- /dev/null +++ b/source/ui/src/test/java/com/clerk/ui/auth/AuthStateSignUpRoutingTest.kt @@ -0,0 +1,132 @@ +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 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() { + 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") + ) + ) + } + } + + @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", "password"), + optionalFields = 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, + ) +} 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") + ) + } +} 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..ce5dea2bd --- /dev/null +++ b/source/ui/src/test/java/com/clerk/ui/signin/SignInFactorOneViewTest.kt @@ -0,0 +1,90 @@ +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) + } + + @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 0c58d708f..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 @@ -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,109 @@ 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()) } + } + + @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()) } + } } 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..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 @@ -222,7 +248,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 +335,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 +361,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/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..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 @@ -29,10 +29,10 @@ 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, ) @@ -40,13 +40,27 @@ class UserButtonBehaviorTest { } @Test - fun `shouldShowUserButton hides when only session user exists and pending is treated as signed out`() { + fun `shouldShowUserButton hides when no session exists and pending sessions are allowed`() { assertFalse( shouldShowUserButton( - hasSessionUser = true, - hasActiveUser = false, - treatPendingAsSignedOut = true, + 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(hasSession = true, hasActiveUser = false, treatPendingAsSignedOut = true) + ) + } } 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/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/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/UiActivity2.kt b/workbench/src/main/java/com/clerk/workbench/UiActivity2.kt index 902bfd2c4..04ed1875b 100644 --- a/workbench/src/main/java/com/clerk/workbench/UiActivity2.kt +++ b/workbench/src/main/java/com/clerk/workbench/UiActivity2.kt @@ -7,14 +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 @@ -33,16 +38,33 @@ class UiActivity2 : ComponentActivity() { ) setContent { 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, ) { - if (user == null || session?.pendingTaskKey != null) { - AuthView(persistIdentifiers = false) - } else { - UserButton() + when { + !isInitialized -> CircularProgressIndicator() + 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() + } + } } } } diff --git a/workbench/src/main/java/com/clerk/workbench/WorkbenchApplication.kt b/workbench/src/main/java/com/clerk/workbench/WorkbenchApplication.kt index 76acfa757..166913cfb 100644 --- a/workbench/src/main/java/com/clerk/workbench/WorkbenchApplication.kt +++ b/workbench/src/main/java/com/clerk/workbench/WorkbenchApplication.kt @@ -11,8 +11,17 @@ 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, key, options = ClerkConfigurationOptions(enableDebugMode = true)) + Clerk.initialize( + this, + key, + options = + ClerkConfigurationOptions( + enableDebugMode = true, + proxyUrl = proxyUrl?.takeIf(String::isNotBlank), + ), + ) } } }