Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Each topic lives in its own file under [`examples/`](examples).
## Embedded Authentication (Beta)

- [Email OTP — first-factor & MFA second-factor](examples/embedded-auth/authorize.md)
- [Username & Password — first-factor](examples/embedded-auth/password-authorize.md)
- [Phone OTP (SMS/Voice) — first-factor & MFA second-factor](examples/embedded-auth/phone-authorize.md)
- [Push Notification Polling (OOB)](examples/embedded-auth/push-oob-polling.md)
- [Recovery Code](examples/embedded-auth/recovery-code.md)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ public class EmbeddedAuthClient(private val auth0: Auth0) {

IdentifierType.PHONE ->
continueStep(EmbeddedCapability.IDENTIFY_PHONE) { addParameter(PHONE_KEY, identifier) }

IdentifierType.USERNAME ->
continueStep(EmbeddedCapability.IDENTIFY_USERNAME) { addParameter(USERNAME_KEY, identifier) }
}

/**
Expand Down Expand Up @@ -134,6 +137,29 @@ public class EmbeddedAuthClient(private val auth0: Auth0) {
return advancing(request)
}

/**
* Verifies the user's [password]. This is the terminal step of a username/password flow.
*
* On success, it yields the [Credentials]. If the server requires further steps (for example
* a second factor) or rejects the password, the request completes through
* [EmbeddedAuthException] instead, with
* [EmbeddedAuthError.InsufficientAuthorization.nextActions] on [EmbeddedAuthException.error]; a
* wrong password reports [EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_PASSWORD].
*/
public fun verifyPassword(password: String): Request<Credentials, EmbeddedAuthException> {
val session = transactionState?.authSession ?: return noActiveSession()
val request = factory.post(authorizeUrl, authorizeCodeAdapter(gson))
.addParameters(
mapOf(
AUTH_SESSION_KEY to session,
ACTION_KEY to EmbeddedCapability.VERIFY_PASSWORD.value,
CLIENT_ID_KEY to clientId
)
)
.addParameter(PASSWORD_KEY, password)
return advancing(request)
}

/**
* Continues the flow by requesting a phone challenge for the authenticator at [index].
*
Expand Down Expand Up @@ -330,6 +356,8 @@ public class EmbeddedAuthClient(private val auth0: Auth0) {
private const val ACTION_KEY = "action"
private const val EMAIL_KEY = "email"
private const val PHONE_KEY = "phone"
private const val USERNAME_KEY = "username"
private const val PASSWORD_KEY = "password"
private const val DELIVERY_METHOD_KEY = "delivery_method"
private const val OTP_KEY = "otp"
private const val INDEX_KEY = "index"
Expand All @@ -344,10 +372,12 @@ public class EmbeddedAuthClient(private val auth0: Auth0) {
private val DEFAULT_CAPABILITIES: Set<EmbeddedCapability> = setOf(
EmbeddedCapability.IDENTIFY_EMAIL,
EmbeddedCapability.IDENTIFY_PHONE,
EmbeddedCapability.IDENTIFY_USERNAME,
EmbeddedCapability.CHALLENGE_EMAIL,
EmbeddedCapability.CHALLENGE_PHONE,
EmbeddedCapability.CHALLENGE_PUSH,
EmbeddedCapability.VERIFY_OTP,
EmbeddedCapability.VERIFY_PASSWORD,
EmbeddedCapability.VERIFY_OOB,
EmbeddedCapability.VERIFY_RECOVERY_CODE,
EmbeddedCapability.CONFIRM_RECOVERY_CODE
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ public sealed interface EmbeddedAuthError {
/** The identifier or the code was invalid; the server does not say which. */
INVALID_IDENTIFIER_OR_CODE,

/** The username or the password was invalid; the server does not say which. */
INVALID_IDENTIFIER_OR_PASSWORD,

/** Authorization is pending (e.g., push notification awaiting user approval). Retry after a delay. */
AUTHORIZATION_PENDING,

Expand Down Expand Up @@ -78,6 +81,15 @@ public sealed interface EmbeddedAuthError {
/** Terminal: additional consent is required before the flow can proceed. */
public data object ConsentRequired : EmbeddedAuthError

/**
* Terminal: breached password detection blocked the login because the password appears in a
* data breach. The user must reset their password before starting a new flow.
*/
public data object PasswordLeaked : EmbeddedAuthError

/** Terminal: no further action is available to continue authorization. Restart the flow. */
public data object NoNextSteps : EmbeddedAuthError

/** The request never reached the server (`cause` is a network error). Retry the step. */
public data object Network : EmbeddedAuthError

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,15 @@ private const val TOO_MANY_LOGINS = "too_many_logins"
private const val IP_BLOCKED = "ip_blocked"
private const val INVALID_CODE = "invalid_code"
private const val INVALID_IDENTIFIER_OR_CODE = "invalid_identifier_or_code"
private const val INVALID_IDENTIFIER_OR_PASSWORD = "invalid_identifier_or_password"
private const val INVALID_GRANT = "invalid_grant"
private const val INVALID_REQUEST = "invalid_request"
private const val TOO_MANY_REQUESTS_STATUS = 429
private const val AUTHORIZATION_REJECTED = "authorization_rejected"
private const val NO_ELIGIBLE_FACTORS = "no_eligible_factors"
private const val CONSENT_REQUIRED = "consent_required"
private const val PASSWORD_LEAKED = "password_leaked"
private const val NO_NEXT_STEPS = "no_next_steps"
private const val AUTHORIZATION_PENDING = "authorization_pending"
private const val SLOW_DOWN = "slow_down"

Expand Down Expand Up @@ -97,6 +100,10 @@ internal fun embeddedAuthErrorAdapter(): ErrorAdapter<EmbeddedAuthException> {
EmbeddedAuthError.NoEligibleFactors
code == ACCESS_DENIED && description == CONSENT_REQUIRED ->
EmbeddedAuthError.ConsentRequired
code == ACCESS_DENIED && description == PASSWORD_LEAKED ->
EmbeddedAuthError.PasswordLeaked
code == ACCESS_DENIED && description == NO_NEXT_STEPS ->
EmbeddedAuthError.NoNextSteps
code == ACCESS_DENIED ->
EmbeddedAuthError.AccessDenied
statusCode == TOO_MANY_REQUESTS_STATUS && code == TOO_MANY_REQUESTS && description == TOO_MANY_ATTEMPTS ->
Expand Down Expand Up @@ -127,6 +134,8 @@ internal fun embeddedAuthErrorAdapter(): ErrorAdapter<EmbeddedAuthException> {
INVALID_CODE -> EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_CODE
INVALID_IDENTIFIER_OR_CODE ->
EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_CODE
INVALID_IDENTIFIER_OR_PASSWORD ->
EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_PASSWORD
AUTHORIZATION_PENDING ->
EmbeddedAuthError.InsufficientAuthorization.Reason.AUTHORIZATION_PENDING
SLOW_DOWN -> EmbeddedAuthError.InsufficientAuthorization.Reason.SLOW_DOWN
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ package com.auth0.android.embedded.authorize
public enum class EmbeddedCapability(public val value: String) {
IDENTIFY_EMAIL("action:identify:email:v1"),
IDENTIFY_PHONE("action:identify:phone:v1"),
IDENTIFY_USERNAME("action:identify:username:v1"),
CHALLENGE_EMAIL("action:challenge:email:v1"),
CHALLENGE_PHONE("action:challenge:phone:v1"),
CHALLENGE_PUSH("action:challenge:push:v1"),
VERIFY_OTP("action:verify:otp:v1"),
VERIFY_PASSWORD("action:verify:password:v1"),
VERIFY_OOB("action:verify:oob:v1"),
VERIFY_RECOVERY_CODE("action:verify:recovery-code:v1"),
CONFIRM_RECOVERY_CODE("action:confirm:recovery-code:v1"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ public enum class IdentifierType {
EMAIL,
/** A phone number. */
PHONE,
/** A username. */
USERNAME,
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ public sealed interface NextAction {
override val capability: EmbeddedCapability = EmbeddedCapability.IDENTIFY_PHONE
}

/** Continue by submitting a username. Act on it with [EmbeddedAuthClient.identify]. */
public data object IdentifyUsername : NextAction {
override val capability: EmbeddedCapability = EmbeddedCapability.IDENTIFY_USERNAME
}

/** Continue by verifying the user's password. Act on it with [EmbeddedAuthClient.verifyPassword]. */
public data object VerifyPassword : NextAction {
override val capability: EmbeddedCapability = EmbeddedCapability.VERIFY_PASSWORD
}

/** Continue by requesting an email challenge. Act on it with [EmbeddedAuthClient.challengeEmail]. */
@ConsistentCopyVisibility
public data class ChallengeEmail internal constructor(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ private fun Map<String, Any>.toNextAction(): NextAction? {
return when (EmbeddedCapability.fromValue(raw)) {
EmbeddedCapability.IDENTIFY_EMAIL -> NextAction.IdentifyEmail
EmbeddedCapability.IDENTIFY_PHONE -> NextAction.IdentifyPhone
EmbeddedCapability.IDENTIFY_USERNAME -> NextAction.IdentifyUsername
EmbeddedCapability.VERIFY_PASSWORD -> NextAction.VerifyPassword
EmbeddedCapability.CHALLENGE_EMAIL -> {
val index = (this[INDEX_KEY] as? Number)?.toInt() ?: return dropped(raw, INDEX_KEY)
val identifier = this[IDENTIFIER_KEY] as? String ?: return dropped(raw, IDENTIFIER_KEY)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,135 @@ public class EmbeddedAuthClientTest {
assertThat(body.getString("type"), `is`("totp"))
}

// ── identify(USERNAME) / verifyPassword ─────────────────────────────────────

@Test
public fun `identify with USERNAME should POST the correct action and username`() {
establishSession()
mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_VERIFY_PASSWORD)

val error = assertEmbeddedError { client.identify("jane", IdentifierType.USERNAME).execute() }

val body = bodyOf(mockAPI.takeRequest())
assertThat(body.getString("action"), `is`("action:identify:username:v1"))
assertThat(body.getString("username"), `is`("jane"))
assertThat(body.getString("auth_session"), `is`(EmbeddedAuthMockServer.AUTH_SESSION))
assertThat(error.nextActions[0], `is`(NextAction.VerifyPassword))
}

@Test
public fun `authorize continuation surfaces IdentifyUsername next action`() {
mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_IDENTIFY_USERNAME)

val error = assertEmbeddedError { client.authorize(EmbeddedAuthMockServer.AUTHORIZE_CONNECTION).execute() }

assertThat(error.nextActions[0], `is`(NextAction.IdentifyUsername))
}

@Test
public fun `verifyPassword should fail with no_active_session when no flow is in progress`() {
val error = assertEmbeddedError { client.verifyPassword("secret").execute() }
assertThat(error.code, `is`("no_active_session"))
assertThat(error.error, `is`(EmbeddedAuthError.NoActiveSession))
}

@Test
public fun `verifyPassword should POST the correct action and password and return credentials`() {
establishSession()
mockAPI.willReturnAuthorizeCode()
mockAPI.willReturnTokens()

val credentials = client.verifyPassword("secret").execute()

val body = bodyOf(mockAPI.takeRequest())
assertThat(body.getString("action"), `is`("action:verify:password:v1"))
assertThat(body.getString("password"), `is`("secret"))
assertThat(body.getString("auth_session"), `is`(EmbeddedAuthMockServer.AUTH_SESSION))
assertThat(body.getString("client_id"), `is`(CLIENT_ID))
assertThat(credentials.accessToken, `is`(EmbeddedAuthMockServer.ACCESS_TOKEN))
assertThat(bodyOf(mockAPI.takeRequest()).getString("code"), `is`(EmbeddedAuthMockServer.AUTHORIZATION_CODE))
}

@Test
public fun `verifyPassword surfaces an MFA continuation and rotates the session`() {
establishSession()
mockAPI.willReturnContinuationWith(
EmbeddedAuthMockServer.NEXT_CHALLENGE_PHONE,
authSession = EmbeddedAuthMockServer.ROTATED_AUTH_SESSION
)

val error = assertEmbeddedError { client.verifyPassword("secret").execute() }
mockAPI.takeRequest()

assertThat(error.error, instanceOf(EmbeddedAuthError.InsufficientAuthorization::class.java))
assertThat(error.nextActions[0], instanceOf(NextAction.ChallengePhone::class.java))

mockAPI.willReturnContinuationWith(EmbeddedAuthMockServer.NEXT_VERIFY_OTP_SMS)
assertEmbeddedError { client.challengePhone().execute() }
val body = bodyOf(mockAPI.takeRequest())
assertThat(body.getString("auth_session"), `is`(EmbeddedAuthMockServer.ROTATED_AUTH_SESSION))
}

@Test
public fun `verifyPassword flags a wrong password as recoverable and keeps the session`() {
establishSession()
mockAPI.willReturnInvalidPassword()

val error = assertEmbeddedError { client.verifyPassword("wrong").execute() }
mockAPI.takeRequest()

val reason = error.error as EmbeddedAuthError.InsufficientAuthorization
assertThat(reason.reason, `is`(EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_PASSWORD))
assertThat(error.nextActions[0], `is`(NextAction.VerifyPassword))

mockAPI.willReturnAuthorizeCode()
mockAPI.willReturnTokens()
client.verifyPassword("right").execute()
val retry = bodyOf(mockAPI.takeRequest())
assertThat(retry.getString("auth_session"), `is`(EmbeddedAuthMockServer.ROTATED_AUTH_SESSION))
}

@Test
public fun `verifyPassword await surfaces a breached password as PasswordLeaked`(): Unit = runTest {
establishSession()
mockAPI.willReturnPasswordLeaked()

val error = assertEmbeddedErrorSuspending { client.verifyPassword("leaked").await() }

assertThat(error.error, `is`(EmbeddedAuthError.PasswordLeaked))
assertThat(error.statusCode, `is`(403))
}

@Test
public fun `verifyPassword await surfaces NoNextSteps as a terminal error`(): Unit = runTest {
establishSession()
mockAPI.willReturnNoNextSteps()

val error = assertEmbeddedErrorSuspending { client.verifyPassword("secret").await() }

assertThat(error.error, `is`(EmbeddedAuthError.NoNextSteps))
assertThat(error.statusCode, `is`(403))
}

@Test
public fun `verifyPassword await should return credentials`(): Unit = runTest {
establishSession()
mockAPI.willReturnAuthorizeCode()
mockAPI.willReturnTokens()

val credentials = client.verifyPassword("secret").await()

assertThat(credentials.accessToken, `is`(EmbeddedAuthMockServer.ACCESS_TOKEN))
}

@Test
public fun `adapter maps insufficient_authorization + invalid_identifier_or_password to reason INVALID_IDENTIFIER_OR_PASSWORD`() {
val json = """{"error":"insufficient_authorization","error_description":"invalid_identifier_or_password","auth_session":"s","next":[${EmbeddedAuthMockServer.NEXT_VERIFY_PASSWORD}]}"""
val ex = embeddedAuthErrorAdapter().fromJsonResponse(403, StringReader(json))
val error = ex.error as EmbeddedAuthError.InsufficientAuthorization
assertThat(error.reason, `is`(EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_PASSWORD))
}

// ── Continuation / typed-error mapping ──────────────────────────────────────

@Test
Expand Down Expand Up @@ -844,6 +973,20 @@ public class EmbeddedAuthClientTest {
assertThat(ex.error, `is`(EmbeddedAuthError.ConsentRequired))
}

@Test
public fun `adapter maps password_leaked to PasswordLeaked`() {
val json = """{"error":"access_denied","error_description":"password_leaked"}"""
val ex = embeddedAuthErrorAdapter().fromJsonResponse(403, StringReader(json))
assertThat(ex.error, `is`(EmbeddedAuthError.PasswordLeaked))
}

@Test
public fun `adapter maps no_next_steps to NoNextSteps`() {
val json = """{"error":"access_denied","error_description":"no_next_steps"}"""
val ex = embeddedAuthErrorAdapter().fromJsonResponse(403, StringReader(json))
assertThat(ex.error, `is`(EmbeddedAuthError.NoNextSteps))
}


@Test
public fun `adapter maps authorization_pending reason to InsufficientAuthorization with AUTHORIZATION_PENDING reason`() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ public class NextActionMapperTest {
const val NEW_CODE_KEY = "new_code"
}

@Test
public fun testIdentifyUsernameMapped() {
val raw = mapOf(ACTION_KEY to "action:identify:username:v1")
val action = listOf(raw).toNextActions().first()

assertThat(action, `is`(NextAction.IdentifyUsername))
}

@Test
public fun testVerifyPasswordMapped() {
val raw = mapOf(ACTION_KEY to "action:verify:password:v1")
val action = listOf(raw).toNextActions().first()

assertThat(action, `is`(NextAction.VerifyPassword))
}

@Test
public fun testChallengePhoneMapped() {
val raw = mapOf(
Expand Down
Loading
Loading