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/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/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/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 }