From 7771584fa33b750ba34bcdf7159d396665b34657 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 12 Nov 2026 15:50:17 +0530 Subject: [PATCH] feat(embedded) : Add password signup flow to the embedded API --- .../android/embedded/EmbeddedAuthClient.kt | 71 ++++++- .../android/embedded/EmbeddedAuthError.kt | 12 ++ .../embedded/authorize/AdvancingRequest.kt | 11 +- .../authorize/CredentialRequirement.kt | 16 ++ .../authorize/EmbeddedAuthAdapters.kt | 9 + .../embedded/authorize/EmbeddedAuthState.kt | 3 +- .../android/embedded/authorize/NextAction.kt | 14 +- .../embedded/authorize/NextActionMapper.kt | 58 ++++- .../embedded/authorize/PasswordComplexity.kt | 26 +++ .../embedded/authorize/SignupPassword.kt | 17 ++ .../android/embedded/authorize/StepRequest.kt | 13 +- .../embedded/EmbeddedAuthClientTest.kt | 200 +++++++++++++++++- .../authorize/NextActionMapperTest.kt | 100 +++++++++ .../android/util/EmbeddedAuthMockServer.kt | 20 ++ examples/embedded-auth/signup.md | 41 +++- .../auth0/sample/embedded/AuthorizeScreen.kt | 65 +++++- .../sample/embedded/EmbeddedViewModel.kt | 7 +- .../src/main/res/values/strings.xml | 5 + 18 files changed, 646 insertions(+), 42 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/embedded/authorize/CredentialRequirement.kt create mode 100644 auth0/src/main/java/com/auth0/android/embedded/authorize/PasswordComplexity.kt create mode 100644 auth0/src/main/java/com/auth0/android/embedded/authorize/SignupPassword.kt diff --git a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt index e1eda9e72..300f07af2 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt @@ -3,6 +3,7 @@ package com.auth0.android.embedded import com.auth0.android.Auth0 import com.auth0.android.embedded.authorize.AdvancingRequest import com.auth0.android.embedded.authorize.AuthorizeCode +import com.auth0.android.embedded.authorize.CredentialRequirement import com.auth0.android.embedded.authorize.EmbeddedAuthState import com.auth0.android.embedded.authorize.EmbeddedCapability import com.auth0.android.embedded.authorize.FailedRequest @@ -10,6 +11,7 @@ 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.SignupPassword import com.auth0.android.embedded.authorize.StepRequest import com.auth0.android.embedded.authorize.authorizeCodeAdapter import com.auth0.android.embedded.authorize.embeddedAuthErrorAdapter @@ -251,14 +253,30 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { * Creates the new user's account and completes the flow. Call it when a * [NextAction.ConfirmSignup] is offered. * + * Pass a [password] when [NextAction.ConfirmSignup.password] is not `null`; it must be passed when + * [SignupPassword.required] is `true`. An empty [password] counts as none. It is sent as it is, and the + * SDK doesn't check it against [SignupPassword.complexity], which is advisory. + * * On success, it yields the [Credentials]. If the server requires further steps the request * completes through [EmbeddedAuthException] instead, with * [EmbeddedAuthError.InsufficientAuthorization.nextActions] on [EmbeddedAuthException.error]. + * + * The server decides whether the passed password is right or not. A missing + * required password completes with [EmbeddedAuthError.InsufficientAuthorization.Reason.PASSWORD_REQUIRED], + * a password the connection doesn't collect with + * [EmbeddedAuthError.InsufficientAuthorization.Reason.PASSWORD_NOT_ACCEPTED], and a password the + * connection's policy rejects with [EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_PASSWORD]. + * Each keeps the flow open, so call again with the right input. * If the account can't be created, the request completes with the terminal * [EmbeddedAuthError.InvalidRequest]; start a new flow with [authorize]. */ - public fun confirmSignup(): Request = - terminalStep(EmbeddedCapability.CONFIRM_SIGNUP) + @JvmOverloads + public fun confirmSignup(password: String? = null): Request { + val sentPassword = password?.takeIf { it.isNotEmpty() } + return terminalStep(EmbeddedCapability.CONFIRM_SIGNUP) { + if (sentPassword != null) addParameter(PASSWORD_KEY, sentPassword) + } + } private fun continueStep( action: EmbeddedCapability, @@ -338,18 +356,49 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { .toString() } - private fun updateSessionFromFailure(error: EmbeddedAuthException) { - if (error.statusCode in 500..599) return - - when (error.error) { - is EmbeddedAuthError.InsufficientAuthorization -> - error.authSession?.let { transactionState = EmbeddedAuthState(it) } - - is EmbeddedAuthError.Network -> Unit - else -> transactionState = null + private fun updateSessionFromFailure(error: EmbeddedAuthException): EmbeddedAuthException { + if (error.statusCode in 500..599) return error + + return when (val failure = error.error) { + is EmbeddedAuthError.InsufficientAuthorization -> { + val session = error.authSession ?: return error + val announced = failure.nextActions.filterIsInstance().firstOrNull()?.passwordRequirement + val requirement = when { + failure.reason == EmbeddedAuthError.InsufficientAuthorization.Reason.PASSWORD_REQUIRED -> + CredentialRequirement.REQUIRED + + else -> announced ?: transactionState?.passwordRequirement + } + val actions = failure.nextActions.map { action -> + if (action is NextAction.ConfirmSignup) action.withRequired(requirement == CredentialRequirement.REQUIRED) else action + } + transactionState = EmbeddedAuthState(session, requirement) + if (actions == failure.nextActions) error else error.withNextActions(actions, failure.reason) + } + + is EmbeddedAuthError.Network -> error + else -> { + transactionState = null + error + } } } + private fun NextAction.ConfirmSignup.withRequired(required: Boolean): NextAction.ConfirmSignup = + NextAction.ConfirmSignup(password?.let { SignupPassword(required, it.complexity) }) + + private fun EmbeddedAuthException.withNextActions( + nextActions: List, + reason: EmbeddedAuthError.InsufficientAuthorization.Reason + ): EmbeddedAuthException = EmbeddedAuthException( + code, + description, + statusCode, + error = EmbeddedAuthError.InsufficientAuthorization(nextActions, reason), + authSession = authSession, + cause = cause + ) + private companion object { private const val EMBEDDED_PATH = "e" private const val AUTHORIZE_PATH = "authorize" 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 2d6328fe6..1b10365e1 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthError.kt @@ -31,6 +31,15 @@ public sealed interface EmbeddedAuthError { /** The username or the password was invalid; the server does not say which. */ INVALID_IDENTIFIER_OR_PASSWORD, + /** A password is needed to create the account and none was sent. Retry with one. */ + PASSWORD_REQUIRED, + + /** A password was sent but the connection doesn't collect one. Retry without it. */ + PASSWORD_NOT_ACCEPTED, + + /** The connection's password policy rejected the password. Retry with another one. */ + INVALID_PASSWORD, + /** Authorization is pending (e.g., push notification awaiting user approval). Retry after a delay. */ AUTHORIZATION_PENDING, @@ -42,6 +51,9 @@ public sealed interface EmbeddedAuthError { /** Terminal: too many wrong OTP submissions. Restart the flow. */ public data object TooManyWrongOtpAttempts : EmbeddedAuthError + /** Terminal: the limit of verification codes sent to this identifier was reached. Restart the flow. */ + public data object TooManyResendAttempts : EmbeddedAuthError + /** Terminal: the OTP challenge expired before it was verified. Restart the flow. */ public data object ChallengeExpired : EmbeddedAuthError 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 6accf14f7..62b601ee3 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 @@ -17,7 +17,7 @@ import com.auth0.android.result.Credentials internal class AdvancingRequest( private val authorize: Request, private val exchange: (authorizationCode: String) -> Request, - private val onStepFailure: (EmbeddedAuthException) -> Unit, + private val onStepFailure: (EmbeddedAuthException) -> EmbeddedAuthException, private val onFlowComplete: () -> Unit ) : Request { @@ -29,8 +29,7 @@ internal class AdvancingRequest( } override fun onFailure(error: EmbeddedAuthException) { - onStepFailure(error) - callback.onFailure(error) + callback.onFailure(onStepFailure(error)) } }) } @@ -40,8 +39,7 @@ internal class AdvancingRequest( val code = try { authorize.await() } catch (error: EmbeddedAuthException) { - onStepFailure(error) - throw error + throw onStepFailure(error) } onFlowComplete() return exchange(code.authorizationCode).await() @@ -52,8 +50,7 @@ internal class AdvancingRequest( val code = try { authorize.execute() } catch (error: EmbeddedAuthException) { - onStepFailure(error) - throw error + throw onStepFailure(error) } onFlowComplete() return exchange(code.authorizationCode).execute() diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/CredentialRequirement.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/CredentialRequirement.kt new file mode 100644 index 000000000..51e440235 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/CredentialRequirement.kt @@ -0,0 +1,16 @@ +package com.auth0.android.embedded.authorize + +/** + * Whether the connection collects a credential when signing up a new user, as announced on + * [NextAction.Signup]. + */ +public enum class CredentialRequirement { + /** The connection doesn't collect this credential. */ + NONE, + + /** The credential can be set up, or skipped. */ + OPTIONAL, + + /** The account can't be created without this credential. */ + REQUIRED +} 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 b1bd199cc..dba6a75b5 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 @@ -32,6 +32,10 @@ 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 PASSWORD_REQUIRED = "password_required" +private const val PASSWORD_NOT_ACCEPTED = "password_not_accepted" +private const val INVALID_PASSWORD = "invalid_password" +private const val TOO_MANY_RESEND_ATTEMPTS = "too_many_resend_attempts" private const val INVALID_GRANT = "invalid_grant" private const val INVALID_REQUEST = "invalid_request" private const val TOO_MANY_REQUESTS_STATUS = 429 @@ -92,6 +96,8 @@ internal fun embeddedAuthErrorAdapter(): ErrorAdapter { ) code == ACCESS_DENIED && description == TOO_MANY_WRONG_OTP_ATTEMPTS -> EmbeddedAuthError.TooManyWrongOtpAttempts + code == ACCESS_DENIED && description == TOO_MANY_RESEND_ATTEMPTS -> + EmbeddedAuthError.TooManyResendAttempts code == ACCESS_DENIED && description == CHALLENGE_EXPIRED -> EmbeddedAuthError.ChallengeExpired code == ACCESS_DENIED && description == AUTHORIZATION_REJECTED -> @@ -136,6 +142,9 @@ internal fun embeddedAuthErrorAdapter(): ErrorAdapter { EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_CODE INVALID_IDENTIFIER_OR_PASSWORD -> EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_PASSWORD + PASSWORD_REQUIRED -> EmbeddedAuthError.InsufficientAuthorization.Reason.PASSWORD_REQUIRED + PASSWORD_NOT_ACCEPTED -> EmbeddedAuthError.InsufficientAuthorization.Reason.PASSWORD_NOT_ACCEPTED + INVALID_PASSWORD -> EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_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/EmbeddedAuthState.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthState.kt index ee66a0508..36be32e1a 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthState.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthState.kt @@ -1,5 +1,6 @@ package com.auth0.android.embedded.authorize internal data class EmbeddedAuthState( - val authSession: String + val authSession: String, + val passwordRequirement: CredentialRequirement? = null ) 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 435ca9767..70058b29b 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 @@ -91,11 +91,13 @@ public sealed interface NextAction { /** * Continue by signing up a new user. Act on it with [EmbeddedAuthClient.signup]. * - * [identifiers] lists the identifiers the connection collects at signup. + * [identifiers] lists the identifiers the connection collects at signup. [passwordRequirement] says + * whether the connection collects a password, which is set up with [EmbeddedAuthClient.confirmSignup]. */ @ConsistentCopyVisibility public data class Signup internal constructor( - public val identifiers: List + public val identifiers: List, + public val passwordRequirement: CredentialRequirement ) : NextAction { override val capability: EmbeddedCapability = EmbeddedCapability.SIGNUP } @@ -103,8 +105,14 @@ public sealed interface NextAction { /** * Continue by creating the new user's account once the required identifiers are verified. Act on * it with [EmbeddedAuthClient.confirmSignup]. + * + * [password] is `null` when the connection doesn't collect a password. Otherwise the app should ask + * for one, and must when [SignupPassword.required] is `true`. */ - public data object ConfirmSignup : NextAction { + @ConsistentCopyVisibility + public data class ConfirmSignup internal constructor( + public val password: SignupPassword? + ) : NextAction { override val capability: EmbeddedCapability = EmbeddedCapability.CONFIRM_SIGNUP } 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 361e6cca5..bbb99a5a0 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 @@ -16,6 +16,11 @@ private const val IDENTIFIERS_KEY = "identifiers" private const val EMAIL_KEY = "email" private const val PHONE_NUMBER_KEY = "phone_number" private const val REQUIRED_KEY = "required" +private const val AUTHENTICATION_METHODS_KEY = "authentication_methods" +private const val PASSWORD_KEY = "password" +private const val PASSWORD_COMPLEXITY_OPTIONS_KEY = "password_complexity_options" +private const val MIN_LENGTH_KEY = "min_length" +private const val CHARACTER_TYPES_KEY = "character_types" internal fun List>.toNextActions(): List = mapNotNull { it.toNextAction() } @@ -66,9 +71,9 @@ private fun Map.toNextAction(): NextAction? { EmbeddedCapability.SIGNUP -> { val identifiers = (this[IDENTIFIERS_KEY] as? Map<*, *>)?.toSignupIdentifiers() ?: return dropped(raw, IDENTIFIERS_KEY) - NextAction.Signup(identifiers) + NextAction.Signup(identifiers, toPasswordRequirement()) } - EmbeddedCapability.CONFIRM_SIGNUP -> NextAction.ConfirmSignup + EmbeddedCapability.CONFIRM_SIGNUP -> NextAction.ConfirmSignup(toSignupPassword()) EmbeddedCapability.UNKNOWN -> NextAction.Unknown(raw) } } @@ -90,6 +95,55 @@ private fun Map<*, *>.toSignupIdentifiers(): List? { return identifiers.ifEmpty { null } } +/** + * Reads whether the signup action announces a password: `required: true` is [CredentialRequirement.REQUIRED], + * `required: false` is [CredentialRequirement.OPTIONAL], and no password entry is [CredentialRequirement.NONE]. + * A password entry without a boolean `required` is logged and treated as [CredentialRequirement.NONE]. + */ +private fun Map.toPasswordRequirement(): CredentialRequirement { + val password = (this[AUTHENTICATION_METHODS_KEY] as? Map<*, *>)?.get(PASSWORD_KEY) ?: return CredentialRequirement.NONE + return when ((password as? Map<*, *>)?.get(REQUIRED_KEY) as? Boolean) { + true -> CredentialRequirement.REQUIRED + false -> CredentialRequirement.OPTIONAL + null -> { + Log.w(TAG, "Ignoring signup password: required field \"$REQUIRED_KEY\" was missing or invalid.") + CredentialRequirement.NONE + } + } +} + +/** + * Reads the confirm action's `password_complexity_options`, which the server sends exactly when the + * connection collects a password. The result is `null` when they are absent, or when `min_length` is missing + * or not a positive integer. Unknown `character_types` are skipped. [SignupPassword.required] is `false` + * here; the client sets it from the requirement announced on the signup action. + */ +private fun Map.toSignupPassword(): SignupPassword? { + val options = this[PASSWORD_COMPLEXITY_OPTIONS_KEY] as? Map<*, *> ?: return null + val minLength = (options[MIN_LENGTH_KEY] as? Number)?.toWholeInt()?.takeIf { it >= 1 } + if (minLength == null) { + Log.w(TAG, "Ignoring signup password: required field \"$MIN_LENGTH_KEY\" was missing or invalid.") + return null + } + val characterTypes = (options[CHARACTER_TYPES_KEY] as? List<*>) + ?.filterIsInstance() + ?.mapNotNull(::passwordCharacterType) + ?.distinct() + .orEmpty() + return SignupPassword(required = false, complexity = PasswordComplexity(minLength, characterTypes)) +} + +private fun Number.toWholeInt(): Int? = + toDouble().takeIf { it % 1.0 == 0.0 && it in Int.MIN_VALUE.toDouble()..Int.MAX_VALUE.toDouble() }?.toInt() + +private fun passwordCharacterType(value: String): PasswordComplexity.CharacterType? = when (value) { + "lowercase" -> PasswordComplexity.CharacterType.LOWERCASE + "uppercase" -> PasswordComplexity.CharacterType.UPPERCASE + "number" -> PasswordComplexity.CharacterType.NUMBER + "special" -> PasswordComplexity.CharacterType.SPECIAL + else -> null +} + private val SIGNUP_IDENTIFIER_TYPES: List> = listOf( EMAIL_KEY to IdentifierType.EMAIL, PHONE_NUMBER_KEY to IdentifierType.PHONE diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/PasswordComplexity.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/PasswordComplexity.kt new file mode 100644 index 000000000..bb8eacb9c --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/PasswordComplexity.kt @@ -0,0 +1,26 @@ +package com.auth0.android.embedded.authorize + +/** + * Advisory rules for a signup password: the minimum length and the character types that are all required. + * + * The server applies the connection's full password policy, which can include rules not listed here, + * such as requiring only some of the character types. A password that meets these rules can still be + * rejected with [com.auth0.android.embedded.EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_PASSWORD]. + */ +@ConsistentCopyVisibility +public data class PasswordComplexity internal constructor( + /** The minimum length, in UTF-16 code units. */ + public val minLength: Int, + + /** The character types the password must contain, all of them. Empty when none is required. */ + public val characterTypes: List +) { + + /** A kind of character a password can be required to contain. */ + public enum class CharacterType { + LOWERCASE, + UPPERCASE, + NUMBER, + SPECIAL + } +} diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/SignupPassword.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/SignupPassword.kt new file mode 100644 index 000000000..7069c6d08 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/SignupPassword.kt @@ -0,0 +1,17 @@ +package com.auth0.android.embedded.authorize + +import com.auth0.android.embedded.EmbeddedAuthClient + +/** + * The password a connection collects when signing up a new user, as offered on [NextAction.ConfirmSignup]. + * + * Submit it with [EmbeddedAuthClient.confirmSignup]. + */ +@ConsistentCopyVisibility +public data class SignupPassword internal constructor( + /** Whether the account can't be created without a password. */ + public val required: Boolean, + + /** The advisory rules for the password. */ + public val complexity: PasswordComplexity +) diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/StepRequest.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/StepRequest.kt index 0666e7b34..da0e53d89 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/authorize/StepRequest.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/StepRequest.kt @@ -9,11 +9,11 @@ import com.auth0.android.request.RequestValidator /** * Runs one non-terminal step of the embedded flow as a single [Request]. These steps never yield * credentials, so on the continuation failure the session is rotated (or cleared) via [onStepFailure] - * before the error is propagated to the caller. + * before the error it returns is propagated to the caller. */ internal class StepRequest( private val request: Request, - private val onStepFailure: (EmbeddedAuthException) -> Unit + private val onStepFailure: (EmbeddedAuthException) -> EmbeddedAuthException ) : Request { override fun start(callback: Callback) { @@ -21,8 +21,7 @@ internal class StepRequest( override fun onSuccess(result: Void?) = callback.onSuccess(result) override fun onFailure(error: EmbeddedAuthException) { - onStepFailure(error) - callback.onFailure(error) + callback.onFailure(onStepFailure(error)) } }) } @@ -31,16 +30,14 @@ internal class StepRequest( override suspend fun await(): Void? = try { request.await() } catch (error: EmbeddedAuthException) { - onStepFailure(error) - throw error + throw onStepFailure(error) } @Throws(Auth0Exception::class) override fun execute(): Void? = try { request.execute() } catch (error: EmbeddedAuthException) { - onStepFailure(error) - throw error + throw onStepFailure(error) } override fun addParameters(parameters: Map): Request { diff --git a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt index 4cd8a2839..665a02a9d 100644 --- a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt @@ -2,11 +2,13 @@ package com.auth0.android.embedded import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception +import com.auth0.android.embedded.authorize.CredentialRequirement 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 import com.auth0.android.embedded.authorize.OtpType +import com.auth0.android.embedded.authorize.PasswordComplexity import com.auth0.android.embedded.authorize.PhoneDeliveryMethod import com.auth0.android.embedded.authorize.SignupIdentifier import com.auth0.android.embedded.authorize.embeddedAuthErrorAdapter @@ -1533,7 +1535,7 @@ public class EmbeddedAuthClientTest { val error = assertEmbeddedErrorSuspending { client.verifyOtp("123456", OtpType.OOB).await() } - assertThat(error.nextActions, `is`(listOf(NextAction.ConfirmSignup))) + assertThat(error.nextActions, `is`(listOf(NextAction.ConfirmSignup(null)))) assertThat(error.nextActions[0].capability, `is`(EmbeddedCapability.CONFIRM_SIGNUP)) } @@ -1638,6 +1640,187 @@ public class EmbeddedAuthClientTest { assertThat(followOn.error, `is`(EmbeddedAuthError.NoActiveSession)) } + // ── signup password ────────────────────────────────────────────────────────── + + @Test + public fun `authorize reports a required signup password`(): Unit = runTest { + val signup = firstSignupAction(EmbeddedAuthMockServer.NEXT_SIGNUP_EMAIL_PASSWORD_REQUIRED) + + assertThat(signup.passwordRequirement, `is`(CredentialRequirement.REQUIRED)) + } + + @Test + public fun `authorize reports an optional signup password`(): Unit = runTest { + val signup = firstSignupAction(EmbeddedAuthMockServer.NEXT_SIGNUP_EMAIL_PASSWORD_OPTIONAL) + + assertThat(signup.passwordRequirement, `is`(CredentialRequirement.OPTIONAL)) + } + + @Test + public fun `authorize reports no signup password when the connection collects none`(): Unit = runTest { + val signup = firstSignupAction(EmbeddedAuthMockServer.NEXT_SIGNUP_EMAIL_REQUIRED) + + assertThat(signup.passwordRequirement, `is`(CredentialRequirement.NONE)) + } + + @Test + public fun `confirm offer carries the password rules and the requirement announced by the signup action`(): Unit = runTest { + establishSignupSession(EmbeddedAuthMockServer.NEXT_SIGNUP_EMAIL_PASSWORD_REQUIRED) + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_CONFIRM_SIGNUP_PASSWORD) + + val error = assertEmbeddedErrorSuspending { client.verifyOtp("123456", OtpType.OOB).await() } + + val password = (error.nextActions[0] as NextAction.ConfirmSignup).password!! + assertThat(password.required, `is`(true)) + assertThat(password.complexity.minLength, `is`(8)) + assertThat( + password.complexity.characterTypes, + `is`(listOf(PasswordComplexity.CharacterType.LOWERCASE, PasswordComplexity.CharacterType.NUMBER)) + ) + } + + @Test + public fun `confirm offer reports an optional password as not required`(): Unit = runTest { + establishSignupSession(EmbeddedAuthMockServer.NEXT_SIGNUP_EMAIL_PASSWORD_OPTIONAL) + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_CONFIRM_SIGNUP_PASSWORD) + + val error = assertEmbeddedErrorSuspending { client.verifyOtp("123456", OtpType.OOB).await() } + + assertThat((error.nextActions[0] as NextAction.ConfirmSignup).password!!.required, `is`(false)) + } + + @Test + public fun `confirm offer keeps the requirement through steps that don't repeat the signup action`(): Unit = runTest { + establishSignupSession(EmbeddedAuthMockServer.NEXT_SIGNUP_EMAIL_PASSWORD_REQUIRED) + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_IDENTIFY_EMAIL) + assertEmbeddedErrorSuspending { client.signup().await() } + mockAPI.takeRequest() + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_CONFIRM_SIGNUP_PASSWORD) + + val error = assertEmbeddedErrorSuspending { client.verifyOtp("123456", OtpType.OOB).await() } + + assertThat((error.nextActions[0] as NextAction.ConfirmSignup).password!!.required, `is`(true)) + } + + @Test + public fun `confirm offer is not required when no signup action announced a password`(): Unit = runTest { + establishSession() + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_CONFIRM_SIGNUP_PASSWORD) + + val error = assertEmbeddedErrorSuspending { client.verifyOtp("123456", OtpType.OOB).await() } + + assertThat((error.nextActions[0] as NextAction.ConfirmSignup).password!!.required, `is`(false)) + } + + @Test + public fun `a new authorize forgets the requirement of the previous flow`(): Unit = runTest { + establishSignupSession(EmbeddedAuthMockServer.NEXT_SIGNUP_EMAIL_PASSWORD_REQUIRED) + establishSignupSession(EmbeddedAuthMockServer.NEXT_IDENTIFY_EMAIL) + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_CONFIRM_SIGNUP_PASSWORD) + + val error = assertEmbeddedErrorSuspending { client.verifyOtp("123456", OtpType.OOB).await() } + + assertThat((error.nextActions[0] as NextAction.ConfirmSignup).password!!.required, `is`(false)) + } + + @Test + public fun `confirmSignup await sends the password unchanged and completes the flow`(): Unit = runTest { + establishSession() + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokens() + + val credentials = client.confirmSignup(" Pa55 word ").await() + + assertThat(credentials.accessToken, `is`(EmbeddedAuthMockServer.ACCESS_TOKEN)) + val body = bodyOf(mockAPI.takeRequest()) + assertThat(body.keys().asSequence().toSet(), `is`(setOf("auth_session", "action", "client_id", "password"))) + assertThat(body.getString("password"), `is`(" Pa55 word ")) + mockAPI.takeRequest() + + val followOn = assertEmbeddedErrorSuspending { client.confirmSignup(" Pa55 word ").await() } + assertThat(followOn.error, `is`(EmbeddedAuthError.NoActiveSession)) + } + + @Test + public fun `confirmSignup await doesn't send an empty password`(): Unit = runTest { + establishSession() + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokens() + + client.confirmSignup("").await() + + assertThat(bodyOf(mockAPI.takeRequest()).has("password"), `is`(false)) + } + + @Test + public fun `confirmSignup await leaves a password the connection doesn't collect to the server`(): Unit = runTest { + establishSignupSession(EmbeddedAuthMockServer.NEXT_SIGNUP_EMAIL_REQUIRED) + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_CONFIRM_SIGNUP) + assertEmbeddedErrorSuspending { client.verifyOtp("123456", OtpType.OOB).await() } + mockAPI.takeRequest() + mockAPI.willReturnSignupPasswordError("password_not_accepted", EmbeddedAuthMockServer.NEXT_CONFIRM_SIGNUP) + + val error = assertEmbeddedErrorSuspending { client.confirmSignup("Pa55word1").await() } + + assertThat(bodyOf(mockAPI.takeRequest()).getString("password"), `is`("Pa55word1")) + val failure = error.error as EmbeddedAuthError.InsufficientAuthorization + assertThat(failure.reason, `is`(EmbeddedAuthError.InsufficientAuthorization.Reason.PASSWORD_NOT_ACCEPTED)) + assertThat(failure.nextActions, `is`(listOf(NextAction.ConfirmSignup(null)))) + } + + @Test + public fun `password_required from the server makes the re-offered confirm required`(): Unit = runTest { + establishSignupSession(EmbeddedAuthMockServer.NEXT_SIGNUP_EMAIL_PASSWORD_OPTIONAL) + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_CONFIRM_SIGNUP_PASSWORD) + assertEmbeddedErrorSuspending { client.verifyOtp("123456", OtpType.OOB).await() } + mockAPI.takeRequest() + mockAPI.willReturnSignupPasswordError("password_required") + + val error = assertEmbeddedErrorSuspending { client.confirmSignup("Pa55word1").await() } + mockAPI.takeRequest() + + val failure = error.error as EmbeddedAuthError.InsufficientAuthorization + assertThat(failure.reason, `is`(EmbeddedAuthError.InsufficientAuthorization.Reason.PASSWORD_REQUIRED)) + assertThat((failure.nextActions[0] as NextAction.ConfirmSignup).password!!.required, `is`(true)) + } + + @Test + public fun `invalid_password re-offers confirm and rotates the session so the user can try again`(): Unit = runTest { + establishSignupSession(EmbeddedAuthMockServer.NEXT_SIGNUP_EMAIL_PASSWORD_REQUIRED) + mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_CONFIRM_SIGNUP_PASSWORD) + assertEmbeddedErrorSuspending { client.verifyOtp("123456", OtpType.OOB).await() } + mockAPI.takeRequest() + mockAPI.willReturnSignupPasswordError("invalid_password") + + val error = assertEmbeddedErrorSuspending { client.confirmSignup("weak").await() } + mockAPI.takeRequest() + + val failure = error.error as EmbeddedAuthError.InsufficientAuthorization + assertThat(failure.reason, `is`(EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_PASSWORD)) + assertThat((failure.nextActions[0] as NextAction.ConfirmSignup).password!!.required, `is`(true)) + + mockAPI.willReturnAuthorizeCode() + mockAPI.willReturnTokens() + client.confirmSignup("Str0ngPassw0rd").await() + + val body = bodyOf(mockAPI.takeRequest()) + assertThat(body.getString("auth_session"), `is`(EmbeddedAuthMockServer.ROTATED_AUTH_SESSION)) + assertThat(body.getString("password"), `is`("Str0ngPassw0rd")) + } + + @Test + public fun `challengeEmail await surfaces TooManyResendAttempts and clears the session`(): Unit = runTest { + establishSession() + mockAPI.willReturnTooManyResendAttempts() + + val error = assertEmbeddedErrorSuspending { client.challengeEmail().await() } + mockAPI.takeRequest() + + assertThat(error.error, `is`(EmbeddedAuthError.TooManyResendAttempts)) + val followOn = assertEmbeddedErrorSuspending { client.challengeEmail().await() } + assertThat(followOn.error, `is`(EmbeddedAuthError.NoActiveSession)) + } + // ── Session preservation on MFA errors ─────────────────────────────────────── @Test @@ -1731,6 +1914,21 @@ public class EmbeddedAuthClientTest { // ── Helpers ────────────────────────────────────────────────────────────────── + private suspend fun firstSignupAction(signupMenu: String): NextAction.Signup { + mockAPI.willReturnContinuationWith(signupMenu) + val error = assertEmbeddedErrorSuspending { + client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).await() + } + return error.nextActions[0] as NextAction.Signup + } + + /** Calls authorize with [firstMenu] as the first set of actions. */ + private fun establishSignupSession(firstMenu: String) { + mockAPI.willReturnContinuationWith(firstMenu) + try { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() } catch (_: EmbeddedAuthException) {} + mockAPI.takeRequest() + } + /** Enqueues a continuation and calls authorize to populate [transactionState]. */ private fun establishSession() { mockAPI.willReturnInsufficientAuthorization() 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 656448c82..c72f314a6 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 @@ -4,6 +4,7 @@ import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.`is` import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.instanceOf +import org.hamcrest.Matchers.nullValue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -179,4 +180,103 @@ public class NextActionMapperTest { action as NextAction.ConfirmRecoveryCode assertThat(action.newCode, `is`("NEW-CODE-123")) } + + @Test + public fun testSignupPasswordRequirementMapped() { + val identifiers = mapOf("email" to mapOf("required" to true)) + val cases: List?, CredentialRequirement>> = listOf( + mapOf("password" to mapOf("required" to true)) to CredentialRequirement.REQUIRED, + mapOf("password" to mapOf("required" to false)) to CredentialRequirement.OPTIONAL, + mapOf("password" to mapOf()) to CredentialRequirement.NONE, + mapOf("password" to mapOf("required" to "yes")) to CredentialRequirement.NONE, + mapOf() to CredentialRequirement.NONE, + null to CredentialRequirement.NONE + ) + for ((methods, expected) in cases) { + val raw = buildMap { + put(ACTION_KEY, "action:signup:v1") + put("identifiers", identifiers) + if (methods != null) put("authentication_methods", methods) + } + val action = listOf(raw).toNextActions().first() as NextAction.Signup + assertThat("authentication_methods=$methods", action.passwordRequirement, `is`(expected)) + } + } + + @Test + public fun testConfirmSignupWithoutPasswordOptionsHasNoPassword() { + val action = listOf(mapOf(ACTION_KEY to "action:signup:confirm:v1")).toNextActions().first() + + assertThat(action, `is`(NextAction.ConfirmSignup(null) as NextAction)) + } + + @Test + public fun testConfirmSignupPasswordComplexityMapped() { + val raw = mapOf( + ACTION_KEY to "action:signup:confirm:v1", + "password_complexity_options" to mapOf( + "min_length" to 8L, + "character_types" to listOf("lowercase", "uppercase", "number", "special") + ) + ) + val action = listOf(raw).toNextActions().first() as NextAction.ConfirmSignup + + val password = action.password!! + assertThat(password.required, `is`(false)) + assertThat(password.complexity.minLength, `is`(8)) + assertThat( + password.complexity.characterTypes, + `is`( + listOf( + PasswordComplexity.CharacterType.LOWERCASE, + PasswordComplexity.CharacterType.UPPERCASE, + PasswordComplexity.CharacterType.NUMBER, + PasswordComplexity.CharacterType.SPECIAL + ) + ) + ) + } + + @Test + public fun testConfirmSignupAcceptsAWholeMinLengthOfAnyNumberType() { + for (minLength in listOf(8, 8L, 8.0)) { + val raw = mapOf( + ACTION_KEY to "action:signup:confirm:v1", + "password_complexity_options" to mapOf("min_length" to minLength) + ) + val action = listOf(raw).toNextActions().first() as NextAction.ConfirmSignup + + assertThat(action.password!!.complexity.minLength, `is`(8)) + assertThat(action.password!!.complexity.characterTypes, `is`(emptyList())) + } + } + + @Test + public fun testConfirmSignupSkipsUnknownAndRepeatedCharacterTypes() { + val raw = mapOf( + ACTION_KEY to "action:signup:confirm:v1", + "password_complexity_options" to mapOf( + "min_length" to 8L, + "character_types" to listOf("number", "emoji", "number", 3, "special") + ) + ) + val action = listOf(raw).toNextActions().first() as NextAction.ConfirmSignup + + assertThat( + action.password!!.complexity.characterTypes, + `is`(listOf(PasswordComplexity.CharacterType.NUMBER, PasswordComplexity.CharacterType.SPECIAL)) + ) + } + + @Test + public fun testConfirmSignupWithoutAValidMinLengthHasNoPassword() { + val invalid = listOf(null, 0L, -1L, 8.5, "8", 3_000_000_000L) + for (minLength in invalid) { + val options = if (minLength == null) mapOf("character_types" to listOf("number")) else mapOf("min_length" to minLength) + val raw = mapOf(ACTION_KEY to "action:signup:confirm:v1", "password_complexity_options" to options) + val action = listOf(raw).toNextActions().first() as NextAction.ConfirmSignup + + assertThat("min_length=$minLength", action.password, `is`(nullValue())) + } + } } 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 a93376706..7178dcec5 100644 --- a/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt @@ -84,6 +84,22 @@ $actionsJson return this } + /** The recoverable `403` for a signup password problem: rotated session and the confirm action again. */ + fun willReturnSignupPasswordError(description: String, confirm: String = NEXT_CONFIRM_SIGNUP_PASSWORD): EmbeddedAuthMockServer { + server.enqueue( + responseWithJSON( + """{ "error": "insufficient_authorization", "error_description": "$description", "auth_session": "$ROTATED_AUTH_SESSION", "next": [ $confirm ] }""", + 403 + ) + ) + return this + } + + fun willReturnTooManyResendAttempts(): EmbeddedAuthMockServer { + server.enqueue(responseWithJSON("""{ "error": "access_denied", "error_description": "too_many_resend_attempts" }""", 403)) + return this + } + fun willReturnTooManyWrongOtpAttempts(): EmbeddedAuthMockServer { server.enqueue(responseWithJSON("""{ "error": "access_denied", "error_description": "too_many_wrong_otp_attempts" }""", 403)) return this @@ -206,6 +222,10 @@ $actionsJson const val NEXT_SIGNUP_NO_REQUIRED = """{ "action": "action:signup:v1", "identifiers": { "email": {} } }""" const val NEXT_SIGNUP_PHONE_NO_REQUIRED = """{ "action": "action:signup:v1", "identifiers": { "email": { "required": true }, "phone_number": {} } }""" const val NEXT_SIGNUP_EMAIL_NON_BOOLEAN_REQUIRED = """{ "action": "action:signup:v1", "identifiers": { "email": { "required": "true" }, "phone_number": { "required": false } } }""" + const val NEXT_SIGNUP_EMAIL_PASSWORD_REQUIRED = """{ "action": "action:signup:v1", "identifiers": { "email": { "required": true } }, "authentication_methods": { "password": { "required": true } } }""" + const val NEXT_SIGNUP_EMAIL_PASSWORD_OPTIONAL = """{ "action": "action:signup:v1", "identifiers": { "email": { "required": true } }, "authentication_methods": { "password": { "required": false } } }""" + const val NEXT_SIGNUP_EMAIL_PASSWORD_NO_REQUIRED = """{ "action": "action:signup:v1", "identifiers": { "email": { "required": true } }, "authentication_methods": { "password": {} } }""" + const val NEXT_CONFIRM_SIGNUP_PASSWORD = """{ "action": "action:signup:confirm:v1", "password_complexity_options": { "min_length": 8, "character_types": ["lowercase", "number"] } }""" const val NEXT_CONFIRM_SIGNUP = """{ "action": "action:signup:confirm:v1" }""" const val NEXT_UNKNOWN = """{ "action": "action:future:unknown:v1" }""" const val IDENTIFIER = "jane@example.com" diff --git a/examples/embedded-auth/signup.md b/examples/embedded-auth/signup.md index 8a90a6e00..d8aae1306 100644 --- a/examples/embedded-auth/signup.md +++ b/examples/embedded-auth/signup.md @@ -15,6 +15,7 @@ a `capabilities` set to `authorize` that leaves them out. val embedded = EmbeddedAuthClient(Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN")) // 1. Start the flow. When the connection allows sign-ups, the next actions include NextAction.Signup. +// signup.passwordRequirement says whether the connection collects a password (NONE, OPTIONAL or REQUIRED). try { embedded.authorize("Username-Password-Authentication").await() } catch (e: EmbeddedAuthException) { @@ -86,6 +87,42 @@ For phone, call `identify("+15551234567", IdentifierType.PHONE)`, then `challengePhone(index, deliveryMethod)` with the values from `NextAction.ChallengePhone`. The other steps are the same. +## Signup with a password + +When the connection collects a password, the account is created with it in the last step. The server offers +`NextAction.ConfirmSignup` with its `password` set: + +- `password == null`: the connection doesn't collect a password. Don't ask for one. +- `password != null`: show a password field. When `password.required` is `true` the user must enter one. + `password.complexity` has the advisory rules to show: `minLength` and the `characterTypes` that are all required. + +`NextAction.Signup.passwordRequirement` (`NONE`, `OPTIONAL` or `REQUIRED`) announces this earlier, so the first +screen can tell the user a password is coming. The SDK keeps it for the flow, so you don't have to carry it to +the confirm step. + +```kotlin +try { + val credentials = embedded.confirmSignup(password).await() +} catch (e: EmbeddedAuthException) { + val error = e.error as? EmbeddedAuthError.InsufficientAuthorization ?: throw e + when (error.reason) { + EmbeddedAuthError.InsufficientAuthorization.Reason.PASSWORD_REQUIRED -> { /* ask for a password and call confirmSignup(password) again */ } + EmbeddedAuthError.InsufficientAuthorization.Reason.PASSWORD_NOT_ACCEPTED -> { /* the connection has no password: call confirmSignup() */ } + EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_PASSWORD -> { /* the password policy rejected it: ask for another one */ } + else -> { /* other error cases */ } + } +} +``` + +- An empty password counts as none. +- The SDK sends the password as it is and the server decides. A missing required password completes with + `PASSWORD_REQUIRED`, and a password sent to a connection that doesn't collect one with `PASSWORD_NOT_ACCEPTED`. + Each keeps the flow open, so call `confirmSignup` again. To avoid the round trip, check `ConfirmSignup.password` + in your UI: `null` means no password field, and `required` means the field is mandatory. +- The SDK doesn't check the password against `complexity`. The server applies the connection's full password policy, + which can include rules the complexity doesn't list, so a password that meets it can still be rejected with + `INVALID_PASSWORD`. A rejected password keeps the flow open: `nextActions` offer `ConfirmSignup` again. +
Using callbacks @@ -96,7 +133,7 @@ embedded.signup() override fun onFailure(error: EmbeddedAuthException) { /* continuation → identify */ } }) -embedded.confirmSignup() +embedded.confirmSignup(password) .start(object : Callback { override fun onSuccess(result: Credentials) { /* account created and signed in */ } override fun onFailure(error: EmbeddedAuthException) { /* branch on error.error as in the coroutine sample */ } @@ -119,7 +156,7 @@ embedded.signup() } }); -embedded.confirmSignup() +embedded.confirmSignup(password) // or embedded.confirmSignup() without a password .start(new Callback() { @Override public void onSuccess(Credentials credentials) { // credentials.getAccessToken() 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 6f1bf97e0..f6018c68f 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 @@ -47,7 +47,9 @@ 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.PasswordComplexity import com.auth0.android.embedded.authorize.PhoneDeliveryMethod +import com.auth0.android.embedded.authorize.SignupPassword import com.auth0.android.embedded.authorize.SignupIdentifier import com.auth0.android.result.Credentials @@ -246,11 +248,9 @@ private fun ActionsSection( onClick = { viewModel.signup() }, ) - NextAction.ConfirmSignup -> ActionCard( - titleRes = R.string.label_confirm_signup, - detail = null, - buttonRes = R.string.action_confirm_signup, - onClick = { viewModel.confirmSignup() }, + is NextAction.ConfirmSignup -> ConfirmSignupCard( + password = action.password, + onConfirm = { viewModel.confirmSignup(it) }, ) is NextAction.Unknown -> InfoCard( @@ -407,6 +407,57 @@ private fun VerifyPasswordCard(onVerify: (String) -> Unit) { } } +@Composable +private fun ConfirmSignupCard(password: SignupPassword?, onConfirm: (String?) -> Unit) { + var value by remember { mutableStateOf("") } + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + stringResource(R.string.label_confirm_signup), + style = MaterialTheme.typography.titleSmall, + ) + if (password != null) { + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = value, + onValueChange = { value = it }, + label = { + Text( + stringResource( + if (password.required) R.string.hint_new_password else R.string.hint_new_password_optional + ) + ) + }, + supportingText = { Text(passwordRules(password.complexity)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + ) + } + Spacer(Modifier.height(12.dp)) + Button( + onClick = { onConfirm(value.takeIf { password != null }) }, + enabled = password == null || !password.required || value.isNotEmpty(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.action_confirm_signup)) + } + } + } +} + +private fun passwordRules(complexity: PasswordComplexity): String = buildString { + append("At least ${complexity.minLength} characters") + if (complexity.characterTypes.isNotEmpty()) { + append(", with ") + append(complexity.characterTypes.joinToString(", ") { it.name.lowercase() }) + } +} + @Composable private fun VerifyRecoveryCodeCard(onVerify: (String) -> Unit) { var code by remember { mutableStateOf("") } @@ -557,6 +608,7 @@ private fun explain(error: EmbeddedAuthError): String = when (error) { 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.TooManyResendAttempts -> "Too many codes were sent to this identifier — start over later." EmbeddedAuthError.TooManyAttempts, EmbeddedAuthError.TooManyLogins, EmbeddedAuthError.IpBlocked -> "Rate limited — try again later." @@ -567,6 +619,9 @@ private fun explain(error: EmbeddedAuthError): String = when (error) { 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.PASSWORD_REQUIRED -> R.string.error_password_required + EmbeddedAuthError.InsufficientAuthorization.Reason.PASSWORD_NOT_ACCEPTED -> R.string.error_password_not_accepted + EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_PASSWORD -> R.string.error_invalid_password 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/EmbeddedViewModel.kt b/sample-embedded/src/main/java/com/auth0/sample/embedded/EmbeddedViewModel.kt index e3a5c4f33..efda00c1e 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 @@ -81,8 +81,11 @@ class EmbeddedViewModel(application: Application) : AndroidViewModel(application /** Continuation: the server answers with the identify step for the new user's identifier. */ fun signup(): Unit = runStep { client.signup().await() } - /** Terminal step: creates the account and yields credentials, or continues if MFA is required. */ - fun confirmSignup(): Unit = runTerminalStep { client.confirmSignup().await() } + /** + * Terminal step: creates the account and yields credentials, or continues if MFA is required or the + * password is rejected. Pass the password only when the confirm action offers one. + */ + fun confirmSignup(password: String? = null): Unit = runTerminalStep { client.confirmSignup(password).await() } /** * Polls for push approval. The SDK performs one poll per call, so the app owns the loop: wait the diff --git a/sample-embedded/src/main/res/values/strings.xml b/sample-embedded/src/main/res/values/strings.xml index 05c2738f8..779c677f9 100644 --- a/sample-embedded/src/main/res/values/strings.xml +++ b/sample-embedded/src/main/res/values/strings.xml @@ -47,6 +47,11 @@ Waiting for you to approve the push notification… New here? Create an account Sign up + Choose a password + Choose a password (optional) + Choose a password to create your account. + This account doesn\'t use a password. + That password isn\'t allowed. Choose another one. Your details are verified Create account Unsupported action: %1$s