-
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?
Changes from all commits
6b9dc1c
e7638de
26a6d8a
e2985ad
69f476a
a0458c0
f45cb47
3dba49a
e341e3b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<AuthorizationCode, AuthenticationException> { | ||
| 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 | ||
| } | ||
| } | ||
| }) | ||
| ``` | ||
|
|
||
| <details> | ||
| <summary>Using coroutines</summary> | ||
|
|
||
| ```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 | ||
| } | ||
| } | ||
| ``` | ||
| </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`. | ||
|
|
||
| ### PAR with PKCE | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| 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<AuthorizationCode, AuthenticationException> { | ||
| 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 | ||
| } | ||
| } | ||
| }) | ||
| ``` | ||
|
|
||
| <details> | ||
| <summary>Using coroutines</summary> | ||
|
|
||
| ```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 | ||
| } | ||
| } | ||
| ``` | ||
| </details> | ||
|
|
||
| #### Backend PKCE Implementation | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Lets just remove this section. |
||
|
|
||
| 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 | ||
| ``` | ||
|
Comment on lines
+419
to
+446
|
||
|
|
||
| ## Authentication API | ||
|
|
||
| The client provides methods to authenticate the user against the Auth0 server. | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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<AuthorizationCode, AuthenticationException>, | ||||||
| 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) { | ||||||
|
||||||
| fun startAuthentication(context: Context, requestCode: Int) { | |
| internal 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 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") |
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."