-
Notifications
You must be signed in to change notification settings - Fork 166
Add PAR (Pushed Authorization Request) support #888
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
…ationCode handling
…into feat/hri-par
…RCodeManager to handle it
There was a problem hiding this 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
AuthorizationCodedata class to encapsulate authorization codes and optional state parameters - Adds
PARCodeManagerto handle PAR-specific authorization flows without PKCE or token exchange - Extends
WebAuthProviderwithauthorizeWithRequestUri()API andPARBuildersupporting 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 |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
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.
| ```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 | ||
| ``` |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
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.
| </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`. |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
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."
| > 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`. |
| 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)) | ||
| } |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
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.
| 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)) | ||
| } |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
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.
| private const val ERROR_VALUE_ACCESS_DENIED = "access_denied" | ||
| } | ||
|
|
||
| fun startAuthentication(context: Context, requestCode: Int) { |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
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.
| fun startAuthentication(context: Context, requestCode: Int) { | |
| internal fun startAuthentication(context: Context, requestCode: Int) { |
|
|
||
| val values = CallbackHelper.getValuesFromUri(result.intentData) | ||
| if (values.isEmpty()) { | ||
| Log.w(TAG, "The response didn't contain any values: code") |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
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.
| Log.w(TAG, "The response didn't contain any values: code") | |
| Log.w(TAG, "The callback response is empty") |
| * 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. | ||
| * |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
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.
| 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 | ||
| } | ||
| } | ||
| } |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
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.
| > [!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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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
pmathew92
left a comment
There was a problem hiding this 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
adds support for Pushed Authorization Request (PAR) flows where the backend-for-frontend (BFF) handles the
/parand/tokenendpoints while the SDK manages the browser-based authorization.Usage