diff --git a/EXAMPLES.md b/EXAMPLES.md index f4fe292e..d8ebecf2 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -12,6 +12,8 @@ - [Specify a Custom Logout URL](#specify-a-custom-logout-url) - [Trusted Web Activity](#trusted-web-activity) - [DPoP [EA]](#dpop-ea) + - [PAR (Pushed Authorization Request)](#par-pushed-authorization-request) + - [PAR with PKCE](#par-with-pkce) - [Authentication API](#authentication-api) - [Login with database connection](#login-with-database-connection) - [Login using MFA with One Time Password code](#login-using-mfa-with-one-time-password-code) @@ -290,6 +292,159 @@ WebAuthProvider.logout(account) > [!NOTE] > DPoP is supported only on Android version 6.0 (API level 23) and above. Trying to use DPoP in any older versions will result in an exception. +## PAR (Pushed Authorization Request) + +PAR (Pushed Authorization Request) enables a Backend-for-Frontend (BFF) pattern where your backend server handles the `/par` and `/token` endpoints while the SDK manages the browser-based authorization flow. + +This is useful when: +- You need to use a confidential client with a `client_secret` +- You want to keep sensitive parameters server-side +- You're implementing a BFF architecture + +### Usage + +The PAR flow requires coordination between your backend (BFF) and the mobile app: + +1. **Backend calls `/par` endpoint** - Your backend initiates the PAR request with the `client_secret` and receives a `request_uri` +2. **SDK opens `/authorize`** - The mobile app uses the `request_uri` to open the browser for user authentication +3. **SDK returns authorization code** - After authentication, the SDK returns the authorization code to the app +4. **Backend exchanges code for tokens** - Your backend exchanges the code for tokens using the `client_secret` + +```kotlin +// Step 1: Your BFF calls /par and returns request_uri to the app +val requestUri = yourBffClient.initiatePAR(scope, audience) + +// Step 2 & 3: SDK opens browser and returns authorization code +WebAuthProvider.authorizeWithRequestUri(account) + .start(context, requestUri, object : Callback { + override fun onSuccess(result: AuthorizationCode) { + // Step 4: Send code to BFF to exchange for tokens + yourBffClient.exchangeCode(result.code) + } + + override fun onFailure(error: AuthenticationException) { + if (error.isCanceled) { + // User closed the browser + } else { + // Handle error + } + } + }) +``` + +
+ Using coroutines + +```kotlin +try { + // Step 1: Your BFF calls /par and returns request_uri + val requestUri = yourBffClient.initiatePAR(scope, audience) + + // Step 2 & 3: SDK opens browser and returns authorization code + val authCode = WebAuthProvider.authorizeWithRequestUri(account) + .await(context, requestUri) + + // Step 4: Send code to BFF to exchange for tokens + val credentials = yourBffClient.exchangeCode(authCode.code) +} catch (e: AuthenticationException) { + if (e.isCanceled) { + // User closed the browser + } else { + // Handle error + } +} +``` +
+ +> [!NOTE] +> The SDK only handles opening the browser with the `request_uri` and returning the authorization code. Token exchange must be performed by your backend server which holds the `client_secret`. + +### PAR with PKCE + +When using PAR with PKCE (Proof Key for Code Exchange), your backend generates a `code_verifier` and `code_challenge` during the `/par` request, and includes the `code_verifier` when exchanging the authorization code for tokens. + +The PKCE flow adds an extra layer of security by ensuring that only the party that initiated the authorization request can exchange the code for tokens. + +```kotlin +// Step 1: Your BFF calls /par with code_challenge and returns request_uri + code_verifier +val parResponse = yourBffClient.initiatePARWithPKCE(scope, audience) +// parResponse contains: requestUri and codeVerifier + +// Step 2 & 3: SDK opens browser and returns authorization code +WebAuthProvider.authorizeWithRequestUri(account) + .start(context, parResponse.requestUri, object : Callback { + override fun onSuccess(result: AuthorizationCode) { + // Step 4: Send code AND code_verifier to BFF to exchange for tokens + yourBffClient.exchangeCodeWithPKCE(result.code, parResponse.codeVerifier) + } + + override fun onFailure(error: AuthenticationException) { + if (error.isCanceled) { + // User closed the browser + } else { + // Handle error + } + } + }) +``` + +
+ Using coroutines + +```kotlin +try { + // Step 1: Your BFF calls /par with code_challenge and returns request_uri + code_verifier + val parResponse = yourBffClient.initiatePARWithPKCE(scope, audience) + + // Step 2 & 3: SDK opens browser and returns authorization code + val authCode = WebAuthProvider.authorizeWithRequestUri(account) + .await(context, parResponse.requestUri) + + // Step 4: Send code AND code_verifier to BFF to exchange for tokens + val credentials = yourBffClient.exchangeCodeWithPKCE(authCode.code, parResponse.codeVerifier) +} catch (e: AuthenticationException) { + if (e.isCanceled) { + // User closed the browser + } else { + // Handle error + } +} +``` +
+ +#### Backend PKCE Implementation + +Your backend should generate the `code_verifier` and `code_challenge` during the `/par` request: + +```kotlin +// Backend: Generate PKCE values +fun generateCodeVerifier(): String { + val randomBytes = ByteArray(32) + SecureRandom().nextBytes(randomBytes) + return Base64.encodeToString(randomBytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) +} + +fun generateCodeChallenge(codeVerifier: String): String { + val bytes = codeVerifier.toByteArray(Charsets.US_ASCII) + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(bytes) + return Base64.encodeToString(hash, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) +} + +// Include in /par request +val codeVerifier = generateCodeVerifier() +val codeChallenge = generateCodeChallenge(codeVerifier) + +// POST to /oauth/par with: +// - code_challenge: codeChallenge +// - code_challenge_method: "S256" + +// Store codeVerifier and return it with request_uri to the app + +// Later, in /oauth/token request, include: +// - code_verifier: codeVerifier +``` + ## Authentication API The client provides methods to authenticate the user against the Auth0 server. 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 00000000..1ae5f24d --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt @@ -0,0 +1,110 @@ +package com.auth0.android.provider + +import android.content.Context +import android.net.Uri +import android.util.Log +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 ctOptions: CustomTabsOptions = CustomTabsOptions.newBuilder().build() +) : ResumableManager() { + + private var requestCode = 0 + + private companion object { + private val TAG = PARCodeManager::class.java.simpleName + private const val KEY_CLIENT_ID = "client_id" + private const val KEY_REQUEST_URI = "request_uri" + 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" + private const val ERROR_VALUE_ACCESS_DENIED = "access_denied" + } + + fun startAuthentication(context: Context, requestCode: Int) { + this.requestCode = requestCode + val uri = buildAuthorizeUri() + AuthenticationActivity.authenticateUsingBrowser(context, uri, false, ctOptions) + } + + override fun resume(result: AuthorizeResult): Boolean { + if (!result.isValid(requestCode)) { + Log.w(TAG, "The Authorize Result is invalid.") + return false + } + + if (result.isCanceled) { + val exception = AuthenticationException( + AuthenticationException.ERROR_VALUE_AUTHENTICATION_CANCELED, + "The user closed the browser app and the authentication was canceled." + ) + callback.onFailure(exception) + return true + } + + val values = CallbackHelper.getValuesFromUri(result.intentData) + if (values.isEmpty()) { + Log.w(TAG, "The response didn't contain any values: code") + return false + } + + Log.d(TAG, "The parsed CallbackURI contains the following parameters: ${values.keys}") + + // Check for error response + val error = values[KEY_ERROR] + if (error != null) { + val description = values[KEY_ERROR_DESCRIPTION] ?: error + val authError = AuthenticationException(error, description) + callback.onFailure(authError) + return true + } + + // Extract code + val code = values[KEY_CODE] + if (code == null) { + val exception = AuthenticationException( + ERROR_VALUE_ACCESS_DENIED, + "No authorization code was received in the callback." + ) + callback.onFailure(exception) + return true + } + + // Extract optional state + val state = values[KEY_STATE] + + // Success - return authorization code with optional state + val authorizationCode = AuthorizationCode(code = code, state = state) + callback.onSuccess(authorizationCode) + return true + } + + override fun failure(exception: AuthenticationException) { + callback.onFailure(exception) + } + + private fun buildAuthorizeUri(): Uri { + val authorizeUri = Uri.parse(account.authorizeUrl) + val builder = authorizeUri.buildUpon() + + // Only add client_id and request_uri for PAR flow + builder.appendQueryParameter(KEY_CLIENT_ID, account.clientId) + builder.appendQueryParameter(KEY_REQUEST_URI, requestUri) + + val uri = builder.build() + Log.d(TAG, "Using the following PAR Authorize URI: $uri") + return uri + } +} 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 955eec9c..a7108d76 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 @@ -79,6 +80,19 @@ public object WebAuthProvider : SenderConstraining { return Builder(account) } + /** + * Initialize the WebAuthProvider instance for request_uri based authorization flows. + * Use this when your BFF has already called the /oauth/par endpoint and you need to + * complete the authorization by opening /authorize with the request_uri. + * + * @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(). @@ -647,4 +661,103 @@ 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 + * WebAuthProvider.authorizeWithRequestUri(account) + * .start(context, requestURI, callback) + * ``` + */ + public class PARBuilder internal constructor(private val account: Auth0) { + private val ctOptions: CustomTabsOptions = CustomTabsOptions.newBuilder().build() + + /** + * 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 from PAR response + * @param callback Callback with authorization code result + */ + public fun start( + context: Context, + requestURI: String, + callback: Callback + ) { + resetManagerInstance() + + 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, + ctOptions = ctOptions + ) + + managerInstance = manager + manager.startAuthentication(context, 110) + } + + /** + * 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 from PAR response + * @return AuthorizationCode containing the authorization code + * @throws AuthenticationException if authentication fails + */ + @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 00000000..bfe1757f --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/AuthorizationCode.kt @@ -0,0 +1,23 @@ +package com.auth0.android.result + +/** + * Result when SDK returns authorization code instead of credentials. + * Used in PAR (Pushed Authorization Request) flows where the BFF + * handles the token exchange. + * + * @property code The authorization code from the callback + * @property state The optional state parameter from the callback, 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. + * This can be used by the BFF to correlate the response with the original request. + */ + 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 00000000..df5ffc52 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/provider/PARCodeManagerTest.kt @@ -0,0 +1,185 @@ +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" + } + + @Before + public fun setUp() { + MockitoAnnotations.openMocks(this) + activity = Mockito.spy(Robolectric.buildActivity(Activity::class.java).get()) + account = Auth0.getInstance(CLIENT_ID, DOMAIN) + + // Prevent CustomTabService from being bound to Test environment + 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()) + + // Create callback intent with code + 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 shouldFailWithMissingCode() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + + // Create callback intent without code + 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()) + + // Create callback intent with error + 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()) + + // Create canceled intent (null data) + 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() { + // Setup context without browser + 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)) + } + + private fun createAuthIntent(queryString: String): Intent { + val uri = Uri.parse("https://$DOMAIN/android/com.auth0.test/callback?$queryString") + return Intent().apply { + data = uri + } + } +}