From 4c6abff5234177b599ef299e44b346f44376c158 Mon Sep 17 00:00:00 2001 From: Mike Balfour <82224783+mbalfour-amzn@users.noreply.github.com> Date: Thu, 31 Oct 2024 16:17:18 -0400 Subject: [PATCH] Backed out API implementation changes (#15) * Backed out API implementation changes. A lot of the internals of the implementation are relied on by the Tracking SDK, so they can't be removed at this time. * Moved context to last parameter. This will make it easier to deprecate the parameter in the future. * Fixed another context parameter and README --- README.md | 10 +- gradle/libs.versions.toml | 2 +- library/build.gradle.kts | 2 +- .../auth/ApiKeyCredentialsProvider.kt | 59 +++ .../amazon/location/auth/AuthHelper.kt | 37 +- .../location/auth/AwsSignerInterceptor.kt | 48 +- .../auth/CognitoCredentialsProvider.kt | 169 +++---- .../auth/EncryptedSharedPreferences.kt | 101 ++++ .../auth/LocationCredentialsProvider.kt | 447 ++++++++++++++---- .../amazon/location/auth/utils/Constants.kt | 3 + .../auth/ApiKeyCredentialsProviderTest.kt | 81 ++++ .../amazon/location/auth/AuthHelperTest.kt | 64 ++- .../location/auth/AwsSignerInterceptorTest.kt | 23 +- .../auth/CognitoCredentialsProviderTest.kt | 233 ++++----- .../auth/CustomCredentialsProviderTest.kt | 80 ++++ .../auth/LocationCredentialsProviderTest.kt | 297 +++++++++--- .../amazon/location/auth/utils/Constants.kt | 9 + 17 files changed, 1270 insertions(+), 395 deletions(-) create mode 100644 library/src/main/java/software/amazon/location/auth/ApiKeyCredentialsProvider.kt create mode 100644 library/src/main/java/software/amazon/location/auth/EncryptedSharedPreferences.kt create mode 100644 library/src/test/java/software/amazon/location/auth/ApiKeyCredentialsProviderTest.kt create mode 100644 library/src/test/java/software/amazon/location/auth/CustomCredentialsProviderTest.kt diff --git a/README.md b/README.md index 1f4360b..0bde921 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ You can create an AuthHelper and use it with the AWS Kotlin SDK: ``` // Create a credential provider using Identity Pool Id with AuthHelper private suspend fun exampleCognitoLogin() { - val authHelper = AuthHelper.withCognitoIdentityPool("MY-COGNITO-IDENTITY-POOL-ID") + val authHelper = AuthHelper.withCognitoIdentityPool("MY-COGNITO-IDENTITY-POOL-ID", applicationContext) // Get instances of the standalone clients: var geoMapsClient = GeoMapsClient(authHelper?.getGeoMapsClientConfig()) @@ -68,7 +68,7 @@ OR // Create a credential provider using custom credential provider with AuthHelper private suspend fun exampleCustomCredentialLogin() { - var authHelper = AuthHelper.withCredentialsProvider(MY-CUSTOM-CREDENTIAL-PROVIDER, "MY-AWS-REGION") + var authHelper = AuthHelper.withCredentialsProvider(MY-CUSTOM-CREDENTIAL-PROVIDER, "MY-AWS-REGION", applicationContext) // Get instances of the standalone clients: var geoMapsClient = GeoMapsClient(authHelper?.getGeoMapsClientConfig()) @@ -82,7 +82,7 @@ OR // Create a credential provider using Api key with AuthHelper private suspend fun exampleApiKeyLogin() { - var authHelper = AuthHelper.withApiKey("MY-API-KEY", "MY-AWS-REGION") + var authHelper = AuthHelper.withApiKey("MY-API-KEY", "MY-AWS-REGION", applicationContext) // Get instances of the standalone clients: var geoMapsClient = GeoMapsClient(authHelper?.getGeoMapsClientConfig()) @@ -100,10 +100,10 @@ HttpRequestUtil.setOkHttpClient( OkHttpClient.Builder() .addInterceptor( AwsSignerInterceptor( - applicationContext, "geo", "MY-AWS-REGION", - locationCredentialsProvider + locationCredentialsProvider, + applicationContext ) ) .build() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index af912f7..b9dda9a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ androidx-junit = "1.2.1" appcompat = "1.7.0" commons-math3 = "3.6.1" -core-ktx = "1.15.0" +core-ktx = "1.13.1" espresso-core = "3.6.1" guava = "32.1.3-jre" junit = "4.13.2" diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 1964fdf..0e4bb70 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -20,7 +20,7 @@ mavenPublishing { publishToMavenCentral(SonatypeHost.DEFAULT, automaticRelease = true) signAllPublications() - coordinates("software.amazon.location", "auth", "1.0.0") + coordinates("software.amazon.location", "auth", "1.1.0") pom { name.set("Amazon Location Service Mobile Authentication SDK for Android") diff --git a/library/src/main/java/software/amazon/location/auth/ApiKeyCredentialsProvider.kt b/library/src/main/java/software/amazon/location/auth/ApiKeyCredentialsProvider.kt new file mode 100644 index 0000000..387dac2 --- /dev/null +++ b/library/src/main/java/software/amazon/location/auth/ApiKeyCredentialsProvider.kt @@ -0,0 +1,59 @@ +package software.amazon.location.auth + +import android.content.Context +import software.amazon.location.auth.utils.Constants.API_KEY + +/** + * Provides API key credentials for accessing services and manages their storage. + */ +class ApiKeyCredentialsProvider { + private var securePreferences: EncryptedSharedPreferences? = null + + /** + * Initializes the provider and saves the provided API key. + * @param context The application context. + * @param apiKey The API key to save. + */ + constructor(context: Context, apiKey: String) { + initialize(context) + saveCredentials(apiKey) + } + + /** + * Initializes the provider and retrieves the API key from the cache. + * @param context The application context. + * @throws Exception If no credentials are found in the cache. + */ + constructor(context: Context) { + initialize(context) + val apiKey = getCachedCredentials() + if (apiKey === null) throw Exception("No credentials found") + } + + private fun initialize(context: Context) { + securePreferences = EncryptedSharedPreferences(context, PREFS_NAME) + securePreferences?.initEncryptedSharedPreferences() + } + + private fun saveCredentials(apiKey: String) { + if (securePreferences === null) throw Exception("Not initialized") + securePreferences!!.put(API_KEY, apiKey) + } + + /** + * Retrieves the cached API key credentials. + * @return The API key or null if not found. + * @throws Exception If the AWSKeyValueStore is not initialized. + */ + fun getCachedCredentials(): String? { + if (securePreferences === null) throw Exception("Not initialized") + return securePreferences!!.get(API_KEY) + } + + /** + * Clears the stored credentials. + */ + fun clearCredentials() { + securePreferences?.remove(API_KEY) + } +} \ No newline at end of file diff --git a/library/src/main/java/software/amazon/location/auth/AuthHelper.kt b/library/src/main/java/software/amazon/location/auth/AuthHelper.kt index 3b9f2a6..e0bf6c2 100644 --- a/library/src/main/java/software/amazon/location/auth/AuthHelper.kt +++ b/library/src/main/java/software/amazon/location/auth/AuthHelper.kt @@ -3,6 +3,7 @@ package software.amazon.location.auth +import android.content.Context import aws.smithy.kotlin.runtime.auth.awscredentials.CredentialsProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -20,13 +21,16 @@ object AuthHelper { */ suspend fun withCognitoIdentityPool( identityPoolId: String, + context: Context, ): LocationCredentialsProvider { return withContext(Dispatchers.IO) { val locationCredentialsProvider = LocationCredentialsProvider( + context, identityPoolId, // Get the region from the identity pool id AwsRegions.fromName(identityPoolId.split(":")[0]), ) + locationCredentialsProvider.verifyAndRefreshCredentials() locationCredentialsProvider // Return the generated locationCredentialsProvider } } @@ -40,12 +44,37 @@ object AuthHelper { suspend fun withCognitoIdentityPool( identityPoolId: String, region: String, + context: Context, ): LocationCredentialsProvider { return withContext(Dispatchers.IO) { val locationCredentialsProvider = LocationCredentialsProvider( + context, identityPoolId, AwsRegions.fromName(region), ) + locationCredentialsProvider.verifyAndRefreshCredentials() + locationCredentialsProvider // Return the generated locationCredentialsProvider + } + } + + /** + * Authenticates using a Cognito Identity Pool ID and a specified region. + * @param identityPoolId The identity pool id. + * @param region The AWS region as a Regions enum. + * @return A LocationCredentialsProvider object. + */ + suspend fun withCognitoIdentityPool( + identityPoolId: String, + region: AwsRegions, + context: Context, + ): LocationCredentialsProvider { + return withContext(Dispatchers.IO) { + val locationCredentialsProvider = LocationCredentialsProvider( + context, + identityPoolId, + region, + ) + locationCredentialsProvider.verifyAndRefreshCredentials() locationCredentialsProvider // Return the generated locationCredentialsProvider } } @@ -85,12 +114,14 @@ object AuthHelper { suspend fun withCredentialsProvider( credentialsProvider: CredentialsProvider, region: String, + context: Context, ): LocationCredentialsProvider { return withContext(Dispatchers.IO) { val locationCredentialsProvider = LocationCredentialsProvider( - credentialsProvider, + context, AwsRegions.fromName(region), ) + locationCredentialsProvider.initializeLocationClient(credentialsProvider) locationCredentialsProvider } } @@ -101,12 +132,14 @@ object AuthHelper { * @param region The AWS region as a string. * @return A LocationCredentialsProvider instance. */ - suspend fun withApiKey(apiKey: String, region: String): LocationCredentialsProvider { + suspend fun withApiKey(apiKey: String, region: String, context: Context): LocationCredentialsProvider { return withContext(Dispatchers.IO) { val locationCredentialsProvider = LocationCredentialsProvider( + context, AwsRegions.fromName(region), apiKey, ) + locationCredentialsProvider.initializeLocationClient() locationCredentialsProvider } } diff --git a/library/src/main/java/software/amazon/location/auth/AwsSignerInterceptor.kt b/library/src/main/java/software/amazon/location/auth/AwsSignerInterceptor.kt index 0fa7d25..e52afe2 100644 --- a/library/src/main/java/software/amazon/location/auth/AwsSignerInterceptor.kt +++ b/library/src/main/java/software/amazon/location/auth/AwsSignerInterceptor.kt @@ -3,7 +3,8 @@ package software.amazon.location.auth -import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials + +import android.content.Context import java.net.URL import java.nio.charset.StandardCharsets import java.security.MessageDigest @@ -16,10 +17,12 @@ import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Response import software.amazon.location.auth.utils.Constants +import software.amazon.location.auth.utils.Constants.API_KEY import software.amazon.location.auth.utils.Constants.HEADER_HOST import software.amazon.location.auth.utils.Constants.HEADER_X_AMZ_CONTENT_SHA256 import software.amazon.location.auth.utils.Constants.HEADER_X_AMZ_DATE import software.amazon.location.auth.utils.Constants.HEADER_X_AMZ_SECURITY_TOKEN +import software.amazon.location.auth.utils.Constants.METHOD import software.amazon.location.auth.utils.Constants.QUERY_PARAM_KEY import software.amazon.location.auth.utils.Constants.TIME_PATTERN import software.amazon.location.auth.utils.HASHING_ALGORITHM @@ -28,34 +31,30 @@ import software.amazon.location.auth.utils.awsAuthorizationHeader class AwsSignerInterceptor( private val serviceName: String, private val region: String, - private val credentialsProvider: LocationCredentialsProvider? + private val credentialsProvider: LocationCredentialsProvider?, + private val context: Context, ) : Interceptor { private val sdfMap = HashMap() + private var securePreferences: EncryptedSharedPreferences?= null override fun intercept(chain: Interceptor.Chain): Response { val originalRequest = chain.request() - val credentials: Credentials? - runBlocking { - credentials = credentialsProvider?.getCredentials() - } - if (!originalRequest.url.host.contains("amazonaws.com") || credentials == null) { + if (!originalRequest.url.host.contains("amazonaws.com") || credentialsProvider?.getCredentialsProvider() == null) { return chain.proceed(originalRequest) } - val method = credentialsProvider?.getMethod() + if (securePreferences == null){ + securePreferences = initPreference(context) + } + val method = securePreferences?.get(METHOD) if (method === null) throw Exception("No credentials found") if (method == "apiKey") { val originalHttpUrl = originalRequest.url val hasKey = originalHttpUrl.queryParameter(QUERY_PARAM_KEY) != null val newHttpUrl = if (!hasKey) { - val apiKey = credentialsProvider?.getApiKey() - if (!apiKey.isNullOrEmpty()) { - originalHttpUrl.newBuilder() - .addQueryParameter(QUERY_PARAM_KEY, apiKey) - .build() - } - else { - originalHttpUrl - } + val apiKey = securePreferences?.get(API_KEY) + originalHttpUrl.newBuilder() + .addQueryParameter(QUERY_PARAM_KEY, apiKey) + .build() } else { originalHttpUrl } @@ -65,9 +64,14 @@ class AwsSignerInterceptor( return chain.proceed(newRequest) } else { - val accessKeyId = credentials.accessKeyId - val secretKey = credentials.secretAccessKey - val sessionToken = credentials.sessionToken + runBlocking { + if (!credentialsProvider.isCredentialsValid()) { + credentialsProvider.verifyAndRefreshCredentials() + } + } + val accessKeyId = credentialsProvider.getCredentialsProvider().accessKeyId + val secretKey = credentialsProvider.getCredentialsProvider().secretKey + val sessionToken = credentialsProvider.getCredentialsProvider().sessionToken if (!accessKeyId.isNullOrEmpty() && !secretKey.isNullOrEmpty() && !sessionToken.isNullOrEmpty() && region.isNotEmpty()) { val dateMilli = Date().time val host = extractHostHeader(originalRequest.url.toString()) @@ -100,6 +104,10 @@ class AwsSignerInterceptor( } } + fun initPreference(context: Context): EncryptedSharedPreferences { + return EncryptedSharedPreferences(context, PREFS_NAME).apply { initEncryptedSharedPreferences() } + } + private fun extractHostHeader(urlString: String): String { val url = URL(urlString) return url.host diff --git a/library/src/main/java/software/amazon/location/auth/CognitoCredentialsProvider.kt b/library/src/main/java/software/amazon/location/auth/CognitoCredentialsProvider.kt index 98ab40c..4ebed13 100644 --- a/library/src/main/java/software/amazon/location/auth/CognitoCredentialsProvider.kt +++ b/library/src/main/java/software/amazon/location/auth/CognitoCredentialsProvider.kt @@ -3,124 +3,103 @@ package software.amazon.location.auth -import android.util.Log -import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials -import aws.smithy.kotlin.runtime.auth.awscredentials.CredentialsProvider -import aws.smithy.kotlin.runtime.collections.Attributes -import aws.sdk.kotlin.runtime.auth.credentials.StaticCredentialsProvider -import aws.sdk.kotlin.services.cognitoidentity.CognitoIdentityClient -import aws.sdk.kotlin.services.cognitoidentity.model.GetCredentialsForIdentityRequest -import aws.sdk.kotlin.services.cognitoidentity.model.GetIdRequest -import aws.smithy.kotlin.runtime.http.HttpException +import android.content.Context +import aws.sdk.kotlin.services.cognitoidentity.model.Credentials import aws.smithy.kotlin.runtime.time.Instant import aws.smithy.kotlin.runtime.time.epochMilliseconds +import aws.smithy.kotlin.runtime.time.fromEpochMilliseconds +import software.amazon.location.auth.utils.Constants.ACCESS_KEY_ID +import software.amazon.location.auth.utils.Constants.EXPIRATION +import software.amazon.location.auth.utils.Constants.IDENTITY_ID +import software.amazon.location.auth.utils.Constants.SECRET_KEY +import software.amazon.location.auth.utils.Constants.SESSION_TOKEN + +/** + * Provides Cognito credentials for accessing services and manages their storage. + */ +class CognitoCredentialsProvider { + private var securePreferences: EncryptedSharedPreferences? = null -// Provide credentials for the given Cognito identity pool. -class CognitoCredentialsProvider /** - * Create a CognitoCredentialsProvider that handles the given identity pool ID. - * The credentials themselves will be lazily fetched on the first resolve() call. - * - * @param identityPoolId The identity pool ID for Cognito. - * @param identityRegion The AWS region where the identity pool is located. + * Constructor that initializes the provider with a context and credentials. + * @param context The application context used to initialize the key-value store. + * @param identityId The identityId to be saved in the key-value store. + * @param credentials The credentials to be saved in the key-value store. */ - ( - private var identityPoolId: String,// Keep track of the region and identity pool ID for use during credential refreshes. - private var identityRegion: String - ) : CredentialsProvider { - - // staticCredentialsProvider holds the cached credentials. - // (Defaults to empty credentials) - private var staticCredentialsProvider = StaticCredentialsProvider( - Credentials.invoke( - accessKeyId = "", - secretAccessKey = "", - sessionToken = null, - expiration = null - )) + constructor(context: Context, identityId: String, credentials: Credentials) { + initialize(context) + saveCredentials(identityId, credentials) + } /** - * Provide the credentials, but refresh them first if they've expired. - * - * @param attributes - * @return The Cognito credentials to use for authentication. + * Constructor that initializes the provider with a context and retrieves cached credentials. + * Throws an exception if no cached credentials are found. + * @param context The application context used to initialize the key-value store. */ - override suspend fun resolve(attributes: Attributes): Credentials { - if (!areCredentialsValid()) { - refreshCognitoCredentials() - } - - return staticCredentialsProvider.credentials + constructor(context: Context) { + initialize(context) + val credentials = getCachedCredentials() + if (credentials === null) throw Exception("No credentials found") } /** - * Fetches a new set of credentials from Cognito. - * - * All of the Cognito access has been pulled out into this function so that it's easy to mock - * away the Cognito calls in unit tests by mocking this method. Successfully mocking at the - * Cognito level is much more difficult due to the nested Builder() calls and lambdas that - * get invoked. - * - * @throws Exception if the credential generation fails. + * Initializes the AWSKeyValueStore with the given context. + * @param context The application context used to initialize the key-value store. */ - suspend fun fetchCognitoCredentials(): aws.sdk.kotlin.services.cognitoidentity.model.Credentials { - var credentials = aws.sdk.kotlin.services.cognitoidentity.model.Credentials.invoke {} - - try { - val poolId = identityPoolId - val cognitoIdentityClient = CognitoIdentityClient { this.region = identityRegion } - val identityId = cognitoIdentityClient.getId(GetIdRequest { this.identityPoolId = poolId }) - .identityId ?: throw Exception("Failed to get identity ID for identity pool") + private fun initialize(context: Context) { + securePreferences = EncryptedSharedPreferences(context, PREFS_NAME) + securePreferences?.initEncryptedSharedPreferences() + } - if (identityId.isNotEmpty()) { - credentials = cognitoIdentityClient.getCredentialsForIdentity( - GetCredentialsForIdentityRequest { this.identityId = identityId }) - .credentials ?: throw Exception("Failed to get credentials for identity") - } - } catch (e: HttpException) { - Log.e("Auth", "Credentials generation failed: ${e.cause} ${e.message}") - throw HttpException("Credentials generation failed", e) - } catch (e: Exception) { - throw Exception("Credentials generation failed", e) + /** + * Saves the given credentials to the key-value store. + * @param identityId The identityId to be saved in the key-value store. + * @param credentials The credentials to be saved in the key-value store. + * @throws Exception if the key-value store is not initialized. + */ + private fun saveCredentials(identityId: String, credentials: Credentials) { + if (securePreferences === null) throw Exception("Not initialized") + securePreferences?.put(IDENTITY_ID, identityId) + credentials.accessKeyId?.let { securePreferences?.put(ACCESS_KEY_ID, it) } + credentials.secretKey?.let { securePreferences?.put(SECRET_KEY, it) } + credentials.sessionToken?.let { securePreferences?.put(SESSION_TOKEN, it) } + credentials.expiration?.let { + securePreferences?.put( + EXPIRATION, + it.epochMilliseconds.toString() + ) } - return credentials } /** - * Generates new AWS credentials using the specified region and identity pool ID. - * - * This function fetches the identity ID and credentials from Cognito, and then initializes - * the CognitoCredentialsProvider with the retrieved credentials. - * - * @throws Exception if the credential generation fails. + * Retrieves cached credentials from the key-value store. + * @return A Credentials object if all required fields are present, otherwise null. */ - private suspend fun refreshCognitoCredentials() { - try { - val credentials = fetchCognitoCredentials() - requireNotNull(credentials.accessKeyId) { "Access key ID is null" } - requireNotNull(credentials.secretKey) { "Secret key is null" } - requireNotNull(credentials.sessionToken) { "Session token is null" } - - staticCredentialsProvider = StaticCredentialsProvider( - Credentials.invoke( - accessKeyId = credentials.accessKeyId!!, - secretAccessKey = credentials.secretKey!!, - sessionToken = credentials.sessionToken, - expiration = credentials.expiration - )) - } catch (e: Exception) { - throw e + fun getCachedCredentials(): Credentials? { + if (securePreferences === null) return null + val mAccessKeyId = securePreferences?.get(ACCESS_KEY_ID) + val mSecretKey = securePreferences?.get(SECRET_KEY) + val mSessionToken = securePreferences?.get(SESSION_TOKEN) + val mExpiration = securePreferences?.get(EXPIRATION) + if (mAccessKeyId.isNullOrEmpty() || mSecretKey.isNullOrEmpty() || mSessionToken.isNullOrEmpty() || mExpiration.isNullOrEmpty()) return null + return Credentials.invoke { + accessKeyId = mAccessKeyId + secretKey = mSecretKey + sessionToken = mSessionToken + expiration = Instant.fromEpochMilliseconds(mExpiration.toLong()) } } /** - * Check to see if the credentials have expired yet or not. + * Clears all credentials from the key-value store. */ - private fun areCredentialsValid(): Boolean { - if (staticCredentialsProvider.credentials.expiration == null) return false - - val expirationTimeMillis = staticCredentialsProvider.credentials.expiration!!.epochMilliseconds - return Instant.now().epochMilliseconds < expirationTimeMillis + fun clearCredentials() { + if (securePreferences === null) throw Exception("Not initialized") + securePreferences?.remove(IDENTITY_ID) + securePreferences?.remove(ACCESS_KEY_ID) + securePreferences?.remove(SECRET_KEY) + securePreferences?.remove(SESSION_TOKEN) + securePreferences?.remove(EXPIRATION) } } diff --git a/library/src/main/java/software/amazon/location/auth/EncryptedSharedPreferences.kt b/library/src/main/java/software/amazon/location/auth/EncryptedSharedPreferences.kt new file mode 100644 index 0000000..9435865 --- /dev/null +++ b/library/src/main/java/software/amazon/location/auth/EncryptedSharedPreferences.kt @@ -0,0 +1,101 @@ +package software.amazon.location.auth + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import java.io.IOException +import java.security.GeneralSecurityException + +/** + * Wrapper class for encrypted SharedPreferences. + * Provides methods to initialize, store, retrieve, and clear encrypted preferences. + * + * @property context The application context. + * @property preferenceName The name of the preferences file. + */ +class EncryptedSharedPreferences(private val context: Context, private val preferenceName: String) { + private var sharedPreferences: SharedPreferences? = null + + /** + * Initializes the EncryptedSharedPreferences instance. + * + * @throws RuntimeException if initialization fails due to security or I/O issues. + */ + fun initEncryptedSharedPreferences() { + try { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + sharedPreferences = EncryptedSharedPreferences.create( + context, + preferenceName, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + } catch (e: GeneralSecurityException) { + throw RuntimeException("Failed to initialize encrypted preferences", e) + } catch (e: IOException) { + throw RuntimeException("Failed to initialize encrypted preferences", e) + } + } + + /** + * Stores a string value in the encrypted preferences. + * @param key The key under which to store the value. + * @param value The value to store. + * @throws Exception if preferences are not initialized. + */ + fun put(key: String, value: String) { + if (sharedPreferences === null) throw Exception("SharedPreferences not initialized") + val editor = sharedPreferences!!.edit() + editor.putString(key, value) + editor.apply() + } + + /** + * Retrieves a string value from the encrypted preferences. + * @param key The key of the value to retrieve. + * @return The retrieved value, or null if the key does not exist. + * @throws Exception if preferences are not initialized. + */ + fun get(key: String): String? { + if (sharedPreferences === null) throw Exception("SharedPreferences not initialized") + return sharedPreferences!!.getString(key, null) + } + + /** + * Clears all entries from the encrypted preferences. + * @throws Exception if preferences are not initialized. + */ + fun clear() { + if (sharedPreferences === null) throw Exception("SharedPreferences not initialized") + sharedPreferences!!.edit().clear().apply() + } + + /** + * Removes a particular key from the encrypted preferences. + * @param key The key to remove. + * @throws Exception if preferences are not initialized. + */ + fun remove(key: String) { + if (sharedPreferences === null) throw Exception("SharedPreferences not initialized") + val editor = sharedPreferences!!.edit() + editor.remove(key) + editor.apply() + } + + /** + * Checks if a particular key exists in the encrypted preferences. + * @param key The key to check for existence. + * @return True if the key exists, false otherwise. + * @throws Exception if preferences are not initialized. + */ + fun contains(key: String): Boolean { + if (sharedPreferences === null) throw Exception("SharedPreferences not initialized") + return sharedPreferences!!.contains(key) + } +} diff --git a/library/src/main/java/software/amazon/location/auth/LocationCredentialsProvider.kt b/library/src/main/java/software/amazon/location/auth/LocationCredentialsProvider.kt index 70629df..0adb6c1 100644 --- a/library/src/main/java/software/amazon/location/auth/LocationCredentialsProvider.kt +++ b/library/src/main/java/software/amazon/location/auth/LocationCredentialsProvider.kt @@ -3,70 +3,115 @@ package software.amazon.location.auth +import android.content.Context +import android.util.Log import aws.sdk.kotlin.runtime.auth.credentials.StaticCredentialsProvider -import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials +import aws.sdk.kotlin.services.cognitoidentity.CognitoIdentityClient +import aws.sdk.kotlin.services.cognitoidentity.model.GetCredentialsForIdentityRequest +import aws.sdk.kotlin.services.cognitoidentity.model.GetIdRequest import aws.sdk.kotlin.services.geomaps.GeoMapsClient import aws.sdk.kotlin.services.geoplaces.GeoPlacesClient import aws.sdk.kotlin.services.georoutes.GeoRoutesClient import aws.sdk.kotlin.services.location.LocationClient +import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials import aws.smithy.kotlin.runtime.auth.awscredentials.CredentialsProvider +import aws.smithy.kotlin.runtime.http.HttpException +import aws.smithy.kotlin.runtime.time.Instant +import aws.smithy.kotlin.runtime.time.epochMilliseconds import software.amazon.location.auth.utils.AwsRegions +import software.amazon.location.auth.utils.Constants.API_KEY +import software.amazon.location.auth.utils.Constants.IDENTITY_POOL_ID +import software.amazon.location.auth.utils.Constants.METHOD +import software.amazon.location.auth.utils.Constants.REGION + +const val PREFS_NAME = "software.amazon.location.auth" /** - * Provides credentials for accessing location-based services through Cognito authentication, - * custom authentication, or API keys. - **/ + * Provides credentials for accessing location-based services through Cognito authentication. + */ class LocationCredentialsProvider { - private var credentialsProvider : CredentialsProvider - - private var identityPoolId: String? = null - private var method: String - private var region: AwsRegions? = null - private var apiKey: String? = null + private var credentialsProvider: CredentialsProvider? = null + private var customCredentials: aws.sdk.kotlin.services.cognitoidentity.model.Credentials? = null + private var emptyCredentials: aws.sdk.kotlin.services.cognitoidentity.model.Credentials? = null + private var context: Context + private var cognitoCredentialsProvider: CognitoCredentialsProvider? = null + private var securePreferences: EncryptedSharedPreferences + private var cognitoIdentityClient: CognitoIdentityClient? = null + private var apiKeyProvider: ApiKeyCredentialsProvider? = null /** * Initializes with Cognito credentials. + * @param context The application context. * @param identityPoolId The identity pool ID for Cognito authentication. * @param region The region for Cognito authentication. */ - constructor(identityPoolId: String, region: AwsRegions) { - this.method = "cognito" - this.identityPoolId = identityPoolId - this.region = region - this.credentialsProvider = CognitoCredentialsProvider(identityPoolId, region.regionName) + constructor(context: Context, identityPoolId: String, region: AwsRegions) { + this.context = context + securePreferences = initPreference(context) + securePreferences.put(METHOD, "cognito") + securePreferences.put(IDENTITY_POOL_ID, identityPoolId) + securePreferences.put(REGION, region.regionName) } /** - * Initializes with custom authentication. - * @param credentialsProvider The custom credentials provider to use for authentication. - * @param region The region for authentication. + * Initializes with region. + * @param context The application context. + * @param region The region for Cognito authentication. */ - constructor(credentialsProvider: CredentialsProvider, region: AwsRegions) { - this.method = "custom" - this.region = region - this.credentialsProvider = credentialsProvider + constructor(context: Context, region: AwsRegions) { + this.context = context + securePreferences = initPreference(context) + securePreferences.put(METHOD, "custom") + securePreferences.put(REGION, region.regionName) } /** * Initializes with an API key. - * NOTE: The order of the parameters are important here to distinguish this constructor - * from the Cognito constructor that takes in (identityPoolId, region). + * @param context The application context. * @param region The region for Cognito authentication. * @param apiKey The API key for authentication. */ - constructor(region: AwsRegions, apiKey: String) { - this.method = "apiKey" - this.apiKey = apiKey - this.region = region - // API Keys "empty out" the credentials provider, since the authentication happens - // via an HTTP interceptor that adds the API key to the HTTP request. - this.credentialsProvider = StaticCredentialsProvider( - Credentials.invoke( - accessKeyId = "", - secretAccessKey = "", - sessionToken = null, - expiration = null - )) + constructor(context: Context, region: AwsRegions, apiKey: String) { + this.context = context + securePreferences = initPreference(context) + securePreferences.put(METHOD, "apiKey") + securePreferences.put(API_KEY, apiKey) + securePreferences.put(REGION, region.regionName) + apiKeyProvider = ApiKeyCredentialsProvider(context, apiKey) + } + + + /** + * Initializes with cached credentials. + * @param context The application context. + * @throws Exception If credentials are not found. + */ + constructor(context: Context) { + this.context = context + securePreferences = initPreference(context) + val method = securePreferences.get(METHOD) + if (method === null) throw Exception("No credentials found") + when (method) { + "cognito" -> { + val identityPoolId = securePreferences.get(IDENTITY_POOL_ID) + val region = securePreferences.get(REGION) + if (identityPoolId === null || region === null) throw Exception("No credentials found") + cognitoCredentialsProvider = CognitoCredentialsProvider(context) + } + "apiKey" -> { + val apiKey = securePreferences.get(API_KEY) + val region = securePreferences.get(REGION) + if (apiKey === null || region === null) throw Exception("No credentials found") + apiKeyProvider = ApiKeyCredentialsProvider(context, apiKey) + } + else -> { + throw Exception("No credentials found") + } + } + } + + fun initPreference(context: Context): EncryptedSharedPreferences { + return EncryptedSharedPreferences(context, PREFS_NAME).apply { initEncryptedSharedPreferences() } } /** @@ -79,14 +124,20 @@ class LocationCredentialsProvider { * @return Lambda that builds a LocationClient config. */ fun getLocationClientConfig() : LocationClient.Config.Builder.() -> Unit { - val region = this.region?.regionName ?: throw Exception("No valid region provided") - val apiKey = this.apiKey - val credentialsProvider = this.credentialsProvider return { - this.region = region - this.credentialsProvider = credentialsProvider - if (apiKey != null) { - this.interceptors = mutableListOf(ApiKeyInterceptor(apiKey)) + val region = securePreferences.get(REGION) ?: throw Exception("No credentials found") + val method = securePreferences.get(METHOD) ?: throw Exception("No credentials found") + when (method) { + "apiKey" -> { + val apiKey = securePreferences.get(API_KEY) ?: throw Exception("API key not found") + this.region = region + this.credentialsProvider = createEmptyCredentialsProvider() + this.interceptors = mutableListOf(ApiKeyInterceptor(apiKey)) + } + else -> { + this.region = region + this.credentialsProvider = createCredentialsProvider() + } } } } @@ -101,14 +152,20 @@ class LocationCredentialsProvider { * @return Lambda that builds a GeoMapsClient config. */ fun getGeoMapsClientConfig() : GeoMapsClient.Config.Builder.() -> Unit { - val region = this.region?.regionName ?: throw Exception("No valid region provided") - val apiKey = this.apiKey - val credentialsProvider = this.credentialsProvider return { - this.region = region - this.credentialsProvider = credentialsProvider - if (apiKey != null) { - this.interceptors = mutableListOf(ApiKeyInterceptor(apiKey)) + val region = securePreferences.get(REGION) ?: throw Exception("No credentials found") + val method = securePreferences.get(METHOD) ?: throw Exception("No credentials found") + when (method) { + "apiKey" -> { + val apiKey = securePreferences.get(API_KEY) ?: throw Exception("API key not found") + this.region = region + this.credentialsProvider = createEmptyCredentialsProvider() + this.interceptors = mutableListOf(ApiKeyInterceptor(apiKey)) + } + else -> { + this.region = region + this.credentialsProvider = createCredentialsProvider() + } } } } @@ -123,14 +180,20 @@ class LocationCredentialsProvider { * @return Lambda that builds a GeoPlacesClient config. */ fun getGeoPlacesClientConfig() : GeoPlacesClient.Config.Builder.() -> Unit { - val region = this.region?.regionName ?: throw Exception("No valid region provided") - val apiKey = this.apiKey - val credentialsProvider = this.credentialsProvider return { - this.region = region - this.credentialsProvider = credentialsProvider - if (apiKey != null) { - this.interceptors = mutableListOf(ApiKeyInterceptor(apiKey)) + val region = securePreferences.get(REGION) ?: throw Exception("No credentials found") + val method = securePreferences.get(METHOD) ?: throw Exception("No credentials found") + when (method) { + "apiKey" -> { + val apiKey = securePreferences.get(API_KEY) ?: throw Exception("API key not found") + this.region = region + this.credentialsProvider = createEmptyCredentialsProvider() + this.interceptors = mutableListOf(ApiKeyInterceptor(apiKey)) + } + else -> { + this.region = region + this.credentialsProvider = createCredentialsProvider() + } } } } @@ -145,51 +208,265 @@ class LocationCredentialsProvider { * @return Lambda that builds a GeoRoutesClient config. */ fun getGeoRoutesClientConfig() : GeoRoutesClient.Config.Builder.() -> Unit { - val region = this.region?.regionName ?: throw Exception("No valid region provided") - val apiKey = this.apiKey - val credentialsProvider = this.credentialsProvider return { - this.region = region - this.credentialsProvider = credentialsProvider - if (apiKey != null) { - this.interceptors = mutableListOf(ApiKeyInterceptor(apiKey)) + val region = securePreferences.get(REGION) ?: throw Exception("No credentials found") + val method = securePreferences.get(METHOD) ?: throw Exception("No credentials found") + when (method) { + "apiKey" -> { + val apiKey = securePreferences.get(API_KEY) ?: throw Exception("API key not found") + this.region = region + this.credentialsProvider = createEmptyCredentialsProvider() + this.interceptors = mutableListOf(ApiKeyInterceptor(apiKey)) + } + else -> { + this.region = region + this.credentialsProvider = createCredentialsProvider() + } } } } + /** - * Retrieves the method type ("cognito", "custom", "apiKey") - * This should only be needed by the AwsSignerInterceptor. - * @return The method type. + * Checks AWS credentials availability and validity. + * + * This function retrieves the identity pool ID and region from secure preferences. It then + * attempts to initialize the CognitoCredentialsProvider. If no credentials are found or if + * the cached credentials are invalid, new credentials are generated. + * + * @throws Exception if the identity pool ID or region is not found, or if credential generation fails. */ - fun getMethod() : String { - return method + suspend fun verifyAndRefreshCredentials() { + val identityPoolId = securePreferences.get(IDENTITY_POOL_ID) + val region = securePreferences.get(REGION) + if (identityPoolId === null || region === null) throw Exception("No credentials found") + val isCredentialsAvailable = try { + cognitoCredentialsProvider = CognitoCredentialsProvider(context) + true + } catch (e: Exception) { + false + } + if (!isCredentialsAvailable) { + generateCredentials(region, identityPoolId) + } else { + if (!isCredentialsValid()) { + generateCredentials(region, identityPoolId) + } + } } /** - * Retrieves the API Key if one was provided. - * This should only be needed by the AwsSignerInterceptor. - * @return The API Key. + * Initializes the Location client with custom CredentialsProvider + * + * This method generates a Cognito identity client if not already present, retrieves an identity ID using the + * provided identity pool ID, and initializes the `CognitoCredentialsProvider` with the resolved credentials. + * + * @param credentialsProvider The provider for AWS credentials. */ - fun getApiKey(): String? { - return apiKey + suspend fun initializeLocationClient(credentialsProvider: CredentialsProvider) { + val region = securePreferences.get(REGION) + if (region === null) throw Exception("No credentials found") + + this.credentialsProvider = credentialsProvider + setCustomCredentials(credentialsProvider, region) + } + + /** + * Sets custom credentials using the provided credentials provider and region. + * + * @param credentialsProvider The provider for AWS credentials, which is used to resolve AWS access credentials. + * @param region The AWS region to be used for initializing the location client. + */ + private suspend fun setCustomCredentials(credentialsProvider: CredentialsProvider, region: String) { + val credentials = credentialsProvider.resolve() + customCredentials = aws.sdk.kotlin.services.cognitoidentity.model.Credentials.invoke { + accessKeyId = credentials.accessKeyId + secretKey = credentials.secretAccessKey + sessionToken = credentials.sessionToken + expiration = credentials.expiration + } } /** - * Returns the CredentialsProvider. - * This can either be a CognitoCredentialsProvider, a custom CredentialsProvider, or an empty StaticCredentialsProvider. - * @return The CredentialsProvider. + * Initializes the AWS Location Service client using API key. + * + * This function retrieves the API key and region from `securePreferences`, + * creates an empty credentials provider, and generates the `locationClient` + * with the provided region and API key. + * + * @throws Exception if the API key or region is not found in `securePreferences`. */ - fun getCredentialsProvider(): CredentialsProvider { - return credentialsProvider + suspend fun initializeLocationClient() { + val apiKey = securePreferences.get(API_KEY) + val region = securePreferences.get(REGION) + if (apiKey === null || region === null) throw Exception("No credentials found") + + val credentials = createEmptyCredentialsProvider().resolve() + emptyCredentials = aws.sdk.kotlin.services.cognitoidentity.model.Credentials.invoke { + accessKeyId = credentials.accessKeyId + secretKey = credentials.secretAccessKey + sessionToken = credentials.sessionToken + } + } + + /** + * Creates an empty `StaticCredentialsProvider` with no access keys or secret keys. + * + * This is useful for bypassing the default credentials provider chain when + * credentials are not yet available or are not required for certain operations. + * + * @return A `StaticCredentialsProvider` instance with empty credentials. + */ + private fun createEmptyCredentialsProvider(): CredentialsProvider = + StaticCredentialsProvider( + Credentials.invoke( + accessKeyId = "", + secretAccessKey = "", + sessionToken = null, + ), + ) + + + /** + * Creates a new instance of CredentialsProvider using the credentials obtained from the current provider. + * + * This function constructs a CredentialsProvider with the AWS credentials retrieved from the existing provider. + * It extracts the access key ID, secret access key, and session token from the current provider and initializes + * a StaticCredentialsProvider with these credentials. + * + * @return A new instance of CredentialsProvider initialized with the current AWS credentials. + * @throws Exception if credentials cannot be retrieved. + */ + private fun createCredentialsProvider(): CredentialsProvider { + if (getCredentialsProvider().accessKeyId == null || getCredentialsProvider().secretKey == null) throw Exception( + "Failed to get credentials" + ) + return StaticCredentialsProvider( + Credentials.invoke( + accessKeyId = getCredentialsProvider().accessKeyId!!, + secretAccessKey = getCredentialsProvider().secretKey!!, + sessionToken = getCredentialsProvider().sessionToken, + expiration = getCredentialsProvider().expiration + ) + ) + } + + /** + * Generates new AWS credentials using the specified region and identity pool ID. + * + * This function fetches the identity ID and credentials from Cognito, and then initializes + * the CognitoCredentialsProvider with the retrieved credentials. + * + * @param region The AWS region where the identity pool is located. + * @param identityPoolId The identity pool ID for Cognito. + * @throws Exception if the credential generation fails. + */ + private suspend fun generateCredentials(region: String, identityPoolId: String) { + try { + cognitoIdentityClient ?: run { + cognitoIdentityClient = generateCognitoIdentityClient(region) + } + val identityId = cognitoIdentityClient?.getId(GetIdRequest { this.identityPoolId = identityPoolId }) + ?.identityId ?: throw Exception("Failed to get identity ID") + + if (identityId.isNotEmpty()) { + val credentials = cognitoIdentityClient?.getCredentialsForIdentity(GetCredentialsForIdentityRequest { this.identityId = identityId }) + ?.credentials ?: throw Exception("Failed to get credentials") + + requireNotNull(credentials.accessKeyId) { "Access key ID is null" } + requireNotNull(credentials.secretKey) { "Secret key is null" } + requireNotNull(credentials.sessionToken) { "Session token is null" } + + cognitoCredentialsProvider = CognitoCredentialsProvider(context, identityId, credentials) + } + } catch (e: HttpException) { + Log.e("Auth", "Credentials generation failed: ${e.cause} ${e.message}") + throw HttpException("Credentials generation failed") + } catch (e: Exception) { + throw Exception("Credentials generation failed", e) + } + } + + /** + * Generates a new instance of CognitoIdentityClient with the specified region. + * + * @param region The AWS region for the CognitoIdentityClient. + * @return A new instance of CognitoIdentityClient. + */ + fun generateCognitoIdentityClient(region: String): CognitoIdentityClient { + return CognitoIdentityClient { this.region = region } + } + + /** + * Checks if the provided credentials are still valid. + * + * @return True if the credentials are valid (i.e., not expired), false otherwise. + */ + fun isCredentialsValid(): Boolean { + val method = securePreferences.get(METHOD) + if (method == "apiKey") return true + + val expirationTimeMillis = customCredentials?.expiration?.epochMilliseconds + ?: cognitoCredentialsProvider?.getCachedCredentials()?.expiration?.epochMilliseconds + ?: throw Exception("Failed to get credentials") + + return Instant.now().epochMilliseconds < expirationTimeMillis + } + + /** + * Retrieves the Cognito credentials. + * @return The Credentials instance. + * @throws Exception If the Cognito provider is not initialized. + */ + fun getCredentialsProvider(): aws.sdk.kotlin.services.cognitoidentity.model.Credentials { + return when (securePreferences.get(METHOD)) { + "apiKey" -> emptyCredentials ?: throw Exception("API key empty credentials not initialized") + "custom" -> customCredentials ?: throw Exception("Custom credentials not initialized") + else -> cognitoCredentialsProvider?.getCachedCredentials() ?: throw Exception("Cognito credentials not initialized") + } + } + + /** + * Retrieves the API key credentials provider. + * @return The ApiKeyCredentialsProvider instance. + * @throws Exception If the API key provider is not initialized. + */ + fun getApiKeyProvider(): ApiKeyCredentialsProvider { + if (apiKeyProvider === null) throw Exception("Api key provider not initialized") + return apiKeyProvider!! + } + + /** + * Refreshes the Cognito credentials. + * @throws Exception If the Cognito provider is not initialized. + */ + suspend fun refresh() { + val region = securePreferences.get(REGION) ?: throw Exception("No credentials found") + val method = securePreferences.get(METHOD) ?: throw Exception("No method found") + + when (method) { + "apiKey" -> return + "custom" -> { + customCredentials?.let { + credentialsProvider?.let { provider -> setCustomCredentials(provider, region) } + return + } + } + else -> { + cognitoCredentialsProvider?.let { + it.clearCredentials() + verifyAndRefreshCredentials() + } ?: throw Exception("Refresh is only supported for Cognito credentials. Make sure to use the cognito constructor.") + } + } } /** - * Retrieves the AWS credentials. - * @return The Credentials instance containing the accessKeyId, secretAccessKey, and sessionToken - * @throws Exception If the credentials provider is not initialized. + * Clears the Cognito credentials. + * @throws Exception If the Cognito provider is not initialized. */ - suspend fun getCredentials(): Credentials { - return credentialsProvider.resolve() + fun clear() { + if (cognitoCredentialsProvider === null) throw Exception("Clear is only supported for Cognito credentials. Make sure to use the cognito constructor.") + cognitoCredentialsProvider?.clearCredentials() } } diff --git a/library/src/main/java/software/amazon/location/auth/utils/Constants.kt b/library/src/main/java/software/amazon/location/auth/utils/Constants.kt index 43c5b07..9942adf 100644 --- a/library/src/main/java/software/amazon/location/auth/utils/Constants.kt +++ b/library/src/main/java/software/amazon/location/auth/utils/Constants.kt @@ -11,12 +11,15 @@ object Constants { const val HEADER_X_AMZ_SECURITY_TOKEN = "x-amz-security-token" const val HEADER_X_AMZ_CONTENT_SHA256= "x-amz-content-sha256" const val HEADER_AUTHORIZATION= "authorization" + const val IDENTITY_ID= "identityId" const val ACCESS_KEY_ID= "accessKeyId" const val SECRET_KEY= "secretKey" const val SESSION_TOKEN= "sessionToken" const val EXPIRATION= "expiration" const val REGION= "region" const val METHOD= "method" + const val IDENTITY_POOL_ID= "identityPoolId" const val DEFAULT_ENCODING = "UTF-8" + const val API_KEY = "apiKey" const val QUERY_PARAM_KEY = "key" } \ No newline at end of file diff --git a/library/src/test/java/software/amazon/location/auth/ApiKeyCredentialsProviderTest.kt b/library/src/test/java/software/amazon/location/auth/ApiKeyCredentialsProviderTest.kt new file mode 100644 index 0000000..4e19cc7 --- /dev/null +++ b/library/src/test/java/software/amazon/location/auth/ApiKeyCredentialsProviderTest.kt @@ -0,0 +1,81 @@ +package software.amazon.location.auth + +import android.content.Context +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.runs +import io.mockk.verify +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import org.junit.Before +import org.junit.Test +import software.amazon.location.auth.utils.Constants.API_KEY_TEST +import software.amazon.location.auth.utils.Constants.REGION + +private const val TEST_API_KEY = "dummyApiKey" + +class ApiKeyCredentialsProviderTest { + + private lateinit var context: Context + @Before + fun setUp() { + context = mockk(relaxed = true) + + mockkConstructor(EncryptedSharedPreferences::class) + + every { anyConstructed().initEncryptedSharedPreferences() } just runs + every { anyConstructed().put(any(), any()) } just runs + every { anyConstructed().get(REGION) } returns "us-east-1" + every { anyConstructed().remove(any()) } just runs + } + + @Test + fun `constructor with apiKey saves credentials`() { + every { anyConstructed().put(API_KEY_TEST, TEST_API_KEY) } just runs + + ApiKeyCredentialsProvider(context, TEST_API_KEY) + + verify { anyConstructed().put(API_KEY_TEST, TEST_API_KEY) } + } + + @Test + fun `constructor without apiKey throws when no credentials found`() { + every { anyConstructed().get(API_KEY_TEST) } returns null + + assertFailsWith { + ApiKeyCredentialsProvider(context) + } + } + + @Test + fun `getCachedCredentials returns apiKey when found`() { + val apiKey = "testApiKey" + every { anyConstructed().get(API_KEY_TEST) } returns apiKey + + val provider = ApiKeyCredentialsProvider(context, apiKey) + val cachedApiKey = provider.getCachedCredentials() + + assertEquals(apiKey, cachedApiKey) + } + + @Test + fun `getCachedCredentials throws when not initialized`() { + val provider = ApiKeyCredentialsProvider(context, "testApiKey") + + every { anyConstructed().get(API_KEY_TEST) } throws Exception("Not initialized") + + assertFailsWith { + provider.getCachedCredentials() + } + } + + @Test + fun `clearCredentials clears the stored credentials`() { + val provider = ApiKeyCredentialsProvider(context, "testApiKey") + provider.clearCredentials() + + verify { anyConstructed().remove(any()) } + } +} \ No newline at end of file diff --git a/library/src/test/java/software/amazon/location/auth/AuthHelperTest.kt b/library/src/test/java/software/amazon/location/auth/AuthHelperTest.kt index 9e893c6..b7ab101 100644 --- a/library/src/test/java/software/amazon/location/auth/AuthHelperTest.kt +++ b/library/src/test/java/software/amazon/location/auth/AuthHelperTest.kt @@ -3,25 +3,63 @@ package software.amazon.location.auth -import aws.sdk.kotlin.runtime.auth.credentials.StaticCredentialsProvider +import android.content.Context import aws.sdk.kotlin.services.cognitoidentity.CognitoIdentityClient import aws.smithy.kotlin.runtime.auth.awscredentials.CredentialsProvider +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.mockkConstructor +import io.mockk.runs import org.junit.Before import org.junit.Test import kotlin.test.assertNotNull import kotlinx.coroutines.runBlocking +import software.amazon.location.auth.utils.AwsRegions +import software.amazon.location.auth.utils.Constants +import software.amazon.location.auth.utils.Constants.IDENTITY_POOL_ID import software.amazon.location.auth.utils.Constants.TEST_API_KEY import software.amazon.location.auth.utils.Constants.TEST_IDENTITY_POOL_ID class AuthHelperTest { + private lateinit var context: Context + private lateinit var credentialsProvider: CredentialsProvider + private lateinit var encryptedSharedPreferences: EncryptedSharedPreferences + private lateinit var cognitoIdentityClient: CognitoIdentityClient + @Before + fun setUp() { + context = mockk(relaxed = true) + credentialsProvider = mockk(relaxed = true) + encryptedSharedPreferences = mockk(relaxed = true) + cognitoIdentityClient = mockk(relaxed = true) + mockkConstructor(EncryptedSharedPreferences::class) + mockkConstructor(LocationCredentialsProvider::class) + + every { anyConstructed().put(any(), any()) } just runs + every { anyConstructed().clear() } just runs + every { anyConstructed().remove(any()) } just runs + every { anyConstructed().get("region") } returns "us-east-1" + every { anyConstructed().get(Constants.API_KEY) } returns "test" + every { anyConstructed().get(Constants.ACCESS_KEY_ID) } returns "test" + every { anyConstructed().get(Constants.SECRET_KEY) } returns "test" + every { anyConstructed().get(Constants.SESSION_TOKEN) } returns "test" + every { anyConstructed().get(Constants.EXPIRATION) } returns "11111" + every { anyConstructed().get(IDENTITY_POOL_ID) } returns TEST_IDENTITY_POOL_ID + every { anyConstructed().generateCognitoIdentityClient("us-east-1") } returns cognitoIdentityClient + coEvery { anyConstructed().initializeLocationClient(any()) } just runs + coEvery { anyConstructed().initializeLocationClient() } just runs + coEvery { anyConstructed().isCredentialsValid() } returns true + every { anyConstructed().initPreference(context) } returns encryptedSharedPreferences + every { anyConstructed().initEncryptedSharedPreferences() } just runs + } + @Test fun `authenticateWithCognitoIdentityPool with identityPoolId creates LocationCredentialsProvider`() { runBlocking { - val provider = AuthHelper.withCognitoIdentityPool(TEST_IDENTITY_POOL_ID) + val provider = AuthHelper.withCognitoIdentityPool(TEST_IDENTITY_POOL_ID, context) assertNotNull(provider) } } @@ -30,20 +68,32 @@ class AuthHelperTest { fun `authenticateWithCognitoIdentityPool with identityPoolId and string region creates LocationCredentialsProvider`() { runBlocking { val provider = - AuthHelper.withCognitoIdentityPool(TEST_IDENTITY_POOL_ID, "us-east-1") + AuthHelper.withCognitoIdentityPool(TEST_IDENTITY_POOL_ID, "us-east-1", context) assertNotNull(provider) } } @Test - fun `authenticateWithCredentialsProvider with identityPoolId`() { - val credentialsProvider = mockk() + fun `authenticateWithCognitoIdentityPool with identityPoolId and Regions enum creates LocationCredentialsProvider`() { + runBlocking { + val provider = + AuthHelper.withCognitoIdentityPool( + TEST_IDENTITY_POOL_ID, + AwsRegions.US_EAST_1, + context + ) + assertNotNull(provider) + } + } + @Test + fun `authenticateWithCredentialsProvider with identityPoolId`() { runBlocking { val provider = AuthHelper.withCredentialsProvider( credentialsProvider, - "us-east-1" + "us-east-1", + context, ) assertNotNull(provider) } @@ -52,7 +102,7 @@ class AuthHelperTest { @Test fun `authenticateWithApiKey creates LocationCredentialsProvider`() { runBlocking { - val provider = AuthHelper.withApiKey(TEST_API_KEY,"us-east-1") + val provider = AuthHelper.withApiKey(TEST_API_KEY,"us-east-1", context) assertNotNull(provider) } } diff --git a/library/src/test/java/software/amazon/location/auth/AwsSignerInterceptorTest.kt b/library/src/test/java/software/amazon/location/auth/AwsSignerInterceptorTest.kt index b9b7fc9..0a77f46 100644 --- a/library/src/test/java/software/amazon/location/auth/AwsSignerInterceptorTest.kt +++ b/library/src/test/java/software/amazon/location/auth/AwsSignerInterceptorTest.kt @@ -3,11 +3,14 @@ package software.amazon.location.auth -import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials +import android.content.Context +import aws.sdk.kotlin.services.cognitoidentity.model.Credentials import io.mockk.coEvery import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.mockkConstructor +import io.mockk.runs import io.mockk.verify import java.net.URL import java.nio.charset.StandardCharsets @@ -22,23 +25,34 @@ import org.junit.Test import software.amazon.location.auth.utils.Constants.HEADER_AUTHORIZATION import software.amazon.location.auth.utils.Constants.HEADER_HOST import software.amazon.location.auth.utils.Constants.HEADER_X_AMZ_SECURITY_TOKEN +import software.amazon.location.auth.utils.Constants.METHOD +import software.amazon.location.auth.utils.Constants.REGION import software.amazon.location.auth.utils.Constants.TEST_REGION import software.amazon.location.auth.utils.Constants.TEST_URL import software.amazon.location.auth.utils.Constants.TEST_URL1 class AwsSignerInterceptorTest { + private lateinit var context: Context private lateinit var interceptor: AwsSignerInterceptor private lateinit var mockCredentialsProvider: LocationCredentialsProvider + private lateinit var encryptedSharedPreferences: EncryptedSharedPreferences @Before fun setUp() { + context = mockk(relaxed = true) + encryptedSharedPreferences = mockk(relaxed = true) mockCredentialsProvider = mockk(relaxed = true) + mockkConstructor(EncryptedSharedPreferences::class) mockkConstructor(AwsSignerInterceptor::class) interceptor = AwsSignerInterceptor( "execute-api", TEST_REGION, - mockCredentialsProvider + mockCredentialsProvider, + context, ) + every { anyConstructed().initEncryptedSharedPreferences() } just runs + every { anyConstructed().get(METHOD) } returns "cognito" + every { anyConstructed().initPreference(context) } returns encryptedSharedPreferences } @Test @@ -66,13 +80,14 @@ class AwsSignerInterceptorTest { val credentials = mockk { every { accessKeyId } returns "testAccessKeyId" - every { secretAccessKey } returns "testSecretKey" + every { secretKey } returns "testSecretKey" every { sessionToken } returns "testSessionToken" } every { chain.request() } returns originalRequest every { chain.proceed(any()) } returns mockk(relaxed = true) - coEvery { mockCredentialsProvider.getCredentials() } returns credentials + coEvery { mockCredentialsProvider.isCredentialsValid() } returns true + coEvery { mockCredentialsProvider.getCredentialsProvider() } returns credentials val response = runBlocking { interceptor.intercept(chain) } diff --git a/library/src/test/java/software/amazon/location/auth/CognitoCredentialsProviderTest.kt b/library/src/test/java/software/amazon/location/auth/CognitoCredentialsProviderTest.kt index e050310..f2d6737 100644 --- a/library/src/test/java/software/amazon/location/auth/CognitoCredentialsProviderTest.kt +++ b/library/src/test/java/software/amazon/location/auth/CognitoCredentialsProviderTest.kt @@ -3,137 +3,156 @@ package software.amazon.location.auth +import android.content.Context import aws.sdk.kotlin.services.cognitoidentity.model.Credentials +import aws.smithy.kotlin.runtime.http.auth.AnonymousIdentity.expiration import aws.smithy.kotlin.runtime.time.Instant -import aws.smithy.kotlin.runtime.time.epochMilliseconds -import aws.smithy.kotlin.runtime.time.fromEpochMilliseconds -import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk import io.mockk.mockkConstructor -import junit.framework.TestCase.assertNotNull +import io.mockk.runs +import io.mockk.verify import junit.framework.TestCase.assertEquals -import kotlinx.coroutines.runBlocking +import junit.framework.TestCase.assertNull +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import org.junit.Before import org.junit.Test +import software.amazon.location.auth.utils.Constants.ACCESS_KEY_ID +import software.amazon.location.auth.utils.Constants.EXPIRATION +import software.amazon.location.auth.utils.Constants.SECRET_KEY +import software.amazon.location.auth.utils.Constants.SESSION_TOKEN class CognitoCredentialsProviderTest { - @Test - fun `Constructs successfully with identity pool and region`() { - val provider = CognitoCredentialsProvider("identityPool", "us-east-1") - assertNotNull(provider) - } - - @Test - fun `Calling resolve for the first time refreshes credentials successfully and returns them`() { - val expirationTime = - Instant.fromEpochMilliseconds(Instant.now().epochMilliseconds + 10000) - - // Create mock CognitoIdentityClient - mockkConstructor(CognitoCredentialsProvider::class) - coEvery { - anyConstructed().fetchCognitoCredentials() - } returns Credentials.invoke { - expiration = expirationTime - secretKey = "testSecretKey" - accessKeyId = "testAccessKeyId" - sessionToken = "testSessionToken" - } - val provider = CognitoCredentialsProvider("identityPool", "us-east-1") + private lateinit var context: Context - runBlocking { + @Before + fun setUp() { + context = mockk(relaxed = true) - // Call resolve - val credentials = provider.resolve() + mockkConstructor(EncryptedSharedPreferences::class) + mockkConstructor(EncryptedSharedPreferences::class) - // Verify the returned credentials match what we expected - assertEquals("testAccessKeyId", credentials.accessKeyId) - assertEquals("testSessionToken", credentials.sessionToken) - assertEquals(expirationTime, credentials.expiration) - } + every { anyConstructed().initEncryptedSharedPreferences() } just runs + every { anyConstructed().put(any(), any()) } just runs + every { anyConstructed().get(any()) } returns null + every { anyConstructed().clear() } just runs + every { anyConstructed().remove(any()) } just runs } + @Test - fun `If credentials aren't expired they are returned successfully from the cache`() { - val expirationTime = - Instant.fromEpochMilliseconds(Instant.now().epochMilliseconds + 10000) - val expirationTime2 = - Instant.fromEpochMilliseconds(Instant.now().epochMilliseconds + 20000) - - // Create mock CognitoIdentityClient - mockkConstructor(CognitoCredentialsProvider::class) - coEvery { - anyConstructed().fetchCognitoCredentials() - } returns Credentials.invoke { - expiration = expirationTime - secretKey = "testSecretKey" - accessKeyId = "testAccessKeyId" - sessionToken = "testSessionToken" - } andThen Credentials.invoke { - expiration = expirationTime2 - secretKey = "testSecretKey2" - accessKeyId = "testAccessKeyId2" - sessionToken = "testSessionToken2" + fun `constructor with credentials saves credentials`() { + val credentials = Credentials.invoke { + accessKeyId = "accessKeyId" + expiration = Instant.now() + secretKey = "secretKey" + sessionToken = "sessionToken" } - val provider = CognitoCredentialsProvider("identityPool", "us-east-1") - - runBlocking { - - // Call resolve - var credentials = provider.resolve() - - // Verify the returned credentials match what we expected - assertEquals("testAccessKeyId", credentials.accessKeyId) - assertEquals("testSessionToken", credentials.sessionToken) - assertEquals(expirationTime, credentials.expiration) + every { + anyConstructed().put( + ACCESS_KEY_ID, + credentials.accessKeyId!! + ) + } just runs + every { + anyConstructed().put( + SECRET_KEY, + credentials.secretKey!! + ) + } just runs + every { + anyConstructed().put( + SESSION_TOKEN, + credentials.sessionToken!! + ) + } just runs + every { + anyConstructed().put( + EXPIRATION, + credentials.expiration.toString() + ) + } just runs + + CognitoCredentialsProvider(context,"", credentials) + + verify { + anyConstructed().put( + ACCESS_KEY_ID, + credentials.accessKeyId!! + ) + anyConstructed().put(SECRET_KEY, credentials.secretKey!!) + anyConstructed().put( + SESSION_TOKEN, + credentials.sessionToken!! + ) + } + } - credentials = provider.resolve() + @Test + fun `constructor without credentials throws when no credentials found`() { + every { anyConstructed().get(ACCESS_KEY_ID) } returns null - // Verify the returned credentials match what we expected - assertEquals("testAccessKeyId", credentials.accessKeyId) - assertEquals("testSessionToken", credentials.sessionToken) - assertEquals(expirationTime, credentials.expiration) + assertFailsWith { + CognitoCredentialsProvider(context) } } + @Test - fun `If credentials are expired they will trigger a refresh`() { - val expirationTime = - Instant.fromEpochMilliseconds(Instant.now().epochMilliseconds - 10000) - val expirationTime2 = - Instant.fromEpochMilliseconds(Instant.now().epochMilliseconds + 20000) - - // Create mock CognitoIdentityClient - mockkConstructor(CognitoCredentialsProvider::class) - coEvery { - anyConstructed().fetchCognitoCredentials() - } returns Credentials.invoke { - expiration = expirationTime - secretKey = "testSecretKey" - accessKeyId = "testAccessKeyId" - sessionToken = "testSessionToken" - } andThen Credentials.invoke { - expiration = expirationTime2 - secretKey = "testSecretKey2" - accessKeyId = "testAccessKeyId2" - sessionToken = "testSessionToken2" + fun `getCachedCredentials returns null when not all credentials are found`() { + every { anyConstructed().get(ACCESS_KEY_ID) } returns "accessKeyId" + every { anyConstructed().get(SECRET_KEY) } returns null + every { anyConstructed().get(SESSION_TOKEN) } returns "sessionToken" + every { anyConstructed().get(EXPIRATION) } returns "1234567890.0" + + val provider = try { + CognitoCredentialsProvider(context) + } catch (e: Exception) { + null } - val provider = CognitoCredentialsProvider("identityPool", "us-east-1") - - runBlocking { - - // Call resolve - var credentials = provider.resolve() - - // Verify the returned credentials match what we expected - assertEquals("testAccessKeyId", credentials.accessKeyId) - assertEquals("testSessionToken", credentials.sessionToken) - assertEquals(expirationTime, credentials.expiration) + val cachedCredentials = provider?.getCachedCredentials() - credentials = provider.resolve() + assertNull(cachedCredentials) + } - // Verify the returned credentials match what we expected - assertEquals("testAccessKeyId2", credentials.accessKeyId) - assertEquals("testSessionToken2", credentials.sessionToken) - assertEquals(expirationTime2, credentials.expiration) + @Test + fun `getCachedCredentials throws when not initialized`() { + val provider = CognitoCredentialsProvider( + context, + "", + Credentials.invoke { + accessKeyId = "accessKeyId" + expiration = Instant.now() + secretKey = "secretKey" + sessionToken = "sessionToken" + } + ) + + every { anyConstructed().get(ACCESS_KEY_ID) } throws Exception("Not initialized") + + assertFailsWith { + provider.getCachedCredentials() } } + + @Test + fun `clearCredentials clears the stored credentials`() { + val provider = CognitoCredentialsProvider( + context, + "", + Credentials.invoke { + accessKeyId = "accessKeyId" + expiration = Instant.now() + secretKey = "secretKey" + sessionToken = "sessionToken" + } + ) + + provider.clearCredentials() + + verify { anyConstructed().remove(any()) } + } } diff --git a/library/src/test/java/software/amazon/location/auth/CustomCredentialsProviderTest.kt b/library/src/test/java/software/amazon/location/auth/CustomCredentialsProviderTest.kt new file mode 100644 index 0000000..893e70b --- /dev/null +++ b/library/src/test/java/software/amazon/location/auth/CustomCredentialsProviderTest.kt @@ -0,0 +1,80 @@ +package software.amazon.location.auth + +import android.content.Context +import aws.sdk.kotlin.services.cognitoidentity.CognitoIdentityClient +import aws.sdk.kotlin.services.cognitoidentity.model.Credentials +import aws.sdk.kotlin.services.location.LocationClient +import aws.smithy.kotlin.runtime.auth.awscredentials.CredentialsProvider +import aws.smithy.kotlin.runtime.time.Instant +import aws.smithy.kotlin.runtime.time.epochMilliseconds +import aws.smithy.kotlin.runtime.time.fromEpochMilliseconds +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.runs +import junit.framework.TestCase.assertNotNull +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import software.amazon.location.auth.utils.AwsRegions +import software.amazon.location.auth.utils.Constants.ACCESS_KEY_ID +import software.amazon.location.auth.utils.Constants.EXPIRATION +import software.amazon.location.auth.utils.Constants.IDENTITY_POOL_ID +import software.amazon.location.auth.utils.Constants.METHOD +import software.amazon.location.auth.utils.Constants.REGION +import software.amazon.location.auth.utils.Constants.SECRET_KEY +import software.amazon.location.auth.utils.Constants.SESSION_TOKEN +import software.amazon.location.auth.utils.Constants.TEST_IDENTITY_POOL_ID + +class CustomCredentialsProviderTest { + private lateinit var context: Context + private lateinit var locationClient: LocationClient + private lateinit var cognitoIdentityClient: CognitoIdentityClient + private lateinit var cognitoCredentialsProvider: CognitoCredentialsProvider + private lateinit var credentialsProvider: CredentialsProvider + + @Before + fun setUp() { + context = mockk(relaxed = true) + cognitoIdentityClient = mockk(relaxed = true) + cognitoCredentialsProvider = mockk(relaxed = true) + credentialsProvider = mockk(relaxed = true) + mockkConstructor(EncryptedSharedPreferences::class) + mockkConstructor(CognitoCredentialsProvider::class) + mockkConstructor(LocationCredentialsProvider::class) + every { anyConstructed().initEncryptedSharedPreferences() } just runs + + every { anyConstructed().generateCognitoIdentityClient("us-east-1") } returns cognitoIdentityClient + every { anyConstructed().put(any(), any()) } just runs + every { anyConstructed().get(REGION) } returns "us-east-1" + every { anyConstructed().clear() } just runs + every { anyConstructed().remove(any()) } just runs + } + + @Test + fun `getCredentialsProvider returns cognito provider successfully with custom credential`() { + val expirationTime = + Instant.fromEpochMilliseconds(Instant.now().epochMilliseconds + 10000) // 10 seconds in the future + val mockCredentials = + Credentials.invoke { + expiration = expirationTime + secretKey = "test" + accessKeyId = "test" + sessionToken = "test" + } + every { anyConstructed().getCachedCredentials() } returns mockCredentials + every { anyConstructed().get(METHOD) } returns "" + every { anyConstructed().get(IDENTITY_POOL_ID) } returns TEST_IDENTITY_POOL_ID + every { anyConstructed().get(ACCESS_KEY_ID) } returns "test" + every { anyConstructed().get(SECRET_KEY) } returns "test" + every { anyConstructed().get(SESSION_TOKEN) } returns "test" + every { anyConstructed().get(EXPIRATION) } returns "11111" + val provider = + LocationCredentialsProvider(context, TEST_IDENTITY_POOL_ID, AwsRegions.US_EAST_1) + runBlocking { + provider.verifyAndRefreshCredentials() + assertNotNull(provider.getCredentialsProvider()) + } + } +} diff --git a/library/src/test/java/software/amazon/location/auth/LocationCredentialsProviderTest.kt b/library/src/test/java/software/amazon/location/auth/LocationCredentialsProviderTest.kt index 6afbf78..3f642c7 100644 --- a/library/src/test/java/software/amazon/location/auth/LocationCredentialsProviderTest.kt +++ b/library/src/test/java/software/amazon/location/auth/LocationCredentialsProviderTest.kt @@ -3,127 +3,288 @@ package software.amazon.location.auth -import aws.sdk.kotlin.runtime.auth.credentials.StaticCredentialsProvider +import android.content.Context +import aws.sdk.kotlin.services.cognitoidentity.CognitoIdentityClient import aws.sdk.kotlin.services.cognitoidentity.model.Credentials +import aws.sdk.kotlin.services.cognitoidentity.model.GetCredentialsForIdentityRequest +import aws.sdk.kotlin.services.cognitoidentity.model.GetCredentialsForIdentityResponse +import aws.sdk.kotlin.services.cognitoidentity.model.GetIdRequest +import aws.sdk.kotlin.services.cognitoidentity.model.GetIdResponse import aws.sdk.kotlin.services.geomaps.GeoMapsClient import aws.sdk.kotlin.services.geoplaces.GeoPlacesClient import aws.sdk.kotlin.services.georoutes.GeoRoutesClient import aws.sdk.kotlin.services.location.LocationClient +import aws.smithy.kotlin.runtime.auth.awscredentials.CredentialsProvider import aws.smithy.kotlin.runtime.time.Instant import aws.smithy.kotlin.runtime.time.epochMilliseconds import aws.smithy.kotlin.runtime.time.fromEpochMilliseconds import io.mockk.coEvery import io.mockk.every +import io.mockk.just +import io.mockk.mockk import io.mockk.mockkConstructor -import junit.framework.TestCase.assertNull +import io.mockk.runs +import io.mockk.verify +import junit.framework.TestCase.assertFalse import junit.framework.TestCase.assertNotNull -import junit.framework.TestCase.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test import software.amazon.location.auth.utils.AwsRegions +import software.amazon.location.auth.utils.Constants.ACCESS_KEY_ID +import software.amazon.location.auth.utils.Constants.API_KEY +import software.amazon.location.auth.utils.Constants.API_KEY_TEST +import software.amazon.location.auth.utils.Constants.EXPIRATION +import software.amazon.location.auth.utils.Constants.IDENTITY_POOL_ID +import software.amazon.location.auth.utils.Constants.METHOD +import software.amazon.location.auth.utils.Constants.REGION +import software.amazon.location.auth.utils.Constants.SECRET_KEY +import software.amazon.location.auth.utils.Constants.SESSION_TOKEN +import software.amazon.location.auth.utils.Constants.TEST_API_KEY import software.amazon.location.auth.utils.Constants.TEST_IDENTITY_POOL_ID class LocationCredentialsProviderTest { - private lateinit var expirationTime: Instant + private lateinit var context: Context + private lateinit var cognitoIdentityClient: CognitoIdentityClient + private lateinit var cognitoCredentialsProvider: CognitoCredentialsProvider + private lateinit var credentialsProvider: CredentialsProvider @Before fun setUp() { - expirationTime = Instant.fromEpochMilliseconds(Instant.now().epochMilliseconds + 10000) // 10 seconds in the future + context = mockk(relaxed = true) + cognitoIdentityClient = mockk(relaxed = true) + cognitoCredentialsProvider = mockk(relaxed = true) + credentialsProvider = mockk(relaxed = true) + mockkConstructor(EncryptedSharedPreferences::class) + mockkConstructor(CognitoCredentialsProvider::class) + mockkConstructor(LocationCredentialsProvider::class) + every { anyConstructed().initEncryptedSharedPreferences() } just runs + + every { anyConstructed().generateCognitoIdentityClient("us-east-1") } returns cognitoIdentityClient + every { anyConstructed().put(any(), any()) } just runs + every { anyConstructed().get(REGION) } returns "us-east-1" + every { anyConstructed().clear() } just runs + every { anyConstructed().remove(any()) } just runs + } + + @Test + fun `constructor with cached credentials for Cognito initializes correctly`() { + every { anyConstructed().get(METHOD) } returns "cognito" + every { anyConstructed().get(ACCESS_KEY_ID) } returns "test" + every { anyConstructed().get(SECRET_KEY) } returns "test" + every { anyConstructed().get(SESSION_TOKEN) } returns "test" + every { anyConstructed().get(EXPIRATION) } returns "11111" + every { anyConstructed().get(IDENTITY_POOL_ID) } returns TEST_IDENTITY_POOL_ID + val provider = LocationCredentialsProvider(context) + assertNotNull(provider) } @Test - fun `Constructs successfully with identity pool and region`() { + fun `getCredentialsProvider returns cognito provider successfully`() { + val expirationTime = + Instant.fromEpochMilliseconds(Instant.now().epochMilliseconds + 10000) // 10 seconds in the future val mockCredentials = Credentials.invoke { expiration = expirationTime - secretKey = "testSecretKey" - accessKeyId = "testAccessKeyId" - sessionToken = "testSessionToken" + secretKey = "test" + accessKeyId = "test" + sessionToken = "test" } - mockkConstructor(CognitoCredentialsProvider::class) - coEvery { - anyConstructed().fetchCognitoCredentials() - } returns mockCredentials - - val provider = LocationCredentialsProvider(TEST_IDENTITY_POOL_ID, AwsRegions.US_EAST_1) - assertNotNull(provider) - assertEquals(provider.getMethod(), "cognito") - assertNull(provider.getApiKey()) - assertNotNull(provider.getCredentialsProvider()) - + every { anyConstructed().getCachedCredentials() } returns mockCredentials + every { anyConstructed().get(METHOD) } returns "" + every { anyConstructed().get(IDENTITY_POOL_ID) } returns TEST_IDENTITY_POOL_ID + every { anyConstructed().get(ACCESS_KEY_ID) } returns "test" + every { anyConstructed().get(SECRET_KEY) } returns "test" + every { anyConstructed().get(SESSION_TOKEN) } returns "test" + every { anyConstructed().get(EXPIRATION) } returns "11111" + val provider = + LocationCredentialsProvider(context, TEST_IDENTITY_POOL_ID, AwsRegions.US_EAST_1) runBlocking { - val credentials = provider.getCredentials() - assertEquals(credentials.accessKeyId, mockCredentials.accessKeyId) - assertEquals(credentials.secretAccessKey, mockCredentials.secretKey) - assertEquals(credentials.sessionToken, mockCredentials.sessionToken) + provider.verifyAndRefreshCredentials() + assertNotNull(provider.getCredentialsProvider()) } } - @Test - fun `Constructs successfully with custom CredentialsProvider`() { - val credentialsProvider = StaticCredentialsProvider( - aws.smithy.kotlin.runtime.auth.awscredentials.Credentials.invoke( - accessKeyId = "testAccessKey", - secretAccessKey = "testSecretAccessKey", - sessionToken = "testSessionToken", + fun `initializeLocationClient_with_pool_id`() { + val expirationTime = + Instant.fromEpochMilliseconds(Instant.now().epochMilliseconds + 10000) // 10 seconds in the future + val mockCredentials = + Credentials.invoke { expiration = expirationTime - )) - val provider = LocationCredentialsProvider(credentialsProvider, AwsRegions.US_EAST_1) - assertNotNull(provider) - assertEquals(provider.getMethod(), "custom") - assertNull(provider.getApiKey()) - assertNotNull(provider.getCredentialsProvider()) + secretKey = "test" + accessKeyId = "test" + sessionToken = "test" + } + every { anyConstructed().getCachedCredentials() } returns mockCredentials + every { anyConstructed().get(METHOD) } returns "" + every { anyConstructed().get(IDENTITY_POOL_ID) } returns TEST_IDENTITY_POOL_ID + every { anyConstructed().get(ACCESS_KEY_ID) } returns "test" + every { anyConstructed().get(SECRET_KEY) } returns "test" + every { anyConstructed().get(SESSION_TOKEN) } returns "test" + every { anyConstructed().get(EXPIRATION) } returns "11111" + val provider = + LocationCredentialsProvider(context, TEST_IDENTITY_POOL_ID, AwsRegions.US_EAST_1) + runBlocking { + provider.initializeLocationClient(credentialsProvider) + //assertNotNull(provider.getLocationClient()) + } + } + @Test + fun `initializeLocationClient_with_api_key`() { + every { anyConstructed().get(METHOD) } returns "apiKey" + every { anyConstructed().get(API_KEY_TEST) } returns TEST_API_KEY + val provider = + LocationCredentialsProvider(context, AwsRegions.US_EAST_1, TEST_API_KEY) runBlocking { - val credentials = provider.getCredentials() - assertEquals(credentials.accessKeyId, credentialsProvider.credentials.accessKeyId) - assertEquals(credentials.secretAccessKey, credentialsProvider.credentials.secretAccessKey) - assertEquals(credentials.sessionToken, credentialsProvider.credentials.sessionToken) + provider.initializeLocationClient() + //assertNotNull(provider.getLocationClient()) + assertNotNull(provider.getApiKeyProvider()) } } @Test - fun `Constructs successfully with ApiKey`() { - val apiKey = "TestApiKey" - val provider = LocationCredentialsProvider(AwsRegions.US_EAST_1, apiKey) - assertNotNull(provider) - assertEquals(provider.getMethod(), "apiKey") - assertEquals(provider.getApiKey(), apiKey) - assertNotNull(provider.getCredentialsProvider()) + fun `isCredentialsValid returns true when credentials are valid`() { + val expirationTime = + Instant.fromEpochMilliseconds(Instant.now().epochMilliseconds + 10000) // 10 seconds in the future + val mockCredentials = + Credentials.invoke { + expiration = expirationTime + secretKey = "test" + accessKeyId = "test" + sessionToken = "test" + } + every { anyConstructed().getCachedCredentials() } returns mockCredentials + every { anyConstructed().get(METHOD) } returns "" + every { anyConstructed().get(METHOD) } returns "cognito" + every { anyConstructed().get(ACCESS_KEY_ID) } returns "test" + every { anyConstructed().get(SECRET_KEY) } returns "test" + every { anyConstructed().get(SESSION_TOKEN) } returns "test" + every { anyConstructed().get(EXPIRATION) } returns "11111" + every { anyConstructed().get(IDENTITY_POOL_ID) } returns TEST_IDENTITY_POOL_ID + val provider = + LocationCredentialsProvider(context, TEST_IDENTITY_POOL_ID, AwsRegions.US_EAST_1) + runBlocking { + provider.verifyAndRefreshCredentials() + provider.refresh() + val result = provider.isCredentialsValid() + assertTrue(result) + } + } + @Test + fun `isCredentialsValid returns false when credentials are expired`() { + val expirationTime = + Instant.fromEpochMilliseconds(Instant.now().epochMilliseconds - 10000) // 10 seconds in the past + val mockCredentials = + mockk { + every { expiration } returns expirationTime + } + every { anyConstructed().getCachedCredentials() } returns mockCredentials + every { anyConstructed().get(METHOD) } returns "cognito" + every { anyConstructed().get(ACCESS_KEY_ID) } returns "test" + every { anyConstructed().get(SECRET_KEY) } returns "test" + every { anyConstructed().get(SESSION_TOKEN) } returns "test" + every { anyConstructed().get(EXPIRATION) } returns "11111" + every { anyConstructed().get(IDENTITY_POOL_ID) } returns TEST_IDENTITY_POOL_ID + val provider = + LocationCredentialsProvider(context, TEST_IDENTITY_POOL_ID, AwsRegions.US_EAST_1) runBlocking { - val credentials = provider.getCredentials() - assertEquals(credentials.accessKeyId, "") - assertEquals(credentials.secretAccessKey, "") - assertNull(credentials.sessionToken) + provider.verifyAndRefreshCredentials() + provider.refresh() + val result = provider.isCredentialsValid() + assertFalse(result) } } @Test - fun `getLocationClientConfig can be used to successfully construct a LocationClient`() { - val apiKey = "TestApiKey" - val provider = LocationCredentialsProvider(AwsRegions.US_EAST_1, apiKey) - assertNotNull(provider.getLocationClientConfig()) + fun `clear successfully clears cognito credentials`() { + every { anyConstructed().get(METHOD) } returns "cognito" + every { anyConstructed().get(ACCESS_KEY_ID) } returns "test" + every { anyConstructed().get(SECRET_KEY) } returns "test" + every { anyConstructed().get(SESSION_TOKEN) } returns "test" + every { anyConstructed().get(EXPIRATION) } returns "11111" + every { anyConstructed().get(IDENTITY_POOL_ID) } returns TEST_IDENTITY_POOL_ID + val provider = + LocationCredentialsProvider(context, TEST_IDENTITY_POOL_ID, AwsRegions.US_EAST_1) + runBlocking { + provider.verifyAndRefreshCredentials() + provider.clear() + } } - fun `getGeoMapsClientConfig can be used to successfully construct a GeoMapsClient`() { - val apiKey = "TestApiKey" - val provider = LocationCredentialsProvider(AwsRegions.US_EAST_1, apiKey) - assertNotNull(provider.getGeoMapsClientConfig()) + @Test + fun `check credentials`() { + every { anyConstructed().get(METHOD) } returns "cognito" + every { anyConstructed().get(IDENTITY_POOL_ID) } returns TEST_IDENTITY_POOL_ID + val provider = + LocationCredentialsProvider(context, TEST_IDENTITY_POOL_ID, AwsRegions.US_EAST_1) + runBlocking { + provider.verifyAndRefreshCredentials() + } } - fun `getGeoPlacesClientConfig can be used to successfully construct a GeoPlacesClient`() { - val apiKey = "TestApiKey" - val provider = LocationCredentialsProvider(AwsRegions.US_EAST_1, apiKey) - assertNotNull(provider.getGeoPlacesClientConfig()) + @Test + fun `get Location Client`() { + every { anyConstructed().get(METHOD) } returns "cognito" + every { anyConstructed().get(ACCESS_KEY_ID) } returns "test" + every { anyConstructed().get(SECRET_KEY) } returns "test" + every { anyConstructed().get(SESSION_TOKEN) } returns "test" + every { anyConstructed().get(EXPIRATION) } returns "11111" + every { anyConstructed().get(IDENTITY_POOL_ID) } returns TEST_IDENTITY_POOL_ID + val identityId = "test-identity-id" + val credentials = + Credentials { + accessKeyId = "test-access-key" + secretKey = "test-secret-key" + sessionToken = "test-session-token" + } + + every { anyConstructed().get(METHOD) } returns "cognito" + every { anyConstructed().get(IDENTITY_POOL_ID) } returns TEST_IDENTITY_POOL_ID + val provider = + LocationCredentialsProvider(context, TEST_IDENTITY_POOL_ID, AwsRegions.US_EAST_1) + coEvery { cognitoIdentityClient.getId(any()) } returns + GetIdResponse { + this.identityId = identityId + } + + coEvery { cognitoIdentityClient.getCredentialsForIdentity(any()) } returns + GetCredentialsForIdentityResponse { + this.credentials = credentials + } + runBlocking { + provider.verifyAndRefreshCredentials() + //val locationClient = provider.getLocationClient() + //assertNotNull(locationClient) + } } - fun `getGeoRoutesClientConfig can be used to successfully construct a GeoRoutesClient`() { - val apiKey = "TestApiKey" - val provider = LocationCredentialsProvider(AwsRegions.US_EAST_1, apiKey) - assertNotNull(provider.getGeoRoutesClientConfig()) + @Test + fun `constructor with cached cognito credentials throws exception on missing data`() { + every { anyConstructed().get(METHOD) } returns "cognito" + every { anyConstructed().get(IDENTITY_POOL_ID) } returns null // Simulate missing data + assertFailsWith { LocationCredentialsProvider(context) } } + @Test + fun `verify SecurePreferences interactions for cognito initialization`() { + LocationCredentialsProvider(context, TEST_IDENTITY_POOL_ID, AwsRegions.US_EAST_1) + verify(exactly = 1) { anyConstructed().put(METHOD, "cognito") } + verify(exactly = 1) { + anyConstructed().put( + IDENTITY_POOL_ID, + TEST_IDENTITY_POOL_ID, + ) + } + verify(exactly = 1) { + anyConstructed().put( + REGION, + AwsRegions.US_EAST_1.regionName, + ) + } + } } diff --git a/library/src/test/java/software/amazon/location/auth/utils/Constants.kt b/library/src/test/java/software/amazon/location/auth/utils/Constants.kt index 508a635..a583163 100644 --- a/library/src/test/java/software/amazon/location/auth/utils/Constants.kt +++ b/library/src/test/java/software/amazon/location/auth/utils/Constants.kt @@ -1,6 +1,14 @@ package software.amazon.location.auth.utils object Constants { + const val API_KEY: String = "api_key_test" + const val ACCESS_KEY_ID = "accessKeyId" + const val SECRET_KEY = "secretKey" + const val SESSION_TOKEN = "sessionToken" + const val EXPIRATION = "expiration" + const val METHOD = "method" + const val IDENTITY_POOL_ID = "identityPoolId" + const val REGION = "region" const val HEADER_HOST = "Host" const val HEADER_X_AMZ_SECURITY_TOKEN = "x-amz-security-token" const val HEADER_AUTHORIZATION = "Authorization" @@ -9,4 +17,5 @@ object Constants { const val TEST_URL1 = "https://example.com" const val TEST_IDENTITY_POOL_ID = "us-east-1:dummyIdentityPoolId" const val TEST_API_KEY = "dummyApiKey" + const val API_KEY_TEST = "apiKey" } \ No newline at end of file