-
Notifications
You must be signed in to change notification settings - Fork 169
feat: Add support for DPoP #850
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
Changes from 12 commits
401547c
0663272
cb038bf
e1e7033
0498227
a96a2f9
96f3b78
a701dbf
75a4241
f14c30f
8f19da1
75ca5a1
93bf422
e33d3d7
abd631e
d6026e4
6265184
1da729d
dfddd77
207ff31
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 | ||||
|---|---|---|---|---|---|---|
|
|
@@ -11,6 +11,7 @@ | |||||
| - [Changing the Return To URL scheme](#changing-the-return-to-url-scheme) | ||||||
| - [Specify a Custom Logout URL](#specify-a-custom-logout-url) | ||||||
| - [Trusted Web Activity](#trusted-web-activity) | ||||||
| - [DPoP [EA]](#dpop-ea) | ||||||
| - [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) | ||||||
|
|
@@ -21,6 +22,7 @@ | |||||
| - [Get user information](#get-user-information) | ||||||
| - [Custom Token Exchange](#custom-token-exchange) | ||||||
| - [Native to Web SSO login [EA]](#native-to-web-sso-login-ea) | ||||||
| - [DPoP [EA]](#dpop-ea-1) | ||||||
| - [My Account API](#my-account-api) | ||||||
| - [Enroll a new passkey](#enroll-a-new-passkey) | ||||||
| - [Credentials Manager](#credentials-manager) | ||||||
|
|
@@ -208,6 +210,76 @@ WebAuthProvider.login(account) | |||||
| .await(this) | ||||||
| ``` | ||||||
|
|
||||||
| ## DPoP [EA] | ||||||
|
|
||||||
| > [!NOTE] | ||||||
| > 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. | ||||||
|
|
||||||
| [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(context:Context)` method. | ||||||
|
|
||||||
| ```kotlin | ||||||
| WebAuthProvider | ||||||
| .useDPoP(requireContext()) | ||||||
| .login(account) | ||||||
| .start(requireContext(), object : Callback<Credentials, AuthenticationException> { | ||||||
| override fun onSuccess(result: Credentials) { | ||||||
| println("Credentials $result") | ||||||
| } | ||||||
| override fun onFailure(error: AuthenticationException) { | ||||||
| print("Error $error") | ||||||
| } | ||||||
| }) | ||||||
| ``` | ||||||
|
|
||||||
| > [!IMPORTANT] | ||||||
| > 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). | ||||||
| > | ||||||
| > 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. | ||||||
|
|
||||||
| 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. | ||||||
|
|
||||||
| ```kotlin | ||||||
| val url ="https://example.com/api/endpoint" | ||||||
| val httpMethod = "GET" | ||||||
| val headerData = DPoPProvider.getHeaderData( | ||||||
| httpMethod, url, | ||||||
| accessToken, tokenType | ||||||
| ) | ||||||
| httpRequest.apply{ | ||||||
| addHeader("Authorization", headerData.authorizationHeader) | ||||||
| headerData.dpopProof?.let { | ||||||
| addHeader("DPoP", it) | ||||||
| } | ||||||
| } | ||||||
| ``` | ||||||
| If your API is issuing DPoP nonce's to prevent replay attacks, you can pass the nonce value to the `getHeaderData()` method to include it in the DPoP proof. Use the `DPoPProvider.isNonceRequiredError(response: Response)` method to check if a particular API response failed because a nonce is required. | ||||||
|
|
||||||
| ```kotlin | ||||||
| if (DPoPProvider.isNonceRequiredError(response)) { | ||||||
| val nonce = response.headers["DPoP-Nonce"] | ||||||
| val dpopProof = DPoPProvider.generateProof( | ||||||
| url, httpMethod, accessToken, nonce | ||||||
| ) | ||||||
| // Retry the request with the new proof | ||||||
| } | ||||||
| ``` | ||||||
|
|
||||||
| On logout, you should call `DPoPProvider.clearKeyPair()` to delete the user's key pair from the Keychain. | ||||||
|
|
||||||
| ```kotlin | ||||||
| WebAuthProvider.logout(account) | ||||||
| .start(requireContext(), object : Callback<Void?, AuthenticationException> { | ||||||
| override fun onSuccess(result: Void?) { | ||||||
| DPoPProvider.clearKeyPair() | ||||||
| } | ||||||
| override fun onFailure(error: AuthenticationException) { | ||||||
| } | ||||||
|
|
||||||
| }) | ||||||
| ``` | ||||||
| > [!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. | ||||||
|
|
||||||
| ## Authentication API | ||||||
|
|
||||||
| The client provides methods to authenticate the user against the Auth0 server. | ||||||
|
|
@@ -651,6 +723,62 @@ authentication | |||||
| ``` | ||||||
| </details> | ||||||
|
|
||||||
| ## DPoP [EA] | ||||||
|
|
||||||
| > [!NOTE] | ||||||
| > 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. | ||||||
|
|
||||||
| [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(context: Context)` method. This ensures that DPoP proofs are generated for requests made through the AuthenticationAPI client. | ||||||
|
|
||||||
| ```kotlin | ||||||
| val client = AuthenticationAPIClient(account).useDPoP(context) | ||||||
| ``` | ||||||
|
|
||||||
| [!IMPORTANT] | ||||||
| > 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). | ||||||
| > | ||||||
| > 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. | ||||||
|
|
||||||
| 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. | ||||||
|
|
||||||
| ```kotlin | ||||||
| val url ="https://example.com/api/endpoint" | ||||||
| val httpMethod = "GET" | ||||||
| val headerData = DPoPProvider.getHeaderData( | ||||||
| httpMethod, url, | ||||||
| accessToken, tokenType | ||||||
| ) | ||||||
| httpRequest.apply{ | ||||||
| addHeader("Authorization", headerData.authorizationHeader) | ||||||
| headerData.dpopProof?.let { | ||||||
| addHeader("DPoP", it) | ||||||
| } | ||||||
| } | ||||||
| ``` | ||||||
| If your API is issuing DPoP nonce's to prevent replay attacks, you can pass the nonce value to the `getHeaderData()` method to include it in the DPoP proof. Use the `DPoPProvider.isNonceRequiredError(response: Response)` method to check if a particular API response failed because a nonce is required. | ||||||
|
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.
Suggested change
|
||||||
|
|
||||||
| ```kotlin | ||||||
| if (DPoPProvider.isNonceRequiredError(response)) { | ||||||
| val nonce = response.headers["DPoP-Nonce"] | ||||||
| val dpopProof = DPoPProvider.generateProof( | ||||||
| url, httpMethod, accessToken, nonce | ||||||
| ) | ||||||
| // Retry the request with the new proof | ||||||
| } | ||||||
| ``` | ||||||
|
|
||||||
| On logout, you should call `DPoPProvider.clearKeyPair()` to delete the user's key pair from the Keychain. | ||||||
|
|
||||||
| ```kotlin | ||||||
|
|
||||||
| DPoPProvider.clearKeyPair() | ||||||
|
|
||||||
| ``` | ||||||
|
|
||||||
| > [!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. | ||||||
|
|
||||||
|
|
||||||
|
|
||||||
| ## My Account API | ||||||
|
|
||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,16 @@ | ||
| package com.auth0.android.authentication | ||
|
|
||
| import android.content.Context | ||
| import android.os.Build | ||
| import android.util.Log | ||
| import androidx.annotation.RequiresApi | ||
| import androidx.annotation.VisibleForTesting | ||
| import com.auth0.android.Auth0 | ||
| import com.auth0.android.Auth0Exception | ||
| import com.auth0.android.NetworkErrorException | ||
| import com.auth0.android.dpop.DPoPException | ||
| import com.auth0.android.dpop.DPoPProvider | ||
| import com.auth0.android.dpop.SenderConstraining | ||
| import com.auth0.android.request.* | ||
| import com.auth0.android.request.internal.* | ||
| import com.auth0.android.request.internal.GsonAdapter.Companion.forMap | ||
|
|
@@ -35,7 +42,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe | |
| private val auth0: Auth0, | ||
| private val factory: RequestFactory<AuthenticationException>, | ||
| private val gson: Gson | ||
| ) { | ||
| ) : SenderConstraining<AuthenticationAPIClient> { | ||
|
|
||
| /** | ||
| * Creates a new API client instance providing Auth0 account info. | ||
|
|
@@ -59,6 +66,16 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe | |
| public val baseURL: String | ||
| get() = auth0.getDomainUrl() | ||
|
|
||
|
|
||
| /** | ||
| * Enable DPoP for this client. | ||
| */ | ||
| @RequiresApi(Build.VERSION_CODES.M) | ||
| public override fun useDPoP(context: Context): AuthenticationAPIClient { | ||
| DPoPProvider.generateKeyPair(context) | ||
| return this | ||
| } | ||
|
|
||
| /** | ||
| * Log in a user with email/username and password for a connection/realm. | ||
| * It will use the password-realm grant type for the `/oauth/token` endpoint | ||
|
|
@@ -561,9 +578,26 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe | |
| * @param accessToken used to fetch it's information | ||
| * @return a request to start | ||
| */ | ||
| public fun userInfo(accessToken: String): Request<UserProfile, AuthenticationException> { | ||
| return profileRequest() | ||
| .addHeader(HEADER_AUTHORIZATION, "Bearer $accessToken") | ||
| public fun userInfo( | ||
| accessToken: String, tokenType: String | ||
| ): Request<UserProfile, AuthenticationException> { | ||
| return profileRequest().apply { | ||
| try { | ||
| val headerData = DPoPProvider.getHeaderData( | ||
| getHttpMethod().toString(), | ||
| getUrl(), | ||
| accessToken, | ||
| tokenType, | ||
| DPoPProvider.auth0Nonce | ||
| ) | ||
| addHeader(HEADER_AUTHORIZATION, headerData.authorizationHeader) | ||
| headerData.dpopProof?.let { | ||
| addHeader(DPoPProvider.DPOP_HEADER, it) | ||
| } | ||
| } catch (exception: DPoPException) { | ||
| Log.e(TAG, "Error generating DPoP proof: ${exception.stackTraceToString()}") | ||
|
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. Could we return an
Contributor
Author
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. Let me check if this can be done without introducing much rework on the Request classes
Contributor
Author
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. Done |
||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -790,8 +824,10 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe | |
| val credentialsAdapter = GsonAdapter( | ||
| Credentials::class.java, gson | ||
| ) | ||
| return factory.post(url.toString(), credentialsAdapter) | ||
| val request = factory.post(url.toString(), credentialsAdapter) | ||
| .addParameters(parameters) | ||
| .addDPoPHeader() | ||
|
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 DPoP proof should not be added for existing sessions. This seems to be adding it every time.
Contributor
Author
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
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. Do we have a test case for this?
Contributor
Author
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. Added cases for the same |
||
| return request | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -927,7 +963,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe | |
| Credentials::class.java, gson | ||
| ) | ||
| val request = factory.post(url.toString(), credentialsAdapter) | ||
| request.addParameters(parameters) | ||
| .addParameters(parameters) | ||
| .addDPoPHeader() | ||
| return request | ||
| } | ||
|
|
||
|
|
@@ -993,7 +1030,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe | |
| T::class.java, gson | ||
| ) | ||
| val request = factory.post(url.toString(), adapter) | ||
| request.addParameters(requestParameters) | ||
| .addParameters(requestParameters) | ||
| .addDPoPHeader() | ||
| return request | ||
| } | ||
|
|
||
|
|
@@ -1017,6 +1055,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe | |
| factory.post(url.toString(), credentialsAdapter), clientId, baseURL | ||
| ) | ||
| request.addParameters(requestParameters) | ||
| .addDPoPHeader() | ||
| return request | ||
| } | ||
|
|
||
|
|
@@ -1046,6 +1085,20 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe | |
| return factory.get(url.toString(), userProfileAdapter) | ||
| } | ||
|
|
||
| /** | ||
| * Helper method to add DPoP proof to all the [Request] | ||
| */ | ||
| private fun <T> Request<T, AuthenticationException>.addDPoPHeader(): Request<T, AuthenticationException> { | ||
| try { | ||
| DPoPProvider.generateProof(getUrl(), getHttpMethod().toString())?.let { | ||
| addHeader(DPoPProvider.DPOP_HEADER, it) | ||
| } | ||
| } catch (exception: DPoPException) { | ||
| Log.e(TAG, "Error generating DPoP proof: ${exception.stackTraceToString()}") | ||
|
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. Could we return an AuthenticationException if this fails?
Contributor
Author
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. Done |
||
| } | ||
| return this | ||
| } | ||
|
|
||
| private companion object { | ||
| private const val SMS_CONNECTION = "sms" | ||
| private const val EMAIL_CONNECTION = "email" | ||
|
|
@@ -1086,6 +1139,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe | |
| private const val HEADER_AUTHORIZATION = "Authorization" | ||
| private const val WELL_KNOWN_PATH = ".well-known" | ||
| private const val JWKS_FILE_PATH = "jwks.json" | ||
| private const val TAG = "AuthenticationAPIClient" | ||
| private fun createErrorAdapter(): ErrorAdapter<AuthenticationException> { | ||
| val mapAdapter = forMap(GsonProvider.gson) | ||
| return object : ErrorAdapter<AuthenticationException> { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| package com.auth0.android.dpop | ||
|
|
||
| import com.auth0.android.Auth0Exception | ||
|
|
||
| public class DPoPException : Auth0Exception { | ||
|
|
||
| internal enum class Code { | ||
| UNSUPPORTED_ERROR, | ||
| KEY_GENERATION_ERROR, | ||
| KEY_STORE_ERROR, | ||
| SIGNING_ERROR, | ||
| UNKNOWN_ERROR, | ||
| } | ||
|
|
||
| private var code: Code? = null | ||
|
|
||
| internal constructor( | ||
| code: Code, | ||
| cause: Throwable? = null | ||
| ) : this( | ||
| code, | ||
| getMessage(code), | ||
| cause | ||
| ) | ||
|
|
||
| internal constructor( | ||
| code: Code, | ||
| message: String, | ||
| cause: Throwable? = null | ||
| ) : super( | ||
| message, | ||
| cause | ||
| ) { | ||
| this.code = code | ||
| } | ||
|
|
||
|
|
||
| public companion object { | ||
|
|
||
| public val UNSUPPORTED_ERROR :DPoPException = DPoPException(Code.UNSUPPORTED_ERROR) | ||
| public val KEY_GENERATION_ERROR: DPoPException = DPoPException(Code.KEY_GENERATION_ERROR) | ||
| public val KEY_STORE_ERROR: DPoPException = DPoPException(Code.KEY_STORE_ERROR) | ||
| public val SIGNING_ERROR: DPoPException = DPoPException(Code.SIGNING_ERROR) | ||
| public val UNKNOWN_ERROR: DPoPException = DPoPException(Code.UNKNOWN_ERROR) | ||
|
|
||
| private const val DEFAULT_MESSAGE = | ||
| "An unknown error has occurred. Please check the error cause for more details." | ||
|
|
||
| private fun getMessage(code: Code): String { | ||
| return when (code) { | ||
| Code.UNSUPPORTED_ERROR -> "DPoP is not supported in versions below Android 9 (API level 28)." | ||
| Code.KEY_GENERATION_ERROR -> "Error generating DPoP key pair." | ||
| Code.KEY_STORE_ERROR -> "Error while accessing the key pair in the keystore." | ||
| Code.SIGNING_ERROR -> "Error while signing the DPoP proof." | ||
| Code.UNKNOWN_ERROR -> DEFAULT_MESSAGE | ||
| } | ||
| } | ||
| } | ||
| } |
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.