Skip to content

Commit 7f0c68f

Browse files
authored
Add support for Multi-Resource Refresh Token (MRRT) (#811)
1 parent 16c3634 commit 7f0c68f

File tree

10 files changed

+1433
-24
lines changed

10 files changed

+1433
-24
lines changed

auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -731,7 +731,9 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
731731
}
732732

733733
/**
734-
* Requests new Credentials using a valid Refresh Token. The received token will have the same audience and scope as first requested.
734+
* Requests new Credentials using a valid Refresh Token. You can request credentials for a specific API by passing its audience value. The default scopes
735+
* configured for the API will be granted if you don't request any specific scopes.
736+
*
735737
*
736738
* 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.
737739
* 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
740742
*
741743
* Example usage:
742744
* ```
743-
* client.renewAuth("{refresh_token}")
744-
* .addParameter("scope", "openid profile email")
745+
* client.renewAuth("{refresh_token}","{audience}","{scope})
745746
* .start(object: Callback<Credentials, AuthenticationException> {
746747
* override fun onSuccess(result: Credentials) { }
747748
* override fun onFailure(error: AuthenticationException) { }
748749
* })
749750
* ```
750751
*
751752
* @param refreshToken used to fetch the new Credentials.
753+
* @param audience Identifier of the API that your application is requesting access to. Defaults to null.
754+
* @param scope Space-separated list of scope values to request. Defaults to null.
752755
* @return a request to start
753756
*/
754-
public fun renewAuth(refreshToken: String): Request<Credentials, AuthenticationException> {
757+
public fun renewAuth(
758+
refreshToken: String,
759+
audience: String? = null,
760+
scope: String? = null
761+
): Request<Credentials, AuthenticationException> {
755762
val parameters = ParameterBuilder.newBuilder()
756763
.setClientId(clientId)
757764
.setRefreshToken(refreshToken)
758765
.setGrantType(ParameterBuilder.GRANT_TYPE_REFRESH_TOKEN)
766+
.apply {
767+
audience?.let {
768+
setAudience(it)
769+
}
770+
scope?.let {
771+
setScope(it)
772+
}
773+
}
759774
.asDictionary()
760775
val url = auth0.getDomainUrl().toHttpUrl().newBuilder()
761776
.addPathSegment(OAUTH_PATH)

auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.auth0.android.authentication.storage
33
import androidx.annotation.VisibleForTesting
44
import com.auth0.android.authentication.AuthenticationAPIClient
55
import com.auth0.android.callback.Callback
6+
import com.auth0.android.result.APICredentials
67
import com.auth0.android.result.Credentials
78
import com.auth0.android.result.SSOCredentials
89
import com.auth0.android.util.Clock
@@ -30,6 +31,7 @@ public abstract class BaseCredentialsManager internal constructor(
3031

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

75+
public abstract fun getApiCredentials(
76+
audience: String,
77+
scope: String? = null,
78+
minTtl: Int = 0,
79+
parameters: Map<String, String> = emptyMap(),
80+
headers: Map<String, String> = emptyMap(),
81+
callback: Callback<APICredentials, CredentialsManagerException>
82+
)
83+
7384
@JvmSynthetic
7485
@Throws(CredentialsManagerException::class)
7586
public abstract suspend fun awaitSsoCredentials(parameters: Map<String, String>)
@@ -115,7 +126,18 @@ public abstract class BaseCredentialsManager internal constructor(
115126
forceRefresh: Boolean
116127
): Credentials
117128

129+
@JvmSynthetic
130+
@Throws(CredentialsManagerException::class)
131+
public abstract suspend fun awaitApiCredentials(
132+
audience: String,
133+
scope: String? = null,
134+
minTtl: Int = 0,
135+
parameters: Map<String, String> = emptyMap(),
136+
headers: Map<String, String> = emptyMap()
137+
): APICredentials
138+
118139
public abstract fun clearCredentials()
140+
public abstract fun clearApiCredentials(audience: String)
119141
public abstract fun hasValidCredentials(): Boolean
120142
public abstract fun hasValidCredentials(minTtl: Long): Boolean
121143

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

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ import androidx.annotation.VisibleForTesting
66
import com.auth0.android.authentication.AuthenticationAPIClient
77
import com.auth0.android.authentication.AuthenticationException
88
import com.auth0.android.callback.Callback
9+
import com.auth0.android.request.internal.GsonProvider
10+
import com.auth0.android.result.APICredentials
911
import com.auth0.android.result.Credentials
1012
import com.auth0.android.result.SSOCredentials
13+
import com.auth0.android.result.toAPICredentials
14+
import com.google.gson.Gson
1115
import kotlinx.coroutines.suspendCancellableCoroutine
1216
import java.util.*
1317
import java.util.concurrent.Executor
@@ -24,6 +28,9 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
2428
jwtDecoder: JWTDecoder,
2529
private val serialExecutor: Executor
2630
) : BaseCredentialsManager(authenticationClient, storage, jwtDecoder) {
31+
32+
private val gson: Gson = GsonProvider.gson
33+
2734
/**
2835
* Creates a new instance of the manager that will store the credentials in the given Storage.
2936
*
@@ -55,6 +62,17 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
5562
storage.store(LEGACY_KEY_CACHE_EXPIRES_AT, credentials.expiresAt.time)
5663
}
5764

65+
/**
66+
* Stores the given [APICredentials] in the storage for the given audience.
67+
* @param apiCredentials the API Credentials to be stored
68+
* @param audience the audience for which the credentials are stored
69+
*/
70+
override fun saveApiCredentials(apiCredentials: APICredentials, audience: String) {
71+
gson.toJson(apiCredentials).let {
72+
storage.store(audience, it)
73+
}
74+
}
75+
5876
/**
5977
* Creates a new request to exchange a refresh token for a session transfer token that can be used to perform web single sign-on.
6078
*
@@ -305,6 +323,44 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
305323
}
306324
}
307325

326+
/**
327+
* Retrieves API credentials from storage and automatically renews them using the refresh token if the access
328+
* token is expired. Otherwise, the retrieved API credentials will be returned as they are still valid.
329+
*
330+
* If there are no stored API credentials, the refresh token will be exchanged for a new set of API credentials.
331+
* New or renewed API credentials will be automatically persisted in storage.
332+
*
333+
* @param audience Identifier of the API that your application is requesting access to.
334+
* @param scope the scope to request for the access token. If null is passed, the previous scope will be kept.
335+
* @param minTtl the minimum time in seconds that the access token should last before expiration.
336+
* @param parameters additional parameters to send in the request to refresh expired credentials.
337+
* @param headers additional headers to send in the request to refresh expired credentials.
338+
*/
339+
@JvmSynthetic
340+
@Throws(CredentialsManagerException::class)
341+
override suspend fun awaitApiCredentials(
342+
audience: String,
343+
scope: String?,
344+
minTtl: Int,
345+
parameters: Map<String, String>,
346+
headers: Map<String, String>
347+
): APICredentials {
348+
return suspendCancellableCoroutine { continuation ->
349+
getApiCredentials(
350+
audience, scope, minTtl, parameters, headers,
351+
object : Callback<APICredentials, CredentialsManagerException> {
352+
override fun onSuccess(result: APICredentials) {
353+
continuation.resume(result)
354+
}
355+
356+
override fun onFailure(error: CredentialsManagerException) {
357+
continuation.resumeWithException(error)
358+
}
359+
}
360+
)
361+
}
362+
}
363+
308364
/**
309365
* Retrieves the credentials from the storage and refresh them if they have already expired.
310366
* It will fail with [CredentialsManagerException] if the saved access_token or id_token is null,
@@ -496,6 +552,99 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
496552
}
497553
}
498554

555+
556+
/**
557+
* Retrieves API credentials from storage and automatically renews them using the refresh token if the access
558+
* token is expired. Otherwise, the retrieved API credentials will be returned via the success callback as they are still valid.
559+
*
560+
* If there are no stored API credentials, the refresh token will be exchanged for a new set of API credentials.
561+
* New or renewed API credentials will be automatically persisted in storage.
562+
*
563+
* @param audience Identifier of the API that your application is requesting access to.
564+
* @param scope the scope to request for the access token. If null is passed, the previous scope will be kept.
565+
* @param minTtl the minimum time in seconds that the access token should last before expiration.
566+
* @param parameters additional parameters to send in the request to refresh expired credentials.
567+
* @param headers headers to use when exchanging a refresh token for API credentials.
568+
* @param callback the callback that will receive a valid [Credentials] or the [CredentialsManagerException].
569+
*/
570+
override fun getApiCredentials(
571+
audience: String,
572+
scope: String?,
573+
minTtl: Int,
574+
parameters: Map<String, String>,
575+
headers: Map<String, String>,
576+
callback: Callback<APICredentials, CredentialsManagerException>
577+
) {
578+
serialExecutor.execute {
579+
//Check if existing api credentials are present and valid
580+
val apiCredentialsJson = storage.retrieveString(audience)
581+
apiCredentialsJson?.let {
582+
val apiCredentials = gson.fromJson(it, APICredentials::class.java)
583+
val willTokenExpire = willExpire(apiCredentials.expiresAt.time, minTtl.toLong())
584+
val scopeChanged = hasScopeChanged(apiCredentials.scope, scope)
585+
val hasExpired = hasExpired(apiCredentials.expiresAt.time)
586+
if (!hasExpired && !willTokenExpire && !scopeChanged) {
587+
callback.onSuccess(apiCredentials)
588+
return@execute
589+
}
590+
}
591+
//Check if refresh token exists or not
592+
val refreshToken = storage.retrieveString(KEY_REFRESH_TOKEN)
593+
if (refreshToken == null) {
594+
callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN)
595+
return@execute
596+
}
597+
598+
val request = authenticationClient.renewAuth(refreshToken, audience, scope)
599+
request.addParameters(parameters)
600+
601+
for (header in headers) {
602+
request.addHeader(header.key, header.value)
603+
}
604+
605+
try {
606+
val newCredentials = request.execute()
607+
val expiresAt = newCredentials.expiresAt.time
608+
val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong())
609+
if (willAccessTokenExpire) {
610+
val tokenLifetime = (expiresAt - currentTimeInMillis - minTtl * 1000) / -1000
611+
val wrongTtlException = CredentialsManagerException(
612+
CredentialsManagerException.Code.LARGE_MIN_TTL, String.format(
613+
Locale.getDefault(),
614+
"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.",
615+
tokenLifetime,
616+
minTtl
617+
)
618+
)
619+
callback.onFailure(wrongTtlException)
620+
return@execute
621+
}
622+
623+
// non-empty refresh token for refresh token rotation scenarios
624+
val updatedRefreshToken =
625+
if (TextUtils.isEmpty(newCredentials.refreshToken)) refreshToken else newCredentials.refreshToken
626+
val newApiCredentials = newCredentials.toAPICredentials()
627+
storage.store(KEY_REFRESH_TOKEN, updatedRefreshToken)
628+
storage.store(KEY_ID_TOKEN, newCredentials.idToken)
629+
saveApiCredentials(newApiCredentials, audience)
630+
callback.onSuccess(newApiCredentials)
631+
} catch (error: AuthenticationException) {
632+
val exception = when {
633+
error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED
634+
635+
error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK
636+
else -> CredentialsManagerException.Code.API_ERROR
637+
}
638+
callback.onFailure(
639+
CredentialsManagerException(
640+
exception, error
641+
)
642+
)
643+
}
644+
}
645+
646+
}
647+
499648
/**
500649
* Checks if a non-expired pair of credentials can be obtained from this manager.
501650
*
@@ -536,6 +685,14 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
536685
storage.remove(LEGACY_KEY_CACHE_EXPIRES_AT)
537686
}
538687

688+
/**
689+
* Removes the credentials for the given audience from the storage if present.
690+
*/
691+
override fun clearApiCredentials(audience: String) {
692+
storage.remove(audience)
693+
Log.d(TAG, "API Credentials for $audience were just removed from the storage")
694+
}
695+
539696
/**
540697
* Helper method to store the given [SSOCredentials] refresh token in the storage.
541698
* Method will silently return if the passed credentials have no refresh token.

0 commit comments

Comments
 (0)