diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/AWSCognitoAuthPlugin.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/AWSCognitoAuthPlugin.kt index e346a2bbc..fbd31bdb1 100644 --- a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/AWSCognitoAuthPlugin.kt +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/AWSCognitoAuthPlugin.kt @@ -61,6 +61,7 @@ import com.amplifyframework.core.Action import com.amplifyframework.core.Amplify import com.amplifyframework.core.Consumer import com.amplifyframework.core.category.CategoryType +import com.amplifyframework.core.configuration.AmplifyOutputsData import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers @@ -128,36 +129,52 @@ class AWSCognitoAuthPlugin : AuthPlugin() { override fun configure(pluginConfiguration: JSONObject, context: Context) { pluginConfigurationJSON = pluginConfiguration try { - val configuration = AuthConfiguration.fromJson(pluginConfiguration) - val credentialStoreClient = CredentialStoreClient(configuration, context, logger) - val authEnvironment = AuthEnvironment( - context, - configuration, - AWSCognitoAuthService.fromConfiguration(configuration), - credentialStoreClient, - configuration.userPool?.let { UserContextDataProvider(context, it.poolId!!, it.appClient!!) }, - HostedUIClient.create(context, configuration.oauth, logger), - logger - ) - - val authStateMachine = AuthStateMachine(authEnvironment) - realPlugin = RealAWSCognitoAuthPlugin( - configuration, - authEnvironment, - authStateMachine, - logger + configure(AuthConfiguration.fromJson(pluginConfiguration), context) + } catch (exception: Exception) { + throw ConfigurationException( + "Failed to configure AWSCognitoAuthPlugin.", + "Make sure your amplifyconfiguration.json is valid.", + exception ) + } + } - blockQueueChannelWhileConfiguring() + @InternalAmplifyApi + override fun configure(amplifyOutputs: AmplifyOutputsData, context: Context) { + try { + configure(AuthConfiguration.from(amplifyOutputs), context) } catch (exception: Exception) { throw ConfigurationException( "Failed to configure AWSCognitoAuthPlugin.", - "Make sure your amplifyconfiguration.json is valid.", + "Make sure your amplify-outputs.json is valid.", exception ) } } + private fun configure(configuration: AuthConfiguration, context: Context) { + val credentialStoreClient = CredentialStoreClient(configuration, context, logger) + val authEnvironment = AuthEnvironment( + context, + configuration, + AWSCognitoAuthService.fromConfiguration(configuration), + credentialStoreClient, + configuration.userPool?.let { UserContextDataProvider(context, it.poolId!!, it.appClient!!) }, + HostedUIClient.create(context, configuration.oauth, logger), + logger + ) + + val authStateMachine = AuthStateMachine(authEnvironment) + realPlugin = RealAWSCognitoAuthPlugin( + configuration, + authEnvironment, + authStateMachine, + logger + ) + + blockQueueChannelWhileConfiguring() + } + // Auth configuration is an async process. Wait until the state machine is in a settled state before attempting // to process any customer calls private fun blockQueueChannelWhileConfiguring() { diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/AuthConfiguration.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/AuthConfiguration.kt index 71906bbb9..0a18e2663 100644 --- a/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/AuthConfiguration.kt +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/auth/cognito/AuthConfiguration.kt @@ -19,6 +19,8 @@ import androidx.annotation.IntRange import com.amplifyframework.annotations.InternalAmplifyApi import com.amplifyframework.auth.AuthUserAttributeKey import com.amplifyframework.auth.cognito.options.AuthFlowType +import com.amplifyframework.auth.exceptions.ConfigurationException +import com.amplifyframework.core.configuration.AmplifyOutputsData import com.amplifyframework.statemachine.codegen.data.IdentityPoolConfiguration import com.amplifyframework.statemachine.codegen.data.OauthConfiguration import com.amplifyframework.statemachine.codegen.data.UserPoolConfiguration @@ -109,6 +111,50 @@ data class AuthConfiguration internal constructor( passwordProtectionSettings = getPasswordProtectionSettings(authConfig) ) } + + fun from(amplifyOutputs: AmplifyOutputsData): AuthConfiguration { + val auth = amplifyOutputs.auth ?: throw ConfigurationException( + "Missing Auth configuration", + "Ensure the auth category is properly configured" + ) + + val oauth = auth.oauth?.let { + OauthConfiguration( + appClient = auth.userPoolClientId, + appSecret = null, // Not supported in Gen2 + domain = it.domain, + scopes = it.scopes.toSet(), + // Note: Gen2 config gives an array for these values, while Gen1 is just a String. In Gen1 + // if you specify multiple URIs the CLI will join them to a comma-delimited string in the json. + // We are matching that behaviour here for Gen2. + signInRedirectURI = it.redirectSignInUri.joinToString(","), + signOutRedirectURI = it.redirectSignOutUri.joinToString(",") + ) + } + + val identityPool = auth.identityPoolId?.let { + IdentityPoolConfiguration(region = auth.awsRegion, poolId = it) + } + + return AuthConfiguration( + userPool = UserPoolConfiguration( + region = auth.awsRegion, + endpoint = null, // Not supported in Gen2 + poolId = auth.userPoolId, + appClient = auth.userPoolClientId, + appClientSecret = null, // Not supported in Gen2 + pinpointAppId = null // Not supported in Gen2 + ), + identityPool = identityPool, + oauth = oauth, + authFlowType = auth.authenticationFlowType.toConfigType(), + signUpAttributes = auth.standardRequiredAttributes, + usernameAttributes = auth.usernameAttributes.map { it.toConfigType() }, + verificationMechanisms = auth.userVerificationTypes.map { it.toConfigType() }, + passwordProtectionSettings = auth.passwordPolicy?.toConfigType() + ) + } + private fun getAuthenticationFlowType(authType: String?): AuthFlowType { return if (!authType.isNullOrEmpty() && AuthFlowType.values().any { it.name == authType }) { AuthFlowType.valueOf(authType) @@ -135,5 +181,29 @@ data class AuthConfiguration internal constructor( private inline fun JSONArray.map(func: JSONArray.(Int) -> T) = List(length()) { func(it) } + + private fun AmplifyOutputsData.Auth.AuthenticationFlowType.toConfigType() = when (this) { + AmplifyOutputsData.Auth.AuthenticationFlowType.USER_SRP_AUTH -> AuthFlowType.USER_SRP_AUTH + AmplifyOutputsData.Auth.AuthenticationFlowType.CUSTOM_AUTH -> AuthFlowType.CUSTOM_AUTH + } + + private fun AmplifyOutputsData.Auth.UsernameAttributes.toConfigType() = when (this) { + AmplifyOutputsData.Auth.UsernameAttributes.EMAIL -> UsernameAttribute.Email + AmplifyOutputsData.Auth.UsernameAttributes.PHONE -> UsernameAttribute.PhoneNumber + AmplifyOutputsData.Auth.UsernameAttributes.USERNAME -> UsernameAttribute.Username + } + + private fun AmplifyOutputsData.Auth.UserVerificationTypes.toConfigType() = when (this) { + AmplifyOutputsData.Auth.UserVerificationTypes.EMAIL -> VerificationMechanism.Email + AmplifyOutputsData.Auth.UserVerificationTypes.PHONE -> VerificationMechanism.PhoneNumber + } + + private fun AmplifyOutputsData.Auth.PasswordPolicy.toConfigType() = PasswordProtectionSettings( + length = minLength ?: 6, + requiresNumber = requireNumbers ?: false, + requiresSpecial = requireSymbols ?: false, + requiresUpper = requireUppercase ?: false, + requiresLower = requireLowercase ?: false + ) } } diff --git a/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/AuthConfigurationTest.kt b/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/AuthConfigurationTest.kt new file mode 100644 index 000000000..23844763a --- /dev/null +++ b/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/AuthConfigurationTest.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package com.amplifyframework.auth.cognito + +import com.amplifyframework.auth.AuthUserAttributeKey +import com.amplifyframework.auth.cognito.options.AuthFlowType +import com.amplifyframework.auth.exceptions.ConfigurationException +import com.amplifyframework.core.configuration.AmplifyOutputsData +import com.amplifyframework.testutils.configuration.amplifyOutputsData +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.junit.Test + +class AuthConfigurationTest { + @Test + fun `configures with amplify outputs`() { + val data = amplifyOutputsData { + auth { + awsRegion = "test-region" + userPoolId = "userpool" + userPoolClientId = "userpool-client" + identityPoolId = "identity-pool" + authenticationFlowType = AmplifyOutputsData.Auth.AuthenticationFlowType.CUSTOM_AUTH + passwordPolicy { + requireLowercase = true + requireSymbols = true + } + oauth { + domain = "https://test.com" + identityProviders += AmplifyOutputsData.Auth.Oauth.IdentityProviders.GOOGLE + scopes += listOf("myScope", "myScope2") + redirectSignInUri += "https://test.com/signin" + redirectSignOutUri += "https://test.com/signout" + responseType = AmplifyOutputsData.Auth.Oauth.ResponseType.TOKEN + } + standardRequiredAttributes += AuthUserAttributeKey.email() + usernameAttributes += AmplifyOutputsData.Auth.UsernameAttributes.EMAIL + userVerificationTypes += AmplifyOutputsData.Auth.UserVerificationTypes.EMAIL + mfaConfiguration = AmplifyOutputsData.Auth.MfaConfiguration.REQUIRED + mfaMethods += AmplifyOutputsData.Auth.MfaMethods.SMS + } + } + + val configuration = AuthConfiguration.from(data) + + configuration.authFlowType shouldBe AuthFlowType.CUSTOM_AUTH + configuration.userPool.shouldNotBeNull().run { + region shouldBe "test-region" + poolId shouldBe "userpool" + appClient shouldBe "userpool-client" + appClientSecret.shouldBeNull() + endpoint.shouldBeNull() + pinpointAppId.shouldBeNull() + } + configuration.oauth.shouldNotBeNull().run { + appClient shouldBe "userpool-client" + appSecret.shouldBeNull() + domain shouldBe "https://test.com" + scopes shouldContainExactly listOf("myScope", "myScope2") + signInRedirectURI shouldBe "https://test.com/signin" + signOutRedirectURI shouldBe "https://test.com/signout" + } + configuration.identityPool.shouldNotBeNull().run { + region shouldBe "test-region" + poolId shouldBe "identity-pool" + } + configuration.passwordProtectionSettings.shouldNotBeNull().run { + length shouldBe 6 + requiresLower.shouldBeTrue() + requiresSpecial.shouldBeTrue() + requiresUpper.shouldBeFalse() + requiresNumber.shouldBeFalse() + } + configuration.signUpAttributes shouldContainExactly listOf(AuthUserAttributeKey.email()) + configuration.usernameAttributes shouldContainExactly listOf(UsernameAttribute.Email) + configuration.verificationMechanisms shouldContainExactly listOf(VerificationMechanism.Email) + } + + @Test + fun `configures with minimal amplify outputs`() { + val data = amplifyOutputsData { + auth { + awsRegion = "test-region" + userPoolId = "userpool" + userPoolClientId = "userpool-client" + authenticationFlowType = AmplifyOutputsData.Auth.AuthenticationFlowType.CUSTOM_AUTH + } + } + + val configuration = AuthConfiguration.from(data) + + configuration.authFlowType shouldBe AuthFlowType.CUSTOM_AUTH + configuration.userPool.shouldNotBeNull().run { + region shouldBe "test-region" + poolId shouldBe "userpool" + appClient shouldBe "userpool-client" + } + + configuration.oauth.shouldBeNull() + configuration.passwordProtectionSettings.shouldBeNull() + configuration.identityPool.shouldBeNull() + } + + @Test + fun `throws exception if auth is not configured in amplify outputs`() { + val data = amplifyOutputsData { + // do not configure auth + } + + shouldThrow { + AuthConfiguration.from(data) + } + } +} diff --git a/testutils/src/main/java/com/amplifyframework/testutils/configuration/AmplifyOutputsDataBuilder.kt b/testutils/src/main/java/com/amplifyframework/testutils/configuration/AmplifyOutputsDataBuilder.kt index 09ddd404c..29adcad63 100644 --- a/testutils/src/main/java/com/amplifyframework/testutils/configuration/AmplifyOutputsDataBuilder.kt +++ b/testutils/src/main/java/com/amplifyframework/testutils/configuration/AmplifyOutputsDataBuilder.kt @@ -15,6 +15,7 @@ package com.amplifyframework.testutils.configuration +import com.amplifyframework.auth.AuthUserAttributeKey import com.amplifyframework.core.configuration.AmplifyOutputsData import kotlinx.serialization.json.JsonObject @@ -35,6 +36,10 @@ class AmplifyOutputsDataBuilder : AmplifyOutputsData { analytics = AnalyticsBuilder().apply(func) } + fun auth(func: AuthBuilder.() -> Unit) { + auth = AuthBuilder().apply(func) + } + fun geo(func: GeoBuilder.() -> Unit) { geo = GeoBuilder().apply(func) } @@ -53,6 +58,49 @@ class AnalyticsBuilder : AmplifyOutputsData.Analytics { override var appId: String = "analytics-app-id" } +class AuthBuilder : AmplifyOutputsData.Auth { + override var awsRegion: String = "us-east-1" + override var authenticationFlowType: AmplifyOutputsData.Auth.AuthenticationFlowType = + AmplifyOutputsData.Auth.AuthenticationFlowType.USER_SRP_AUTH + override var userPoolId: String = "user-pool-id" + override var userPoolClientId: String = "user-pool-client-id" + override var identityPoolId: String? = null + override var passwordPolicy: AmplifyOutputsData.Auth.PasswordPolicy? = null + override var oauth: AmplifyOutputsData.Auth.Oauth? = null + override val standardRequiredAttributes: MutableList = mutableListOf() + override val usernameAttributes: MutableList = mutableListOf() + override val userVerificationTypes: MutableList = mutableListOf() + override var unauthenticatedIdentitiesEnabled: Boolean = true + override var mfaConfiguration: AmplifyOutputsData.Auth.MfaConfiguration? = null + override val mfaMethods: MutableList = mutableListOf() + + fun passwordPolicy(func: PasswordPolicyBuilder.() -> Unit) { + passwordPolicy = PasswordPolicyBuilder().apply(func) + } + + fun oauth(func: OauthBuilder.() -> Unit) { + oauth = OauthBuilder().apply(func) + } +} + +class PasswordPolicyBuilder : AmplifyOutputsData.Auth.PasswordPolicy { + override var minLength: Int? = null + override var requireNumbers: Boolean? = null + override var requireLowercase: Boolean? = null + override var requireUppercase: Boolean? = null + override var requireSymbols: Boolean? = null +} + +class OauthBuilder : AmplifyOutputsData.Auth.Oauth { + override val identityProviders: MutableList = mutableListOf() + override var domain: String = "domain" + override val scopes: MutableList = mutableListOf() + override val redirectSignInUri: MutableList = mutableListOf() + override val redirectSignOutUri: MutableList = mutableListOf() + override var responseType: AmplifyOutputsData.Auth.Oauth.ResponseType = + AmplifyOutputsData.Auth.Oauth.ResponseType.CODE +} + class GeoBuilder : AmplifyOutputsData.Geo { override var awsRegion: String = "us-east-1" override var maps: AmplifyOutputsData.Geo.Maps? = null