diff --git a/EXAMPLES.md b/EXAMPLES.md index 1163d863a..1fc8ca1ee 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -31,6 +31,7 @@ - [Get user information](#get-user-information) - [Custom Token Exchange](#custom-token-exchange) - [Native to Web SSO login](#native-to-web-sso-login) + - [Pushed Authorization Requests (PAR)](#pushed-authorization-requests-par) - [DPoP](#dpop-1) - [My Account API](#my-account-api) - [Enroll a new passkey](#enroll-a-new-passkey) @@ -1691,6 +1692,75 @@ authentication ``` +### Pushed Authorization Requests (PAR) + +This feature handles the browser authorization step of a [PAR (RFC 9126)](https://www.rfc-editor.org/rfc/rfc9126.html) flow. It opens the `/authorize` endpoint with a `request_uri` obtained from your backend's PAR endpoint call, and returns the authorization code for your backend to exchange for tokens. + +> [!IMPORTANT] +> Auth0 only supports PAR for **confidential clients**. Since mobile apps are public clients, the `/oauth/par` and `/oauth/token` calls must be made by your backend (BFF - Backend for Frontend). The SDK only handles opening the browser with the `request_uri` and returning the resulting authorization code. +> +> Your Auth0 application configured in the SDK should use the **same client_id** as the one your backend uses when calling the `/oauth/par` endpoint. + +```kotlin +WebAuthProvider.authorizeWithRequestUri(account) + .start(context, requestUri, object : Callback { + override fun onSuccess(result: AuthorizationCode) { + // Send result.code to your BFF for token exchange + // Validate result.state against the state your BFF used in the PAR request + } + + override fun onFailure(exception: AuthenticationException) { + // Handle error + } + }) +``` + +> [!NOTE] +> The SDK does not validate the `state` parameter. The `state` is generated by your BFF when calling `/oauth/par` and returned as-is in `AuthorizationCode.state`. Your app or BFF **must** validate that the returned `state` matches the original value to prevent CSRF attacks. + +
+ Using coroutines + +```kotlin +try { + val authCode = WebAuthProvider.authorizeWithRequestUri(account) + .await(context, requestUri) + // Send authCode.code to your BFF for token exchange + // Validate authCode.state against the state your BFF used in the PAR request +} catch (e: AuthenticationException) { + e.printStackTrace() +} +``` +
+ +
+ Using Java + +```java +WebAuthProvider.authorizeWithRequestUri(account) + .start(context, requestUri, new Callback() { + @Override + public void onSuccess(@NonNull AuthorizationCode result) { + // Send result.getCode() to your BFF for token exchange + // Validate result.getState() against the state your BFF used in the PAR request + } + + @Override + public void onFailure(@NonNull AuthenticationException exception) { + // Handle error + } + }); +``` +
+ +You can also pass a session transfer token to enable web SSO by transferring an existing native session to the browser: + +```kotlin +WebAuthProvider.authorizeWithRequestUri(account) + .withSessionTransferToken(sessionTransferToken) + .await(context, requestUri) +``` + ## DPoP [DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) (Demonstrating Proof of Possession) is an application-level mechanism for sender-constraining OAuth 2.0 access and refresh tokens by proving that the app is in possession of a certain private key. You can enable it by calling the `useDPoP(context: Context)` method. This ensures that DPoP proofs are generated for requests made through the AuthenticationAPI client. diff --git a/auth0/src/main/java/com/auth0/android/provider/AuthorizeResultParser.kt b/auth0/src/main/java/com/auth0/android/provider/AuthorizeResultParser.kt new file mode 100644 index 000000000..f271588a3 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/AuthorizeResultParser.kt @@ -0,0 +1,52 @@ +package com.auth0.android.provider + +import com.auth0.android.authentication.AuthenticationException + +/** + * Parses the result from an authorization redirect callback. + */ +internal object AuthorizeResultParser { + + sealed class CodeResult { + data class Success(val code: String, val state: String?) : CodeResult() + data class Error(val exception: AuthenticationException) : CodeResult() + object Canceled : CodeResult() + object Invalid : CodeResult() + } + + private const val KEY_CODE = "code" + private const val KEY_STATE = "state" + private const val KEY_ERROR = "error" + private const val KEY_ERROR_DESCRIPTION = "error_description" + + fun parse(result: AuthorizeResult, requestCode: Int): CodeResult { + if (!result.isValid(requestCode)) { + return CodeResult.Invalid + } + + if (result.isCanceled) { + return CodeResult.Canceled + } + + val values = CallbackHelper.getValuesFromUri(result.intentData) + if (values.isEmpty()) { + return CodeResult.Invalid + } + + val error = values[KEY_ERROR] + if (error != null) { + val description = values[KEY_ERROR_DESCRIPTION] ?: error + return CodeResult.Error(AuthenticationException(error, description)) + } + + val code = values[KEY_CODE] + ?: return CodeResult.Error( + AuthenticationException( + "access_denied", + "No authorization code was received in the callback." + ) + ) + + return CodeResult.Success(code = code, state = values[KEY_STATE]) + } +} diff --git a/auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt b/auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt new file mode 100644 index 000000000..190e81502 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt @@ -0,0 +1,88 @@ +package com.auth0.android.provider + +import android.content.Context +import com.auth0.android.Auth0 +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback +import com.auth0.android.result.AuthorizationCode + +/** + * Manager for handling PAR (Pushed Authorization Request) code-only flows. + * This manager handles opening the authorize URL with a request_uri and + * returns the authorization code to the caller for BFF token exchange. + */ +internal class PARCodeManager( + private val account: Auth0, + private val callback: Callback, + private val requestUri: String, + private val sessionTransferToken: String? = null, + private val ctOptions: CustomTabsOptions = CustomTabsOptions.newBuilder().build() +) : ResumableManager() { + + private var requestCode = 0 + + fun startAuthentication(context: Context, requestCode: Int) { + this.requestCode = requestCode + val additionalParams = buildMap { + sessionTransferToken?.let { put("session_transfer_token", it) } + } + val uri = PARUtils.buildAuthorizeUri(account, requestUri, additionalParams) + AuthenticationActivity.authenticateUsingBrowser(context, uri, false, ctOptions) + } + + override fun resume(result: AuthorizeResult): Boolean { + return when (val parsed = AuthorizeResultParser.parse(result, requestCode)) { + is AuthorizeResultParser.CodeResult.Success -> { + callback.onSuccess(AuthorizationCode(parsed.code, parsed.state)) + true + } + is AuthorizeResultParser.CodeResult.Error -> { + callback.onFailure(parsed.exception) + true + } + is AuthorizeResultParser.CodeResult.Canceled -> { + callback.onFailure( + AuthenticationException( + AuthenticationException.ERROR_VALUE_AUTHENTICATION_CANCELED, + "The user closed the browser app and the authentication was canceled." + ) + ) + true + } + AuthorizeResultParser.CodeResult.Invalid -> false + } + } + + override fun failure(exception: AuthenticationException) { + callback.onFailure(exception) + } + + internal fun toState(): PARCodeManagerState { + return PARCodeManagerState( + auth0 = account, + requestCode = requestCode, + requestUri = requestUri, + sessionTransferToken = sessionTransferToken, + ctOptions = ctOptions + ) + } + + internal companion object { + private val TAG = PARCodeManager::class.java.simpleName + + fun fromState( + state: PARCodeManagerState, + callback: Callback + ): PARCodeManager { + val manager = PARCodeManager( + account = state.auth0, + callback = callback, + requestUri = state.requestUri, + sessionTransferToken = state.sessionTransferToken, + ctOptions = state.ctOptions + ) + manager.requestCode = state.requestCode + return manager + } + } +} diff --git a/auth0/src/main/java/com/auth0/android/provider/PARCodeManagerState.kt b/auth0/src/main/java/com/auth0/android/provider/PARCodeManagerState.kt new file mode 100644 index 000000000..a1ce7c4af --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/PARCodeManagerState.kt @@ -0,0 +1,87 @@ +package com.auth0.android.provider + +import android.os.Parcel +import android.os.Parcelable +import android.util.Base64 +import androidx.core.os.ParcelCompat +import com.auth0.android.Auth0 +import com.auth0.android.request.internal.GsonProvider +import com.google.gson.Gson + +internal data class PARCodeManagerState( + val auth0: Auth0, + val requestCode: Int, + val requestUri: String, + val sessionTransferToken: String?, + val ctOptions: CustomTabsOptions +) { + + private class PARCodeManagerJson( + val auth0ClientId: String, + val auth0DomainUrl: String, + val auth0ConfigurationUrl: String?, + val requestCode: Int, + val requestUri: String, + val sessionTransferToken: String?, + val ctOptions: String + ) + + fun serializeToJson(gson: Gson = GsonProvider.gson): String { + val parcel = Parcel.obtain() + try { + parcel.writeParcelable(ctOptions, Parcelable.PARCELABLE_WRITE_RETURN_VALUE) + val ctOptionsEncoded = Base64.encodeToString(parcel.marshall(), Base64.DEFAULT) + + val json = PARCodeManagerJson( + auth0ClientId = auth0.clientId, + auth0DomainUrl = auth0.domain, + auth0ConfigurationUrl = auth0.configurationDomain, + requestCode = requestCode, + requestUri = requestUri, + sessionTransferToken = sessionTransferToken, + ctOptions = ctOptionsEncoded + ) + return gson.toJson(json) + } finally { + parcel.recycle() + } + } + + companion object { + fun deserializeState( + json: String, + gson: Gson = GsonProvider.gson + ): PARCodeManagerState { + val parcel = Parcel.obtain() + try { + val parsed = gson.fromJson(json, PARCodeManagerJson::class.java) + + val decodedCtOptionsBytes = Base64.decode(parsed.ctOptions, Base64.DEFAULT) + parcel.unmarshall(decodedCtOptionsBytes, 0, decodedCtOptionsBytes.size) + parcel.setDataPosition(0) + + val customTabsOptions = ParcelCompat.readParcelable( + parcel, + CustomTabsOptions::class.java.classLoader, + CustomTabsOptions::class.java + ) ?: error("Couldn't deserialize CustomTabsOptions from Parcel") + + val auth0 = Auth0.getInstance( + clientId = parsed.auth0ClientId, + domain = parsed.auth0DomainUrl, + configurationDomain = parsed.auth0ConfigurationUrl + ) + + return PARCodeManagerState( + auth0 = auth0, + requestCode = parsed.requestCode, + requestUri = parsed.requestUri, + sessionTransferToken = parsed.sessionTransferToken, + ctOptions = customTabsOptions + ) + } finally { + parcel.recycle() + } + } + } +} diff --git a/auth0/src/main/java/com/auth0/android/provider/PARUtils.kt b/auth0/src/main/java/com/auth0/android/provider/PARUtils.kt new file mode 100644 index 000000000..c2bc48234 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/PARUtils.kt @@ -0,0 +1,40 @@ +package com.auth0.android.provider + +import android.net.Uri +import com.auth0.android.Auth0 +import com.auth0.android.authentication.AuthenticationException +import androidx.core.net.toUri + +/** + * Shared utilities for PAR (Pushed Authorization Request) flows. + */ +internal object PARUtils { + + internal const val REQUEST_URI_PREFIX = "urn:ietf:params:oauth:request_uri:" + + /** + * Validates that the request_uri conforms to the expected format. + * @return true if valid, false otherwise. + */ + fun isValidRequestUri(requestUri: String): Boolean { + return requestUri.startsWith(REQUEST_URI_PREFIX) + } + + /** + * Builds a minimal /authorize URI for PAR flows containing only client_id and request_uri, + * plus any additional query parameters. + */ + fun buildAuthorizeUri( + account: Auth0, + requestUri: String, + additionalParameters: Map = emptyMap() + ): Uri { + val builder = account.authorizeUrl.toUri().buildUpon() + .appendQueryParameter("client_id", account.clientId) + .appendQueryParameter("request_uri", requestUri) + for ((key, value) in additionalParameters) { + builder.appendQueryParameter(key, value) + } + return builder.build() + } +} diff --git a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt index 955eec9c5..6d647a432 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -11,6 +11,7 @@ import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.Callback import com.auth0.android.dpop.DPoP import com.auth0.android.dpop.SenderConstraining +import com.auth0.android.result.AuthorizationCode import com.auth0.android.result.Credentials import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine @@ -30,9 +31,12 @@ import kotlin.coroutines.resumeWithException public object WebAuthProvider : SenderConstraining { private val TAG: String? = WebAuthProvider::class.simpleName private const val KEY_BUNDLE_OAUTH_MANAGER_STATE = "oauth_manager_state" + private const val KEY_BUNDLE_PAR_MANAGER_STATE = "par_manager_state" + private const val AUTH_REQUEST_CODE = 110 private var dPoP : DPoP? = null private val callbacks = CopyOnWriteArraySet>() + private val parCallbacks = CopyOnWriteArraySet>() @JvmStatic @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @@ -79,6 +83,22 @@ public object WebAuthProvider : SenderConstraining { return Builder(account) } + /** + * Initialize the WebAuthProvider instance for request_uri based authorization flows (PAR). + * Use this when your backend (BFF) has already called the /oauth/par endpoint and you need to + * complete the authorization by opening /authorize with the request_uri. + * + * The SDK only handles opening the browser and returning the authorization code. + * Token exchange must be performed by your backend server which holds the client_secret. + * + * @param account to use for authentication + * @return a new PARBuilder instance to customize. + */ + @JvmStatic + public fun authorizeWithRequestUri(account: Auth0): PARBuilder { + return PARBuilder(account) + } + /** * Finishes the authentication or log out flow by passing the data received in the activity's onNewIntent() callback. * The final result will be delivered to the callback specified when calling start(). @@ -116,14 +136,18 @@ public object WebAuthProvider : SenderConstraining { if (manager is OAuthManager) { val managerState = manager.toState() bundle.putString(KEY_BUNDLE_OAUTH_MANAGER_STATE, managerState.serializeToJson()) + } else if (manager is PARCodeManager) { + val managerState = manager.toState() + bundle.putString(KEY_BUNDLE_PAR_MANAGER_STATE, managerState.serializeToJson()) } } internal fun onRestoreInstanceState(bundle: Bundle) { if (managerInstance == null) { - val stateJson = bundle.getString(KEY_BUNDLE_OAUTH_MANAGER_STATE).orEmpty() - if (stateJson.isNotBlank()) { - val state = OAuthManagerState.deserializeState(stateJson) + val oauthStateJson = bundle.getString(KEY_BUNDLE_OAUTH_MANAGER_STATE).orEmpty() + val parStateJson = bundle.getString(KEY_BUNDLE_PAR_MANAGER_STATE).orEmpty() + if (oauthStateJson.isNotBlank()) { + val state = OAuthManagerState.deserializeState(oauthStateJson) managerInstance = OAuthManager.fromState( state, object : Callback { @@ -140,6 +164,24 @@ public object WebAuthProvider : SenderConstraining { } } ) + } else if (parStateJson.isNotBlank()) { + val state = PARCodeManagerState.deserializeState(parStateJson) + managerInstance = PARCodeManager.fromState( + state, + object : Callback { + override fun onSuccess(result: AuthorizationCode) { + for (callback in parCallbacks) { + callback.onSuccess(result) + } + } + + override fun onFailure(error: AuthenticationException) { + for (callback in parCallbacks) { + callback.onFailure(error) + } + } + } + ) } } } @@ -604,7 +646,7 @@ public object WebAuthProvider : SenderConstraining { account.getDomainUrl() ) } - manager.startAuthentication(context, redirectUri!!, 110) + manager.startAuthentication(context, redirectUri!!, AUTH_REQUEST_CODE) } /** @@ -647,4 +689,141 @@ public object WebAuthProvider : SenderConstraining { private const val KEY_CONNECTION_SCOPE = "connection_scope" } } + + /** + * Builder for PAR (Pushed Authorization Request) code-only authentication flows. + * + * Use this builder when your backend (BFF) has already called the PAR endpoint + * and you need to complete the authorization flow by opening the authorize URL + * with the request_uri. + * + * Example usage: + * ```kotlin + * val authCode = WebAuthProvider.authorizeWithRequestUri(account) + * .await(context, requestUri) + * // Send authCode.code to your BFF for token exchange + * // IMPORTANT: Validate authCode.state against the state your BFF + * // used in the PAR request to prevent CSRF attacks. + * ``` + */ + public class PARBuilder internal constructor(private val account: Auth0) { + private var ctOptions: CustomTabsOptions = CustomTabsOptions.newBuilder().build() + private var sessionTransferToken: String? = null + + /** + * When using a Custom Tabs compatible Browser, apply these customization options. + * + * @param options the Custom Tabs customization options + * @return the current builder instance + */ + public fun withCustomTabsOptions(options: CustomTabsOptions): PARBuilder { + ctOptions = options + return this + } + + /** + * Provide a session transfer token to be passed as a query parameter to the /authorize endpoint. + * This enables web single sign-on by transferring an existing session to the browser. + * + * @param token the session transfer token obtained from [AuthenticationAPIClient.ssoExchange] + * @return the current builder instance + */ + public fun withSessionTransferToken(token: String): PARBuilder { + this.sessionTransferToken = token + return this + } + + /** + * Start the PAR authorization flow using a request_uri from a PAR response. + * Opens the browser with the authorize URL and returns the authorization code + * for the app to exchange via BFF. + * + * @param context An Activity context to run the authentication. + * @param requestUri The request_uri obtained from the PAR endpoint (must start with "urn:ietf:params:oauth:request_uri:") + * @param callback Callback with authorization code result + */ + public fun start( + context: Context, + requestUri: String, + callback: Callback + ) { + resetManagerInstance() + + if (!PARUtils.isValidRequestUri(requestUri)) { + val ex = AuthenticationException( + "a0.invalid_request_uri", + "The request_uri must start with \"${PARUtils.REQUEST_URI_PREFIX}\"." + ) + callback.onFailure(ex) + return + } + + if (!ctOptions.hasCompatibleBrowser(context.packageManager)) { + val ex = AuthenticationException( + "a0.browser_not_available", + "No compatible Browser application is installed." + ) + callback.onFailure(ex) + return + } + + val manager = PARCodeManager( + account = account, + callback = callback, + requestUri = requestUri, + sessionTransferToken = sessionTransferToken, + ctOptions = ctOptions + ) + + managerInstance = manager + manager.startAuthentication(context, AUTH_REQUEST_CODE) + } + + /** + * Start the PAR authorization flow using a request_uri from a PAR response. + * Opens the browser with the authorize URL and returns the authorization code + * for the app to exchange via BFF. + * + * @param context An Activity context to run the authentication. + * @param requestUri The request_uri obtained from the PAR endpoint (must start with "urn:ietf:params:oauth:request_uri:") + * @return AuthorizationCode containing the authorization code + * @throws AuthenticationException if authentication fails or the request_uri is invalid + */ + @JvmSynthetic + @Throws(AuthenticationException::class) + public suspend fun await( + context: Context, + requestUri: String + ): AuthorizationCode { + return await(context, requestUri, Dispatchers.Main.immediate) + } + + /** + * Used internally so that [CoroutineContext] can be injected for testing purpose + */ + internal suspend fun await( + context: Context, + requestUri: String, + coroutineContext: CoroutineContext + ): AuthorizationCode { + return withContext(coroutineContext) { + suspendCancellableCoroutine { continuation -> + start( + context, + requestUri, + object : Callback { + override fun onSuccess(result: AuthorizationCode) { + continuation.resume(result) + } + + override fun onFailure(error: AuthenticationException) { + continuation.resumeWithException(error) + } + } + ) + } + } + } + + } } diff --git a/auth0/src/main/java/com/auth0/android/result/AuthorizationCode.kt b/auth0/src/main/java/com/auth0/android/result/AuthorizationCode.kt new file mode 100644 index 000000000..071d8420f --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/AuthorizationCode.kt @@ -0,0 +1,27 @@ +package com.auth0.android.result + +/** + * Result returned when the SDK completes a PAR (Pushed Authorization Request) flow. + * Contains the authorization code that should be sent to your backend (BFF) for token exchange. + * + * **Important:** The SDK does not validate the [state] parameter. Your app or BFF must validate + * that the returned [state] matches the value originally used in the PAR request to prevent + * CSRF attacks. + * + * @property code The authorization code received from Auth0. + * @property state The optional state parameter received from Auth0, if present. + */ +public data class AuthorizationCode( + /** + * The authorization code received from Auth0. + * This code should be sent to your BFF for token exchange. + */ + public val code: String, + + /** + * The optional state parameter received from Auth0. + * Your app or BFF must validate this against the original state used in the + * PAR request to prevent CSRF attacks. + */ + public val state: String? = null +) diff --git a/auth0/src/test/java/com/auth0/android/provider/PARCodeManagerTest.kt b/auth0/src/test/java/com/auth0/android/provider/PARCodeManagerTest.kt new file mode 100644 index 000000000..f1094e287 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/provider/PARCodeManagerTest.kt @@ -0,0 +1,235 @@ +package com.auth0.android.provider + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import com.auth0.android.Auth0 +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback +import com.auth0.android.provider.WebAuthProvider.authorizeWithRequestUri +import com.auth0.android.provider.WebAuthProvider.resume +import com.auth0.android.request.internal.ThreadSwitcherShadow +import com.auth0.android.result.AuthorizationCode +import com.nhaarman.mockitokotlin2.* +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.CoreMatchers.notNullValue +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.MockitoAnnotations +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ThreadSwitcherShadow::class]) +public class PARCodeManagerTest { + + @Mock + private lateinit var callback: Callback + + private lateinit var activity: Activity + private lateinit var account: Auth0 + + private val authCodeCaptor: KArgumentCaptor = argumentCaptor() + private val authExceptionCaptor: KArgumentCaptor = argumentCaptor() + private val intentCaptor: KArgumentCaptor = argumentCaptor() + + private companion object { + private const val DOMAIN = "samples.auth0.com" + private const val CLIENT_ID = "test-client-id" + private const val REQUEST_URI = "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c" + private const val AUTH_CODE = "test-authorization-code" + private const val SESSION_TRANSFER_TOKEN = "test-session-transfer-token" + } + + @Before + public fun setUp() { + MockitoAnnotations.openMocks(this) + activity = Mockito.spy(Robolectric.buildActivity(Activity::class.java).get()) + account = Auth0.getInstance(CLIENT_ID, DOMAIN) + + Mockito.doReturn(false).`when`(activity).bindService( + any(), + any(), + ArgumentMatchers.anyInt() + ) + BrowserPickerTest.setupBrowserContext( + activity, + listOf("com.auth0.browser"), + null, + null + ) + } + + @Test + public fun shouldStartPARFlowWithCorrectAuthorizeUri() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + Assert.assertNotNull(WebAuthProvider.managerInstance) + + verify(activity).startActivity(intentCaptor.capture()) + val uri = intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + + assertThat(uri, `is`(notNullValue())) + assertThat(uri?.scheme, `is`("https")) + assertThat(uri?.host, `is`(DOMAIN)) + assertThat(uri?.path, `is`("/authorize")) + assertThat(uri?.getQueryParameter("client_id"), `is`(CLIENT_ID)) + assertThat(uri?.getQueryParameter("request_uri"), `is`(REQUEST_URI)) + } + + @Test + public fun shouldResumeWithValidCode() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + + val intent = createAuthIntent("code=$AUTH_CODE") + + Assert.assertTrue(resume(intent)) + + verify(callback).onSuccess(authCodeCaptor.capture()) + val authCode = authCodeCaptor.firstValue + assertThat(authCode, `is`(notNullValue())) + assertThat(authCode.code, `is`(AUTH_CODE)) + } + + @Test + public fun shouldResumeWithCodeAndStateFromRedirect() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + + val intent = createAuthIntent("code=$AUTH_CODE&state=par-state") + + Assert.assertTrue(resume(intent)) + + verify(callback).onSuccess(authCodeCaptor.capture()) + val authCode = authCodeCaptor.firstValue + assertThat(authCode.code, `is`(AUTH_CODE)) + assertThat(authCode.state, `is`("par-state")) + } + + @Test + public fun shouldFailWithMissingCode() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + + val intent = createAuthIntent("foo=bar") + + Assert.assertTrue(resume(intent)) + + verify(callback).onFailure(authExceptionCaptor.capture()) + val exception = authExceptionCaptor.firstValue + assertThat(exception, `is`(notNullValue())) + assertThat(exception.isAccessDenied, `is`(true)) + } + + @Test + public fun shouldFailWithErrorResponse() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + + val intent = createAuthIntent("error=access_denied&error_description=User%20denied%20access") + + Assert.assertTrue(resume(intent)) + + verify(callback).onFailure(authExceptionCaptor.capture()) + val exception = authExceptionCaptor.firstValue + assertThat(exception, `is`(notNullValue())) + assertThat(exception.getCode(), `is`("access_denied")) + } + + @Test + public fun shouldHandleCanceledAuthentication() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + + val intent = Intent() + + Assert.assertTrue(resume(intent)) + + verify(callback).onFailure(authExceptionCaptor.capture()) + val exception = authExceptionCaptor.firstValue + assertThat(exception, `is`(notNullValue())) + assertThat(exception.isCanceled, `is`(true)) + } + + @Test + public fun shouldFailWhenNoBrowserAvailable() { + BrowserPickerTest.setupBrowserContext( + activity, + emptyList(), + null, + null + ) + + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(callback).onFailure(authExceptionCaptor.capture()) + val exception = authExceptionCaptor.firstValue + assertThat(exception, `is`(notNullValue())) + assertThat(exception.isBrowserAppNotAvailable, `is`(true)) + } + + @Test + public fun shouldIncludeSessionTransferTokenInAuthorizeUri() { + authorizeWithRequestUri(account) + .withSessionTransferToken(SESSION_TRANSFER_TOKEN) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + val uri = intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + + assertThat(uri, `is`(notNullValue())) + assertThat(uri?.getQueryParameter("client_id"), `is`(CLIENT_ID)) + assertThat(uri?.getQueryParameter("request_uri"), `is`(REQUEST_URI)) + assertThat(uri?.getQueryParameter("session_transfer_token"), `is`(SESSION_TRANSFER_TOKEN)) + } + + @Test + public fun shouldNotIncludeSessionTransferTokenWhenNotProvided() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + val uri = intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + + assertThat(uri, `is`(notNullValue())) + assertThat(uri?.getQueryParameter("session_transfer_token"), `is`(org.hamcrest.CoreMatchers.nullValue())) + } + + @Test + public fun shouldFailWithInvalidRequestUri() { + authorizeWithRequestUri(account) + .start(activity, "invalid-request-uri", callback) + + verify(callback).onFailure(authExceptionCaptor.capture()) + val exception = authExceptionCaptor.firstValue + assertThat(exception, `is`(notNullValue())) + assertThat(exception.getCode(), `is`("a0.invalid_request_uri")) + } + + private fun createAuthIntent(queryString: String): Intent { + val uri = Uri.parse("https://$DOMAIN/android/com.auth0.test/callback?$queryString") + return Intent().apply { + data = uri + } + } +} diff --git a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt index d603d3456..e033aa8df 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -3022,4 +3022,88 @@ public class WebAuthProviderTest { private const val KEY_STATE = "state" private const val KEY_NONCE = "nonce" } + + + @Test + public fun shouldAuthorizeWithRequestUri() { + val requestUri = "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c" + val parCallback: Callback = mock() + + WebAuthProvider.authorizeWithRequestUri(account) + .start(activity, requestUri, parCallback) + + Assert.assertNotNull(WebAuthProvider.managerInstance) + verify(activity).startActivity(intentCaptor.capture()) + val uri = intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + + assertThat(uri, `is`(notNullValue())) + assertThat(uri?.getQueryParameter("client_id"), `is`(JwtTestUtils.EXPECTED_AUDIENCE)) + assertThat(uri?.getQueryParameter("request_uri"), `is`(requestUri)) + } + + @Test + public fun shouldAuthorizeWithRequestUriAndSessionTransferToken() { + val requestUri = "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c" + val sessionTransferToken = "stt_test_token_value" + val parCallback: Callback = mock() + + WebAuthProvider.authorizeWithRequestUri(account) + .withSessionTransferToken(sessionTransferToken) + .start(activity, requestUri, parCallback) + + verify(activity).startActivity(intentCaptor.capture()) + val uri = intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + + assertThat(uri, `is`(notNullValue())) + assertThat(uri?.getQueryParameter("client_id"), `is`(JwtTestUtils.EXPECTED_AUDIENCE)) + assertThat(uri?.getQueryParameter("request_uri"), `is`(requestUri)) + assertThat(uri?.getQueryParameter("session_transfer_token"), `is`(sessionTransferToken)) + } + + @Test + public fun shouldFailAuthorizeWithRequestUriWhenInvalidRequestUri() { + val parCallback: Callback = mock() + val exceptionCaptor: KArgumentCaptor = argumentCaptor() + + WebAuthProvider.authorizeWithRequestUri(account) + .start(activity, "invalid-uri", parCallback) + + verify(parCallback).onFailure(exceptionCaptor.capture()) + assertThat(exceptionCaptor.firstValue.getCode(), `is`("a0.invalid_request_uri")) + } + + @Test + public fun shouldFailAuthorizeWithRequestUriWhenNoBrowserAvailable() { + BrowserPickerTest.setupBrowserContext(activity, emptyList(), null, null) + val requestUri = "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c" + val parCallback: Callback = mock() + val exceptionCaptor: KArgumentCaptor = argumentCaptor() + + WebAuthProvider.authorizeWithRequestUri(account) + .start(activity, requestUri, parCallback) + + verify(parCallback).onFailure(exceptionCaptor.capture()) + assertThat(exceptionCaptor.firstValue.isBrowserAppNotAvailable, `is`(true)) + } + + @Test + public fun shouldResumeAuthorizeWithRequestUriWithCode() { + val requestUri = "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c" + val parCallback: Callback = mock() + val codeCaptor: KArgumentCaptor = argumentCaptor() + + WebAuthProvider.authorizeWithRequestUri(account) + .start(activity, requestUri, parCallback) + + verify(activity).startActivity(intentCaptor.capture()) + + val intent = Intent().apply { + data = Uri.parse("https://${JwtTestUtils.EXPECTED_BASE_DOMAIN}/android/com.auth0.test/callback?code=test-code&state=test-state") + } + Assert.assertTrue(resume(intent)) + + verify(parCallback).onSuccess(codeCaptor.capture()) + assertThat(codeCaptor.firstValue.code, `is`("test-code")) + assertThat(codeCaptor.firstValue.state, `is`("test-state")) + } } \ No newline at end of file