From 188a009a294e76353008aafac9369038ca4a3a01 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Tue, 11 Mar 2025 15:16:27 +0530 Subject: [PATCH 1/6] Updated the renewAuth api to take audience and scope parameter while getting credentials --- .../authentication/AuthenticationAPIClient.kt | 18 ++- .../auth0/android/result/APICredentials.kt | 57 ++++++++ .../com/auth0/android/result/Credentials.kt | 3 +- .../AuthenticationAPIClientTest.kt | 133 ++++++++++++++++-- .../storage/SecureCredentialsManagerTest.kt | 6 +- 5 files changed, 200 insertions(+), 17 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/result/APICredentials.kt diff --git a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt index a57bfd3d..8c7611bb 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -749,13 +749,27 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * ``` * * @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 { + public fun renewAuth( + refreshToken: String, + audience: String? = null, + scope: String? = null + ): Request { 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) @@ -942,7 +956,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe /** * Helper function to make a request to the /oauth/token endpoint with a custom response type. */ - private inline fun loginWithTokenGeneric(parameters: Map): Request { + private inline fun loginWithTokenGeneric(parameters: Map): Request { val url = auth0.getDomainUrl().toHttpUrl().newBuilder() .addPathSegment(OAUTH_PATH) .addPathSegment(TOKEN_PATH) diff --git a/auth0/src/main/java/com/auth0/android/result/APICredentials.kt b/auth0/src/main/java/com/auth0/android/result/APICredentials.kt new file mode 100644 index 00000000..a8182b3b --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/APICredentials.kt @@ -0,0 +1,57 @@ +package com.auth0.android.result + +import com.google.gson.annotations.SerializedName +import java.util.Date + +/** + * Holds the user's credentials obtained from Auth0 for a specific API as the result of exchanging a refresh token. + * + * * *accessToken*: Access Token for Auth0 API + * * *type*: The type of the received Access Token. + * * *expiresAt*: The token expiration date. + * * *scope*: The token's granted scope. + * + */ +public data class APICredentials( + /** + * Getter for the Access Token for Auth0 API. + * + * @return the Access Token. + */ + @field:SerializedName("access_token") + val accessToken: String, + /** + * Getter for the type of the received Token. + * + * @return the token type. + */ + @field:SerializedName("token_type") + val type: String, + /** + * Getter for the expiration date of the Access Token. + * Once expired, the Access Token can no longer be used to access an API and a new Access Token needs to be obtained. + * + * @return the expiration date of this Access Token + */ + @field:SerializedName("expires_at") + val expiresAt: Date, + /** + * Getter for the access token's granted scope. Only available if the requested scope differs from the granted one. + * + * @return the granted scope. + */ + @field:SerializedName("scope") + val scope: String? +) { + override fun toString(): String { + return "APICredentials( accessToken='xxxxx', type='$type', expiresAt='$expiresAt', scope='$scope')" + } +} + + +/** + * Converts a Credentials instance to an APICredentials instance. + */ +internal fun Credentials.toAPICredentials(): APICredentials { + return APICredentials(accessToken, type, expiresAt, scope) +} diff --git a/auth0/src/main/java/com/auth0/android/result/Credentials.kt b/auth0/src/main/java/com/auth0/android/result/Credentials.kt index 42904212..ec32ed1f 100755 --- a/auth0/src/main/java/com/auth0/android/result/Credentials.kt +++ b/auth0/src/main/java/com/auth0/android/result/Credentials.kt @@ -3,8 +3,7 @@ package com.auth0.android.result import com.auth0.android.request.internal.GsonProvider import com.auth0.android.request.internal.Jwt import com.google.gson.annotations.SerializedName - -import java.util.* +import java.util.Date /** * Holds the user's credentials returned by Auth0. diff --git a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt index 3ea781de..dd122c31 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -2277,7 +2277,7 @@ public class AuthenticationAPIClientTest { public fun shouldCustomTokenExchange() { mockAPI.willReturnSuccessfulLogin() val callback = MockAuthenticationCallback() - client.customTokenExchange( "subject-token-type","subject-token") + client.customTokenExchange("subject-token-type", "subject-token") .start(callback) ShadowLooper.idleMainLooper() val request = mockAPI.takeRequest() @@ -2355,10 +2355,10 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldFetchSessionToken(){ + public fun shouldFetchSessionToken() { mockAPI.willReturnSuccessfulLogin() val callback = MockAuthenticationCallback() - client.fetchSessionToken( "refresh-token") + client.fetchSessionToken("refresh-token") .start(callback) ShadowLooper.idleMainLooper() val request = mockAPI.takeRequest() @@ -2375,8 +2375,14 @@ public class AuthenticationAPIClientTest { Matchers.hasEntry("grant_type", ParameterBuilder.GRANT_TYPE_TOKEN_EXCHANGE) ) assertThat(body, Matchers.hasEntry("subject_token", "refresh-token")) - assertThat(body, Matchers.hasEntry("subject_token_type", ParameterBuilder.TOKEN_TYPE_REFRESH_TOKEN)) - assertThat(body, Matchers.hasEntry("requested_token_type", ParameterBuilder.TOKEN_TYPE_SESSION_TOKEN)) + assertThat( + body, + Matchers.hasEntry("subject_token_type", ParameterBuilder.TOKEN_TYPE_REFRESH_TOKEN) + ) + assertThat( + body, + Matchers.hasEntry("requested_token_type", ParameterBuilder.TOKEN_TYPE_SESSION_TOKEN) + ) assertThat( callback, AuthenticationCallbackMatcher.hasPayloadOfType( SSOCredentials::class.java @@ -2385,9 +2391,9 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldFetchSessionTokenSync(){ + public fun shouldFetchSessionTokenSync() { mockAPI.willReturnSuccessfulLogin() - val ssoCredentials= client.fetchSessionToken( "refresh-token") + val ssoCredentials = client.fetchSessionToken("refresh-token") .execute() val request = mockAPI.takeRequest() assertThat( @@ -2403,8 +2409,14 @@ public class AuthenticationAPIClientTest { Matchers.hasEntry("grant_type", ParameterBuilder.GRANT_TYPE_TOKEN_EXCHANGE) ) assertThat(body, Matchers.hasEntry("subject_token", "refresh-token")) - assertThat(body, Matchers.hasEntry("subject_token_type", ParameterBuilder.TOKEN_TYPE_REFRESH_TOKEN)) - assertThat(body, Matchers.hasEntry("requested_token_type", ParameterBuilder.TOKEN_TYPE_SESSION_TOKEN)) + assertThat( + body, + Matchers.hasEntry("subject_token_type", ParameterBuilder.TOKEN_TYPE_REFRESH_TOKEN) + ) + assertThat( + body, + Matchers.hasEntry("requested_token_type", ParameterBuilder.TOKEN_TYPE_SESSION_TOKEN) + ) assertThat(ssoCredentials, Matchers.`is`(Matchers.notNullValue())) } @@ -2429,8 +2441,14 @@ public class AuthenticationAPIClientTest { Matchers.hasEntry("grant_type", ParameterBuilder.GRANT_TYPE_TOKEN_EXCHANGE) ) assertThat(body, Matchers.hasEntry("subject_token", "refresh-token")) - assertThat(body, Matchers.hasEntry("subject_token_type", ParameterBuilder.TOKEN_TYPE_REFRESH_TOKEN)) - assertThat(body, Matchers.hasEntry("requested_token_type", ParameterBuilder.TOKEN_TYPE_SESSION_TOKEN)) + assertThat( + body, + Matchers.hasEntry("subject_token_type", ParameterBuilder.TOKEN_TYPE_REFRESH_TOKEN) + ) + assertThat( + body, + Matchers.hasEntry("requested_token_type", ParameterBuilder.TOKEN_TYPE_SESSION_TOKEN) + ) assertThat(ssoCredentials, Matchers.`is`(Matchers.notNullValue())) } @@ -2530,6 +2548,99 @@ public class AuthenticationAPIClientTest { assertThat(credentials, Matchers.`is`(Matchers.notNullValue())) } + @Test + public fun shouldRenewAuthWithOAuthTokenAndAudience() { + val auth0 = auth0 + val client = AuthenticationAPIClient(auth0) + mockAPI.willReturnSuccessfulLogin() + val credentials = client.renewAuth("refreshToken", "_audience") + .execute() + val request = mockAPI.takeRequest() + assertThat( + request.getHeader("Accept-Language"), Matchers.`is`( + defaultLocale + ) + ) + assertThat(request.path, Matchers.equalTo("/oauth/token")) + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) + assertThat(body, Matchers.hasEntry("refresh_token", "refreshToken")) + assertThat(body, Matchers.hasEntry("grant_type", "refresh_token")) + assertThat(body, Matchers.hasEntry("audience", "_audience")) + assertThat(body, Matchers.not(Matchers.hasKey("scope"))) + assertThat(credentials, Matchers.`is`(Matchers.notNullValue())) + } + + @Test + public fun shouldRenewAuthWithOAuthTokenAndScope() { + val auth0 = auth0 + val client = AuthenticationAPIClient(auth0) + mockAPI.willReturnSuccessfulLogin() + val credentials = client.renewAuth(refreshToken = "refreshToken", scope = "read:data") + .execute() + val request = mockAPI.takeRequest() + assertThat( + request.getHeader("Accept-Language"), Matchers.`is`( + defaultLocale + ) + ) + assertThat(request.path, Matchers.equalTo("/oauth/token")) + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) + assertThat(body, Matchers.hasEntry("refresh_token", "refreshToken")) + assertThat(body, Matchers.hasEntry("grant_type", "refresh_token")) + assertThat(body, Matchers.hasEntry("scope", "read:data openid")) + assertThat(body, Matchers.not(Matchers.hasKey("audience"))) + assertThat(credentials, Matchers.`is`(Matchers.notNullValue())) + } + + @Test + public fun shouldRenewAuthWithOAuthAudienceAndScopeEnforcingOpendId() { + val auth0 = auth0 + val client = AuthenticationAPIClient(auth0) + mockAPI.willReturnSuccessfulLogin() + val credentials = client.renewAuth("refreshToken", "_audience", "read:data write:data") + .execute() + val request = mockAPI.takeRequest() + assertThat( + request.getHeader("Accept-Language"), Matchers.`is`( + defaultLocale + ) + ) + assertThat(request.path, Matchers.equalTo("/oauth/token")) + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) + assertThat(body, Matchers.hasEntry("refresh_token", "refreshToken")) + assertThat(body, Matchers.hasEntry("grant_type", "refresh_token")) + assertThat(body, Matchers.hasEntry("audience", "_audience")) + assertThat(body, Matchers.hasEntry("scope", "read:data write:data openid")) + assertThat(credentials, Matchers.`is`(Matchers.notNullValue())) + } + + @Test + public fun shouldRenewAuthWithOAuthAudienceAndScopeNotEnforcingOpendId() { + val auth0 = auth0 + val client = AuthenticationAPIClient(auth0) + mockAPI.willReturnSuccessfulLogin() + val credentials = + client.renewAuth("refreshToken", "_audience", "openid read:data write:data") + .execute() + val request = mockAPI.takeRequest() + assertThat( + request.getHeader("Accept-Language"), Matchers.`is`( + defaultLocale + ) + ) + assertThat(request.path, Matchers.equalTo("/oauth/token")) + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) + assertThat(body, Matchers.hasEntry("refresh_token", "refreshToken")) + assertThat(body, Matchers.hasEntry("grant_type", "refresh_token")) + assertThat(body, Matchers.hasEntry("audience", "_audience")) + assertThat(body, Matchers.hasEntry("scope", "openid read:data write:data")) + assertThat(credentials, Matchers.`is`(Matchers.notNullValue())) + } + @Test public fun shouldFetchProfileSyncAfterLoginRequest() { mockAPI.willReturnSuccessfulLogin() diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt index 2de21a2e..13e011d4 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt @@ -1822,7 +1822,7 @@ public class SecureCredentialsManagerTest { "newScope" ) Mockito.`when`( - client.renewAuth("refreshToken") + client.renewAuth(refreshToken = "refreshToken") ).thenReturn(request) Mockito.`when`(request.execute()).thenReturn(renewedCredentials) val serialExecutor = Executors.newSingleThreadExecutor() @@ -1893,7 +1893,9 @@ public class SecureCredentialsManagerTest { } latch.await() // Wait for all threads to finish Mockito.verify(client, Mockito.times(1)) - .renewAuth(any()) // verify that api client's renewAuth is called only once + .renewAuth( + refreshToken = "refreshToken" + ) // verify that api client's renewAuth is called only once Mockito.verify(request, Mockito.times(1)).execute() // Verify single network request } From de1bbd215126138323eb066a91b5d1c6d967cc9c Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 17 Mar 2025 17:36:13 +0530 Subject: [PATCH 2/6] Added support for getting api credentials in the CredentialsManager class --- .../authentication/AuthenticationAPIClient.kt | 7 +- .../storage/BaseCredentialsManager.kt | 22 +++ .../storage/CredentialsManager.kt | 160 ++++++++++++++++++ 3 files changed, 186 insertions(+), 3 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt index 8c7611bb..4604813a 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -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,8 +742,7 @@ 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 { * override fun onSuccess(result: Credentials) { } * override fun onFailure(error: AuthenticationException) { } diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt index 042f629b..6f321838 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt @@ -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 saveSsoCredentials(ssoCredentials: SSOCredentials) public abstract fun getCredentials(callback: Callback) public abstract fun getSsoCredentials(callback: Callback) @@ -63,6 +65,15 @@ public abstract class BaseCredentialsManager internal constructor( callback: Callback ) + public abstract fun getApiCredentials( + audience: String, + scope: String? = null, + minTtl: Int = 0, + parameters: Map = emptyMap(), + headers: Map = emptyMap(), + callback: Callback + ) + @JvmSynthetic @Throws(CredentialsManagerException::class) public abstract suspend fun awaitSsoCredentials(): SSOCredentials @@ -102,7 +113,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 = emptyMap(), + headers: Map = emptyMap() + ): APICredentials + public abstract fun clearCredentials() + public abstract fun clearApiCredentials(audience: String) public abstract fun hasValidCredentials(): Boolean public abstract fun hasValidCredentials(minTtl: Long): Boolean 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 index 8f6b3e51..5c805d2b 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt @@ -5,8 +5,12 @@ import androidx.annotation.VisibleForTesting 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 @@ -23,6 +27,9 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting 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. * @@ -54,6 +61,16 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting 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) + } + } /** * Stores the given [SSOCredentials] refresh token in the storage. @@ -241,6 +258,45 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting } } + /** + * 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, + headers: Map + ): APICredentials { + return suspendCancellableCoroutine { continuation -> + getApiCredentials( + audience, scope, minTtl, parameters, headers, + object : Callback { + 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, @@ -418,6 +474,103 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting } } + + /** + * 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, + headers: Map, + callback: Callback + ) { + 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 + 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() + saveCredentials( + recreateCredentials( + newCredentials.idToken, newCredentials.accessToken, newCredentials.type, + updatedRefreshToken, newCredentials.expiresAt, newCredentials.scope + ) + ) + saveCredentials(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. * @@ -458,6 +611,13 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting 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) + } + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal fun recreateCredentials( idToken: String, From 600eb1e30ad20b0394cf13bba6611f996af65a97 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Tue, 18 Mar 2025 10:25:52 +0530 Subject: [PATCH 3/6] Added MRRT api support in SecureCredentialsManager class --- .../storage/CredentialsManager.kt | 10 +- .../storage/SecureCredentialsManager.kt | 335 ++++++++++++++++-- 2 files changed, 307 insertions(+), 38 deletions(-) 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 index 5c805d2b..68dfe488 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt @@ -546,13 +546,9 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting val updatedRefreshToken = if (TextUtils.isEmpty(newCredentials.refreshToken)) refreshToken else newCredentials.refreshToken val newApiCredentials = newCredentials.toAPICredentials() - saveCredentials( - recreateCredentials( - newCredentials.idToken, newCredentials.accessToken, newCredentials.type, - updatedRefreshToken, newCredentials.expiresAt, newCredentials.scope - ) - ) - saveCredentials(newApiCredentials, audience) + 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 { diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt index 955827f9..a12bcc10 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt @@ -11,9 +11,11 @@ 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.OptionalCredentials import com.auth0.android.result.SSOCredentials +import com.auth0.android.result.toAPICredentials import com.google.gson.Gson import kotlinx.coroutines.suspendCancellableCoroutine import java.lang.ref.WeakReference @@ -38,7 +40,6 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT ) : BaseCredentialsManager(apiClient, storage, jwtDecoder) { private val gson: Gson = GsonProvider.gson - /** * Creates a new SecureCredentialsManager to handle Credentials * @@ -128,6 +129,36 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT } } + /** + * 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) { + val json = gson.toJson(apiCredentials) + try { + val encrypted = crypto.encrypt(json.toByteArray()) + val encryptedEncoded = Base64.encodeToString(encrypted, Base64.DEFAULT) + storage.store(audience, encryptedEncoded) + } catch (e: IncompatibleDeviceException) { + throw CredentialsManagerException( + CredentialsManagerException.Code.INCOMPATIBLE_DEVICE, + e + ) + } catch (e: CryptoException) { + /* + * If the keys were invalidated in the call above a good new pair is going to be available + * to use on the next call. We clear any existing credentials so #hasValidCredentials returns + * a true value. Retrying this operation will succeed. + */ + clearApiCredentials(audience) + throw CredentialsManagerException( + CredentialsManagerException.Code.CRYPTO_EXCEPTION, + e + ) + } + } + /** * Stores the given [SSOCredentials] refresh token in the storage. * This method must be called if the SSOCredentials are obtained by directly invoking [AuthenticationAPIClient.fetchSessionToken] api and @@ -143,7 +174,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT try { existingCredentials = getExistingCredentials() } catch (exception: CredentialsManagerException) { - Log.e(TAG,"Error while fetching existing credentials", exception) + Log.e(TAG, "Error while fetching existing credentials", exception) return@execute } // Checking if the existing one needs to be replaced with the new one @@ -162,10 +193,10 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT */ override fun getSsoCredentials(callback: Callback) { serialExecutor.execute { - lateinit var existingCredentials:Credentials - try{ + lateinit var existingCredentials: Credentials + try { existingCredentials = getExistingCredentials() - }catch (exception:CredentialsManagerException){ + } catch (exception: CredentialsManagerException) { callback.onFailure(exception) return@execute } @@ -362,6 +393,48 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT } } + /** + * 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, + headers: Map + ): APICredentials { + return suspendCancellableCoroutine { continuation -> + getApiCredentials( + audience, + scope, + minTtl, + parameters, + headers, + object : Callback { + override fun onSuccess(result: APICredentials) { + continuation.resume(result) + } + + override fun onFailure(error: CredentialsManagerException) { + continuation.resumeWithException(error) + } + }) + } + } + /** * Tries to obtain the credentials from the Storage. The callback's [Callback.onSuccess] method will be called with the result. * If something unexpected happens, the [Callback.onFailure] method will be called with the error. Some devices are not compatible @@ -487,20 +560,14 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT } if (fragmentActivity != null && localAuthenticationOptions != null && localAuthenticationManagerFactory != null) { + fragmentActivity.get()?.let { fragmentActivity -> - val localAuthenticationManager = localAuthenticationManagerFactory.create( - activity = fragmentActivity, - authenticationOptions = localAuthenticationOptions, - resultCallback = localAuthenticationResultCallback( - scope, - minTtl, - parameters, - headers, - forceRefresh, - callback + startBiometricAuthentication( + fragmentActivity, + biometricAuthenticationCredentialsCallback( + scope, minTtl, parameters, headers, forceRefresh, callback ) ) - localAuthenticationManager.authenticate() } ?: run { callback.onFailure(CredentialsManagerException.BIOMETRIC_ERROR_NO_ACTIVITY) } @@ -510,22 +577,48 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT continueGetCredentials(scope, minTtl, parameters, headers, forceRefresh, callback) } - private val localAuthenticationResultCallback = - { scope: String?, minTtl: Int, parameters: Map, headers: Map, forceRefresh: Boolean, callback: Callback -> - object : Callback { - override fun onSuccess(result: Boolean) { - continueGetCredentials( - scope, minTtl, parameters, headers, forceRefresh, - callback - ) - } - override fun onFailure(error: CredentialsManagerException) { - callback.onFailure(error) - } + /** + * 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 additional headers to send in the request to refresh expired credentials. + */ + override fun getApiCredentials( + audience: String, + scope: String?, + minTtl: Int, + parameters: Map, + headers: Map, + callback: Callback + ) { + + if (fragmentActivity != null && localAuthenticationOptions != null && localAuthenticationManagerFactory != null) { + + fragmentActivity.get()?.let { fragmentActivity -> + startBiometricAuthentication( + fragmentActivity, + biometricAuthenticationApiCredentialsCallback( + audience, scope, minTtl, parameters, headers, callback + ) + ) + } ?: run { + callback.onFailure(CredentialsManagerException.BIOMETRIC_ERROR_NO_ACTIVITY) } + return } + continueGetApiCredentials(audience, scope, minTtl, parameters, headers, callback) + } + + /** * Delete the stored credentials */ @@ -537,6 +630,13 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT Log.d(TAG, "Credentials were just removed from the storage") } + /** + * Removes the credentials for the given audience from the storage if present. + */ + override fun clearApiCredentials(audience: String) { + storage.remove(audience) + } + /** * Returns whether this manager contains a valid non-expired pair of credentials. * @@ -713,6 +813,129 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT } } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun continueGetApiCredentials( + audience: String, + scope: String?, + minTtl: Int = 0, + parameters: Map, + headers: Map, + callback: Callback + ) { + serialExecutor.execute { + val encryptedEncodedJson = storage.retrieveString(audience) + //Check if existing api credentials are present and valid + encryptedEncodedJson?.let { encryptedEncoded -> + val encrypted = Base64.decode(encryptedEncoded, Base64.DEFAULT) + val json: String + try { + json = String(crypto.decrypt(encrypted)) + } catch (e: IncompatibleDeviceException) { + callback.onFailure( + CredentialsManagerException( + CredentialsManagerException.Code.INCOMPATIBLE_DEVICE, + e + ) + ) + return@execute + } catch (e: CryptoException) { + //If keys were invalidated, existing credentials will not be recoverable. + clearApiCredentials(audience) + callback.onFailure( + CredentialsManagerException( + CredentialsManagerException.Code.CRYPTO_EXCEPTION, + e + ) + ) + return@execute + } + + val apiCredentials = gson.fromJson(json, APICredentials::class.java) + + val expiresAt = apiCredentials.expiresAt.time + val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) + val scopeChanged = hasScopeChanged(apiCredentials.scope, scope) + val hasExpired = hasExpired(apiCredentials.expiresAt.time) + if (!hasExpired && !willAccessTokenExpire && !scopeChanged) { + callback.onSuccess(apiCredentials) + return@execute + } + } + + //Check if refresh token exists or not + lateinit var existingCredentials: Credentials + try { + existingCredentials = getExistingCredentials() + } catch (exception: CredentialsManagerException) { + callback.onFailure(exception) + return@execute + } + val refreshToken = existingCredentials.refreshToken + 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 + 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() + saveCredentials( + existingCredentials.copy( + refreshToken = updatedRefreshToken, + idToken = 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 + ) + ) + } + + } + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun clearFragmentActivity() { + fragmentActivity!!.clear() + } + /** * Helper method to fetch existing credentials from the storage. * This method is not thread safe @@ -751,11 +974,61 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT return existingCredentials } - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal fun clearFragmentActivity() { - fragmentActivity!!.clear() + /** + * Helper method to start the biometric authentication for accessing the credentials + */ + private fun startBiometricAuthentication( + fragmentActivity: FragmentActivity, + biometricResultCallback: Callback + ) { + val localAuthenticationManager = localAuthenticationManagerFactory?.create( + activity = fragmentActivity, + authenticationOptions = localAuthenticationOptions!!, + resultCallback = biometricResultCallback, + ) + localAuthenticationManager?.authenticate() } + /** + * Biometric authentication callback for authentication credentials + */ + private val biometricAuthenticationCredentialsCallback = + { scope: String?, minTtl: Int, parameters: Map, headers: Map, + forceRefresh: Boolean, callback: Callback -> + object : Callback { + override fun onSuccess(result: Boolean) { + continueGetCredentials( + scope, minTtl, parameters, headers, forceRefresh, + callback + ) + } + + override fun onFailure(error: CredentialsManagerException) { + callback.onFailure(error) + } + } + } + + /** + * Biometric authentication callback for authentication credentials + */ + private val biometricAuthenticationApiCredentialsCallback = + { audience: String, scope: String?, minTtl: Int, parameters: Map, headers: Map, + callback: Callback -> + object : Callback { + override fun onSuccess(result: Boolean) { + continueGetApiCredentials( + audience, scope, minTtl, parameters, headers, + callback + ) + } + + override fun onFailure(error: CredentialsManagerException) { + callback.onFailure(error) + } + } + } + internal companion object { private val TAG = SecureCredentialsManager::class.java.simpleName From d3db6637224fd31290aec9ce7402e352a5046a4e Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 19 Mar 2025 15:56:45 +0530 Subject: [PATCH 4/6] Added UTs for the CredentialManager class --- .../storage/CredentialsManagerTest.kt | 341 +++++++++++++++++- .../android/result/ApiCredentialsMock.kt | 20 + 2 files changed, 358 insertions(+), 3 deletions(-) create mode 100644 auth0/src/test/java/com/auth0/android/result/ApiCredentialsMock.kt diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt index 44b1353c..f3fc9abd 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt @@ -5,12 +5,17 @@ import com.auth0.android.authentication.AuthenticationAPIClient import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.Callback import com.auth0.android.request.Request +import com.auth0.android.request.internal.GsonProvider import com.auth0.android.request.internal.Jwt +import com.auth0.android.result.APICredentials +import com.auth0.android.result.ApiCredentialsMock import com.auth0.android.result.Credentials import com.auth0.android.result.CredentialsMock import com.auth0.android.result.SSOCredentials import com.auth0.android.result.SsoCredentialsMock +import com.auth0.android.result.toAPICredentials import com.auth0.android.util.Clock +import com.google.gson.Gson import com.nhaarman.mockitokotlin2.KArgumentCaptor import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.argumentCaptor @@ -62,6 +67,9 @@ public class CredentialsManagerTest { @Mock private lateinit var ssoCallback: Callback + @Mock + private lateinit var apiCallback: Callback + @Mock private lateinit var jwtDecoder: JWTDecoder @@ -73,9 +81,12 @@ public class CredentialsManagerTest { private val ssoCredentialsCaptor: KArgumentCaptor = argumentCaptor() + private val apiCredentialsCaptor: KArgumentCaptor = argumentCaptor() + @get:Rule public var exception: ExpectedException = ExpectedException.none() private lateinit var manager: CredentialsManager + private lateinit var gson: Gson @Before public fun setUp() { @@ -100,6 +111,7 @@ public class CredentialsManagerTest { any(), ArgumentMatchers.anyString() ) + gson = GsonProvider.gson } @Test @@ -196,6 +208,22 @@ public class CredentialsManagerTest { verifyNoMoreInteractions(storage) } + @Test + public fun shouldSaveApiCredentialsInStorage() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val apiCredentials = APICredentials( + accessToken = "apiAccessToken", + type = "type", + expiresAt = Date(expirationTime), + scope = "read:data" + ) + val captor: KArgumentCaptor = argumentCaptor() + manager.saveApiCredentials(apiCredentials, "audience") + verify(storage).store(captor.capture(), eq(gson.toJson(apiCredentials))) + Assert.assertEquals("audience", captor.firstValue) + verifyNoMoreInteractions(storage) + } + @Test public fun shouldThrowOnSetIfCredentialsDoesNotHaveIdTokenOrAccessToken() { exception.expect(CredentialsManagerException::class.java) @@ -225,7 +253,7 @@ public class CredentialsManagerTest { public fun shouldNotSaveIfTheSsoCredentialsHasNoRefreshToken() { val ssoCredentials = SsoCredentialsMock.create( "accessToken", - "issuedTokenType", "tokenType", null,60 + "issuedTokenType", "tokenType", null, 60 ) manager.saveSsoCredentials(ssoCredentials) verifyZeroInteractions(storage) @@ -236,7 +264,7 @@ public class CredentialsManagerTest { verifyNoMoreInteractions(storage) val ssoCredentials = SsoCredentialsMock.create( "accessToken", - "issuedTokenType", "tokenType", "refresh_token",60 + "issuedTokenType", "tokenType", "refresh_token", 60 ) Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")) .thenReturn("refresh_token") @@ -249,7 +277,7 @@ public class CredentialsManagerTest { verifyNoMoreInteractions(storage) val ssoCredentials = SsoCredentialsMock.create( "accessToken", - "issuedTokenType", "tokenType", "refresh_token",60 + "issuedTokenType", "tokenType", "refresh_token", 60 ) Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")) .thenReturn("refresh-token") @@ -360,6 +388,304 @@ public class CredentialsManagerTest { verify(storage).store("com.auth0.refresh_token", credentials.refreshToken) } + + @Test + public fun shouldGetExistingAPICredentialsIfAlreadyPresentAndNotExpired() { + verifyNoMoreInteractions(client) + val accessTokenExpiry = CredentialsMock.ONE_HOUR_AHEAD_MS + val apiCredentials = ApiCredentialsMock.create( + "token", "type", + Date(accessTokenExpiry), "scope" + ) + Mockito.`when`(storage.retrieveString("audience")).thenReturn(gson.toJson(apiCredentials)) + manager.getApiCredentials("audience", "scope", callback = apiCallback) + verify(apiCallback).onSuccess(apiCredentialsCaptor.capture()) + val retrievedCredentials = apiCredentialsCaptor.firstValue + MatcherAssert.assertThat(retrievedCredentials, Is.`is`(Matchers.notNullValue())) + Assert.assertEquals(retrievedCredentials.accessToken, apiCredentials.accessToken) + } + + @Test + public fun shouldThrowExceptionIfThereISNoRefreshTokenToGetNewApiToken() { + verifyNoMoreInteractions(client) + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn(null) + manager.getApiCredentials(audience = "audience", callback = apiCallback) + verify(apiCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat( + exception.message, + Is.`is`("Credentials need to be renewed but no Refresh Token is available to renew them.") + ) + } + + @Test + public fun shouldRenewApiCredentialsIfThereIsNoExistingApiCredentials() { + verifyNoMoreInteractions(client) + Mockito.`when`(storage.retrieveString("audience")).thenReturn(null) + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`( + client.renewAuth("refreshToken", "audience", "newScope") + ).thenReturn(request) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS + ONE_HOUR_SECONDS * 1000) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + + // Trigger success + val newRefresh: String? = null + val renewedCredentials = + Credentials("newId", "newAccess", "newType", newRefresh, newDate, "newScope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + manager.getApiCredentials("audience", "newScope", callback = apiCallback) + verify(apiCallback).onSuccess( + apiCredentialsCaptor.capture() + ) + + // Verify the credentials are property stored + verify(storage).store("com.auth0.id_token", renewedCredentials.idToken) + // RefreshToken should not be replaced + verify(storage).store("com.auth0.refresh_token", "refreshToken") + verify(storage).store("audience", gson.toJson(renewedCredentials.toAPICredentials())) + // Verify the returned credentials are the latest + val newAPiCredentials = apiCredentialsCaptor.firstValue + MatcherAssert.assertThat(newAPiCredentials, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(newAPiCredentials.accessToken, Is.`is`("newAccess")) + MatcherAssert.assertThat(newAPiCredentials.type, Is.`is`("newType")) + MatcherAssert.assertThat(newAPiCredentials.expiresAt, Is.`is`(newDate)) + MatcherAssert.assertThat(newAPiCredentials.scope, Is.`is`("newScope")) + } + + @Test + public fun shouldRenewApiCredentialsIfCurrentTokenHasExpired() { + verifyNoMoreInteractions(client) + val accessTokenExpiry = CredentialsMock.CURRENT_TIME_MS - 3000 + val apiCredentials = ApiCredentialsMock.create( + "token", "type", + Date(accessTokenExpiry), "scope" + ) + Mockito.`when`(storage.retrieveString("audience")).thenReturn(gson.toJson(apiCredentials)) + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`( + client.renewAuth("refreshToken", "audience", "newScope") + ).thenReturn(request) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS + ONE_HOUR_SECONDS * 1000) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + + // Trigger success + val newRefresh: String? = null + val renewedCredentials = + Credentials("newId", "newAccess", "newType", newRefresh, newDate, "newScope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + manager.getApiCredentials("audience", "newScope", callback = apiCallback) + verify(apiCallback).onSuccess( + apiCredentialsCaptor.capture() + ) + + // Verify the credentials are property stored + verify(storage).store("com.auth0.id_token", renewedCredentials.idToken) + // RefreshToken should not be replaced + verify(storage).store("com.auth0.refresh_token", "refreshToken") + verify(storage).store("audience", gson.toJson(renewedCredentials.toAPICredentials())) + // Verify the returned credentials are the latest + val newAPiCredentials = apiCredentialsCaptor.firstValue + MatcherAssert.assertThat(newAPiCredentials, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(newAPiCredentials.accessToken, Is.`is`("newAccess")) + MatcherAssert.assertThat(newAPiCredentials.type, Is.`is`("newType")) + MatcherAssert.assertThat(newAPiCredentials.expiresAt, Is.`is`(newDate)) + MatcherAssert.assertThat(newAPiCredentials.scope, Is.`is`("newScope")) + } + + @Test + public fun shouldRenewApiCredentialsIfCurrentTokenWillExpireWithInMinTtl() { + verifyNoMoreInteractions(client) + val accessTokenExpiry = CredentialsMock.CURRENT_TIME_MS - 10000 + val apiCredentials = ApiCredentialsMock.create( + "token", "type", + Date(accessTokenExpiry), "scope" + ) + Mockito.`when`(storage.retrieveString("audience")).thenReturn(gson.toJson(apiCredentials)) + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`( + client.renewAuth("refreshToken", "audience", "newScope") + ).thenReturn(request) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS + ONE_HOUR_SECONDS * 1000) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + + // Trigger success + val newRefresh: String? = null + val renewedCredentials = + Credentials("newId", "newAccess", "newType", newRefresh, newDate, "newScope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + manager.getApiCredentials("audience", "newScope", minTtl = 10, callback = apiCallback) + verify(apiCallback).onSuccess( + apiCredentialsCaptor.capture() + ) + + // Verify the credentials are property stored + verify(storage).store("com.auth0.id_token", renewedCredentials.idToken) + // RefreshToken should not be replaced + verify(storage).store("com.auth0.refresh_token", "refreshToken") + verify(storage).store("audience", gson.toJson(renewedCredentials.toAPICredentials())) + // Verify the returned credentials are the latest + val newAPiCredentials = apiCredentialsCaptor.firstValue + MatcherAssert.assertThat(newAPiCredentials, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(newAPiCredentials.accessToken, Is.`is`("newAccess")) + MatcherAssert.assertThat(newAPiCredentials.type, Is.`is`("newType")) + MatcherAssert.assertThat(newAPiCredentials.expiresAt, Is.`is`(newDate)) + MatcherAssert.assertThat(newAPiCredentials.scope, Is.`is`("newScope")) + } + + @Test + public fun shouldRenewApiCredentialsIfSavedScopeIsDifferentFromRequiredScope() { + verifyNoMoreInteractions(client) + val accessTokenExpiry = CredentialsMock.ONE_HOUR_AHEAD_MS + val apiCredentials = ApiCredentialsMock.create( + "token", "type", + Date(accessTokenExpiry), null + ) + Mockito.`when`(storage.retrieveString("audience")).thenReturn(gson.toJson(apiCredentials)) + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`( + client.renewAuth("refreshToken", "audience", "newScope") + ).thenReturn(request) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS + ONE_HOUR_SECONDS * 1000) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + + // Trigger success + val newRefresh: String? = null + val renewedCredentials = + Credentials("newId", "newAccess", "newType", newRefresh, newDate, "newScope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + manager.getApiCredentials("audience", "newScope", callback = apiCallback) + verify(apiCallback).onSuccess( + apiCredentialsCaptor.capture() + ) + + // Verify the credentials are property stored + verify(storage).store("com.auth0.id_token", renewedCredentials.idToken) + // RefreshToken should not be replaced + verify(storage).store("com.auth0.refresh_token", "refreshToken") + verify(storage).store("audience", gson.toJson(renewedCredentials.toAPICredentials())) + // Verify the returned credentials are the latest + val newAPiCredentials = apiCredentialsCaptor.firstValue + MatcherAssert.assertThat(newAPiCredentials, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(newAPiCredentials.accessToken, Is.`is`("newAccess")) + MatcherAssert.assertThat(newAPiCredentials.type, Is.`is`("newType")) + MatcherAssert.assertThat(newAPiCredentials.expiresAt, Is.`is`(newDate)) + MatcherAssert.assertThat(newAPiCredentials.scope, Is.`is`("newScope")) + } + + @Test + public fun shouldReplaceTheExistingRefreshTokenIfaNewOneIsObtainedInApiCredentials() { + verifyNoMoreInteractions(client) + Mockito.`when`(storage.retrieveString("audience")).thenReturn(null) + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`( + client.renewAuth("refreshToken", "audience", "newScope") + ).thenReturn(request) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS + ONE_HOUR_SECONDS * 1000) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + + // Trigger success + val renewedCredentials = + Credentials("newId", "newAccess", "newType", "newRefreshToken", newDate, "newScope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + manager.getApiCredentials("audience", "newScope", callback = apiCallback) + verify(apiCallback).onSuccess( + apiCredentialsCaptor.capture() + ) + + // Verify the credentials are property stored + verify(storage).store("com.auth0.id_token", renewedCredentials.idToken) + // RefreshToken should not be replaced + verify(storage).store("com.auth0.refresh_token", "newRefreshToken") + verify(storage).store("audience", gson.toJson(renewedCredentials.toAPICredentials())) + // Verify the returned credentials are the latest + val newAPiCredentials = apiCredentialsCaptor.firstValue + MatcherAssert.assertThat(newAPiCredentials, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(newAPiCredentials.accessToken, Is.`is`("newAccess")) + MatcherAssert.assertThat(newAPiCredentials.type, Is.`is`("newType")) + MatcherAssert.assertThat(newAPiCredentials.expiresAt, Is.`is`(newDate)) + MatcherAssert.assertThat(newAPiCredentials.scope, Is.`is`("newScope")) + } + + @Test + public fun shouldThrowExceptionIfTheNewAPiCredentialTokenHasLowerLifetimeThanMinTTLRequested() { + verifyNoMoreInteractions(client) + Mockito.`when`(storage.retrieveString("audience")).thenReturn(null) + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`( + client.renewAuth("refreshToken", "audience", "newScope") + ).thenReturn(request) + val newDate = Date(CredentialsMock.CURRENT_TIME_MS + 1 * 1000) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + + // Trigger success + val newRefresh: String? = null + val renewedCredentials = + Credentials("newId", "newAccess", "newType", newRefresh, newDate, "newScope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + manager.getApiCredentials("audience", "newScope", minTtl = 1, callback = apiCallback) + verify(apiCallback).onFailure( + exceptionCaptor.capture() + ) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + Assert.assertEquals( + "The lifetime of the renewed Access Token (0) is less than the minTTL requested (1). Increase the 'Token Expiration' setting of your Auth0 API in the dashboard, or request a lower minTTL.", + exception.message + ) + } + + @Test + @ExperimentalCoroutinesApi + public fun shouldAwaitNonExpiredApiCredentialsFromStorage(): Unit = runTest { + verifyNoMoreInteractions(client) + val accessTokenExpiry = CredentialsMock.ONE_HOUR_AHEAD_MS + val apiCredentials = ApiCredentialsMock.create( + "token", "type", + Date(accessTokenExpiry), "scope" + ) + Mockito.`when`(storage.retrieveString("audience")).thenReturn(gson.toJson(apiCredentials)) + val retrievedCredentials = manager.awaitApiCredentials("audience", "scope") + MatcherAssert.assertThat(retrievedCredentials, Is.`is`(Matchers.notNullValue())) + Assert.assertEquals(retrievedCredentials.accessToken, apiCredentials.accessToken) + } + + @Test + @ExperimentalCoroutinesApi + public fun shouldAwaitNewApiCredentialsIfOneIsNotStored(): Unit = runTest { + verifyNoMoreInteractions(client) + Mockito.`when`(storage.retrieveString("audience")).thenReturn(null) + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")) + .thenReturn("refresh_token") + + Mockito.`when`( + client.renewAuth("refresh_token", "audience") + ).thenReturn(request) + val newDate = Date(CredentialsMock.CURRENT_TIME_MS + 1 * 1000) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + val renewedCredentials = + Credentials("newId", "newAccess", "newType",null, newDate, "newScope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + val retrievedCredentials = manager.awaitApiCredentials("audience") + MatcherAssert.assertThat(retrievedCredentials, Is.`is`(Matchers.notNullValue())) + Assert.assertEquals(retrievedCredentials.accessToken, renewedCredentials.accessToken) + } + @Test public fun shouldFailOnGetCredentialsWhenNoAccessTokenOrIdTokenWasSaved() { verifyNoMoreInteractions(client) @@ -1125,6 +1451,15 @@ public class CredentialsManagerTest { verifyNoMoreInteractions(storage) } + @Test + public fun shouldClearApiCredentials() { + val captor = argumentCaptor() + manager.clearApiCredentials("audience") + verify(storage).remove(captor.capture()) + Assert.assertEquals("audience", captor.firstValue) + verifyNoMoreInteractions(storage) + } + @Test public fun shouldHaveCredentialsWhenTokenHasNotExpired() { val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS diff --git a/auth0/src/test/java/com/auth0/android/result/ApiCredentialsMock.kt b/auth0/src/test/java/com/auth0/android/result/ApiCredentialsMock.kt new file mode 100644 index 00000000..5886b1ef --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/result/ApiCredentialsMock.kt @@ -0,0 +1,20 @@ +package com.auth0.android.result + +import java.util.Date + +public class ApiCredentialsMock { + + public companion object { + + public fun create( + accessToken: String, + type: String, + expiresAt: Date, + scope: String?, + ): APICredentials { + return APICredentials( + accessToken, type, expiresAt, scope + ) + } + } +} \ No newline at end of file From daec7f4c2789ec40c030c6eb732db4335386969c Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 20 Mar 2025 14:56:08 +0530 Subject: [PATCH 5/6] Added UT for the secure credentials manager class --- .../storage/CredentialsManagerTest.kt | 34 +- .../storage/SecureCredentialsManagerTest.kt | 444 +++++++++++++++++- 2 files changed, 460 insertions(+), 18 deletions(-) diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt index f3fc9abd..ab48e2cf 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt @@ -68,7 +68,7 @@ public class CredentialsManagerTest { private lateinit var ssoCallback: Callback @Mock - private lateinit var apiCallback: Callback + private lateinit var apiCredentialsCallback: Callback @Mock private lateinit var jwtDecoder: JWTDecoder @@ -398,8 +398,8 @@ public class CredentialsManagerTest { Date(accessTokenExpiry), "scope" ) Mockito.`when`(storage.retrieveString("audience")).thenReturn(gson.toJson(apiCredentials)) - manager.getApiCredentials("audience", "scope", callback = apiCallback) - verify(apiCallback).onSuccess(apiCredentialsCaptor.capture()) + manager.getApiCredentials("audience", "scope", callback = apiCredentialsCallback) + verify(apiCredentialsCallback).onSuccess(apiCredentialsCaptor.capture()) val retrievedCredentials = apiCredentialsCaptor.firstValue MatcherAssert.assertThat(retrievedCredentials, Is.`is`(Matchers.notNullValue())) Assert.assertEquals(retrievedCredentials.accessToken, apiCredentials.accessToken) @@ -409,8 +409,8 @@ public class CredentialsManagerTest { public fun shouldThrowExceptionIfThereISNoRefreshTokenToGetNewApiToken() { verifyNoMoreInteractions(client) Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn(null) - manager.getApiCredentials(audience = "audience", callback = apiCallback) - verify(apiCallback).onFailure(exceptionCaptor.capture()) + manager.getApiCredentials(audience = "audience", callback = apiCredentialsCallback) + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) val exception = exceptionCaptor.firstValue MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) MatcherAssert.assertThat( @@ -437,8 +437,8 @@ public class CredentialsManagerTest { val renewedCredentials = Credentials("newId", "newAccess", "newType", newRefresh, newDate, "newScope") Mockito.`when`(request.execute()).thenReturn(renewedCredentials) - manager.getApiCredentials("audience", "newScope", callback = apiCallback) - verify(apiCallback).onSuccess( + manager.getApiCredentials("audience", "newScope", callback = apiCredentialsCallback) + verify(apiCredentialsCallback).onSuccess( apiCredentialsCaptor.capture() ) @@ -479,8 +479,8 @@ public class CredentialsManagerTest { val renewedCredentials = Credentials("newId", "newAccess", "newType", newRefresh, newDate, "newScope") Mockito.`when`(request.execute()).thenReturn(renewedCredentials) - manager.getApiCredentials("audience", "newScope", callback = apiCallback) - verify(apiCallback).onSuccess( + manager.getApiCredentials("audience", "newScope", callback = apiCredentialsCallback) + verify(apiCredentialsCallback).onSuccess( apiCredentialsCaptor.capture() ) @@ -521,8 +521,8 @@ public class CredentialsManagerTest { val renewedCredentials = Credentials("newId", "newAccess", "newType", newRefresh, newDate, "newScope") Mockito.`when`(request.execute()).thenReturn(renewedCredentials) - manager.getApiCredentials("audience", "newScope", minTtl = 10, callback = apiCallback) - verify(apiCallback).onSuccess( + manager.getApiCredentials("audience", "newScope", minTtl = 10, callback = apiCredentialsCallback) + verify(apiCredentialsCallback).onSuccess( apiCredentialsCaptor.capture() ) @@ -563,8 +563,8 @@ public class CredentialsManagerTest { val renewedCredentials = Credentials("newId", "newAccess", "newType", newRefresh, newDate, "newScope") Mockito.`when`(request.execute()).thenReturn(renewedCredentials) - manager.getApiCredentials("audience", "newScope", callback = apiCallback) - verify(apiCallback).onSuccess( + manager.getApiCredentials("audience", "newScope", callback = apiCredentialsCallback) + verify(apiCredentialsCallback).onSuccess( apiCredentialsCaptor.capture() ) @@ -599,8 +599,8 @@ public class CredentialsManagerTest { val renewedCredentials = Credentials("newId", "newAccess", "newType", "newRefreshToken", newDate, "newScope") Mockito.`when`(request.execute()).thenReturn(renewedCredentials) - manager.getApiCredentials("audience", "newScope", callback = apiCallback) - verify(apiCallback).onSuccess( + manager.getApiCredentials("audience", "newScope", callback = apiCredentialsCallback) + verify(apiCredentialsCallback).onSuccess( apiCredentialsCaptor.capture() ) @@ -636,8 +636,8 @@ public class CredentialsManagerTest { val renewedCredentials = Credentials("newId", "newAccess", "newType", newRefresh, newDate, "newScope") Mockito.`when`(request.execute()).thenReturn(renewedCredentials) - manager.getApiCredentials("audience", "newScope", minTtl = 1, callback = apiCallback) - verify(apiCallback).onFailure( + manager.getApiCredentials("audience", "newScope", minTtl = 1, callback = apiCredentialsCallback) + verify(apiCredentialsCallback).onFailure( exceptionCaptor.capture() ) val exception = exceptionCaptor.firstValue diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt index 13e011d4..6df5cfd7 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt @@ -13,6 +13,8 @@ import com.auth0.android.callback.Callback import com.auth0.android.request.Request import com.auth0.android.request.internal.GsonProvider import com.auth0.android.request.internal.Jwt +import com.auth0.android.result.APICredentials +import com.auth0.android.result.ApiCredentialsMock import com.auth0.android.result.Credentials import com.auth0.android.result.CredentialsMock import com.auth0.android.result.SSOCredentials @@ -82,6 +84,9 @@ public class SecureCredentialsManagerTest { @Mock private lateinit var ssoCredentialsRequest: Request + @Mock + private lateinit var apiCredentialsCallback: Callback + @Mock private lateinit var crypto: CryptoUtil @@ -104,6 +109,7 @@ public class SecureCredentialsManagerTest { private val exceptionCaptor: KArgumentCaptor = argumentCaptor() private val ssoCredentialsCaptor: KArgumentCaptor = argumentCaptor() + private val apiCredentialsCaptor: KArgumentCaptor = argumentCaptor() private val stringCaptor: KArgumentCaptor = argumentCaptor() @@ -545,6 +551,32 @@ public class SecureCredentialsManagerTest { ) } + @Test + public fun shouldSaveApiCredentialsInStorage() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val apiCredentials = APICredentials( + accessToken = "apiAccessToken", + type = "type", + expiresAt = Date(expirationTime), + scope = "read:data" + ) + val json = gson.toJson(apiCredentials) + prepareJwtDecoderMock(Date(expirationTime)) + Mockito.`when`(crypto.encrypt(any())).thenReturn(json.toByteArray()) + val captor: KArgumentCaptor = argumentCaptor() + manager.saveApiCredentials(apiCredentials, "audience") + verify(storage).store(eq("audience"), captor.capture()) + val encodedJson = captor.firstValue + MatcherAssert.assertThat(encodedJson, Is.`is`(Matchers.notNullValue())) + val decoded = Base64.decode(encodedJson, Base64.DEFAULT) + val storedCredentials = gson.fromJson(String(decoded), APICredentials::class.java) + Assert.assertEquals("apiAccessToken", storedCredentials.accessToken) + Assert.assertEquals("type", storedCredentials.type) + Assert.assertEquals(expirationTime, storedCredentials.expiresAt.time) + Assert.assertEquals("read:data", storedCredentials.scope) + verifyNoMoreInteractions(storage) + } + /* * SAVE Credentials tests */ @@ -1894,7 +1926,7 @@ public class SecureCredentialsManagerTest { latch.await() // Wait for all threads to finish Mockito.verify(client, Mockito.times(1)) .renewAuth( - refreshToken = "refreshToken" + refreshToken = "refreshToken" ) // verify that api client's renewAuth is called only once Mockito.verify(request, Mockito.times(1)).execute() // Verify single network request } @@ -1912,6 +1944,15 @@ public class SecureCredentialsManagerTest { verifyNoMoreInteractions(storage) } + @Test + public fun shouldClearApiCredentials() { + val captor = argumentCaptor() + manager.clearApiCredentials("audience") + verify(storage).remove(captor.capture()) + Assert.assertEquals("audience", captor.firstValue) + verifyNoMoreInteractions(storage) + } + /* * HAS Credentials tests */ @@ -2009,6 +2050,385 @@ public class SecureCredentialsManagerTest { MatcherAssert.assertThat(manager.hasValidCredentials(), Is.`is`(true)) } + //APICredentials + + @Test + public fun shouldGetExistingAPICredentialsIfAlreadyPresentAndNotExpired() { + verifyNoMoreInteractions(client) + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + val accessTokenExpiry = CredentialsMock.ONE_HOUR_AHEAD_MS + val apiCredentials = ApiCredentialsMock.create( + "token", "type", + Date(accessTokenExpiry), "scope" + ) + val storedJson = gson.toJson(apiCredentials) + val encoded = String(Base64.encode(storedJson.toByteArray(), Base64.DEFAULT)) + Mockito.`when`(crypto.decrypt(storedJson.toByteArray())) + .thenReturn(storedJson.toByteArray()) + Mockito.`when`(storage.retrieveString("audience")).thenReturn(encoded) + manager.getApiCredentials("audience", "scope", callback = apiCredentialsCallback) + verify(apiCredentialsCallback).onSuccess(apiCredentialsCaptor.capture()) + val retrievedCredentials = apiCredentialsCaptor.firstValue + MatcherAssert.assertThat(retrievedCredentials, Is.`is`(Matchers.notNullValue())) + Assert.assertEquals(retrievedCredentials.accessToken, apiCredentials.accessToken) + } + + @Test + public fun shouldThrowExceptionIfThereISNoRefreshTokenToGetNewApiToken() { + verifyNoMoreInteractions(client) + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + val expiresAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS) + insertTestCredentials( + hasIdToken = true, + hasAccessToken = true, + hasRefreshToken = false, + willExpireAt = expiresAt, + scope = "scope" + ) + manager.getApiCredentials(audience = "audience", callback = apiCredentialsCallback) + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat( + exception.message, + Is.`is`("Credentials need to be renewed but no Refresh Token is available to renew them.") + ) + } + + + @Test + public fun shouldRenewApiCredentialsIfThereIsNoExistingApiCredentials() { + verifyNoMoreInteractions(client) + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + Mockito.`when`(storage.retrieveString("audience")).thenReturn(null) + val expiresAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS) + insertTestCredentials( + hasIdToken = true, + hasAccessToken = true, + hasRefreshToken = true, + willExpireAt = expiresAt, + scope = "scope" + ) + Mockito.`when`( + client.renewAuth("refreshToken", "audience", "newScope") + ).thenReturn(request) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS + ONE_HOUR_SECONDS * 1000) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + + // Trigger success + val newRefresh: String? = null + val renewedCredentials = + Credentials("newId", "newAccess", "newType", newRefresh, newDate, "newScope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + val expectedJson = gson.toJson(renewedCredentials) + Mockito.`when`(crypto.encrypt(any())) + .thenReturn(expectedJson.toByteArray()) + manager.getApiCredentials("audience", "newScope", callback = apiCredentialsCallback) + verify(apiCredentialsCallback).onSuccess( + apiCredentialsCaptor.capture() + ) + + + // Verify the credentials are property stored + verify(storage).store(eq("com.auth0.credentials"), stringCaptor.capture()) + MatcherAssert.assertThat(stringCaptor.firstValue, Is.`is`(Matchers.notNullValue())) + // Verify the returned credentials are the latest + val newAPiCredentials = apiCredentialsCaptor.firstValue + MatcherAssert.assertThat(newAPiCredentials, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(newAPiCredentials.accessToken, Is.`is`("newAccess")) + MatcherAssert.assertThat(newAPiCredentials.type, Is.`is`("newType")) + MatcherAssert.assertThat(newAPiCredentials.expiresAt, Is.`is`(newDate)) + MatcherAssert.assertThat(newAPiCredentials.scope, Is.`is`("newScope")) + } + + @Test + public fun shouldRenewApiCredentialsIfCurrentTokenHasExpired() { + verifyNoMoreInteractions(client) + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + val accessTokenExpiry = CredentialsMock.CURRENT_TIME_MS - 3000 + insertTestCredentials( + true, + true, + true, + Date(CredentialsMock.CURRENT_TIME_MS + 10 * 1000), + "scope" + ) + insertTestApiCredentials("audience", true, Date(accessTokenExpiry), "scope") + Mockito.`when`( + client.renewAuth("refreshToken", "audience", "scope") + ).thenReturn(request) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS + ONE_HOUR_SECONDS * 1000) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + + // Trigger success + val newRefresh: String? = null + val renewedCredentials = + Credentials("newId", "newAccess", "newType", newRefresh, newDate, "newScope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + val expectedJson = gson.toJson(renewedCredentials) + Mockito.`when`(crypto.encrypt(any())) + .thenReturn(expectedJson.toByteArray()) + manager.getApiCredentials("audience", "scope", callback = apiCredentialsCallback) + verify(apiCredentialsCallback).onSuccess( + apiCredentialsCaptor.capture() + ) + + // Verify the returned credentials are the latest + val newAPiCredentials = apiCredentialsCaptor.firstValue + MatcherAssert.assertThat(newAPiCredentials, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(newAPiCredentials.accessToken, Is.`is`("newAccess")) + MatcherAssert.assertThat(newAPiCredentials.type, Is.`is`("newType")) + MatcherAssert.assertThat(newAPiCredentials.expiresAt, Is.`is`(newDate)) + MatcherAssert.assertThat(newAPiCredentials.scope, Is.`is`("newScope")) + } + + @Test + public fun shouldRenewApiCredentialsIfCurrentTokenWillExpireWithInMinTtl() { + verifyNoMoreInteractions(client) + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + val accessTokenExpiry = CredentialsMock.CURRENT_TIME_MS - 10000 + insertTestCredentials( + true, + true, + true, + Date(CredentialsMock.CURRENT_TIME_MS + 10 * 1000), + "scope" + ) + insertTestApiCredentials("audience", true, Date(accessTokenExpiry), "scope") + Mockito.`when`( + client.renewAuth("refreshToken", "audience", "scope") + ).thenReturn(request) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS + ONE_HOUR_SECONDS * 1000) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + + // Trigger success + val newRefresh: String? = null + val renewedCredentials = + Credentials("newId", "newAccess", "newType", newRefresh, newDate, "newScope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + val expectedJson = gson.toJson(renewedCredentials) + Mockito.`when`(crypto.encrypt(any())) + .thenReturn(expectedJson.toByteArray()) + manager.getApiCredentials( + "audience", + "scope", + minTtl = 10, + callback = apiCredentialsCallback + ) + verify(apiCredentialsCallback).onSuccess( + apiCredentialsCaptor.capture() + ) + + // Verify the returned credentials are the latest + val newAPiCredentials = apiCredentialsCaptor.firstValue + MatcherAssert.assertThat(newAPiCredentials, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(newAPiCredentials.accessToken, Is.`is`("newAccess")) + MatcherAssert.assertThat(newAPiCredentials.type, Is.`is`("newType")) + MatcherAssert.assertThat(newAPiCredentials.expiresAt, Is.`is`(newDate)) + MatcherAssert.assertThat(newAPiCredentials.scope, Is.`is`("newScope")) + } + + @Test + public fun shouldRenewApiCredentialsIfSavedScopeIsDifferentFromRequiredScope() { + verifyNoMoreInteractions(client) + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + val accessTokenExpiry = CredentialsMock.CURRENT_TIME_MS + insertTestCredentials( + true, + true, + true, + Date(CredentialsMock.CURRENT_TIME_MS + 10 * 1000), + "scope" + ) + insertTestApiCredentials("audience", true, Date(accessTokenExpiry), null) + Mockito.`when`( + client.renewAuth("refreshToken", "audience", "newScope") + ).thenReturn(request) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS + ONE_HOUR_SECONDS * 1000) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + + // Trigger success + val newRefresh: String? = null + val renewedCredentials = + Credentials("newId", "newAccess", "newType", newRefresh, newDate, "newScope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + val expectedJson = gson.toJson(renewedCredentials) + Mockito.`when`(crypto.encrypt(any())) + .thenReturn(expectedJson.toByteArray()) + manager.getApiCredentials("audience", "newScope", callback = apiCredentialsCallback) + verify(apiCredentialsCallback).onSuccess( + apiCredentialsCaptor.capture() + ) + + // Verify the returned credentials are the latest + val newAPiCredentials = apiCredentialsCaptor.firstValue + MatcherAssert.assertThat(newAPiCredentials, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(newAPiCredentials.accessToken, Is.`is`("newAccess")) + MatcherAssert.assertThat(newAPiCredentials.type, Is.`is`("newType")) + MatcherAssert.assertThat(newAPiCredentials.expiresAt, Is.`is`(newDate)) + MatcherAssert.assertThat(newAPiCredentials.scope, Is.`is`("newScope")) + } + + @Test + public fun shouldReplaceTheExistingRefreshTokenIfaNewOneIsObtainedInApiCredentials() { + verifyNoMoreInteractions(client) + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + val accessTokenExpiry = CredentialsMock.CURRENT_TIME_MS + insertTestCredentials( + true, + true, + true, + Date(CredentialsMock.CURRENT_TIME_MS + 10 * 1000), + "scope" + ) + insertTestApiCredentials("audience", true, Date(accessTokenExpiry), null) + Mockito.`when`( + client.renewAuth("refreshToken", "audience", "newScope") + ).thenReturn(request) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS + ONE_HOUR_SECONDS * 1000) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + + // Trigger success + val renewedCredentials = + Credentials("newId", "newAccess", "newType", "newRefreshToken", newDate, "newScope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + val expectedJson = gson.toJson(renewedCredentials) + Mockito.`when`(crypto.encrypt(any())) + .thenReturn(expectedJson.toByteArray()) + manager.getApiCredentials("audience", "newScope", callback = apiCredentialsCallback) + verify(apiCredentialsCallback).onSuccess( + apiCredentialsCaptor.capture() + ) + + // Verify the returned credentials are the latest + val newAPiCredentials = apiCredentialsCaptor.firstValue + verify(storage).store(eq("com.auth0.credentials"), stringCaptor.capture()) + val encodedJson = stringCaptor.firstValue + val decoded = Base64.decode(encodedJson, Base64.DEFAULT) + val newCredentials = gson.fromJson(String(decoded), Credentials::class.java) + Assert.assertEquals("newRefreshToken", newCredentials.refreshToken) + MatcherAssert.assertThat(newAPiCredentials, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(newAPiCredentials.accessToken, Is.`is`("newAccess")) + MatcherAssert.assertThat(newAPiCredentials.type, Is.`is`("newType")) + MatcherAssert.assertThat(newAPiCredentials.expiresAt, Is.`is`(newDate)) + MatcherAssert.assertThat(newAPiCredentials.scope, Is.`is`("newScope")) + } + + @Test + public fun shouldThrowExceptionIfTheNewAPiCredentialTokenHasLowerLifetimeThanMinTTLRequested() { + verifyNoMoreInteractions(client) + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + insertTestCredentials( + true, + true, + true, + Date(CredentialsMock.CURRENT_TIME_MS + 10 * 1000), + "scope" + ) + Mockito.`when`(storage.retrieveString("audience")).thenReturn(null) + Mockito.`when`( + client.renewAuth("refreshToken", "audience", "newScope") + ).thenReturn(request) + val newDate = Date(CredentialsMock.CURRENT_TIME_MS + 1 * 1000) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + + // Trigger success + val renewedCredentials = + Credentials("newId", "newAccess", "newType", "newRefreshToken", newDate, "newScope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + val expectedJson = gson.toJson(renewedCredentials) + Mockito.`when`(crypto.encrypt(any())) + .thenReturn(expectedJson.toByteArray()) + manager.getApiCredentials( + "audience", + "newScope", + minTtl = 1, + callback = apiCredentialsCallback + ) + verify(apiCredentialsCallback).onFailure( + exceptionCaptor.capture() + ) + + // Verify the returned credentials are the latest + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + } + + @Test + @ExperimentalCoroutinesApi + public fun shouldAwaitNonExpiredApiCredentialsFromStorage(): Unit = runTest { + verifyNoMoreInteractions(client) + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + val accessTokenExpiry = CredentialsMock.ONE_HOUR_AHEAD_MS + insertTestApiCredentials( + "audience1", true, Date(accessTokenExpiry), null + ) + val retrievedCredentials = manager.awaitApiCredentials("audience1") + MatcherAssert.assertThat(retrievedCredentials, Is.`is`(Matchers.notNullValue())) + Assert.assertEquals("accessToken", retrievedCredentials.accessToken) + } + + @Test + @ExperimentalCoroutinesApi + public fun shouldAwaitNewApiCredentialsIfOneIsNotStored(): Unit = runTest { + verifyNoMoreInteractions(client) + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + insertTestCredentials( + true, true, true, Date(CredentialsMock.ONE_HOUR_AHEAD_MS), null + ) + + Mockito.`when`(storage.retrieveString("audience")).thenReturn(null) + + Mockito.`when`( + client.renewAuth("refreshToken", "audience") + ).thenReturn(request) + val newDate = Date(CredentialsMock.CURRENT_TIME_MS + 1 * 1000) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + val renewedCredentials = + Credentials("newId", "newAccess", "newType", null, newDate, "newScope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + val expectedJson = gson.toJson(renewedCredentials) + Mockito.`when`(crypto.encrypt(any())) + .thenReturn(expectedJson.toByteArray()) + val retrievedCredentials = manager.awaitApiCredentials("audience") + MatcherAssert.assertThat(retrievedCredentials, Is.`is`(Matchers.notNullValue())) + Assert.assertEquals(retrievedCredentials.accessToken, renewedCredentials.accessToken) + } + /* * Authentication tests */ @@ -2680,6 +3100,28 @@ public class SecureCredentialsManagerTest { return storedJson } + + private fun insertTestApiCredentials( + audience: String, + hasAccessToken: Boolean, + willExpireAt: Date, + scope: String? + ): String { + val storedCredentials = ApiCredentialsMock.create( + if (hasAccessToken) "accessToken" else "", + "type", + willExpireAt, + scope + ) + val storedJson = gson.toJson(storedCredentials) + val encoded = String(Base64.encode(storedJson.toByteArray(), Base64.DEFAULT)) + Mockito.`when`(crypto.decrypt(storedJson.toByteArray())) + .thenReturn(storedJson.toByteArray()) + Mockito.`when`(storage.retrieveString(audience)).thenReturn(encoded) + return storedJson + } + + private fun prepareJwtDecoderMock(expiresAt: Date?) { val jwtMock = mock() Mockito.`when`(jwtMock.expiresAt).thenReturn(expiresAt) From 6fd83640abe0b324afebccf8366373352b1b650f Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 20 Mar 2025 15:46:45 +0530 Subject: [PATCH 6/6] Fixing the failed test --- .../authentication/storage/SecureCredentialsManagerTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt index 6df5cfd7..f70d7b1c 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt @@ -2414,7 +2414,7 @@ public class SecureCredentialsManagerTest { Mockito.`when`( client.renewAuth("refreshToken", "audience") ).thenReturn(request) - val newDate = Date(CredentialsMock.CURRENT_TIME_MS + 1 * 1000) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS) val jwtMock = mock() Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock)