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
2 changes: 1 addition & 1 deletion .github/actions/rl-scanner/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ runs:
- name: Install RL Wrapper
shell: bash
run: |
pip install rl-wrapper>=1.0.0 --index-url "https://${{ env.PRODSEC_TOOLS_USER }}:${{ env.PRODSEC_TOOLS_TOKEN }}@a0us.jfrog.io/artifactory/api/pypi/python-local/simple"
pip install rl-wrapper --index-url "https://${{ env.PRODSEC_TOOLS_USER }}:${{ env.PRODSEC_TOOLS_TOKEN }}@a0us.jfrog.io/artifactory/api/pypi/python/simple"

- name: Run RL Scanner
shell: bash
Expand Down
82 changes: 78 additions & 4 deletions V4_MIGRATION_GUIDE.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
# Migration Guide from SDK v3 to v4

## Overview
> **Note:** This guide is actively maintained during the v4 development phase. As new changes are merged, this document will be updated to reflect the latest breaking changes and migration steps.

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, updated default values for better out-of-the-box behavior, and behavior changes to simplify credential management. This guide documents the changes required when migrating from v3 to v4.

---

## Table of Contents

- [**Requirements Changes**](#requirements-changes)
+ [Java Version](#java-version)
+ [Gradle and Android Gradle Plugin](#gradle-and-android-gradle-plugin)
+ [Kotlin Version](#kotlin-version)
- [**Breaking Changes**](#breaking-changes)
+ [Classes Removed](#classes-removed)
+ [DPoP Configuration Moved to Builder](#dpop-configuration-moved-to-builder)
- [**Default Values Changed**](#default-values-changed)
+ [Credentials Manager minTTL](#credentials-manager-minttl)
- [**Behavior Changes**](#behavior-changes)
+ [clearCredentials() Now Clears All Storage](#clearCredentials-now-clears-all-storage)
+ [Storage Interface: New removeAll() Method](#storage-interface-new-removeall-method)
- [**Dependency Changes**](#dependency-changes)
+ [Gson 2.8.9 → 2.11.0](#️-gson-289--2110-transitive-dependency)
+ [DefaultClient.Builder](#defaultclientbuilder)

---

## Requirements Changes

Expand Down Expand Up @@ -103,6 +123,60 @@ WebAuthProvider
This change ensures that DPoP configuration is scoped to individual login requests rather than
persisting across the entire application lifecycle.

## Default Values Changed

### Credentials Manager `minTTL`

**Change:** The default `minTtl` value changed from `0` to `60` seconds.

This change affects the following Credentials Manager methods:

- `getCredentials(callback)` / `awaitCredentials()`
- `getCredentials(scope, minTtl, callback)` / `awaitCredentials(scope, minTtl)`
- `getCredentials(scope, minTtl, parameters, callback)` / `awaitCredentials(scope, minTtl, parameters)`
- `getCredentials(scope, minTtl, parameters, forceRefresh, callback)` / `awaitCredentials(scope, minTtl, parameters, forceRefresh)`
- `getCredentials(scope, minTtl, parameters, headers, forceRefresh, callback)` / `awaitCredentials(scope, minTtl, parameters, headers, forceRefresh)`
- `hasValidCredentials()`

**Impact:** Credentials will be renewed if they expire within 60 seconds, instead of only when already expired.

<details>
<summary>Migration example</summary>

```kotlin
// v3 - minTtl defaulted to 0, had to be set explicitly
credentialsManager.getCredentials(scope = null, minTtl = 60, callback = callback)

// v4 - minTtl defaults to 60 seconds
credentialsManager.getCredentials(callback)

// v4 - use 0 to restore v3 behavior
credentialsManager.getCredentials(scope = null, minTtl = 0, callback = callback)
```
</details>

**Reason:** A `minTtl` of `0` meant credentials were not renewed until expired, which could result in delivering access tokens that expire immediately after retrieval, causing subsequent API requests to fail. Setting a default value of `60` seconds ensures the access token remains valid for a reasonable period.

## Behavior Changes

### `clearCredentials()` Now Clears All Storage

**Change:** `clearCredentials()` now calls `Storage.removeAll()` instead of removing individual credential keys.

In v3, `clearCredentials()` removed only specific credential keys (access token, refresh token, ID token, etc.) from the underlying `Storage`.

In v4, `clearCredentials()` calls `Storage.removeAll()`, which clears **all** values in the storage — including any API credentials stored for specific audiences.

**Impact:** If you need to remove only the primary credentials while preserving other stored data, consider using a separate `Storage` instance for API credentials.

**Reason:** This simplifies credential cleanup and ensures no stale data remains in storage after logout. It aligns the behavior with the Swift SDK's `clear()` method, which also clears all stored values.

### `Storage` Interface: New `removeAll()` Method

**Change:** The `Storage` interface now includes a `removeAll()` method with a default empty implementation.

**Impact:** Existing custom `Storage` implementations will continue to compile and work without changes. Override `removeAll()` to provide the actual clearing behavior if your custom storage is used with `clearCredentials()`.

## 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 @@ -22,6 +22,17 @@ public abstract class BaseCredentialsManager internal constructor(
) {
private var _clock: Clock = ClockImpl()

public companion object {
/**
* Default minimum time to live (in seconds) for the access token.
* When retrieving credentials, if the access token has less than this amount of time
* remaining before expiration, it will be automatically renewed.
* This ensures the access token is valid for at least a short window after retrieval,
* preventing downstream API call failures from nearly-expired tokens.
*/
public const val DEFAULT_MIN_TTL: Int = 60
Copy link
Contributor

Choose a reason for hiding this comment

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

Updated the V4_Migration guide for this change. Refer the Swift one

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated the V4 Migration Guide following the Swift V3 Migration Guide format.

}

/**
* Updates the clock instance used for expiration verification purposes.
* The use of this method can help on situations where the clock comes from an external synced source.
Expand Down Expand Up @@ -83,7 +94,7 @@ public abstract class BaseCredentialsManager internal constructor(
public abstract fun getApiCredentials(
audience: String,
scope: String? = null,
minTtl: Int = 0,
minTtl: Int = DEFAULT_MIN_TTL,
parameters: Map<String, String> = emptyMap(),
headers: Map<String, String> = emptyMap(),
callback: Callback<APICredentials, CredentialsManagerException>
Expand Down Expand Up @@ -139,7 +150,7 @@ public abstract class BaseCredentialsManager internal constructor(
public abstract suspend fun awaitApiCredentials(
audience: String,
scope: String? = null,
minTtl: Int = 0,
minTtl: Int = DEFAULT_MIN_TTL,
parameters: Map<String, String> = emptyMap(),
headers: Map<String, String> = emptyMap()
): APICredentials
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
@JvmSynthetic
@Throws(CredentialsManagerException::class)
override suspend fun awaitCredentials(): Credentials {
return awaitCredentials(null, 0)
return awaitCredentials(null, DEFAULT_MIN_TTL)
}

/**
Expand Down Expand Up @@ -390,7 +390,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
* @param callback the callback that will receive a valid [Credentials] or the [CredentialsManagerException].
*/
override fun getCredentials(callback: Callback<Credentials, CredentialsManagerException>) {
getCredentials(null, 0, callback)
getCredentials(null, DEFAULT_MIN_TTL, callback)
}

/**
Expand Down Expand Up @@ -702,7 +702,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
* @return whether there are valid credentials stored on this manager.
*/
override fun hasValidCredentials(): Boolean {
return hasValidCredentials(0)
return hasValidCredentials(DEFAULT_MIN_TTL.toLong())
}

/**
Expand All @@ -727,13 +727,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
* Removes the credentials from the storage if present.
*/
override fun clearCredentials() {
storage.remove(KEY_ACCESS_TOKEN)
storage.remove(KEY_REFRESH_TOKEN)
storage.remove(KEY_ID_TOKEN)
storage.remove(KEY_TOKEN_TYPE)
storage.remove(KEY_EXPIRES_AT)
storage.remove(KEY_SCOPE)
storage.remove(LEGACY_KEY_CACHE_EXPIRES_AT)
storage.removeAll()
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
@JvmSynthetic
@Throws(CredentialsManagerException::class)
override suspend fun awaitCredentials(): Credentials {
return awaitCredentials(null, 0)
return awaitCredentials(null, DEFAULT_MIN_TTL)
}

/**
Expand Down Expand Up @@ -579,7 +579,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
override fun getCredentials(
callback: Callback<Credentials, CredentialsManagerException>
) {
getCredentials(null, 0, callback)
getCredentials(null, DEFAULT_MIN_TTL, callback)
}

/**
Expand Down Expand Up @@ -754,10 +754,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
* Delete the stored credentials
*/
override fun clearCredentials() {
storage.remove(KEY_CREDENTIALS)
storage.remove(KEY_EXPIRES_AT)
storage.remove(LEGACY_KEY_CACHE_EXPIRES_AT)
storage.remove(KEY_CAN_REFRESH)
storage.removeAll()
clearBiometricSession()
Log.d(TAG, "Credentials were just removed from the storage")
}
Expand All @@ -779,7 +776,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
* @return whether this manager contains a valid non-expired pair of credentials or not.
*/
override fun hasValidCredentials(): Boolean {
return hasValidCredentials(0)
return hasValidCredentials(DEFAULT_MIN_TTL.toLong())
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ public class SharedPreferencesStorage @JvmOverloads constructor(
sp.edit().remove(name).apply()
}

override fun removeAll() {
sp.edit().clear().apply()
}

private companion object {
private const val SHARED_PREFERENCES_NAME = "com.auth0.authentication.storage"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,9 @@ public interface Storage {
* @param name the name of the value to remove.
*/
public fun remove(name: String)

/**
* Removes all values from the storage.
*/
public fun removeAll() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.auth0.android.authentication.storage
import com.auth0.android.NetworkErrorException
import com.auth0.android.authentication.AuthenticationAPIClient
import com.auth0.android.authentication.AuthenticationException
import com.auth0.android.authentication.storage.BaseCredentialsManager.Companion.DEFAULT_MIN_TTL
import com.auth0.android.callback.Callback
import com.auth0.android.request.Request
import com.auth0.android.request.internal.GsonProvider
Expand Down Expand Up @@ -672,7 +673,7 @@ public class CredentialsManagerTest {
Mockito.`when`(
client.renewAuth("refresh_token", "audience")
).thenReturn(request)
val newDate = Date(CredentialsMock.CURRENT_TIME_MS + 1 * 1000)
val newDate = Date(CredentialsMock.CURRENT_TIME_MS + (DEFAULT_MIN_TTL + 10) * 1000L)
val jwtMock = mock<Jwt>()
Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate)
Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock)
Expand Down Expand Up @@ -1479,13 +1480,7 @@ public class CredentialsManagerTest {
@Test
public fun shouldClearCredentials() {
manager.clearCredentials()
verify(storage).remove("com.auth0.id_token")
verify(storage).remove("com.auth0.access_token")
verify(storage).remove("com.auth0.refresh_token")
verify(storage).remove("com.auth0.token_type")
verify(storage).remove("com.auth0.expires_at")
verify(storage).remove("com.auth0.scope")
verify(storage).remove("com.auth0.cache_expires_at")
verify(storage).removeAll()
verifyNoMoreInteractions(storage)
}

Expand Down Expand Up @@ -1770,6 +1765,103 @@ public class CredentialsManagerTest {
MatcherAssert.assertThat(manager.hasValidCredentials(), Is.`is`(true))
}

@Test
public fun shouldRenewCredentialsViaCallbackWhenTokenExpiresWithinDefaultMinTtl() {
// Token expires in 30 seconds, which is within DEFAULT_MIN_TTL (60s)
val expirationTime = CredentialsMock.CURRENT_TIME_MS + 30 * 1000
Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken")
Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken")
Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken")
Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type")
Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime)
Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at"))
.thenReturn(expirationTime)
Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope")
Mockito.`when`(
client.renewAuth("refreshToken")
).thenReturn(request)
val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS)
val jwtMock = mock<Jwt>()
Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate)
Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock)

val renewedCredentials =
Credentials("newId", "newAccess", "newType", "refreshToken", newDate, "newScope")
Mockito.`when`(request.execute()).thenReturn(renewedCredentials)
// Use no-arg getCredentials which now uses DEFAULT_MIN_TTL
manager.getCredentials(callback)
verify(callback).onSuccess(
credentialsCaptor.capture()
)
// Verify renewal was triggered (client.renewAuth was called)
verify(client).renewAuth("refreshToken")
val retrievedCredentials = credentialsCaptor.firstValue
MatcherAssert.assertThat(retrievedCredentials, Is.`is`(Matchers.notNullValue()))
MatcherAssert.assertThat(retrievedCredentials.idToken, Is.`is`("newId"))
MatcherAssert.assertThat(retrievedCredentials.accessToken, Is.`is`("newAccess"))
}

@Test
@ExperimentalCoroutinesApi
public fun shouldAwaitRenewedCredentialsWhenTokenExpiresWithinDefaultMinTtl(): Unit = runTest {
// Token expires in 30 seconds, which is within DEFAULT_MIN_TTL (60s)
val expirationTime = CredentialsMock.CURRENT_TIME_MS + 30 * 1000
Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken")
Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken")
Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken")
Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type")
Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime)
Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at"))
.thenReturn(expirationTime)
Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope")
Mockito.`when`(
client.renewAuth("refreshToken")
).thenReturn(request)
val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS)
val jwtMock = mock<Jwt>()
Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate)
Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock)

val renewedCredentials =
Credentials("newId", "newAccess", "newType", "refreshToken", newDate, "newScope")
Mockito.`when`(request.execute()).thenReturn(renewedCredentials)
// Use no-arg awaitCredentials which now uses DEFAULT_MIN_TTL
val result = manager.awaitCredentials()
// Verify renewal was triggered
verify(client).renewAuth("refreshToken")
MatcherAssert.assertThat(result, Is.`is`(Matchers.notNullValue()))
MatcherAssert.assertThat(result.idToken, Is.`is`("newId"))
MatcherAssert.assertThat(result.accessToken, Is.`is`("newAccess"))
}

@Test
public fun shouldNotHaveValidCredentialsWhenTokenExpiresWithinDefaultMinTtlAndNoRefreshToken() {
// Token expires in 30 seconds, within DEFAULT_MIN_TTL (60s), and no refresh token
val expirationTime = CredentialsMock.CURRENT_TIME_MS + 30 * 1000
Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime)
Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at"))
.thenReturn(expirationTime)
Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn(null)
Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken")
Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken")
// No-arg hasValidCredentials now uses DEFAULT_MIN_TTL, so token expiring in 30s is invalid
Assert.assertFalse(manager.hasValidCredentials())
}

@Test
public fun shouldHaveValidCredentialsWhenTokenExpiresWithinDefaultMinTtlButRefreshTokenAvailable() {
// Token expires in 30 seconds, within DEFAULT_MIN_TTL (60s), but refresh token is available
val expirationTime = CredentialsMock.CURRENT_TIME_MS + 30 * 1000
Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime)
Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at"))
.thenReturn(expirationTime)
Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken")
Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken")
Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken")
// Even though token expires within DEFAULT_MIN_TTL, refresh token makes it valid
MatcherAssert.assertThat(manager.hasValidCredentials(), Is.`is`(true))
}

@Test
public fun shouldNotHaveCredentialsWhenAccessTokenAndIdTokenAreMissing() {
Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn(null)
Expand Down Expand Up @@ -1812,7 +1904,7 @@ public class CredentialsManagerTest {
//now, update the clock and retry
manager.setClock(object : Clock {
override fun getCurrentTimeMillis(): Long {
return CredentialsMock.CURRENT_TIME_MS - 1000
return CredentialsMock.CURRENT_TIME_MS - (DEFAULT_MIN_TTL * 1000 + 1000)
Copy link
Contributor

Choose a reason for hiding this comment

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

Why this +1000 ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The +1000 adds 1 extra millisecond-second (1000ms = 1s) past the DEFAULT_MIN_TTL boundary, so the token has 61 seconds remaining — just over the 60-second threshold. This ensures the token is considered valid without refresh in the test scenario.

}
})
MatcherAssert.assertThat(manager.hasValidCredentials(), Is.`is`(true))
Expand All @@ -1829,7 +1921,6 @@ public class CredentialsManagerTest {
})
}


@Test
public fun shouldAddParametersToRequest() {
Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken")
Expand Down
Loading
Loading