From 9b0d135b1136e87889a3ff933c9bf0f4f9a37091 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 30 Sep 2026 17:55:02 +0530 Subject: [PATCH 1/4] refactor : Made the EmbeddedAuthException class return better welldefiend types --- .../android/embedded/EmbeddedAuthClient.kt | 32 +-- .../android/embedded/EmbeddedAuthError.kt | 54 ++++ .../android/embedded/EmbeddedAuthException.kt | 58 +---- .../authorize/EmbeddedAuthAdapters.kt | 54 +++- .../embedded/EmbeddedAuthClientTest.kt | 235 ++++++++++++------ .../android/util/EmbeddedAuthMockServer.kt | 6 + .../auth0/sample/embedded/AuthorizeScreen.kt | 25 +- .../sample/embedded/EmbeddedViewModel.kt | 10 +- 8 files changed, 321 insertions(+), 153 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt 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 56fc8adb7..b9bd8747c 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt @@ -20,8 +20,6 @@ import com.google.gson.Gson import okhttp3.HttpUrl.Companion.toHttpUrl /** - * - * * API client for Auth0's embedded authentication API. * * ``` @@ -34,6 +32,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrl */ public class EmbeddedAuthClient(private val auth0: Auth0) { + /** Used for both `/e/authorize` and the `/oauth/token` exchange. */ private val factory: RequestFactory = RequestFactory(auth0.networkingClient, embeddedAuthErrorAdapter()) @@ -41,7 +40,7 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { private val clientId: String get() = auth0.clientId - + @Volatile private var transactionState: EmbeddedAuthState? = null @@ -50,8 +49,8 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { * * 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. + * [EmbeddedAuthError.InsufficientAuthorization.nextActions] on [EmbeddedAuthException.error] + * 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 @@ -82,7 +81,7 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { * Continues the flow by submitting an identifier of the given [type]. * * This call never resolves successfully; it completes through [EmbeddedAuthException] whose - * [EmbeddedAuthException.nextActions] carry the next step to call. + * [EmbeddedAuthError.InsufficientAuthorization.nextActions] carry the next step to call. */ public fun identify( identifier: String, @@ -96,7 +95,7 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { * 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. + * [EmbeddedAuthError.InsufficientAuthorization.nextActions] carry the next step to call. */ @JvmOverloads public fun challengeEmail(index: Int = 0): Request = @@ -108,8 +107,8 @@ 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 - * completes through [EmbeddedAuthException] instead, with the next step on - * [EmbeddedAuthException.nextActions]. + * completes through [EmbeddedAuthException] instead, with + * [EmbeddedAuthError.InsufficientAuthorization.nextActions] on [EmbeddedAuthException.error]. */ @JvmOverloads public fun verifyOtp( @@ -150,7 +149,8 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { private fun noActiveSession(): Request = FailedRequest( EmbeddedAuthException( NO_ACTIVE_SESSION_ERROR, - "No embedded authentication flow is in progress. Call authorize() first." + "No embedded authentication flow is in progress. Call authorize() first.", + error = EmbeddedAuthError.NoActiveSession ) ) @@ -191,12 +191,12 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { } private fun updateSessionFromFailure(error: EmbeddedAuthException) { - when { - error.isInsufficientAuthorization && error.authSession != null -> - transactionState = EmbeddedAuthState(error.authSession) + when (error.error) { + is EmbeddedAuthError.InsufficientAuthorization -> + error.authSession?.let { transactionState = EmbeddedAuthState(it) } - error.isAccessDenied || error.isTooManyAttempts || error.isTooManyLogins -> - transactionState = null + is EmbeddedAuthError.Network -> Unit + else -> transactionState = null } } @@ -214,6 +214,8 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { private const val AUTH_SESSION_KEY = "auth_session" private const val ACTION_KEY = "action" private const val EMAIL_KEY = "email" + + @Suppress("unused") private const val PHONE_KEY = "phone" private const val OTP_KEY = "otp" private const val INDEX_KEY = "index" diff --git a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt new file mode 100644 index 000000000..12b387fd2 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt @@ -0,0 +1,54 @@ +package com.auth0.android.embedded + +import com.auth0.android.embedded.authorize.NextAction + +/** + * The typed classification of an [EmbeddedAuthException]. + * + */ +public sealed interface EmbeddedAuthError { + + /** + * The flow is not complete and can continue: act on one of [nextActions]. + * Corresponds to `code == "insufficient_authorization"`. + */ + public data class InsufficientAuthorization( + public val nextActions: List + ) : EmbeddedAuthError + + /** Terminal: too many wrong OTP submissions. Restart the flow. */ + public data object TooManyWrongOtpAttempts : EmbeddedAuthError + + /** Terminal: the OTP challenge expired before it was verified. Restart the flow. */ + public data object ChallengeExpired : EmbeddedAuthError + + /** + * Terminal: access denied for a reason not modelled as its own case. + * Read [EmbeddedAuthException.description] for specifics. + */ + public data object AccessDenied : EmbeddedAuthError + + /** Terminal: rate-limited by attack protection . */ + public data object TooManyAttempts : EmbeddedAuthError + + /** Terminal: rate-limited by same-user-login protection. */ + public data object TooManyLogins : EmbeddedAuthError + + /** + * Terminal: the grant being redeemed is no longer valid. Start a new flow with + * [EmbeddedAuthClient.authorize]. + */ + public data object SessionExpired : EmbeddedAuthError + + /** The request never reached the server (`cause` is a network error). Retry the step. */ + public data object Network : EmbeddedAuthError + + /** Client-side guard: no flow is in progress. Call [EmbeddedAuthClient.authorize] first. */ + public data object NoActiveSession : EmbeddedAuthError + + /** + * An error this version of the SDK does not classify. Read [EmbeddedAuthException.code] / [EmbeddedAuthException.description] / + * [EmbeddedAuthException.statusCode] to diagnose. + */ + public data object Unknown : EmbeddedAuthError +} 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 d1aebdbd3..b6dd61d7d 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthException.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthException.kt @@ -1,11 +1,12 @@ 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. + * + * Branch on [error] with an exhaustive `when` to handle each case; read [code], [description], + * and [statusCode] for diagnostics or to present specific messages. */ public class EmbeddedAuthException internal constructor( @@ -16,57 +17,10 @@ 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(), + /** The typed classification of this error. */ + public val error: EmbeddedAuthError, 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 - - /** 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 && - 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_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" - private const val TOO_MANY_REQUESTS_STATUS = 429 - } -} +) : Auth0Exception(description, cause) 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 index 5d7fb8ca1..d46272d60 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthAdapters.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthAdapters.kt @@ -2,6 +2,7 @@ package com.auth0.android.embedded.authorize import com.auth0.android.Auth0Exception import com.auth0.android.NetworkErrorException +import com.auth0.android.embedded.EmbeddedAuthError import com.auth0.android.embedded.EmbeddedAuthException import com.auth0.android.request.ErrorAdapter import com.auth0.android.request.JsonAdapter @@ -20,11 +21,23 @@ private const val AUTH_SESSION_KEY = "auth_session" private const val DEFAULT_DESCRIPTION = "An error occurred when trying to authenticate with the server." +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 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 INVALID_GRANT = "invalid_grant" +private const val TOO_MANY_REQUESTS_STATUS = 429 + /** 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]. */ +/** + * Translates every embedded-authentication error response into an [EmbeddedAuthException]. + */ internal fun embeddedAuthErrorAdapter(): ErrorAdapter { val mapAdapter = forMap(GsonProvider.gson) return object : ErrorAdapter { @@ -37,11 +50,13 @@ internal fun embeddedAuthErrorAdapter(): ErrorAdapter { return if (bodyText.isBlank()) EmbeddedAuthException( Auth0Exception.EMPTY_BODY_ERROR, Auth0Exception.EMPTY_RESPONSE_BODY_DESCRIPTION, - statusCode + statusCode, + error = EmbeddedAuthError.Unknown ) else EmbeddedAuthException( Auth0Exception.NON_JSON_ERROR, bodyText, - statusCode + statusCode, + error = EmbeddedAuthError.Unknown ) } @@ -51,14 +66,37 @@ internal fun embeddedAuthErrorAdapter(): ErrorAdapter { reader: Reader ): EmbeddedAuthException { val values = mapAdapter.fromJson(reader) + val code = values[ERROR_KEY] as? String ?: Auth0Exception.UNKNOWN_ERROR + val description = values[ERROR_DESCRIPTION_KEY] as? String ?: DEFAULT_DESCRIPTION + val authSession = values[AUTH_SESSION_KEY] as? String @Suppress("UNCHECKED_CAST") val nextRaw = values[NEXT_KEY] as? List> ?: emptyList() + + val error: EmbeddedAuthError = when { + code == INSUFFICIENT_AUTHORIZATION -> + EmbeddedAuthError.InsufficientAuthorization(nextRaw.toNextActions()) + code == ACCESS_DENIED && description == TOO_MANY_WRONG_OTP_ATTEMPTS -> + EmbeddedAuthError.TooManyWrongOtpAttempts + code == ACCESS_DENIED && description == CHALLENGE_EXPIRED -> + EmbeddedAuthError.ChallengeExpired + code == ACCESS_DENIED -> + EmbeddedAuthError.AccessDenied + statusCode == TOO_MANY_REQUESTS_STATUS && code == TOO_MANY_REQUESTS && description == TOO_MANY_ATTEMPTS -> + EmbeddedAuthError.TooManyAttempts + statusCode == TOO_MANY_REQUESTS_STATUS && code == TOO_MANY_REQUESTS && description == TOO_MANY_LOGINS -> + EmbeddedAuthError.TooManyLogins + code == INVALID_GRANT -> + EmbeddedAuthError.SessionExpired + else -> + EmbeddedAuthError.Unknown + } + return EmbeddedAuthException( - values[ERROR_KEY] as? String ?: Auth0Exception.UNKNOWN_ERROR, - values[ERROR_DESCRIPTION_KEY] as? String ?: DEFAULT_DESCRIPTION, + code, + description, statusCode, - nextActions = nextRaw.toNextActions(), - authSession = values[AUTH_SESSION_KEY] as? String + error = error, + authSession = authSession ) } @@ -66,10 +104,12 @@ internal fun embeddedAuthErrorAdapter(): ErrorAdapter { return if (isNetworkError(cause)) EmbeddedAuthException( Auth0Exception.UNKNOWN_ERROR, "Failed to execute the network request", + error = EmbeddedAuthError.Network, cause = NetworkErrorException(cause) ) else EmbeddedAuthException( Auth0Exception.UNKNOWN_ERROR, DEFAULT_DESCRIPTION, + error = EmbeddedAuthError.Unknown, cause = Auth0Exception(DEFAULT_DESCRIPTION, cause) ) } 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 e94587ebb..2e3b90223 100644 --- a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt @@ -6,6 +6,7 @@ import com.auth0.android.embedded.authorize.IdentifierType 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.authorize.embeddedAuthErrorAdapter import com.auth0.android.util.EmbeddedAuthMockServer import com.auth0.android.util.SSLTestUtils.testClient import kotlinx.coroutines.test.runTest @@ -17,7 +18,6 @@ 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 @@ -25,6 +25,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import java.io.StringReader @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE) @@ -51,6 +52,8 @@ public class EmbeddedAuthClientTest { mockAPI.shutdown() } + // ── Authorize — request shape ─────────────────────────────────────────────── + @Test public fun `authorize should POST the authorize endpoint with the default scope and no audience`() { mockAPI.willReturnJsonError() @@ -58,7 +61,6 @@ public class EmbeddedAuthClientTest { 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() @@ -89,9 +91,10 @@ public class EmbeddedAuthClientTest { assertThat(body.getString("audience"), `is`("https://api.example.com")) } + // ── verifyOtp — token exchange ────────────────────────────────────────────── + @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() @@ -107,7 +110,6 @@ public class EmbeddedAuthClientTest { 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")) @@ -128,21 +130,18 @@ public class EmbeddedAuthClientTest { mockAPI.willReturnAuthorizeCode() mockAPI.willReturnTokensWithoutIdToken() - var error: EmbeddedAuthException? = null - try { - client.verifyOtp("123456", OtpType.OOB).execute() - } catch (ex: EmbeddedAuthException) { - error = ex - } + val error = assertEmbeddedError { client.verifyOtp("123456", OtpType.OOB).execute() } assertThat(error, `is`(notNullValue())) } + // ── identify ──────────────────────────────────────────────────────────────── @Test public fun `identify with email should fail with no_active_session when no flow is in progress`() { val error = assertEmbeddedError { client.identify("jane@example.com", IdentifierType.EMAIL).execute() } assertThat(error.code, `is`("no_active_session")) + assertThat(error.error, `is`(EmbeddedAuthError.NoActiveSession)) } @Test @@ -163,12 +162,13 @@ public class EmbeddedAuthClientTest { assertThat(body.getString("auth_session"), `is`(EmbeddedAuthMockServer.AUTH_SESSION)) } - + // ── challengeEmail ─────────────────────────────────────────────────────────── @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")) + assertThat(error.error, `is`(EmbeddedAuthError.NoActiveSession)) } @Test @@ -199,12 +199,13 @@ public class EmbeddedAuthClientTest { assertThat(bodyOf(mockAPI.takeRequest()).getInt("index"), `is`(2)) } - + // ── verifyOtp — no session ─────────────────────────────────────────────────── @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")) + assertThat(error.error, `is`(EmbeddedAuthError.NoActiveSession)) } @Test @@ -221,15 +222,15 @@ public class EmbeddedAuthClientTest { assertThat(body.getString("type"), `is`("totp")) } - + // ── Continuation / typed-error mapping ────────────────────────────────────── @Test - public fun `authorize continuation sets isInsufficientAuthorization and populates nextActions`() { + public fun `authorize continuation surfaces InsufficientAuthorization 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.error, instanceOf(EmbeddedAuthError.InsufficientAuthorization::class.java)) assertThat(error.nextActions, hasSize(1)) assertThat(error.nextActions[0], instanceOf(NextAction.IdentifyEmail::class.java)) } @@ -336,33 +337,31 @@ public class EmbeddedAuthClientTest { } @Test - public fun `authorize surfaces access_denied as a terminal error with no next actions`() { + public fun `authorize surfaces AccessDenied 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())) + assertThat(error.error, `is`(EmbeddedAuthError.AccessDenied)) } @Test - public fun `authorize surfaces too_many_attempts as a terminal error`() { + public fun `authorize surfaces TooManyAttempts as a terminal error`() { mockAPI.willReturnTooManyAttempts() val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } - assertThat(error.isTooManyAttempts, `is`(true)) + assertThat(error.error, `is`(EmbeddedAuthError.TooManyAttempts)) assertThat(error.statusCode, `is`(429)) } @Test - public fun `authorize surfaces too_many_logins as a terminal error`() { + public fun `authorize surfaces TooManyLogins as a terminal error`() { mockAPI.willReturnTooManyLogins() val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } - assertThat(error.isTooManyLogins, `is`(true)) + assertThat(error.error, `is`(EmbeddedAuthError.TooManyLogins)) assertThat(error.statusCode, `is`(429)) } @@ -373,43 +372,52 @@ public class EmbeddedAuthClientTest { val error = assertEmbeddedError { client.verifyOtp("000000").execute() } - assertThat(error.isInvalidCode, `is`(true)) - assertThat(error.isInsufficientAuthorization, `is`(true)) - assertThat(error.isAccessDenied, `is`(false)) + assertThat(error.error, instanceOf(EmbeddedAuthError.InsufficientAuthorization::class.java)) assertThat(error.nextActions, `is`(not(empty()))) } @Test - public fun `verifyOtp flags too many wrong OTP attempts as terminal`() { + public fun `verifyOtp flags too many wrong OTP attempts as TooManyWrongOtpAttempts`() { 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)) + assertThat(error.error, `is`(EmbeddedAuthError.TooManyWrongOtpAttempts)) } @Test - public fun `verifyOtp flags an expired challenge as terminal`() { + public fun `verifyOtp flags an expired challenge as ChallengeExpired`() { establishSession() mockAPI.willReturnChallengeExpired() val error = assertEmbeddedError { client.verifyOtp("000000").execute() } - assertThat(error.isChallengeExpired, `is`(true)) - assertThat(error.isAccessDenied, `is`(true)) + assertThat(error.error, `is`(EmbeddedAuthError.ChallengeExpired)) + } + + @Test + public fun `authorize surfaces SessionExpired when the continuation returns invalid_grant`() { + mockAPI.willReturnInsufficientAuthorization() + try { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } catch (_: EmbeddedAuthException) {} + mockAPI.takeRequest() + + mockAPI.willReturnInvalidGrant() + + val error = assertEmbeddedError { client.challengeEmail().execute() } + + assertThat(error.error, `is`(EmbeddedAuthError.SessionExpired)) + assertThat(error.code, `is`("invalid_grant")) } @Test - public fun `identify with email continuation sets isInsufficientAuthorization and populates nextActions`() { + public fun `identify with email continuation surfaces InsufficientAuthorization and populates nextActions`() { establishSession() mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_CHALLENGE_EMAIL) val error = assertEmbeddedError { client.identify("jane@example.com", IdentifierType.EMAIL).execute() } - assertThat(error.isInsufficientAuthorization, `is`(true)) + assertThat(error.error, instanceOf(EmbeddedAuthError.InsufficientAuthorization::class.java)) assertThat(error.nextActions[0], instanceOf(NextAction.ChallengeEmail::class.java)) } @@ -420,18 +428,17 @@ public class EmbeddedAuthClientTest { val error = assertEmbeddedError { client.identify("jane@example.com", IdentifierType.EMAIL).execute() } - assertThat(error.isAccessDenied, `is`(true)) - assertThat(error.nextActions, `is`(empty())) + assertThat(error.error, `is`(EmbeddedAuthError.AccessDenied)) } @Test - public fun `challengeEmail continuation sets isInsufficientAuthorization and populates nextActions`() { + public fun `challengeEmail continuation surfaces InsufficientAuthorization and populates nextActions`() { establishSession() mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_VERIFY_OTP) val error = assertEmbeddedError { client.challengeEmail().execute() } - assertThat(error.isInsufficientAuthorization, `is`(true)) + assertThat(error.error, instanceOf(EmbeddedAuthError.InsufficientAuthorization::class.java)) assertThat(error.nextActions[0], instanceOf(NextAction.VerifyOtp::class.java)) } @@ -442,7 +449,7 @@ public class EmbeddedAuthClientTest { val error = assertEmbeddedError { client.challengeEmail().execute() } - assertThat(error.isTooManyAttempts, `is`(true)) + assertThat(error.error, `is`(EmbeddedAuthError.TooManyAttempts)) } @Test @@ -452,7 +459,7 @@ public class EmbeddedAuthClientTest { val error = assertEmbeddedError { client.verifyOtp("123456").execute() } - assertThat(error.isInsufficientAuthorization, `is`(true)) + assertThat(error.error, instanceOf(EmbeddedAuthError.InsufficientAuthorization::class.java)) assertThat(error.nextActions[0], instanceOf(NextAction.VerifyOtp::class.java)) } @@ -463,14 +470,13 @@ public class EmbeddedAuthClientTest { val error = assertEmbeddedError { client.verifyOtp("123456").execute() } - assertThat(error.isTooManyAttempts, `is`(true)) + assertThat(error.error, `is`(EmbeddedAuthError.TooManyAttempts)) } - + // ── Session-management behaviour ───────────────────────────────────────────── @Test public fun `the auth_session is rotated between steps`() { - // authorize → session A; identify → 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() @@ -494,7 +500,6 @@ public class EmbeddedAuthClientTest { 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")) } @@ -508,7 +513,6 @@ public class EmbeddedAuthClientTest { 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() @@ -519,7 +523,6 @@ public class EmbeddedAuthClientTest { 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() @@ -534,21 +537,15 @@ public class EmbeddedAuthClientTest { } @Test - public fun `a transient server error leaves the session intact for retry`() { + public fun `a transient server error clears the session`() { establishSession() - // A 5xx is not terminal — the session must survive so the same step can be retried. mockAPI.willReturnPlainTextError() assertEmbeddedError { client.challengeEmail().execute() } mockAPI.takeRequest() - // The retry must still carry the established session, not fail with no_active_session. - mockAPI.willReturnInsufficientAuthorization(EmbeddedAuthMockServer.ROTATED_AUTH_SESSION) - assertEmbeddedError { client.challengeEmail().execute() } - assertThat( - bodyOf(mockAPI.takeRequest()).getString("auth_session"), - `is`(EmbeddedAuthMockServer.AUTH_SESSION) - ) + val error = assertEmbeddedError { client.challengeEmail().execute() } + assertThat(error.code, `is`("no_active_session")) } @Test @@ -556,14 +553,11 @@ public class EmbeddedAuthClientTest { establishSession() mockAPI.shutdown() - // The step fails at the network layer — transient, so the session must survive. val error = assertEmbeddedError { client.challengeEmail().execute() } - assertThat(error.isNetworkError, `is`(true)) + assertThat(error.error, `is`(EmbeddedAuthError.Network)) - // A preserved session means the retry reaches the network again (another network error); - // a wrongly-cleared session would instead short-circuit client-side with no_active_session. val retry = assertEmbeddedError { client.challengeEmail().execute() } - assertThat(retry.isNetworkError, `is`(true)) + assertThat(retry.error, `is`(EmbeddedAuthError.Network)) assertThat(retry.code, not(`is`("no_active_session"))) } @@ -575,7 +569,6 @@ public class EmbeddedAuthClientTest { assertEmbeddedError { client.challengeEmail().execute() } mockAPI.takeRequest() - // Terminal failure ended the flow — any follow-on step must fail with no_active_session. val error = assertEmbeddedError { client.challengeEmail().execute() } assertThat(error.code, `is`("no_active_session")) } @@ -588,14 +581,23 @@ public class EmbeddedAuthClientTest { assertEmbeddedError { client.challengeEmail().execute() } mockAPI.takeRequest() - // Rate limiting is terminal for this flow — the session must be dropped. val error = assertEmbeddedError { client.challengeEmail().execute() } assertThat(error.code, `is`("no_active_session")) } + @Test + public fun `a SessionExpired error clears the session`() { + establishSession() + + mockAPI.willReturnInvalidGrant() + assertEmbeddedError { client.challengeEmail().execute() } + mockAPI.takeRequest() + val error = assertEmbeddedError { client.challengeEmail().execute() } + assertThat(error.code, `is`("no_active_session")) + } - // --- await() (coroutine) coverage: the step wrappers run distinct code paths for await() --- + // ── await() (coroutine) paths ────────────────────────────────────────────── @Test public fun `authorize await surfaces a continuation through the coroutine path`(): Unit = runTest { @@ -605,7 +607,7 @@ public class EmbeddedAuthClientTest { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).await() } - assertThat(error.isInsufficientAuthorization, `is`(true)) + assertThat(error.error, instanceOf(EmbeddedAuthError.InsufficientAuthorization::class.java)) assertThat(error.nextActions[0], instanceOf(NextAction.IdentifyEmail::class.java)) } @@ -628,10 +630,9 @@ public class EmbeddedAuthClientTest { mockAPI.willReturnAccessDenied() val error = assertEmbeddedErrorSuspending { client.challengeEmail().await() } - assertThat(error.isAccessDenied, `is`(true)) + assertThat(error.error, `is`(EmbeddedAuthError.AccessDenied)) mockAPI.takeRequest() - // onStepFailure ran on the coroutine path — the session must be gone. val followOn = assertEmbeddedErrorSuspending { client.challengeEmail().await() } assertThat(followOn.code, `is`("no_active_session")) } @@ -647,7 +648,6 @@ public class EmbeddedAuthClientTest { assertThat(credentials.idToken, `is`(EmbeddedAuthMockServer.ID_TOKEN)) assertThat(credentials.accessToken, `is`(EmbeddedAuthMockServer.ACCESS_TOKEN)) - // onFlowComplete ran on the coroutine path — the session must be cleared. val followOn = assertEmbeddedErrorSuspending { client.verifyOtp("999999").await() } assertThat(followOn.code, `is`("no_active_session")) } @@ -662,14 +662,106 @@ public class EmbeddedAuthClientTest { mockAPI.takeRequest() mockAPI.takeRequest() - // The failed exchange must not clear the session — a retry should reach the server. mockAPI.willReturnAuthorizeCode() mockAPI.willReturnTokens() val credentials = client.verifyOtp("123456").await() assertThat(credentials.accessToken, `is`(EmbeddedAuthMockServer.ACCESS_TOKEN)) } + // ── Adapter mapping unit tests (§5 of EmbeddedAuthError spec) ───────────── + @Test + public fun `adapter maps insufficient_authorization to InsufficientAuthorization`() { + val json = """{"error":"insufficient_authorization","error_description":"continue","auth_session":"s","next":[${EmbeddedAuthMockServer.NEXT_IDENTIFY_EMAIL}]}""" + val ex = embeddedAuthErrorAdapter().fromJsonResponse(403, StringReader(json)) + val error = ex.error as EmbeddedAuthError.InsufficientAuthorization + assertThat(error.nextActions, hasSize(1)) + assertThat(error.nextActions[0], instanceOf(NextAction.IdentifyEmail::class.java)) + } + + @Test + public fun `adapter maps access_denied + too_many_wrong_otp_attempts to TooManyWrongOtpAttempts`() { + val json = """{"error":"access_denied","error_description":"too_many_wrong_otp_attempts"}""" + val ex = embeddedAuthErrorAdapter().fromJsonResponse(403, StringReader(json)) + assertThat(ex.error, `is`(EmbeddedAuthError.TooManyWrongOtpAttempts)) + } + + @Test + public fun `adapter maps access_denied + challenge_expired to ChallengeExpired`() { + val json = """{"error":"access_denied","error_description":"challenge_expired"}""" + val ex = embeddedAuthErrorAdapter().fromJsonResponse(403, StringReader(json)) + assertThat(ex.error, `is`(EmbeddedAuthError.ChallengeExpired)) + } + + @Test + public fun `adapter maps access_denied with any other description to AccessDenied`() { + val json = """{"error":"access_denied","error_description":"Access denied."}""" + val ex = embeddedAuthErrorAdapter().fromJsonResponse(403, StringReader(json)) + assertThat(ex.error, `is`(EmbeddedAuthError.AccessDenied)) + } + + @Test + public fun `adapter maps 429 too_many_requests + too_many_attempts to TooManyAttempts`() { + val json = """{"error":"too_many_requests","error_description":"too_many_attempts"}""" + val ex = embeddedAuthErrorAdapter().fromJsonResponse(429, StringReader(json)) + assertThat(ex.error, `is`(EmbeddedAuthError.TooManyAttempts)) + } + + @Test + public fun `adapter maps 429 too_many_requests + too_many_logins to TooManyLogins`() { + val json = """{"error":"too_many_requests","error_description":"too_many_logins"}""" + val ex = embeddedAuthErrorAdapter().fromJsonResponse(429, StringReader(json)) + assertThat(ex.error, `is`(EmbeddedAuthError.TooManyLogins)) + } + + @Test + public fun `adapter maps invalid_grant to SessionExpired`() { + val json = """{"error":"invalid_grant","error_description":"The auth_session has expired."}""" + val ex = embeddedAuthErrorAdapter().fromJsonResponse(400, StringReader(json)) + assertThat(ex.error, `is`(EmbeddedAuthError.SessionExpired)) + } + + @Test + public fun `adapter maps invalid_request to Unknown`() { + val json = """{"error":"invalid_request","error_description":"Bad request."}""" + val ex = embeddedAuthErrorAdapter().fromJsonResponse(400, StringReader(json)) + assertThat(ex.error, `is`(EmbeddedAuthError.Unknown)) + } + + @Test + public fun `adapter maps server_error to Unknown`() { + val json = """{"error":"server_error","error_description":"Unexpected error."}""" + val ex = embeddedAuthErrorAdapter().fromJsonResponse(500, StringReader(json)) + assertThat(ex.error, `is`(EmbeddedAuthError.Unknown)) + } + + @Test + public fun `adapter maps blank body to Unknown`() { + val ex = embeddedAuthErrorAdapter().fromRawResponse(404, "", emptyMap()) + assertThat(ex.error, `is`(EmbeddedAuthError.Unknown)) + assertThat(ex.code, `is`(Auth0Exception.EMPTY_BODY_ERROR)) + } + + @Test + public fun `adapter maps non-JSON body to Unknown`() { + val ex = embeddedAuthErrorAdapter().fromRawResponse(500, "Internal Server Error", emptyMap()) + assertThat(ex.error, `is`(EmbeddedAuthError.Unknown)) + assertThat(ex.code, `is`(Auth0Exception.NON_JSON_ERROR)) + } + + @Test + public fun `adapter maps network exception to Network`() { + val ex = embeddedAuthErrorAdapter().fromException(java.net.UnknownHostException("host not found")) + assertThat(ex.error, `is`(EmbeddedAuthError.Network)) + } + + @Test + public fun `adapter maps non-network exception to Unknown`() { + val ex = embeddedAuthErrorAdapter().fromException(RuntimeException("unexpected")) + assertThat(ex.error, `is`(EmbeddedAuthError.Unknown)) + } + + // ── Helpers ────────────────────────────────────────────────────────────────── /** Enqueues a continuation and calls authorize to populate [transactionState]. */ private fun establishSession() { @@ -692,7 +784,6 @@ public class EmbeddedAuthClientTest { return error!! } - private fun bodyOf(request: RecordedRequest): JSONObject = JSONObject(request.body.readUtf8()) @@ -700,3 +791,7 @@ public class EmbeddedAuthClientTest { private const val CLIENT_ID = "CLIENT_ID" } } + +/** Extracts nextActions from an InsufficientAuthorization error for concise assertions. */ +private val EmbeddedAuthException.nextActions: List + get() = (error as EmbeddedAuthError.InsufficientAuthorization).nextActions 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 249e2d201..b3a98216d 100644 --- a/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt @@ -94,6 +94,12 @@ $actionsJson return this } + /** An `invalid_grant` returned by `/e/authorize` continuation (expired `auth_session`). */ + fun willReturnInvalidGrant(description: String = "The auth_session has expired."): EmbeddedAuthMockServer { + server.enqueue(responseWithJSON("""{ "error": "invalid_grant", "error_description": "$description" }""", 400)) + 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)) 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 e3b844fca..8d609fea5 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 @@ -42,6 +42,7 @@ 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.EmbeddedAuthError import com.auth0.android.embedded.EmbeddedAuthException import com.auth0.android.embedded.authorize.NextAction import com.auth0.android.embedded.authorize.OtpType @@ -320,12 +321,11 @@ private fun formatCredentials(credentials: Credentials): String = buildString { } private fun formatError(error: EmbeddedAuthException): String = buildString { - appendLine("Authorize step failed.") + appendLine(explain(error.error)) appendLine() - appendLine("code: ${error.code}") - appendLine("description: ${error.description}") - appendLine("HTTP status: ${error.statusCode}") - appendLine("network error: ${error.isNetworkError}") + appendLine("code: ${error.code}") + appendLine("description: ${error.description}") + appendLine("HTTP status: ${error.statusCode}") // Surface the underlying cause chain — client-side parse errors hide here, not in code/description. var cause = error.cause if (cause != null) { @@ -337,3 +337,18 @@ private fun formatError(error: EmbeddedAuthException): String = buildString { } } } + +/** + * Turns the typed [EmbeddedAuthError] into a user-facing headline. Note the trailing `else`: + * future flows (MFA, passkeys) add variants, and the catch-all keeps this compiling when they do. + */ +private fun explain(error: EmbeddedAuthError): String = when (error) { + is EmbeddedAuthError.InsufficientAuthorization -> "More steps are required to continue." + EmbeddedAuthError.TooManyWrongOtpAttempts, + EmbeddedAuthError.ChallengeExpired, + EmbeddedAuthError.SessionExpired -> "This flow can't continue — start over." + EmbeddedAuthError.TooManyAttempts, + EmbeddedAuthError.TooManyLogins -> "Rate limited — try again later." + EmbeddedAuthError.Network -> "The request didn't reach the server — check your connection and retry." + else -> "Authorize step failed." +} 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 dd8368172..b662bed56 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 @@ -5,6 +5,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.auth0.android.Auth0 import com.auth0.android.embedded.EmbeddedAuthClient +import com.auth0.android.embedded.EmbeddedAuthError import com.auth0.android.embedded.EmbeddedAuthException import com.auth0.android.embedded.authorize.IdentifierType import com.auth0.android.embedded.authorize.OtpType @@ -75,10 +76,11 @@ class EmbeddedViewModel(application: Application) : AndroidViewModel(application } private fun EmbeddedAuthException.toAuthorizeState(): AuthorizeUiState = - if (isInsufficientAuthorization && nextActions.isNotEmpty()) { - AuthorizeUiState.ActionsAvailable(nextActions) - } else { - AuthorizeUiState.Failed(this) + when (val e = error) { + is EmbeddedAuthError.InsufficientAuthorization -> + if (e.nextActions.isNotEmpty()) AuthorizeUiState.ActionsAvailable(e.nextActions) + else AuthorizeUiState.Failed(this) + else -> AuthorizeUiState.Failed(this) } // endregion From 75d5ac1add4bd4bf47082e0d640f636cc4d3d6a1 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 30 Sep 2026 18:06:48 +0530 Subject: [PATCH 2/4] updated example files on the same --- examples/embedded-auth/authorize.md | 72 +++++++++++++++-------------- 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/examples/embedded-auth/authorize.md b/examples/embedded-auth/authorize.md index 0e5ccce97..f929b9d6e 100644 --- a/examples/embedded-auth/authorize.md +++ b/examples/embedded-auth/authorize.md @@ -3,7 +3,9 @@ > [!IMPORTANT] > Embedded Authorization is currently in [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta). 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 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` whose typed `error` property (an `EmbeddedAuthError`) tells you whether the flow can continue — and, when it can, carries the next action to take — until the terminal call succeeds and returns `Credentials`. + +`EmbeddedAuthError` is a sealed interface. Branch on it with a `when`. The recoverable case, `EmbeddedAuthError.InsufficientAuthorization`, carries the `nextActions` list; the terminal cases do not. `Network` signals the request never reached the server, so the same step is safe to retry. The flow is served by `EmbeddedAuthClient`, created from an `Auth0` instance: @@ -20,10 +22,13 @@ Call `authorize()` with the connection name. The `scope` defaults to `"openid pr 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. + when (val error = e.error) { + is EmbeddedAuthError.InsufficientAuthorization -> { + // Flow is in progress — inspect error.nextActions for the next step. + } + else -> { + // Terminal error — e.g. AccessDenied, SessionExpired, Network. + } } } ``` @@ -40,15 +45,18 @@ client.authorize( #### 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: +Each non-terminal step throws `EmbeddedAuthException` whose `error` is `EmbeddedAuthError.InsufficientAuthorization`, carrying 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 identify() */ } - 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 */ } +val error = exception.error +if (error is EmbeddedAuthError.InsufficientAuthorization) { + for (action in error.nextActions) { + when (action) { + is NextAction.IdentifyEmail -> { /* show email field, call identify() */ } + 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 */ } + } } } ``` @@ -61,8 +69,9 @@ After `authorize()` returns a continuation with `NextAction.IdentifyEmail`: try { client.identify("jane@example.com", IdentifierType.EMAIL).await() } catch (e: EmbeddedAuthException) { - if (e.isInsufficientAuthorization) { - // Continue with e.nextActions (e.g. ChallengeEmail or VerifyOtp). + val error = e.error + if (error is EmbeddedAuthError.InsufficientAuthorization) { + // Continue with error.nextActions (e.g. ChallengeEmail or VerifyOtp). } } ``` @@ -93,32 +102,26 @@ 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. 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: +If the server still requires further steps it throws `EmbeddedAuthException` whose `error` is `EmbeddedAuthError.InsufficientAuthorization`, just like the earlier steps. A wrong code comes back as that same continuation — the flow is still recoverable, so re-prompt and retry with the `nextActions` the case carries. -```kotlin -if (e.isInsufficientAuthorization) { - if (e.isInvalidCode) { /* wrong code — re-prompt the user */ } - // Continue with e.nextActions. -} -``` #### Terminal errors Some errors end the flow and must not be retried without restarting from `authorize()`: ```kotlin -when { - 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 */ } +when (e.error) { + EmbeddedAuthError.TooManyWrongOtpAttempts -> { /* too many wrong codes — flow denied */ } + EmbeddedAuthError.ChallengeExpired -> { /* the code expired — restart the flow */ } + EmbeddedAuthError.AccessDenied -> { /* denied for another reason — do not retry */ } + EmbeddedAuthError.TooManyAttempts -> { /* rate-limited by attack protection */ } + EmbeddedAuthError.TooManyLogins -> { /* too many login attempts */ } + EmbeddedAuthError.SessionExpired -> { /* the grant expired — restart via authorize() */ } + EmbeddedAuthError.Network -> { /* transient — safe to retry the same step */ } + else -> { /* Unknown/unclassified — inspect e.code and e.description */ } } ``` -`isTooManyWrongOtpAttempts` and `isChallengeExpired` are specific cases of `isAccessDenied`, so check them first. -
Using callbacks @@ -130,8 +133,9 @@ client // Unexpected — authorize always continues through onFailure. } override fun onFailure(exception: EmbeddedAuthException) { - if (exception.isInsufficientAuthorization) { - // Handle exception.nextActions. + val error = exception.error + if (error is EmbeddedAuthError.InsufficientAuthorization) { + // Handle error.nextActions. } } }) @@ -168,8 +172,8 @@ client @Override public void onFailure(@NonNull EmbeddedAuthException e) { - if (e.isInsufficientAuthorization()) { - for (NextAction action : e.getNextActions()) { + if (e.getError() instanceof EmbeddedAuthError.InsufficientAuthorization ia) { + for (NextAction action : ia.getNextActions()) { // Handle each action. } } From 0a7e901040ece1951439c6d485feee0a5db64890 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 1 Oct 2026 15:59:21 +0530 Subject: [PATCH 3/4] Handled sub sections in InsufficientAuthorization --- .../android/embedded/EmbeddedAuthClient.kt | 2 ++ .../android/embedded/EmbeddedAuthError.kt | 20 ++++++++++-- .../authorize/EmbeddedAuthAdapters.kt | 15 ++++++++- .../embedded/EmbeddedAuthClientTest.kt | 32 +++++++++++++++++-- examples/embedded-auth/authorize.md | 17 +++++++++- 5 files changed, 78 insertions(+), 8 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 b9bd8747c..74cd429e3 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt @@ -191,6 +191,8 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { } private fun updateSessionFromFailure(error: EmbeddedAuthException) { + if (error.statusCode in 500..599) return + when (error.error) { is EmbeddedAuthError.InsufficientAuthorization -> error.authSession?.let { transactionState = EmbeddedAuthState(it) } diff --git a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt index 12b387fd2..dc3027bc3 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt @@ -10,11 +10,25 @@ public sealed interface EmbeddedAuthError { /** * The flow is not complete and can continue: act on one of [nextActions]. - * Corresponds to `code == "insufficient_authorization"`. + * Corresponds to `code == "insufficient_authorization"`. [reason] reports why the + * previous step's input was rejected, when the server says so. */ public data class InsufficientAuthorization( - public val nextActions: List - ) : EmbeddedAuthError + public val nextActions: List, + public val reason: Reason + ) : EmbeddedAuthError { + + public enum class Reason { + /** a plain step-forward continuation. */ + NONE, + + /** The submitted one-time code was invalid. */ + INVALID_CODE, + + /** The identifier or the code was invalid; the server does not say which. */ + INVALID_IDENTIFIER_OR_CODE + } + } /** Terminal: too many wrong OTP submissions. Restart the flow. */ public data object TooManyWrongOtpAttempts : EmbeddedAuthError 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 index d46272d60..479ceabc8 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthAdapters.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthAdapters.kt @@ -28,6 +28,8 @@ private const val CHALLENGE_EXPIRED = "challenge_expired" 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 INVALID_CODE = "invalid_code" +private const val INVALID_IDENTIFIER_OR_CODE = "invalid_identifier_or_code" private const val INVALID_GRANT = "invalid_grant" private const val TOO_MANY_REQUESTS_STATUS = 429 @@ -74,7 +76,10 @@ internal fun embeddedAuthErrorAdapter(): ErrorAdapter { val error: EmbeddedAuthError = when { code == INSUFFICIENT_AUTHORIZATION -> - EmbeddedAuthError.InsufficientAuthorization(nextRaw.toNextActions()) + EmbeddedAuthError.InsufficientAuthorization( + nextRaw.toNextActions(), + insufficientAuthorizationReason(description) + ) code == ACCESS_DENIED && description == TOO_MANY_WRONG_OTP_ATTEMPTS -> EmbeddedAuthError.TooManyWrongOtpAttempts code == ACCESS_DENIED && description == CHALLENGE_EXPIRED -> @@ -100,6 +105,14 @@ internal fun embeddedAuthErrorAdapter(): ErrorAdapter { ) } + fun insufficientAuthorizationReason(description: String): + EmbeddedAuthError.InsufficientAuthorization.Reason = when (description) { + INVALID_CODE -> EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_CODE + INVALID_IDENTIFIER_OR_CODE -> + EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_CODE + else -> EmbeddedAuthError.InsufficientAuthorization.Reason.NONE + } + override fun fromException(cause: Throwable): EmbeddedAuthException { return if (isNetworkError(cause)) EmbeddedAuthException( Auth0Exception.UNKNOWN_ERROR, 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 2e3b90223..3ac337cac 100644 --- a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt @@ -537,15 +537,16 @@ public class EmbeddedAuthClientTest { } @Test - public fun `a transient server error clears the session`() { + public fun `a 5xx server error leaves the session intact for retry`() { establishSession() mockAPI.willReturnPlainTextError() assertEmbeddedError { client.challengeEmail().execute() } mockAPI.takeRequest() - val error = assertEmbeddedError { client.challengeEmail().execute() } - assertThat(error.code, `is`("no_active_session")) + mockAPI.willReturnPlainTextError() + val retry = assertEmbeddedError { client.challengeEmail().execute() } + assertThat(retry.code, not(`is`("no_active_session"))) } @Test @@ -677,6 +678,31 @@ public class EmbeddedAuthClientTest { val error = ex.error as EmbeddedAuthError.InsufficientAuthorization assertThat(error.nextActions, hasSize(1)) assertThat(error.nextActions[0], instanceOf(NextAction.IdentifyEmail::class.java)) + assertThat(error.reason, `is`(EmbeddedAuthError.InsufficientAuthorization.Reason.NONE)) + } + + @Test + public fun `adapter maps insufficient_authorization + invalid_code to reason INVALID_CODE`() { + val json = """{"error":"insufficient_authorization","error_description":"invalid_code","auth_session":"s","next":[${EmbeddedAuthMockServer.NEXT_VERIFY_OTP}]}""" + val ex = embeddedAuthErrorAdapter().fromJsonResponse(403, StringReader(json)) + val error = ex.error as EmbeddedAuthError.InsufficientAuthorization + assertThat(error.reason, `is`(EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_CODE)) + } + + @Test + public fun `adapter maps insufficient_authorization + invalid_identifier_or_code to reason INVALID_IDENTIFIER_OR_CODE`() { + val json = """{"error":"insufficient_authorization","error_description":"invalid_identifier_or_code","auth_session":"s","next":[${EmbeddedAuthMockServer.NEXT_IDENTIFY_EMAIL}]}""" + val ex = embeddedAuthErrorAdapter().fromJsonResponse(403, StringReader(json)) + val error = ex.error as EmbeddedAuthError.InsufficientAuthorization + assertThat(error.reason, `is`(EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_CODE)) + } + + @Test + public fun `adapter maps insufficient_authorization without a known description to reason NONE`() { + val json = """{"error":"insufficient_authorization","auth_session":"s","next":[${EmbeddedAuthMockServer.NEXT_IDENTIFY_EMAIL}]}""" + val ex = embeddedAuthErrorAdapter().fromJsonResponse(403, StringReader(json)) + val error = ex.error as EmbeddedAuthError.InsufficientAuthorization + assertThat(error.reason, `is`(EmbeddedAuthError.InsufficientAuthorization.Reason.NONE)) } @Test diff --git a/examples/embedded-auth/authorize.md b/examples/embedded-auth/authorize.md index f929b9d6e..8d31258dd 100644 --- a/examples/embedded-auth/authorize.md +++ b/examples/embedded-auth/authorize.md @@ -102,7 +102,22 @@ val credentials = client.verifyOtp( // credentials.accessToken, credentials.idToken, etc. are now available. ``` -If the server still requires further steps it throws `EmbeddedAuthException` whose `error` is `EmbeddedAuthError.InsufficientAuthorization`, just like the earlier steps. A wrong code comes back as that same continuation — the flow is still recoverable, so re-prompt and retry with the `nextActions` the case carries. +If the server still requires further steps it throws `EmbeddedAuthException` whose `error` is `EmbeddedAuthError.InsufficientAuthorization`, just like the earlier steps. A wrong code comes back as that same continuation — the flow is still recoverable, so re-prompt and retry with the `nextActions` the case carries. The continuation's `reason` tells you *why* the previous input was rejected, so you can show a precise message before re-prompting: + +```kotlin +val error = exception.error +if (error is EmbeddedAuthError.InsufficientAuthorization) { + val message = when (error.reason) { + EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_CODE -> + "That code was incorrect. Try again." + EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_CODE -> + "That identifier or code was incorrect. Try again." + EmbeddedAuthError.InsufficientAuthorization.Reason.NONE -> + null // plain step-forward — no rejection to report + } + // Re-prompt using error.nextActions, optionally surfacing `message`. +} +``` #### Terminal errors From 37f3205b20b6296a13870914b2af9816b97987c1 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 1 Oct 2026 16:42:08 +0530 Subject: [PATCH 4/4] Added invalid request error type --- .../java/com/auth0/android/embedded/EmbeddedAuthError.kt | 6 ++++++ .../android/embedded/authorize/EmbeddedAuthAdapters.kt | 3 +++ .../com/auth0/android/embedded/EmbeddedAuthClientTest.kt | 4 ++-- examples/embedded-auth/authorize.md | 1 + 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt index dc3027bc3..03dab0f41 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt @@ -54,6 +54,12 @@ public sealed interface EmbeddedAuthError { */ public data object SessionExpired : EmbeddedAuthError + /** + * Terminal: the request was malformed (`code == "invalid_request"`).Read [EmbeddedAuthException.description] for + * specifics, fix the request, and start a new flow with [EmbeddedAuthClient.authorize]. + */ + public data object InvalidRequest : EmbeddedAuthError + /** The request never reached the server (`cause` is a network error). Retry the step. */ public data object Network : EmbeddedAuthError 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 index 479ceabc8..911018b55 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthAdapters.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthAdapters.kt @@ -31,6 +31,7 @@ private const val TOO_MANY_LOGINS = "too_many_logins" private const val INVALID_CODE = "invalid_code" private const val INVALID_IDENTIFIER_OR_CODE = "invalid_identifier_or_code" private const val INVALID_GRANT = "invalid_grant" +private const val INVALID_REQUEST = "invalid_request" private const val TOO_MANY_REQUESTS_STATUS = 429 /** Parses the `200` body of `/e/authorize` into the code to exchange for tokens. */ @@ -92,6 +93,8 @@ internal fun embeddedAuthErrorAdapter(): ErrorAdapter { EmbeddedAuthError.TooManyLogins code == INVALID_GRANT -> EmbeddedAuthError.SessionExpired + code == INVALID_REQUEST -> + EmbeddedAuthError.InvalidRequest else -> EmbeddedAuthError.Unknown } 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 3ac337cac..9f11b0a36 100644 --- a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt @@ -748,10 +748,10 @@ public class EmbeddedAuthClientTest { } @Test - public fun `adapter maps invalid_request to Unknown`() { + public fun `adapter maps invalid_request to InvalidRequest`() { val json = """{"error":"invalid_request","error_description":"Bad request."}""" val ex = embeddedAuthErrorAdapter().fromJsonResponse(400, StringReader(json)) - assertThat(ex.error, `is`(EmbeddedAuthError.Unknown)) + assertThat(ex.error, `is`(EmbeddedAuthError.InvalidRequest)) } @Test diff --git a/examples/embedded-auth/authorize.md b/examples/embedded-auth/authorize.md index 8d31258dd..1f2bf06f2 100644 --- a/examples/embedded-auth/authorize.md +++ b/examples/embedded-auth/authorize.md @@ -132,6 +132,7 @@ when (e.error) { EmbeddedAuthError.TooManyAttempts -> { /* rate-limited by attack protection */ } EmbeddedAuthError.TooManyLogins -> { /* too many login attempts */ } EmbeddedAuthError.SessionExpired -> { /* the grant expired — restart via authorize() */ } + EmbeddedAuthError.InvalidRequest -> { /* malformed request — integration fault, fix and restart */ } EmbeddedAuthError.Network -> { /* transient — safe to retry the same step */ } else -> { /* Unknown/unclassified — inspect e.code and e.description */ } }