Skip to content

Commit 6b9dc1c

Browse files
feat: Implement PAR (Pushed Authorization Request) flow with AuthorizationCode handling
1 parent 2f6dc87 commit 6b9dc1c

File tree

3 files changed

+237
-0
lines changed

3 files changed

+237
-0
lines changed
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package com.auth0.android.provider
2+
3+
import android.content.Context
4+
import android.net.Uri
5+
import android.util.Log
6+
import com.auth0.android.Auth0
7+
import com.auth0.android.authentication.AuthenticationException
8+
import com.auth0.android.callback.Callback
9+
import com.auth0.android.result.AuthorizationCode
10+
11+
/**
12+
* Manager for handling PAR (Pushed Authorization Request) code-only flows.
13+
* This manager handles opening the authorize URL with a request_uri and
14+
* returns the authorization code to the caller for BFF token exchange.
15+
*/
16+
internal class PARCodeManager(
17+
private val account: Auth0,
18+
private val callback: Callback<AuthorizationCode, AuthenticationException>,
19+
private val requestUri: String,
20+
private val ctOptions: CustomTabsOptions = CustomTabsOptions.newBuilder().build()
21+
) : ResumableManager() {
22+
23+
private var requestCode = 0
24+
25+
private companion object {
26+
private val TAG = PARCodeManager::class.java.simpleName
27+
private const val KEY_CLIENT_ID = "client_id"
28+
private const val KEY_REQUEST_URI = "request_uri"
29+
private const val KEY_AUTH0_CLIENT_INFO = "auth0Client"
30+
private const val KEY_CODE = "code"
31+
private const val KEY_ERROR = "error"
32+
private const val KEY_ERROR_DESCRIPTION = "error_description"
33+
private const val ERROR_VALUE_ACCESS_DENIED = "access_denied"
34+
}
35+
36+
fun startAuthentication(context: Context, requestCode: Int) {
37+
this.requestCode = requestCode
38+
val uri = buildAuthorizeUri()
39+
AuthenticationActivity.authenticateUsingBrowser(context, uri, false, ctOptions)
40+
}
41+
42+
override fun resume(result: AuthorizeResult): Boolean {
43+
if (!result.isValid(requestCode)) {
44+
Log.w(TAG, "The Authorize Result is invalid.")
45+
return false
46+
}
47+
48+
if (result.isCanceled) {
49+
val exception = AuthenticationException(
50+
AuthenticationException.ERROR_VALUE_AUTHENTICATION_CANCELED,
51+
"The user closed the browser app and the authentication was canceled."
52+
)
53+
callback.onFailure(exception)
54+
return true
55+
}
56+
57+
val values = CallbackHelper.getValuesFromUri(result.intentData)
58+
if (values.isEmpty()) {
59+
Log.w(TAG, "The response didn't contain any values: code")
60+
return false
61+
}
62+
63+
Log.d(TAG, "The parsed CallbackURI contains the following parameters: ${values.keys}")
64+
65+
// Check for error response
66+
val error = values[KEY_ERROR]
67+
if (error != null) {
68+
val description = values[KEY_ERROR_DESCRIPTION] ?: error
69+
val authError = AuthenticationException(error, description)
70+
callback.onFailure(authError)
71+
return true
72+
}
73+
74+
// Extract code
75+
val code = values[KEY_CODE]
76+
if (code == null) {
77+
val exception = AuthenticationException(
78+
ERROR_VALUE_ACCESS_DENIED,
79+
"No authorization code was received in the callback."
80+
)
81+
callback.onFailure(exception)
82+
return true
83+
}
84+
85+
// Success - return authorization code
86+
val authorizationCode = AuthorizationCode(code = code)
87+
callback.onSuccess(authorizationCode)
88+
return true
89+
}
90+
91+
override fun failure(exception: AuthenticationException) {
92+
callback.onFailure(exception)
93+
}
94+
95+
private fun buildAuthorizeUri(): Uri {
96+
val authorizeUri = Uri.parse(account.authorizeUrl)
97+
val builder = authorizeUri.buildUpon()
98+
99+
// Only add client_id and request_uri for PAR flow
100+
builder.appendQueryParameter(KEY_CLIENT_ID, account.clientId)
101+
builder.appendQueryParameter(KEY_REQUEST_URI, requestUri)
102+
builder.appendQueryParameter(KEY_AUTH0_CLIENT_INFO, account.auth0UserAgent.value)
103+
104+
val uri = builder.build()
105+
Log.d(TAG, "Using the following PAR Authorize URI: $uri")
106+
return uri
107+
}
108+
}

auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import com.auth0.android.authentication.AuthenticationException
1111
import com.auth0.android.callback.Callback
1212
import com.auth0.android.dpop.DPoP
1313
import com.auth0.android.dpop.SenderConstraining
14+
import com.auth0.android.result.AuthorizationCode
1415
import com.auth0.android.result.Credentials
1516
import kotlinx.coroutines.Dispatchers
1617
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -79,6 +80,19 @@ public object WebAuthProvider : SenderConstraining<WebAuthProvider> {
7980
return Builder(account)
8081
}
8182

83+
/**
84+
* Initialize the WebAuthProvider instance for PAR (Pushed Authorization Request) flows.
85+
* Use this when your BFF has already called the /oauth/par endpoint and you need to
86+
* complete the authorization by opening /authorize with the request_uri.
87+
*
88+
* @param account to use for authentication
89+
* @return a new PARBuilder instance to customize.
90+
*/
91+
@JvmStatic
92+
public fun par(account: Auth0): PARBuilder {
93+
return PARBuilder(account)
94+
}
95+
8296
/**
8397
* Finishes the authentication or log out flow by passing the data received in the activity's onNewIntent() callback.
8498
* The final result will be delivered to the callback specified when calling start().
@@ -647,4 +661,103 @@ public object WebAuthProvider : SenderConstraining<WebAuthProvider> {
647661
private const val KEY_CONNECTION_SCOPE = "connection_scope"
648662
}
649663
}
664+
665+
/**
666+
* Builder for PAR (Pushed Authorization Request) code-only authentication flows.
667+
*
668+
* Use this builder when your backend (BFF) has already called the PAR endpoint
669+
* and you need to complete the authorization flow by opening the authorize URL
670+
* with the request_uri.
671+
*
672+
* Example usage:
673+
* ```kotlin
674+
* WebAuthProvider.par(account)
675+
* .start(context, requestURI, callback)
676+
* ```
677+
*/
678+
public class PARBuilder internal constructor(private val account: Auth0) {
679+
private val ctOptions: CustomTabsOptions = CustomTabsOptions.newBuilder().build()
680+
681+
/**
682+
* Start the PAR authorization flow using a request_uri from a PAR response.
683+
* Opens the browser with the authorize URL and returns the authorization code
684+
* for the app to exchange via BFF.
685+
*
686+
* @param context An Activity context to run the authentication.
687+
* @param requestURI The request_uri from PAR response
688+
* @param callback Callback with authorization code result
689+
*/
690+
public fun start(
691+
context: Context,
692+
requestURI: String,
693+
callback: Callback<AuthorizationCode, AuthenticationException>
694+
) {
695+
resetManagerInstance()
696+
697+
if (!ctOptions.hasCompatibleBrowser(context.packageManager)) {
698+
val ex = AuthenticationException(
699+
"a0.browser_not_available",
700+
"No compatible Browser application is installed."
701+
)
702+
callback.onFailure(ex)
703+
return
704+
}
705+
706+
val manager = PARCodeManager(
707+
account = account,
708+
callback = callback,
709+
requestUri = requestURI,
710+
ctOptions = ctOptions
711+
)
712+
713+
managerInstance = manager
714+
manager.startAuthentication(context, 110)
715+
}
716+
717+
/**
718+
* Start the PAR authorization flow using a request_uri from a PAR response.
719+
* Opens the browser with the authorize URL and returns the authorization code
720+
* for the app to exchange via BFF.
721+
*
722+
* @param context An Activity context to run the authentication.
723+
* @param requestURI The request_uri from PAR response
724+
* @return AuthorizationCode containing the authorization code
725+
* @throws AuthenticationException if authentication fails
726+
*/
727+
@JvmSynthetic
728+
@Throws(AuthenticationException::class)
729+
public suspend fun await(
730+
context: Context,
731+
requestURI: String
732+
): AuthorizationCode {
733+
return await(context, requestURI, Dispatchers.Main.immediate)
734+
}
735+
736+
/**
737+
* Used internally so that [CoroutineContext] can be injected for testing purpose
738+
*/
739+
internal suspend fun await(
740+
context: Context,
741+
requestURI: String,
742+
coroutineContext: CoroutineContext
743+
): AuthorizationCode {
744+
return withContext(coroutineContext) {
745+
suspendCancellableCoroutine { continuation ->
746+
start(
747+
context,
748+
requestURI,
749+
object : Callback<AuthorizationCode, AuthenticationException> {
750+
override fun onSuccess(result: AuthorizationCode) {
751+
continuation.resume(result)
752+
}
753+
754+
override fun onFailure(error: AuthenticationException) {
755+
continuation.resumeWithException(error)
756+
}
757+
}
758+
)
759+
}
760+
}
761+
}
762+
}
650763
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.auth0.android.result
2+
3+
/**
4+
* Result when SDK returns authorization code instead of credentials.
5+
* Used in PAR (Pushed Authorization Request) flows where the BFF
6+
* handles the token exchange.
7+
*
8+
* @property code The authorization code from the callback
9+
*/
10+
public data class AuthorizationCode(
11+
/**
12+
* The authorization code received from Auth0.
13+
* This code should be sent to your BFF for token exchange.
14+
*/
15+
public val code: String
16+
)

0 commit comments

Comments
 (0)