Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions source/api/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
<activity
android:name="com.clerk.api.sso.SSOReceiverActivity"
android:exported="true"
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar">
android:theme="@style/Theme.Clerk.AuthBridge">
<intent-filter>
<action android:name="android.intent.action.VIEW" />

Expand Down
1 change: 1 addition & 0 deletions source/api/src/main/kotlin/com/clerk/api/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
152 changes: 110 additions & 42 deletions source/api/src/main/kotlin/com/clerk/api/auth/Auth.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<SignIn, ClerkErrorResponse> {
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<SignIn, ClerkErrorResponse> {
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<ClerkErrorResponse> {
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",
)
)
)
)
}

/**
Expand Down Expand Up @@ -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<SignIn, NativeMagicLinkError> {
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<SignIn, NativeMagicLinkError> {
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<SignIn, NativeMagicLinkError> {
return nativeMagicLink.complete(flowId, approvalToken)
}

// endregion

// region Sign Up
Expand Down Expand Up @@ -640,30 +703,35 @@ 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.
*
* ### 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
Expand Down
55 changes: 55 additions & 0 deletions source/api/src/main/kotlin/com/clerk/api/log/SafeUriLog.kt
Original file line number Diff line number Diff line change
@@ -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<String> =
runCatching { uri.queryParameterNames.map { it.trim() }.filter { it.isNotEmpty() }.toSet() }
.getOrDefault(emptySet())

private fun fragmentParamKeys(fragment: String?): Set<String> {
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()
}
}
Original file line number Diff line number Diff line change
@@ -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<Unit, NativeMagicLinkError>,
private val refreshClientState: suspend () -> Unit,
) {
suspend fun complete(
flowId: String,
approvalToken: String,
pending: PendingNativeMagicLinkFlow,
): ClerkResult<SignIn, NativeMagicLinkError> {
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<ClerkErrorResponse>
): ClerkResult.Failure<NativeMagicLinkError> {
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<SignIn, NativeMagicLinkError> {
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<SignIn, NativeMagicLinkError> {
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)
Comment on lines +65 to +70
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential null error passed to apiFailure when activationResult.error is null.

Line 67 handles potential null via activationResult.error?.reasonCode, but line 70 passes activationResult.error directly to apiFailure. If the error is null, the caller receives a Failure with null error, which may cause downstream issues.

Proposed fix
   private suspend fun completeAfterTicketSignIn(
     signIn: SignIn
   ): ClerkResult<SignIn, NativeMagicLinkError> {
     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)
+      ClerkResult.apiFailure(
+        activationResult.error ?: NativeMagicLinkError(reasonCode = reasonCode)
+      )
     } else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@source/api/src/main/kotlin/com/clerk/api/magiclink/NativeMagicLinkCompletionRunner.kt`
around lines 65 - 70, The failure branch in NativeMagicLinkCompletionRunner uses
activationResult.error?.reasonCode but then passes activationResult.error
directly to ClerkResult.apiFailure which can be null; update the failure path to
ensure a non-null error is passed to ClerkResult.apiFailure (for example by
constructing a default error object using the computed
reasonCode/NativeMagicLinkReason.SESSION_ACTIVATION_FAILED.code) before calling
NativeMagicLinkLogger.completeFailure and ClerkResult.apiFailure, referencing
activationResult, clearPendingFlow(),
NativeMagicLinkLogger.completeFailure(...), and ClerkResult.apiFailure(...) to
locate where to insert this fallback.

} else {
clearPendingFlow()
refreshClientState()
NativeMagicLinkLogger.completeSuccess()
ClerkResult.success(signIn)
}
}
}
Loading