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 67e9fefb..148ab3cf 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) { } @@ -749,13 +750,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) 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 d82ed818..daf4cd00 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 getCredentials(callback: Callback) public abstract fun getSsoCredentials( parameters: Map, @@ -70,6 +72,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(parameters: Map) @@ -115,7 +126,18 @@ public abstract class BaseCredentialsManager internal constructor( forceRefresh: Boolean ): Credentials + @JvmSynthetic + @Throws(CredentialsManagerException::class) + public abstract suspend fun awaitApiCredentials( + audience: String, + scope: String? = null, + minTtl: Int = 0, + parameters: Map = 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 06b182b0..78218a97 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 @@ -6,8 +6,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 @@ -24,6 +28,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. * @@ -55,6 +62,17 @@ 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) + } + } + /** * Creates a new request to exchange a refresh token for a session transfer token that can be used to perform web single sign-on. * @@ -305,6 +323,45 @@ 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, @@ -496,6 +553,99 @@ 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() + storage.store(KEY_REFRESH_TOKEN, updatedRefreshToken) + storage.store(KEY_ID_TOKEN, newCredentials.idToken) + saveApiCredentials(newApiCredentials, audience) + callback.onSuccess(newApiCredentials) + } catch (error: AuthenticationException) { + val exception = when { + error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED + + error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK + else -> CredentialsManagerException.Code.API_ERROR + } + callback.onFailure( + CredentialsManagerException( + exception, error + ) + ) + } + } + + } + /** * Checks if a non-expired pair of credentials can be obtained from this manager. * @@ -536,6 +686,13 @@ 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) + } + /** * Helper method to store the given [SSOCredentials] refresh token in the storage. * Method will silently return if the passed credentials have no refresh token. 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 f675e761..788845e5 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 * @@ -125,6 +126,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 + ) + } + } + /** * Creates a new request to exchange a refresh token for a session transfer token that can be used to perform web single sign-on. * @@ -168,10 +199,10 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT callback: Callback ) { serialExecutor.execute { - lateinit var existingCredentials: Credentials - try { - existingCredentials = getExistingCredentials() + val existingCredentials: Credentials = try { + getExistingCredentials() } catch (exception: CredentialsManagerException) { + Log.e(TAG, "Error while fetching existing credentials", exception) callback.onFailure(exception) return@execute } @@ -181,7 +212,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT } val request = - authenticationClient.ssoExchange(existingCredentials.refreshToken!!) + authenticationClient.ssoExchange(existingCredentials.refreshToken) try { if (parameters.isNotEmpty()) { request.addParameters(parameters) @@ -406,6 +437,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 @@ -520,15 +593,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( + startBiometricAuthentication( + fragmentActivity, + biometricAuthenticationCredentialsCallback( scope, minTtl, parameters, headers, forceRefresh, callback ) ) - localAuthenticationManager.authenticate() } ?: run { callback.onFailure(CredentialsManagerException.BIOMETRIC_ERROR_NO_ACTIVITY) } @@ -553,6 +625,47 @@ 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. + * + * @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 */ @@ -564,6 +677,14 @@ 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) + Log.d(TAG, "API Credentials for $audience were just removed from the storage") + } + /** * Returns whether this manager contains a valid non-expired pair of credentials. * @@ -747,6 +868,127 @@ 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 { + 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 + val existingCredentials: Credentials = try { + 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 @@ -782,11 +1024,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 API 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) + } + } + } + /** * Helper method to stores the given [ssoCredentials] refresh token in the storage. * Method will silently return if the passed credentials have no refresh token. 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 ffc78cf6..f9589337 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -2533,6 +2533,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/CredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt index 5105c314..d610ef77 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 apiCredentialsCallback: 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) @@ -395,6 +423,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 = 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`(storage.retrieveString("com.auth0.refresh_token")).thenReturn(null) + 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`(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 = apiCredentialsCallback) + verify(apiCredentialsCallback).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 = apiCredentialsCallback) + verify(apiCredentialsCallback).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 = apiCredentialsCallback) + verify(apiCredentialsCallback).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 = apiCredentialsCallback) + verify(apiCredentialsCallback).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 = apiCredentialsCallback) + verify(apiCredentialsCallback).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 = apiCredentialsCallback) + verify(apiCredentialsCallback).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) @@ -1200,6 +1526,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/authentication/storage/SecureCredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt index 19ad357b..21c3034c 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 @@ -81,6 +83,9 @@ public class SecureCredentialsManagerTest { @Mock private lateinit var SSOCredentialsRequest: Request + @Mock + private lateinit var apiCredentialsCallback: Callback + @Mock private lateinit var crypto: CryptoUtil @@ -102,7 +107,8 @@ public class SecureCredentialsManagerTest { private val credentialsCaptor: KArgumentCaptor = argumentCaptor() private val exceptionCaptor: KArgumentCaptor = argumentCaptor() - private val SSOCredentialsCaptor: KArgumentCaptor = argumentCaptor() + private val ssoCredentialsCaptor: KArgumentCaptor = argumentCaptor() + private val apiCredentialsCaptor: KArgumentCaptor = argumentCaptor() private val stringCaptor: KArgumentCaptor = argumentCaptor() @@ -329,9 +335,9 @@ public class SecureCredentialsManagerTest { Mockito.`when`(crypto.encrypt(any())).thenReturn(json.toByteArray()) manager.getSsoCredentials(ssoCallback) verify(ssoCallback).onSuccess( - SSOCredentialsCaptor.capture() + ssoCredentialsCaptor.capture() ) - val credentials = SSOCredentialsCaptor.firstValue + val credentials = ssoCredentialsCaptor.firstValue MatcherAssert.assertThat(credentials.sessionTransferToken, Is.`is`(Matchers.notNullValue())) MatcherAssert.assertThat(credentials.sessionTransferToken, Is.`is`("web-sso-token")) MatcherAssert.assertThat(credentials.tokenType, Is.`is`("token-type")) @@ -556,6 +562,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 */ @@ -1870,7 +1902,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() @@ -1941,7 +1973,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 } @@ -1958,6 +1992,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 */ @@ -2055,6 +2098,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.ONE_HOUR_AHEAD_MS) + 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 */ @@ -2726,6 +3148,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) 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