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..0ec0df871 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) } } /** @@ -119,20 +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]. 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 = + terminalStep(EmbeddedCapability.VERIFY_PASSWORD) { + addParameter(PASSWORD_KEY, password) + } /** * Continues the flow by requesting a phone challenge for the authenticator at [index]. @@ -192,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]. @@ -229,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, @@ -256,7 +264,7 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { ) ) request.addPayload() - return stepping(request) + return advancing(request) } private fun noActiveSession(): Request = FailedRequest( @@ -330,6 +338,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 +354,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..2d6328fe6 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,7 +81,20 @@ public sealed interface EmbeddedAuthError { /** Terminal: additional consent is required before the flow can proceed. */ public data object ConsentRequired : EmbeddedAuthError - /** The request never reached the server (`cause` is a network error). Retry the step. */ + /** + * 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; 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/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..afeb64524 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 @@ -223,6 +270,187 @@ 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 `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() + + 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) + + 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 and clears the session`(): Unit = runTest { + establishSession() + mockAPI.willReturnPasswordLeaked() + + val error = assertEmbeddedErrorSuspending { client.verifyPassword("leaked").await() } + + 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 and clears the session`(): Unit = runTest { + establishSession() + mockAPI.willReturnNoNextSteps() + + val error = assertEmbeddedErrorSuspending { client.verifyPassword("secret").await() } + + 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 clears the session when the token exchange fails`(): Unit = runTest { + establishSession() + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokensWithoutIdToken() + assertEmbeddedErrorSuspending { client.verifyPassword("secret").await() } + mockAPI.takeRequest() + mockAPI.takeRequest() + + val followOn = assertEmbeddedErrorSuspending { client.verifyPassword("secret").await() } + assertThat(followOn.error, `is`(EmbeddedAuthError.NoActiveSession)) + } + + @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 @@ -532,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 @@ -681,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() @@ -690,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) ───────────── @@ -844,6 +1068,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`() { @@ -1267,6 +1505,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/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..a624d78b5 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,21 @@ $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 willReturnInvalidUsernameFormat(): EmbeddedAuthMockServer { + server.enqueue(responseWithJSON("""{ "error": "invalid_request", "error_description": "Invalid username format." }""", 400)) + return this + } + fun willReturnTooManyAttempts(): EmbeddedAuthMockServer { server.enqueue(responseWithJSON("""{ "error": "too_many_requests", "error_description": "too_many_attempts" }""", 429)) return this @@ -151,6 +176,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..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: @@ -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,12 +155,14 @@ 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 */ } 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 new file mode 100644 index 000000000..004de97f4 --- /dev/null +++ b/examples/embedded-auth/password-authorize.md @@ -0,0 +1,102 @@ +# 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` 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)). + +```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) { + when (e.error) { + is EmbeddedAuthError.InsufficientAuthorization -> { /* continuation → VerifyPassword */ } + EmbeddedAuthError.InvalidRequest -> { + /* 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 */ } + } +} + +// 3. Verify the password. Returns Credentials when no further factor is required. +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 — 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 */ } + } +} +``` + +
+ 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) { /* branch on error.error as in the coroutine sample */ } + }) +``` +
+ +
+ 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) { + // branch on e.getError() as in the coroutine sample + } + }); +``` +
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..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 @@ -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 @@ -127,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) @@ -147,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, @@ -171,6 +184,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,9 +354,47 @@ 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 remember { 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("") } + var code by remember { mutableStateOf("") } OutlinedCard(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { Text( @@ -471,9 +534,18 @@ 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." 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 cab40eecf..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 @@ -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() } @@ -161,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 d1c0c0dad..c65e5f475 100644 --- a/sample-embedded/src/main/res/values/strings.xml +++ b/sample-embedded/src/main/res/values/strings.xml @@ -19,12 +19,19 @@ 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 One-time code Identify with email Identify with phone + Username + Identify with username + Password + Enter your password Email me a code Verify code Submit