Skip to content

Conversation

@subhankarmaiti
Copy link
Contributor

@subhankarmaiti subhankarmaiti commented Dec 11, 2025

adds support for Pushed Authorization Request (PAR) flows where the backend-for-frontend (BFF) handles the /par and /token endpoints while the SDK manages the browser-based authorization.

Usage

// Callback style
WebAuthProvider.authorizeWithRequestUri(account)
    .start(context, requestUri, object : Callback<AuthorizationCode, AuthenticationException> {
        override fun onSuccess(result: AuthorizationCode) {
            // Exchange result.code for tokens via BFF
        }
        override fun onFailure(error: AuthenticationException) {
            // Handle error
        }
    })

// Coroutines
val authCode = WebAuthProvider.authorizeWithRequestUri(account)
    .await(context, requestUri)
// Exchange authCode.code for tokens via BFF

@subhankarmaiti subhankarmaiti requested a review from a team as a code owner December 11, 2025 07:07
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds support for Pushed Authorization Request (PAR) flows, enabling a Backend-for-Frontend (BFF) architecture where the backend handles sensitive OAuth operations (/par and /token endpoints) while the SDK manages browser-based authorization. The implementation returns authorization codes to the app for BFF token exchange instead of performing token exchange directly in the SDK.

  • Introduces AuthorizationCode data class to encapsulate authorization codes and optional state parameters
  • Adds PARCodeManager to handle PAR-specific authorization flows without PKCE or token exchange
  • Extends WebAuthProvider with authorizeWithRequestUri() API and PARBuilder supporting both callback and coroutine patterns

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
auth0/src/main/java/com/auth0/android/result/AuthorizationCode.kt New data class to represent authorization code results with optional state parameter
auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt New manager class implementing PAR flow logic, handling browser launch and callback processing
auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt Adds authorizeWithRequestUri() method and PARBuilder class with callback and coroutine-based APIs
auth0/src/test/java/com/auth0/android/provider/PARCodeManagerTest.kt Comprehensive test suite covering PAR flow scenarios including success, error, and cancellation cases
EXAMPLES.md Detailed documentation with usage examples for basic PAR and PAR with PKCE flows

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

* for the app to exchange via BFF.
*
* @param context An Activity context to run the authentication.
* @param requestURI The request_uri from PAR response
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameter documentation says "for the app to exchange via BFF" which is slightly inaccurate. The authorization code is returned to the app, which then sends it to the BFF for token exchange. Consider rewording to: "for the app to send to the BFF for token exchange" for clarity.

Copilot uses AI. Check for mistakes.
Comment on lines +419 to +446
```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
```
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code example for backend PKCE implementation is written in Kotlin, but this is meant to demonstrate backend code. Backend services often use different languages or frameworks. Consider clarifying that this is pseudocode or example logic that should be adapted to the backend's language/framework, or provide a language-agnostic description instead.

Copilot uses AI. Check for mistakes.
</details>

> [!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`.
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The note says "The SDK only handles opening the browser with the request_uri" but this is slightly incomplete. The SDK also handles receiving the authorization code from the callback. Consider clarifying: "The SDK only handles opening the browser with the request_uri and returning the authorization code from the callback. Token exchange must be performed by your backend server which holds the client_secret."

Suggested change
> 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`.
> The SDK only handles opening the browser with the `request_uri` and returning the authorization code from the callback. Token exchange must be performed by your backend server which holds the `client_secret`.

Copilot uses AI. Check for mistakes.
Comment on lines +89 to +104
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))
}
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test shouldResumeWithValidCode doesn't verify that the optional state parameter is properly captured and returned in the AuthorizationCode result. Add a test case that includes a state parameter in the callback to ensure the state field is properly extracted and returned, since the AuthorizationCode class includes an optional state property that should be tested.

Copilot uses AI. Check for mistakes.
Comment on lines +89 to +104
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))
}
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test shouldResumeWithValidCode verifies success but doesn't verify that WebAuthProvider.managerInstance is cleared after successful completion. Add an assertion to ensure the manager instance is cleaned up after resume() returns true, consistent with how it's tested in WebAuthProviderTest (see shouldClearLogoutManagerInstanceAfterSuccessfulLogout). This prevents memory leaks and ensures proper cleanup of resources.

Copilot uses AI. Check for mistakes.
private const val ERROR_VALUE_ACCESS_DENIED = "access_denied"
}

fun startAuthentication(context: Context, requestCode: Int) {
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The startAuthentication function should have an explicit visibility modifier. Following the pattern used in other Manager classes like LogoutManager (which uses 'fun startLogout'), consider marking this as 'internal fun' for consistency, since it's only called internally by the PARBuilder.

Suggested change
fun startAuthentication(context: Context, requestCode: Int) {
internal fun startAuthentication(context: Context, requestCode: Int) {

Copilot uses AI. Check for mistakes.

val values = CallbackHelper.getValuesFromUri(result.intentData)
if (values.isEmpty()) {
Log.w(TAG, "The response didn't contain any values: code")
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The log message "The response didn't contain any values: code" is misleading. Unlike OAuthManager which logs "The response didn't contain any of these values: code, state", this PAR flow only requires the 'code' parameter. However, the check 'values.isEmpty()' is too broad - it will reject callbacks that have query parameters but are missing the code. Consider changing the log message to be more accurate (e.g., "The callback response is empty") or adjust the check to specifically validate for missing 'code' after checking for errors, similar to how it's done later in the function.

Suggested change
Log.w(TAG, "The response didn't contain any values: code")
Log.w(TAG, "The callback response is empty")

Copilot uses AI. Check for mistakes.
* 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.
*
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameter documentation says "for the app to exchange via BFF" which is slightly inaccurate. The authorization code is returned to the app, which then sends it to the BFF for token exchange. Consider rewording to: "for the app to send to the BFF for token exchange" for clarity.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +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<AuthorizationCode, AuthenticationException>

private lateinit var activity: Activity
private lateinit var account: Auth0

private val authCodeCaptor: KArgumentCaptor<AuthorizationCode> = argumentCaptor()
private val authExceptionCaptor: KArgumentCaptor<AuthenticationException> = argumentCaptor()
private val intentCaptor: KArgumentCaptor<Intent> = 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<Uri>(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
}
}
}
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PARBuilder.await() method lacks test coverage. The PR includes comprehensive callback-based tests but no coroutine-based tests for the PAR flow. Add tests similar to how LogoutBuilder.await() is tested in WebAuthProviderTest.kt (see shouldResumeLogoutSuccessfullyWithCoroutines test) to ensure the coroutine-based API works correctly.

Copilot uses AI. Check for mistakes.
> [!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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PKCE flow is something that the backend handles in this scenario. The SDK doesn't play any role other than providing the authorization code. Mentioning the PKCE nuances can cause confusion to the users. Lets just keep it simple with the normal flow description

```
</details>

#### Backend PKCE Implementation
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets just remove this section.

* ```
*/
public class PARBuilder internal constructor(private val account: Auth0) {
private val ctOptions: CustomTabsOptions = CustomTabsOptions.newBuilder().build()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You have defined a property to define CustomTabOptions but no method in the PARBuilder for the user to provide one

Copy link
Contributor

@pmathew92 pmathew92 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to address the changes in the documentation

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants