From aa06e809949c67d88592deec5adcf011ebd117a5 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Fri, 9 Oct 2026 17:20:02 +0530 Subject: [PATCH 1/7] feat: Password support for /e/authorize endpoint --- EXAMPLES.md | 1 + .../android/embedded/EmbeddedAuthClient.kt | 30 +++++ .../android/embedded/EmbeddedAuthError.kt | 3 + .../authorize/EmbeddedAuthAdapters.kt | 3 + .../embedded/authorize/EmbeddedCapability.kt | 2 + .../embedded/authorize/IdentifierType.kt | 2 + .../android/embedded/authorize/NextAction.kt | 10 ++ .../embedded/authorize/NextActionMapper.kt | 2 + .../embedded/EmbeddedAuthClientTest.kt | 107 ++++++++++++++++++ .../authorize/NextActionMapperTest.kt | 16 +++ .../android/util/EmbeddedAuthMockServer.kt | 12 ++ examples/embedded-auth/password-authorize.md | 88 ++++++++++++++ 12 files changed, 276 insertions(+) create mode 100644 examples/embedded-auth/password-authorize.md diff --git a/EXAMPLES.md b/EXAMPLES.md index 8d584e874..235485b84 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -32,6 +32,7 @@ Each topic lives in its own file under [`examples/`](examples). ## Embedded Authentication (Beta) - [Email OTP — first-factor & MFA second-factor](examples/embedded-auth/authorize.md) +- [Username & Password — first-factor](examples/embedded-auth/password-authorize.md) - [Phone OTP (SMS/Voice) — first-factor & MFA second-factor](examples/embedded-auth/phone-authorize.md) - [Push Notification Polling (OOB)](examples/embedded-auth/push-oob-polling.md) - [Recovery Code](examples/embedded-auth/recovery-code.md) 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 4d6a7c5a8..562eac469 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt @@ -94,6 +94,9 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { IdentifierType.PHONE -> continueStep(EmbeddedCapability.IDENTIFY_PHONE) { addParameter(PHONE_KEY, identifier) } + + IdentifierType.USERNAME -> + continueStep(EmbeddedCapability.IDENTIFY_USERNAME) { addParameter(USERNAME_KEY, identifier) } } /** @@ -134,6 +137,29 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { return advancing(request) } + /** + * Verifies the user's [password]. This is the terminal step of a username/password flow. + * + * On success, it yields the [Credentials]. If the server requires further steps (for example + * a second factor) or rejects the password, the request completes through + * [EmbeddedAuthException] instead, with + * [EmbeddedAuthError.InsufficientAuthorization.nextActions] on [EmbeddedAuthException.error]; a + * wrong password reports [EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_PASSWORD]. + */ + public fun verifyPassword(password: String): Request { + val session = transactionState?.authSession ?: return noActiveSession() + val request = factory.post(authorizeUrl, authorizeCodeAdapter(gson)) + .addParameters( + mapOf( + AUTH_SESSION_KEY to session, + ACTION_KEY to EmbeddedCapability.VERIFY_PASSWORD.value, + CLIENT_ID_KEY to clientId + ) + ) + .addParameter(PASSWORD_KEY, password) + return advancing(request) + } + /** * Continues the flow by requesting a phone challenge for the authenticator at [index]. * @@ -330,6 +356,8 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { private const val ACTION_KEY = "action" private const val EMAIL_KEY = "email" private const val PHONE_KEY = "phone" + private const val USERNAME_KEY = "username" + private const val PASSWORD_KEY = "password" private const val DELIVERY_METHOD_KEY = "delivery_method" private const val OTP_KEY = "otp" private const val INDEX_KEY = "index" @@ -344,10 +372,12 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { private val DEFAULT_CAPABILITIES: Set = setOf( EmbeddedCapability.IDENTIFY_EMAIL, EmbeddedCapability.IDENTIFY_PHONE, + EmbeddedCapability.IDENTIFY_USERNAME, EmbeddedCapability.CHALLENGE_EMAIL, EmbeddedCapability.CHALLENGE_PHONE, EmbeddedCapability.CHALLENGE_PUSH, EmbeddedCapability.VERIFY_OTP, + EmbeddedCapability.VERIFY_PASSWORD, EmbeddedCapability.VERIFY_OOB, EmbeddedCapability.VERIFY_RECOVERY_CODE, EmbeddedCapability.CONFIRM_RECOVERY_CODE 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 efb9b840e..629a3d3e9 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt @@ -28,6 +28,9 @@ public sealed interface EmbeddedAuthError { /** The identifier or the code was invalid; the server does not say which. */ INVALID_IDENTIFIER_OR_CODE, + /** The username or the password was invalid; the server does not say which. */ + INVALID_IDENTIFIER_OR_PASSWORD, + /** Authorization is pending (e.g., push notification awaiting user approval). Retry after a delay. */ AUTHORIZATION_PENDING, 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 c833aa672..eac16baae 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 IP_BLOCKED = "ip_blocked" private const val INVALID_CODE = "invalid_code" private const val INVALID_IDENTIFIER_OR_CODE = "invalid_identifier_or_code" +private const val INVALID_IDENTIFIER_OR_PASSWORD = "invalid_identifier_or_password" private const val INVALID_GRANT = "invalid_grant" private const val INVALID_REQUEST = "invalid_request" private const val TOO_MANY_REQUESTS_STATUS = 429 @@ -127,6 +128,8 @@ internal fun embeddedAuthErrorAdapter(): ErrorAdapter { INVALID_CODE -> EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_CODE INVALID_IDENTIFIER_OR_CODE -> EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_CODE + INVALID_IDENTIFIER_OR_PASSWORD -> + EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_PASSWORD AUTHORIZATION_PENDING -> EmbeddedAuthError.InsufficientAuthorization.Reason.AUTHORIZATION_PENDING SLOW_DOWN -> EmbeddedAuthError.InsufficientAuthorization.Reason.SLOW_DOWN diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedCapability.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedCapability.kt index 385aeb5fd..32c0ea262 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedCapability.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedCapability.kt @@ -4,10 +4,12 @@ package com.auth0.android.embedded.authorize public enum class EmbeddedCapability(public val value: String) { IDENTIFY_EMAIL("action:identify:email:v1"), IDENTIFY_PHONE("action:identify:phone:v1"), + IDENTIFY_USERNAME("action:identify:username:v1"), CHALLENGE_EMAIL("action:challenge:email:v1"), CHALLENGE_PHONE("action:challenge:phone:v1"), CHALLENGE_PUSH("action:challenge:push:v1"), VERIFY_OTP("action:verify:otp:v1"), + VERIFY_PASSWORD("action:verify:password:v1"), VERIFY_OOB("action:verify:oob:v1"), VERIFY_RECOVERY_CODE("action:verify:recovery-code:v1"), CONFIRM_RECOVERY_CODE("action:confirm:recovery-code:v1"), diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/IdentifierType.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/IdentifierType.kt index a1f5e8ff6..b42c3a6e9 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/authorize/IdentifierType.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/IdentifierType.kt @@ -8,4 +8,6 @@ public enum class IdentifierType { EMAIL, /** A phone number. */ PHONE, + /** A username. */ + USERNAME, } 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 2d9269deb..29f106c45 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 @@ -18,6 +18,16 @@ public sealed interface NextAction { override val capability: EmbeddedCapability = EmbeddedCapability.IDENTIFY_PHONE } + /** Continue by submitting a username. Act on it with [EmbeddedAuthClient.identify]. */ + public data object IdentifyUsername : NextAction { + override val capability: EmbeddedCapability = EmbeddedCapability.IDENTIFY_USERNAME + } + + /** Continue by verifying the user's password. Act on it with [EmbeddedAuthClient.verifyPassword]. */ + public data object VerifyPassword : NextAction { + override val capability: EmbeddedCapability = EmbeddedCapability.VERIFY_PASSWORD + } + /** Continue by requesting an email challenge. Act on it with [EmbeddedAuthClient.challengeEmail]. */ @ConsistentCopyVisibility public data class ChallengeEmail internal constructor( 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 e32426830..3cc767ffd 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 @@ -20,6 +20,8 @@ private fun Map.toNextAction(): NextAction? { return when (EmbeddedCapability.fromValue(raw)) { EmbeddedCapability.IDENTIFY_EMAIL -> NextAction.IdentifyEmail EmbeddedCapability.IDENTIFY_PHONE -> NextAction.IdentifyPhone + EmbeddedCapability.IDENTIFY_USERNAME -> NextAction.IdentifyUsername + EmbeddedCapability.VERIFY_PASSWORD -> NextAction.VerifyPassword 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) 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 2b96d6eec..1d2143c8b 100644 --- a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt @@ -223,6 +223,113 @@ public class EmbeddedAuthClientTest { assertThat(body.getString("type"), `is`("totp")) } + // ── identify(USERNAME) / verifyPassword ───────────────────────────────────── + + @Test + public fun `identify with USERNAME should POST the correct action and username`() { + establishSession() + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_VERIFY_PASSWORD) + + val error = assertEmbeddedError { client.identify("jane", IdentifierType.USERNAME).execute() } + + val body = bodyOf(mockAPI.takeRequest()) + assertThat(body.getString("action"), `is`("action:identify:username:v1")) + assertThat(body.getString("username"), `is`("jane")) + assertThat(body.getString("auth_session"), `is`(EmbeddedAuthMockServer.AUTH_SESSION)) + assertThat(error.nextActions[0], `is`(NextAction.VerifyPassword)) + } + + @Test + public fun `authorize continuation surfaces IdentifyUsername next action`() { + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_IDENTIFY_USERNAME) + + val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } + + assertThat(error.nextActions[0], `is`(NextAction.IdentifyUsername)) + } + + @Test + public fun `verifyPassword should fail with no_active_session when no flow is in progress`() { + val error = assertEmbeddedError { client.verifyPassword("secret").execute() } + assertThat(error.code, `is`("no_active_session")) + assertThat(error.error, `is`(EmbeddedAuthError.NoActiveSession)) + } + + @Test + public fun `verifyPassword should POST the correct action and password and return credentials`() { + establishSession() + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokens() + + val credentials = client.verifyPassword("secret").execute() + + val body = bodyOf(mockAPI.takeRequest()) + assertThat(body.getString("action"), `is`("action:verify:password:v1")) + assertThat(body.getString("password"), `is`("secret")) + assertThat(body.getString("auth_session"), `is`(EmbeddedAuthMockServer.AUTH_SESSION)) + assertThat(body.getString("client_id"), `is`(CLIENT_ID)) + assertThat(credentials.accessToken, `is`(EmbeddedAuthMockServer.ACCESS_TOKEN)) + assertThat(bodyOf(mockAPI.takeRequest()).getString("code"), `is`(EmbeddedAuthMockServer.AUTHORIZATION_CODE)) + } + + @Test + public fun `verifyPassword surfaces an MFA continuation and rotates the session`() { + establishSession() + mockAPI.willReturnContinuationWith( + EmbeddedAuthMockServer.NEXT_CHALLENGE_PHONE, + authSession = EmbeddedAuthMockServer.ROTATED_AUTH_SESSION + ) + + val error = assertEmbeddedError { client.verifyPassword("secret").execute() } + mockAPI.takeRequest() + + assertThat(error.error, instanceOf(EmbeddedAuthError.InsufficientAuthorization::class.java)) + assertThat(error.nextActions[0], instanceOf(NextAction.ChallengePhone::class.java)) + + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_VERIFY_OTP_SMS) + assertEmbeddedError { client.challengePhone().execute() } + val body = bodyOf(mockAPI.takeRequest()) + assertThat(body.getString("auth_session"), `is`(EmbeddedAuthMockServer.ROTATED_AUTH_SESSION)) + } + + @Test + public fun `verifyPassword flags a wrong password as recoverable and keeps the session`() { + establishSession() + mockAPI.willReturnInvalidPassword() + + val error = assertEmbeddedError { client.verifyPassword("wrong").execute() } + mockAPI.takeRequest() + + val reason = error.error as EmbeddedAuthError.InsufficientAuthorization + assertThat(reason.reason, `is`(EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_PASSWORD)) + assertThat(error.nextActions[0], `is`(NextAction.VerifyPassword)) + + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokens() + client.verifyPassword("right").execute() + val retry = bodyOf(mockAPI.takeRequest()) + assertThat(retry.getString("auth_session"), `is`(EmbeddedAuthMockServer.ROTATED_AUTH_SESSION)) + } + + @Test + public fun `verifyPassword await should return credentials`(): Unit = runTest { + establishSession() + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokens() + + val credentials = client.verifyPassword("secret").await() + + assertThat(credentials.accessToken, `is`(EmbeddedAuthMockServer.ACCESS_TOKEN)) + } + + @Test + public fun `adapter maps insufficient_authorization + invalid_identifier_or_password to reason INVALID_IDENTIFIER_OR_PASSWORD`() { + val json = """{"error":"insufficient_authorization","error_description":"invalid_identifier_or_password","auth_session":"s","next":[${EmbeddedAuthMockServer.NEXT_VERIFY_PASSWORD}]}""" + val ex = embeddedAuthErrorAdapter().fromJsonResponse(403, StringReader(json)) + val error = ex.error as EmbeddedAuthError.InsufficientAuthorization + assertThat(error.reason, `is`(EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_PASSWORD)) + } + // ── Continuation / typed-error mapping ────────────────────────────────────── @Test diff --git a/auth0/src/test/java/com/auth0/android/embedded/authorize/NextActionMapperTest.kt b/auth0/src/test/java/com/auth0/android/embedded/authorize/NextActionMapperTest.kt index 7f9cb0e75..656448c82 100644 --- a/auth0/src/test/java/com/auth0/android/embedded/authorize/NextActionMapperTest.kt +++ b/auth0/src/test/java/com/auth0/android/embedded/authorize/NextActionMapperTest.kt @@ -23,6 +23,22 @@ public class NextActionMapperTest { const val NEW_CODE_KEY = "new_code" } + @Test + public fun testIdentifyUsernameMapped() { + val raw = mapOf(ACTION_KEY to "action:identify:username:v1") + val action = listOf(raw).toNextActions().first() + + assertThat(action, `is`(NextAction.IdentifyUsername)) + } + + @Test + public fun testVerifyPasswordMapped() { + val raw = mapOf(ACTION_KEY to "action:verify:password:v1") + val action = listOf(raw).toNextActions().first() + + assertThat(action, `is`(NextAction.VerifyPassword)) + } + @Test public fun testChallengePhoneMapped() { val raw = mapOf( 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 1823c7928..bccda2e82 100644 --- a/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt @@ -74,6 +74,16 @@ $actionsJson return this } + fun willReturnInvalidPassword(): EmbeddedAuthMockServer { + server.enqueue( + responseWithJSON( + """{ "error": "insufficient_authorization", "error_description": "invalid_identifier_or_password", "auth_session": "$ROTATED_AUTH_SESSION", "next": [ $NEXT_VERIFY_PASSWORD ] }""", + 403 + ) + ) + return this + } + fun willReturnTooManyWrongOtpAttempts(): EmbeddedAuthMockServer { server.enqueue(responseWithJSON("""{ "error": "access_denied", "error_description": "too_many_wrong_otp_attempts" }""", 403)) return this @@ -151,6 +161,8 @@ $actionsJson // 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_IDENTIFY_USERNAME = """{ "action": "action:identify:username:v1" }""" + const val NEXT_VERIFY_PASSWORD = """{ "action": "action:verify:password:v1" }""" const val NEXT_CHALLENGE_EMAIL = """{ "action": "action:challenge:email:v1", "index": 1, "identifier": "jane@example.com" }""" const val NEXT_CHALLENGE_PHONE = """{ "action": "action:challenge:phone:v1", "index": 0, "identifier": "+1234567890", "delivery_methods": ["text", "voice"] }""" const val NEXT_CHALLENGE_PUSH = """{ "action": "action:challenge:push:v1", "index": 0, "name": "My Phone" }""" diff --git a/examples/embedded-auth/password-authorize.md b/examples/embedded-auth/password-authorize.md new file mode 100644 index 000000000..75ff6140a --- /dev/null +++ b/examples/embedded-auth/password-authorize.md @@ -0,0 +1,88 @@ +# Username & Password — first factor + +Authenticate with a username and password. The sequence is `authorize` → `identify` (username) → +`verifyPassword`; the server decides what follows by advertising `NextAction` entries (see +[authorize.md](authorize.md) for the next-action pattern). + +Each non-terminal step completes by throwing an `EmbeddedAuthException` whose `error` is +`EmbeddedAuthError.InsufficientAuthorization`. `verifyPassword` is terminal when no further factor is +required and returns `Credentials`; if the tenant requires MFA it instead throws an +`InsufficientAuthorization` continuation carrying the MFA next actions (see +[phone-authorize.md](phone-authorize.md), [push-oob-polling.md](push-oob-polling.md), +[totp.md](totp.md), [recovery-code.md](recovery-code.md)). + +```kotlin +val embedded = EmbeddedAuthClient(Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN")) + +// 1. Start the flow — always continues with the next actions (e.g. IdentifyUsername). +try { + embedded.authorize("Username-Password-Authentication").await() +} catch (e: EmbeddedAuthException) { /* inspect (e.error as InsufficientAuthorization).nextActions */ } + +// 2. Identify by username. The server continues with a VerifyPassword action. +try { + embedded.identify("jane", IdentifierType.USERNAME).await() +} catch (e: EmbeddedAuthException) { /* continuation → VerifyPassword */ } + +// 3. Verify the password. Terminal — returns Credentials on success. +try { + val credentials = embedded.verifyPassword("the-password").await() + // credentials.accessToken, credentials.idToken, ... +} catch (e: EmbeddedAuthException) { + when (val error = e.error) { + is EmbeddedAuthError.InsufficientAuthorization -> { + if (error.reason == EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_PASSWORD) { + /* wrong username or password — re-prompt and call verifyPassword again */ + } else { + /* more MFA required — act on error.nextActions */ + } + } + else -> { /* other terminal error */ } + } +} +``` + +
+ Using callbacks + +```kotlin +embedded.identify("jane", IdentifierType.USERNAME) + .start(object : Callback { + override fun onSuccess(result: Void?) { } + override fun onFailure(error: EmbeddedAuthException) { /* continuation → VerifyPassword */ } + }) + +embedded.verifyPassword("the-password") + .start(object : Callback { + override fun onSuccess(result: Credentials) { /* signed in */ } + override fun onFailure(error: EmbeddedAuthException) { /* wrong password or MFA required */ } + }) +``` +
+ +
+ Using Java + +```java +Auth0 auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN"); +EmbeddedAuthClient embedded = new EmbeddedAuthClient(auth0); + +embedded.identify("jane", IdentifierType.USERNAME) + .start(new Callback() { + @Override public void onSuccess(Void result) { } + @Override public void onFailure(@NonNull EmbeddedAuthException e) { + // continuation → verifyPassword(...) + } + }); + +embedded.verifyPassword("the-password") + .start(new Callback() { + @Override public void onSuccess(Credentials credentials) { + // credentials.getAccessToken() + } + @Override public void onFailure(@NonNull EmbeddedAuthException e) { + // wrong password, or MFA required + } + }); +``` +
From ffa0c3b8fcfd4689f0385a8baa25fd9c7ef89be0 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 21 Oct 2026 15:58:58 +0530 Subject: [PATCH 2/7] Added changes for sample app --- .../auth0/sample/embedded/AuthorizeScreen.kt | 51 +++++++++++++++++++ .../sample/embedded/EmbeddedViewModel.kt | 6 +++ .../src/main/res/values/strings.xml | 4 ++ 3 files changed, 61 insertions(+) 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 32141f8e3..6ffc9ea81 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 @@ -38,6 +38,7 @@ 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.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel @@ -171,6 +172,18 @@ private fun ActionsSection(actions: List, viewModel: EmbeddedViewMod onSubmit = { viewModel.identifyPhone(it) }, ) + NextAction.IdentifyUsername -> IdentifyCard( + labelRes = R.string.action_identify_username, + hintRes = R.string.hint_username, + keyboardType = KeyboardType.Text, + submitRes = R.string.action_submit, + onSubmit = { viewModel.identifyUsername(it) }, + ) + + NextAction.VerifyPassword -> VerifyPasswordCard( + onVerify = { viewModel.verifyPassword(it) }, + ) + is NextAction.ChallengeEmail -> ChallengeEmailCard( identifier = action.identifier, onChallenge = { viewModel.challengeEmail(action.index) }, @@ -329,6 +342,44 @@ private fun ActionCard(titleRes: Int, detail: String?, buttonRes: Int, onClick: } } +/** The password is submitted as typed — never trimmed — and masked on screen. */ +@Composable +private fun VerifyPasswordCard(onVerify: (String) -> Unit) { + var password by rememberSaveable { mutableStateOf("") } + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + stringResource(R.string.action_verify_password), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.hint_password)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { if (password.isNotEmpty()) onVerify(password) }, + ), + ) + Spacer(Modifier.height(12.dp)) + Button( + onClick = { onVerify(password) }, + enabled = password.isNotEmpty(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.action_verify)) + } + } + } +} + @Composable private fun VerifyRecoveryCodeCard(onVerify: (String) -> Unit) { var code by rememberSaveable { mutableStateOf("") } 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 cab40eecf..3f71fd0a1 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 @@ -58,6 +58,9 @@ class EmbeddedViewModel(application: Application) : AndroidViewModel(application fun identifyPhone(phone: String): Unit = runStep { client.identify(phone, IdentifierType.PHONE).await() } + fun identifyUsername(username: String): Unit = + runStep { client.identify(username, IdentifierType.USERNAME).await() } + fun challengePhone(index: Int, deliveryMethod: PhoneDeliveryMethod): Unit = runStep { client.challengePhone(index, deliveryMethod).await() } @@ -69,6 +72,9 @@ class EmbeddedViewModel(application: Application) : AndroidViewModel(application /** Terminal step: on success this yields [Credentials][com.auth0.android.result.Credentials]. */ fun verifyOtp(code: String, type: OtpType): Unit = runTerminalStep { client.verifyOtp(code, type).await() } + /** Terminal step: yields credentials, or continues if the server requires a second factor. */ + fun verifyPassword(password: String): Unit = runTerminalStep { client.verifyPassword(password).await() } + /** Terminal step: acknowledges the rotated recovery code. */ fun confirmRecoveryCode(): Unit = runTerminalStep { client.confirmRecoveryCode().await() } diff --git a/sample-embedded/src/main/res/values/strings.xml b/sample-embedded/src/main/res/values/strings.xml index d1c0c0dad..3458d7684 100644 --- a/sample-embedded/src/main/res/values/strings.xml +++ b/sample-embedded/src/main/res/values/strings.xml @@ -25,6 +25,10 @@ One-time code Identify with email Identify with phone + Username + Identify with username + Password + Enter your password Email me a code Verify code Submit From 05cbe633e4deebf7cc48e187a9c2d57742598d08 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 21 Oct 2026 16:05:25 +0530 Subject: [PATCH 3/7] Added required doc change --- examples/embedded-auth/authorize.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/embedded-auth/authorize.md b/examples/embedded-auth/authorize.md index 5449966c7..a68069a42 100644 --- a/examples/embedded-auth/authorize.md +++ b/examples/embedded-auth/authorize.md @@ -60,6 +60,8 @@ if (error is EmbeddedAuthError.InsufficientAuthorization) { when (action) { is NextAction.IdentifyEmail -> { /* show email field, call identify(..., EMAIL) */ } is NextAction.IdentifyPhone -> { /* show phone field, call identify(..., PHONE) */ } + is NextAction.IdentifyUsername -> { /* show username field, call identify(..., USERNAME) */ } + is NextAction.VerifyPassword -> { /* show password field, call verifyPassword() */ } is NextAction.ChallengeEmail -> { /* send email code, call challengeEmail(action.index) */ } is NextAction.ChallengePhone -> { /* send SMS/voice code, call challengePhone(action.index, ...) */ } is NextAction.ChallengePush -> { /* trigger push, call challengePush(action.index) */ } @@ -73,7 +75,7 @@ if (error is EmbeddedAuthError.InsufficientAuthorization) { } ``` -See the dedicated examples for each factor: [Phone OTP (SMS/Voice)](phone-authorize.md), [Push (OOB)](push-oob-polling.md), [TOTP](totp.md), and [Recovery Code](recovery-code.md). +See the dedicated examples for each factor: [Username & Password](password-authorize.md), [Phone OTP (SMS/Voice)](phone-authorize.md), [Push (OOB)](push-oob-polling.md), [TOTP](totp.md), and [Recovery Code](recovery-code.md). #### Identify the user @@ -128,6 +130,8 @@ if (error is EmbeddedAuthError.InsufficientAuthorization) { "That code was incorrect. Try again." EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_CODE -> "That identifier or code was incorrect. Try again." + EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_PASSWORD -> + "That username or password was incorrect. Try again." EmbeddedAuthError.InsufficientAuthorization.Reason.NONE -> null // plain step-forward — no rejection to report EmbeddedAuthError.InsufficientAuthorization.Reason.AUTHORIZATION_PENDING, From ae37d166bab293bef069a5f842b8acf0ddf2180b Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Fri, 23 Oct 2026 16:35:13 +0530 Subject: [PATCH 4/7] Added breached_password attacj protection --- .../android/embedded/EmbeddedAuthError.kt | 6 ++++++ .../embedded/authorize/EmbeddedAuthAdapters.kt | 3 +++ .../android/embedded/EmbeddedAuthClientTest.kt | 18 ++++++++++++++++++ .../android/util/EmbeddedAuthMockServer.kt | 5 +++++ examples/embedded-auth/authorize.md | 1 + examples/embedded-auth/password-authorize.md | 3 +++ .../auth0/sample/embedded/AuthorizeScreen.kt | 1 + 7 files changed, 37 insertions(+) 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 629a3d3e9..7a9dc6608 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt @@ -81,6 +81,12 @@ public sealed interface EmbeddedAuthError { /** Terminal: additional consent is required before the flow can proceed. */ public data object ConsentRequired : EmbeddedAuthError + /** + * Terminal: breached password detection blocked the login because the password appears in a + * data breach. The user must reset their password before starting a new flow. + */ + public data object PasswordLeaked : 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 eac16baae..5c21cc554 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 @@ -38,6 +38,7 @@ private const val TOO_MANY_REQUESTS_STATUS = 429 private const val AUTHORIZATION_REJECTED = "authorization_rejected" private const val NO_ELIGIBLE_FACTORS = "no_eligible_factors" private const val CONSENT_REQUIRED = "consent_required" +private const val PASSWORD_LEAKED = "password_leaked" private const val AUTHORIZATION_PENDING = "authorization_pending" private const val SLOW_DOWN = "slow_down" @@ -98,6 +99,8 @@ internal fun embeddedAuthErrorAdapter(): ErrorAdapter { EmbeddedAuthError.NoEligibleFactors code == ACCESS_DENIED && description == CONSENT_REQUIRED -> EmbeddedAuthError.ConsentRequired + code == ACCESS_DENIED && description == PASSWORD_LEAKED -> + EmbeddedAuthError.PasswordLeaked code == ACCESS_DENIED -> EmbeddedAuthError.AccessDenied statusCode == TOO_MANY_REQUESTS_STATUS && code == TOO_MANY_REQUESTS && description == TOO_MANY_ATTEMPTS -> 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 1d2143c8b..d778f2148 100644 --- a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt @@ -311,6 +311,17 @@ public class EmbeddedAuthClientTest { assertThat(retry.getString("auth_session"), `is`(EmbeddedAuthMockServer.ROTATED_AUTH_SESSION)) } + @Test + public fun `verifyPassword await surfaces a breached password as PasswordLeaked`(): Unit = runTest { + establishSession() + mockAPI.willReturnPasswordLeaked() + + val error = assertEmbeddedErrorSuspending { client.verifyPassword("leaked").await() } + + assertThat(error.error, `is`(EmbeddedAuthError.PasswordLeaked)) + assertThat(error.statusCode, `is`(403)) + } + @Test public fun `verifyPassword await should return credentials`(): Unit = runTest { establishSession() @@ -951,6 +962,13 @@ public class EmbeddedAuthClientTest { assertThat(ex.error, `is`(EmbeddedAuthError.ConsentRequired)) } + @Test + public fun `adapter maps password_leaked to PasswordLeaked`() { + val json = """{"error":"access_denied","error_description":"password_leaked"}""" + val ex = embeddedAuthErrorAdapter().fromJsonResponse(403, StringReader(json)) + assertThat(ex.error, `is`(EmbeddedAuthError.PasswordLeaked)) + } + @Test public fun `adapter maps authorization_pending reason to InsufficientAuthorization with AUTHORIZATION_PENDING reason`() { 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 bccda2e82..bfc44d4cd 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,11 @@ $actionsJson return this } + fun willReturnPasswordLeaked(): EmbeddedAuthMockServer { + server.enqueue(responseWithJSON("""{ "error": "access_denied", "error_description": "password_leaked" }""", 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 a68069a42..1d65d60e0 100644 --- a/examples/embedded-auth/authorize.md +++ b/examples/embedded-auth/authorize.md @@ -155,6 +155,7 @@ when (e.error) { EmbeddedAuthError.AuthorizationRejected -> { /* user rejected or canceled (e.g. denied the push) */ } EmbeddedAuthError.NoEligibleFactors -> { /* no eligible factors — send the user to enroll one, then restart */ } EmbeddedAuthError.ConsentRequired -> { /* additional consent required before continuing */ } + EmbeddedAuthError.PasswordLeaked -> { /* breached password — user must reset it, then restart */ } EmbeddedAuthError.TooManyAttempts -> { /* rate-limited by attack protection */ } EmbeddedAuthError.TooManyLogins -> { /* too many login attempts */ } EmbeddedAuthError.IpBlocked -> { /* suspicious-IP throttling blocked this IP */ } diff --git a/examples/embedded-auth/password-authorize.md b/examples/embedded-auth/password-authorize.md index 75ff6140a..7e75d94db 100644 --- a/examples/embedded-auth/password-authorize.md +++ b/examples/embedded-auth/password-authorize.md @@ -37,6 +37,9 @@ try { /* more MFA required — act on error.nextActions */ } } + EmbeddedAuthError.PasswordLeaked -> { + /* breached password detected — send the user to reset their password, then restart */ + } else -> { /* other terminal error */ } } } 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 6ffc9ea81..652118357 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 @@ -522,6 +522,7 @@ private fun explain(error: EmbeddedAuthError): String = when (error) { EmbeddedAuthError.AuthorizationRejected -> "The push notification was denied." EmbeddedAuthError.NoEligibleFactors -> "No enrolled factor can satisfy this login." EmbeddedAuthError.ConsentRequired -> "Consent is required, which this flow can't collect." + EmbeddedAuthError.PasswordLeaked -> "This password appeared in a data breach — reset it and try again." EmbeddedAuthError.TooManyAttempts, EmbeddedAuthError.TooManyLogins, EmbeddedAuthError.IpBlocked -> "Rate limited — try again later." From e51c01e0c60539c33af621af28e70dbdf1c19f04 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Fri, 23 Oct 2026 17:42:54 +0530 Subject: [PATCH 5/7] Added error case for no_next_step terminal scenario --- .../android/embedded/EmbeddedAuthError.kt | 3 +++ .../embedded/authorize/EmbeddedAuthAdapters.kt | 3 +++ .../android/embedded/EmbeddedAuthClientTest.kt | 18 ++++++++++++++++++ .../android/util/EmbeddedAuthMockServer.kt | 5 +++++ examples/embedded-auth/authorize.md | 1 + .../auth0/sample/embedded/AuthorizeScreen.kt | 1 + 6 files changed, 31 insertions(+) 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 7a9dc6608..6285abfef 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt @@ -87,6 +87,9 @@ public sealed interface EmbeddedAuthError { */ public data object PasswordLeaked : EmbeddedAuthError + /** Terminal: no further action is available to continue authorization. Restart the flow. */ + public data object NoNextSteps : 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 5c21cc554..b1bd199cc 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 @@ -39,6 +39,7 @@ private const val AUTHORIZATION_REJECTED = "authorization_rejected" private const val NO_ELIGIBLE_FACTORS = "no_eligible_factors" private const val CONSENT_REQUIRED = "consent_required" private const val PASSWORD_LEAKED = "password_leaked" +private const val NO_NEXT_STEPS = "no_next_steps" private const val AUTHORIZATION_PENDING = "authorization_pending" private const val SLOW_DOWN = "slow_down" @@ -101,6 +102,8 @@ internal fun embeddedAuthErrorAdapter(): ErrorAdapter { EmbeddedAuthError.ConsentRequired code == ACCESS_DENIED && description == PASSWORD_LEAKED -> EmbeddedAuthError.PasswordLeaked + code == ACCESS_DENIED && description == NO_NEXT_STEPS -> + EmbeddedAuthError.NoNextSteps code == ACCESS_DENIED -> EmbeddedAuthError.AccessDenied statusCode == TOO_MANY_REQUESTS_STATUS && code == TOO_MANY_REQUESTS && description == TOO_MANY_ATTEMPTS -> 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 d778f2148..6f3601684 100644 --- a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt @@ -322,6 +322,17 @@ public class EmbeddedAuthClientTest { assertThat(error.statusCode, `is`(403)) } + @Test + public fun `verifyPassword await surfaces NoNextSteps as a terminal error`(): Unit = runTest { + establishSession() + mockAPI.willReturnNoNextSteps() + + val error = assertEmbeddedErrorSuspending { client.verifyPassword("secret").await() } + + assertThat(error.error, `is`(EmbeddedAuthError.NoNextSteps)) + assertThat(error.statusCode, `is`(403)) + } + @Test public fun `verifyPassword await should return credentials`(): Unit = runTest { establishSession() @@ -969,6 +980,13 @@ public class EmbeddedAuthClientTest { assertThat(ex.error, `is`(EmbeddedAuthError.PasswordLeaked)) } + @Test + public fun `adapter maps no_next_steps to NoNextSteps`() { + val json = """{"error":"access_denied","error_description":"no_next_steps"}""" + val ex = embeddedAuthErrorAdapter().fromJsonResponse(403, StringReader(json)) + assertThat(ex.error, `is`(EmbeddedAuthError.NoNextSteps)) + } + @Test public fun `adapter maps authorization_pending reason to InsufficientAuthorization with AUTHORIZATION_PENDING reason`() { 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 bfc44d4cd..e68268589 100644 --- a/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt @@ -99,6 +99,11 @@ $actionsJson return this } + fun willReturnNoNextSteps(): EmbeddedAuthMockServer { + server.enqueue(responseWithJSON("""{ "error": "access_denied", "error_description": "no_next_steps" }""", 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 1d65d60e0..1abf16e3a 100644 --- a/examples/embedded-auth/authorize.md +++ b/examples/embedded-auth/authorize.md @@ -156,6 +156,7 @@ when (e.error) { EmbeddedAuthError.NoEligibleFactors -> { /* no eligible factors — send the user to enroll one, then restart */ } EmbeddedAuthError.ConsentRequired -> { /* additional consent required before continuing */ } EmbeddedAuthError.PasswordLeaked -> { /* breached password — user must reset it, then restart */ } + EmbeddedAuthError.NoNextSteps -> { /* no further action available — restart the flow */ } EmbeddedAuthError.TooManyAttempts -> { /* rate-limited by attack protection */ } EmbeddedAuthError.TooManyLogins -> { /* too many login attempts */ } EmbeddedAuthError.IpBlocked -> { /* suspicious-IP throttling blocked this IP */ } 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 652118357..5739fd0e5 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 @@ -523,6 +523,7 @@ private fun explain(error: EmbeddedAuthError): String = when (error) { EmbeddedAuthError.NoEligibleFactors -> "No enrolled factor can satisfy this login." EmbeddedAuthError.ConsentRequired -> "Consent is required, which this flow can't collect." EmbeddedAuthError.PasswordLeaked -> "This password appeared in a data breach — reset it and try again." + EmbeddedAuthError.NoNextSteps -> "No further steps are available to continue — start over." EmbeddedAuthError.TooManyAttempts, EmbeddedAuthError.TooManyLogins, EmbeddedAuthError.IpBlocked -> "Rate limited — try again later." From a6554fc648194c813ee2eec2cec4a04704fd1a46 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 28 Oct 2026 11:02:54 +0530 Subject: [PATCH 6/7] Changes with respect to tests and docs --- .../android/embedded/EmbeddedAuthClient.kt | 80 ++++++--------- .../embedded/EmbeddedAuthClientTest.kt | 97 ++++++++++++++++++- .../android/util/EmbeddedAuthMockServer.kt | 5 + examples/embedded-auth/password-authorize.md | 31 ++++-- 4 files changed, 152 insertions(+), 61 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 562eac469..0ec0df871 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt @@ -122,43 +122,28 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { 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 EmbeddedCapability.VERIFY_OTP.value, - CLIENT_ID_KEY to clientId - ) - ) - .addParameter(OTP_KEY, code) - .addParameter(TYPE_KEY, type.value) - return advancing(request) - } + ): Request = + terminalStep(EmbeddedCapability.VERIFY_OTP) { + addParameter(OTP_KEY, code) + addParameter(TYPE_KEY, type.value) + } /** - * Verifies the user's [password]. This is the terminal step of a username/password flow. + * Verifies the user's [password]. Completes the flow when no further factor is required. * * On success, it yields the [Credentials]. If the server requires further steps (for example * a second factor) or rejects the password, the request completes through * [EmbeddedAuthException] instead, with * [EmbeddedAuthError.InsufficientAuthorization.nextActions] on [EmbeddedAuthException.error]; a * wrong password reports [EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_PASSWORD]. + * + * A password found in a data breach completes with the terminal + * [EmbeddedAuthError.PasswordLeaked] */ - public fun verifyPassword(password: String): Request { - val session = transactionState?.authSession ?: return noActiveSession() - val request = factory.post(authorizeUrl, authorizeCodeAdapter(gson)) - .addParameters( - mapOf( - AUTH_SESSION_KEY to session, - ACTION_KEY to EmbeddedCapability.VERIFY_PASSWORD.value, - CLIENT_ID_KEY to clientId - ) - ) - .addParameter(PASSWORD_KEY, password) - return advancing(request) - } + public fun verifyPassword(password: String): Request = + terminalStep(EmbeddedCapability.VERIFY_PASSWORD) { + addParameter(PASSWORD_KEY, password) + } /** * Continues the flow by requesting a phone challenge for the authenticator at [index]. @@ -218,18 +203,8 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { * On rejection or expiry the request completes with a terminal [EmbeddedAuthError] such as * [EmbeddedAuthError.AuthorizationRejected] or [EmbeddedAuthError.ChallengeExpired]. */ - public fun verifyOob(): Request { - val session = transactionState?.authSession ?: return noActiveSession() - val request = factory.post(authorizeUrl, authorizeCodeAdapter(gson)) - .addParameters( - mapOf( - AUTH_SESSION_KEY to session, - ACTION_KEY to EmbeddedCapability.VERIFY_OOB.value, - CLIENT_ID_KEY to clientId - ) - ) - return advancing(request) - } + public fun verifyOob(): Request = + terminalStep(EmbeddedCapability.VERIFY_OOB) /** * Verifies a recovery [code]. @@ -255,25 +230,32 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { * * On success, it yields the [Credentials]. */ - public fun confirmRecoveryCode(): Request { + public fun confirmRecoveryCode(): Request = + terminalStep(EmbeddedCapability.CONFIRM_RECOVERY_CODE) + + private fun continueStep( + action: EmbeddedCapability, + addPayload: Request.() -> Unit = {} + ): Request { val session = transactionState?.authSession ?: return noActiveSession() - val request = factory.post(authorizeUrl, authorizeCodeAdapter(gson)) + val request = factory.post(authorizeUrl) .addParameters( mapOf( AUTH_SESSION_KEY to session, - ACTION_KEY to EmbeddedCapability.CONFIRM_RECOVERY_CODE.value, + ACTION_KEY to action.value, CLIENT_ID_KEY to clientId ) ) - return advancing(request) + request.addPayload() + return stepping(request) } - private fun continueStep( + private fun terminalStep( action: EmbeddedCapability, - addPayload: Request.() -> Unit = {} - ): Request { + addPayload: Request.() -> Unit = {} + ): Request { val session = transactionState?.authSession ?: return noActiveSession() - val request = factory.post(authorizeUrl) + val request = factory.post(authorizeUrl, authorizeCodeAdapter(gson)) .addParameters( mapOf( AUTH_SESSION_KEY to session, @@ -282,7 +264,7 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { ) ) request.addPayload() - return stepping(request) + return advancing(request) } private fun noActiveSession(): Request = FailedRequest( 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 6f3601684..b63b5c47d 100644 --- a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt @@ -2,6 +2,7 @@ package com.auth0.android.embedded import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception +import com.auth0.android.embedded.authorize.EmbeddedCapability import com.auth0.android.embedded.authorize.IdentifierType import com.auth0.android.embedded.authorize.NextAction import com.auth0.android.embedded.authorize.OtpChannel @@ -92,6 +93,52 @@ public class EmbeddedAuthClientTest { assertThat(body.getString("audience"), `is`("https://api.example.com")) } + @Test + public fun `authorize should send the default capabilities`() { + mockAPI.willReturnJsonError() + + try { + client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() + } catch (_: EmbeddedAuthException) { + } + + val capabilities = capabilitiesOf(bodyOf(mockAPI.takeRequest())) + assertThat( + capabilities, + `is`( + listOf( + "action:identify:email:v1", + "action:identify:phone:v1", + "action:identify:username:v1", + "action:challenge:email:v1", + "action:challenge:phone:v1", + "action:challenge:push:v1", + "action:verify:otp:v1", + "action:verify:password:v1", + "action:verify:oob:v1", + "action:verify:recovery-code:v1", + "action:confirm:recovery-code:v1" + ) + ) + ) + } + + @Test + public fun `authorize should send only the given capabilities`() { + mockAPI.willReturnJsonError() + + try { + client.authorize( + connection = EmbeddedAuthMockServer.AUTHORIZE_CONNECTION, + capabilities = setOf(EmbeddedCapability.IDENTIFY_EMAIL, EmbeddedCapability.VERIFY_OTP) + ).execute() + } catch (_: EmbeddedAuthException) { + } + + val capabilities = capabilitiesOf(bodyOf(mockAPI.takeRequest())) + assertThat(capabilities, `is`(listOf("action:identify:email:v1", "action:verify:otp:v1"))) + } + // ── verifyOtp — token exchange ────────────────────────────────────────────── @Test @@ -239,6 +286,22 @@ public class EmbeddedAuthClientTest { assertThat(error.nextActions[0], `is`(NextAction.VerifyPassword)) } + @Test + public fun `authorize await surfaces NoNextSteps as a terminal error and leaves no session`(): Unit = runTest { + mockAPI.willReturnNoNextSteps() + + val error = assertEmbeddedErrorSuspending { + client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).await() + } + + assertThat(error.error, `is`(EmbeddedAuthError.NoNextSteps)) + assertThat(error.statusCode, `is`(403)) + mockAPI.takeRequest() + + val followOn = assertEmbeddedErrorSuspending { client.identify("jane", IdentifierType.USERNAME).await() } + assertThat(followOn.error, `is`(EmbeddedAuthError.NoActiveSession)) + } + @Test public fun `authorize continuation surfaces IdentifyUsername next action`() { mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_IDENTIFY_USERNAME) @@ -312,7 +375,7 @@ public class EmbeddedAuthClientTest { } @Test - public fun `verifyPassword await surfaces a breached password as PasswordLeaked`(): Unit = runTest { + public fun `verifyPassword await surfaces a breached password as PasswordLeaked and clears the session`(): Unit = runTest { establishSession() mockAPI.willReturnPasswordLeaked() @@ -320,10 +383,14 @@ public class EmbeddedAuthClientTest { assertThat(error.error, `is`(EmbeddedAuthError.PasswordLeaked)) assertThat(error.statusCode, `is`(403)) + mockAPI.takeRequest() + + val followOn = assertEmbeddedErrorSuspending { client.verifyPassword("secret").await() } + assertThat(followOn.error, `is`(EmbeddedAuthError.NoActiveSession)) } @Test - public fun `verifyPassword await surfaces NoNextSteps as a terminal error`(): Unit = runTest { + public fun `verifyPassword await surfaces NoNextSteps as a terminal error and clears the session`(): Unit = runTest { establishSession() mockAPI.willReturnNoNextSteps() @@ -331,6 +398,27 @@ public class EmbeddedAuthClientTest { assertThat(error.error, `is`(EmbeddedAuthError.NoNextSteps)) assertThat(error.statusCode, `is`(403)) + mockAPI.takeRequest() + + val followOn = assertEmbeddedErrorSuspending { client.verifyPassword("secret").await() } + assertThat(followOn.error, `is`(EmbeddedAuthError.NoActiveSession)) + } + + @Test + public fun `verifyPassword await keeps the session for retry when the token exchange fails`(): Unit = runTest { + establishSession() + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokensWithoutIdToken() + assertEmbeddedErrorSuspending { client.verifyPassword("secret").await() } + mockAPI.takeRequest() + mockAPI.takeRequest() + + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokens() + val credentials = client.verifyPassword("secret").await() + + assertThat(credentials.accessToken, `is`(EmbeddedAuthMockServer.ACCESS_TOKEN)) + assertThat(bodyOf(mockAPI.takeRequest()).getString("auth_session"), `is`(EmbeddedAuthMockServer.AUTH_SESSION)) } @Test @@ -1410,6 +1498,11 @@ public class EmbeddedAuthClientTest { private fun bodyOf(request: RecordedRequest): JSONObject = JSONObject(request.body.readUtf8()) + private fun capabilitiesOf(body: JSONObject): List { + val array = body.getJSONArray("capabilities") + return List(array.length()) { array.getString(it) } + } + 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 e68268589..57b0a6d9e 100644 --- a/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt @@ -99,6 +99,11 @@ $actionsJson return this } + fun willReturnInvalidUsernameFormat(): EmbeddedAuthMockServer { + server.enqueue(responseWithJSON("""{ "error": "invalid_request", "error_description": "Invalid username format." }""", 400)) + return this + } + fun willReturnNoNextSteps(): EmbeddedAuthMockServer { server.enqueue(responseWithJSON("""{ "error": "access_denied", "error_description": "no_next_steps" }""", 403)) return this diff --git a/examples/embedded-auth/password-authorize.md b/examples/embedded-auth/password-authorize.md index 7e75d94db..6c3425c30 100644 --- a/examples/embedded-auth/password-authorize.md +++ b/examples/embedded-auth/password-authorize.md @@ -5,9 +5,11 @@ Authenticate with a username and password. The sequence is `authorize` → `iden [authorize.md](authorize.md) for the next-action pattern). Each non-terminal step completes by throwing an `EmbeddedAuthException` whose `error` is -`EmbeddedAuthError.InsufficientAuthorization`. `verifyPassword` is terminal when no further factor is -required and returns `Credentials`; if the tenant requires MFA it instead throws an -`InsufficientAuthorization` continuation carrying the MFA next actions (see +`EmbeddedAuthError.InsufficientAuthorization`. `verifyPassword` completes the flow and returns +`Credentials` when no further factor is required; otherwise it throws an `InsufficientAuthorization` +continuation. Always act on that continuation's `nextActions`: after a wrong password they offer +`VerifyPassword` again (possibly alongside other first-factor options), and when the tenant requires +MFA they carry the MFA actions (see [phone-authorize.md](phone-authorize.md), [push-oob-polling.md](push-oob-polling.md), [totp.md](totp.md), [recovery-code.md](recovery-code.md)). @@ -22,9 +24,18 @@ try { // 2. Identify by username. The server continues with a VerifyPassword action. try { embedded.identify("jane", IdentifierType.USERNAME).await() -} catch (e: EmbeddedAuthException) { /* continuation → VerifyPassword */ } +} catch (e: EmbeddedAuthException) { + when (e.error) { + is EmbeddedAuthError.InsufficientAuthorization -> { /* continuation → VerifyPassword */ } + EmbeddedAuthError.InvalidRequest -> { + /* the username was rejected (e.g. "Invalid username format.") — fix the input and identify again */ + } + EmbeddedAuthError.NoNextSteps -> { /* no step can continue for this user — restart the flow */ } + else -> { /* other terminal error */ } + } +} -// 3. Verify the password. Terminal — returns Credentials on success. +// 3. Verify the password. Returns Credentials when no further factor is required. try { val credentials = embedded.verifyPassword("the-password").await() // credentials.accessToken, credentials.idToken, ... @@ -32,14 +43,14 @@ try { when (val error = e.error) { is EmbeddedAuthError.InsufficientAuthorization -> { if (error.reason == EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_PASSWORD) { - /* wrong username or password — re-prompt and call verifyPassword again */ - } else { - /* more MFA required — act on error.nextActions */ + /* wrong username or password — tell the user before re-prompting */ } + /* build the next screen from error.nextActions: VerifyPassword to retry, or MFA actions */ } EmbeddedAuthError.PasswordLeaked -> { /* breached password detected — send the user to reset their password, then restart */ } + EmbeddedAuthError.NoNextSteps -> { /* no step can continue the flow — restart */ } else -> { /* other terminal error */ } } } @@ -58,7 +69,7 @@ embedded.identify("jane", IdentifierType.USERNAME) embedded.verifyPassword("the-password") .start(object : Callback { override fun onSuccess(result: Credentials) { /* signed in */ } - override fun onFailure(error: EmbeddedAuthException) { /* wrong password or MFA required */ } + override fun onFailure(error: EmbeddedAuthException) { /* branch on error.error as in the coroutine sample */ } }) ``` @@ -84,7 +95,7 @@ embedded.verifyPassword("the-password") // credentials.getAccessToken() } @Override public void onFailure(@NonNull EmbeddedAuthException e) { - // wrong password, or MFA required + // branch on e.getError() as in the coroutine sample } }); ``` From ea16c30fa7a855e8b96307a179eca136a40c757e Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Fri, 30 Oct 2026 14:43:47 +0530 Subject: [PATCH 7/7] Addressed the review comments from @nandan and @utkrisht --- .../android/embedded/EmbeddedAuthError.kt | 6 ++- .../embedded/authorize/AdvancingRequest.kt | 23 +++++----- .../embedded/EmbeddedAuthClientTest.kt | 43 +++++++++++-------- .../android/util/EmbeddedAuthMockServer.kt | 8 ++-- examples/embedded-auth/authorize.md | 4 +- examples/embedded-auth/password-authorize.md | 2 +- .../auth0/sample/embedded/AuthorizeScreen.kt | 27 ++++++++++-- .../auth0/sample/embedded/AuthorizeUiState.kt | 11 ++++- .../sample/embedded/EmbeddedViewModel.kt | 2 +- .../src/main/res/values/strings.xml | 3 ++ 10 files changed, 83 insertions(+), 46 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 6285abfef..2d6328fe6 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt @@ -90,7 +90,11 @@ public sealed interface EmbeddedAuthError { /** Terminal: no further action is available to continue authorization. Restart the flow. */ public data object NoNextSteps : EmbeddedAuthError - /** The request never reached the server (`cause` is a network error). Retry the step. */ + /** + * The request never reached the server (`cause` is a network error). Retry the step; if the retry + * fails with [NoActiveSession], the flow had already ended, so start a new one with + * [EmbeddedAuthClient.authorize]. + */ public data object Network : EmbeddedAuthError /** Client-side guard: no flow is in progress. Call [EmbeddedAuthClient.authorize] first. */ 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 index 26c4b40c8..6accf14f7 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/authorize/AdvancingRequest.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/AdvancingRequest.kt @@ -9,9 +9,10 @@ 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. + * or cleared via [onStepFailure]; on the `200` that ends `/e/authorize` the flow is completed via + * [onFlowComplete] before the authorization code is exchanged for [Credentials], because the server + * has consumed the session once it issues the code. A failed token exchange therefore ends the flow, + * and the caller must restart it. */ internal class AdvancingRequest( private val authorize: Request, @@ -23,14 +24,8 @@ internal class AdvancingRequest( 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) - }) + onFlowComplete() + exchange(result.authorizationCode).start(callback) } override fun onFailure(error: EmbeddedAuthException) { @@ -48,7 +43,8 @@ internal class AdvancingRequest( onStepFailure(error) throw error } - return exchange(code.authorizationCode).await().also { onFlowComplete() } + onFlowComplete() + return exchange(code.authorizationCode).await() } @Throws(Auth0Exception::class) @@ -59,7 +55,8 @@ internal class AdvancingRequest( onStepFailure(error) throw error } - return exchange(code.authorizationCode).execute().also { onFlowComplete() } + onFlowComplete() + return exchange(code.authorizationCode).execute() } override fun addParameters(parameters: Map): Request { 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 b63b5c47d..afeb64524 100644 --- a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt @@ -286,6 +286,21 @@ public class EmbeddedAuthClientTest { assertThat(error.nextActions[0], `is`(NextAction.VerifyPassword)) } + @Test + public fun `identify await with USERNAME surfaces an invalid username as InvalidRequest and clears the session`(): Unit = runTest { + establishSession() + mockAPI.willReturnInvalidUsernameFormat() + + val error = assertEmbeddedErrorSuspending { client.identify("jane", IdentifierType.USERNAME).await() } + + assertThat(error.error, `is`(EmbeddedAuthError.InvalidRequest)) + assertThat(error.statusCode, `is`(400)) + mockAPI.takeRequest() + + val followOn = assertEmbeddedErrorSuspending { client.identify("jane", IdentifierType.USERNAME).await() } + assertThat(followOn.error, `is`(EmbeddedAuthError.NoActiveSession)) + } + @Test public fun `authorize await surfaces NoNextSteps as a terminal error and leaves no session`(): Unit = runTest { mockAPI.willReturnNoNextSteps() @@ -405,7 +420,7 @@ public class EmbeddedAuthClientTest { } @Test - public fun `verifyPassword await keeps the session for retry when the token exchange fails`(): Unit = runTest { + public fun `verifyPassword await clears the session when the token exchange fails`(): Unit = runTest { establishSession() mockAPI.willReturnAuthorizeCode() mockAPI.willReturnTokensWithoutIdToken() @@ -413,12 +428,8 @@ public class EmbeddedAuthClientTest { mockAPI.takeRequest() mockAPI.takeRequest() - mockAPI.willReturnAuthorizeCode() - mockAPI.willReturnTokens() - val credentials = client.verifyPassword("secret").await() - - assertThat(credentials.accessToken, `is`(EmbeddedAuthMockServer.ACCESS_TOKEN)) - assertThat(bodyOf(mockAPI.takeRequest()).getString("auth_session"), `is`(EmbeddedAuthMockServer.AUTH_SESSION)) + val followOn = assertEmbeddedErrorSuspending { client.verifyPassword("secret").await() } + assertThat(followOn.error, `is`(EmbeddedAuthError.NoActiveSession)) } @Test @@ -749,18 +760,16 @@ public class EmbeddedAuthClientTest { } @Test - public fun `a failed token exchange leaves the session intact for retry`() { + public fun `a failed token exchange clears the session`() { establishSession() mockAPI.willReturnAuthorizeCode() mockAPI.willReturnTokensWithoutIdToken() - try { client.verifyOtp("123456").execute() } catch (_: EmbeddedAuthException) {} + assertEmbeddedError { client.verifyOtp("123456").execute() } mockAPI.takeRequest() mockAPI.takeRequest() - mockAPI.willReturnAuthorizeCode() - mockAPI.willReturnTokens() - val credentials = client.verifyOtp("123456").execute() - assertThat(credentials.accessToken, `is`(EmbeddedAuthMockServer.ACCESS_TOKEN)) + val followOn = assertEmbeddedError { client.verifyOtp("123456").execute() } + assertThat(followOn.error, `is`(EmbeddedAuthError.NoActiveSession)) } @Test @@ -898,7 +907,7 @@ public class EmbeddedAuthClientTest { } @Test - public fun `verifyOtp await leaves the session intact when the token exchange fails`(): Unit = runTest { + public fun `verifyOtp await clears the session when the token exchange fails`(): Unit = runTest { establishSession() mockAPI.willReturnAuthorizeCode() mockAPI.willReturnTokensWithoutIdToken() @@ -907,10 +916,8 @@ public class EmbeddedAuthClientTest { mockAPI.takeRequest() mockAPI.takeRequest() - mockAPI.willReturnAuthorizeCode() - mockAPI.willReturnTokens() - val credentials = client.verifyOtp("123456").await() - assertThat(credentials.accessToken, `is`(EmbeddedAuthMockServer.ACCESS_TOKEN)) + val followOn = assertEmbeddedErrorSuspending { client.verifyOtp("123456").await() } + assertThat(followOn.error, `is`(EmbeddedAuthError.NoActiveSession)) } // ── Adapter mapping unit tests (§5 of EmbeddedAuthError spec) ───────────── 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 57b0a6d9e..a624d78b5 100644 --- a/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt @@ -99,13 +99,13 @@ $actionsJson return this } - fun willReturnInvalidUsernameFormat(): EmbeddedAuthMockServer { - server.enqueue(responseWithJSON("""{ "error": "invalid_request", "error_description": "Invalid username format." }""", 400)) + fun willReturnNoNextSteps(): EmbeddedAuthMockServer { + server.enqueue(responseWithJSON("""{ "error": "access_denied", "error_description": "no_next_steps" }""", 403)) return this } - fun willReturnNoNextSteps(): EmbeddedAuthMockServer { - server.enqueue(responseWithJSON("""{ "error": "access_denied", "error_description": "no_next_steps" }""", 403)) + fun willReturnInvalidUsernameFormat(): EmbeddedAuthMockServer { + server.enqueue(responseWithJSON("""{ "error": "invalid_request", "error_description": "Invalid username format." }""", 400)) return this } diff --git a/examples/embedded-auth/authorize.md b/examples/embedded-auth/authorize.md index 1abf16e3a..cf14bc99c 100644 --- a/examples/embedded-auth/authorize.md +++ b/examples/embedded-auth/authorize.md @@ -9,7 +9,7 @@ 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. +`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; if the retry fails with `NoActiveSession`, the flow had already ended, so restart with `authorize()`. The flow is served by `EmbeddedAuthClient`, created from an `Auth0` instance: @@ -162,7 +162,7 @@ when (e.error) { EmbeddedAuthError.IpBlocked -> { /* suspicious-IP throttling blocked this IP */ } 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 */ } + EmbeddedAuthError.Network -> { /* transient — retry the same step; on NoActiveSession restart with authorize() */ } else -> { /* Unknown/unclassified — inspect e.code and e.description */ } } ``` diff --git a/examples/embedded-auth/password-authorize.md b/examples/embedded-auth/password-authorize.md index 6c3425c30..004de97f4 100644 --- a/examples/embedded-auth/password-authorize.md +++ b/examples/embedded-auth/password-authorize.md @@ -28,7 +28,7 @@ try { when (e.error) { is EmbeddedAuthError.InsufficientAuthorization -> { /* continuation → VerifyPassword */ } EmbeddedAuthError.InvalidRequest -> { - /* the username was rejected (e.g. "Invalid username format.") — fix the input and identify again */ + /* the username was rejected (e.g. "Invalid username format.") — fix the input and restart with authorize() */ } EmbeddedAuthError.NoNextSteps -> { /* no step can continue for this user — restart the flow */ } else -> { /* other terminal error */ } 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 5739fd0e5..86423610e 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 @@ -128,7 +128,7 @@ public fun AuthorizeScreen( ActionsSection(current.otherActions, viewModel) } } - is AuthorizeUiState.ActionsAvailable -> ActionsSection(current.actions, viewModel) + is AuthorizeUiState.ActionsAvailable -> ActionsSection(current.actions, viewModel, current.reason) is AuthorizeUiState.Authenticated -> InfoCard(formatCredentials(current.credentials)) is AuthorizeUiState.Failed -> InfoCard(formatError(current.error)) is AuthorizeUiState.Message -> InfoCard(current.text) @@ -148,7 +148,19 @@ public fun AuthorizeScreen( } @Composable -private fun ActionsSection(actions: List, viewModel: EmbeddedViewModel) { +private fun ActionsSection( + actions: List, + viewModel: EmbeddedViewModel, + reason: EmbeddedAuthError.InsufficientAuthorization.Reason = EmbeddedAuthError.InsufficientAuthorization.Reason.NONE, +) { + reason.messageRes()?.let { + Text( + text = stringResource(it), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(12.dp)) + } Text( text = stringResource(R.string.label_choose_action), style = MaterialTheme.typography.titleMedium, @@ -345,7 +357,7 @@ private fun ActionCard(titleRes: Int, detail: String?, buttonRes: Int, onClick: /** The password is submitted as typed — never trimmed — and masked on screen. */ @Composable private fun VerifyPasswordCard(onVerify: (String) -> Unit) { - var password by rememberSaveable { mutableStateOf("") } + var password by remember { mutableStateOf("") } OutlinedCard(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { Text( @@ -382,7 +394,7 @@ private fun VerifyPasswordCard(onVerify: (String) -> Unit) { @Composable private fun VerifyRecoveryCodeCard(onVerify: (String) -> Unit) { - var code by rememberSaveable { mutableStateOf("") } + var code by remember { mutableStateOf("") } OutlinedCard(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { Text( @@ -530,3 +542,10 @@ private fun explain(error: EmbeddedAuthError): String = when (error) { EmbeddedAuthError.Network -> "The request didn't reach the server — check your connection and retry." else -> "Authorize step failed." } + +private fun EmbeddedAuthError.InsufficientAuthorization.Reason.messageRes(): Int? = when (this) { + EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_PASSWORD -> R.string.error_invalid_identifier_or_password + EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_CODE -> R.string.error_invalid_code + EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_CODE -> R.string.error_invalid_identifier_or_code + else -> null +} 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 index a49e9a9c3..7adc7557c 100644 --- a/sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeUiState.kt +++ b/sample-embedded/src/main/java/com/auth0/sample/embedded/AuthorizeUiState.kt @@ -1,5 +1,6 @@ package com.auth0.sample.embedded +import com.auth0.android.embedded.EmbeddedAuthError import com.auth0.android.embedded.EmbeddedAuthException import com.auth0.android.embedded.authorize.NextAction import com.auth0.android.result.Credentials @@ -19,8 +20,14 @@ sealed interface AuthorizeUiState { */ data class Polling(val otherActions: List) : AuthorizeUiState - /** The server returned a continuation: these are the steps the user can take next. */ - data class ActionsAvailable(val actions: List) : AuthorizeUiState + /** + * The server returned a continuation: these are the steps the user can take next. [reason] says why + * the previous step did not complete, e.g. a wrong password or code. + */ + data class ActionsAvailable( + val actions: List, + val reason: EmbeddedAuthError.InsufficientAuthorization.Reason = EmbeddedAuthError.InsufficientAuthorization.Reason.NONE, + ) : AuthorizeUiState /** Terminal success: [verifyOtp][EmbeddedAuthClient.verifyOtp] yielded credentials. */ data class Authenticated(val credentials: Credentials) : AuthorizeUiState 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 3f71fd0a1..792a9035d 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 @@ -167,7 +167,7 @@ class EmbeddedViewModel(application: Application) : AndroidViewModel(application private fun EmbeddedAuthException.toAuthorizeState(): AuthorizeUiState = when (val e = error) { is EmbeddedAuthError.InsufficientAuthorization -> - if (e.nextActions.isNotEmpty()) AuthorizeUiState.ActionsAvailable(e.nextActions) + if (e.nextActions.isNotEmpty()) AuthorizeUiState.ActionsAvailable(e.nextActions, e.reason) else AuthorizeUiState.Failed(this) else -> AuthorizeUiState.Failed(this) } diff --git a/sample-embedded/src/main/res/values/strings.xml b/sample-embedded/src/main/res/values/strings.xml index 3458d7684..c65e5f475 100644 --- a/sample-embedded/src/main/res/values/strings.xml +++ b/sample-embedded/src/main/res/values/strings.xml @@ -19,6 +19,9 @@ Working… Enter a connection and tap Start Authorize. Choose how to continue + Wrong username or password. Try again. + That code is incorrect. Try again. + The identifier or code is incorrect. Try again. Start over Email Phone number