Skip to content

Commit e9914b7

Browse files
authored
feat :Add partial support for PAR auth flow (#967)
1 parent e8a2257 commit e9914b7

9 files changed

Lines changed: 866 additions & 4 deletions

File tree

EXAMPLES.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
- [Get user information](#get-user-information)
3232
- [Custom Token Exchange](#custom-token-exchange)
3333
- [Native to Web SSO login](#native-to-web-sso-login)
34+
- [Pushed Authorization Requests (PAR)](#pushed-authorization-requests-par)
3435
- [DPoP](#dpop-1)
3536
- [My Account API](#my-account-api)
3637
- [Enroll a new passkey](#enroll-a-new-passkey)
@@ -1691,6 +1692,75 @@ authentication
16911692
```
16921693
</details>
16931694

1695+
### Pushed Authorization Requests (PAR)
1696+
1697+
This feature handles the browser authorization step of a [PAR (RFC 9126)](https://www.rfc-editor.org/rfc/rfc9126.html) flow. It opens the `/authorize` endpoint with a `request_uri` obtained from your backend's PAR endpoint call, and returns the authorization code for your backend to exchange for tokens.
1698+
1699+
> [!IMPORTANT]
1700+
> Auth0 only supports PAR for **confidential clients**. Since mobile apps are public clients, the `/oauth/par` and `/oauth/token` calls must be made by your backend (BFF - Backend for Frontend). The SDK only handles opening the browser with the `request_uri` and returning the resulting authorization code.
1701+
>
1702+
> Your Auth0 application configured in the SDK should use the **same client_id** as the one your backend uses when calling the `/oauth/par` endpoint.
1703+
1704+
```kotlin
1705+
WebAuthProvider.authorizeWithRequestUri(account)
1706+
.start(context, requestUri, object : Callback<AuthorizationCode, AuthenticationException> {
1707+
override fun onSuccess(result: AuthorizationCode) {
1708+
// Send result.code to your BFF for token exchange
1709+
// Validate result.state against the state your BFF used in the PAR request
1710+
}
1711+
1712+
override fun onFailure(exception: AuthenticationException) {
1713+
// Handle error
1714+
}
1715+
})
1716+
```
1717+
1718+
> [!NOTE]
1719+
> The SDK does not validate the `state` parameter. The `state` is generated by your BFF when calling `/oauth/par` and returned as-is in `AuthorizationCode.state`. Your app or BFF **must** validate that the returned `state` matches the original value to prevent CSRF attacks.
1720+
1721+
<details>
1722+
<summary>Using coroutines</summary>
1723+
1724+
```kotlin
1725+
try {
1726+
val authCode = WebAuthProvider.authorizeWithRequestUri(account)
1727+
.await(context, requestUri)
1728+
// Send authCode.code to your BFF for token exchange
1729+
// Validate authCode.state against the state your BFF used in the PAR request
1730+
} catch (e: AuthenticationException) {
1731+
e.printStackTrace()
1732+
}
1733+
```
1734+
</details>
1735+
1736+
<details>
1737+
<summary>Using Java</summary>
1738+
1739+
```java
1740+
WebAuthProvider.authorizeWithRequestUri(account)
1741+
.start(context, requestUri, new Callback<AuthorizationCode, AuthenticationException>() {
1742+
@Override
1743+
public void onSuccess(@NonNull AuthorizationCode result) {
1744+
// Send result.getCode() to your BFF for token exchange
1745+
// Validate result.getState() against the state your BFF used in the PAR request
1746+
}
1747+
1748+
@Override
1749+
public void onFailure(@NonNull AuthenticationException exception) {
1750+
// Handle error
1751+
}
1752+
});
1753+
```
1754+
</details>
1755+
1756+
You can also pass a session transfer token to enable web SSO by transferring an existing native session to the browser:
1757+
1758+
```kotlin
1759+
WebAuthProvider.authorizeWithRequestUri(account)
1760+
.withSessionTransferToken(sessionTransferToken)
1761+
.await(context, requestUri)
1762+
```
1763+
16941764
## DPoP
16951765

16961766
[DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) (Demonstrating Proof of Possession) is an application-level mechanism for sender-constraining OAuth 2.0 access and refresh tokens by proving that the app is in possession of a certain private key. You can enable it by calling the `useDPoP(context: Context)` method. This ensures that DPoP proofs are generated for requests made through the AuthenticationAPI client.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.auth0.android.provider
2+
3+
import com.auth0.android.authentication.AuthenticationException
4+
5+
/**
6+
* Parses the result from an authorization redirect callback.
7+
*/
8+
internal object AuthorizeResultParser {
9+
10+
sealed class CodeResult {
11+
data class Success(val code: String, val state: String?) : CodeResult()
12+
data class Error(val exception: AuthenticationException) : CodeResult()
13+
object Canceled : CodeResult()
14+
object Invalid : CodeResult()
15+
}
16+
17+
private const val KEY_CODE = "code"
18+
private const val KEY_STATE = "state"
19+
private const val KEY_ERROR = "error"
20+
private const val KEY_ERROR_DESCRIPTION = "error_description"
21+
22+
fun parse(result: AuthorizeResult, requestCode: Int): CodeResult {
23+
if (!result.isValid(requestCode)) {
24+
return CodeResult.Invalid
25+
}
26+
27+
if (result.isCanceled) {
28+
return CodeResult.Canceled
29+
}
30+
31+
val values = CallbackHelper.getValuesFromUri(result.intentData)
32+
if (values.isEmpty()) {
33+
return CodeResult.Invalid
34+
}
35+
36+
val error = values[KEY_ERROR]
37+
if (error != null) {
38+
val description = values[KEY_ERROR_DESCRIPTION] ?: error
39+
return CodeResult.Error(AuthenticationException(error, description))
40+
}
41+
42+
val code = values[KEY_CODE]
43+
?: return CodeResult.Error(
44+
AuthenticationException(
45+
"access_denied",
46+
"No authorization code was received in the callback."
47+
)
48+
)
49+
50+
return CodeResult.Success(code = code, state = values[KEY_STATE])
51+
}
52+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package com.auth0.android.provider
2+
3+
import android.content.Context
4+
import com.auth0.android.Auth0
5+
import com.auth0.android.authentication.AuthenticationException
6+
import com.auth0.android.callback.Callback
7+
import com.auth0.android.result.AuthorizationCode
8+
9+
/**
10+
* Manager for handling PAR (Pushed Authorization Request) code-only flows.
11+
* This manager handles opening the authorize URL with a request_uri and
12+
* returns the authorization code to the caller for BFF token exchange.
13+
*/
14+
internal class PARCodeManager(
15+
private val account: Auth0,
16+
private val callback: Callback<AuthorizationCode, AuthenticationException>,
17+
private val requestUri: String,
18+
private val sessionTransferToken: String? = null,
19+
private val ctOptions: CustomTabsOptions = CustomTabsOptions.newBuilder().build()
20+
) : ResumableManager() {
21+
22+
private var requestCode = 0
23+
24+
fun startAuthentication(context: Context, requestCode: Int) {
25+
this.requestCode = requestCode
26+
val additionalParams = buildMap {
27+
sessionTransferToken?.let { put("session_transfer_token", it) }
28+
}
29+
val uri = PARUtils.buildAuthorizeUri(account, requestUri, additionalParams)
30+
AuthenticationActivity.authenticateUsingBrowser(context, uri, false, ctOptions)
31+
}
32+
33+
override fun resume(result: AuthorizeResult): Boolean {
34+
return when (val parsed = AuthorizeResultParser.parse(result, requestCode)) {
35+
is AuthorizeResultParser.CodeResult.Success -> {
36+
callback.onSuccess(AuthorizationCode(parsed.code, parsed.state))
37+
true
38+
}
39+
is AuthorizeResultParser.CodeResult.Error -> {
40+
callback.onFailure(parsed.exception)
41+
true
42+
}
43+
is AuthorizeResultParser.CodeResult.Canceled -> {
44+
callback.onFailure(
45+
AuthenticationException(
46+
AuthenticationException.ERROR_VALUE_AUTHENTICATION_CANCELED,
47+
"The user closed the browser app and the authentication was canceled."
48+
)
49+
)
50+
true
51+
}
52+
AuthorizeResultParser.CodeResult.Invalid -> false
53+
}
54+
}
55+
56+
override fun failure(exception: AuthenticationException) {
57+
callback.onFailure(exception)
58+
}
59+
60+
internal fun toState(): PARCodeManagerState {
61+
return PARCodeManagerState(
62+
auth0 = account,
63+
requestCode = requestCode,
64+
requestUri = requestUri,
65+
sessionTransferToken = sessionTransferToken,
66+
ctOptions = ctOptions
67+
)
68+
}
69+
70+
internal companion object {
71+
private val TAG = PARCodeManager::class.java.simpleName
72+
73+
fun fromState(
74+
state: PARCodeManagerState,
75+
callback: Callback<AuthorizationCode, AuthenticationException>
76+
): PARCodeManager {
77+
val manager = PARCodeManager(
78+
account = state.auth0,
79+
callback = callback,
80+
requestUri = state.requestUri,
81+
sessionTransferToken = state.sessionTransferToken,
82+
ctOptions = state.ctOptions
83+
)
84+
manager.requestCode = state.requestCode
85+
return manager
86+
}
87+
}
88+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package com.auth0.android.provider
2+
3+
import android.os.Parcel
4+
import android.os.Parcelable
5+
import android.util.Base64
6+
import androidx.core.os.ParcelCompat
7+
import com.auth0.android.Auth0
8+
import com.auth0.android.request.internal.GsonProvider
9+
import com.google.gson.Gson
10+
11+
internal data class PARCodeManagerState(
12+
val auth0: Auth0,
13+
val requestCode: Int,
14+
val requestUri: String,
15+
val sessionTransferToken: String?,
16+
val ctOptions: CustomTabsOptions
17+
) {
18+
19+
private class PARCodeManagerJson(
20+
val auth0ClientId: String,
21+
val auth0DomainUrl: String,
22+
val auth0ConfigurationUrl: String?,
23+
val requestCode: Int,
24+
val requestUri: String,
25+
val sessionTransferToken: String?,
26+
val ctOptions: String
27+
)
28+
29+
fun serializeToJson(gson: Gson = GsonProvider.gson): String {
30+
val parcel = Parcel.obtain()
31+
try {
32+
parcel.writeParcelable(ctOptions, Parcelable.PARCELABLE_WRITE_RETURN_VALUE)
33+
val ctOptionsEncoded = Base64.encodeToString(parcel.marshall(), Base64.DEFAULT)
34+
35+
val json = PARCodeManagerJson(
36+
auth0ClientId = auth0.clientId,
37+
auth0DomainUrl = auth0.domain,
38+
auth0ConfigurationUrl = auth0.configurationDomain,
39+
requestCode = requestCode,
40+
requestUri = requestUri,
41+
sessionTransferToken = sessionTransferToken,
42+
ctOptions = ctOptionsEncoded
43+
)
44+
return gson.toJson(json)
45+
} finally {
46+
parcel.recycle()
47+
}
48+
}
49+
50+
companion object {
51+
fun deserializeState(
52+
json: String,
53+
gson: Gson = GsonProvider.gson
54+
): PARCodeManagerState {
55+
val parcel = Parcel.obtain()
56+
try {
57+
val parsed = gson.fromJson(json, PARCodeManagerJson::class.java)
58+
59+
val decodedCtOptionsBytes = Base64.decode(parsed.ctOptions, Base64.DEFAULT)
60+
parcel.unmarshall(decodedCtOptionsBytes, 0, decodedCtOptionsBytes.size)
61+
parcel.setDataPosition(0)
62+
63+
val customTabsOptions = ParcelCompat.readParcelable(
64+
parcel,
65+
CustomTabsOptions::class.java.classLoader,
66+
CustomTabsOptions::class.java
67+
) ?: error("Couldn't deserialize CustomTabsOptions from Parcel")
68+
69+
val auth0 = Auth0.getInstance(
70+
clientId = parsed.auth0ClientId,
71+
domain = parsed.auth0DomainUrl,
72+
configurationDomain = parsed.auth0ConfigurationUrl
73+
)
74+
75+
return PARCodeManagerState(
76+
auth0 = auth0,
77+
requestCode = parsed.requestCode,
78+
requestUri = parsed.requestUri,
79+
sessionTransferToken = parsed.sessionTransferToken,
80+
ctOptions = customTabsOptions
81+
)
82+
} finally {
83+
parcel.recycle()
84+
}
85+
}
86+
}
87+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.auth0.android.provider
2+
3+
import android.net.Uri
4+
import com.auth0.android.Auth0
5+
import com.auth0.android.authentication.AuthenticationException
6+
import androidx.core.net.toUri
7+
8+
/**
9+
* Shared utilities for PAR (Pushed Authorization Request) flows.
10+
*/
11+
internal object PARUtils {
12+
13+
internal const val REQUEST_URI_PREFIX = "urn:ietf:params:oauth:request_uri:"
14+
15+
/**
16+
* Validates that the request_uri conforms to the expected format.
17+
* @return true if valid, false otherwise.
18+
*/
19+
fun isValidRequestUri(requestUri: String): Boolean {
20+
return requestUri.startsWith(REQUEST_URI_PREFIX)
21+
}
22+
23+
/**
24+
* Builds a minimal /authorize URI for PAR flows containing only client_id and request_uri,
25+
* plus any additional query parameters.
26+
*/
27+
fun buildAuthorizeUri(
28+
account: Auth0,
29+
requestUri: String,
30+
additionalParameters: Map<String, String> = emptyMap()
31+
): Uri {
32+
val builder = account.authorizeUrl.toUri().buildUpon()
33+
.appendQueryParameter("client_id", account.clientId)
34+
.appendQueryParameter("request_uri", requestUri)
35+
for ((key, value) in additionalParameters) {
36+
builder.appendQueryParameter(key, value)
37+
}
38+
return builder.build()
39+
}
40+
}

0 commit comments

Comments
 (0)