Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,12 +233,12 @@ WebAuthProvider.login(account)
> [!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 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.
[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)` method on the login Builder.

```kotlin
WebAuthProvider
.useDPoP()
.login(account)
.useDPoP(requireContext())
.start(requireContext(), object : Callback<Credentials, AuthenticationException> {
override fun onSuccess(result: Credentials) {
println("Credentials $result")
Expand Down
26 changes: 26 additions & 0 deletions V4_MIGRATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,32 @@ buildscript {
- [signinWithPasskey()](auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt#L235-L253) - Sign in a user using passkeys
- [signupWithPasskey()](auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt#L319-L344) - Sign up a user and returns a challenge for key generation

### DPoP Configuration Moved to Builder

The `useDPoP()` method has been moved from the `WebAuthProvider` object to the login `Builder` class. This change allows DPoP to be configured per-request instead of globally.
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The text says the useDPoP() method moved, but the API requires a Context parameter (useDPoP(context) / useDPoP(context: Context)). Updating the inline code reference would avoid implying a zero-arg overload still exists.

Suggested change
The `useDPoP()` method has been moved from the `WebAuthProvider` object to the login `Builder` class. This change allows DPoP to be configured per-request instead of globally.
The `useDPoP(context: Context)` method has been moved from the `WebAuthProvider` object to the login `Builder` class. This change allows DPoP to be configured per-request instead of globally.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: should be useDPoP(context: Context) to match the actual signature and avoid implying a zero-arg overload exists.


**v3 (global configuration — no longer supported):**

```kotlin
// ❌ This no longer works
WebAuthProvider
.useDPoP(context)
.login(account)
.start(context, callback)
```

**v4 (builder-based configuration — required):**

```kotlin
// ✅ Use this instead
WebAuthProvider
.login(account)
.useDPoP(context)
.start(context, callback)
```

This change ensures that DPoP configuration is scoped to individual login requests rather than persisting across the entire application lifecycle.

## Dependency Changes

### ⚠️ Gson 2.8.9 → 2.11.0 (Transitive Dependency)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,9 @@ import kotlin.coroutines.resumeWithException
*
* It uses an external browser by sending the [android.content.Intent.ACTION_VIEW] intent.
*/
public object WebAuthProvider : SenderConstraining<WebAuthProvider> {
public object WebAuthProvider {
private val TAG: String? = WebAuthProvider::class.simpleName
private const val KEY_BUNDLE_OAUTH_MANAGER_STATE = "oauth_manager_state"
private var dPoP : DPoP? = null

private val callbacks = CopyOnWriteArraySet<Callback<Credentials, AuthenticationException>>()

Expand All @@ -49,12 +48,6 @@ public object WebAuthProvider : SenderConstraining<WebAuthProvider> {
callbacks -= callback
}

// Public methods
public override fun useDPoP(context: Context): WebAuthProvider {
dPoP = DPoP(context)
return this
}

/**
* Initialize the WebAuthProvider instance for logging out the user using an account. Additional settings can be configured
* in the LogoutBuilder, like changing the scheme of the return to URL.
Expand Down Expand Up @@ -305,14 +298,15 @@ public object WebAuthProvider : SenderConstraining<WebAuthProvider> {
}
}

public class Builder internal constructor(private val account: Auth0) {
public class Builder internal constructor(private val account: Auth0) : SenderConstraining<Builder> {
private val values: MutableMap<String, String> = mutableMapOf()
private val headers: MutableMap<String, String> = mutableMapOf()
private var pkce: PKCE? = null
private var issuer: String? = null
private var scheme: String = "https"
private var redirectUri: String? = null
private var invitationUrl: String? = null
private var dPoP: DPoP? = null
private var ctOptions: CustomTabsOptions = CustomTabsOptions.newBuilder().build()
private var leeway: Int? = null
private var launchAsTwa: Boolean = false
Expand Down Expand Up @@ -548,6 +542,18 @@ public object WebAuthProvider : SenderConstraining<WebAuthProvider> {
return this
}

/**
* Enable DPoP (Demonstrating Proof-of-Possession) for this authentication request.
* DPoP binds access tokens to the client's cryptographic key, providing enhanced security.
*
* @param context the Android context used to access the keystore for DPoP key management
* @return the current builder instance
*/
public override fun useDPoP(context: Context): Builder {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that DPoP is scoped to the Builder (no longer on the singleton), the DPoP state won't survive process death. OAuthManager.toState() doesn't persist DPoP, and fromState() restores without it — so a DPoP-enabled login will silently resume without DPoP proofs if the OS kills the activity mid-redirect.

Please add a dpoPEnabled: Boolean flag to OAuthManagerState, persist it in toState(), and reconstruct DPoP(context) in fromState() when the flag is true. No need to serialize the DPoP instance itself (it holds a Context).

Please:

1>Add dpoPEnabled: Boolean = false to OAuthManagerState
2>Set it in toState() based on dPoP != null
3>In fromState(), if dpoPEnabled == true, pass DPoP(context) to the reconstructed OAuthManager

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed this

dPoP = DPoP(context)
return this
}
Comment on lines +553 to +556
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because DPoP is now configured per login Builder instance, make sure the authentication flow still survives process death/restoration: WebAuthProvider saves/restores OAuthManager via OAuthManager.toState()/fromState(), but OAuthManager.toState() does not currently persist DPoP and OAuthManagerState/PKCE reconstruction doesn’t re-enable DPoP on the restored AuthenticationAPIClient. This can cause a DPoP-enabled login to resume without DPoP proofs after restore. Consider persisting only a lightweight “DPoP enabled” flag in state and reconstructing/configuring DPoP (without serializing a Context-bearing DPoP instance) when restoring.

Copilot uses AI. Check for mistakes.

/**
* Request user Authentication. The result will be received in the callback.
* An error is raised if there are no browser applications installed in the device, or if
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,8 @@ public class WebAuthProviderTest {
@Test
public fun enablingDPoPWillGenerateNewKeyPairIfOneDoesNotExist() {
`when`(mockKeyStore.hasKeyPair()).thenReturn(false)
WebAuthProvider.useDPoP(mockContext)
.login(account)
login(account)
.useDPoP(mockContext)
.start(activity, callback)
Comment on lines 332 to 336
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There’s no test asserting the new per-request behavior (i.e., enabling DPoP on one login builder does not affect a subsequent login call that doesn’t call useDPoP). Adding a regression test for the non-sticky behavior would better cover the API change described in the PR motivation.

Copilot uses AI. Check for mistakes.
verify(mockKeyStore).generateKeyPair(any(), any())
}
Expand All @@ -358,8 +358,8 @@ public class WebAuthProviderTest {
`when`(mockKeyStore.hasKeyPair()).thenReturn(true)
`when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey()))

WebAuthProvider.useDPoP(mockContext)
.login(account)
login(account)
.useDPoP(mockContext)
.start(activity, callback)

verify(activity).startActivity(intentCaptor.capture())
Expand Down Expand Up @@ -2741,21 +2741,13 @@ public class WebAuthProviderTest {

//DPoP

public fun shouldReturnSameInstanceWhenCallingUseDPoPMultipleTimes() {
val provider1 = WebAuthProvider.useDPoP(mockContext)
val provider2 = WebAuthProvider.useDPoP(mockContext)

assertThat(provider1, `is`(provider2))
assertThat(WebAuthProvider.useDPoP(mockContext), `is`(provider1))
}

@Test
public fun shouldPassDPoPInstanceToOAuthManagerWhenDPoPIsEnabled() {
`when`(mockKeyStore.hasKeyPair()).thenReturn(true)
`when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey()))

WebAuthProvider.useDPoP(mockContext)
.login(account)
login(account)
.useDPoP(mockContext)
.start(activity, callback)

val managerInstance = WebAuthProvider.managerInstance as OAuthManager
Expand All @@ -2775,8 +2767,8 @@ public class WebAuthProviderTest {
public fun shouldGenerateKeyPairWhenDPoPIsEnabledAndNoKeyPairExists() {
`when`(mockKeyStore.hasKeyPair()).thenReturn(false)

WebAuthProvider.useDPoP(mockContext)
.login(account)
login(account)
.useDPoP(mockContext)
.start(activity, callback)

verify(mockKeyStore).generateKeyPair(any(), any())
Expand All @@ -2787,8 +2779,8 @@ public class WebAuthProviderTest {
`when`(mockKeyStore.hasKeyPair()).thenReturn(true)
`when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey()))

WebAuthProvider.useDPoP(mockContext)
.login(account)
login(account)
.useDPoP(mockContext)
.start(activity, callback)

verify(mockKeyStore, never()).generateKeyPair(any(), any())
Expand All @@ -2809,8 +2801,8 @@ public class WebAuthProviderTest {
`when`(mockKeyStore.hasKeyPair()).thenReturn(true)
`when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey()))

WebAuthProvider.useDPoP(mockContext)
.login(account)
login(account)
.useDPoP(mockContext)
.start(activity, callback)

verify(activity).startActivity(intentCaptor.capture())
Expand All @@ -2829,8 +2821,8 @@ public class WebAuthProviderTest {
`when`(mockKeyStore.hasKeyPair()).thenReturn(true)
`when`(mockKeyStore.getKeyPair()).thenReturn(null)

WebAuthProvider.useDPoP(mockContext)
.login(account)
login(account)
.useDPoP(mockContext)
.start(activity, callback)

verify(activity).startActivity(intentCaptor.capture())
Expand All @@ -2845,8 +2837,8 @@ public class WebAuthProviderTest {
`when`(mockKeyStore.hasKeyPair()).thenReturn(true)
`when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey()))

val builder = WebAuthProvider.useDPoP(mockContext)
.login(account)
val builder = login(account)
.useDPoP(mockContext)
.withConnection("test-connection")

builder.start(activity, callback)
Expand All @@ -2861,8 +2853,7 @@ public class WebAuthProviderTest {

@Test
public fun shouldNotAffectLogoutWhenDPoPIsEnabled() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test name is now misleading — it no longer enables DPoP at all, it just tests default logout. Consider renaming to shouldNotIncludeDPoPParametersInLogoutURI.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. This test is no longer required. Removed it

WebAuthProvider.useDPoP(mockContext)
.logout(account)
logout(account)
.start(activity, voidCallback)

verify(activity).startActivity(intentCaptor.capture())
Expand All @@ -2879,8 +2870,8 @@ public class WebAuthProviderTest {
doThrow(DPoPException.KEY_GENERATION_ERROR)
.`when`(mockKeyStore).generateKeyPair(any(), any())

WebAuthProvider.useDPoP(mockContext)
.login(account)
login(account)
.useDPoP(mockContext)
.start(activity, callback)

// Verify that the authentication fails when DPoP key generation fails
Expand Down Expand Up @@ -2909,8 +2900,8 @@ public class WebAuthProviderTest {
val proxyAccount: Auth0 = Auth0.getInstance(JwtTestUtils.EXPECTED_AUDIENCE, mockAPI.domain)
proxyAccount.networkingClient = SSLTestUtils.testClient

WebAuthProvider.useDPoP(mockContext)
.login(proxyAccount)
login(proxyAccount)
.useDPoP(mockContext)
.withPKCE(pkce)
.start(activity, authCallback)

Expand Down
Loading