diff --git a/.gitignore b/.gitignore index 8becb521..0d2a32b2 100644 --- a/.gitignore +++ b/.gitignore @@ -99,4 +99,5 @@ gen-external-apklibs version.txt # Internal planning docs -plans/ \ No newline at end of file +plans/ +docs/ \ No newline at end of file diff --git a/EXAMPLES.md b/EXAMPLES.md index 1520f3cf..e955a3c5 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -27,6 +27,9 @@ - [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) @@ -1488,6 +1491,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 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 94b956f3..b80f3487 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,7 @@ 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 @@ -118,6 +119,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 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 00000000..f4abbbb0 --- /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 00000000..117fbcaa --- /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/result/PasswordlessChallenge.kt b/auth0/src/main/java/com/auth0/android/result/PasswordlessChallenge.kt new file mode 100644 index 00000000..36e4f8e5 --- /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/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt index 72ca29fe..f0546a2a 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -141,6 +141,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() 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 00000000..9403058a --- /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 com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.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" + } +}