Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
73 changes: 60 additions & 13 deletions V4_MIGRATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

## Overview

v4 of the Auth0 Android SDK includes significant build toolchain updates to support the latest Android development environment. This guide documents the changes required when migrating from v3 to v4.
v4 of the Auth0 Android SDK includes significant build toolchain updates to support the latest
Android development environment. This guide documents the changes required when migrating from v3 to
v4.

## Requirements Changes

Expand Down Expand Up @@ -50,7 +52,8 @@ buildscript {

### Kotlin Version

v4 uses **Kotlin 2.0.21**. If you're using Kotlin in your project, you may need to update your Kotlin version to ensure compatibility.
v4 uses **Kotlin 2.0.21**. If you're using Kotlin in your project, you may need to update your
Kotlin version to ensure compatibility.

```groovy
buildscript {
Expand All @@ -62,20 +65,59 @@ buildscript {

### Classes Removed

- The `com.auth0.android.provider.PasskeyAuthProvider` class has been removed. Use the APIs from the [AuthenticationAPIClient](auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt) class for passkey operations:
- [passkeyChallenge()](auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt#L366-L387) - Request a challenge to initiate passkey login flow
- [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
- The `com.auth0.android.provider.PasskeyAuthProvider` class has been removed. Use the APIs from
the [AuthenticationAPIClient](auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt)
class for passkey operations:
- [passkeyChallenge()](auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt#L366-L387) -
Request a challenge to initiate passkey login flow
- [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(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.

**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)

v4 updates the internal Gson dependency from **2.8.9** to **2.11.0**. While the SDK does not expose Gson types in its public API, Gson is included as a transitive runtime dependency. If your app also uses Gson, be aware of the following changes introduced in Gson 2.10+:
v4 updates the internal Gson dependency from **2.8.9** to **2.11.0**. While the SDK does not expose
Gson types in its public API, Gson is included as a transitive runtime dependency. If your app also
uses Gson, be aware of the following changes introduced in Gson 2.10+:

- **`TypeToken` with unresolved type variables is rejected at runtime.** Code like `object : TypeToken<List<T>>() {}` (where `T` is a generic parameter) will throw `IllegalArgumentException`. Use Kotlin `reified` type parameters or pass concrete types instead.
- **Strict type coercion is enforced.** Gson no longer silently coerces JSON objects or arrays to `String`. If your code relies on this behavior, you will see `JsonSyntaxException`.
- **Built-in ProGuard/R8 rules are included.** Gson 2.11.0 ships its own keep rules, so you may be able to remove custom Gson ProGuard rules from your project.
- **`TypeToken` with unresolved type variables is rejected at runtime.** Code like
`object : TypeToken<List<T>>() {}` (where `T` is a generic parameter) will throw
`IllegalArgumentException`. Use Kotlin `reified` type parameters or pass concrete types instead.
- **Strict type coercion is enforced.** Gson no longer silently coerces JSON objects or arrays to
`String`. If your code relies on this behavior, you will see `JsonSyntaxException`.
- **Built-in ProGuard/R8 rules are included.** Gson 2.11.0 ships its own keep rules, so you may be
able to remove custom Gson ProGuard rules from your project.

If you need to pin Gson to an older version, you can use Gradle's `resolutionStrategy`:

Expand All @@ -94,11 +136,14 @@ implementation('com.auth0.android:auth0:<version>') {
implementation 'com.google.code.gson:gson:2.8.9' // your preferred version
```

> **Note:** Pinning or excluding is not recommended long-term, as the SDK has been tested and validated against Gson 2.11.0.
> **Note:** Pinning or excluding is not recommended long-term, as the SDK has been tested and
> validated against Gson 2.11.0.

### DefaultClient.Builder

v4 introduces a `DefaultClient.Builder` for configuring the HTTP client. This replaces the constructor-based approach with a more flexible builder pattern that supports additional options such as write/call timeouts, custom interceptors, and custom loggers.
v4 introduces a `DefaultClient.Builder` for configuring the HTTP client. This replaces the
constructor-based approach with a more flexible builder pattern that supports additional options
such as write/call timeouts, custom interceptors, and custom loggers.

**v3 (constructor-based — deprecated):**

Expand All @@ -123,7 +168,9 @@ val client = DefaultClient.Builder()
.build()
```

The legacy constructor is deprecated but **not removed** — existing code will continue to compile and run. Your IDE will show a deprecation warning with a suggested `ReplaceWith` quick-fix to migrate to the Builder.
The legacy constructor is deprecated but **not removed** — existing code will continue to compile
and run. Your IDE will show a deprecation warning with a suggested `ReplaceWith` quick-fix to
migrate to the Builder.

## Getting Help

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public open class AuthenticationActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState != null) {
WebAuthProvider.onRestoreInstanceState(savedInstanceState)
WebAuthProvider.onRestoreInstanceState(savedInstanceState, this)
intentLaunched = savedInstanceState.getBoolean(EXTRA_INTENT_LAUNCHED, false)
}
}
Expand Down
14 changes: 11 additions & 3 deletions auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,8 @@ internal class OAuthManager(
auth0 = account,
idTokenVerificationIssuer = idTokenVerificationIssuer,
idTokenVerificationLeeway = idTokenVerificationLeeway,
customAuthorizeUrl = this.customAuthorizeUrl
customAuthorizeUrl = this.customAuthorizeUrl,
dPoPEnabled = dPoP != null
)
}

Expand Down Expand Up @@ -387,14 +388,21 @@ internal class OAuthManager(

internal fun OAuthManager.Companion.fromState(
state: OAuthManagerState,
callback: Callback<Credentials, AuthenticationException>
callback: Callback<Credentials, AuthenticationException>,
context: Context
): OAuthManager {
// Enable DPoP on the restored PKCE's AuthenticationAPIClient so that
// the token exchange request includes the DPoP proof after process restore.
if (state.dPoPEnabled && state.pkce != null) {
state.pkce.apiClient.useDPoP(context)
}
return OAuthManager(
account = state.auth0,
ctOptions = state.ctOptions,
parameters = state.parameters,
callback = callback,
customAuthorizeUrl = state.customAuthorizeUrl
customAuthorizeUrl = state.customAuthorizeUrl,
dPoP = if (state.dPoPEnabled ) DPoP(context) else null
).apply {
setHeaders(
state.headers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import android.util.Base64
import androidx.core.os.ParcelCompat
import com.auth0.android.Auth0
import com.auth0.android.authentication.AuthenticationAPIClient
import com.auth0.android.dpop.DPoP
import com.auth0.android.request.internal.GsonProvider
import com.google.gson.Gson

Expand All @@ -20,7 +19,7 @@ internal data class OAuthManagerState(
val idTokenVerificationLeeway: Int?,
val idTokenVerificationIssuer: String?,
val customAuthorizeUrl: String? = null,
val dPoP: DPoP? = null
val dPoPEnabled: Boolean = false
) {

private class OAuthManagerJson(
Expand All @@ -37,7 +36,7 @@ internal data class OAuthManagerState(
val idTokenVerificationLeeway: Int?,
val idTokenVerificationIssuer: String?,
val customAuthorizeUrl: String? = null,
val dPoP: DPoP? = null
val dPoPEnabled: Boolean
)

fun serializeToJson(
Expand All @@ -62,7 +61,7 @@ internal data class OAuthManagerState(
idTokenVerificationIssuer = idTokenVerificationIssuer,
idTokenVerificationLeeway = idTokenVerificationLeeway,
customAuthorizeUrl = this.customAuthorizeUrl,
dPoP = this.dPoP
dPoPEnabled = this.dPoPEnabled
)
return gson.toJson(json)
} finally {
Expand Down Expand Up @@ -112,7 +111,7 @@ internal data class OAuthManagerState(
idTokenVerificationIssuer = oauthManagerJson.idTokenVerificationIssuer,
idTokenVerificationLeeway = oauthManagerJson.idTokenVerificationLeeway,
customAuthorizeUrl = oauthManagerJson.customAuthorizeUrl,
dPoP = oauthManagerJson.dPoP
dPoPEnabled = oauthManagerJson.dPoPEnabled
)
} finally {
parcel.recycle()
Expand Down
29 changes: 18 additions & 11 deletions auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt
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 @@ -119,7 +112,7 @@ public object WebAuthProvider : SenderConstraining<WebAuthProvider> {
}
}

internal fun onRestoreInstanceState(bundle: Bundle) {
internal fun onRestoreInstanceState(bundle: Bundle, context: Context) {
if (managerInstance == null) {
val stateJson = bundle.getString(KEY_BUNDLE_OAUTH_MANAGER_STATE).orEmpty()
if (stateJson.isNotBlank()) {
Expand All @@ -138,7 +131,8 @@ public object WebAuthProvider : SenderConstraining<WebAuthProvider> {
callback.onFailure(error)
}
}
}
},
context
)
}
}
Expand Down Expand Up @@ -305,14 +299,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 +543,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
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
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 @@ -44,4 +44,88 @@ internal class OAuthManagerStateTest {
Assert.assertEquals(1, deserializedState.idTokenVerificationLeeway)
Assert.assertEquals("issuer", deserializedState.idTokenVerificationIssuer)
}

@Test
fun `serialize should persist dPoPEnabled flag as true`() {
val auth0 = Auth0.getInstance("clientId", "domain")
val state = OAuthManagerState(
auth0 = auth0,
parameters = mapOf("param1" to "value1"),
headers = mapOf("header1" to "value1"),
requestCode = 1,
ctOptions = CustomTabsOptions.newBuilder()
.showTitle(true)
.withBrowserPicker(
BrowserPicker.newBuilder().withAllowedPackages(emptyList()).build()
)
.build(),
pkce = PKCE(mock(), "redirectUri", mapOf("header1" to "value1")),
idTokenVerificationLeeway = 1,
idTokenVerificationIssuer = "issuer",
dPoPEnabled = true
)

val json = state.serializeToJson()

Assert.assertTrue(json.isNotBlank())
Assert.assertTrue(json.contains("\"dPoPEnabled\":true"))

val deserializedState = OAuthManagerState.deserializeState(json)

Assert.assertTrue(deserializedState.dPoPEnabled)
}

@Test
fun `serialize should persist dPoPEnabled flag as false by default`() {
val auth0 = Auth0.getInstance("clientId", "domain")
val state = OAuthManagerState(
auth0 = auth0,
parameters = mapOf("param1" to "value1"),
headers = mapOf("header1" to "value1"),
requestCode = 1,
ctOptions = CustomTabsOptions.newBuilder()
.showTitle(true)
.withBrowserPicker(
BrowserPicker.newBuilder().withAllowedPackages(emptyList()).build()
)
.build(),
pkce = PKCE(mock(), "redirectUri", mapOf("header1" to "value1")),
idTokenVerificationLeeway = 1,
idTokenVerificationIssuer = "issuer"
)

val json = state.serializeToJson()

val deserializedState = OAuthManagerState.deserializeState(json)

Assert.assertFalse(deserializedState.dPoPEnabled)
}

@Test
fun `deserialize should default dPoPEnabled to false when field is missing from JSON`() {
val auth0 = Auth0.getInstance("clientId", "domain")
val state = OAuthManagerState(
auth0 = auth0,
parameters = emptyMap(),
headers = emptyMap(),
requestCode = 0,
ctOptions = CustomTabsOptions.newBuilder()
.showTitle(true)
.withBrowserPicker(
BrowserPicker.newBuilder().withAllowedPackages(emptyList()).build()
)
.build(),
pkce = PKCE(mock(), "redirectUri", emptyMap()),
idTokenVerificationLeeway = null,
idTokenVerificationIssuer = null
)

val json = state.serializeToJson()
// Remove the dPoPEnabled field to simulate legacy JSON
val legacyJson = json.replace(",\"dPoPEnabled\":false", "")

val deserializedState = OAuthManagerState.deserializeState(legacyJson)

Assert.assertFalse(deserializedState.dPoPEnabled)
}
}
Loading
Loading