diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ASF/CognitoUserPoolASF.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ASF/CognitoUserPoolASF.swift index fdfcb18e9d..4850d897ac 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ASF/CognitoUserPoolASF.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ASF/CognitoUserPoolASF.swift @@ -86,7 +86,7 @@ struct CognitoUserPoolASF: AdvancedSecurityBehavior { contextData: [String: String], userPoolId: String ) throws -> String { - let timestamp = String(format: "%lli", floor(Date().timeIntervalSince1970 * 1_000)) + let timestamp = String(format: "%lli", Int64(Date().timeIntervalSince1970 * 1_000)) let payload = [ "contextData": contextData, "username": username, diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/CredentialStore/MigrateLegacyCredentialStore.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/CredentialStore/MigrateLegacyCredentialStore.swift index b308395caa..fa821d1893 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/CredentialStore/MigrateLegacyCredentialStore.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/CredentialStore/MigrateLegacyCredentialStore.swift @@ -316,7 +316,12 @@ struct MigrateLegacyCredentialStore: Action { scopes: scopes ?? [], providerInfo: provider, presentationAnchor: nil, - preferPrivateSession: false + preferPrivateSession: false, + nonce: nil, + language: nil, + loginHint: nil, + prompt: nil, + resource: nil )) default: return .apiBased(.userSRP) diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/FetchAuthorizationSession/InformSessionError.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/FetchAuthorizationSession/InformSessionError.swift index ebf8b7a72f..94d99df1c6 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/FetchAuthorizationSession/InformSessionError.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/FetchAuthorizationSession/InformSessionError.swift @@ -21,7 +21,7 @@ struct InformSessionError: Action { logVerbose("\(#fileID) Starting execution", environment: environment) let event: AuthorizationEvent = switch error { case .service(let serviceError): - if isNotAuthorizedError(serviceError) { + if serviceError is AWSCognitoIdentityProvider.NotAuthorizedException { .init(eventType: .throwError( .sessionExpired(error: serviceError))) } else { @@ -34,11 +34,6 @@ struct InformSessionError: Action { logVerbose("\(#fileID) Sending event \(event.type)", environment: environment) await dispatcher.send(event) } - - func isNotAuthorizedError(_ error: Error) -> Bool { - error is AWSCognitoIdentity.NotAuthorizedException - || error is AWSCognitoIdentityProvider.NotAuthorizedException - } } extension InformSessionError: DefaultLogger { diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignUp/ConfirmSignUp.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignUp/ConfirmSignUp.swift index 53b3c3810d..627ef733f1 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignUp/ConfirmSignUp.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignUp/ConfirmSignUp.swift @@ -49,7 +49,7 @@ struct ConfirmSignUp: Action { await dispatcher.send(SignUpEvent(eventType: .signedUp(dataToSend, .init(.done)))) } } catch let error as SignUpError { - let errorEvent = SignUpEvent(eventType: .throwAuthError(error)) + let errorEvent = SignUpEvent(eventType: .throwAuthError(error, data)) logVerbose( "\(#fileID) Sending event \(errorEvent)", environment: environment @@ -57,7 +57,7 @@ struct ConfirmSignUp: Action { await dispatcher.send(errorEvent) } catch { let error = SignUpError.service(error: error) - let errorEvent = SignUpEvent(eventType: .throwAuthError(error)) + let errorEvent = SignUpEvent(eventType: .throwAuthError(error, data)) logVerbose( "\(#fileID) Sending event \(errorEvent)", environment: environment diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignUp/InitiateSignUp.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignUp/InitiateSignUp.swift index 760c8b2d82..f850a90aff 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignUp/InitiateSignUp.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignUp/InitiateSignUp.swift @@ -63,7 +63,7 @@ struct InitiateSignUp: Action { } await dispatcher.send(event) } catch let error as SignUpError { - let errorEvent = SignUpEvent(eventType: .throwAuthError(error)) + let errorEvent = SignUpEvent(eventType: .throwAuthError(error, data)) logVerbose( "\(#fileID) Sending event \(errorEvent)", environment: environment @@ -71,7 +71,7 @@ struct InitiateSignUp: Action { await dispatcher.send(errorEvent) } catch { let error = SignUpError.service(error: error) - let errorEvent = SignUpEvent(eventType: .throwAuthError(error)) + let errorEvent = SignUpEvent(eventType: .throwAuthError(error, data)) logVerbose( "\(#fileID) Sending event \(errorEvent)", environment: environment diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Options/AWSAuthWebUISignInOptions.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Options/AWSAuthWebUISignInOptions.swift index 4f0268600a..a9cc6dd434 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Options/AWSAuthWebUISignInOptions.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Options/AWSAuthWebUISignInOptions.swift @@ -27,12 +27,76 @@ public struct AWSAuthWebUISignInOptions { /// Safari always honors the request. public let preferPrivateSession: Bool + /// A random value that you can add to the request. The nonce value that you provide is included in the ID token + /// that Amazon Cognito issues. To guard against replay attacks, your app can inspect the nonce claim in the ID + /// token and compare it to the one you generated. + public let nonce: String? + + /// The language that you want to display user-interactive pages in + /// For more information, see Managed login localization - + /// https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-managed-login.html#managed-login-localization + public let language: String? + + /// A username prompt that you want to pass to the authorization server. You can collect a username, email + /// address or phone number from your user and allow the destination provider to pre-populate the user's + /// sign-in name. + public let loginHint: String? + + /// An OIDC parameter that controls authentication behavior for existing sessions. + public let prompt: [Prompt]? + + /// The identifier of a resource that you want to bind to the access token in the `aud` claim. When you include + /// this parameter, Amazon Cognito validates that the value is a URL and sets the audience of the resulting + /// access token to the requested resource. Values for this parameter must begin with "https://", "http://localhost", + /// or a custom URL scheme like "myapp://". + public let resource: String? + public init( idpIdentifier: String? = nil, - preferPrivateSession: Bool = false + preferPrivateSession: Bool = false, + nonce: String? = nil, + language: String? = nil, + loginHint: String? = nil, + prompt: [Prompt]? = nil, + resource: String? = nil ) { self.idpIdentifier = idpIdentifier self.preferPrivateSession = preferPrivateSession + self.nonce = nonce + self.language = language + self.loginHint = loginHint + self.prompt = prompt + self.resource = resource + } +} + +public extension AWSAuthWebUISignInOptions { + + enum Prompt: String, Codable { + /// Amazon Cognito silently continues authentication for users who have a valid authenticated session. + /// With this prompt, users can silently authenticate between different app clients in your user pool. + /// If the user is not already authenticated, the authorization server returns a login_required error. + case none + + /// Amazon Cognito requires users to re-authenticate even if they have an existing session. Send this + /// value when you want to verify the user's identity again. Authenticated users who have an existing + /// session can return to sign-in without invalidating that session. When a user who has an existing + /// session signs in again, Amazon Cognito assigns them a new session cookie. This parameter can also + /// be forwarded to your IdPs. IdPs that accept this parameter also request a new authentication + /// attempt from the user. + case login + + /// This value has no effect on local sign-in and must be submitted in requests that redirect to IdPs. + /// When included in your authorization request, this parameter adds prompt=select_account to the URL + /// path for the IdP redirect destination. When IdPs support this parameter, they request that users + /// select the account that they want to log in with. + case selectAccount = "select_account" + + /// This value has no effect on local sign-in and must be submitted in requests that redirect to IdPs. + /// When included in your authorization request, this parameter adds prompt=consent to the URL path for + /// the IdP redirect destination. When IdPs support this parameter, they request user consent before + /// they redirect back to your user pool. + case consent } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/HostedUISignInHelper.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/HostedUISignInHelper.swift index 47a9359847..80317a092f 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/HostedUISignInHelper.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/HostedUISignInHelper.swift @@ -131,7 +131,12 @@ struct HostedUISignInHelper: DefaultLogger { scopes: request.options.scopes ?? scopeFromConfig, providerInfo: providerInfo, presentationAnchor: request.presentationAnchor, - preferPrivateSession: privateSession + preferPrivateSession: privateSession, + nonce: pluginOptions?.nonce, + language: pluginOptions?.language, + loginHint: pluginOptions?.loginHint, + promptValues: pluginOptions?.prompt, + resource: pluginOptions?.resource ) let signInData = SignInEventData( username: nil, diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/HostedUIOptions.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/HostedUIOptions.swift index 7ef648ebdf..90460bb078 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/HostedUIOptions.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/HostedUIOptions.swift @@ -17,6 +17,16 @@ struct HostedUIOptions { let presentationAnchor: AuthUIPresentationAnchor? let preferPrivateSession: Bool + + let nonce: String? + + let language: String? + + let loginHint: String? + + let prompt: String? + + let resource: String? } extension HostedUIOptions: Codable { @@ -28,6 +38,16 @@ extension HostedUIOptions: Codable { case providerInfo case preferPrivateSession + + case nonce + + case language = "lang" + + case loginHint = "login_hint" + + case prompt + + case resource } init(from decoder: Decoder) throws { @@ -36,6 +56,11 @@ extension HostedUIOptions: Codable { self.providerInfo = try values.decode(HostedUIProviderInfo.self, forKey: .providerInfo) self.preferPrivateSession = try values.decode(Bool.self, forKey: .preferPrivateSession) self.presentationAnchor = nil + self.nonce = try values.decode(String.self, forKey: .nonce) + self.language = try values.decode(String.self, forKey: .language) + self.loginHint = try values.decode(String.self, forKey: .loginHint) + self.prompt = try values.decode(String.self, forKey: .prompt) + self.resource = try values.decode(String.self, forKey: .resource) } func encode(to encoder: Encoder) throws { @@ -43,7 +68,40 @@ extension HostedUIOptions: Codable { try container.encode(scopes, forKey: .scopes) try container.encode(providerInfo, forKey: .providerInfo) try container.encode(preferPrivateSession, forKey: .preferPrivateSession) + try container.encode(nonce, forKey: .nonce) + try container.encode(language, forKey: .language) + try container.encode(loginHint, forKey: .loginHint) + try container.encodeIfPresent(prompt, forKey: .prompt) + try container.encode(resource, forKey: .resource) } } extension HostedUIOptions: Equatable { } + +#if os(iOS) || os(macOS) || os(visionOS) +extension HostedUIOptions { + init( + scopes: [String], + providerInfo: HostedUIProviderInfo, + presentationAnchor: AuthUIPresentationAnchor?, + preferPrivateSession: Bool, + nonce: String?, + language: String?, + loginHint: String?, + promptValues: [AWSAuthWebUISignInOptions.Prompt]?, + resource: String? + ) { + self.init( + scopes: scopes, + providerInfo: providerInfo, + presentationAnchor: presentationAnchor, + preferPrivateSession: preferPrivateSession, + nonce: nonce, + language: language, + loginHint: loginHint, + prompt: promptValues?.map { "\($0.rawValue)" }.joined(separator: " "), + resource: resource + ) + } +} +#endif diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignUpEvent.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignUpEvent.swift index 6dbc71b9b2..3c7f2fe1aa 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignUpEvent.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignUpEvent.swift @@ -21,7 +21,7 @@ struct SignUpEvent: StateMachineEvent { case initiateSignUpComplete(SignUpEventData, AuthSignUpResult) case confirmSignUp(SignUpEventData, ConfirmationCode, ForceAliasCreation?) case signedUp(SignUpEventData, AuthSignUpResult) - case throwAuthError(SignUpError) + case throwAuthError(SignUpError, SignUpEventData) } init( diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignUpState.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignUpState.swift index 412bdcf530..90292de54c 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignUpState.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignUpState.swift @@ -14,7 +14,7 @@ enum SignUpState: State { case awaitingUserConfirmation(SignUpEventData, AuthSignUpResult) case confirmingSignUp(SignUpEventData) case signedUp(SignUpEventData, AuthSignUpResult) - case error(SignUpError) + case error(SignUpError, SignUpEventData) } extension SignUpState { diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignUp/SignUpState+Resolver.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignUp/SignUpState+Resolver.swift index 682a078da4..753a492be2 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignUp/SignUpState+Resolver.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignUp/SignUpState+Resolver.swift @@ -46,8 +46,8 @@ extension SignUpState { case .confirmSignUp(let data, let code, let forceAliasCreation): let action = ConfirmSignUp(data: data, confirmationCode: code, forceAliasCreation: forceAliasCreation) return .init(newState: .confirmingSignUp(data), actions: [action]) - case .throwAuthError(let error): - return .init(newState: .error(error)) + case .throwAuthError(let error, let signUpData): + return .init(newState: .error(error, signUpData)) default: return .from(oldState) } @@ -84,8 +84,8 @@ extension SignUpState { return .init(newState: .confirmingSignUp(data), actions: [action]) case .signedUp(let data, let result): return .init(newState: .signedUp(data, result)) - case .throwAuthError(let error): - return .init(newState: .error(error)) + case .throwAuthError(let error, let signUpData): + return .init(newState: .error(error, signUpData)) } } @@ -100,8 +100,8 @@ extension SignUpState { case .confirmSignUp(let data, let code, let forceAliasCreation): let action = ConfirmSignUp(data: data, confirmationCode: code, forceAliasCreation: forceAliasCreation) return .init(newState: .confirmingSignUp(data), actions: [action]) - case .throwAuthError(let error): - return .init(newState: .error(error)) + case .throwAuthError(let error, let signUpData): + return .init(newState: .error(error, signUpData)) default: return .from(oldState) } @@ -120,8 +120,8 @@ extension SignUpState { return .init(newState: .confirmingSignUp(data), actions: [action]) case .signedUp(let data, let result): return .init(newState: .signedUp(data, result)) - case .throwAuthError(let error): - return .init(newState: .error(error)) + case .throwAuthError(let error, let signUpData): + return .init(newState: .error(error, signUpData)) default: return .from(oldState) } @@ -138,8 +138,8 @@ extension SignUpState { case .confirmSignUp(let data, let code, let forceAliasCreation): let action = ConfirmSignUp(data: data, confirmationCode: code, forceAliasCreation: forceAliasCreation) return .init(newState: .confirmingSignUp(data), actions: [action]) - case .throwAuthError(let error): - return .init(newState: .error(error)) + case .throwAuthError(let error, let signUpData): + return .init(newState: .error(error, signUpData)) default: return .from(oldState) } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/AuthCognitoTokens+Validation.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/AuthCognitoTokens+Validation.swift index 2879bc27d1..7ee0aea0c2 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/AuthCognitoTokens+Validation.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/AuthCognitoTokens+Validation.swift @@ -13,18 +13,19 @@ extension AWSCognitoUserPoolTokens { func doesExpire(in seconds: TimeInterval = 0) -> Bool { - let currentTime = Date(timeIntervalSinceNow: seconds) guard let idTokenClaims = try? AWSAuthService().getTokenClaims(tokenString: idToken).get(), let accessTokenClaims = try? AWSAuthService().getTokenClaims(tokenString: accessToken).get(), let idTokenExpiration = idTokenClaims["exp"]?.doubleValue, let accessTokenExpiration = accessTokenClaims["exp"]?.doubleValue else { - return currentTime > expiration + // If token parsing fails, return as expired, to just force refresh + return true } let idTokenExpiry = Date(timeIntervalSince1970: idTokenExpiration) let accessTokenExpiry = Date(timeIntervalSince1970: accessTokenExpiration) + let currentTime = Date(timeIntervalSinceNow: seconds) return currentTime > idTokenExpiry || currentTime > accessTokenExpiry } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/HostedUI/HostedUIRequestHelper.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/HostedUI/HostedUIRequestHelper.swift index aef9ae9671..ccc84dfc36 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/HostedUI/HostedUIRequestHelper.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/HostedUI/HostedUIRequestHelper.swift @@ -58,6 +58,21 @@ enum HostedUIRequestHelper { components.queryItems?.append( .init(name: "identity_provider", value: authProvider.userPoolProviderName)) } + if let nonce = options.nonce { + components.queryItems?.append(.init(name: "nonce", value: nonce)) + } + if let language = options.language { + components.queryItems?.append(.init(name: "lang", value: language)) + } + if let loginHint = options.loginHint { + components.queryItems?.append(.init(name: "login_hint", value: loginHint)) + } + if let prompt = options.prompt { + components.queryItems?.append(.init(name: "prompt", value: prompt)) + } + if let resource = options.resource { + components.queryItems?.append(.init(name: "resource", value: resource)) + } guard let url = components.url else { throw HostedUIError.signInURI diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignUpTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignUpTask.swift index ba56406b02..ebb6beab21 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignUpTask.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignUpTask.swift @@ -58,7 +58,7 @@ class AWSAuthConfirmSignUpTask: AuthConfirmSignUpTask, DefaultLogger { switch signUpState { case .signedUp(_, let result): return result - case .error(let signUpError): + case .error(let signUpError, _): throw signUpError.authError default: continue @@ -81,6 +81,11 @@ class AWSAuthConfirmSignUpTask: AuthConfirmSignUpTask, DefaultLogger { // only include session if the cached username matches // the username in confirmSignUp() call session = data.session + } else if case .error(_, let data) = signUpState, + request.username == data.username { + // only include session if the cached username matches + // the username in confirmSignUp() call + session = data.session } let pluginOptions = request.options.pluginOptions as? AWSAuthConfirmSignUpOptions diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignUpTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignUpTask.swift index e80dc3f04d..9624146712 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignUpTask.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignUpTask.swift @@ -62,7 +62,7 @@ class AWSAuthSignUpTask: AuthSignUpTask, DefaultLogger { return result case .signedUp(_, let result): return result - case .error(let signUpError): + case .error(let signUpError, _): throw signUpError.authError default: continue diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Mocks/MockData/SignedInData+Mock.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Mocks/MockData/SignedInData+Mock.swift index 19d5c6d200..91394a5862 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Mocks/MockData/SignedInData+Mock.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Mocks/MockData/SignedInData+Mock.swift @@ -36,7 +36,12 @@ extension SignedInData { scopes: [], providerInfo: .init(authProvider: .google, idpIdentifier: ""), presentationAnchor: nil, - preferPrivateSession: false + preferPrivateSession: false, + nonce: nil, + language: nil, + loginHint: nil, + prompt: nil, + resource: nil )), cognitoUserPoolTokens: tokens ) diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Support/HostedUIRequestHelperTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Support/HostedUIRequestHelperTests.swift index e62234191f..610eb154de 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Support/HostedUIRequestHelperTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Support/HostedUIRequestHelperTests.swift @@ -24,7 +24,12 @@ class HostedUIRequestHelperTests: XCTestCase { idpIdentifier: nil ), presentationAnchor: nil, - preferPrivateSession: false + preferPrivateSession: false, + nonce: nil, + language: nil, + loginHint: nil, + prompt: nil, + resource: nil ) ) @@ -83,4 +88,41 @@ class HostedUIRequestHelperTests: XCTestCase { let encodedSecret = try XCTUnwrap(encodedSecret) XCTAssertEqual("Basic \(encodedSecret)", header) } + + #if os(iOS) || os(macOS) || os(visionOS) + /// Given: A HostedUI configuration that defines a client secret + /// When: HostedUIRequestHelper.createSignInURL is invoked with cognito oidc parameters + /// Then: A URL is generated that contains all the cognito oidc parameters in url query parameters + func testCreateSignInURL_withCognitoOIDCParametersInOptions_shouldContainOIDCParametersInURLQueryParams() throws { + createConfiguration(clientSecret: "clientSecret") + let signInURL = try HostedUIRequestHelper.createSignInURL( + state: "state", + proofKey: "proofKey", + userContextData: nil, + configuration: configuration, + options: .init( + scopes: [], + providerInfo: .init(authProvider: nil, idpIdentifier: nil), + presentationAnchor: nil, + preferPrivateSession: false, + nonce: "nonce", + language: "en", + loginHint: "username", + promptValues: [.login, .consent], + resource: "http://localhost" + ) + ) + + guard let urlComponents = URLComponents(url: signInURL, resolvingAgainstBaseURL: false) else { + XCTFail("Failed to get URL components from \(signInURL)") + return + } + + XCTAssertEqual("nonce", urlComponents.queryItems?.first(where: { $0.name == "nonce"})?.value) + XCTAssertEqual("en", urlComponents.queryItems?.first(where: { $0.name == "lang"})?.value) + XCTAssertEqual("username", urlComponents.queryItems?.first(where: { $0.name == "login_hint"})?.value) + XCTAssertEqual("login consent", urlComponents.queryItems?.first(where: { $0.name == "prompt"})?.value) + XCTAssertEqual("http://localhost", urlComponents.queryItems?.first(where: { $0.name == "resource"})?.value) + } + #endif } diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AuthenticationProviderDeleteUserTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AuthenticationProviderDeleteUserTests.swift index 54b067ebfe..b47f9f8499 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AuthenticationProviderDeleteUserTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AuthenticationProviderDeleteUserTests.swift @@ -141,7 +141,17 @@ class AuthenticationProviderDeleteUserTests: BasePluginTest { mockIdentityProvider = MockIdentityProvider( mockRevokeTokenResponse: { _ in RevokeTokenOutput() - }, mockGlobalSignOutResponse: { _ in + }, + mockGetTokensFromRefreshTokenResponse: { _ in + return GetTokensFromRefreshTokenOutput( + authenticationResult: .init( + accessToken: "accessTokenNew", + expiresIn: 100, + idToken: "idTokenNew", + refreshToken: "refreshTokenNew" + )) + }, + mockGlobalSignOutResponse: { _ in GlobalSignOutOutput() }, mockDeleteUserOutput: { _ in diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthAutoSignInTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthAutoSignInTests.swift index 31097c9161..4c2bcb63e0 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthAutoSignInTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthAutoSignInTests.swift @@ -454,7 +454,7 @@ class AWSAuthAutoSignInTests: BasePluginTest { let initialStateError = AuthState.configured( .signedOut(.init(lastKnownUserName: nil)), .configured, - .error(.service(error: AuthError.service("Unknown error", "Unknown error"))) + .error(.service(error: AuthError.service("Unknown error", "Unknown error")), .init(username: "username", session: "sessio")) ) let authPluginError = configureCustomPluginWith( diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthConfirmSignUpAPITests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthConfirmSignUpAPITests.swift index 490d2bb7cc..3f21555f10 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthConfirmSignUpAPITests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthConfirmSignUpAPITests.swift @@ -140,7 +140,10 @@ class AWSAuthConfirmSignUpAPITests: BasePluginTest { let initialStateError = AuthState.configured( .signedOut(.init(lastKnownUserName: nil)), .configured, - .error(.service(error: AuthError.service("Unknown error", "Unknown error"))) + .error( + .service(error: AuthError.service("Unknown error", "Unknown error")), + .init(username: "username", session: "sessio") + ) ) let authPluginError = configureCustomPluginWith( @@ -280,7 +283,10 @@ class AWSAuthConfirmSignUpAPITests: BasePluginTest { let initialStateError = AuthState.configured( .signedOut(.init(lastKnownUserName: nil)), .configured, - .error(.service(error: AuthError.service("Unknown error", "Unknown error"))) + .error( + .service(error: AuthError.service("Unknown error", "Unknown error")), + .init(username: "username", session: "sessio") + ) ) let authPluginError = configureCustomPluginWith( @@ -595,4 +601,227 @@ class AWSAuthConfirmSignUpAPITests: BasePluginTest { XCTAssertEqual(awsCognitoAuthError, expectedCognitoError) } } + + // MARK: - Session Caching Tests + + /// Given: Configured auth machine in `.error` sign up state with a cached session for matching username + /// When: `Auth.confirmSignUp(for:confirmationCode:options:)` is invoked with the same username + /// Then: The cached session is used and confirm sign up completes with `.completeAutoSignIn` + func testConfirmSignUpUsesSessionFromErrorStateWithMatchingUsername() async throws { + let cachedSession = "cached-session-from-error" + let mockIdentityProvider = MockIdentityProvider( + mockConfirmSignUpResponse: { request in + // Verify the cached session is passed through + XCTAssertEqual(request.session, cachedSession) + return .init(session: "new-session") + } + ) + + let initialStateError = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .error( + .service(error: AuthError.service("Previous error", "Recovery")), + .init(username: "jeffb", session: cachedSession) + ) + ) + + let authPluginError = configureCustomPluginWith( + userPool: { mockIdentityProvider }, + initialState: initialStateError + ) + + let result = try await authPluginError.confirmSignUp( + for: "jeffb", + confirmationCode: "123456", + options: options + ) + + guard case .completeAutoSignIn(let session) = result.nextStep else { + XCTFail("Result should be .completeAutoSignIn for next step") + return + } + XCTAssertTrue(result.isSignUpComplete, "Sign up result should be complete") + XCTAssertEqual(session, "new-session") + } + + /// Given: Configured auth machine in `.error` sign up state with a cached session for different username + /// When: `Auth.confirmSignUp(for:confirmationCode:options:)` is invoked with a different username + /// Then: The cached session is NOT used + func testConfirmSignUpDoesNotUseSessionFromErrorStateWithDifferentUsername() async throws { + let cachedSession = "cached-session-from-error" + let mockIdentityProvider = MockIdentityProvider( + mockConfirmSignUpResponse: { request in + // Verify the cached session is NOT passed through + XCTAssertNil(request.session) + return .init(session: "new-session") + } + ) + + let initialStateError = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .error( + .service(error: AuthError.service("Previous error", "Recovery")), + .init(username: "different-user", session: cachedSession) + ) + ) + + let authPluginError = configureCustomPluginWith( + userPool: { mockIdentityProvider }, + initialState: initialStateError + ) + + let result = try await authPluginError.confirmSignUp( + for: "jeffb", + confirmationCode: "123456", + options: options + ) + + guard case .completeAutoSignIn(let session) = result.nextStep else { + XCTFail("Result should be .completeAutoSignIn for next step") + return + } + XCTAssertTrue(result.isSignUpComplete, "Sign up result should be complete") + XCTAssertEqual(session, "new-session") + } + + /// Given: Configured auth machine in `.error` sign up state with nil session + /// When: `Auth.confirmSignUp(for:confirmationCode:options:)` is invoked + /// Then: No session is passed and confirm sign up completes successfully + func testConfirmSignUpFromErrorStateWithNilSession() async throws { + let mockIdentityProvider = MockIdentityProvider( + mockConfirmSignUpResponse: { request in + XCTAssertNil(request.session) + return .init() + } + ) + + let initialStateError = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .error( + .service(error: AuthError.service("Previous error", "Recovery")), + .init(username: "jeffb", session: nil) + ) + ) + + let authPluginError = configureCustomPluginWith( + userPool: { mockIdentityProvider }, + initialState: initialStateError + ) + + let result = try await authPluginError.confirmSignUp( + for: "jeffb", + confirmationCode: "123456", + options: options + ) + + guard case .done = result.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(result.isSignUpComplete, "Sign up result should be complete") + } + + /// Given: Configured auth machine in `.awaitingUserConfirmation` with a session + /// When: `Auth.confirmSignUp(for:confirmationCode:options:)` is invoked with matching username + /// Then: The cached session is used for auto sign-in + func testConfirmSignUpUsesSessionFromAwaitingUserConfirmationState() async throws { + let cachedSession = "cached-session-awaiting" + let mockIdentityProvider = MockIdentityProvider( + mockConfirmSignUpResponse: { request in + XCTAssertEqual(request.session, cachedSession) + return .init(session: "new-session") + } + ) + + let initialState = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .awaitingUserConfirmation( + SignUpEventData(username: "jeffb", session: cachedSession), + .init(.confirmUser()) + ) + ) + + let authPlugin = configureCustomPluginWith( + userPool: { mockIdentityProvider }, + initialState: initialState + ) + + let result = try await authPlugin.confirmSignUp( + for: "jeffb", + confirmationCode: "123456", + options: options + ) + + guard case .completeAutoSignIn(let session) = result.nextStep else { + XCTFail("Result should be .completeAutoSignIn for next step") + return + } + XCTAssertTrue(result.isSignUpComplete, "Sign up result should be complete") + XCTAssertEqual(session, "new-session") + } + + /// Given: Configured auth machine transitions to error state during confirm sign up + /// When: The error state contains session data + /// Then: The session is preserved in the error state for potential retry + func testSessionPreservedInErrorStateDuringConfirmSignUp() async throws { + let initialSession = "initial-session" + let mockIdentityProvider = MockIdentityProvider( + mockConfirmSignUpResponse: { _ in + throw AWSCognitoIdentityProvider.CodeMismatchException() + } + ) + + let initialState = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .awaitingUserConfirmation( + SignUpEventData(username: "jeffb", session: initialSession), + .init(.confirmUser()) + ) + ) + + let authPlugin = configureCustomPluginWith( + userPool: { mockIdentityProvider }, + initialState: initialState + ) + + do { + _ = try await authPlugin.confirmSignUp( + for: "jeffb", + confirmationCode: "wrong-code", + options: options + ) + XCTFail("Should throw error") + } catch { + guard let authError = error as? AuthError else { + XCTFail("Should throw Auth error") + return + } + + guard case .service = authError else { + XCTFail("Auth error should be of type service error") + return + } + + // Verify the state machine is now in error state + let currentState = await authPlugin.authStateMachine.currentState + guard case .configured(_, _, let signUpState) = currentState else { + XCTFail("Should be in configured state") + return + } + + guard case .error(_, let data) = signUpState else { + XCTFail("Should be in error state") + return + } + + // Verify session is preserved in error state + XCTAssertEqual(data.session, initialSession) + XCTAssertEqual(data.username, "jeffb") + } + } } diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthSignUpAPITests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthSignUpAPITests.swift index 81004e3afe..7720926417 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthSignUpAPITests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthSignUpAPITests.swift @@ -145,7 +145,10 @@ class AWSAuthSignUpAPITests: BasePluginTest { let initialStateError = AuthState.configured( .signedOut(.init(lastKnownUserName: nil)), .configured, - .error(.service(error: AuthError.service("Unknown error", "Unknown error"))) + .error( + .service(error: AuthError.service("Unknown error", "Unknown error")), + .init(username: "username", session: "sessio") + ) ) let authPluginError = configureCustomPluginWith( @@ -286,7 +289,10 @@ class AWSAuthSignUpAPITests: BasePluginTest { let initialStateError = AuthState.configured( .signedOut(.init(lastKnownUserName: nil)), .configured, - .error(.service(error: AuthError.service("Unknown error", "Unknown error"))) + .error( + .service(error: AuthError.service("Unknown error", "Unknown error")), + .init(username: "username", session: "sessio") + ) ) let authPluginError = configureCustomPluginWith( @@ -948,4 +954,165 @@ class AWSAuthSignUpAPITests: BasePluginTest { XCTAssertEqual(awsCognitoAuthError, expectedCognitoError) } } + + // MARK: - Session Caching in Error State Tests + + /// Given: Configured auth machine in `.error` sign up state with cached session data + /// When: Sign up transitions to error state during initiation + /// Then: The session data is preserved in the error state + func testSessionPreservedInErrorStateDuringSignUp() async throws { + let mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + throw AWSCognitoIdentityProvider.UsernameExistsException() + } + ) + + let authPlugin = configureCustomPluginWith( + userPool: { mockIdentityProvider }, + initialState: initialState + ) + + do { + _ = try await authPlugin.signUp( + username: "jeffb", + password: "Valid&99", + options: options + ) + XCTFail("Should throw error") + } catch { + guard let authError = error as? AuthError else { + XCTFail("Should throw Auth error") + return + } + + guard case .service = authError else { + XCTFail("Auth error should be of type service error") + return + } + + // Verify the state machine is now in error state + let currentState = await authPlugin.authStateMachine.currentState + guard case .configured(_, _, let signUpState) = currentState else { + XCTFail("Should be in configured state") + return + } + + guard case .error(_, let data) = signUpState else { + XCTFail("Should be in error state") + return + } + + // Verify username is preserved in error state + XCTAssertEqual(data.username, "jeffb") + } + } + + /// Given: Configured auth machine in `.error` sign up state + /// When: A new sign up is initiated + /// Then: The error state is cleared and new sign up proceeds + func testSignUpFromErrorStateStartsNewFlow() async throws { + let mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + return .init( + codeDeliveryDetails: .init( + attributeName: "email", + deliveryMedium: .email, + destination: "test@example.com" + ), + userConfirmed: false, + userSub: UUID().uuidString + ) + } + ) + + let initialStateError = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .error( + .service(error: AuthError.service("Previous error", "Recovery")), + .init(username: "old-user", session: "old-session") + ) + ) + + let authPluginError = configureCustomPluginWith( + userPool: { mockIdentityProvider }, + initialState: initialStateError + ) + + let result = try await authPluginError.signUp( + username: "new-user", + password: "Valid&99", + options: options + ) + + guard case .confirmUser = result.nextStep else { + XCTFail("Result should be .confirmUser for next step") + return + } + XCTAssertFalse(result.isSignUpComplete, "Sign up should not be complete") + + // Verify the state machine moved to awaitingUserConfirmation + let currentState = await authPluginError.authStateMachine.currentState + guard case .configured(_, _, let signUpState) = currentState else { + XCTFail("Should be in configured state") + return + } + + guard case .awaitingUserConfirmation(let data, _) = signUpState else { + XCTFail("Should be in awaitingUserConfirmation state") + return + } + + // Verify new username is in the state + XCTAssertEqual(data.username, "new-user") + } + + /// Given: Configured auth machine with passwordless sign up that fails + /// When: Sign up error occurs with session data + /// Then: Session data is preserved in error state for retry + func testPasswordlessSignUpPreservesSessionInErrorState() async throws { + let mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + throw AWSCognitoIdentityProvider.InvalidParameterException() + } + ) + + let authPlugin = configureCustomPluginWith( + userPool: { mockIdentityProvider }, + initialState: initialState + ) + + do { + _ = try await authPlugin.signUp( + username: "jeffb", + options: options + ) + XCTFail("Should throw error") + } catch { + guard let authError = error as? AuthError else { + XCTFail("Should throw Auth error") + return + } + + guard case .service = authError else { + XCTFail("Auth error should be of type service error") + return + } + + // Verify the state machine is now in error state + let currentState = await authPlugin.authStateMachine.currentState + guard case .configured(_, _, let signUpState) = currentState else { + XCTFail("Should be in configured state") + return + } + + guard case .error(_, let data) = signUpState else { + XCTFail("Should be in error state") + return + } + + // Verify username is preserved in error state + XCTAssertEqual(data.username, "jeffb") + } + } } diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/SignUpState+Codable.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/SignUpState+Codable.swift index f7f4dab976..48c7921b7a 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/SignUpState+Codable.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/SignUpState+Codable.swift @@ -39,7 +39,8 @@ extension SignUpState: Codable { self = .signedUp(eventData, result) } else if type == "SignUpState.error" { let eventError = try values.decode(SignUpError.self, forKey: .SignUpError) - self = .error(eventError) + let eventData = try values.decode(SignUpEventData.self, forKey: .SignUpEventData) + self = .error(eventError, eventData) } else { fatalError("Decoding not supported") }