diff --git a/EXAMPLES.md b/EXAMPLES.md index 6ad20c050..442214f88 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -47,6 +47,7 @@ - [Enroll a TOTP (Authenticator App) Method](#enroll-a-totp-authenticator-app-method) - [Enroll a Push Notification Method](#enroll-a-push-notification-method) - [Enroll a Recovery Code](#enroll-a-recovery-code) + - [Enroll a Password Method](#enroll-a-password-method) - [Verify an Enrollment](#verify-an-enrollment) - [Delete an Authentication Method](#delete-an-authentication-method) - [Update an Authentication Method](#update-an-authentication-method) @@ -2595,6 +2596,69 @@ myAccountClient.enrollRecoveryCode() ``` +### Enroll a Password Method +**Scopes required:** `create:me:authentication_methods` + +Enrolling a password authentication method is a two-step process. First, you request an enrollment challenge, which returns the connection's password [policy](https://auth0.com/docs/authenticate/database-connections/password-options) so you can guide the user to choose a compliant password. Then, you confirm the enrollment with the new password. + +#### 1. Request an enrollment challenge + +```kotlin +myAccountClient.enrollPassword() + .start(object : Callback { + override fun onSuccess(result: PasswordEnrollmentChallenge) { + // Use result.policy to validate the user's new password before confirming. + // Then use result.id and result.authSession to verify. + } + override fun onFailure(error: MyAccountException) { } + }) +``` + +
+ Using Java + +```java +myAccountClient.enrollPassword() + .start(new Callback() { + @Override + public void onSuccess(PasswordEnrollmentChallenge result) { + // Use result.getPolicy() to validate the user's new password before confirming. + // Then use result.getId() and result.getAuthSession() to verify. + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); +``` +
+ +#### 2. Confirm the enrollment + +```kotlin +myAccountClient.verifyPassword("challenge_id_from_enroll", "auth_session_from_enroll", "new_password") + .start(object : Callback { + override fun onSuccess(result: PasswordAuthenticationMethod) { + // Enrollment successful + } + override fun onFailure(error: MyAccountException) { } + }) +``` + +
+ Using Java + +```java +myAccountClient.verifyPassword("challenge_id_from_enroll", "auth_session_from_enroll", "new_password") + .start(new Callback() { + @Override + public void onSuccess(PasswordAuthenticationMethod result) { + // Enrollment successful + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); +``` +
+ ### Verify an Enrollment **Scopes required:** `create:me:authentication_methods` diff --git a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt index 994616162..f8acc933e 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -24,7 +24,9 @@ import com.auth0.android.result.Factor import com.auth0.android.result.Factors import com.auth0.android.result.PasskeyAuthenticationMethod import com.auth0.android.result.PasskeyEnrollmentChallenge +import com.auth0.android.result.PasswordAuthenticationMethod import com.auth0.android.result.PasskeyRegistrationChallenge +import com.auth0.android.result.PasswordEnrollmentChallenge import com.auth0.android.result.RecoveryCodeEnrollmentChallenge import com.auth0.android.result.TotpEnrollmentChallenge import com.google.gson.Gson @@ -743,6 +745,100 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting .addHeader(AUTHORIZATION_KEY, authorizationHeader) } + /** + * Starts the enrollment of a password authentication method. This is the first part of a + * two-step flow: the returned challenge carries the [com.auth0.android.result.PasswordPolicy] + * the new password must satisfy, along with the `id` and `authSession` needed to confirm the + * enrollment via [verifyPassword]. + * + * ## Scopes Required + * `create:me:authentication_methods` + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * apiClient.enrollPassword() + * .start(object : Callback { + * override fun onSuccess(result: PasswordEnrollmentChallenge) { + * // Use result.policy to guide the user, then call verifyPassword(...) + * } + * override fun onFailure(error: MyAccountException) { //... } + * }) + * ``` + * @param userIdentity Unique identifier of the current user's identity. Needed if the user logged in with a [linked account](https://auth0.com/docs/manage-users/user-accounts/user-account-linking) + * @param connection Name of the database connection where the user is stored + * @return a request that will yield a password enrollment challenge. + */ + @JvmOverloads + public fun enrollPassword( + userIdentity: String? = null, + connection: String? = null + ): Request { + val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() + val params = ParameterBuilder.newBuilder().apply { + set(TYPE_KEY, AuthenticationMethodType.PASSWORD.type) + userIdentity?.let { set(USER_IDENTITY_ID_KEY, it) } + connection?.let { set(CONNECTION_KEY, it) } + }.asDictionary() + return factory.post( + url.toString(), + GsonAdapter(PasswordEnrollmentChallenge::class.java, gson), + dPoP + ) + .addParameters(params) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) + } + + /** + * Confirms the enrollment of a password method by providing the new password. + * + * ## Scopes Required + * `create:me:authentication_methods` + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * val authMethodId = "from_enrollment_challenge" + * val authSession = "from_enrollment_challenge" + * val newPassword = "the_users_new_password" + * + * apiClient.verifyPassword(authMethodId, authSession, newPassword) + * .start(object : Callback { + * override fun onSuccess(result: PasswordAuthenticationMethod) { //... } + * override fun onFailure(error: MyAccountException) { //... } + * }) + * ``` + * @param authenticationMethodId The ID of the method being verified (from the enrollment challenge). + * @param authSession The auth session from the enrollment challenge. + * @param newPassword The new password to set, satisfying the policy from the enrollment challenge. + * @return a request that will yield the newly verified password authentication method. + */ + public fun verifyPassword( + authenticationMethodId: String, + authSession: String, + newPassword: String + ): Request { + val url = getDomainUrlBuilder() + .addPathSegment(AUTHENTICATION_METHODS) + .addPathSegment(authenticationMethodId) + .addPathSegment(VERIFY) + .build() + val params = mapOf(NEW_PASSWORD_KEY to newPassword, AUTH_SESSION_KEY to authSession) + return factory.post( + url.toString(), + GsonAdapter(PasswordAuthenticationMethod::class.java, gson), + dPoP + ) + .addParameters(params) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) + } + // WebAuthn methods are private. /** * Starts the enrollment of a WebAuthn Platform (e.g., biometrics) authenticator. @@ -824,6 +920,7 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting private const val AUTHORIZATION_KEY = "Authorization" private const val LOCATION_KEY = "location" private const val AUTH_SESSION_KEY = "auth_session" + private const val NEW_PASSWORD_KEY = "new_password" private const val AUTHN_RESPONSE_KEY = "authn_response" private const val PREFERRED_AUTHENTICATION_METHOD = "preferred_authentication_method" private const val AUTHENTICATION_METHOD_NAME = "name" diff --git a/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt b/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt index 5357a3fc0..ecc4f7016 100644 --- a/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt +++ b/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt @@ -24,7 +24,8 @@ public sealed class EnrollmentChallenge { jsonObject.has("barcode_uri") -> TotpEnrollmentChallenge::class.java jsonObject.has("recovery_code") -> RecoveryCodeEnrollmentChallenge::class.java jsonObject.has("authn_params_public_key") -> PasskeyEnrollmentChallenge::class.java - jsonObject.has("oob_code") -> OobEnrollmentChallenge::class.java + jsonObject.has("oob_code") -> OobEnrollmentChallenge::class.java + jsonObject.has("policy") -> PasswordEnrollmentChallenge::class.java else -> MfaEnrollmentChallenge::class.java } return context.deserialize(jsonObject, targetClass) @@ -72,4 +73,17 @@ public data class RecoveryCodeEnrollmentChallenge( override val authSession: String, @SerializedName("recovery_code") public val recoveryCode: String +) : EnrollmentChallenge() + +/** + * Enrollment challenge for a password authentication method. Includes the [policy] the new password + * must satisfy, so the app can guide the user before confirming the enrollment. + */ +public data class PasswordEnrollmentChallenge( + @SerializedName("id") + override val id: String, + @SerializedName("auth_session") + override val authSession: String, + @SerializedName("policy") + public val policy: PasswordPolicy ) : EnrollmentChallenge() \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/result/PasswordPolicy.kt b/auth0/src/main/java/com/auth0/android/result/PasswordPolicy.kt new file mode 100644 index 000000000..e1bb22ae0 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/PasswordPolicy.kt @@ -0,0 +1,122 @@ +package com.auth0.android.result + +import com.google.gson.annotations.SerializedName + +/** + * Describes the password policy that a new password must satisfy, as returned by the My Account API + * when starting a password enrollment. Use it to build a UI that guides the user toward a compliant + * password. + */ +public data class PasswordPolicy( + /** + * Rules governing the structural complexity of the password (length, character types, etc.). + */ + @SerializedName("complexity") + public val complexity: PasswordComplexity, + /** + * Rules that prevent the password from containing personal information taken from the user profile. + */ + @SerializedName("profile_data") + public val profileData: PasswordProfileData, + /** + * Rules that prevent reuse of previously used passwords. + */ + @SerializedName("history") + public val history: PasswordHistory, + /** + * Rules that prevent the use of common dictionary words as passwords. + */ + @SerializedName("dictionary") + public val dictionary: PasswordDictionary +) + +/** + * Structural complexity requirements for a password. + */ +public data class PasswordComplexity( + /** + * The minimum number of characters the password must contain. + */ + @SerializedName("min_length") + public val minLength: Int?, + /** + * The character classes the password may be required to include. + * Possible values: `uppercase`, `lowercase`, `number`, `special`. + */ + @SerializedName("character_types") + public val characterTypes: List?, + /** + * How the [characterTypes] requirement is enforced. + * Possible values: `all` (every listed type is required), `three_of_four`. + */ + @SerializedName("character_type_rule") + public val characterTypeRule: String?, + /** + * Whether identical consecutive characters are permitted. + * Possible values: `allow`, `block`. + */ + @SerializedName("identical_characters") + public val identicalCharacters: String?, + /** + * Whether sequential characters (e.g. `abc`, `123`) are permitted. + * Possible values: `allow`, `block`. + */ + @SerializedName("sequential_characters") + public val sequentialCharacters: String?, + /** + * How a password that exceeds the maximum allowed length is handled. + * Possible values: `truncate`, `error`. + */ + @SerializedName("max_length_exceeded") + public val maxLengthExceeded: String? +) + +/** + * Rules that block the use of personal information from the user profile within a password. + */ +public data class PasswordProfileData( + /** + * Whether blocking of personal information is enabled. + */ + @SerializedName("active") + public val active: Boolean?, + /** + * The user profile fields whose values must not appear in the password + * (e.g. `name`, `email`, `user_metadata.first`). + */ + @SerializedName("blocked_fields") + public val blockedFields: List? +) + +/** + * Rules that prevent reuse of previously used passwords. + */ +public data class PasswordHistory( + /** + * Whether password history enforcement is enabled. + */ + @SerializedName("active") + public val active: Boolean?, + /** + * The number of previous passwords that cannot be reused. + */ + @SerializedName("size") + public val size: Int? +) + +/** + * Rules that prevent the use of common dictionary words as passwords. + */ +public data class PasswordDictionary( + /** + * Whether dictionary checking is enabled. + */ + @SerializedName("active") + public val active: Boolean?, + /** + * The default dictionary used for the check. + * Possible values: `en_10k`, `en_100k`. + */ + @SerializedName("default") + public val default: String? +) diff --git a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt index 76a095428..5e60b18c7 100644 --- a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt @@ -13,6 +13,8 @@ import com.auth0.android.result.EnrollmentChallenge import com.auth0.android.result.Factor import com.auth0.android.result.PasskeyAuthenticationMethod import com.auth0.android.result.PasskeyEnrollmentChallenge +import com.auth0.android.result.PasswordAuthenticationMethod +import com.auth0.android.result.PasswordEnrollmentChallenge import com.auth0.android.result.RecoveryCodeEnrollmentChallenge import com.auth0.android.result.TotpEnrollmentChallenge import com.auth0.android.util.AuthenticationAPIMockServer.Companion.SESSION_ID @@ -544,6 +546,101 @@ public class MyAccountAPIClientTest { assertThat(body, Matchers.hasEntry("type", "push-notification" as Any)) } + @Test + public fun `enrollPassword should send correct payload`() { + val callback = MockMyAccountCallback() + client.enrollPassword().start(callback) + + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods")) + assertThat(request.method, Matchers.equalTo("POST")) + assertThat(body, Matchers.hasEntry("type", "password" as Any)) + } + + @Test + public fun `enrollPassword should include userIdentity and connection parameters`() { + val callback = MockMyAccountCallback() + client.enrollPassword(USER_IDENTITY, CONNECTION).start(callback) + + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("type", "password" as Any)) + assertThat(body, Matchers.hasEntry("identity_user_id", USER_IDENTITY as Any)) + assertThat(body, Matchers.hasEntry("connection", CONNECTION as Any)) + } + + @Test + public fun `enrollPassword should parse challenge with policy`() { + mockAPI.willReturnPasswordEnrollmentChallenge() + val challenge = client.enrollPassword().execute() + mockAPI.takeRequest() + + assertThat(challenge, Matchers.instanceOf(PasswordEnrollmentChallenge::class.java)) + assertThat(challenge.id, Matchers.equalTo("password|new")) + assertThat(challenge.authSession, Matchers.equalTo("SESSION_ID")) + assertThat(challenge.policy.complexity.minLength, Matchers.equalTo(8)) + assertThat( + challenge.policy.complexity.characterTypes, + Matchers.contains("uppercase", "lowercase", "number", "special") + ) + assertThat( + challenge.policy.complexity.characterTypeRule, + Matchers.equalTo("three_of_four") + ) + assertThat(challenge.policy.profileData.active, Matchers.equalTo(true)) + assertThat(challenge.policy.profileData.blockedFields, Matchers.contains("name", "email")) + assertThat(challenge.policy.history.size, Matchers.equalTo(5)) + assertThat(challenge.policy.dictionary.default, Matchers.equalTo("en_10k")) + } + + @Test + public fun `verifyPassword should send correct payload`() { + val callback = MockMyAccountCallback() + val methodId = "password|new" + val newPassword = "S3cr3tP@ssw0rd" + val session = "abc-def" + client.verifyPassword(methodId, session, newPassword).start(callback) + + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat( + request.path, + Matchers.equalTo("/me/v1/authentication-methods/password%7Cnew/verify") + ) + assertThat(request.method, Matchers.equalTo("POST")) + assertThat(body, Matchers.hasEntry("new_password", newPassword as Any)) + assertThat(body, Matchers.hasEntry("auth_session", session as Any)) + } + + @Test + public fun `verifyPassword should return PasswordAuthenticationMethod on success`() { + mockAPI.willReturnPasswordAuthenticationMethod() + val response = client.verifyPassword("password|new", AUTH_SESSION, "S3cr3tP@ssw0rd") + .execute() + mockAPI.takeRequest() + + assertThat(response, Matchers.instanceOf(PasswordAuthenticationMethod::class.java)) + assertThat(response.id, Matchers.equalTo("password|pwd_a1b2c3d4e5f6")) + assertThat(response.type, Matchers.equalTo("password")) + assertThat(response.identityUserId, Matchers.equalTo("user_98765432")) + } + + @Test + public fun `enrollPassword should include DPoP proof header on POST when DPoP is enabled`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback() + dpopClient.enrollPassword().start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.method, Matchers.equalTo("POST")) + } + // DPoP tests @Test diff --git a/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt b/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt index 53cdba370..d4905cb41 100644 --- a/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt @@ -96,6 +96,54 @@ internal class MyAccountAPIMockServer : APIMockServer() { return this } + fun willReturnPasswordEnrollmentChallenge(): MyAccountAPIMockServer { + val json = """ + { + "id": "$PASSWORD_METHOD_ID", + "auth_session": "$SESSION_ID", + "policy": { + "complexity": { + "min_length": 8, + "character_types": ["uppercase", "lowercase", "number", "special"], + "character_type_rule": "three_of_four", + "identical_characters": "block", + "sequential_characters": "block", + "max_length_exceeded": "error" + }, + "profile_data": { + "active": true, + "blocked_fields": ["name", "email"] + }, + "history": { + "active": true, + "size": 5 + }, + "dictionary": { + "active": true, + "default": "en_10k" + } + } + } + """.trimIndent() + server.enqueue(responseWithJSON(json, 202)) + return this + } + + fun willReturnPasswordAuthenticationMethod(): MyAccountAPIMockServer { + val json = """ + { + "id": "$PASSWORD_VERIFIED_METHOD_ID", + "type": "password", + "created_at": "2023-06-15T14:30:25.000Z", + "usage": ["primary"], + "identity_user_id": "user_98765432", + "last_password_reset": "2023-06-15T14:30:25.000Z" + } + """.trimIndent() + server.enqueue(responseWithJSON(json, 201)) + return this + } + fun willReturnErrorForBadRequest(): MyAccountAPIMockServer { val responseBody = """ { @@ -148,5 +196,7 @@ internal class MyAccountAPIMockServer : APIMockServer() { private companion object { private const val SESSION_ID = "SESSION_ID" private const val CHALLENGE = "CHALLENGE" + private const val PASSWORD_METHOD_ID = "password|new" + private const val PASSWORD_VERIFIED_METHOD_ID = "password|pwd_a1b2c3d4e5f6" } } \ No newline at end of file