From 1758ef0df6a042052ea8487ee03ed8e04f45b6ab Mon Sep 17 00:00:00 2001 From: Alex Nachbaur Date: Thu, 30 Jan 2025 17:35:51 -0800 Subject: [PATCH] Refactor authentication flows for consistency and customization. - Introduces the concept of an Authentication Context, which is used to supply flow-specific capabilities to adapt the flows to specific use-cases. - Normalize the coalescence of values supplied to OAuth2Client configuration, auth flow context, and user-supplied information. - Introduce support for ACR Values - Pave the way for refactoring these objects and types to support actors and @Sendable --- .../SignInViewController.swift | 10 +- .../AuthFoundation.docc/APIClient.md | 4 - .../AuthFoundation.docc/AuthFoundation.md | 1 - .../AuthFoundation.docc/Credential.md | 4 - .../CustomizingNetworkRequests.md | 2 +- .../AuthFoundation/AuthFoundation.docc/JWT.md | 4 +- .../AuthFoundation.docc/Keychain.md | 4 - .../AuthFoundation.docc/OAuth2Client.md | 4 - .../Debugging/APIRequestObserver.swift | 66 ++++- .../OAuth2Client+Deprecations.swift | 45 +++ .../AuthFoundation/JWT/Enums/JWTClaim.swift | 3 - .../Extensions/Claim+ValueExtensions.swift | 8 +- Sources/AuthFoundation/JWT/JWK.swift | 2 + Sources/AuthFoundation/JWT/JWT.swift | 3 +- .../AuthFoundation/JWT/Protocols/Claim.swift | 2 +- .../Migrators/OIDCLegacyMigrator.swift | 14 +- .../AuthFoundation/Network/APIClient.swift | 7 +- .../AuthFoundation/Network/APIRequest.swift | 15 + .../Network/APIRequestArgument.swift | 13 + .../AuthFoundation/Network/APIResponse.swift | 3 - .../AuthFoundation/Network/OktaAPIError.swift | 2 + .../OAuth2/Authentication.swift | 125 ++++++-- .../OAuth2/ClientAuthentication.swift | 16 +- .../AuthFoundation/OAuth2/Configuration.swift | 161 ++++++++--- .../Extensions/OAuth2Error+Extensions.swift | 30 +- .../ProvidesOAuth2Parameters+Extensions.swift | 59 ++-- .../AuthFoundation/OAuth2/OAuth2Client.swift | 72 +++-- .../OAuth2/OAuth2ClientConfiguration.swift | 40 +-- .../AuthFoundation/OAuth2/OAuth2Error.swift | 61 ++-- .../OAuth2/OAuth2TokenRequest.swift | 19 +- .../OAuth2/ProvidesOAuth2Parameters.swift | 9 +- .../AuthFoundation/Requests/KeysRequest.swift | 1 + .../Requests/Token+Requests.swift | 74 +++-- .../Requests/UserInfo+Requests.swift | 1 + .../Resources/en.lproj/AuthFoundation.strings | 3 +- .../AuthFoundation/Responses/GrantType.swift | 2 +- .../Responses/OAuth2ServerError.swift | 27 +- .../Responses/OpenIdConfiguration.swift | 2 + .../AuthFoundation/Responses/TokenInfo.swift | 3 + .../AuthFoundation/Responses/UserInfo.swift | 2 + .../AuthFoundation/Security/Keychain.swift | 5 +- Sources/AuthFoundation/Security/PKCE.swift | 5 +- .../{Internal => }/PKCEExtensions.swift | 6 - .../Security/SecurityUtilities.swift | 6 + .../Token Management/Token+Context.swift | 44 ++- .../Token+Initialization.swift | 7 +- .../Token Management/Token+Metadata.swift | 3 + .../Token Management/Token.swift | 14 +- .../User Management/Credential.swift | 10 +- .../Utilities/Dictionary+Extensions.swift | 64 +++++ .../AuthFoundation/Utilities/JSONValue.swift | 7 +- .../Utilities/String+Extensions.swift | 21 ++ .../Utilities/URL+Extensions.swift} | 37 +-- Sources/OktaDirectAuth/DirectAuthFlow.swift | 271 ++++++++++-------- .../Extensions/ErrorExtensions.swift | 10 + .../Extensions/Status+PublicExtensions.swift | 10 +- .../AuthenticationFactor.swift | 2 - .../ContinuationFactor.swift | 23 +- .../PrimaryFactor.swift | 16 +- .../SecondaryFactor.swift | 16 +- .../DirectAuthFlow+SendResponses.swift | 7 +- .../Extensions/Intent+Extensions.swift | 17 +- .../OAuth2Error+DirectAuthExtensions.swift | 26 ++ .../OpenIdConfiguration+Extensions.swift | 24 ++ .../Internal/Requests/ChallengeRequest.swift | 31 +- .../Requests/OOBAuthenticateRequest.swift | 46 +-- .../Internal/Requests/TokenRequest.swift | 58 ++-- .../Internal/Requests/WebAuthnRequest.swift | 28 +- .../Step Handlers/OOBStepHandler.swift | 27 +- ...ift => GrantType+InternalExtensions.swift} | 0 .../Utilities/Status+InternalExtensions.swift | 13 + .../Extensions/DirectAuthenticationFlow.md | 4 - .../Resources/en.lproj/OktaDirectAuth.strings | 2 + .../PublicKeyCredentialDescriptor.swift | 2 +- .../PublicKeyCredentialRequestOptions.swift | 20 +- .../Type/AuthenticatorTransport.swift | 2 +- .../Type/PublicKeyCredentialHints.swift | 2 +- .../Type/PublicKeyCredentialType.swift | 2 +- .../Type/UserVerificationRequirement.swift | 2 +- .../OktaDirectAuth/WebAuthn/WebAuthn.swift | 4 +- .../AuthorizationCodeFlow+Context.swift | 236 +++++++++++++++ .../AuthorizationCodeFlow.swift | 210 +++++--------- .../DeviceAuthorizationFlow+Context.swift | 57 ++++ ...DeviceAuthorizationFlow+Verification.swift | 60 ++++ .../DeviceAuthorizationFlow.swift | 152 ++++------ .../Authentication/JWTAuthorizationFlow.swift | 72 ++--- .../Authentication/ResourceOwnerFlow.swift | 76 ++--- .../Authentication/SessionTokenFlow.swift | 102 +++---- .../TokenExchangeFlow+Context.swift | 59 ++++ .../Authentication/TokenExchangeFlow.swift | 98 ++++--- .../AuthorizationCodeFlow+Deprecations.swift | 62 ++++ ...DeviceAuthorizationFlow+Deprecations.swift | 31 ++ .../JWTAuthorizationFlow+Deprecations.swift | 24 ++ .../ResourceOwnerFlow+Deprecations.swift | 24 ++ .../SessionLogoutFlow+Deprecations.swift | 23 ++ .../SessionTokenFlow+Deprecations.swift | 45 +++ .../TokenExchangeFlow+Deprecations.swift | 25 ++ .../Authentication+Extensions.swift | 6 + .../Extensions/ErrorExtensions.swift | 12 - .../OktaOAuth2/Internal/Enum+Extensions.swift | 5 + ...orizationCodeFlow+InternalExtensions.swift | 45 +++ .../AuthorizationCodeFlow+Extensions.swift | 68 ----- .../AuthorizationCodeFlow+Requests.swift | 80 +++--- .../DeviceAuthorizeFlow+Requests.swift | 50 ++-- .../JWTAuthorizationFlow+Requests.swift | 33 +-- .../Requests/ResourceOwnerFlow+Requests.swift | 33 +-- .../Requests/TokenExchangeFlow+Requests.swift | 30 +- .../Logout/SessionLogoutFlow+Context.swift | 81 ++++++ .../OktaOAuth2/Logout/SessionLogoutFlow.swift | 181 ++++-------- .../Resources/en.lproj/OktaOAuth2.strings | 3 - .../WebAuthentication+Deprecated.swift | 113 +------- .../Internal/Options+Extensions.swift | 85 ------ .../WebAuthenticationError+Extensions.swift | 10 + .../AuthenticationServicesProvider.swift | 33 ++- .../Providers/WebAuthenticationProvider.swift | 4 +- .../WebAuthentication.swift | 249 ++++++---------- .../Extensions/WebAuthentication.md | 34 +-- .../AuthFoundationTests/APIClientTests.swift | 4 +- Tests/AuthFoundationTests/APIRetryTests.swift | 18 +- .../CredentialCoordinatorTests.swift | 4 +- .../CredentialRevokeTests.swift | 4 +- .../DefaultCredentialDataSourceTests.swift | 4 +- .../DefaultTimeCoordinatorTests.swift | 4 +- Tests/AuthFoundationTests/ErrorTests.swift | 6 +- .../KeychainTokenStorageTests.swift | 4 +- .../OAuth2ClientTests.swift | 66 +++-- Tests/AuthFoundationTests/PKCETests.swift | 2 +- Tests/AuthFoundationTests/TokenTests.swift | 47 +-- .../UserDefaultsTokenStorageTests.swift | 8 +- .../DirectAuth1FATests.swift | 4 +- .../DirectAuth2FATests.swift | 6 +- .../DirectAuthenticationFlowTests.swift | 16 +- .../FactorStepHandlerTests.swift | 83 ++++-- .../ModelEqualityTests.swift | 56 ++++ Tests/OktaDirectAuthTests/RequestTests.swift | 102 +++---- .../AuthorizationCodeFlowContextTests.swift | 82 ++++++ .../AuthorizationCodeFlowSuccessTests.swift | 105 ++++--- .../DeviceAuthorizationFlowErrorTests.swift | 22 +- .../DeviceAuthorizationFlowSuccessTests.swift | 40 +-- .../JWTAuthorizationFlowTests.swift | 11 +- Tests/OktaOAuth2Tests/OAuth2ClientTests.swift | 79 +++-- .../ResourceOwnerFlowTests.swift | 4 +- .../SessionLogoutFlowFailureTests.swift | 13 +- .../SessionLogoutFlowSuccessTests.swift | 33 ++- .../SessionTokenFlowTests.swift | 8 +- .../TokenExchangeFlowTests.swift | 6 +- ...iceAuthorizationFlowDelegateRecorder.swift | 6 +- Tests/TestCommon/Data+Extensions.swift | 2 +- Tests/TestCommon/MockApiClient.swift | 4 +- Tests/TestCommon/MockToken.swift | 18 +- Tests/TestCommon/URLSessionMock.swift | 8 + .../AuthenticationServicesProviderTests.swift | 17 +- .../OptionsTests.swift | 65 ----- .../ProviderTestBase.swift | 11 +- .../WebAuthenticationFlowTests.swift | 17 +- .../WebAuthenticationInitializerTests.swift | 14 +- .../WebAuthenticationMocks.swift | 8 +- 157 files changed, 3216 insertions(+), 2123 deletions(-) create mode 100644 Sources/AuthFoundation/Deprecations/OAuth2Client+Deprecations.swift rename Sources/{OktaDirectAuth/Internal => AuthFoundation/OAuth2}/Extensions/OAuth2Error+Extensions.swift (63%) rename Sources/AuthFoundation/Security/{Internal => }/PKCEExtensions.swift (88%) rename Sources/{OktaDirectAuth/Extensions/AuthFlowConfiguration+Extensions.swift => AuthFoundation/Utilities/URL+Extensions.swift} (52%) create mode 100644 Sources/OktaDirectAuth/Internal/Extensions/OAuth2Error+DirectAuthExtensions.swift create mode 100644 Sources/OktaDirectAuth/Internal/Extensions/OpenIdConfiguration+Extensions.swift rename Sources/OktaDirectAuth/Internal/Utilities/{Array+InternalExtensions.swift => GrantType+InternalExtensions.swift} (100%) create mode 100644 Sources/OktaOAuth2/Authentication/AuthorizationCodeFlow+Context.swift create mode 100644 Sources/OktaOAuth2/Authentication/DeviceAuthorizationFlow+Context.swift create mode 100644 Sources/OktaOAuth2/Authentication/DeviceAuthorizationFlow+Verification.swift create mode 100644 Sources/OktaOAuth2/Authentication/TokenExchangeFlow+Context.swift create mode 100644 Sources/OktaOAuth2/Deprecations/AuthorizationCodeFlow+Deprecations.swift create mode 100644 Sources/OktaOAuth2/Deprecations/DeviceAuthorizationFlow+Deprecations.swift create mode 100644 Sources/OktaOAuth2/Deprecations/JWTAuthorizationFlow+Deprecations.swift create mode 100644 Sources/OktaOAuth2/Deprecations/ResourceOwnerFlow+Deprecations.swift create mode 100644 Sources/OktaOAuth2/Deprecations/SessionLogoutFlow+Deprecations.swift create mode 100644 Sources/OktaOAuth2/Deprecations/SessionTokenFlow+Deprecations.swift create mode 100644 Sources/OktaOAuth2/Deprecations/TokenExchangeFlow+Deprecations.swift create mode 100644 Sources/OktaOAuth2/Internal/Extensions/AuthorizationCodeFlow+InternalExtensions.swift delete mode 100644 Sources/OktaOAuth2/Internal/Requests/AuthorizationCodeFlow+Extensions.swift rename Sources/OktaOAuth2/{ => Internal}/Requests/JWTAuthorizationFlow+Requests.swift (65%) rename Sources/OktaOAuth2/{ => Internal}/Requests/TokenExchangeFlow+Requests.swift (83%) create mode 100644 Sources/OktaOAuth2/Logout/SessionLogoutFlow+Context.swift delete mode 100644 Sources/WebAuthenticationUI/Internal/Options+Extensions.swift create mode 100644 Tests/OktaDirectAuthTests/ModelEqualityTests.swift create mode 100644 Tests/OktaOAuth2Tests/AuthorizationCodeFlowContextTests.swift delete mode 100644 Tests/WebAuthenticationUITests/OptionsTests.swift diff --git a/Samples/DirectAuthSignIn/DirectAuthSignIn/SignInViewController.swift b/Samples/DirectAuthSignIn/DirectAuthSignIn/SignInViewController.swift index 17e7c1159..940a5fe29 100644 --- a/Samples/DirectAuthSignIn/DirectAuthSignIn/SignInViewController.swift +++ b/Samples/DirectAuthSignIn/DirectAuthSignIn/SignInViewController.swift @@ -21,14 +21,14 @@ class SignInViewController: UIHostingController { // Workaround to remove the `device_sso` scope, when included in the property list. if let configuration = try? OAuth2Client.PropertyListConfiguration(), - let ssoRange = configuration.scopes.range(of: "device_sso") + let ssoRange = configuration.scope.range(of: "device_sso") { - var scopes = configuration.scopes - scopes.removeSubrange(ssoRange) + var scope = configuration.scope + scope.removeSubrange(ssoRange) - flow = DirectAuthenticationFlow(issuer: configuration.issuer, + flow = DirectAuthenticationFlow(issuerURL: configuration.issuerURL, clientId: configuration.clientId, - scopes: scopes) + scope: scope) } else { flow = try? DirectAuthenticationFlow() } diff --git a/Sources/AuthFoundation/AuthFoundation.docc/APIClient.md b/Sources/AuthFoundation/AuthFoundation.docc/APIClient.md index edae74186..38eb560a1 100644 --- a/Sources/AuthFoundation/AuthFoundation.docc/APIClient.md +++ b/Sources/AuthFoundation/AuthFoundation.docc/APIClient.md @@ -1,9 +1,5 @@ # ``AuthFoundation/APIClient`` -@Metadata { - @DocumentationExtension(mergeBehavior: append) -} - ## Subclassing Notes Many features of the APIClient protocol have default implementations that should serve most purposes, but there are some methods that either must be implemented in concrete instances of APIClient, or may need to be customized for special behavior. diff --git a/Sources/AuthFoundation/AuthFoundation.docc/AuthFoundation.md b/Sources/AuthFoundation/AuthFoundation.docc/AuthFoundation.md index e60b7ce15..99ccfb07c 100644 --- a/Sources/AuthFoundation/AuthFoundation.docc/AuthFoundation.md +++ b/Sources/AuthFoundation/AuthFoundation.docc/AuthFoundation.md @@ -96,7 +96,6 @@ You can use AuthFoundation when you want to: ### Error Types - ``APIClientError`` -- ``AuthenticationError`` - ``ClaimError`` - ``CredentialError`` - ``JSONError`` diff --git a/Sources/AuthFoundation/AuthFoundation.docc/Credential.md b/Sources/AuthFoundation/AuthFoundation.docc/Credential.md index 2e0a250b3..b10e7bf0e 100644 --- a/Sources/AuthFoundation/AuthFoundation.docc/Credential.md +++ b/Sources/AuthFoundation/AuthFoundation.docc/Credential.md @@ -1,9 +1,5 @@ # ``AuthFoundation/Credential`` -@Metadata { - @DocumentationExtension(mergeBehavior: append) -} - ## Storing Credentials The Credential class fundamentally is used as a convenience to simplify access and storage of ``Token``s. Regardless of how the token is created, it can be securely stored by using ``store(_:tags:security:)``. This saves the token for later use. diff --git a/Sources/AuthFoundation/AuthFoundation.docc/CustomizingNetworkRequests.md b/Sources/AuthFoundation/AuthFoundation.docc/CustomizingNetworkRequests.md index 8d9091cf6..afc38b995 100644 --- a/Sources/AuthFoundation/AuthFoundation.docc/CustomizingNetworkRequests.md +++ b/Sources/AuthFoundation/AuthFoundation.docc/CustomizingNetworkRequests.md @@ -32,4 +32,4 @@ When you add your delegate (e.g. using ``OAuth2Client/add(delegate:)``), your cl ### Monitoring outgoing network requests -Building upon the previous section, another method ``APIClientDelegate`` supports is handling responses to requests, through the use of ``APIClientDelegate/api(client:didSend:received:)-4mcbm``. Information about the raw response, including ``APIResponse/RateLimit``, associated links (e.g. next, previous, and current pagination results), and information about the request ID (which can be used for debugging purposes). +Building upon the previous section, another method ``APIClientDelegate`` supports is handling responses to requests, through the use of ``APIClientDelegate/api(client:didSend:received:)-4mcbm``. Information about the raw response, including ``APIResponse/APIRateLimit``, associated links (e.g. next, previous, and current pagination results), and information about the request ID (which can be used for debugging purposes). diff --git a/Sources/AuthFoundation/AuthFoundation.docc/JWT.md b/Sources/AuthFoundation/AuthFoundation.docc/JWT.md index ec33f806c..d07324ead 100644 --- a/Sources/AuthFoundation/AuthFoundation.docc/JWT.md +++ b/Sources/AuthFoundation/AuthFoundation.docc/JWT.md @@ -8,8 +8,8 @@ These objects conform to both the ``HasClaims`` and ``Expires`` protocols, to pr Reading information, or "Claims", from a JWT token can be done in three different ways: -1. Accessing values using convenience properties -2. Using keyed subscripting of common claims using the ``Claim`` enum +1. Accessing values using convenience properties. +2. Using keyed subscripting of common claims using the ``ClaimType`` enum corresponding to the claim container. 3. Using keyed subscripting of custom claims using the claim's string name. Some common properties, such as ``HasClaims/subject`` or ``issuer``, are defined as properties on the JWT object, to simplify access to these values. Additionally, some claims that return dates or time intervals have conveniences such as ``issuedAt``, ``expirationTime``, ``expiresIn``, or ``scope``, that returns their values in the expected type. diff --git a/Sources/AuthFoundation/AuthFoundation.docc/Keychain.md b/Sources/AuthFoundation/AuthFoundation.docc/Keychain.md index 409a6070c..bd3bfac96 100644 --- a/Sources/AuthFoundation/AuthFoundation.docc/Keychain.md +++ b/Sources/AuthFoundation/AuthFoundation.docc/Keychain.md @@ -1,9 +1,5 @@ # ``AuthFoundation/Keychain`` -@Metadata { - @DocumentationExtension(mergeBehavior: append) -} - ## Creating Keychain Items The ``Keychain/Item`` struct represents individual items in the keychain. It can be used to create new items, and is returned as a result when getting an existing item. diff --git a/Sources/AuthFoundation/AuthFoundation.docc/OAuth2Client.md b/Sources/AuthFoundation/AuthFoundation.docc/OAuth2Client.md index 18519e98a..61c15caa8 100644 --- a/Sources/AuthFoundation/AuthFoundation.docc/OAuth2Client.md +++ b/Sources/AuthFoundation/AuthFoundation.docc/OAuth2Client.md @@ -1,9 +1,5 @@ # ``AuthFoundation/OAuth2Client`` -@Metadata { - @DocumentationExtension(mergeBehavior: append) -} - This class serves two purposes: 1. Expose high-level actions a client can perform against an OAuth2 service. 2. Connect authentication flows to the OAuth2 servers they intend to authenticate against. diff --git a/Sources/AuthFoundation/Debugging/APIRequestObserver.swift b/Sources/AuthFoundation/Debugging/APIRequestObserver.swift index a86e41788..69b3c8b76 100644 --- a/Sources/AuthFoundation/Debugging/APIRequestObserver.swift +++ b/Sources/AuthFoundation/Debugging/APIRequestObserver.swift @@ -19,8 +19,34 @@ import OSLog // // If the minimum supported version of this SDK is to increase in the future, this class should be updated to use the modern Logger struct. +#if DEBUG /// Convenience class used for debugging SDK network operations. -public class DebugAPIRequestObserver: OAuth2ClientDelegate { +/// +/// Developers can use this to assist in debugging interactions with the Client SDK, and any network operations that are performed on behalf of the user via this SDK. +/// +/// > Important: This is only available in `DEBUG` builds, and should not be used within production applications. +/// +/// To use this debug tool, you can either add the shared observer as a delegate to the ``OAuth2Client`` instance you would like to observe: +/// +/// ```swift +/// let flow = try AuthorizationCodeFlow() +/// flow.client.add(delegate: DebugAPIRequestObserver.shared) +/// ``` +/// +/// Or alternatively you can use the ``observeAllOAuth2Clients`` convenience property to automatically bind to ``OAuth2Client`` instances as they are initialized. +/// +/// ```swift +/// func application( +/// _ application: UIApplication, +/// didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool +/// { +/// // Application setup +/// #if DEBUG +/// DebugAPIRequestObserver.shared.observeAllOAuth2Clients = true +/// #endif +/// } +/// ``` +public final class DebugAPIRequestObserver: OAuth2ClientDelegate { /// Shared convenience instance to use. public static var shared: DebugAPIRequestObserver = { DebugAPIRequestObserver() @@ -28,7 +54,30 @@ public class DebugAPIRequestObserver: OAuth2ClientDelegate { /// Indicates if HTTP request and response headers should be logged. public var showHeaders = false + + /// Convenience flag that automatically binds newly-created ``OAuth2Client`` instances to the debug observer. + public var observeAllOAuth2Clients: Bool = false { + didSet { + if observeAllOAuth2Clients { + oauth2Observer = NotificationCenter.default.addObserver( + forName: .oauth2ClientCreated, + object: nil, + queue: nil, + using: { [weak self] notification in + guard let self = self, + let client = notification.object as? OAuth2Client + else { + return + } + client.add(delegate: self) + }) + } else { + oauth2Observer = nil + } + } + } + @_documentation(visibility: private) public func api(client: any APIClient, willSend request: inout URLRequest) { var headers = "" if showHeaders { @@ -52,6 +101,7 @@ public class DebugAPIRequestObserver: OAuth2ClientDelegate { } } + @_documentation(visibility: private) public func api(client: any APIClient, didSend request: URLRequest, received response: HTTPURLResponse) { var headers = "" if showHeaders { @@ -64,6 +114,7 @@ public class DebugAPIRequestObserver: OAuth2ClientDelegate { headers) } + @_documentation(visibility: private) public func api(client: any APIClient, didSend request: URLRequest, received error: APIClientError, @@ -75,6 +126,7 @@ public class DebugAPIRequestObserver: OAuth2ClientDelegate { os_log(.debug, log: Self.log, "Error:\n%{public}s", result) } + @_documentation(visibility: private) public func api(client: any APIClient, didSend request: URLRequest, received response: APIResponse) where T: Decodable @@ -86,11 +138,19 @@ public class DebugAPIRequestObserver: OAuth2ClientDelegate { } private static var log = OSLog(subsystem: "com.okta.client.network", category: "Debugging") + private var oauth2Observer: NSObjectProtocol? { + didSet { + if let oldValue = oldValue, + oauth2Observer == nil + { + NotificationCenter.default.removeObserver(oldValue) + } + } + } private func requestId(from headers: [AnyHashable: Any]?, using name: String?) -> String { headers?.first(where: { (key, _) in (key as? String)?.lowercased() == name?.lowercased() })?.value as? String ?? "" } } - - +#endif diff --git a/Sources/AuthFoundation/Deprecations/OAuth2Client+Deprecations.swift b/Sources/AuthFoundation/Deprecations/OAuth2Client+Deprecations.swift new file mode 100644 index 000000000..a6a13f287 --- /dev/null +++ b/Sources/AuthFoundation/Deprecations/OAuth2Client+Deprecations.swift @@ -0,0 +1,45 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") +// +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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. +// + +import Foundation + +extension OAuth2Client { + @_documentation(visibility: private) + @available(*, deprecated, renamed: "init(domain:clientId:scope:redirectUri:logoutRedirectUri:authentication:session:)") + public convenience init(domain: String, + clientId: String, + scopes: String, + authentication: ClientAuthentication = .none, + session: URLSessionProtocol? = nil) throws + { + try self.init(domain: domain, + clientId: clientId, + scope: scopes, + authentication: authentication, + session: session) + } + + @_documentation(visibility: private) + @available(*, deprecated, renamed: "init(issuerURL:clientId:scope:redirectUri:logoutRedirectUri:authentication:session:)") + public convenience init(baseURL: URL, + clientId: String, + scopes: String, + authentication: ClientAuthentication = .none, + session: URLSessionProtocol? = nil) + { + self.init(issuerURL: baseURL, + clientId: clientId, + scope: scopes, + authentication: authentication, + session: session) + } +} diff --git a/Sources/AuthFoundation/JWT/Enums/JWTClaim.swift b/Sources/AuthFoundation/JWT/Enums/JWTClaim.swift index b21011c47..875af68f3 100644 --- a/Sources/AuthFoundation/JWT/Enums/JWTClaim.swift +++ b/Sources/AuthFoundation/JWT/Enums/JWTClaim.swift @@ -12,9 +12,6 @@ import Foundation -@available(*, deprecated, renamed: "JWTClaim") -public typealias Claim = JWTClaim - /// List of registered and public claims. public enum JWTClaim: Codable, IsClaim { /// Issuer diff --git a/Sources/AuthFoundation/JWT/Extensions/Claim+ValueExtensions.swift b/Sources/AuthFoundation/JWT/Extensions/Claim+ValueExtensions.swift index 8da6ecfc9..3186a4870 100644 --- a/Sources/AuthFoundation/JWT/Extensions/Claim+ValueExtensions.swift +++ b/Sources/AuthFoundation/JWT/Extensions/Claim+ValueExtensions.swift @@ -66,7 +66,7 @@ public extension HasClaims { } /// Returns the value for the given key as an array of values converted using a``ClaimConvertable`` type. - /// - Parameter key: String payload key name. + /// - Parameter claim: The claim type to retrieve. /// - Returns: Value converted to an array of the requested type. func value(for claim: ClaimType) throws -> [T] { try value(for: claim.rawValue) @@ -81,7 +81,7 @@ public extension HasClaims { } /// Returns the optional value for the given key as an array of values converted using a``ClaimConvertable`` type. - /// - Parameter key: String payload key name. + /// - Parameter claim: The claim type to retrieve. /// - Returns: Optional value converted to an array of the requested type. func value(for claim: ClaimType) -> [T]? { value(for: claim.rawValue) @@ -103,7 +103,7 @@ public extension HasClaims { } /// Returns the value for the given key as an array of values converted using a``ClaimConvertable`` type. - /// - Parameter key: String payload key name. + /// - Parameter claim: The claim type to retrieve. /// - Returns: Value converted to an array of the requested type. func value(for claim: ClaimType) throws -> [String: T] { try value(for: claim.rawValue) @@ -118,7 +118,7 @@ public extension HasClaims { } /// Returns the optional value for the given key as an array of values converted using a``ClaimConvertable`` type. - /// - Parameter key: String payload key name. + /// - Parameter claim: Payload claim to retrieve. /// - Returns: Optional value converted to an array of the requested type. func value(for claim: ClaimType) -> [String: T]? { value(for: claim.rawValue) diff --git a/Sources/AuthFoundation/JWT/JWK.swift b/Sources/AuthFoundation/JWT/JWK.swift index b147818f8..cd0beba63 100644 --- a/Sources/AuthFoundation/JWT/JWK.swift +++ b/Sources/AuthFoundation/JWT/JWK.swift @@ -39,6 +39,7 @@ public struct JWK: Codable, Equatable, Identifiable, Hashable { /// A default implementation of ``JWKValidator`` is provided and will be used if this value is not changed. public static var validator: JWKValidator = DefaultJWKValidator() + @_documentation(visibility: internal) public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) type = try container.decode(KeyType.self, forKey: .keyType) @@ -56,6 +57,7 @@ public struct JWK: Codable, Equatable, Identifiable, Hashable { } } + @_documentation(visibility: internal) public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(type, forKey: .keyType) diff --git a/Sources/AuthFoundation/JWT/JWT.swift b/Sources/AuthFoundation/JWT/JWT.swift index 64b6f7632..49401a95f 100644 --- a/Sources/AuthFoundation/JWT/JWT.swift +++ b/Sources/AuthFoundation/JWT/JWT.swift @@ -58,6 +58,7 @@ public struct JWT: RawRepresentable, Codable, HasClaims, Expires { case algorithm = "alg" } + @_documentation(visibility: internal) public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) keyId = try container.decode(String.self, forKey: .keyId) @@ -72,7 +73,7 @@ public struct JWT: RawRepresentable, Codable, HasClaims, Expires { } /// Verifies the JWT token using the given ``JWK`` key. - /// - Parameter key: JWK key to use to verify this token. + /// - Parameter keySet: JWK keyset which should be used to verify this token. /// - Returns: Returns whether or not signing passes for this token/key combination. /// - Throws: ``JWTError`` public func validate(using keySet: JWKS) throws -> Bool { diff --git a/Sources/AuthFoundation/JWT/Protocols/Claim.swift b/Sources/AuthFoundation/JWT/Protocols/Claim.swift index ded6a06ce..b02fc6f90 100644 --- a/Sources/AuthFoundation/JWT/Protocols/Claim.swift +++ b/Sources/AuthFoundation/JWT/Protocols/Claim.swift @@ -30,7 +30,7 @@ public protocol HasClaims { public extension HasClaims { /// Returns the collection of claims this object contains. /// - /// > Note: This will only return the list of official claims defined in the ``Claim`` enum. For custom claims, please see the ``customClaims`` property. + /// > Note: This will only return the list of official claims defined in the ``ClaimType`` enum corresponding to this claim container. For custom claims, please see the ``customClaims`` property. var claims: [ClaimType] { payload.keys.compactMap { ClaimType(rawValue: $0) } } diff --git a/Sources/AuthFoundation/Migration/Migrators/OIDCLegacyMigrator.swift b/Sources/AuthFoundation/Migration/Migrators/OIDCLegacyMigrator.swift index a498f03ab..e568b4e63 100644 --- a/Sources/AuthFoundation/Migration/Migrators/OIDCLegacyMigrator.swift +++ b/Sources/AuthFoundation/Migration/Migrators/OIDCLegacyMigrator.swift @@ -42,15 +42,6 @@ extension SDKVersion.Migration { try register(try OAuth2Client.PropertyListConfiguration(plist: fileURL)) } - @available(*, deprecated, renamed: "register(clientId:)") - public static func register(issuer: URL, - clientId: String, - redirectUri: URL, - scopes: String) - { - register(clientId: clientId) - } - /// Registers the legacy OIDC migration using the supplied configuration values. /// - Parameters: /// - clientId: Client ID for your application. @@ -164,9 +155,10 @@ extension SDKVersion.Migration { idToken = nil } - let configuration = OAuth2Client.Configuration(baseURL: issuer, + let configuration = OAuth2Client.Configuration(issuerURL: issuer, clientId: clientId, - scopes: scope) + scope: scope, + redirectUri: redirectURL) var clientSettings: [String: String] = [ "client_id": clientId, "redirect_uri": redirectURL.absoluteString, diff --git a/Sources/AuthFoundation/Network/APIClient.swift b/Sources/AuthFoundation/Network/APIClient.swift index 8ebfac641..1b69ae3fa 100644 --- a/Sources/AuthFoundation/Network/APIClient.swift +++ b/Sources/AuthFoundation/Network/APIClient.swift @@ -16,7 +16,7 @@ import Foundation import FoundationNetworking #endif -public protocol APIClientConfiguration: AnyObject { +public protocol APIClientConfiguration { var baseURL: URL { get } } @@ -45,7 +45,7 @@ public protocol APIClient { /// /// The userInfo property may be included, which can include contextual information that can help decoders formulate objects. /// - Returns: Decoded object. - func decode(_ type: T.Type, from data: Data, userInfo: [CodingUserInfoKey: Any]?) throws -> T + func decode(_ type: T.Type, from data: Data, parsing context: APIParsingContext?) throws -> T /// Parses HTTP response body data when a request fails. /// - Returns: Error instance, if any, described within the data. @@ -102,6 +102,7 @@ extension APIClientDelegate { public enum APIRetry { /// Indicates the APIRequest should not be retried. case doNotRetry + /// The APIRequest should be retried, up to the given maximum number of times. case retry(maximumCount: Int) @@ -315,7 +316,7 @@ extension APIClient { return APIResponse(result: try decode(T.self, from: jsonData, - userInfo: context?.codingUserInfo), + parsing: context), date: date ?? Date(), statusCode: response.statusCode, links: relatedLinks(from: response.allHeaderFields["Link"] as? String), diff --git a/Sources/AuthFoundation/Network/APIRequest.swift b/Sources/AuthFoundation/Network/APIRequest.swift index 2dfdf15ed..f074abfcd 100644 --- a/Sources/AuthFoundation/Network/APIRequest.swift +++ b/Sources/AuthFoundation/Network/APIRequest.swift @@ -148,7 +148,22 @@ public protocol APIParsingContext { } extension APIParsingContext { + @_documentation(visibility: private) + public var codingUserInfo: [CodingUserInfoKey: Any]? { + if let flowRequest = self as? any AuthenticationFlowRequest, + let persistValues = flowRequest.context.persistValues, + !persistValues.isEmpty + { + return [ .clientSettings: persistValues ] + } + + return nil + } + + @_documentation(visibility: private) public func error(from data: Data) -> Error? { nil } + + @_documentation(visibility: private) public func resultType(from response: HTTPURLResponse) -> APIResponseResult { APIResponseResult(statusCode: response.statusCode) } diff --git a/Sources/AuthFoundation/Network/APIRequestArgument.swift b/Sources/AuthFoundation/Network/APIRequestArgument.swift index 6e3257464..0ab702402 100644 --- a/Sources/AuthFoundation/Network/APIRequestArgument.swift +++ b/Sources/AuthFoundation/Network/APIRequestArgument.swift @@ -134,5 +134,18 @@ extension JWT: APIRequestArgument {} @_documentation(visibility: private) extension GrantType: APIRequestArgument {} +@_documentation(visibility: private) +extension [GrantType]: APIRequestArgument { + public var stringValue: String { + map(\.rawValue) + .joined(separator: " ") + } +} + @_documentation(visibility: private) extension Token.Kind: APIRequestArgument {} + +@_documentation(visibility: private) +extension URL: APIRequestArgument { + public var stringValue: String { absoluteString } +} diff --git a/Sources/AuthFoundation/Network/APIResponse.swift b/Sources/AuthFoundation/Network/APIResponse.swift index c1fb28a95..3bd1eaeb1 100644 --- a/Sources/AuthFoundation/Network/APIResponse.swift +++ b/Sources/AuthFoundation/Network/APIResponse.swift @@ -14,9 +14,6 @@ import Foundation /// Describes a response from an Okta request, which includes the supplied result, and other associated response metadata. public struct APIResponse: Decodable { - @available(*, deprecated, renamed: "APIRateLimit") - public typealias RateLimit = APIRateLimit - /// Links between response resources. public enum Link: String, Codable { case current = "self", next, previous diff --git a/Sources/AuthFoundation/Network/OktaAPIError.swift b/Sources/AuthFoundation/Network/OktaAPIError.swift index cd7e7013b..9a4b6629f 100644 --- a/Sources/AuthFoundation/Network/OktaAPIError.swift +++ b/Sources/AuthFoundation/Network/OktaAPIError.swift @@ -29,8 +29,10 @@ public struct OktaAPIError: Decodable, Error, LocalizedError, Equatable { /// Further information about what caused this error. public let causes: [String] + @_documentation(visibility: internal) public var errorDescription: String? { summary } + @_documentation(visibility: internal) public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) code = try container.decode(String.self, forKey: .code) diff --git a/Sources/AuthFoundation/OAuth2/Authentication.swift b/Sources/AuthFoundation/OAuth2/Authentication.swift index 3f866f7d7..0e134db26 100644 --- a/Sources/AuthFoundation/OAuth2/Authentication.swift +++ b/Sources/AuthFoundation/OAuth2/Authentication.swift @@ -30,50 +30,127 @@ public protocol AuthenticationDelegate: AnyObject { /// A protocol defining a type of authentication flow. /// /// OAuth2 supports a variety of authentication flows, each with its own capabilities, configuration, and limitations. To normalize these differences, the AuthenticationFlow protocol is used to represent the common capabilities provided by all flows. -public protocol AuthenticationFlow: AnyObject, UsesDelegateCollection { +public protocol AuthenticationFlow: AnyObject, UsesDelegateCollection, IDTokenValidatorContext { + associatedtype Context: AuthenticationContext + + /// The object that stores the context and state for the current authentication session. + var context: Context? { get } + /// Indicates if this flow is currently authenticating. var isAuthenticating: Bool { get } + /// Optional request parameters to be added to requests made from this flow. + var additionalParameters: [String: APIRequestArgument]? { get } + /// Resets the authentication session. func reset() /// The collection of delegates this flow notifies for key authentication events. var delegateCollection: DelegateCollection { get } + + /// Required minimal initializer shared by all authentication flows. + init(client: OAuth2Client, + additionalParameters: [String: any APIRequestArgument]?) throws } -/// Optional configuration settings that can be used to customize an authentication flow. -public protocol AuthenticationFlowConfiguration: Equatable, ProvidesOAuth2Parameters { - /// The "nonce" value to send with this authorization request. - var nonce: String? { get } +extension AuthenticationFlow { + @_documentation(visibility: private) + public var nonce: String? { + guard let validatorContext = context as? IDTokenValidatorContext + else { + return nil + } + + return validatorContext.nonce + } + + @_documentation(visibility: private) + public var maxAge: TimeInterval? { + guard let validatorContext = context as? IDTokenValidatorContext + else { + return nil + } + + return validatorContext.maxAge + } + + /// Initializer that uses the configuration defined within the application's `Okta.plist` file. + public init() throws { + try self.init(try .init()) + } + + /// Initializer that uses the configuration defined within the given file URL. + /// - Parameter fileURL: File URL to a `plist` containing client configuration. + public init(plist fileURL: URL) throws { + try self.init(try .init(plist: fileURL)) + } - /// The maximum age an ID token can be when authenticating. - var maxAge: TimeInterval? { get } + private init(_ config: OAuth2Client.PropertyListConfiguration) throws { + try self.init(client: .init(config), + additionalParameters: config.additionalParameters) + } +} +/// Common protocol that all ``AuthenticationFlow`` ``AuthenticationFlow/Context`` type aliases must conform to. +/// +/// While instances of a particular ``AuthenticationFlow`` is configured for a particular OAuth2 client, the context supplied to the flow's `start` function represents the specific settings to customize an individual sign-in using that flow. +public protocol AuthenticationContext: ProvidesOAuth2Parameters { /// The ACR values, if any, which should be requested by the client. var acrValues: [String]? { get } + + /// The values from this context that should be persisted into the ``Token/Context-swift.struct`` when the resulting token is created. + /// + /// This is used to keep some data critical to the future lifecycle of the token associated with the object in storage, which may not be included in the final token response payload. + var persistValues: [String: String]? { get } } -extension AuthenticationFlowConfiguration { - public var additionalParameters: [String: any APIRequestArgument]? { - var result = [String: any APIRequestArgument]() - - if let nonce = nonce { - result["nonce"] = nonce +extension AuthenticationContext { + @_documentation(visibility: internal) + public var persistValues: [String: String]? { + if let acrValues = acrValues { + return ["acr_values": acrValues.joined(separator: " ")] } - if let maxAge = maxAge { - result["max_age"] = Int(maxAge).stringValue - } + return nil + } +} - if let acrValues = acrValues { +/// Common ``AuthenticationContext`` implementation for common or generic implementations of ``AuthenticationFlow``. +public struct StandardAuthenticationContext: AuthenticationContext { + /// The ACR values, if any, which should be requested by the client. + public var acrValues: [String]? + + /// Custom request parameters to be added to requests made for this particular sign-in attempt. + public var additionalParameters: [String: any APIRequestArgument]? + + /// Designated initializer. + /// - Parameters: + /// - acrValues: Authentication Context Reference values to include with this sign-in. + /// - additionalParameters: Custom request parameters to be added to requests made for this sign-in. + public init(acrValues: [String]? = nil, + additionalParameters: [String: any APIRequestArgument]? = nil) + { + self.acrValues = acrValues + self.additionalParameters = additionalParameters + } + + /// Designated initializer. + /// - Parameter additionalParameters: Custom request parameters to be added to requests made for this sign-in. + public init(_ additionalParameters: [String: any APIRequestArgument]?) { + self.init(acrValues: additionalParameters?.spaceSeparatedValues(for: "acr_values"), + additionalParameters: additionalParameters?.omitting("acr_values")) + } + + @_documentation(visibility: internal) + public func parameters(for category: OAuth2APIRequestCategory) -> [String: any APIRequestArgument]? { + var result = additionalParameters ?? [:] + + if category == .authorization, + let acrValues = acrValues + { result["acr_values"] = acrValues.joined(separator: " ") } - - return result - } -} -/// Errors that may be generated during the process of authenticating with a variety of authentication flows. -public enum AuthenticationError: Error { - case flowNotReady + return result.nilIfEmpty + } } diff --git a/Sources/AuthFoundation/OAuth2/ClientAuthentication.swift b/Sources/AuthFoundation/OAuth2/ClientAuthentication.swift index cdd058cd9..410b24a96 100644 --- a/Sources/AuthFoundation/OAuth2/ClientAuthentication.swift +++ b/Sources/AuthFoundation/OAuth2/ClientAuthentication.swift @@ -22,12 +22,18 @@ extension OAuth2Client { case clientSecret(String) @_documentation(visibility: private) - public var additionalParameters: [String: APIRequestArgument]? { - switch self { - case .none: + public func parameters(for category: OAuth2APIRequestCategory) -> [String: any APIRequestArgument]? { + switch category { + case .authorization, .token, .resource, .other: + switch self { + case .none: + return nil + case .clientSecret(let secret): + return ["client_secret": secret] + } + + case .configuration: return nil - case .clientSecret(let secret): - return ["client_secret": secret] } } } diff --git a/Sources/AuthFoundation/OAuth2/Configuration.swift b/Sources/AuthFoundation/OAuth2/Configuration.swift index 28b82ada1..963f6ae5b 100644 --- a/Sources/AuthFoundation/OAuth2/Configuration.swift +++ b/Sources/AuthFoundation/OAuth2/Configuration.swift @@ -16,46 +16,58 @@ extension OAuth2Client { /// The configuration for an ``OAuth2Client``. /// /// This defines the basic information necessary for interacting with an OAuth2 authorization server. - public final class Configuration: Codable, Equatable, Hashable, APIClientConfiguration { + public struct Configuration: Codable, Equatable, Hashable, ProvidesOAuth2Parameters { /// The base URL for interactions with this OAuth2 server. - public let baseURL: URL + public var issuerURL: URL /// The discovery URL used to retrieve the ``OpenIdConfiguration`` for this client. - public let discoveryURL: URL + public var discoveryURL: URL /// The unique client ID representing this ``OAuth2Client``. - public let clientId: String + public var clientId: String /// The list of OAuth2 scopes requested for this client. - public let scopes: String + public var scope: String + /// The Redirect URI, if this client configuration requires it. + public var redirectUri: URL? + + /// The Logout Redirect URI, if this client configuration requires it. + public var logoutRedirectUri: URL? + /// The type of authentication this client will perform when interacting with the authorization server. - public let authentication: ClientAuthentication + public var authentication: ClientAuthentication /// Initializer for constructing an OAuth2Client. /// - Parameters: - /// - baseURL: Base URL. + /// - issuerURL: Issuer URL for this client configuration. /// - discoveryURL: Discovery URL, or `nil` to accept the default OpenIDConfiguration endpoint. /// - clientId: The client ID. - /// - scopes: The list of OAuth2 scopes. - /// - authentication: The client authentication model to use (Default: `.none`) - public init(baseURL: URL, + /// - scope: The list of OAuth2 scopes. + /// - redirectUri: Optional `redirect_uri` value for this client. + /// - logoutRedirectUri: Optional `logout_redirect_uri` value for this client. + /// - authentication: The client authentication model to use (Default: ``OAuth2Client/ClientAuthentication/none``) + public init(issuerURL: URL, discoveryURL: URL? = nil, clientId: String, - scopes: String, + scope: String, + redirectUri: URL? = nil, + logoutRedirectUri: URL? = nil, authentication: ClientAuthentication = .none) { - var relativeURL = baseURL + var relativeURL = issuerURL // Ensure the base URL contains a trailing slash in its path, so request paths can be safely appended. if !relativeURL.lastPathComponent.isEmpty { relativeURL = relativeURL.appendingComponent("") } - self.baseURL = baseURL + self.issuerURL = issuerURL self.discoveryURL = discoveryURL ?? relativeURL.appendingComponent(".well-known/openid-configuration") self.clientId = clientId - self.scopes = scopes + self.scope = scope + self.redirectUri = redirectUri + self.logoutRedirectUri = logoutRedirectUri self.authentication = authentication } @@ -63,41 +75,106 @@ extension OAuth2Client { /// - Parameters: /// - domain: Domain name for the OAuth2 client. /// - clientId: The client ID. - /// - scopes: The list of OAuth2 scopes. - /// - authentication: The client authentication model to use (Default: `.none`) - public convenience init(domain: String, - clientId: String, - scopes: String, - authentication: ClientAuthentication = .none) throws + /// - scope: The list of OAuth2 scopes. + /// - redirectUri: Optional `redirect_uri` value for this client. + /// - logoutRedirectUri: Optional `logout_redirect_uri` value for this client. + /// - authentication: The client authentication model to use (Default: ``OAuth2Client/ClientAuthentication/none``) + public init(domain: String, + clientId: String, + scope: String, + redirectUri: String? = nil, + logoutRedirectUri: String? = nil, + authentication: ClientAuthentication = .none) throws { - guard let url = URL(string: "https://\(domain)") else { - throw OAuth2Error.invalidUrl + self.init(issuerURL: try URL(requiredString: "https://\(domain)"), + clientId: clientId, + scope: scope, + redirectUri: try URL(string: redirectUri), + logoutRedirectUri: try URL(string: logoutRedirectUri), + authentication: authentication) + } + + @_documentation(visibility: private) + public func parameters(for category: OAuth2APIRequestCategory) -> [String: any APIRequestArgument]? { + var result = authentication.parameters(for: category) ?? [:] + + switch category { + case .authorization, .token: + result["scope"] = scope + result["client_id"] = clientId + result["redirect_uri"] = redirectUri + case .configuration, .resource, .other: break } - self.init(baseURL: url, clientId: clientId, scopes: scopes, authentication: authentication) + return result.compactMapValues { $0 } } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.baseURL = try container.decode(URL.self, forKey: .baseURL) - self.discoveryURL = try container.decode(URL.self, forKey: .discoveryURL) - self.clientId = try container.decode(String.self, forKey: .clientId) - self.scopes = try container.decode(String.self, forKey: .scopes) - self.authentication = try container.decodeIfPresent(OAuth2Client.ClientAuthentication.self, forKey: .authentication) ?? .none + } +} + +fileprivate extension OAuth2Client.Configuration { + enum CodingKeysV1: String, CodingKey, CaseIterable { + case baseURL + case discoveryURL + case clientId + case scopes + case authentication + } + + enum CodingKeysV2: String, CodingKey, CaseIterable { + case issuerURL + case discoveryURL + case redirectUri + case logoutRedirectUri + case clientId + case scope + case authentication + } +} + +extension OAuth2Client.Configuration { + @_documentation(visibility: private) + public init(from decoder: Decoder) throws { + if let container = try? decoder.container(keyedBy: CodingKeysV1.self), + container.allKeys.contains(.baseURL) + { + self.init(issuerURL: try container.decode(URL.self, forKey: .baseURL), + discoveryURL: try container.decodeIfPresent(URL.self, forKey: .discoveryURL), + clientId: try container.decode(String.self, forKey: .clientId), + scope: try container.decode(String.self, forKey: .scopes), + authentication: try container.decodeIfPresent(OAuth2Client.ClientAuthentication.self, forKey: .authentication) ?? .none) } - - public static func == (lhs: OAuth2Client.Configuration, rhs: OAuth2Client.Configuration) -> Bool { - lhs.baseURL == rhs.baseURL && - lhs.clientId == rhs.clientId && - lhs.scopes == rhs.scopes && - lhs.authentication == rhs.authentication + + else if let container = try? decoder.container(keyedBy: CodingKeysV2.self) { + self.init(issuerURL: try container.decode(URL.self, forKey: .issuerURL), + discoveryURL: try container.decodeIfPresent(URL.self, forKey: .discoveryURL), + clientId: try container.decode(String.self, forKey: .clientId), + scope: try container.decode(String.self, forKey: .scope), + redirectUri: try container.decodeIfPresent(URL.self, forKey: .redirectUri), + logoutRedirectUri: try container.decodeIfPresent(URL.self, forKey: .logoutRedirectUri), + authentication: try container.decodeIfPresent(OAuth2Client.ClientAuthentication.self, forKey: .authentication) ?? .none) } - public func hash(into hasher: inout Hasher) { - hasher.combine(baseURL) - hasher.combine(clientId) - hasher.combine(scopes) - hasher.combine(authentication) + else { + throw DecodingError.dataCorrupted( + DecodingError.Context(codingPath: decoder.codingPath, + debugDescription: "Unsupported OAuth 2.0 configuration version")) } } + + @_documentation(visibility: private) + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeysV2.self) + try container.encode(issuerURL, forKey: .issuerURL) + try container.encode(discoveryURL, forKey: .discoveryURL) + try container.encode(clientId, forKey: .clientId) + try container.encode(scope, forKey: .scope) + try container.encodeIfPresent(redirectUri, forKey: .redirectUri) + try container.encodeIfPresent(logoutRedirectUri, forKey: .logoutRedirectUri) + try container.encode(authentication, forKey: .authentication) + } +} + +extension OAuth2Client.Configuration: APIClientConfiguration { + @_documentation(visibility: private) + public var baseURL: URL { issuerURL } } diff --git a/Sources/OktaDirectAuth/Internal/Extensions/OAuth2Error+Extensions.swift b/Sources/AuthFoundation/OAuth2/Extensions/OAuth2Error+Extensions.swift similarity index 63% rename from Sources/OktaDirectAuth/Internal/Extensions/OAuth2Error+Extensions.swift rename to Sources/AuthFoundation/OAuth2/Extensions/OAuth2Error+Extensions.swift index 08325d506..9ae11928e 100644 --- a/Sources/OktaDirectAuth/Internal/Extensions/OAuth2Error+Extensions.swift +++ b/Sources/AuthFoundation/OAuth2/Extensions/OAuth2Error+Extensions.swift @@ -1,5 +1,5 @@ // -// Copyright (c) 2023-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// Copyright (c) 2025-Present, Okta, Inc. and/or its affiliates. All rights reserved. // The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") // // You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. @@ -10,25 +10,27 @@ // See the License for the specific language governing permissions and limitations under the License. // -import Foundation - extension OAuth2Error { - init(_ error: Error) { + @_documentation(visibility: internal) + public init(_ error: Error) { if let error = error as? OAuth2Error { self = error } else if let error = error as? APIClientError { - self = .network(error: error) - } else if let error = error as? DirectAuthenticationFlowError { - switch error { - case .network(error: let error): - self = OAuth2Error(error) - case .oauth2(error: let error): - self = error - default: - self = .error(error) - } + self.init(error) + } else if let error = error as? OAuth2ServerError { + self = .server(error: error) } else { self = .error(error) } } + + @_documentation(visibility: internal) + public init(_ error: APIClientError) { + switch error { + case .serverError(let error): + self.init(error) + default: + self = .network(error: error) + } + } } diff --git a/Sources/AuthFoundation/OAuth2/Internal/ProvidesOAuth2Parameters+Extensions.swift b/Sources/AuthFoundation/OAuth2/Internal/ProvidesOAuth2Parameters+Extensions.swift index 30d5967ae..8587781af 100644 --- a/Sources/AuthFoundation/OAuth2/Internal/ProvidesOAuth2Parameters+Extensions.swift +++ b/Sources/AuthFoundation/OAuth2/Internal/ProvidesOAuth2Parameters+Extensions.swift @@ -12,36 +12,53 @@ import Foundation -extension ProvidesOAuth2Parameters { - @_documentation(visibility: private) - public var shouldOverride: Bool { true } -} - extension Dictionary { - @_documentation(visibility: private) + @_documentation(visibility: internal) @inlinable - public mutating func merge(_ oauth2Parameters: ProvidesOAuth2Parameters?) { - guard let oauth2Parameters = oauth2Parameters, - let additionalParameters = oauth2Parameters.additionalParameters + public mutating func merge(_ additionalParameters: Self?) { + guard let additionalParameters = additionalParameters else { return } - merge(additionalParameters) { oauth2Parameters.shouldOverride ? $1 : $0 } + merge(additionalParameters) { $1 } } - - @_documentation(visibility: private) + + @_documentation(visibility: internal) + @inlinable public var maxAge: TimeInterval? { + if let value = self["max_age"] as? String { + return TimeInterval(value) + } + + if let value = self["max_age"] as? Double { + return TimeInterval(value) + } + + return nil + } + + @_documentation(visibility: internal) @inlinable - public func merging(_ oauth2Parameters: ProvidesOAuth2Parameters?) -> [Key: Value] { - var result = self - result.merge(oauth2Parameters) - return result + public func spaceSeparatedValues(for key: String) -> [String]? { + if let value = self[key] as? [String] { + return value + } + + if let value = self[key] as? String { + return value.components(separatedBy: .whitespaces) + } + + return nil } -} -extension Dictionary: ProvidesOAuth2Parameters { - @_documentation(visibility: private) - public var additionalParameters: [String: any APIRequestArgument]? { - self + @_documentation(visibility: internal) + @inlinable + public mutating func removeSpaceSeparatedValues(forKey key: String) -> [String]? { + if let value = spaceSeparatedValues(for: key) { + removeValue(forKey: key) + return value + } + + return nil } } diff --git a/Sources/AuthFoundation/OAuth2/OAuth2Client.swift b/Sources/AuthFoundation/OAuth2/OAuth2Client.swift index abb910dd1..ecd2f5638 100644 --- a/Sources/AuthFoundation/OAuth2/OAuth2Client.swift +++ b/Sources/AuthFoundation/OAuth2/OAuth2Client.swift @@ -54,44 +54,65 @@ public final class OAuth2Client { /// Constructs an OAuth2Client for the given domain. /// - Parameters: - /// - domain: Okta domain to use for the base URL. + /// - domain: Okta domain to use when composing the issuer URL. /// - clientId: The unique client ID representing this client. - /// - scopes: The list of OAuth2 scopes requested for this client. - /// - authentication: The client authentication model to use (Default: `.none`) + /// - scope: The list of OAuth2 scopes requested for this client. + /// - redirectUri: Optional `redirect_uri` value for this client. + /// - logoutRedirectUri: Optional `logout_redirect_uri` value for this client. + /// - authentication: The client authentication model to use (Default: ``OAuth2Client/ClientAuthentication/none``) /// - session: Optional URLSession to use for network requests. public convenience init(domain: String, clientId: String, - scopes: String, + scope: String, + redirectUri: String? = nil, + logoutRedirectUri: String? = nil, authentication: ClientAuthentication = .none, session: URLSessionProtocol? = nil) throws { self.init(try Configuration(domain: domain, clientId: clientId, - scopes: scopes, + scope: scope, + redirectUri: redirectUri, + logoutRedirectUri: logoutRedirectUri, authentication: authentication), session: session) } /// Constructs an OAuth2Client for the given domain. /// - Parameters: - /// - baseURL: The base URL for operations against this client. + /// - issuerURL: The issuer URL for operations against this client. /// - clientId: The unique client ID representing this client. - /// - scopes: The list of OAuth2 scopes requested for this client. + /// - scope: The list of OAuth2 scopes requested for this client. + /// - redirectUri: Optional `redirect_uri` value for this client. + /// - logoutRedirectUri: Optional `logout_redirect_uri` value for this client. /// - authentication: The client authentication model to use (Default: `.none`) /// - session: Optional URLSession to use for network requests. - public convenience init(baseURL: URL, + public convenience init(issuerURL: URL, clientId: String, - scopes: String, + scope: String, + redirectUri: URL? = nil, + logoutRedirectUri: URL? = nil, authentication: ClientAuthentication = .none, session: URLSessionProtocol? = nil) { - self.init(Configuration(baseURL: baseURL, + self.init(Configuration(issuerURL: issuerURL, clientId: clientId, - scopes: scopes, + scope: scope, + redirectUri: redirectUri, + logoutRedirectUri: logoutRedirectUri, authentication: authentication), session: session) } + @_documentation(visibility: internal) + public convenience init(_ config: OAuth2Client.PropertyListConfiguration) { + self.init(issuerURL: config.issuerURL, + clientId: config.clientId, + scope: config.scope, + redirectUri: config.redirectUri, + logoutRedirectUri: config.logoutRedirectUri) + } + /// Constructs an OAuth2Client for the given base URL. /// - Parameters: /// - configuration: The pre-formed configuration for this client. @@ -198,8 +219,8 @@ public final class OAuth2Client { let request = Token.RefreshRequest(openIdConfiguration: configuration, clientConfiguration: self.configuration, refreshToken: refreshToken, - id: token.id, - configuration: clientSettings) + scope: nil, + id: token.id) let backgroundTask = BackgroundTask(named: "Refresh Token \(token.id)") request.send(to: self) { result in self.refreshQueue.sync(flags: .barrier) { @@ -641,10 +662,22 @@ extension OAuth2Client: APIClient { return nil } - public func decode(_ type: T.Type, from data: Data, userInfo: [CodingUserInfoKey: Any]? = nil) throws -> T where T: Decodable { - var info: [CodingUserInfoKey: Any] = userInfo ?? [:] - if info[.apiClientConfiguration] == nil { - info[.apiClientConfiguration] = configuration + @_documentation(visibility: private) + public func decode(_ type: T.Type, from data: Data, parsing context: APIParsingContext? = nil) throws -> T where T: Decodable { + var info: [CodingUserInfoKey: Any] = context?.codingUserInfo ?? [:] + + if let tokenRequest = context as? any OAuth2TokenRequest, + info[.tokenContext] == nil + { + var clientSettings = info[.clientSettings] as? [String: String] ?? [:] + if let tokenRequest = tokenRequest as? any AuthenticationFlowRequest, + let persistValues = tokenRequest.context.persistValues + { + clientSettings.merge(persistValues) { (_, new) in new } + } + + info[.tokenContext] = Token.Context(configuration: configuration, + clientSettings: clientSettings) } let jsonDecoder: JSONDecoder @@ -659,22 +692,27 @@ extension OAuth2Client: APIClient { return try jsonDecoder.decode(type, from: data) } + @_documentation(visibility: private) public func willSend(request: inout URLRequest) { delegateCollection.invoke { $0.api(client: self, willSend: &request) } } + @_documentation(visibility: private) public func didSend(request: URLRequest, received error: APIClientError, requestId: String?, rateLimit: APIRateLimit?) { delegateCollection.invoke { $0.api(client: self, didSend: request, received: error, requestId: requestId, rateLimit: rateLimit) } } + @_documentation(visibility: private) public func shouldRetry(request: URLRequest) -> APIRetry { return delegateCollection.call({ $0.api(client: self, shouldRetry: request) }).first ?? .default } + @_documentation(visibility: private) public func didSend(request: URLRequest, received response: HTTPURLResponse) { delegateCollection.invoke { $0.api(client: self, didSend: request, received: response) } } + @_documentation(visibility: private) public func didSend(request: URLRequest, received response: APIResponse) where T: Decodable { delegateCollection.invoke { $0.api(client: self, didSend: request, received: response) } } diff --git a/Sources/AuthFoundation/OAuth2/OAuth2ClientConfiguration.swift b/Sources/AuthFoundation/OAuth2/OAuth2ClientConfiguration.swift index 8439cec31..d2fe030d4 100644 --- a/Sources/AuthFoundation/OAuth2/OAuth2ClientConfiguration.swift +++ b/Sources/AuthFoundation/OAuth2/OAuth2ClientConfiguration.swift @@ -24,15 +24,17 @@ extension OAuth2Client { /// Utility struct used internally to process `Okta.plist` and other similar client configuration files. /// /// > Important: This struct is intended for internal use, and may be subject to change. - public struct PropertyListConfiguration: ProvidesOAuth2Parameters { + public struct PropertyListConfiguration { + private static let ignoreAdditionalKeys: Set = ["issuer", "issuer_url", "client_id", "scope", "scopes", "redirect_uri", "logout_redirect_uri"] + /// The client issuer URL, defined in the "issuer" key. - public let issuer: URL + public let issuerURL: URL /// The client ID, defined in the "clientId" key. public let clientId: String /// The client scopes, defined in the "scopes" key. - public let scopes: String + public let scope: String /// The client's redirect URI, if one is applicable, defined in the "redirectUri" key. public let redirectUri: URL? @@ -69,57 +71,59 @@ extension OAuth2Client { throw PropertyListConfigurationError.cannotParsePropertyList(error) } - guard let dict = plistContent as? [String: String] else { + guard let rawDict = plistContent as? [String: String] else { throw PropertyListConfigurationError.cannotParsePropertyList(nil) } - guard let clientId = dict["clientId"], + let dict = rawDict.map(by: \.snakeCase) + guard let clientId = dict["client_id"], !clientId.isEmpty, - let issuer = dict["issuer"], + let issuer = dict.value("issuer", or: "issuer_url"), let issuerUrl = URL(string: issuer), - let scopes = dict["scopes"], - !scopes.isEmpty + let scope = dict.value("scope", or: "scopes"), + !scope.isEmpty else { throw PropertyListConfigurationError.missingConfigurationValues } let redirectUri: URL? - if let redirectUriString = dict["redirectUri"] { + if let redirectUriString = dict["redirect_uri"] { redirectUri = URL(string: redirectUriString) } else { redirectUri = nil } let logoutRedirectUri: URL? - if let logoutRedirectUriString = dict["logoutRedirectUri"] { + if let logoutRedirectUriString = dict["logout_redirect_uri"] { logoutRedirectUri = URL(string: logoutRedirectUriString) } else { logoutRedirectUri = nil } // Filter only additional parameters - let additionalParameters = dict.filter { - !["clientId", "issuer", "scopes", "redirectUri", "logoutRedirectUri"].contains($0.key) + let additionalParameters = rawDict.filter { (key, _) in + !Self.ignoreAdditionalKeys.contains(key) && + !Self.ignoreAdditionalKeys.contains(key.snakeCase) } - self.init(issuer: issuerUrl, + self.init(issuerURL: issuerUrl, clientId: clientId, - scopes: scopes, + scope: scope, redirectUri: redirectUri, logoutRedirectUri: logoutRedirectUri, additionalParameters: additionalParameters.isEmpty ? nil : additionalParameters) } - init(issuer: URL, + init(issuerURL: URL, clientId: String, - scopes: String, + scope: String, redirectUri: URL? = nil, logoutRedirectUri: URL? = nil, additionalParameters: [String: String]? = nil) { - self.issuer = issuer + self.issuerURL = issuerURL self.clientId = clientId - self.scopes = scopes + self.scope = scope self.redirectUri = redirectUri self.logoutRedirectUri = logoutRedirectUri self.additionalParameters = additionalParameters diff --git a/Sources/AuthFoundation/OAuth2/OAuth2Error.swift b/Sources/AuthFoundation/OAuth2/OAuth2Error.swift index bd5be679c..46c323714 100644 --- a/Sources/AuthFoundation/OAuth2/OAuth2Error.swift +++ b/Sources/AuthFoundation/OAuth2/OAuth2Error.swift @@ -20,9 +20,9 @@ public enum OAuth2Error: Error { /// Cannot compose a URL to authenticate with. case cannotComposeUrl - /// An OAuth2 server error was reported, with the given values. - case oauth2Error(code: String, description: String?, additionalKeys: [String: String]? = nil) - + /// An OAuth2 server error has been returned. + case server(error: OAuth2ServerError) + /// A network error was encountered, encapsulating a ``APIClientError`` type describing the underlying error. case network(error: APIClientError) @@ -32,6 +32,9 @@ public enum OAuth2Error: Error { /// Cannot perform an operation since the token is missing its client configuration. case missingClientConfiguration + /// An operation was performed which requires a `redirect_uri`, but none was supplied to the client configuration. + case missingRedirectUri + /// Could not verify the token's signature. case signatureInvalid @@ -63,51 +66,43 @@ extension OAuth2Error: LocalizedError { bundle: .authFoundation, comment: "Invalid URL") + case .missingRedirectUri: + return NSLocalizedString("missing_client_redirect_uri", + tableName: "AuthFoundation", + bundle: .authFoundation, + comment: "Missing redirect URI") + case .cannotComposeUrl: return NSLocalizedString("cannot_compose_url_description", tableName: "AuthFoundation", bundle: .authFoundation, - comment: "Invalid URL") + comment: "Cannot compose URL") - case .oauth2Error(let code, let description, _): - if let description = description { - return String.localizedStringWithFormat( - NSLocalizedString("oauth2_error_description", - tableName: "AuthFoundation", - bundle: .authFoundation, - comment: "Invalid URL"), - description, code) - } - - return String.localizedStringWithFormat( - NSLocalizedString("oauth2_error_code_description", - tableName: "AuthFoundation", - bundle: .authFoundation, - comment: "Invalid URL"), - code) + case .server(error: let error): + return error.errorDescription - case .network(let error): - return error.localizedDescription + case .network(error: let error): + return error.errorDescription case .missingToken(let type): return String.localizedStringWithFormat( NSLocalizedString("missing_token_description", tableName: "AuthFoundation", bundle: .authFoundation, - comment: "Invalid URL"), + comment: "Missing token"), type.rawValue) case .missingClientConfiguration: return NSLocalizedString("missing_client_configuration_description", tableName: "AuthFoundation", bundle: .authFoundation, - comment: "Invalid URL") + comment: "Missing client configuration") case .signatureInvalid: return NSLocalizedString("signature_invalid", tableName: "AuthFoundation", bundle: .authFoundation, - comment: "Invalid URL") + comment: "Signature is invalid") case .missingLocationHeader: return NSLocalizedString("missing_location_header", @@ -120,7 +115,7 @@ extension OAuth2Error: LocalizedError { NSLocalizedString("missing_openid_configuration_attribute", tableName: "AuthFoundation", bundle: .authFoundation, - comment: "Invalid URL"), + comment: "Missing OpenID configuration attribute"), name) case .error(let error): @@ -133,14 +128,14 @@ extension OAuth2Error: LocalizedError { NSLocalizedString("error_description", tableName: "AuthFoundation", bundle: .authFoundation, - comment: "Invalid URL"), + comment: "Localized error description"), errorString) case .cannotRevoke: return NSLocalizedString("cannot_revoke_token", tableName: "AuthFoundation", bundle: .authFoundation, - comment: "") + comment: "Cannot revoke token") case .multiple(errors: let errors): let errorString = errors @@ -151,7 +146,7 @@ extension OAuth2Error: LocalizedError { NSLocalizedString("multiple_oauth2_errors", tableName: "AuthFoundation", bundle: .authFoundation, - comment: ""), + comment: "Multiple OAuth2 errors"), errorString) case .missingOAuth2ResponseKey(let key): @@ -159,7 +154,7 @@ extension OAuth2Error: LocalizedError { NSLocalizedString("missing_oauth2_response_key", tableName: "AuthFoundation", bundle: .authFoundation, - comment: ""), + comment: "Missing OAuth2 response key"), key) } @@ -175,14 +170,14 @@ extension OAuth2Error: Equatable { public static func == (lhs: OAuth2Error, rhs: OAuth2Error) -> Bool { switch (lhs, rhs) { case (.invalidUrl, .invalidUrl): return true + case (.missingRedirectUri, .missingRedirectUri): return true case (.cannotComposeUrl, .cannotComposeUrl): return true case (.signatureInvalid, .signatureInvalid): return true case (.missingLocationHeader, .missingLocationHeader): return true case (.missingClientConfiguration, .missingClientConfiguration): return true + case (.server(error: let lhsError), .server(error: let rhsError)): + return lhsError == rhsError - case (.oauth2Error(code: let lhsCode, description: let lhsDescription, additionalKeys: _), .oauth2Error(code: let rhsCode, description: let rhsDescription, additionalKeys: _)): - return (lhsCode == rhsCode && lhsDescription == rhsDescription) - case (.network(error: let lhsError), .network(error: let rhsError)): return lhsError == rhsError diff --git a/Sources/AuthFoundation/OAuth2/OAuth2TokenRequest.swift b/Sources/AuthFoundation/OAuth2/OAuth2TokenRequest.swift index 2a977e5c5..ba7b8ada8 100644 --- a/Sources/AuthFoundation/OAuth2/OAuth2TokenRequest.swift +++ b/Sources/AuthFoundation/OAuth2/OAuth2TokenRequest.swift @@ -15,25 +15,12 @@ import Foundation /// Protocol that represents a type of ``APIRequest`` that can be used to exchange a token. /// /// Many different OAuth2 authentication flows can issue tokens, but the types of arguments and their particular workflow can differ. This protocol abstracts the necessary interface for requests that are capable of returning tokens, while allowing the specific arguments and validation steps to be implemented for each unique type of flow. -public protocol OAuth2TokenRequest: APIRequest, APIRequestBody where ResponseType == Token { - /// The application's OAuth2 `client_id` value for this token request. - var clientId: String { get } - - /// The client's Open ID Configuration object defining the settings and endpoints used to interact with this Authorization Server. - var openIdConfiguration: OpenIdConfiguration { get } - - /// The flow's configuration settings used to customize the token request. - var authenticationFlowConfiguration: (any AuthenticationFlowConfiguration)? { get } +public protocol OAuth2TokenRequest: APIParsingContext, OAuth2APIRequest, APIRequestBody where ResponseType == Token { + /// The configuration for the OAuth2 client this token is being requested from. + var clientConfiguration: OAuth2Client.Configuration { get } } extension OAuth2TokenRequest { - /// Convenience function that exposes an array of ACR Values requested by this OAuth2 token request, if applicable. - public var acrValues: [String]? { - bodyParameters? - .stringComponents["acr_values"]? - .components(separatedBy: .whitespaces) - } - public var url: URL { openIdConfiguration.tokenEndpoint } public var httpMethod: APIRequestMethod { .post } public var contentType: APIContentType? { .formEncoded } diff --git a/Sources/AuthFoundation/OAuth2/ProvidesOAuth2Parameters.swift b/Sources/AuthFoundation/OAuth2/ProvidesOAuth2Parameters.swift index b885e91d3..626009223 100644 --- a/Sources/AuthFoundation/OAuth2/ProvidesOAuth2Parameters.swift +++ b/Sources/AuthFoundation/OAuth2/ProvidesOAuth2Parameters.swift @@ -14,9 +14,8 @@ import Foundation /// Used by types that are capable of providing parameters to OAuth2 API requests. public protocol ProvidesOAuth2Parameters { - /// The additional parameters this authentication type will contribute to outgoing API requests when needed. - var additionalParameters: [String: APIRequestArgument]? { get } - - /// Indicates if the parameters included in the result should override those previously declared. - var shouldOverride: Bool { get } + /// The OAuth2 parameters this authentication type will contribute to outgoing API requests when needed. + /// - Parameter category: Category describing the type of request the parameters should be applicable to. + /// - Returns: Parameters to include in requests for the given request category. + func parameters(for category: OAuth2APIRequestCategory) -> [String: any APIRequestArgument]? } diff --git a/Sources/AuthFoundation/Requests/KeysRequest.swift b/Sources/AuthFoundation/Requests/KeysRequest.swift index a4e247840..b1d1d9bef 100644 --- a/Sources/AuthFoundation/Requests/KeysRequest.swift +++ b/Sources/AuthFoundation/Requests/KeysRequest.swift @@ -26,6 +26,7 @@ extension OAuth2Client { extension OAuth2Client.KeysRequest: OAuth2APIRequest { typealias ResponseType = JWKS + var category: OAuth2APIRequestCategory { .configuration } var httpMethod: APIRequestMethod { .get } var url: URL { openIdConfiguration.jwksUri } var acceptsType: APIContentType? { .json } diff --git a/Sources/AuthFoundation/Requests/Token+Requests.swift b/Sources/AuthFoundation/Requests/Token+Requests.swift index 958564f73..61f796feb 100644 --- a/Sources/AuthFoundation/Requests/Token+Requests.swift +++ b/Sources/AuthFoundation/Requests/Token+Requests.swift @@ -43,8 +43,8 @@ extension Token { let openIdConfiguration: OpenIdConfiguration let clientConfiguration: OAuth2Client.Configuration let refreshToken: String + let scope: String? let id: String - let configuration: [String: APIRequestArgument] static let placeholderId = "temporary_id" } @@ -80,8 +80,39 @@ extension Token: APIAuthorization { /// Sub-protocol of ``APIRequest`` used to define requests that are performed using links supplied via an organization's ``OpenIdConfiguration``. public protocol OAuth2APIRequest: APIRequest { - /// The ``OpenIdConfiguration`` used to formulate this request's ``APIRequest/url``. + /// The client's Open ID Configuration object defining the settings and endpoints used to interact with this Authorization Server. var openIdConfiguration: OpenIdConfiguration { get } + + /// The category for the request being made, which can be used to determine which arguments are included. + var category: OAuth2APIRequestCategory { get } +} + +/// Protocol used by requests that are initiated by a class conforming to ``AuthenticationFlow``. +/// +/// Some authentication flows consist of multiple requests, and as a result critical context information that is important for response parsing and object persistence may not be available on the final request. This object enables the context from the flow to be made available to the API request and response parsing lifecycle. +public protocol AuthenticationFlowRequest { + associatedtype Flow: AuthenticationFlow + + /// The authentication flow's ``AuthenticationContext`` instance that created this request. + var context: Flow.Context { get } +} + +/// Categorizes the types of requests made to an authorization server. +public enum OAuth2APIRequestCategory: CaseIterable { + /// Requests used for discovery of an authorization server's configuration + case configuration + + /// Initiates an authorization workflow. + case authorization + + /// Requests a token from an authorization server. + case token + + /// Perform a resource server request using an access token. + case resource + + /// Other uncategorized requests. + case other } extension Token.RevokeRequest: OAuth2APIRequest, APIRequestBody { @@ -90,6 +121,7 @@ extension Token.RevokeRequest: OAuth2APIRequest, APIRequestBody { var httpMethod: APIRequestMethod { .post } var contentType: APIContentType? { .formEncoded } var acceptsType: APIContentType? { .json } + var category: OAuth2APIRequestCategory { .other } var bodyParameters: [String: APIRequestArgument]? { var result = configuration result["token"] = token @@ -98,7 +130,7 @@ extension Token.RevokeRequest: OAuth2APIRequest, APIRequestBody { result["token_type_hint"] = hint } - result.merge(clientAuthentication) + result.merge(clientAuthentication.parameters(for: category)) return result } @@ -111,6 +143,7 @@ extension Token.IntrospectRequest: OAuth2APIRequest, APIRequestBody { var contentType: APIContentType? { .formEncoded } var acceptsType: APIContentType? { .json } var authorization: APIAuthorization? { nil } + var category: OAuth2APIRequestCategory { .other } var bodyParameters: [String: APIRequestArgument]? { var result: [String: APIRequestArgument] = [ "token": token.token(of: type) ?? "", @@ -118,48 +151,41 @@ extension Token.IntrospectRequest: OAuth2APIRequest, APIRequestBody { "token_type_hint": type ] - result.merge(clientConfiguration.authentication) + result.merge(clientConfiguration.parameters(for: category)) return result } } extension Token.RefreshRequest: OAuth2APIRequest, APIRequestBody, APIParsingContext, OAuth2TokenRequest { - var authenticationFlowConfiguration: (any AuthenticationFlowConfiguration)? { - nil - } - typealias ResponseType = Token var httpMethod: APIRequestMethod { .post } var url: URL { openIdConfiguration.tokenEndpoint } var contentType: APIContentType? { .formEncoded } var acceptsType: APIContentType? { .json } - var clientId: String { clientConfiguration.clientId } + var category: OAuth2APIRequestCategory { .token } var bodyParameters: [String: APIRequestArgument]? { - var result: [String: APIRequestArgument] = configuration - result["grant_type"] = "refresh_token" - result["refresh_token"] = refreshToken + var result: [String: any APIRequestArgument] = [ + "grant_type": "refresh_token", + "refresh_token": refreshToken, + ] + result.merge(clientConfiguration.parameters(for: category)) - result.merge(clientConfiguration.authentication) + if let scope = scope { + result["scope"] = scope + } else { + result.removeValue(forKey: "scope") + } return result } var codingUserInfo: [CodingUserInfoKey: Any]? { - guard let settings = configuration.reduce(into: [:], { partialResult, item in - guard let key = CodingUserInfoKey(rawValue: item.key) else { return } - partialResult?[key] = item.value - }) else { return nil } - - var result: [CodingUserInfoKey: Any] = [ - .clientSettings: settings - ] - if id != Self.placeholderId { - result[.tokenId] = id + return [.tokenId: id] } - return result + return nil } } diff --git a/Sources/AuthFoundation/Requests/UserInfo+Requests.swift b/Sources/AuthFoundation/Requests/UserInfo+Requests.swift index 2566f6733..7bcda1a78 100644 --- a/Sources/AuthFoundation/Requests/UserInfo+Requests.swift +++ b/Sources/AuthFoundation/Requests/UserInfo+Requests.swift @@ -38,4 +38,5 @@ extension UserInfo.Request: APIRequest, OAuth2APIRequest { var httpMethod: APIRequestMethod { .get } var acceptsType: APIContentType? { .json } var authorization: APIAuthorization? { token } + var category: OAuth2APIRequestCategory { .resource } } diff --git a/Sources/AuthFoundation/Resources/en.lproj/AuthFoundation.strings b/Sources/AuthFoundation/Resources/en.lproj/AuthFoundation.strings index 282a7f74a..101ca9b2f 100644 --- a/Sources/AuthFoundation/Resources/en.lproj/AuthFoundation.strings +++ b/Sources/AuthFoundation/Resources/en.lproj/AuthFoundation.strings @@ -14,8 +14,9 @@ /* OAuth2Error */ "missing_client_configuration_description" = "Cannot perform an operation since the token is missing its client configuration."; +"missing_client_redirect_uri" = "An operation was performed which requires a `redirect_uri`, but none was supplied to the client configuration."; "cannot_compose_url_description" = "Cannot compose a URL to authenticate with."; -"oauth2_error_description" = "Authentication error: %@ (code %d)."; +"oauth2_error_description" = "Authentication error: %@ (%@)."; "oauth2_error_code_description" = "Authentication error code %@."; "missing_token_description" = "Missing %@ token."; "error_description" = "Error: %@"; diff --git a/Sources/AuthFoundation/Responses/GrantType.swift b/Sources/AuthFoundation/Responses/GrantType.swift index c01f27ef5..69a52c601 100644 --- a/Sources/AuthFoundation/Responses/GrantType.swift +++ b/Sources/AuthFoundation/Responses/GrantType.swift @@ -13,7 +13,7 @@ import Foundation /// An enumeration used to define a grant type, which defines the methods an application can use to gain access tokens from an authorization server. -public enum GrantType: Codable, Hashable, IsClaim { +public enum GrantType: Codable, Hashable, Equatable, IsClaim { case authorizationCode case implicit case refreshToken diff --git a/Sources/AuthFoundation/Responses/OAuth2ServerError.swift b/Sources/AuthFoundation/Responses/OAuth2ServerError.swift index e8050f4ed..3c67f18d7 100644 --- a/Sources/AuthFoundation/Responses/OAuth2ServerError.swift +++ b/Sources/AuthFoundation/Responses/OAuth2ServerError.swift @@ -23,8 +23,27 @@ public struct OAuth2ServerError: Decodable, Error, LocalizedError, Equatable { /// Contains any additional values the server error reported alongside the code and description. public var additionalValues: [String: Any] - public var errorDescription: String? { description } + public var errorDescription: String? { + if let description = description { + return String.localizedStringWithFormat( + NSLocalizedString("oauth2_error_description", + tableName: "AuthFoundation", + bundle: .authFoundation, + comment: "OAuth2 server error description"), + description, + code.rawValue + ) + } + + return String.localizedStringWithFormat( + NSLocalizedString("oauth2_error_code_description", + tableName: "AuthFoundation", + bundle: .authFoundation, + comment: "OAuth2 server error code"), + code.rawValue) + } + @_documentation(visibility: internal) public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) code = try container.decode(Code.self, forKey: .code) @@ -34,12 +53,14 @@ public struct OAuth2ServerError: Decodable, Error, LocalizedError, Equatable { self.additionalValues = additionalContainer.decodeUnkeyedContainer(exclude: CodingKeys.self) } - public init(code: String, description: String?, additionalValues: [String: Any]) { + @_documentation(visibility: internal) + public init(code: String, description: String?, additionalValues: [String: Any] = [:]) { self.code = .init(rawValue: code) ?? .other(code: code) self.description = description self.additionalValues = additionalValues } + @_documentation(visibility: internal) public static func == (lhs: OAuth2ServerError, rhs: OAuth2ServerError) -> Bool { lhs.code == rhs.code && lhs.description == rhs.description @@ -56,7 +77,7 @@ extension OAuth2ServerError { public enum Code: Decodable { /// The authorization request is still pending as the end user hasn't yet completed the user-interaction step case authorizationPending - /// the authorization request is still pending and polling should continue + /// The authorization request is still pending and polling should continue case slowDown /// The `device_code` has expired, and the device authorization session has concluded. case expiredToken diff --git a/Sources/AuthFoundation/Responses/OpenIdConfiguration.swift b/Sources/AuthFoundation/Responses/OpenIdConfiguration.swift index 8bfe9975e..7dea88f43 100644 --- a/Sources/AuthFoundation/Responses/OpenIdConfiguration.swift +++ b/Sources/AuthFoundation/Responses/OpenIdConfiguration.swift @@ -22,6 +22,8 @@ public struct OpenIdConfiguration: Codable, JSONClaimContainer { public var payload: [String: Any] { jsonPayload.jsonValue.anyValue as? [String: Any] ?? [:] } let jsonPayload: AnyJSON + + @_documentation(visibility: internal) public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let json = try container.decode(JSON.self) diff --git a/Sources/AuthFoundation/Responses/TokenInfo.swift b/Sources/AuthFoundation/Responses/TokenInfo.swift index 358113002..a6c7f0c29 100644 --- a/Sources/AuthFoundation/Responses/TokenInfo.swift +++ b/Sources/AuthFoundation/Responses/TokenInfo.swift @@ -22,10 +22,12 @@ public struct TokenInfo: Codable, JSONClaimContainer { public let payload: [String: Any] + @_documentation(visibility: internal) public init(_ info: [String: Any]) { self.payload = info } + @_documentation(visibility: internal) public init(from decoder: Decoder) throws { self.init(try Self.decodePayload(from: decoder)) } @@ -38,5 +40,6 @@ public struct TokenInfo: Codable, JSONClaimContainer { } extension TokenInfo: JSONDecodable { + @_documentation(visibility: internal) public static var jsonDecoder = JSONDecoder() } diff --git a/Sources/AuthFoundation/Responses/UserInfo.swift b/Sources/AuthFoundation/Responses/UserInfo.swift index add7a225e..33522578f 100644 --- a/Sources/AuthFoundation/Responses/UserInfo.swift +++ b/Sources/AuthFoundation/Responses/UserInfo.swift @@ -26,11 +26,13 @@ public struct UserInfo: Codable, JSONClaimContainer { self.payload = info } + @_documentation(visibility: internal) public init(from decoder: Decoder) throws { self.init(try Self.decodePayload(from: decoder)) } } extension UserInfo { + @_documentation(visibility: internal) public static var jsonDecoder = JSONDecoder() } diff --git a/Sources/AuthFoundation/Security/Keychain.swift b/Sources/AuthFoundation/Security/Keychain.swift index 1946809d5..8a3111e3d 100644 --- a/Sources/AuthFoundation/Security/Keychain.swift +++ b/Sources/AuthFoundation/Security/Keychain.swift @@ -115,13 +115,15 @@ public struct Keychain { /// - Parameters: /// - account: The account ID or key to use for this item. /// - service: The service to group similar items together. + /// - server: The server name for this item. /// - accessibility: Defines when the item may be retrieved, based on device state. /// - accessGroup: The access group to store this item within. + /// - accessControl: The access control object to use. /// - synchronizable: Indicates if this keychain item may be synchronized to iCloud Keychain. /// - label: The human-readable label to summarize this item. /// - description: The human-readable description to add notes related to this item. /// - generic: Generic data associated with this item, which may always be read and is not restricted by the ``accessibility`` option. - /// - valueData: The secret value for this item. + /// - value: The secret value for this item. public init(account: String, service: String? = nil, server: String? = nil, @@ -245,6 +247,7 @@ public struct Keychain { /// - Parameters: /// - account: The account to search for, or `nil` to return all accounts. /// - service: The service to search for, or `nil` to return all services. + /// - server: The server name to search for, or `nil` to return all servers. /// - accessGroup: The access group to search within, or `nil` to return all access groups. public init(account: String? = nil, service: String? = nil, server: String? = nil, accessGroup: String? = nil) { self.account = account diff --git a/Sources/AuthFoundation/Security/PKCE.swift b/Sources/AuthFoundation/Security/PKCE.swift index 368431b53..ea793729c 100644 --- a/Sources/AuthFoundation/Security/PKCE.swift +++ b/Sources/AuthFoundation/Security/PKCE.swift @@ -26,12 +26,15 @@ public struct PKCE: Codable, Equatable { public let method: Method /// Enumeration describing the possible challenge encoding methods. - public enum Method: String, Codable { + public enum Method: String, Codable, APIRequestArgument { /// SHA256-encoding method case sha256 = "S256" /// Plain encoding, for clients where SHA256-encoding is unavailable. case plain + + @_documentation(visibility: internal) + public var stringValue: String { rawValue } } public init?() { diff --git a/Sources/AuthFoundation/Security/Internal/PKCEExtensions.swift b/Sources/AuthFoundation/Security/PKCEExtensions.swift similarity index 88% rename from Sources/AuthFoundation/Security/Internal/PKCEExtensions.swift rename to Sources/AuthFoundation/Security/PKCEExtensions.swift index bc246d5d6..9b2e3ae9d 100644 --- a/Sources/AuthFoundation/Security/Internal/PKCEExtensions.swift +++ b/Sources/AuthFoundation/Security/PKCEExtensions.swift @@ -19,9 +19,3 @@ internal extension String { data(using: .ascii)?.sha256()?.base64URLEncodedString } } - -extension FixedWidthInteger { - static func random() -> Self { - return Self.random(in: .min ... .max) - } -} diff --git a/Sources/AuthFoundation/Security/SecurityUtilities.swift b/Sources/AuthFoundation/Security/SecurityUtilities.swift index ea59cda4f..2d27f6fb1 100644 --- a/Sources/AuthFoundation/Security/SecurityUtilities.swift +++ b/Sources/AuthFoundation/Security/SecurityUtilities.swift @@ -29,3 +29,9 @@ extension Array where Element == UInt8 { Data(self).base64URLEncodedString } } + +extension FixedWidthInteger { + static func random() -> Self { + return Self.random(in: .min ... .max) + } +} diff --git a/Sources/AuthFoundation/Token Management/Token+Context.swift b/Sources/AuthFoundation/Token Management/Token+Context.swift index 5f9c89a34..85debe4cc 100644 --- a/Sources/AuthFoundation/Token Management/Token+Context.swift +++ b/Sources/AuthFoundation/Token Management/Token+Context.swift @@ -28,9 +28,7 @@ extension Token { if let settings = clientSettings as? [String: String]? { self.clientSettings = settings - } - - else if let settings = clientSettings as? [CodingUserInfoKey: String] { + } else if let settings = clientSettings as? [CodingUserInfoKey: String] { self.clientSettings = settings.reduce(into: [String: String]()) { (partialResult, tuple: (key: CodingUserInfoKey, value: String)) in partialResult[tuple.key.rawValue] = tuple.value } @@ -40,15 +38,43 @@ extension Token { } public init(from decoder: any Decoder) throws { + var configuration: OAuth2Client.Configuration + var clientSettings: [String: String]? + + // If the context is being decoded from previously-encoded data if let container = try? decoder.container(keyedBy: Token.Context.CodingKeys.self) { - self.init(configuration: try container.decode(OAuth2Client.Configuration.self, forKey: .configuration), - clientSettings: try container.decodeIfPresent([String: String].self, forKey: .clientSettings)) - } else if let configuration = decoder.userInfo[.apiClientConfiguration] as? OAuth2Client.Configuration { - self.init(configuration: configuration, - clientSettings: decoder.userInfo[.clientSettings]) - } else { + configuration = try container.decode(OAuth2Client.Configuration.self, forKey: .configuration) + clientSettings = try container.decodeIfPresent([String: String].self, forKey: .clientSettings) + } + + // No other decoding strategies supported + else { throw TokenError.contextMissing } + + // Migrate from v1 or v2 token storage, where the `redirect_uri` was not + // supplied in the OAuth2.Configuration + if let redirectUriString = clientSettings?["redirect_uri"], + let redirectUri = URL(string: redirectUriString), + configuration.redirectUri != redirectUri + { + configuration = .init(issuerURL: configuration.issuerURL, + discoveryURL: configuration.discoveryURL, + clientId: configuration.clientId, + scope: configuration.scope, + redirectUri: redirectUri, + authentication: configuration.authentication) + + clientSettings = clientSettings?.filter { (key, _) in + !["redirect_uri", "client_id", "scope"].contains(key) + } + if clientSettings?.isEmpty ?? true { + clientSettings = nil + } + } + + self.init(configuration: configuration, + clientSettings: clientSettings) } } } diff --git a/Sources/AuthFoundation/Token Management/Token+Initialization.swift b/Sources/AuthFoundation/Token Management/Token+Initialization.swift index 35462101b..b2ab24de0 100644 --- a/Sources/AuthFoundation/Token Management/Token+Initialization.swift +++ b/Sources/AuthFoundation/Token Management/Token+Initialization.swift @@ -14,9 +14,11 @@ import Foundation extension Token { public convenience init(from decoder: Decoder) throws { - var id: String = UUID().uuidString + // Initialize defaults supplied from the decoder's userInfo dictionary + var id: String = decoder.userInfo[.tokenId] as? String ?? UUID().uuidString var issuedAt: Date = Date.nowCoordinated - var context: Context? + var context: Context? = decoder.userInfo[.tokenContext] as? Token.Context + var json: AnyJSON // Initialize defaults supplied from the decoder's userInfo dictionary @@ -29,6 +31,7 @@ extension Token { clientSettings: decoder.userInfo[.clientSettings]) } + // Attempt to decode V1 token data if let container = try? decoder.container(keyedBy: CodingKeysV1.self), [.id, .accessToken].allSatisfy(container.allKeys.contains) diff --git a/Sources/AuthFoundation/Token Management/Token+Metadata.swift b/Sources/AuthFoundation/Token Management/Token+Metadata.swift index b5178c761..92b1ff9bb 100644 --- a/Sources/AuthFoundation/Token Management/Token+Metadata.swift +++ b/Sources/AuthFoundation/Token Management/Token+Metadata.swift @@ -63,10 +63,12 @@ extension Token { } extension Token.Metadata { + @_documentation(visibility: internal) public static var jsonDecoder = JSONDecoder() } extension Token.Metadata: Codable { + @_documentation(visibility: internal) public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -83,6 +85,7 @@ extension Token.Metadata: Codable { } } + @_documentation(visibility: internal) public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) diff --git a/Sources/AuthFoundation/Token Management/Token.swift b/Sources/AuthFoundation/Token Management/Token.swift index a1310b873..b2c82156e 100644 --- a/Sources/AuthFoundation/Token Management/Token.swift +++ b/Sources/AuthFoundation/Token Management/Token.swift @@ -93,7 +93,9 @@ public final class Token: Codable, Equatable, Hashable, JSONClaimContainer, Expi } /// Validates the claims within this JWT token, to ensure it matches the given ``OAuth2Client``. - /// - Parameter client: Client to validate the token's claims against. + /// - Parameters: + /// - client: Client to validate the token's claims against. + /// - context: Optional ``IDTokenValidatorContext`` to use when validating the token. public func validate(using client: OAuth2Client, with context: IDTokenValidatorContext?) throws { guard let idToken = idToken else { return @@ -126,11 +128,8 @@ public final class Token: Codable, Equatable, Hashable, JSONClaimContainer, Expi let request = Token.RefreshRequest(openIdConfiguration: configuration, clientConfiguration: client.configuration, refreshToken: refreshToken, - id: Token.RefreshRequest.placeholderId, - configuration: [ - "client_id": client.configuration.clientId, - "scope": client.configuration.scopes - ]) + scope: nil, + id: Token.RefreshRequest.placeholderId) client.exchange(token: request) { result in switch result { case .success(let response): @@ -191,7 +190,7 @@ public final class Token: Codable, Equatable, Hashable, JSONClaimContainer, Expi // When the custom MFA attestation ACR value is used, allow for // an empty / unspecified access token. - else if let acrValues = idToken?.authenticationClassReference, + else if let acrValues = context.clientSettings?["acr_values"]?.components(separatedBy: .whitespaces), acrValues.contains("urn:okta:app:mfa:attestation") { accessToken = "" @@ -258,6 +257,7 @@ extension CodingUserInfoKey { // swiftlint:disable force_unwrapping public static let tokenId = CodingUserInfoKey(rawValue: "tokenId")! public static let apiClientConfiguration = CodingUserInfoKey(rawValue: "apiClientConfiguration")! + public static let tokenContext = CodingUserInfoKey(rawValue: "tokenContext")! public static let clientSettings = CodingUserInfoKey(rawValue: "clientSettings")! public static let request = CodingUserInfoKey(rawValue: "request")! // swiftlint:enable force_unwrapping diff --git a/Sources/AuthFoundation/User Management/Credential.swift b/Sources/AuthFoundation/User Management/Credential.swift index 1e8348617..d7061f6e1 100644 --- a/Sources/AuthFoundation/User Management/Credential.swift +++ b/Sources/AuthFoundation/User Management/Credential.swift @@ -128,7 +128,7 @@ public final class Credential: Equatable, OAuth2ClientDelegate { /// Updates the metadata associated with this credential. /// /// This is used internally by the ``tags`` setter, except the use of this function allows you to catch errors. - /// - Parameter metadata: Metadata to set. + /// - Parameter tags: Metadata tags and values to set. public func setTags(_ tags: [String: String]) throws { guard let coordinator = coordinator else { throw CredentialError.missingCoordinator @@ -196,7 +196,9 @@ public final class Credential: Equatable, OAuth2ClientDelegate { } /// Attempt to refresh the token if it either has expired, or is about to expire. - /// - Parameter completion: Completion block invoked to indicate the status of the token, if the refresh was successful or if an error occurred. + /// - Parameters: + /// - graceInterval: The grace interval before a token is due to expire before it should be refreshed. + /// - completion: Completion block invoked to indicate the status of the token, if the refresh was successful or if an error occurred. public func refreshIfNeeded(graceInterval: TimeInterval = Credential.refreshGraceInterval, completion: @escaping (Result) -> Void) { @@ -249,7 +251,9 @@ public final class Credential: Equatable, OAuth2ClientDelegate { } /// Introspect the token to check it for validity, and read the additional information associated with it. - /// - Parameter completion: Completion block invoked when a result is returned. + /// - Parameters: + /// - type: The token type to introspect. + /// - completion: Completion block invoked when a result is returned. public func introspect(_ type: Token.Kind, completion: @escaping (Result) -> Void) { oauth2.introspect(token: token, type: type) { result in completion(result) diff --git a/Sources/AuthFoundation/Utilities/Dictionary+Extensions.swift b/Sources/AuthFoundation/Utilities/Dictionary+Extensions.swift index 5b97f16f6..8c5c51f4d 100644 --- a/Sources/AuthFoundation/Utilities/Dictionary+Extensions.swift +++ b/Sources/AuthFoundation/Utilities/Dictionary+Extensions.swift @@ -30,6 +30,70 @@ extension Dictionary where Key == String, Value == String { } } +extension Collection { + @_documentation(visibility: internal) + @inlinable public var nilIfEmpty: Self? { + isEmpty ? nil : self + } +} + +extension Sequence where Element: Equatable { + @_documentation(visibility: internal) + @inlinable + public func omitting(_ values: Element...) -> [Element] { + filter { !values.contains($0) } + } +} + +extension Dictionary { + @_documentation(visibility: internal) + @inlinable + public func omitting(_ keys: Key...) -> Self { + filter { !keys.contains($0.key) } + } +} + +extension Dictionary where Key: Hashable { + @_documentation(visibility: internal) + @inlinable + public func map(by keyPath: KeyPath) -> Self { + var result: Self = [:] + + for (key, value) in self { + result[key[keyPath: keyPath]] = value + } + + return result + } + + @_documentation(visibility: internal) + @inlinable + public func value(_ key: Key, or alternatives: Key...) -> Value? { + if let value = self[key] { + return value + } + + if let first = alternatives.first(where: self.keys.contains) { + return self[first] + } + + return nil + } + +// @_documentation(visibility: internal) +// @inlinable +// public func values(_ key: Key, using keyPath: KeyPath) -> [Value] { +// keys.filter { $0 == key || $0 == key[keyPath: keyPath] } +// .compactMap { self[$0] } +// } +// +// @_documentation(visibility: internal) +// @inlinable +// public func first(_ key: Key, using keyPath: KeyPath) -> Value? { +// values(key, using: keyPath).first +// } +} + extension Dictionary where Key == String, Value == (any APIRequestArgument)? { @_documentation(visibility: internal) public var percentQueryEncoded: String { diff --git a/Sources/AuthFoundation/Utilities/JSONValue.swift b/Sources/AuthFoundation/Utilities/JSONValue.swift index 74f1ef741..afcf0c448 100644 --- a/Sources/AuthFoundation/Utilities/JSONValue.swift +++ b/Sources/AuthFoundation/Utilities/JSONValue.swift @@ -17,10 +17,6 @@ public enum JSONError: Error { case invalidContentEncoding } -@_documentation(visibility: private) -@available(*, deprecated, renamed: "JSON") -public typealias JSONValue = JSON - /// Efficiently represents ``JSON`` values, and exchanges between its String or Data representations. /// /// JSON data may be imported from multiple sources, be it Data, a String, or an alread-parsed JSON object. Transforming data between these states, and dealing with error conditions every time, can be cumbersome. AnyJSON is a convenience wrapper class that allows underlying JSON to be lazily mapped between types as needed. @@ -183,6 +179,7 @@ public enum JSON: Equatable { return dictionary[key] } + @_documentation(visibility: internal) public static func == (lhs: JSON, rhs: JSON) -> Bool { switch (lhs, rhs) { case (.string(let lhsValue), .string(let rhsValue)): @@ -204,6 +201,7 @@ public enum JSON: Equatable { } extension JSON: Codable { + @_documentation(visibility: internal) public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let value = try? container.decode(String.self) { @@ -226,6 +224,7 @@ extension JSON: Codable { } } + @_documentation(visibility: internal) public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { diff --git a/Sources/AuthFoundation/Utilities/String+Extensions.swift b/Sources/AuthFoundation/Utilities/String+Extensions.swift index 7a7462414..e7f3dca43 100644 --- a/Sources/AuthFoundation/Utilities/String+Extensions.swift +++ b/Sources/AuthFoundation/Utilities/String+Extensions.swift @@ -23,4 +23,25 @@ extension String { return result } + + @_documentation(visibility: internal) + public static func nonce(length: UInt = 16) -> String { + [UInt8].random(count: Int(length)).base64URLEncodedString + } + + @_documentation(visibility: internal) + public var camelCase: String { + let words = self.split(separator: "_") + return words.enumerated().map { offset, element in + offset == 0 ? element.lowercased() : element.capitalized + }.reduce("", +) + } + + @_documentation(visibility: internal) + public var snakeCase: String { + let words = self.split(whereSeparator: { $0.isUppercase }) + return words.enumerated().map { offset, element in + offset == 0 ? element.lowercased() : "_\(element.lowercased())" + }.reduce("", +) + } } diff --git a/Sources/OktaDirectAuth/Extensions/AuthFlowConfiguration+Extensions.swift b/Sources/AuthFoundation/Utilities/URL+Extensions.swift similarity index 52% rename from Sources/OktaDirectAuth/Extensions/AuthFlowConfiguration+Extensions.swift rename to Sources/AuthFoundation/Utilities/URL+Extensions.swift index 3cab77818..4d4d0eb78 100644 --- a/Sources/OktaDirectAuth/Extensions/AuthFlowConfiguration+Extensions.swift +++ b/Sources/AuthFoundation/Utilities/URL+Extensions.swift @@ -11,28 +11,31 @@ // import Foundation -import AuthFoundation -extension DirectAuthenticationFlow.Configuration { - public var additionalParameters: [String: any APIRequestArgument]? { - var result = [String: any APIRequestArgument]() - - result["grant_types_supported"] = grantTypesSupported - .map(\.rawValue) - .joined(separator: " ") - - if let nonce = nonce { - result["nonce"] = nonce +extension URL { + @_documentation(visibility: internal) + @inlinable + public init?(string value: String?) throws { + guard let value = value else { + return nil } - - if let maxAge = maxAge { - result["max_age"] = Int(maxAge).stringValue + + guard let result = URL(string: value) else { + throw OAuth2Error.invalidUrl } + + self = result + } - if let acrValues = acrValues { - result["acr_values"] = acrValues.joined(separator: " ") + @_documentation(visibility: internal) + @inlinable + public init(requiredString value: String?) throws { + guard let value = value, + let result = URL(string: value) + else { + throw OAuth2Error.invalidUrl } - return result + self = result } } diff --git a/Sources/OktaDirectAuth/DirectAuthFlow.swift b/Sources/OktaDirectAuth/DirectAuthFlow.swift index d0d9b292e..76f7401ad 100644 --- a/Sources/OktaDirectAuth/DirectAuthFlow.swift +++ b/Sources/OktaDirectAuth/DirectAuthFlow.swift @@ -36,6 +36,12 @@ public enum DirectAuthenticationFlowError: Error { /// The context supplied with the authenticator continuation request is invalid. case invalidContinuationContext + /// An operation was attempted to be performed on a flow that was not yet started. + case flowNotStarted + + /// The flow has gotten into an inconsistent state, possibly due to concurrent authentication operations being performed. + case inconsistentContextState + /// Some authenticators require specific arguments to be supplied, but are missing in this case. case missingArguments(_ names: [String]) @@ -133,7 +139,7 @@ public class DirectAuthenticationFlow: AuthenticationFlow { /// Enumeration defining the list of possible authenticator "Continuation" factors, which are used. /// - /// Some authenticators cannot complete authentication in a single step, and requires either user intervention or an additional challenge response from the client. These circumstances are represented by the ``DirectAuthenticationFlow/Status/continuation(_:)`` status. In this case, the appropriate Continuation Factor response type can be supplied to the ``DirectAuthenticationFlow/resume(_:with:)-9i2pz`` function. + /// Some authenticators cannot complete authentication in a single step, and requires either user intervention or an additional challenge response from the client. These circumstances are represented by the ``DirectAuthenticationFlow/Status/continuation(_:)`` status. In this case, the appropriate Continuation Factor response type can be supplied to the ``DirectAuthenticationFlow/resume(with:)-9gu1l`` function. public enum ContinuationFactor: Equatable { /// Continues an OOB authentication by transfering the binding to another authenticator, and waiting for its response. /// @@ -173,33 +179,49 @@ public class DirectAuthenticationFlow: AuthenticationFlow { } /// Configuration which can be used to customize the authentication flow, as needed. - public struct Configuration: AuthenticationFlowConfiguration { - /// The "nonce" value to send with this authorization request. - public var nonce: String? - - /// The maximum age an ID token can be when authenticating. - public var maxAge: TimeInterval? - + public struct Context: AuthenticationContext, Equatable { /// The ACR values, if any, which should be requested by the client. public var acrValues: [String]? - /// The list of grant types the application supports. - public var grantTypesSupported: [GrantType] = .directAuth + /// The intent of the current flow. + public var intent: Intent = .signIn - public init(nonce: String? = nil, - maxAge: TimeInterval? = nil, + /// The current status returned from this authentication flow. + public internal(set) var currentStatus: Status? + + public init(maxAge: TimeInterval? = nil, acrValues: [String]? = nil, - grantTypesSupported: [GrantType] = .directAuth) + intent: Intent = .signIn) + { + self.init(acrValues: acrValues, + intent: intent) + } + + init(acrValues: [String]?, + intent: Intent) { - self.nonce = nonce - self.maxAge = maxAge self.acrValues = acrValues - self.grantTypesSupported = grantTypesSupported + self.intent = intent + } + + @_documentation(visibility: internal) + public func parameters(for category: OAuth2APIRequestCategory) -> [String: any APIRequestArgument]? { + var result: [String: any APIRequestArgument] = [:] + + if let acrValues = acrValues { + result["acr_values"] = acrValues.joined(separator: " ") + } + + if category == .token { + result.merge(intent.parameters(for: category)) + } + + return result.nilIfEmpty } } /// Channel used when authenticating an out-of-band factor using Okta Verify. - public enum OOBChannel: String, Codable, APIRequestArgument { + public enum OOBChannel: String, Codable, Equatable, APIRequestArgument { /// Utilize Okta Verify Push notifications to authenticate the user. case push @@ -226,26 +248,26 @@ public class DirectAuthenticationFlow: AuthenticationFlow { /// The current status of the authentication flow. /// - /// This value is returned from ``DirectAuthenticationFlow/start(_:with:)`` and ``DirectAuthenticationFlow/resume(_:with:)`` to indicate the result of an individual authentication step. This can be used to drive your application's sign-in workflow. + /// This value is returned from ``DirectAuthenticationFlow/start(_:with:)`` and ``DirectAuthenticationFlow/resume(with:)`` to indicate the result of an individual authentication step. This can be used to drive your application's sign-in workflow. public enum Status: Equatable { /// Authentication was successful, returning the given token. case success(_ token: Token) /// Indicates that the current authentication factor requires some sort of continuation. /// - /// When this status is returned, the developer should inspect the type of continuation that is occurring, and should use the ``DirectAuthenticationFlow/resume(_:with:)-9i2pz function to resume authenticating this factor. + /// When this status is returned, the developer should inspect the type of continuation that is occurring, and should use the ``DirectAuthenticationFlow/resume(with:)-9gu1l`` function to resume authenticating this factor. case continuation(_ type: ContinuationType) /// Indicates the user should be challenged with some other secondary factor. /// - /// When this status is returned, the developer should use the ``DirectAuthenticationFlow/resume(_:with:)`` function to supply a secondary factor to verify the user. + /// When this status is returned, the developer should use the ``DirectAuthenticationFlow/resume(with:)`` function to supply a secondary factor to verify the user. case mfaRequired(_ context: MFAContext) } /// The type of authentication continuation that is requested. /// /// Some authenticators follow a challenge and response pattern, whereby the client either needs to prompt the user for some out-of-band information, or the client needs to respond directly to a challenge sent from the server. When these situations occur, this enum can be used to determine which action should be taken by the client. - public enum ContinuationType { + public enum ContinuationType: Equatable { /// Indicates the user is being prompted with a WebAuthn challenge request. case webAuthn(_ context: WebAuthnContext) @@ -256,7 +278,7 @@ public class DirectAuthenticationFlow: AuthenticationFlow { case prompt(_ context: BindingContext) /// Holds information about a challenge request when initiating a WebAuthn authentication. - public struct WebAuthnContext { + public struct WebAuthnContext: Equatable { /// The credential request returned from the server. public let request: WebAuthn.CredentialRequestOptions @@ -264,7 +286,7 @@ public class DirectAuthenticationFlow: AuthenticationFlow { } /// Holds information about the binding update received when verifying OOB factors - public struct BindingContext { + public struct BindingContext: Equatable { let oobResponse: OOBResponse let mfaContext: MFAContext? } @@ -273,7 +295,7 @@ public class DirectAuthenticationFlow: AuthenticationFlow { /// Indicates the intent for the user authentication operation. /// /// This value is used to toggle behavior to distinguish between sign-in authentication, password recovery / reset operations, etc. - public enum Intent: String, Codable { + public enum Intent: String, Codable, Equatable { /// The user intends to sign in. case signIn @@ -284,12 +306,19 @@ public class DirectAuthenticationFlow: AuthenticationFlow { /// The OAuth2Client this authentication flow will use. public let client: OAuth2Client - /// The configuration settings that can be used to customize this authentication flow. - public let configuration: Configuration - - /// The intent of the current flow. - public private(set) var intent: Intent = .signIn - + /// The list of grant types the application supports. + public let supportedGrantTypes: [GrantType] + + /// The context that stores the state for the current authentication session. + public internal(set) var context: Context? { + didSet { + print("Reset context") + } + } + + /// Any additional query string parameters you would like to supply to the authorization server for all requests from this flow. + public let additionalParameters: [String: APIRequestArgument]? + /// Indicates whether or not this flow is currently in the process of authenticating a user. public private(set) var isAuthenticating: Bool = false { didSet { @@ -307,87 +336,67 @@ public class DirectAuthenticationFlow: AuthenticationFlow { /// Convenience initializer to construct an authentication flow from variables. /// - Parameters: - /// - issuer: The issuer URL. + /// - issuerURL: The issuer URL. /// - clientId: The client ID - /// - scopes: The scopes to request - /// - configuration: The configuration settings used to customize this authentication flow. - public convenience init(issuer: URL, + /// - scope: The scopes to request + /// - grantTypes: The supported list of grant types the application has been configured to use + /// - additionalParameters: Custom request parameters to be added to requests made for this sign-in. + public convenience init(issuerURL: URL, clientId: String, - scopes: String, - configuration: Configuration = .init()) + scope: String, + supportedGrants grantTypes: [GrantType] = .directAuth, + additionalParameters: [String: any APIRequestArgument]? = nil) { - self.init(configuration: configuration, - client: .init(baseURL: issuer, + self.init(client: .init(issuerURL: issuerURL, clientId: clientId, - scopes: scopes)) + scope: scope), + supportedGrants: grantTypes, + additionalParameters: additionalParameters) } /// Initializer to construct an authentication flow from a pre-defined configuration and client. /// - Parameters: - /// - configuration: The configuration settings used to customize this authentication flow. /// - client: The `OAuth2Client` to use with this flow. - public init(configuration: Configuration = .init(), - client: OAuth2Client) + /// - grantTypes: The supported list of grant types the application has been configured to use + /// - additionalParameters: Custom request parameters to be added to requests made for this sign-in. + public init(client: OAuth2Client, + supportedGrants grantTypes: [GrantType] = .directAuth, + additionalParameters: [String: any APIRequestArgument]? = nil) { // Ensure this SDK's static version is included in the user agent. SDKVersion.register(sdk: Version) self.client = client - self.configuration = configuration + self.supportedGrantTypes = grantTypes + self.additionalParameters = additionalParameters client.add(delegate: self) } - /// Initializer that uses the configuration defined within the application's `Okta.plist` file. - public convenience init() throws { - try self.init(try .init()) - } - - /// Initializer that uses the configuration defined within the given file URL. - /// - Parameter fileURL: File URL to a `plist` containing client configuration. - public convenience init(plist fileURL: URL) throws { - try self.init(try .init(plist: fileURL)) - } - - private convenience init(_ config: OAuth2Client.PropertyListConfiguration) throws { - let supportedGrantTypes: [GrantType] - if let supportedGrants = config.additionalParameters?["supportedGrants"] as? String { - supportedGrantTypes = try .from(string: supportedGrants) - } else { - supportedGrantTypes = .directAuth - } + public required init(client: OAuth2Client, additionalParameters: [String: any APIRequestArgument]?) throws { + // Ensure this SDK's static version is included in the user agent. + SDKVersion.register(sdk: Version) - let supportedAcrValues: [String]? - if let acrValues = config.additionalParameters?["acrValues"] as? String { - supportedAcrValues = acrValues.components(separatedBy: " ") - } else if let acrValues = config.additionalParameters?["acrValues"] as? [String] { - supportedAcrValues = acrValues - } else { - supportedAcrValues = nil - } + self.client = client + self.supportedGrantTypes = .directAuth + self.additionalParameters = additionalParameters - self.init(issuer: config.issuer, - clientId: config.clientId, - scopes: config.scopes, - configuration: .init(acrValues: supportedAcrValues, - grantTypesSupported: supportedGrantTypes)) + client.add(delegate: self) } - - var stepHandler: (any StepHandler)? - + /// Start user authentication, with the given username login hint and primary factor. /// - Parameters: /// - loginHint: The login hint, or username, to authenticate. /// - factor: The primary factor to use when authenticating the user. - /// - intent: The intent behind this authentication (default: `signIn`) + /// - context: Context information used to customize the sign-in flow. /// - completion: Completion block called when the operation completes. public func start(_ loginHint: String, with factor: PrimaryFactor, - intent: Intent = .signIn, + context: Context = .init(), completion: @escaping (Result) -> Void) { reset() - self.intent = intent + self.context = context runStep(loginHint: loginHint, with: factor, completion: completion) } @@ -395,32 +404,39 @@ public class DirectAuthenticationFlow: AuthenticationFlow { /// /// This function should be used when ``Status/mfaRequired(_:)`` is received. /// - Parameters: - /// - status: The previous status returned from the server. /// - factor: The secondary factor to use when authenticating the user. /// - completion: Completion block called when the operation completes. - public func resume(_ status: DirectAuthenticationFlow.Status, - with factor: SecondaryFactor, + public func resume(with factor: SecondaryFactor, completion: @escaping (Result) -> Void) { - runStep(currentStatus: status, with: factor, completion: completion) + guard isAuthenticating, + context != nil + else { + completion(.failure(.flowNotStarted)) + return + } + + runStep(with: factor, completion: completion) } /// Continues authentication of a current factor (either primary or secondary) when an additional step is required. /// /// This function should be used when ``Status/continuation(_:)`` is received. /// - Parameters: - /// - status: The previous status returned from the server. /// - factor: The continuation factor to use when authenticating the user. /// - completion: Completion block called when the operation completes. - public func resume(_ status: DirectAuthenticationFlow.Status, - with factor: ContinuationFactor, + public func resume(with factor: ContinuationFactor, completion: @escaping (Result) -> Void) { - runStep(currentStatus: status, with: factor, completion: completion) + guard context != nil else { + completion(.failure(.flowNotStarted)) + return + } + + runStep(with: factor, completion: completion) } func runStep(loginHint: String? = nil, - currentStatus: Status? = nil, with factor: Factor, completion: @escaping (Result) -> Void) { @@ -429,35 +445,52 @@ public class DirectAuthenticationFlow: AuthenticationFlow { client.openIdConfiguration { result in switch result { case .success(let configuration): + let stepHandler: any StepHandler do { - self.stepHandler = try factor.stepHandler(flow: self, - openIdConfiguration: configuration, - loginHint: loginHint, - currentStatus: currentStatus, - factor: factor) - self.stepHandler?.process { result in - self.stepHandler = nil - if case let .success(status) = result, - case .success = status - { - self.reset() - } - completion(result) - } + stepHandler = try factor.stepHandler(flow: self, + openIdConfiguration: configuration, + loginHint: loginHint, + factor: factor) } catch { self.send(error: error, completion: completion) + return } - + + self.process(stepHandler, completion: completion) + case .failure(let error): self.send(error: error, completion: completion) } } } + func process(_ stepHandler: any StepHandler, completion: @escaping (Result) -> Void) { + guard let oldContext = context else { + completion(.failure(.inconsistentContextState)) + return + } + + stepHandler.process { result in + guard self.context == oldContext else { + self.send(error: DirectAuthenticationFlowError.inconsistentContextState, + completion: completion) + return + } + + if case let .success(newStatus) = result { + var newContext = oldContext + newContext.currentStatus = newStatus + self.context = newContext + } + + completion(result) + } + } + /// Resets the authentication session. public func reset() { isAuthenticating = false - intent = .signIn + context = nil } // MARK: Private properties / methods @@ -470,14 +503,14 @@ extension DirectAuthenticationFlow { /// - Parameters: /// - loginHint: The login hint, or username, to authenticate. /// - factor: The primary factor to use when authenticating the user. - /// - intent: The intent behind this authentication (default: `signIn`) + /// - context: Context information used to customize the sign-in flow. /// - Returns: Status returned when the operation completes. public func start(_ loginHint: String, with factor: PrimaryFactor, - intent: DirectAuthenticationFlow.Intent = .signIn) async throws -> DirectAuthenticationFlow.Status + context: Context = .init()) async throws -> DirectAuthenticationFlow.Status { try await withCheckedThrowingContinuation { continuation in - start(loginHint, with: factor, intent: intent) { result in + start(loginHint, with: factor, context: context) { result in continuation.resume(with: result) } } @@ -487,12 +520,11 @@ extension DirectAuthenticationFlow { /// /// This function should be used when ``Status/mfaRequired(_:)`` is received. /// - Parameters: - /// - status: The previous status returned from the server. /// - factor: The secondary factor to use when authenticating the user. /// - Returns: Status returned when the operation completes. - public func resume(_ status: DirectAuthenticationFlow.Status, with factor: SecondaryFactor) async throws -> DirectAuthenticationFlow.Status { + public func resume(with factor: SecondaryFactor) async throws -> DirectAuthenticationFlow.Status { try await withCheckedThrowingContinuation { continuation in - resume(status, with: factor) { result in + resume(with: factor) { result in continuation.resume(with: result) } } @@ -502,12 +534,11 @@ extension DirectAuthenticationFlow { /// /// This function should be used when ``Status/continuation(_:)`` is received. /// - Parameters: - /// - status: The previous status returned from the server. /// - factor: The continuation factor to use when authenticating the user. /// - Returns: Status returned when the operation completes. - public func resume(_ status: DirectAuthenticationFlow.Status, with factor: ContinuationFactor) async throws -> DirectAuthenticationFlow.Status { + public func resume(with factor: ContinuationFactor) async throws -> DirectAuthenticationFlow.Status { try await withCheckedThrowingContinuation { continuation in - resume(status, with: factor) { result in + resume(with: factor) { result in continuation.resume(with: result) } } @@ -524,11 +555,15 @@ extension DirectAuthenticationFlow: OAuth2ClientDelegate { extension OAuth2Client { /// Creates a new flow to authenticate users, with the given grants the application supports. - /// - Parameter grantTypes: The list of grants this application supports. Defaults to the full list of values supported by this SDK. + /// - Parameters: + /// - grantTypes: The supported list of grant types the application has been configured to use + /// - additionalParameters: Custom request parameters to be added to requests made for this sign-in. /// - Returns: Initialized authentication flow. - public func directAuthenticationFlow(supportedGrants grantTypes: [GrantType] = .directAuth) -> DirectAuthenticationFlow + public func directAuthenticationFlow(supportedGrants grantTypes: [GrantType] = .directAuth, + additionalParameters: [String: String]? = nil) -> DirectAuthenticationFlow { - DirectAuthenticationFlow(configuration: .init(grantTypesSupported: grantTypes), - client: self) + DirectAuthenticationFlow(client: self, + supportedGrants: grantTypes, + additionalParameters: additionalParameters) } } diff --git a/Sources/OktaDirectAuth/Extensions/ErrorExtensions.swift b/Sources/OktaDirectAuth/Extensions/ErrorExtensions.swift index e9cdd8114..67bbfa45d 100644 --- a/Sources/OktaDirectAuth/Extensions/ErrorExtensions.swift +++ b/Sources/OktaDirectAuth/Extensions/ErrorExtensions.swift @@ -15,6 +15,16 @@ import Foundation extension DirectAuthenticationFlowError: LocalizedError { public var errorDescription: String? { switch self { + case .flowNotStarted: + return NSLocalizedString("flow_not_started", + tableName: "OktaDirectAuth", + bundle: .oktaDirectAuth, + comment: "Flow not started") + case .inconsistentContextState: + return NSLocalizedString("inconsistent_context_state", + tableName: "OktaDirectAuth", + bundle: .oktaDirectAuth, + comment: "Inconsistent context state") case .pollingTimeoutExceeded: return NSLocalizedString("polling_timeout_exceeded", tableName: "OktaDirectAuth", diff --git a/Sources/OktaDirectAuth/Extensions/Status+PublicExtensions.swift b/Sources/OktaDirectAuth/Extensions/Status+PublicExtensions.swift index e02f358f3..a87fee44b 100644 --- a/Sources/OktaDirectAuth/Extensions/Status+PublicExtensions.swift +++ b/Sources/OktaDirectAuth/Extensions/Status+PublicExtensions.swift @@ -15,10 +15,12 @@ import Foundation extension DirectAuthenticationFlow.Status { public static func == (lhs: DirectAuthenticationFlow.Status, rhs: DirectAuthenticationFlow.Status) -> Bool { switch (lhs, rhs) { - case (.success(let lhsToken), .success(let rhsToken)): - return lhsToken == rhsToken - case (.mfaRequired(let lhsContext), .mfaRequired(let rhsContext)): - return lhsContext == rhsContext + case (.success(let lhs), .success(let rhs)): + return lhs == rhs + case (.mfaRequired(let lhs), .mfaRequired(let rhs)): + return lhs == rhs + case (.continuation(let lhs), .continuation(let rhs)): + return lhs == rhs default: return false } diff --git a/Sources/OktaDirectAuth/Internal/Authentication Factors/AuthenticationFactor.swift b/Sources/OktaDirectAuth/Internal/Authentication Factors/AuthenticationFactor.swift index a171ed2fb..34caf97c3 100644 --- a/Sources/OktaDirectAuth/Internal/Authentication Factors/AuthenticationFactor.swift +++ b/Sources/OktaDirectAuth/Internal/Authentication Factors/AuthenticationFactor.swift @@ -29,12 +29,10 @@ protocol AuthenticationFactor: HasTokenParameters { /// - flow: The current flow for this authentication step. /// - openIdConfiguration: OpenID configuration for this org. /// - loginHint: The login hint for this session. - /// - currentStatus: The current status this step is being created from, if applicable. /// - factor: The factor for the step to process. /// - Returns: A step handler capable of processing this authentication factor. func stepHandler(flow: DirectAuthenticationFlow, openIdConfiguration: OpenIdConfiguration, loginHint: String?, - currentStatus: DirectAuthenticationFlow.Status?, factor: Self) throws -> any StepHandler } diff --git a/Sources/OktaDirectAuth/Internal/Authentication Factors/ContinuationFactor.swift b/Sources/OktaDirectAuth/Internal/Authentication Factors/ContinuationFactor.swift index d21ce6f1a..f258a4917 100644 --- a/Sources/OktaDirectAuth/Internal/Authentication Factors/ContinuationFactor.swift +++ b/Sources/OktaDirectAuth/Internal/Authentication Factors/ContinuationFactor.swift @@ -17,10 +17,13 @@ extension DirectAuthenticationFlow.ContinuationFactor: AuthenticationFactor { func stepHandler(flow: DirectAuthenticationFlow, openIdConfiguration: AuthFoundation.OpenIdConfiguration, loginHint: String? = nil, - currentStatus: DirectAuthenticationFlow.Status?, factor: Self) throws -> StepHandler { - let bindingContext = currentStatus?.continuationType?.bindingContext + guard let context = flow.context else { + throw DirectAuthenticationFlowError.inconsistentContextState + } + + let bindingContext = context.currentStatus?.continuationType?.bindingContext switch self { case .transfer: @@ -31,7 +34,7 @@ extension DirectAuthenticationFlow.ContinuationFactor: AuthenticationFactor { return try OOBStepHandler(flow: flow, openIdConfiguration: openIdConfiguration, - currentStatus: currentStatus, + context: context, loginHint: loginHint, channel: bindingContext.oobResponse.channel, factor: factor) @@ -44,22 +47,20 @@ extension DirectAuthenticationFlow.ContinuationFactor: AuthenticationFactor { let request = TokenRequest(openIdConfiguration: openIdConfiguration, clientConfiguration: flow.client.configuration, - authenticationFlowConfiguration: flow.configuration, - currentStatus: currentStatus, + context: context, factor: factor, - intent: flow.intent, - parameters: bindingContext.oobResponse) + parameters: bindingContext.oobResponse, + grantTypesSupported: flow.supportedGrantTypes) return TokenStepHandler(flow: flow, request: request) case .webAuthn(response: let response): let request = TokenRequest(openIdConfiguration: openIdConfiguration, clientConfiguration: flow.client.configuration, - authenticationFlowConfiguration: flow.configuration, - currentStatus: currentStatus, + context: context, loginHint: loginHint, factor: factor, - intent: flow.intent, - parameters: response) + parameters: response, + grantTypesSupported: flow.supportedGrantTypes) return TokenStepHandler(flow: flow, request: request) } } diff --git a/Sources/OktaDirectAuth/Internal/Authentication Factors/PrimaryFactor.swift b/Sources/OktaDirectAuth/Internal/Authentication Factors/PrimaryFactor.swift index 8f7c4a865..2e489cb67 100644 --- a/Sources/OktaDirectAuth/Internal/Authentication Factors/PrimaryFactor.swift +++ b/Sources/OktaDirectAuth/Internal/Authentication Factors/PrimaryFactor.swift @@ -28,32 +28,34 @@ extension DirectAuthenticationFlow.PrimaryFactor: AuthenticationFactor { func stepHandler(flow: DirectAuthenticationFlow, openIdConfiguration: OpenIdConfiguration, loginHint: String? = nil, - currentStatus: DirectAuthenticationFlow.Status? = nil, factor: Self) throws -> StepHandler { + guard let context = flow.context else { + throw DirectAuthenticationFlowError.inconsistentContextState + } + switch self { case .otp: fallthrough case .password: let request = TokenRequest(openIdConfiguration: openIdConfiguration, clientConfiguration: flow.client.configuration, - authenticationFlowConfiguration: flow.configuration, - currentStatus: currentStatus, + context: context, loginHint: loginHint, factor: factor, - intent: flow.intent) + grantTypesSupported: flow.supportedGrantTypes) return TokenStepHandler(flow: flow, request: request) case .oob(channel: let channel): return try OOBStepHandler(flow: flow, openIdConfiguration: openIdConfiguration, - currentStatus: currentStatus, + context: context, loginHint: loginHint, channel: channel, factor: factor) case .webAuthn: - let mfaContext = currentStatus?.mfaContext + let mfaContext = context.currentStatus?.mfaContext let request = try WebAuthnChallengeRequest(openIdConfiguration: openIdConfiguration, clientConfiguration: flow.client.configuration, - authenticationFlowConfiguration: flow.configuration, + context: context, loginHint: loginHint, mfaToken: mfaContext?.mfaToken) return ChallengeStepHandler(flow: flow, request: request) { diff --git a/Sources/OktaDirectAuth/Internal/Authentication Factors/SecondaryFactor.swift b/Sources/OktaDirectAuth/Internal/Authentication Factors/SecondaryFactor.swift index a27320a2a..8e4511cde 100644 --- a/Sources/OktaDirectAuth/Internal/Authentication Factors/SecondaryFactor.swift +++ b/Sources/OktaDirectAuth/Internal/Authentication Factors/SecondaryFactor.swift @@ -17,31 +17,33 @@ extension DirectAuthenticationFlow.SecondaryFactor: AuthenticationFactor { func stepHandler(flow: DirectAuthenticationFlow, openIdConfiguration: AuthFoundation.OpenIdConfiguration, loginHint: String? = nil, - currentStatus: DirectAuthenticationFlow.Status?, factor: Self) throws -> StepHandler { + guard let context = flow.context else { + throw DirectAuthenticationFlowError.inconsistentContextState + } + switch self { case .otp: let request = TokenRequest(openIdConfiguration: openIdConfiguration, clientConfiguration: flow.client.configuration, - authenticationFlowConfiguration: flow.configuration, - currentStatus: currentStatus, + context: context, loginHint: loginHint, factor: factor, - intent: flow.intent) + grantTypesSupported: flow.supportedGrantTypes) return TokenStepHandler(flow: flow, request: request) case .oob(channel: let channel): return try OOBStepHandler(flow: flow, openIdConfiguration: openIdConfiguration, - currentStatus: currentStatus, + context: context, loginHint: loginHint, channel: channel, factor: factor) case .webAuthn: - let mfaContext = currentStatus?.mfaContext + let mfaContext = context.currentStatus?.mfaContext let request = try WebAuthnChallengeRequest(openIdConfiguration: openIdConfiguration, clientConfiguration: flow.client.configuration, - authenticationFlowConfiguration: flow.configuration, + context: context, loginHint: loginHint, mfaToken: mfaContext?.mfaToken) return ChallengeStepHandler(flow: flow, request: request) { diff --git a/Sources/OktaDirectAuth/Internal/DirectAuthFlow+SendResponses.swift b/Sources/OktaDirectAuth/Internal/DirectAuthFlow+SendResponses.swift index fda9f2d91..a417150a5 100644 --- a/Sources/OktaDirectAuth/Internal/DirectAuthFlow+SendResponses.swift +++ b/Sources/OktaDirectAuth/Internal/DirectAuthFlow+SendResponses.swift @@ -16,9 +16,10 @@ extension DirectAuthenticationFlow { func send(success response: APIResponse, completion: @escaping (Result) -> Void) { - reset() delegateCollection.invoke { $0.authentication(flow: self, received: response.result) } completion(.success(.success(response.result))) + + reset() } func send(state: Status, @@ -31,11 +32,11 @@ extension DirectAuthenticationFlow { func send(error: Error, completion: @escaping (Result) -> Void) { - reset() - let oauth2Error = OAuth2Error(error) delegateCollection.invoke { $0.authentication(flow: self, received: oauth2Error) } completion(.failure(DirectAuthenticationFlowError(error))) + + reset() } } diff --git a/Sources/OktaDirectAuth/Internal/Extensions/Intent+Extensions.swift b/Sources/OktaDirectAuth/Internal/Extensions/Intent+Extensions.swift index df4e215f3..d80732821 100644 --- a/Sources/OktaDirectAuth/Internal/Extensions/Intent+Extensions.swift +++ b/Sources/OktaDirectAuth/Internal/Extensions/Intent+Extensions.swift @@ -14,20 +14,17 @@ import Foundation extension DirectAuthenticationFlow.Intent: ProvidesOAuth2Parameters { @_documentation(visibility: private) - public var overrideParameters: Bool { - true - } - - @_documentation(visibility: private) - public var additionalParameters: [String: any AuthFoundation.APIRequestArgument]? { - switch self { - case .signIn: - return nil - case .recovery: + public func parameters(for category: OAuth2APIRequestCategory) -> [String: any APIRequestArgument]? { + guard case .recovery = self else { return nil } + + switch category { + case .authorization, .token: return [ "scope": "okta.myAccount.password.manage", "intent": "recovery", ] + case .configuration, .resource, .other: + return nil } } } diff --git a/Sources/OktaDirectAuth/Internal/Extensions/OAuth2Error+DirectAuthExtensions.swift b/Sources/OktaDirectAuth/Internal/Extensions/OAuth2Error+DirectAuthExtensions.swift new file mode 100644 index 000000000..ed27683a9 --- /dev/null +++ b/Sources/OktaDirectAuth/Internal/Extensions/OAuth2Error+DirectAuthExtensions.swift @@ -0,0 +1,26 @@ +// +// Copyright (c) 2023-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") +// +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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. +// + +import Foundation + +extension OAuth2Error { + init(_ error: DirectAuthenticationFlowError) { + switch error { + case .network(error: let error): + self = OAuth2Error(error) + case .oauth2(error: let error): + self = error + default: + self = .error(error) + } + } +} diff --git a/Sources/OktaDirectAuth/Internal/Extensions/OpenIdConfiguration+Extensions.swift b/Sources/OktaDirectAuth/Internal/Extensions/OpenIdConfiguration+Extensions.swift new file mode 100644 index 000000000..566ab9fd3 --- /dev/null +++ b/Sources/OktaDirectAuth/Internal/Extensions/OpenIdConfiguration+Extensions.swift @@ -0,0 +1,24 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") +// +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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. +// + +import Foundation +import AuthFoundation + +extension OpenIdConfiguration { + var challengeEndpoint: URL? { + tokenEndpoint.url(replacing: "/token", with: "/challenge") + } + + var primaryAuthenticateEndpoint: URL? { + tokenEndpoint.url(replacing: "/token", with: "/primary-authenticate") + } +} diff --git a/Sources/OktaDirectAuth/Internal/Requests/ChallengeRequest.swift b/Sources/OktaDirectAuth/Internal/Requests/ChallengeRequest.swift index 57b4ac54c..343dc1e0e 100644 --- a/Sources/OktaDirectAuth/Internal/Requests/ChallengeRequest.swift +++ b/Sources/OktaDirectAuth/Internal/Requests/ChallengeRequest.swift @@ -13,22 +13,18 @@ import Foundation import AuthFoundation -extension OpenIdConfiguration { - var challengeEndpoint: URL? { - tokenEndpoint.url(replacing: "/token", with: "/challenge") - } -} - -struct ChallengeRequest { +struct ChallengeRequest: AuthenticationFlowRequest { + typealias Flow = DirectAuthenticationFlow + let url: URL let clientConfiguration: OAuth2Client.Configuration - let authenticationFlowConfiguration: (any AuthenticationFlowConfiguration)? + let context: Flow.Context let mfaToken: String let challengeTypesSupported: [GrantType] init(openIdConfiguration: OpenIdConfiguration, clientConfiguration: OAuth2Client.Configuration, - authenticationFlowConfiguration: (any AuthenticationFlowConfiguration)?, + context: DirectAuthenticationFlow.Context, mfaToken: String, challengeTypesSupported: [GrantType]) throws { @@ -38,7 +34,7 @@ struct ChallengeRequest { self.url = url self.clientConfiguration = clientConfiguration - self.authenticationFlowConfiguration = authenticationFlowConfiguration + self.context = context self.mfaToken = mfaToken self.challengeTypesSupported = challengeTypesSupported } @@ -78,17 +74,18 @@ extension ChallengeRequest: APIRequest, APIRequestBody { var httpMethod: APIRequestMethod { .post } var contentType: APIContentType? { .formEncoded } var acceptsType: APIContentType? { .json } + var category: AuthFoundation.OAuth2APIRequestCategory { .other } var bodyParameters: [String: APIRequestArgument]? { - var result: [String: APIRequestArgument] = [ - "client_id": clientConfiguration.clientId, + var result = clientConfiguration.parameters(for: category) ?? [:] + result.merge(context.parameters(for: category)) + result.merge([ "mfa_token": mfaToken, "challenge_types_supported": challengeTypesSupported - .map(\.rawValue) - .joined(separator: " ") - ] + ]) - result.merge(clientConfiguration.authentication) - result.merge(authenticationFlowConfiguration) + if result["client_id"] == nil { + result["client_id"] = clientConfiguration.clientId + } return result } diff --git a/Sources/OktaDirectAuth/Internal/Requests/OOBAuthenticateRequest.swift b/Sources/OktaDirectAuth/Internal/Requests/OOBAuthenticateRequest.swift index ad0b30a94..09cf32d8b 100644 --- a/Sources/OktaDirectAuth/Internal/Requests/OOBAuthenticateRequest.swift +++ b/Sources/OktaDirectAuth/Internal/Requests/OOBAuthenticateRequest.swift @@ -13,13 +13,7 @@ import Foundation import AuthFoundation -extension OpenIdConfiguration { - var primaryAuthenticateEndpoint: URL? { - tokenEndpoint.url(replacing: "/token", with: "/primary-authenticate") - } -} - -struct OOBResponse: Codable, HasTokenParameters { +struct OOBResponse: Codable, Equatable, HasTokenParameters { let oobCode: String let expiresIn: TimeInterval let interval: TimeInterval? @@ -39,19 +33,31 @@ struct OOBResponse: Codable, HasTokenParameters { func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: APIRequestArgument] { ["oob_code": oobCode] } + + static func == (lhs: OOBResponse, rhs: OOBResponse) -> Bool { + let tolerance: TimeInterval = 0.000001 + + return lhs.oobCode == rhs.oobCode + && abs(lhs.expiresIn - rhs.expiresIn) < tolerance + && abs((lhs.interval ?? 0) - (rhs.interval ?? 0)) < tolerance + && lhs.channel == rhs.channel + && lhs.bindingMethod == rhs.bindingMethod + } } -struct OOBAuthenticateRequest { +struct OOBAuthenticateRequest: AuthenticationFlowRequest { + typealias Flow = DirectAuthenticationFlow + let url: URL let clientConfiguration: OAuth2Client.Configuration - let authenticationFlowConfiguration: (any AuthenticationFlowConfiguration)? + let context: Flow.Context let loginHint: String let channelHint: DirectAuthenticationFlow.OOBChannel let challengeHint: GrantType init(openIdConfiguration: OpenIdConfiguration, clientConfiguration: OAuth2Client.Configuration, - authenticationFlowConfiguration: (any AuthenticationFlowConfiguration)?, + context: DirectAuthenticationFlow.Context, loginHint: String, channelHint: DirectAuthenticationFlow.OOBChannel, challengeHint: GrantType) throws @@ -62,14 +68,14 @@ struct OOBAuthenticateRequest { self.url = url self.clientConfiguration = clientConfiguration - self.authenticationFlowConfiguration = authenticationFlowConfiguration + self.context = context self.loginHint = loginHint self.channelHint = channelHint self.challengeHint = challengeHint } } -enum BindingMethod: String, Codable { +enum BindingMethod: String, Codable, Equatable { case none case transfer case prompt @@ -81,17 +87,21 @@ extension OOBAuthenticateRequest: APIRequest, APIRequestBody { var httpMethod: APIRequestMethod { .post } var contentType: APIContentType? { .formEncoded } var acceptsType: APIContentType? { .json } + var category: AuthFoundation.OAuth2APIRequestCategory { .other } + var bodyParameters: [String: APIRequestArgument]? { - var result: [String: APIRequestArgument] = [ - "client_id": clientConfiguration.clientId, + var result = clientConfiguration.parameters(for: category) ?? [:] + result.merge(context.parameters(for: category)) + result.merge([ "login_hint": loginHint, "channel_hint": channelHint, "challenge_hint": challengeHint, - ] + ]) + + if result["client_id"] == nil { + result["client_id"] = clientConfiguration.clientId + } - result.merge(clientConfiguration.authentication) - result.merge(authenticationFlowConfiguration) - return result } } diff --git a/Sources/OktaDirectAuth/Internal/Requests/TokenRequest.swift b/Sources/OktaDirectAuth/Internal/Requests/TokenRequest.swift index 4275b3805..fe01fe975 100644 --- a/Sources/OktaDirectAuth/Internal/Requests/TokenRequest.swift +++ b/Sources/OktaDirectAuth/Internal/Requests/TokenRequest.swift @@ -13,46 +13,44 @@ import Foundation import AuthFoundation -struct TokenRequest { +struct TokenRequest: AuthenticationFlowRequest { + typealias Flow = DirectAuthenticationFlow + let openIdConfiguration: OpenIdConfiguration let clientConfiguration: OAuth2Client.Configuration - let authenticationFlowConfiguration: (any AuthenticationFlowConfiguration)? - let currentStatus: DirectAuthenticationFlow.Status? + let context: Flow.Context let loginHint: String? let factor: any AuthenticationFactor - let intent: DirectAuthenticationFlow.Intent let parameters: (any HasTokenParameters)? - + let grantTypesSupported: [GrantType]? + init(openIdConfiguration: OpenIdConfiguration, clientConfiguration: OAuth2Client.Configuration, - authenticationFlowConfiguration: (any AuthenticationFlowConfiguration)?, - currentStatus: DirectAuthenticationFlow.Status?, + context: DirectAuthenticationFlow.Context, loginHint: String? = nil, factor: any AuthenticationFactor, - intent: DirectAuthenticationFlow.Intent, - parameters: (any HasTokenParameters)? = nil) + parameters: (any HasTokenParameters)? = nil, + grantTypesSupported: [GrantType]? = nil) { self.openIdConfiguration = openIdConfiguration self.clientConfiguration = clientConfiguration - self.authenticationFlowConfiguration = authenticationFlowConfiguration - self.currentStatus = currentStatus + self.context = context self.loginHint = loginHint self.factor = factor - self.intent = intent self.parameters = parameters + self.grantTypesSupported = grantTypesSupported } } extension TokenRequest: OAuth2TokenRequest, OAuth2APIRequest, APIRequestBody { - var clientId: String { clientConfiguration.clientId } + var category: OAuth2APIRequestCategory { .token } + var bodyParameters: [String: APIRequestArgument]? { - var result = factor.tokenParameters(currentStatus: currentStatus) - result["client_id"] = clientConfiguration.clientId - result["scope"] = clientConfiguration.scopes - - result.merge(parameters?.tokenParameters(currentStatus: currentStatus)) - result.merge(authenticationFlowConfiguration) - + var result = clientConfiguration.parameters(for: category) ?? [:] + result.merge(context.parameters(for: category)) + result.merge(factor.tokenParameters(currentStatus: context.currentStatus)) + result.merge(parameters?.tokenParameters(currentStatus: context.currentStatus)) + if let loginHint = loginHint { let key: String if let factor = factor as? DirectAuthenticationFlow.PrimaryFactor { @@ -63,20 +61,14 @@ extension TokenRequest: OAuth2TokenRequest, OAuth2APIRequest, APIRequestBody { result[key] = loginHint } - result.merge(clientConfiguration.authentication) - result.merge(intent) + if let grantTypesSupported = grantTypesSupported?.map(\.rawValue) { + result["grant_types_supported"] = grantTypesSupported.joined(separator: " ") + } + + if result["client_id"] == nil { + result["client_id"] = clientConfiguration.clientId + } return result } } - -extension TokenRequest: APIParsingContext { - var codingUserInfo: [CodingUserInfoKey: Any]? { - [ - .clientSettings: [ - "client_id": clientConfiguration.clientId, - "scope": clientConfiguration.scopes - ] - ] - } -} diff --git a/Sources/OktaDirectAuth/Internal/Requests/WebAuthnRequest.swift b/Sources/OktaDirectAuth/Internal/Requests/WebAuthnRequest.swift index cb59c59ea..3cedad0cc 100644 --- a/Sources/OktaDirectAuth/Internal/Requests/WebAuthnRequest.swift +++ b/Sources/OktaDirectAuth/Internal/Requests/WebAuthnRequest.swift @@ -13,16 +13,18 @@ import Foundation import AuthFoundation -struct WebAuthnChallengeRequest { +struct WebAuthnChallengeRequest: AuthenticationFlowRequest { + typealias Flow = DirectAuthenticationFlow + let url: URL let clientConfiguration: OAuth2Client.Configuration - let authenticationFlowConfiguration: (any AuthenticationFlowConfiguration)? + let context: Flow.Context let loginHint: String? let mfaToken: String? init(openIdConfiguration: OpenIdConfiguration, clientConfiguration: OAuth2Client.Configuration, - authenticationFlowConfiguration: (any AuthenticationFlowConfiguration)?, + context: DirectAuthenticationFlow.Context, loginHint: String? = nil, mfaToken: String? = nil) throws { @@ -32,7 +34,7 @@ struct WebAuthnChallengeRequest { self.url = url self.clientConfiguration = clientConfiguration - self.authenticationFlowConfiguration = authenticationFlowConfiguration + self.context = context self.loginHint = loginHint self.mfaToken = mfaToken } @@ -44,11 +46,18 @@ extension WebAuthnChallengeRequest: APIRequest, APIRequestBody { var httpMethod: APIRequestMethod { .post } var contentType: APIContentType? { .formEncoded } var acceptsType: APIContentType? { .json } + var category: AuthFoundation.OAuth2APIRequestCategory { .other } var bodyParameters: [String: APIRequestArgument]? { - var result: [String: APIRequestArgument] = [ - "client_id": clientConfiguration.clientId, + var result = clientConfiguration.parameters(for: category) ?? [:] + + // Only supply context parameters (e.g. intent, max_age, nonce, etc) on the initial request + if mfaToken == nil { + result.merge(context.parameters(for: category)) + } + + result.merge([ "challenge_hint": GrantType.webAuthn - ] + ]) if let loginHint = loginHint { result["login_hint"] = loginHint @@ -58,8 +67,9 @@ extension WebAuthnChallengeRequest: APIRequest, APIRequestBody { result["mfa_token"] = mfaToken } - result.merge(clientConfiguration.authentication) - result.merge(authenticationFlowConfiguration) + if result["client_id"] == nil { + result["client_id"] = clientConfiguration.clientId + } return result } diff --git a/Sources/OktaDirectAuth/Internal/Step Handlers/OOBStepHandler.swift b/Sources/OktaDirectAuth/Internal/Step Handlers/OOBStepHandler.swift index a8f4852ee..77d723dfb 100644 --- a/Sources/OktaDirectAuth/Internal/Step Handlers/OOBStepHandler.swift +++ b/Sources/OktaDirectAuth/Internal/Step Handlers/OOBStepHandler.swift @@ -16,7 +16,7 @@ import AuthFoundation class OOBStepHandler: StepHandler { let flow: DirectAuthenticationFlow let openIdConfiguration: OpenIdConfiguration - let currentStatus: DirectAuthenticationFlow.Status? + let context: DirectAuthenticationFlow.Context let loginHint: String? let channel: DirectAuthenticationFlow.OOBChannel let factor: Factor @@ -24,21 +24,21 @@ class OOBStepHandler: StepHandler { init(flow: DirectAuthenticationFlow, openIdConfiguration: OpenIdConfiguration, - currentStatus: DirectAuthenticationFlow.Status?, + context: DirectAuthenticationFlow.Context, loginHint: String?, channel: DirectAuthenticationFlow.OOBChannel, factor: Factor) throws { self.flow = flow self.openIdConfiguration = openIdConfiguration - self.currentStatus = currentStatus + self.context = context self.loginHint = loginHint self.channel = channel self.factor = factor } func process(completion: @escaping (Result) -> Void) { - if let bindingContext = currentStatus?.continuationType?.bindingContext { + if let bindingContext = context.currentStatus?.continuationType?.bindingContext { self.requestToken(using: bindingContext.oobResponse, completion: completion) } else { requestOOBCode { [weak self] result in @@ -49,7 +49,7 @@ class OOBStepHandler: StepHandler { case .failure(let error): self.flow.process(error, completion: completion) case .success(let response): - let mfaContext = currentStatus?.mfaContext + let mfaContext = context.currentStatus?.mfaContext switch response.bindingMethod { case .none: @@ -84,7 +84,7 @@ class OOBStepHandler: StepHandler { } // Request where OOB is used as the secondary factor - else if case let .mfaRequired(context) = currentStatus { + else if case let .mfaRequired(context) = context.currentStatus { requestOOBCode(mfaToken: context.mfaToken, completion: completion) } @@ -100,10 +100,10 @@ class OOBStepHandler: StepHandler { do { let request = try OOBAuthenticateRequest(openIdConfiguration: openIdConfiguration, clientConfiguration: flow.client.configuration, - authenticationFlowConfiguration: flow.configuration, + context: context, loginHint: loginHint, channelHint: channel, - challengeHint: factor.grantType(currentStatus: currentStatus)) + challengeHint: factor.grantType(currentStatus: context.currentStatus)) request.send(to: flow.client) { result in switch result { case .failure(let error): @@ -121,10 +121,10 @@ class OOBStepHandler: StepHandler { completion: @escaping (Result) -> Void) { do { - let grantType = factor.grantType(currentStatus: currentStatus) + let grantType = factor.grantType(currentStatus: context.currentStatus) let request = try ChallengeRequest(openIdConfiguration: openIdConfiguration, clientConfiguration: flow.client.configuration, - authenticationFlowConfiguration: flow.configuration, + context: context, mfaToken: mfaToken, challengeTypesSupported: [grantType]) request.send(to: flow.client) { result in @@ -152,11 +152,10 @@ class OOBStepHandler: StepHandler { let request = TokenRequest(openIdConfiguration: openIdConfiguration, clientConfiguration: flow.client.configuration, - authenticationFlowConfiguration: flow.configuration, - currentStatus: currentStatus, + context: context, factor: factor, - intent: flow.intent, - parameters: response) + parameters: response, + grantTypesSupported: flow.supportedGrantTypes) self.poll = PollingHandler(client: flow.client, request: request, expiresIn: response.expiresIn, diff --git a/Sources/OktaDirectAuth/Internal/Utilities/Array+InternalExtensions.swift b/Sources/OktaDirectAuth/Internal/Utilities/GrantType+InternalExtensions.swift similarity index 100% rename from Sources/OktaDirectAuth/Internal/Utilities/Array+InternalExtensions.swift rename to Sources/OktaDirectAuth/Internal/Utilities/GrantType+InternalExtensions.swift diff --git a/Sources/OktaDirectAuth/Internal/Utilities/Status+InternalExtensions.swift b/Sources/OktaDirectAuth/Internal/Utilities/Status+InternalExtensions.swift index bc7931510..cf3fb3e77 100644 --- a/Sources/OktaDirectAuth/Internal/Utilities/Status+InternalExtensions.swift +++ b/Sources/OktaDirectAuth/Internal/Utilities/Status+InternalExtensions.swift @@ -35,6 +35,19 @@ extension DirectAuthenticationFlow.ContinuationType { return nil } } + + public static func == (lhs: DirectAuthenticationFlow.ContinuationType, rhs: DirectAuthenticationFlow.ContinuationType) -> Bool { + switch (lhs, rhs) { + case (.webAuthn(let lhs), .webAuthn(let rhs)): + return lhs == rhs + case (.transfer(let lhs_context, let lhs_code), .transfer(let rhs_context, let rhs_code)): + return lhs_context == rhs_context && lhs_code == rhs_code + case (.prompt(let lhs), .prompt(let rhs)): + return lhs == rhs + default: + return false + } + } } extension DirectAuthenticationFlow.Status { diff --git a/Sources/OktaDirectAuth/OktaDirectAuth.docc/Extensions/DirectAuthenticationFlow.md b/Sources/OktaDirectAuth/OktaDirectAuth.docc/Extensions/DirectAuthenticationFlow.md index 2c9665335..c8bf1d2e2 100644 --- a/Sources/OktaDirectAuth/OktaDirectAuth.docc/Extensions/DirectAuthenticationFlow.md +++ b/Sources/OktaDirectAuth/OktaDirectAuth.docc/Extensions/DirectAuthenticationFlow.md @@ -1,9 +1,5 @@ # ``OktaDirectAuth/DirectAuthenticationFlow`` -@Metadata { - @DocumentationExtension(mergeBehavior: append) -} - ## Usage You can create an instance of ``DirectAuthenticationFlow`` with your client settings, or you can use one of several convenience initializers to simplify the process. diff --git a/Sources/OktaDirectAuth/Resources/en.lproj/OktaDirectAuth.strings b/Sources/OktaDirectAuth/Resources/en.lproj/OktaDirectAuth.strings index fe4ab9d93..fd0384fbf 100644 --- a/Sources/OktaDirectAuth/Resources/en.lproj/OktaDirectAuth.strings +++ b/Sources/OktaDirectAuth/Resources/en.lproj/OktaDirectAuth.strings @@ -1,4 +1,6 @@ /* DirectAuthenticationFlowError */ +"flow_not_started" = "An operation was attempted to be performed on a flow that was not yet started."; +"inconsistent_context_state" = "The sign in flow has gotten into an inconsistent state, possibly due to concurrent authentication operations being performed."; "polling_timeout_exceeded" = "Authentication timed out while polling the server."; "missing_arguments" = "Could not authenticate since some expected arguments were missing. [%@]"; "binding_code_missing" = "Did not receive a binding code from server"; diff --git a/Sources/OktaDirectAuth/WebAuthn/PublicKeyCredentialDescriptor.swift b/Sources/OktaDirectAuth/WebAuthn/PublicKeyCredentialDescriptor.swift index c555551dd..ed582e03e 100644 --- a/Sources/OktaDirectAuth/WebAuthn/PublicKeyCredentialDescriptor.swift +++ b/Sources/OktaDirectAuth/WebAuthn/PublicKeyCredentialDescriptor.swift @@ -18,7 +18,7 @@ extension WebAuthn { - Note: [W3C Reccomendation](https://www.w3.org/TR/webauthn/#dictionary-credential-descriptor) */ - public struct PublicKeyCredentialDescriptor: Codable { + public struct PublicKeyCredentialDescriptor: Codable, Equatable { /// This member contains the credential ID of the public key credential the caller is referring to. public let id: String diff --git a/Sources/OktaDirectAuth/WebAuthn/PublicKeyCredentialRequestOptions.swift b/Sources/OktaDirectAuth/WebAuthn/PublicKeyCredentialRequestOptions.swift index ce7591bd6..278f3cbe7 100644 --- a/Sources/OktaDirectAuth/WebAuthn/PublicKeyCredentialRequestOptions.swift +++ b/Sources/OktaDirectAuth/WebAuthn/PublicKeyCredentialRequestOptions.swift @@ -11,6 +11,7 @@ // import Foundation +import AuthFoundation extension WebAuthn { /** @@ -18,7 +19,7 @@ extension WebAuthn { - Note: [W3C Reccomendation](https://www.w3.org/TR/webauthn/#dictionary-assertion-options) */ - public struct PublicKeyCredentialRequestOptions: Codable { + public struct PublicKeyCredentialRequestOptions: Codable, Equatable { /// This member specifies a challenge that the authenticator signs, along with other data, when producing an authentication assertion. See the § 13.4.3 Cryptographic Challenges security consideration. public let challenge: String @@ -38,7 +39,7 @@ extension WebAuthn { public let hints: [PublicKeyCredentialHints]? /// The Relying Party MAY use this to provide client extension inputs requesting additional processing by the client and authenticator. - public let extensions: [String: Any?]? + public let extensions: [String: JSON]? enum CodingKeys: String, CodingKey { case allowCredentials @@ -50,6 +51,7 @@ extension WebAuthn { case userVerification } + @_documentation(visibility: internal) public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -65,27 +67,21 @@ extension WebAuthn { timeout = nil } - if let jsonValues = try container.decodeIfPresent([String: JSON].self, forKey: .extensions) { - extensions = jsonValues.mapValues({ $0.anyValue }) - } else { - extensions = nil - } + extensions = try container.decodeIfPresent([String: JSON].self, forKey: .extensions) } + @_documentation(visibility: internal) public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(challenge, forKey: .challenge) try container.encodeIfPresent(allowCredentials, forKey: .allowCredentials) try container.encodeIfPresent(rpID, forKey: .rpID) try container.encodeIfPresent(hints, forKey: .hints) - + try container.encodeIfPresent(extensions, forKey: .extensions) + if let timeout = timeout { try container.encode(UInt64(timeout * 1000), forKey: .timeout) } - - if let extensions = extensions { - try container.encode(try extensions.mapValues({ try JSON($0) }), forKey: .extensions) - } } } } diff --git a/Sources/OktaDirectAuth/WebAuthn/Type/AuthenticatorTransport.swift b/Sources/OktaDirectAuth/WebAuthn/Type/AuthenticatorTransport.swift index cf916bef0..945382e92 100644 --- a/Sources/OktaDirectAuth/WebAuthn/Type/AuthenticatorTransport.swift +++ b/Sources/OktaDirectAuth/WebAuthn/Type/AuthenticatorTransport.swift @@ -18,7 +18,7 @@ extension WebAuthn { - Note: [W3C Reccomendation](https://www.w3.org/TR/webauthn/#dom-publickeycredentialdescriptor-type) */ - public enum AuthenticatorTransport: String, Codable { + public enum AuthenticatorTransport: String, Codable, Equatable { /// Indicates the respective authenticator can be contacted over Bluetooth Smart (Bluetooth Low Energy / BLE). case ble diff --git a/Sources/OktaDirectAuth/WebAuthn/Type/PublicKeyCredentialHints.swift b/Sources/OktaDirectAuth/WebAuthn/Type/PublicKeyCredentialHints.swift index 3ecb695e6..6d779a7ab 100644 --- a/Sources/OktaDirectAuth/WebAuthn/Type/PublicKeyCredentialHints.swift +++ b/Sources/OktaDirectAuth/WebAuthn/Type/PublicKeyCredentialHints.swift @@ -18,7 +18,7 @@ extension WebAuthn { - Note: [W3C Reccomendation](https://w3c.github.io/webauthn/#enumdef-publickeycredentialhints) */ - public enum PublicKeyCredentialHints: String, Codable { + public enum PublicKeyCredentialHints: String, Codable, Equatable { /// Indicates that the Relying Party believes that users will satisfy this request with a physical security key. case securityKey = "security-key" diff --git a/Sources/OktaDirectAuth/WebAuthn/Type/PublicKeyCredentialType.swift b/Sources/OktaDirectAuth/WebAuthn/Type/PublicKeyCredentialType.swift index b20354fc0..93783b754 100644 --- a/Sources/OktaDirectAuth/WebAuthn/Type/PublicKeyCredentialType.swift +++ b/Sources/OktaDirectAuth/WebAuthn/Type/PublicKeyCredentialType.swift @@ -18,7 +18,7 @@ extension WebAuthn { - Note: [W3C Reccomendation](https://www.w3.org/TR/webauthn/#dom-publickeycredentialdescriptor-type) */ - public enum PublicKeyCredentialType: String, Codable { + public enum PublicKeyCredentialType: String, Codable, Equatable { /// Descripes a public key credential type. case publicKey = "public-key" } diff --git a/Sources/OktaDirectAuth/WebAuthn/Type/UserVerificationRequirement.swift b/Sources/OktaDirectAuth/WebAuthn/Type/UserVerificationRequirement.swift index a787d86e5..2a6648ecc 100644 --- a/Sources/OktaDirectAuth/WebAuthn/Type/UserVerificationRequirement.swift +++ b/Sources/OktaDirectAuth/WebAuthn/Type/UserVerificationRequirement.swift @@ -18,7 +18,7 @@ extension WebAuthn { - Note: [W3C Reccomendation](https://www.w3.org/TR/webauthn/#enum-userVerificationRequirement) */ - public enum UserVerificationRequirement: String, Codable { + public enum UserVerificationRequirement: String, Codable, Equatable { /// This value indicates that the Relying Party requires user verification for the operation and will fail the operation if the response does not have the UV flag set. case required diff --git a/Sources/OktaDirectAuth/WebAuthn/WebAuthn.swift b/Sources/OktaDirectAuth/WebAuthn/WebAuthn.swift index bad37091b..f2561ffeb 100644 --- a/Sources/OktaDirectAuth/WebAuthn/WebAuthn.swift +++ b/Sources/OktaDirectAuth/WebAuthn/WebAuthn.swift @@ -35,7 +35,7 @@ import Foundation /// ``` public struct WebAuthn { /// Represents the credential challenge returned from the server when a WebAuthn authentication is initiated. - public struct CredentialRequestOptions: Codable { + public struct CredentialRequestOptions: Codable, Equatable { /// The public key request options supplied to the client from the server. public let publicKey: WebAuthn.PublicKeyCredentialRequestOptions @@ -43,7 +43,7 @@ public struct WebAuthn { public let authenticatorEnrollments: [AuthenticatorEnrollment]? /// Defines additional authenticator enrollment information supplied by the server. - public struct AuthenticatorEnrollment: Codable { + public struct AuthenticatorEnrollment: Codable, Equatable { /// The ID supplied from the server representing this credential. /// /// **Note:** This should be identical to the ``WebAuthn/PublicKeyCredentialRequestOptions/rpID`` value. diff --git a/Sources/OktaOAuth2/Authentication/AuthorizationCodeFlow+Context.swift b/Sources/OktaOAuth2/Authentication/AuthorizationCodeFlow+Context.swift new file mode 100644 index 000000000..bf5db31f7 --- /dev/null +++ b/Sources/OktaOAuth2/Authentication/AuthorizationCodeFlow+Context.swift @@ -0,0 +1,236 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") +// +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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. +// + +import Foundation + +extension AuthorizationCodeFlow { + /// A model representing the context and current state for an authorization session. + public struct Context: AuthenticationContext, IDTokenValidatorContext { + /// The `PKCE` credentials to use in the authorization request. + /// + /// This value may be `nil` on platforms that do not support PKCE. + public let pkce: PKCE? + + /// The "nonce" value to send with this authorization request. + public let nonce: String? + + /// The maximum age an ID token can be when authenticating. + public var maxAge: TimeInterval? { + didSet { + authenticationURL = nil + } + } + + /// The ACR values, if any, which should be requested by the client. + public var acrValues: [String]? { + didSet { + authenticationURL = nil + } + } + + /// The state string to use when creating an authentication URL. + public var state: String { + didSet { + authenticationURL = nil + } + } + + /// The username to pre-populate if prompting for authentication. + public var loginHint: String? { + didSet { + authenticationURL = nil + } + } + + /// Value passed to the Social IdP when performing social login. + public var display: String? { + didSet { + authenticationURL = nil + } + } + + public var idTokenHint: String? { + didSet { + authenticationURL = nil + } + } + + /// Control how the user is prompted when authentication starts. + /// + /// > Note: The default behavior is ``Prompt/none``. + public var prompt: Prompt? { + didSet { + authenticationURL = nil + } + } + + /// Optional preferred languages and scripts for the user interface. + public var uiLocales: [String]? { + didSet { + authenticationURL = nil + } + } + + /// Optional preferred languages and scripts for Claims being returned. + public var claimsLocales: [String]? { + didSet { + authenticationURL = nil + } + } + + /// Any additional query string parameters you would like to supply to the authorization server. + public var additionalParameters: [String: any APIRequestArgument]? { + didSet { + authenticationURL = nil + } + } + + /// The current authentication URL, or `nil` if one has not yet been generated. + public internal(set) var authenticationURL: URL? + + /// Initializer for creating a context with a custom state string. + /// - Parameters: + /// - state: State string to use, or `nil` to accept an automatically generated default. + /// - maxAge: The maximum age an ID token can be when authenticating. + public init(state: String? = nil, + maxAge: TimeInterval? = nil, + acrValues: [String]? = nil, + additionalParameters: [String: any APIRequestArgument]? = nil) + { + let nonce = additionalParameters?["nonce"] as? String ?? .nonce() + let state = state ?? additionalParameters?["state"] as? String ?? UUID().uuidString + let maxAge = maxAge ?? additionalParameters?.maxAge + self.init(pkce: PKCE(), + nonce: nonce, + maxAge: maxAge, + acrValues: additionalParameters?.spaceSeparatedValues(for: "acr_values"), + state: state, + additionalParameters: additionalParameters?.omitting("nonce", "max_age", "acr_values", "state")) + } + + init(pkce: PKCE?, + nonce: String, + maxAge: TimeInterval?, + acrValues: [String]?, + state: String, + additionalParameters: [String: any APIRequestArgument]?) + { + self.pkce = pkce + self.nonce = nonce + self.state = state + self.maxAge = maxAge + self.acrValues = acrValues + + var remainingParameters = additionalParameters + self.idTokenHint = remainingParameters?.removeValue(forKey: "id_token_hint") as? String + self.loginHint = remainingParameters?.removeValue(forKey: "login_hint") as? String + self.display = remainingParameters?.removeValue(forKey: "display") as? String + self.uiLocales = remainingParameters?.removeSpaceSeparatedValues(forKey: "ui_locales") + self.claimsLocales = remainingParameters?.removeSpaceSeparatedValues(forKey: "claims_locales") + + if let stringValue = remainingParameters?["prompt"] as? String, + let prompt = Prompt(rawValue: stringValue) + { + self.prompt = prompt + remainingParameters?.removeValue(forKey: "prompt") + } + + self.additionalParameters = remainingParameters + } + + @_documentation(visibility: internal) + public func parameters(for category: OAuth2APIRequestCategory) -> [String: any APIRequestArgument]? { + var result = additionalParameters ?? [:] + + switch category { + case .authorization: + result["state"] = state + result["response_type"] = "code" + + if let nonce = nonce { + result["nonce"] = nonce + } + + if let maxAge = maxAge { + result["max_age"] = Int(maxAge).stringValue + } + + if let acrValues = acrValues { + result["acr_values"] = acrValues.joined(separator: " ") + } + + if let pkce = pkce { + result["code_challenge"] = pkce.codeChallenge + result["code_challenge_method"] = pkce.method + } + + if let loginHint = loginHint { + result["login_hint"] = loginHint + } + + if let idTokenHint = idTokenHint { + result["id_token_hint"] = idTokenHint + } + + if let display = display { + result["display"] = display + } + + if let prompt = prompt { + result["prompt"] = prompt + } + + case .token: + if let pkce = pkce { + result["code_verifier"] = pkce.codeVerifier + } + + case .configuration, .resource, .other: break + } + + return result.nilIfEmpty + } + } + + /// Defines how a user will be prompted to sign in. + /// + /// This is used with the ``WebAuthentication/Option/prompt(_:)`` enumeration. For more information, see the [API documentation for this parameter](https://developer.okta.com/docs/reference/api/oidc/#parameter-details). + public enum Prompt: String { + /// If an Okta session already exists, the user is silently authenticated. Otherwise, the user is prompted to authenticate. + case none + + /// Display the Okta consent dialog, even if the user has already given consent. + case consent + + /// Always prompt the user for authentication. + case login + + /// The user is always prompted for authentication, and the user consent dialog appears. + case loginAndConsent = "login consent" + + @_documentation(visibility: internal) + public init?(rawValue: String) { + switch rawValue.lowercased() { + case "none": + self = .none + case "consent": + self = .consent + case "login": + self = .login + case "login consent", "consent login": + self = .loginAndConsent + default: + return nil + } + } + } +} diff --git a/Sources/OktaOAuth2/Authentication/AuthorizationCodeFlow.swift b/Sources/OktaOAuth2/Authentication/AuthorizationCodeFlow.swift index bb1901c6e..ee0506de7 100644 --- a/Sources/OktaOAuth2/Authentication/AuthorizationCodeFlow.swift +++ b/Sources/OktaOAuth2/Authentication/AuthorizationCodeFlow.swift @@ -65,44 +65,7 @@ public protocol AuthorizationCodeFlowDelegate: AuthenticationDelegate { /// let redirectUri: URL /// let token = try await flow.resume(with: redirectUri) /// ``` -public class AuthorizationCodeFlow: AuthenticationFlow, ProvidesOAuth2Parameters { - /// A model representing the context and current state for an authorization session. - public struct Context: Equatable { - /// The `PKCE` credentials to use in the authorization request. - /// - /// This value may be `nil` on platforms that do not support PKCE. - public let pkce: PKCE? - - /// The state string to use when creating an authentication URL. - public let state: String - - /// The "nonce" value to send with this authorization request. - public let nonce: String - - /// The maximum age an ID token can be when authenticating. - public let maxAge: TimeInterval? - - /// The current authentication URL, or `nil` if one has not yet been generated. - public internal(set) var authenticationURL: URL? - - /// Initializer for creating a context with a custom state string. - /// - Parameters: - /// - state: State string to use, or `nil` to accept an automatically generated default. - /// - maxAge: The maximum age an ID token can be when authenticating. - public init(state: String? = nil, maxAge: TimeInterval? = nil) { - self.init(state: state ?? UUID().uuidString, - maxAge: maxAge, - nonce: [UInt8].random(count: 16).base64URLEncodedString, - pkce: PKCE()) - } - - init(state: String, maxAge: TimeInterval?, nonce: String, pkce: PKCE?) { - self.state = state - self.maxAge = maxAge - self.nonce = nonce - self.pkce = pkce - } - } +public class AuthorizationCodeFlow: AuthenticationFlow { /// Errors reported during processing and handling of redirect URLs. /// @@ -118,10 +81,7 @@ public class AuthorizationCodeFlow: AuthenticationFlow, ProvidesOAuth2Parameters /// The OAuth2Client this authentication flow will use. public let client: OAuth2Client - /// The redirect URI defined for your client. - public let redirectUri: URL - - /// Any additional query string parameters you would like to supply to the authorization server. + /// Any additional query string parameters you would like to supply to the authorization server for all requests from this flow. public let additionalParameters: [String: APIRequestArgument]? /// Indicates whether or not this flow is currently in the process of authenticating a user. @@ -140,114 +100,90 @@ public class AuthorizationCodeFlow: AuthenticationFlow, ProvidesOAuth2Parameters } /// The context that stores the state for the current authentication session. - public private(set) var context: Context? { - didSet { - guard let url = context?.authenticationURL else { - return - } + public private(set) var context: Context? - delegateCollection.invoke { $0.authentication(flow: self, shouldAuthenticateUsing: url) } - } - } - /// Convenience initializer to construct an authentication flow from variables. /// - Parameters: - /// - issuer: The issuer URL. + /// - issuerURL: The issuer URL. /// - clientId: The client ID. - /// - scopes: The scopes to request. + /// - scope: The scopes to request. /// - redirectUri: The redirect URI for the client. /// - additionalParameters: Optional additional query string parameters you would like to supply to the authorization server. - public convenience init(issuer: URL, - clientId: String, - scopes: String, - redirectUri: URL, - additionalParameters: [String: APIRequestArgument]? = nil) + public init(issuerURL: URL, + clientId: String, + scope: String, + redirectUri: URL, + additionalParameters: [String: any APIRequestArgument]? = nil) { - self.init(redirectUri: redirectUri, - additionalParameters: additionalParameters, - client: .init(baseURL: issuer, - clientId: clientId, - scopes: scopes)) + self.client = .init(issuerURL: issuerURL, + clientId: clientId, + scope: scope, + redirectUri: redirectUri) + self.additionalParameters = additionalParameters + + client.add(delegate: self) + + // Ensure this SDK's static version is included in the user agent. + SDKVersion.register(sdk: Version) } - + /// Initializer to construct an authentication flow from a pre-defined configuration and client. /// - Parameters: - /// - redirectUri: The redirect URI for the client. - /// - additionalParameters: Optional additional query string parameters you would like to supply to the authorization server. /// - client: The `OAuth2Client` to use with this flow. - public init(redirectUri: URL, - additionalParameters: [String: APIRequestArgument]? = nil, - client: OAuth2Client) + /// - additionalParameters: Optional additional query string parameters you would like to supply to the authorization server. + public required init(client: OAuth2Client, + additionalParameters: [String: any APIRequestArgument]? = nil) throws { + guard client.configuration.redirectUri != nil else { + throw OAuth2Error.missingRedirectUri + } + // Ensure this SDK's static version is included in the user agent. SDKVersion.register(sdk: Version) self.client = client - self.redirectUri = redirectUri self.additionalParameters = additionalParameters client.add(delegate: self) } - /// Initializer that uses the configuration defined within the application's `Okta.plist` file. - public convenience init() throws { - try self.init(try .init()) - } - - /// Initializer that uses the configuration defined within the given file URL. - /// - Parameter fileURL: File URL to a `plist` containing client configuration. - public convenience init(plist fileURL: URL) throws { - try self.init(try .init(plist: fileURL)) - } - - private convenience init(_ config: OAuth2Client.PropertyListConfiguration) throws { - guard let redirectUri = config.redirectUri else { - throw OAuth2Client.PropertyListConfigurationError.missingConfigurationValues - } - - self.init(issuer: config.issuer, - clientId: config.clientId, - scopes: config.scopes, - redirectUri: redirectUri, - additionalParameters: config.additionalParameters) - } - /// Initiates an authentication flow, with an optional ``Context-swift.struct``. /// /// This method is used to begin an authentication session. It is asynchronous, and will invoke the appropriate delegate methods when a response is received. /// - Parameters: - /// - context: Optional context to provide when customizing the state parameter. - /// - additionalParameters: Optional additional query string parameters you would like to supply to the authorization server. + /// - options: Options to customize the authentication flow. /// - completion: Completion block for receiving the response. - public func start(with context: Context? = nil, - additionalParameters: [String: String]? = nil, + public func start(with context: Context = .init(), completion: @escaping (Result) -> Void) { - var context = context ?? Context() + self.context = context isAuthenticating = true client.openIdConfiguration { result in switch result { case .failure(let error): - self.reset() - self.delegateCollection.invoke { $0.authentication(flow: self, received: error) } completion(.failure(error)) + self.reset() + case .success(let configuration): do { + var context = context // Capture the local context value defined above let url = try self.createAuthenticationURL(from: configuration.authorizationEndpoint, - using: context, - additionalParameters: additionalParameters) + using: context) context.authenticationURL = url self.context = context + self.delegateCollection.invoke { delegate in + delegate.authentication(flow: self, shouldAuthenticateUsing: url) + } + completion(.success(url)) } catch { - self.reset() - let oauthError = error as? OAuth2Error ?? .error(error) self.delegateCollection.invoke { $0.authentication(flow: self, received: oauthError) } completion(.failure(oauthError)) + self.reset() } } } @@ -262,20 +198,35 @@ public class AuthorizationCodeFlow: AuthenticationFlow, ProvidesOAuth2Parameters /// - url: Authorization redirect URI /// - completion: Completion block to retrieve the returned result. public func resume(with url: URL, completion: @escaping (Result) -> Void) throws { - let code = try authorizationCode(from: url) + guard let redirectUri = client.configuration.redirectUri else { + throw OAuth2Error.missingRedirectUri + } + + guard let context = self.context else { + throw OAuth2Error.missingClientConfiguration + } + let code = try url.authorizationCode(redirectUri: redirectUri, state: context.state) + let clientConfiguration = client.configuration + let additionalParameters = additionalParameters + client.openIdConfiguration { result in switch result { - case .success(let configuration): - let request = TokenRequest(openIdConfiguration: configuration, - clientConfiguration: self.client.configuration, - redirectUri: self.redirectUri.absoluteString, - grantType: .authorizationCode, - grantValue: code, - pkce: self.context?.pkce, - nonce: self.context?.nonce, - maxAge: self.context?.maxAge, - authenticationFlowConfiguration: nil) + case .success(let openIdConfiguration): + let request: TokenRequest + do { + request = try TokenRequest(openIdConfiguration: openIdConfiguration, + clientConfiguration: clientConfiguration, + additionalParameters: additionalParameters, + context: context, + authorizationCode: code) + } catch { + let error = OAuth2Error(error) + self.delegateCollection.invoke { $0.authentication(flow: self, received: error) } + completion(.failure(error)) + return + } + self.client.exchange(token: request) { result in self.reset() @@ -297,8 +248,8 @@ public class AuthorizationCodeFlow: AuthenticationFlow, ProvidesOAuth2Parameters } public func reset() { - context = nil isAuthenticating = false + context = nil } // MARK: Private properties / methods @@ -312,11 +263,11 @@ extension AuthorizationCodeFlow { /// This method is used to begin an authentication session. /// - Parameters: /// - context: Optional context to provide when customizing the state parameter. - /// - additionalParameters: Optional additional query string parameters you would like to supply to the authorization server. + /// - options: Options to customize this authentication flow. /// - Returns: The URL a user should be presented with within a browser, to continue authorization. - public func start(with context: Context? = nil, additionalParameters: [String: String]? = nil) async throws -> URL { + public func start(with context: Context = .init()) async throws -> URL { try await withCheckedThrowingContinuation { continuation in - start(with: context, additionalParameters: additionalParameters) { result in + start(with: context) { result in continuation.resume(with: result) } } @@ -345,20 +296,11 @@ extension AuthorizationCodeFlow { } } + extension AuthorizationCodeFlow: UsesDelegateCollection { public typealias Delegate = AuthorizationCodeFlowDelegate } -extension AuthorizationCodeFlow { - func authorizationCode(from url: URL) throws -> String { - guard let context = context else { - throw AuthenticationError.flowNotReady - } - - return try url.authorizationCode(redirectUri: redirectUri, state: context.state) - } -} - extension AuthorizationCodeFlow: OAuth2ClientDelegate { } @@ -366,15 +308,11 @@ extension AuthorizationCodeFlow: OAuth2ClientDelegate { extension OAuth2Client { /// Creates a new Authorization Code flow configured to use this OAuth2Client. /// - Parameters: - /// - redirectUri: Redirect URI /// - additionalParameters: Additional parameters to pass to the flow /// - Returns: Initialized authorization flow. - public func authorizationCodeFlow( - redirectUri: URL, - additionalParameters: [String: String]? = nil) -> AuthorizationCodeFlow + public func authorizationCodeFlow(additionalParameters: [String: String]? = nil) throws -> AuthorizationCodeFlow { - AuthorizationCodeFlow(redirectUri: redirectUri, - additionalParameters: additionalParameters, - client: self) + try AuthorizationCodeFlow(client: self, + additionalParameters: additionalParameters) } } diff --git a/Sources/OktaOAuth2/Authentication/DeviceAuthorizationFlow+Context.swift b/Sources/OktaOAuth2/Authentication/DeviceAuthorizationFlow+Context.swift new file mode 100644 index 000000000..b7134162e --- /dev/null +++ b/Sources/OktaOAuth2/Authentication/DeviceAuthorizationFlow+Context.swift @@ -0,0 +1,57 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") +// +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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. +// + +import Foundation + +extension DeviceAuthorizationFlow { + /// A model representing the context and current state for an authorization session. + public struct Context: AuthenticationContext { + /// Verification information used to + public internal(set) var verification: Verification? + + /// The ACR values, if any, which should be requested by the client. + public var acrValues: [String]? + + /// Any additional query string parameters you would like to supply to the authorization server. + public var additionalParameters: [String: any APIRequestArgument]? + + /// Initializer for creating a context with a custom state string. + /// - Parameters: + /// - state: State string to use, or `nil` to accept an automatically generated default. + /// - maxAge: The maximum age an ID token can be when authenticating. + public init(acrValues: [String]? = nil, + additionalParameters: [String: any APIRequestArgument]? = nil) + { + self.acrValues = acrValues + self.additionalParameters = additionalParameters + } + + @_documentation(visibility: internal) + public func parameters(for category: OAuth2APIRequestCategory) -> [String: any APIRequestArgument]? { + var result = additionalParameters ?? [:] + + switch category { + case .authorization: + if let acrValues = acrValues { + result["acr_values"] = acrValues.joined(separator: " ") + } + + case .token: + result["grant_type"] = GrantType.deviceCode + + case .configuration, .resource, .other: break + } + + return result + } + } +} diff --git a/Sources/OktaOAuth2/Authentication/DeviceAuthorizationFlow+Verification.swift b/Sources/OktaOAuth2/Authentication/DeviceAuthorizationFlow+Verification.swift new file mode 100644 index 000000000..bb8c1fb9c --- /dev/null +++ b/Sources/OktaOAuth2/Authentication/DeviceAuthorizationFlow+Verification.swift @@ -0,0 +1,60 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") +// +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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. +// + +import Foundation + +extension DeviceAuthorizationFlow { + /// Represents the user verification response of the ``DeviceAuthorizationFlow`` authentication flow. + /// + /// The values contained within this verification object should be used to present the user with the code and URL to visit to authorize their device. + public struct Verification: Decodable, Equatable, Expires { + let deviceCode: String + var interval: TimeInterval + + /// The date this context was created. + public let issuedAt: Date? + + /// The code that should be displayed to the user. + public let userCode: String + + /// The URI the user should be prompted to open in order to authorize the application. + public let verificationUri: URL + + /// A convenience URI that combines the ``verificationUri`` and the ``userCode``, to make a clickable link. + public let verificationUriComplete: URL? + + /// The time interval after which the authorization context will expire. + public let expiresIn: TimeInterval + + enum CodingKeys: String, CodingKey, CaseIterable { + case issuedAt + case userCode + case verificationUri + case verificationUriComplete + case expiresIn + case deviceCode + case interval + } + + @_documentation(visibility: internal) + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + issuedAt = try container.decodeIfPresent(Date.self, forKey: .issuedAt) ?? Date() + deviceCode = try container.decode(String.self, forKey: .deviceCode) + userCode = try container.decode(String.self, forKey: .userCode) + verificationUri = try container.decode(URL.self, forKey: .verificationUri) + verificationUriComplete = try container.decodeIfPresent(URL.self, forKey: .verificationUriComplete) + expiresIn = try container.decode(TimeInterval.self, forKey: .expiresIn) + interval = try container.decodeIfPresent(TimeInterval.self, forKey: .interval) ?? 5.0 + } + } +} diff --git a/Sources/OktaOAuth2/Authentication/DeviceAuthorizationFlow.swift b/Sources/OktaOAuth2/Authentication/DeviceAuthorizationFlow.swift index 5f01a478d..6252358ce 100644 --- a/Sources/OktaOAuth2/Authentication/DeviceAuthorizationFlow.swift +++ b/Sources/OktaOAuth2/Authentication/DeviceAuthorizationFlow.swift @@ -33,7 +33,7 @@ public protocol DeviceAuthorizationFlowDelegate: AuthenticationDelegate { /// - Parameters: /// - flow: The authentication flow that has finished. /// - context: The context to display to the user. - func authentication(flow: Flow, received context: DeviceAuthorizationFlow.Context) + func authentication(flow: Flow, received verification: DeviceAuthorizationFlow.Verification) } /// An authentication flow class that implements the Device Authorization Grant flow exchange. @@ -64,51 +64,12 @@ public protocol DeviceAuthorizationFlowDelegate: AuthenticationDelegate { /// let token = try await flow.resume(with: context) /// ``` public class DeviceAuthorizationFlow: AuthenticationFlow { - /// A model representing the context and current state for an authorization session. - public struct Context: Decodable, Equatable, Expires { - let deviceCode: String - var interval: TimeInterval - - /// The date this context was created. - public let issuedAt: Date? - - /// The code that should be displayed to the user. - public let userCode: String - - /// The URI the user should be prompted to open in order to authorize the application. - public let verificationUri: URL - - /// A convenience URI that combines the ``verificationUri`` and the ``userCode``, to make a clickable link. - public let verificationUriComplete: URL? - - /// The time interval after which the authorization context will expire. - public let expiresIn: TimeInterval - - enum CodingKeys: String, CodingKey, CaseIterable { - case issuedAt - case userCode - case verificationUri - case verificationUriComplete - case expiresIn - case deviceCode - case interval - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - issuedAt = try container.decodeIfPresent(Date.self, forKey: .issuedAt) ?? Date() - deviceCode = try container.decode(String.self, forKey: .deviceCode) - userCode = try container.decode(String.self, forKey: .userCode) - verificationUri = try container.decode(URL.self, forKey: .verificationUri) - verificationUriComplete = try container.decodeIfPresent(URL.self, forKey: .verificationUriComplete) - expiresIn = try container.decode(TimeInterval.self, forKey: .expiresIn) - interval = try container.decodeIfPresent(TimeInterval.self, forKey: .interval) ?? 5.0 - } - } - /// The OAuth2Client this authentication flow will use. public let client: OAuth2Client + /// Any additional query string parameters you would like to supply to the authorization server for all requests from this flow. + public let additionalParameters: [String: APIRequestArgument]? + /// Indicates whether or not this flow is currently in the process of authenticating a user. public private(set) var isAuthenticating: Bool = false { didSet { @@ -125,60 +86,40 @@ public class DeviceAuthorizationFlow: AuthenticationFlow { } /// The context that stores the state for the current authentication session. - public private(set) var context: Context? { - didSet { - guard let context = context else { - return - } - - delegateCollection.invoke { $0.authentication(flow: self, received: context) } - } - } + public private(set) var context: Context? /// Convenience initializer to construct an authentication flow from variables. /// - Parameters: - /// - issuer: The issuer URL. + /// - issuerURL: The issuer URL. /// - clientId: The client ID - /// - scopes: The scopes to request - public convenience init(issuer: URL, + /// - scope: The scopes to request + /// - additionalParameters: Optional additional query string parameters you would like to supply to the authorization server. + public convenience init(issuerURL: URL, clientId: String, - scopes: String) + scope: String, + additionalParameters: [String: any APIRequestArgument]? = nil) { - self.init(client: OAuth2Client(baseURL: issuer, + self.init(client: OAuth2Client(issuerURL: issuerURL, clientId: clientId, - scopes: scopes)) + scope: scope), + additionalParameters: additionalParameters) } - + /// Initializer to construct an authentication flow from a pre-defined configuration and client. /// - Parameters: - /// - configuration: The configuration to use for this authentication flow. /// - client: The `OAuth2Client` to use with this flow. - public init(client: OAuth2Client) { + /// - additionalParameters: Optional additional query string parameters you would like to supply to the authorization server. + public required init(client: OAuth2Client, + additionalParameters: [String: any APIRequestArgument]? = nil) { // Ensure this SDK's static version is included in the user agent. SDKVersion.register(sdk: Version) self.client = client + self.additionalParameters = additionalParameters client.add(delegate: self) } - /// Initializer that uses the configuration defined within the application's `Okta.plist` file. - public convenience init() throws { - self.init(try OAuth2Client.PropertyListConfiguration()) - } - - /// Initializer that uses the configuration defined within the given file URL. - /// - Parameter fileURL: File URL to a `plist` containing client configuration. - public convenience init(plist fileURL: URL) throws { - self.init(try OAuth2Client.PropertyListConfiguration(plist: fileURL)) - } - - private convenience init(_ config: OAuth2Client.PropertyListConfiguration) { - self.init(issuer: config.issuer, - clientId: config.clientId, - scopes: config.scopes) - } - /// Initiates a device authentication flow. /// /// This method is used to begin an authentication session. The resulting ``Context-swift.struct`` object can be used to display the user code and URI necessary for them to complete authentication on a different device. @@ -186,8 +127,12 @@ public class DeviceAuthorizationFlow: AuthenticationFlow { /// The ``resume(with:completion:)`` method also uses this context, to poll the server to determine when the user approves the authorization request. /// - Parameters: /// - completion: Completion block for receiving the context. - public func start(completion: @escaping (Result) -> Void) { + public func start(context: Context = .init(), completion: @escaping (Result) -> Void) { isAuthenticating = true + self.context = context + + let clientConfiguration = client.configuration + let additionalParameters = additionalParameters client.openIdConfiguration { result in switch result { @@ -199,15 +144,16 @@ public class DeviceAuthorizationFlow: AuthenticationFlow { } let request = AuthorizeRequest(url: url, - clientId: self.client.configuration.clientId, - scope: self.client.configuration.scopes) + clientConfiguration: clientConfiguration, + additionalParameters: additionalParameters, + context: context) request.send(to: self.client) { result in switch result { case .failure(let error): self.delegateCollection.invoke { $0.authentication(flow: self, received: .network(error: error)) } completion(.failure(.network(error: error))) case .success(let response): - self.context = response.result + self.context?.verification = response.result self.delegateCollection.invoke { $0.authentication(flow: self, received: response.result) } completion(.success(response.result)) } @@ -226,7 +172,7 @@ public class DeviceAuthorizationFlow: AuthenticationFlow { /// - Parameters: /// - context: Device authorization context object. /// - completion: Completion block for receiving the token. - public func resume(with context: Context, completion: @escaping (Result) -> Void) { + public func resume(with verification: DeviceAuthorizationFlow.Verification, completion: @escaping (Result) -> Void) { self.completion = completion scheduleTimer() } @@ -235,9 +181,9 @@ public class DeviceAuthorizationFlow: AuthenticationFlow { public func reset() { timer?.cancel() timer = nil - context = nil completion = nil isAuthenticating = false + context = nil } // MARK: Private properties / methods @@ -248,21 +194,24 @@ public class DeviceAuthorizationFlow: AuthenticationFlow { public let delegateCollection = DelegateCollection() func scheduleTimer(offsetBy interval: TimeInterval? = nil) { - guard var context = context else { + guard var context = self.context, + var verification = context.verification + else { return } if let interval = interval { - context.interval += interval + verification.interval += interval + context.verification = verification self.context = context } let completion = self.completion let timerSource = DispatchSource.makeTimerSource() - timerSource.schedule(deadline: .now() + context.interval, repeating: context.interval) + timerSource.schedule(deadline: .now() + verification.interval, repeating: verification.interval) timerSource.setEventHandler { - self.getToken(using: context) { result in + self.getToken(deviceCode: verification.deviceCode, context: context) { result in switch result { case .failure(let error): self.reset() @@ -291,7 +240,7 @@ extension DeviceAuthorizationFlow { /// /// The ``resume(with:)`` method also uses this context, to poll the server to determine when the user approves the authorization request. /// - Returns: The information a user should be presented with to continue authorization on a different device. - public func start() async throws -> Context { + public func start(with context: Context = .init()) async throws -> DeviceAuthorizationFlow.Verification { try await withCheckedThrowingContinuation { continuation in start { result in continuation.resume(with: result) @@ -305,9 +254,9 @@ extension DeviceAuthorizationFlow { /// - Parameters: /// - context: Device authorization context object. /// - Returns: The Token created as a result of exchanging an authorization code. - public func resume(with context: Context) async throws -> Token { + public func resume(with verification: DeviceAuthorizationFlow.Verification) async throws -> Token { try await withCheckedThrowingContinuation { continuation in - resume(with: context) { result in + resume(with: verification) { result in continuation.resume(with: result) } } @@ -319,14 +268,18 @@ extension DeviceAuthorizationFlow: UsesDelegateCollection { } extension DeviceAuthorizationFlow { - func getToken(using context: Context, completion: @escaping(Result) -> Void) { + func getToken(deviceCode: String, context: Context, completion: @escaping(Result) -> Void) { + let clientConfiguration = client.configuration + let additionalParameters = additionalParameters + client.openIdConfiguration { result in switch result { - case .success(let configuration): - let request = TokenRequest(openIdConfiguration: configuration, - clientId: self.client.configuration.clientId, - deviceCode: context.deviceCode, - authenticationFlowConfiguration: nil) + case .success(let openIdConfiguration): + let request = TokenRequest(openIdConfiguration: openIdConfiguration, + clientConfiguration: clientConfiguration, + additionalParameters: additionalParameters, + context: context, + deviceCode: deviceCode) self.client.exchange(token: request) { result in switch result { case .failure(let error): @@ -371,7 +324,8 @@ extension DeviceAuthorizationFlow: OAuth2ClientDelegate { extension OAuth2Client { /// Creates a new Device Authorization flow configured to use this OAuth2Client. /// - Returns: Initialized authorization flow. - public func deviceAuthorizationFlow() -> DeviceAuthorizationFlow { - DeviceAuthorizationFlow(client: self) + public func deviceAuthorizationFlow(additionalParameters: [String: String]? = nil) -> DeviceAuthorizationFlow { + DeviceAuthorizationFlow(client: self, + additionalParameters: additionalParameters) } } diff --git a/Sources/OktaOAuth2/Authentication/JWTAuthorizationFlow.swift b/Sources/OktaOAuth2/Authentication/JWTAuthorizationFlow.swift index 3c984fe3e..46ad7b591 100644 --- a/Sources/OktaOAuth2/Authentication/JWTAuthorizationFlow.swift +++ b/Sources/OktaOAuth2/Authentication/JWTAuthorizationFlow.swift @@ -15,9 +15,17 @@ import AuthFoundation /// An authentication flow class that implements the JWT Authorization Bearer Flow, for authenticating users using JWTs signed by a trusted key. public class JWTAuthorizationFlow: AuthenticationFlow { + public typealias Context = StandardAuthenticationContext + /// The OAuth2Client this authentication flow will use. public let client: OAuth2Client + /// The context that stores the state for the current authentication session. + public private(set) var context: Context? + + /// Any additional query string parameters you would like to supply to the authorization server for all requests from this flow. + public let additionalParameters: [String: APIRequestArgument]? + /// Indicates whether or not this flow is currently in the process of authenticating a user. /// ``JWTAuthorizationFlow/init(issuer:clientId:scopes:)`` public private(set) var isAuthenticating: Bool = false { @@ -38,59 +46,54 @@ public class JWTAuthorizationFlow: AuthenticationFlow { /// - Parameters: /// - issuer: The issuer URL. /// - clientId: The client ID - /// - scopes: The scopes to request - public convenience init(issuer: URL, + /// - scope: The scopes to request + public convenience init(issuerURL: URL, clientId: String, - scopes: String) + scope: String, + additionalParameters: [String: any APIRequestArgument]? = nil) { - self.init(client: OAuth2Client(baseURL: issuer, + self.init(client: OAuth2Client(issuerURL: issuerURL, clientId: clientId, - scopes: scopes)) + scope: scope), + additionalParameters: additionalParameters) } /// Initializer to construct an authentication flow from an OAuth2Client. /// - Parameter client: `OAuth2Client` instance to authenticate with. - public init(client: OAuth2Client) { + public required init(client: OAuth2Client, + additionalParameters: [String: any APIRequestArgument]? = nil) + { // Ensure this SDK's static version is included in the user agent. SDKVersion.register(sdk: Version) self.client = client + self.additionalParameters = additionalParameters client.add(delegate: self) } - /// Initializer that uses the configuration defined within the application's `Okta.plist` file. - public convenience init() throws { - self.init(try OAuth2Client.PropertyListConfiguration()) - } - - /// Initializer that uses the configuration defined within the given file URL. - /// - Parameter fileURL: File URL to a `plist` containing client configuration. - public convenience init(plist fileURL: URL) throws { - self.init(try OAuth2Client.PropertyListConfiguration(plist: fileURL)) - } - - private convenience init(_ config: OAuth2Client.PropertyListConfiguration) { - self.init(issuer: config.issuer, - clientId: config.clientId, - scopes: config.scopes) - } - /// Authenticates using the supplied JWT bearer assertion. /// - Parameters: /// - assertion: JWT Assertion /// - completion: Completion invoked when a response is received. - public func start(with assertion: JWT, completion: @escaping (Result) -> Void) { + public func start(with assertion: JWT, + context: Context = .init(), + completion: @escaping (Result) -> Void) + { isAuthenticating = true + self.context = context + + let clientConfiguration = client.configuration + let additionalParameters = additionalParameters client.openIdConfiguration { result in switch result { - case .success(let configuration): - let request = TokenRequest(openIdConfiguration: configuration, - clientId: self.client.configuration.clientId, - scope: self.client.configuration.scopes, - assertion: assertion, - authenticationFlowConfiguration: nil) + case .success(let openIdConfiguration): + let request = TokenRequest(openIdConfiguration: openIdConfiguration, + clientConfiguration: clientConfiguration, + additionalParameters: additionalParameters, + context: context, + assertion: assertion) self.client.exchange(token: request) { result in self.reset() @@ -114,6 +117,7 @@ public class JWTAuthorizationFlow: AuthenticationFlow { /// Resets the flow for later reuse. public func reset() { isAuthenticating = false + context = nil } // MARK: Private properties / methods @@ -126,9 +130,9 @@ extension JWTAuthorizationFlow { /// /// - Parameter jwt: JWT Assertion /// - Returns: The token resulting from signing in. - public func start(with assertion: JWT) async throws -> Token { + public func start(with assertion: JWT, context: Context = .init()) async throws -> Token { try await withCheckedThrowingContinuation { continuation in - start(with: assertion) { result in + start(with: assertion, context: context) { result in continuation.resume(with: result) } } @@ -144,7 +148,7 @@ extension JWTAuthorizationFlow: OAuth2ClientDelegate {} extension OAuth2Client { /// Creates a new JWT Authorization flow configured to use this OAuth2Client. /// - Returns: Initialized authorization flow. - public func jwtAuthorizationFlow() -> JWTAuthorizationFlow { - JWTAuthorizationFlow(client: self) + public func jwtAuthorizationFlow(additionalParameters: [String: String]? = nil) -> JWTAuthorizationFlow { + JWTAuthorizationFlow(client: self, additionalParameters: additionalParameters) } } diff --git a/Sources/OktaOAuth2/Authentication/ResourceOwnerFlow.swift b/Sources/OktaOAuth2/Authentication/ResourceOwnerFlow.swift index c6f03c6ad..156d0e8f9 100644 --- a/Sources/OktaOAuth2/Authentication/ResourceOwnerFlow.swift +++ b/Sources/OktaOAuth2/Authentication/ResourceOwnerFlow.swift @@ -19,9 +19,17 @@ import AuthFoundation /// /// > Important: Resource Owner authentication does not support MFA or other more secure authentication models, and is not recommended for production applications. Please use the DirectAuth SDK's DirectAuthenticationFlow class instead. public class ResourceOwnerFlow: AuthenticationFlow { + public typealias Context = StandardAuthenticationContext + /// The OAuth2Client this authentication flow will use. public let client: OAuth2Client + /// The context that stores the state for the current authentication session. + public private(set) var context: Context? + + /// Any additional query string parameters you would like to supply to the authorization server for all requests from this flow. + public let additionalParameters: [String: APIRequestArgument]? + /// Indicates whether or not this flow is currently in the process of authenticating a user. /// ``ResourceOwnerFlow/init(issuer:clientId:scopes:)`` public private(set) var isAuthenticating: Bool = false { @@ -40,61 +48,62 @@ public class ResourceOwnerFlow: AuthenticationFlow { /// Convenience initializer to construct an authentication flow from variables. /// - Parameters: - /// - issuer: The issuer URL. + /// - issuerURL: The issuer URL. /// - clientId: The client ID - /// - scopes: The scopes to request - public convenience init(issuer: URL, + /// - scope: The scopes to request + /// - additionalParameters: Optional query parameters to supply tot he authorization server for all requests from this flow. + public convenience init(issuerURL: URL, clientId: String, - scopes: String) + scope: String, + additionalParameters: [String: APIRequestArgument]? = nil) { - self.init(client: OAuth2Client(baseURL: issuer, + self.init(client: OAuth2Client(issuerURL: issuerURL, clientId: clientId, - scopes: scopes)) + scope: scope), + additionalParameters: additionalParameters) } - public init(client: OAuth2Client) { + /// Initializer that uses the predefined OAuth2Client + /// - Parameters: + /// - client: ``OAuth2Client`` client instance to authenticate with. + /// - additionalParameters: Optional query parameters to supply tot he authorization server for all requests from this flow. + public required init(client: OAuth2Client, + additionalParameters: [String: APIRequestArgument]? = nil) + { // Ensure this SDK's static version is included in the user agent. SDKVersion.register(sdk: Version) self.client = client + self.additionalParameters = additionalParameters client.add(delegate: self) } - /// Initializer that uses the configuration defined within the application's `Okta.plist` file. - public convenience init() throws { - self.init(try OAuth2Client.PropertyListConfiguration()) - } - - /// Initializer that uses the configuration defined within the given file URL. - /// - Parameter fileURL: File URL to a `plist` containing client configuration. - public convenience init(plist fileURL: URL) throws { - self.init(try OAuth2Client.PropertyListConfiguration(plist: fileURL)) - } - - private convenience init(_ config: OAuth2Client.PropertyListConfiguration) { - self.init(issuer: config.issuer, - clientId: config.clientId, - scopes: config.scopes) - } - /// Authenticates using the supplied username and password. /// - Parameters: /// - username: Username /// - password: Password /// - completion: Completion invoked when a response is received. - public func start(username: String, password: String, completion: @escaping (Result) -> Void) { + public func start(username: String, + password: String, + context: Context = .init(), + completion: @escaping (Result) -> Void) + { isAuthenticating = true + self.context = context + + let clientConfiguration = client.configuration + let additionalParameters = additionalParameters client.openIdConfiguration { result in switch result { - case .success(let configuration): - let request = TokenRequest(openIdConfiguration: configuration, - clientId: self.client.configuration.clientId, - scope: self.client.configuration.scopes, + case .success(let openIdConfiguration): + let request = TokenRequest(openIdConfiguration: openIdConfiguration, + clientConfiguration: clientConfiguration, + additionalParameters: additionalParameters, + context: context, username: username, - password: password, - authenticationFlowConfiguration: nil) + password: password) self.client.exchange(token: request) { result in self.reset() @@ -118,6 +127,7 @@ public class ResourceOwnerFlow: AuthenticationFlow { /// Resets the flow for later reuse. public func reset() { isAuthenticating = false + context = nil } // MARK: Private properties / methods @@ -149,7 +159,7 @@ extension ResourceOwnerFlow: OAuth2ClientDelegate { extension OAuth2Client { /// Creates a new Resource Owner flow configured to use this OAuth2Client. /// - Returns: Initialized authorization flow. - public func resourceOwnerFlow() -> ResourceOwnerFlow { - ResourceOwnerFlow(client: self) + public func resourceOwnerFlow(additionalParameters: [String: String]? = nil) -> ResourceOwnerFlow { + ResourceOwnerFlow(client: self, additionalParameters: additionalParameters) } } diff --git a/Sources/OktaOAuth2/Authentication/SessionTokenFlow.swift b/Sources/OktaOAuth2/Authentication/SessionTokenFlow.swift index f01cbcd4f..bbb2b82b4 100644 --- a/Sources/OktaOAuth2/Authentication/SessionTokenFlow.swift +++ b/Sources/OktaOAuth2/Authentication/SessionTokenFlow.swift @@ -20,16 +20,18 @@ import FoundationNetworking /// An authentication flow class that exchanges a Session Token for access tokens. /// /// This flow is typically used in conjunction with the [classic Okta native authentication library](https://github.com/okta/okta-auth-swift). For native authentication using the Okta Identity Engine (OIE), please use the [Okta IDX library](https://github.com/okta/okta-idx-swift). -public class SessionTokenFlow: AuthenticationFlow, ProvidesOAuth2Parameters { +public class SessionTokenFlow: AuthenticationFlow { + public typealias Context = AuthorizationCodeFlow.Context + /// The OAuth2Client this authentication flow will use. public let client: OAuth2Client - /// The redirect URI defined for your client. - public let redirectUri: URL - - /// Any additional query string parameters you would like to supply to the authorization server. + /// The context that stores the state for the current authentication session. + public private(set) var context: Context? + + /// Any additional query string parameters you would like to supply to the authorization server for all requests from this flow. public let additionalParameters: [String: APIRequestArgument]? - + /// Indicates whether or not this flow is currently in the process of authenticating a user. public private(set) var isAuthenticating: Bool = false { didSet { @@ -47,76 +49,67 @@ public class SessionTokenFlow: AuthenticationFlow, ProvidesOAuth2Parameters { /// Convenience initializer to construct an authentication flow from variables. /// - Parameters: - /// - issuer: The issuer URL. + /// - issuerURL: The issuer URL. /// - clientId: The client ID - /// - scopes: The scopes to request - public convenience init(issuer: URL, + /// - scope: The scopes to request + /// - additionalParameters: Optional query parameters to supply tot he authorization server for all requests from this flow. + public convenience init(issuerURL: URL, clientId: String, - scopes: String, + scope: String, redirectUri: URL, - additionalParameters: [String: APIRequestArgument]? = nil) + additionalParameters: [String: APIRequestArgument]? = nil) throws { - self.init(redirectUri: redirectUri, - additionalParameters: additionalParameters, - client: .init(baseURL: issuer, - clientId: clientId, - scopes: scopes)) + try self.init(client: OAuth2Client(issuerURL: issuerURL, + clientId: clientId, + scope: scope, + redirectUri: redirectUri), + additionalParameters: additionalParameters) } - - public init(redirectUri: URL, - additionalParameters: [String: APIRequestArgument]? = nil, - client: OAuth2Client) + + /// Initializer that uses the predefined OAuth2Client + /// - Parameters: + /// - client: ``OAuth2Client`` client instance to authenticate with. + /// - additionalParameters: Optional query parameters to supply tot he authorization server for all requests from this flow. + public required init(client: OAuth2Client, + additionalParameters: [String: APIRequestArgument]? = nil) throws { + guard client.configuration.redirectUri != nil else { + throw OAuth2Error.missingRedirectUri + } + // Ensure this SDK's static version is included in the user agent. SDKVersion.register(sdk: Version) self.client = client - self.redirectUri = redirectUri self.additionalParameters = additionalParameters client.add(delegate: self) } - /// Initializer that uses the configuration defined within the application's `Okta.plist` file. - public convenience init() throws { - try self.init(try .init()) - } - - /// Initializer that uses the configuration defined within the given file URL. - /// - Parameter fileURL: File URL to a `plist` containing client configuration. - public convenience init(plist fileURL: URL) throws { - try self.init(try .init(plist: fileURL)) - } - - private convenience init(_ config: OAuth2Client.PropertyListConfiguration) throws { - guard let redirectUri = config.redirectUri else { - throw OAuth2Client.PropertyListConfigurationError.missingConfigurationValues - } - - self.init(issuer: config.issuer, - clientId: config.clientId, - scopes: config.scopes, - redirectUri: redirectUri, - additionalParameters: config.additionalParameters) - } - /// Authenticates using the supplied session token. /// - Parameters: /// - sessionToken: Session token to exchange. /// - context: Optional context to provide when customizing the state parameter. /// - completion: Completion invoked when a response is received. public func start(with sessionToken: String, - context: AuthorizationCodeFlow.Context? = nil, + context: Context = .init(), completion: @escaping (Result) -> Void) { isAuthenticating = true + self.context = context + var parameters = additionalParameters ?? [:] parameters["sessionToken"] = sessionToken - let flow = AuthorizationCodeFlow(redirectUri: redirectUri, - additionalParameters: parameters, - client: client) + let flow: AuthorizationCodeFlow + do { + flow = try AuthorizationCodeFlow(client: client, additionalParameters: parameters) + } catch { + completion(.failure(OAuth2Error(error))) + return + } + flow.start(with: context) { result in switch result { case .failure(let error): @@ -142,6 +135,7 @@ public class SessionTokenFlow: AuthenticationFlow, ProvidesOAuth2Parameters { /// Resets the flow for later reuse. public func reset() { isAuthenticating = false + context = nil } // MARK: Private properties / methods @@ -153,7 +147,7 @@ public class SessionTokenFlow: AuthenticationFlow, ProvidesOAuth2Parameters { } private func complete(using flow: AuthorizationCodeFlow, url: URL, completion: @escaping (Result) -> Void) { - guard let scheme = redirectUri.scheme else { + guard let scheme = client.configuration.redirectUri?.scheme else { completion(.failure(.invalidUrl)) return } @@ -182,7 +176,7 @@ public class SessionTokenFlow: AuthenticationFlow, ProvidesOAuth2Parameters { extension SessionTokenFlow { /// Asynchronously authenticates with the given session token. public func start(with sessionToken: String, - context: AuthorizationCodeFlow.Context? = nil) async throws -> Token + context: Context = .init()) async throws -> Token { try await withCheckedThrowingContinuation { continuation in start(with: sessionToken, context: context) { result in @@ -271,11 +265,9 @@ extension OAuth2Client { /// - redirectUri: Redirect URI /// - additionalParameters: Additional parameters to pass to the flow /// - Returns: Initialized authorization flow. - public func sessionTokenFlow(redirectUri: URL, - additionalParameters: [String: String]? = nil) -> SessionTokenFlow + public func sessionTokenFlow(additionalParameters: [String: String]? = nil) throws -> SessionTokenFlow { - SessionTokenFlow(redirectUri: redirectUri, - additionalParameters: additionalParameters, - client: self) + try SessionTokenFlow(client: self, + additionalParameters: additionalParameters) } } diff --git a/Sources/OktaOAuth2/Authentication/TokenExchangeFlow+Context.swift b/Sources/OktaOAuth2/Authentication/TokenExchangeFlow+Context.swift new file mode 100644 index 000000000..605c2ce3d --- /dev/null +++ b/Sources/OktaOAuth2/Authentication/TokenExchangeFlow+Context.swift @@ -0,0 +1,59 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") +// +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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. +// + +import Foundation + +extension TokenExchangeFlow { + /// A model representing the context and current state for an authorization session. + public struct Context: AuthenticationContext { + /// Server audience. + public var audience: Audience + + /// The ACR values, if any, which should be requested by the client. + public var acrValues: [String]? + + /// Any additional query string parameters you would like to supply to the authorization server. + public var additionalParameters: [String: any APIRequestArgument]? + + /// Initializer for creating a context with a custom state string. + /// - Parameters: + /// - state: State string to use, or `nil` to accept an automatically generated default. + /// - maxAge: The maximum age an ID token can be when authenticating. + public init(audience: Audience = .default, + acrValues: [String]? = nil, + additionalParameters: [String: any APIRequestArgument]? = nil) + { + self.audience = audience + self.acrValues = acrValues + self.additionalParameters = additionalParameters + } + + @_documentation(visibility: internal) + public func parameters(for category: OAuth2APIRequestCategory) -> [String: any APIRequestArgument]? { + var result = additionalParameters ?? [:] + + switch category { + case .authorization, .token: + if let acrValues = acrValues { + result["acr_values"] = acrValues.joined(separator: " ") + } + + result["audience"] = audience + result["grant_type"] = GrantType.tokenExchange + + case .configuration, .resource, .other: break + } + + return result + } + } +} diff --git a/Sources/OktaOAuth2/Authentication/TokenExchangeFlow.swift b/Sources/OktaOAuth2/Authentication/TokenExchangeFlow.swift index 3bc41da27..f18aef18e 100644 --- a/Sources/OktaOAuth2/Authentication/TokenExchangeFlow.swift +++ b/Sources/OktaOAuth2/Authentication/TokenExchangeFlow.swift @@ -16,11 +16,11 @@ import Foundation /// An authentication flow class that implements the Token Exchange Flow. public class TokenExchangeFlow: AuthenticationFlow { /// Identifies the audience of the authorization server. - public enum Audience { + public enum Audience: APIRequestArgument { case `default` case custom(String) - var value: String { + public var stringValue: String { switch self { case .default: return "api://default" @@ -28,13 +28,24 @@ public class TokenExchangeFlow: AuthenticationFlow { return aud } } + + init (_ value: String?) { + if let value = value { + self = .custom(value) + } else { + self = .default + } + } } /// The OAuth2 client this authentication flow will use. public let client: OAuth2Client - /// Server audience. - public let audience: Audience + /// The context that stores the state for the current authentication session. + public private(set) var context: Context? + + /// Any additional query string parameters you would like to supply to the authorization server for all requests from this flow. + public let additionalParameters: [String: APIRequestArgument]? /// Indicates whether or not this flow is currently in the process of authenticating a user. public private(set) var isAuthenticating: Bool = false { @@ -56,58 +67,48 @@ public class TokenExchangeFlow: AuthenticationFlow { /// Convenience initializer to construct a flow from variables. /// - Parameters: - /// - issuer: The issuer URL. + /// - issuerURL: The issuer URL. /// - clientId: The client ID. - /// - scopes: The scopes to request. + /// - scope: The scopes to request. /// - audience: The audience of the authorization server. - public convenience init(issuer: URL, + /// - additionalParameters: Optional query parameters to supply tot he authorization server for all requests from this flow. + public convenience init(issuerURL: URL, clientId: String, - scopes: String, - audience: Audience = .default) { - self.init(audience: audience, - client: OAuth2Client(baseURL: issuer, + scope: String, + additionalParameters: [String: APIRequestArgument]? = nil) + { + self.init(client: OAuth2Client(issuerURL: issuerURL, clientId: clientId, - scopes: scopes)) - } - - /// Initializer that uses the configuration defined within the application's `Okta.plist` file. - public convenience init() throws { - self.init(try OAuth2Client.PropertyListConfiguration()) + scope: scope), + additionalParameters: additionalParameters) } - - /// Initializer that uses the configuration defined within the given file URL. - /// - Parameter fileURL: File URL to a `plist` containing client configuration. - public convenience init(plist fileURL: URL) throws { - self.init(try OAuth2Client.PropertyListConfiguration(plist: fileURL)) - } - - private convenience init(_ config: OAuth2Client.PropertyListConfiguration) { - self.init(issuer: config.issuer, - clientId: config.clientId, - scopes: config.scopes) - } - + /// Initializer to construct a flow from a default audience and client. /// - Parameters: /// - audience: The audience of the authorization server. /// - client: The `OAuth2Client` to use with this flow. - public init(audience: Audience = .default, client: OAuth2Client) { + public required init(client: OAuth2Client, + additionalParameters: [String: APIRequestArgument]? = nil) + { // Ensure this SDK's static version is included in the user agent. SDKVersion.register(sdk: Version) self.client = client - self.audience = audience + self.additionalParameters = additionalParameters client.add(delegate: self) } - + /// Initiates a token exchange flow. /// /// This method is used to begin a token exchange. This method is asynchronous, and will invoke the appropriate delegate methods when a response is received. /// - Parameters: /// - tokens: Tokens to exchange. /// - completion: Completion block for receiving the response. - public func start(with tokens: [TokenType], completion: @escaping (Result) -> Void) { + public func start(with tokens: [TokenType], + context: Context = .init(), + completion: @escaping (Result) -> Void) + { guard !tokens.isEmpty else { delegateCollection.invoke { $0.authentication(flow: self, received: OAuth2Error.cannotComposeUrl) } completion(.failure(OAuth2Error.cannotComposeUrl)) @@ -116,16 +117,18 @@ public class TokenExchangeFlow: AuthenticationFlow { } isAuthenticating = true + self.context = context + let clientConfiguration = client.configuration + let additionalParameters = additionalParameters client.openIdConfiguration { result in switch result { - case .success(let configuration): - let request = TokenRequest(openIdConfiguration: configuration, - clientId: self.client.configuration.clientId, - tokens: tokens, - scope: self.client.configuration.scopes, - audience: self.audience.value, - authenticationFlowConfiguration: nil) + case .success(let openIdConfiguration): + let request = TokenRequest(openIdConfiguration: openIdConfiguration, + clientConfiguration: clientConfiguration, + additionalParameters: additionalParameters, + context: context, + tokens: tokens) self.client.exchange(token: request) { result in switch result { case .failure(let error): @@ -149,6 +152,7 @@ public class TokenExchangeFlow: AuthenticationFlow { public func reset() { isAuthenticating = false + context = nil } } @@ -161,9 +165,11 @@ extension TokenExchangeFlow { /// Asynchronously initiates a token exchange flow. /// - Parameter tokens: Tokens to exchange. If empty, the method throws an error. /// - Returns: The the token created as a result of exchanging the tokens. - public func start(with tokens: [TokenType]) async throws -> Token { + public func start(with tokens: [TokenType], + context: Context = .init()) async throws -> Token + { try await withCheckedThrowingContinuation { continuation in - start(with: tokens) { result in + start(with: tokens, context: context) { result in continuation.resume(with: result) } } @@ -174,7 +180,9 @@ extension OAuth2Client { /// Creates a new Token Exchange flow configured to use this OAuth2Client, using the supplied arguments. /// - Parameter audience: Audience to configure the flow to use /// - Returns: Initialized authorization flow. - public func tokenExchangeFlow(audience: TokenExchangeFlow.Audience = .default) -> TokenExchangeFlow { - TokenExchangeFlow(audience: audience, client: self) + public func tokenExchangeFlow(additionalParameters: [String: String]? = nil) -> TokenExchangeFlow + { + TokenExchangeFlow(client: self, + additionalParameters: additionalParameters) } } diff --git a/Sources/OktaOAuth2/Deprecations/AuthorizationCodeFlow+Deprecations.swift b/Sources/OktaOAuth2/Deprecations/AuthorizationCodeFlow+Deprecations.swift new file mode 100644 index 000000000..6f68a75fe --- /dev/null +++ b/Sources/OktaOAuth2/Deprecations/AuthorizationCodeFlow+Deprecations.swift @@ -0,0 +1,62 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") +// +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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. +// + +import Foundation + +extension AuthorizationCodeFlow { + @_documentation(visibility: private) + @available(*, deprecated, renamed: "init(issuerURL:clientId:scope:redirectUri:additionalParameters:)") + public convenience init(issuer: URL, + clientId: String, + scopes: String, + redirectUri: URL, + additionalParameters: [String: APIRequestArgument]? = nil) + { + fatalError() + } + + @_documentation(visibility: private) + @available(*, deprecated, renamed: "init(client:additionalParameters:)") + public convenience init(redirectUri: URL, + additionalParameters: [String: APIRequestArgument]?, + client: OAuth2Client) + { + fatalError() + } + + @_documentation(visibility: private) + @available(*, deprecated, renamed: "start(with:completion:)") + public func start(with context: Context? = nil, + additionalParameters: [String: String]?, + completion: @escaping (Result) -> Void) + { + fatalError() + } + + @_documentation(visibility: private) + @available(*, deprecated, renamed: "start(with:)") + public func start(with context: Context?, additionalParameters: [String: String]?) async throws -> URL + { + fatalError() + } +} + +extension OAuth2Client { + @_documentation(visibility: private) + @available(*, deprecated, renamed: "authorizationCodeFlow(additionalParameters:)") + public func authorizationCodeFlow( + redirectUri: URL, + additionalParameters: [String: String]?) -> AuthorizationCodeFlow + { + fatalError() + } +} diff --git a/Sources/OktaOAuth2/Deprecations/DeviceAuthorizationFlow+Deprecations.swift b/Sources/OktaOAuth2/Deprecations/DeviceAuthorizationFlow+Deprecations.swift new file mode 100644 index 000000000..ab353e4d0 --- /dev/null +++ b/Sources/OktaOAuth2/Deprecations/DeviceAuthorizationFlow+Deprecations.swift @@ -0,0 +1,31 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") +// +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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. +// + +import Foundation +import AuthFoundation + +extension DeviceAuthorizationFlow { + @_documentation(visibility: private) + @available(*, deprecated, renamed: "init(issuerURL:clientId:scope:additionalParameters:)") + public convenience init(issuer: URL, + clientId: String, + scopes: String) + { + fatalError() + } + + @_documentation(visibility: private) + @available(*, deprecated, renamed: "init(client:additionalParameters:)") + public convenience init(client: OAuth2Client) { + fatalError() + } +} diff --git a/Sources/OktaOAuth2/Deprecations/JWTAuthorizationFlow+Deprecations.swift b/Sources/OktaOAuth2/Deprecations/JWTAuthorizationFlow+Deprecations.swift new file mode 100644 index 000000000..3c011183c --- /dev/null +++ b/Sources/OktaOAuth2/Deprecations/JWTAuthorizationFlow+Deprecations.swift @@ -0,0 +1,24 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") +// +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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. +// + +import Foundation + +extension JWTAuthorizationFlow { + @_documentation(visibility: private) + @available(*, deprecated, renamed: "init(issuerURL:clientId:scope:additionalParameters:)") + public convenience init(issuer: URL, + clientId: String, + scopes: String) + { + self.init(issuerURL: issuer, clientId: clientId, scope: scopes) + } +} diff --git a/Sources/OktaOAuth2/Deprecations/ResourceOwnerFlow+Deprecations.swift b/Sources/OktaOAuth2/Deprecations/ResourceOwnerFlow+Deprecations.swift new file mode 100644 index 000000000..caebe501d --- /dev/null +++ b/Sources/OktaOAuth2/Deprecations/ResourceOwnerFlow+Deprecations.swift @@ -0,0 +1,24 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") +// +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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. +// + +import Foundation + +extension ResourceOwnerFlow { + @_documentation(visibility: private) + @available(*, deprecated, renamed: "init(issuerURL:clientId:scope:additionalParameters:)") + public convenience init(issuer: URL, + clientId: String, + scopes: String) + { + self.init(issuerURL: issuer, clientId: clientId, scope: scopes) + } +} diff --git a/Sources/OktaOAuth2/Deprecations/SessionLogoutFlow+Deprecations.swift b/Sources/OktaOAuth2/Deprecations/SessionLogoutFlow+Deprecations.swift new file mode 100644 index 000000000..2fb347d85 --- /dev/null +++ b/Sources/OktaOAuth2/Deprecations/SessionLogoutFlow+Deprecations.swift @@ -0,0 +1,23 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") +// +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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. +// + +import Foundation + +extension SessionLogoutFlow { + @_documentation(visibility: private) + @available(*, deprecated, renamed: "init(client:additionalParameters:)") + public convenience init(logoutRedirectUri: URL, + additionalParameters: [String: APIRequestArgument]? = nil, + client: OAuth2Client) { + fatalError() + } +} diff --git a/Sources/OktaOAuth2/Deprecations/SessionTokenFlow+Deprecations.swift b/Sources/OktaOAuth2/Deprecations/SessionTokenFlow+Deprecations.swift new file mode 100644 index 000000000..393f65138 --- /dev/null +++ b/Sources/OktaOAuth2/Deprecations/SessionTokenFlow+Deprecations.swift @@ -0,0 +1,45 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") +// +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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. +// + +import Foundation + +extension ResourceOwnerFlow { + @_documentation(visibility: private) + @available(*, deprecated, renamed: "init(issuerURL:clientId:scope:redirectUri:additionalParameters:)") + public convenience init(issuer: URL, + clientId: String, + scopes: String, + redirectUri: URL, + additionalParameters: [String: APIRequestArgument]? = nil) + { + fatalError() + } + + @_documentation(visibility: private) + @available(*, deprecated, renamed: "init(client:additionalParameters:)") + public convenience init(redirectUri: URL, + additionalParameters: [String: APIRequestArgument]? = nil, + client: OAuth2Client) + { + fatalError() + } +} + +extension OAuth2Client { + @_documentation(visibility: private) + @available(*, deprecated, renamed: "sessionTokenFlow(additionalParameters:)") + public func sessionTokenFlow(redirectUri: URL, + additionalParameters: [String: String]? = nil) throws -> SessionTokenFlow + { + fatalError() + } +} diff --git a/Sources/OktaOAuth2/Deprecations/TokenExchangeFlow+Deprecations.swift b/Sources/OktaOAuth2/Deprecations/TokenExchangeFlow+Deprecations.swift new file mode 100644 index 000000000..e40fbfd5d --- /dev/null +++ b/Sources/OktaOAuth2/Deprecations/TokenExchangeFlow+Deprecations.swift @@ -0,0 +1,25 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") +// +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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. +// + +import Foundation + +extension TokenExchangeFlow { + @_documentation(visibility: private) + @available(*, deprecated, renamed: "init(issuerURL:clientId:scope:additionalParameters:)") + public convenience init(issuer: URL, + clientId: String, + scopes: String, + audience: Audience = .default) + { + fatalError() + } +} diff --git a/Sources/OktaOAuth2/Extensions/Authentication+Extensions.swift b/Sources/OktaOAuth2/Extensions/Authentication+Extensions.swift index e315f6bd7..a15d883ea 100644 --- a/Sources/OktaOAuth2/Extensions/Authentication+Extensions.swift +++ b/Sources/OktaOAuth2/Extensions/Authentication+Extensions.swift @@ -13,11 +13,17 @@ import Foundation extension AuthenticationDelegate { + @_documentation(visibility: internal) public func authenticationStarted(flow: Flow) {} + + @_documentation(visibility: internal) public func authenticationFinished(flow: Flow) {} } extension AuthorizationCodeFlowDelegate { + @_documentation(visibility: internal) public func authentication(flow: Flow, customizeUrl urlComponents: inout URLComponents) {} + + @_documentation(visibility: internal) public func authentication(flow: Flow, shouldAuthenticateUsing url: URL) {} } diff --git a/Sources/OktaOAuth2/Extensions/ErrorExtensions.swift b/Sources/OktaOAuth2/Extensions/ErrorExtensions.swift index 8b897823c..fe7b1584a 100644 --- a/Sources/OktaOAuth2/Extensions/ErrorExtensions.swift +++ b/Sources/OktaOAuth2/Extensions/ErrorExtensions.swift @@ -31,18 +31,6 @@ extension AuthorizationCodeFlow.RedirectError { } } -extension AuthenticationError: LocalizedError { - public var errorDescription: String? { - switch self { - case .flowNotReady: - return NSLocalizedString("flow_not_ready_description", - tableName: "OktaOAuth2", - bundle: .oktaOAuth2, - comment: "Invalid URL") - } - } -} - extension AuthorizationCodeFlow.RedirectError: LocalizedError { public var errorDescription: String? { switch self { diff --git a/Sources/OktaOAuth2/Internal/Enum+Extensions.swift b/Sources/OktaOAuth2/Internal/Enum+Extensions.swift index 97815c009..1374c66f4 100644 --- a/Sources/OktaOAuth2/Internal/Enum+Extensions.swift +++ b/Sources/OktaOAuth2/Internal/Enum+Extensions.swift @@ -23,3 +23,8 @@ extension GrantType { } } } + +extension AuthorizationCodeFlow.Prompt: APIRequestArgument { + @_documentation(visibility: internal) + public var stringValue: String { rawValue } +} diff --git a/Sources/OktaOAuth2/Internal/Extensions/AuthorizationCodeFlow+InternalExtensions.swift b/Sources/OktaOAuth2/Internal/Extensions/AuthorizationCodeFlow+InternalExtensions.swift new file mode 100644 index 000000000..068d231b9 --- /dev/null +++ b/Sources/OktaOAuth2/Internal/Extensions/AuthorizationCodeFlow+InternalExtensions.swift @@ -0,0 +1,45 @@ +// +// Copyright (c) 2021-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") +// +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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. +// + +import Foundation +import AuthFoundation + +extension AuthorizationCodeFlow { + func createAuthenticationURL(from authenticationUrl: URL, + using context: AuthorizationCodeFlow.Context) throws -> URL + { + guard client.configuration.redirectUri != nil else { + throw OAuth2Error.missingRedirectUri + } + + guard var components = URLComponents(url: authenticationUrl, resolvingAgainstBaseURL: true) + else { + throw OAuth2Error.invalidUrl + } + + var parameters = additionalParameters ?? [:] + parameters.merge(client.configuration.parameters(for: .authorization)) + parameters.merge(context.parameters(for: .authorization)) + + components.percentEncodedQuery = parameters + .mapValues(\.stringValue) + .percentQueryEncoded + + delegateCollection.invoke { $0.authentication(flow: self, customizeUrl: &components) } + + guard let url = components.url else { + throw OAuth2Error.cannotComposeUrl + } + + return url + } +} diff --git a/Sources/OktaOAuth2/Internal/Requests/AuthorizationCodeFlow+Extensions.swift b/Sources/OktaOAuth2/Internal/Requests/AuthorizationCodeFlow+Extensions.swift deleted file mode 100644 index 0eba2562b..000000000 --- a/Sources/OktaOAuth2/Internal/Requests/AuthorizationCodeFlow+Extensions.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// Copyright (c) 2021-Present, Okta, Inc. and/or its affiliates. All rights reserved. -// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") -// -// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. -// Unless required by applicable law or agreed to in writing, software -// distributed under the License 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. -// - -import Foundation -import AuthFoundation - -extension AuthorizationCodeFlow { - func authenticationUrlComponents(from authenticationUrl: URL, - using context: AuthorizationCodeFlow.Context, - additionalParameters: [String: APIRequestArgument]?) throws -> URLComponents - { - guard var components = URLComponents(url: authenticationUrl, resolvingAgainstBaseURL: true) - else { - throw OAuth2Error.invalidUrl - } - - components.percentEncodedQuery = queryParameters(using: context, - additionalParameters: additionalParameters).percentQueryEncoded - - return components - } - - private func queryParameters(using context: AuthorizationCodeFlow.Context, - additionalParameters: [String: APIRequestArgument]?) -> [String: String] - { - var parameters = self.additionalParameters ?? [:] - parameters.merge(additionalParameters) - - parameters["client_id"] = client.configuration.clientId - parameters["scope"] = client.configuration.scopes - parameters["redirect_uri"] = redirectUri.absoluteString - parameters["response_type"] = "code" - parameters["state"] = context.state - parameters["nonce"] = context.nonce - - if let pkce = context.pkce { - parameters["code_challenge"] = pkce.codeChallenge - parameters["code_challenge_method"] = pkce.method.rawValue - } - - return parameters.mapValues(\.stringValue) - } - - func createAuthenticationURL(from authenticationUrl: URL, - using context: AuthorizationCodeFlow.Context, - additionalParameters: [String: String]?) throws -> URL - { - var components = try authenticationUrlComponents(from: authenticationUrl, - using: context, - additionalParameters: additionalParameters) - delegateCollection.invoke { $0.authentication(flow: self, customizeUrl: &components) } - - guard let url = components.url else { - throw OAuth2Error.cannotComposeUrl - } - - return url - } -} diff --git a/Sources/OktaOAuth2/Internal/Requests/AuthorizationCodeFlow+Requests.swift b/Sources/OktaOAuth2/Internal/Requests/AuthorizationCodeFlow+Requests.swift index 83f367be6..69f5687ad 100644 --- a/Sources/OktaOAuth2/Internal/Requests/AuthorizationCodeFlow+Requests.swift +++ b/Sources/OktaOAuth2/Internal/Requests/AuthorizationCodeFlow+Requests.swift @@ -14,58 +14,50 @@ import Foundation import AuthFoundation extension AuthorizationCodeFlow { - struct TokenRequest { + struct TokenRequest: OAuth2TokenRequest, AuthenticationFlowRequest { + typealias ResponseType = Token + typealias Flow = AuthorizationCodeFlow + let openIdConfiguration: OpenIdConfiguration let clientConfiguration: OAuth2Client.Configuration - let redirectUri: String - let grantType: GrantType - let grantValue: String - let pkce: PKCE? - let nonce: String? - let maxAge: TimeInterval? - let authenticationFlowConfiguration: (any AuthFoundation.AuthenticationFlowConfiguration)? + let additionalParameters: [String: any APIRequestArgument]? + let context: Flow.Context + let redirectUri: URL + let authorizationCode: String + + init(openIdConfiguration: OpenIdConfiguration, + clientConfiguration: OAuth2Client.Configuration, + additionalParameters: [String: any APIRequestArgument]?, + context: Context, + authorizationCode: String) throws + { + guard let redirectUri = clientConfiguration.redirectUri else { + throw OAuth2Error.missingRedirectUri + } + + self.openIdConfiguration = openIdConfiguration + self.clientConfiguration = clientConfiguration + self.additionalParameters = additionalParameters + self.context = context + self.authorizationCode = authorizationCode + self.redirectUri = redirectUri + } } } -extension AuthorizationCodeFlow.TokenRequest: OAuth2TokenRequest { - var clientId: String { clientConfiguration.clientId } -} - -extension AuthorizationCodeFlow.TokenRequest: OAuth2APIRequest {} - -extension AuthorizationCodeFlow.TokenRequest: APIRequestBody { +extension AuthorizationCodeFlow.TokenRequest { + var category: AuthFoundation.OAuth2APIRequestCategory { .token } var bodyParameters: [String: APIRequestArgument]? { - var result: [String: APIRequestArgument] = [ - "client_id": clientConfiguration.clientId, - "redirect_uri": redirectUri, - "grant_type": grantType, - grantType.responseKey: grantValue - ] - - if let pkce = pkce { - result["code_verifier"] = pkce.codeVerifier - } - - if let additional = clientConfiguration.authentication.additionalParameters { - result.merge(additional, uniquingKeysWith: { $1 }) - } - - result.merge(authenticationFlowConfiguration) + var result = additionalParameters ?? [:] + result.merge(clientConfiguration.parameters(for: category)) + result.merge(context.parameters(for: category)) + result.merge([ + "grant_type": GrantType.authorizationCode, + GrantType.authorizationCode.responseKey: authorizationCode, + ]) return result } } -extension AuthorizationCodeFlow.TokenRequest: APIParsingContext { - var codingUserInfo: [CodingUserInfoKey: Any]? { - [ - .clientSettings: [ - "client_id": clientConfiguration.clientId, - "redirect_uri": redirectUri, - "scope": clientConfiguration.scopes - ] - ] - } -} - -extension AuthorizationCodeFlow.TokenRequest: IDTokenValidatorContext {} +extension AuthorizationCodeFlow.TokenRequest: APIParsingContext {} diff --git a/Sources/OktaOAuth2/Internal/Requests/DeviceAuthorizeFlow+Requests.swift b/Sources/OktaOAuth2/Internal/Requests/DeviceAuthorizeFlow+Requests.swift index 3afa670f5..4e6be8352 100644 --- a/Sources/OktaOAuth2/Internal/Requests/DeviceAuthorizeFlow+Requests.swift +++ b/Sources/OktaOAuth2/Internal/Requests/DeviceAuthorizeFlow+Requests.swift @@ -14,48 +14,50 @@ import Foundation import AuthFoundation extension DeviceAuthorizationFlow { - struct TokenRequest { + struct TokenRequest: AuthenticationFlowRequest { + typealias Flow = DeviceAuthorizationFlow + let openIdConfiguration: OpenIdConfiguration - let clientId: String + let clientConfiguration: OAuth2Client.Configuration + let additionalParameters: [String: any APIRequestArgument]? + let context: Flow.Context let deviceCode: String - let authenticationFlowConfiguration: (any AuthFoundation.AuthenticationFlowConfiguration)? } - struct AuthorizeRequest { + struct AuthorizeRequest: AuthenticationFlowRequest { + typealias Flow = DeviceAuthorizationFlow + let url: URL - let clientId: String - let scope: String + let clientConfiguration: OAuth2Client.Configuration + let additionalParameters: [String: any APIRequestArgument]? + let context: Flow.Context } } extension DeviceAuthorizationFlow.AuthorizeRequest: APIRequest, APIRequestBody { - typealias ResponseType = DeviceAuthorizationFlow.Context + typealias ResponseType = DeviceAuthorizationFlow.Verification var httpMethod: APIRequestMethod { .post } var contentType: APIContentType? { .formEncoded } var acceptsType: APIContentType? { .json } + var category: OAuth2APIRequestCategory { .authorization } var bodyParameters: [String: APIRequestArgument]? { - [ - "client_id": clientId, - "scope": scope - ] + var result = additionalParameters ?? [:] + result.merge(clientConfiguration.parameters(for: category)) + result.merge(context.parameters(for: category)) + return result } } extension DeviceAuthorizationFlow.TokenRequest: OAuth2TokenRequest, OAuth2APIRequest, APIRequestBody, APIParsingContext { + var category: OAuth2APIRequestCategory { .token } var bodyParameters: [String: APIRequestArgument]? { - [ - "client_id": clientId, - "device_code": deviceCode, - "grant_type": GrantType.deviceCode, - ].merging(authenticationFlowConfiguration) - } - - var codingUserInfo: [CodingUserInfoKey: Any]? { - [ - .clientSettings: [ - "client_id": clientId - ] - ] + var result = additionalParameters ?? [:] + result.merge(clientConfiguration.parameters(for: category)) + result.merge(context.parameters(for: category)) + result.merge([ + "device_code": deviceCode + ]) + return result } } diff --git a/Sources/OktaOAuth2/Requests/JWTAuthorizationFlow+Requests.swift b/Sources/OktaOAuth2/Internal/Requests/JWTAuthorizationFlow+Requests.swift similarity index 65% rename from Sources/OktaOAuth2/Requests/JWTAuthorizationFlow+Requests.swift rename to Sources/OktaOAuth2/Internal/Requests/JWTAuthorizationFlow+Requests.swift index 2d4ea35e0..ccd66830b 100644 --- a/Sources/OktaOAuth2/Requests/JWTAuthorizationFlow+Requests.swift +++ b/Sources/OktaOAuth2/Internal/Requests/JWTAuthorizationFlow+Requests.swift @@ -14,31 +14,28 @@ import Foundation import AuthFoundation extension JWTAuthorizationFlow { - struct TokenRequest { + struct TokenRequest: AuthenticationFlowRequest { + typealias Flow = JWTAuthorizationFlow + let openIdConfiguration: OpenIdConfiguration - let clientId: String - let scope: String + let clientConfiguration: OAuth2Client.Configuration + let additionalParameters: [String: any APIRequestArgument]? + let context: Flow.Context let assertion: JWT - let authenticationFlowConfiguration: (any AuthFoundation.AuthenticationFlowConfiguration)? } } extension JWTAuthorizationFlow.TokenRequest: OAuth2TokenRequest, OAuth2APIRequest, APIRequestBody, APIParsingContext { + var category: OAuth2APIRequestCategory { .token } var bodyParameters: [String: APIRequestArgument]? { - [ - "client_id": clientId, - "scope": scope, - "grant_type": GrantType.jwtBearer, + var result = additionalParameters ?? [:] + result.merge(clientConfiguration.parameters(for: category)) + result.merge(context.parameters(for: category)) + result.merge([ "assertion": assertion, - ].merging(authenticationFlowConfiguration) - } - - var codingUserInfo: [CodingUserInfoKey: Any]? { - [ - .clientSettings: [ - "client_id": clientId, - "scope": scope, - ] - ] + "grant_type": GrantType.jwtBearer, + ]) + return result + } } diff --git a/Sources/OktaOAuth2/Internal/Requests/ResourceOwnerFlow+Requests.swift b/Sources/OktaOAuth2/Internal/Requests/ResourceOwnerFlow+Requests.swift index bbb7be6a0..ff6f15cfc 100644 --- a/Sources/OktaOAuth2/Internal/Requests/ResourceOwnerFlow+Requests.swift +++ b/Sources/OktaOAuth2/Internal/Requests/ResourceOwnerFlow+Requests.swift @@ -14,33 +14,30 @@ import Foundation import AuthFoundation extension ResourceOwnerFlow { - struct TokenRequest { + struct TokenRequest: AuthenticationFlowRequest { + typealias Flow = ResourceOwnerFlow + let openIdConfiguration: OpenIdConfiguration - let clientId: String - let scope: String + let clientConfiguration: OAuth2Client.Configuration + let additionalParameters: [String: APIRequestArgument]? + let context: Flow.Context let username: String let password: String - let authenticationFlowConfiguration: (any AuthFoundation.AuthenticationFlowConfiguration)? } } extension ResourceOwnerFlow.TokenRequest: OAuth2TokenRequest, OAuth2APIRequest, APIRequestBody, APIParsingContext { + var category: AuthFoundation.OAuth2APIRequestCategory { .token } + var bodyParameters: [String: APIRequestArgument]? { - [ - "client_id": clientId, - "scope": scope, + var result = additionalParameters ?? [:] + result.merge(clientConfiguration.parameters(for: category)) + result.merge(context.parameters(for: category)) + result.merge([ "grant_type": GrantType.password, "username": username, - "password": password - ].merging(authenticationFlowConfiguration) - } - - var codingUserInfo: [CodingUserInfoKey: Any]? { - [ - .clientSettings: [ - "client_id": clientId, - "scope": scope - ] - ] + "password": password, + ]) + return result } } diff --git a/Sources/OktaOAuth2/Requests/TokenExchangeFlow+Requests.swift b/Sources/OktaOAuth2/Internal/Requests/TokenExchangeFlow+Requests.swift similarity index 83% rename from Sources/OktaOAuth2/Requests/TokenExchangeFlow+Requests.swift rename to Sources/OktaOAuth2/Internal/Requests/TokenExchangeFlow+Requests.swift index 67967b0c4..b320bf4a5 100644 --- a/Sources/OktaOAuth2/Requests/TokenExchangeFlow+Requests.swift +++ b/Sources/OktaOAuth2/Internal/Requests/TokenExchangeFlow+Requests.swift @@ -62,37 +62,33 @@ extension TokenExchangeFlow { } extension TokenExchangeFlow { - struct TokenRequest { + struct TokenRequest: AuthenticationFlowRequest { + typealias Flow = TokenExchangeFlow + let openIdConfiguration: OpenIdConfiguration - let clientId: String + let clientConfiguration: OAuth2Client.Configuration + let additionalParameters: [String: APIRequestArgument]? + let context: Flow.Context let tokens: [TokenType] - let scope: String - let audience: String - let grantType = GrantType.tokenExchange - let authenticationFlowConfiguration: (any AuthFoundation.AuthenticationFlowConfiguration)? } } -extension TokenExchangeFlow.TokenRequest: OAuth2TokenRequest, OAuth2APIRequest, APIRequestBody { +extension TokenExchangeFlow.TokenRequest: OAuth2TokenRequest, OAuth2APIRequest, APIRequestBody, APIParsingContext { var httpMethod: APIRequestMethod { .post } var url: URL { openIdConfiguration.tokenEndpoint } var contentType: APIContentType? { .formEncoded } var acceptsType: APIContentType? { .json } + var category: OAuth2APIRequestCategory { .token } var bodyParameters: [String: APIRequestArgument]? { - var result: [String: APIRequestArgument] = [ - "client_id": clientId, - "grant_type": grantType.rawValue, - "scope": scope, - "audience": audience - ] - - result.merge(authenticationFlowConfiguration) - + var result = additionalParameters ?? [:] + result.merge(clientConfiguration.parameters(for: category)) + result.merge(context.parameters(for: category)) + for token in tokens { result[token.key] = token.value result[token.keyType] = token.urn } - + return result } } diff --git a/Sources/OktaOAuth2/Logout/SessionLogoutFlow+Context.swift b/Sources/OktaOAuth2/Logout/SessionLogoutFlow+Context.swift new file mode 100644 index 000000000..9711e1079 --- /dev/null +++ b/Sources/OktaOAuth2/Logout/SessionLogoutFlow+Context.swift @@ -0,0 +1,81 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") +// +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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. +// + +import Foundation + +extension SessionLogoutFlow { + /// A model representing the context and current state for a logout session. + public struct Context: Equatable { + /// The state string to use when creating an logout URL. + public let state: String + + /// The ID token hint to use when signing out. + public var idToken: String? { + didSet { + logoutURL = nil + } + } + + /// A hint about the identifier used to log out (analogous to the ``AuthorizationCodeFlow/Context-swift.struct/loginHint`` parameter). + public var logoutHint: String? { + didSet { + logoutURL = nil + } + } + + /// Any additional query string parameters you would like to supply to the authorization server. + public var additionalParameters: [String: any APIRequestArgument]? { + didSet { + logoutURL = nil + } + } + + /// The current logout URL, or `nil` if one has not yet been generated. + public internal(set) var logoutURL: URL? + + /// Initializer for creating a context. + /// - Parameters: + /// - state: State string to use, or `nil` to accept an automatically generated default. + public init(idToken: String? = nil, + logoutHint: String? = nil, + state: String? = nil, + additionalParameters: [String: any APIRequestArgument]? = nil) + { + self.idToken = idToken + self.logoutHint = logoutHint + self.state = state ?? UUID().uuidString + self.additionalParameters = additionalParameters + } + + /// Initializer for creating a context. + /// - Parameters: + /// - state: State string to use, or `nil` to accept an automatically generated default. + public init(token: Token, + logoutHint: String? = nil, + state: String? = nil, + additionalParameters: [String: any APIRequestArgument]? = nil) + { + self.init(idToken: token.idToken?.rawValue, + logoutHint: logoutHint, + state: state, + additionalParameters: additionalParameters) + } + + @_documentation(visibility: internal) + public static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.state == rhs.state + && lhs.idToken == rhs.idToken + && lhs.logoutHint == rhs.logoutHint + && lhs.additionalParameters?.mapValues(\.stringValue) == rhs.additionalParameters?.mapValues(\.stringValue) + } + } +} diff --git a/Sources/OktaOAuth2/Logout/SessionLogoutFlow.swift b/Sources/OktaOAuth2/Logout/SessionLogoutFlow.swift index 945fc7777..c76991f70 100644 --- a/Sources/OktaOAuth2/Logout/SessionLogoutFlow.swift +++ b/Sources/OktaOAuth2/Logout/SessionLogoutFlow.swift @@ -54,34 +54,10 @@ public protocol SessionLogoutFlowDelegate: LogoutFlowDelegate { /// // Create the logout URL. Open this in a browser. /// let authorizeUrl = try await flow.start() /// ``` -public class SessionLogoutFlow: LogoutFlow, ProvidesOAuth2Parameters { - /// A model representing the context and current state for a logout session. - public struct Context: Codable, Equatable { - /// The ID token string used for log-out. - public let idToken: String - - /// The state string to use when creating an logout URL. - public let state: String - - /// The current logout URL, or `nil` if one has not yet been generated. - public internal(set) var logoutURL: URL? - - /// Initializer for creating a context. - /// - Parameters: - /// - idToken: The ID token string used for log-out. - /// - state: State string to use, or `nil` to accept an automatically generated default. - public init(idToken: String, state: String? = nil) { - self.idToken = idToken - self.state = state ?? UUID().uuidString - } - } - +public class SessionLogoutFlow: LogoutFlow { /// The OAuth2Client this logout flow will use. public let client: OAuth2Client - /// The logout redirect URI. - public let logoutRedirectUri: URL - /// Any additional query string parameters you would like to supply to the authorization server. public let additionalParameters: [String: APIRequestArgument]? @@ -101,40 +77,34 @@ public class SessionLogoutFlow: LogoutFlow, ProvidesOAuth2Parameters { /// Convenience initializer to construct a logout flow. /// - Parameters: - /// - issuer: The issuer URL. + /// - issuerURL: The issuer URL. /// - clientId: The client ID. - /// - scopes: The client's scopes. + /// - scope: The client's scopes. /// - logoutRedirectUri: The logout redirect URI. - public convenience init?(issuer: URL, - clientId: String, - scopes: String, - logoutRedirectUri: URL?, - additionalParameters: [String: String]? = nil) + public convenience init(issuerURL: URL, + clientId: String, + scope: String, + logoutRedirectUri: URL? = nil, + additionalParameters: [String: String]? = nil) { - guard let logoutRedirectUri = logoutRedirectUri else { - return nil - } - - self.init(logoutRedirectUri: logoutRedirectUri, - additionalParameters: additionalParameters, - client: OAuth2Client(baseURL: issuer, - clientId: clientId, - scopes: scopes)) + self.init(client: .init(issuerURL: issuerURL, + clientId: clientId, + scope: scope, + logoutRedirectUri: logoutRedirectUri), + additionalParameters: additionalParameters) } - + /// Initializer to construct a logout flow from a pre-defined client. /// - Parameters: - /// - logoutRedirectUri: The logout redirect URI. /// - client: The `OAuth2Client` to use with this flow. - public init(logoutRedirectUri: URL, - additionalParameters: [String: APIRequestArgument]? = nil, - client: OAuth2Client) + /// - additionalParameters: Optional additional query string parameters you would like to supply to the authorization server. + public required init(client: OAuth2Client, + additionalParameters: [String: any APIRequestArgument]? = nil) { // Ensure this SDK's static version is included in the user agent. SDKVersion.register(sdk: Version) self.client = client - self.logoutRedirectUri = logoutRedirectUri self.additionalParameters = additionalParameters client.add(delegate: self) @@ -145,42 +115,21 @@ public class SessionLogoutFlow: LogoutFlow, ProvidesOAuth2Parameters { /// This method is used to begin a logout session. It is asynchronous, and will invoke the appropriate delegate methods when a response is received. /// - Parameters: /// - idToken: The ID token string. - /// - additionalParameters: Optional parameters to add to the authorization URL query string. - /// - completion: Optional completion block for receiving the response. If `nil`, you may rely upon the appropriate delegate API methods. - public func start(idToken: String, - additionalParameters: [String: String]? = nil, - completion: @escaping (Result) -> Void) throws - { - try start(with: Context(idToken: idToken), - additionalParameters: additionalParameters, - completion: completion) - } - - /// Initiates an logout flow, with a required ``Context-swift.struct`` object. - /// - /// This method is used to begin a logout session. It is asynchronous, and will invoke the appropriate delegate methods when a response is received. - /// - Parameters: /// - context: Represents current state for a logout session. - /// - additionalParameters: Optional parameters to add to the authorization URL query string. /// - completion: Optional completion block for receiving the response. If `nil`, you may rely upon the appropriate delegate API methods. - public func start(with context: Context, - additionalParameters: [String: String]? = nil, - completion: @escaping (Result) -> Void) throws + public func start(with context: Context = .init(), + completion: @escaping (Result) -> Void) { - guard !inProgress else { - completion(.failure(.missingClientConfiguration)) - return - } - + self.context = context inProgress = true client.openIdConfiguration { result in - defer { self.reset() } - switch result { case .failure(let error): self.delegateCollection.invoke { $0.logout(flow: self, received: error) } completion(.failure(error)) + self.reset() + case .success(let configuration): do { guard let endSessionEndpoint = configuration.endSessionEndpoint else { @@ -188,17 +137,18 @@ public class SessionLogoutFlow: LogoutFlow, ProvidesOAuth2Parameters { } let url = try self.createLogoutURL(from: endSessionEndpoint, - using: context, - additionalParameters: additionalParameters) + context: context) var context = context context.logoutURL = url self.context = context completion(.success(url)) + self.reset() } catch { let oauthError = error as? OAuth2Error ?? .error(error) self.delegateCollection.invoke { $0.logout(flow: self, received: oauthError) } completion(.failure(oauthError)) + self.reset() } } } @@ -220,20 +170,6 @@ public class SessionLogoutFlow: LogoutFlow, ProvidesOAuth2Parameters { @available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6, *) extension SessionLogoutFlow { - /// Asynchronously initiates a logout flow, with a required ID Token. - /// - /// This method is used to begin a logout session. The method will invoke the appropriate delegate methods when a response is received. - /// - Parameters: - /// - idToken: The ID token string. - /// - additionalParameters: Optional parameters to add to the authorization URL query string. - /// - Returns: The URL a user should be presented with within a broser, to befing a logout flow. - public func start(idToken: String, - additionalParameters: [String: String]? = nil) async throws -> URL - { - try await start(with: .init(idToken: idToken), - additionalParameters: additionalParameters) - } - /// Initiates an logout flow, with a required ``Context-swift.struct`` object. /// /// This method is used to begin a logout session. The method will invoke the appropriate delegate methods when a response is received. @@ -241,18 +177,11 @@ extension SessionLogoutFlow { /// - context: Represents current state for a logout session. /// - additionalParameters: Optional parameters to add to the authorization URL query string. /// - Returns: The URL a user should be presented with within a broser, to befing a logout flow. - public func start(with context: Context, - additionalParameters: [String: String]? = nil) async throws -> URL + public func start(with context: Context = .init()) async throws -> URL { try await withCheckedThrowingContinuation { continuation in - do { - try start(with: context, additionalParameters: additionalParameters) { result in - continuation.resume(with: result) - } - } catch let error as APIClientError { - continuation.resume(with: .failure(error)) - } catch { - continuation.resume(with: .failure(APIClientError.serverError(error))) + start(with: context) { result in + continuation.resume(with: result) } } } @@ -266,47 +195,43 @@ extension SessionLogoutFlow: OAuth2ClientDelegate { } private extension SessionLogoutFlow { - func logoutUrlComponents(from logoutUrl: URL, - using context: SessionLogoutFlow.Context, - additionalParameters: [String: String]?) throws -> URLComponents + func createLogoutURL(from endSessionEndpoint: URL, + context: SessionLogoutFlow.Context) throws -> URL { - guard var components = URLComponents(url: logoutUrl, resolvingAgainstBaseURL: true) + guard var components = URLComponents(url: endSessionEndpoint, resolvingAgainstBaseURL: true) else { throw OAuth2Error.invalidUrl } - - components.percentEncodedQuery = queryParameters(using: context, additionalParameters: additionalParameters).percentQueryEncoded - return components - } - - func queryParameters(using context: SessionLogoutFlow.Context, - additionalParameters: [String: APIRequestArgument]?) -> [String: String] - { var result = self.additionalParameters ?? [:] - result.merge(additionalParameters) + result.merge(context.additionalParameters) - result["id_token_hint"] = context.idToken - result["post_logout_redirect_uri"] = logoutRedirectUri.absoluteString + result["client_id"] = client.configuration.clientId result["state"] = context.state + + if let idToken = context.idToken { + result["id_token_hint"] = idToken + } + + if let logoutHint = context.logoutHint { + result["logout_hint"] = logoutHint + } + + if let logoutRedirectUri = client.configuration.logoutRedirectUri { + result["post_logout_redirect_uri"] = logoutRedirectUri.absoluteString + } // If requesting a login prompt, the post_logout_redirect_uri should be omitted. - if let prompt = additionalParameters?["prompt"] as? String, + if let prompt = result["prompt"] as? String, ["login", "consent", "login consent", "consent login"].contains(prompt.lowercased()) { result.removeValue(forKey: "post_logout_redirect_uri") } - - return result.mapValues(\.stringValue) - } - func createLogoutURL(from url: URL, - using context: SessionLogoutFlow.Context, - additionalParameters: [String: String]?) throws -> URL - { - var components = try logoutUrlComponents(from: url, - using: context, - additionalParameters: additionalParameters) + components.percentEncodedQuery = result + .mapValues(\.stringValue) + .percentQueryEncoded + delegateCollection.invoke { $0.logout(flow: self, customizeUrl: &components) } guard let url = components.url else { @@ -321,7 +246,9 @@ extension OAuth2Client { /// Creates a new session logout flow for this redirect URI. /// - Parameter logoutRedirectUri: Logout redirect URI to use /// - Returns: ``SessionLogoutFlow`` to log out of this client. - public func sessionLogoutFlow(logoutRedirectUri: URL) -> SessionLogoutFlow { - SessionLogoutFlow(logoutRedirectUri: logoutRedirectUri, client: self) + public func sessionLogoutFlow(additionalParameters: [String: APIRequestArgument]? = nil) -> SessionLogoutFlow + { + SessionLogoutFlow(client: self, + additionalParameters: additionalParameters) } } diff --git a/Sources/OktaOAuth2/Resources/en.lproj/OktaOAuth2.strings b/Sources/OktaOAuth2/Resources/en.lproj/OktaOAuth2.strings index c87e2dc96..687a6b1be 100644 --- a/Sources/OktaOAuth2/Resources/en.lproj/OktaOAuth2.strings +++ b/Sources/OktaOAuth2/Resources/en.lproj/OktaOAuth2.strings @@ -1,7 +1,4 @@ -/* AuthenticationError */ -"flow_not_ready_description" = "The authentication flow is not ready."; - /* AuthorizationCodeFlow.RedirectError */ "invalid_redirect_url_description" = "Invalid authorization redirect URL."; "unexpected_scheme_description" = "Received an unexpected callback scheme."; diff --git a/Sources/WebAuthenticationUI/Extensions/WebAuthentication+Deprecated.swift b/Sources/WebAuthenticationUI/Extensions/WebAuthentication+Deprecated.swift index 0abdba03b..1cec4ed7c 100644 --- a/Sources/WebAuthenticationUI/Extensions/WebAuthentication+Deprecated.swift +++ b/Sources/WebAuthenticationUI/Extensions/WebAuthentication+Deprecated.swift @@ -15,117 +15,6 @@ import Foundation // TODO: Remove on the next major release. #if canImport(UIKit) || canImport(AppKit) extension WebAuthentication { - @available(*, deprecated, renamed: "signIn(from:options:completion:)") - public final func signIn(from window: WindowAnchor?, - additionalParameters: [String: String]?, - completion: @escaping (Result) -> Void) - { - signIn(from: window, options: options(from: additionalParameters), completion: completion) - } - @available(*, deprecated, renamed: "signOut(from:credential:options:completion:)") - public final func signOut(from window: WindowAnchor? = nil, - credential: Credential? = .default, - additionalParameters: [String: String]?, - completion: @escaping (Result) -> Void) - { - signOut(from: window, credential: credential, options: options(from: additionalParameters), completion: completion) - } - - @available(*, deprecated, renamed: "signOut(from:token:options:completion:)") - public final func signOut(from window: WindowAnchor? = nil, - token: Token, - additionalParameters: [String: String]?, - completion: @escaping (Result) -> Void) - { - signOut(from: window, token: token, options: options(from: additionalParameters), completion: completion) - } - - @available(*, deprecated, renamed: "signOut(from:token:options:completion:)") - public final func signOut(from window: WindowAnchor? = nil, - token: String, - additionalParameters: [String: String]?, - completion: @escaping (Result) -> Void) - { - signOut(from: window, token: token, options: options(from: additionalParameters), completion: completion) - } - - fileprivate func options(from additionalParameters: [String: String]?) -> [WebAuthentication.Option]? { - return additionalParameters?.reduce(into: [WebAuthentication.Option]()) { (result, item) in - switch item.key { - case "login_hint": - result.append(.login(hint: item.value)) - case "display": - result.append(.display(item.value)) - case "idp": - guard let url = URL(string: item.value) else { return } - result.append(.idp(url: url)) - case "idp_scope": - result.append(.idpScope(item.value)) - case "prompt": - let prompt: WebAuthentication.Option.Prompt - switch item.value { - case "none": - prompt = .none - case "consent": - prompt = .consent - case "login": - prompt = .login - case "login consent", "consent login": - prompt = .loginAndConsent - default: - return - } - result.append(.prompt(prompt)) - case "max_age": - let age: TimeInterval - if #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) { - guard let value = try? TimeInterval(item.value, format: .number) else { return } - age = value - } else { - guard let value = Double(item.value) else { return } - age = TimeInterval(value) - } - - result.append(.maxAge(age)) - default: - result.append(.custom(key: item.key, value: item.value)) - } - } - } } - -@available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6, *) -extension WebAuthentication { - @available(*, deprecated, renamed: "signIn(from:options:)") - public final func signIn(from window: WindowAnchor?, - additionalParameters: [String: String]?) async throws -> Token - { - try await signIn(from: window, options: options(from: additionalParameters)) - } - - @available(*, deprecated, renamed: "signOut(from:credential:options:)") - public final func signOut(from window: WindowAnchor?, - credential: Credential? = .default, - additionalParameters: [String: String]?) async throws - { - try await signOut(from: window, credential: credential, options: options(from: additionalParameters)) - } - - @available(*, deprecated, renamed: "signOut(from:token:options:)") - public final func signOut(from window: WindowAnchor?, - token: Token, - additionalParameters: [String: String]?) async throws - { - try await signOut(from: window, token: token, options: options(from: additionalParameters)) - } - - @available(*, deprecated, renamed: "signOut(from:token:options:)") - public final func signOut(from window: WindowAnchor?, - token: String, - additionalParameters: [String: String]?) async throws - { - try await signOut(from: window, token: token, options: options(from: additionalParameters)) - } -} -#endif \ No newline at end of file +#endif diff --git a/Sources/WebAuthenticationUI/Internal/Options+Extensions.swift b/Sources/WebAuthenticationUI/Internal/Options+Extensions.swift deleted file mode 100644 index 630db255e..000000000 --- a/Sources/WebAuthenticationUI/Internal/Options+Extensions.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// Copyright (c) 2022-Present, Okta, Inc. and/or its affiliates. All rights reserved. -// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") -// -// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. -// Unless required by applicable law or agreed to in writing, software -// distributed under the License 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. -// - -import Foundation -import OktaOAuth2 - -#if canImport(UIKit) || canImport(AppKit) -extension WebAuthentication.Option { - var queryItems: [String: String] { - switch self { - case .login(hint: let username): - return ["login_hint": username] - case .display(let value): - return ["display": value] - case .idp(url: let url): - return ["idp": url.absoluteString] - case .idpScope(let scope): - return ["idp_scope": scope] - case .prompt(let value): - return ["prompt": value.rawValue] - case .custom(key: let key, value: let value): - return [key: value] - case .maxAge, .state: - return [:] - } - } -} - -extension Collection where Element == WebAuthentication.Option { - var additionalParameters: [String: String] { - map(\.queryItems) - .reduce([:]) { $0.merging($1) { current, _ in current } } - } - - var state: String? { - guard case let .state(state) = filter({ - switch $0 { - case .state: - return true - default: - return false - } - }).first else { - return nil - } - - return state - } - - var maxAge: TimeInterval? { - guard case let .maxAge(result) = filter({ - switch $0 { - case .maxAge: - return true - default: - return false - } - }).first else { - return nil - } - - return result - } - - var context: AuthorizationCodeFlow.Context? { - let state = self.state - let maxAge = self.maxAge - - guard state != nil || maxAge != nil else { - return nil - } - - return .init(state: state, maxAge: maxAge) - } -} -#endif \ No newline at end of file diff --git a/Sources/WebAuthenticationUI/Internal/WebAuthenticationError+Extensions.swift b/Sources/WebAuthenticationUI/Internal/WebAuthenticationError+Extensions.swift index 8b5b7d4f9..ad2f4eb25 100644 --- a/Sources/WebAuthenticationUI/Internal/WebAuthenticationError+Extensions.swift +++ b/Sources/WebAuthenticationUI/Internal/WebAuthenticationError+Extensions.swift @@ -15,6 +15,16 @@ import Foundation #if canImport(UIKit) || canImport(AppKit) extension WebAuthenticationError: LocalizedError { + init(_ error: Error) { + if let error = error as? OAuth2Error { + self = .oauth2(error: error) + } else if let error = error as? OAuth2ServerError { + self = .serverError(error) + } else { + self = .generic(error: error) + } + } + public var errorDescription: String? { switch self { case .noCompatibleAuthenticationProviders: diff --git a/Sources/WebAuthenticationUI/Providers/AuthenticationServicesProvider.swift b/Sources/WebAuthenticationUI/Providers/AuthenticationServicesProvider.swift index 361c98bc8..8dfe079e8 100644 --- a/Sources/WebAuthenticationUI/Providers/AuthenticationServicesProvider.swift +++ b/Sources/WebAuthenticationUI/Providers/AuthenticationServicesProvider.swift @@ -38,6 +38,8 @@ extension ASWebAuthenticationSession: AuthenticationServicesProviderSession {} class AuthenticationServicesProvider: NSObject, WebAuthenticationProvider { let loginFlow: AuthorizationCodeFlow let logoutFlow: SessionLogoutFlow? + private let loginRedirectURI: URL + private let logoutRedirectURI: URL? private(set) weak var delegate: WebAuthenticationProviderDelegate? private(set) var authenticationSession: AuthenticationServicesProviderSession? @@ -46,13 +48,24 @@ class AuthenticationServicesProvider: NSObject, WebAuthenticationProvider { init(loginFlow: AuthorizationCodeFlow, logoutFlow: SessionLogoutFlow?, from window: WebAuthentication.WindowAnchor?, - delegate: WebAuthenticationProviderDelegate) + delegate: WebAuthenticationProviderDelegate) throws { self.loginFlow = loginFlow self.logoutFlow = logoutFlow self.anchor = window self.delegate = delegate + guard let loginRedirectURI = loginFlow.client.configuration.redirectUri else { + throw OAuth2Error.missingRedirectUri + } + self.loginRedirectURI = loginRedirectURI + + if let logoutFlow = logoutFlow { + self.logoutRedirectURI = logoutFlow.client.configuration.logoutRedirectUri + } else { + self.logoutRedirectURI = nil + } + super.init() self.loginFlow.add(delegate: self) @@ -64,8 +77,8 @@ class AuthenticationServicesProvider: NSObject, WebAuthenticationProvider { self.logoutFlow?.remove(delegate: self) } - func start(context: AuthorizationCodeFlow.Context?, additionalParameters: [String: String]?) { - loginFlow.start(with: context, additionalParameters: additionalParameters) { _ in } + func start(context: AuthorizationCodeFlow.Context) { + loginFlow.start(with: context) { _ in } } func createSession(url: URL, callbackURLScheme: String?, completionHandler: @escaping ASWebAuthenticationSession.CompletionHandler) -> AuthenticationServicesProviderSession { @@ -79,7 +92,7 @@ class AuthenticationServicesProvider: NSObject, WebAuthenticationProvider { authenticationSession = createSession( url: url, - callbackURLScheme: loginFlow.redirectUri.scheme, + callbackURLScheme: loginRedirectURI.scheme, completionHandler: { url, error in self.process(url: url, error: error) }) @@ -94,22 +107,22 @@ class AuthenticationServicesProvider: NSObject, WebAuthenticationProvider { } } - func logout(context: SessionLogoutFlow.Context, additionalParameters: [String: String]?) { + func logout(context: SessionLogoutFlow.Context) { guard let logoutFlow = logoutFlow else { return } // LogoutFlow invokes delegate, so an error is propagated from delegate method - try? logoutFlow.start(with: context, additionalParameters: additionalParameters) { _ in } + logoutFlow.start(with: context) { _ in } } func logout(using url: URL) { - guard let logoutFlow = logoutFlow else { + guard let logoutRedirectURI = logoutRedirectURI else { return } authenticationSession = createSession(url: url, - callbackURLScheme: logoutFlow.logoutRedirectUri.scheme, + callbackURLScheme: logoutRedirectURI.scheme, completionHandler: { url, error in self.processLogout(url: url, error: error) }) @@ -159,7 +172,7 @@ class AuthenticationServicesProvider: NSObject, WebAuthenticationProvider { { received(error: .userCancelledLogin) } else if let url = url, - let serverError = try? url.oauth2ServerError(redirectUri: loginFlow.redirectUri) + let serverError = try? url.oauth2ServerError(redirectUri: loginRedirectURI) { received(error: .serverError(serverError)) } else { @@ -192,7 +205,7 @@ class AuthenticationServicesProvider: NSObject, WebAuthenticationProvider { { received(logoutError: .userCancelledLogin) } else if let url = url, - let serverError = try? url.oauth2ServerError(redirectUri: logoutFlow?.logoutRedirectUri) + let serverError = try? url.oauth2ServerError(redirectUri: logoutRedirectURI) { received(logoutError: .serverError(serverError)) } else { diff --git a/Sources/WebAuthenticationUI/Providers/WebAuthenticationProvider.swift b/Sources/WebAuthenticationUI/Providers/WebAuthenticationProvider.swift index ce9c4bb2c..6b85bdd15 100644 --- a/Sources/WebAuthenticationUI/Providers/WebAuthenticationProvider.swift +++ b/Sources/WebAuthenticationUI/Providers/WebAuthenticationProvider.swift @@ -20,8 +20,8 @@ protocol WebAuthenticationProvider { var logoutFlow: SessionLogoutFlow? { get } var delegate: WebAuthenticationProviderDelegate? { get } - func start(context: AuthorizationCodeFlow.Context?, additionalParameters: [String: String]?) - func logout(context: SessionLogoutFlow.Context, additionalParameters: [String: String]?) + func start(context: AuthorizationCodeFlow.Context) + func logout(context: SessionLogoutFlow.Context) func cancel() } diff --git a/Sources/WebAuthenticationUI/WebAuthentication.swift b/Sources/WebAuthenticationUI/WebAuthentication.swift index 564c75bdb..04d1724d7 100644 --- a/Sources/WebAuthenticationUI/WebAuthentication.swift +++ b/Sources/WebAuthenticationUI/WebAuthentication.swift @@ -60,54 +60,6 @@ public class WebAuthentication { public typealias WindowAnchor = UIWindow #endif - /// Describes available options for customizing the sign on process. - public enum Option { - /// The username to pre-populate if prompting for authentication. - case login(hint: String) - - /// Value passed to the Social IdP when performing social login. - case display(String) - - /// Allowable elapsed time since the user was last authenticated. - case maxAge(TimeInterval) - - /// Specify a custom state to use when authorizing. - /// - /// If this value is not specified, one will be automatically generated. - case state(String) - - /// Identity Provider to use if there's no Okta session. - case idp(url: URL) - - /// Identity Provider to use if there's no Okta session. - case idpScope(String) - - /// Control how the user is prompted when authentication starts. - /// - /// > Note: The default behavior is ``Prompt/none``. - case prompt(Prompt) - - /// Supply a custom key/value pair to the authorization server. - case custom(key: String, value: String) - - /// Defines how a user will be prompted to sign in. - /// - /// This is used with the ``WebAuthentication/Option/prompt(_:)`` enumeration. For more information, see the [API documentation for this parameter](https://developer.okta.com/docs/reference/api/oidc/#parameter-details). - public enum Prompt: String { - /// If an Okta session already exists, the user is silently authenticated. Otherwise, the user is prompted to authenticate. - case none - - /// Display the Okta consent dialog, even if the user has already given consent. - case consent - - /// Always prompt the user for authentication. - case login - - /// The user is always prompted for authentication, and the user consent dialog appears. - case loginAndConsent = "login consent" - } - } - /// Active / default shared instance of the ``WebAuthentication`` session. /// /// This convenience property can be used in one of two ways: @@ -135,90 +87,81 @@ public class WebAuthentication { /// The underlying OAuth2 flow that implements the session logout behaviour. public let signOutFlow: SessionLogoutFlow? - /// Context information about the current authorization code flow. - /// - /// This represents the state and other challenge data necessary to resume the authentication flow. - /// - /// > Warning: This is deprecated, and will be removed in a future release. - @available(*, deprecated, renamed: "signInFlow.context") - public var context: AuthorizationCodeFlow.Context? { - signInFlow.context - } - /// Indicates whether or not the developer prefers an ephemeral browser session, or if the user's browser state should be shared with the system browser. public var ephemeralSession: Bool = false /// Starts sign-in using the configured client. /// - Parameters: /// - window: Window from which the sign in process will be started. - /// - options: Options to add to the authorization URL. + /// - context: Context options used when composing the authorization URL. /// - completion: Completion block that will be invoked when authentication finishes. public final func signIn(from window: WindowAnchor?, - options: [Option]? = nil, + context: AuthorizationCodeFlow.Context = .init(), completion: @escaping (Result) -> Void) { if provider != nil { cancel() } - let provider = createWebAuthenticationProvider(loginFlow: signInFlow, - logoutFlow: signOutFlow, - from: window, - delegate: self) + let provider: WebAuthenticationProvider? + do { + provider = try createWebAuthenticationProvider(loginFlow: signInFlow, + logoutFlow: signOutFlow, + from: window, + delegate: self) + } catch { + completion(.failure(.init(error))) + return + } + + guard let provider = provider else { + completion(.failure(.noCompatibleAuthenticationProviders)) + return + } + self.completionBlock = completion self.provider = provider - provider?.start(context: options?.context, - additionalParameters: options?.additionalParameters) + provider.start(context: context) } /// Starts log-out using the credential. /// - Parameters: /// - window: Window from which the sign in process will be started. /// - credential: Stored credentials that will retrieve the ID token. - /// - options: Options to add to the authorization URL. + /// - context: Context options used when composing the signout URL. /// - completion: Completion block that will be invoked when log-out finishes. public final func signOut(from window: WindowAnchor? = nil, credential: Credential? = .default, - options: [Option]? = nil, + context: SessionLogoutFlow.Context = .init(), completion: @escaping (Result) -> Void) { - guard let token = credential?.token else { - completion(.failure(.missingIdToken)) - return - } - - signOut(from: window, token: token, options: options, completion: completion) + signOut(from: window, token: credential?.token, context: context, completion: completion) } /// Starts log-out using the `Token` object. /// - Parameters: /// - window: Window from which the sign in process will be started. /// - token: Token object that will retrieve the ID token. - /// - options: Options to add to the authorization URL. + /// - context: Context options used when composing the signout URL. /// - completion: Completion block that will be invoked when sign-out finishes. public final func signOut(from window: WindowAnchor? = nil, - token: Token, - options: [Option]? = nil, + token: Token?, + context: SessionLogoutFlow.Context = .init(), completion: @escaping (Result) -> Void) { - guard let idToken = token.idToken else { - completion(.failure(.missingIdToken)) - return - } - - signOut(from: window, token: idToken.rawValue, options: options, completion: completion) + var context = context + context.idToken = token?.idToken?.rawValue + signOut(from: window, context: context, completion: completion) } /// Starts log-out using the ID token. /// - Parameters: /// - window: Window from which the sign in process will be started. - /// - token: The ID token string used for log-out. - /// - options: Options to add to the authorization URL. + /// - context: Context options used when composing the signout URL. /// - completion: Completion block that will be invoked when sign-out finishes. public final func signOut(from window: WindowAnchor? = nil, - token: String, - options: [Option]? = nil, + context: SessionLogoutFlow.Context = .init(), completion: @escaping (Result) -> Void) { var provider = provider @@ -227,18 +170,25 @@ public class WebAuthentication { cancel() } - provider = createWebAuthenticationProvider(loginFlow: signInFlow, - logoutFlow: signOutFlow, - from: window, - delegate: self) + do { + provider = try createWebAuthenticationProvider(loginFlow: signInFlow, + logoutFlow: signOutFlow, + from: window, + delegate: self) + } catch { + completion(.failure(.init(error))) + return + } + + guard let provider = provider else { + completion(.failure(.noCompatibleAuthenticationProviders)) + return + } self.logoutCompletionBlock = completion self.provider = provider - let context = SessionLogoutFlow.Context(idToken: token, - state: options?.state) - provider?.logout(context: context, - additionalParameters: options?.additionalParameters) + provider.logout(context: context) } /// Cancels the authentication session. @@ -270,7 +220,7 @@ public class WebAuthentication { /// If the URI does not match the configured URI scheme, this method will thrown an error. /// - Parameter url: URL from which to attempt to resume authentication. public final func resume(with url: URL) throws { - guard url.scheme?.lowercased() == signInFlow.redirectUri.scheme?.lowercased() + guard url.scheme?.lowercased() == signInFlow.client.configuration.redirectUri?.scheme?.lowercased() else { throw WebAuthenticationError.invalidRedirectScheme(url.scheme) } @@ -295,77 +245,62 @@ public class WebAuthentication { /// Initializes a web authentication session using the supplied client credentials. /// - Parameters: - /// - issuer: The URL for the OAuth2 issuer. + /// - issuerURL: The URL for the OAuth2 issuer. /// - clientId: The client's ID. - /// - scopes: The scopes the client is requesting. + /// - scope: The scopes the client is requesting. /// - redirectUri: The redirect URI for the configured client. /// - logoutRedirectUri: The logout URI for the client, if applicable. /// - additionalParameters: Optional parameters to add to the authorization query string. - public convenience init(issuer: URL, + public convenience init(issuerURL: URL, clientId: String, - scopes: String, + scope: String, redirectUri: URL, logoutRedirectUri: URL? = nil, - additionalParameters: [String: APIRequestArgument]? = nil) + additionalParameters: [String: APIRequestArgument]? = nil) throws { - let client = OAuth2Client(baseURL: issuer, + let client = OAuth2Client(issuerURL: issuerURL, clientId: clientId, - scopes: scopes) - - let logoutFlow: SessionLogoutFlow? - if let logoutRedirectUri = logoutRedirectUri { - logoutFlow = SessionLogoutFlow(logoutRedirectUri: logoutRedirectUri, - additionalParameters: additionalParameters, - client: client) - } else { - logoutFlow = nil - } - - self.init(loginFlow: AuthorizationCodeFlow(redirectUri: redirectUri, - additionalParameters: additionalParameters, - client: client), - logoutFlow: logoutFlow) + scope: scope, + redirectUri: redirectUri, + logoutRedirectUri: logoutRedirectUri) + try self.init(client: client, additionalParameters: additionalParameters) } convenience init(_ config: OAuth2Client.PropertyListConfiguration) throws { - guard let redirectUri = config.redirectUri else { - throw OAuth2Client.PropertyListConfigurationError.missingConfigurationValues + try self.init(client: OAuth2Client(config), + additionalParameters: config.additionalParameters) + } + + convenience init(client: OAuth2Client, additionalParameters: [String: APIRequestArgument]?) throws { + let loginFlow = try AuthorizationCodeFlow(client: client, + additionalParameters: additionalParameters) + let logoutFlow: SessionLogoutFlow? + if client.configuration.logoutRedirectUri != nil { + logoutFlow = SessionLogoutFlow(client: client, + additionalParameters: additionalParameters) + } else { + logoutFlow = nil } - self.init(issuer: config.issuer, - clientId: config.clientId, - scopes: config.scopes, - redirectUri: redirectUri, - logoutRedirectUri: config.logoutRedirectUri, - additionalParameters: config.additionalParameters) + self.init(loginFlow: loginFlow, logoutFlow: logoutFlow) + } - + func createWebAuthenticationProvider(loginFlow: AuthorizationCodeFlow, logoutFlow: SessionLogoutFlow?, from window: WebAuthentication.WindowAnchor?, - delegate: WebAuthenticationProviderDelegate) -> WebAuthenticationProvider? + delegate: WebAuthenticationProviderDelegate) throws -> WebAuthenticationProvider? { - AuthenticationServicesProvider(loginFlow: loginFlow, - logoutFlow: logoutFlow, - from: window, - delegate: delegate) - } - - /// Initializes a web authentication session using the supplied AuthorizationCodeFlow and optional context. - /// - Parameters: - /// - flow: Authorization code flow instance for this client. - /// - context: Optional context to initialize authentication with. - /// - /// > Warning: This is deprecated, and will be removed in a future release. - @available(*, deprecated, renamed: "init(loginFlow:logoutFlow:)") - public convenience init(loginFlow: AuthorizationCodeFlow, logoutFlow: SessionLogoutFlow?, context: AuthorizationCodeFlow.Context?) { - self.init(loginFlow: loginFlow, logoutFlow: logoutFlow) + try AuthenticationServicesProvider(loginFlow: loginFlow, + logoutFlow: logoutFlow, + from: window, + delegate: delegate) } /// Initializes a web authentication session using the supplied AuthorizationCodeFlow and optional context. /// - Parameters: - /// - flow: Authorization code flow instance for this client. - /// - context: Optional context to initialize authentication with. + /// - loginFlow: Authorization code flow instance for signing in to this client. + /// - logoutFlow: Session sign out flow to use when signing out from this client. public init(loginFlow: AuthorizationCodeFlow, logoutFlow: SessionLogoutFlow?) { // Ensure this SDK's static version is included in the user agent. SDKVersion.register(sdk: Version) @@ -388,13 +323,13 @@ extension WebAuthentication { /// Asynchronously initiates authentication from the given window. /// - Parameters: /// - window: The window from which the authentication browser should be shown. - /// - options: Options to add to the authorization URL. + /// - context: Context options used when composing the authorization URL. /// - Returns: The token representing the signed-in user. public final func signIn(from window: WindowAnchor?, - options: [Option]? = nil) async throws -> Token + context: AuthorizationCodeFlow.Context = .init()) async throws -> Token { try await withCheckedThrowingContinuation { continuation in - self.signIn(from: window, options: options) { continuation.resume(with: $0) } + self.signIn(from: window, context: context) { continuation.resume(with: $0) } } } @@ -402,13 +337,13 @@ extension WebAuthentication { /// - Parameters: /// - window: Window from which the sign in process will be started. /// - credential: Stored credentials that will retrieve the ID token. - /// - options: Options to add to the authorization URL. + /// - context: Context options used when composing the signout URL. public final func signOut(from window: WindowAnchor?, credential: Credential? = .default, - options: [Option]? = nil) async throws + context: SessionLogoutFlow.Context = .init()) async throws { try await withCheckedThrowingContinuation { continuation in - self.signOut(from: window, credential: credential, options: options) { continuation.resume(with: $0) } + self.signOut(from: window, credential: credential, context: context) { continuation.resume(with: $0) } } } @@ -416,26 +351,24 @@ extension WebAuthentication { /// - Parameters: /// - window: Window from which the sign in process will be started. /// - token: Token object that will retrieve the ID token. - /// - options: Options to add to the authorization URL. + /// - context: Context options used when composing the signout URL. public final func signOut(from window: WindowAnchor?, token: Token, - options: [Option]? = nil) async throws + context: SessionLogoutFlow.Context = .init()) async throws { try await withCheckedThrowingContinuation { continuation in - self.signOut(from: window, token: token, options: options) { continuation.resume(with: $0) } + self.signOut(from: window, token: token, context: context) { continuation.resume(with: $0) } } } /// Asynchronous convenience method that initiates log-out using the ID Token, throwing the error if fails. /// - Parameters: /// - window: Window from which the sign in process will be started. - /// - idToken: The ID token used for log-out. - /// - options: Options to add to the authorization URL. + /// - context: Context options used when composing the signout URL. public final func signOut(from window: WindowAnchor?, - token: String, - options: [Option]? = nil) async throws { + context: SessionLogoutFlow.Context = .init()) async throws { try await withCheckedThrowingContinuation { continuation in - self.signOut(from: window, token: token, options: options) { continuation.resume(with: $0) } + self.signOut(from: window, context: context) { continuation.resume(with: $0) } } } } diff --git a/Sources/WebAuthenticationUI/WebAuthenticationUI.docc/Extensions/WebAuthentication.md b/Sources/WebAuthenticationUI/WebAuthenticationUI.docc/Extensions/WebAuthentication.md index d631f6f1a..7d48e0a0b 100644 --- a/Sources/WebAuthenticationUI/WebAuthenticationUI.docc/Extensions/WebAuthentication.md +++ b/Sources/WebAuthenticationUI/WebAuthenticationUI.docc/Extensions/WebAuthentication.md @@ -12,32 +12,36 @@ - ``init()`` - ``init(plist:)`` -- ``init(issuer:clientId:scopes:redirectUri:logoutRedirectUri:additionalParameters:)`` -- ``init(loginFlow:logoutFlow:context:)`` +- ``init(issuerURL:clientId:scope:redirectUri:logoutRedirectUri:additionalParameters:)`` +- ``init(loginFlow:logoutFlow:)`` ### Sign In -- ``signIn(from:options:)`` -- ``signIn(from:options:completion:)`` +- ``signIn(from:context:)`` +- ``signIn(from:context:completion:)`` ### Sign Out Using Credential - -- ``signOut(from:credential:options:)`` -- ``signOut(from:credential:options:completion:)`` +- ``signOut(from:credential:context:)`` +- ``signOut(from:credential:context:completion:)`` ### Sign Out Using Token - -- ``signOut(from:token:options:)-4x9ic`` -- ``signOut(from:token:options:)-33i36`` -- ``signOut(from:token:options:completion:)-3q66k`` -- ``signOut(from:token:options:completion:)-2jka2`` +- ``signOut(from:token:context:)`` +- ``signOut(from:token:context:completion:)`` + +### Sign Out Using Custom Settings + +- +- ``signOut(from:context:)`` +- ``signOut(from:context:completion:)`` ### Sign In Using App Link +- ``resume(with:)`` - ``resume(with:)-9xiuc`` -- ``resume(with:)-5hhn1`` ### Customizing OAuth2 Flows @@ -45,14 +49,10 @@ - ``signOutFlow`` - ``context`` -@Metadata { - @DocumentationExtension(mergeBehavior: append) -} - ## Usage Signing in and -out is intended to be simple using this class, with several options for configuring your client (see for more information). Once your client is configured, it is available through a shared singleton property ``shared``, or can be directly accessed if you create an instance directly. -The ``signIn(from:options:)`` function (or ``signIn(from:options:completion:)`` for applications not using Swift Concurrency) presents a browser from the window supplied to the `from` argument, which the user can use to enter their account information. After the sign in completes, the result is returned allowing your application to continue. +The ``signIn(from:context:)`` function (or ``signIn(from:context:completion:)`` for applications not using Swift Concurrency) presents a browser from the window supplied to the `from` argument, which the user can use to enter their account information. After the sign in completes, the result is returned allowing your application to continue. -When signing a user out, the ``signOut(from:credential:options:)`` function similarly presents a browser to the user, in order to clear cookies and other browser state that is associated with the user's session. +When signing a user out, the ``signOut(from:context:)`` function similarly presents a browser to the user, in order to clear cookies and other browser state that is associated with the user's session. diff --git a/Tests/AuthFoundationTests/APIClientTests.swift b/Tests/AuthFoundationTests/APIClientTests.swift index ea423f6e4..15059a409 100644 --- a/Tests/AuthFoundationTests/APIClientTests.swift +++ b/Tests/AuthFoundationTests/APIClientTests.swift @@ -36,9 +36,9 @@ class APIClientTests: XCTestCase { let requestId = UUID().uuidString override func setUpWithError() throws { - configuration = OAuth2Client.Configuration(baseURL: baseUrl, + configuration = OAuth2Client.Configuration(issuerURL: baseUrl, clientId: "clientid", - scopes: "openid") + scope: "openid") client = MockApiClient(configuration: configuration, session: urlSession, baseURL: baseUrl) diff --git a/Tests/AuthFoundationTests/APIRetryTests.swift b/Tests/AuthFoundationTests/APIRetryTests.swift index 688454d5a..061fedb86 100644 --- a/Tests/AuthFoundationTests/APIRetryTests.swift +++ b/Tests/AuthFoundationTests/APIRetryTests.swift @@ -35,27 +35,27 @@ class APIRetryDelegateRecorder: APIClientDelegate { class APIRetryTests: XCTestCase { var client: MockApiClient! - let baseUrl = URL(string: "https://example.okta.com/oauth2/v1/token")! + let issuerURL = URL(string: "https://example.okta.com")! var configuration: OAuth2Client.Configuration! let urlSession = URLSessionMock() var apiRequest: MockApiRequest! let requestId = UUID().uuidString override func setUpWithError() throws { - configuration = OAuth2Client.Configuration(baseURL: baseUrl, + configuration = OAuth2Client.Configuration(issuerURL: issuerURL, clientId: "clientid", - scopes: "openid") + scope: "openid") client = MockApiClient(configuration: configuration, session: urlSession, - baseURL: baseUrl) - apiRequest = MockApiRequest(url: baseUrl) + baseURL: issuerURL) + apiRequest = MockApiRequest(url: issuerURL.appending(path: "/oauth2/v1/token")) } func testShouldNotRetry() throws { client = MockApiClient(configuration: configuration, - session: urlSession, - baseURL: baseUrl, - shouldRetry: .doNotRetry) + session: urlSession, + baseURL: issuerURL, + shouldRetry: .doNotRetry) try performRetryRequest(count: 1) XCTAssertNil(client.request?.allHTTPHeaderFields?["X-Okta-Retry-Count"]) } @@ -81,7 +81,7 @@ class APIRetryTests: XCTestCase { func testCustomRetryCount() throws { client = MockApiClient(configuration: configuration, session: urlSession, - baseURL: baseUrl, + baseURL: issuerURL, shouldRetry: .retry(maximumCount: 5)) try performRetryRequest(count: 6) XCTAssertEqual(client.request?.allHTTPHeaderFields?["X-Okta-Retry-Count"], "5") diff --git a/Tests/AuthFoundationTests/CredentialCoordinatorTests.swift b/Tests/AuthFoundationTests/CredentialCoordinatorTests.swift index f85220145..7b0de33de 100644 --- a/Tests/AuthFoundationTests/CredentialCoordinatorTests.swift +++ b/Tests/AuthFoundationTests/CredentialCoordinatorTests.swift @@ -33,9 +33,9 @@ final class UserCoordinatorTests: XCTestCase { refreshToken: nil, idToken: nil, deviceSecret: nil, - context: Token.Context(configuration: .init(baseURL: URL(string: "https://example.com")!, + context: Token.Context(configuration: .init(issuerURL: URL(string: "https://example.com")!, clientId: "clientid", - scopes: "openid"), + scope: "openid"), clientSettings: nil)) override func setUpWithError() throws { diff --git a/Tests/AuthFoundationTests/CredentialRevokeTests.swift b/Tests/AuthFoundationTests/CredentialRevokeTests.swift index 9cade6167..6a8fd1739 100644 --- a/Tests/AuthFoundationTests/CredentialRevokeTests.swift +++ b/Tests/AuthFoundationTests/CredentialRevokeTests.swift @@ -28,9 +28,9 @@ final class CredentialTests: XCTestCase { refreshToken: "refresh123", idToken: nil, deviceSecret: "device123", - context: Token.Context(configuration: .init(baseURL: URL(string: "https://example.com/oauth2/default")!, + context: Token.Context(configuration: .init(issuerURL: URL(string: "https://example.com/oauth2/default")!, clientId: "clientid", - scopes: "openid"), + scope: "openid"), clientSettings: [ "client_id": "foo" ])) override func setUpWithError() throws { diff --git a/Tests/AuthFoundationTests/DefaultCredentialDataSourceTests.swift b/Tests/AuthFoundationTests/DefaultCredentialDataSourceTests.swift index 8b242fc35..82c1f62de 100644 --- a/Tests/AuthFoundationTests/DefaultCredentialDataSourceTests.swift +++ b/Tests/AuthFoundationTests/DefaultCredentialDataSourceTests.swift @@ -41,9 +41,9 @@ final class DefaultCredentialDataSourceTests: XCTestCase { var dataSource: DefaultCredentialDataSource! var delegate: CredentialDataSourceDelegateRecorder! - let configuration = OAuth2Client.Configuration(baseURL: URL(string: "https://example.com")!, + let configuration = OAuth2Client.Configuration(issuerURL: URL(string: "https://example.com")!, clientId: "clientid", - scopes: "openid") + scope: "openid") override func setUpWithError() throws { diff --git a/Tests/AuthFoundationTests/DefaultTimeCoordinatorTests.swift b/Tests/AuthFoundationTests/DefaultTimeCoordinatorTests.swift index c043e4ca9..925465c5d 100644 --- a/Tests/AuthFoundationTests/DefaultTimeCoordinatorTests.swift +++ b/Tests/AuthFoundationTests/DefaultTimeCoordinatorTests.swift @@ -29,9 +29,9 @@ final class DefaultTimeCoordinatorTests: XCTestCase { coordinator = DefaultTimeCoordinator() Date.coordinator = coordinator - configuration = OAuth2Client.Configuration(baseURL: baseUrl, + configuration = OAuth2Client.Configuration(issuerURL: baseUrl, clientId: "clientid", - scopes: "openid") + scope: "openid") client = MockApiClient(configuration: configuration, session: urlSession, baseURL: baseUrl) diff --git a/Tests/AuthFoundationTests/ErrorTests.swift b/Tests/AuthFoundationTests/ErrorTests.swift index 0cfd04407..f24b88705 100644 --- a/Tests/AuthFoundationTests/ErrorTests.swift +++ b/Tests/AuthFoundationTests/ErrorTests.swift @@ -77,8 +77,8 @@ final class ErrorTests: XCTestCase { XCTAssertEqual(OAuth2Error.network(error: APIClientError.serverError(TestLocalizedError.nestedError)).errorDescription, "Nested Error") - XCTAssertTrue(OAuth2Error.oauth2Error(code: "123", description: "AuthError").errorDescription?.contains("AuthError") ?? false) - XCTAssertNotEqual(OAuth2Error.oauth2Error(code: "123", description: nil).errorDescription, + XCTAssertTrue(OAuth2Error.server(error: .init(code: "123", description: "AuthError")).errorDescription?.contains("AuthError") ?? false) + XCTAssertNotEqual(OAuth2Error.server(error: .init(code: "123", description: nil)).errorDescription, "oauth2_error_code_description") XCTAssertNotEqual(OAuth2Error.missingToken(type: .accessToken).errorDescription, @@ -188,7 +188,7 @@ final class ErrorTests: XCTestCase { let error = try defaultJSONDecoder.decode(OAuth2ServerError.self, from: json) XCTAssertEqual(error.code, .invalidRequest) XCTAssertEqual(error.description, "Description") - XCTAssertEqual(error.errorDescription, "Description") + XCTAssertEqual(error.errorDescription, "Authentication error: Description (invalid_request).") } func testOAuth2ServerErrorCodes() { diff --git a/Tests/AuthFoundationTests/KeychainTokenStorageTests.swift b/Tests/AuthFoundationTests/KeychainTokenStorageTests.swift index a94220aa5..ca321d2f6 100644 --- a/Tests/AuthFoundationTests/KeychainTokenStorageTests.swift +++ b/Tests/AuthFoundationTests/KeychainTokenStorageTests.swift @@ -44,9 +44,9 @@ final class KeychainTokenStorageTests: XCTestCase { refreshToken: nil, idToken: nil, deviceSecret: nil, - context: Token.Context(configuration: .init(baseURL: URL(string: "https://example.com")!, + context: Token.Context(configuration: .init(issuerURL: URL(string: "https://example.com")!, clientId: "clientid", - scopes: "openid"), + scope: "openid"), clientSettings: nil)) override func setUpWithError() throws { diff --git a/Tests/AuthFoundationTests/OAuth2ClientTests.swift b/Tests/AuthFoundationTests/OAuth2ClientTests.swift index e19c038d1..974cafbd3 100644 --- a/Tests/AuthFoundationTests/OAuth2ClientTests.swift +++ b/Tests/AuthFoundationTests/OAuth2ClientTests.swift @@ -12,9 +12,9 @@ final class OAuth2ClientTests: XCTestCase { var urlSession: URLSessionMock! var client: OAuth2Client! var openIdConfiguration: OpenIdConfiguration! - let configuration = OAuth2Client.Configuration(baseURL: URL(string: "https://example.com")!, + let configuration = OAuth2Client.Configuration(issuerURL: URL(string: "https://example.com")!, clientId: "clientid", - scopes: "openid") + scope: "openid") var token: Token! override func setUpWithError() throws { @@ -52,58 +52,64 @@ final class OAuth2ClientTests: XCTestCase { client = try OAuth2Client(domain: "example.com", clientId: "abc123", - scopes: "openid profile") - XCTAssertEqual(client.configuration, .init(baseURL: URL(string: "https://example.com")!, + scope: "openid profile") + XCTAssertEqual(client.configuration, .init(issuerURL: URL(string: "https://example.com")!, clientId: "abc123", - scopes: "openid profile", + scope: "openid profile", authentication: .none)) // Ensure the default session is ephemeral let urlSession = try XCTUnwrap(client.session as? URLSession) XCTAssertEqual(urlSession.configuration.urlCache?.diskCapacity, 0) - client = OAuth2Client(baseURL: URL(string: "https://example.com")!, - clientId: "abc123", - scopes: "openid profile") - XCTAssertEqual(client.configuration, .init(baseURL: URL(string: "https://example.com")!, + client = OAuth2Client(issuerURL: URL(string: "https://example.com")!, + clientId: "abc123", + scope: "openid profile") + XCTAssertEqual(client.configuration, .init(issuerURL: URL(string: "https://example.com")!, clientId: "abc123", - scopes: "openid profile", + scope: "openid profile", authentication: .none)) client = try OAuth2Client(domain: "example.com", clientId: "abc123", - scopes: "openid profile", + scope: "openid profile", authentication: .clientSecret("supersecret")) - XCTAssertEqual(client.configuration, .init(baseURL: URL(string: "https://example.com")!, + XCTAssertEqual(client.configuration, .init(issuerURL: URL(string: "https://example.com")!, clientId: "abc123", - scopes: "openid profile", + scope: "openid profile", authentication: .clientSecret("supersecret"))) } func testConfiguration() throws { XCTAssertNotEqual(try OAuth2Client.Configuration(domain: "example.com", clientId: "abc123", - scopes: "openid profile", + scope: "openid profile", authentication: .none), try OAuth2Client.Configuration(domain: "example.com", - clientId: "abc123", - scopes: "openid profile", - authentication: .clientSecret("supersecret"))) + clientId: "abc123", + scope: "openid profile", + authentication: .clientSecret("supersecret"))) } - + func testClientAuthentication() throws { XCTAssertNotEqual(OAuth2Client.ClientAuthentication.none, .clientSecret("supersecret")) XCTAssertEqual(OAuth2Client.ClientAuthentication.none, .none) - + XCTAssertNotEqual(OAuth2Client.ClientAuthentication.clientSecret("supersecret1"), - .clientSecret("supersecret2")) + .clientSecret("supersecret2")) XCTAssertEqual(OAuth2Client.ClientAuthentication.clientSecret("supersecret"), .clientSecret("supersecret")) + + for category in OAuth2APIRequestCategory.allCases.omitting(.configuration) { + XCTAssertNil(OAuth2Client.ClientAuthentication.none.parameters(for: category)) + XCTAssertEqual(OAuth2Client.ClientAuthentication.clientSecret("supersecret").parameters(for: category)?.stringComponents, + ["client_secret": "supersecret"]) + } - XCTAssertNil(OAuth2Client.ClientAuthentication.none.additionalParameters) - XCTAssertEqual(OAuth2Client.ClientAuthentication.clientSecret("supersecret").additionalParameters?.stringComponents, - ["client_secret": "supersecret"]) + XCTAssertNil(OAuth2Client.ClientAuthentication.none.parameters(for: .configuration)) + XCTAssertNil(OAuth2Client.ClientAuthentication.clientSecret("supersecret").parameters(for: .configuration)) + } func testOpenIDConfiguration() throws { @@ -240,9 +246,9 @@ final class OAuth2ClientTests: XCTestCase { } func testIntrospectTokenRequestClientAuthentication() throws { - let clientConfiguration = OAuth2Client.Configuration(baseURL: client.configuration.baseURL, + let clientConfiguration = OAuth2Client.Configuration(issuerURL: client.configuration.baseURL, clientId: client.configuration.clientId, - scopes: client.configuration.scopes, + scope: client.configuration.scope, authentication: .clientSecret("supersecret")) let request = try Token.IntrospectRequest(openIdConfiguration: openIdConfiguration, clientConfiguration: clientConfiguration, @@ -452,7 +458,7 @@ final class OAuth2ClientTests: XCTestCase { idToken: nil, deviceSecret: nil, context: Token.Context(configuration: self.configuration, - clientSettings: [])) + clientSettings: nil)) urlSession.expect("https://example.com/.well-known/openid-configuration", data: try data(from: .module, for: "openid-configuration", in: "MockResponses"), contentType: "application/json") @@ -538,15 +544,15 @@ final class OAuth2ClientTests: XCTestCase { } func testRefreshRequestClientAuthentication() throws { - let clientConfiguration = OAuth2Client.Configuration(baseURL: client.configuration.baseURL, + let clientConfiguration = OAuth2Client.Configuration(issuerURL: client.configuration.baseURL, clientId: client.configuration.clientId, - scopes: client.configuration.scopes, + scope: client.configuration.scope, authentication: .clientSecret("supersecret")) let request = Token.RefreshRequest(openIdConfiguration: openIdConfiguration, clientConfiguration: clientConfiguration, refreshToken: "the-token", - id: "token-id", - configuration: [:]) + scope: nil, + id: "token-id") let parameters = try XCTUnwrap(request.bodyParameters as? [String: String]) XCTAssertEqual(parameters["refresh_token"], "the-token") XCTAssertEqual(parameters["grant_type"], "refresh_token") diff --git a/Tests/AuthFoundationTests/PKCETests.swift b/Tests/AuthFoundationTests/PKCETests.swift index 39e170dcd..7a55b7674 100644 --- a/Tests/AuthFoundationTests/PKCETests.swift +++ b/Tests/AuthFoundationTests/PKCETests.swift @@ -12,7 +12,7 @@ import XCTest -@testable import AuthFoundation +@testable import OktaOAuth2 final class PKCETests: XCTestCase { func testPKCE() throws { diff --git a/Tests/AuthFoundationTests/TokenTests.swift b/Tests/AuthFoundationTests/TokenTests.swift index 8f77ad3c1..94bf504f6 100644 --- a/Tests/AuthFoundationTests/TokenTests.swift +++ b/Tests/AuthFoundationTests/TokenTests.swift @@ -15,18 +15,19 @@ import XCTest @testable import TestCommon fileprivate struct MockTokenRequest: OAuth2TokenRequest { - let authenticationFlowConfiguration: (any AuthFoundation.AuthenticationFlowConfiguration)? = nil - let openIdConfiguration: AuthFoundation.OpenIdConfiguration - let clientId: String + let context: (any AuthenticationContext)? = nil + let openIdConfiguration: OpenIdConfiguration + let clientConfiguration: OAuth2Client.Configuration let url: URL + let category = OAuth2APIRequestCategory.token var bodyParameters: [String: APIRequestArgument]? } final class TokenTests: XCTestCase { var openIdConfiguration: OpenIdConfiguration! - let configuration = OAuth2Client.Configuration(baseURL: URL(string: "https://example.com")!, + let configuration = OAuth2Client.Configuration(issuerURL: URL(string: "https://example.com")!, clientId: "clientid", - scopes: "openid") + scope: "openid") override func setUpWithError() throws { JWK.validator = MockJWKValidator() @@ -66,7 +67,9 @@ final class TokenTests: XCTestCase { func testTokenContextCodingUserInfoKeySettings() throws { let context = Token.Context(configuration: configuration, - clientSettings: [CodingUserInfoKey.apiClientConfiguration: "bar"]) + clientSettings: [ + CodingUserInfoKey.apiClientConfiguration: "bar", + ]) XCTAssertEqual(context.clientSettings, ["apiClientConfiguration": "bar"]) let data = try JSONEncoder().encode(context) @@ -84,7 +87,9 @@ final class TokenTests: XCTestCase { """) let decoder = defaultJSONDecoder - decoder.userInfo = [.apiClientConfiguration: configuration] + decoder.userInfo = [ + .apiClientConfiguration: configuration, + ] let token = try decoder.decode(Token.self, from: data) XCTAssertNil(token.scope) @@ -101,7 +106,7 @@ final class TokenTests: XCTestCase { idToken: nil, deviceSecret: "the_device_secret", context: Token.Context(configuration: configuration, - clientSettings: [])) + clientSettings: [:])) XCTAssertEqual(token.token(of: .accessToken), token.accessToken) XCTAssertEqual(token.token(of: .refreshToken), token.refreshToken) @@ -114,7 +119,7 @@ final class TokenTests: XCTestCase { func testMFAAttestationToken() throws { let request = MockTokenRequest(openIdConfiguration: openIdConfiguration, - clientId: configuration.clientId, + clientConfiguration: configuration, url: configuration.baseURL, bodyParameters: [ "acr_values": "urn:okta:app:mfa:attestation" @@ -124,6 +129,9 @@ final class TokenTests: XCTestCase { decoder.userInfo = [ .apiClientConfiguration: configuration, .request: request, + .clientSettings: [ + "acr_values": "urn:okta:app:mfa:attestation" + ] ] let token = try decoder.decode(Token.self, @@ -133,7 +141,6 @@ final class TokenTests: XCTestCase { XCTAssertTrue(token.accessToken.isEmpty) } - func testMFAAttestationTokenFailed() throws { let decoder = defaultJSONDecoder decoder.userInfo = [ @@ -200,16 +207,14 @@ final class TokenTests: XCTestCase { XCTAssertEqual(token.refreshToken, "refresh-kl2QWaYgyHaLkCdc6exjsowP9KUTW1ilAWC") XCTAssertEqual(token.deviceSecret, "device_lh4nMHgcUWLJIVgkcbQwnnSI2F8JMwNshLoa") XCTAssertEqual(token.issuedAt?.timeIntervalSinceReferenceDate, 744576826.0011461) - XCTAssertEqual(token.context.configuration.scopes, "openid profile offline_access") - XCTAssertEqual(token.context, .init(configuration: .init(baseURL: try XCTUnwrap(URL(string: "https://example.com/oauth2/default")), + XCTAssertEqual(token.context.configuration.scope, "openid profile offline_access") + XCTAssertEqual(token.context.configuration.redirectUri?.absoluteString, "com.example:/callback") + XCTAssertEqual(token.context, .init(configuration: .init(issuerURL: try XCTUnwrap(URL(string: "https://example.com/oauth2/default")), clientId: "0oatheclientid", - scopes: "openid profile offline_access", + scope: "openid profile offline_access", + redirectUri: URL(string: "com.example:/callback"), authentication: .none), - clientSettings: [ - "client_id": "0oatheclientid", - "scope": "openid profile offline_access", - "redirect_uri":"com.example:/callback", - ])) + clientSettings: nil)) XCTAssertEqual(token.jsonPayload.jsonValue, try JSON([ "scope": "profile offline_access openid", "access_token": JWT.mockAccessToken, @@ -234,7 +239,7 @@ final class TokenTests: XCTestCase { idToken: nil, deviceSecret: "the_device_secret", context: Token.Context(configuration: configuration, - clientSettings: [])) + clientSettings: [:])) XCTAssertEqual(token.allClaims.sorted(), [ "expires_in", "token_type", @@ -274,9 +279,9 @@ final class TokenTests: XCTestCase { } """)) - return OAuth2Client(baseURL: URL(string: "https://example.com/")!, + return OAuth2Client(issuerURL: URL(string: "https://example.com/")!, clientId: "clientId", - scopes: "openid profile offline_access", + scope: "openid profile offline_access", session: urlSession) } } diff --git a/Tests/AuthFoundationTests/UserDefaultsTokenStorageTests.swift b/Tests/AuthFoundationTests/UserDefaultsTokenStorageTests.swift index 01b362369..cddb32af1 100644 --- a/Tests/AuthFoundationTests/UserDefaultsTokenStorageTests.swift +++ b/Tests/AuthFoundationTests/UserDefaultsTokenStorageTests.swift @@ -28,9 +28,9 @@ final class UserDefaultTokenStorageTests: XCTestCase { refreshToken: nil, idToken: nil, deviceSecret: nil, - context: Token.Context(configuration: .init(baseURL: URL(string: "https://example.com")!, + context: Token.Context(configuration: .init(issuerURL: URL(string: "https://example.com")!, clientId: "clientid", - scopes: "openid"), + scope: "openid"), clientSettings: nil)) let newToken = try! Token(id: "TokenId2", @@ -42,9 +42,9 @@ final class UserDefaultTokenStorageTests: XCTestCase { refreshToken: nil, idToken: nil, deviceSecret: nil, - context: Token.Context(configuration: .init(baseURL: URL(string: "https://example.com")!, + context: Token.Context(configuration: .init(issuerURL: URL(string: "https://example.com")!, clientId: "clientid", - scopes: "openid"), + scope: "openid"), clientSettings: nil)) override func setUpWithError() throws { diff --git a/Tests/OktaDirectAuthTests/DirectAuth1FATests.swift b/Tests/OktaDirectAuthTests/DirectAuth1FATests.swift index 5b8216692..63502aa19 100644 --- a/Tests/OktaDirectAuthTests/DirectAuth1FATests.swift +++ b/Tests/OktaDirectAuthTests/DirectAuth1FATests.swift @@ -23,9 +23,9 @@ final class DirectAuth1FATests: XCTestCase { override func setUpWithError() throws { urlSession = URLSessionMock() - client = OAuth2Client(baseURL: issuer, + client = OAuth2Client(issuerURL: issuer, clientId: "theClientId", - scopes: "openid profile offline_access", + scope: "openid profile offline_access", session: urlSession) flow = client.directAuthenticationFlow() diff --git a/Tests/OktaDirectAuthTests/DirectAuth2FATests.swift b/Tests/OktaDirectAuthTests/DirectAuth2FATests.swift index cd87c31f0..583476865 100644 --- a/Tests/OktaDirectAuthTests/DirectAuth2FATests.swift +++ b/Tests/OktaDirectAuthTests/DirectAuth2FATests.swift @@ -23,9 +23,9 @@ final class DirectAuth2FATests: XCTestCase { override func setUpWithError() throws { urlSession = URLSessionMock() - client = OAuth2Client(baseURL: issuer, + client = OAuth2Client(issuerURL: issuer, clientId: "theClientId", - scopes: "openid profile offline_access", + scope: "openid profile offline_access", session: urlSession) flow = client.directAuthenticationFlow() @@ -56,7 +56,7 @@ final class DirectAuth2FATests: XCTestCase { XCTFail("Not expecting a successful token response") case .mfaRequired(let context): XCTAssertFalse(context.mfaToken.isEmpty) - let newState = try await flow.resume(state, with: .oob(channel: .push)) + let newState = try await flow.resume(with: .oob(channel: .push)) switch newState { case .success(_): // Success! diff --git a/Tests/OktaDirectAuthTests/DirectAuthenticationFlowTests.swift b/Tests/OktaDirectAuthTests/DirectAuthenticationFlowTests.swift index b96072d52..82ba4539f 100644 --- a/Tests/OktaDirectAuthTests/DirectAuthenticationFlowTests.swift +++ b/Tests/OktaDirectAuthTests/DirectAuthenticationFlowTests.swift @@ -19,7 +19,7 @@ struct TestStepHandler: StepHandler { let flow: OktaDirectAuth.DirectAuthenticationFlow let openIdConfiguration: AuthFoundation.OpenIdConfiguration let loginHint: String? - let currentStatus: OktaDirectAuth.DirectAuthenticationFlow.Status? + let context: OktaDirectAuth.DirectAuthenticationFlow.Context let factor: TestFactor let result: (Result)? @@ -44,7 +44,6 @@ struct TestFactor: AuthenticationFactor { func stepHandler(flow: OktaDirectAuth.DirectAuthenticationFlow, openIdConfiguration: AuthFoundation.OpenIdConfiguration, loginHint: String?, - currentStatus: OktaDirectAuth.DirectAuthenticationFlow.Status?, factor: TestFactor) throws -> OktaDirectAuth.StepHandler { if let exception = exception { @@ -54,7 +53,7 @@ struct TestFactor: AuthenticationFactor { return TestStepHandler(flow: flow, openIdConfiguration: openIdConfiguration, loginHint: loginHint, - currentStatus: currentStatus, + context: flow.context!, factor: factor, result: result) } @@ -68,9 +67,9 @@ final class DirectAuthenticationFlowTests: XCTestCase { var flow: DirectAuthenticationFlow! override func setUpWithError() throws { - client = OAuth2Client(baseURL: issuer, + client = OAuth2Client(issuerURL: issuer, clientId: "clientId", - scopes: "openid profile", + scope: "openid profile", session: urlSession) openIdConfiguration = try mock(from: .module, for: "openid-configuration", @@ -95,6 +94,9 @@ final class DirectAuthenticationFlowTests: XCTestCase { let wait = expectation(description: "run step") let token = Token.mockToken() let factor = TestFactor(result: .success(.success(token)), exception: nil) + flow.context = .init(acrValues: nil, + intent: .signIn) + flow.runStep(with: factor) { result in XCTAssertEqual(result, .success(.success(token))) wait.fulfill() @@ -109,6 +111,8 @@ final class DirectAuthenticationFlowTests: XCTestCase { let wait = expectation(description: "run step") let factor = TestFactor(result: .failure(.pollingTimeoutExceeded), exception: nil) + flow.context = .init(acrValues: nil, + intent: .signIn) flow.runStep(with: factor) { result in XCTAssertEqual(result, .failure(.pollingTimeoutExceeded)) wait.fulfill() @@ -123,6 +127,8 @@ final class DirectAuthenticationFlowTests: XCTestCase { let wait = expectation(description: "run step") let factor = TestFactor(result: nil, exception: APIClientError.invalidRequestData) + flow.context = .init(acrValues: nil, + intent: .signIn) flow.runStep(with: factor) { result in XCTAssertEqual(result, .failure(.network(error: .invalidRequestData))) wait.fulfill() diff --git a/Tests/OktaDirectAuthTests/FactorStepHandlerTests.swift b/Tests/OktaDirectAuthTests/FactorStepHandlerTests.swift index e491f238e..2ed8969eb 100644 --- a/Tests/OktaDirectAuthTests/FactorStepHandlerTests.swift +++ b/Tests/OktaDirectAuthTests/FactorStepHandlerTests.swift @@ -26,9 +26,9 @@ final class FactorStepHandlerTests: XCTestCase { var flow: DirectAuthenticationFlow! override func setUpWithError() throws { - client = OAuth2Client(baseURL: issuer, + client = OAuth2Client(issuerURL: issuer, clientId: "clientId", - scopes: "openid profile", + scope: "openid profile", session: urlSession) openIdConfiguration = try mock(from: .module, for: "openid-configuration", @@ -50,14 +50,16 @@ final class FactorStepHandlerTests: XCTestCase { loginHint: String?, bodyParams: [String: String]) throws { + let context = DirectAuthenticationFlow.Context(acrValues: nil, + intent: .signIn) + flow.context = context let handler = try factor.stepHandler(flow: flow, openIdConfiguration: openIdConfiguration, loginHint: loginHint, - currentStatus: nil, factor: factor) let tokenStepHandler = try XCTUnwrap(handler as? TokenStepHandler) let request = try XCTUnwrap(tokenStepHandler.request as? TokenRequest) - XCTAssertEqual(request.clientId, client.configuration.clientId) + XCTAssertEqual(request.clientConfiguration.clientId, client.configuration.clientId) if let loginHint = loginHint { XCTAssertEqual(request.loginHint, loginHint) @@ -65,7 +67,7 @@ final class FactorStepHandlerTests: XCTestCase { XCTAssertNil(request.loginHint) } - XCTAssertEqual(request.authenticationFlowConfiguration as? DirectAuthenticationFlow.Configuration, flow.configuration) + XCTAssertEqual(request.context, context) XCTAssertEqual(request.bodyParameters?.stringComponents, bodyParams) } @@ -75,7 +77,7 @@ final class FactorStepHandlerTests: XCTestCase { loginHint: "jane.doe@example.com", bodyParams: [ "client_id": client.configuration.clientId, - "scope": client.configuration.scopes, + "scope": client.configuration.scope, "grant_type": "password", "username": "jane.doe@example.com", "password": "foo", @@ -89,7 +91,7 @@ final class FactorStepHandlerTests: XCTestCase { loginHint: "jane.doe@example.com", bodyParams: [ "client_id": client.configuration.clientId, - "scope": client.configuration.scopes, + "scope": client.configuration.scope, "grant_type": "urn:okta:params:oauth:grant-type:otp", "login_hint": "jane.doe@example.com", "otp": "123456", @@ -103,7 +105,7 @@ final class FactorStepHandlerTests: XCTestCase { loginHint: nil, bodyParams: [ "client_id": client.configuration.clientId, - "scope": client.configuration.scopes, + "scope": client.configuration.scope, "grant_type": "http://auth0.com/oauth/grant-type/mfa-otp", "otp": "123456", "grant_types_supported": "password urn:okta:params:oauth:grant-type:oob urn:okta:params:oauth:grant-type:otp http://auth0.com/oauth/grant-type/mfa-oob http://auth0.com/oauth/grant-type/mfa-otp urn:okta:params:oauth:grant-type:webauthn urn:okta:params:oauth:grant-type:mfa-webauthn", @@ -114,10 +116,11 @@ final class FactorStepHandlerTests: XCTestCase { func assertOOBStepHandler(factor: T, loginHint: String?) throws { + flow.context = .init(acrValues: nil, + intent: .signIn) let handler = try factor.stepHandler(flow: flow, openIdConfiguration: openIdConfiguration, loginHint: loginHint, - currentStatus: nil, factor: factor) let tokenStepHandler = try XCTUnwrap(handler as? OOBStepHandler) if let loginHint = loginHint { @@ -149,13 +152,15 @@ final class FactorStepHandlerTests: XCTestCase { data: try data(from: .module, for: "token", in: "MockResponses")) let factor = PrimaryFactor.password("SuperSecret") + flow.context = .init(acrValues: nil, + intent: .signIn) let handler = try factor.stepHandler(flow: flow, openIdConfiguration: openIdConfiguration, loginHint: "jane.doe@example.com", factor: factor) let wait = expectation(description: "process") - handler.process { result in + flow.process(handler) { result in switch result { case .success(let status): switch status { @@ -183,13 +188,15 @@ final class FactorStepHandlerTests: XCTestCase { statusCode: 400) let factor = PrimaryFactor.password("SuperSecret") + flow.context = .init(acrValues: nil, + intent: .signIn) let handler = try factor.stepHandler(flow: flow, openIdConfiguration: openIdConfiguration, loginHint: "jane.doe@example.com", factor: factor) let wait = expectation(description: "process") - handler.process { result in + flow.process(handler) { result in switch result { case .success(let status): switch status { @@ -221,13 +228,15 @@ final class FactorStepHandlerTests: XCTestCase { data: try data(from: .module, for: "token", in: "MockResponses")) let factor = PrimaryFactor.oob(channel: .push) + flow.context = .init(acrValues: nil, + intent: .signIn) let handler = try factor.stepHandler(flow: flow, openIdConfiguration: openIdConfiguration, loginHint: "jane.doe@example.com", factor: factor) let wait = expectation(description: "process") - handler.process { result in + flow.process(handler) { result in switch result { case .success(let status): switch status { @@ -256,13 +265,15 @@ final class FactorStepHandlerTests: XCTestCase { data: try data(from: .module, for: "token", in: "MockResponses")) let factor = PrimaryFactor.oob(channel: .push) + flow.context = .init(acrValues: nil, + intent: .signIn) let handler = try factor.stepHandler(flow: flow, openIdConfiguration: openIdConfiguration, loginHint: "jane.doe@example.com", factor: factor) let processExpectation = expectation(description: "process") - handler.process { result in + flow.process(handler) { result in guard case let .success(status) = result, case let .continuation(continuation) = status else { @@ -276,7 +287,6 @@ final class FactorStepHandlerTests: XCTestCase { let factor = SecondaryFactor.oob(channel: .push) let resumeHandler = try factor.stepHandler(flow: self.flow, openIdConfiguration: self.openIdConfiguration, - currentStatus: status, factor: factor) self.assertGettingTokenAfterBindingTransfer(using: resumeHandler) } catch { @@ -291,7 +301,7 @@ final class FactorStepHandlerTests: XCTestCase { "1c266114-a1be-4252-8ad1-04986c5b9ac1") processExpectation.fulfill() } - wait(for: [processExpectation], timeout: 5) + wait(for: [processExpectation], timeout: 500000) let tokenBody = try XCTUnwrap(urlSession.requests.first(where: { request in request.url?.lastPathComponent == "token" @@ -315,13 +325,15 @@ final class FactorStepHandlerTests: XCTestCase { data: try data(from: .module, for: "token", in: "MockResponses")) let factor = PrimaryFactor.oob(channel: .push) + flow.context = .init(acrValues: nil, + intent: .signIn) let handler = try factor.stepHandler(flow: flow, openIdConfiguration: openIdConfiguration, loginHint: "jane.doe@example.com", factor: factor) let processExpectation = expectation(description: "process") - handler.process { result in + flow.process(handler) { result in switch result { case .success(_): XCTFail("Not expecting success") @@ -347,13 +359,15 @@ final class FactorStepHandlerTests: XCTestCase { statusCode: 400) let factor = PrimaryFactor.oob(channel: .push) + flow.context = .init(acrValues: nil, + intent: .signIn) let handler = try factor.stepHandler(flow: flow, openIdConfiguration: openIdConfiguration, loginHint: "jane.doe@example.com", factor: factor) let wait = expectation(description: "process") - handler.process { result in + flow.process(handler) { result in switch result { case .success(let status): switch status { @@ -384,14 +398,18 @@ final class FactorStepHandlerTests: XCTestCase { data: try data(from: .module, for: "token", in: "MockResponses")) let factor = SecondaryFactor.oob(channel: .push) + var context = DirectAuthenticationFlow.Context(acrValues: nil, + intent: .signIn) + context.currentStatus = .mfaRequired(.init(supportedChallengeTypes: nil, + mfaToken: "abcd1234")) + flow.context = context + let handler = try factor.stepHandler(flow: flow, openIdConfiguration: openIdConfiguration, - currentStatus: .mfaRequired(.init(supportedChallengeTypes: nil, - mfaToken: "abcd1234")), factor: factor) let wait = expectation(description: "process") - handler.process { result in + flow.process(handler) { result in switch result { case .success(let status): switch status { @@ -420,14 +438,18 @@ final class FactorStepHandlerTests: XCTestCase { data: try data(from: .module, for: "token", in: "MockResponses")) let factor = SecondaryFactor.oob(channel: .push) + var context = DirectAuthenticationFlow.Context(acrValues: nil, + intent: .signIn) + context.currentStatus = .mfaRequired(.init(supportedChallengeTypes: nil, + mfaToken: "abcd1234")) + flow.context = context + let handler = try factor.stepHandler(flow: flow, openIdConfiguration: openIdConfiguration, - currentStatus: .mfaRequired(.init(supportedChallengeTypes: nil, - mfaToken: "abcd1234")), factor: factor) let processExpectation = expectation(description: "process") - handler.process { result in + flow.process(handler) { result in guard case .success(let status) = result, case let .continuation(continuation) = status else { @@ -440,7 +462,6 @@ final class FactorStepHandlerTests: XCTestCase { do { let resumeHandler = try factor.stepHandler(flow: self.flow, openIdConfiguration: self.openIdConfiguration, - currentStatus: status, factor: factor) self.assertGettingTokenAfterBindingTransfer(using: resumeHandler) } catch { @@ -470,14 +491,18 @@ final class FactorStepHandlerTests: XCTestCase { data: try data(from: .module, for: "token", in: "MockResponses")) let factor = SecondaryFactor.oob(channel: .push) + var context = DirectAuthenticationFlow.Context(acrValues: nil, + intent: .signIn) + context.currentStatus = .mfaRequired(.init(supportedChallengeTypes: nil, + mfaToken: "abcd1234")) + flow.context = context + let handler = try factor.stepHandler(flow: flow, openIdConfiguration: openIdConfiguration, - currentStatus: .mfaRequired(.init(supportedChallengeTypes: nil, - mfaToken: "abcd1234")), factor: factor) let processExpectation = expectation(description: "process") - handler.process { result in + flow.process(handler) { result in switch result { case .success(_): XCTFail("Not expecting success") @@ -491,13 +516,13 @@ final class FactorStepHandlerTests: XCTestCase { private func assertGettingTokenAfterBindingTransfer(using handler: StepHandler) { let tokenExpectation = expectation(description: "get token") - handler.process { result in + flow.process(handler) { result in + defer { tokenExpectation.fulfill() } guard case .success(let status) = result, case .success(_) = status else { XCTFail("Did not receive token") return } - tokenExpectation.fulfill() } wait(for: [tokenExpectation], timeout: 2.0) } diff --git a/Tests/OktaDirectAuthTests/ModelEqualityTests.swift b/Tests/OktaDirectAuthTests/ModelEqualityTests.swift new file mode 100644 index 000000000..5addff76c --- /dev/null +++ b/Tests/OktaDirectAuthTests/ModelEqualityTests.swift @@ -0,0 +1,56 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") +// +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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. +// + +import XCTest +@testable import TestCommon +@testable import OktaDirectAuth + +final class ModelEqualityTests: XCTestCase { + typealias Status = DirectAuthenticationFlow.Status + typealias MFAContext = DirectAuthenticationFlow.MFAContext + typealias ContinuationType = DirectAuthenticationFlow.ContinuationType + + func testOOBChannelEquality() { + XCTAssertEqual(DirectAuthenticationFlow.OOBChannel.sms, DirectAuthenticationFlow.OOBChannel.sms) + XCTAssertNotEqual(DirectAuthenticationFlow.OOBChannel.sms, DirectAuthenticationFlow.OOBChannel.push) + } + + func testMFAContextEquality() { + XCTAssertEqual(MFAContext(supportedChallengeTypes: nil, mfaToken: "abcd123"), + MFAContext(supportedChallengeTypes: nil, mfaToken: "abcd123")) + XCTAssertEqual(MFAContext(supportedChallengeTypes: .directAuth, mfaToken: "abcd123"), + MFAContext(supportedChallengeTypes: .directAuth, mfaToken: "abcd123")) + XCTAssertNotEqual(MFAContext(supportedChallengeTypes: .directAuth, mfaToken: "abcd123"), + MFAContext(supportedChallengeTypes: [.password], mfaToken: "zyxw987")) + } + + func testContinuationTypeEquality() { + let oobResponse = OOBResponse(oobCode: "abcd123", expiresIn: 30, interval: 1, channel: .push, bindingMethod: .transfer, bindingCode: "1234") + XCTAssertEqual(ContinuationType.prompt(.init(oobResponse: oobResponse, mfaContext: nil)), + ContinuationType.prompt(.init(oobResponse: oobResponse, mfaContext: nil))) + XCTAssertNotEqual( + ContinuationType.prompt(.init(oobResponse: oobResponse, mfaContext: nil)), + ContinuationType.prompt(.init(oobResponse: oobResponse, mfaContext: .init(MFAContext(supportedChallengeTypes: .directAuth, mfaToken: "abcd123"))))) + } + + func testStatusEquality() { + let oobResponse = OOBResponse(oobCode: "abcd123", expiresIn: 30, interval: 1, channel: .push, bindingMethod: .transfer, bindingCode: "1234") + let mfaContext = MFAContext(supportedChallengeTypes: .directAuth, mfaToken: "abcd123") + XCTAssertEqual(Status.success(.simpleMockToken), + Status.success(.simpleMockToken)) + XCTAssertEqual(Status.continuation(.prompt(.init(oobResponse: oobResponse, mfaContext: nil))), + Status.continuation(.prompt(.init(oobResponse: oobResponse, mfaContext: nil)))) + XCTAssertEqual(Status.continuation(.prompt(.init(oobResponse: oobResponse, mfaContext: mfaContext))), + Status.continuation(.prompt(.init(oobResponse: oobResponse, mfaContext: mfaContext)))) + XCTAssertEqual(Status.mfaRequired(mfaContext), Status.mfaRequired(mfaContext)) + } +} diff --git a/Tests/OktaDirectAuthTests/RequestTests.swift b/Tests/OktaDirectAuthTests/RequestTests.swift index d1a9b991a..4873a68f3 100644 --- a/Tests/OktaDirectAuthTests/RequestTests.swift +++ b/Tests/OktaDirectAuthTests/RequestTests.swift @@ -17,6 +17,7 @@ import XCTest final class RequestTests: XCTestCase { var openIdConfiguration: OpenIdConfiguration! + let issuer = URL(string: "https://example.com")! override func setUpWithError() throws { openIdConfiguration = try mock(from: .module, @@ -29,30 +30,28 @@ final class RequestTests: XCTestCase { // No authentication, sign-in intent request = .init(openIdConfiguration: openIdConfiguration, - clientConfiguration: try .init(domain: "example.com", - clientId: "theClientId", - scopes: "openid profile"), - authenticationFlowConfiguration: nil, - currentStatus: nil, - factor: DirectAuthenticationFlow.PrimaryFactor.password("password123"), - intent: .signIn) + clientConfiguration: .init(issuerURL: issuer, + clientId: "theClientId", + scope: "openid profile"), + context: .init(acrValues: nil, + intent: .signIn), + factor: DirectAuthenticationFlow.PrimaryFactor.password("password123")) XCTAssertEqual(request.bodyParameters?.stringComponents, [ "client_id": "theClientId", "scope": "openid profile", "grant_type": "password", - "password": "password123" + "password": "password123", ]) // No authentication, ACR Values request = .init(openIdConfiguration: openIdConfiguration, - clientConfiguration: try .init(domain: "example.com", - clientId: "theClientId", - scopes: "openid profile"), - authenticationFlowConfiguration: DirectAuthenticationFlow.Configuration(acrValues: ["urn:foo:bar", "urn:baz:boo"]), - currentStatus: nil, - factor: DirectAuthenticationFlow.PrimaryFactor.password("password123"), - intent: .signIn) + clientConfiguration: .init(issuerURL: issuer, + clientId: "theClientId", + scope: "openid profile"), + context: .init(acrValues: ["urn:foo:bar", "urn:baz:boo"], + intent: .signIn), + factor: DirectAuthenticationFlow.PrimaryFactor.password("password123")) XCTAssertEqual(request.bodyParameters?.stringComponents, [ "client_id": "theClientId", @@ -60,44 +59,41 @@ final class RequestTests: XCTestCase { "grant_type": "password", "password": "password123", "acr_values": "urn:foo:bar urn:baz:boo", - "grant_types_supported": "password urn:okta:params:oauth:grant-type:oob urn:okta:params:oauth:grant-type:otp http://auth0.com/oauth/grant-type/mfa-oob http://auth0.com/oauth/grant-type/mfa-otp urn:okta:params:oauth:grant-type:webauthn urn:okta:params:oauth:grant-type:mfa-webauthn", ]) // Client Secret authentication, sign-in intent request = .init(openIdConfiguration: openIdConfiguration, - clientConfiguration: try .init(domain: "example.com", - clientId: "theClientId", - scopes: "openid profile", - authentication: .clientSecret("supersecret")), - authenticationFlowConfiguration: nil, - currentStatus: nil, - factor: DirectAuthenticationFlow.PrimaryFactor.password("password123"), - intent: .signIn) + clientConfiguration: .init(issuerURL: issuer, + clientId: "theClientId", + scope: "openid profile", + authentication: .clientSecret("supersecret")), + context: .init(acrValues: nil, + intent: .signIn), + factor: DirectAuthenticationFlow.PrimaryFactor.password("password123")) XCTAssertEqual(request.bodyParameters?.stringComponents, [ "client_id": "theClientId", "client_secret": "supersecret", "scope": "openid profile", "grant_type": "password", - "password": "password123" + "password": "password123", ]) // No authentication, recovery intent request = .init(openIdConfiguration: openIdConfiguration, - clientConfiguration: try .init(domain: "example.com", - clientId: "theClientId", - scopes: "openid profile"), - authenticationFlowConfiguration: nil, - currentStatus: nil, - factor: DirectAuthenticationFlow.PrimaryFactor.otp(code: "123456"), - intent: .recovery) + clientConfiguration: .init(issuerURL: issuer, + clientId: "theClientId", + scope: "openid profile"), + context: .init(acrValues: nil, + intent: .recovery), + factor: DirectAuthenticationFlow.PrimaryFactor.otp(code: "123456")) XCTAssertEqual(request.bodyParameters?.stringComponents, [ "client_id": "theClientId", "scope": "okta.myAccount.password.manage", "grant_type": "urn:okta:params:oauth:grant-type:otp", "intent": "recovery", - "otp": "123456" + "otp": "123456", ]) } @@ -106,10 +102,11 @@ final class RequestTests: XCTestCase { // No authentication request = try .init(openIdConfiguration: openIdConfiguration, - clientConfiguration: try .init(domain: "example.com", - clientId: "theClientId", - scopes: "openid profile"), - authenticationFlowConfiguration: nil, + clientConfiguration: .init(issuerURL: issuer, + clientId: "theClientId", + scope: "openid profile"), + context: .init(acrValues: nil, + intent: .signIn), loginHint: "user@example.com", channelHint: .push, challengeHint: .oob) @@ -123,11 +120,12 @@ final class RequestTests: XCTestCase { // Client Secret authentication request = try .init(openIdConfiguration: openIdConfiguration, - clientConfiguration: try .init(domain: "example.com", - clientId: "theClientId", - scopes: "openid profile", - authentication: .clientSecret("supersecret")), - authenticationFlowConfiguration: nil, + clientConfiguration: .init(issuerURL: issuer, + clientId: "theClientId", + scope: "openid profile", + authentication: .clientSecret("supersecret")), + context: .init(acrValues: nil, + intent: .signIn), loginHint: "user@example.com", channelHint: .push, challengeHint: .oob) @@ -146,10 +144,11 @@ final class RequestTests: XCTestCase { // No authentication request = try .init(openIdConfiguration: openIdConfiguration, - clientConfiguration: try .init(domain: "example.com", - clientId: "theClientId", - scopes: "openid profile"), - authenticationFlowConfiguration: nil, + clientConfiguration: .init(issuerURL: issuer, + clientId: "theClientId", + scope: "openid profile"), + context: .init(acrValues: nil, + intent: .signIn), mfaToken: "abcd123", challengeTypesSupported: [.password, .oob]) XCTAssertEqual(request.bodyParameters?.stringComponents, @@ -161,11 +160,12 @@ final class RequestTests: XCTestCase { // Client Secret authentication request = try .init(openIdConfiguration: openIdConfiguration, - clientConfiguration: try .init(domain: "example.com", - clientId: "theClientId", - scopes: "openid profile", - authentication: .clientSecret("supersecret")), - authenticationFlowConfiguration: nil, + clientConfiguration: .init(issuerURL: issuer, + clientId: "theClientId", + scope: "openid profile", + authentication: .clientSecret("supersecret")), + context: .init(acrValues: nil, + intent: .signIn), mfaToken: "abcd123", challengeTypesSupported: [.password, .oob]) XCTAssertEqual(request.bodyParameters?.stringComponents, diff --git a/Tests/OktaOAuth2Tests/AuthorizationCodeFlowContextTests.swift b/Tests/OktaOAuth2Tests/AuthorizationCodeFlowContextTests.swift new file mode 100644 index 000000000..9423a909a --- /dev/null +++ b/Tests/OktaOAuth2Tests/AuthorizationCodeFlowContextTests.swift @@ -0,0 +1,82 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") +// +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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. +// + +import XCTest +@testable import TestCommon +@testable import AuthFoundation +@testable import OktaOAuth2 + +class AuthorizationCodeFlowContextTests: XCTestCase { + typealias Prompt = AuthorizationCodeFlow.Prompt + typealias Context = AuthorizationCodeFlow.Context + + func testPrompts() throws { + XCTAssertEqual(Prompt.none.rawValue, "none") + XCTAssertEqual(Prompt.login.rawValue, "login") + XCTAssertEqual(Prompt.consent.rawValue, "consent") + XCTAssertEqual(Prompt.loginAndConsent.rawValue, "login consent") + XCTAssertEqual(Prompt(rawValue: "NONE"), Prompt.none) + XCTAssertEqual(Prompt(rawValue: "LOGIN"), .login) + XCTAssertEqual(Prompt(rawValue: "CONSENT"), .consent) + XCTAssertEqual(Prompt(rawValue: "LOGIN CONSENT"), .loginAndConsent) + XCTAssertEqual(Prompt(rawValue: "consent login"), .loginAndConsent) + XCTAssertNil(Prompt(rawValue: "something invalid")) + } + + func testContextInitializers() throws { + var context = Context() + XCTAssertNotNil(context.state) + XCTAssertNil(context.maxAge) + + context = Context(state: "foo") + XCTAssertEqual(context.state, "foo") + XCTAssertNil(context.maxAge) + + context = Context(maxAge: 100) + XCTAssertNotNil(context.state) + XCTAssertEqual(context.maxAge, 100) + + context = Context(additionalParameters: [ + "nonce": "some_nonce", + "state": "baz", + "acr_values": "urn:ietf:params:acr:nist:1 urn:ietf:params:acr:nist:2", + "max_age": "50", + "login_hint": "user@example.com", + "id_token_hint": "abcdef123456", + "ui_locales": "en-US fr", + "claims_locales": "en-US fr", + "display": "mobile", + "prompt": "none", + "name": "value", + ]) + XCTAssertEqual(context.nonce, "some_nonce") + XCTAssertEqual(context.state, "baz") + XCTAssertEqual(context.acrValues, [ + "urn:ietf:params:acr:nist:1", + "urn:ietf:params:acr:nist:2", + ]) + XCTAssertEqual(context.maxAge, 50) + XCTAssertEqual(context.loginHint, "user@example.com") + XCTAssertEqual(context.idTokenHint, "abcdef123456") + XCTAssertEqual(context.display, "mobile") + XCTAssertEqual(context.prompt, Prompt.none) + XCTAssertEqual(context.uiLocales, ["en-US", "fr"]) + XCTAssertEqual(context.claimsLocales, ["en-US", "fr"]) + XCTAssertEqual(context.additionalParameters?["name"] as? String, "value") + XCTAssertEqual(Array(try XCTUnwrap(context.additionalParameters?.keys)), ["name"]) + + context = Context(additionalParameters: ["prompt": "somethingInvalid"]) + XCTAssertNil(context.prompt) + XCTAssertEqual(context.additionalParameters?["prompt"] as? String, "somethingInvalid") + } +} + diff --git a/Tests/OktaOAuth2Tests/AuthorizationCodeFlowSuccessTests.swift b/Tests/OktaOAuth2Tests/AuthorizationCodeFlowSuccessTests.swift index 7d555595e..5928d9cda 100644 --- a/Tests/OktaOAuth2Tests/AuthorizationCodeFlowSuccessTests.swift +++ b/Tests/OktaOAuth2Tests/AuthorizationCodeFlowSuccessTests.swift @@ -55,9 +55,10 @@ final class AuthorizationCodeFlowSuccessTests: XCTestCase { var flow: AuthorizationCodeFlow! override func setUpWithError() throws { - client = OAuth2Client(baseURL: issuer, + client = OAuth2Client(issuerURL: issuer, clientId: "clientId", - scopes: "openid profile", + scope: "openid profile", + redirectUri: redirectUri, session: urlSession) JWK.validator = MockJWKValidator() Token.idTokenValidator = MockIDTokenValidator() @@ -73,8 +74,7 @@ final class AuthorizationCodeFlowSuccessTests: XCTestCase { data: try data(from: .module, for: "keys", in: "MockResponses"), contentType: "application/json") - flow = client.authorizationCodeFlow(redirectUri: redirectUri, - additionalParameters: ["additional": "param"]) + flow = try client.authorizationCodeFlow(additionalParameters: ["additional": "param"]) } override func tearDownWithError() throws { @@ -92,9 +92,14 @@ final class AuthorizationCodeFlowSuccessTests: XCTestCase { XCTAssertFalse(delegate.started) // Begin - let context = AuthorizationCodeFlow.Context(state: "ABC123", maxAge: nil, nonce: "nonce_string", pkce: nil) + let context = AuthorizationCodeFlow.Context(pkce: nil, + nonce: "nonce_string", + maxAge: nil, + acrValues: nil, + state: "ABC123", + additionalParameters: ["foo": "bar"]) var expect = expectation(description: "network request") - flow.start(with: context, additionalParameters: ["foo": "bar"]) { _ in + flow.start(with: context) { _ in expect.fulfill() } waitForExpectations(timeout: 1.0) { error in @@ -130,10 +135,15 @@ final class AuthorizationCodeFlowSuccessTests: XCTestCase { XCTAssertFalse(flow.isAuthenticating) // Begin - let context = AuthorizationCodeFlow.Context(state: "ABC123", maxAge: nil, nonce: "nonce_string", pkce: nil) + let context = AuthorizationCodeFlow.Context(pkce: nil, + nonce: "nonce_string", + maxAge: nil, + acrValues: ["some:acr:value"], + state: "ABC123", + additionalParameters: ["foo": "bar"]) var wait = expectation(description: "resume") var url: URL? - flow.start(with: context, additionalParameters: ["foo": "bar"]) { result in + flow.start(with: context) { result in switch result { case .success(let redirectUrl): url = redirectUrl @@ -151,7 +161,7 @@ final class AuthorizationCodeFlowSuccessTests: XCTestCase { XCTAssertNotNil(flow.context?.authenticationURL) XCTAssertEqual(url, flow.context?.authenticationURL) XCTAssertEqual(flow.context?.authenticationURL?.absoluteString, - "https://example.okta.com/oauth2/v1/authorize?additional=param&client_id=clientId&foo=bar&nonce=nonce_string&redirect_uri=com.example:/callback&response_type=code&scope=openid%20profile&state=ABC123") + "https://example.okta.com/oauth2/v1/authorize?acr_values=some:acr:value&additional=param&client_id=clientId&foo=bar&nonce=nonce_string&redirect_uri=com.example:/callback&response_type=code&scope=openid%20profile&state=ABC123") // Exchange code var token: Token? @@ -172,6 +182,20 @@ final class AuthorizationCodeFlowSuccessTests: XCTestCase { XCTAssertNil(flow.context) XCTAssertFalse(flow.isAuthenticating) XCTAssertNotNil(token) + + XCTAssertEqual(token?.context.clientSettings, [ + "acr_values": "some:acr:value", + ]) + + XCTAssertEqual(try XCTUnwrap(urlSession.formDecodedBody(matching: "/v1/token")), [ + "grant_type": "authorization_code", + "redirect_uri": "com.example:/callback", + "scope": "openid+profile", + "client_id": "clientId", + "code": "ABCEasyAs123", + "additional": "param", + "foo": "bar", + ]) } @available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6, *) @@ -181,8 +205,13 @@ final class AuthorizationCodeFlowSuccessTests: XCTestCase { XCTAssertFalse(flow.isAuthenticating) // Begin - let context = AuthorizationCodeFlow.Context(state: "ABC123", maxAge: nil, nonce: "nonce_string", pkce: nil) - let url = try await flow.start(with: context, additionalParameters: ["foo": "bar"]) + let context = AuthorizationCodeFlow.Context(pkce: nil, + nonce: "nonce_string", + maxAge: nil, + acrValues: nil, + state: "ABC123", + additionalParameters: ["foo": "bar"]) + let url = try await flow.start(with: context) XCTAssertEqual(flow.context?.state, context.state) XCTAssertTrue(flow.isAuthenticating) @@ -202,8 +231,8 @@ final class AuthorizationCodeFlowSuccessTests: XCTestCase { func testAuthorizationCodeFromURL() throws { typealias RedirectError = AuthorizationCodeFlow.RedirectError - XCTAssertThrowsError(try flow.authorizationCode(from: URL(string: "https://example.com")!)) { error in - XCTAssertEqual(error as? AuthenticationError, .flowNotReady) + XCTAssertThrowsError(try URL(string: "https://example.com")!.authorizationCode(redirectUri: redirectUri, state: "ABC123" )) { error in + XCTAssertEqual(error as? RedirectError, .unexpectedScheme("https")) } let wait = expectation(description: "Start the flow") @@ -212,7 +241,7 @@ final class AuthorizationCodeFlowSuccessTests: XCTestCase { } waitForExpectations(timeout: 1) - XCTAssertEqual(try flow.authorizationCode(from: URL(string: "com.example:/?state=ABC123&code=foo")!), "foo") + XCTAssertEqual(try URL(string: "com.example:/?state=ABC123&code=foo")!.authorizationCode(redirectUri: redirectUri, state: "ABC123"), "foo") } func testTokenRequestParameters() throws { @@ -222,44 +251,50 @@ final class AuthorizationCodeFlowSuccessTests: XCTestCase { var request: AuthorizationCodeFlow.TokenRequest // No authentication - request = .init(openIdConfiguration: openIdConfiguration, - clientConfiguration: try .init(domain: "example.com", - clientId: "theClientId", - scopes: "openid profile"), - redirectUri: "https://example.com/redirect", - grantType: .authorizationCode, - grantValue: "abcd123", - pkce: pkce, - nonce: "nonce_str", - maxAge: 60, - authenticationFlowConfiguration: nil) + request = try .init(openIdConfiguration: openIdConfiguration, + clientConfiguration: .init(issuerURL: issuer, + clientId: "theClientId", + scope: "openid profile", + redirectUri: URL(string: "https://example.com/redirect")!), + additionalParameters: nil, + context: .init(pkce: pkce, + nonce: "nonce_str", + maxAge: 60, + acrValues: nil, + state: "the_state", + additionalParameters: nil), + authorizationCode: "abcd123") XCTAssertEqual(request.bodyParameters?.stringComponents, [ "client_id": "theClientId", "redirect_uri": "https://example.com/redirect", + "scope": "openid profile", "grant_type": "authorization_code", "code_verifier": pkce.codeVerifier, "code": "abcd123" ]) // Client Secret authentication - request = .init(openIdConfiguration: openIdConfiguration, - clientConfiguration: try .init(domain: "example.com", + request = try .init(openIdConfiguration: openIdConfiguration, + clientConfiguration: .init(issuerURL: issuer, clientId: "theClientId", - scopes: "openid profile", + scope: "openid profile", + redirectUri: URL(string: "https://example.com/redirect")!, authentication: .clientSecret("supersecret")), - redirectUri: "https://example.com/redirect", - grantType: .authorizationCode, - grantValue: "abcd123", - pkce: pkce, - nonce: "nonce_str", - maxAge: 60, - authenticationFlowConfiguration: nil) + additionalParameters: nil, + context: .init(pkce: pkce, + nonce: "nonce_str", + maxAge: 60, + acrValues: nil, + state: "the_state", + additionalParameters: nil), + authorizationCode: "abcd123") XCTAssertEqual(request.bodyParameters?.stringComponents, [ "client_id": "theClientId", "client_secret": "supersecret", "redirect_uri": "https://example.com/redirect", + "scope": "openid profile", "grant_type": "authorization_code", "code_verifier": pkce.codeVerifier, "code": "abcd123" diff --git a/Tests/OktaOAuth2Tests/DeviceAuthorizationFlowErrorTests.swift b/Tests/OktaOAuth2Tests/DeviceAuthorizationFlowErrorTests.swift index 5619d6a31..8eb05dc98 100644 --- a/Tests/OktaOAuth2Tests/DeviceAuthorizationFlowErrorTests.swift +++ b/Tests/OktaOAuth2Tests/DeviceAuthorizationFlowErrorTests.swift @@ -22,9 +22,9 @@ final class DeviceAuthorizationFlowErrorTests: XCTestCase { var flow: DeviceAuthorizationFlow! override func setUpWithError() throws { - client = OAuth2Client(baseURL: issuer, + client = OAuth2Client(issuerURL: issuer, clientId: "clientId", - scopes: "openid profile", + scope: "openid profile", session: urlSession) JWK.validator = MockJWKValidator() Token.idTokenValidator = MockIDTokenValidator() @@ -88,11 +88,11 @@ final class DeviceAuthorizationFlowErrorTests: XCTestCase { // Begin var wait = expectation(description: "resume") - var context: DeviceAuthorizationFlow.Context? + var verification: DeviceAuthorizationFlow.Verification? flow.start { result in switch result { case .success(let response): - context = response + verification = response case .failure(let error): XCTAssertNil(error) } @@ -102,18 +102,18 @@ final class DeviceAuthorizationFlowErrorTests: XCTestCase { XCTAssertNil(error) } - context = try XCTUnwrap(context) - XCTAssertEqual(flow.context?.deviceCode, context?.deviceCode) + verification = try XCTUnwrap(verification) + XCTAssertEqual(flow.context?.verification?.deviceCode, verification?.deviceCode) XCTAssertTrue(flow.isAuthenticating) - XCTAssertNotNil(flow.context?.verificationUri) - XCTAssertEqual(context, flow.context) - XCTAssertEqual(flow.context?.verificationUri.absoluteString, "https://example.okta.com/activate") - XCTAssertEqual(flow.context?.interval, 1) + XCTAssertNotNil(flow.context?.verification?.verificationUri) + XCTAssertEqual(verification, flow.context?.verification) + XCTAssertEqual(flow.context?.verification?.verificationUri.absoluteString, "https://example.okta.com/activate") + XCTAssertEqual(flow.context?.verification?.interval, 1) // Exchange code var token: Token? wait = expectation(description: "resume") - flow.resume(with: context!) { result in + flow.resume(with: verification!) { result in switch result { case .success(let resultToken): token = resultToken diff --git a/Tests/OktaOAuth2Tests/DeviceAuthorizationFlowSuccessTests.swift b/Tests/OktaOAuth2Tests/DeviceAuthorizationFlowSuccessTests.swift index e63a2b03b..099626055 100644 --- a/Tests/OktaOAuth2Tests/DeviceAuthorizationFlowSuccessTests.swift +++ b/Tests/OktaOAuth2Tests/DeviceAuthorizationFlowSuccessTests.swift @@ -22,9 +22,9 @@ final class DeviceAuthorizationFlowSuccessTests: XCTestCase { var flow: DeviceAuthorizationFlow! override func setUpWithError() throws { - client = OAuth2Client(baseURL: issuer, + client = OAuth2Client(issuerURL: issuer, clientId: "clientId", - scopes: "openid profile", + scope: "openid profile", session: urlSession) JWK.validator = MockJWKValidator() Token.idTokenValidator = MockIDTokenValidator() @@ -68,13 +68,13 @@ final class DeviceAuthorizationFlowSuccessTests: XCTestCase { XCTAssertNil(error) } - XCTAssertNotNil(delegate.context) - XCTAssertEqual(flow.context, delegate.context) + XCTAssertNotNil(delegate.verification) + XCTAssertEqual(flow.context?.verification, delegate.verification) XCTAssertTrue(flow.isAuthenticating) - XCTAssertEqual(delegate.context?.verificationUri.absoluteString, "https://example.okta.com/activate") + XCTAssertEqual(delegate.verification?.verificationUri.absoluteString, "https://example.okta.com/activate") XCTAssertTrue(delegate.started) - let context = try XCTUnwrap(delegate.context) + let context = try XCTUnwrap(delegate.verification) // Exchange code expect = expectation(description: "Wait for timer") @@ -97,11 +97,11 @@ final class DeviceAuthorizationFlowSuccessTests: XCTestCase { // Begin var wait = expectation(description: "resume") - var context: DeviceAuthorizationFlow.Context? + var verification: DeviceAuthorizationFlow.Verification? flow.start { result in switch result { case .success(let response): - context = response + verification = response case .failure(let error): XCTAssertNil(error) } @@ -111,17 +111,17 @@ final class DeviceAuthorizationFlowSuccessTests: XCTestCase { XCTAssertNil(error) } - context = try XCTUnwrap(context) - XCTAssertEqual(flow.context?.deviceCode, context?.deviceCode) + verification = try XCTUnwrap(verification) + XCTAssertEqual(flow.context?.verification?.deviceCode, verification?.deviceCode) XCTAssertTrue(flow.isAuthenticating) - XCTAssertNotNil(flow.context?.verificationUri) - XCTAssertEqual(context, flow.context) - XCTAssertEqual(flow.context?.verificationUri.absoluteString, "https://example.okta.com/activate") + XCTAssertNotNil(flow.context?.verification?.verificationUri) + XCTAssertEqual(verification, flow.context?.verification) + XCTAssertEqual(flow.context?.verification?.verificationUri.absoluteString, "https://example.okta.com/activate") // Exchange code var token: Token? wait = expectation(description: "resume") - flow.resume(with: context!) { result in + flow.resume(with: verification!) { result in switch result { case .success(let resultToken): token = resultToken @@ -146,15 +146,15 @@ final class DeviceAuthorizationFlowSuccessTests: XCTestCase { XCTAssertFalse(flow.isAuthenticating) // Begin - let context = try await flow.start() + let verification = try await flow.start() - XCTAssertEqual(flow.context, context) + XCTAssertEqual(flow.context?.verification, verification) XCTAssertTrue(flow.isAuthenticating) - XCTAssertEqual(context, flow.context) - XCTAssertEqual(flow.context?.verificationUri.absoluteString, "https://example.okta.com/activate") + XCTAssertEqual(verification, flow.context?.verification) + XCTAssertEqual(flow.context?.verification?.verificationUri.absoluteString, "https://example.okta.com/activate") // Exchange code - let token = try await flow.resume(with: context) + let token = try await flow.resume(with: verification) XCTAssertNil(flow.context) XCTAssertFalse(flow.isAuthenticating) @@ -170,7 +170,7 @@ final class DeviceAuthorizationFlowSuccessTests: XCTestCase { "expires_in": 600 } """) - let context = try defaultJSONDecoder.decode(DeviceAuthorizationFlow.Context.self, from: data) + let context = try defaultJSONDecoder.decode(DeviceAuthorizationFlow.Verification.self, from: data) XCTAssertEqual(context.deviceCode, "1a521d9f-0922-4e6d-8db9-8b654297435a") XCTAssertEqual(context.userCode, "GDLMZQCT") diff --git a/Tests/OktaOAuth2Tests/JWTAuthorizationFlowTests.swift b/Tests/OktaOAuth2Tests/JWTAuthorizationFlowTests.swift index 8f011b568..209e33b9e 100644 --- a/Tests/OktaOAuth2Tests/JWTAuthorizationFlowTests.swift +++ b/Tests/OktaOAuth2Tests/JWTAuthorizationFlowTests.swift @@ -51,9 +51,9 @@ final class JWTAuthorizationFlowTests: XCTestCase { var jwt: JWT! override func setUpWithError() throws { - client = OAuth2Client(baseURL: issuer, + client = OAuth2Client(issuerURL: issuer, clientId: "clientId", - scopes: "profile openid", + scope: "profile openid", session: urlSession) JWK.validator = MockJWKValidator() Token.idTokenValidator = MockIDTokenValidator() @@ -106,7 +106,12 @@ final class JWTAuthorizationFlowTests: XCTestCase { })) XCTAssertEqual(request.url?.absoluteString, "https://example.okta.com/oauth2/v1/token") - XCTAssertEqual(request.bodyString, "assertion=\(JWT.mockIDToken)&client_id=clientId&grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&scope=profile+openid") + XCTAssertEqual(request.httpBody?.urlFormEncoded, [ + "assertion": JWT.mockIDToken, + "client_id": "clientId", + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "scope": "profile+openid", + ]) } func testAuthenticationSucceeded() throws { diff --git a/Tests/OktaOAuth2Tests/OAuth2ClientTests.swift b/Tests/OktaOAuth2Tests/OAuth2ClientTests.swift index 2881383de..0faf3f23e 100644 --- a/Tests/OktaOAuth2Tests/OAuth2ClientTests.swift +++ b/Tests/OktaOAuth2Tests/OAuth2ClientTests.swift @@ -11,7 +11,7 @@ final class OAuth2ClientTests: XCTestCase { override func setUpWithError() throws { urlSession = URLSessionMock() - client = OAuth2Client(baseURL: issuer, clientId: "theClientId", scopes: "openid profile offline_access", session: urlSession) + client = OAuth2Client(issuerURL: issuer, clientId: "theClientId", scope: "openid profile offline_access", session: urlSession) JWK.validator = MockJWKValidator() Token.idTokenValidator = MockIDTokenValidator() @@ -24,28 +24,32 @@ final class OAuth2ClientTests: XCTestCase { } func testAuthorizationCodeConstructor() throws { - let flow = AuthorizationCodeFlow(issuer: issuer, + let flow = AuthorizationCodeFlow(issuerURL: issuer, clientId: "theClientId", - scopes: "openid profile offline_access", + scope: "openid profile offline_access", redirectUri: redirectUri) XCTAssertNotNil(flow) } func testExchange() throws { - let pkce = PKCE() + let pkce = try XCTUnwrap(PKCE()) let (openIdConfiguration, openIdData) = try openIdConfiguration() let clientConfiguration = try OAuth2Client.Configuration(domain: "example.com", - clientId: "client_id", - scopes: "openid profile offline_access") - let request = AuthorizationCodeFlow.TokenRequest(openIdConfiguration: openIdConfiguration, - clientConfiguration: clientConfiguration, - redirectUri: redirectUri.absoluteString, - grantType: .authorizationCode, - grantValue: "abc123", - pkce: pkce, - nonce: nil, - maxAge: nil, - authenticationFlowConfiguration: nil) + clientId: "theClientId", + scope: "openid profile offline_access", + redirectUri: redirectUri.absoluteString) + let context = AuthorizationCodeFlow.Context(pkce: pkce, + nonce: .nonce(), + maxAge: nil, + acrValues: nil, + state: "state", + additionalParameters: nil) + let request = try AuthorizationCodeFlow.TokenRequest( + openIdConfiguration: openIdConfiguration, + clientConfiguration: clientConfiguration, + additionalParameters: nil, + context: context, + authorizationCode: "abc123") urlSession.expect("https://example.com/oauth2/default/.well-known/openid-configuration", data: openIdData, @@ -71,6 +75,14 @@ final class OAuth2ClientTests: XCTestCase { waitForExpectations(timeout: 1.0) { error in XCTAssertNil(error) } + XCTAssertEqual(try XCTUnwrap(urlSession.formDecodedBody(matching: "/v1/token")), [ + "grant_type": "authorization_code", + "code_verifier": pkce.codeVerifier, + "code": "abc123", + "scope": "openid+profile+offline_access", + "redirect_uri": "com.example:/callback", + "client_id": "theClientId" + ]) XCTAssertNotNil(token) XCTAssertEqual(token?.tokenType, "Bearer") @@ -82,21 +94,25 @@ final class OAuth2ClientTests: XCTestCase { } func testExchangeFailed() throws { - let pkce = PKCE() + let pkce = try XCTUnwrap(PKCE()) Token.idTokenValidator = MockIDTokenValidator(error: .invalidIssuer) let (openIdConfiguration, openIdData) = try openIdConfiguration(named: "openid-configuration-invalid-issuer") let clientConfiguration = try OAuth2Client.Configuration(domain: "example.com", - clientId: "client_id", - scopes: "openid profile offline_access") - let request = AuthorizationCodeFlow.TokenRequest(openIdConfiguration: openIdConfiguration, - clientConfiguration: clientConfiguration, - redirectUri: redirectUri.absoluteString, - grantType: .authorizationCode, - grantValue: "abc123", - pkce: pkce, - nonce: nil, - maxAge: nil, - authenticationFlowConfiguration: nil) + clientId: "theClientId", + scope: "openid profile offline_access", + redirectUri: redirectUri.absoluteString) + let context = AuthorizationCodeFlow.Context(pkce: pkce, + nonce: .nonce(), + maxAge: nil, + acrValues: nil, + state: "state", + additionalParameters: nil) + let request = try AuthorizationCodeFlow.TokenRequest( + openIdConfiguration: openIdConfiguration, + clientConfiguration: clientConfiguration, + additionalParameters: nil, + context: context, + authorizationCode: "abc123") urlSession.expect("https://example.com/oauth2/default/.well-known/openid-configuration", data: openIdData, @@ -123,5 +139,14 @@ final class OAuth2ClientTests: XCTestCase { waitForExpectations(timeout: 1.0) { error in XCTAssertNil(error) } + + XCTAssertEqual(try XCTUnwrap(urlSession.formDecodedBody(matching: "/v1/token")), [ + "grant_type": "authorization_code", + "code_verifier": pkce.codeVerifier, + "code": "abc123", + "scope": "openid+profile+offline_access", + "redirect_uri": "com.example:/callback", + "client_id": "theClientId" + ]) } } diff --git a/Tests/OktaOAuth2Tests/ResourceOwnerFlowTests.swift b/Tests/OktaOAuth2Tests/ResourceOwnerFlowTests.swift index c60f03696..a7add870a 100644 --- a/Tests/OktaOAuth2Tests/ResourceOwnerFlowTests.swift +++ b/Tests/OktaOAuth2Tests/ResourceOwnerFlowTests.swift @@ -45,9 +45,9 @@ final class ResourceOwnerFlowSuccessTests: XCTestCase { var flow: ResourceOwnerFlow! override func setUpWithError() throws { - client = OAuth2Client(baseURL: issuer, + client = OAuth2Client(issuerURL: issuer, clientId: "clientId", - scopes: "openid profile", + scope: "openid profile", session: urlSession) JWK.validator = MockJWKValidator() Token.idTokenValidator = MockIDTokenValidator() diff --git a/Tests/OktaOAuth2Tests/SessionLogoutFlowFailureTests.swift b/Tests/OktaOAuth2Tests/SessionLogoutFlowFailureTests.swift index a64a04a22..9d0e6e015 100644 --- a/Tests/OktaOAuth2Tests/SessionLogoutFlowFailureTests.swift +++ b/Tests/OktaOAuth2Tests/SessionLogoutFlowFailureTests.swift @@ -26,13 +26,18 @@ class SessionLogoutFlowFailureTests: XCTestCase { let state = "state" override func setUpWithError() throws { - client = OAuth2Client(baseURL: issuer, clientId: "clientId", scopes: "openid", session: urlSession) + client = OAuth2Client(issuerURL: issuer, + clientId: "clientId", + scope: "openid", + redirectUri: redirectUri, + logoutRedirectUri: logoutRedirectUri, + session: urlSession) urlSession.expect("https://example.com/.well-known/openid-configuration", data: nil, error: OAuth2Error.cannotComposeUrl) - flow = SessionLogoutFlow(logoutRedirectUri: logoutRedirectUri, client: client) + flow = client.sessionLogoutFlow() } func testDelegate() throws { @@ -47,7 +52,7 @@ class SessionLogoutFlowFailureTests: XCTestCase { let context = SessionLogoutFlow.Context(idToken: logoutIDToken, state: state) let resumeExpection = expectation(description: "Expect success") - try flow.start(with: context) { result in + flow.start(with: context) { result in XCTAssertTrue(self.flow.inProgress) resumeExpection.fulfill() } @@ -70,7 +75,7 @@ class SessionLogoutFlowFailureTests: XCTestCase { let context = SessionLogoutFlow.Context(idToken: logoutIDToken, state: state) let resumeExpection = expectation(description: "Expect success") - try flow.start(with: context) { result in + flow.start(with: context) { result in switch result { case .success: XCTFail() diff --git a/Tests/OktaOAuth2Tests/SessionLogoutFlowSuccessTests.swift b/Tests/OktaOAuth2Tests/SessionLogoutFlowSuccessTests.swift index a3122d2c2..1b67c2b46 100644 --- a/Tests/OktaOAuth2Tests/SessionLogoutFlowSuccessTests.swift +++ b/Tests/OktaOAuth2Tests/SessionLogoutFlowSuccessTests.swift @@ -44,13 +44,18 @@ final class SessionLogoutFlowSuccessTests: XCTestCase { let state = "state" override func setUpWithError() throws { - client = OAuth2Client(baseURL: issuer, clientId: "clientId", scopes: "openid", session: urlSession) + client = OAuth2Client(issuerURL: issuer, + clientId: "clientId", + scope: "openid", + redirectUri: redirectUri, + logoutRedirectUri: logoutRedirectUri, + session: urlSession) urlSession.expect("https://example.com/.well-known/openid-configuration", data: try data(from: .module, for: "openid-configuration", in: "MockResponses"), contentType: "application/json") - flow = SessionLogoutFlow(logoutRedirectUri: logoutRedirectUri, client: client) + flow = SessionLogoutFlow(client: client) } func testWithDelegate() throws { @@ -65,9 +70,8 @@ final class SessionLogoutFlowSuccessTests: XCTestCase { let context = SessionLogoutFlow.Context(idToken: logoutIDToken, state: state) let resumeExpection = expectation(description: "Expect success") - try flow.start(with: context) { result in + flow.start(with: context) { result in XCTAssertTrue(self.flow.inProgress) - XCTAssertNotEqual(self.flow.context, context) XCTAssertEqual(self.flow.context?.state, context.state) resumeExpection.fulfill() } @@ -76,7 +80,8 @@ final class SessionLogoutFlowSuccessTests: XCTestCase { XCTAssertEqual(delegate.url?.absoluteString, """ https://example.okta.com/oauth2/v1/logout\ - ?id_token_hint=\(logoutIDToken)\ + ?client_id=clientId\ + &id_token_hint=\(logoutIDToken)\ &post_logout_redirect_uri=\(logoutRedirectUri.absoluteString)\ &state=\(state)\ #\(delegate.fragment) @@ -93,7 +98,7 @@ final class SessionLogoutFlowSuccessTests: XCTestCase { let context = SessionLogoutFlow.Context(idToken: logoutIDToken, state: state) let resumeExpection = expectation(description: "Expect success") - try flow.start(with: context) { result in + flow.start(with: context) { result in switch result { case .success(let url): XCTAssertEqual(url, self.flow.context?.logoutURL) @@ -104,12 +109,12 @@ final class SessionLogoutFlowSuccessTests: XCTestCase { XCTAssertTrue(self.flow.inProgress) let newContext = self.flow.context - XCTAssertNotEqual(newContext, context) XCTAssertEqual(newContext?.state, context.state) XCTAssertNotNil(newContext?.logoutURL) XCTAssertEqual(newContext?.logoutURL?.absoluteString, """ https://example.okta.com/oauth2/v1/logout\ - ?id_token_hint=\(self.logoutIDToken)\ + ?client_id=clientId\ + &id_token_hint=\(self.logoutIDToken)\ &post_logout_redirect_uri=\(self.logoutRedirectUri.absoluteString)\ &state=\(self.state) """) @@ -126,15 +131,18 @@ final class SessionLogoutFlowSuccessTests: XCTestCase { XCTAssertNil(flow.context) XCTAssertFalse(flow.inProgress) - let context = SessionLogoutFlow.Context(idToken: logoutIDToken, state: state) + let context = SessionLogoutFlow.Context(idToken: logoutIDToken, + state: state, + additionalParameters: ["prompt": "login"]) let resumeExpection = expectation(description: "Expect success") - try flow.start(with: context, additionalParameters: ["prompt": "login"]) { result in + flow.start(with: context) { result in switch result { case .success(let url): XCTAssertEqual(url.absoluteString, """ https://example.okta.com/oauth2/v1/logout\ - ?id_token_hint=\(self.logoutIDToken)\ + ?client_id=clientId\ + &id_token_hint=\(self.logoutIDToken)\ &prompt=login\ &state=\(self.state) """) @@ -160,7 +168,8 @@ final class SessionLogoutFlowSuccessTests: XCTestCase { XCTAssertNotEqual(flow.context, context) XCTAssertEqual(logoutUrl.absoluteString, """ https://example.okta.com/oauth2/v1/logout\ - ?id_token_hint=\(logoutIDToken)\ + ?client_id=clientId\ + &id_token_hint=\(logoutIDToken)\ &post_logout_redirect_uri=\(logoutRedirectUri.absoluteString)\ &state=\(state) """) diff --git a/Tests/OktaOAuth2Tests/SessionTokenFlowTests.swift b/Tests/OktaOAuth2Tests/SessionTokenFlowTests.swift index 7b47f9e75..87bac45bc 100644 --- a/Tests/OktaOAuth2Tests/SessionTokenFlowTests.swift +++ b/Tests/OktaOAuth2Tests/SessionTokenFlowTests.swift @@ -45,9 +45,10 @@ final class SessionTokenFlowSuccessTests: XCTestCase { var flow: SessionTokenFlow! override func setUpWithError() throws { - client = OAuth2Client(baseURL: issuer, + client = OAuth2Client(issuerURL: issuer, clientId: "clientId", - scopes: "openid profile", + scope: "openid profile", + redirectUri: redirectUri, session: urlSession) JWK.validator = MockJWKValidator() Token.idTokenValidator = MockIDTokenValidator() @@ -64,8 +65,7 @@ final class SessionTokenFlowSuccessTests: XCTestCase { data: try data(from: .module, for: "token", in: "MockResponses"), contentType: "application/json") - flow = client.sessionTokenFlow(redirectUri: redirectUri, - additionalParameters: ["additional": "param"]) + flow = try client.sessionTokenFlow(additionalParameters: ["additional": "param"]) } override func tearDownWithError() throws { diff --git a/Tests/OktaOAuth2Tests/TokenExchangeFlowTests.swift b/Tests/OktaOAuth2Tests/TokenExchangeFlowTests.swift index 105025a9b..9ada6987a 100644 --- a/Tests/OktaOAuth2Tests/TokenExchangeFlowTests.swift +++ b/Tests/OktaOAuth2Tests/TokenExchangeFlowTests.swift @@ -51,9 +51,9 @@ final class TokenExchangeFlowTests: XCTestCase { private let tokens: [TokenExchangeFlow.TokenType] = [.actor(type: .deviceSecret, value: "secret"), .subject(type: .idToken, value: "id_token")] override func setUpWithError() throws { - client = OAuth2Client(baseURL: issuer, + client = OAuth2Client(issuerURL: issuer, clientId: "clientId", - scopes: "profile openid device_sso", + scope: "profile openid device_sso", session: urlSession) JWK.validator = MockJWKValidator() Token.idTokenValidator = MockIDTokenValidator() @@ -69,7 +69,7 @@ final class TokenExchangeFlowTests: XCTestCase { data: try data(from: .module, for: "token", in: "MockResponses"), contentType: "application/json") - flow = client.tokenExchangeFlow(audience: .default) + flow = client.tokenExchangeFlow() } override func tearDownWithError() throws { diff --git a/Tests/OktaOAuth2Tests/Utilities/DeviceAuthorizationFlowDelegateRecorder.swift b/Tests/OktaOAuth2Tests/Utilities/DeviceAuthorizationFlowDelegateRecorder.swift index 62e8eeb95..c5feef3a9 100644 --- a/Tests/OktaOAuth2Tests/Utilities/DeviceAuthorizationFlowDelegateRecorder.swift +++ b/Tests/OktaOAuth2Tests/Utilities/DeviceAuthorizationFlowDelegateRecorder.swift @@ -14,7 +14,7 @@ import Foundation import OktaOAuth2 class DeviceAuthorizationFlowDelegateRecorder: DeviceAuthorizationFlowDelegate { - var context: DeviceAuthorizationFlow.Context? + var verification: DeviceAuthorizationFlow.Verification? var token: Token? var error: OAuth2Error? var url: URL? @@ -29,8 +29,8 @@ class DeviceAuthorizationFlowDelegateRecorder: DeviceAuthorizationFlowDelegate { finished = true } - func authentication(flow: Flow, received context: DeviceAuthorizationFlow.Context) { - self.context = context + func authentication(flow: Flow, received verification: DeviceAuthorizationFlow.Verification) { + self.verification = verification } func authentication(flow: Flow, received token: Token) { diff --git a/Tests/TestCommon/Data+Extensions.swift b/Tests/TestCommon/Data+Extensions.swift index f81b4180c..b32ec47f2 100644 --- a/Tests/TestCommon/Data+Extensions.swift +++ b/Tests/TestCommon/Data+Extensions.swift @@ -13,7 +13,7 @@ import Foundation extension Data { - func urlFormEncoded() -> [String:String?]? { + var urlFormEncoded: [String: String?]? { guard let string = String(data: self, encoding: .utf8), let url = URL(string: "?\(string)"), let components = URLComponents(url: url, resolvingAgainstBaseURL: false), diff --git a/Tests/TestCommon/MockApiClient.swift b/Tests/TestCommon/MockApiClient.swift index c0680ece5..3e39bf687 100644 --- a/Tests/TestCommon/MockApiClient.swift +++ b/Tests/TestCommon/MockApiClient.swift @@ -35,8 +35,8 @@ class MockApiClient: APIClient { self.shouldRetry = shouldRetry } - func decode(_ type: T.Type, from data: Data, userInfo: [CodingUserInfoKey : Any]?) throws -> T where T : Decodable { - var info: [CodingUserInfoKey: Any] = userInfo ?? [:] + func decode(_ type: T.Type, from data: Data, parsing context: (any APIParsingContext)?) throws -> T where T : Decodable { + var info: [CodingUserInfoKey: Any] = context?.codingUserInfo ?? [:] if info[.apiClientConfiguration] == nil { info[.apiClientConfiguration] = configuration } diff --git a/Tests/TestCommon/MockToken.swift b/Tests/TestCommon/MockToken.swift index 6b80b04fa..cc3b01e9e 100644 --- a/Tests/TestCommon/MockToken.swift +++ b/Tests/TestCommon/MockToken.swift @@ -21,9 +21,9 @@ extension Token { } static let mockConfiguration = OAuth2Client.Configuration( - baseURL: URL(string: "https://example.com")!, + issuerURL: URL(string: "https://example.com")!, clientId: "0oa3en4fIMQ3ddc204w5", - scopes: "offline_access profile openid") + scope: "offline_access profile openid") static let simpleMockToken = mockToken() @@ -49,18 +49,18 @@ extension Token { } static func token(with options: [MockOptions] = []) -> Token { - var scopes = "openid" + var scope = "openid" var refreshToken: String? = nil if options.contains(.refreshToken) { refreshToken = "refresh123" - scopes += " offline_access" + scope += " offline_access" } var deviceSecret: String? = nil if options.contains(.deviceSecret) { deviceSecret = "device123" - scopes += " device_sso" + scope += " device_sso" } var idToken: JWT? = nil @@ -73,14 +73,14 @@ extension Token { tokenType: "Bearer", expiresIn: 300, accessToken: JWT.mockAccessToken, - scope: scopes, + scope: scope, refreshToken: refreshToken, idToken: idToken, deviceSecret: deviceSecret, - context: Token.Context(configuration: .init(baseURL: URL(string: "https://example.com/oauth2/default")!, + context: Token.Context(configuration: .init(issuerURL: URL(string: "https://example.com/oauth2/default")!, clientId: "clientid", - scopes: scopes), - clientSettings: [ "client_id": "clientid" ])) + scope: scope), + clientSettings: nil)) } } diff --git a/Tests/TestCommon/URLSessionMock.swift b/Tests/TestCommon/URLSessionMock.swift index 92e00739e..e6203af67 100644 --- a/Tests/TestCommon/URLSessionMock.swift +++ b/Tests/TestCommon/URLSessionMock.swift @@ -44,6 +44,14 @@ class URLSessionMock: URLSessionProtocol { } } + func request(matching string: String) -> URLRequest? { + requests.first(where: { $0.url?.absoluteString.localizedCaseInsensitiveContains(string) ?? false }) + } + + func formDecodedBody(matching string: String) -> [String: String?]? { + request(matching: string)?.httpBody?.urlFormEncoded + } + func expect(_ url: String, data: Data?, statusCode: Int = 200, diff --git a/Tests/WebAuthenticationUITests/AuthenticationServicesProviderTests.swift b/Tests/WebAuthenticationUITests/AuthenticationServicesProviderTests.swift index e9a78aced..52ab54b3f 100644 --- a/Tests/WebAuthenticationUITests/AuthenticationServicesProviderTests.swift +++ b/Tests/WebAuthenticationUITests/AuthenticationServicesProviderTests.swift @@ -72,7 +72,10 @@ class AuthenticationServicesProviderTests: ProviderTestBase { override func setUpWithError() throws { try super.setUpWithError() - provider = TestAuthenticationServicesProvider(loginFlow: loginFlow, logoutFlow: logoutFlow, from: nil, delegate: delegate) + provider = try TestAuthenticationServicesProvider(loginFlow: loginFlow, + logoutFlow: logoutFlow, + from: nil, + delegate: delegate) } override func tearDownWithError() throws { @@ -82,7 +85,7 @@ class AuthenticationServicesProviderTests: ProviderTestBase { } func testSuccessfulAuthentication() throws { - provider.start(context: .init(state: "state"), additionalParameters: nil) + provider.start(context: .init(state: "state")) try waitFor(.authenticateUrl) XCTAssertNotNil(provider.authenticationSession) @@ -99,7 +102,7 @@ class AuthenticationServicesProviderTests: ProviderTestBase { } func testErrorResponse() throws { - provider.start(context: .init(state: "state"), additionalParameters: nil) + provider.start(context: .init(state: "state")) try waitFor(.authenticateUrl) XCTAssertNotNil(provider.authenticationSession) @@ -123,7 +126,7 @@ class AuthenticationServicesProviderTests: ProviderTestBase { } func testUserCancelled() throws { - provider.start(context: .init(state: "state"), additionalParameters: nil) + provider.start(context: .init(state: "state")) try waitFor(.authenticateUrl) XCTAssertNotNil(provider.authenticationSession) @@ -140,7 +143,7 @@ class AuthenticationServicesProviderTests: ProviderTestBase { } func testNoResponse() throws { - provider.start(context: .init(state: "state"), additionalParameters: nil) + provider.start(context: .init(state: "state")) try waitFor(.authenticateUrl) XCTAssertNotNil(provider.authenticationSession) @@ -156,7 +159,7 @@ class AuthenticationServicesProviderTests: ProviderTestBase { func testLogout() throws { MockAuthenticationServicesProviderSession.redirectUri = URL(string: "com.example:/logout?foo=bar") - provider.logout(context: .init(idToken: "idToken", state: "state"), additionalParameters: nil) + provider.logout(context: .init(idToken: "idToken", state: "state")) try waitFor(.logoutUrl) XCTAssertNotNil(provider.authenticationSession) @@ -170,7 +173,7 @@ class AuthenticationServicesProviderTests: ProviderTestBase { func testLogoutError() throws { MockAuthenticationServicesProviderSession.redirectError = WebAuthenticationError.userCancelledLogin - provider.logout(context: .init(idToken: "idToken", state: "state"), additionalParameters: nil) + provider.logout(context: .init(idToken: "idToken", state: "state")) try waitFor(.logoutUrl) XCTAssertNotNil(provider.authenticationSession) diff --git a/Tests/WebAuthenticationUITests/OptionsTests.swift b/Tests/WebAuthenticationUITests/OptionsTests.swift deleted file mode 100644 index 771854add..000000000 --- a/Tests/WebAuthenticationUITests/OptionsTests.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// Copyright (c) 2022-Present, Okta, Inc. and/or its affiliates. All rights reserved. -// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") -// -// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. -// Unless required by applicable law or agreed to in writing, software -// distributed under the License 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. -// - -#if canImport(UIKit) || canImport(AppKit) - -import XCTest -@testable import TestCommon -@testable import WebAuthenticationUI - -class OptionsTests: XCTestCase { - func testOptionProperties() throws { - let options: [WebAuthentication.Option] = [ - .login(hint: "foo"), - .display("mobile"), - .idpScope("foo bar"), - .idp(url: URL(string: "https://example.com/idp")!), - .prompt(.none), - .maxAge(500), - .state("ABC123"), - .custom(key: "name", value: "value") - ] - - XCTAssertEqual(options.additionalParameters, [ - "login_hint": "foo", - "display": "mobile", - "idp_scope": "foo bar", - "idp": "https://example.com/idp", - "prompt": "none", - "name": "value" - ]) - - XCTAssertEqual(options.maxAge, 500) - XCTAssertEqual(options.state, "ABC123") - - let context = try XCTUnwrap(options.context) - XCTAssertEqual(context.state, "ABC123") - XCTAssertEqual(context.maxAge, 500) - } - - func testMissingOptions() throws { - let options: [WebAuthentication.Option] = [] - - XCTAssertNil(options.maxAge) - XCTAssertNil(options.state) - XCTAssertNil(options.context) - } - - func testPrompts() throws { - XCTAssertEqual(WebAuthentication.Option.Prompt.none.rawValue, "none") - XCTAssertEqual(WebAuthentication.Option.Prompt.login.rawValue, "login") - XCTAssertEqual(WebAuthentication.Option.Prompt.consent.rawValue, "consent") - XCTAssertEqual(WebAuthentication.Option.Prompt.loginAndConsent.rawValue, "login consent") - } -} - -#endif diff --git a/Tests/WebAuthenticationUITests/ProviderTestBase.swift b/Tests/WebAuthenticationUITests/ProviderTestBase.swift index bc796b485..bb15474b3 100644 --- a/Tests/WebAuthenticationUITests/ProviderTestBase.swift +++ b/Tests/WebAuthenticationUITests/ProviderTestBase.swift @@ -85,12 +85,14 @@ class ProviderTestBase: XCTestCase, AuthorizationCodeFlowDelegate, SessionLogout Token.idTokenValidator = MockIDTokenValidator() Token.accessTokenValidator = MockTokenHashValidator() - client = OAuth2Client(baseURL: issuer, + client = OAuth2Client(issuerURL: issuer, clientId: "clientId", - scopes: "openid profile", + scope: "openid profile", + redirectUri: redirectUri, + logoutRedirectUri: logoutRedirectUri, session: urlSession) - logoutFlow = client.sessionLogoutFlow(logoutRedirectUri: logoutRedirectUri) + logoutFlow = client.sessionLogoutFlow() logoutFlow.add(delegate: self) urlSession.expect("https://example.com/.well-known/openid-configuration", @@ -102,8 +104,7 @@ class ProviderTestBase: XCTestCase, AuthorizationCodeFlowDelegate, SessionLogout urlSession.expect("https://example.okta.com/oauth2/v1/keys?client_id=clientId", data: try data(from: .module, for: "keys", in: "MockResponses"), contentType: "application/json") - loginFlow = client.authorizationCodeFlow(redirectUri: redirectUri, - additionalParameters: ["additional": "param"]) + loginFlow = try client.authorizationCodeFlow(additionalParameters: ["additional": "param"]) loginFlow.add(delegate: self) delegate.reset() diff --git a/Tests/WebAuthenticationUITests/WebAuthenticationFlowTests.swift b/Tests/WebAuthenticationUITests/WebAuthenticationFlowTests.swift index 704bccd7f..62a9f20f2 100644 --- a/Tests/WebAuthenticationUITests/WebAuthenticationFlowTests.swift +++ b/Tests/WebAuthenticationUITests/WebAuthenticationFlowTests.swift @@ -28,23 +28,24 @@ class WebAuthenticationUITests: XCTestCase { private var client: OAuth2Client! override func setUpWithError() throws { - client = OAuth2Client(baseURL: issuer, + client = OAuth2Client(issuerURL: issuer, clientId: "clientId", - scopes: "openid profile", + scope: "openid profile", + redirectUri: redirectUri, + logoutRedirectUri: logoutRedirectUri, session: urlSession) urlSession.expect("https://example.com/.well-known/openid-configuration", data: try data(from: .module, for: "openid-configuration", in: "MockResponses"), contentType: "application/json") - loginFlow = client.authorizationCodeFlow(redirectUri: redirectUri, - additionalParameters: ["additional": "param"]) - logoutFlow = SessionLogoutFlow(logoutRedirectUri: logoutRedirectUri, client: client) + loginFlow = try client.authorizationCodeFlow(additionalParameters: ["additional": "param"]) + logoutFlow = SessionLogoutFlow(client: client) } func testStart() throws { let webAuth = WebAuthenticationMock(loginFlow: loginFlow, logoutFlow: logoutFlow) - webAuth.signIn(from: nil, options: [.state("qwe")]) { result in } + webAuth.signIn(from: nil, context: .init(state: "qwe")) { result in } let webAuthProvider = try XCTUnwrap(webAuth.provider as? WebAuthenticationProviderMock) @@ -55,7 +56,7 @@ class WebAuthenticationUITests: XCTestCase { func testLogout() throws { let webAuth = WebAuthenticationMock(loginFlow: loginFlow, logoutFlow: logoutFlow) - webAuth.signOut(from: nil, token: "idToken", options: [.state("qwe")]) { result in } + webAuth.signOut(from: nil, context: .init(idToken: "idToken", state: "qwe")) { result in } let provider = try XCTUnwrap(webAuth.provider as? WebAuthenticationProviderMock) XCTAssertNil(webAuth.completionBlock) @@ -68,7 +69,7 @@ class WebAuthenticationUITests: XCTestCase { XCTAssertNil(webAuth.provider) - webAuth.signIn(from: nil, options: [.state("qwe")]) { result in } + webAuth.signIn(from: nil, context: .init(state: "qwe")) { result in } let webAuthProvider = try XCTUnwrap(webAuth.provider as? WebAuthenticationProviderMock) diff --git a/Tests/WebAuthenticationUITests/WebAuthenticationInitializerTests.swift b/Tests/WebAuthenticationUITests/WebAuthenticationInitializerTests.swift index afe521f89..a91ad6dd4 100644 --- a/Tests/WebAuthenticationUITests/WebAuthenticationInitializerTests.swift +++ b/Tests/WebAuthenticationUITests/WebAuthenticationInitializerTests.swift @@ -24,14 +24,14 @@ class WebAuthenticationInitializerTests: XCTestCase { private let logoutRedirectUri = URL(string: "com.example:/logout")! func testInitializer() throws { - let auth = WebAuthentication(issuer: issuer, - clientId: "client_id", - scopes: "openid profile", - redirectUri: redirectUri, - logoutRedirectUri: logoutRedirectUri, - additionalParameters: ["foo": "bar"]) + let auth = try WebAuthentication(issuerURL: issuer, + clientId: "client_id", + scope: "openid profile", + redirectUri: redirectUri, + logoutRedirectUri: logoutRedirectUri, + additionalParameters: ["foo": "bar"]) XCTAssertEqual(auth.signInFlow.client.configuration.clientId, "client_id") - XCTAssertEqual(auth.signInFlow.client.configuration.scopes, "openid profile") + XCTAssertEqual(auth.signInFlow.client.configuration.scope, "openid profile") XCTAssertTrue(auth.signInFlow.client === auth.signOutFlow?.client) XCTAssertEqual(auth.signInFlow.additionalParameters?.stringComponents, ["foo": "bar"]) XCTAssertEqual(auth.signOutFlow?.additionalParameters?.stringComponents, ["foo": "bar"]) diff --git a/Tests/WebAuthenticationUITests/WebAuthenticationMocks.swift b/Tests/WebAuthenticationUITests/WebAuthenticationMocks.swift index 42bc2c986..eb60c9fe0 100644 --- a/Tests/WebAuthenticationUITests/WebAuthenticationMocks.swift +++ b/Tests/WebAuthenticationUITests/WebAuthenticationMocks.swift @@ -44,18 +44,18 @@ class WebAuthenticationProviderMock: WebAuthenticationProvider { self.delegate = delegate } - func start(context: AuthorizationCodeFlow.Context?, additionalParameters: [String: String]?) { + func start(context: AuthorizationCodeFlow.Context) { state = .started - loginFlow.start(with: nil, additionalParameters: additionalParameters) { result in + loginFlow.start(with: context) { result in } } - func logout(context: SessionLogoutFlow.Context, additionalParameters: [String: String]?) { + func logout(context: SessionLogoutFlow.Context) { state = .started - try? logoutFlow?.start(idToken: "idToken", additionalParameters: additionalParameters) { result in + logoutFlow?.start(with: context) { result in } }