diff --git a/.github/workflows/documentation-ghpages.yaml b/.github/workflows/documentation-ghpages.yaml index b206fa584..60c62f80a 100644 --- a/.github/workflows/documentation-ghpages.yaml +++ b/.github/workflows/documentation-ghpages.yaml @@ -6,7 +6,7 @@ on: - master env: - DEVELOPER_DIR: /Applications/Xcode_14.2.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_15.3.app/Contents/Developer NSUnbufferedIO: YES # NOTE: The DocC `generate-documentation` plugin does not handle the @@ -20,7 +20,7 @@ env: jobs: ExportToGHPages: name: Export to Github Pages - runs-on: macos-12 + runs-on: macos-latest-large steps: - uses: actions/checkout@master - name: Build Documentation diff --git a/.github/workflows/documentation.yaml b/.github/workflows/documentation.yaml index 44f317ef6..250fb89d8 100644 --- a/.github/workflows/documentation.yaml +++ b/.github/workflows/documentation.yaml @@ -17,13 +17,13 @@ on: - 'Sources/**/*.md' env: - DEVELOPER_DIR: /Applications/Xcode_14.2.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_15.3.app/Contents/Developer NSUnbufferedIO: YES jobs: BuildDocumentation: name: Build Documentation Archives - runs-on: macos-12 + runs-on: macos-latest-large steps: - uses: actions/checkout@master - name: AuthFoundation diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index db6874b56..a0d763a31 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -20,7 +20,7 @@ on: jobs: SwiftLint: - runs-on: macos-latest + runs-on: macos-latest-large steps: - uses: actions/checkout@v1 - name: Lint code using SwiftLint diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 75d9f2401..96c11b1d6 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -21,15 +21,15 @@ on: - 'Tests/**/*.swift' env: - DEVELOPER_DIR: /Applications/Xcode_14.2.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_15.3.app/Contents/Developer NSUnbufferedIO: YES - iOS_DESTINATION: "platform=iOS Simulator,OS=16.2,name=iPhone 14 Pro Max" - tvOS_DESTINATION: "platform=tvOS Simulator,OS=16.1,name=Apple TV" + iOS_DESTINATION: "platform=iOS Simulator,OS=17.4,name=iPhone 15 Pro Max" + tvOS_DESTINATION: "platform=tvOS Simulator,OS=17.4,name=Apple TV" jobs: SwiftBuild: name: Swift Unit Tests - runs-on: macos-12 + runs-on: macos-latest-large timeout-minutes: 10 steps: - name: Get swift version @@ -42,7 +42,7 @@ jobs: Cocoapods: name: CocoaPods Build - runs-on: macos-12 + runs-on: macos-latest-large timeout-minutes: 10 needs: - SwiftBuild @@ -60,7 +60,7 @@ jobs: XcodeBuild: name: Xcode Unit Tests - runs-on: macos-12 + runs-on: macos-latest-large timeout-minutes: 25 steps: - uses: actions/checkout@master diff --git a/Samples/DirectAuthSignIn/DirectAuthSignIn.xcodeproj/project.pbxproj b/Samples/DirectAuthSignIn/DirectAuthSignIn.xcodeproj/project.pbxproj index 1ca0248b1..8d4a4d923 100644 --- a/Samples/DirectAuthSignIn/DirectAuthSignIn.xcodeproj/project.pbxproj +++ b/Samples/DirectAuthSignIn/DirectAuthSignIn.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ 967D0ACE29F89379002A5AD3 /* SignInScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 967D0ACC29F89231002A5AD3 /* SignInScreen.swift */; }; 967D0AD129FAE193002A5AD3 /* SwiftOTP in Frameworks */ = {isa = PBXBuildFile; productRef = 967D0AD029FAE193002A5AD3 /* SwiftOTP */; }; 967D0AD329FAE48E002A5AD3 /* DirectAuth2FASignInTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 967D0AD229FAE48E002A5AD3 /* DirectAuth2FASignInTests.swift */; }; + E05F32392BE0736A00BB20D1 /* ContinuationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E05F32382BE0736A00BB20D1 /* ContinuationView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -72,6 +73,7 @@ 967D0AC929EF47F3002A5AD3 /* SecondaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryView.swift; sourceTree = ""; }; 967D0ACC29F89231002A5AD3 /* SignInScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInScreen.swift; sourceTree = ""; }; 967D0AD229FAE48E002A5AD3 /* DirectAuth2FASignInTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectAuth2FASignInTests.swift; sourceTree = ""; }; + E05F32382BE0736A00BB20D1 /* ContinuationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinuationView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -126,6 +128,7 @@ 967D0ABE29EDB274002A5AD3 /* SignInView.swift */, 967D0AC729EF47EB002A5AD3 /* PrimaryView.swift */, 967D0AC929EF47F3002A5AD3 /* SecondaryView.swift */, + E05F32382BE0736A00BB20D1 /* ContinuationView.swift */, 967D0AC229EE1E03002A5AD3 /* UnconfiguredView.swift */, 967D0A3229EA1BE4002A5AD3 /* Main.storyboard */, 967D0A3529EA1BE6002A5AD3 /* Assets.xcassets */, @@ -325,6 +328,7 @@ 967D0A9A29EA1CAD002A5AD3 /* TokenDetailViewController.swift in Sources */, 967D0ABF29EDB274002A5AD3 /* SignInView.swift in Sources */, 967D0A3129EA1BE4002A5AD3 /* SignInViewController.swift in Sources */, + E05F32392BE0736A00BB20D1 /* ContinuationView.swift in Sources */, 967D0AC829EF47EB002A5AD3 /* PrimaryView.swift in Sources */, 967D0A2D29EA1BE4002A5AD3 /* AppDelegate.swift in Sources */, 967D0A2F29EA1BE4002A5AD3 /* SceneDelegate.swift in Sources */, diff --git a/Samples/DirectAuthSignIn/DirectAuthSignIn/ContinuationView.swift b/Samples/DirectAuthSignIn/DirectAuthSignIn/ContinuationView.swift new file mode 100644 index 000000000..dbf9cbf98 --- /dev/null +++ b/Samples/DirectAuthSignIn/DirectAuthSignIn/ContinuationView.swift @@ -0,0 +1,84 @@ +// +// 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 SwiftUI +import OktaDirectAuth + +extension SignInView { + struct ContinuationView: View { + let flow: DirectAuthenticationFlow + + @State var status: DirectAuthenticationFlow.Status + @State var selectedFactor: SignInView.Factor = .code + @State var verificationCode: String = "" + + var factor: DirectAuthenticationFlow.ContinuationFactor? { + switch selectedFactor { + case .code: + return .prompt(code: verificationCode) + default: + return nil + } + } + + @Binding var error: Error? + @Binding var hasError: Bool + + var body: some View { + VStack { + Text("Please continue authenticating.") + .padding(25) + + VStack(alignment: .leading, spacing: 1) { + Picker(selection: $selectedFactor, label: EmptyView()) { + ForEach(SignInView.Factor.continuationFactors, id: \.self) { + Text($0.title) + } + }.pickerStyle(.menu) + .accessibilityIdentifier("factor_type_button") + .padding(.horizontal, -10) + .padding(.vertical, -4) + + if selectedFactor == .code { + TextField("123456", text: $verificationCode) + .textContentType(.oneTimeCode) + .accessibilityIdentifier("verification_code_button") + .padding(10) + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(.secondary, lineWidth: 1) + } + } + + if let factor = factor { + Button("Continue") { + Task { + do { + status = try await flow.resume(status, with: factor) + if case let .success(token) = status { + Credential.default = try Credential.store(token) + } + } catch { + self.error = error + self.hasError = true + } + } + } + .accessibilityIdentifier("signin_button") + .font(.headline) + .buttonStyle(.borderedProminent) + } + }.padding() + } + } + } +} diff --git a/Samples/DirectAuthSignIn/DirectAuthSignIn/PrimaryView.swift b/Samples/DirectAuthSignIn/DirectAuthSignIn/PrimaryView.swift index 2a9a37f13..d4ce7db48 100644 --- a/Samples/DirectAuthSignIn/DirectAuthSignIn/PrimaryView.swift +++ b/Samples/DirectAuthSignIn/DirectAuthSignIn/PrimaryView.swift @@ -26,7 +26,7 @@ extension SignInView { @State var oneTimeCode: String = "" @State var selectedFactor: SignInView.Factor = .password - var factor: DirectAuthenticationFlow.PrimaryFactor { + var factor: DirectAuthenticationFlow.PrimaryFactor? { switch selectedFactor { case .password: return .password(password) @@ -34,6 +34,12 @@ extension SignInView { return .otp(code: oneTimeCode) case .oob: return .oob(channel: .push) + case .sms: + return .oob(channel: .sms) + case .voice: + return .oob(channel: .voice) + default: + return nil } } @@ -86,26 +92,28 @@ extension SignInView { RoundedRectangle(cornerRadius: 6) .stroke(.secondary, lineWidth: 1) } - case .oob: EmptyView() + case .oob, .sms, .voice, .code: EmptyView() } } - Button("Sign In") { - Task { - do { - status = try await flow.start(username, with: factor) - if case let .success(token) = status { - Credential.default = try Credential.store(token) + if let factor = factor { + Button("Sign In") { + Task { + do { + status = try await flow.start(username, with: factor) + if case let .success(token) = status { + Credential.default = try Credential.store(token) + } + } catch { + self.error = error + self.hasError = true } - } catch { - self.error = error - self.hasError = true } } + .accessibilityIdentifier("signin_button") + .font(.headline) + .buttonStyle(.borderedProminent) } - .accessibilityIdentifier("signin_button") - .font(.headline) - .buttonStyle(.borderedProminent) }.padding() } } diff --git a/Samples/DirectAuthSignIn/DirectAuthSignIn/SecondaryView.swift b/Samples/DirectAuthSignIn/DirectAuthSignIn/SecondaryView.swift index f76a8e7bb..1ce665b02 100644 --- a/Samples/DirectAuthSignIn/DirectAuthSignIn/SecondaryView.swift +++ b/Samples/DirectAuthSignIn/DirectAuthSignIn/SecondaryView.swift @@ -29,6 +29,12 @@ extension SignInView { return .oob(channel: .push) case .password: return nil + case .sms: + return .oob(channel: .sms) + case .voice: + return .oob(channel: .voice) + default: + return nil } } diff --git a/Samples/DirectAuthSignIn/DirectAuthSignIn/SignInView.swift b/Samples/DirectAuthSignIn/DirectAuthSignIn/SignInView.swift index d283416eb..95678a420 100644 --- a/Samples/DirectAuthSignIn/DirectAuthSignIn/SignInView.swift +++ b/Samples/DirectAuthSignIn/DirectAuthSignIn/SignInView.swift @@ -21,20 +21,25 @@ struct SignInView: View { @State var hasError: Bool = false enum Factor { - case password, otp, oob + case password, otp, oob, sms, voice, code var title: String { switch self { case .password: return "Password" case .otp: return "One-Time Code" case .oob: return "Push Notification" + case .sms: return "Phone: SMS" + case .voice: return "Phone: Voice" + case .code: return "Verification Code" } } - static let primaryFactors: [Factor] = [.password, .otp, .oob] - static let secondaryFactors: [Factor] = [.otp, .oob] + static let primaryFactors: [Factor] = [.password, .otp, .oob, .sms, .voice] + static let secondaryFactors: [Factor] = [.otp, .oob, .sms, .voice] + static let continuationFactors: [Factor] = [.code] } + // swiftlint:disable force_unwrapping var body: some View { VStack { VStack(alignment: .leading, spacing: 15) { @@ -42,16 +47,24 @@ struct SignInView: View { .font(.title) if let flow = flow { - if let status = status { - SecondaryView(flow: flow, - status: status, - error: $error, - hasError: $hasError) - } else { + switch status { + case nil: PrimaryView(flow: flow, status: $status, error: $error, hasError: $hasError) + case .mfaRequired(_): + SecondaryView(flow: flow, + status: status!, + error: $error, + hasError: $hasError) + case .continuation(_): + ContinuationView(flow: flow, + status: status!, + error: $error, + hasError: $hasError) + case .success(_): + ProgressView() } } else { UnconfiguredView() @@ -80,6 +93,7 @@ struct SignInView: View { } .navigationTitle("Direct Authentication") } + // swiftlint:enable force_unwrapping } // swiftlint:disable force_unwrapping diff --git a/Sources/OktaDirectAuth/DirectAuthFlow.swift b/Sources/OktaDirectAuth/DirectAuthFlow.swift index 80ffe2309..2b0884554 100644 --- a/Sources/OktaDirectAuth/DirectAuthFlow.swift +++ b/Sources/OktaDirectAuth/DirectAuthFlow.swift @@ -25,12 +25,30 @@ public protocol DirectAuthenticationFlowDelegate: AuthenticationDelegate { /// Errors that may be generated while authenticating using ``DirectAuthenticationFlow``. public enum DirectAuthenticationFlowError: Error { + /// When polling for a background authenticator, this error may be thrown if polling for an out-of-band verification takes too long. case pollingTimeoutExceeded + + /// An authentication factor expects a "binding code" but it isn't present. + /// + /// For more information, please see the [related documentation](https://developer.okta.com/docs/guides/configure-direct-auth-grants/dmfaoobov/main/). case bindingCodeMissing + + /// The context supplied with the authenticator continuation request is invalid. + case invalidContinuationContext + + /// Some authenticators require specific arguments to be supplied, but are missing in this case. case missingArguments(_ names: [String]) + + /// An underlying network error has occurred. case network(error: APIClientError) + + /// An OAuth2 error has been returned. case oauth2(error: OAuth2Error) + + /// An OAuth2 server error has been returned. case server(error: OAuth2ServerError) + + /// Some other unknown error has been returned. case other(error: Error) } @@ -108,23 +126,67 @@ public class DirectAuthenticationFlow: AuthenticationFlow { /// This requests that a new WebAuthn challenge is generated and returned to the client, which can subsequently be used to sign the attestation for return back to the server. /// /// ```swift - /// let status = try await flow.start("jane.doe@example.com", with: .webAuthn) + /// let status = try await flow.resume(status, with: .webAuthn) /// ``` case webAuthn + } + + /// 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. + public enum ContinuationFactor: Equatable { + /// Continues an OOB authentication by transfering the binding to another authenticator, and waiting for its response. + /// + /// For example, if an Okta Verify number challenge needs to be presented to the user (also referred to as a "Binding Transfer"), the OOB authentication can be continued. + /// + /// ``` + /// if case let .continuation(type) = status, + /// case let .transfer(_, code: code) = type + /// { + /// // Present the code to the user + /// status = try await flow.resume(status, with: .transfer) + /// } + /// ``` + case transfer + + /// Respond to an OOB authentication where a code is supplied to a second channel, which will be supplied here. + /// + /// This is used when some code needs to be supplied by the user in response to an out-of-band authentication, for example when authenticating using an SMS phone factor. + /// + /// ``` + /// var status = try await flow.start("jane.doe@example.com", with: .oob(channel: .sms) + /// if case let .continuation(type) = status, + /// case let .prompt(_) = type + /// { + /// // Prompt the user to input the code + /// let verificationCode = await getCodeFromUser() + /// + /// let newStatus = try await flow.resume(status, with: .prompt(verificationCode)) + /// } + /// ``` + case prompt(code: String) /// Respond to a WebAuthn challenge with an authenticator assertion. /// - /// This uses a previously supplied WebAuthn challenge (using ``DirectAuthenticationFlow/PrimaryFactor/webAuthn`` or ``webAuthn``) to respond to the server with the signed attestation from the local authenticator. - case webAuthnAssertion(_ response: WebAuthn.AuthenticatorAssertionResponse) + /// This uses a previously supplied WebAuthn challenge (using ``DirectAuthenticationFlow/PrimaryFactor/webAuthn`` or ``DirectAuthenticationFlow/SecondaryFactor/webAuthn``) to respond to the server with the signed attestation from the local authenticator. + case webAuthn(response: WebAuthn.AuthenticatorAssertionResponse) } /// Channel used when authenticating an out-of-band factor using Okta Verify. public enum OOBChannel: String, Codable { /// Utilize Okta Verify Push notifications to authenticate the user. case push + + /// Receive a phone verification code via SMS. + case sms + + /// Receive a phone verification code via a voice call. + case voice } /// Context information used to define a request from the server to perform a multifactor authentication. + /// + /// This is largely used internally to ensure the secondary factor is linked to the user's current authentication session, but can be used to see the list of challenge types that are supported. public struct MFAContext: Equatable { /// The list of possible grant types that the user can be challenged with. public let supportedChallengeTypes: [GrantType]? @@ -136,27 +198,6 @@ public class DirectAuthenticationFlow: AuthenticationFlow { } } - /// Represents the different types of binding updates that can be received - public enum BindingUpdateType { - /// Binding requires transfer of a code from one channel to another - case transfer(_ code: String) - } - - /// Holds information about the binding update received when verifying OOB factors - public struct BindingUpdateContext { - /// Holds the type of binding update received from the server - public let update: BindingUpdateType - let oobResponse: OOBResponse - } - - /// Holds information about a challenge request when initiating a WebAuthn authentication. - public struct WebAuthnContext { - /// The credential request returned from the server. - public let request: WebAuthn.CredentialRequestOptions - - let mfaContext: MFAContext? - } - /// 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. @@ -164,16 +205,43 @@ public class DirectAuthenticationFlow: AuthenticationFlow { /// Authentication was successful, returning the given token. case success(_ token: Token) - /// Indicates that there is an update about binding authentication channels when verifying OOB factors - case bindingUpdate(_ context: BindingUpdateContext) + /// 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. + 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. 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 { /// Indicates the user is being prompted with a WebAuthn challenge request. case webAuthn(_ context: WebAuthnContext) + + /// Indicates that there is an update about binding authentication channels when verifying OOB factors. + case transfer(_ context: BindingContext, code: String) + + /// Indicates that the authenticator will prompt the user for a code, will occur through a secondary channel, such as SMS phone verification. + case prompt(_ context: BindingContext) + + /// Holds information about a challenge request when initiating a WebAuthn authentication. + public struct WebAuthnContext { + /// The credential request returned from the server. + public let request: WebAuthn.CredentialRequestOptions + + let mfaContext: MFAContext? + } + + /// Holds information about the binding update received when verifying OOB factors + public struct BindingContext { + let oobResponse: OOBResponse + let mfaContext: MFAContext? + } } /// The OAuth2Client this authentication flow will use. @@ -282,6 +350,20 @@ public class DirectAuthenticationFlow: AuthenticationFlow { { runStep(currentStatus: status, 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, + completion: @escaping (Result) -> Void) + { + runStep(currentStatus: status, with: factor, completion: completion) + } func runStep(loginHint: String? = nil, currentStatus: Status? = nil, @@ -357,6 +439,21 @@ extension DirectAuthenticationFlow { } } } + + /// 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. + /// - Returns: Status returned when the operation completes. + public func resume(_ status: DirectAuthenticationFlow.Status, with factor: ContinuationFactor) async throws -> DirectAuthenticationFlow.Status { + try await withCheckedThrowingContinuation { continuation in + resume(status, with: factor) { result in + continuation.resume(with: result) + } + } + } } #endif diff --git a/Sources/OktaDirectAuth/Extensions/ErrorExtensions.swift b/Sources/OktaDirectAuth/Extensions/ErrorExtensions.swift index bbfea3cf4..e9cdd8114 100644 --- a/Sources/OktaDirectAuth/Extensions/ErrorExtensions.swift +++ b/Sources/OktaDirectAuth/Extensions/ErrorExtensions.swift @@ -41,6 +41,11 @@ extension DirectAuthenticationFlowError: LocalizedError { return error.localizedDescription case .other(error: let error): return error.localizedDescription + case .invalidContinuationContext: + return NSLocalizedString("invalid_continuation_context", + tableName: "OktaDirectAuth", + bundle: .oktaDirectAuth, + comment: "Invalid continuation context") } } } diff --git a/Sources/OktaDirectAuth/Internal/Authentication Factors/ContinuationFactor.swift b/Sources/OktaDirectAuth/Internal/Authentication Factors/ContinuationFactor.swift new file mode 100644 index 000000000..51faf1276 --- /dev/null +++ b/Sources/OktaDirectAuth/Internal/Authentication Factors/ContinuationFactor.swift @@ -0,0 +1,105 @@ +// +// 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 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 + + switch self { + case .transfer: + guard let bindingContext = bindingContext + else { + throw DirectAuthenticationFlowError.invalidContinuationContext + } + + return try OOBStepHandler(flow: flow, + openIdConfiguration: openIdConfiguration, + currentStatus: currentStatus, + loginHint: loginHint, + channel: bindingContext.oobResponse.channel, + factor: factor) + + case .prompt(code: _): + guard let bindingContext = bindingContext + else { + throw DirectAuthenticationFlowError.invalidContinuationContext + } + + let request = TokenRequest(openIdConfiguration: openIdConfiguration, + clientConfiguration: flow.client.configuration, + currentStatus: currentStatus, + factor: factor, + 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, + currentStatus: currentStatus, + loginHint: loginHint, + factor: factor, + parameters: response, + grantTypesSupported: flow.supportedGrantTypes) + return TokenStepHandler(flow: flow, request: request) + } + } + + func grantType(currentStatus: DirectAuthenticationFlow.Status?) -> GrantType { + let hasMFAToken = (currentStatus?.mfaContext?.mfaToken != nil) + + switch self { + case .webAuthn(response: _): + if hasMFAToken { + return .webAuthnMFA + } else { + return .webAuthn + } + case .transfer, .prompt(code: _): + if hasMFAToken { + return .oobMFA + } else { + return .oob + } + } + } +} + +extension DirectAuthenticationFlow.ContinuationFactor: HasTokenParameters { + func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: String] { + var result: [String: String] = [ + "grant_type": grantType(currentStatus: currentStatus).rawValue, + ] + + if let context = currentStatus?.mfaContext { + result["mfa_token"] = context.mfaToken + } + + if let context = currentStatus?.continuationType?.bindingContext { + result["oob_code"] = context.oobResponse.oobCode + } + + if case let .prompt(code) = self { + result["binding_code"] = code + } + + return result + } +} diff --git a/Sources/OktaDirectAuth/Internal/Authentication Factors/PrimaryFactor.swift b/Sources/OktaDirectAuth/Internal/Authentication Factors/PrimaryFactor.swift index dba965104..b5a78ff0d 100644 --- a/Sources/OktaDirectAuth/Internal/Authentication Factors/PrimaryFactor.swift +++ b/Sources/OktaDirectAuth/Internal/Authentication Factors/PrimaryFactor.swift @@ -29,7 +29,7 @@ extension DirectAuthenticationFlow.PrimaryFactor: AuthenticationFactor { openIdConfiguration: OpenIdConfiguration, loginHint: String? = nil, currentStatus: DirectAuthenticationFlow.Status? = nil, - factor: DirectAuthenticationFlow.PrimaryFactor) throws -> StepHandler + factor: Self) throws -> StepHandler { switch self { case .otp: fallthrough @@ -42,17 +42,12 @@ extension DirectAuthenticationFlow.PrimaryFactor: AuthenticationFactor { grantTypesSupported: flow.supportedGrantTypes) return TokenStepHandler(flow: flow, request: request) case .oob(channel: let channel): - var bindingContext: DirectAuthenticationFlow.BindingUpdateContext? - if case .bindingUpdate(let context) = currentStatus { - bindingContext = context - } return try OOBStepHandler(flow: flow, openIdConfiguration: openIdConfiguration, currentStatus: currentStatus, loginHint: loginHint, channel: channel, - factor: factor, - bindingContext: bindingContext) + factor: factor) case .webAuthn: let mfaContext = currentStatus?.mfaContext let request = try WebAuthnChallengeRequest(openIdConfiguration: openIdConfiguration, @@ -60,12 +55,26 @@ extension DirectAuthenticationFlow.PrimaryFactor: AuthenticationFactor { loginHint: loginHint, mfaToken: mfaContext?.mfaToken) return ChallengeStepHandler(flow: flow, request: request) { - .webAuthn(.init(request: $0, - mfaContext: mfaContext)) + .continuation(.webAuthn(.init(request: $0, mfaContext: mfaContext))) } } } + func grantType(currentStatus: DirectAuthenticationFlow.Status?) -> GrantType { + switch self { + case .otp: + return .otp + case .password: + return .password + case .oob: + return .oob + case .webAuthn: + return .webAuthn + } + } +} + +extension DirectAuthenticationFlow.PrimaryFactor: HasTokenParameters { func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: String] { var result: [String: String] = [ "grant_type": grantType(currentStatus: currentStatus).rawValue, @@ -82,17 +91,4 @@ extension DirectAuthenticationFlow.PrimaryFactor: AuthenticationFactor { return result } - - func grantType(currentStatus: DirectAuthenticationFlow.Status?) -> GrantType { - switch self { - case .otp: - return .otp - case .password: - return .password - case .oob: - return .oob - case .webAuthn: - return .webAuthn - } - } } diff --git a/Sources/OktaDirectAuth/Internal/Authentication Factors/SecondaryFactor.swift b/Sources/OktaDirectAuth/Internal/Authentication Factors/SecondaryFactor.swift index 5ad80f378..b8c6d1aaa 100644 --- a/Sources/OktaDirectAuth/Internal/Authentication Factors/SecondaryFactor.swift +++ b/Sources/OktaDirectAuth/Internal/Authentication Factors/SecondaryFactor.swift @@ -18,12 +18,8 @@ extension DirectAuthenticationFlow.SecondaryFactor: AuthenticationFactor { openIdConfiguration: AuthFoundation.OpenIdConfiguration, loginHint: String? = nil, currentStatus: DirectAuthenticationFlow.Status?, - factor: DirectAuthenticationFlow.SecondaryFactor) throws -> StepHandler + factor: Self) throws -> StepHandler { - var bindingContext: DirectAuthenticationFlow.BindingUpdateContext? - if case .bindingUpdate(let context) = currentStatus { - bindingContext = context - } switch self { case .otp: let request = TokenRequest(openIdConfiguration: openIdConfiguration, @@ -39,8 +35,7 @@ extension DirectAuthenticationFlow.SecondaryFactor: AuthenticationFactor { currentStatus: currentStatus, loginHint: loginHint, channel: channel, - factor: factor, - bindingContext: bindingContext) + factor: factor) case .webAuthn: let mfaContext = currentStatus?.mfaContext let request = try WebAuthnChallengeRequest(openIdConfiguration: openIdConfiguration, @@ -48,41 +43,11 @@ extension DirectAuthenticationFlow.SecondaryFactor: AuthenticationFactor { loginHint: loginHint, mfaToken: mfaContext?.mfaToken) return ChallengeStepHandler(flow: flow, request: request) { - .webAuthn(.init(request: $0, - mfaContext: mfaContext)) + .continuation(.webAuthn(.init(request: $0, mfaContext: mfaContext))) } - case .webAuthnAssertion(let response): - let request = TokenRequest(openIdConfiguration: openIdConfiguration, - clientConfiguration: flow.client.configuration, - currentStatus: currentStatus, - loginHint: loginHint, - factor: factor, - parameters: response, - grantTypesSupported: flow.supportedGrantTypes) - return TokenStepHandler(flow: flow, request: request) } } - func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: String] { - var result: [String: String] = [ - "grant_type": grantType(currentStatus: currentStatus).rawValue, - ] - - if let context = currentStatus?.mfaContext { - result["mfa_token"] = context.mfaToken - } - - switch self { - case .otp(code: let code): - result["otp"] = code - case .webAuthnAssertion(_): break - case .oob(channel: _): break - case .webAuthn: break - } - - return result - } - func grantType(currentStatus: DirectAuthenticationFlow.Status?) -> GrantType { let hasMFAToken = (currentStatus?.mfaContext?.mfaToken != nil) @@ -95,7 +60,7 @@ extension DirectAuthenticationFlow.SecondaryFactor: AuthenticationFactor { } else { return .oob } - case .webAuthn, .webAuthnAssertion(_): + case .webAuthn: if hasMFAToken { return .webAuthnMFA } else { @@ -104,3 +69,21 @@ extension DirectAuthenticationFlow.SecondaryFactor: AuthenticationFactor { } } } + +extension DirectAuthenticationFlow.SecondaryFactor: HasTokenParameters { + func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: String] { + var result: [String: String] = [ + "grant_type": grantType(currentStatus: currentStatus).rawValue, + ] + + if let context = currentStatus?.mfaContext { + result["mfa_token"] = context.mfaToken + } + + if case let .otp(code: code) = self { + result["otp"] = code + } + + return result + } +} diff --git a/Sources/OktaDirectAuth/Internal/Requests/OOBAuthenticateRequest.swift b/Sources/OktaDirectAuth/Internal/Requests/OOBAuthenticateRequest.swift index 3749d9e81..e35393591 100644 --- a/Sources/OktaDirectAuth/Internal/Requests/OOBAuthenticateRequest.swift +++ b/Sources/OktaDirectAuth/Internal/Requests/OOBAuthenticateRequest.swift @@ -22,12 +22,12 @@ extension OpenIdConfiguration { struct OOBResponse: Codable, HasTokenParameters { let oobCode: String let expiresIn: TimeInterval - let interval: TimeInterval + let interval: TimeInterval? let channel: DirectAuthenticationFlow.OOBChannel let bindingMethod: BindingMethod let bindingCode: String? - init(oobCode: String, expiresIn: TimeInterval, interval: TimeInterval, channel: DirectAuthenticationFlow.OOBChannel, bindingMethod: BindingMethod, bindingCode: String? = nil) { + init(oobCode: String, expiresIn: TimeInterval, interval: TimeInterval?, channel: DirectAuthenticationFlow.OOBChannel, bindingMethod: BindingMethod, bindingCode: String? = nil) { self.oobCode = oobCode self.expiresIn = expiresIn self.interval = interval @@ -46,11 +46,13 @@ struct OOBAuthenticateRequest { let clientConfiguration: OAuth2Client.Configuration let loginHint: String let channelHint: DirectAuthenticationFlow.OOBChannel + let challengeHint: GrantType init(openIdConfiguration: OpenIdConfiguration, clientConfiguration: OAuth2Client.Configuration, loginHint: String, - channelHint: DirectAuthenticationFlow.OOBChannel) throws + channelHint: DirectAuthenticationFlow.OOBChannel, + challengeHint: GrantType) throws { guard let url = openIdConfiguration.primaryAuthenticateEndpoint else { throw OAuth2Error.cannotComposeUrl @@ -60,12 +62,14 @@ struct OOBAuthenticateRequest { self.clientConfiguration = clientConfiguration self.loginHint = loginHint self.channelHint = channelHint + self.challengeHint = challengeHint } } enum BindingMethod: String, Codable { case none case transfer + case prompt } extension OOBAuthenticateRequest: APIRequest, APIRequestBody { @@ -78,7 +82,8 @@ extension OOBAuthenticateRequest: APIRequest, APIRequestBody { var result: [String: Any] = [ "client_id": clientConfiguration.clientId, "login_hint": loginHint, - "channel_hint": channelHint.rawValue + "channel_hint": channelHint.rawValue, + "challenge_hint": challengeHint.rawValue, ] if let parameters = clientConfiguration.authentication.additionalParameters { diff --git a/Sources/OktaDirectAuth/Internal/Step Handlers/OOBStepHandler.swift b/Sources/OktaDirectAuth/Internal/Step Handlers/OOBStepHandler.swift index 343bc0d9e..233e8c050 100644 --- a/Sources/OktaDirectAuth/Internal/Step Handlers/OOBStepHandler.swift +++ b/Sources/OktaDirectAuth/Internal/Step Handlers/OOBStepHandler.swift @@ -20,7 +20,6 @@ class OOBStepHandler: StepHandler { let loginHint: String? let channel: DirectAuthenticationFlow.OOBChannel let factor: Factor - private let bindingContext: DirectAuthenticationFlow.BindingUpdateContext? private var poll: PollingHandler? init(flow: DirectAuthenticationFlow, @@ -28,8 +27,7 @@ class OOBStepHandler: StepHandler { currentStatus: DirectAuthenticationFlow.Status?, loginHint: String?, channel: DirectAuthenticationFlow.OOBChannel, - factor: Factor, - bindingContext: DirectAuthenticationFlow.BindingUpdateContext? = nil) throws + factor: Factor) throws { self.flow = flow self.openIdConfiguration = openIdConfiguration @@ -37,11 +35,10 @@ class OOBStepHandler: StepHandler { self.loginHint = loginHint self.channel = channel self.factor = factor - self.bindingContext = bindingContext } func process(completion: @escaping (Result) -> Void) { - if let bindingContext { + if let bindingContext = currentStatus?.continuationType?.bindingContext { self.requestToken(using: bindingContext.oobResponse, completion: completion) } else { requestOOBCode { [weak self] result in @@ -52,9 +49,14 @@ class OOBStepHandler: StepHandler { case .failure(let error): self.flow.process(error, completion: completion) case .success(let response): + let mfaContext = currentStatus?.mfaContext + switch response.bindingMethod { case .none: self.requestToken(using: response, completion: completion) + case .prompt: + completion(.success(.continuation(.prompt(.init(oobResponse: response, + mfaContext: mfaContext))))) case .transfer: guard let bindingCode = response.bindingCode, bindingCode.isEmpty == false @@ -63,8 +65,9 @@ class OOBStepHandler: StepHandler { return } - completion(.success(.bindingUpdate(.init(update: .transfer(bindingCode), - oobResponse: response)))) + completion(.success(.continuation(.transfer(.init(oobResponse: response, + mfaContext: mfaContext), + code: bindingCode)))) } } } @@ -98,7 +101,8 @@ class OOBStepHandler: StepHandler { let request = try OOBAuthenticateRequest(openIdConfiguration: openIdConfiguration, clientConfiguration: flow.client.configuration, loginHint: loginHint, - channelHint: channel) + channelHint: channel, + challengeHint: factor.grantType(currentStatus: currentStatus)) request.send(to: flow.client) { result in switch result { case .failure(let error): @@ -139,6 +143,11 @@ class OOBStepHandler: StepHandler { } private func requestToken(using response: OOBResponse, completion: @escaping (Result) -> Void) { + guard let interval = response.interval else { + completion(.failure(.missingArguments(["interval"]))) + return + } + let request = TokenRequest(openIdConfiguration: openIdConfiguration, clientConfiguration: flow.client.configuration, currentStatus: currentStatus, @@ -148,7 +157,7 @@ class OOBStepHandler: StepHandler { self.poll = PollingHandler(client: flow.client, request: request, expiresIn: response.expiresIn, - interval: response.interval) { pollHandler, result in + interval: interval) { pollHandler, result in switch result { case .success(let response): return .success(response.result) diff --git a/Sources/OktaDirectAuth/Internal/Utilities/Status+InternalExtensions.swift b/Sources/OktaDirectAuth/Internal/Utilities/Status+InternalExtensions.swift index affb41c85..bc7931510 100644 --- a/Sources/OktaDirectAuth/Internal/Utilities/Status+InternalExtensions.swift +++ b/Sources/OktaDirectAuth/Internal/Utilities/Status+InternalExtensions.swift @@ -12,12 +12,45 @@ import Foundation +extension DirectAuthenticationFlow.ContinuationType { + var mfaContext: DirectAuthenticationFlow.MFAContext? { + if case let .webAuthn(context) = self { + return context.mfaContext + } else if case let .transfer(context, code: _) = self { + return context.mfaContext + } else if case let .prompt(context) = self { + return context.mfaContext + } else { + return nil + } + } + + var bindingContext: DirectAuthenticationFlow.ContinuationType.BindingContext? { + switch self { + case .transfer(let context, _): + return context + case .prompt(let context): + return context + default: + return nil + } + } +} + extension DirectAuthenticationFlow.Status { + var continuationType: DirectAuthenticationFlow.ContinuationType? { + if case let .continuation(type) = self { + return type + } + + return nil + } + var mfaContext: DirectAuthenticationFlow.MFAContext? { if case let .mfaRequired(context) = self { return context - } else if case let .webAuthn(context) = self { - return context.mfaContext + } else if let mfaContext = continuationType?.mfaContext { + return mfaContext } else { return nil } diff --git a/Sources/OktaDirectAuth/Resources/en.lproj/OktaDirectAuth.strings b/Sources/OktaDirectAuth/Resources/en.lproj/OktaDirectAuth.strings index 8ad067de6..fe4ab9d93 100644 --- a/Sources/OktaDirectAuth/Resources/en.lproj/OktaDirectAuth.strings +++ b/Sources/OktaDirectAuth/Resources/en.lproj/OktaDirectAuth.strings @@ -2,3 +2,4 @@ "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"; +"invalid_continuation_context" = "Authentication attempted to continue from an invalid context."; diff --git a/Sources/OktaDirectAuth/WebAuthn/WebAuthn.swift b/Sources/OktaDirectAuth/WebAuthn/WebAuthn.swift index 5ad5f31d0..bad37091b 100644 --- a/Sources/OktaDirectAuth/WebAuthn/WebAuthn.swift +++ b/Sources/OktaDirectAuth/WebAuthn/WebAuthn.swift @@ -18,12 +18,14 @@ import Foundation /// /// ```swift /// let challengeStatus = try await flow.start("user@example.com", with: .webAuthn) -/// guard case let .webAuthn(let request) = challengeStatus else { return } +/// guard case let .continuation(let type) = challengeStatus, +/// case let .webAuthn(let context) = type +/// else { return } /// /// // Supply challenge request values to your authenticator /// let responseStatus = try await flow.resume( /// challengeStatus, -/// with: .webAuthnAssertion(.init( +/// with: .webAuthn(.init( /// clientDataJSON: authJson, /// authenticatorData: authData, /// signature: authSignature, diff --git a/Tests/OktaDirectAuthTests/DirectAuth1FATests.swift b/Tests/OktaDirectAuthTests/DirectAuth1FATests.swift index 03ffbd598..445fba9fb 100644 --- a/Tests/OktaDirectAuthTests/DirectAuth1FATests.swift +++ b/Tests/OktaDirectAuthTests/DirectAuth1FATests.swift @@ -57,10 +57,8 @@ final class DirectAuth1FATests: XCTestCase { XCTAssertNotNil(token.refreshToken) case .mfaRequired(_): XCTFail("Not expecting MFA Required") - case .bindingUpdate(_): - XCTFail("Not expecting binding update") - case .webAuthn(request: _): - XCTFail("Not expecting webauthn request") + case .continuation(_): + XCTFail("Not expecting continuation status") } } @@ -79,10 +77,8 @@ final class DirectAuth1FATests: XCTestCase { XCTAssertNotNil(token.refreshToken) case .mfaRequired(_): XCTFail("Not expecting MFA Required") - case .bindingUpdate(_): - XCTFail("Not expecting binding update") - case .webAuthn(request: _): - XCTFail("Not expecting webauthn request") + case .continuation(_): + XCTFail("Not expecting continuation status") } } @@ -101,10 +97,8 @@ final class DirectAuth1FATests: XCTestCase { XCTAssertNotNil(token.refreshToken) case .mfaRequired(_): XCTFail("Not expecting MFA Required") - case .bindingUpdate(_): - XCTFail("Not expecting binding update") - case .webAuthn(request: _): - XCTFail("Not expecting webauthn request") + case .continuation(_): + XCTFail("Not expecting continuation status") } } #endif diff --git a/Tests/OktaDirectAuthTests/DirectAuth2FATests.swift b/Tests/OktaDirectAuthTests/DirectAuth2FATests.swift index cadc95e26..38bf51517 100644 --- a/Tests/OktaDirectAuthTests/DirectAuth2FATests.swift +++ b/Tests/OktaDirectAuthTests/DirectAuth2FATests.swift @@ -64,15 +64,11 @@ final class DirectAuth2FATests: XCTestCase { break case .mfaRequired(_): XCTFail("Not expecting MFA Required") - case .bindingUpdate(_): - XCTFail("Not expecting binding update") - case .webAuthn(request: _): - XCTFail("Not expecting webauthn request") + case .continuation(_): + XCTFail("Not expecting continuation status") } - case .bindingUpdate(_): - XCTFail("Not expecting binding update") - case .webAuthn(request: _): - XCTFail("Not expecting webauthn request") + case .continuation(_): + XCTFail("Not expecting continuation status") } XCTAssertFalse(flow.isAuthenticating) } diff --git a/Tests/OktaDirectAuthTests/ExtensionTests.swift b/Tests/OktaDirectAuthTests/ExtensionTests.swift index dbe66e466..d495e6907 100644 --- a/Tests/OktaDirectAuthTests/ExtensionTests.swift +++ b/Tests/OktaDirectAuthTests/ExtensionTests.swift @@ -28,10 +28,10 @@ final class ExtensionTests: XCTestCase { let mfaContext = DirectAuthenticationFlow.MFAContext(supportedChallengeTypes: [.oob], mfaToken: "abc123") XCTAssertEqual(Status.mfaRequired(mfaContext).mfaContext?.mfaToken, "abc123") - let webAuthnContext = DirectAuthenticationFlow.WebAuthnContext( + let webAuthnContext = DirectAuthenticationFlow.ContinuationType.WebAuthnContext( request: try mock(from: .module, for: "challenge-webauthn", in: "MockResponses"), mfaContext: mfaContext) - XCTAssertEqual(Status.webAuthn(webAuthnContext).mfaContext?.mfaToken, "abc123") + XCTAssertEqual(Status.continuation(.webAuthn(webAuthnContext)).mfaContext?.mfaToken, "abc123") } func testStatusEquality() throws { diff --git a/Tests/OktaDirectAuthTests/FactorPropertyTests.swift b/Tests/OktaDirectAuthTests/FactorPropertyTests.swift index 425a1f0ed..23b229b7f 100644 --- a/Tests/OktaDirectAuthTests/FactorPropertyTests.swift +++ b/Tests/OktaDirectAuthTests/FactorPropertyTests.swift @@ -16,7 +16,8 @@ import XCTest final class FactorPropertyTests: XCTestCase { typealias PrimaryFactor = DirectAuthenticationFlow.PrimaryFactor typealias SecondaryFactor = DirectAuthenticationFlow.SecondaryFactor - + typealias ContinuationFactor = DirectAuthenticationFlow.ContinuationFactor + func testLoginHint() throws { XCTAssertEqual(PrimaryFactor.password("foo").loginHintKey, "username") XCTAssertEqual(PrimaryFactor.otp(code: "123456").loginHintKey, "login_hint") @@ -80,22 +81,42 @@ final class FactorPropertyTests: XCTestCase { "grant_type": "urn:okta:params:oauth:grant-type:mfa-webauthn", "mfa_token": "abc123", ]) + } + + func testContinuationTokenParameters() throws { + var parameters: [String: String] = [:] - parameters = SecondaryFactor.webAuthnAssertion(.init(clientDataJSON: "", - authenticatorData: "", - signature: "", - userHandle: nil)).tokenParameters(currentStatus: nil) + parameters = ContinuationFactor.prompt(code: "123456") + .tokenParameters(currentStatus: .continuation( + .prompt(.init(oobResponse: .init(oobCode: "oob_abcd123", + expiresIn: 300, + interval: nil, + channel: .sms, + bindingMethod: .prompt, + bindingCode: "abcd123"), + mfaContext: nil)))) + XCTAssertEqual(parameters, [ + "grant_type": "urn:okta:params:oauth:grant-type:oob", + "oob_code": "oob_abcd123", + "binding_code": "123456", + ]) + + parameters = ContinuationFactor.webAuthn(response: .init(clientDataJSON: "", + authenticatorData: "", + signature: "", + userHandle: nil)).tokenParameters(currentStatus: nil) XCTAssertEqual(parameters, [ "grant_type": "urn:okta:params:oauth:grant-type:webauthn", ]) - let context = DirectAuthenticationFlow.WebAuthnContext( + let context = DirectAuthenticationFlow.ContinuationType.WebAuthnContext( request: try mock(from: .module, for: "challenge-webauthn", in: "MockResponses"), mfaContext: .init(supportedChallengeTypes: nil, mfaToken: "abc123")) - parameters = SecondaryFactor.webAuthnAssertion(.init(clientDataJSON: "", - authenticatorData: "", - signature: "", - userHandle: nil)).tokenParameters(currentStatus: .webAuthn(context)) + parameters = ContinuationFactor.webAuthn(response: .init(clientDataJSON: "", + authenticatorData: "", + signature: "", + userHandle: nil)).tokenParameters(currentStatus: .continuation(.webAuthn(context))) + XCTAssertEqual(parameters, [ "grant_type": "urn:okta:params:oauth:grant-type:mfa-webauthn", "mfa_token": "abc123", diff --git a/Tests/OktaDirectAuthTests/FactorStepHandlerTests.swift b/Tests/OktaDirectAuthTests/FactorStepHandlerTests.swift index e2cb34295..f91176efe 100644 --- a/Tests/OktaDirectAuthTests/FactorStepHandlerTests.swift +++ b/Tests/OktaDirectAuthTests/FactorStepHandlerTests.swift @@ -160,7 +160,7 @@ final class FactorStepHandlerTests: XCTestCase { case .success(let status): switch status { case .success(_): break - case .mfaRequired(_), .bindingUpdate(_), .webAuthn(request: _): + case .mfaRequired(_), .continuation(_): XCTFail("Did not receive a success response") } case .failure(let error): @@ -193,7 +193,7 @@ final class FactorStepHandlerTests: XCTestCase { switch result { case .success(let status): switch status { - case .success(_), .bindingUpdate(_), .webAuthn(request: _): + case .success(_), .continuation(_): XCTFail("Did not receive a mfa_required response") case .mfaRequired(let context): XCTAssertEqual(context.mfaToken, "abcd1234") @@ -232,7 +232,7 @@ final class FactorStepHandlerTests: XCTestCase { case .success(let status): switch status { case .success(_): break - case .mfaRequired(_), .bindingUpdate(_), .webAuthn(request: _): + case .mfaRequired(_), .continuation(_): XCTFail("Did not receive a success response") } case .failure(let error): @@ -263,13 +263,14 @@ final class FactorStepHandlerTests: XCTestCase { let processExpectation = expectation(description: "process") handler.process { result in - guard case .success(let status) = result, - case .bindingUpdate(let context) = status else { + guard case let .success(status) = result, + case let .continuation(continuation) = status + else { XCTFail("Did not receive binding update in result: \(result)") return } - switch context.update { - case .transfer(let code): + switch continuation { + case .transfer(_, code: let code): XCTAssertEqual(code, "12") do { let factor = SecondaryFactor.oob(channel: .push) @@ -281,8 +282,13 @@ final class FactorStepHandlerTests: XCTestCase { } catch { XCTFail("Did not expect error creating step handler: \(error)") } + case .prompt(_): + XCTFail("Did not expect a prompt continuation") + case .webAuthn(_): + XCTFail("Did not expect a webauthn continuation") } - XCTAssertEqual(context.oobResponse.oobCode, "1c266114-a1be-4252-8ad1-04986c5b9ac1") + XCTAssertEqual(continuation.bindingContext?.oobResponse.oobCode, + "1c266114-a1be-4252-8ad1-04986c5b9ac1") processExpectation.fulfill() } wait(for: [processExpectation], timeout: 5) @@ -351,7 +357,7 @@ final class FactorStepHandlerTests: XCTestCase { switch result { case .success(let status): switch status { - case .success(_), .bindingUpdate(_), .webAuthn(request: _): + case .success(_), .continuation(_): XCTFail("Did not receive a mfa_required response") case .mfaRequired(let context): XCTAssertEqual(context.mfaToken, "abcd1234") @@ -390,7 +396,7 @@ final class FactorStepHandlerTests: XCTestCase { case .success(let status): switch status { case .success(_): break - case .mfaRequired(_), .bindingUpdate(_), .webAuthn(request: _): + case .mfaRequired(_), .continuation(_): XCTFail("Did not receive a success response") } case .failure(let error): @@ -423,12 +429,13 @@ final class FactorStepHandlerTests: XCTestCase { let processExpectation = expectation(description: "process") handler.process { result in guard case .success(let status) = result, - case .bindingUpdate(let context) = status else { + case let .continuation(continuation) = status + else { XCTFail("Did not receive binding update in result: \(result)") return } - switch context.update { - case .transfer(let code): + switch continuation { + case .transfer(_, let code): XCTAssertEqual(code, "12") do { let resumeHandler = try factor.stepHandler(flow: self.flow, @@ -439,8 +446,12 @@ final class FactorStepHandlerTests: XCTestCase { } catch { XCTFail("Did not expect error creating step handler: \(error)") } + case .prompt(_): + XCTFail("Did not expect a prompt continuation") + case .webAuthn(_): + XCTFail("Did not expect a webauthn continuation") } - XCTAssertEqual(context.oobResponse.oobCode, "1c266114-a1be-4252-8ad1-04986c5b9ac1") + XCTAssertEqual(continuation.bindingContext?.oobResponse.oobCode, "1c266114-a1be-4252-8ad1-04986c5b9ac1") processExpectation.fulfill() } wait(for: [processExpectation], timeout: 5) diff --git a/Tests/OktaDirectAuthTests/RequestTests.swift b/Tests/OktaDirectAuthTests/RequestTests.swift index 12e11584c..a803394a1 100644 --- a/Tests/OktaDirectAuthTests/RequestTests.swift +++ b/Tests/OktaDirectAuthTests/RequestTests.swift @@ -69,11 +69,13 @@ final class RequestTests: XCTestCase { clientId: "theClientId", scopes: "openid profile"), loginHint: "user@example.com", - channelHint: .push) + channelHint: .push, + challengeHint: .oob) XCTAssertEqual(request.bodyParameters as? [String: String], [ "client_id": "theClientId", "channel_hint": "push", + "challenge_hint": "urn:okta:params:oauth:grant-type:oob", "login_hint": "user@example.com" ]) @@ -84,12 +86,14 @@ final class RequestTests: XCTestCase { scopes: "openid profile", authentication: .clientSecret("supersecret")), loginHint: "user@example.com", - channelHint: .push) + channelHint: .push, + challengeHint: .oob) XCTAssertEqual(request.bodyParameters as? [String: String], [ "client_id": "theClientId", "client_secret": "supersecret", "channel_hint": "push", + "challenge_hint": "urn:okta:params:oauth:grant-type:oob", "login_hint": "user@example.com" ]) }