diff --git a/EXAMPLES.md b/EXAMPLES.md index 567d95ca0..f5b99c44a 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -25,6 +25,16 @@ - [DPoP [EA]](#dpop-ea-1) - [My Account API](#my-account-api) - [Enroll a new passkey](#enroll-a-new-passkey) + - [Get Available Factors](#get-available-factors) + - [Get All Enrolled Authentication Methods](#get-all-enrolled-authentication-methods) + - [Get a Single Authentication Method by ID](#get-a-single-authentication-method-by-id) + - [Enroll a Phone Method](#enroll-a-phone-method) + - [Enroll an Email Method](#enroll-an-email-method) + - [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) + - [Verify an Enrollment](#verify-an-enrollment) + - [Delete an Authentication Method](#delete-an-authentication-method) - [Credentials Manager](#credentials-manager) - [Secure Credentials Manager](#secure-credentials-manager) - [Usage](#usage) @@ -959,6 +969,396 @@ client.enroll(passkeyCredential, challenge) ``` +### Get Available Factors +**Scopes required:** `read:me:factors` + +Retrieves the list of multi-factor authentication (MFA) factors that are enabled for the tenant and available for the user to enroll. + +**Prerequisites:** + +Enable the desired MFA factors you want to be listed. Go to Auth0 Dashboard > Security > Multi-factor Auth. + +```kotlin +myAccountClient.getFactors() + .start(object : Callback, MyAccountException> { + override fun onSuccess(result: Factors) { + // List of available factors in result.factors + } + override fun onFailure(error: MyAccountException) { } + }) +``` +
+ Using Java + +```java +myAccountClient.getFactors() + .start(new Callback, MyAccountException>() { + @Override + public void onSuccess(Factors result) { + // List of available factors in result.getFactors() + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); +``` +
+ +### Get All Enrolled Authentication Methods +**Scopes required:** `read:me:authentication_methods` + +Retrieves a detailed list of all the authentication methods that the current user has already enrolled in. + + +**Prerequisites:** + +The user must have one or more authentication methods already enrolled. + +```kotlin +myAccountClient.getAuthenticationMethods() + .start(object : Callback, MyAccountException> { + override fun onSuccess(result: AuthenticationMethods) { + // List of enrolled methods in result.authenticationMethods + } + override fun onFailure(error: MyAccountException) { } + }) +``` +
+ Using Java + +```java +myAccountClient.getAuthenticationMethods() + .start(new Callback, MyAccountException>() { + @Override + public void onSuccess(AuthenticationMethods result) { + // List of enrolled methods in result.getAuthenticationMethods() + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); +``` +
+ +### Get a Single Authentication Method by ID +**Scopes required:** `read:me:authentication_methods` + +Retrieves a single authentication method by its unique ID. + +**Prerequisites:** + +The user must have the specific authentication method (identified by its ID) already enrolled. + +```kotlin +myAccountClient.getAuthenticationMethodById("phone|dev_...") + .start(object : Callback { + override fun onSuccess(result: AuthenticationMethod) { + // The requested authentication method + } + override fun onFailure(error: MyAccountException) { } + }) +``` +
+ Using Java + +```java +myAccountClient.getAuthenticationMethodById("phone|dev_...") + .start(new Callback() { + @Override + public void onSuccess(AuthenticationMethod result) { + // The requested authentication method + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); +``` +
+ +### Enroll a Phone Method +**Scopes required:** `create:me:authentication_methods` + +Enrolling a new phone authentication method is a two-step process. First, you request an enrollment challenge which sends an OTP to the user. Then, you must verify the enrollment with the received OTP. + +**Prerequisites:** + +Enable the MFA grant type for your application. Go to Auth0 Dashboard > Applications > Your App > Advanced Settings > Grant Types and select MFA. + +Enable the Phone Message factor. Go to Auth0 Dashboard > Security > Multi-factor Auth > Phone Message. + +```kotlin +myAccountClient.enrollPhone("+11234567890", PhoneAuthenticationMethodType.SMS) + .start(object : Callback { + override fun onSuccess(result: EnrollmentChallenge) { + // OTP sent. Use result.id and result.authSession to verify. + } + override fun onFailure(error: MyAccountException) { } + }) +``` +
+ Using Java + +```java +myAccountClient.enrollPhone("+11234567890", PhoneAuthenticationMethodType.SMS) + .start(new Callback() { + @Override + public void onSuccess(EnrollmentChallenge result) { + // OTP sent. Use result.getId() and result.getAuthSession() to verify. + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); +``` + +
+ +### Enroll an Email Method +**Scopes required:** `create:me:authentication_methods` + +Enrolling a new email authentication method is a two-step process. First, you request an enrollment challenge which sends an OTP to the user. Then, you must verify the enrollment with the received OTP. + +**Prerequisites:** + +Enable the MFA grant type for your application. Go to Auth0 Dashboard > Applications > Your App > Advanced Settings > Grant Types and select MFA. + +Enable the Email factor. Go to Auth0 Dashboard > Security > Multi-factor Auth > Email. + +```kotlin +myAccountClient.enrollEmail("user@example.com") + .start(object : Callback { + override fun onSuccess(result: EnrollmentChallenge) { + // OTP sent. Use result.id and result.authSession to verify. + } + override fun onFailure(error: MyAccountException) { } + }) +``` +
+ Using Java + +```java +myAccountClient.enrollEmail("user@example.com") + .start(new Callback() { + @Override + public void onSuccess(EnrollmentChallenge result) { + // OTP sent. Use result.getId() and result.getAuthSession() to verify. + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); +``` +
+ +### Enroll a TOTP (Authenticator App) Method + +**Scopes required:** `create:me:authentication_methods` + +Enrolling a new TOTP (Authenticator App) authentication method is a two-step process. First, you request an enrollment challenge which provides a QR code or manual entry key. Then, you must verify the enrollment with an OTP from the authenticator app. + +**Prerequisites:** + +Enable the MFA grant type for your application. Go to Auth0 Dashboard > Applications > Your App > Advanced Settings > Grant Types and select MFA. + +Enable the One-time Password factor. Go to Auth0 Dashboard > Security > Multi-factor Auth > One-time Password. + +```kotlin +myAccountClient.enrollTotp() + .start(object : Callback { + override fun onSuccess(result: TotpEnrollmentChallenge) { + // The result is already a TotpEnrollmentChallenge, no cast is needed. + // Show QR code from result.barcodeUri or manual code from result.manualInputCode + // Then use result.id and result.authSession to verify. + } + override fun onFailure(error: MyAccountException) { } + }) +``` + +
+ Using Java + +```java +myAccountClient.enrollTotp() + .start(new Callback() { + @Override + public void onSuccess(TotpEnrollmentChallenge result) { + // The result is already a TotpEnrollmentChallenge, no cast is needed. + // Show QR code from result.getBarcodeUri() or manual code from result.getManualInputCode() + // Then use result.getId() and result.getAuthSession() to verify. + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); +``` +
+ +### Enroll a Push Notification Method +**Scopes required:** `create:me:authentication_methods` + +Enrolling a new Push Notification authentication method is a two-step process. First, you request an enrollment challenge which provides a QR code. Then, after the user scans the QR code and approves, you must confirm the enrollment. + +**Prerequisites:** + +Enable the MFA grant type for your application. Go to Auth0 Dashboard > Applications > Your App > Advanced Settings > Grant Types and select MFA. + +Enable the Push Notification factor. Go to Auth0 Dashboard > Security > Multi-factor Auth > Push Notification using Auth0 Guardian. + +```kotlin +myAccountClient.enrollPushNotification() + .start(object : Callback { + override fun onSuccess(result: TotpEnrollmentChallenge) { + // The result is already a TotpEnrollmentChallenge, no cast is needed. + // Show QR code from result.barcodeUri to be scanned by Auth0 Guardian/Verify + // Then use result.id and result.authSession to verify. + } + override fun onFailure(error: MyAccountException) { } + }) +``` +
+ Using Java + +```java +myAccountClient.enrollPushNotification() + .start(new Callback() { + @Override + public void onSuccess(TotpEnrollmentChallenge result) { + // The result is already a TotpEnrollmentChallenge, no cast is needed. + // Show QR code from result.getBarcodeUri() to be scanned by Auth0 Guardian/Verify + // Then use result.getId() and result.getAuthSession() to verify. + } + @Override + public void onFailure(@NonNull MyAccountException error) { } +}); +``` +
+ +### Enroll a Recovery Code +**Scopes required:** `create:me:authentication_methods` + +Enrolls a new recovery code for the user. This is a single-step process that immediately returns the recovery code. The user must save this code securely as it will not be shown again. + +**Prerequisites:** + +Enable the MFA grant type for your application. Go to Auth0 Dashboard > Applications > Your App > Advanced Settings > Grant Types and select MFA. + +Enable the Recovery Code factor. Go to Auth0 Dashboard > Security > Multi-factor Auth > Recovery Code. + +```kotlin +myAccountClient.enrollRecoveryCode() + .start(object : Callback { + override fun onSuccess(result: RecoveryCodeEnrollmentChallenge) { + // The result is already a RecoveryCodeEnrollmentChallenge, no cast is needed. + // Display and require the user to save result.recoveryCode + // This method is already verified. + } + override fun onFailure(error: MyAccountException) { } + }) + +``` +
+ Using Java + +```java +myAccountClient.enrollRecoveryCode() + .start(new Callback() { + @Override + public void onSuccess(RecoveryCodeEnrollmentChallenge result) { + // The result is already a RecoveryCodeEnrollmentChallenge, no cast is needed. + // Display and require the user to save result.getRecoveryCode() + // This method is already verified. + } + @Override + public void onFailure(@NonNull MyAccountException error) { } +}); +``` +
+ +### Verify an Enrollment +**Scopes required:** `create:me:authentication_methods` + +Confirms the enrollment of an authentication method after the user has completed the initial challenge (e.g., entered an OTP, scanned a QR code). + +Prerequisites: + +An enrollment must have been successfully started to obtain the challenge_id and auth_session. + +```kotlin +// For OTP-based factors (TOTP, Email, Phone) +myAccountClient.verifyOtp("challenge_id_from_enroll", "123456", "auth_session_from_enroll") + .start(object : Callback { + override fun onSuccess(result: AuthenticationMethod) { + // Enrollment successful + } + override fun onFailure(error: MyAccountException) { } + }) + +// For Push Notification factor +myAccountClient.verify("challenge_id_from_enroll", "auth_session_from_enroll") + .start(object : Callback { + override fun onSuccess(result: AuthenticationMethod) { + // Enrollment successful + } + override fun onFailure(error: MyAccountException) { } + }) +``` +
+ Using Java + +```java +// For OTP-based factors (TOTP, Email, Phone) +myAccountClient.verifyOtp("challenge_id_from_enroll", "123456", "auth_session_from_enroll") + .start(new Callback() { + @Override + public void onSuccess(AuthenticationMethod result) { + // Enrollment successful + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); + +// For Push Notification factor +myAccountClient.verify("challenge_id_from_enroll", "auth_session_from_enroll") + .start(new Callback() { + @Override + public void onSuccess(AuthenticationMethod result) { + // Enrollment successful + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); +``` +
+ +### Delete an Authentication Method +**Scopes required:** `delete:me:authentication_methods` + +Deletes an existing authentication method belonging to the current user. + +**Prerequisites:** + +The user must have the specific authentication method (identified by its ID) already enrolled. + +```kotlin +myAccountClient.deleteAuthenticationMethod("phone|dev_...") + .start(object : Callback { + override fun onSuccess(result: Unit) { + // Deletion successful + } + override fun onFailure(error: MyAccountException) { } + }) +``` +
+ Using Java + +```java +myAccountClient.deleteAuthenticationMethod("phone|dev_...") + .start(new Callback() { + @Override + public void onSuccess(Void result) { + // Deletion successful + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); +``` +
+ + ## Credentials Manager ### Secure Credentials Manager 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 401dd0d5d..fa5c2328a 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -14,9 +14,17 @@ import com.auth0.android.request.internal.GsonAdapter.Companion.forMap import com.auth0.android.request.internal.GsonProvider import com.auth0.android.request.internal.RequestFactory import com.auth0.android.request.internal.ResponseUtils.isNetworkError +import com.auth0.android.result.AuthenticationMethod +import com.auth0.android.result.AuthenticationMethods +import com.auth0.android.result.EnrollmentChallenge +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.PasskeyRegistrationChallenge +import com.auth0.android.result.RecoveryCodeEnrollmentChallenge +import com.auth0.android.result.TotpEnrollmentChallenge + import com.google.gson.Gson import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl @@ -29,7 +37,7 @@ import java.net.URLDecoder * Auth0 My Account API client for managing the current user's account. * * You can use the refresh token to get an access token for the My Account API. Refer to [com.auth0.android.authentication.storage.CredentialsManager.getApiCredentials] - * , or alternatively [com.auth0.android.authentication.AuthenticationAPIClient.renewAuth] if you are not using CredentialsManager. + * , or alternatively [com.auth0.android.authentication.AuthenticationAPIClient.renewAuth] if you are not using CredentialsManager. * * ## Usage * ```kotlin @@ -64,7 +72,7 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting auth0, accessToken, RequestFactory(auth0.networkingClient, createErrorAdapter()), - Gson() + GsonProvider.gson ) @@ -138,19 +146,11 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting public fun passkeyEnrollmentChallenge( userIdentity: String? = null, connection: String? = null ): Request { - - val url = getDomainUrlBuilder() - .addPathSegment(AUTHENTICATION_METHODS) - .build() - + val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() val params = ParameterBuilder.newBuilder().apply { set(TYPE_KEY, "passkey") - userIdentity?.let { - set(USER_IDENTITY_ID_KEY, userIdentity) - } - connection?.let { - set(CONNECTION_KEY, connection) - } + userIdentity?.let { set(USER_IDENTITY_ID_KEY, it) } + connection?.let { set(CONNECTION_KEY, it) } }.asDictionary() val passkeyEnrollmentAdapter: JsonAdapter = @@ -158,35 +158,22 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting override fun fromJson( reader: Reader, metadata: Map ): PasskeyEnrollmentChallenge { - val headers = metadata.mapValues { (_, value) -> - when (value) { - is List<*> -> value.filterIsInstance() - else -> emptyList() - } - } - val locationHeader = headers[LOCATION_KEY]?.get(0)?.split("/")?.lastOrNull() - locationHeader ?: throw MyAccountException("Authentication method ID not found") - val authenticationId = - URLDecoder.decode( - locationHeader, - "UTF-8" - ) - - val passkeyRegistrationChallenge = gson.fromJson( - reader, PasskeyRegistrationChallenge::class.java - ) + val location = (metadata[LOCATION_KEY] as? List<*>)?.filterIsInstance() + ?.firstOrNull() + val authId = + location?.split("/")?.lastOrNull()?.let { URLDecoder.decode(it, "UTF-8") } + ?: throw MyAccountException("Authentication method ID not found in Location header.") + val challenge = gson.fromJson(reader, PasskeyRegistrationChallenge::class.java) return PasskeyEnrollmentChallenge( - authenticationId, - passkeyRegistrationChallenge.authSession, - passkeyRegistrationChallenge.authParamsPublicKey + authId, + challenge.authSession, + challenge.authParamsPublicKey ) } } - val post = factory.post(url.toString(), passkeyEnrollmentAdapter) + return factory.post(url.toString(), passkeyEnrollmentAdapter) .addParameters(params) .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") - - return post } /** @@ -228,15 +215,13 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting public fun enroll( credentials: PublicKeyCredentials, challenge: PasskeyEnrollmentChallenge ): Request { - val authMethodId = challenge.authenticationMethodId - val url = - getDomainUrlBuilder() - .addPathSegment(AUTHENTICATION_METHODS) - .addPathSegment(authMethodId) - .addPathSegment(VERIFY) - .build() - - val authenticatorResponse = mapOf( + val url = getDomainUrlBuilder() + .addPathSegment(AUTHENTICATION_METHODS) + .addPathSegment(challenge.authenticationMethodId) + .addPathSegment(VERIFY) + .build() + + val authnResponse = mapOf( "authenticatorAttachment" to "platform", "clientExtensionResults" to credentials.clientExtensionResults, "id" to credentials.id, @@ -244,24 +229,567 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting "type" to "public-key", "response" to mapOf( "clientDataJSON" to credentials.response.clientDataJSON, - "attestationObject" to credentials.response.attestationObject + "attestationObject" to credentials.response.attestationObject, ) ) + val params = ParameterBuilder.newBuilder() + .set(AUTH_SESSION_KEY, challenge.authSession) + .asDictionary() + + return factory.post( + url.toString(), + GsonAdapter(PasskeyAuthenticationMethod::class.java, gson) + ) + .addParameters(params) + .addParameter(AUTHN_RESPONSE_KEY, authnResponse) + .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + } + + + /** + * Retrieves a detailed list of authentication methods belonging to the user. + * + * ## Availability + * + * This feature is currently available in + * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). + * Please reach out to Auth0 support to get it enabled for your tenant. + * + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * + * apiClient.getAuthenticationMethods() + * .start(object : Callback, MyAccountException> { + * override fun onSuccess(result: List) { + * Log.d("MyApp", "Authentication methods: $result") + * } + * + * override fun onFailure(error: MyAccountException) { + * Log.e("MyApp", "Failed with: ${error.message}") + * } + * }) + * ``` + * + */ + public fun getAuthenticationMethods(): Request, MyAccountException> { + val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() + + val listAdapter = object : JsonAdapter> { + override fun fromJson(reader: Reader, metadata: Map): List { + val container = gson.fromJson(reader, AuthenticationMethods::class.java) + return container.authenticationMethods + } + } + return factory.get(url.toString(), listAdapter) + .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + } + + + /** + * Retrieves a single authentication method belonging to the user. + * + * ## Availability + * + * This feature is currently available in + * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). + * Please reach out to Auth0 support to get it enabled for your tenant. + * + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * + * apiClient.getAuthenticationMethodById(authenticationMethodId, ) + * .start(object : Callback { + * override fun onSuccess(result: AuthenticationMethod) { + * Log.d("MyApp", "Authentication method $result") + * } + * + * override fun onFailure(error: MyAccountException) { + * Log.e("MyApp", "Failed with: ${error.message}") + * } + * }) + * ``` + * + * @param authenticationMethodId Id of the authentication method to be retrieved + * + */ + public fun getAuthenticationMethodById(authenticationMethodId: String): Request { + val url = getDomainUrlBuilder() + .addPathSegment(AUTHENTICATION_METHODS) + .addPathSegment(authenticationMethodId) + .build() + return factory.get(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) + .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + } + + /** + * Updates a single authentication method belonging to the user. + * + * ## Availability + * + * This feature is currently available in + * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). + * Please reach out to Auth0 support to get it enabled for your tenant. + * + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * + * apiClient.updateAuthenticationMethodById(authenticationMethodId,preferredAuthenticationMethod, authenticationMethodName) + * .start(object : Callback { + * override fun onSuccess(result: AuthenticationMethod) { + * Log.d("MyApp", "Authentication method $result") + * } + * + * override fun onFailure(error: MyAccountException) { + * Log.e("MyApp", "Failed with: ${error.message}") + * } + * }) + * ``` + * + * @param authenticationMethodId Id of the authentication method to be retrieved + * @param authenticationMethodName The friendly name of the authentication method + * @param preferredAuthenticationMethod The preferred authentication method for the user. (for phone authenticators) + * + */ + @JvmOverloads + internal fun updateAuthenticationMethodById( + authenticationMethodId: String, + authenticationMethodName: String? = null, + preferredAuthenticationMethod: PhoneAuthenticationMethodType? = null + ): Request { + val url = getDomainUrlBuilder() + .addPathSegment(AUTHENTICATION_METHODS) + .addPathSegment(authenticationMethodId) + .build() + val params = ParameterBuilder.newBuilder().apply { - set(AUTH_SESSION_KEY, challenge.authSession) + authenticationMethodName?.let { set(AUTHENTICATION_METHOD_NAME, it) } + preferredAuthenticationMethod?.let { + set( + PREFERRED_AUTHENTICATION_METHOD, + it.value + ) + } }.asDictionary() - val passkeyAuthenticationAdapter = GsonAdapter( - PasskeyAuthenticationMethod::class.java - ) + return factory.patch(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) + .addParameters(params) + .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + } - val request = factory.post( - url.toString(), passkeyAuthenticationAdapter - ).addParameters(params) - .addParameter(AUTHN_RESPONSE_KEY, authenticatorResponse) + + /** + * Deletes an existing authentication method belonging to the user. + * + * ## Availability + * + * This feature is currently available in + * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). + * Please reach out to Auth0 support to get it enabled for your tenant. + * + * ## Scopes Required + * `delete:me:authentication_methods` + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * + * apiClient.deleteAuthenticationMethod(authenticationMethodId) + * .start(object : Callback { + * override fun onSuccess(result: Void) { + * Log.d("MyApp", "Authentication method deleted") + * } + * + * override fun onFailure(error: MyAccountException) { + * Log.e("MyApp", "Failed with: ${error.message}") + * } + * }) + * ``` + * + * @param authenticationMethodId Id of the authentication method to be deleted + * + */ + public fun deleteAuthenticationMethod(authenticationMethodId: String): Request { + val url = getDomainUrlBuilder() + .addPathSegment(AUTHENTICATION_METHODS) + .addPathSegment(authenticationMethodId) + .build() + val voidAdapter = object : JsonAdapter { + override fun fromJson(reader: Reader, metadata: Map): Void? = null + } + return factory.delete(url.toString(), voidAdapter) + .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + } + + /** + * Gets the list of factors available for the user to enroll. + * + * ## Scopes Required + * `read:me:factors` + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * apiClient.getFactors() + * .start(object : Callback, MyAccountException> { + * override fun onSuccess(result: List) { + * Log.d("MyApp", "Available factors: $result") + * } + * override fun onFailure(error: MyAccountException) { + * Log.e("MyApp", "Error getting factors: $error") + * } + * }) + * ``` + * @return A request to get the list of available factors. + */ + public fun getFactors(): Request, MyAccountException> { + val url = getDomainUrlBuilder().addPathSegment(FACTORS).build() + + val listAdapter = object : JsonAdapter> { + override fun fromJson(reader: Reader, metadata: Map): List { + val container = gson.fromJson(reader, Factors::class.java) + return container.factors + } + } + return factory.get(url.toString(), listAdapter) + .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + } + + /** + * Starts the enrollment of a phone authentication method. + * + * ## Scopes Required + * `create:me:authentication_methods` + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * apiClient.enrollPhone("+11234567890", "sms") + * .start(object : Callback { + * override fun onSuccess(result: EnrollmentChallenge) { + * // The enrollment has started. 'result.id' contains the ID for verification. + * Log.d("MyApp", "Enrollment started. ID: ${result.id}") + * } + * override fun onFailure(error: MyAccountException) { + * Log.e("MyApp", "Failed with: ${error.message}") + * } + * }) + * ``` + * @param phoneNumber The phone number to enroll in E.164 format. + * @param preferredMethod The preferred method for this factor ("sms" or "voice"). + * @return A request that will yield an enrollment challenge. + */ + public fun enrollPhone( + phoneNumber: String, + preferredMethod: PhoneAuthenticationMethodType + ): Request { + val params = ParameterBuilder.newBuilder() + .set(TYPE_KEY, "phone") + .set(PHONE_NUMBER_KEY, phoneNumber) + .set(PREFERRED_AUTHENTICATION_METHOD, preferredMethod.value) + .asDictionary() + return buildEnrollmentRequest(params) + } + + /** + * Starts the enrollment of an email authentication method. + * + * ## Scopes Required + * `create:me:authentication_methods` + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * apiClient.enrollEmail("user@example.com") + * .start(object : Callback { + * override fun onSuccess(result: EnrollmentChallenge) { + * // The enrollment has started. 'result.id' contains the ID for verification. + * Log.d("MyApp", "Enrollment started. ID: ${result.id}") + * } + * override fun onFailure(error: MyAccountException) { + * Log.e("MyApp", "Failed with: ${error.message}") + * } + * }) + * ``` + * @param email the email address to enroll. + * @return a request that will yield an enrollment challenge. + */ + public fun enrollEmail(email: String): Request { + val params = ParameterBuilder.newBuilder() + .set(TYPE_KEY, "email") + .set(EMAIL_KEY, email) + .asDictionary() + return buildEnrollmentRequest(params) + } + + /** + * Starts the enrollment of a TOTP (authenticator app) method. + * + * ## Scopes Required + * `create:me:authentication_methods` + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * apiClient.enrollTotp() + * .start(object : Callback { + * override fun onSuccess(result: EnrollmentChallenge) { + * // The result will be a TotpEnrollmentChallenge with a barcode_uri + * Log.d("MyApp", "Enrollment started for TOTP.") + * } + * override fun onFailure(error: MyAccountException) { //... } + * }) + * ``` + * @return a request that will yield an enrollment challenge. + */ + public fun enrollTotp(): Request { + val params = ParameterBuilder.newBuilder().set(TYPE_KEY, "totp").asDictionary() + val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() + val adapter = GsonAdapter(TotpEnrollmentChallenge::class.java, gson) + return factory.post(url.toString(), adapter) + .addParameters(params) + .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + } + + /** + * Starts the enrollment of a Push Notification authenticator. + * + * ## Scopes Required + * `create:me:authentication_methods` + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * apiClient.enrollPushNotification() + * .start(object : Callback { + * override fun onSuccess(result: EnrollmentChallenge) { + * // The result will be a TotpEnrollmentChallenge containing a barcode_uri + * Log.d("MyApp", "Enrollment started for Push Notification.") + * } + * override fun onFailure(error: MyAccountException) { //... } + * }) + * ``` + * @return a request that will yield an enrollment challenge. + */ + public fun enrollPushNotification(): Request { + val params = ParameterBuilder.newBuilder().set(TYPE_KEY, "push-notification").asDictionary() + val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() + // The response structure for push notification challenge is the same as TOTP (contains barcode_uri) + val adapter = GsonAdapter(TotpEnrollmentChallenge::class.java, gson) + return factory.post(url.toString(), adapter) + .addParameters(params) + .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + } + + /** + * Starts the enrollment of a Recovery Code authenticator. + * + * ## Scopes Required + * `create:me:authentication_methods` + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * apiClient.enrollRecoveryCode() + * .start(object : Callback { + * override fun onSuccess(result: EnrollmentChallenge) { + * // The result will be a RecoveryCodeEnrollmentChallenge containing the code + * Log.d("MyApp", "Recovery Code enrollment started.") + * } + * override fun onFailure(error: MyAccountException) { //... } + * }) + * ``` + * @return a request that will yield an enrollment challenge containing the recovery code. + */ + public fun enrollRecoveryCode(): Request { + val params = ParameterBuilder.newBuilder().set(TYPE_KEY, "recovery-code").asDictionary() + val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() + val adapter = GsonAdapter(RecoveryCodeEnrollmentChallenge::class.java, gson) + return factory.post(url.toString(), adapter) + .addParameters(params) + .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + } + + /** + * Confirms the enrollment of a phone, email, or TOTP method by providing the one-time password (OTP). + * + * ## 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 otp = "123456" + * + * apiClient.verifyOtp(authMethodId, otp, authSession) + * .start(object : Callback { + * override fun onSuccess(result: AuthenticationMethod) { //... } + * override fun onFailure(error: MyAccountException) { //... } + * }) + * ``` + * @param authenticationMethodId The ID of the method being verified (from the enrollment challenge). + * @param otpCode The OTP code sent to the user's phone or email, or from their authenticator app. + * @param authSession The auth session from the enrollment challenge. + * @return a request that will yield the newly verified authentication method. + */ + public fun verifyOtp( + authenticationMethodId: String, + otpCode: String, + authSession: String + ): Request { + val url = getDomainUrlBuilder() + .addPathSegment(AUTHENTICATION_METHODS) + .addPathSegment(authenticationMethodId) + .addPathSegment(VERIFY) + .build() + val params = mapOf("otp_code" to otpCode, AUTH_SESSION_KEY to authSession) + return factory.post(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) + .addParameters(params) + .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + } + + /** + * Confirms the enrollment for factors that do not require an OTP. + * + * ## 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" + * + * apiClient.verify(authMethodId, authSession) + * .start(object : Callback { + * override fun onSuccess(result: AuthenticationMethod) { //... } + * 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. + * @return a request that will yield the newly verified authentication method. + */ + public fun verify( + authenticationMethodId: String, + authSession: String + ): Request { + val url = getDomainUrlBuilder() + .addPathSegment(AUTHENTICATION_METHODS) + .addPathSegment(authenticationMethodId) + .addPathSegment(VERIFY) + .build() + val params = mapOf(AUTH_SESSION_KEY to authSession) + return factory.post(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) + .addParameters(params) + .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + } + + // WebAuthn methods are private. + /** + * Starts the enrollment of a WebAuthn Platform (e.g., biometrics) authenticator. + * + * ## Scopes Required + * `create:me:authentication_methods` + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * apiClient.enrollWebAuthnPlatform() + * .start(object : Callback { + * override fun onSuccess(result: EnrollmentChallenge) { + * Log.d("MyApp", "Enrollment started for WebAuthn Platform.") + * } + * override fun onFailure(error: MyAccountException) { //... } + * }) + * ``` + * @return a request that will yield an enrollment challenge. + */ + private fun enrollWebAuthnPlatform(): Request { + val params = ParameterBuilder.newBuilder().set(TYPE_KEY, "webauthn-platform").asDictionary() + return buildEnrollmentRequest(params) + } + + /** + * Starts the enrollment of a WebAuthn Roaming (e.g., security key) authenticator. + * + * ## Scopes Required + * `create:me:authentication_methods` + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * apiClient.enrollWebAuthnRoaming() + * .start(object : Callback { + * override fun onSuccess(result: EnrollmentChallenge) { + * // The result will be a PasskeyEnrollmentChallenge for WebAuthn + * Log.d("MyApp", "Enrollment started for WebAuthn Roaming.") + * } + * override fun onFailure(error: MyAccountException) { //... } + * }) + * ``` + * @return a request that will yield an enrollment challenge. + */ + private fun enrollWebAuthnRoaming(): Request { + val params = ParameterBuilder.newBuilder().set(TYPE_KEY, "webauthn-roaming").asDictionary() + return buildEnrollmentRequest(params) + } + + private fun buildEnrollmentRequest(params: Map): Request { + val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() + return factory.post(url.toString(), GsonAdapter(EnrollmentChallenge::class.java, gson)) + .addParameters(params) .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") - return request } private fun getDomainUrlBuilder(): HttpUrl.Builder { @@ -283,6 +811,12 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting private const val LOCATION_KEY = "location" private const val AUTH_SESSION_KEY = "auth_session" private const val AUTHN_RESPONSE_KEY = "authn_response" + private const val PREFERRED_AUTHENTICATION_METHOD = "preferred_authentication_method" + private const val AUTHENTICATION_METHOD_NAME = "name" + private const val FACTORS = "factors" + private const val PHONE_NUMBER_KEY = "phone_number" + private const val EMAIL_KEY = "email" + private fun createErrorAdapter(): ErrorAdapter { val mapAdapter = forMap(GsonProvider.gson) return object : ErrorAdapter { @@ -314,4 +848,5 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting } } } -} \ No newline at end of file +} + diff --git a/auth0/src/main/java/com/auth0/android/myaccount/PhoneAuthenticationMethod.kt b/auth0/src/main/java/com/auth0/android/myaccount/PhoneAuthenticationMethod.kt new file mode 100644 index 000000000..4394c80fe --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/myaccount/PhoneAuthenticationMethod.kt @@ -0,0 +1,10 @@ +package com.auth0.android.myaccount + +/** + * Represents the preferred method for phone-based multi-factor authentication, either "sms" or "voice". + * This is used when enrolling a new phone factor or updating an existing one. + */ +public enum class PhoneAuthenticationMethodType(public val value: String) { + SMS("sms"), + VOICE("voice") +} \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/result/AuthenticationMethod.kt b/auth0/src/main/java/com/auth0/android/result/AuthenticationMethod.kt new file mode 100644 index 000000000..fcd5df8ce --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/AuthenticationMethod.kt @@ -0,0 +1,165 @@ +package com.auth0.android.result + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.annotations.JsonAdapter +import com.google.gson.annotations.SerializedName +import java.lang.reflect.Type + +public data class AuthenticationMethods( + @SerializedName("authentication_methods") + public val authenticationMethods: List +) + +@JsonAdapter(AuthenticationMethod.Deserializer::class) +public sealed class AuthenticationMethod { + public abstract val id: String + public abstract val type: String + public abstract val createdAt: String + public abstract val usage: List + + internal class Deserializer : JsonDeserializer { + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): AuthenticationMethod? { + val jsonObject = json.asJsonObject + val type = jsonObject.get("type")?.asString + val targetClass = when (type) { + "password" -> PasswordAuthenticationMethod::class.java + "passkey" -> PasskeyAuthenticationMethod::class.java + "recovery-code" -> RecoveryCodeAuthenticationMethod::class.java + "push-notification" -> PushNotificationAuthenticationMethod::class.java + "totp" -> TotpAuthenticationMethod::class.java + "webauthn-platform" -> WebAuthnPlatformAuthenticationMethod::class.java + "webauthn-roaming" -> WebAuthnRoamingAuthenticationMethod::class.java + "phone" -> PhoneAuthenticationMethod::class.java + "email" -> EmailAuthenticationMethod::class.java + else -> null + } + return context.deserialize(jsonObject, targetClass) + } + } +} + +public data class PasswordAuthenticationMethod( + @SerializedName("id") override val id: String, + @SerializedName("type") override val type: String, + @SerializedName("created_at") override val createdAt: String, + @SerializedName("usage") override val usage: List, + @SerializedName("identity_user_id") + public val identityUserId: String?, + @SerializedName("last_password_reset") + public val lastPasswordReset: String? +) : AuthenticationMethod() + +public data class PasskeyAuthenticationMethod( + @SerializedName("id") override val id: String, + @SerializedName("type") override val type: String, + @SerializedName("created_at") override val createdAt: String, + @SerializedName("usage") override val usage: List, + @SerializedName("credential_backed_up") + public val credentialBackedUp: Boolean?, + @SerializedName("credential_device_type") + public val credentialDeviceType: String?, + @SerializedName("identity_user_id") + public val identityUserId: String?, + @SerializedName("key_id") + public val keyId: String?, + @SerializedName("public_key") + public val publicKey: String?, + @SerializedName("transports") + public val transports: List?, + @SerializedName("user_agent") + public val userAgent: String?, + @SerializedName("user_handle") + public val userHandle: String? +) : AuthenticationMethod() + +public sealed class MfaAuthenticationMethod : AuthenticationMethod() { + public abstract val confirmed: Boolean? +} + +public data class RecoveryCodeAuthenticationMethod( + @SerializedName("id") override val id: String, + @SerializedName("type") override val type: String, + @SerializedName("created_at") override val createdAt: String, + @SerializedName("usage") override val usage: List, + @SerializedName("confirmed") override val confirmed: Boolean? +) : MfaAuthenticationMethod() + +public data class PushNotificationAuthenticationMethod( + @SerializedName("id") override val id: String, + @SerializedName("type") override val type: String, + @SerializedName("created_at") override val createdAt: String, + @SerializedName("usage") override val usage: List, + @SerializedName("confirmed") override val confirmed: Boolean?, + @SerializedName("name") + public val name: String? +) : MfaAuthenticationMethod() + +public data class TotpAuthenticationMethod( + @SerializedName("id") override val id: String, + @SerializedName("type") override val type: String, + @SerializedName("created_at") override val createdAt: String, + @SerializedName("usage") override val usage: List, + @SerializedName("confirmed") override val confirmed: Boolean?, + @SerializedName("name") + public val name: String? +) : MfaAuthenticationMethod() + +public sealed class WebAuthnAuthenticationMethod : MfaAuthenticationMethod() { + public abstract val name: String? + public abstract val keyId: String? + public abstract val publicKey: String? +} + +public data class WebAuthnPlatformAuthenticationMethod( + @SerializedName("id") override val id: String, + @SerializedName("type") override val type: String, + @SerializedName("created_at") override val createdAt: String, + @SerializedName("usage") override val usage: List, + @SerializedName("confirmed") override val confirmed: Boolean?, + @SerializedName("name") override val name: String?, + @SerializedName("key_id") override val keyId: String?, + @SerializedName("public_key") override val publicKey: String? +) : WebAuthnAuthenticationMethod() + +public data class WebAuthnRoamingAuthenticationMethod( + @SerializedName("id") override val id: String, + @SerializedName("type") override val type: String, + @SerializedName("created_at") override val createdAt: String, + @SerializedName("usage") override val usage: List, + @SerializedName("confirmed") override val confirmed: Boolean?, + @SerializedName("name") override val name: String?, + @SerializedName("key_id") override val keyId: String?, + @SerializedName("public_key") override val publicKey: String? +) : WebAuthnAuthenticationMethod() + +public data class PhoneAuthenticationMethod( + @SerializedName("id") override val id: String, + @SerializedName("type") override val type: String, + @SerializedName("created_at") override val createdAt: String, + @SerializedName("usage") override val usage: List, + @SerializedName("confirmed") override val confirmed: Boolean?, + @SerializedName("name") + public val name: String?, + @SerializedName("phone_number") + public val phoneNumber: String?, + @SerializedName("preferred_authentication_method") + public val preferredAuthenticationMethod: String? +) : MfaAuthenticationMethod() + +public data class EmailAuthenticationMethod( + @SerializedName("id") override val id: String, + @SerializedName("type") override val type: String, + @SerializedName("created_at") override val createdAt: String, + @SerializedName("usage") override val usage: List, + @SerializedName("confirmed") override val confirmed: Boolean?, + @SerializedName("name") + public val name: String?, + @SerializedName("email") + public val email: String? +) : MfaAuthenticationMethod() \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt b/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt new file mode 100644 index 000000000..f79df9abc --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt @@ -0,0 +1,58 @@ +package com.auth0.android.result + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.annotations.JsonAdapter +import com.google.gson.annotations.SerializedName +import java.lang.reflect.Type + +@JsonAdapter(EnrollmentChallenge.Deserializer::class) +public sealed class EnrollmentChallenge { + public abstract val id: String? + public abstract val authSession: String + + internal class Deserializer : JsonDeserializer { + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): EnrollmentChallenge? { + val jsonObject = json.asJsonObject + val targetClass = when { + jsonObject.has("barcode_uri") -> TotpEnrollmentChallenge::class.java + jsonObject.has("recovery_code") -> RecoveryCodeEnrollmentChallenge::class.java + jsonObject.has("authn_params_public_key") -> PasskeyEnrollmentChallenge::class.java + else -> MfaEnrollmentChallenge::class.java + } + return context.deserialize(jsonObject, targetClass) + } + } +} + +public data class MfaEnrollmentChallenge( + @SerializedName("id") + override val id: String, + @SerializedName("auth_session") + override val authSession: String +) : EnrollmentChallenge() + +public data class TotpEnrollmentChallenge( + @SerializedName("id") + override val id: String, + @SerializedName("auth_session") + override val authSession: String, + @SerializedName("barcode_uri") + public val barcodeUri: String, + @SerializedName("manual_input_code") + public val manualInputCode: String? +) : EnrollmentChallenge() + +public data class RecoveryCodeEnrollmentChallenge( + @SerializedName("id") + override val id: String, + @SerializedName("auth_session") + override val authSession: String, + @SerializedName("recovery_code") + public val recoveryCode: String +) : EnrollmentChallenge() \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/result/EnrollmentPayload.kt b/auth0/src/main/java/com/auth0/android/result/EnrollmentPayload.kt new file mode 100644 index 000000000..32612b363 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/EnrollmentPayload.kt @@ -0,0 +1,41 @@ +package com.auth0.android.result + +import com.google.gson.annotations.SerializedName + +/** + * Represents the payload for an enrollment request. + * This is a sealed class to handle different types of enrollment payloads. + */ +public sealed class EnrollmentPayload( + @SerializedName("type") + public open val type: String +) + +public data class PasskeyEnrollmentPayload( + @SerializedName("connection") + public val connection: String?, + @SerializedName("identity_user_id") + public val identityUserId: String? +) : EnrollmentPayload("passkey") + +public object WebAuthnPlatformEnrollmentPayload : EnrollmentPayload("webauthn-platform") + +public object WebAuthnRoamingEnrollmentPayload : EnrollmentPayload("webauthn-roaming") + +public object TotpEnrollmentPayload : EnrollmentPayload("totp") + +public object PushNotificationEnrollmentPayload : EnrollmentPayload("push-notification") + +public object RecoveryCodeEnrollmentPayload : EnrollmentPayload("recovery-code") + +public data class EmailEnrollmentPayload( + @SerializedName("email") + public val email: String +) : EnrollmentPayload("email") + +public data class PhoneEnrollmentPayload( + @SerializedName("phone_number") + public val phoneNumber: String, + @SerializedName("preferred_authentication_method") + public val preferredAuthenticationMethod: String +) : EnrollmentPayload("phone") \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/result/Factor.kt b/auth0/src/main/java/com/auth0/android/result/Factor.kt new file mode 100644 index 000000000..3a2f4b6f4 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/Factor.kt @@ -0,0 +1,10 @@ +package com.auth0.android.result + +import com.google.gson.annotations.SerializedName + +public data class Factor( + @SerializedName("type") + public val type: String, + @SerializedName("usage") + public val usage: List? +) \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/result/Factors.kt b/auth0/src/main/java/com/auth0/android/result/Factors.kt new file mode 100644 index 000000000..d9085c8ea --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/Factors.kt @@ -0,0 +1,11 @@ +package com.auth0.android.result + +import com.google.gson.annotations.SerializedName + +/** + * A wrapper class for the list of factors returned by the API. + */ +public data class Factors( + @SerializedName("factors") + public val factors: List +) \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/result/PasskeyAuthenticationMethod.kt b/auth0/src/main/java/com/auth0/android/result/PasskeyAuthenticationMethod.kt deleted file mode 100644 index 8d31a26d4..000000000 --- a/auth0/src/main/java/com/auth0/android/result/PasskeyAuthenticationMethod.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.auth0.android.result - - -import com.google.gson.annotations.SerializedName - -/** - * A passkey authentication method. - */ -public data class PasskeyAuthenticationMethod( - @SerializedName("created_at") - val createdAt: String, - @SerializedName("credential_backed_up") - val credentialBackedUp: Boolean, - @SerializedName("credential_device_type") - val credentialDeviceType: String, - @SerializedName("id") - val id: String, - @SerializedName("identity_user_id") - val identityUserId: String, - @SerializedName("key_id") - val keyId: String, - @SerializedName("public_key") - val publicKey: String, - @SerializedName("transports") - val transports: List?, - @SerializedName("type") - val type: String, - @SerializedName("user_agent") - val userAgent: String, - @SerializedName("user_handle") - val userHandle: String -) \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/result/PasskeyEnrollmentChallenge.kt b/auth0/src/main/java/com/auth0/android/result/PasskeyEnrollmentChallenge.kt index 2aca92352..f9b766bd0 100644 --- a/auth0/src/main/java/com/auth0/android/result/PasskeyEnrollmentChallenge.kt +++ b/auth0/src/main/java/com/auth0/android/result/PasskeyEnrollmentChallenge.kt @@ -1,14 +1,11 @@ package com.auth0.android.result -import com.google.gson.annotations.SerializedName - /** - * Represents the challenge data required for enrolling a passkey. + * A passkey enrollment challenge, combining the authentication method ID from the response headers + * with the challenge details from the response body. */ public data class PasskeyEnrollmentChallenge( - val authenticationMethodId: String, - @SerializedName("auth_session") - val authSession: String, - @SerializedName("authn_params_public_key") - val authParamsPublicKey: AuthnParamsPublicKey -) + public val authenticationMethodId: String, + public val authSession: String, + public val authParamsPublicKey: AuthnParamsPublicKey +) \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/result/VerificationPayload.kt b/auth0/src/main/java/com/auth0/android/result/VerificationPayload.kt new file mode 100644 index 000000000..59e8146e7 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/VerificationPayload.kt @@ -0,0 +1,11 @@ +package com.auth0.android.result + +import com.google.gson.annotations.SerializedName + +/** + * Represents the payload for a verification request, such as providing an OTP code. + */ +public data class VerifyOtpPayload( + @SerializedName("otp_code") + public val otpCode: String +) \ No newline at end of file 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 a61501c60..342cf3b34 100644 --- a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt @@ -3,8 +3,13 @@ package com.auth0.android.myaccount import com.auth0.android.Auth0 import com.auth0.android.request.PublicKeyCredentials import com.auth0.android.request.Response +import com.auth0.android.result.AuthenticationMethod +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.RecoveryCodeEnrollmentChallenge +import com.auth0.android.result.TotpEnrollmentChallenge import com.auth0.android.util.AuthenticationAPIMockServer.Companion.SESSION_ID import com.auth0.android.util.MockMyAccountCallback import com.auth0.android.util.MyAccountAPIMockServer @@ -23,8 +28,6 @@ import org.junit.runner.RunWith import org.mockito.MockitoAnnotations import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import java.util.Map - @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE) @@ -110,7 +113,7 @@ public class MyAccountAPIClientTest { } mockAPI.takeRequest() assertThat(error, Matchers.notNullValue()) - assertThat(error?.message, Matchers.`is`("Authentication method ID not found")) + assertThat(error?.message, Matchers.`is`("Authentication method ID not found in Location header.")) } @@ -309,9 +312,163 @@ public class MyAccountAPIClientTest { ) } + @Test + public fun `getFactors should build correct URL and Authorization header`() { + val callback = MockMyAccountCallback>() + client.getFactors().start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/me/v1/factors")) + assertThat(request.getHeader("Authorization"), Matchers.equalTo("Bearer $ACCESS_TOKEN")) + assertThat(request.method, Matchers.equalTo("GET")) + } + + @Test + public fun `getAuthenticationMethods should build correct URL and Authorization header`() { + val callback = MockMyAccountCallback>() + client.getAuthenticationMethods().start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods")) + assertThat(request.getHeader("Authorization"), Matchers.equalTo("Bearer $ACCESS_TOKEN")) + assertThat(request.method, Matchers.equalTo("GET")) + } + + @Test + public fun `getAuthenticationMethodById should build correct URL and Authorization header`() { + val callback = MockMyAccountCallback() + val methodId = "email|12345" + client.getAuthenticationMethodById(methodId).start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods/email%7C12345")) + assertThat(request.getHeader("Authorization"), Matchers.equalTo("Bearer $ACCESS_TOKEN")) + assertThat(request.method, Matchers.equalTo("GET")) + } + + @Test + public fun `deleteAuthenticationMethod should build correct URL and Authorization header`() { + val callback = MockMyAccountCallback() + val methodId = "email|12345" + client.deleteAuthenticationMethod(methodId).start(callback) - private fun bodyFromRequest(request: RecordedRequest): kotlin.collections.Map { - val mapType = object : TypeToken?>() {}.type + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods/email%7C12345")) + assertThat(request.getHeader("Authorization"), Matchers.equalTo("Bearer $ACCESS_TOKEN")) + assertThat(request.method, Matchers.equalTo("DELETE")) + } + + @Test + public fun `updateAuthenticationMethodById for phone should build correct URL and payload`() { + val callback = MockMyAccountCallback() + val methodId = "phone|12345" + client.updateAuthenticationMethodById(methodId, preferredAuthenticationMethod = PhoneAuthenticationMethodType.SMS).start(callback) + + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods/phone%7C12345")) + assertThat(request.method, Matchers.equalTo("PATCH")) + assertThat(body, Matchers.hasEntry("preferred_authentication_method", "sms" as Any)) + } + + @Test + public fun `updateAuthenticationMethodById for totp should build correct URL and payload`() { + val callback = MockMyAccountCallback() + val methodId = "totp|12345" + val name = "My Authenticator" + client.updateAuthenticationMethodById(methodId, authenticationMethodName = name).start(callback) + + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods/totp%7C12345")) + assertThat(request.method, Matchers.equalTo("PATCH")) + assertThat(body, Matchers.hasEntry("name", name as Any)) + } + + @Test + public fun `enrollEmail should send correct payload`() { + val callback = MockMyAccountCallback() + val email = "test@example.com" + client.enrollEmail(email).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", "email" as Any)) + assertThat(body, Matchers.hasEntry("email", email as Any)) + } + + @Test + public fun `enrollPhone should send correct payload`() { + val callback = MockMyAccountCallback() + val phoneNumber = "+11234567890" + client.enrollPhone(phoneNumber, PhoneAuthenticationMethodType.SMS).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", "phone" as Any)) + assertThat(body, Matchers.hasEntry("phone_number", phoneNumber as Any)) + assertThat(body, Matchers.hasEntry("preferred_authentication_method", "sms" as Any)) + } + + @Test + public fun `enrollTotp should send correct payload`() { + val callback = MockMyAccountCallback() + client.enrollTotp().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", "totp" as Any)) + } + + + @Test + public fun `enrollRecoveryCode should send correct payload`() { + val callback = MockMyAccountCallback() + client.enrollRecoveryCode().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", "recovery-code" as Any)) + } + + @Test + public fun `verifyOtp should send correct payload`() { + val callback = MockMyAccountCallback() + val methodId = "email|123" + val otp = "123456" + val session = "abc-def" + client.verifyOtp(methodId, otp, session).start(callback) + + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods/email%7C123/verify")) + assertThat(request.method, Matchers.equalTo("POST")) + assertThat(body, Matchers.hasEntry("otp_code", otp as Any)) + assertThat(body, Matchers.hasEntry("auth_session", session as Any)) + } + + @Test + public fun `enrollPushNotification should send correct payload`() { + val callback = MockMyAccountCallback() + client.enrollPushNotification().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", "push-notification" as Any)) + } + + private fun bodyFromRequest(request: RecordedRequest): Map { + val mapType = object : TypeToken?>() {}.type return gson.fromJson(request.body.readUtf8(), mapType) } @@ -347,5 +504,3 @@ public class MyAccountAPIClientTest { private const val AUTH_SESSION = "session456" } } - -