From 6b9dc1ce29b20afa3959b5e36852807b756b3910 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 11 Dec 2025 11:58:16 +0530 Subject: [PATCH 1/7] feat: Implement PAR (Pushed Authorization Request) flow with AuthorizationCode handling --- .../auth0/android/provider/PARCodeManager.kt | 108 +++++++++++++++++ .../auth0/android/provider/WebAuthProvider.kt | 113 ++++++++++++++++++ .../auth0/android/result/AuthorizationCode.kt | 16 +++ 3 files changed, 237 insertions(+) create mode 100644 auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt create mode 100644 auth0/src/main/java/com/auth0/android/result/AuthorizationCode.kt 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..f18429ff --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt @@ -0,0 +1,108 @@ +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_AUTH0_CLIENT_INFO = "auth0Client" + private const val KEY_CODE = "code" + 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 + } + + // Success - return authorization code + val authorizationCode = AuthorizationCode(code = code) + 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) + builder.appendQueryParameter(KEY_AUTH0_CLIENT_INFO, account.auth0UserAgent.value) + + 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..bf3438bc 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 PAR (Pushed Authorization Request) 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 par(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.par(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..62509505 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/AuthorizationCode.kt @@ -0,0 +1,16 @@ +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 + */ +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 +) From e7638de81e2eff31925db9f96d56ce142bee9891 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 11 Dec 2025 11:58:24 +0530 Subject: [PATCH 2/7] feat: Add unit tests for PAR (Pushed Authorization Request) flow in PARCodeManager --- .../android/provider/PARCodeManagerTest.kt | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 auth0/src/test/java/com/auth0/android/provider/PARCodeManagerTest.kt 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..279750d4 --- /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.par +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() { + par(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() { + par(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() { + par(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() { + par(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() { + par(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 + ) + + par(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 + } + } +} From 26a6d8ad28d5c1c19cff6dcd7185b196dad60d1f Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 11 Dec 2025 12:10:00 +0530 Subject: [PATCH 3/7] feat: Add PAR (Pushed Authorization Request) section to examples documentation --- EXAMPLES.md | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/EXAMPLES.md b/EXAMPLES.md index f4fe292e..e6f6464f 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -12,6 +12,7 @@ - [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) - [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 +291,73 @@ 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.par(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.par(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`. + ## Authentication API The client provides methods to authenticate the user against the Auth0 server. From e2985adcc0af6d6074b8c0cd554e938f8563b00c Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 11 Dec 2025 12:31:20 +0530 Subject: [PATCH 4/7] refactor: Remove unused KEY_AUTH0_CLIENT_INFO constant from PARCodeManager --- .../src/main/java/com/auth0/android/provider/PARCodeManager.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt b/auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt index f18429ff..b8eb95e6 100644 --- a/auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt @@ -26,7 +26,6 @@ internal class PARCodeManager( 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_AUTH0_CLIENT_INFO = "auth0Client" private const val KEY_CODE = "code" private const val KEY_ERROR = "error" private const val KEY_ERROR_DESCRIPTION = "error_description" @@ -99,7 +98,6 @@ internal class PARCodeManager( // Only add client_id and request_uri for PAR flow builder.appendQueryParameter(KEY_CLIENT_ID, account.clientId) builder.appendQueryParameter(KEY_REQUEST_URI, requestUri) - builder.appendQueryParameter(KEY_AUTH0_CLIENT_INFO, account.auth0UserAgent.value) val uri = builder.build() Log.d(TAG, "Using the following PAR Authorize URI: $uri") From a0458c041cd8910e42772b3b732f952c0155f48b Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 11 Dec 2025 14:17:52 +0530 Subject: [PATCH 5/7] rename `par` method to `authorizeWithPAR` for clarity and consistency --- EXAMPLES.md | 4 ++-- .../com/auth0/android/provider/WebAuthProvider.kt | 4 ++-- .../auth0/android/provider/PARCodeManagerTest.kt | 14 +++++++------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index e6f6464f..1b6adf51 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -314,7 +314,7 @@ The PAR flow requires coordination between your backend (BFF) and the mobile app val requestUri = yourBffClient.initiatePAR(scope, audience) // Step 2 & 3: SDK opens browser and returns authorization code -WebAuthProvider.par(account) +WebAuthProvider.authorizeWithPAR(account) .start(context, requestUri, object : Callback { override fun onSuccess(result: AuthorizationCode) { // Step 4: Send code to BFF to exchange for tokens @@ -340,7 +340,7 @@ try { val requestUri = yourBffClient.initiatePAR(scope, audience) // Step 2 & 3: SDK opens browser and returns authorization code - val authCode = WebAuthProvider.par(account) + val authCode = WebAuthProvider.authorizeWithPAR(account) .await(context, requestUri) // Step 4: Send code to BFF to exchange for tokens 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 bf3438bc..fe7bee9a 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -89,7 +89,7 @@ public object WebAuthProvider : SenderConstraining { * @return a new PARBuilder instance to customize. */ @JvmStatic - public fun par(account: Auth0): PARBuilder { + public fun authorizeWithPAR(account: Auth0): PARBuilder { return PARBuilder(account) } @@ -671,7 +671,7 @@ public object WebAuthProvider : SenderConstraining { * * Example usage: * ```kotlin - * WebAuthProvider.par(account) + * WebAuthProvider.authorizeWithPAR(account) * .start(context, requestURI, callback) * ``` */ diff --git a/auth0/src/test/java/com/auth0/android/provider/PARCodeManagerTest.kt b/auth0/src/test/java/com/auth0/android/provider/PARCodeManagerTest.kt index 279750d4..2ae792f3 100644 --- a/auth0/src/test/java/com/auth0/android/provider/PARCodeManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/PARCodeManagerTest.kt @@ -6,7 +6,7 @@ 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.par +import com.auth0.android.provider.WebAuthProvider.authorizeWithPAR import com.auth0.android.provider.WebAuthProvider.resume import com.auth0.android.request.internal.ThreadSwitcherShadow import com.auth0.android.result.AuthorizationCode @@ -69,7 +69,7 @@ public class PARCodeManagerTest { @Test public fun shouldStartPARFlowWithCorrectAuthorizeUri() { - par(account) + authorizeWithPAR(account) .start(activity, REQUEST_URI, callback) Assert.assertNotNull(WebAuthProvider.managerInstance) @@ -87,7 +87,7 @@ public class PARCodeManagerTest { @Test public fun shouldResumeWithValidCode() { - par(account) + authorizeWithPAR(account) .start(activity, REQUEST_URI, callback) verify(activity).startActivity(intentCaptor.capture()) @@ -105,7 +105,7 @@ public class PARCodeManagerTest { @Test public fun shouldFailWithMissingCode() { - par(account) + authorizeWithPAR(account) .start(activity, REQUEST_URI, callback) verify(activity).startActivity(intentCaptor.capture()) @@ -123,7 +123,7 @@ public class PARCodeManagerTest { @Test public fun shouldFailWithErrorResponse() { - par(account) + authorizeWithPAR(account) .start(activity, REQUEST_URI, callback) verify(activity).startActivity(intentCaptor.capture()) @@ -141,7 +141,7 @@ public class PARCodeManagerTest { @Test public fun shouldHandleCanceledAuthentication() { - par(account) + authorizeWithPAR(account) .start(activity, REQUEST_URI, callback) verify(activity).startActivity(intentCaptor.capture()) @@ -167,7 +167,7 @@ public class PARCodeManagerTest { null ) - par(account) + authorizeWithPAR(account) .start(activity, REQUEST_URI, callback) verify(callback).onFailure(authExceptionCaptor.capture()) From 3dba49a4874b3e7f0bd29563e1cb96d22fab9341 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 11 Dec 2025 14:46:41 +0530 Subject: [PATCH 6/7] feat: Update examples and methods for request_uri based authorization flows --- EXAMPLES.md | 91 ++++++++++++++++++- .../auth0/android/provider/WebAuthProvider.kt | 6 +- .../android/provider/PARCodeManagerTest.kt | 14 +-- 3 files changed, 99 insertions(+), 12 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 1b6adf51..d8ebecf2 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -13,6 +13,7 @@ - [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) @@ -314,7 +315,7 @@ The PAR flow requires coordination between your backend (BFF) and the mobile app val requestUri = yourBffClient.initiatePAR(scope, audience) // Step 2 & 3: SDK opens browser and returns authorization code -WebAuthProvider.authorizeWithPAR(account) +WebAuthProvider.authorizeWithRequestUri(account) .start(context, requestUri, object : Callback { override fun onSuccess(result: AuthorizationCode) { // Step 4: Send code to BFF to exchange for tokens @@ -340,7 +341,7 @@ try { val requestUri = yourBffClient.initiatePAR(scope, audience) // Step 2 & 3: SDK opens browser and returns authorization code - val authCode = WebAuthProvider.authorizeWithPAR(account) + val authCode = WebAuthProvider.authorizeWithRequestUri(account) .await(context, requestUri) // Step 4: Send code to BFF to exchange for tokens @@ -358,6 +359,92 @@ try { > [!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/WebAuthProvider.kt b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt index fe7bee9a..a7108d76 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -81,7 +81,7 @@ public object WebAuthProvider : SenderConstraining { } /** - * Initialize the WebAuthProvider instance for PAR (Pushed Authorization Request) flows. + * 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. * @@ -89,7 +89,7 @@ public object WebAuthProvider : SenderConstraining { * @return a new PARBuilder instance to customize. */ @JvmStatic - public fun authorizeWithPAR(account: Auth0): PARBuilder { + public fun authorizeWithRequestUri(account: Auth0): PARBuilder { return PARBuilder(account) } @@ -671,7 +671,7 @@ public object WebAuthProvider : SenderConstraining { * * Example usage: * ```kotlin - * WebAuthProvider.authorizeWithPAR(account) + * WebAuthProvider.authorizeWithRequestUri(account) * .start(context, requestURI, callback) * ``` */ diff --git a/auth0/src/test/java/com/auth0/android/provider/PARCodeManagerTest.kt b/auth0/src/test/java/com/auth0/android/provider/PARCodeManagerTest.kt index 2ae792f3..df5ffc52 100644 --- a/auth0/src/test/java/com/auth0/android/provider/PARCodeManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/PARCodeManagerTest.kt @@ -6,7 +6,7 @@ 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.authorizeWithPAR +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 @@ -69,7 +69,7 @@ public class PARCodeManagerTest { @Test public fun shouldStartPARFlowWithCorrectAuthorizeUri() { - authorizeWithPAR(account) + authorizeWithRequestUri(account) .start(activity, REQUEST_URI, callback) Assert.assertNotNull(WebAuthProvider.managerInstance) @@ -87,7 +87,7 @@ public class PARCodeManagerTest { @Test public fun shouldResumeWithValidCode() { - authorizeWithPAR(account) + authorizeWithRequestUri(account) .start(activity, REQUEST_URI, callback) verify(activity).startActivity(intentCaptor.capture()) @@ -105,7 +105,7 @@ public class PARCodeManagerTest { @Test public fun shouldFailWithMissingCode() { - authorizeWithPAR(account) + authorizeWithRequestUri(account) .start(activity, REQUEST_URI, callback) verify(activity).startActivity(intentCaptor.capture()) @@ -123,7 +123,7 @@ public class PARCodeManagerTest { @Test public fun shouldFailWithErrorResponse() { - authorizeWithPAR(account) + authorizeWithRequestUri(account) .start(activity, REQUEST_URI, callback) verify(activity).startActivity(intentCaptor.capture()) @@ -141,7 +141,7 @@ public class PARCodeManagerTest { @Test public fun shouldHandleCanceledAuthentication() { - authorizeWithPAR(account) + authorizeWithRequestUri(account) .start(activity, REQUEST_URI, callback) verify(activity).startActivity(intentCaptor.capture()) @@ -167,7 +167,7 @@ public class PARCodeManagerTest { null ) - authorizeWithPAR(account) + authorizeWithRequestUri(account) .start(activity, REQUEST_URI, callback) verify(callback).onFailure(authExceptionCaptor.capture()) From e341e3b9d62d98086ffbf5e502563b321fdf8ae1 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 11 Dec 2025 14:54:04 +0530 Subject: [PATCH 7/7] feat: Add optional state parameter to AuthorizationCode and update PARCodeManager to handle it --- .../java/com/auth0/android/provider/PARCodeManager.kt | 8 ++++++-- .../java/com/auth0/android/result/AuthorizationCode.kt | 9 ++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt b/auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt index b8eb95e6..1ae5f24d 100644 --- a/auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt @@ -27,6 +27,7 @@ internal class PARCodeManager( 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" @@ -81,8 +82,11 @@ internal class PARCodeManager( return true } - // Success - return authorization code - val authorizationCode = AuthorizationCode(code = code) + // 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 } diff --git a/auth0/src/main/java/com/auth0/android/result/AuthorizationCode.kt b/auth0/src/main/java/com/auth0/android/result/AuthorizationCode.kt index 62509505..bfe1757f 100644 --- a/auth0/src/main/java/com/auth0/android/result/AuthorizationCode.kt +++ b/auth0/src/main/java/com/auth0/android/result/AuthorizationCode.kt @@ -6,11 +6,18 @@ package com.auth0.android.result * 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 + 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 )