Skip to content

Commit d9bd17a

Browse files
authored
feat: Add support for DPoP (#850)
1 parent 8b23be9 commit d9bd17a

34 files changed

+3618
-121
lines changed

EXAMPLES.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
- [Changing the Return To URL scheme](#changing-the-return-to-url-scheme)
1212
- [Specify a Custom Logout URL](#specify-a-custom-logout-url)
1313
- [Trusted Web Activity](#trusted-web-activity)
14+
- [DPoP [EA]](#dpop-ea)
1415
- [Authentication API](#authentication-api)
1516
- [Login with database connection](#login-with-database-connection)
1617
- [Login using MFA with One Time Password code](#login-using-mfa-with-one-time-password-code)
@@ -21,6 +22,7 @@
2122
- [Get user information](#get-user-information)
2223
- [Custom Token Exchange](#custom-token-exchange)
2324
- [Native to Web SSO login [EA]](#native-to-web-sso-login-ea)
25+
- [DPoP [EA]](#dpop-ea-1)
2426
- [My Account API](#my-account-api)
2527
- [Enroll a new passkey](#enroll-a-new-passkey)
2628
- [Credentials Manager](#credentials-manager)
@@ -208,6 +210,76 @@ WebAuthProvider.login(account)
208210
.await(this)
209211
```
210212

213+
## DPoP [EA]
214+
215+
> [!NOTE]
216+
> This feature is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to get it enabled for your tenant.
217+
218+
[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()` method.
219+
220+
```kotlin
221+
WebAuthProvider
222+
.useDPoP()
223+
.login(account)
224+
.start(requireContext(), object : Callback<Credentials, AuthenticationException> {
225+
override fun onSuccess(result: Credentials) {
226+
println("Credentials $result")
227+
}
228+
override fun onFailure(error: AuthenticationException) {
229+
print("Error $error")
230+
}
231+
})
232+
```
233+
234+
> [!IMPORTANT]
235+
> DPoP will only be used for new user sessions created after enabling it. DPoP **will not** be applied to any requests involving existing access and refresh tokens (such as exchanging the refresh token for new credentials).
236+
>
237+
> This means that, after you've enabled it in your app, DPoP will only take effect when users log in again. It's up to you to decide how to roll out this change to your users. For example, you might require users to log in again the next time they open your app. You'll need to implement the logic to handle this transition based on your app's requirements.
238+
239+
When making requests to your own APIs, use the `DPoP.getHeaderData()` method to get the `Authorization` and `DPoP` header values to be used. The `Authorization` header value is generated using the access token and token type, while the `DPoP` header value is the generated DPoP proof.
240+
241+
```kotlin
242+
val url ="https://example.com/api/endpoint"
243+
val httpMethod = "GET"
244+
val headerData = DPoP.getHeaderData(
245+
httpMethod, url,
246+
accessToken, tokenType
247+
)
248+
httpRequest.apply{
249+
addHeader("Authorization", headerData.authorizationHeader)
250+
headerData.dpopProof?.let {
251+
addHeader("DPoP", it)
252+
}
253+
}
254+
```
255+
If your API is issuing DPoP nonces to prevent replay attacks, you can pass the nonce value to the `getHeaderData()` method to include it in the DPoP proof. Use the `DPoP.isNonceRequiredError(response: Response)` method to check if a particular API response failed because a nonce is required.
256+
257+
```kotlin
258+
if (DPoP.isNonceRequiredError(response)) {
259+
val nonce = response.headers["DPoP-Nonce"]
260+
val dpopProof = DPoPProvider.generateProof(
261+
url, httpMethod, accessToken, nonce
262+
)
263+
// Retry the request with the new proof
264+
}
265+
```
266+
267+
On logout, you should call `DPoP.clearKeyPair()` to delete the user's key pair from the Keychain.
268+
269+
```kotlin
270+
WebAuthProvider.logout(account)
271+
.start(requireContext(), object : Callback<Void?, AuthenticationException> {
272+
override fun onSuccess(result: Void?) {
273+
DPoPProvider.clearKeyPair()
274+
}
275+
override fun onFailure(error: AuthenticationException) {
276+
}
277+
278+
})
279+
```
280+
> [!NOTE]
281+
> 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.
282+
211283
## Authentication API
212284

213285
The client provides methods to authenticate the user against the Auth0 server.
@@ -651,6 +723,62 @@ authentication
651723
```
652724
</details>
653725

726+
## DPoP [EA]
727+
728+
> [!NOTE]
729+
> This feature is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to get it enabled for your tenant.
730+
731+
[DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) (Demonstrating Proof of Posession) 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()` method. This ensures that DPoP proofs are generated for requests made through the AuthenticationAPI client.
732+
733+
```kotlin
734+
val client = AuthenticationAPIClient(account).useDPoP()
735+
```
736+
737+
[!IMPORTANT]
738+
> DPoP will only be used for new user sessions created after enabling it. DPoP **will not** be applied to any requests involving existing access and refresh tokens (such as exchanging the refresh token for new credentials).
739+
>
740+
> This means that, after you've enabled it in your app, DPoP will only take effect when users log in again. It's up to you to decide how to roll out this change to your users. For example, you might require users to log in again the next time they open your app. You'll need to implement the logic to handle this transition based on your app's requirements.
741+
742+
When making requests to your own APIs, use the `DPoP.getHeaderData()` method to get the `Authorization` and `DPoP` header values to be used. The `Authorization` header value is generated using the access token and token type, while the `DPoP` header value is the generated DPoP proof.
743+
744+
```kotlin
745+
val url ="https://example.com/api/endpoint"
746+
val httpMethod = "GET"
747+
val headerData = DPoP.getHeaderData(
748+
httpMethod, url,
749+
accessToken, tokenType
750+
)
751+
httpRequest.apply{
752+
addHeader("Authorization", headerData.authorizationHeader)
753+
headerData.dpopProof?.let {
754+
addHeader("DPoP", it)
755+
}
756+
}
757+
```
758+
If your API is issuing DPoP nonces to prevent replay attacks, you can pass the nonce value to the `getHeaderData()` method to include it in the DPoP proof. Use the `DPoP.isNonceRequiredError(response: Response)` method to check if a particular API response failed because a nonce is required.
759+
760+
```kotlin
761+
if (DPoP.isNonceRequiredError(response)) {
762+
val nonce = response.headers["DPoP-Nonce"]
763+
val dpopProof = DPoPProvider.generateProof(
764+
url, httpMethod, accessToken, nonce
765+
)
766+
// Retry the request with the new proof
767+
}
768+
```
769+
770+
On logout, you should call `DPoP.clearKeyPair()` to delete the user's key pair from the Keychain.
771+
772+
```kotlin
773+
774+
DPoP.clearKeyPair()
775+
776+
```
777+
778+
> [!NOTE]
779+
> 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.
780+
781+
654782

655783
## My Account API
656784

auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package com.auth0.android.authentication
22

3+
import android.content.Context
34
import androidx.annotation.VisibleForTesting
45
import com.auth0.android.Auth0
56
import com.auth0.android.Auth0Exception
67
import com.auth0.android.NetworkErrorException
8+
import com.auth0.android.dpop.DPoP
9+
import com.auth0.android.dpop.DPoPException
10+
import com.auth0.android.dpop.SenderConstraining
711
import com.auth0.android.request.*
812
import com.auth0.android.request.internal.*
913
import com.auth0.android.request.internal.GsonAdapter.Companion.forMap
@@ -35,7 +39,9 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
3539
private val auth0: Auth0,
3640
private val factory: RequestFactory<AuthenticationException>,
3741
private val gson: Gson
38-
) {
42+
) : SenderConstraining<AuthenticationAPIClient> {
43+
44+
private var dPoP: DPoP? = null
3945

4046
/**
4147
* Creates a new API client instance providing Auth0 account info.
@@ -59,6 +65,14 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
5965
public val baseURL: String
6066
get() = auth0.getDomainUrl()
6167

68+
/**
69+
* Enable DPoP for this client.
70+
*/
71+
public override fun useDPoP(context: Context): AuthenticationAPIClient {
72+
dPoP = DPoP(context)
73+
return this
74+
}
75+
6276
/**
6377
* Log in a user with email/username and password for a connection/realm.
6478
* It will use the password-realm grant type for the `/oauth/token` endpoint
@@ -561,9 +575,11 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
561575
* @param accessToken used to fetch it's information
562576
* @return a request to start
563577
*/
564-
public fun userInfo(accessToken: String): Request<UserProfile, AuthenticationException> {
578+
public fun userInfo(
579+
accessToken: String, tokenType: String = "Bearer"
580+
): Request<UserProfile, AuthenticationException> {
565581
return profileRequest()
566-
.addHeader(HEADER_AUTHORIZATION, "Bearer $accessToken")
582+
.addHeader(HEADER_AUTHORIZATION, "$tokenType $accessToken")
567583
}
568584

569585
/**
@@ -790,8 +806,9 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
790806
val credentialsAdapter = GsonAdapter(
791807
Credentials::class.java, gson
792808
)
793-
return factory.post(url.toString(), credentialsAdapter)
809+
val request = factory.post(url.toString(), credentialsAdapter, dPoP)
794810
.addParameters(parameters)
811+
return request
795812
}
796813

797814
/**
@@ -926,8 +943,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
926943
val credentialsAdapter: JsonAdapter<Credentials> = GsonAdapter(
927944
Credentials::class.java, gson
928945
)
929-
val request = factory.post(url.toString(), credentialsAdapter)
930-
request.addParameters(parameters)
946+
val request = factory.post(url.toString(), credentialsAdapter, dPoP)
947+
.addParameters(parameters)
931948
return request
932949
}
933950

@@ -992,8 +1009,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
9921009
val adapter: JsonAdapter<T> = GsonAdapter(
9931010
T::class.java, gson
9941011
)
995-
val request = factory.post(url.toString(), adapter)
996-
request.addParameters(requestParameters)
1012+
val request = factory.post(url.toString(), adapter, dPoP)
1013+
.addParameters(requestParameters)
9971014
return request
9981015
}
9991016

@@ -1014,7 +1031,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
10141031
Credentials::class.java, gson
10151032
)
10161033
val request = BaseAuthenticationRequest(
1017-
factory.post(url.toString(), credentialsAdapter), clientId, baseURL
1034+
factory.post(url.toString(), credentialsAdapter, dPoP), clientId, baseURL
10181035
)
10191036
request.addParameters(requestParameters)
10201037
return request
@@ -1043,7 +1060,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
10431060
val userProfileAdapter: JsonAdapter<UserProfile> = GsonAdapter(
10441061
UserProfile::class.java, gson
10451062
)
1046-
return factory.get(url.toString(), userProfileAdapter)
1063+
return factory.get(url.toString(), userProfileAdapter, dPoP)
10471064
}
10481065

10491066
private companion object {
@@ -1086,6 +1103,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
10861103
private const val HEADER_AUTHORIZATION = "Authorization"
10871104
private const val WELL_KNOWN_PATH = ".well-known"
10881105
private const val JWKS_FILE_PATH = "jwks.json"
1106+
private const val TAG = "AuthenticationAPIClient"
10891107
private fun createErrorAdapter(): ErrorAdapter<AuthenticationException> {
10901108
val mapAdapter = forMap(GsonProvider.gson)
10911109
return object : ErrorAdapter<AuthenticationException> {
@@ -1109,6 +1127,11 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
11091127
"Failed to execute the network request", NetworkErrorException(cause)
11101128
)
11111129
}
1130+
if (cause is DPoPException) {
1131+
return AuthenticationException(
1132+
cause.message ?: "Error while attaching DPoP proof", cause
1133+
)
1134+
}
11121135
return AuthenticationException(
11131136
"Something went wrong", Auth0Exception("Something went wrong", cause)
11141137
)

0 commit comments

Comments
 (0)