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..6285abfef 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, @@ -78,6 +81,15 @@ 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 + + /** 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 c833aa672..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 @@ -31,12 +31,15 @@ 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 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" @@ -97,6 +100,10 @@ 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 && description == NO_NEXT_STEPS -> + EmbeddedAuthError.NoNextSteps code == ACCESS_DENIED -> EmbeddedAuthError.AccessDenied statusCode == TOO_MANY_REQUESTS_STATUS && code == TOO_MANY_REQUESTS && description == TOO_MANY_ATTEMPTS -> @@ -127,6 +134,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..6f3601684 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,135 @@ 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 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 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() + 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 @@ -844,6 +973,20 @@ 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 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/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..e68268589 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 @@ -84,6 +94,16 @@ $actionsJson return this } + fun willReturnPasswordLeaked(): EmbeddedAuthMockServer { + server.enqueue(responseWithJSON("""{ "error": "access_denied", "error_description": "password_leaked" }""", 403)) + 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 @@ -151,6 +171,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/authorize.md b/examples/embedded-auth/authorize.md index 5449966c7..1abf16e3a 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, @@ -151,6 +155,8 @@ 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.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/examples/embedded-auth/password-authorize.md b/examples/embedded-auth/password-authorize.md new file mode 100644 index 000000000..7e75d94db --- /dev/null +++ b/examples/embedded-auth/password-authorize.md @@ -0,0 +1,91 @@ +# 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 */ + } + } + EmbeddedAuthError.PasswordLeaked -> { + /* breached password detected — send the user to reset their password, then restart */ + } + 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 + } + }); +``` +
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..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 @@ -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("") } @@ -471,6 +522,8 @@ 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.NoNextSteps -> "No further steps are available to continue — start over." EmbeddedAuthError.TooManyAttempts, EmbeddedAuthError.TooManyLogins, EmbeddedAuthError.IpBlocked -> "Rate limited — try again later." 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