diff --git a/EXAMPLES.md b/EXAMPLES.md index 575591ca7..8d584e874 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -31,7 +31,11 @@ Each topic lives in its own file under [`examples/`](examples). ## Embedded Authentication (Beta) -- [Authorize (email OTP flow)](examples/embedded-auth/authorize.md) +- [Email OTP — first-factor & MFA second-factor](examples/embedded-auth/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) +- [TOTP (Authenticator App)](examples/embedded-auth/totp.md) ## Other APIs and features 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 74cd429e3..4d6a7c5a8 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt @@ -7,7 +7,9 @@ import com.auth0.android.embedded.authorize.EmbeddedAuthState import com.auth0.android.embedded.authorize.EmbeddedCapability import com.auth0.android.embedded.authorize.FailedRequest import com.auth0.android.embedded.authorize.IdentifierType +import com.auth0.android.embedded.authorize.NextAction import com.auth0.android.embedded.authorize.OtpType +import com.auth0.android.embedded.authorize.PhoneDeliveryMethod import com.auth0.android.embedded.authorize.StepRequest import com.auth0.android.embedded.authorize.authorizeCodeAdapter import com.auth0.android.embedded.authorize.embeddedAuthErrorAdapter @@ -89,6 +91,9 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { ): Request = when (type) { IdentifierType.EMAIL -> continueStep(EmbeddedCapability.IDENTIFY_EMAIL) { addParameter(EMAIL_KEY, identifier) } + + IdentifierType.PHONE -> + continueStep(EmbeddedCapability.IDENTIFY_PHONE) { addParameter(PHONE_KEY, identifier) } } /** @@ -129,6 +134,114 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { return advancing(request) } + /** + * Continues the flow by requesting a phone challenge for the authenticator at [index]. + * + * [index] defaults to the first enrolled authenticator (`0`) — the right value when phone is + * the first factor. As an MFA step-up, echo the [NextAction.ChallengePhone.index] the server + * offered, since a user may have several phone authenticators enrolled. + * + * This call never resolves successfully; it completes through [EmbeddedAuthException] whose + * [EmbeddedAuthError.InsufficientAuthorization.nextActions] carry the next step to call. + */ + @JvmOverloads + public fun challengePhone( + index: Int = 0, + deliveryMethod: PhoneDeliveryMethod = PhoneDeliveryMethod.TEXT + ): Request = + continueStep(EmbeddedCapability.CHALLENGE_PHONE) { + addParameter(INDEX_KEY, index) + addParameter(DELIVERY_METHOD_KEY, deliveryMethod.value) + } + + /** + * Continues the flow by requesting a push notification challenge for the authenticator at [index]. + * + * This call never resolves successfully; it completes through [EmbeddedAuthException] whose + * [EmbeddedAuthError.InsufficientAuthorization.nextActions] will contain a [NextAction.VerifyOob] + * entry. After receiving that continuation, call [verifyOob] repeatedly, waiting + * [NextAction.VerifyOob.pollInMs] milliseconds between each call, until the push is approved, + * rejected, or expires. + * + * [index] must be echoed from the [NextAction.ChallengePush.index] the server offered — a user + * may have several push authenticators enrolled. + */ + public fun challengePush(index: Int): Request = + continueStep(EmbeddedCapability.CHALLENGE_PUSH) { + addParameter(INDEX_KEY, index) + } + + /** + * Performs a single poll to check whether the push notification sent by [challengePush] has + * been approved. + * + * This method performs **one poll per call** — the SDK does not poll automatically. After + * calling [challengePush], wait [NextAction.VerifyOob.pollInMs] milliseconds, then call this + * method. On each [EmbeddedAuthError.InsufficientAuthorization] continuation with reason + * [EmbeddedAuthError.InsufficientAuthorization.Reason.AUTHORIZATION_PENDING] or + * [EmbeddedAuthError.InsufficientAuthorization.Reason.SLOW_DOWN], read the updated + * [NextAction.VerifyOob.pollInMs] from [EmbeddedAuthError.InsufficientAuthorization.nextActions], + * wait that interval, and call this method again. + * + * The flow is sequential. Stop polling before calling any other step or starting a new flow + * with [authorize], and do so between polls — while waiting out the interval — not while a call + * to this method is in flight. Let an in-flight poll complete first; a poll that completes after + * another step has started can overwrite that step's session. + * + * On approval the server returns an authorization code and this call yields [Credentials]. + * 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) + } + + /** + * Verifies a recovery [code]. + * + * This call **never** yields [Credentials] directly. The server always responds with a + * continuation whose [EmbeddedAuthError.InsufficientAuthorization.nextActions] contains a + * [NextAction.ConfirmRecoveryCode] entry carrying the newly rotated code to display to the + * user. Call [confirmRecoveryCode] next to acknowledge the new code and complete the flow. + */ + public fun verifyRecoveryCode(code: String): Request = + continueStep( + EmbeddedCapability.VERIFY_RECOVERY_CODE, + addPayload = { addParameter(CODE_KEY, code) } + ) + + /** + * Acknowledges the rotated recovery code returned by [verifyRecoveryCode] and completes + * the flow. + * + * Must only be called after [verifyRecoveryCode] returns a continuation with a + * [NextAction.ConfirmRecoveryCode] next action. Display [NextAction.ConfirmRecoveryCode.newCode] + * to the user before calling this method so they can record their new recovery code. + * + * On success, it yields the [Credentials]. + */ + public fun confirmRecoveryCode(): 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.CONFIRM_RECOVERY_CODE.value, + CLIENT_ID_KEY to clientId + ) + ) + return advancing(request) + } + private fun continueStep( action: EmbeddedCapability, addPayload: Request.() -> Unit = {} @@ -216,9 +329,8 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { private const val AUTH_SESSION_KEY = "auth_session" private const val ACTION_KEY = "action" private const val EMAIL_KEY = "email" - - @Suppress("unused") private const val PHONE_KEY = "phone" + private const val DELIVERY_METHOD_KEY = "delivery_method" private const val OTP_KEY = "otp" private const val INDEX_KEY = "index" private const val TYPE_KEY = "type" @@ -231,8 +343,14 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { private val DEFAULT_CAPABILITIES: Set = setOf( EmbeddedCapability.IDENTIFY_EMAIL, + EmbeddedCapability.IDENTIFY_PHONE, EmbeddedCapability.CHALLENGE_EMAIL, - EmbeddedCapability.VERIFY_OTP + EmbeddedCapability.CHALLENGE_PHONE, + EmbeddedCapability.CHALLENGE_PUSH, + EmbeddedCapability.VERIFY_OTP, + 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 03dab0f41..efb9b840e 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt @@ -26,7 +26,13 @@ public sealed interface EmbeddedAuthError { INVALID_CODE, /** The identifier or the code was invalid; the server does not say which. */ - INVALID_IDENTIFIER_OR_CODE + INVALID_IDENTIFIER_OR_CODE, + + /** Authorization is pending (e.g., push notification awaiting user approval). Retry after a delay. */ + AUTHORIZATION_PENDING, + + /** The app polled too fast. Retry after the interval the server returns. */ + SLOW_DOWN } } @@ -48,6 +54,9 @@ public sealed interface EmbeddedAuthError { /** Terminal: rate-limited by same-user-login protection. */ public data object TooManyLogins : EmbeddedAuthError + /** Terminal: rate-limited by suspicious-IP throttling for the device's IP address. */ + public data object IpBlocked : EmbeddedAuthError + /** * Terminal: the grant being redeemed is no longer valid. Start a new flow with * [EmbeddedAuthClient.authorize]. @@ -60,6 +69,15 @@ public sealed interface EmbeddedAuthError { */ public data object InvalidRequest : EmbeddedAuthError + /** Terminal: the user rejected or canceled the authorization (e.g., denied a push notification). */ + public data object AuthorizationRejected : EmbeddedAuthError + + /** Terminal: no eligible factors are available for the user. Enroll factors and retry the flow. */ + public data object NoEligibleFactors : 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. */ 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 911018b55..c833aa672 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthAdapters.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthAdapters.kt @@ -28,11 +28,17 @@ private const val CHALLENGE_EXPIRED = "challenge_expired" private const val TOO_MANY_REQUESTS = "too_many_requests" private const val TOO_MANY_ATTEMPTS = "too_many_attempts" private const val TOO_MANY_LOGINS = "too_many_logins" +private const val 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_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 AUTHORIZATION_PENDING = "authorization_pending" +private const val SLOW_DOWN = "slow_down" /** Parses the `200` body of `/e/authorize` into the code to exchange for tokens. */ internal fun authorizeCodeAdapter(gson: Gson): JsonAdapter = @@ -85,12 +91,20 @@ internal fun embeddedAuthErrorAdapter(): ErrorAdapter { EmbeddedAuthError.TooManyWrongOtpAttempts code == ACCESS_DENIED && description == CHALLENGE_EXPIRED -> EmbeddedAuthError.ChallengeExpired + code == ACCESS_DENIED && description == AUTHORIZATION_REJECTED -> + EmbeddedAuthError.AuthorizationRejected + code == ACCESS_DENIED && description == NO_ELIGIBLE_FACTORS -> + EmbeddedAuthError.NoEligibleFactors + code == ACCESS_DENIED && description == CONSENT_REQUIRED -> + EmbeddedAuthError.ConsentRequired code == ACCESS_DENIED -> EmbeddedAuthError.AccessDenied statusCode == TOO_MANY_REQUESTS_STATUS && code == TOO_MANY_REQUESTS && description == TOO_MANY_ATTEMPTS -> EmbeddedAuthError.TooManyAttempts statusCode == TOO_MANY_REQUESTS_STATUS && code == TOO_MANY_REQUESTS && description == TOO_MANY_LOGINS -> EmbeddedAuthError.TooManyLogins + statusCode == TOO_MANY_REQUESTS_STATUS && code == TOO_MANY_REQUESTS && description == IP_BLOCKED -> + EmbeddedAuthError.IpBlocked code == INVALID_GRANT -> EmbeddedAuthError.SessionExpired code == INVALID_REQUEST -> @@ -113,6 +127,9 @@ internal fun embeddedAuthErrorAdapter(): ErrorAdapter { INVALID_CODE -> EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_CODE INVALID_IDENTIFIER_OR_CODE -> EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_CODE + AUTHORIZATION_PENDING -> + EmbeddedAuthError.InsufficientAuthorization.Reason.AUTHORIZATION_PENDING + SLOW_DOWN -> EmbeddedAuthError.InsufficientAuthorization.Reason.SLOW_DOWN else -> EmbeddedAuthError.InsufficientAuthorization.Reason.NONE } 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 353bf9cc6..385aeb5fd 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 @@ -5,7 +5,12 @@ public enum class EmbeddedCapability(public val value: String) { IDENTIFY_EMAIL("action:identify:email:v1"), IDENTIFY_PHONE("action:identify:phone:v1"), CHALLENGE_EMAIL("action:challenge:email:v1"), + CHALLENGE_PHONE("action:challenge:phone:v1"), + CHALLENGE_PUSH("action:challenge:push:v1"), VERIFY_OTP("action:verify:otp:v1"), + VERIFY_OOB("action:verify:oob:v1"), + VERIFY_RECOVERY_CODE("action:verify:recovery-code:v1"), + CONFIRM_RECOVERY_CODE("action:confirm:recovery-code:v1"), UNKNOWN("Unknown"); internal companion object { 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 3e6ed462d..a1f5e8ff6 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 @@ -6,4 +6,6 @@ import com.auth0.android.embedded.EmbeddedAuthClient public enum class IdentifierType { /** An email address. */ EMAIL, + /** A phone number. */ + PHONE, } 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 931f5c942..2d9269deb 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 @@ -36,6 +36,48 @@ public sealed interface NextAction { override val capability: EmbeddedCapability = EmbeddedCapability.VERIFY_OTP } + /** Continue by challenging a phone factor. Act on it with [EmbeddedAuthClient.challengePhone]. */ + @ConsistentCopyVisibility + public data class ChallengePhone internal constructor( + public val index: Int, + public val identifier: String, + public val deliveryMethods: List, + ) : NextAction { + override val capability: EmbeddedCapability = EmbeddedCapability.CHALLENGE_PHONE + } + + /** Continue by challenging a push notification factor. Act on it with [EmbeddedAuthClient.challengePush]. */ + @ConsistentCopyVisibility + public data class ChallengePush internal constructor( + public val index: Int, + public val name: String, + ) : NextAction { + override val capability: EmbeddedCapability = EmbeddedCapability.CHALLENGE_PUSH + } + + /** Continue by verifying an out-of-band (push) notification. Act on it with [EmbeddedAuthClient.verifyOob]. */ + @ConsistentCopyVisibility + public data class VerifyOob internal constructor( + public val pollInMs: Int, + ) : NextAction { + override val capability: EmbeddedCapability = EmbeddedCapability.VERIFY_OOB + } + + /** Continue by verifying a recovery code. Act on it with [EmbeddedAuthClient.verifyRecoveryCode]. */ + public data object VerifyRecoveryCode : NextAction { + override val capability: EmbeddedCapability = EmbeddedCapability.VERIFY_RECOVERY_CODE + } + + /** Continue by confirming a recovery code after verification. Act on it with [EmbeddedAuthClient.confirmRecoveryCode]. */ + @ConsistentCopyVisibility + public data class ConfirmRecoveryCode internal constructor( + public val newCode: String, + ) : NextAction { + override val capability: EmbeddedCapability = EmbeddedCapability.CONFIRM_RECOVERY_CODE + + override fun toString(): String = "ConfirmRecoveryCode(newCode='xxxxx')" + } + /** An action the server offered that this version of the SDK does not model. */ @ConsistentCopyVisibility public data class Unknown 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 deabc519d..e32426830 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 @@ -8,6 +8,10 @@ private const val ACTION_KEY = "action" private const val CHANNEL_KEY = "channel" private const val IDENTIFIER_KEY = "identifier" private const val INDEX_KEY = "index" +private const val DELIVERY_METHODS_KEY = "delivery_methods" +private const val POLL_IN_MS_KEY = "poll_in_ms" +private const val NAME_KEY = "name" +private const val NEW_CODE_KEY = "new_code" internal fun List>.toNextActions(): List = mapNotNull { it.toNextAction() } @@ -29,12 +33,36 @@ private fun Map.toNextAction(): NextAction? { identifier = this[IDENTIFIER_KEY] as? String ) } + EmbeddedCapability.CHALLENGE_PHONE -> { + 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) + val deliveryMethods = (this[DELIVERY_METHODS_KEY] as? List<*>) + ?.filterIsInstance() + ?.mapNotNull(PhoneDeliveryMethod::fromValue) + ?: return dropped(raw, DELIVERY_METHODS_KEY) + NextAction.ChallengePhone(index, identifier, deliveryMethods) + } + EmbeddedCapability.CHALLENGE_PUSH -> { + val index = (this[INDEX_KEY] as? Number)?.toInt() ?: return dropped(raw, INDEX_KEY) + val name = this[NAME_KEY] as? String ?: return dropped(raw, NAME_KEY) + NextAction.ChallengePush(index, name) + } + EmbeddedCapability.VERIFY_OOB -> { + val pollInMs = (this[POLL_IN_MS_KEY] as? Number)?.toInt() + ?: return dropped(raw, POLL_IN_MS_KEY) + NextAction.VerifyOob(pollInMs) + } + EmbeddedCapability.VERIFY_RECOVERY_CODE -> NextAction.VerifyRecoveryCode + EmbeddedCapability.CONFIRM_RECOVERY_CODE -> { + val newCode = this[NEW_CODE_KEY] as? String ?: return dropped(raw, NEW_CODE_KEY) + NextAction.ConfirmRecoveryCode(newCode) + } EmbeddedCapability.UNKNOWN -> NextAction.Unknown(raw) } } /** - * Logs and drops a recognised next action whose required [field] the server omitted or sent + * Logs and drops a recognized next action whose required [field] the server omitted or sent * malformed. Returns `null` so the entry is filtered out by [toNextActions]. */ private fun dropped(action: String, field: String): NextAction? { diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/PhoneDeliveryMethod.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/PhoneDeliveryMethod.kt index bab6bd18f..9e145b747 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/authorize/PhoneDeliveryMethod.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/PhoneDeliveryMethod.kt @@ -1,7 +1,7 @@ package com.auth0.android.embedded.authorize /** How a phone one-time code is delivered. */ -internal enum class PhoneDeliveryMethod(val value: String) { +public enum class PhoneDeliveryMethod(public val value: String) { /** Deliver the code as an SMS text message. */ TEXT("text"), 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 9f11b0a36..2b96d6eec 100644 --- a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt @@ -6,6 +6,7 @@ import com.auth0.android.embedded.authorize.IdentifierType import com.auth0.android.embedded.authorize.NextAction import com.auth0.android.embedded.authorize.OtpChannel import com.auth0.android.embedded.authorize.OtpType +import com.auth0.android.embedded.authorize.PhoneDeliveryMethod import com.auth0.android.embedded.authorize.embeddedAuthErrorAdapter import com.auth0.android.util.EmbeddedAuthMockServer import com.auth0.android.util.SSLTestUtils.testClient @@ -336,6 +337,22 @@ public class EmbeddedAuthClientTest { assertThat(error.nextActions[1], instanceOf(NextAction.IdentifyPhone::class.java)) } + @Test + public fun `continuation can offer an MFA fork of phone, push, and recovery-code at once`() { + mockAPI.willReturnContinuationWith( + EmbeddedAuthMockServer.NEXT_CHALLENGE_PHONE, + EmbeddedAuthMockServer.NEXT_CHALLENGE_PUSH, + EmbeddedAuthMockServer.NEXT_VERIFY_RECOVERY_CODE, + ) + + val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } + + assertThat(error.nextActions, hasSize(3)) + assertThat(error.nextActions[0], instanceOf(NextAction.ChallengePhone::class.java)) + assertThat(error.nextActions[1], instanceOf(NextAction.ChallengePush::class.java)) + assertThat(error.nextActions[2], instanceOf(NextAction.VerifyRecoveryCode::class.java)) + } + @Test public fun `authorize surfaces AccessDenied as a terminal error with no next actions`() { mockAPI.willReturnAccessDenied() @@ -365,6 +382,16 @@ public class EmbeddedAuthClientTest { assertThat(error.statusCode, `is`(429)) } + @Test + public fun `authorize surfaces IpBlocked as a terminal error`() { + mockAPI.willReturnIpBlocked() + + val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } + + assertThat(error.error, `is`(EmbeddedAuthError.IpBlocked)) + assertThat(error.statusCode, `is`(429)) + } + @Test public fun `verifyOtp flags a wrong code as recoverable with retry actions`() { establishSession() @@ -740,6 +767,13 @@ public class EmbeddedAuthClientTest { assertThat(ex.error, `is`(EmbeddedAuthError.TooManyLogins)) } + @Test + public fun `adapter maps 429 too_many_requests + ip_blocked to IpBlocked`() { + val json = """{"error":"too_many_requests","error_description":"ip_blocked"}""" + val ex = embeddedAuthErrorAdapter().fromJsonResponse(429, StringReader(json)) + assertThat(ex.error, `is`(EmbeddedAuthError.IpBlocked)) + } + @Test public fun `adapter maps invalid_grant to SessionExpired`() { val json = """{"error":"invalid_grant","error_description":"The auth_session has expired."}""" @@ -787,6 +821,426 @@ public class EmbeddedAuthClientTest { assertThat(ex.error, `is`(EmbeddedAuthError.Unknown)) } + // ── Adapter MFA error mappings ────────────────────────────────────────────── + + @Test + public fun `adapter maps authorization_rejected to AuthorizationRejected`() { + val json = """{"error":"access_denied","error_description":"authorization_rejected"}""" + val ex = embeddedAuthErrorAdapter().fromJsonResponse(403, StringReader(json)) + assertThat(ex.error, `is`(EmbeddedAuthError.AuthorizationRejected)) + } + + @Test + public fun `adapter maps no_eligible_factors to NoEligibleFactors`() { + val json = """{"error":"access_denied","error_description":"no_eligible_factors"}""" + val ex = embeddedAuthErrorAdapter().fromJsonResponse(403, StringReader(json)) + assertThat(ex.error, `is`(EmbeddedAuthError.NoEligibleFactors)) + } + + @Test + public fun `adapter maps consent_required to ConsentRequired`() { + val json = """{"error":"access_denied","error_description":"consent_required"}""" + val ex = embeddedAuthErrorAdapter().fromJsonResponse(403, StringReader(json)) + assertThat(ex.error, `is`(EmbeddedAuthError.ConsentRequired)) + } + + + @Test + public fun `adapter maps authorization_pending reason to InsufficientAuthorization with AUTHORIZATION_PENDING reason`() { + val json = """{"error":"insufficient_authorization","error_description":"authorization_pending","auth_session":"test_session"}""" + val ex = embeddedAuthErrorAdapter().fromJsonResponse(403, StringReader(json)) + assertThat(ex.error, instanceOf(EmbeddedAuthError.InsufficientAuthorization::class.java)) + val insuff = ex.error as EmbeddedAuthError.InsufficientAuthorization + assertThat(insuff.reason, `is`(EmbeddedAuthError.InsufficientAuthorization.Reason.AUTHORIZATION_PENDING)) + } + + @Test + public fun `adapter maps slow_down reason to InsufficientAuthorization with SLOW_DOWN reason`() { + val json = """{"error":"insufficient_authorization","error_description":"slow_down","auth_session":"test_session"}""" + val ex = embeddedAuthErrorAdapter().fromJsonResponse(403, StringReader(json)) + assertThat(ex.error, instanceOf(EmbeddedAuthError.InsufficientAuthorization::class.java)) + val insuff = ex.error as EmbeddedAuthError.InsufficientAuthorization + assertThat(insuff.reason, `is`(EmbeddedAuthError.InsufficientAuthorization.Reason.SLOW_DOWN)) + } + + // ── identify(PHONE) ───────────────────────────────────────────────────────── + + @Test + public fun `identify with phone should fail with no_active_session when no flow is in progress`() { + val error = assertEmbeddedError { client.identify("+1234567890", IdentifierType.PHONE).execute() } + assertThat(error.code, `is`("no_active_session")) + assertThat(error.error, `is`(EmbeddedAuthError.NoActiveSession)) + } + + @Test + public fun `identify with phone should POST the authorize endpoint with the correct action and phone`() { + establishSession() + mockAPI.willReturnInsufficientAuthorization() + + try { + client.identify("+1234567890", IdentifierType.PHONE).execute() + } catch (_: EmbeddedAuthException) { + } + + val request = mockAPI.takeRequest() + val body = bodyOf(request) + assertThat(request.requestUrl?.encodedPath, `is`("/e/authorize")) + assertThat(body.getString("action"), `is`("action:identify:phone:v1")) + assertThat(body.getString("phone"), `is`("+1234567890")) + assertThat(body.getString("auth_session"), `is`(EmbeddedAuthMockServer.AUTH_SESSION)) + } + + @Test + public fun `identify with phone surfaces ChallengePhone next action`() { + establishSession() + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_CHALLENGE_PHONE) + + val error = assertEmbeddedError { client.identify("+1234567890", IdentifierType.PHONE).execute() } + + assertThat(error.nextActions, hasSize(1)) + val action = error.nextActions[0] as NextAction.ChallengePhone + assertThat(action.index, `is`(0)) + assertThat(action.identifier, `is`("+1234567890")) + assertThat(action.deliveryMethods, hasSize(2)) + } + + // ── challengePhone ────────────────────────────────────────────────────────── + + @Test + public fun `challengePhone should fail with no_active_session when no flow is in progress`() { + val error = assertEmbeddedError { client.challengePhone().execute() } + assertThat(error.code, `is`("no_active_session")) + assertThat(error.error, `is`(EmbeddedAuthError.NoActiveSession)) + } + + @Test + public fun `challengePhone should POST the authorize endpoint with the correct action, index, and delivery method`() { + establishSession() + mockAPI.willReturnInsufficientAuthorization() + + try { + client.challengePhone(index = 2, deliveryMethod = PhoneDeliveryMethod.VOICE).execute() + } catch (_: EmbeddedAuthException) { + } + + val body = bodyOf(mockAPI.takeRequest()) + assertThat(body.getString("action"), `is`("action:challenge:phone:v1")) + assertThat(body.getInt("index"), `is`(2)) + assertThat(body.getString("delivery_method"), `is`("voice")) + } + + @Test + public fun `challengePhone defaults the delivery method to text`() { + establishSession() + mockAPI.willReturnInsufficientAuthorization() + + try { + client.challengePhone(index = 1).execute() + } catch (_: EmbeddedAuthException) { + } + + val body = bodyOf(mockAPI.takeRequest()) + assertThat(body.getString("delivery_method"), `is`("text")) + } + + @Test + public fun `challengePhone surfaces VerifyOtp next action with SMS channel`() { + establishSession() + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_VERIFY_OTP_SMS) + + val error = assertEmbeddedError { client.challengePhone(index = 0, deliveryMethod = PhoneDeliveryMethod.TEXT).execute() } + + assertThat(error.nextActions, hasSize(1)) + val action = error.nextActions[0] as NextAction.VerifyOtp + assertThat(action.channel, `is`(OtpChannel.SMS)) + } + + // ── challengePush ─────────────────────────────────────────────────────────── + + @Test + public fun `challengePush should fail with no_active_session when no flow is in progress`() { + val error = assertEmbeddedError { client.challengePush(index = 0).execute() } + assertThat(error.code, `is`("no_active_session")) + assertThat(error.error, `is`(EmbeddedAuthError.NoActiveSession)) + } + + @Test + public fun `challengePush should POST the authorize endpoint with the correct action and index`() { + establishSession() + mockAPI.willReturnInsufficientAuthorization() + + try { + client.challengePush(index = 0).execute() + } catch (_: EmbeddedAuthException) { + } + + val body = bodyOf(mockAPI.takeRequest()) + assertThat(body.getString("action"), `is`("action:challenge:push:v1")) + assertThat(body.getInt("index"), `is`(0)) + } + + @Test + public fun `challengePush surfaces VerifyOob next action`() { + establishSession() + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_VERIFY_OOB) + + val error = assertEmbeddedError { client.challengePush(index = 0).execute() } + + assertThat(error.nextActions, hasSize(1)) + val action = error.nextActions[0] as NextAction.VerifyOob + assertThat(action.pollInMs, `is`(5000)) + } + + // ── verifyOob ─────────────────────────────────────────────────────────────── + + @Test + public fun `verifyOob should fail with no_active_session when no flow is in progress`() { + val error = assertEmbeddedError { client.verifyOob().execute() } + assertThat(error.code, `is`("no_active_session")) + assertThat(error.error, `is`(EmbeddedAuthError.NoActiveSession)) + } + + @Test + public fun `verifyOob should POST the authorize endpoint with the correct action`() { + establishSession() + mockAPI.willReturnInsufficientAuthorization() + + try { + client.verifyOob().execute() + } catch (_: EmbeddedAuthException) { + } + + val body = bodyOf(mockAPI.takeRequest()) + assertThat(body.getString("action"), `is`("action:verify:oob:v1")) + } + + @Test + public fun `verifyOob should exchange the code and return credentials on 200`() { + establishSession() + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokens() + + val credentials = client.verifyOob().execute() + + assertThat(credentials.idToken, `is`(EmbeddedAuthMockServer.ID_TOKEN)) + assertThat(credentials.accessToken, `is`(EmbeddedAuthMockServer.ACCESS_TOKEN)) + } + + @Test + public fun `the session is cleared after a successful verifyOob exchange`() { + establishSession() + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokens() + client.verifyOob().execute() + + val error = assertEmbeddedError { client.verifyOob().execute() } + assertThat(error.code, `is`("no_active_session")) + } + + @Test + public fun `verifyOob surfaces authorization_pending reason on 403`() { + establishSession() + mockAPI.server.enqueue(mockAPI.responseWithJSON( + """{"error":"insufficient_authorization","error_description":"authorization_pending","auth_session":"${EmbeddedAuthMockServer.AUTH_SESSION}","next":[${EmbeddedAuthMockServer.NEXT_VERIFY_OOB}]}""", + 403 + )) + + val error = assertEmbeddedError { client.verifyOob().execute() } + + assertThat(error.error, instanceOf(EmbeddedAuthError.InsufficientAuthorization::class.java)) + val insuff = error.error as EmbeddedAuthError.InsufficientAuthorization + assertThat(insuff.reason, `is`(EmbeddedAuthError.InsufficientAuthorization.Reason.AUTHORIZATION_PENDING)) + assertThat(insuff.nextActions, hasSize(1)) + } + + @Test + public fun `verifyOob surfaces slow_down reason on 403`() { + establishSession() + mockAPI.server.enqueue(mockAPI.responseWithJSON( + """{"error":"insufficient_authorization","error_description":"slow_down","auth_session":"${EmbeddedAuthMockServer.AUTH_SESSION}","next":[${EmbeddedAuthMockServer.NEXT_VERIFY_OOB}]}""", + 403 + )) + + val error = assertEmbeddedError { client.verifyOob().execute() } + + assertThat(error.error, instanceOf(EmbeddedAuthError.InsufficientAuthorization::class.java)) + val insuff = error.error as EmbeddedAuthError.InsufficientAuthorization + assertThat(insuff.reason, `is`(EmbeddedAuthError.InsufficientAuthorization.Reason.SLOW_DOWN)) + } + + // ── verifyRecoveryCode ────────────────────────────────────────────────────── + + @Test + public fun `verifyRecoveryCode should fail with no_active_session when no flow is in progress`() { + val error = assertEmbeddedError { client.verifyRecoveryCode("CODE-123").execute() } + assertThat(error.code, `is`("no_active_session")) + assertThat(error.error, `is`(EmbeddedAuthError.NoActiveSession)) + } + + @Test + public fun `verifyRecoveryCode should POST the authorize endpoint with the correct action and code`() { + establishSession() + mockAPI.willReturnInsufficientAuthorization() + + try { + client.verifyRecoveryCode("RECOVERY-CODE-XYZ").execute() + } catch (_: EmbeddedAuthException) { + } + + val body = bodyOf(mockAPI.takeRequest()) + assertThat(body.getString("action"), `is`("action:verify:recovery-code:v1")) + assertThat(body.getString("code"), `is`("RECOVERY-CODE-XYZ")) + } + + @Test + public fun `verifyRecoveryCode surfaces ConfirmRecoveryCode next action on continuation`() { + establishSession() + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_CONFIRM_RECOVERY_CODE) + + val error = assertEmbeddedError { client.verifyRecoveryCode("RECOVERY-CODE-OLD").execute() } + + assertThat(error.nextActions, hasSize(1)) + val action = error.nextActions[0] as NextAction.ConfirmRecoveryCode + assertThat(action.newCode, `is`("NEW-CODE-XYZ")) + } + + // ── confirmRecoveryCode ───────────────────────────────────────────────────── + + @Test + public fun `confirmRecoveryCode should fail with no_active_session when no flow is in progress`() { + val error = assertEmbeddedError { client.confirmRecoveryCode().execute() } + assertThat(error.code, `is`("no_active_session")) + assertThat(error.error, `is`(EmbeddedAuthError.NoActiveSession)) + } + + @Test + public fun `confirmRecoveryCode should POST the authorize endpoint with the correct action`() { + establishSession() + mockAPI.willReturnInsufficientAuthorization() + + try { + client.confirmRecoveryCode().execute() + } catch (_: EmbeddedAuthException) { + } + + val body = bodyOf(mockAPI.takeRequest()) + assertThat(body.getString("action"), `is`("action:confirm:recovery-code:v1")) + } + + @Test + public fun `confirmRecoveryCode should exchange the code and return credentials on 200`() { + establishSession() + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokens() + + val credentials = client.confirmRecoveryCode().execute() + + assertThat(credentials.idToken, `is`(EmbeddedAuthMockServer.ID_TOKEN)) + assertThat(credentials.accessToken, `is`(EmbeddedAuthMockServer.ACCESS_TOKEN)) + } + + @Test + public fun `the session is cleared after a successful confirmRecoveryCode exchange`() { + establishSession() + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokens() + client.confirmRecoveryCode().execute() + + val error = assertEmbeddedError { client.confirmRecoveryCode().execute() } + assertThat(error.code, `is`("no_active_session")) + } + + // ── Session preservation on MFA errors ─────────────────────────────────────── + + @Test + public fun `session is preserved on authorization_pending (push polling)`() { + // Verify that after authorization_pending, the session is NOT cleared + // (subsequent verifyOob can continue polling) + establishSession() + mockAPI.server.enqueue(mockAPI.responseWithJSON( + """{"error":"insufficient_authorization","error_description":"authorization_pending","auth_session":"${EmbeddedAuthMockServer.AUTH_SESSION}","next":[${EmbeddedAuthMockServer.NEXT_VERIFY_OOB}]}""", + 403 + )) + + try { + client.verifyOob().execute() + } catch (_: EmbeddedAuthException) { + } + mockAPI.takeRequest() + + // Now verify the session is still available for the next call + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokens() + + val credentials = client.verifyOob().execute() + assertThat(credentials.accessToken, `is`(EmbeddedAuthMockServer.ACCESS_TOKEN)) + + // Verify the second verifyOob used the preserved session + val secondRequest = mockAPI.takeRequest() + val secondBody = bodyOf(secondRequest) + assertThat(secondBody.getString("auth_session"), `is`(EmbeddedAuthMockServer.AUTH_SESSION)) + } + + @Test + public fun `session is rotated across push polls`() { + // A pending push can rotate auth_session; the next verifyOob must send the new value. + establishSession() + mockAPI.server.enqueue(mockAPI.responseWithJSON( + """{"error":"insufficient_authorization","error_description":"authorization_pending","auth_session":"${EmbeddedAuthMockServer.ROTATED_AUTH_SESSION}","next":[${EmbeddedAuthMockServer.NEXT_VERIFY_OOB}]}""", + 403 + )) + + try { + client.verifyOob().execute() + } catch (_: EmbeddedAuthException) { + } + // First poll still carries the original session. + assertThat(bodyOf(mockAPI.takeRequest()).getString("auth_session"), `is`(EmbeddedAuthMockServer.AUTH_SESSION)) + + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokens() + + val credentials = client.verifyOob().execute() + assertThat(credentials.accessToken, `is`(EmbeddedAuthMockServer.ACCESS_TOKEN)) + + // The second poll must use the rotated session returned by the pending continuation. + assertThat(bodyOf(mockAPI.takeRequest()).getString("auth_session"), `is`(EmbeddedAuthMockServer.ROTATED_AUTH_SESSION)) + } + + @Test + public fun `session is cleared on authorization_rejected (terminal error)`() { + establishSession() + mockAPI.server.enqueue(mockAPI.responseWithJSON( + """{"error":"access_denied","error_description":"authorization_rejected"}""", + 403 + )) + + val error = assertEmbeddedError { client.verifyOob().execute() } + assertThat(error.error, `is`(EmbeddedAuthError.AuthorizationRejected)) + mockAPI.takeRequest() + + // After terminal error, session is cleared → next call → noActiveSession + val error2 = assertEmbeddedError { client.verifyOob().execute() } + assertThat(error2.error, `is`(EmbeddedAuthError.NoActiveSession)) + } + + @Test + public fun `session is cleared on no_eligible_factors (terminal error)`() { + establishSession() + mockAPI.server.enqueue(mockAPI.responseWithJSON( + """{"error":"access_denied","error_description":"no_eligible_factors"}""", + 403 + )) + + val error = assertEmbeddedError { client.verifyRecoveryCode("CODE").execute() } + assertThat(error.error, `is`(EmbeddedAuthError.NoEligibleFactors)) + mockAPI.takeRequest() + + // After terminal error, session is cleared + val error2 = assertEmbeddedError { client.verifyRecoveryCode("CODE").execute() } + assertThat(error2.error, `is`(EmbeddedAuthError.NoActiveSession)) + } + // ── Helpers ────────────────────────────────────────────────────────────────── /** Enqueues a continuation and calls authorize to populate [transactionState]. */ 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 new file mode 100644 index 000000000..7f9cb0e75 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/embedded/authorize/NextActionMapperTest.kt @@ -0,0 +1,166 @@ +package com.auth0.android.embedded.authorize + +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.instanceOf +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +public class NextActionMapperTest { + + private companion object { + const val ACTION_KEY = "action" + const val INDEX_KEY = "index" + const val IDENTIFIER_KEY = "identifier" + const val DELIVERY_METHODS_KEY = "delivery_methods" + const val NAME_KEY = "name" + const val POLL_IN_MS_KEY = "poll_in_ms" + const val NEW_CODE_KEY = "new_code" + } + + @Test + public fun testChallengePhoneMapped() { + val raw = mapOf( + ACTION_KEY to "action:challenge:phone:v1", + INDEX_KEY to 1, + IDENTIFIER_KEY to "+1234567890", + DELIVERY_METHODS_KEY to listOf("text", "voice") + ) + val action = listOf(raw).toNextActions().first() + + assertThat(action, instanceOf(NextAction.ChallengePhone::class.java)) + action as NextAction.ChallengePhone + assertThat(action.index, `is`(1)) + assertThat(action.identifier, `is`("+1234567890")) + assertThat(action.deliveryMethods, `is`(listOf(PhoneDeliveryMethod.TEXT, PhoneDeliveryMethod.VOICE))) + } + + @Test + public fun testChallengePushMapped() { + val raw = mapOf( + ACTION_KEY to "action:challenge:push:v1", + INDEX_KEY to 0, + NAME_KEY to "My Phone" + ) + val action = listOf(raw).toNextActions().first() + + assertThat(action, instanceOf(NextAction.ChallengePush::class.java)) + action as NextAction.ChallengePush + assertThat(action.index, `is`(0)) + assertThat(action.name, `is`("My Phone")) + } + + @Test + public fun testVerifyOobMapped() { + val raw = mapOf( + ACTION_KEY to "action:verify:oob:v1", + POLL_IN_MS_KEY to 5000 + ) + val action = listOf(raw).toNextActions().first() + + assertThat(action, instanceOf(NextAction.VerifyOob::class.java)) + action as NextAction.VerifyOob + assertThat(action.pollInMs, `is`(5000)) + } + + @Test + public fun testChallengePhoneDroppedIfNoIndex() { + val raw = mapOf( + ACTION_KEY to "action:challenge:phone:v1", + IDENTIFIER_KEY to "+1234567890", + DELIVERY_METHODS_KEY to listOf("text") + // no index — protocol violation + ) + assertThat(listOf(raw).toNextActions().isEmpty(), `is`(true)) + } + + @Test + public fun testChallengePhoneDroppedIfNoIdentifier() { + val raw = mapOf( + ACTION_KEY to "action:challenge:phone:v1", + INDEX_KEY to 0, + DELIVERY_METHODS_KEY to listOf("text") + // no identifier — protocol violation + ) + assertThat(listOf(raw).toNextActions().isEmpty(), `is`(true)) + } + + @Test + public fun testChallengePhoneDroppedIfNoDeliveryMethods() { + val raw = mapOf( + ACTION_KEY to "action:challenge:phone:v1", + INDEX_KEY to 0, + IDENTIFIER_KEY to "+1234567890" + // no delivery_methods — protocol violation (server contract requires it) + ) + assertThat(listOf(raw).toNextActions().isEmpty(), `is`(true)) + } + + @Test + public fun testChallengePushDroppedIfNoIndex() { + val raw = mapOf( + ACTION_KEY to "action:challenge:push:v1", + NAME_KEY to "My Phone" + // no index — protocol violation + ) + assertThat(listOf(raw).toNextActions().isEmpty(), `is`(true)) + } + + @Test + public fun testChallengePushDroppedIfNoName() { + val raw = mapOf( + ACTION_KEY to "action:challenge:push:v1", + INDEX_KEY to 0 + // no name — protocol violation (server contract requires it) + ) + assertThat(listOf(raw).toNextActions().isEmpty(), `is`(true)) + } + + @Test + public fun testConfirmRecoveryCodeDroppedIfNoNewCode() { + val raw = mapOf( + ACTION_KEY to "action:confirm:recovery-code:v1" + // no new_code — protocol violation + ) + assertThat(listOf(raw).toNextActions().isEmpty(), `is`(true)) + } + + @Test + public fun testVerifyOobDroppedIfNoPollInMs() { + val raw = mapOf( + ACTION_KEY to "action:verify:oob:v1" + // no poll_in_ms — protocol violation + ) + val actions = listOf(raw).toNextActions() + + assertThat(actions.isEmpty(), `is`(true)) + } + + @Test + public fun testVerifyRecoveryCodeMapped() { + val raw = mapOf( + ACTION_KEY to "action:verify:recovery-code:v1" + ) + val action = listOf(raw).toNextActions().first() + + assertThat(action, instanceOf(NextAction.VerifyRecoveryCode::class.java)) + } + + @Test + public fun testConfirmRecoveryCodeMapped() { + val raw = mapOf( + ACTION_KEY to "action:confirm:recovery-code:v1", + NEW_CODE_KEY to "NEW-CODE-123" + ) + val action = listOf(raw).toNextActions().first() + + assertThat(action, instanceOf(NextAction.ConfirmRecoveryCode::class.java)) + action as NextAction.ConfirmRecoveryCode + assertThat(action.newCode, `is`("NEW-CODE-123")) + } +} 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 b3a98216d..1823c7928 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 willReturnIpBlocked(): EmbeddedAuthMockServer { + server.enqueue(responseWithJSON("""{ "error": "too_many_requests", "error_description": "ip_blocked" }""", 429)) + return this + } + /** An `invalid_grant` returned by `/e/authorize` continuation (expired `auth_session`). */ fun willReturnInvalidGrant(description: String = "The auth_session has expired."): EmbeddedAuthMockServer { server.enqueue(responseWithJSON("""{ "error": "invalid_grant", "error_description": "$description" }""", 400)) @@ -147,7 +152,13 @@ $actionsJson const val NEXT_IDENTIFY_EMAIL = """{ "action": "action:identify:email:v1" }""" const val NEXT_IDENTIFY_PHONE = """{ "action": "action:identify:phone:v1" }""" const val NEXT_CHALLENGE_EMAIL = """{ "action": "action:challenge:email:v1", "index": 1, "identifier": "jane@example.com" }""" + const val NEXT_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" }""" const val NEXT_VERIFY_OTP = """{ "action": "action:verify:otp:v1", "channel": "email", "identifier": "jane@example.com" }""" + const val NEXT_VERIFY_OTP_SMS = """{ "action": "action:verify:otp:v1", "channel": "sms", "identifier": "+1234567890" }""" + const val NEXT_VERIFY_OOB = """{ "action": "action:verify:oob:v1", "poll_in_ms": 5000 }""" + const val NEXT_VERIFY_RECOVERY_CODE = """{ "action": "action:verify:recovery-code:v1" }""" + const val NEXT_CONFIRM_RECOVERY_CODE = """{ "action": "action:confirm:recovery-code:v1", "new_code": "NEW-CODE-XYZ" }""" const val NEXT_VERIFY_OTP_UNKNOWN_CHANNEL = """{ "action": "action:verify:otp:v1", "channel": "carrier-pigeon", "identifier": "jane@example.com" }""" const val NEXT_VERIFY_OTP_NO_CHANNEL = """{ "action": "action:verify:otp:v1", "identifier": "jane@example.com" }""" const val NEXT_VERIFY_OTP_MIXED_CASE_CHANNEL = """{ "action": "action:verify:otp:v1", "channel": "SmS", "identifier": "jane@example.com" }""" diff --git a/examples/embedded-auth/authorize.md b/examples/embedded-auth/authorize.md index c668f39e8..5449966c7 100644 --- a/examples/embedded-auth/authorize.md +++ b/examples/embedded-auth/authorize.md @@ -3,6 +3,10 @@ > [!IMPORTANT] > Embedded Authorization is currently in [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta). Please reach out to Auth0 support to get it enabled for your tenant. + +> [!NOTE] +> The Embedded Authorization flow is supported only for **existing users** who have already signed up. It does not work for fresh / new user sign-ups. + 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. @@ -45,25 +49,35 @@ client.authorize( #### Handle next actions -Each non-terminal step throws `EmbeddedAuthException` whose `error` is `EmbeddedAuthError.InsufficientAuthorization`, carrying a list of `NextAction` entries describing what the server will accept next. Iterate them to build your UI: +Each non-terminal step throws `EmbeddedAuthException` whose `error` is `EmbeddedAuthError.InsufficientAuthorization`, carrying a list of `NextAction` entries describing what the server will accept next. The list is a *menu*, not a sequence: the server advertises every action valid right now given what the user has enrolled. + +The same actions appear whether a factor is the **first factor** or an **MFA second factor** — the server decides which to offer based on the connection and the user's enrollment. For example, `IdentifyPhone` + `ChallengePhone` + `VerifyOtp` can log a user in by SMS/voice as a first factor, and the same `ChallengePhone` + `VerifyOtp` pair can appear later as a step-up second factor after a password. Iterate the menu to build your UI: ```kotlin val error = exception.error if (error is EmbeddedAuthError.InsufficientAuthorization) { for (action in error.nextActions) { when (action) { - is NextAction.IdentifyEmail -> { /* show email field, call identify() */ } - is NextAction.ChallengeEmail -> { /* show "send code" button, call challengeEmail(action.index) */ } - is NextAction.VerifyOtp -> { /* show OTP field, call verifyOtp() */ } - is NextAction.Unknown -> { /* unsupported — skip or show disabled */ } + is NextAction.IdentifyEmail -> { /* show email field, call identify(..., EMAIL) */ } + is NextAction.IdentifyPhone -> { /* show phone field, call identify(..., PHONE) */ } + 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) */ } + is NextAction.VerifyOtp -> { /* show OTP field, call verifyOtp() */ } + is NextAction.VerifyOob -> { /* poll the pending push, call verifyOob() */ } + is NextAction.VerifyRecoveryCode -> { /* show recovery-code field, call verifyRecoveryCode() */ } + is NextAction.ConfirmRecoveryCode -> { /* show action.newCode, call confirmRecoveryCode() */ } + is NextAction.Unknown -> { /* unsupported — skip or show disabled */ } } } } ``` +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). + #### Identify the user -After `authorize()` returns a continuation with `NextAction.IdentifyEmail`: +`identify()` takes the identifier and its `IdentifierType`. When `authorize()` returns a continuation with `NextAction.IdentifyEmail`, submit an email: ```kotlin try { @@ -76,15 +90,14 @@ try { } ``` +When `NextAction.IdentifyPhone` is present, submit a phone number instead — the server then continues with a `ChallengePhone` action (see [Phone OTP](phone-authorize.md)): + ```kotlin -client.identifyPhone("+15550001234").await() +client.identify("+15550001234", IdentifierType.PHONE).await() ``` #### Request an email challenge -> [!NOTE] -> The Email OTP flow is supported only for **existing users** who have already signed up. It does not work for fresh / new user sign-ups. - When `NextAction.ChallengeEmail` is present, ask the server to send a one-time code: ```kotlin @@ -99,7 +112,7 @@ client.challengeEmail(action.index).await() ```kotlin val credentials = client.verifyOtp( code = "123456", - type = OtpType.OOB // OOB for emailed codes; TOTP for authenticator-app codes + type = OtpType.OOB // OOB for email and SMS/voice codes; TOTP for authenticator-app codes ).await() // credentials.accessToken, credentials.idToken, etc. are now available. @@ -117,6 +130,9 @@ if (error is EmbeddedAuthError.InsufficientAuthorization) { "That identifier or code was incorrect. Try again." EmbeddedAuthError.InsufficientAuthorization.Reason.NONE -> null // plain step-forward — no rejection to report + EmbeddedAuthError.InsufficientAuthorization.Reason.AUTHORIZATION_PENDING, + EmbeddedAuthError.InsufficientAuthorization.Reason.SLOW_DOWN -> + null // push polling only — see push-oob-polling.md } // Re-prompt using error.nextActions, optionally surfacing `message`. } @@ -132,8 +148,12 @@ when (e.error) { EmbeddedAuthError.TooManyWrongOtpAttempts -> { /* too many wrong codes — flow denied */ } EmbeddedAuthError.ChallengeExpired -> { /* the code expired — restart the flow */ } EmbeddedAuthError.AccessDenied -> { /* denied for another reason — do not retry */ } + EmbeddedAuthError.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.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 */ } @@ -141,41 +161,6 @@ when (e.error) { } ``` -
- Using callbacks - -```kotlin -client - .authorize("Username-Password-Authentication") - .start(object : Callback { - override fun onSuccess(result: Void?) { - // Unexpected — authorize always continues through onFailure. - } - override fun onFailure(exception: EmbeddedAuthException) { - val error = exception.error - if (error is EmbeddedAuthError.InsufficientAuthorization) { - // Handle error.nextActions. - } - } - }) -``` - -Terminal step with callbacks: - -```kotlin -client - .verifyOtp("123456", OtpType.OOB) - .start(object : Callback { - override fun onSuccess(result: Credentials) { - // Authenticated. - } - override fun onFailure(exception: EmbeddedAuthException) { - // Handle continuation or terminal error. - } - }) -``` -
-
Using Java diff --git a/examples/embedded-auth/phone-authorize.md b/examples/embedded-auth/phone-authorize.md new file mode 100644 index 000000000..29a48566c --- /dev/null +++ b/examples/embedded-auth/phone-authorize.md @@ -0,0 +1,86 @@ +# Phone OTP (SMS/Voice) — first-factor & MFA second-factor + +Authenticate with a phone number and an SMS or voice one-time code. The same +`identify` → `challengePhone` → `verifyOtp` sequence works whether phone is the first +factor or an MFA step-up — the server decides which by advertising the matching +`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`; the terminal `verifyOtp` returns `Credentials`. + +```kotlin +val embedded = EmbeddedAuthClient(Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN")) + +// 1. Start the flow — always continues with the next actions (e.g. IdentifyPhone). +try { + embedded.authorize("Username-Password-Authentication").await() +} catch (e: EmbeddedAuthException) { /* inspect (e.error as InsufficientAuthorization).nextActions */ } + +// 2. Identify by phone. The server continues with a ChallengePhone action carrying the +// authenticator index and the delivery methods it will accept. +val challenge = try { + embedded.identify("+15550123456", IdentifierType.PHONE).await() + return +} catch (e: EmbeddedAuthException) { + (e.error as? EmbeddedAuthError.InsufficientAuthorization) + ?.nextActions?.filterIsInstance()?.firstOrNull() + ?: throw e +} + +// 3. Send the code using the index and a delivery method the server offered. Pick VOICE for a +// voice call; the first offered method is used here. Continues with VerifyOtp. +try { + val method = challenge.deliveryMethods.firstOrNull() ?: PhoneDeliveryMethod.TEXT + embedded.challengePhone(index = challenge.index, deliveryMethod = method).await() +} catch (e: EmbeddedAuthException) { /* continuation → VerifyOtp */ } + +// 4. Verify the code the user received. Terminal — returns Credentials on success. +try { + val credentials = embedded.verifyOtp(code = "123456", type = OtpType.OOB).await() + // credentials.accessToken, credentials.idToken, ... +} catch (e: EmbeddedAuthException) { + when (e.error) { + is EmbeddedAuthError.InsufficientAuthorization -> { /* wrong code or more MFA — re-prompt */ } + EmbeddedAuthError.TooManyWrongOtpAttempts -> { /* too many wrong codes */ } + else -> { /* other terminal error */ } + } +} +``` + +`challengePhone` offers the delivery methods the server advertised in +`NextAction.ChallengePhone.deliveryMethods` — pass `PhoneDeliveryMethod.VOICE` for a voice +call. SMS and voice codes are both verified with `OtpType.OOB`. + +
+ Using Java + +```java +Auth0 auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN"); +EmbeddedAuthClient embedded = new EmbeddedAuthClient(auth0); + +embedded.identify("+15550123456", IdentifierType.PHONE) + .start(new Callback() { + @Override public void onSuccess(Void result) { } + @Override public void onFailure(@NonNull EmbeddedAuthException e) { + // continuation → read NextAction.ChallengePhone for its index and deliveryMethods + } + }); + +// Send the code using the index and a delivery method the server offered. +embedded.challengePhone(0, PhoneDeliveryMethod.TEXT) + .start(new Callback() { + @Override public void onSuccess(Void result) { } + @Override public void onFailure(@NonNull EmbeddedAuthException e) { + // continuation → verifyOtp(...) + } + }); + +embedded.verifyOtp("123456", OtpType.OOB) + .start(new Callback() { + @Override public void onSuccess(Credentials credentials) { + // credentials.getAccessToken() + } + @Override public void onFailure(@NonNull EmbeddedAuthException e) { } + }); +``` +
diff --git a/examples/embedded-auth/push-oob-polling.md b/examples/embedded-auth/push-oob-polling.md new file mode 100644 index 000000000..60b4e8912 --- /dev/null +++ b/examples/embedded-auth/push-oob-polling.md @@ -0,0 +1,102 @@ +# Push Notification Polling (OOB) + +Push MFA is the one factor that needs a loop. **The SDK never polls** — `verifyOob()` +performs exactly one poll per call, and the app owns the loop. + +The pattern is always the same: + +1. `challengePush(index)` sends the notification and continues with a `NextAction.VerifyOob` + carrying `pollInMs` — how long to wait before the first poll. +2. Wait that long, then call `verifyOob()`. +3. Each `verifyOob()` either: + - **succeeds** → the push was approved; you get `Credentials`. + - **continues** with `AUTHORIZATION_PENDING` or `SLOW_DOWN` and a fresh + `VerifyOob(pollInMs)` → wait the new delay and poll again. + - **fails terminally** (`AuthorizationRejected`, `ChallengeExpired`, `SessionExpired`) + → stop. + +The flow is sequential. To switch to another factor the server offered, or to start a new flow +with `authorize()`, stop polling first — between polls, while waiting out the interval, not while a +`verifyOob()` call is in flight. Let an in-flight poll complete first; a poll that completes after +another step has started can overwrite that step's session. + +Both pending states arrive as `EmbeddedAuthError.InsufficientAuthorization` carrying a new +`VerifyOob` delay, so one helper covers them: it returns the next delay to wait, or `null` +when the outcome is terminal. + +```kotlin +val embedded = EmbeddedAuthClient(Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN")) + +// Next poll delay from a pending-push continuation, or null when the push is terminal. +private fun EmbeddedAuthException.nextPushDelayMs(): Long? = + (error as? EmbeddedAuthError.InsufficientAuthorization) + ?.nextActions?.filterIsInstance() + ?.firstOrNull()?.pollInMs?.toLong() + +// Assumes the flow has already reached the push factor (see authorize.md / identify). +// Pass the index from the NextAction.ChallengePush the server offered. +suspend fun verifyPush(pushIndex: Int): Credentials { + // challengePush always continues with a VerifyOob carrying the first poll delay. + var delayMs = try { + embedded.challengePush(pushIndex).await() + error("challengePush always continues") + } catch (e: EmbeddedAuthException) { + e.nextPushDelayMs() ?: throw e + } + + while (true) { + delay(delayMs) + try { + return embedded.verifyOob().await() // approved + } catch (e: EmbeddedAuthException) { + delayMs = e.nextPushDelayMs() ?: throw e // pending → loop; terminal → rethrow + } + } +} +``` + +
+ Using Java + +```java +Auth0 auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN"); +EmbeddedAuthClient embedded = new EmbeddedAuthClient(auth0); +Handler handler = new Handler(Looper.getMainLooper()); + +// Next poll delay for a pending push, or null if the outcome is terminal. +Long nextPushDelayMs(EmbeddedAuthException e) { + if (!(e.getError() instanceof EmbeddedAuthError.InsufficientAuthorization pending)) { + return null; + } + for (NextAction action : pending.getNextActions()) { + if (action instanceof NextAction.VerifyOob oob) { + return (long) oob.getPollInMs(); + } + } + return null; // no pending VerifyOob → terminal +} + +void pollPush(long delayMs) { + handler.postDelayed(() -> + embedded.verifyOob().start(new Callback() { + @Override public void onSuccess(Credentials credentials) { + // approved: credentials.getAccessToken() + } + @Override public void onFailure(@NonNull EmbeddedAuthException exception) { + Long next = nextPushDelayMs(exception); + if (next != null) pollPush(next); // pending → poll again + // else terminal: inspect exception.getError() + } + }), delayMs); +} + +// challengePush always continues; its continuation carries the first poll delay. +embedded.challengePush(0).start(new Callback() { + @Override public void onSuccess(Void result) { } + @Override public void onFailure(@NonNull EmbeddedAuthException exception) { + Long delay = nextPushDelayMs(exception); + if (delay != null) pollPush(delay); + } +}); +``` +
diff --git a/examples/embedded-auth/recovery-code.md b/examples/embedded-auth/recovery-code.md new file mode 100644 index 000000000..86a56fc51 --- /dev/null +++ b/examples/embedded-auth/recovery-code.md @@ -0,0 +1,65 @@ +# Recovery Code Verification & Confirmation + +Recovery-code MFA is always two steps: + +1. `verifyRecoveryCode` validates the user's code. It **never** returns `Credentials` — it + always continues with a `NextAction.ConfirmRecoveryCode` carrying a server-rotated new code. +2. `confirmRecoveryCode` acknowledges that new code (show it to the user first) and is + terminal — it returns `Credentials`. + +```kotlin +val embedded = EmbeddedAuthClient(Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN")) + +// The rotated recovery code carried by a ConfirmRecoveryCode continuation, if present. +private fun EmbeddedAuthException.rotatedRecoveryCode(): String? = + (error as? EmbeddedAuthError.InsufficientAuthorization) + ?.nextActions?.filterIsInstance() + ?.firstOrNull()?.newCode + +// 1. Verify the current recovery code — always continues, never returns Credentials. +try { + embedded.verifyRecoveryCode("ABCD-1234-EFGH-5678").await() +} catch (e: EmbeddedAuthException) { + val newCode = e.rotatedRecoveryCode() ?: throw e + // Show newCode to the user so they can save their new recovery code. +} + +// 2. Acknowledge the rotated code and complete the flow. Terminal — returns Credentials. +val credentials = embedded.confirmRecoveryCode().await() +// credentials.accessToken, credentials.idToken, ... +``` + +
+ Using Java + +```java +Auth0 auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN"); +EmbeddedAuthClient embedded = new EmbeddedAuthClient(auth0); + +embedded.verifyRecoveryCode("ABCD-1234-EFGH-5678") + .start(new Callback() { + @Override public void onSuccess(Void result) { } + @Override public void onFailure(@NonNull EmbeddedAuthException e) { + if (!(e.getError() instanceof EmbeddedAuthError.InsufficientAuthorization pending)) { + return; // terminal: inspect e.getError() + } + String newCode = null; + for (NextAction action : pending.getNextActions()) { + if (action instanceof NextAction.ConfirmRecoveryCode confirm) { + newCode = confirm.getNewCode(); + break; + } + } + if (newCode == null) return; + // Show newCode to the user, then acknowledge: + embedded.confirmRecoveryCode() + .start(new Callback() { + @Override public void onSuccess(Credentials credentials) { + // credentials.getAccessToken() + } + @Override public void onFailure(@NonNull EmbeddedAuthException ex) { } + }); + } + }); +``` +
diff --git a/examples/embedded-auth/totp.md b/examples/embedded-auth/totp.md new file mode 100644 index 000000000..2159f4539 --- /dev/null +++ b/examples/embedded-auth/totp.md @@ -0,0 +1,38 @@ +# Time-based One-Time Password (TOTP) + +TOTP (authenticator-app codes) reuses the existing `verifyOtp` — only the `OtpType` differs. +When the flow reaches a TOTP challenge, the server advertises a `NextAction.VerifyOtp` with +`channel = TOTP`; the user reads the 6-digit code from their authenticator app and you verify +it. `verifyOtp` is terminal: it returns `Credentials` on success. + +```kotlin +val embedded = EmbeddedAuthClient(Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN")) + +try { + val credentials = embedded.verifyOtp(code = "123456", type = OtpType.TOTP).await() + // credentials.accessToken, credentials.idToken, ... +} catch (e: EmbeddedAuthException) { + when (e.error) { + is EmbeddedAuthError.InsufficientAuthorization -> { /* wrong code or more MFA — re-prompt */ } + EmbeddedAuthError.TooManyWrongOtpAttempts -> { /* too many wrong codes */ } + else -> { /* other terminal error */ } + } +} +``` + +
+ Using Java + +```java +Auth0 auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN"); +EmbeddedAuthClient embedded = new EmbeddedAuthClient(auth0); + +embedded.verifyOtp("123456", OtpType.TOTP) + .start(new Callback() { + @Override public void onSuccess(Credentials credentials) { + // credentials.getAccessToken() + } + @Override public void onFailure(@NonNull EmbeddedAuthException e) { } + }); +``` +
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 8d609fea5..32141f8e3 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 @@ -17,7 +17,6 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.TextButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme @@ -45,7 +44,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.auth0.android.embedded.EmbeddedAuthError import com.auth0.android.embedded.EmbeddedAuthException import com.auth0.android.embedded.authorize.NextAction +import com.auth0.android.embedded.authorize.OtpChannel import com.auth0.android.embedded.authorize.OtpType +import com.auth0.android.embedded.authorize.PhoneDeliveryMethod import com.auth0.android.result.Credentials @OptIn(ExperimentalMaterial3Api::class) @@ -53,7 +54,6 @@ import com.auth0.android.result.Credentials public fun AuthorizeScreen( viewModel: EmbeddedViewModel = viewModel(), initialConnection: String, - onBack: () -> Unit = {}, ) { val state by viewModel.authorizeState.collectAsStateWithLifecycle() var connection by rememberSaveable { mutableStateOf(initialConnection) } @@ -67,14 +67,6 @@ public fun AuthorizeScreen( topBar = { TopAppBar( title = { Text(stringResource(R.string.title_screen)) }, - navigationIcon = { - TextButton(onClick = onBack) { - Text( - text = "← Back", - color = MaterialTheme.colorScheme.onPrimary, - ) - } - }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.primary, titleContentColor = MaterialTheme.colorScheme.onPrimary, @@ -128,6 +120,13 @@ public fun AuthorizeScreen( when (val current = state) { AuthorizeUiState.Idle -> InfoCard(stringResource(R.string.status_idle_authorize)) AuthorizeUiState.Loading -> InfoCard(stringResource(R.string.status_authorizing)) + is AuthorizeUiState.Polling -> { + InfoCard(stringResource(R.string.status_waiting_for_push)) + if (current.otherActions.isNotEmpty()) { + Spacer(Modifier.height(20.dp)) + ActionsSection(current.otherActions, viewModel) + } + } is AuthorizeUiState.ActionsAvailable -> ActionsSection(current.actions, viewModel) is AuthorizeUiState.Authenticated -> InfoCard(formatCredentials(current.credentials)) is AuthorizeUiState.Failed -> InfoCard(formatError(current.error)) @@ -164,8 +163,12 @@ private fun ActionsSection(actions: List, viewModel: EmbeddedViewMod onSubmit = { viewModel.identifyEmail(it) }, ) - NextAction.IdentifyPhone -> InfoCard( - stringResource(R.string.label_unsupported_action, "identify:phone"), + NextAction.IdentifyPhone -> IdentifyCard( + labelRes = R.string.action_identify_phone, + hintRes = R.string.hint_phone, + keyboardType = KeyboardType.Phone, + submitRes = R.string.action_submit, + onSubmit = { viewModel.identifyPhone(it) }, ) is NextAction.ChallengeEmail -> ChallengeEmailCard( @@ -174,10 +177,42 @@ private fun ActionsSection(actions: List, viewModel: EmbeddedViewMod ) is NextAction.VerifyOtp -> VerifyOtpCard( + channel = action.channel, identifier = action.identifier, onVerify = { code, type -> viewModel.verifyOtp(code, type) }, ) + is NextAction.ChallengePhone -> ChallengePhoneCard( + identifier = action.identifier, + deliveryMethods = action.deliveryMethods, + onChallenge = { method -> viewModel.challengePhone(action.index, method) }, + ) + + is NextAction.ChallengePush -> ActionCard( + titleRes = R.string.action_challenge_push, + detail = action.name, + buttonRes = R.string.action_challenge_push, + onClick = { viewModel.challengePush(action.index) }, + ) + + is NextAction.VerifyOob -> ActionCard( + titleRes = R.string.label_push_sent, + detail = null, + buttonRes = R.string.action_check_push, + onClick = { viewModel.verifyPush(action.pollInMs) }, + ) + + NextAction.VerifyRecoveryCode -> VerifyRecoveryCodeCard( + onVerify = { viewModel.verifyRecoveryCode(it) }, + ) + + is NextAction.ConfirmRecoveryCode -> ActionCard( + titleRes = R.string.label_new_recovery_code, + detail = action.newCode, + buttonRes = R.string.action_confirm_recovery_code, + onClick = { viewModel.confirmRecoveryCode() }, + ) + is NextAction.Unknown -> InfoCard( stringResource(R.string.label_unsupported_action, action.rawAction), ) @@ -244,9 +279,95 @@ private fun ChallengeEmailCard(identifier: String, onChallenge: () -> Unit) { } @Composable -private fun VerifyOtpCard(identifier: String?, onVerify: (String, OtpType) -> Unit) { +private fun ChallengePhoneCard( + identifier: String, + deliveryMethods: List, + onChallenge: (PhoneDeliveryMethod) -> Unit, +) { + var method by remember { mutableStateOf(deliveryMethods.firstOrNull() ?: PhoneDeliveryMethod.TEXT) } + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + stringResource(R.string.action_challenge_phone), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(Modifier.height(4.dp)) + Text(identifier, style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + deliveryMethods.forEach { option -> + FilterChip( + selected = method == option, + onClick = { method = option }, + label = { Text(option.name) }, + ) + } + } + Spacer(Modifier.height(12.dp)) + Button(onClick = { onChallenge(method) }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.action_challenge_phone)) + } + } + } +} + +/** A titled card with optional detail text and a single action button. */ +@Composable +private fun ActionCard(titleRes: Int, detail: String?, buttonRes: Int, onClick: () -> Unit) { + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text(stringResource(titleRes), style = MaterialTheme.typography.titleSmall) + if (detail != null) { + Spacer(Modifier.height(4.dp)) + Text(detail, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace) + } + Spacer(Modifier.height(12.dp)) + Button(onClick = onClick, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(buttonRes)) + } + } + } +} + +@Composable +private fun VerifyRecoveryCodeCard(onVerify: (String) -> Unit) { var code by rememberSaveable { mutableStateOf("") } - var type by remember { mutableStateOf(OtpType.OOB) } + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + stringResource(R.string.action_verify_recovery_code), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = code, + onValueChange = { code = it }, + label = { Text(stringResource(R.string.hint_recovery_code)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { if (code.isNotBlank()) onVerify(code.trim()) }, + ), + ) + Spacer(Modifier.height(12.dp)) + Button( + onClick = { onVerify(code.trim()) }, + enabled = code.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.action_verify)) + } + } + } +} + +@Composable +private fun VerifyOtpCard(channel: OtpChannel, identifier: String?, onVerify: (String, OtpType) -> Unit) { + var code by rememberSaveable { mutableStateOf("") } + var type by remember(channel) { + mutableStateOf(if (channel == OtpChannel.TOTP) OtpType.TOTP else OtpType.OOB) + } OutlinedCard(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { Text( @@ -347,8 +468,12 @@ private fun explain(error: EmbeddedAuthError): String = when (error) { EmbeddedAuthError.TooManyWrongOtpAttempts, EmbeddedAuthError.ChallengeExpired, EmbeddedAuthError.SessionExpired -> "This flow can't continue — start over." + 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.TooManyAttempts, - EmbeddedAuthError.TooManyLogins -> "Rate limited — try again later." + 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." } 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 f39120a31..a49e9a9c3 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 @@ -13,6 +13,12 @@ sealed interface AuthorizeUiState { /** A call is in flight. */ data object Loading : AuthorizeUiState + /** + * Waiting for the user to approve a push notification; the ViewModel is polling. + * [otherActions] are the alternative factors the server offered, which the user can switch to. + */ + 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 diff --git a/sample-embedded/src/main/java/com/auth0/sample/embedded/EmbeddedScreen.kt b/sample-embedded/src/main/java/com/auth0/sample/embedded/EmbeddedScreen.kt deleted file mode 100644 index ac0d671ab..000000000 --- a/sample-embedded/src/main/java/com/auth0/sample/embedded/EmbeddedScreen.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.auth0.sample.embedded - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.unit.dp - -/** Landing screen: collects a connection and starts the embedded authorization flow. */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -public fun EmbeddedScreen( - onAuthorize: (connection: String) -> Unit = {}, -) { - var connection by rememberSaveable { mutableStateOf("Username-Password-Authentication") } - val startAuthorize = { onAuthorize(connection.trim()) } - - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.title_screen)) }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primary, - titleContentColor = MaterialTheme.colorScheme.onPrimary, - ), - ) - }, - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(horizontal = 20.dp, vertical = 16.dp), - ) { - OutlinedTextField( - value = connection, - onValueChange = { connection = it }, - label = { Text(stringResource(R.string.hint_connection_required)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { startAuthorize() }), - ) - - // Give the button some breathing room below the input. - Spacer(Modifier.height(24.dp)) - - Button( - onClick = startAuthorize, - enabled = connection.isNotBlank(), - shape = RoundedCornerShape(12.dp), - modifier = Modifier - .fillMaxWidth() - .height(52.dp), - ) { - Text(stringResource(R.string.action_start_authorize)) - } - } - } -} 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 b662bed56..cab40eecf 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 @@ -8,12 +8,20 @@ import com.auth0.android.embedded.EmbeddedAuthClient import com.auth0.android.embedded.EmbeddedAuthError import com.auth0.android.embedded.EmbeddedAuthException import com.auth0.android.embedded.authorize.IdentifierType +import com.auth0.android.embedded.authorize.NextAction import com.auth0.android.embedded.authorize.OtpType +import com.auth0.android.embedded.authorize.PhoneDeliveryMethod import com.auth0.android.request.DefaultClient +import com.auth0.android.result.Credentials +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class EmbeddedViewModel(application: Application) : AndroidViewModel(application) { @@ -30,42 +38,117 @@ class EmbeddedViewModel(application: Application) : AndroidViewModel(application ) } + /** Defaults to the tenant's Management API; swap for your own API identifier. */ + private val defaultAudience: String = + "https://${application.getString(R.string.com_auth0_domain)}/api/v2/" + // region Authorize (/e/authorize) — the interactive multi-step flow. private val _authorizeState = MutableStateFlow(AuthorizeUiState.Idle) val authorizeState: StateFlow = _authorizeState.asStateFlow() /** Kicks off the flow. Completes via a continuation (see [runStep]). */ - fun startAuthorize(connection: String): Unit = runStep { client.authorize(connection).await() } + fun startAuthorize(connection: String): Unit = runStep { + client.authorize(connection, audience = defaultAudience).await() + } fun identifyEmail(email: String): Unit = runStep { client.identify(email, IdentifierType.EMAIL).await() } fun challengeEmail(index: Int): Unit = runStep { client.challengeEmail(index).await() } + fun identifyPhone(phone: String): Unit = runStep { client.identify(phone, IdentifierType.PHONE).await() } + + fun challengePhone(index: Int, deliveryMethod: PhoneDeliveryMethod): Unit = + runStep { client.challengePhone(index, deliveryMethod).await() } + + fun challengePush(index: Int): Unit = runStep { client.challengePush(index).await() } + + /** Continuation: the server always answers with a [NextAction.ConfirmRecoveryCode] carrying the rotated code. */ + fun verifyRecoveryCode(code: String): Unit = runStep { client.verifyRecoveryCode(code).await() } + /** Terminal step: on success this yields [Credentials][com.auth0.android.result.Credentials]. */ - fun verifyOtp(code: String, type: OtpType): Unit { - _authorizeState.value = AuthorizeUiState.Loading + fun verifyOtp(code: String, type: OtpType): Unit = runTerminalStep { client.verifyOtp(code, type).await() } + + /** Terminal step: acknowledges the rotated recovery code. */ + fun confirmRecoveryCode(): Unit = runTerminalStep { client.confirmRecoveryCode().await() } + + /** + * Polls for push approval. The SDK performs one poll per call, so the app owns the loop: wait the + * server-advertised `pollInMs` between polls, and give up on a terminal error. The flow is + * sequential, so every other step stops polling first via [stopPolling]. + */ + fun verifyPush(initialPollInMs: Int) { + pollJob?.cancel() + val offered = (_authorizeState.value as? AuthorizeUiState.ActionsAvailable)?.actions.orEmpty() + _authorizeState.value = AuthorizeUiState.Polling(offered.filterNot { it is NextAction.VerifyOob }) + pollJob = viewModelScope.launch { + var delayMs = initialPollInMs.toLong() + while (true) { + delay(delayMs) + try { + _authorizeState.value = AuthorizeUiState.Authenticated( + withContext(NonCancellable) { client.verifyOob().await() } + ) + return@launch + } catch (error: EmbeddedAuthException) { + val pending = error.error as? EmbeddedAuthError.InsufficientAuthorization + val reason = pending?.reason + if (reason != EmbeddedAuthError.InsufficientAuthorization.Reason.AUTHORIZATION_PENDING && + reason != EmbeddedAuthError.InsufficientAuthorization.Reason.SLOW_DOWN + ) { + _authorizeState.value = error.toAuthorizeState() + return@launch + } + delayMs = pending.nextActions.filterIsInstance() + .firstOrNull()?.pollInMs?.toLong() ?: delayMs + _authorizeState.value = + AuthorizeUiState.Polling(pending.nextActions.filterNot { it is NextAction.VerifyOob }) + } + } + } + } + + fun resetAuthorize() { viewModelScope.launch { + stopPolling() + _authorizeState.value = AuthorizeUiState.Idle + } + } + + private var pollJob: Job? = null + + /** + * Stops the push-polling loop between polls. A cancel during the wait takes effect immediately; + * a poll already in flight completes first, so the SDK keeps the session it returned. + */ + private suspend fun stopPolling() { + pollJob?.cancelAndJoin() + pollJob = null + } + + private fun runTerminalStep(step: suspend () -> Credentials) { + viewModelScope.launch { + stopPolling() + if (_authorizeState.value is AuthorizeUiState.Authenticated) return@launch + _authorizeState.value = AuthorizeUiState.Loading _authorizeState.value = try { - AuthorizeUiState.Authenticated(client.verifyOtp(code, type).await()) + AuthorizeUiState.Authenticated(step()) } catch (error: EmbeddedAuthException) { error.toAuthorizeState() } } } - fun resetAuthorize() { - _authorizeState.value = AuthorizeUiState.Idle - } - /** * Runs a non-terminal step. These steps never resolve successfully — the server drives the * flow forward by responding with `403 insufficient_authorization` carrying the next actions, * which the SDK surfaces as an [EmbeddedAuthException]. A plain success here is unexpected. */ private fun runStep(step: suspend () -> Unit) { - _authorizeState.value = AuthorizeUiState.Loading viewModelScope.launch { + stopPolling() + if (_authorizeState.value is AuthorizeUiState.Authenticated) return@launch + _authorizeState.value = AuthorizeUiState.Loading _authorizeState.value = try { step() AuthorizeUiState.Message("Step completed without a continuation.") diff --git a/sample-embedded/src/main/java/com/auth0/sample/embedded/MainActivity.kt b/sample-embedded/src/main/java/com/auth0/sample/embedded/MainActivity.kt index 0eff1ea21..2e2a059a2 100644 --- a/sample-embedded/src/main/java/com/auth0/sample/embedded/MainActivity.kt +++ b/sample-embedded/src/main/java/com/auth0/sample/embedded/MainActivity.kt @@ -4,10 +4,6 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.lifecycle.viewmodel.compose.viewModel import com.auth0.sample.embedded.ui.theme.EmbeddedDiscoveryTheme @@ -24,24 +20,8 @@ public class MainActivity : ComponentActivity() { @Composable private fun EmbeddedApp(viewModel: EmbeddedViewModel = viewModel()) { - var showAuthorize by rememberSaveable { mutableStateOf(false) } - var authorizeConnection by rememberSaveable { mutableStateOf("Username-Password-Authentication") } - - if (showAuthorize) { - AuthorizeScreen( - viewModel = viewModel, - initialConnection = authorizeConnection, - onBack = { - viewModel.resetAuthorize() - showAuthorize = false - }, - ) - } else { - EmbeddedScreen( - onAuthorize = { connection -> - authorizeConnection = connection - showAuthorize = true - }, - ) - } + AuthorizeScreen( + viewModel = viewModel, + initialConnection = "Username-Password-Authentication", + ) } diff --git a/sample-embedded/src/main/res/values/strings.xml b/sample-embedded/src/main/res/values/strings.xml index 34c6cf858..d1c0c0dad 100644 --- a/sample-embedded/src/main/res/values/strings.xml +++ b/sample-embedded/src/main/res/values/strings.xml @@ -29,5 +29,14 @@ Verify code Submit Verify + Recovery code + Send me a code + Send a push notification + Check approval + Use a recovery code + I saved my new code + Approve the push notification on your device + Your new recovery code — save it now + Waiting for you to approve the push notification… Unsupported action: %1$s