From af93d4ee48ba991f02cd182c4737c7ce010adc3d Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Tue, 30 Jun 2026 13:35:46 +0530 Subject: [PATCH 1/2] Adding the new changes in main to v4 --- .../authentication/AuthenticationAPIClient.kt | 73 ++++- .../passwordless/DeliveryMethod.kt | 17 + .../passwordless/PasswordlessClient.kt | 302 ++++++++++++++++++ .../authentication/request/ActorToken.kt | 20 ++ .../myaccount/AuthenticationMethodType.kt | 16 + .../android/myaccount/MyAccountAPIClient.kt | 24 +- .../com/auth0/android/request/UserData.kt | 14 +- .../internal/UserProfileDeserializer.java | 33 +- .../com/auth0/android/result/ActorClaim.kt | 17 + .../android/result/PasswordlessChallenge.kt | 17 + .../com/auth0/android/result/UserProfile.kt | 10 +- .../AuthenticationAPIClientTest.kt | 175 +++++++++- .../authentication/PasswordlessClientTest.kt | 284 ++++++++++++++++ .../myaccount/MyAccountAPIClientTest.kt | 47 +++ .../request/internal/UserProfileGsonTest.kt | 125 ++++++++ .../auth0/android/result/UserProfileTest.java | 6 +- 16 files changed, 1159 insertions(+), 21 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/authentication/passwordless/DeliveryMethod.kt create mode 100644 auth0/src/main/java/com/auth0/android/authentication/passwordless/PasswordlessClient.kt create mode 100644 auth0/src/main/java/com/auth0/android/authentication/request/ActorToken.kt create mode 100644 auth0/src/main/java/com/auth0/android/myaccount/AuthenticationMethodType.kt create mode 100644 auth0/src/main/java/com/auth0/android/result/ActorClaim.kt create mode 100644 auth0/src/main/java/com/auth0/android/result/PasswordlessChallenge.kt create mode 100644 auth0/src/test/java/com/auth0/android/authentication/PasswordlessClientTest.kt diff --git a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt index 40867a9aa..e0266b557 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -6,6 +6,8 @@ import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception import com.auth0.android.NetworkErrorException import com.auth0.android.authentication.mfa.MfaApiClient +import com.auth0.android.authentication.passwordless.PasswordlessClient +import com.auth0.android.authentication.request.ActorToken import com.auth0.android.dpop.DPoP import com.auth0.android.dpop.DPoPException import com.auth0.android.dpop.SenderConstraining @@ -121,6 +123,27 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe return MfaApiClient(this.auth0, mfaToken) } + /** + * Creates a [PasswordlessClient] for the database-connection passwordless flow. + * + * ## 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 passwordless = authClient.passwordlessClient() + * ``` + * + * @return a new [PasswordlessClient] instance bound to this client's Auth0 account. + */ + public fun passwordlessClient(): PasswordlessClient { + return PasswordlessClient(this.auth0, gson, this.dPoP) + } + /** * Log in a user with email/username and password for a connection/realm. * It will use the password-realm grant type for the `/oauth/token` endpoint @@ -281,17 +304,24 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * * Example usage: * - * * ``` - * client.signupWithPasskey("{userData}","{realm}","{organization}") - * .addParameter("scope","scope") - * .start(object: Callback { - * override fun onSuccess(result: PasskeyRegistration) { } + * val userData = UserData( + * email = "user@example.com", + * name = "John Doe", + * givenName = "John", + * familyName = "Doe", + * nickName = "johnny", + * picture = "https://example.com/photo.png", + * userMetadata = mapOf("signup_source" to "android_app") + * ) + * client.signupWithPasskey(userData, "{realm}", "{organization}") + * .start(object: Callback { + * override fun onSuccess(result: PasskeyRegistrationChallenge) { } * override fun onFailure(error: AuthenticationException) { } * }) * ``` * - * @param userData user information of the client + * @param userData user information for registration. * @param realm the connection to use. If excluded, the application will use the default connection configured in the tenant * @param organization id of the organization to be associated with the user while signing up * @return a request to configure and start that will yield [PasskeyRegistrationChallenge] @@ -301,7 +331,6 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe realm: String? = null, organization: String? = null ): Request { - val user = gson.toJsonTree(userData) val url = auth0.getDomainUrl().toHttpUrl().newBuilder() .addPathSegment(PASSKEY_PATH) .addPathSegment(REGISTER_PATH) @@ -319,7 +348,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe ) val post = factory.post(url.toString(), passkeyRegistrationChallengeAdapter) .addParameters(params) as BaseRequest - post.addParameter(USER_PROFILE_KEY, user) + post.addParameter(USER_PROFILE_KEY, gson.toJsonTree(userData.toUserProfile())) + userData.userMetadata?.let { post.addParameter(USER_METADATA_KEY, it) } return post } @@ -650,17 +680,31 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * }) * ``` * + * For delegation/impersonation scenarios, pass [ActorToken] with actor token details. + * When the server issues tokens with an `act` claim, it will be available via [Credentials.user] actor property. + * + * Note: When `actor_token` is present, Auth0 will not issue a refresh token regardless of + * whether `offline_access` is in the scope. The [Credentials.refreshToken] will be null. + * * @param subjectTokenType the subject token type that is associated with the existing Identity Provider. e.g. 'http://acme.com/legacy-token' * @param subjectToken the subject token, typically obtained through the Identity Provider's SDK * @param organization id of the organization the user belongs to + * @param actorToken optional actor token details for delegation/impersonation flows. * @return a request to configure and start that will yield [Credentials] */ + @JvmOverloads public fun customTokenExchange( subjectTokenType: String, subjectToken: String, - organization: String? = null + organization: String? = null, + actorToken: ActorToken? = null ): AuthenticationRequest { - return tokenExchange(subjectTokenType, subjectToken, organization) + return tokenExchange( + subjectTokenType, + subjectToken, + organization, + actorToken + ) } /** @@ -943,7 +987,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe private fun tokenExchange( subjectTokenType: String, subjectToken: String, - organization: String? = null + organization: String? = null, + actorToken: ActorToken? = null ): AuthenticationRequest { val parameters = ParameterBuilder.newAuthenticationBuilder().apply { setGrantType(ParameterBuilder.GRANT_TYPE_TOKEN_EXCHANGE) @@ -952,6 +997,10 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe organization?.let { set(ORGANIZATION_KEY, it) } + actorToken?.let { + set(ACTOR_TOKEN_KEY, it.token) + set(ACTOR_TOKEN_TYPE_KEY, it.tokenType) + } }.asDictionary() return loginWithToken(parameters) } @@ -979,7 +1028,9 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe private const val TOKEN_KEY = "token" private const val ONE_TIME_PASSWORD_KEY = "otp" private const val SUBJECT_TOKEN_KEY = "subject_token" + private const val ACTOR_TOKEN_KEY = "actor_token" private const val SUBJECT_TOKEN_TYPE_KEY = "subject_token_type" + private const val ACTOR_TOKEN_TYPE_KEY = "actor_token_type" private const val ORGANIZATION_KEY = "organization" private const val USER_METADATA_KEY = "user_metadata" private const val AUTH_SESSION_KEY = "auth_session" diff --git a/auth0/src/main/java/com/auth0/android/authentication/passwordless/DeliveryMethod.kt b/auth0/src/main/java/com/auth0/android/authentication/passwordless/DeliveryMethod.kt new file mode 100644 index 000000000..f4abbbb0e --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/authentication/passwordless/DeliveryMethod.kt @@ -0,0 +1,17 @@ +package com.auth0.android.authentication.passwordless + +/** + * Delivery method for a phone-number OTP challenge. + * + * Maps to the `delivery_method` request parameter of `POST /otp/challenge`. [TEXT] sends the + * one-time code via SMS (the server default); [VOICE] delivers it through a voice call. + * + * @property value the wire value sent to the server. + */ +public enum class DeliveryMethod(public val value: String) { + /** Deliver the one-time code via SMS. */ + TEXT("text"), + + /** Deliver the one-time code via a voice call. */ + VOICE("voice") +} diff --git a/auth0/src/main/java/com/auth0/android/authentication/passwordless/PasswordlessClient.kt b/auth0/src/main/java/com/auth0/android/authentication/passwordless/PasswordlessClient.kt new file mode 100644 index 000000000..117fbcaa7 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/authentication/passwordless/PasswordlessClient.kt @@ -0,0 +1,302 @@ +package com.auth0.android.authentication.passwordless + +import androidx.annotation.VisibleForTesting +import com.auth0.android.Auth0 +import com.auth0.android.Auth0Exception +import com.auth0.android.NetworkErrorException +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.authentication.ParameterBuilder +import com.auth0.android.dpop.DPoP +import com.auth0.android.dpop.DPoPException +import com.auth0.android.request.AuthenticationRequest +import com.auth0.android.request.ErrorAdapter +import com.auth0.android.request.JsonAdapter +import com.auth0.android.request.Request +import com.auth0.android.request.RequestOptions +import com.auth0.android.request.RequestValidator +import com.auth0.android.request.internal.BaseAuthenticationRequest +import com.auth0.android.request.internal.GsonAdapter +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.Credentials +import com.auth0.android.result.PasswordlessChallenge +import com.google.gson.Gson +import okhttp3.HttpUrl.Companion.toHttpUrl +import java.io.IOException +import java.io.Reader + +/** + * API client for the database-connection passwordless authentication flow. + * + * Obtain an instance from + * [com.auth0.android.authentication.AuthenticationAPIClient.passwordlessClient]. + * + * ## 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. + * + * @see com.auth0.android.authentication.AuthenticationAPIClient.passwordlessClient + */ +public class PasswordlessClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal constructor( + private val auth0: Auth0, + private val gson: Gson, + private val dPoP: DPoP? +) { + + private val requestFactory: RequestFactory by lazy { + RequestFactory(auth0.networkingClient, createErrorAdapter()).apply { + setAuth0ClientInfo(auth0.auth0UserAgent.value) + } + } + + /** + * Creates a new PasswordlessClient instance. + * + * @param auth0 the Auth0 account information. + */ + public constructor(auth0: Auth0) : this(auth0, GsonProvider.gson, null) + + private val clientId: String = auth0.clientId + private val baseURL: String = auth0.getDomainUrl() + + /** + * Issues an OTP challenge to an email address for a database connection. + * + * Sends a one-time code to the given email for a connection that has `email_otp` enabled. + * For privacy, the server **always responds successfully regardless of whether the user + * exists** (user-enumeration prevention). On success an opaque [PasswordlessChallenge.authSession] + * is returned — pass it to [loginWithOTP] together with the code the user receives. + * + * ## 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 + * passwordless.challengeWithEmail("user@example.com", "Username-Password-Authentication") + * .start(object : Callback { + * override fun onSuccess(result: PasswordlessChallenge) { + * val challenge = result + * } + * override fun onFailure(error: AuthenticationException) { } + * }) + * ``` + * + * @param email the email address to send the one-time code to. + * @param connection the name of the database connection; it must have `email_otp` enabled. + * @param allowSignup whether to allow sign-up if the user does not yet exist. Defaults to `false`. + * @return a request that, when started, yields a [PasswordlessChallenge] containing the `auth_session`. + * @see loginWithOTP + */ + @JvmOverloads + public fun challengeWithEmail( + email: String, + connection: String, + allowSignup: Boolean = false + ): Request { + val parameters = ParameterBuilder.newBuilder() + .setClientId(clientId) + .setConnection(connection) + .set(ALLOW_SIGNUP_KEY, allowSignup.toString()) + .set(EMAIL_KEY, email) + .asDictionary() + return challengeRequest(parameters).addValidator(object : RequestValidator { + override fun validate(options: RequestOptions) { + requireNotBlank(email, EMAIL_KEY) + requireNotBlank(connection, CONNECTION_KEY) + } + }) + } + + /** + * Issues an OTP challenge to a phone number for a database connection. + * + * Sends a one-time code to the given phone number for a connection that has `phone_otp` + * enabled, delivered either by SMS or voice call per [deliveryMethod]. For privacy, the server + * **always responds successfully regardless of whether the user exists**. + * + * ## 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 + * passwordless.challengeWithPhoneNumber("+15555550123", "Username-Password-Authentication", DeliveryMethod.TEXT) + * .start(object : Callback { + * override fun onSuccess(result: PasswordlessChallenge) { + * val challenge = result + * } + * override fun onFailure(error: AuthenticationException) { } + * }) + * ``` + * + * @param phoneNumber the E.164 phone number to send the one-time code to (e.g. `"+15555550123"`). + * @param connection the name of the database connection; it must have `phone_otp` enabled. + * @param deliveryMethod how to deliver the code. Defaults to [DeliveryMethod.TEXT]. + * @param allowSignup whether to allow sign-up if the user does not yet exist. Defaults to `false`. + * @return a request that, when started, yields a [PasswordlessChallenge] containing the `auth_session`. + * @see loginWithOTP + */ + @JvmOverloads + public fun challengeWithPhoneNumber( + phoneNumber: String, + connection: String, + deliveryMethod: DeliveryMethod = DeliveryMethod.TEXT, + allowSignup: Boolean = false + ): Request { + val parameters = ParameterBuilder.newBuilder() + .setClientId(clientId) + .setConnection(connection) + .set(ALLOW_SIGNUP_KEY, allowSignup.toString()) + .set(PHONE_NUMBER_KEY, phoneNumber) + .set(DELIVERY_METHOD_KEY, deliveryMethod.value) + .asDictionary() + return challengeRequest(parameters).addValidator(object : RequestValidator { + override fun validate(options: RequestOptions) { + requireNotBlank(phoneNumber, PHONE_NUMBER_KEY) + requireNotBlank(connection, CONNECTION_KEY) + } + }) + } + + /** + * Completes the OTP flow by verifying the one-time code and obtaining credentials. + * + * Exchanges the opaque `auth_session` returned by [challengeWithEmail] or + * [challengeWithPhoneNumber], together with the code the user received, for [Credentials] using + * the passwordless OTP grant on `POST /oauth/token`. When DPoP is enabled on the originating + * [com.auth0.android.authentication.AuthenticationAPIClient], a DPoP proof is attached. + * + * ## 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 + * passwordless.loginWithOTP(challenge, "123456") + * .start(object : Callback { + * override fun onSuccess(result: Credentials) { } + * override fun onFailure(error: AuthenticationException) { } + * }) + * ``` + * + * @param passwordlessChallenge the challenge from a prior challenge (see [PasswordlessChallenge]). + * @param otp the one-time code the user received via email, SMS, or voice call. + * @return a request that, when started, yields [Credentials] on success. + * @see challengeWithEmail + * @see challengeWithPhoneNumber + */ + public fun loginWithOTP( + passwordlessChallenge: PasswordlessChallenge, + otp: String + ): AuthenticationRequest { + val url = baseURL.toHttpUrl().newBuilder() + .addPathSegment(OAUTH_PATH) + .addPathSegment(TOKEN_PATH) + .build() + + val parameters = ParameterBuilder.newAuthenticationBuilder() + .setClientId(clientId) + .setGrantType(ParameterBuilder.GRANT_TYPE_PASSWORDLESS_OTP) + .set(AUTH_SESSION_KEY, passwordlessChallenge.authSession) + .set(ONE_TIME_PASSWORD_KEY, otp) + .asDictionary() + + val credentialsAdapter: JsonAdapter = + GsonAdapter(Credentials::class.java, gson) + + val request = BaseAuthenticationRequest( + requestFactory.post(url.toString(), credentialsAdapter, dPoP), clientId, baseURL + ).apply { + addParameters(parameters) + addValidator(object : RequestValidator { + override fun validate(options: RequestOptions) { + requireNotBlank(otp, ONE_TIME_PASSWORD_KEY) + } + }) + } + return request + } + + private fun challengeRequest( + parameters: Map + ): Request { + val url = baseURL.toHttpUrl().newBuilder() + .addPathSegment(OTP_PATH) + .addPathSegment(CHALLENGE_PATH) + .build() + + val challengeAdapter: JsonAdapter = + GsonAdapter(PasswordlessChallenge::class.java, gson) + + return requestFactory.post(url.toString(), challengeAdapter) + .addParameters(parameters) + } + + private fun requireNotBlank(value: String, name: String) { + if (value.isBlank()) { + throw AuthenticationException(INVALID_REQUEST, "$name is required") + } + } + + private fun createErrorAdapter(): ErrorAdapter { + val mapAdapter = GsonAdapter.forMap(gson) + return object : ErrorAdapter { + override fun fromRawResponse( + statusCode: Int, bodyText: String, headers: Map> + ): AuthenticationException = AuthenticationException(bodyText, statusCode) + + @Throws(IOException::class) + override fun fromJsonResponse( + statusCode: Int, + reader: Reader + ): AuthenticationException { + val values = mapAdapter.fromJson(reader) + return AuthenticationException(values, statusCode) + } + + override fun fromException(cause: Throwable): AuthenticationException { + if (isNetworkError(cause)) { + return AuthenticationException( + "Failed to execute the network request", NetworkErrorException(cause) + ) + } + if (cause is DPoPException) { + return AuthenticationException( + cause.message ?: "Error while attaching DPoP proof", cause + ) + } + return AuthenticationException( + "Something went wrong", Auth0Exception("Something went wrong", cause) + ) + } + } + } + + private companion object { + private const val OTP_PATH = "otp" + private const val CHALLENGE_PATH = "challenge" + private const val OAUTH_PATH = "oauth" + private const val TOKEN_PATH = "token" + private const val EMAIL_KEY = "email" + private const val PHONE_NUMBER_KEY = "phone_number" + private const val DELIVERY_METHOD_KEY = "delivery_method" + private const val ALLOW_SIGNUP_KEY = "allow_signup" + private const val CONNECTION_KEY = "connection" + private const val AUTH_SESSION_KEY = "auth_session" + private const val ONE_TIME_PASSWORD_KEY = "otp" + private const val INVALID_REQUEST = "invalid_request" + } +} diff --git a/auth0/src/main/java/com/auth0/android/authentication/request/ActorToken.kt b/auth0/src/main/java/com/auth0/android/authentication/request/ActorToken.kt new file mode 100644 index 000000000..68fc073b2 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/authentication/request/ActorToken.kt @@ -0,0 +1,20 @@ +package com.auth0.android.authentication.request + +/** + * Represents the acting party in a token exchange delegation/impersonation flow. + * + * An `ActorToken` bundles the token and its type URI together, ensuring both are always provided as required by + * [RFC 8693](https://tools.ietf.org/html/rfc8693). Auth0 requires both `actor_token` and `actor_token_type` to be + * present when performing delegation. + * + * @param token The token representing the acting party (the entity performing actions on behalf of the subject). + * @param tokenType A URI indicating the type of the actor token (e.g., `urn:ietf:params:oauth:token-type:id_token` + * or a custom URI like `http://corporate-idp/id-token`). + * + * @see [RFC 8693: OAuth 2.0 Token Exchange](https://tools.ietf.org/html/rfc8693#section-2.1) + * @see [Custom Token Exchange Documentation](https://auth0.com/docs/authenticate/custom-token-exchange) + */ +public data class ActorToken( + val token: String, + val tokenType: String +) diff --git a/auth0/src/main/java/com/auth0/android/myaccount/AuthenticationMethodType.kt b/auth0/src/main/java/com/auth0/android/myaccount/AuthenticationMethodType.kt new file mode 100644 index 000000000..52d1d4d58 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/myaccount/AuthenticationMethodType.kt @@ -0,0 +1,16 @@ +package com.auth0.android.myaccount + +/** + * Represents the types of authentication methods supported by the My Account API. + */ +public enum class AuthenticationMethodType(public val type: String) { + PASSWORD("password"), + PASSKEY("passkey"), + TOTP("totp"), + PHONE("phone"), + EMAIL("email"), + PUSH("push-notification"), + RECOVERY_CODE("recovery-code"), + WEBAUTHN_PLATFORM("webauthn-platform"), + WEBAUTHN_ROAMING("webauthn-roaming") +} 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 0a71b1f66..69a683cc6 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -264,6 +264,7 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * val apiClient = MyAccountAPIClient(auth0, accessToken) * * + * // Get all authentication methods * apiClient.getAuthenticationMethods() * .start(object : Callback, MyAccountException> { * override fun onSuccess(result: List) { @@ -274,11 +275,30 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * Log.e("MyApp", "Failed with: ${error.message}") * } * }) + * + * // Get authentication methods filtered by type + * apiClient.getAuthenticationMethods(AuthenticationMethodType.PASSKEY) + * .start(object : Callback, MyAccountException> { + * override fun onSuccess(result: List) { + * Log.d("MyApp", "Passkey methods: $result") + * } + * + * override fun onFailure(error: MyAccountException) { + * Log.e("MyApp", "Failed with: ${error.message}") + * } + * }) * ``` * + * @param type Optional filter to retrieve only authentication methods of a specific type. + * @return A request to get the list of authentication methods. + * */ - public fun getAuthenticationMethods(): Request, MyAccountException> { - val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() + @JvmOverloads + public fun getAuthenticationMethods(type: AuthenticationMethodType? = null): Request, MyAccountException> { + val url = getDomainUrlBuilder().apply { + addPathSegment(AUTHENTICATION_METHODS) + type?.let { addQueryParameter(TYPE_KEY, it.type) } + }.build() val listAdapter = object : JsonAdapter> { override fun fromJson( diff --git a/auth0/src/main/java/com/auth0/android/request/UserData.kt b/auth0/src/main/java/com/auth0/android/request/UserData.kt index 693b64368..e22850bb8 100644 --- a/auth0/src/main/java/com/auth0/android/request/UserData.kt +++ b/auth0/src/main/java/com/auth0/android/request/UserData.kt @@ -8,10 +8,22 @@ import com.google.gson.annotations.SerializedName * @param phoneNumber the phone number of the user. phone number can be optional, required, or forbidden depending on the attribute configuration for the database * @param userName the username of the user. username can be optional, required, or forbidden depending on the attribute configuration for the database * @param name optional display name + * @param givenName the first name of the user + * @param familyName the last name of the user + * @param nickName the preferred nickname of the user + * @param picture URL pointing to the user's profile picture + * @param userMetadata additional user metadata as key-value pairs */ public data class UserData( @field:SerializedName("email") val email: String? = null, @field:SerializedName("phone_number") val phoneNumber: String? = null, @field:SerializedName("username") val userName: String? = null, @field:SerializedName("name") val name: String? = null, -) \ No newline at end of file + @field:SerializedName("given_name") val givenName: String? = null, + @field:SerializedName("family_name") val familyName: String? = null, + @field:SerializedName("nickname") val nickName: String? = null, + @field:SerializedName("picture") val picture: String? = null, + @field:SerializedName("user_metadata") val userMetadata: Map? = null, +) { + internal fun toUserProfile(): UserData = copy(userMetadata = null) +} diff --git a/auth0/src/main/java/com/auth0/android/request/internal/UserProfileDeserializer.java b/auth0/src/main/java/com/auth0/android/request/internal/UserProfileDeserializer.java index b40fbffd1..57259286f 100755 --- a/auth0/src/main/java/com/auth0/android/request/internal/UserProfileDeserializer.java +++ b/auth0/src/main/java/com/auth0/android/request/internal/UserProfileDeserializer.java @@ -1,5 +1,8 @@ package com.auth0.android.request.internal; +import android.util.Log; + +import com.auth0.android.result.ActorClaim; import com.auth0.android.result.UserIdentity; import com.auth0.android.result.UserProfile; import com.google.gson.Gson; @@ -11,12 +14,15 @@ import com.google.gson.reflect.TypeToken; import java.lang.reflect.Type; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Map; class UserProfileDeserializer implements JsonDeserializer { + private static final String TAG = UserProfileDeserializer.class.getSimpleName(); + private final Gson iso8601DateGson; public UserProfileDeserializer() { @@ -44,10 +50,35 @@ public UserProfile deserialize(JsonElement json, Type typeOfT, JsonDeserializati final Type identitiesType = new TypeToken>() {}.getType(); final List identities = context.deserialize(object.remove("identities"), identitiesType); + final ActorClaim actor = deserializeActorClaim(object.remove("act"), context); + final Type metadataType = new TypeToken>() {}.getType(); Map userMetadata = context.deserialize(object.remove("user_metadata"), metadataType); Map appMetadata = context.deserialize(object.remove("app_metadata"), metadataType); Map extraInfo = context.deserialize(object, metadataType); - return new UserProfile(id, name, nickname, picture, email, emailVerified, familyName, createdAt, identities, extraInfo, userMetadata, appMetadata, givenName); + return new UserProfile(id, name, nickname, picture, email, emailVerified, familyName, createdAt, identities, extraInfo, userMetadata, appMetadata, givenName, actor); + } + + private ActorClaim deserializeActorClaim(JsonElement actElement, JsonDeserializationContext context) { + if (actElement == null || actElement.isJsonNull() || !actElement.isJsonObject()) { + return null; + } + + JsonObject actObject = actElement.getAsJsonObject(); + String sub = context.deserialize(actObject.remove("sub"), String.class); + if (sub == null) { + Log.w(TAG, "act claim present but missing required 'sub' field, ignoring actor"); + return null; + } + + ActorClaim nestedActor = deserializeActorClaim(actObject.remove("act"), context); + + final Type mapType = new TypeToken>() {}.getType(); + Map extraProperties = context.deserialize(actObject, mapType); + if (extraProperties == null) { + extraProperties = Collections.emptyMap(); + } + + return new ActorClaim(sub, nestedActor, extraProperties); } } diff --git a/auth0/src/main/java/com/auth0/android/result/ActorClaim.kt b/auth0/src/main/java/com/auth0/android/result/ActorClaim.kt new file mode 100644 index 000000000..4f6998890 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/ActorClaim.kt @@ -0,0 +1,17 @@ +package com.auth0.android.result + +import java.io.Serializable + +/** + * Represents the `act` (actor) claim in an ID token, used in delegation and impersonation scenarios. + * See RFC 8693 Section 4.4 for the specification of the `act` claim. + * + * @param sub The unique identifier of the actor (required). + * @param actor A nested actor claim representing a delegation chain. + * @param extraProperties Additional custom properties set via the `setActor` Action command. + */ +public data class ActorClaim( + val sub: String, + val actor: ActorClaim? = null, + val extraProperties: Map = emptyMap() +) : Serializable diff --git a/auth0/src/main/java/com/auth0/android/result/PasswordlessChallenge.kt b/auth0/src/main/java/com/auth0/android/result/PasswordlessChallenge.kt new file mode 100644 index 000000000..36e4f8e55 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/PasswordlessChallenge.kt @@ -0,0 +1,17 @@ +package com.auth0.android.result + +import com.auth0.android.request.internal.JsonRequired +import com.google.gson.annotations.SerializedName + +/** + * Result of a passwordless challenge. + * + * Holds the opaque `auth_session` token returned when a passwordless challenge is issued. + * + * @see [com.auth0.android.authentication.passwordless.PasswordlessClient.challengeWithEmail] + * @see [com.auth0.android.authentication.passwordless.PasswordlessClient.challengeWithPhoneNumber] + */ +public class PasswordlessChallenge( + @field:JsonRequired @field:SerializedName("auth_session") + public val authSession: String +) diff --git a/auth0/src/main/java/com/auth0/android/result/UserProfile.kt b/auth0/src/main/java/com/auth0/android/result/UserProfile.kt index 911165e09..feabaa361 100755 --- a/auth0/src/main/java/com/auth0/android/result/UserProfile.kt +++ b/auth0/src/main/java/com/auth0/android/result/UserProfile.kt @@ -7,7 +7,7 @@ import java.util.* * Class that holds the information of a user's profile in Auth0. * Used in [com.auth0.android.authentication.AuthenticationAPIClient]. */ -public class UserProfile( +public class UserProfile @JvmOverloads constructor( private val id: String?, public val name: String?, public val nickname: String?, @@ -25,7 +25,13 @@ public class UserProfile( private val extraInfo: Map?, private val userMetadata: Map?, private val appMetadata: Map?, - public val givenName: String? + public val givenName: String?, + /** + * The actor claim from the ID token, representing the acting party in delegation + * or impersonation scenarios (e.g., an AI agent acting on behalf of a user). + * Only present when the token was issued via Custom Token Exchange with an actor. + */ + public val actor: ActorClaim? = null ) : Serializable { /** diff --git a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt index c261573fa..ba435a449 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.res.Resources import com.auth0.android.Auth0 import com.auth0.android.authentication.ParameterBuilder.Companion.newBuilder +import com.auth0.android.authentication.request.ActorToken import com.auth0.android.dpop.DPoPException import com.auth0.android.dpop.DPoPKeyStore import com.auth0.android.dpop.DPoPUtil @@ -13,6 +14,7 @@ import com.auth0.android.provider.JwtTestUtils import com.auth0.android.request.HttpMethod import com.auth0.android.request.NetworkingClient import com.auth0.android.request.PublicKeyCredentials +import com.auth0.android.request.UserData import com.auth0.android.request.RequestOptions import com.auth0.android.request.ServerResponse import com.auth0.android.request.internal.RequestFactory @@ -138,6 +140,12 @@ public class AuthenticationAPIClientTest { assertThat(client.baseURL.toHttpUrlOrNull()!!.encodedPath, Matchers.`is`("/")) } + @Test + public fun shouldCreatePasswordlessClient() { + val client = AuthenticationAPIClient(Auth0.getInstance(CLIENT_ID, DOMAIN)) + assertThat(client.passwordlessClient(), Matchers.`is`(Matchers.notNullValue())) + } + @Test public fun shouldCreateClientWithContextInfo() { val context: Context = mock() @@ -207,7 +215,7 @@ public class AuthenticationAPIClientTest { val auth0 = auth0 val client = AuthenticationAPIClient(auth0) val registrationResponse = client.signupWithPasskey( - mock(), + UserData(email = "test@example.com"), MY_CONNECTION, "testOrganization" ) @@ -228,6 +236,49 @@ public class AuthenticationAPIClientTest { assertThat(registrationResponse.authSession, Matchers.comparesEqualTo(SESSION_ID)) } + @Test + public fun shouldSignupWithPasskeyWithAllUserDataFields() { + mockAPI.willReturnSuccessfulPasskeyRegistration() + val auth0 = auth0 + val client = AuthenticationAPIClient(auth0) + val userData = UserData( + email = "test@example.com", + phoneNumber = "+1234567890", + userName = "testuser", + name = "Test User", + givenName = "Test", + familyName = "User", + nickName = "testy", + picture = "https://example.com/photo.png", + userMetadata = mapOf("key1" to "value1") + ) + val registrationResponse = client.signupWithPasskey( + userData, + MY_CONNECTION, + "testOrganization" + ).execute() + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(request.path, Matchers.equalTo("/passkey/register")) + assertThat(body, Matchers.hasKey("user_profile")) + @Suppress("UNCHECKED_CAST") + val userProfile = body["user_profile"] as Map + assertThat(userProfile, Matchers.hasEntry("email", "test@example.com")) + assertThat(userProfile, Matchers.hasEntry("phone_number", "+1234567890")) + assertThat(userProfile, Matchers.hasEntry("username", "testuser")) + assertThat(userProfile, Matchers.hasEntry("name", "Test User")) + assertThat(userProfile, Matchers.hasEntry("given_name", "Test")) + assertThat(userProfile, Matchers.hasEntry("family_name", "User")) + assertThat(userProfile, Matchers.hasEntry("nickname", "testy")) + assertThat(userProfile, Matchers.hasEntry("picture", "https://example.com/photo.png")) + assertThat(userProfile, Matchers.not(Matchers.hasKey("user_metadata"))) + assertThat(body, Matchers.hasKey("user_metadata")) + @Suppress("UNCHECKED_CAST") + val metadata = body["user_metadata"] as Map + assertThat(metadata, Matchers.hasEntry("key1", "value1")) + assertThat(registrationResponse, Matchers.`is`(Matchers.notNullValue())) + } + @Test public fun shouldGetPasskeyChallenge() { mockAPI.willReturnSuccessfulPasskeyChallenge() @@ -2190,6 +2241,128 @@ public class AuthenticationAPIClientTest { assertThat(credentials, Matchers.`is`(Matchers.notNullValue())) } + @Test + public fun shouldCustomTokenExchangeWithOptions() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + val actorToken = ActorToken( + token = "actor-token-value", + tokenType = "urn:custom:actor-token-type" + ) + client.customTokenExchange( + "subject-token-type", + "subject-token", + "org_12345", + actorToken + ).start(callback) + ShadowLooper.idleMainLooper() + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/oauth/token")) + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) + assertThat( + body, + Matchers.hasEntry("grant_type", ParameterBuilder.GRANT_TYPE_TOKEN_EXCHANGE) + ) + assertThat(body, Matchers.hasEntry("subject_token", "subject-token")) + assertThat(body, Matchers.hasEntry("subject_token_type", "subject-token-type")) + assertThat(body, Matchers.hasEntry("organization", "org_12345")) + assertThat(body, Matchers.hasEntry("actor_token", "actor-token-value")) + assertThat(body, Matchers.hasEntry("actor_token_type", "urn:custom:actor-token-type")) + assertThat(body, Matchers.hasEntry("scope", "openid profile email")) + assertThat( + callback, AuthenticationCallbackMatcher.hasPayloadOfType( + Credentials::class.java + ) + ) + } + + @Test + public fun shouldCustomTokenExchangeWithOptionsSyncWithoutOrganization() { + mockAPI.willReturnSuccessfulLogin() + val actorToken = ActorToken( + token = "actor-token-value", + tokenType = "urn:custom:actor-token-type" + ) + val credentials = client + .customTokenExchange( + "subject-token-type", + "subject-token", + actorToken = actorToken + ) + .execute() + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/oauth/token")) + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) + assertThat( + body, + Matchers.hasEntry("grant_type", ParameterBuilder.GRANT_TYPE_TOKEN_EXCHANGE) + ) + assertThat(body, Matchers.hasEntry("subject_token", "subject-token")) + assertThat(body, Matchers.hasEntry("subject_token_type", "subject-token-type")) + assertThat(body, Matchers.not(Matchers.hasKey("organization"))) + assertThat(body, Matchers.hasEntry("actor_token", "actor-token-value")) + assertThat(body, Matchers.hasEntry("actor_token_type", "urn:custom:actor-token-type")) + assertThat(body, Matchers.hasEntry("scope", "openid profile email")) + assertThat(credentials, Matchers.`is`(Matchers.notNullValue())) + } + + @Test + @ExperimentalCoroutinesApi + public fun shouldAwaitCustomTokenExchangeWithOptions(): Unit = runTest { + mockAPI.willReturnSuccessfulLogin() + val actorToken = ActorToken( + token = "actor-token-value", + tokenType = "urn:custom:actor-token-type" + ) + val credentials = client + .customTokenExchange( + "subject-token-type", + "subject-token", + "org_abc", + actorToken + ) + .await() + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/oauth/token")) + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) + assertThat( + body, + Matchers.hasEntry("grant_type", ParameterBuilder.GRANT_TYPE_TOKEN_EXCHANGE) + ) + assertThat(body, Matchers.hasEntry("subject_token", "subject-token")) + assertThat(body, Matchers.hasEntry("subject_token_type", "subject-token-type")) + assertThat(body, Matchers.hasEntry("organization", "org_abc")) + assertThat(body, Matchers.hasEntry("actor_token", "actor-token-value")) + assertThat(body, Matchers.hasEntry("actor_token_type", "urn:custom:actor-token-type")) + assertThat(body, Matchers.hasEntry("scope", "openid profile email")) + assertThat(credentials, Matchers.`is`(Matchers.notNullValue())) + } + + @Test + public fun shouldCustomTokenExchangeWithoutOptions() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + client.customTokenExchange("subject-token-type", "subject-token", "org_12345") + .start(callback) + ShadowLooper.idleMainLooper() + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/oauth/token")) + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("subject_token", "subject-token")) + assertThat(body, Matchers.hasEntry("subject_token_type", "subject-token-type")) + assertThat(body, Matchers.hasEntry("organization", "org_12345")) + assertThat(body, Matchers.not(Matchers.hasKey("actor_token"))) + assertThat(body, Matchers.not(Matchers.hasKey("actor_token_type"))) + assertThat( + callback, AuthenticationCallbackMatcher.hasPayloadOfType( + Credentials::class.java + ) + ) + } + @Test public fun shouldSsoExchange() { mockAPI.willReturnSuccessfulSSOExchange() diff --git a/auth0/src/test/java/com/auth0/android/authentication/PasswordlessClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/PasswordlessClientTest.kt new file mode 100644 index 000000000..ded7730cb --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/authentication/PasswordlessClientTest.kt @@ -0,0 +1,284 @@ +package com.auth0.android.authentication + +import android.content.Context +import com.auth0.android.Auth0 +import com.auth0.android.authentication.passwordless.DeliveryMethod +import com.auth0.android.authentication.passwordless.PasswordlessClient +import com.auth0.android.dpop.DPoPKeyStore +import com.auth0.android.dpop.DPoPUtil +import com.auth0.android.dpop.FakeECPrivateKey +import com.auth0.android.dpop.FakeECPublicKey +import com.auth0.android.request.internal.ThreadSwitcherShadow +import com.auth0.android.result.Credentials +import com.auth0.android.result.PasswordlessChallenge +import com.auth0.android.util.SSLTestUtils +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.containsString +import org.hamcrest.Matchers.hasEntry +import org.hamcrest.Matchers.notNullValue +import org.hamcrest.Matchers.nullValue +import org.junit.After +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ThreadSwitcherShadow::class]) +@OptIn(ExperimentalCoroutinesApi::class) +public class PasswordlessClientTest { + + private lateinit var mockServer: MockWebServer + private lateinit var auth0: Auth0 + private lateinit var passwordlessClient: PasswordlessClient + private lateinit var gson: Gson + private lateinit var mockKeyStore: DPoPKeyStore + private lateinit var mockContext: Context + + @Before + public fun setUp() { + mockServer = SSLTestUtils.createMockWebServer() + mockServer.start() + val domain = mockServer.url("/").toString() + auth0 = Auth0.getInstance(CLIENT_ID, domain, domain) + auth0.networkingClient = SSLTestUtils.testClient + passwordlessClient = PasswordlessClient(auth0) + gson = GsonBuilder().serializeNulls().create() + mockKeyStore = mock() + mockContext = mock() + whenever(mockContext.applicationContext).thenReturn(mockContext) + DPoPUtil.keyStore = mockKeyStore + } + + @After + public fun tearDown() { + mockServer.shutdown() + } + + private fun enqueueMockResponse(json: String, statusCode: Int = 200) { + mockServer.enqueue( + MockResponse() + .setResponseCode(statusCode) + .addHeader("Content-Type", "application/json") + .setBody(json) + ) + } + + private fun enqueueErrorResponse(error: String, description: String, statusCode: Int = 400) { + enqueueMockResponse("""{"error": "$error", "error_description": "$description"}""", statusCode) + } + + private inline fun bodyFromRequest(request: RecordedRequest): Map { + val mapType = object : TypeToken>() {}.type + return gson.fromJson(request.body.readUtf8(), mapType) + } + + @Test + public fun shouldCreateClient() { + assertThat(PasswordlessClient(auth0), `is`(notNullValue())) + } + + @Test + public fun shouldChallengeWithEmailHitOtpChallengeWithCorrectParams(): Unit = runTest { + enqueueMockResponse("""{"auth_session": "session_abc"}""") + + val challenge = passwordlessClient + .challengeWithEmail("user@example.com", CONNECTION, allowSignup = true) + .await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/otp/challenge")) + assertThat(request.method, `is`("POST")) + val body = bodyFromRequest(request) + assertThat(body, hasEntry("client_id", CLIENT_ID)) + assertThat(body, hasEntry("connection", CONNECTION)) + assertThat(body, hasEntry("email", "user@example.com")) + assertThat(body, hasEntry("allow_signup", "true")) + assertThat(challenge.authSession, `is`("session_abc")) + } + + @Test + public fun shouldChallengeWithEmailDefaultAllowSignupFalse(): Unit = runTest { + enqueueMockResponse("""{"auth_session": "session_abc"}""") + + passwordlessClient.challengeWithEmail("user@example.com", CONNECTION).await() + + val body = bodyFromRequest(mockServer.takeRequest()) + assertThat(body, hasEntry("allow_signup", "false")) + } + + @Test + public fun shouldChallengeWithPhoneNumberHitOtpChallengeWithCorrectParams(): Unit = runTest { + enqueueMockResponse("""{"auth_session": "session_abc"}""") + + val challenge = passwordlessClient.challengeWithPhoneNumber( + "+15555550123", CONNECTION, DeliveryMethod.VOICE, allowSignup = true + ).await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/otp/challenge")) + val body = bodyFromRequest(request) + assertThat(body, hasEntry("client_id", CLIENT_ID)) + assertThat(body, hasEntry("connection", CONNECTION)) + assertThat(body, hasEntry("phone_number", "+15555550123")) + assertThat(body, hasEntry("delivery_method", "voice")) + assertThat(body, hasEntry("allow_signup", "true")) + assertThat(challenge.authSession, `is`("session_abc")) + } + + @Test + public fun shouldChallengeWithPhoneNumberDefaultDeliveryMethodText(): Unit = runTest { + enqueueMockResponse("""{"auth_session": "session_abc"}""") + + passwordlessClient.challengeWithPhoneNumber("+15555550123", CONNECTION).await() + + val body = bodyFromRequest(mockServer.takeRequest()) + assertThat(body, hasEntry("delivery_method", "text")) + } + + @Test + public fun shouldIncludeAuth0ClientHeaderInChallenge(): Unit = runTest { + enqueueMockResponse("""{"auth_session": "session_abc"}""") + + passwordlessClient.challengeWithEmail("user@example.com", CONNECTION).await() + + assertThat(mockServer.takeRequest().getHeader("Auth0-Client"), `is`(notNullValue())) + } + + @Test + public fun shouldPropagateChallengeApiError() { + enqueueErrorResponse("invalid_connection", "Connection does not exist", 400) + + val exception = assertThrows(AuthenticationException::class.java) { + runTest { passwordlessClient.challengeWithEmail("user@example.com", CONNECTION).await() } + } + assertThat(exception.getCode(), `is`("invalid_connection")) + } + + @Test + public fun shouldFailChallengeWithBlankEmailWithoutNetworkCall() { + val exception = assertThrows(AuthenticationException::class.java) { + runTest { passwordlessClient.challengeWithEmail("", CONNECTION).await() } + } + assertThat(exception.getCode(), `is`("invalid_request")) + assertThat(exception.getDescription(), containsString("email is required")) + assertThat(mockServer.requestCount, `is`(0)) + } + + @Test + public fun shouldFailChallengeWithBlankConnectionWithoutNetworkCall() { + val exception = assertThrows(AuthenticationException::class.java) { + runTest { passwordlessClient.challengeWithEmail("user@example.com", "").await() } + } + assertThat(exception.getCode(), `is`("invalid_request")) + assertThat(exception.getDescription(), containsString("connection is required")) + assertThat(mockServer.requestCount, `is`(0)) + } + + @Test + public fun shouldFailChallengeWithBlankPhoneWithoutNetworkCall() { + val exception = assertThrows(AuthenticationException::class.java) { + runTest { passwordlessClient.challengeWithPhoneNumber("", CONNECTION).await() } + } + assertThat(exception.getCode(), `is`("invalid_request")) + assertThat(exception.getDescription(), containsString("phone_number is required")) + assertThat(mockServer.requestCount, `is`(0)) + } + + @Test + public fun shouldLoginWithOtpHitOauthTokenWithCorrectParams(): Unit = runTest { + enqueueMockResponse( + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + ) + + passwordlessClient.loginWithOTP(PasswordlessChallenge("session_abc"), "123456").await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/oauth/token")) + assertThat(request.method, `is`("POST")) + val body = bodyFromRequest(request) + assertThat(body, hasEntry("client_id", CLIENT_ID)) + assertThat(body, hasEntry("grant_type", "http://auth0.com/oauth/grant-type/passwordless/otp")) + assertThat(body, hasEntry("auth_session", "session_abc")) + assertThat(body, hasEntry("otp", "123456")) + } + + @Test + public fun shouldLoginWithOtpReturnCredentials(): Unit = runTest { + enqueueMockResponse( + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + ) + + val credentials = passwordlessClient.loginWithOTP(PasswordlessChallenge("session_abc"), "123456").await() + + assertThat(credentials.accessToken, `is`(ACCESS_TOKEN)) + } + + @Test + public fun shouldPropagateLoginWithOtpApiError() { + enqueueErrorResponse("invalid_grant", "Invalid or expired code", 403) + + val exception = assertThrows(AuthenticationException::class.java) { + runTest { passwordlessClient.loginWithOTP(PasswordlessChallenge("session_abc"), "000000").await() } + } + assertThat(exception.getCode(), `is`("invalid_grant")) + } + + @Test + public fun shouldFailLoginWithBlankOtpWithoutNetworkCall() { + val exception = assertThrows(AuthenticationException::class.java) { + runTest { passwordlessClient.loginWithOTP(PasswordlessChallenge("session_abc"), "").await() } + } + assertThat(exception.getCode(), `is`("invalid_request")) + assertThat(exception.getDescription(), containsString("otp is required")) + assertThat(mockServer.requestCount, `is`(0)) + } + + @Test + public fun shouldAttachDpopHeaderOnLoginWhenDpopEnabled(): Unit = runTest { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + val dpopClient = AuthenticationAPIClient(auth0).useDPoP(mockContext).passwordlessClient() + enqueueMockResponse( + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + ) + + dpopClient.loginWithOTP(PasswordlessChallenge("session_abc"), "123456").await() + + val request = mockServer.takeRequest() + assertThat(request.getHeader("DPoP"), `is`(notNullValue())) + } + + @Test + public fun shouldNotAttachDpopHeaderOnChallengeWhenDpopEnabled(): Unit = runTest { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + val dpopClient = AuthenticationAPIClient(auth0).useDPoP(mockContext).passwordlessClient() + enqueueMockResponse("""{"auth_session": "session_abc"}""") + + dpopClient.challengeWithEmail("user@example.com", CONNECTION).await() + + val request = mockServer.takeRequest() + assertThat(request.getHeader("DPoP"), `is`(nullValue())) + } + + private companion object { + private const val CLIENT_ID = "CLIENT_ID" + private const val CONNECTION = "Username-Password-Authentication" + private const val ACCESS_TOKEN = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0In0.sig" + private const val ID_TOKEN = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0In0.sig" + } +} 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 3dca6f932..83991a859 100644 --- a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt @@ -345,6 +345,53 @@ public class MyAccountAPIClientTest { assertThat(request.method, Matchers.equalTo("GET")) } + @Test + public fun `getAuthenticationMethods should include type query parameter when specified`() { + val callback = MockMyAccountCallback>() + client.getAuthenticationMethods(AuthenticationMethodType.PASSKEY).start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods?type=passkey")) + assertThat(request.getHeader("Authorization"), Matchers.equalTo("Bearer $ACCESS_TOKEN")) + assertThat(request.method, Matchers.equalTo("GET")) + } + + @Test + public fun `getAuthenticationMethods should not include type query parameter when null`() { + val callback = MockMyAccountCallback>() + client.getAuthenticationMethods(null).start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods")) + assertThat(request.method, Matchers.equalTo("GET")) + } + + @Test + public fun `getAuthenticationMethods should include correct type value for each AuthenticationMethodType`() { + val typesToExpected = mapOf( + AuthenticationMethodType.PHONE to "phone", + AuthenticationMethodType.EMAIL to "email", + AuthenticationMethodType.TOTP to "totp", + AuthenticationMethodType.PUSH to "push-notification", + AuthenticationMethodType.RECOVERY_CODE to "recovery-code", + AuthenticationMethodType.PASSWORD to "password", + AuthenticationMethodType.WEBAUTHN_PLATFORM to "webauthn-platform", + AuthenticationMethodType.WEBAUTHN_ROAMING to "webauthn-roaming" + ) + + for ((type, expected) in typesToExpected) { + val callback = MockMyAccountCallback>() + client.getAuthenticationMethods(type).start(callback) + + val request = mockAPI.takeRequest() + assertThat( + request.path, + Matchers.equalTo("/me/v1/authentication-methods?type=$expected") + ) + assertThat(request.method, Matchers.equalTo("GET")) + } + } + @Test public fun `getAuthenticationMethodById should build correct URL and Authorization header`() { val callback = MockMyAccountCallback() diff --git a/auth0/src/test/java/com/auth0/android/request/internal/UserProfileGsonTest.kt b/auth0/src/test/java/com/auth0/android/request/internal/UserProfileGsonTest.kt index 884ddf25f..262ed4b88 100755 --- a/auth0/src/test/java/com/auth0/android/request/internal/UserProfileGsonTest.kt +++ b/auth0/src/test/java/com/auth0/android/request/internal/UserProfileGsonTest.kt @@ -11,10 +11,13 @@ import org.hamcrest.collection.IsMapWithSize import org.junit.Assert import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner import java.io.StringReader import java.text.SimpleDateFormat import java.util.* +@RunWith(RobolectricTestRunner::class) public class UserProfileGsonTest : GsonBaseTest() { @Before @@ -345,6 +348,128 @@ public class UserProfileGsonTest : GsonBaseTest() { ) } + @Test + @Throws(Exception::class) + public fun shouldReturnProfileWithActorClaim() { + val userProfile = pojoFrom( + StringReader( + """{ + "sub": "auth0|123", + "name": "Test User", + "act": { + "sub": "agent-id-456", + "type": "ai_agent", + "name": "My Agent" + } +}""" + ), UserProfile::class.java + ) + assertThat(userProfile, `is`(notNullValue())) + assertThat(userProfile.actor, `is`(notNullValue())) + assertThat(userProfile.actor!!.sub, `is`("agent-id-456")) + assertThat(userProfile.actor!!.extraProperties, hasEntry("type", "ai_agent" as Any)) + assertThat(userProfile.actor!!.extraProperties, hasEntry("name", "My Agent" as Any)) + assertThat(userProfile.actor!!.actor, `is`(nullValue())) + } + + @Test + @Throws(Exception::class) + public fun shouldReturnProfileWithNestedActorClaim() { + val userProfile = pojoFrom( + StringReader( + """{ + "sub": "auth0|123", + "name": "Test User", + "act": { + "sub": "agent-id-456", + "act": { + "sub": "service-id-789", + "role": "intermediary" + } + } +}""" + ), UserProfile::class.java + ) + assertThat(userProfile, `is`(notNullValue())) + assertThat(userProfile.actor, `is`(notNullValue())) + assertThat(userProfile.actor!!.sub, `is`("agent-id-456")) + assertThat(userProfile.actor!!.actor, `is`(notNullValue())) + assertThat(userProfile.actor!!.actor!!.sub, `is`("service-id-789")) + assertThat( + userProfile.actor!!.actor!!.extraProperties, + hasEntry("role", "intermediary" as Any) + ) + assertThat(userProfile.actor!!.actor!!.actor, `is`(nullValue())) + } + + @Test + @Throws(Exception::class) + public fun shouldReturnProfileWithNullActorWhenNotPresent() { + val userProfile = pojoFrom( + StringReader( + """{ + "sub": "auth0|123", + "name": "Test User" +}""" + ), UserProfile::class.java + ) + assertThat(userProfile, `is`(notNullValue())) + assertThat(userProfile.actor, `is`(nullValue())) + } + + @Test + @Throws(Exception::class) + public fun shouldReturnProfileWithNullActorWhenActIsNull() { + val userProfile = pojoFrom( + StringReader( + """{ + "sub": "auth0|123", + "name": "Test User", + "act": null +}""" + ), UserProfile::class.java + ) + assertThat(userProfile, `is`(notNullValue())) + assertThat(userProfile.actor, `is`(nullValue())) + } + + @Test + @Throws(Exception::class) + public fun shouldReturnProfileWithNullActorWhenActHasNoSub() { + val userProfile = pojoFrom( + StringReader( + """{ + "sub": "auth0|123", + "name": "Test User", + "act": { + "type": "ai_agent" + } +}""" + ), UserProfile::class.java + ) + assertThat(userProfile, `is`(notNullValue())) + assertThat(userProfile.actor, `is`(nullValue())) + } + + @Test + @Throws(Exception::class) + public fun shouldNotIncludeActInExtraInfo() { + val userProfile = pojoFrom( + StringReader( + """{ + "sub": "auth0|123", + "name": "Test User", + "act": { + "sub": "agent-id-456" + } +}""" + ), UserProfile::class.java + ) + assertThat(userProfile, `is`(notNullValue())) + assertThat(userProfile.actor, `is`(notNullValue())) + assertThat(userProfile.getExtraInfo(), not(hasKey("act"))) + } + private fun getUTCDate(year: Int, month: Int, day: Int, hr: Int, min: Int, sec: Int, ms: Int): Date { val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) cal[Calendar.YEAR] = year diff --git a/auth0/src/test/java/com/auth0/android/result/UserProfileTest.java b/auth0/src/test/java/com/auth0/android/result/UserProfileTest.java index f764335cf..d0fbaa9ec 100644 --- a/auth0/src/test/java/com/auth0/android/result/UserProfileTest.java +++ b/auth0/src/test/java/com/auth0/android/result/UserProfileTest.java @@ -29,7 +29,7 @@ public void setUp() { extraInfo = Collections.emptyMap(); userMetadata = Collections.emptyMap(); appMetadata = Collections.emptyMap(); - userProfile = new UserProfile("id", "name", "nickname", "pictureUrl", "email", true, "familyName", createdAt, identities, extraInfo, userMetadata, appMetadata, "givenName"); + userProfile = new UserProfile("id", "name", "nickname", "pictureUrl", "email", true, "familyName", createdAt, identities, extraInfo, userMetadata, appMetadata, "givenName", null); } @Test @@ -40,13 +40,13 @@ public void getId() { @Test public void shouldReturnSubIfMissingId() { Map extraInfo = Collections.singletonMap("sub", "fromSub"); - UserProfile userProfile = new UserProfile(null, null, null, null, null, false, null, null, null, extraInfo, null, null, null); + UserProfile userProfile = new UserProfile(null, null, null, null, null, false, null, null, null, extraInfo, null, null, null, null); assertThat(userProfile.getId(), is("fromSub")); } @Test public void shouldGetNullIdIfMissing() { - UserProfile userProfile = new UserProfile(null, null, null, null, null, false, null, null, null, null, null, null, null); + UserProfile userProfile = new UserProfile(null, null, null, null, null, false, null, null, null, null, null, null, null, null); assertThat(userProfile.getId(), is(nullValue())); } From 5f663c5d78361714283babe9dece351f08264864 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Tue, 30 Jun 2026 15:32:48 +0530 Subject: [PATCH 2/2] Added the missign Dpop change --- EXAMPLES.md | 306 +++++++++++++++++- .../android/myaccount/MyAccountAPIClient.kt | 118 ++++--- .../myaccount/MyAccountAPIClientTest.kt | 107 ++++++ 3 files changed, 453 insertions(+), 78 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index bb3b4e2ea..7dac9ecd2 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -27,12 +27,17 @@ - [Passwordless Login](#passwordless-login) - [Step 1: Request the code](#step-1-request-the-code) - [Step 2: Input the code](#step-2-input-the-code) + - [Passwordless Login with a Database Connection (EA)](#passwordless-login-with-a-database-connection-ea) + - [Step 1: Issue an OTP challenge](#step-1-issue-an-otp-challenge) + - [Step 2: Verify the code and log in](#step-2-verify-the-code-and-log-in) - [Sign Up with a database connection](#sign-up-with-a-database-connection) - [Get user information](#get-user-information) - [Custom Token Exchange](#custom-token-exchange) + - [Custom Token Exchange with Actor Token (Delegation/Impersonation)](#custom-token-exchange-with-actor-token-delegationimpersonation) - [Native to Web SSO login](#native-to-web-sso-login) - [DPoP](#dpop-1) - [My Account API](#my-account-api) + - [Using DPoP](#using-dpop) - [Enroll a new passkey](#enroll-a-new-passkey) - [Get Available Factors](#get-available-factors) - [Get All Enrolled Authentication Methods](#get-all-enrolled-authentication-methods) @@ -1582,6 +1587,124 @@ authentication > The default scope used is `openid profile email`. Regardless of the scopes set to the request, the `openid` scope is always enforced. +### Passwordless Login with a Database Connection (EA) + +> [!IMPORTANT] +> Passwordless Login for database connections is currently 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. + +This flow lets users authenticate with a one-time code sent over email or SMS/voice against a **database connection** that has `email_otp` or `phone_otp` enabled. It is distinct from the `/passwordless/start` flow described above, which uses dedicated passwordless connections. + +Obtain a `PasswordlessClient` from the `AuthenticationAPIClient`: + +```kotlin +val passwordless = AuthenticationAPIClient(account).passwordlessClient() +``` + +The flow has two steps: first issue an OTP challenge, then — after the user enters the code they received — exchange it for credentials. **Save the `PasswordlessChallenge` from step 1**, as you pass that same object into `loginWithOTP` in step 2. + +#### Step 1: Issue an OTP challenge + +Send a one-time code to the user's email. For privacy, the server **always responds successfully regardless of whether the user exists**. On success, save the returned `PasswordlessChallenge` for step 2. + +```kotlin +// keep this reference until the user enters the code +var challenge: PasswordlessChallenge? = null + +passwordless + .challengeWithEmail("info@auth0.com", "my-database-connection") + .start(object: Callback { + override fun onFailure(exception: AuthenticationException) { } + + override fun onSuccess(result: PasswordlessChallenge) { + challenge = result + } + }) +``` + +To send the code over SMS or voice instead, use `challengeWithPhoneNumber` against a connection with `phone_otp` enabled, choosing the `DeliveryMethod`: + +```kotlin +passwordless + .challengeWithPhoneNumber("+15555550123", "my-database-connection", DeliveryMethod.TEXT) + .start(object: Callback { + override fun onFailure(exception: AuthenticationException) { } + + override fun onSuccess(result: PasswordlessChallenge) { + challenge = result + } + }) +``` + +Both challenge methods accept an optional `allowSignup` parameter (defaults to `false`) that controls whether a new user is created if one does not yet exist. + +#### Step 2: Verify the code and log in + +Once the user enters the code, pass the saved `challenge` together with that code to `loginWithOTP` to obtain `Credentials`. If DPoP is enabled on the originating `AuthenticationAPIClient`, a DPoP proof is attached automatically to this token request. + +```kotlin +passwordless + .loginWithOTP(challenge, "123456") + .start(object: Callback { + override fun onFailure(exception: AuthenticationException) { } + + override fun onSuccess(credentials: Credentials) { } + }) +``` + +
+ Using coroutines + +```kotlin +// Step 1: issue the challenge and keep it +val challenge = passwordless + .challengeWithEmail("info@auth0.com", "my-database-connection") + .await() + +// Step 2: once the user enters the code, pass the saved challenge back to log in +val credentials = passwordless + .loginWithOTP(challenge, "123456") + .await() +``` +
+ +
+ Using Java + +```java +// Step 1: issue the challenge and keep it +passwordless + .challengeWithEmail("info@auth0.com", "my-database-connection", false) + .start(new Callback() { + @Override + public void onSuccess(PasswordlessChallenge result) { + challenge = result; + } + + @Override + public void onFailure(@NonNull AuthenticationException error) { + //Error! + } + }); + +// Step 2: once the user enters the code, pass the saved challenge back to log in +passwordless + .loginWithOTP(challenge, "123456") + .start(new Callback() { + @Override + public void onSuccess(@Nullable Credentials payload) { + //Logged in! + } + + @Override + public void onFailure(@NonNull AuthenticationException error) { + //Error! + } + }); +``` +
+ +> The default scope used is `openid profile email`. Regardless of the scopes set to the request, the `openid` scope is always enforced. + ### Sign Up with a database connection ```kotlin @@ -1732,6 +1855,96 @@ authentication +#### Custom Token Exchange with Actor Token (Delegation/Impersonation) + +For delegation or impersonation scenarios where one principal acts on behalf of another (e.g., an AI agent acting on behalf of a user), pass `ActorToken` with the actor token details: + +> **Note:** When `actor_token` is present in the request, Auth0 will not issue a refresh token regardless of whether `offline_access` is in the scope. The `Credentials.refreshToken` will be `null` in this flow. + +```kotlin +import com.auth0.android.authentication.request.ActorToken + +val actorToken = ActorToken( + token = "actor-token-value", + tokenType = "urn:my-org:actor-token-type" +) + +authentication + .customTokenExchange( + subjectTokenType = "http://my-org/custom-token", + subjectToken = "subject-token-value", + organization = "org_12345", + actorToken = actorToken + ) + .start(object : Callback { + override fun onSuccess(result: Credentials) { + // Access the actor claim from the ID token + val actor = result.user.actor + if (actor != null) { + println("Actor sub: ${actor.sub}") + println("Actor properties: ${actor.extraProperties}") + // Nested delegation chain (if present) + val nestedActor = actor.actor + } + } + + override fun onFailure(exception: AuthenticationException) { + // Handle error + } + }) +``` + +
+ Using coroutines + +```kotlin +try { + val actorToken = ActorToken( + token = "actor-token-value", + tokenType = "urn:my-org:actor-token-type" + ) + val credentials = authentication + .customTokenExchange( + subjectTokenType = "http://my-org/custom-token", + subjectToken = "subject-token-value", + actorToken = actorToken + ) + .await() + // Access the actor claim + val actor = credentials.user.actor +} catch (e: AuthenticationException) { + e.printStackTrace() +} +``` +
+ +
+ Using Java + +```java +ActorToken actorToken = new ActorToken( + "actor-token-value", + "urn:my-org:actor-token-type" +); + +authentication + .customTokenExchange("http://my-org/custom-token", "subject-token-value", null, actorToken) + .start(new Callback() { + @Override + public void onSuccess(@Nullable Credentials payload) { + ActorClaim actor = payload.getUser().getActor(); + if (actor != null) { + Log.d("CTE", "Actor: " + actor.getSub()); + } + } + @Override + public void onFailure(@NonNull AuthenticationException error) { + // Handle error + } + }); +``` +
+ ## Native to Web SSO login @@ -1864,12 +2077,33 @@ val manager = CredentialsManager(apiClient, storage) ## My Account API -> [!NOTE] -> The My Account API 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. - Use the Auth0 My Account API to manage the current user's account. -To call the My Account API, you need an access token issued specifically for this API, including any required scopes for the operations you want to perform. See [API credentials [EA]](#api-credentials-ea) to learn how to obtain one. +To call the My Account API, you need an access token issued specifically for this API, including any required scopes for the operations you want to perform. See [API credentials](#api-credentials) to learn how to obtain one. + +```kotlin +val client = MyAccountAPIClient(auth0, accessToken) +``` + +#### Using DPoP + +If your application uses [DPoP (Demonstrating Proof of Possession)](https://auth0.com/docs/get-started/authentication-and-authorization-flow/call-your-api-using-the-authorization-code-flow-with-dpop), you can enable it on the My Account API client: + +```kotlin +val client = MyAccountAPIClient(auth0, accessToken).useDPoP(context) +``` + +When DPoP is enabled, the client will automatically: +- Use the `DPoP` authorization scheme instead of `Bearer` +- Include a DPoP proof header on every request + +
+ Using Java + +```java +MyAccountAPIClient client = new MyAccountAPIClient(auth0, accessToken).useDPoP(context); +``` +
### Enroll a new passkey @@ -2078,7 +2312,7 @@ myAccountClient.getFactors() ### 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. +Retrieves a detailed list of all the authentication methods that the current user has already enrolled in. You can optionally filter the results by type using `AuthenticationMethodType`. **Prerequisites:** @@ -2086,10 +2320,20 @@ Retrieves a detailed list of all the authentication methods that the current use The user must have one or more authentication methods already enrolled. ```kotlin +// Get all authentication methods myAccountClient.getAuthenticationMethods() .start(object : Callback, MyAccountException> { - override fun onSuccess(result: AuthenticationMethods) { - // List of enrolled methods in result.authenticationMethods + override fun onSuccess(result: List) { + // List of enrolled methods + } + override fun onFailure(error: MyAccountException) { } + }) + +// Get authentication methods filtered by type +myAccountClient.getAuthenticationMethods(AuthenticationMethodType.PASSKEY) + .start(object : Callback, MyAccountException> { + override fun onSuccess(result: List) { + // List of enrolled passkey methods only } override fun onFailure(error: MyAccountException) { } }) @@ -2098,11 +2342,23 @@ myAccountClient.getAuthenticationMethods() Using Java ```java +// Get all authentication methods myAccountClient.getAuthenticationMethods() .start(new Callback, MyAccountException>() { @Override - public void onSuccess(AuthenticationMethods result) { - // List of enrolled methods in result.getAuthenticationMethods() + public void onSuccess(List result) { + // List of enrolled methods + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); + +// Get authentication methods filtered by type +myAccountClient.getAuthenticationMethods(AuthenticationMethodType.PASSKEY) + .start(new Callback, MyAccountException>() { + @Override + public void onSuccess(List result) { + // List of enrolled passkey methods only } @Override public void onFailure(@NonNull MyAccountException error) { } @@ -2705,10 +2961,7 @@ val isValid = secureCredentialsManager.isBiometricSessionValid() ### Other Credentials -#### API credentials [EA] - -> [!NOTE] -> 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. +#### API credentials When the user logs in, you can request an access token for a specific API by passing its API identifier as the [audience](#specify-audience) value. The access token in the resulting credentials can then be used to make authenticated requests to that API. @@ -2833,8 +3086,19 @@ To sign up a user with passkey ```kotlin // Using Coroutines try { + val userData = UserData( + email = "user@example.com", + phoneNumber = "+11234567890", + name = "John Doe", + givenName = "John", + familyName = "Doe", + nickName = "johnny", + picture = "https://example.com/photo.png", + userMetadata = mapOf("signup_source" to "android_app") + ) + val challenge = authenticationApiClient.signupWithPasskey( - "{user-data}", + userData, "{realm}", "{organization-id}" ).await() @@ -2864,7 +3128,19 @@ try { Using Java ```java - authenticationAPIClient.signupWithPasskey("{user-data}", "{realm}","{organization-id}") + UserData userData = new UserData( + "user@example.com", // email + "+11234567890", // phoneNumber + null, // userName + "John Doe", // name + "John", // givenName + "Doe", // familyName + "johnny", // nickName + "https://example.com/photo.png", // picture + Map.of("signup_source", "android_app") // userMetadata + ); + + authenticationAPIClient.signupWithPasskey(userData, "{realm}","{organization-id}") .start(new Callback() { @Override public void onSuccess(PasskeyRegistrationChallenge result) { 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 69a683cc6..a81e88ada 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -1,10 +1,13 @@ package com.auth0.android.myaccount +import android.content.Context import androidx.annotation.VisibleForTesting import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception import com.auth0.android.NetworkErrorException import com.auth0.android.authentication.ParameterBuilder +import com.auth0.android.dpop.DPoP +import com.auth0.android.dpop.SenderConstraining import com.auth0.android.request.ErrorAdapter import com.auth0.android.request.JsonAdapter import com.auth0.android.request.PublicKeyCredentials @@ -52,7 +55,12 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting private val accessToken: String, private val factory: RequestFactory, private val gson: Gson -) { +) : SenderConstraining { + + private var dPoP: DPoP? = null + + private val authorizationHeader: String + get() = if (dPoP != null) "DPoP $accessToken" else "Bearer $accessToken" /** * Creates a new MyAccountAPI client instance. @@ -75,6 +83,25 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting GsonProvider.gson ) + /** + * Enable DPoP (Demonstrating Proof of Possession) for this client. + * + * When enabled, requests will include a DPoP proof header and the Authorization header + * will use the "DPoP" scheme instead of "Bearer". + * + * Example usage: + * ```kotlin + * val client = MyAccountAPIClient(auth0, accessToken).useDPoP(context) + * ``` + * + * @param context the Android context + * @return this client instance for chaining + */ + override fun useDPoP(context: Context): MyAccountAPIClient { + dPoP = DPoP(context) + return this + } + /** * Requests a challenge for enrolling a new passkey. This is the first part of the enrollment flow. @@ -82,12 +109,6 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * You can specify an optional user identity identifier and an optional database connection name. * If a connection name is not specified, your tenant's default directory will be used. * - * ## 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 * * `create:me:authentication_methods` @@ -171,20 +192,14 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting ) } } - return factory.post(url.toString(), passkeyEnrollmentAdapter) + return factory.post(url.toString(), passkeyEnrollmentAdapter, dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** * Enrolls a new passkey credential. This is the last part of the enrollment flow. * - * ## 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 * * `create:me:authentication_methods` @@ -239,23 +254,18 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting return factory.post( url.toString(), - GsonAdapter(PasskeyAuthenticationMethod::class.java, gson) + GsonAdapter(PasskeyAuthenticationMethod::class.java, gson), + dPoP ) .addParameters(params) .addParameter(AUTHN_RESPONSE_KEY, authnResponse) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** * 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 * @@ -309,20 +319,14 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting return container.authenticationMethods } } - return factory.get(url.toString(), listAdapter) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + return factory.get(url.toString(), listAdapter, dPoP) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** * 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 * @@ -351,19 +355,13 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting .addPathSegment(AUTHENTICATION_METHODS) .addPathSegment(authenticationMethodId) .build() - return factory.get(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + return factory.get(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson), dPoP) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** * 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 * @@ -410,21 +408,15 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting } }.asDictionary() - return factory.patch(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) + return factory.patch(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson), dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** * 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` * @@ -458,8 +450,8 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting val voidAdapter = object : JsonAdapter { override fun fromJson(reader: Reader, metadata: Map): Void? = null } - return factory.delete(url.toString(), voidAdapter) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + return factory.delete(url.toString(), voidAdapter, dPoP) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -495,8 +487,8 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting return container.factors } } - return factory.get(url.toString(), listAdapter) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + return factory.get(url.toString(), listAdapter, dPoP) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -599,9 +591,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting 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) + return factory.post(url.toString(), adapter, dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -632,9 +624,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting 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) + return factory.post(url.toString(), adapter, dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -664,9 +656,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting 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) + return factory.post(url.toString(), adapter, dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -707,9 +699,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting .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)) + return factory.post(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson), dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -747,9 +739,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting .addPathSegment(VERIFY) .build() val params = mapOf(AUTH_SESSION_KEY to authSession) - return factory.post(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) + return factory.post(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson), dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } // WebAuthn methods are private. @@ -810,9 +802,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting private fun buildEnrollmentRequest(params: Map): Request { val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() - return factory.post(url.toString(), GsonAdapter(EnrollmentChallenge::class.java, gson)) + return factory.post(url.toString(), GsonAdapter(EnrollmentChallenge::class.java, gson), dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } private fun getDomainUrlBuilder(): HttpUrl.Builder { 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 83991a859..e2d6116a5 100644 --- a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt @@ -1,6 +1,11 @@ package com.auth0.android.myaccount +import android.content.Context import com.auth0.android.Auth0 +import com.auth0.android.dpop.DPoPKeyStore +import com.auth0.android.dpop.DPoPUtil +import com.auth0.android.dpop.FakeECPrivateKey +import com.auth0.android.dpop.FakeECPublicKey import com.auth0.android.request.PublicKeyCredentials import com.auth0.android.request.Response import com.auth0.android.result.AuthenticationMethod @@ -18,6 +23,7 @@ import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.reflect.TypeToken import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever import okhttp3.mockwebserver.RecordedRequest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers @@ -36,11 +42,17 @@ public class MyAccountAPIClientTest { private lateinit var client: MyAccountAPIClient private lateinit var gson: Gson private lateinit var mockAPI: MyAccountAPIMockServer + private lateinit var mockKeyStore: DPoPKeyStore + private lateinit var mockContext: Context @Before public fun setUp() { mockAPI = MyAccountAPIMockServer() MockitoAnnotations.openMocks(this) + mockKeyStore = mock() + mockContext = mock() + whenever(mockContext.applicationContext).thenReturn(mockContext) + DPoPUtil.keyStore = mockKeyStore gson = GsonBuilder().serializeNulls().create() client = MyAccountAPIClient(auth0, ACCESS_TOKEN) } @@ -532,6 +544,101 @@ public class MyAccountAPIClientTest { assertThat(body, Matchers.hasEntry("type", "push-notification" as Any)) } + // DPoP tests + + @Test + public fun `should use Bearer authorization header when DPoP is not enabled`() { + val callback = MockMyAccountCallback>() + client.getFactors().start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("Bearer $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + } + + @Test + public fun `should use DPoP authorization header 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.getFactors().start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + } + + @Test + public fun `should include DPoP proof header on POST requests 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.enrollEmail("test@example.com").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")) + } + + @Test + public fun `should not include DPoP proof header when DPoP is not enabled`() { + val callback = MockMyAccountCallback() + client.enrollEmail("test@example.com").start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("Bearer $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + } + + @Test + public fun `should not include DPoP proof header when DPoP is enabled but no key pair exists`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback>() + dpopClient.getFactors().start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + } + + @Test + public fun `should use DPoP authorization header on PATCH requests 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.updateAuthenticationMethodById("method|123", authenticationMethodName = "Test") + .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("PATCH")) + } + + @Test + public fun `should use DPoP authorization header on DELETE requests 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.deleteAuthenticationMethod("method|123").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("DELETE")) + } + private inline fun bodyFromRequest(request: RecordedRequest): Map { val mapType = object : TypeToken>() {}.type return gson.fromJson(request.body.readUtf8(), mapType)