From af824f7cbb72ebfe2c78798b50470edbad40ff7b Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 17 Sep 2026 12:15:18 +0530 Subject: [PATCH 01/15] feat : Add support for /e/authorize email OTP flow --- .../android/embedded/EmbeddedAuthClient.kt | 252 ++++++++++++------ .../android/embedded/EmbeddedAuthException.kt | 33 +++ .../embedded/authorize/AdvancingRequest.kt | 78 ++++++ .../embedded/authorize/AuthorizeCode.kt | 7 + .../embedded/authorize/EmbeddedAction.kt | 15 ++ .../authorize/EmbeddedAuthAdapters.kt | 92 +++++++ .../embedded/authorize/EmbeddedAuthState.kt | 5 + .../embedded/authorize/FailedRequest.kt | 25 ++ .../android/embedded/authorize/NextAction.kt | 46 ++++ .../embedded/authorize/NextActionMapper.kt | 25 ++ .../android/embedded/authorize/OtpType.kt | 12 + .../android/embedded/authorize/StepRequest.kt | 59 ++++ .../{ => discovery}/DiscoveryMapperTest.kt | 2 +- 13 files changed, 575 insertions(+), 76 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/embedded/authorize/AdvancingRequest.kt create mode 100644 auth0/src/main/java/com/auth0/android/embedded/authorize/AuthorizeCode.kt create mode 100644 auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAction.kt create mode 100644 auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthAdapters.kt create mode 100644 auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthState.kt create mode 100644 auth0/src/main/java/com/auth0/android/embedded/authorize/FailedRequest.kt create mode 100644 auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt create mode 100644 auth0/src/main/java/com/auth0/android/embedded/authorize/NextActionMapper.kt create mode 100644 auth0/src/main/java/com/auth0/android/embedded/authorize/OtpType.kt create mode 100644 auth0/src/main/java/com/auth0/android/embedded/authorize/StepRequest.kt rename auth0/src/test/java/com/auth0/android/embedded/{ => discovery}/DiscoveryMapperTest.kt (99%) diff --git a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt index b9472613..0b757d85 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt @@ -1,23 +1,24 @@ package com.auth0.android.embedded import com.auth0.android.Auth0 -import com.auth0.android.Auth0Exception -import com.auth0.android.NetworkErrorException -import com.auth0.android.embedded.discovery.DiscoveryResponse +import com.auth0.android.embedded.authorize.AdvancingRequest +import com.auth0.android.embedded.authorize.AuthorizeCode +import com.auth0.android.embedded.authorize.EmbeddedAction +import com.auth0.android.embedded.authorize.EmbeddedAuthState +import com.auth0.android.embedded.authorize.FailedRequest +import com.auth0.android.embedded.authorize.OtpType +import com.auth0.android.embedded.authorize.StepRequest +import com.auth0.android.embedded.authorize.authorizeCodeAdapter +import com.auth0.android.embedded.authorize.discoveryAdapter +import com.auth0.android.embedded.authorize.embeddedAuthErrorAdapter import com.auth0.android.embedded.discovery.DiscoveryResult -import com.auth0.android.embedded.discovery.toDiscoveryResult -import com.auth0.android.request.ErrorAdapter -import com.auth0.android.request.JsonAdapter import com.auth0.android.request.Request import com.auth0.android.request.internal.GsonAdapter -import com.auth0.android.request.internal.GsonAdapter.Companion.forMap import com.auth0.android.request.internal.GsonProvider import com.auth0.android.request.internal.RequestFactory -import com.auth0.android.request.internal.ResponseUtils.isNetworkError +import com.auth0.android.result.Credentials import com.google.gson.Gson import okhttp3.HttpUrl.Companion.toHttpUrl -import java.io.IOException -import java.io.Reader /** * API client for Auth0's embedded authentication API. @@ -31,13 +32,15 @@ import java.io.Reader public class EmbeddedAuthClient(private val auth0: Auth0) { private val factory: RequestFactory = - RequestFactory(auth0.networkingClient, createErrorAdapter()) + RequestFactory(auth0.networkingClient, embeddedAuthErrorAdapter()) private val gson: Gson = GsonProvider.gson private val clientId: String get() = auth0.clientId + private var transactionState: EmbeddedAuthState? = null + /** * * Returns the grant types a client can use, derived from the client's enabled grants, @@ -69,76 +72,175 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { return factory.get(url.toString(), discoveryAdapter(gson)) } + /** + * Begins an embedded authorization flow, abandoning any flow already in progress. + * + * This call never resolves successfully: the server always answers with a continuation, so the + * request completes through [EmbeddedAuthException]. Inspect + * [EmbeddedAuthException.isInsufficientAuthorization] and [EmbeddedAuthException.nextActions] to + * learn which step to call next. A terminal error is reported on the same failure channel. + */ + @JvmOverloads + public fun authorize( + connection: String? = null, + capabilities: Set = DEFAULT_CAPABILITIES + ): Request { + transactionState = null + val request = factory.post(authorizeUrl()) + .addParameters(buildMap { + put(CLIENT_ID_KEY, clientId) + connection?.let { put(CONNECTION_KEY, it) } + }) + .addParameter(CAPABILITIES_KEY, capabilities.map { it.value }) + return stepping(request) + } + + /** + * Continues the flow by submitting an email identifier. + * + * This call never resolves successfully; it completes through [EmbeddedAuthException] whose + * [EmbeddedAuthException.nextActions] carry the next step to call. + */ + public fun identifyEmail(email: String): Request = + continueStep(EmbeddedAction.IDENTIFY_EMAIL) { addParameter(EMAIL_KEY, email) } + + /** + * Continues the flow by submitting a phone identifier. + * + * This call never resolves successfully; it completes through [EmbeddedAuthException] whose + * [EmbeddedAuthException.nextActions] carry the next step to call. + */ + public fun identifyPhone(phone: String): Request = + continueStep(EmbeddedAction.IDENTIFY_PHONE) { addParameter(PHONE_KEY, phone) } + + /** + * Continues the flow by requesting an email challenge for the authenticator at [index]. + * + * This call never resolves successfully; it completes through [EmbeddedAuthException] whose + * [EmbeddedAuthException.nextActions] carry the next step to call. + */ + @JvmOverloads + public fun challengeEmail(index: Int = 0): Request = + continueStep(EmbeddedAction.CHALLENGE_EMAIL) { + addParameter(INDEX_KEY, index) + } + + /** + * Verifies a one-time [code] of the given [type]. This is the terminal step of the flow. + * + * On success it yields the [Credentials]. If the server requires further steps the request + * completes through [EmbeddedAuthException] instead, with the next step on + * [EmbeddedAuthException.nextActions]. + */ + @JvmOverloads + public fun verifyOtp( + code: String, + type: OtpType = OtpType.OOB + ): Request { + val session = transactionState?.authSession ?: return noActiveSession() + val request = factory.post(authorizeUrl(), authorizeCodeAdapter(gson)) + .addParameters( + mapOf( + AUTH_SESSION_KEY to session, + ACTION_KEY to EmbeddedAction.VERIFY_OTP.value, + CLIENT_ID_KEY to clientId + ) + ) + .addParameter(OTP_KEY, code) + .addParameter(TYPE_KEY, type.value) + return advancing(request) + } + + private fun continueStep( + action: EmbeddedAction, + addPayload: Request.() -> Unit = {} + ): Request { + val session = transactionState?.authSession ?: return noActiveSession() + val request = factory.post(authorizeUrl()) + .addParameters( + mapOf( + AUTH_SESSION_KEY to session, + ACTION_KEY to action.value, + CLIENT_ID_KEY to clientId + ) + ) + request.addPayload() + return stepping(request) + } + + private fun noActiveSession(): Request = FailedRequest( + EmbeddedAuthException( + NO_ACTIVE_SESSION_ERROR, + "No embedded authentication flow is in progress. Call authorize() first." + ) + ) + + private fun stepping( + request: Request + ): Request = StepRequest(request, ::updateSessionFromFailure) + + private fun advancing( + request: Request + ): Request = AdvancingRequest( + authorize = request, + exchange = ::exchange, + onStepFailure = ::updateSessionFromFailure, + onFlowComplete = { transactionState = null } + ) + + private fun exchange(authorizationCode: String): Request { + val url = auth0.getDomainUrl().toHttpUrl().newBuilder() + .addPathSegment(OAUTH_PATH) + .addPathSegment(TOKEN_PATH) + .build() + return factory.post(url.toString(), GsonAdapter(Credentials::class.java, gson)) + .addParameters( + mapOf( + CLIENT_ID_KEY to clientId, + GRANT_TYPE_KEY to GRANT_TYPE_AUTHORIZATION_CODE, + CODE_KEY to authorizationCode + ) + ) + } + + private fun authorizeUrl(): String = auth0.getDomainUrl().toHttpUrl().newBuilder() + .addPathSegment(EMBEDDED_PATH) + .addPathSegment(AUTHORIZE_PATH) + .build() + .toString() + + private fun updateSessionFromFailure(error: EmbeddedAuthException) { + transactionState = if (error.isInsufficientAuthorization && error.authSession != null) { + EmbeddedAuthState(error.authSession) + } else { + null + } + } + private companion object { private const val EMBEDDED_PATH = "e" private const val DISCOVERY_PATH = "discovery" + private const val AUTHORIZE_PATH = "authorize" + private const val OAUTH_PATH = "oauth" + private const val TOKEN_PATH = "token" + private const val CLIENT_ID_KEY = "client_id" private const val CONNECTION_KEY = "connection" - private const val ERROR_KEY = "error" - private const val ERROR_DESCRIPTION_KEY = "error_description" - private const val DEFAULT_DESCRIPTION = - "An error occurred when trying to authenticate with the server." - - /** - * Parses the wire payload and translates it into the public [DiscoveryResult]. - */ - private fun discoveryAdapter(gson: Gson): JsonAdapter { - val adapter = GsonAdapter(DiscoveryResponse::class.java, gson) - return object : JsonAdapter { - @Throws(IOException::class) - override fun fromJson( - reader: Reader, - metadata: Map - ): DiscoveryResult = adapter.fromJson(reader, metadata).toDiscoveryResult() - } - } + private const val CAPABILITIES_KEY = "capabilities" + private const val AUTH_SESSION_KEY = "auth_session" + private const val ACTION_KEY = "action" + private const val EMAIL_KEY = "email" + private const val PHONE_KEY = "phone" + private const val OTP_KEY = "otp" + private const val INDEX_KEY = "index" + private const val TYPE_KEY = "type" + private const val GRANT_TYPE_KEY = "grant_type" + private const val CODE_KEY = "code" + private const val GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code" + private const val NO_ACTIVE_SESSION_ERROR = "no_active_session" - private fun createErrorAdapter(): ErrorAdapter { - val mapAdapter = forMap(GsonProvider.gson) - return object : ErrorAdapter { - - override fun fromRawResponse( - statusCode: Int, - bodyText: String, - headers: Map> - ): EmbeddedAuthException { - return if (bodyText.isBlank()) EmbeddedAuthException( - Auth0Exception.EMPTY_BODY_ERROR, - Auth0Exception.EMPTY_RESPONSE_BODY_DESCRIPTION, - statusCode - ) else EmbeddedAuthException( - Auth0Exception.NON_JSON_ERROR, - bodyText, - statusCode - ) - } - - @Throws(IOException::class) - override fun fromJsonResponse( - statusCode: Int, - reader: Reader - ): EmbeddedAuthException { - val values = mapAdapter.fromJson(reader) - return EmbeddedAuthException( - values[ERROR_KEY] as? String ?: Auth0Exception.UNKNOWN_ERROR, - values[ERROR_DESCRIPTION_KEY] as? String ?: DEFAULT_DESCRIPTION, - statusCode - ) - } - - override fun fromException(cause: Throwable): EmbeddedAuthException { - return if (isNetworkError(cause)) EmbeddedAuthException( - Auth0Exception.UNKNOWN_ERROR, - "Failed to execute the network request", - cause = NetworkErrorException(cause) - ) else EmbeddedAuthException( - Auth0Exception.UNKNOWN_ERROR, - DEFAULT_DESCRIPTION, - cause = Auth0Exception(DEFAULT_DESCRIPTION, cause) - ) - } - } - } + private val DEFAULT_CAPABILITIES: Set = + EmbeddedAction.entries.toSet() - EmbeddedAction.UNKNOWN } init { diff --git a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthException.kt b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthException.kt index c675ed13..c677ccf3 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthException.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthException.kt @@ -2,6 +2,7 @@ package com.auth0.android.embedded import com.auth0.android.Auth0Exception import com.auth0.android.NetworkErrorException +import com.auth0.android.embedded.authorize.NextAction /** * Represents an error raised by Auth0's embedded authentication API. @@ -15,9 +16,41 @@ public class EmbeddedAuthException internal constructor( /** HTTP status code of the response, or `0` when no response was received. */ public val statusCode: Int = 0, + /** When the attempt is not finished, the menu of actions the server will accept next. */ + public val nextActions: List = emptyList(), + + internal val authSession: String? = null, + cause: Throwable? = null ) : Auth0Exception(description, cause) { public val isNetworkError: Boolean get() = cause is NetworkErrorException + + /** The attempt isn't done: the server returned a continuation. Read [nextActions] for what to call next. */ + public val isInsufficientAuthorization: Boolean + get() = code == INSUFFICIENT_AUTHORIZATION + + /** Terminal: the attempt was denied and must not be retried. */ + public val isAccessDenied: Boolean + get() = code == ACCESS_DENIED + + /** Terminal: too many failed verification attempts. */ + public val isTooManyAttempts: Boolean + get() = statusCode == TOO_MANY_REQUESTS_STATUS && + code == TOO_MANY_REQUESTS && description == TOO_MANY_ATTEMPTS + + /** Terminal: too many login attempts. */ + public val isTooManyLogins: Boolean + get() = statusCode == TOO_MANY_REQUESTS_STATUS && + code == TOO_MANY_REQUESTS && description == TOO_MANY_LOGINS + + private companion object { + private const val INSUFFICIENT_AUTHORIZATION = "insufficient_authorization" + private const val ACCESS_DENIED = "access_denied" + private const val TOO_MANY_REQUESTS = "too_many_requests" + private const val TOO_MANY_ATTEMPTS = "too_many_attempts" + private const val TOO_MANY_LOGINS = "too_many_logins" + private const val TOO_MANY_REQUESTS_STATUS = 429 + } } diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/AdvancingRequest.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/AdvancingRequest.kt new file mode 100644 index 00000000..5f81cae6 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/AdvancingRequest.kt @@ -0,0 +1,78 @@ +package com.auth0.android.embedded.authorize + +import com.auth0.android.Auth0Exception +import com.auth0.android.callback.Callback +import com.auth0.android.embedded.EmbeddedAuthException +import com.auth0.android.request.Request +import com.auth0.android.result.Credentials + +/** + * Runs one step of the embedded flow as a single [Request]. On a step failure the session is rotated + * or cleared via [onStepFailure]; on the `200` that ends `/e/authorize` the authorization code is + * exchanged for [Credentials] and the flow is completed via [onFlowComplete] only once that succeeds, + * so a failed token exchange leaves the session intact for the caller to retry. + */ +internal class AdvancingRequest( + private val authorize: Request, + private val exchange: (authorizationCode: String) -> Request, + private val onStepFailure: (EmbeddedAuthException) -> Unit, + private val onFlowComplete: () -> Unit +) : Request { + + override fun start(callback: Callback) { + authorize.start(object : Callback { + override fun onSuccess(result: AuthorizeCode) { + exchange(result.authorizationCode).start(object : Callback { + override fun onSuccess(result: Credentials) { + onFlowComplete() + callback.onSuccess(result) + } + + override fun onFailure(error: EmbeddedAuthException) = callback.onFailure(error) + }) + } + + override fun onFailure(error: EmbeddedAuthException) { + onStepFailure(error) + callback.onFailure(error) + } + }) + } + + @Throws(Auth0Exception::class) + override suspend fun await(): Credentials { + val code = try { + authorize.await() + } catch (error: EmbeddedAuthException) { + onStepFailure(error) + throw error + } + return exchange(code.authorizationCode).await().also { onFlowComplete() } + } + + @Throws(Auth0Exception::class) + override fun execute(): Credentials { + val code = try { + authorize.execute() + } catch (error: EmbeddedAuthException) { + onStepFailure(error) + throw error + } + return exchange(code.authorizationCode).execute().also { onFlowComplete() } + } + + override fun addParameters(parameters: Map): Request { + authorize.addParameters(parameters) + return this + } + + override fun addParameter(name: String, value: String): Request { + authorize.addParameter(name, value) + return this + } + + override fun addHeader(name: String, value: String): Request { + authorize.addHeader(name, value) + return this + } +} diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/AuthorizeCode.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/AuthorizeCode.kt new file mode 100644 index 00000000..a85b9fa3 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/AuthorizeCode.kt @@ -0,0 +1,7 @@ +package com.auth0.android.embedded.authorize + +import com.google.gson.annotations.SerializedName + +internal class AuthorizeCode( + @SerializedName("authorization_code") val authorizationCode: String +) diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAction.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAction.kt new file mode 100644 index 00000000..04473ef3 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAction.kt @@ -0,0 +1,15 @@ +package com.auth0.android.embedded.authorize + +/** A single step in the embedded authentication flow. [value] is the raw string used by `/e/authorize`. */ +public enum class EmbeddedAction(public val value: String) { + IDENTIFY_EMAIL("action:identify:email:v1"), + IDENTIFY_PHONE("action:identify:phone:v1"), + CHALLENGE_EMAIL("action:challenge:email:v1"), + VERIFY_OTP("action:verify:otp:v1"), + UNKNOWN("Unknown"); + + internal companion object { + fun fromValue(value: String): EmbeddedAction = + entries.firstOrNull { it.value == value } ?: UNKNOWN + } +} diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthAdapters.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthAdapters.kt new file mode 100644 index 00000000..7f3e3db8 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthAdapters.kt @@ -0,0 +1,92 @@ +package com.auth0.android.embedded.authorize + +import com.auth0.android.Auth0Exception +import com.auth0.android.NetworkErrorException +import com.auth0.android.embedded.EmbeddedAuthException +import com.auth0.android.embedded.discovery.DiscoveryResponse +import com.auth0.android.embedded.discovery.DiscoveryResult +import com.auth0.android.embedded.discovery.toDiscoveryResult +import com.auth0.android.request.ErrorAdapter +import com.auth0.android.request.JsonAdapter +import com.auth0.android.request.internal.GsonAdapter +import com.auth0.android.request.internal.GsonAdapter.Companion.forMap +import com.auth0.android.request.internal.GsonProvider +import com.auth0.android.request.internal.ResponseUtils.isNetworkError +import com.google.gson.Gson +import java.io.IOException +import java.io.Reader + +private const val ERROR_KEY = "error" +private const val ERROR_DESCRIPTION_KEY = "error_description" +private const val NEXT_KEY = "next" +private const val AUTH_SESSION_KEY = "auth_session" +private const val DEFAULT_DESCRIPTION = + "An error occurred when trying to authenticate with the server." + +/** Parses the discovery payload and translates it into the public [DiscoveryResult]. */ +internal fun discoveryAdapter(gson: Gson): JsonAdapter { + val adapter = GsonAdapter(DiscoveryResponse::class.java, gson) + return object : JsonAdapter { + @Throws(IOException::class) + override fun fromJson( + reader: Reader, + metadata: Map + ): DiscoveryResult = adapter.fromJson(reader, metadata).toDiscoveryResult() + } +} + +/** Parses the `200` body of `/e/authorize` into the code to exchange for tokens. */ +internal fun authorizeCodeAdapter(gson: Gson): JsonAdapter = + GsonAdapter(AuthorizeCode::class.java, gson) + +/** Translates every embedded-authentication error response into an [EmbeddedAuthException]. */ +internal fun embeddedAuthErrorAdapter(): ErrorAdapter { + val mapAdapter = forMap(GsonProvider.gson) + return object : ErrorAdapter { + + override fun fromRawResponse( + statusCode: Int, + bodyText: String, + headers: Map> + ): EmbeddedAuthException { + return if (bodyText.isBlank()) EmbeddedAuthException( + Auth0Exception.EMPTY_BODY_ERROR, + Auth0Exception.EMPTY_RESPONSE_BODY_DESCRIPTION, + statusCode + ) else EmbeddedAuthException( + Auth0Exception.NON_JSON_ERROR, + bodyText, + statusCode + ) + } + + @Throws(IOException::class) + override fun fromJsonResponse( + statusCode: Int, + reader: Reader + ): EmbeddedAuthException { + val values = mapAdapter.fromJson(reader) + @Suppress("UNCHECKED_CAST") + val nextRaw = values[NEXT_KEY] as? List> ?: emptyList() + return EmbeddedAuthException( + values[ERROR_KEY] as? String ?: Auth0Exception.UNKNOWN_ERROR, + values[ERROR_DESCRIPTION_KEY] as? String ?: DEFAULT_DESCRIPTION, + statusCode, + nextActions = nextRaw.toNextActions(), + authSession = values[AUTH_SESSION_KEY] as? String + ) + } + + override fun fromException(cause: Throwable): EmbeddedAuthException { + return if (isNetworkError(cause)) EmbeddedAuthException( + Auth0Exception.UNKNOWN_ERROR, + "Failed to execute the network request", + cause = NetworkErrorException(cause) + ) else EmbeddedAuthException( + Auth0Exception.UNKNOWN_ERROR, + DEFAULT_DESCRIPTION, + cause = Auth0Exception(DEFAULT_DESCRIPTION, cause) + ) + } + } +} diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthState.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthState.kt new file mode 100644 index 00000000..ee66a050 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthState.kt @@ -0,0 +1,5 @@ +package com.auth0.android.embedded.authorize + +internal data class EmbeddedAuthState( + val authSession: String +) diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/FailedRequest.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/FailedRequest.kt new file mode 100644 index 00000000..e17f2bf8 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/FailedRequest.kt @@ -0,0 +1,25 @@ +package com.auth0.android.embedded.authorize + +import com.auth0.android.Auth0Exception +import com.auth0.android.callback.Callback +import com.auth0.android.embedded.EmbeddedAuthException +import com.auth0.android.request.Request + +/** A request that has already failed; used to report calling a continuation with no flow active. */ +internal class FailedRequest( + private val error: EmbeddedAuthException +) : Request { + + override fun start(callback: Callback): Unit = callback.onFailure(error) + + @Throws(Auth0Exception::class) + override suspend fun await(): T = throw error + + @Throws(Auth0Exception::class) + override fun execute(): T = throw error + + override fun addParameters(parameters: Map): Request = this + override fun addParameter(name: String, value: String): Request = this + override fun addParameter(name: String, value: Any): Request = this + override fun addHeader(name: String, value: String): Request = this +} diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt new file mode 100644 index 00000000..fcb5bee0 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt @@ -0,0 +1,46 @@ +package com.auth0.android.embedded.authorize + +import com.auth0.android.embedded.EmbeddedAuthClient +import com.auth0.android.embedded.EmbeddedAuthException + +/** One way to continue the embedded authentication flow, as reported on [EmbeddedAuthException.nextActions]. */ +public sealed interface NextAction { + + public val action: EmbeddedAction + + /** Continue by submitting an email address. Act on it with [EmbeddedAuthClient.identifyEmail]. */ + public data object IdentifyEmail : NextAction { + override val action: EmbeddedAction = EmbeddedAction.IDENTIFY_EMAIL + } + + /** Continue by submitting a phone number. Act on it with [EmbeddedAuthClient.identifyPhone]. */ + public data object IdentifyPhone : NextAction { + override val action: EmbeddedAction = EmbeddedAction.IDENTIFY_PHONE + } + + /** Continue by requesting an email challenge. Act on it with [EmbeddedAuthClient.challengeEmail]. */ + @ConsistentCopyVisibility + public data class ChallengeEmail internal constructor( + public val index: Int?, + public val identifier: String? + ) : NextAction { + override val action: EmbeddedAction = EmbeddedAction.CHALLENGE_EMAIL + } + + /** Continue by verifying a one-time code. Act on it with [EmbeddedAuthClient.verifyOtp]. */ + @ConsistentCopyVisibility + public data class VerifyOtp internal constructor( + public val channel: String?, + public val identifier: String? + ) : NextAction { + override val action: EmbeddedAction = EmbeddedAction.VERIFY_OTP + } + + /** An action the server offered that this version of the SDK does not model. */ + @ConsistentCopyVisibility + public data class Unknown internal constructor( + public val rawAction: String + ) : NextAction { + override val action: EmbeddedAction = EmbeddedAction.UNKNOWN + } +} diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/NextActionMapper.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextActionMapper.kt new file mode 100644 index 00000000..94201cdf --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextActionMapper.kt @@ -0,0 +1,25 @@ +package com.auth0.android.embedded.authorize + +private const val ACTION_KEY = "action" +private const val CHANNEL_KEY = "channel" +private const val IDENTIFIER_KEY = "identifier" +private const val INDEX_KEY = "index" + +internal fun List>.toNextActions(): List = mapNotNull { it.toNextAction() } + +private fun Map.toNextAction(): NextAction? { + val raw = this[ACTION_KEY] as? String ?: return null + return when (EmbeddedAction.fromValue(raw)) { + EmbeddedAction.IDENTIFY_EMAIL -> NextAction.IdentifyEmail + EmbeddedAction.IDENTIFY_PHONE -> NextAction.IdentifyPhone + EmbeddedAction.CHALLENGE_EMAIL -> NextAction.ChallengeEmail( + index = (this[INDEX_KEY] as? Number)?.toInt(), + identifier = this[IDENTIFIER_KEY] as? String + ) + EmbeddedAction.VERIFY_OTP -> NextAction.VerifyOtp( + channel = this[CHANNEL_KEY] as? String, + identifier = this[IDENTIFIER_KEY] as? String + ) + EmbeddedAction.UNKNOWN -> NextAction.Unknown(raw) + } +} diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/OtpType.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/OtpType.kt new file mode 100644 index 00000000..41b35b91 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/OtpType.kt @@ -0,0 +1,12 @@ +package com.auth0.android.embedded.authorize + +import com.auth0.android.embedded.EmbeddedAuthClient + +/** The kind of one-time password verified with [EmbeddedAuthClient.verifyOtp]. */ +public enum class OtpType(public val value: String) { + /** An out-of-band code delivered over email, SMS, or voice. */ + OOB("oob"), + + /** A time-based code from an authenticator app (TOTP). */ + TOTP("totp") +} diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/StepRequest.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/StepRequest.kt new file mode 100644 index 00000000..ea4e1daa --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/StepRequest.kt @@ -0,0 +1,59 @@ +package com.auth0.android.embedded.authorize + +import com.auth0.android.Auth0Exception +import com.auth0.android.callback.Callback +import com.auth0.android.embedded.EmbeddedAuthException +import com.auth0.android.request.Request + +/** + * Runs one non-terminal step of the embedded flow as a single [Request]. These steps never yield + * credentials, so on the continuation failure the session is rotated (or cleared) via [onStepFailure] + * before the error is propagated to the caller. + */ +internal class StepRequest( + private val request: Request, + private val onStepFailure: (EmbeddedAuthException) -> Unit +) : Request { + + override fun start(callback: Callback) { + request.start(object : Callback { + override fun onSuccess(result: Void?) = callback.onSuccess(result) + + override fun onFailure(error: EmbeddedAuthException) { + onStepFailure(error) + callback.onFailure(error) + } + }) + } + + @Throws(Auth0Exception::class) + override suspend fun await(): Void? = try { + request.await() + } catch (error: EmbeddedAuthException) { + onStepFailure(error) + throw error + } + + @Throws(Auth0Exception::class) + override fun execute(): Void? = try { + request.execute() + } catch (error: EmbeddedAuthException) { + onStepFailure(error) + throw error + } + + override fun addParameters(parameters: Map): Request { + request.addParameters(parameters) + return this + } + + override fun addParameter(name: String, value: String): Request { + request.addParameter(name, value) + return this + } + + override fun addHeader(name: String, value: String): Request { + request.addHeader(name, value) + return this + } +} diff --git a/auth0/src/test/java/com/auth0/android/embedded/DiscoveryMapperTest.kt b/auth0/src/test/java/com/auth0/android/embedded/discovery/DiscoveryMapperTest.kt similarity index 99% rename from auth0/src/test/java/com/auth0/android/embedded/DiscoveryMapperTest.kt rename to auth0/src/test/java/com/auth0/android/embedded/discovery/DiscoveryMapperTest.kt index 13134926..e3a26da9 100644 --- a/auth0/src/test/java/com/auth0/android/embedded/DiscoveryMapperTest.kt +++ b/auth0/src/test/java/com/auth0/android/embedded/discovery/DiscoveryMapperTest.kt @@ -1,4 +1,4 @@ -package com.auth0.android.embedded +package com.auth0.android.embedded.discovery import com.auth0.android.embedded.discovery.Alternative import com.auth0.android.embedded.discovery.DiscoveryResponse From 3138b099d7627a72e60870c9c4d9bb87c662768d Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Fri, 18 Sep 2026 11:18:35 +0530 Subject: [PATCH 02/15] Made connection mandatory request in the initial request --- .../java/com/auth0/android/embedded/EmbeddedAuthClient.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt index 0b757d85..26a7c185 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt @@ -82,14 +82,14 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { */ @JvmOverloads public fun authorize( - connection: String? = null, + connection: String, capabilities: Set = DEFAULT_CAPABILITIES ): Request { transactionState = null val request = factory.post(authorizeUrl()) .addParameters(buildMap { put(CLIENT_ID_KEY, clientId) - connection?.let { put(CONNECTION_KEY, it) } + put(CONNECTION_KEY, connection) }) .addParameter(CAPABILITIES_KEY, capabilities.map { it.value }) return stepping(request) From 936c0770fbfa926c80589dcce7f0910bf92385d7 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Fri, 18 Sep 2026 14:26:37 +0530 Subject: [PATCH 03/15] Passing default scope to the initial authorize request --- .../auth0/android/embedded/EmbeddedAuthClient.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt index 26a7c185..07323617 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt @@ -79,10 +79,18 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { * request completes through [EmbeddedAuthException]. Inspect * [EmbeddedAuthException.isInsufficientAuthorization] and [EmbeddedAuthException.nextActions] to * learn which step to call next. A terminal error is reported on the same failure channel. + * + * @param connection name of the connection to authenticate against. + * @param scope space-separated scopes to request. Must include `openid` for the terminal token + * exchange to return an ID token; defaults to `"openid profile email"`. + * @param audience optional API audience to request an access token for. + * @param capabilities the set of steps this client can handle in the flow. */ @JvmOverloads public fun authorize( connection: String, + scope: String = DEFAULT_SCOPE, + audience: String? = null, capabilities: Set = DEFAULT_CAPABILITIES ): Request { transactionState = null @@ -90,6 +98,8 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { .addParameters(buildMap { put(CLIENT_ID_KEY, clientId) put(CONNECTION_KEY, connection) + put(SCOPE_KEY, scope) + audience?.let { put(AUDIENCE_KEY, it) } }) .addParameter(CAPABILITIES_KEY, capabilities.map { it.value }) return stepping(request) @@ -226,6 +236,8 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { private const val CLIENT_ID_KEY = "client_id" private const val CONNECTION_KEY = "connection" + private const val SCOPE_KEY = "scope" + private const val AUDIENCE_KEY = "audience" private const val CAPABILITIES_KEY = "capabilities" private const val AUTH_SESSION_KEY = "auth_session" private const val ACTION_KEY = "action" @@ -239,6 +251,8 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { private const val GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code" private const val NO_ACTIVE_SESSION_ERROR = "no_active_session" + private const val DEFAULT_SCOPE = "openid profile email offline_access" + private val DEFAULT_CAPABILITIES: Set = EmbeddedAction.entries.toSet() - EmbeddedAction.UNKNOWN } From 25eb27973701414d532bddaf5ccaae490ab1a020 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 21 Sep 2026 10:56:44 +0530 Subject: [PATCH 04/15] Minor change in the authorize url creation --- .../android/embedded/EmbeddedAuthClient.kt | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt index 07323617..6a7cd5da 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt @@ -82,7 +82,7 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { * * @param connection name of the connection to authenticate against. * @param scope space-separated scopes to request. Must include `openid` for the terminal token - * exchange to return an ID token; defaults to `"openid profile email"`. + * exchange to return an ID token; defaults to `"openid profile email offline_access"`. * @param audience optional API audience to request an access token for. * @param capabilities the set of steps this client can handle in the flow. */ @@ -94,7 +94,7 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { capabilities: Set = DEFAULT_CAPABILITIES ): Request { transactionState = null - val request = factory.post(authorizeUrl()) + val request = factory.post(authorizeUrl) .addParameters(buildMap { put(CLIENT_ID_KEY, clientId) put(CONNECTION_KEY, connection) @@ -138,7 +138,7 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { /** * Verifies a one-time [code] of the given [type]. This is the terminal step of the flow. * - * On success it yields the [Credentials]. If the server requires further steps the request + * On success, it yields the [Credentials]. If the server requires further steps the request * completes through [EmbeddedAuthException] instead, with the next step on * [EmbeddedAuthException.nextActions]. */ @@ -148,7 +148,7 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { type: OtpType = OtpType.OOB ): Request { val session = transactionState?.authSession ?: return noActiveSession() - val request = factory.post(authorizeUrl(), authorizeCodeAdapter(gson)) + val request = factory.post(authorizeUrl, authorizeCodeAdapter(gson)) .addParameters( mapOf( AUTH_SESSION_KEY to session, @@ -166,7 +166,7 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { addPayload: Request.() -> Unit = {} ): Request { val session = transactionState?.authSession ?: return noActiveSession() - val request = factory.post(authorizeUrl()) + val request = factory.post(authorizeUrl) .addParameters( mapOf( AUTH_SESSION_KEY to session, @@ -213,11 +213,13 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { ) } - private fun authorizeUrl(): String = auth0.getDomainUrl().toHttpUrl().newBuilder() - .addPathSegment(EMBEDDED_PATH) - .addPathSegment(AUTHORIZE_PATH) - .build() - .toString() + private val authorizeUrl: String by lazy { + auth0.getDomainUrl().toHttpUrl().newBuilder() + .addPathSegment(EMBEDDED_PATH) + .addPathSegment(AUTHORIZE_PATH) + .build() + .toString() + } private fun updateSessionFromFailure(error: EmbeddedAuthException) { transactionState = if (error.isInsufficientAuthorization && error.authSession != null) { From 4331096926b63de2ecfb6a1be12017a2516d521f Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 21 Sep 2026 11:43:49 +0530 Subject: [PATCH 05/15] Added the UTs for authorize email OTP flow --- .../embedded/EmbeddedAuthClientTest.kt | 471 ++++++++++++++++++ .../android/util/EmbeddedAuthMockServer.kt | 97 ++++ 2 files changed, 568 insertions(+) diff --git a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt index ec640054..afebfe49 100644 --- a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt @@ -2,15 +2,22 @@ package com.auth0.android.embedded import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception +import com.auth0.android.embedded.authorize.NextAction +import com.auth0.android.embedded.authorize.OtpType import com.auth0.android.embedded.discovery.GrantType import com.auth0.android.util.EmbeddedAuthMockServer import com.auth0.android.util.SSLTestUtils.testClient import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.RecordedRequest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.containsInAnyOrder +import org.hamcrest.Matchers.empty +import org.hamcrest.Matchers.hasSize +import org.hamcrest.Matchers.instanceOf import org.hamcrest.Matchers.`is` import org.hamcrest.Matchers.notNullValue import org.hamcrest.Matchers.nullValue +import org.json.JSONObject import org.junit.After import org.junit.Before import org.junit.Test @@ -183,6 +190,470 @@ public class EmbeddedAuthClientTest { ) } + @Test + public fun `authorize should POST the authorize endpoint with the default scope and no audience`() { + mockAPI.willReturnJsonError() + + try { + client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() + } catch (_: EmbeddedAuthException) { + // authorize always completes through a failure; we only inspect the request here. + } + + val request = mockAPI.takeRequest() + assertThat(request.method, `is`("POST")) + assertThat(request.requestUrl?.encodedPath, `is`("/e/authorize")) + val body = bodyOf(request) + assertThat(body.getString("client_id"), `is`(CLIENT_ID)) + assertThat(body.getString("connection"), `is`(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION)) + assertThat(body.getString("scope"), `is`("openid profile email offline_access")) + assertThat(body.has("audience"), `is`(false)) + } + + @Test + public fun `authorize should send the given scope and audience`() { + mockAPI.willReturnJsonError() + + try { + client.authorize( + connection = EmbeddedAuthMockServer.AUTHORIZE_CONNECTION, + scope = "openid offline_access", + audience = "https://api.example.com" + ).execute() + } catch (_: EmbeddedAuthException) { + } + + val body = bodyOf(mockAPI.takeRequest()) + assertThat(body.getString("scope"), `is`("openid offline_access")) + assertThat(body.getString("audience"), `is`("https://api.example.com")) + } + + @Test + public fun `verifyOtp should exchange the code and return credentials with an id token`() { + // Establish a session, then run the terminal step through the token exchange. + mockAPI.willReturnInsufficientAuthorization() + try { + client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() + } catch (_: EmbeddedAuthException) { + } + mockAPI.takeRequest() + + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokens() + + val credentials = client.verifyOtp("123456", OtpType.OOB).execute() + + assertThat(credentials.idToken, `is`(EmbeddedAuthMockServer.ID_TOKEN)) + assertThat(credentials.accessToken, `is`(EmbeddedAuthMockServer.ACCESS_TOKEN)) + + // The verify step posts to /e/authorize; the exchange posts to /oauth/token. + mockAPI.takeRequest() + val tokenRequest = mockAPI.takeRequest() + assertThat(tokenRequest.requestUrl?.encodedPath, `is`("/oauth/token")) + val tokenBody = bodyOf(tokenRequest) + assertThat(tokenBody.getString("grant_type"), `is`("authorization_code")) + assertThat(tokenBody.getString("code"), `is`(EmbeddedAuthMockServer.AUTHORIZATION_CODE)) + } + + @Test + public fun `verifyOtp should surface a token response without an id token as an error`() { + mockAPI.willReturnInsufficientAuthorization() + try { + client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() + } catch (_: EmbeddedAuthException) { + } + mockAPI.takeRequest() + + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokensWithoutIdToken() + + var error: EmbeddedAuthException? = null + try { + client.verifyOtp("123456", OtpType.OOB).execute() + } catch (ex: EmbeddedAuthException) { + error = ex + } + + assertThat(error, `is`(notNullValue())) + } + + + @Test + public fun `identifyEmail should fail with no_active_session when no flow is in progress`() { + val error = assertEmbeddedError { client.identifyEmail("jane@example.com").execute() } + assertThat(error.code, `is`("no_active_session")) + } + + @Test + public fun `identifyEmail should POST the authorize endpoint with the correct action and email`() { + establishSession() + mockAPI.willReturnInsufficientAuthorization() + + try { + client.identifyEmail("jane@example.com").execute() + } catch (_: EmbeddedAuthException) { + } + + val request = mockAPI.takeRequest() + val body = bodyOf(request) + assertThat(request.requestUrl?.encodedPath, `is`("/e/authorize")) + assertThat(body.getString("action"), `is`("action:identify:email:v1")) + assertThat(body.getString("email"), `is`("jane@example.com")) + assertThat(body.getString("auth_session"), `is`(EmbeddedAuthMockServer.AUTH_SESSION)) + } + + + + @Test + public fun `identifyPhone should fail with no_active_session when no flow is in progress`() { + val error = assertEmbeddedError { client.identifyPhone("+15550001234").execute() } + assertThat(error.code, `is`("no_active_session")) + } + + @Test + public fun `identifyPhone should POST the authorize endpoint with the correct action and phone`() { + establishSession() + mockAPI.willReturnInsufficientAuthorization() + + try { + client.identifyPhone("+15550001234").execute() + } catch (_: EmbeddedAuthException) { + } + + val body = bodyOf(mockAPI.takeRequest()) + assertThat(body.getString("action"), `is`("action:identify:phone:v1")) + assertThat(body.getString("phone"), `is`("+15550001234")) + } + + + + @Test + public fun `challengeEmail should fail with no_active_session when no flow is in progress`() { + val error = assertEmbeddedError { client.challengeEmail().execute() } + assertThat(error.code, `is`("no_active_session")) + } + + @Test + public fun `challengeEmail should POST the authorize endpoint with the correct action and default index`() { + establishSession() + mockAPI.willReturnInsufficientAuthorization() + + try { + client.challengeEmail().execute() + } catch (_: EmbeddedAuthException) { + } + + val body = bodyOf(mockAPI.takeRequest()) + assertThat(body.getString("action"), `is`("action:challenge:email:v1")) + assertThat(body.getInt("index"), `is`(0)) + } + + @Test + public fun `challengeEmail should POST the given index`() { + establishSession() + mockAPI.willReturnInsufficientAuthorization() + + try { + client.challengeEmail(index = 2).execute() + } catch (_: EmbeddedAuthException) { + } + + assertThat(bodyOf(mockAPI.takeRequest()).getInt("index"), `is`(2)) + } + + + + @Test + public fun `verifyOtp should fail with no_active_session when no flow is in progress`() { + val error = assertEmbeddedError { client.verifyOtp("123456").execute() } + assertThat(error.code, `is`("no_active_session")) + } + + @Test + public fun `verifyOtp should POST the correct action, otp, and type`() { + establishSession() + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokens() + + client.verifyOtp("654321", OtpType.TOTP).execute() + + val body = bodyOf(mockAPI.takeRequest()) + assertThat(body.getString("action"), `is`("action:verify:otp:v1")) + assertThat(body.getString("otp"), `is`("654321")) + assertThat(body.getString("type"), `is`("totp")) + } + + + + @Test + public fun `authorize continuation sets isInsufficientAuthorization and populates nextActions`() { + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_IDENTIFY_EMAIL) + + val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } + + assertThat(error.isInsufficientAuthorization, `is`(true)) + assertThat(error.nextActions, hasSize(1)) + assertThat(error.nextActions[0], instanceOf(NextAction.IdentifyEmail::class.java)) + } + + @Test + public fun `authorize continuation surfaces IdentifyPhone next action`() { + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_IDENTIFY_PHONE) + + val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } + + assertThat(error.nextActions[0], instanceOf(NextAction.IdentifyPhone::class.java)) + } + + @Test + public fun `authorize continuation surfaces ChallengeEmail with index and identifier`() { + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_CHALLENGE_EMAIL) + + val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } + + val action = error.nextActions[0] as NextAction.ChallengeEmail + assertThat(action.index, `is`(1)) + assertThat(action.identifier, `is`(EmbeddedAuthMockServer.IDENTIFIER)) + } + + @Test + public fun `authorize continuation surfaces VerifyOtp with channel and identifier`() { + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_VERIFY_OTP) + + val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } + + val action = error.nextActions[0] as NextAction.VerifyOtp + assertThat(action.channel, `is`("email")) + assertThat(action.identifier, `is`(EmbeddedAuthMockServer.IDENTIFIER)) + } + + @Test + public fun `authorize continuation surfaces Unknown next action for unrecognised actions`() { + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_UNKNOWN) + + val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } + + val action = error.nextActions[0] as NextAction.Unknown + assertThat(action.rawAction, `is`("action:future:unknown:v1")) + } + + @Test + public fun `authorize continuation can carry multiple next actions`() { + mockAPI.willReturnContinuationWith( + EmbeddedAuthMockServer.NEXT_IDENTIFY_EMAIL, + EmbeddedAuthMockServer.NEXT_IDENTIFY_PHONE, + ) + + val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } + + assertThat(error.nextActions, hasSize(2)) + assertThat(error.nextActions[0], instanceOf(NextAction.IdentifyEmail::class.java)) + assertThat(error.nextActions[1], instanceOf(NextAction.IdentifyPhone::class.java)) + } + + @Test + public fun `authorize surfaces access_denied as a terminal error with no next actions`() { + mockAPI.willReturnAccessDenied() + + val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } + + assertThat(error.isInsufficientAuthorization, `is`(false)) + assertThat(error.isAccessDenied, `is`(true)) + assertThat(error.nextActions, `is`(empty())) + } + + @Test + public fun `authorize surfaces too_many_attempts as a terminal error`() { + mockAPI.willReturnTooManyAttempts() + + val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } + + assertThat(error.isTooManyAttempts, `is`(true)) + assertThat(error.statusCode, `is`(429)) + } + + @Test + public fun `authorize surfaces too_many_logins as a terminal error`() { + mockAPI.willReturnTooManyLogins() + + val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } + + assertThat(error.isTooManyLogins, `is`(true)) + assertThat(error.statusCode, `is`(429)) + } + + @Test + public fun `identifyEmail continuation sets isInsufficientAuthorization and populates nextActions`() { + establishSession() + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_CHALLENGE_EMAIL) + + val error = assertEmbeddedError { client.identifyEmail("jane@example.com").execute() } + + assertThat(error.isInsufficientAuthorization, `is`(true)) + assertThat(error.nextActions[0], instanceOf(NextAction.ChallengeEmail::class.java)) + } + + @Test + public fun `identifyEmail surfaces a terminal error`() { + establishSession() + mockAPI.willReturnAccessDenied() + + val error = assertEmbeddedError { client.identifyEmail("jane@example.com").execute() } + + assertThat(error.isAccessDenied, `is`(true)) + assertThat(error.nextActions, `is`(empty())) + } + + @Test + public fun `identifyPhone continuation sets isInsufficientAuthorization and populates nextActions`() { + establishSession() + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_VERIFY_OTP) + + val error = assertEmbeddedError { client.identifyPhone("+15550001234").execute() } + + assertThat(error.isInsufficientAuthorization, `is`(true)) + assertThat(error.nextActions[0], instanceOf(NextAction.VerifyOtp::class.java)) + } + + @Test + public fun `identifyPhone surfaces a terminal error`() { + establishSession() + mockAPI.willReturnAccessDenied() + + val error = assertEmbeddedError { client.identifyPhone("+15550001234").execute() } + + assertThat(error.isAccessDenied, `is`(true)) + } + + @Test + public fun `challengeEmail continuation sets isInsufficientAuthorization and populates nextActions`() { + establishSession() + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_VERIFY_OTP) + + val error = assertEmbeddedError { client.challengeEmail().execute() } + + assertThat(error.isInsufficientAuthorization, `is`(true)) + assertThat(error.nextActions[0], instanceOf(NextAction.VerifyOtp::class.java)) + } + + @Test + public fun `challengeEmail surfaces a terminal error`() { + establishSession() + mockAPI.willReturnTooManyAttempts() + + val error = assertEmbeddedError { client.challengeEmail().execute() } + + assertThat(error.isTooManyAttempts, `is`(true)) + } + + @Test + public fun `verifyOtp surfaces a continuation when the server requires further steps`() { + establishSession() + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_VERIFY_OTP) + + val error = assertEmbeddedError { client.verifyOtp("123456").execute() } + + assertThat(error.isInsufficientAuthorization, `is`(true)) + assertThat(error.nextActions[0], instanceOf(NextAction.VerifyOtp::class.java)) + } + + @Test + public fun `verifyOtp surfaces a terminal error`() { + establishSession() + mockAPI.willReturnTooManyAttempts() + + val error = assertEmbeddedError { client.verifyOtp("123456").execute() } + + assertThat(error.isTooManyAttempts, `is`(true)) + } + + + + @Test + public fun `the auth_session is rotated between steps`() { + // authorize → session A; identifyEmail → should send A, server returns B; challengeEmail → should send B + mockAPI.willReturnInsufficientAuthorization(EmbeddedAuthMockServer.AUTH_SESSION) + try { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } catch (_: EmbeddedAuthException) {} + mockAPI.takeRequest() + + mockAPI.willReturnInsufficientAuthorization(EmbeddedAuthMockServer.ROTATED_AUTH_SESSION) + try { client.identifyEmail("jane@example.com").execute() } catch (_: EmbeddedAuthException) {} + assertThat(bodyOf(mockAPI.takeRequest()).getString("auth_session"), `is`(EmbeddedAuthMockServer.AUTH_SESSION)) + + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokens() + client.verifyOtp("123456").execute() + assertThat(bodyOf(mockAPI.takeRequest()).getString("auth_session"), `is`(EmbeddedAuthMockServer.ROTATED_AUTH_SESSION)) + } + + @Test + public fun `the session is cleared after a successful token exchange`() { + establishSession() + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokens() + client.verifyOtp("123456").execute() + mockAPI.takeRequest() + mockAPI.takeRequest() + + // Session is gone — any follow-on step must fail with no_active_session. + val error = assertEmbeddedError { client.verifyOtp("999999").execute() } + assertThat(error.code, `is`("no_active_session")) + } + + @Test + public fun `a failed token exchange leaves the session intact for retry`() { + establishSession() + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokensWithoutIdToken() + try { client.verifyOtp("123456").execute() } catch (_: EmbeddedAuthException) {} + mockAPI.takeRequest() + mockAPI.takeRequest() + + // Session still active — verifyOtp should reach the server rather than failing immediately. + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokens() + val credentials = client.verifyOtp("123456").execute() + assertThat(credentials.accessToken, `is`(EmbeddedAuthMockServer.ACCESS_TOKEN)) + } + + @Test + public fun `calling authorize again resets an in-progress flow`() { + establishSession() + + // Start a fresh flow — the old session must not bleed into the next step. + mockAPI.willReturnInsufficientAuthorization(EmbeddedAuthMockServer.ROTATED_AUTH_SESSION) + try { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } catch (_: EmbeddedAuthException) {} + mockAPI.takeRequest() + + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokens() + client.verifyOtp("123456").execute() + assertThat( + bodyOf(mockAPI.takeRequest()).getString("auth_session"), + `is`(EmbeddedAuthMockServer.ROTATED_AUTH_SESSION) + ) + } + + + + /** Enqueues a continuation and calls authorize to populate [transactionState]. */ + private fun establishSession() { + mockAPI.willReturnInsufficientAuthorization() + try { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } catch (_: EmbeddedAuthException) {} + mockAPI.takeRequest() + } + + private fun assertEmbeddedError(block: () -> Unit): EmbeddedAuthException { + var error: EmbeddedAuthException? = null + try { block() } catch (ex: EmbeddedAuthException) { error = ex } + assertThat("expected EmbeddedAuthException", error, `is`(notNullValue())) + return error!! + } + + + private fun bodyOf(request: RecordedRequest): JSONObject = + JSONObject(request.body.readUtf8()) + private companion object { private const val CLIENT_ID = "CLIENT_ID" } diff --git a/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt b/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt index 55a50e1d..d54b2a5a 100644 --- a/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt @@ -80,6 +80,90 @@ internal class EmbeddedAuthMockServer : APIMockServer() { return this } + /** The `403 insufficient_authorization` continuation that carries the rotated session and next step. */ + fun willReturnInsufficientAuthorization( + authSession: String = AUTH_SESSION + ): EmbeddedAuthMockServer { + val json = """ + { + "error": "insufficient_authorization", + "error_description": "The flow is not complete.", + "auth_session": "$authSession", + "next": [ + { "action": "action:verify:otp:v1", "channel": "email", "identifier": "$IDENTIFIER" } + ] + } + """.trimIndent() + server.enqueue(responseWithJSON(json, 403)) + return this + } + + fun willReturnContinuationWith(vararg actions: String, authSession: String = AUTH_SESSION): EmbeddedAuthMockServer { + val actionsJson = actions.joinToString(",\n") { " $it" } + val json = """ + { + "error": "insufficient_authorization", + "error_description": "The flow is not complete.", + "auth_session": "$authSession", + "next": [ +$actionsJson + ] + } + """.trimIndent() + server.enqueue(responseWithJSON(json, 403)) + return this + } + + fun willReturnAccessDenied(): EmbeddedAuthMockServer { + server.enqueue(responseWithJSON("""{ "error": "access_denied", "error_description": "Access denied." }""", 403)) + return this + } + + fun willReturnTooManyAttempts(): EmbeddedAuthMockServer { + server.enqueue(responseWithJSON("""{ "error": "too_many_requests", "error_description": "too_many_attempts" }""", 429)) + return this + } + + fun willReturnTooManyLogins(): EmbeddedAuthMockServer { + server.enqueue(responseWithJSON("""{ "error": "too_many_requests", "error_description": "too_many_logins" }""", 429)) + return this + } + + /** The `200` that ends `/e/authorize`, carrying the code to exchange for tokens. */ + fun willReturnAuthorizeCode(): EmbeddedAuthMockServer { + server.enqueue(responseWithJSON("""{ "authorization_code": "$AUTHORIZATION_CODE" }""", 200)) + return this + } + + /** A standard token response, including an `id_token`. */ + fun willReturnTokens(): EmbeddedAuthMockServer { + val json = """ + { + "access_token": "$ACCESS_TOKEN", + "id_token": "$ID_TOKEN", + "token_type": "Bearer", + "expires_in": 86400, + "scope": "openid profile email" + } + """.trimIndent() + server.enqueue(responseWithJSON(json, 200)) + return this + } + + /** A `200` token response missing `id_token` — reproduces the openid-less exchange failure. */ + fun willReturnTokensWithoutIdToken(): EmbeddedAuthMockServer { + val json = """ + { + "access_token": "$ACCESS_TOKEN", + "token_type": "Bearer", + "expires_in": 86400, + "scope": "profile email" + } + """.trimIndent() + server.enqueue(responseWithJSON(json, 200)) + return this + } + companion object { const val PASSWORD_REALM = "Username-Password-Authentication" const val PASSKEY_CONNECTION = "passkey-connection" @@ -92,5 +176,18 @@ internal class EmbeddedAuthMockServer : APIMockServer() { const val PLAIN_TEXT_ERROR = "Internal Server Error" const val ERROR_CODE = "invalid_request" const val ERROR_DESCRIPTION = "The connection was not found." + const val AUTH_SESSION = "auth-session-token" + const val ROTATED_AUTH_SESSION = "auth-session-token-rotated" + + // Prebuilt action JSON fragments for willReturnContinuationWith(). + const val NEXT_IDENTIFY_EMAIL = """{ "action": "action:identify:email:v1" }""" + const val NEXT_IDENTIFY_PHONE = """{ "action": "action:identify:phone:v1" }""" + const val NEXT_CHALLENGE_EMAIL = """{ "action": "action:challenge:email:v1", "index": 1, "identifier": "jane@example.com" }""" + const val NEXT_VERIFY_OTP = """{ "action": "action:verify:otp:v1", "channel": "email", "identifier": "jane@example.com" }""" + const val NEXT_UNKNOWN = """{ "action": "action:future:unknown:v1" }""" + const val IDENTIFIER = "jane@example.com" + const val AUTHORIZATION_CODE = "the-authorization-code" + const val ACCESS_TOKEN = "the-access-token" + const val ID_TOKEN = "the-id-token" } } From dece9c13b27424a4c1ade769123843f8acdef3b3 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 21 Sep 2026 15:40:26 +0530 Subject: [PATCH 06/15] Updated the sample app --- .../auth0/sample/embedded/AuthorizeScreen.kt | 345 ++++++++++++++++++ .../auth0/sample/embedded/AuthorizeUiState.kt | 27 ++ .../auth0/sample/embedded/DiscoveryScreen.kt | 48 ++- .../sample/embedded/EmbeddedViewModel.kt | 61 ++++ .../com/auth0/sample/embedded/MainActivity.kt | 33 +- .../src/main/res/values/strings.xml | 24 +- 6 files changed, 530 insertions(+), 8 deletions(-) create mode 100644 sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeScreen.kt create mode 100644 sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeUiState.kt diff --git a/sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeScreen.kt b/sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeScreen.kt new file mode 100644 index 00000000..ef5cc793 --- /dev/null +++ b/sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeScreen.kt @@ -0,0 +1,345 @@ +package com.auth0.sample.embedded + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.TextButton +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.auth0.android.embedded.EmbeddedAuthException +import com.auth0.android.embedded.authorize.NextAction +import com.auth0.android.embedded.authorize.OtpType +import com.auth0.android.result.Credentials + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +public fun AuthorizeScreen( + viewModel: EmbeddedViewModel = viewModel(), + initialConnection: String, + onBack: () -> Unit = {}, +) { + val state by viewModel.authorizeState.collectAsStateWithLifecycle() + var connection by rememberSaveable { mutableStateOf(initialConnection) } + val isLoading = state is AuthorizeUiState.Loading + val isIdle = state is AuthorizeUiState.Idle + + val canStart = isIdle && connection.isNotBlank() + val startAuthorize = { viewModel.startAuthorize(connection.trim()) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_screen)) }, + navigationIcon = { + TextButton(onClick = onBack) { + Text( + text = "← Back", + color = MaterialTheme.colorScheme.onPrimary, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 20.dp, vertical = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + OutlinedTextField( + value = connection, + onValueChange = { connection = it }, + label = { Text(stringResource(R.string.hint_connection_required)) }, + singleLine = true, + enabled = isIdle, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { if (canStart) startAuthorize() }), + ) + + Spacer(Modifier.height(24.dp)) + + Button( + onClick = startAuthorize, + enabled = canStart, + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(12.dp)) + Text(stringResource(R.string.status_authorizing)) + } else { + Text(stringResource(R.string.action_start_authorize)) + } + } + + Spacer(Modifier.height(28.dp)) + + when (val current = state) { + AuthorizeUiState.Idle -> InfoCard(stringResource(R.string.status_idle_authorize)) + AuthorizeUiState.Loading -> InfoCard(stringResource(R.string.status_authorizing)) + is AuthorizeUiState.ActionsAvailable -> ActionsSection(current.actions, viewModel) + is AuthorizeUiState.Authenticated -> InfoCard(formatCredentials(current.credentials)) + is AuthorizeUiState.Failed -> InfoCard(formatError(current.error)) + is AuthorizeUiState.Message -> InfoCard(current.text) + } + + if (!isIdle && !isLoading) { + Spacer(Modifier.height(20.dp)) + OutlinedButton( + onClick = { viewModel.resetAuthorize() }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.action_start_over)) + } + } + } + } +} + +@Composable +private fun ActionsSection(actions: List, viewModel: EmbeddedViewModel) { + Text( + text = stringResource(R.string.label_choose_action), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(12.dp)) + actions.forEach { action -> + when (action) { + NextAction.IdentifyEmail -> IdentifyCard( + labelRes = R.string.action_identify_email, + hintRes = R.string.hint_email, + keyboardType = KeyboardType.Email, + submitRes = R.string.action_submit, + onSubmit = { viewModel.identifyEmail(it) }, + ) + + NextAction.IdentifyPhone -> IdentifyCard( + labelRes = R.string.action_identify_phone, + hintRes = R.string.hint_phone, + keyboardType = KeyboardType.Phone, + submitRes = R.string.action_submit, + onSubmit = { viewModel.identifyPhone(it) }, + ) + + is NextAction.ChallengeEmail -> ChallengeEmailCard( + identifier = action.identifier, + onChallenge = { viewModel.challengeEmail(action.index ?: 0) }, + ) + + is NextAction.VerifyOtp -> VerifyOtpCard( + identifier = action.identifier, + onVerify = { code, type -> viewModel.verifyOtp(code, type) }, + ) + + is NextAction.Unknown -> InfoCard( + stringResource(R.string.label_unsupported_action, action.rawAction), + ) + } + Spacer(Modifier.height(12.dp)) + } +} + +@Composable +private fun IdentifyCard( + labelRes: Int, + hintRes: Int, + keyboardType: KeyboardType, + submitRes: Int, + onSubmit: (String) -> Unit, +) { + var value by rememberSaveable(labelRes) { mutableStateOf("") } + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text(stringResource(labelRes), style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = value, + onValueChange = { value = it }, + label = { Text(stringResource(hintRes)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { if (value.isNotBlank()) onSubmit(value.trim()) }, + ), + ) + Spacer(Modifier.height(12.dp)) + Button( + onClick = { onSubmit(value.trim()) }, + enabled = value.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(submitRes)) + } + } + } +} + +@Composable +private fun ChallengeEmailCard(identifier: String?, onChallenge: () -> Unit) { + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + stringResource(R.string.action_challenge_email), + style = MaterialTheme.typography.titleSmall, + ) + if (identifier != null) { + Spacer(Modifier.height(4.dp)) + Text(identifier, style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(12.dp)) + Button(onClick = onChallenge, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.action_challenge_email)) + } + } + } +} + +@Composable +private fun VerifyOtpCard(identifier: String?, onVerify: (String, OtpType) -> Unit) { + var code by rememberSaveable { mutableStateOf("") } + var type by remember { mutableStateOf(OtpType.OOB) } + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + stringResource(R.string.action_verify_otp), + style = MaterialTheme.typography.titleSmall, + ) + if (identifier != null) { + Spacer(Modifier.height(4.dp)) + Text(identifier, style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = type == OtpType.OOB, + onClick = { type = OtpType.OOB }, + label = { Text("OOB") }, + ) + FilterChip( + selected = type == OtpType.TOTP, + onClick = { type = OtpType.TOTP }, + label = { Text("TOTP") }, + ) + } + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = code, + onValueChange = { code = it }, + label = { Text(stringResource(R.string.hint_otp)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { if (code.isNotBlank()) onVerify(code.trim(), type) }, + ), + ) + Spacer(Modifier.height(12.dp)) + Button( + onClick = { onVerify(code.trim(), type) }, + enabled = code.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.action_verify)) + } + } + } +} + +@Composable +private fun InfoCard(text: String) { + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.Top) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + ) + } + } +} + +// The multi-step flow ends here; show only non-sensitive fields — never the token values. +private fun formatCredentials(credentials: Credentials): String = buildString { + appendLine("Authenticated ✓") + appendLine() + appendLine("token type: ${credentials.type}") + appendLine("scope: ${credentials.scope ?: "(none)"}") + appendLine("expires at: ${credentials.expiresAt}") + appendLine("refresh token: ${if (credentials.refreshToken != null) "present" else "absent"}") +} + +private fun formatError(error: EmbeddedAuthException): String = buildString { + appendLine("Authorize step failed.") + appendLine() + appendLine("code: ${error.code}") + appendLine("description: ${error.description}") + appendLine("HTTP status: ${error.statusCode}") + appendLine("network error: ${error.isNetworkError}") + // Surface the underlying cause chain — client-side parse errors hide here, not in code/description. + var cause = error.cause + if (cause != null) { + appendLine() + appendLine("cause:") + while (cause != null) { + appendLine(" • ${cause::class.java.simpleName}: ${cause.message}") + cause = cause.cause + } + } +} diff --git a/sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeUiState.kt b/sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeUiState.kt new file mode 100644 index 00000000..f39120a3 --- /dev/null +++ b/sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeUiState.kt @@ -0,0 +1,27 @@ +package com.auth0.sample.embedded + +import com.auth0.android.embedded.EmbeddedAuthException +import com.auth0.android.embedded.authorize.NextAction +import com.auth0.android.result.Credentials + +/** State of the multi-step embedded `/e/authorize` flow driven by [EmbeddedViewModel]. */ +sealed interface AuthorizeUiState { + + /** No flow in progress. */ + data object Idle : AuthorizeUiState + + /** A call is in flight. */ + data object Loading : AuthorizeUiState + + /** The server returned a continuation: these are the steps the user can take next. */ + data class ActionsAvailable(val actions: List) : AuthorizeUiState + + /** Terminal success: [verifyOtp][EmbeddedAuthClient.verifyOtp] yielded credentials. */ + data class Authenticated(val credentials: Credentials) : AuthorizeUiState + + /** Terminal failure (access denied, too many attempts, network error, …). */ + data class Failed(val error: EmbeddedAuthException) : AuthorizeUiState + + /** An unexpected non-continuation outcome, shown for diagnostics. */ + data class Message(val text: String) : AuthorizeUiState +} diff --git a/sample-embedded/src/main/java/com/auth0/sample/embedded/DiscoveryScreen.kt b/sample-embedded/src/main/java/com/auth0/sample/embedded/DiscoveryScreen.kt index 285d42c5..9f8ecb40 100644 --- a/sample-embedded/src/main/java/com/auth0/sample/embedded/DiscoveryScreen.kt +++ b/sample-embedded/src/main/java/com/auth0/sample/embedded/DiscoveryScreen.kt @@ -41,9 +41,12 @@ import com.auth0.android.embedded.EmbeddedAuthException @OptIn(ExperimentalMaterial3Api::class) @Composable -public fun EmbeddedScreen(viewModel: EmbeddedViewModel = viewModel()) { +public fun EmbeddedScreen( + viewModel: EmbeddedViewModel = viewModel(), + onAuthorize: (connection: String) -> Unit = {}, +) { val state by viewModel.uiState.collectAsStateWithLifecycle() - var connection by rememberSaveable { mutableStateOf("") } + var connection by rememberSaveable { mutableStateOf("Username-Password-Authentication") } val isLoading = state is DiscoveryUiState.Loading val runDiscovery = { viewModel.discover(connection.trim().ifBlank { null }) } @@ -51,7 +54,7 @@ public fun EmbeddedScreen(viewModel: EmbeddedViewModel = viewModel()) { Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(R.string.title_discovery)) }, + title = { Text(stringResource(R.string.title_screen)) }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.primary, titleContentColor = MaterialTheme.colorScheme.onPrimary, @@ -110,6 +113,8 @@ public fun EmbeddedScreen(viewModel: EmbeddedViewModel = viewModel()) { ResultCard( state = state, + connection = connection.trim(), + onAuthorize = onAuthorize, modifier = Modifier .fillMaxWidth() .weight(1f), @@ -119,7 +124,42 @@ public fun EmbeddedScreen(viewModel: EmbeddedViewModel = viewModel()) { } @Composable -private fun ResultCard(state: DiscoveryUiState, modifier: Modifier = Modifier) { +private fun ResultCard( + state: DiscoveryUiState, + modifier: Modifier = Modifier, + connection: String = "", + onAuthorize: (String) -> Unit = {}, +) { + // When discovery confirms embedded authorization is available, offer the authorize flow. + if (state is DiscoveryUiState.Success +// && state.result.hasEmbeddedAuthorization + ) { + OutlinedCard(modifier = modifier) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.status_authorize_available), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(20.dp)) + Button( + onClick = { onAuthorize(connection) }, + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + ) { + Text(stringResource(R.string.action_start_authorize)) + } + } + } + return + } + OutlinedCard(modifier = modifier) { val text = when (state) { DiscoveryUiState.Idle -> stringResource(R.string.status_idle_discovery) diff --git a/sample-embedded/src/main/java/com/auth0/sample/embedded/EmbeddedViewModel.kt b/sample-embedded/src/main/java/com/auth0/sample/embedded/EmbeddedViewModel.kt index 8fbee6c1..29216840 100644 --- a/sample-embedded/src/main/java/com/auth0/sample/embedded/EmbeddedViewModel.kt +++ b/sample-embedded/src/main/java/com/auth0/sample/embedded/EmbeddedViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.auth0.android.Auth0 import com.auth0.android.embedded.EmbeddedAuthClient import com.auth0.android.embedded.EmbeddedAuthException +import com.auth0.android.embedded.authorize.OtpType import com.auth0.android.request.DefaultClient import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -27,6 +28,8 @@ class EmbeddedViewModel(application: Application) : AndroidViewModel(application ) } + // region Discovery (/e/discovery) — retained for reference; not shown in the UI. + private val _uiState = MutableStateFlow(DiscoveryUiState.Idle) val uiState: StateFlow = _uiState.asStateFlow() @@ -40,4 +43,62 @@ class EmbeddedViewModel(application: Application) : AndroidViewModel(application } } } + + // endregion + + // region Authorize (/e/authorize) — the interactive multi-step flow. + + private val _authorizeState = MutableStateFlow(AuthorizeUiState.Idle) + val authorizeState: StateFlow = _authorizeState.asStateFlow() + + /** Kicks off the flow. Completes via a continuation (see [runStep]). */ + fun startAuthorize(connection: String): Unit = runStep { client.authorize(connection).await() } + + fun identifyEmail(email: String): Unit = runStep { client.identifyEmail(email).await() } + + fun identifyPhone(phone: String): Unit = runStep { client.identifyPhone(phone).await() } + + fun challengeEmail(index: Int): Unit = runStep { client.challengeEmail(index).await() } + + /** Terminal step: on success this yields [Credentials][com.auth0.android.result.Credentials]. */ + fun verifyOtp(code: String, type: OtpType): Unit { + _authorizeState.value = AuthorizeUiState.Loading + viewModelScope.launch { + _authorizeState.value = try { + AuthorizeUiState.Authenticated(client.verifyOtp(code, type).await()) + } catch (error: EmbeddedAuthException) { + error.toAuthorizeState() + } + } + } + + fun resetAuthorize() { + _authorizeState.value = AuthorizeUiState.Idle + } + + /** + * Runs a non-terminal step. These steps never resolve successfully — the server drives the + * flow forward by responding with `403 insufficient_authorization` carrying the next actions, + * which the SDK surfaces as an [EmbeddedAuthException]. A plain success here is unexpected. + */ + private fun runStep(step: suspend () -> Unit) { + _authorizeState.value = AuthorizeUiState.Loading + viewModelScope.launch { + _authorizeState.value = try { + step() + AuthorizeUiState.Message("Step completed without a continuation.") + } catch (error: EmbeddedAuthException) { + error.toAuthorizeState() + } + } + } + + private fun EmbeddedAuthException.toAuthorizeState(): AuthorizeUiState = + if (isInsufficientAuthorization && nextActions.isNotEmpty()) { + AuthorizeUiState.ActionsAvailable(nextActions) + } else { + AuthorizeUiState.Failed(this) + } + + // endregion } diff --git a/sample-embedded/src/main/java/com/auth0/sample/embedded/MainActivity.kt b/sample-embedded/src/main/java/com/auth0/sample/embedded/MainActivity.kt index ccf2f508..ba643455 100644 --- a/sample-embedded/src/main/java/com/auth0/sample/embedded/MainActivity.kt +++ b/sample-embedded/src/main/java/com/auth0/sample/embedded/MainActivity.kt @@ -3,6 +3,12 @@ package com.auth0.sample.embedded import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.lifecycle.viewmodel.compose.viewModel import com.auth0.sample.embedded.ui.theme.EmbeddedDiscoveryTheme public class MainActivity : ComponentActivity() { @@ -10,8 +16,33 @@ public class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) setContent { EmbeddedDiscoveryTheme { - EmbeddedScreen() + EmbeddedApp() } } } } + +@Composable +private fun EmbeddedApp(viewModel: EmbeddedViewModel = viewModel()) { + var showAuthorize by rememberSaveable { mutableStateOf(false) } + var authorizeConnection by rememberSaveable { mutableStateOf("Username-Password-Authentication") } + + if (showAuthorize) { + AuthorizeScreen( + viewModel = viewModel, + initialConnection = authorizeConnection, + onBack = { + viewModel.resetAuthorize() + showAuthorize = false + }, + ) + } else { + EmbeddedScreen( + viewModel = viewModel, + onAuthorize = { connection -> + authorizeConnection = connection + showAuthorize = true + }, + ) + } +} diff --git a/sample-embedded/src/main/res/values/strings.xml b/sample-embedded/src/main/res/values/strings.xml index 20e4ac81..83f8262d 100644 --- a/sample-embedded/src/main/res/values/strings.xml +++ b/sample-embedded/src/main/res/values/strings.xml @@ -2,14 +2,32 @@ Auth0 Embedded Discovery - DOMAIN - CLIENT_ID + prince.test-aws-alien-lovebird-260921.auth0c.com + gZhGB6VKrO0pfJjiLPbJHcFfMJzzJfvB demo - Embedded (/e/discovery) + Embedded Connection (optional) Discover Result Enter a connection (optional) and tap Discover. Discovering… + + Embedded authorization is available for this connection. Tap below to start the flow. + Connection + Start Authorize + Working… + Enter a connection and tap Start Authorize. + Choose how to continue + Start over + Email + Phone number + One-time code + Identify with email + Identify with phone + Email me a code + Verify code + Submit + Verify + Unsupported action: %1$s From 1f9212afb3963c84548d03dfa966d8e288947bab Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 21 Sep 2026 19:08:58 +0530 Subject: [PATCH 07/15] Added examples.md file --- EXAMPLES.md | 1 + examples/embedded-auth/authorize.md | 184 ++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 examples/embedded-auth/authorize.md diff --git a/EXAMPLES.md b/EXAMPLES.md index 90201853..bc3b961c 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -31,6 +31,7 @@ Each topic lives in its own file under [`examples/`](examples). ## Embedded Authentication (EA) - [Discovery](examples/embedded-auth/discovery.md) +- [Authorize (email OTP flow)](examples/embedded-auth/authorize.md) ## Other APIs and features diff --git a/examples/embedded-auth/authorize.md b/examples/embedded-auth/authorize.md new file mode 100644 index 00000000..5b36132d --- /dev/null +++ b/examples/embedded-auth/authorize.md @@ -0,0 +1,184 @@ +### Embedded Authorization (EA) + +> [!IMPORTANT] +> Embedded Authorization is currently in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to get it enabled for your tenant. + +The embedded authorization flow (`/e/authorize`) lets your app authenticate users without leaving the app for a browser. The server drives the flow step-by-step: each call completes through an `EmbeddedAuthException` that carries the next action to take, until the terminal call succeeds and returns `Credentials`. + +The flow is served by `EmbeddedAuthClient`, created from an `Auth0` instance: + +```kotlin +val account = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") +val client = EmbeddedAuthClient(account) +``` + +#### Start the flow + +Call `authorize()` with the connection name. The `scope` defaults to `"openid profile email offline_access"`; pass a custom value if you need a different scope or an `audience`. + +```kotlin +try { + client.authorize("Username-Password-Authentication").await() +} catch (e: EmbeddedAuthException) { + if (e.isInsufficientAuthorization) { + // Flow is in progress — inspect e.nextActions for the next step. + } else { + // Terminal error — e.g. access_denied, network failure. + } +} +``` + +With custom scope and audience: + +```kotlin +client.authorize( + connection = "Username-Password-Authentication", + scope = "openid profile email offline_access", + audience = "https://api.example.com" +).await() +``` + +#### Handle next actions + +Each non-terminal step throws `EmbeddedAuthException` with `isInsufficientAuthorization = true` and a list of `NextAction` entries describing what the server will accept next. Iterate them to build your UI: + +```kotlin +for (action in exception.nextActions) { + when (action) { + is NextAction.IdentifyEmail -> { /* show email field, call identifyEmail() */ } + is NextAction.IdentifyPhone -> { /* show phone field, call identifyPhone() */ } + is NextAction.ChallengeEmail -> { /* show "send code" button, call challengeEmail(action.index ?: 0) */ } + is NextAction.VerifyOtp -> { /* show OTP field, call verifyOtp() */ } + is NextAction.Unknown -> { /* unsupported — skip or show disabled */ } + } +} +``` + +#### Identify the user + +After `authorize()` returns a continuation with `NextAction.IdentifyEmail`: + +```kotlin +try { + client.identifyEmail("jane@example.com").await() +} catch (e: EmbeddedAuthException) { + if (e.isInsufficientAuthorization) { + // Continue with e.nextActions (e.g. ChallengeEmail or VerifyOtp). + } +} +``` + +Or by phone number when `NextAction.IdentifyPhone` is present: + +```kotlin +client.identifyPhone("+15550001234").await() +``` + +#### Request an email challenge + +When `NextAction.ChallengeEmail` is present, ask the server to send a one-time code: + +```kotlin +// action.index selects which email authenticator to challenge (defaults to 0). +client.challengeEmail(action.index ?: 0).await() +``` + +#### Verify the one-time code + +`verifyOtp` is a terminal step. On success, it returns `Credentials` directly without throwing: + +```kotlin +val credentials = client.verifyOtp( + code = "123456", + type = OtpType.OOB // OOB for emailed codes; TOTP for authenticator-app codes +).await() + +// credentials.accessToken, credentials.idToken, etc. are now available. +``` + +If the server still requires further steps it throws `EmbeddedAuthException` with `isInsufficientAuthorization = true`, just like the earlier steps. + +#### Terminal errors + +Some errors end the flow and must not be retried without restarting from `authorize()`: + +```kotlin +when { + e.isAccessDenied -> { /* deny — do not retry */ } + e.isTooManyAttempts -> { /* too many failed OTP attempts */ } + e.isTooManyLogins -> { /* too many login attempts */ } + e.isNetworkError -> { /* transient — safe to retry the same step */ } +} +``` + +
+ Using callbacks + +```kotlin +client + .authorize("Username-Password-Authentication") + .start(object : Callback { + override fun onSuccess(result: Void?) { + // Unexpected — authorize always continues through onFailure. + } + override fun onFailure(exception: EmbeddedAuthException) { + if (exception.isInsufficientAuthorization) { + // Handle exception.nextActions. + } + } + }) +``` + +Terminal step with callbacks: + +```kotlin +client + .verifyOtp("123456", OtpType.OOB) + .start(object : Callback { + override fun onSuccess(result: Credentials) { + // Authenticated. + } + override fun onFailure(exception: EmbeddedAuthException) { + // Handle continuation or terminal error. + } + }) +``` +
+ +
+ Using Java + +```java +Auth0 account = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN"); +EmbeddedAuthClient client = new EmbeddedAuthClient(account); + +client + .authorize("Username-Password-Authentication") + .start(new Callback() { + @Override + public void onSuccess(Void result) { } + + @Override + public void onFailure(@NonNull EmbeddedAuthException e) { + if (e.isInsufficientAuthorization()) { + for (NextAction action : e.getNextActions()) { + // Handle each action. + } + } + } + }); + +// Terminal step +client + .verifyOtp("123456", OtpType.OOB) + .start(new Callback() { + @Override + public void onSuccess(Credentials credentials) { + // Authenticated. + } + + @Override + public void onFailure(@NonNull EmbeddedAuthException e) { } + }); +``` +
From a5bb721c08c589af0a13c7571763f37e20791c26 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Tue, 22 Sep 2026 14:38:33 +0530 Subject: [PATCH 08/15] Made index defaults to 0 --- .../java/com/auth0/android/embedded/authorize/NextAction.kt | 2 +- .../auth0/android/embedded/authorize/NextActionMapper.kt | 2 +- examples/embedded-auth/authorize.md | 6 +++--- .../main/java/com/auth0/sample/embedded/AuthorizeScreen.kt | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt index fcb5bee0..a000adf4 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt @@ -21,7 +21,7 @@ public sealed interface NextAction { /** Continue by requesting an email challenge. Act on it with [EmbeddedAuthClient.challengeEmail]. */ @ConsistentCopyVisibility public data class ChallengeEmail internal constructor( - public val index: Int?, + public val index: Int, public val identifier: String? ) : NextAction { override val action: EmbeddedAction = EmbeddedAction.CHALLENGE_EMAIL diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/NextActionMapper.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextActionMapper.kt index 94201cdf..a847b5f9 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/authorize/NextActionMapper.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextActionMapper.kt @@ -13,7 +13,7 @@ private fun Map.toNextAction(): NextAction? { EmbeddedAction.IDENTIFY_EMAIL -> NextAction.IdentifyEmail EmbeddedAction.IDENTIFY_PHONE -> NextAction.IdentifyPhone EmbeddedAction.CHALLENGE_EMAIL -> NextAction.ChallengeEmail( - index = (this[INDEX_KEY] as? Number)?.toInt(), + index = (this[INDEX_KEY] as? Number)?.toInt() ?: 0, identifier = this[IDENTIFIER_KEY] as? String ) EmbeddedAction.VERIFY_OTP -> NextAction.VerifyOtp( diff --git a/examples/embedded-auth/authorize.md b/examples/embedded-auth/authorize.md index 5b36132d..84796d80 100644 --- a/examples/embedded-auth/authorize.md +++ b/examples/embedded-auth/authorize.md @@ -47,7 +47,7 @@ for (action in exception.nextActions) { when (action) { is NextAction.IdentifyEmail -> { /* show email field, call identifyEmail() */ } is NextAction.IdentifyPhone -> { /* show phone field, call identifyPhone() */ } - is NextAction.ChallengeEmail -> { /* show "send code" button, call challengeEmail(action.index ?: 0) */ } + is NextAction.ChallengeEmail -> { /* show "send code" button, call challengeEmail(action.index) */ } is NextAction.VerifyOtp -> { /* show OTP field, call verifyOtp() */ } is NextAction.Unknown -> { /* unsupported — skip or show disabled */ } } @@ -79,8 +79,8 @@ client.identifyPhone("+15550001234").await() When `NextAction.ChallengeEmail` is present, ask the server to send a one-time code: ```kotlin -// action.index selects which email authenticator to challenge (defaults to 0). -client.challengeEmail(action.index ?: 0).await() +// action.index selects which email authenticator to challenge (0 if not specified by the server). +client.challengeEmail(action.index).await() ``` #### Verify the one-time code diff --git a/sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeScreen.kt b/sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeScreen.kt index ef5cc793..00581f8d 100644 --- a/sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeScreen.kt +++ b/sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeScreen.kt @@ -173,7 +173,7 @@ private fun ActionsSection(actions: List, viewModel: EmbeddedViewMod is NextAction.ChallengeEmail -> ChallengeEmailCard( identifier = action.identifier, - onChallenge = { viewModel.challengeEmail(action.index ?: 0) }, + onChallenge = { viewModel.challengeEmail(action.index) }, ) is NextAction.VerifyOtp -> VerifyOtpCard( From ee600f50ff404d39383224d1eb5a7a03f9a99356 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 23 Sep 2026 11:56:55 +0530 Subject: [PATCH 09/15] Made the OtpChannel an enum type --- .../android/embedded/authorize/NextAction.kt | 2 +- .../embedded/authorize/NextActionMapper.kt | 2 +- .../android/embedded/authorize/OtpChannel.kt | 15 ++++++++++++ .../embedded/EmbeddedAuthClientTest.kt | 23 ++++++++++++++++++- .../android/util/EmbeddedAuthMockServer.kt | 2 ++ 5 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/embedded/authorize/OtpChannel.kt diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt index a000adf4..8dd468a1 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt @@ -30,7 +30,7 @@ public sealed interface NextAction { /** Continue by verifying a one-time code. Act on it with [EmbeddedAuthClient.verifyOtp]. */ @ConsistentCopyVisibility public data class VerifyOtp internal constructor( - public val channel: String?, + public val channel: OtpChannel?, public val identifier: String? ) : NextAction { override val action: EmbeddedAction = EmbeddedAction.VERIFY_OTP diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/NextActionMapper.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextActionMapper.kt index a847b5f9..772e32e5 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/authorize/NextActionMapper.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextActionMapper.kt @@ -17,7 +17,7 @@ private fun Map.toNextAction(): NextAction? { identifier = this[IDENTIFIER_KEY] as? String ) EmbeddedAction.VERIFY_OTP -> NextAction.VerifyOtp( - channel = this[CHANNEL_KEY] as? String, + channel = OtpChannel.fromValue(this[CHANNEL_KEY] as? String), identifier = this[IDENTIFIER_KEY] as? String ) EmbeddedAction.UNKNOWN -> NextAction.Unknown(raw) diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/OtpChannel.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/OtpChannel.kt new file mode 100644 index 00000000..bb8d1ffc --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/OtpChannel.kt @@ -0,0 +1,15 @@ +package com.auth0.android.embedded.authorize + +/** The channel a one-time code is delivered over, as reported on [NextAction.VerifyOtp.channel]. */ +public enum class OtpChannel(public val value: String) { + SMS("sms"), + VOICE("voice"), + EMAIL("email"), + TOTP("totp"); + + internal companion object { + /** Maps the raw `channel` string to an [OtpChannel], or `null` when absent or unrecognised. */ + fun fromValue(value: String?): OtpChannel? = + entries.firstOrNull { it.value.equals(value, ignoreCase = true) } + } +} diff --git a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt index afebfe49..1373934d 100644 --- a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt @@ -3,6 +3,7 @@ package com.auth0.android.embedded import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception import com.auth0.android.embedded.authorize.NextAction +import com.auth0.android.embedded.authorize.OtpChannel import com.auth0.android.embedded.authorize.OtpType import com.auth0.android.embedded.discovery.GrantType import com.auth0.android.util.EmbeddedAuthMockServer @@ -423,10 +424,30 @@ public class EmbeddedAuthClientTest { val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } val action = error.nextActions[0] as NextAction.VerifyOtp - assertThat(action.channel, `is`("email")) + assertThat(action.channel, `is`(OtpChannel.EMAIL)) assertThat(action.identifier, `is`(EmbeddedAuthMockServer.IDENTIFIER)) } + @Test + public fun `authorize continuation maps an unrecognised OTP channel to a null channel`() { + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_VERIFY_OTP_UNKNOWN_CHANNEL) + + val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } + + val action = error.nextActions[0] as NextAction.VerifyOtp + assertThat(action.channel, `is`(nullValue())) + } + + @Test + public fun `authorize continuation maps the OTP channel case insensitively`() { + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_VERIFY_OTP_MIXED_CASE_CHANNEL) + + val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } + + val action = error.nextActions[0] as NextAction.VerifyOtp + assertThat(action.channel, `is`(OtpChannel.SMS)) + } + @Test public fun `authorize continuation surfaces Unknown next action for unrecognised actions`() { mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_UNKNOWN) diff --git a/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt b/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt index d54b2a5a..f9e093ef 100644 --- a/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt @@ -184,6 +184,8 @@ $actionsJson const val NEXT_IDENTIFY_PHONE = """{ "action": "action:identify:phone:v1" }""" const val NEXT_CHALLENGE_EMAIL = """{ "action": "action:challenge:email:v1", "index": 1, "identifier": "jane@example.com" }""" const val NEXT_VERIFY_OTP = """{ "action": "action:verify:otp:v1", "channel": "email", "identifier": "jane@example.com" }""" + const val NEXT_VERIFY_OTP_UNKNOWN_CHANNEL = """{ "action": "action:verify:otp:v1", "channel": "carrier-pigeon", "identifier": "jane@example.com" }""" + const val NEXT_VERIFY_OTP_MIXED_CASE_CHANNEL = """{ "action": "action:verify:otp:v1", "channel": "SmS", "identifier": "jane@example.com" }""" const val NEXT_UNKNOWN = """{ "action": "action:future:unknown:v1" }""" const val IDENTIFIER = "jane@example.com" const val AUTHORIZATION_CODE = "the-authorization-code" From daf99675bc98af0578d7f80544b432a170a85f81 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 23 Sep 2026 12:16:22 +0530 Subject: [PATCH 10/15] Added few more helpers for the error cases --- .../android/embedded/EmbeddedAuthException.kt | 16 ++++++++ .../embedded/EmbeddedAuthClientTest.kt | 37 +++++++++++++++++++ .../android/util/EmbeddedAuthMockServer.kt | 20 ++++++++++ examples/embedded-auth/authorize.md | 21 ++++++++--- 4 files changed, 89 insertions(+), 5 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthException.kt b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthException.kt index c677ccf3..d1aebdbd 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthException.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthException.kt @@ -31,10 +31,23 @@ public class EmbeddedAuthException internal constructor( public val isInsufficientAuthorization: Boolean get() = code == INSUFFICIENT_AUTHORIZATION + /** Recoverable: the submitted code or identifier was wrong — let the user retry with [nextActions]. */ + public val isInvalidCode: Boolean + get() = code == INSUFFICIENT_AUTHORIZATION && + description in INVALID_CODE_DESCRIPTIONS + /** Terminal: the attempt was denied and must not be retried. */ public val isAccessDenied: Boolean get() = code == ACCESS_DENIED + /** Terminal: the challenge was denied after too many wrong one-time-code attempts. */ + public val isTooManyWrongOtpAttempts: Boolean + get() = code == ACCESS_DENIED && description == TOO_MANY_WRONG_OTP_ATTEMPTS + + /** Terminal: the challenge expired before it was verified. */ + public val isChallengeExpired: Boolean + get() = code == ACCESS_DENIED && description == CHALLENGE_EXPIRED + /** Terminal: too many failed verification attempts. */ public val isTooManyAttempts: Boolean get() = statusCode == TOO_MANY_REQUESTS_STATUS && @@ -48,6 +61,9 @@ public class EmbeddedAuthException internal constructor( private companion object { private const val INSUFFICIENT_AUTHORIZATION = "insufficient_authorization" private const val ACCESS_DENIED = "access_denied" + private const val TOO_MANY_WRONG_OTP_ATTEMPTS = "too_many_wrong_otp_attempts" + private const val CHALLENGE_EXPIRED = "challenge_expired" + private val INVALID_CODE_DESCRIPTIONS = setOf("invalid_code", "invalid_identifier_or_code") private const val TOO_MANY_REQUESTS = "too_many_requests" private const val TOO_MANY_ATTEMPTS = "too_many_attempts" private const val TOO_MANY_LOGINS = "too_many_logins" diff --git a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt index 1373934d..5bf3cc6c 100644 --- a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt @@ -13,6 +13,7 @@ import okhttp3.mockwebserver.RecordedRequest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.containsInAnyOrder import org.hamcrest.Matchers.empty +import org.hamcrest.Matchers.not import org.hamcrest.Matchers.hasSize import org.hamcrest.Matchers.instanceOf import org.hamcrest.Matchers.`is` @@ -503,6 +504,42 @@ public class EmbeddedAuthClientTest { assertThat(error.statusCode, `is`(429)) } + @Test + public fun `verifyOtp flags a wrong code as recoverable with retry actions`() { + establishSession() + mockAPI.willReturnInvalidCode() + + val error = assertEmbeddedError { client.verifyOtp("000000").execute() } + + assertThat(error.isInvalidCode, `is`(true)) + assertThat(error.isInsufficientAuthorization, `is`(true)) + assertThat(error.isAccessDenied, `is`(false)) + assertThat(error.nextActions, `is`(not(empty()))) + } + + @Test + public fun `verifyOtp flags too many wrong OTP attempts as terminal`() { + establishSession() + mockAPI.willReturnTooManyWrongOtpAttempts() + + val error = assertEmbeddedError { client.verifyOtp("000000").execute() } + + assertThat(error.isTooManyWrongOtpAttempts, `is`(true)) + assertThat(error.isAccessDenied, `is`(true)) + assertThat(error.isInvalidCode, `is`(false)) + } + + @Test + public fun `verifyOtp flags an expired challenge as terminal`() { + establishSession() + mockAPI.willReturnChallengeExpired() + + val error = assertEmbeddedError { client.verifyOtp("000000").execute() } + + assertThat(error.isChallengeExpired, `is`(true)) + assertThat(error.isAccessDenied, `is`(true)) + } + @Test public fun `identifyEmail continuation sets isInsufficientAuthorization and populates nextActions`() { establishSession() diff --git a/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt b/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt index f9e093ef..edf83317 100644 --- a/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt @@ -119,6 +119,26 @@ $actionsJson return this } + fun willReturnInvalidCode(): EmbeddedAuthMockServer { + server.enqueue( + responseWithJSON( + """{ "error": "insufficient_authorization", "error_description": "invalid_code", "auth_session": "$ROTATED_AUTH_SESSION", "next": [ $NEXT_VERIFY_OTP ] }""", + 403 + ) + ) + return this + } + + fun willReturnTooManyWrongOtpAttempts(): EmbeddedAuthMockServer { + server.enqueue(responseWithJSON("""{ "error": "access_denied", "error_description": "too_many_wrong_otp_attempts" }""", 403)) + return this + } + + fun willReturnChallengeExpired(): EmbeddedAuthMockServer { + server.enqueue(responseWithJSON("""{ "error": "access_denied", "error_description": "challenge_expired" }""", 403)) + return this + } + fun willReturnTooManyAttempts(): EmbeddedAuthMockServer { server.enqueue(responseWithJSON("""{ "error": "too_many_requests", "error_description": "too_many_attempts" }""", 429)) return this diff --git a/examples/embedded-auth/authorize.md b/examples/embedded-auth/authorize.md index 84796d80..038f6659 100644 --- a/examples/embedded-auth/authorize.md +++ b/examples/embedded-auth/authorize.md @@ -96,7 +96,14 @@ val credentials = client.verifyOtp( // credentials.accessToken, credentials.idToken, etc. are now available. ``` -If the server still requires further steps it throws `EmbeddedAuthException` with `isInsufficientAuthorization = true`, just like the earlier steps. +If the server still requires further steps it throws `EmbeddedAuthException` with `isInsufficientAuthorization = true`, just like the earlier steps. When the user submitted a wrong code, `isInvalidCode` is `true` on that same continuation — the flow is still recoverable, so re-prompt and retry with the `nextActions` the exception carries: + +```kotlin +if (e.isInsufficientAuthorization) { + if (e.isInvalidCode) { /* wrong code — re-prompt the user */ } + // Continue with e.nextActions. +} +``` #### Terminal errors @@ -104,13 +111,17 @@ Some errors end the flow and must not be retried without restarting from `author ```kotlin when { - e.isAccessDenied -> { /* deny — do not retry */ } - e.isTooManyAttempts -> { /* too many failed OTP attempts */ } - e.isTooManyLogins -> { /* too many login attempts */ } - e.isNetworkError -> { /* transient — safe to retry the same step */ } + e.isTooManyWrongOtpAttempts -> { /* too many wrong codes — flow denied */ } + e.isChallengeExpired -> { /* the code expired — restart the flow */ } + e.isAccessDenied -> { /* denied for another reason — do not retry */ } + e.isTooManyAttempts -> { /* too many failed OTP attempts */ } + e.isTooManyLogins -> { /* too many login attempts */ } + e.isNetworkError -> { /* transient — safe to retry the same step */ } } ``` +`isTooManyWrongOtpAttempts` and `isChallengeExpired` are specific cases of `isAccessDenied`, so check them first. +
Using callbacks From 5e41d9a0bb3a3fd0cbe326b14e19ec7440e33c00 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 24 Sep 2026 14:43:19 +0530 Subject: [PATCH 11/15] Addressed few chnages with respect to the updated spec --- .../android/embedded/authorize/NextAction.kt | 4 +-- .../embedded/authorize/NextActionMapper.kt | 34 ++++++++++++++----- .../embedded/authorize/PhoneDeliveryMethod.kt | 18 ++++++++++ .../embedded/discovery/DiscoveryMapper.kt | 9 +++-- .../embedded/discovery/DiscoveryResponse.kt | 2 +- .../embedded/EmbeddedAuthClientTest.kt | 32 +++++++++++++++-- .../embedded/discovery/DiscoveryMapperTest.kt | 5 --- .../android/util/EmbeddedAuthMockServer.kt | 3 ++ .../auth0/sample/embedded/AuthorizeScreen.kt | 8 ++--- 9 files changed, 86 insertions(+), 29 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/embedded/authorize/PhoneDeliveryMethod.kt diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt index 8dd468a1..ee2602bb 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt @@ -22,7 +22,7 @@ public sealed interface NextAction { @ConsistentCopyVisibility public data class ChallengeEmail internal constructor( public val index: Int, - public val identifier: String? + public val identifier: String ) : NextAction { override val action: EmbeddedAction = EmbeddedAction.CHALLENGE_EMAIL } @@ -30,7 +30,7 @@ public sealed interface NextAction { /** Continue by verifying a one-time code. Act on it with [EmbeddedAuthClient.verifyOtp]. */ @ConsistentCopyVisibility public data class VerifyOtp internal constructor( - public val channel: OtpChannel?, + public val channel: OtpChannel, public val identifier: String? ) : NextAction { override val action: EmbeddedAction = EmbeddedAction.VERIFY_OTP diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/NextActionMapper.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextActionMapper.kt index 772e32e5..9f492bc4 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/authorize/NextActionMapper.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextActionMapper.kt @@ -1,5 +1,9 @@ package com.auth0.android.embedded.authorize +import android.util.Log + +private const val TAG = "NextActionMapper" + private const val ACTION_KEY = "action" private const val CHANNEL_KEY = "channel" private const val IDENTIFIER_KEY = "identifier" @@ -12,14 +16,28 @@ private fun Map.toNextAction(): NextAction? { return when (EmbeddedAction.fromValue(raw)) { EmbeddedAction.IDENTIFY_EMAIL -> NextAction.IdentifyEmail EmbeddedAction.IDENTIFY_PHONE -> NextAction.IdentifyPhone - EmbeddedAction.CHALLENGE_EMAIL -> NextAction.ChallengeEmail( - index = (this[INDEX_KEY] as? Number)?.toInt() ?: 0, - identifier = this[IDENTIFIER_KEY] as? String - ) - EmbeddedAction.VERIFY_OTP -> NextAction.VerifyOtp( - channel = OtpChannel.fromValue(this[CHANNEL_KEY] as? String), - identifier = this[IDENTIFIER_KEY] as? String - ) + EmbeddedAction.CHALLENGE_EMAIL -> { + val index = (this[INDEX_KEY] as? Number)?.toInt() ?: return dropped(raw, INDEX_KEY) + val identifier = this[IDENTIFIER_KEY] as? String ?: return dropped(raw, IDENTIFIER_KEY) + NextAction.ChallengeEmail(index = index, identifier = identifier) + } + EmbeddedAction.VERIFY_OTP -> { + val channel = OtpChannel.fromValue(this[CHANNEL_KEY] as? String) + ?: return dropped(raw, CHANNEL_KEY) + NextAction.VerifyOtp( + channel = channel, + identifier = this[IDENTIFIER_KEY] as? String + ) + } EmbeddedAction.UNKNOWN -> NextAction.Unknown(raw) } } + +/** + * Logs and drops a recognised next action whose required [field] the server omitted or sent + * malformed. Returns `null` so the entry is filtered out by [toNextActions]. + */ +private fun dropped(action: String, field: String): NextAction? { + Log.w(TAG, "Dropping \"$action\" next action: required field \"$field\" was missing or invalid.") + return null +} diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/PhoneDeliveryMethod.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/PhoneDeliveryMethod.kt new file mode 100644 index 00000000..7377479e --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/PhoneDeliveryMethod.kt @@ -0,0 +1,18 @@ +package com.auth0.android.embedded.authorize + +import com.auth0.android.embedded.EmbeddedAuthClient + +/** How a phone one-time code is delivered, chosen when calling [EmbeddedAuthClient.challengePhone]. */ +public enum class PhoneDeliveryMethod(public val value: String) { + /** Deliver the code as an SMS text message. */ + TEXT("text"), + + /** Deliver the code by a voice call. */ + VOICE("voice"); + + internal companion object { + /** Maps the raw `delivery_method` string to a [PhoneDeliveryMethod], or `null` when absent or unrecognised. */ + fun fromValue(value: String?): PhoneDeliveryMethod? = + entries.firstOrNull { it.value.equals(value, ignoreCase = true) } + } +} diff --git a/auth0/src/main/java/com/auth0/android/embedded/discovery/DiscoveryMapper.kt b/auth0/src/main/java/com/auth0/android/embedded/discovery/DiscoveryMapper.kt index c68372e4..b695cb2e 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/discovery/DiscoveryMapper.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/discovery/DiscoveryMapper.kt @@ -3,18 +3,17 @@ package com.auth0.android.embedded.discovery /** * Translates the `GET /e/discovery` wire payload into the public [DiscoveryResult]. * - * An entry the SDK does not recognise becomes [LoginOption.Unknown] rather than being dropped, and + * An entry the SDK does not recognize becomes [LoginOption.Unknown] rather than being dropped, and * no single entry can fail the whole response. */ internal fun DiscoveryResponse.toDiscoveryResult(): DiscoveryResult = - DiscoveryResult(alternatives.orEmpty().mapNotNull { it.toLoginOption() }) + DiscoveryResult(alternatives.mapNotNull { it.toLoginOption() }) /** - * Maps one wire entry to its [LoginOption], or `null` if it named no grant type or omitted a - * property its variant needs to be usable. + * Maps one wire entry to its [LoginOption], or `null` if it omitted a property its variant needs + * to be usable. */ internal fun Alternative.toLoginOption(): LoginOption? { - val grantType = grantType ?: return null return when (grantType) { GRANT_PASSWORD -> LoginOption.Password diff --git a/auth0/src/main/java/com/auth0/android/embedded/discovery/DiscoveryResponse.kt b/auth0/src/main/java/com/auth0/android/embedded/discovery/DiscoveryResponse.kt index de22ddc0..c522e805 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/discovery/DiscoveryResponse.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/discovery/DiscoveryResponse.kt @@ -26,7 +26,7 @@ internal data class DiscoveryResponse( */ internal data class Alternative( @SerializedName("grant_type") - val grantType: String?, + val grantType: String, @SerializedName("type") val type: String? = null, diff --git a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt index 5bf3cc6c..6ff99585 100644 --- a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt @@ -430,13 +430,39 @@ public class EmbeddedAuthClientTest { } @Test - public fun `authorize continuation maps an unrecognised OTP channel to a null channel`() { + public fun `authorize continuation drops a VerifyOtp action with an unrecognised channel`() { mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_VERIFY_OTP_UNKNOWN_CHANNEL) val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } - val action = error.nextActions[0] as NextAction.VerifyOtp - assertThat(action.channel, `is`(nullValue())) + assertThat(error.nextActions, `is`(empty())) + } + + @Test + public fun `authorize continuation drops a VerifyOtp action without a channel`() { + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_VERIFY_OTP_NO_CHANNEL) + + val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } + + assertThat(error.nextActions, `is`(empty())) + } + + @Test + public fun `authorize continuation drops a ChallengeEmail action without an identifier`() { + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_CHALLENGE_EMAIL_NO_IDENTIFIER) + + val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } + + assertThat(error.nextActions, `is`(empty())) + } + + @Test + public fun `authorize continuation drops a ChallengeEmail action without an index`() { + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_CHALLENGE_EMAIL_NO_INDEX) + + val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } + + assertThat(error.nextActions, `is`(empty())) } @Test diff --git a/auth0/src/test/java/com/auth0/android/embedded/discovery/DiscoveryMapperTest.kt b/auth0/src/test/java/com/auth0/android/embedded/discovery/DiscoveryMapperTest.kt index e3a26da9..297e0761 100644 --- a/auth0/src/test/java/com/auth0/android/embedded/discovery/DiscoveryMapperTest.kt +++ b/auth0/src/test/java/com/auth0/android/embedded/discovery/DiscoveryMapperTest.kt @@ -144,11 +144,6 @@ public class DiscoveryMapperTest { assertThat((option as LoginOption.Unknown).connection, `is`("r")) } - @Test - public fun `an entry without a grant type is dropped`() { - assertThat(Alternative(grantType = null).toLoginOption(), `is`(nullValue())) - } - @Test public fun `a known grant missing a required property is dropped`() { assertThat(Alternative(grantType = GRANT_PASSWORD_REALM).toLoginOption(), `is`(nullValue())) diff --git a/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt b/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt index edf83317..384cb904 100644 --- a/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt @@ -205,7 +205,10 @@ $actionsJson const val NEXT_CHALLENGE_EMAIL = """{ "action": "action:challenge:email:v1", "index": 1, "identifier": "jane@example.com" }""" const val NEXT_VERIFY_OTP = """{ "action": "action:verify:otp:v1", "channel": "email", "identifier": "jane@example.com" }""" const val NEXT_VERIFY_OTP_UNKNOWN_CHANNEL = """{ "action": "action:verify:otp:v1", "channel": "carrier-pigeon", "identifier": "jane@example.com" }""" + const val NEXT_VERIFY_OTP_NO_CHANNEL = """{ "action": "action:verify:otp:v1", "identifier": "jane@example.com" }""" const val NEXT_VERIFY_OTP_MIXED_CASE_CHANNEL = """{ "action": "action:verify:otp:v1", "channel": "SmS", "identifier": "jane@example.com" }""" + const val NEXT_CHALLENGE_EMAIL_NO_IDENTIFIER = """{ "action": "action:challenge:email:v1", "index": 1 }""" + const val NEXT_CHALLENGE_EMAIL_NO_INDEX = """{ "action": "action:challenge:email:v1", "identifier": "jane@example.com" }""" const val NEXT_UNKNOWN = """{ "action": "action:future:unknown:v1" }""" const val IDENTIFIER = "jane@example.com" const val AUTHORIZATION_CODE = "the-authorization-code" diff --git a/sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeScreen.kt b/sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeScreen.kt index 00581f8d..708d9224 100644 --- a/sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeScreen.kt +++ b/sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeScreen.kt @@ -229,17 +229,15 @@ private fun IdentifyCard( } @Composable -private fun ChallengeEmailCard(identifier: String?, onChallenge: () -> Unit) { +private fun ChallengeEmailCard(identifier: String, onChallenge: () -> Unit) { OutlinedCard(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { Text( stringResource(R.string.action_challenge_email), style = MaterialTheme.typography.titleSmall, ) - if (identifier != null) { - Spacer(Modifier.height(4.dp)) - Text(identifier, style = MaterialTheme.typography.bodySmall) - } + Spacer(Modifier.height(4.dp)) + Text(identifier, style = MaterialTheme.typography.bodySmall) Spacer(Modifier.height(12.dp)) Button(onClick = onChallenge, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.action_challenge_email)) From a4f4fbf2b0a62bd9e71ef9efff9897d2ff8a118b Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 28 Sep 2026 08:44:15 +0530 Subject: [PATCH 12/15] Made indentify phone internal --- .../java/com/auth0/android/embedded/EmbeddedAuthClient.kt | 2 +- .../java/com/auth0/sample/embedded/AuthorizeScreen.kt | 8 ++------ .../java/com/auth0/sample/embedded/EmbeddedViewModel.kt | 2 -- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt index 6a7cd5da..5a9a085e 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt @@ -120,7 +120,7 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { * This call never resolves successfully; it completes through [EmbeddedAuthException] whose * [EmbeddedAuthException.nextActions] carry the next step to call. */ - public fun identifyPhone(phone: String): Request = + internal fun identifyPhone(phone: String): Request = continueStep(EmbeddedAction.IDENTIFY_PHONE) { addParameter(PHONE_KEY, phone) } /** diff --git a/sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeScreen.kt b/sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeScreen.kt index 708d9224..e3b844fc 100644 --- a/sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeScreen.kt +++ b/sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeScreen.kt @@ -163,12 +163,8 @@ private fun ActionsSection(actions: List, viewModel: EmbeddedViewMod onSubmit = { viewModel.identifyEmail(it) }, ) - NextAction.IdentifyPhone -> IdentifyCard( - labelRes = R.string.action_identify_phone, - hintRes = R.string.hint_phone, - keyboardType = KeyboardType.Phone, - submitRes = R.string.action_submit, - onSubmit = { viewModel.identifyPhone(it) }, + NextAction.IdentifyPhone -> InfoCard( + stringResource(R.string.label_unsupported_action, "identify:phone"), ) is NextAction.ChallengeEmail -> ChallengeEmailCard( diff --git a/sample-embedded/src/main/java/com/auth0/sample/embedded/EmbeddedViewModel.kt b/sample-embedded/src/main/java/com/auth0/sample/embedded/EmbeddedViewModel.kt index 29216840..92c90f2c 100644 --- a/sample-embedded/src/main/java/com/auth0/sample/embedded/EmbeddedViewModel.kt +++ b/sample-embedded/src/main/java/com/auth0/sample/embedded/EmbeddedViewModel.kt @@ -56,8 +56,6 @@ class EmbeddedViewModel(application: Application) : AndroidViewModel(application fun identifyEmail(email: String): Unit = runStep { client.identifyEmail(email).await() } - fun identifyPhone(phone: String): Unit = runStep { client.identifyPhone(phone).await() } - fun challengeEmail(index: Int): Unit = runStep { client.challengeEmail(index).await() } /** Terminal step: on success this yields [Credentials][com.auth0.android.result.Credentials]. */ From 30d7efe271010873db8bac866c444ea5da2b5ad9 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 28 Sep 2026 11:21:19 +0530 Subject: [PATCH 13/15] Renamed EmbeddedAction to EmbeddedCApability --- .../android/embedded/EmbeddedAuthClient.kt | 18 +++++++++--------- ...EmbeddedAction.kt => EmbeddedCapability.kt} | 4 ++-- .../android/embedded/authorize/NextAction.kt | 12 ++++++------ .../embedded/authorize/NextActionMapper.kt | 12 ++++++------ 4 files changed, 23 insertions(+), 23 deletions(-) rename auth0/src/main/java/com/auth0/android/embedded/authorize/{EmbeddedAction.kt => EmbeddedCapability.kt} (79%) diff --git a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt index 5a9a085e..a6b98744 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt @@ -3,7 +3,7 @@ package com.auth0.android.embedded import com.auth0.android.Auth0 import com.auth0.android.embedded.authorize.AdvancingRequest import com.auth0.android.embedded.authorize.AuthorizeCode -import com.auth0.android.embedded.authorize.EmbeddedAction +import com.auth0.android.embedded.authorize.EmbeddedCapability import com.auth0.android.embedded.authorize.EmbeddedAuthState import com.auth0.android.embedded.authorize.FailedRequest import com.auth0.android.embedded.authorize.OtpType @@ -91,7 +91,7 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { connection: String, scope: String = DEFAULT_SCOPE, audience: String? = null, - capabilities: Set = DEFAULT_CAPABILITIES + capabilities: Set = DEFAULT_CAPABILITIES ): Request { transactionState = null val request = factory.post(authorizeUrl) @@ -112,7 +112,7 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { * [EmbeddedAuthException.nextActions] carry the next step to call. */ public fun identifyEmail(email: String): Request = - continueStep(EmbeddedAction.IDENTIFY_EMAIL) { addParameter(EMAIL_KEY, email) } + continueStep(EmbeddedCapability.IDENTIFY_EMAIL) { addParameter(EMAIL_KEY, email) } /** * Continues the flow by submitting a phone identifier. @@ -121,7 +121,7 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { * [EmbeddedAuthException.nextActions] carry the next step to call. */ internal fun identifyPhone(phone: String): Request = - continueStep(EmbeddedAction.IDENTIFY_PHONE) { addParameter(PHONE_KEY, phone) } + continueStep(EmbeddedCapability.IDENTIFY_PHONE) { addParameter(PHONE_KEY, phone) } /** * Continues the flow by requesting an email challenge for the authenticator at [index]. @@ -131,7 +131,7 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { */ @JvmOverloads public fun challengeEmail(index: Int = 0): Request = - continueStep(EmbeddedAction.CHALLENGE_EMAIL) { + continueStep(EmbeddedCapability.CHALLENGE_EMAIL) { addParameter(INDEX_KEY, index) } @@ -152,7 +152,7 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { .addParameters( mapOf( AUTH_SESSION_KEY to session, - ACTION_KEY to EmbeddedAction.VERIFY_OTP.value, + ACTION_KEY to EmbeddedCapability.VERIFY_OTP.value, CLIENT_ID_KEY to clientId ) ) @@ -162,7 +162,7 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { } private fun continueStep( - action: EmbeddedAction, + action: EmbeddedCapability, addPayload: Request.() -> Unit = {} ): Request { val session = transactionState?.authSession ?: return noActiveSession() @@ -255,8 +255,8 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { private const val DEFAULT_SCOPE = "openid profile email offline_access" - private val DEFAULT_CAPABILITIES: Set = - EmbeddedAction.entries.toSet() - EmbeddedAction.UNKNOWN + private val DEFAULT_CAPABILITIES: Set = + EmbeddedCapability.entries.toSet() - EmbeddedCapability.UNKNOWN } init { diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAction.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedCapability.kt similarity index 79% rename from auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAction.kt rename to auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedCapability.kt index 04473ef3..353bf9cc 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAction.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedCapability.kt @@ -1,7 +1,7 @@ package com.auth0.android.embedded.authorize /** A single step in the embedded authentication flow. [value] is the raw string used by `/e/authorize`. */ -public enum class EmbeddedAction(public val value: String) { +public enum class EmbeddedCapability(public val value: String) { IDENTIFY_EMAIL("action:identify:email:v1"), IDENTIFY_PHONE("action:identify:phone:v1"), CHALLENGE_EMAIL("action:challenge:email:v1"), @@ -9,7 +9,7 @@ public enum class EmbeddedAction(public val value: String) { UNKNOWN("Unknown"); internal companion object { - fun fromValue(value: String): EmbeddedAction = + fun fromValue(value: String): EmbeddedCapability = entries.firstOrNull { it.value == value } ?: UNKNOWN } } diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt index ee2602bb..a6dace41 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt @@ -6,16 +6,16 @@ import com.auth0.android.embedded.EmbeddedAuthException /** One way to continue the embedded authentication flow, as reported on [EmbeddedAuthException.nextActions]. */ public sealed interface NextAction { - public val action: EmbeddedAction + public val action: EmbeddedCapability /** Continue by submitting an email address. Act on it with [EmbeddedAuthClient.identifyEmail]. */ public data object IdentifyEmail : NextAction { - override val action: EmbeddedAction = EmbeddedAction.IDENTIFY_EMAIL + override val action: EmbeddedCapability = EmbeddedCapability.IDENTIFY_EMAIL } /** Continue by submitting a phone number. Act on it with [EmbeddedAuthClient.identifyPhone]. */ public data object IdentifyPhone : NextAction { - override val action: EmbeddedAction = EmbeddedAction.IDENTIFY_PHONE + override val action: EmbeddedCapability = EmbeddedCapability.IDENTIFY_PHONE } /** Continue by requesting an email challenge. Act on it with [EmbeddedAuthClient.challengeEmail]. */ @@ -24,7 +24,7 @@ public sealed interface NextAction { public val index: Int, public val identifier: String ) : NextAction { - override val action: EmbeddedAction = EmbeddedAction.CHALLENGE_EMAIL + override val action: EmbeddedCapability = EmbeddedCapability.CHALLENGE_EMAIL } /** Continue by verifying a one-time code. Act on it with [EmbeddedAuthClient.verifyOtp]. */ @@ -33,7 +33,7 @@ public sealed interface NextAction { public val channel: OtpChannel, public val identifier: String? ) : NextAction { - override val action: EmbeddedAction = EmbeddedAction.VERIFY_OTP + override val action: EmbeddedCapability = EmbeddedCapability.VERIFY_OTP } /** An action the server offered that this version of the SDK does not model. */ @@ -41,6 +41,6 @@ public sealed interface NextAction { public data class Unknown internal constructor( public val rawAction: String ) : NextAction { - override val action: EmbeddedAction = EmbeddedAction.UNKNOWN + override val action: EmbeddedCapability = EmbeddedCapability.UNKNOWN } } diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/NextActionMapper.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextActionMapper.kt index 9f492bc4..deabc519 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/authorize/NextActionMapper.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextActionMapper.kt @@ -13,15 +13,15 @@ internal fun List>.toNextActions(): List = mapNotNu private fun Map.toNextAction(): NextAction? { val raw = this[ACTION_KEY] as? String ?: return null - return when (EmbeddedAction.fromValue(raw)) { - EmbeddedAction.IDENTIFY_EMAIL -> NextAction.IdentifyEmail - EmbeddedAction.IDENTIFY_PHONE -> NextAction.IdentifyPhone - EmbeddedAction.CHALLENGE_EMAIL -> { + return when (EmbeddedCapability.fromValue(raw)) { + EmbeddedCapability.IDENTIFY_EMAIL -> NextAction.IdentifyEmail + EmbeddedCapability.IDENTIFY_PHONE -> NextAction.IdentifyPhone + EmbeddedCapability.CHALLENGE_EMAIL -> { val index = (this[INDEX_KEY] as? Number)?.toInt() ?: return dropped(raw, INDEX_KEY) val identifier = this[IDENTIFIER_KEY] as? String ?: return dropped(raw, IDENTIFIER_KEY) NextAction.ChallengeEmail(index = index, identifier = identifier) } - EmbeddedAction.VERIFY_OTP -> { + EmbeddedCapability.VERIFY_OTP -> { val channel = OtpChannel.fromValue(this[CHANNEL_KEY] as? String) ?: return dropped(raw, CHANNEL_KEY) NextAction.VerifyOtp( @@ -29,7 +29,7 @@ private fun Map.toNextAction(): NextAction? { identifier = this[IDENTIFIER_KEY] as? String ) } - EmbeddedAction.UNKNOWN -> NextAction.Unknown(raw) + EmbeddedCapability.UNKNOWN -> NextAction.Unknown(raw) } } From 1f407309297af4481b19fc41f267f9f47c91ac9e Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 28 Sep 2026 11:22:20 +0530 Subject: [PATCH 14/15] minor name change --- .../auth0/android/embedded/authorize/NextAction.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt index a6dace41..856053f8 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/NextAction.kt @@ -6,16 +6,16 @@ import com.auth0.android.embedded.EmbeddedAuthException /** One way to continue the embedded authentication flow, as reported on [EmbeddedAuthException.nextActions]. */ public sealed interface NextAction { - public val action: EmbeddedCapability + public val capability: EmbeddedCapability /** Continue by submitting an email address. Act on it with [EmbeddedAuthClient.identifyEmail]. */ public data object IdentifyEmail : NextAction { - override val action: EmbeddedCapability = EmbeddedCapability.IDENTIFY_EMAIL + override val capability: EmbeddedCapability = EmbeddedCapability.IDENTIFY_EMAIL } /** Continue by submitting a phone number. Act on it with [EmbeddedAuthClient.identifyPhone]. */ public data object IdentifyPhone : NextAction { - override val action: EmbeddedCapability = EmbeddedCapability.IDENTIFY_PHONE + override val capability: EmbeddedCapability = EmbeddedCapability.IDENTIFY_PHONE } /** Continue by requesting an email challenge. Act on it with [EmbeddedAuthClient.challengeEmail]. */ @@ -24,7 +24,7 @@ public sealed interface NextAction { public val index: Int, public val identifier: String ) : NextAction { - override val action: EmbeddedCapability = EmbeddedCapability.CHALLENGE_EMAIL + override val capability: EmbeddedCapability = EmbeddedCapability.CHALLENGE_EMAIL } /** Continue by verifying a one-time code. Act on it with [EmbeddedAuthClient.verifyOtp]. */ @@ -33,7 +33,7 @@ public sealed interface NextAction { public val channel: OtpChannel, public val identifier: String? ) : NextAction { - override val action: EmbeddedCapability = EmbeddedCapability.VERIFY_OTP + override val capability: EmbeddedCapability = EmbeddedCapability.VERIFY_OTP } /** An action the server offered that this version of the SDK does not model. */ @@ -41,6 +41,6 @@ public sealed interface NextAction { public data class Unknown internal constructor( public val rawAction: String ) : NextAction { - override val action: EmbeddedCapability = EmbeddedCapability.UNKNOWN + override val capability: EmbeddedCapability = EmbeddedCapability.UNKNOWN } } From 50412456e5612661fb226d420c46dfb29dbd5692 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 28 Sep 2026 11:40:38 +0530 Subject: [PATCH 15/15] Removed client details --- sample-embedded/src/main/res/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sample-embedded/src/main/res/values/strings.xml b/sample-embedded/src/main/res/values/strings.xml index 83f8262d..34c6cf85 100644 --- a/sample-embedded/src/main/res/values/strings.xml +++ b/sample-embedded/src/main/res/values/strings.xml @@ -2,8 +2,8 @@ Auth0 Embedded Discovery - prince.test-aws-alien-lovebird-260921.auth0c.com - gZhGB6VKrO0pfJjiLPbJHcFfMJzzJfvB + DOMAIN + CLIENT_ID demo Embedded