Skip to content

Add support for Multi-Resource Refresh Token (MRRT) #811

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

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -731,7 +731,9 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
}

/**
* Requests new Credentials using a valid Refresh Token. The received token will have the same audience and scope as first requested.
* Requests new Credentials using a valid Refresh Token. You can request credentials for a specific API by passing its audience value. The default scopes configured for
* the API will be granted if you don't request any specific scopes.
*
*
* This method will use the /oauth/token endpoint with the 'refresh_token' grant, and the response will include an id_token and an access_token if 'openid' scope was requested when the refresh_token was obtained.
* Additionally, if the application has Refresh Token Rotation configured, a new one-time use refresh token will also be included in the response.
@@ -740,22 +742,35 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
*
* Example usage:
* ```
* client.renewAuth("{refresh_token}")
* .addParameter("scope", "openid profile email")
* client.renewAuth("{refresh_token}","{audience}","{scope})
* .start(object: Callback<Credentials, AuthenticationException> {
* override fun onSuccess(result: Credentials) { }
* override fun onFailure(error: AuthenticationException) { }
* })
* ```
*
* @param refreshToken used to fetch the new Credentials.
* @param audience Identifier of the API that your application is requesting access to. Defaults to null.
* @param scope Space-separated list of scope values to request. Defaults to null.
* @return a request to start
*/
public fun renewAuth(refreshToken: String): Request<Credentials, AuthenticationException> {
public fun renewAuth(
refreshToken: String,
audience: String? = null,
scope: String? = null
): Request<Credentials, AuthenticationException> {
val parameters = ParameterBuilder.newBuilder()
.setClientId(clientId)
.setRefreshToken(refreshToken)
.setGrantType(ParameterBuilder.GRANT_TYPE_REFRESH_TOKEN)
.apply {
audience?.let {
setAudience(it)
}
scope?.let {
setScope(it)
}
}
.asDictionary()
val url = auth0.getDomainUrl().toHttpUrl().newBuilder()
.addPathSegment(OAUTH_PATH)
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ package com.auth0.android.authentication.storage
import androidx.annotation.VisibleForTesting
import com.auth0.android.authentication.AuthenticationAPIClient
import com.auth0.android.callback.Callback
import com.auth0.android.result.APICredentials
import com.auth0.android.result.Credentials
import com.auth0.android.result.SSOCredentials
import com.auth0.android.util.Clock
@@ -30,6 +31,7 @@ public abstract class BaseCredentialsManager internal constructor(

@Throws(CredentialsManagerException::class)
public abstract fun saveCredentials(credentials: Credentials)
public abstract fun saveApiCredentials(apiCredentials: APICredentials, audience: String)
public abstract fun getCredentials(callback: Callback<Credentials, CredentialsManagerException>)
public abstract fun getSsoCredentials(
parameters: Map<String, String>,
@@ -70,6 +72,15 @@ public abstract class BaseCredentialsManager internal constructor(
callback: Callback<Credentials, CredentialsManagerException>
)

public abstract fun getApiCredentials(
audience: String,
scope: String? = null,
minTtl: Int = 0,
parameters: Map<String, String> = emptyMap(),
headers: Map<String, String> = emptyMap(),
callback: Callback<APICredentials, CredentialsManagerException>
)

@JvmSynthetic
@Throws(CredentialsManagerException::class)
public abstract suspend fun awaitSsoCredentials(parameters: Map<String, String>)
@@ -115,7 +126,18 @@ public abstract class BaseCredentialsManager internal constructor(
forceRefresh: Boolean
): Credentials

@JvmSynthetic
@Throws(CredentialsManagerException::class)
public abstract suspend fun awaitApiCredentials(
audience: String,
scope: String? = null,
minTtl: Int = 0,
parameters: Map<String, String> = emptyMap(),
headers: Map<String, String> = emptyMap()
): APICredentials

public abstract fun clearCredentials()
public abstract fun clearApiCredentials(audience: String)
public abstract fun hasValidCredentials(): Boolean
public abstract fun hasValidCredentials(minTtl: Long): Boolean

Original file line number Diff line number Diff line change
@@ -6,8 +6,12 @@
import com.auth0.android.authentication.AuthenticationAPIClient
import com.auth0.android.authentication.AuthenticationException
import com.auth0.android.callback.Callback
import com.auth0.android.request.internal.GsonProvider
import com.auth0.android.result.APICredentials
import com.auth0.android.result.Credentials
import com.auth0.android.result.SSOCredentials
import com.auth0.android.result.toAPICredentials
import com.google.gson.Gson
import kotlinx.coroutines.suspendCancellableCoroutine
import java.util.*
import java.util.concurrent.Executor
@@ -24,6 +28,9 @@
jwtDecoder: JWTDecoder,
private val serialExecutor: Executor
) : BaseCredentialsManager(authenticationClient, storage, jwtDecoder) {

private val gson: Gson = GsonProvider.gson

/**
* Creates a new instance of the manager that will store the credentials in the given Storage.
*
@@ -55,6 +62,17 @@
storage.store(LEGACY_KEY_CACHE_EXPIRES_AT, credentials.expiresAt.time)
}

/**
* Stores the given [APICredentials] in the storage for the given audience.
* @param apiCredentials the API Credentials to be stored
* @param audience the audience for which the credentials are stored
*/
override fun saveApiCredentials(apiCredentials: APICredentials, audience: String) {
gson.toJson(apiCredentials).let {
storage.store(audience, it)
}
}

/**
* Creates a new request to exchange a refresh token for a session transfer token that can be used to perform web single sign-on.
*
@@ -305,6 +323,45 @@
}
}

/**
* Retrieves API credentials from storage and automatically renews them using the refresh token if the access
* token is expired. Otherwise, the retrieved API credentials will be returned via the success case as they are still valid.
*
* If there are no stored API credentials, the refresh token will be exchanged for a new set of API credentials.
* New or renewed API credentials will be automatically stored in storage.
* This is a Coroutine that is exposed only for Kotlin.
*
* @param audience Identifier of the API that your application is requesting access to.
* @param scope the scope to request for the access token. If null is passed, the previous scope will be kept.
* @param minTtl the minimum time in seconds that the access token should last before expiration.
* @param parameters additional parameters to send in the request to refresh expired credentials.
* @param headers additional headers to send in the request to refresh expired credentials.
*/
@JvmSynthetic
@Throws(CredentialsManagerException::class)
override suspend fun awaitApiCredentials(
audience: String,
scope: String?,
minTtl: Int,
parameters: Map<String, String>,
headers: Map<String, String>
): APICredentials {
return suspendCancellableCoroutine { continuation ->
getApiCredentials(
audience, scope, minTtl, parameters, headers,
object : Callback<APICredentials, CredentialsManagerException> {
override fun onSuccess(result: APICredentials) {
continuation.resume(result)
}

override fun onFailure(error: CredentialsManagerException) {
continuation.resumeWithException(error)
}
}
)
}
}

/**
* Retrieves the credentials from the storage and refresh them if they have already expired.
* It will fail with [CredentialsManagerException] if the saved access_token or id_token is null,
@@ -496,6 +553,99 @@
}
}


/**
* Retrieves API credentials from storage and automatically renews them using the refresh token if the access
* token is expired. Otherwise, the retrieved API credentials will be returned via the success case as they are still valid.
*
* If there are no stored API credentials, the refresh token will be exchanged for a new set of API credentials.
* New or renewed API credentials will be automatically stored in storage.
*
* @param audience Identifier of the API that your application is requesting access to.
* @param scope the scope to request for the access token. If null is passed, the previous scope will be kept.
* @param minTtl the minimum time in seconds that the access token should last before expiration.
* @param parameters additional parameters to send in the request to refresh expired credentials.
* @param headers headers to use when exchanging a refresh token for API credentials.
* @param callback the callback that will receive a valid [Credentials] or the [CredentialsManagerException].
*/
override fun getApiCredentials(
audience: String,
scope: String?,
minTtl: Int,
parameters: Map<String, String>,
headers: Map<String, String>,
callback: Callback<APICredentials, CredentialsManagerException>
) {
serialExecutor.execute {
//Check if existing api credentials are present and valid
val apiCredentialsJson = storage.retrieveString(audience)
apiCredentialsJson?.let {
val apiCredentials = gson.fromJson(it, APICredentials::class.java)
val willTokenExpire = willExpire(apiCredentials.expiresAt.time, minTtl.toLong())
val scopeChanged = hasScopeChanged(apiCredentials.scope, scope)
val hasExpired = hasExpired(apiCredentials.expiresAt.time)
if (!hasExpired && !willTokenExpire && !scopeChanged) {
callback.onSuccess(apiCredentials)
return@execute
}
}
//Check if refresh token exists or not
val refreshToken = storage.retrieveString(KEY_REFRESH_TOKEN)
if (refreshToken == null) {
callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN)
return@execute
}

val request = authenticationClient.renewAuth(refreshToken, audience, scope)
request.addParameters(parameters)

for (header in headers) {
request.addHeader(header.key, header.value)
}

try {
val newCredentials = request.execute()
val expiresAt = newCredentials.expiresAt.time
val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong())
if (willAccessTokenExpire) {
val tokenLifetime = (expiresAt - currentTimeInMillis - minTtl * 1000) / -1000

Check warning

Code scanning / CodeQL

Result of multiplication cast to wider type Warning

Potential overflow in
int multiplication
before it is converted to long by use in a numeric context.

Copilot Autofix

AI 3 days ago

To fix the issue, one of the operands in the multiplication should be explicitly cast to long before the multiplication occurs. This ensures that the multiplication is performed using long arithmetic, avoiding the risk of integer overflow. Specifically, minTtl should be cast to long before multiplying it by 1000.

The change should be made on line 611, where the multiplication occurs. The updated code will cast minTtl to long before performing the multiplication.


Suggested changeset 1
auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt
--- a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt
+++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt
@@ -610,3 +610,3 @@
                 if (willAccessTokenExpire) {
-                    val tokenLifetime = (expiresAt - currentTimeInMillis - minTtl * 1000) / -1000
+                    val tokenLifetime = (expiresAt - currentTimeInMillis - minTtl.toLong() * 1000) / -1000
                     val wrongTtlException = CredentialsManagerException(
EOF
@@ -610,3 +610,3 @@
if (willAccessTokenExpire) {
val tokenLifetime = (expiresAt - currentTimeInMillis - minTtl * 1000) / -1000
val tokenLifetime = (expiresAt - currentTimeInMillis - minTtl.toLong() * 1000) / -1000
val wrongTtlException = CredentialsManagerException(
Copilot is powered by AI and may make mistakes. Always verify output.
val wrongTtlException = CredentialsManagerException(
CredentialsManagerException.Code.LARGE_MIN_TTL, String.format(
Locale.getDefault(),
"The lifetime of the renewed Access Token (%d) is less than the minTTL requested (%d). Increase the 'Token Expiration' setting of your Auth0 API in the dashboard, or request a lower minTTL.",
tokenLifetime,
minTtl
)
)
callback.onFailure(wrongTtlException)
return@execute
}

// non-empty refresh token for refresh token rotation scenarios
val updatedRefreshToken =
if (TextUtils.isEmpty(newCredentials.refreshToken)) refreshToken else newCredentials.refreshToken
val newApiCredentials = newCredentials.toAPICredentials()
storage.store(KEY_REFRESH_TOKEN, updatedRefreshToken)
storage.store(KEY_ID_TOKEN, newCredentials.idToken)
saveApiCredentials(newApiCredentials, audience)
callback.onSuccess(newApiCredentials)
} catch (error: AuthenticationException) {
val exception = when {
error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED

error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK
else -> CredentialsManagerException.Code.API_ERROR
}
callback.onFailure(
CredentialsManagerException(
exception, error
)
)
}
}

}

/**
* Checks if a non-expired pair of credentials can be obtained from this manager.
*
@@ -536,6 +686,13 @@
storage.remove(LEGACY_KEY_CACHE_EXPIRES_AT)
}

/**
* Removes the credentials for the given audience from the storage if present.
*/
override fun clearApiCredentials(audience: String) {
storage.remove(audience)
}

/**
* Helper method to store the given [SSOCredentials] refresh token in the storage.
* Method will silently return if the passed credentials have no refresh token.
Loading