From 01bbaa457c1e2324f3acabf5ceacf1d0d8a2476f Mon Sep 17 00:00:00 2001 From: Mike Nachbaur <74688448+mikenachbaur-okta@users.noreply.github.com> Date: Thu, 22 Feb 2024 11:40:08 -0800 Subject: [PATCH] Implement WebAuthn authenticator support in DirectAuth (#172) Includes the following updates and improvements: * Implement WebAuthn authenticator support in DirectAuth * Update linting rules * Update TimeCoordinator to use a lock, now that ThreadSafe is deleted * Handle MFA scenarios in WebAuthn properly --- .swiftlint.yml | 1 + .../xcshareddata/IDETemplateMacros.plist | 2 +- .../OIDCSignInViewController.swift | 3 +- .../Migrators/OIDCLegacyMigrator.swift | 12 +- .../AuthFoundation/Responses/GrantType.swift | 11 +- .../Utilities/DelegateCollection.swift | 3 +- .../AuthFoundation/Utilities/JSONValue.swift | 172 ++++++++++++++++++ .../Property Wrappers/ThreadSafe.swift | 42 ----- .../Utilities/TimeCoordinator.swift | 10 +- Sources/OktaDirectAuth/DirectAuthFlow.swift | 34 ++++ .../AuthenticationFactor.swift | 16 +- .../PrimaryFactor.swift | 50 +++-- .../SecondaryFactor.swift | 52 +++++- .../Requests/OOBAuthenticateRequest.swift | 12 +- .../Internal/Requests/TokenRequest.swift | 32 ++-- .../Internal/Requests/WebAuthnRequest.swift | 80 ++++++++ .../Step Handlers/ChallengeStepHandler.swift | 45 +++++ .../Step Handlers/OOBStepHandler.swift | 36 ++-- .../Utilities/Status+InternalExtensions.swift | 6 +- .../PublicKeyCredentialDescriptor.swift | 31 ++++ .../PublicKeyCredentialRequestOptions.swift | 91 +++++++++ .../Type/AuthenticatorTransport.swift | 40 ++++ .../Type/PublicKeyCredentialHints.swift | 31 ++++ .../Type/PublicKeyCredentialType.swift | 25 +++ .../Type/UserVerificationRequirement.swift | 31 ++++ .../OktaDirectAuth/WebAuthn/WebAuthn.swift | 78 ++++++++ .../AuthFoundationTests/JSONValueTests.swift | 153 ++++++++++++++++ .../AuthFoundationTests/ThreadSafeTests.swift | 67 ------- .../DirectAuth1FATests.swift | 6 + .../DirectAuth2FATests.swift | 4 + .../DirectAuthenticationFlowTests.swift | 6 +- .../OktaDirectAuthTests/ExtensionTests.swift | 11 +- .../FactorPropertyTests.swift | 76 ++++++-- .../FactorStepHandlerTests.swift | 26 +-- .../MockResponses/challenge-webauthn.json | 25 +++ ...nticate-binding-transfer-missingCode.json} | 0 ...rimary-authenticate-binding-transfer.json} | 0 ...nticate.json => primary-authenticate.json} | 0 Tests/OktaDirectAuthTests/RequestTests.swift | 2 + 39 files changed, 1089 insertions(+), 233 deletions(-) create mode 100644 Sources/AuthFoundation/Utilities/JSONValue.swift delete mode 100644 Sources/AuthFoundation/Utilities/Property Wrappers/ThreadSafe.swift create mode 100644 Sources/OktaDirectAuth/Internal/Requests/WebAuthnRequest.swift create mode 100644 Sources/OktaDirectAuth/Internal/Step Handlers/ChallengeStepHandler.swift create mode 100644 Sources/OktaDirectAuth/WebAuthn/PublicKeyCredentialDescriptor.swift create mode 100644 Sources/OktaDirectAuth/WebAuthn/PublicKeyCredentialRequestOptions.swift create mode 100644 Sources/OktaDirectAuth/WebAuthn/Type/AuthenticatorTransport.swift create mode 100644 Sources/OktaDirectAuth/WebAuthn/Type/PublicKeyCredentialHints.swift create mode 100644 Sources/OktaDirectAuth/WebAuthn/Type/PublicKeyCredentialType.swift create mode 100644 Sources/OktaDirectAuth/WebAuthn/Type/UserVerificationRequirement.swift create mode 100644 Sources/OktaDirectAuth/WebAuthn/WebAuthn.swift create mode 100644 Tests/AuthFoundationTests/JSONValueTests.swift delete mode 100644 Tests/AuthFoundationTests/ThreadSafeTests.swift create mode 100644 Tests/OktaDirectAuthTests/MockResponses/challenge-webauthn.json rename Tests/OktaDirectAuthTests/MockResponses/{oob-authenticate-binding-transfer-missingCode.json => primary-authenticate-binding-transfer-missingCode.json} (100%) rename Tests/OktaDirectAuthTests/MockResponses/{oob-authenticate-binding-transfer.json => primary-authenticate-binding-transfer.json} (100%) rename Tests/OktaDirectAuthTests/MockResponses/{oob-authenticate.json => primary-authenticate.json} (100%) diff --git a/.swiftlint.yml b/.swiftlint.yml index 308e21d33..496c466e0 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -12,6 +12,7 @@ only_rules: - operator_usage_whitespace - return_arrow_whitespace - trailing_whitespace + - attributes # Empty - empty_collection_literal diff --git a/OktaMobileSDK.xcworkspace/xcshareddata/IDETemplateMacros.plist b/OktaMobileSDK.xcworkspace/xcshareddata/IDETemplateMacros.plist index aab448c93..da634eac1 100644 --- a/OktaMobileSDK.xcworkspace/xcshareddata/IDETemplateMacros.plist +++ b/OktaMobileSDK.xcworkspace/xcshareddata/IDETemplateMacros.plist @@ -4,7 +4,7 @@ FILEHEADER -// Copyright (c) 2023-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// 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. diff --git a/Samples/OIDCMigration/OIDCMigration/OIDCSignInViewController.swift b/Samples/OIDCMigration/OIDCMigration/OIDCSignInViewController.swift index 7fdf56bb0..eecbc7025 100644 --- a/Samples/OIDCMigration/OIDCMigration/OIDCSignInViewController.swift +++ b/Samples/OIDCMigration/OIDCMigration/OIDCSignInViewController.swift @@ -35,7 +35,8 @@ class OIDCSignInViewController: UIViewController { object: nil) } - @objc func dismissProfile() { + @objc + func dismissProfile() { guard presentedViewController != nil else { return } self.presentedViewController?.dismiss(animated: true, completion: { diff --git a/Sources/AuthFoundation/Migration/Migrators/OIDCLegacyMigrator.swift b/Sources/AuthFoundation/Migration/Migrators/OIDCLegacyMigrator.swift index 372b1d0cd..bedf4f3a7 100644 --- a/Sources/AuthFoundation/Migration/Migrators/OIDCLegacyMigrator.swift +++ b/Sources/AuthFoundation/Migration/Migrators/OIDCLegacyMigrator.swift @@ -211,7 +211,8 @@ extension SDKVersion.Migration { NotificationCenter.default.post(name: .credentialMigrated, object: credential) } - @objc(_OIDCLegacyStateManager) class StateManager: NSObject, NSCoding { + @objc(_OIDCLegacyStateManager) + class StateManager: NSObject, NSCoding { @objc let authState: AuthState? @objc let accessibility: String? @@ -222,7 +223,8 @@ extension SDKVersion.Migration { accessibility = coder.decodeObject(forKey: "accessibility") as? String } - @objc(_OIDCLegacyAuthState) class AuthState: NSObject, NSCoding { + @objc(_OIDCLegacyAuthState) + class AuthState: NSObject, NSCoding { @objc let refreshToken: String? @objc let scope: String? @objc let lastTokenResponse: TokenResponse? @@ -238,7 +240,8 @@ extension SDKVersion.Migration { } } - @objc(_OIDCLegacyTokenResponse) class TokenResponse: NSObject, NSCoding { + @objc(_OIDCLegacyTokenResponse) + class TokenResponse: NSObject, NSCoding { @objc let accessToken: String? @objc let accessTokenExpirationDate: Date? @objc let tokenType: String? @@ -260,7 +263,8 @@ extension SDKVersion.Migration { } } - @objc(_OIDCLegacyAuthorizationResponse) class AuthorizationResponse: NSObject, NSCoding { + @objc(_OIDCLegacyAuthorizationResponse) + class AuthorizationResponse: NSObject, NSCoding { @objc let authorizationCode: String? @objc let state: String? diff --git a/Sources/AuthFoundation/Responses/GrantType.swift b/Sources/AuthFoundation/Responses/GrantType.swift index e013336fc..1bc68888b 100644 --- a/Sources/AuthFoundation/Responses/GrantType.swift +++ b/Sources/AuthFoundation/Responses/GrantType.swift @@ -23,6 +23,8 @@ public enum GrantType: Codable, Hashable { case oob case otpMFA case oobMFA + case webAuthn + case webAuthnMFA case other(_ type: String) } @@ -36,8 +38,9 @@ private let grantTypeMapping: [String: GrantType] = [ "urn:okta:params:oauth:grant-type:otp": .otp, "urn:okta:params:oauth:grant-type:oob": .oob, "http://auth0.com/oauth/grant-type/mfa-otp": .otpMFA, - "http://auth0.com/oauth/grant-type/mfa-oob": .oobMFA - + "http://auth0.com/oauth/grant-type/mfa-oob": .oobMFA, + "urn:okta:params:oauth:grant-type:webauthn": .webAuthn, + "urn:okta:params:oauth:grant-type:mfa-webauthn": .webAuthnMFA, ] extension GrantType: RawRepresentable { @@ -75,6 +78,10 @@ extension GrantType: RawRepresentable { return "http://auth0.com/oauth/grant-type/mfa-otp" case .oobMFA: return "http://auth0.com/oauth/grant-type/mfa-oob" + case .webAuthn: + return "urn:okta:params:oauth:grant-type:webauthn" + case .webAuthnMFA: + return "urn:okta:params:oauth:grant-type:mfa-webauthn" } } } diff --git a/Sources/AuthFoundation/Utilities/DelegateCollection.swift b/Sources/AuthFoundation/Utilities/DelegateCollection.swift index 3cc11f39a..d5f430d3e 100644 --- a/Sources/AuthFoundation/Utilities/DelegateCollection.swift +++ b/Sources/AuthFoundation/Utilities/DelegateCollection.swift @@ -26,8 +26,7 @@ extension UsesDelegateCollection { } public final class DelegateCollection { - @WeakCollection - private var delegates: [AnyObject?] + @WeakCollection private var delegates: [AnyObject?] public init() { delegates = [] diff --git a/Sources/AuthFoundation/Utilities/JSONValue.swift b/Sources/AuthFoundation/Utilities/JSONValue.swift new file mode 100644 index 000000000..70bce3971 --- /dev/null +++ b/Sources/AuthFoundation/Utilities/JSONValue.swift @@ -0,0 +1,172 @@ +// +// 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 + +public enum JSONValueError: Error { + case cannotDecode(value: Any) +} + +/// Represent mixed JSON values as instances of `Any`. This is used to expose API response values to Swift native types +/// where Swift enums are not supported. +public enum JSONValue: Equatable { + case string(String) + case number(Double) + case bool(Bool) + case dictionary([String: JSONValue]) + case array([JSONValue]) + case object(Any) + case null + + public init(_ value: Any?) throws { + if let value = value as? String { + self = .string(value) + } else if let value = value as? NSNumber { + self = .number(value.doubleValue) + } else if let value = value as? Bool { + self = .bool(value) + } else if let value = value as? [String: Any] { + self = .dictionary(try value.mapValues({ try JSONValue($0) })) + } else if let value = value as? [Any] { + self = .array(try value.map({ try JSONValue($0) })) + } else if value == nil { + self = .null + } else { + throw JSONValueError.cannotDecode(value: value as Any) + } + } + + /// Returns the value as an instance of `Any`. + public var anyValue: Any? { + switch self { + case let .string(value): + return value + case let .number(value): + return value + case let .bool(value): + return value + case let .dictionary(value): + return value.reduce(into: [String: Any?]()) { + $0[$1.key] = $1.value.anyValue + } + case let .array(value): + return value.map { $0.anyValue } + case let .object(value): + return value + case .null: + return nil + } + } + + public static func == (lhs: JSONValue, rhs: JSONValue) -> Bool { + switch (lhs, rhs) { + case (.string(let lhsValue), .string(let rhsValue)): + return lhsValue == rhsValue + case (.number(let lhsValue), .number(let rhsValue)): + return lhsValue == rhsValue + case (.bool(let lhsValue), .bool(let rhsValue)): + return lhsValue == rhsValue + case (.dictionary(let lhsValue), .dictionary(let rhsValue)): + return lhsValue == rhsValue + case (.array(let lhsValue), .array(let rhsValue)): + return lhsValue == rhsValue + case (.object(let lhsValue), .object(let rhsValue)): + if let lhsValue = lhsValue as? AnyHashable, + let rhsValue = rhsValue as? AnyHashable + { + return lhsValue == rhsValue + } else { + return false + } + case (.null, .null): + return true + default: + return false + } + } +} + +extension JSONValue: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .dictionary(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else if container.decodeNil() { + self = .null + } else { + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, + debugDescription: "Invalid JSON value \(decoder.codingPath)")) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .string(value): + try container.encode(value) + case let .number(value): + try container.encode(value) + case let .bool(value): + try container.encode(value) + case let .dictionary(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + case let .object(value): + if let value = value as? Codable { + try container.encode(value) + } else { + throw EncodingError.invalidValue(value, .init(codingPath: encoder.codingPath, + debugDescription: "Value is not encodable at \(encoder.codingPath)")) + } + case .null: + try container.encodeNil() + } + } +} + +extension JSONValue: CustomDebugStringConvertible { + public var debugDescription: String { + switch self { + case .string(let str): + return str.debugDescription + case .number(let num): + return num.debugDescription + case .bool(let bool): + return bool ? "true" : "false" + case .null: + return "null" + case .object(let obj): + if let obj = obj as? CustomDebugStringConvertible { + return obj.debugDescription + } else { + return "Custom object \(String(describing: obj))" + } + default: + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted] + // swiftlint:disable force_unwrapping + // swiftlint:disable force_try + return try! String(data: encoder.encode(self), encoding: .utf8)! + // swiftlint:enable force_try + // swiftlint:enable force_unwrapping + } + } +} diff --git a/Sources/AuthFoundation/Utilities/Property Wrappers/ThreadSafe.swift b/Sources/AuthFoundation/Utilities/Property Wrappers/ThreadSafe.swift deleted file mode 100644 index 5d00e33df..000000000 --- a/Sources/AuthFoundation/Utilities/Property Wrappers/ThreadSafe.swift +++ /dev/null @@ -1,42 +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 - -/// Property wrapper that implements thread-safe locking for wrapped property values. -/// -/// > Important: This is only useful for primitive value types, and will produce unexpected results when used with collection value types, such as dictionaries or arrays. -@propertyWrapper -public struct ThreadSafe { - private var value: T - private let lock = NSLock() - - public var wrappedValue: T { - get { value } - - _modify { - lock.lock() - var mutated: T = value - - defer { - value = mutated - lock.unlock() - } - - yield &mutated - } - } - - public init(wrappedValue: T) { - self.value = wrappedValue - } -} diff --git a/Sources/AuthFoundation/Utilities/TimeCoordinator.swift b/Sources/AuthFoundation/Utilities/TimeCoordinator.swift index 5b30fa44e..3cbcdc51b 100644 --- a/Sources/AuthFoundation/Utilities/TimeCoordinator.swift +++ b/Sources/AuthFoundation/Utilities/TimeCoordinator.swift @@ -53,13 +53,17 @@ class DefaultTimeCoordinator: TimeCoordinator, OAuth2ClientDelegate { Date.coordinator = DefaultTimeCoordinator() } - @ThreadSafe - private(set) var offset: TimeInterval + private let lock = UnfairLock() + private var _offset: TimeInterval + private(set) var offset: TimeInterval { + get { lock.withLock { _offset } } + set { lock.withLock { _offset = newValue } } + } private var observer: NSObjectProtocol? init() { - self.offset = 0 + self._offset = 0 self.observer = NotificationCenter.default.addObserver(forName: .oauth2ClientCreated, object: nil, queue: nil, diff --git a/Sources/OktaDirectAuth/DirectAuthFlow.swift b/Sources/OktaDirectAuth/DirectAuthFlow.swift index ab268be49..80ffe2309 100644 --- a/Sources/OktaDirectAuth/DirectAuthFlow.swift +++ b/Sources/OktaDirectAuth/DirectAuthFlow.swift @@ -70,6 +70,15 @@ public class DirectAuthenticationFlow: AuthenticationFlow { /// /// > Note: While `.oob` accepts a `channel` argument, at this time only the `push` option is available. case oob(channel: OOBChannel = .push) + + /// Authenticate the user using WebAuthn. + /// + /// 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) + /// ``` + case webAuthn } /// Enumeration defining the list of possible secondary authentication factors. @@ -93,6 +102,20 @@ public class DirectAuthenticationFlow: AuthenticationFlow { /// /// > Note: While `.oob` accepts a `channel` argument, at this time only the `push` option is available. case oob(channel: OOBChannel) + + /// Authenticate the user using WebAuthn. + /// + /// 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) + /// ``` + case webAuthn + + /// 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) } /// Channel used when authenticating an out-of-band factor using Okta Verify. @@ -126,6 +149,14 @@ public class DirectAuthenticationFlow: AuthenticationFlow { 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. @@ -140,6 +171,9 @@ public class DirectAuthenticationFlow: AuthenticationFlow { /// /// 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) + + /// Indicates the user is being prompted with a WebAuthn challenge request. + case webAuthn(_ context: WebAuthnContext) } /// The OAuth2Client this authentication flow will use. diff --git a/Sources/OktaDirectAuth/Internal/Authentication Factors/AuthenticationFactor.swift b/Sources/OktaDirectAuth/Internal/Authentication Factors/AuthenticationFactor.swift index 1863fde69..6822ac85f 100644 --- a/Sources/OktaDirectAuth/Internal/Authentication Factors/AuthenticationFactor.swift +++ b/Sources/OktaDirectAuth/Internal/Authentication Factors/AuthenticationFactor.swift @@ -13,19 +13,23 @@ import Foundation import AuthFoundation +/// Defines the additional token parameters that can be introduced through input arguments. +protocol HasTokenParameters { + /// Parameters to include in the API request. + func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: String] +} + /// Defines the common properties and functions shared between factor types. -protocol AuthenticationFactor { +protocol AuthenticationFactor: HasTokenParameters { /// The grant type supported by this factor. - var grantType: GrantType { get } - - /// Parameters to include in the API request. - var tokenParameters: [String: Any]? { get } - + func grantType(currentStatus: DirectAuthenticationFlow.Status?) -> GrantType + /// Returns a step handler capable of handling this authentication factor. /// - Parameters: /// - 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, diff --git a/Sources/OktaDirectAuth/Internal/Authentication Factors/PrimaryFactor.swift b/Sources/OktaDirectAuth/Internal/Authentication Factors/PrimaryFactor.swift index 98934c1d9..dba965104 100644 --- a/Sources/OktaDirectAuth/Internal/Authentication Factors/PrimaryFactor.swift +++ b/Sources/OktaDirectAuth/Internal/Authentication Factors/PrimaryFactor.swift @@ -26,54 +26,64 @@ extension DirectAuthenticationFlow.PrimaryFactor { extension DirectAuthenticationFlow.PrimaryFactor: AuthenticationFactor { func stepHandler(flow: DirectAuthenticationFlow, - openIdConfiguration: AuthFoundation.OpenIdConfiguration, + openIdConfiguration: OpenIdConfiguration, loginHint: String? = nil, currentStatus: DirectAuthenticationFlow.Status? = nil, factor: DirectAuthenticationFlow.PrimaryFactor) throws -> StepHandler { - var bindingContext: DirectAuthenticationFlow.BindingUpdateContext? - if case .bindingUpdate(let context) = currentStatus { - bindingContext = context - } switch self { case .otp: fallthrough case .password: let request = TokenRequest(openIdConfiguration: openIdConfiguration, clientConfiguration: flow.client.configuration, + currentStatus: currentStatus, loginHint: loginHint, factor: factor, 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, - mfaToken: currentStatus?.mfaToken, channel: channel, factor: factor, bindingContext: bindingContext) + case .webAuthn: + let mfaContext = currentStatus?.mfaContext + let request = try WebAuthnChallengeRequest(openIdConfiguration: openIdConfiguration, + clientConfiguration: flow.client.configuration, + loginHint: loginHint, + mfaToken: mfaContext?.mfaToken) + return ChallengeStepHandler(flow: flow, request: request) { + .webAuthn(.init(request: $0, + mfaContext: mfaContext)) + } } } - var tokenParameters: [String: Any]? { + func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: String] { + var result: [String: String] = [ + "grant_type": grantType(currentStatus: currentStatus).rawValue, + ] + switch self { case .otp(code: let code): - return [ - "grant_type": grantType.rawValue, - "otp": code - ] + result["otp"] = code case .password(let password): - return [ - "grant_type": grantType.rawValue, - "password": password - ] - case .oob: - return nil + result["password"] = password + case .oob: break + case .webAuthn: break } - + + return result } - var grantType: GrantType { + func grantType(currentStatus: DirectAuthenticationFlow.Status?) -> GrantType { switch self { case .otp: return .otp @@ -81,6 +91,8 @@ extension DirectAuthenticationFlow.PrimaryFactor: AuthenticationFactor { 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 575e41490..e6bad13b9 100644 --- a/Sources/OktaDirectAuth/Internal/Authentication Factors/SecondaryFactor.swift +++ b/Sources/OktaDirectAuth/Internal/Authentication Factors/SecondaryFactor.swift @@ -28,41 +28,73 @@ extension DirectAuthenticationFlow.SecondaryFactor: AuthenticationFactor { case .otp: let request = TokenRequest(openIdConfiguration: openIdConfiguration, clientConfiguration: flow.client.configuration, + currentStatus: currentStatus, loginHint: loginHint, factor: factor, - mfaToken: currentStatus?.mfaToken, grantTypesSupported: flow.supportedGrantTypes) return TokenStepHandler(flow: flow, request: request) case .oob(channel: let channel): return try OOBStepHandler(flow: flow, openIdConfiguration: openIdConfiguration, + currentStatus: currentStatus, loginHint: loginHint, - mfaToken: currentStatus?.mfaToken, channel: channel, factor: factor, bindingContext: bindingContext) + case .webAuthn: + let mfaContext = currentStatus?.mfaContext + let request = try WebAuthnChallengeRequest(openIdConfiguration: openIdConfiguration, + clientConfiguration: flow.client.configuration, + loginHint: loginHint, + mfaToken: mfaContext?.mfaToken) + return ChallengeStepHandler(flow: flow, request: request) { + .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) } } - var tokenParameters: [String: Any]? { + 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): - return [ - "grant_type": grantType.rawValue, - "otp": code - ] - case .oob: - return nil + result["otp"] = code + case .webAuthnAssertion(_): break + case .oob(channel: _): break + case .webAuthn: break } + return result } - var grantType: GrantType { + func grantType(currentStatus: DirectAuthenticationFlow.Status?) -> GrantType { switch self { case .otp: return .otpMFA case .oob: return .oobMFA + case .webAuthn, .webAuthnAssertion(_): + if currentStatus?.mfaContext?.mfaToken != nil { + return .webAuthnMFA + } else { + return .webAuthn + } } } } diff --git a/Sources/OktaDirectAuth/Internal/Requests/OOBAuthenticateRequest.swift b/Sources/OktaDirectAuth/Internal/Requests/OOBAuthenticateRequest.swift index cbea63bea..3749d9e81 100644 --- a/Sources/OktaDirectAuth/Internal/Requests/OOBAuthenticateRequest.swift +++ b/Sources/OktaDirectAuth/Internal/Requests/OOBAuthenticateRequest.swift @@ -14,12 +14,12 @@ import Foundation import AuthFoundation extension OpenIdConfiguration { - var oobAuthenticateEndpoint: URL? { - tokenEndpoint.url(replacing: "/token", with: "/oob-authenticate") + var primaryAuthenticateEndpoint: URL? { + tokenEndpoint.url(replacing: "/token", with: "/primary-authenticate") } } -struct OOBResponse: Codable { +struct OOBResponse: Codable, HasTokenParameters { let oobCode: String let expiresIn: TimeInterval let interval: TimeInterval @@ -35,6 +35,10 @@ struct OOBResponse: Codable { self.bindingMethod = bindingMethod self.bindingCode = bindingCode } + + func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: String] { + ["oob_code": oobCode] + } } struct OOBAuthenticateRequest { @@ -48,7 +52,7 @@ struct OOBAuthenticateRequest { loginHint: String, channelHint: DirectAuthenticationFlow.OOBChannel) throws { - guard let url = openIdConfiguration.oobAuthenticateEndpoint else { + guard let url = openIdConfiguration.primaryAuthenticateEndpoint else { throw OAuth2Error.cannotComposeUrl } diff --git a/Sources/OktaDirectAuth/Internal/Requests/TokenRequest.swift b/Sources/OktaDirectAuth/Internal/Requests/TokenRequest.swift index fb60abb04..e66cc334f 100644 --- a/Sources/OktaDirectAuth/Internal/Requests/TokenRequest.swift +++ b/Sources/OktaDirectAuth/Internal/Requests/TokenRequest.swift @@ -16,26 +16,26 @@ import AuthFoundation struct TokenRequest { let openIdConfiguration: OpenIdConfiguration let clientConfiguration: OAuth2Client.Configuration + let currentStatus: DirectAuthenticationFlow.Status? let loginHint: String? let factor: any AuthenticationFactor - let mfaToken: String? - let oobCode: String? + let parameters: (any HasTokenParameters)? let grantTypesSupported: [GrantType]? init(openIdConfiguration: OpenIdConfiguration, clientConfiguration: OAuth2Client.Configuration, + currentStatus: DirectAuthenticationFlow.Status?, loginHint: String? = nil, factor: any AuthenticationFactor, - mfaToken: String? = nil, - oobCode: String? = nil, + parameters: (any HasTokenParameters)? = nil, grantTypesSupported: [GrantType]? = nil) { self.openIdConfiguration = openIdConfiguration self.clientConfiguration = clientConfiguration + self.currentStatus = currentStatus self.loginHint = loginHint self.factor = factor - self.mfaToken = mfaToken - self.oobCode = oobCode + self.parameters = parameters self.grantTypesSupported = grantTypesSupported } } @@ -47,18 +47,12 @@ extension TokenRequest: OAuth2TokenRequest, OAuth2APIRequest, APIRequestBody { var contentType: APIContentType? { .formEncoded } var acceptsType: APIContentType? { .json } var bodyParameters: [String: Any]? { - var result: [String: Any] = [ - "client_id": clientConfiguration.clientId, - "grant_type": factor.grantType.rawValue, - "scope": clientConfiguration.scopes - ] - - if let mfaToken = mfaToken { - result["mfa_token"] = mfaToken - } + var result = factor.tokenParameters(currentStatus: currentStatus) + result["client_id"] = clientConfiguration.clientId + result["scope"] = clientConfiguration.scopes - if let oobCode = oobCode { - result["oob_code"] = oobCode + if let tokenParameters = parameters?.tokenParameters(currentStatus: currentStatus) { + result.merge(tokenParameters, uniquingKeysWith: { $1 }) } if let loginHint = loginHint { @@ -75,10 +69,6 @@ extension TokenRequest: OAuth2TokenRequest, OAuth2APIRequest, APIRequestBody { result["grant_types_supported"] = grantTypesSupported.joined(separator: " ") } - if let parameters = factor.tokenParameters { - result.merge(parameters, uniquingKeysWith: { $1 }) - } - if let parameters = clientConfiguration.authentication.additionalParameters { result.merge(parameters, uniquingKeysWith: { $1 }) } diff --git a/Sources/OktaDirectAuth/Internal/Requests/WebAuthnRequest.swift b/Sources/OktaDirectAuth/Internal/Requests/WebAuthnRequest.swift new file mode 100644 index 000000000..3401afd5b --- /dev/null +++ b/Sources/OktaDirectAuth/Internal/Requests/WebAuthnRequest.swift @@ -0,0 +1,80 @@ +// +// 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 + +struct WebAuthnChallengeRequest { + let url: URL + let clientConfiguration: OAuth2Client.Configuration + let loginHint: String? + let mfaToken: String? + + init(openIdConfiguration: OpenIdConfiguration, + clientConfiguration: OAuth2Client.Configuration, + loginHint: String? = nil, + mfaToken: String? = nil) throws + { + guard let url = openIdConfiguration.primaryAuthenticateEndpoint else { + throw OAuth2Error.cannotComposeUrl + } + + self.url = url + self.clientConfiguration = clientConfiguration + self.loginHint = loginHint + self.mfaToken = mfaToken + } +} + +extension WebAuthnChallengeRequest: APIRequest, APIRequestBody { + typealias ResponseType = WebAuthn.CredentialRequestOptions + + var httpMethod: APIRequestMethod { .post } + var contentType: APIContentType? { .formEncoded } + var acceptsType: APIContentType? { .json } + var bodyParameters: [String: Any]? { + var result: [String: Any] = [ + "client_id": clientConfiguration.clientId, + "challenge_hint": GrantType.webAuthn.rawValue + ] + + if let loginHint = loginHint { + result["login_hint"] = loginHint + } + + if let mfaToken = mfaToken { + result["mfa_token"] = mfaToken + } + + if let parameters = clientConfiguration.authentication.additionalParameters { + result.merge(parameters, uniquingKeysWith: { $1 }) + } + + return result + } +} + +extension WebAuthn.AuthenticatorAssertionResponse: HasTokenParameters { + func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: String] { + var result = [ + "clientDataJSON": clientDataJSON, + "authenticatorData": authenticatorData, + "signature": signature, + ] + + if let userHandle = userHandle { + result["userHandle"] = userHandle + } + + return result + } +} diff --git a/Sources/OktaDirectAuth/Internal/Step Handlers/ChallengeStepHandler.swift b/Sources/OktaDirectAuth/Internal/Step Handlers/ChallengeStepHandler.swift new file mode 100644 index 000000000..e33d4b284 --- /dev/null +++ b/Sources/OktaDirectAuth/Internal/Step Handlers/ChallengeStepHandler.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 +import AuthFoundation + +class ChallengeStepHandler: StepHandler { + let flow: DirectAuthenticationFlow + let request: Request + private let statusBlock: (_ response: Request.ResponseType) throws -> DirectAuthenticationFlow.Status + + init(flow: DirectAuthenticationFlow, + request: Request, + statusBlock: @escaping (_ response: Request.ResponseType) throws -> DirectAuthenticationFlow.Status) + { + self.flow = flow + self.request = request + self.statusBlock = statusBlock + } + + func process(completion: @escaping (Result) -> Void) { + request.send(to: flow.client) { result in + switch result { + case .failure(let error): + self.flow.process(error, completion: completion) + case .success(let response): + do { + let status = try self.statusBlock(response.result) + completion(.success(status)) + } catch { + completion(.failure(.init(error))) + } + } + } + } +} diff --git a/Sources/OktaDirectAuth/Internal/Step Handlers/OOBStepHandler.swift b/Sources/OktaDirectAuth/Internal/Step Handlers/OOBStepHandler.swift index f2b57f8f3..343bc0d9e 100644 --- a/Sources/OktaDirectAuth/Internal/Step Handlers/OOBStepHandler.swift +++ b/Sources/OktaDirectAuth/Internal/Step Handlers/OOBStepHandler.swift @@ -16,8 +16,8 @@ import AuthFoundation class OOBStepHandler: StepHandler { let flow: DirectAuthenticationFlow let openIdConfiguration: OpenIdConfiguration + let currentStatus: DirectAuthenticationFlow.Status? let loginHint: String? - let mfaToken: String? let channel: DirectAuthenticationFlow.OOBChannel let factor: Factor private let bindingContext: DirectAuthenticationFlow.BindingUpdateContext? @@ -25,16 +25,16 @@ class OOBStepHandler: StepHandler { init(flow: DirectAuthenticationFlow, openIdConfiguration: OpenIdConfiguration, + currentStatus: DirectAuthenticationFlow.Status?, loginHint: String?, - mfaToken: String?, channel: DirectAuthenticationFlow.OOBChannel, factor: Factor, bindingContext: DirectAuthenticationFlow.BindingUpdateContext? = nil) throws { self.flow = flow self.openIdConfiguration = openIdConfiguration + self.currentStatus = currentStatus self.loginHint = loginHint - self.mfaToken = mfaToken self.channel = channel self.factor = factor self.bindingContext = bindingContext @@ -56,12 +56,15 @@ class OOBStepHandler: StepHandler { case .none: self.requestToken(using: response, completion: completion) case .transfer: - guard let bindingCode = response.bindingCode, bindingCode.isEmpty == false else { + guard let bindingCode = response.bindingCode, + bindingCode.isEmpty == false + else { completion(.failure(.bindingCodeMissing)) return } - let context = DirectAuthenticationFlow.BindingUpdateContext(update: .transfer(bindingCode), oobResponse: response) - completion(.success(.bindingUpdate(context))) + + completion(.success(.bindingUpdate(.init(update: .transfer(bindingCode), + oobResponse: response)))) } } } @@ -78,8 +81,8 @@ class OOBStepHandler: StepHandler { } // Request where OOB is used as the secondary factor - else if let mfaToken = mfaToken { - requestOOBCode(mfaToken: mfaToken, completion: completion) + else if case let .mfaRequired(context) = currentStatus { + requestOOBCode(mfaToken: context.mfaToken, completion: completion) } // Cannot create a request @@ -113,10 +116,11 @@ class OOBStepHandler: StepHandler { completion: @escaping (Result) -> Void) { do { + let grantType = factor.grantType(currentStatus: currentStatus) let request = try ChallengeRequest(openIdConfiguration: openIdConfiguration, clientConfiguration: flow.client.configuration, mfaToken: mfaToken, - challengeTypesSupported: [factor.grantType]) + challengeTypesSupported: [grantType]) request.send(to: flow.client) { result in switch result { case .failure(let error): @@ -135,13 +139,13 @@ class OOBStepHandler: StepHandler { } private func requestToken(using response: OOBResponse, completion: @escaping (Result) -> Void) { - let request = TokenRequest(openIdConfiguration: self.openIdConfiguration, - clientConfiguration: self.flow.client.configuration, - factor: self.factor, - mfaToken: self.mfaToken, - oobCode: response.oobCode, - grantTypesSupported: self.flow.supportedGrantTypes) - self.poll = PollingHandler(client: self.flow.client, + let request = TokenRequest(openIdConfiguration: openIdConfiguration, + clientConfiguration: flow.client.configuration, + currentStatus: currentStatus, + factor: factor, + parameters: response, + grantTypesSupported: flow.supportedGrantTypes) + self.poll = PollingHandler(client: flow.client, request: request, expiresIn: response.expiresIn, interval: response.interval) { pollHandler, result in diff --git a/Sources/OktaDirectAuth/Internal/Utilities/Status+InternalExtensions.swift b/Sources/OktaDirectAuth/Internal/Utilities/Status+InternalExtensions.swift index a09276ffb..affb41c85 100644 --- a/Sources/OktaDirectAuth/Internal/Utilities/Status+InternalExtensions.swift +++ b/Sources/OktaDirectAuth/Internal/Utilities/Status+InternalExtensions.swift @@ -13,9 +13,11 @@ import Foundation extension DirectAuthenticationFlow.Status { - var mfaToken: String? { + var mfaContext: DirectAuthenticationFlow.MFAContext? { if case let .mfaRequired(context) = self { - return context.mfaToken + return context + } else if case let .webAuthn(context) = self { + return context.mfaContext } else { return nil } diff --git a/Sources/OktaDirectAuth/WebAuthn/PublicKeyCredentialDescriptor.swift b/Sources/OktaDirectAuth/WebAuthn/PublicKeyCredentialDescriptor.swift new file mode 100644 index 000000000..c555551dd --- /dev/null +++ b/Sources/OktaDirectAuth/WebAuthn/PublicKeyCredentialDescriptor.swift @@ -0,0 +1,31 @@ +// +// 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 WebAuthn { + /** + This dictionary contains the attributes that are specified by a caller when referring to a public key credential as an input parameter to the create() or get() methods. It mirrors the fields of the PublicKeyCredential object returned by the latter methods. + + - Note: [W3C Reccomendation](https://www.w3.org/TR/webauthn/#dictionary-credential-descriptor) + */ + public struct PublicKeyCredentialDescriptor: Codable { + /// This member contains the credential ID of the public key credential the caller is referring to. + public let id: String + + /// This member contains the type of the public key credential the caller is referring to. + public let type: PublicKeyCredentialType + + /// This OPTIONAL member contains a hint as to how the client might communicate with the managing authenticator of the public key credential the caller is referring to. The values SHOULD be members of AuthenticatorTransport but client platforms MUST ignore unknown values. + public let transports: [AuthenticatorTransport]? + } +} diff --git a/Sources/OktaDirectAuth/WebAuthn/PublicKeyCredentialRequestOptions.swift b/Sources/OktaDirectAuth/WebAuthn/PublicKeyCredentialRequestOptions.swift new file mode 100644 index 000000000..e2b664958 --- /dev/null +++ b/Sources/OktaDirectAuth/WebAuthn/PublicKeyCredentialRequestOptions.swift @@ -0,0 +1,91 @@ +// +// 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 WebAuthn { + /** + The PublicKeyCredentialRequestOptions dictionary supplies get() with the data it needs to generate an assertion. Its challenge member MUST be present, while its other members are OPTIONAL. + + - Note: [W3C Reccomendation](https://www.w3.org/TR/webauthn/#dictionary-assertion-options) + */ + public struct PublicKeyCredentialRequestOptions: Codable { + /// 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 + + /// Specifies the RP ID claimed by the Relying Party. The client MUST verify that the Relying Party's origin matches the scope of this RP ID. The authenticator MUST verify that this RP ID exactly equals the rpId of the credential to be used for the authentication ceremony. + public internal(set) var rpID: String? + + /// Used by the client to find authenticators eligible for this authentication ceremony. + public let allowCredentials: [PublicKeyCredentialDescriptor]? + + /// Specifies a time that the Relying Party is willing to wait for the call to complete. + public let timeout: TimeInterval? + + /// Specifies the Relying Party's requirements regarding user verification. Eligible authenticators are filtered to only those capable of satisfying this requirement. + public let userVerification: UserVerificationRequirement? + + /// Guides the user agent in interacting with the user. + 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?]? + + enum CodingKeys: String, CodingKey { + case allowCredentials + case challenge + case extensions + case rpID + case timeout + case hints + case userVerification + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + allowCredentials = try container.decodeIfPresent([PublicKeyCredentialDescriptor].self, forKey: .allowCredentials) + challenge = try container.decode(String.self, forKey: .challenge) + rpID = try container.decodeIfPresent(String.self, forKey: .rpID) + hints = try container.decodeIfPresent([PublicKeyCredentialHints].self, forKey: .hints) + userVerification = try container.decodeIfPresent(UserVerificationRequirement.self, forKey: .userVerification) + + if let interval = try container.decodeIfPresent(UInt64.self, forKey: .timeout) { + timeout = Double(interval) / 1000.0 + } else { + timeout = nil + } + + if let jsonValues = try container.decodeIfPresent([String: JSONValue].self, forKey: .extensions) { + extensions = jsonValues.mapValues({ $0.anyValue }) + } else { + extensions = nil + } + } + + 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) + + if let timeout = timeout { + try container.encode(UInt64(timeout * 1000), forKey: .timeout) + } + + if let extensions = extensions { + try container.encode(try extensions.mapValues({ try JSONValue($0) }), forKey: .extensions) + } + } + } +} diff --git a/Sources/OktaDirectAuth/WebAuthn/Type/AuthenticatorTransport.swift b/Sources/OktaDirectAuth/WebAuthn/Type/AuthenticatorTransport.swift new file mode 100644 index 000000000..cf916bef0 --- /dev/null +++ b/Sources/OktaDirectAuth/WebAuthn/Type/AuthenticatorTransport.swift @@ -0,0 +1,40 @@ +// +// 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 WebAuthn { + /** + This member contains the type of the public key credential the caller is referring to. + + - Note: [W3C Reccomendation](https://www.w3.org/TR/webauthn/#dom-publickeycredentialdescriptor-type) + */ + public enum AuthenticatorTransport: String, Codable { + /// Indicates the respective authenticator can be contacted over Bluetooth Smart (Bluetooth Low Energy / BLE). + case ble + + /// Indicates the respective authenticator can be contacted over Near Field Communication (NFC). + case nfc + + /// Indicates the respective authenticator is contacted using a client device-specific transport, i.e., it is a platform authenticator. These authenticators are not removable from the client device. + case platform = "internal" + + /// Indicates the respective authenticator can be contacted over removable USB + case usb + + /// Indicates the respective authenticator can be contacted over ISO/IEC 7816 smart card with contacts. + case smartCard = "smart-card" + + /// Indicates the respective authenticator can be contacted using a combination of (often separate) data-transport and proximity mechanisms. This supports, for example, authentication on a desktop computer using a smartphone. + case hybrid + } +} diff --git a/Sources/OktaDirectAuth/WebAuthn/Type/PublicKeyCredentialHints.swift b/Sources/OktaDirectAuth/WebAuthn/Type/PublicKeyCredentialHints.swift new file mode 100644 index 000000000..3ecb695e6 --- /dev/null +++ b/Sources/OktaDirectAuth/WebAuthn/Type/PublicKeyCredentialHints.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 + +extension WebAuthn { + /** + WebAuthn Relying Parties may use this enumeration to communicate hints to the user-agent about how a request may be best completed. + + - Note: [W3C Reccomendation](https://w3c.github.io/webauthn/#enumdef-publickeycredentialhints) + */ + public enum PublicKeyCredentialHints: String, Codable { + /// Indicates that the Relying Party believes that users will satisfy this request with a physical security key. + case securityKey = "security-key" + + /// Indicates that the Relying Party believes that users will satisfy this request with a platform authenticator attached to the client device. + case clientDevice = "client-device" + + /// Indicates that the Relying Party believes that users will satisfy this request with general-purpose authenticators such as smartphones. + case hybrid + } +} diff --git a/Sources/OktaDirectAuth/WebAuthn/Type/PublicKeyCredentialType.swift b/Sources/OktaDirectAuth/WebAuthn/Type/PublicKeyCredentialType.swift new file mode 100644 index 000000000..b20354fc0 --- /dev/null +++ b/Sources/OktaDirectAuth/WebAuthn/Type/PublicKeyCredentialType.swift @@ -0,0 +1,25 @@ +// +// 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 WebAuthn { + /** + This member contains the type of the public key credential the caller is referring to. + + - Note: [W3C Reccomendation](https://www.w3.org/TR/webauthn/#dom-publickeycredentialdescriptor-type) + */ + public enum PublicKeyCredentialType: String, Codable { + /// 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 new file mode 100644 index 000000000..a787d86e5 --- /dev/null +++ b/Sources/OktaDirectAuth/WebAuthn/Type/UserVerificationRequirement.swift @@ -0,0 +1,31 @@ +// +// 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 WebAuthn { + /** + A WebAuthn Relying Party may require user verification for some of its operations but not for others, and may use this type to express its needs. + + - Note: [W3C Reccomendation](https://www.w3.org/TR/webauthn/#enum-userVerificationRequirement) + */ + public enum UserVerificationRequirement: String, Codable { + /// 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 + + /// This value indicates that the Relying Party prefers user verification for the operation if possible, but will not fail the operation if the response does not have the UV flag set. + case preferred + + /// This value indicates that the Relying Party does not want user verification employed during the operation. + case discouraged + } +} diff --git a/Sources/OktaDirectAuth/WebAuthn/WebAuthn.swift b/Sources/OktaDirectAuth/WebAuthn/WebAuthn.swift new file mode 100644 index 000000000..5ad5f31d0 --- /dev/null +++ b/Sources/OktaDirectAuth/WebAuthn/WebAuthn.swift @@ -0,0 +1,78 @@ +// +// 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 + +/// Exposes the types and classes used to authenticate using WebAuthn. +/// +/// Example: +/// +/// ```swift +/// let challengeStatus = try await flow.start("user@example.com", with: .webAuthn) +/// guard case let .webAuthn(let request) = challengeStatus else { return } +/// +/// // Supply challenge request values to your authenticator +/// let responseStatus = try await flow.resume( +/// challengeStatus, +/// with: .webAuthnAssertion(.init( +/// clientDataJSON: authJson, +/// authenticatorData: authData, +/// signature: authSignature, +/// userHandle: nil) +/// ) +/// ) +/// ``` +public struct WebAuthn { + /// Represents the credential challenge returned from the server when a WebAuthn authentication is initiated. + public struct CredentialRequestOptions: Codable { + /// The public key request options supplied to the client from the server. + public let publicKey: WebAuthn.PublicKeyCredentialRequestOptions + + /// Defines additional authenticator enrollment information supplied by the server. + public let authenticatorEnrollments: [AuthenticatorEnrollment]? + + /// Defines additional authenticator enrollment information supplied by the server. + public struct AuthenticatorEnrollment: Codable { + /// The ID supplied from the server representing this credential. + /// + /// **Note:** This should be identical to the ``WebAuthn/PublicKeyCredentialRequestOptions/rpID`` value. + public let credentialId: String + + /// The human-readable display name for this authenticator. + public let displayName: String + + /// Additional profile information related to this authenticator. + public let profile: [String: String] + } + } + + /// Defines the set of data expected from the client in response to an authenticator challenge. + /// + /// This value should be supplied to the ``DirectAuthenticationFlow/SecondaryFactor/webAuthnAssertion`` type. + public struct AuthenticatorAssertionResponse: Codable, Equatable { + /// The client data JSON response, represented as a string. + public let clientDataJSON: String + + /// The authenticator data for the response. + public let authenticatorData: String + + /// The signature generated from the authenticator. + public let signature: String + + /// The optional user handle to supply to the server, typically if the resident key is enabled. + public let userHandle: String? + } +} + +extension WebAuthn.CredentialRequestOptions: JSONDecodable { + public static var jsonDecoder = JSONDecoder() +} diff --git a/Tests/AuthFoundationTests/JSONValueTests.swift b/Tests/AuthFoundationTests/JSONValueTests.swift new file mode 100644 index 000000000..22e2f20b7 --- /dev/null +++ b/Tests/AuthFoundationTests/JSONValueTests.swift @@ -0,0 +1,153 @@ +/* + * 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 XCTest +@testable import AuthFoundation + +class JSONValueTests: XCTestCase { + let decoder = JSONDecoder() + let encoder = JSONEncoder() + + func testString() throws { + let value = JSONValue.string("Test String") + XCTAssertNotNil(value) + XCTAssertEqual(value.debugDescription, "\"Test String\"") + + if let stringValue = value.anyValue as? String { + XCTAssertEqual(stringValue, "Test String") + } else { + XCTFail("Object not a string") + } + + let encoded = try encoder.encode(value) + XCTAssertNotNil(encoded) + let decoded = try decoder.decode(JSONValue.self, from: encoded) + XCTAssertEqual(decoded, value) + } + + func testNumber() throws { + let value = JSONValue.number(1) + XCTAssertNotNil(value) + XCTAssertEqual(value.debugDescription, "1.0") + + if let numberValue = value.anyValue as? NSNumber { + XCTAssertEqual(numberValue, NSNumber(integerLiteral: 1)) + } else { + XCTFail("Object not a number") + } + + let encoded = try encoder.encode(value) + XCTAssertNotNil(encoded) + let decoded = try decoder.decode(JSONValue.self, from: encoded) + XCTAssertEqual(decoded, value) + } + + func testBool() throws { + let value = JSONValue.bool(true) + XCTAssertNotNil(value) + XCTAssertEqual(value.debugDescription, "true") + XCTAssertEqual(JSONValue.bool(false).debugDescription, "false") + + if let boolValue = value.anyValue as? NSNumber { + XCTAssertEqual(boolValue, NSNumber(booleanLiteral: true)) + } else { + XCTFail("Object not a bool") + } + + let encoded = try encoder.encode(value) + XCTAssertNotNil(encoded) + let decoded = try decoder.decode(JSONValue.self, from: encoded) + XCTAssertEqual(decoded, value) + } + + func testNull() throws { + let value = JSONValue.null + XCTAssertNotNil(value) + XCTAssertEqual(value.debugDescription, "null") + + XCTAssertNil(value.anyValue) + + let encoded = try encoder.encode(value) + XCTAssertNotNil(encoded) + let decoded = try decoder.decode(JSONValue.self, from: encoded) + XCTAssertEqual(decoded, value) + } + + func testArray() throws { + let value = JSONValue.array([JSONValue.string("foo"), JSONValue.string("bar")]) + XCTAssertNotNil(value) + XCTAssertEqual(value.debugDescription, """ + [ + "foo", + "bar" + ] + """) + + if let arrayValue = value.anyValue as? NSArray { + XCTAssertEqual(arrayValue, ["foo", "bar"]) + } else { + XCTFail("Object not a array") + } + + let encoded = try encoder.encode(value) + XCTAssertNotNil(encoded) + let decoded = try decoder.decode(JSONValue.self, from: encoded) + XCTAssertEqual(decoded, value) + } + + func testDictionary() throws { + let value = JSONValue.dictionary( + ["foo": JSONValue.dictionary( + ["bar": JSONValue.array( + [JSONValue.string("woof")]) + ]) + ]) + XCTAssertNotNil(value) + XCTAssertEqual(value.debugDescription, """ + { + "foo" : { + "bar" : [ + "woof" + ] + } + } + """) + if let dictValue = value.anyValue as? NSDictionary { + XCTAssertEqual(dictValue, ["foo": ["bar": ["woof"]]]) + } else { + XCTFail("Object not a dictionary") + } + + let encoded = try encoder.encode(value) + XCTAssertNotNil(encoded) + let decoded = try decoder.decode(JSONValue.self, from: encoded) + XCTAssertEqual(decoded, value) + } + + func testObject() throws { + let object = URL(string: "https://example.com")! + let value = JSONValue.object(object) + XCTAssertNotNil(value) + XCTAssertEqual(value.debugDescription, "https://example.com") + + if let urlValue = value.anyValue as? URL { + XCTAssertEqual(urlValue, URL(string: "https://example.com")) + } else { + XCTFail("Object not a URL") + } + + XCTAssertEqual(value, JSONValue.object(URL(string: "https://example.com")!)) + let encoded = try encoder.encode(value) + let decoded = try decoder.decode(JSONValue.self, from: encoded) + XCTAssertEqual(decoded.anyValue as? String, "https://example.com") + } +} diff --git a/Tests/AuthFoundationTests/ThreadSafeTests.swift b/Tests/AuthFoundationTests/ThreadSafeTests.swift deleted file mode 100644 index 1de603006..000000000 --- a/Tests/AuthFoundationTests/ThreadSafeTests.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// 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 AuthFoundation -import XCTest - -class ThreadSafeTests: XCTestCase { - @ThreadSafe - var primitiveValue: Int = 0 - - var readQueue: DispatchQueue! - var queues: [DispatchQueue]! - - override func setUpWithError() throws { - queues = [DispatchQueue]() - for index in 0...3 { - queues.append(DispatchQueue(label: "\(name)[\(index)]")) - } - readQueue = DispatchQueue(label: "\(name).read") - - primitiveValue = 0 - } - - func testPrimitiveUpdates() { - let group = DispatchGroup() - - func add() { - primitiveValue += 1 - } - - for queue in queues { - group.enter() - queue.async { - for _ in 0..<100 { - add() - } - group.leave() - } - } - - group.enter() - readQueue.async { - for _ in 0..<400 { - _ = self.primitiveValue - } - - group.leave() - } - - let wait = expectation(description: "Wait for writes to finish") - group.notify(queue: .main) { - wait.fulfill() - } - waitForExpectations(timeout: 5) - - XCTAssertEqual(primitiveValue, queues.count * 100) - } -} diff --git a/Tests/OktaDirectAuthTests/DirectAuth1FATests.swift b/Tests/OktaDirectAuthTests/DirectAuth1FATests.swift index b68af549d..03ffbd598 100644 --- a/Tests/OktaDirectAuthTests/DirectAuth1FATests.swift +++ b/Tests/OktaDirectAuthTests/DirectAuth1FATests.swift @@ -59,6 +59,8 @@ final class DirectAuth1FATests: XCTestCase { XCTFail("Not expecting MFA Required") case .bindingUpdate(_): XCTFail("Not expecting binding update") + case .webAuthn(request: _): + XCTFail("Not expecting webauthn request") } } @@ -79,6 +81,8 @@ final class DirectAuth1FATests: XCTestCase { XCTFail("Not expecting MFA Required") case .bindingUpdate(_): XCTFail("Not expecting binding update") + case .webAuthn(request: _): + XCTFail("Not expecting webauthn request") } } @@ -99,6 +103,8 @@ final class DirectAuth1FATests: XCTestCase { XCTFail("Not expecting MFA Required") case .bindingUpdate(_): XCTFail("Not expecting binding update") + case .webAuthn(request: _): + XCTFail("Not expecting webauthn request") } } #endif diff --git a/Tests/OktaDirectAuthTests/DirectAuth2FATests.swift b/Tests/OktaDirectAuthTests/DirectAuth2FATests.swift index 590308874..cadc95e26 100644 --- a/Tests/OktaDirectAuthTests/DirectAuth2FATests.swift +++ b/Tests/OktaDirectAuthTests/DirectAuth2FATests.swift @@ -66,9 +66,13 @@ final class DirectAuth2FATests: XCTestCase { XCTFail("Not expecting MFA Required") case .bindingUpdate(_): XCTFail("Not expecting binding update") + case .webAuthn(request: _): + XCTFail("Not expecting webauthn request") } case .bindingUpdate(_): XCTFail("Not expecting binding update") + case .webAuthn(request: _): + XCTFail("Not expecting webauthn request") } XCTAssertFalse(flow.isAuthenticating) } diff --git a/Tests/OktaDirectAuthTests/DirectAuthenticationFlowTests.swift b/Tests/OktaDirectAuthTests/DirectAuthenticationFlowTests.swift index f7fdf4fa5..55ebea4bd 100644 --- a/Tests/OktaDirectAuthTests/DirectAuthenticationFlowTests.swift +++ b/Tests/OktaDirectAuthTests/DirectAuthenticationFlowTests.swift @@ -33,12 +33,12 @@ struct TestFactor: AuthenticationFactor { let result: (Result)? let exception: (any Error)? - var grantType: AuthFoundation.GrantType { + func grantType(currentStatus: DirectAuthenticationFlow.Status?) -> GrantType { .implicit } - var tokenParameters: [String : Any]? { - nil + func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: String] { + [:] } func stepHandler(flow: OktaDirectAuth.DirectAuthenticationFlow, diff --git a/Tests/OktaDirectAuthTests/ExtensionTests.swift b/Tests/OktaDirectAuthTests/ExtensionTests.swift index 1fcfe68b6..8254f29cc 100644 --- a/Tests/OktaDirectAuthTests/ExtensionTests.swift +++ b/Tests/OktaDirectAuthTests/ExtensionTests.swift @@ -23,8 +23,15 @@ final class ExtensionTests: XCTestCase { } func testAuthFlowStatus() throws { - XCTAssertNil(Status.success(Token.mockToken()).mfaToken) - XCTAssertEqual(Status.mfaRequired(.init(supportedChallengeTypes: nil, mfaToken: "abc123")).mfaToken, "abc123") + XCTAssertNil(Status.success(Token.mockToken()).mfaContext) + + let mfaContext = DirectAuthenticationFlow.MFAContext(supportedChallengeTypes: [.oob], mfaToken: "abc123") + XCTAssertEqual(Status.mfaRequired(mfaContext).mfaContext?.mfaToken, "abc123") + + let webAuthnContext = DirectAuthenticationFlow.WebAuthnContext( + request: try mock(from: .module, for: "challenge-webauthn", in: "MockResponses"), + mfaContext: mfaContext) + XCTAssertEqual(Status.webAuthn(webAuthnContext).mfaContext?.mfaToken, "abc123") } func testStatusEquality() throws { diff --git a/Tests/OktaDirectAuthTests/FactorPropertyTests.swift b/Tests/OktaDirectAuthTests/FactorPropertyTests.swift index 02f1f066f..959da5430 100644 --- a/Tests/OktaDirectAuthTests/FactorPropertyTests.swift +++ b/Tests/OktaDirectAuthTests/FactorPropertyTests.swift @@ -21,32 +21,78 @@ final class FactorPropertyTests: XCTestCase { XCTAssertEqual(PrimaryFactor.password("foo").loginHintKey, "username") XCTAssertEqual(PrimaryFactor.otp(code: "123456").loginHintKey, "login_hint") XCTAssertEqual(PrimaryFactor.oob(channel: .push).loginHintKey, "login_hint") + XCTAssertEqual(PrimaryFactor.webAuthn.loginHintKey, "login_hint") } - func testGrantTypes() throws { - XCTAssertEqual(PrimaryFactor.password("foo").grantType, .password) - XCTAssertEqual(PrimaryFactor.otp(code: "123456").grantType, .otp) - XCTAssertEqual(PrimaryFactor.oob(channel: .push).grantType, .oob) - - XCTAssertEqual(SecondaryFactor.otp(code: "123456").grantType, .otpMFA) - XCTAssertEqual(SecondaryFactor.oob(channel: .push).grantType, .oobMFA) - } - - func testTokenParameters() throws { - XCTAssertEqual(PrimaryFactor.password("foo").tokenParameters as? [String: String], [ + func testPrimaryTokenParameters() throws { + var parameters: [String: String] = [:] + + parameters = PrimaryFactor.password("foo").tokenParameters(currentStatus: nil) + XCTAssertEqual(parameters, [ "grant_type": "password", "password": "foo" ]) - XCTAssertEqual(PrimaryFactor.otp(code: "123456").tokenParameters as? [String: String], [ + + parameters = PrimaryFactor.otp(code: "123456").tokenParameters(currentStatus: nil) + XCTAssertEqual(parameters, [ "grant_type": "urn:okta:params:oauth:grant-type:otp", "otp": "123456" ]) - XCTAssertNil(PrimaryFactor.oob(channel: .push).tokenParameters) + + parameters = PrimaryFactor.oob(channel: .push).tokenParameters(currentStatus: nil) + XCTAssertEqual(parameters, [ + "grant_type": "urn:okta:params:oauth:grant-type:oob" + ]) + + parameters = PrimaryFactor.webAuthn.tokenParameters(currentStatus: nil) + XCTAssertEqual(parameters, [ + "grant_type": "urn:okta:params:oauth:grant-type:webauthn" + ]) + } + + func testSecondaryTokenParameters() throws { + var parameters: [String: String] = [:] - XCTAssertEqual(SecondaryFactor.otp(code: "123456").tokenParameters as? [String: String], [ + parameters = SecondaryFactor.otp(code: "123456").tokenParameters(currentStatus: nil) + XCTAssertEqual(parameters, [ "grant_type": "http://auth0.com/oauth/grant-type/mfa-otp", "otp": "123456" ]) - XCTAssertNil(SecondaryFactor.oob(channel: .push).tokenParameters) + + parameters = SecondaryFactor.oob(channel: .push).tokenParameters(currentStatus: nil) + XCTAssertEqual(parameters, [ + "grant_type": "http://auth0.com/oauth/grant-type/mfa-oob" + ]) + + parameters = SecondaryFactor.webAuthn.tokenParameters(currentStatus: nil) + XCTAssertEqual(parameters, [ + "grant_type": "urn:okta:params:oauth:grant-type:webauthn", + ]) + + parameters = SecondaryFactor.webAuthn.tokenParameters(currentStatus: .mfaRequired(.init(supportedChallengeTypes: nil, mfaToken: "abc123"))) + XCTAssertEqual(parameters, [ + "grant_type": "urn:okta:params:oauth:grant-type:mfa-webauthn", + "mfa_token": "abc123", + ]) + + parameters = SecondaryFactor.webAuthnAssertion(.init(clientDataJSON: "", + authenticatorData: "", + signature: "", + userHandle: nil)).tokenParameters(currentStatus: nil) + XCTAssertEqual(parameters, [ + "grant_type": "urn:okta:params:oauth:grant-type:webauthn", + ]) + + let context = DirectAuthenticationFlow.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)) + 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 c9bb3145a..a07a0c146 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(_): + case .mfaRequired(_), .bindingUpdate(_), .webAuthn(request: _): 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(_): + case .success(_), .bindingUpdate(_), .webAuthn(request: _): XCTFail("Did not receive a mfa_required response") case .mfaRequired(let context): XCTAssertEqual(context.mfaToken, "abcd1234") @@ -215,8 +215,8 @@ final class FactorStepHandlerTests: XCTestCase { urlSession.expect("https://example.okta.com/oauth2/v1/keys?client_id=clientId", data: try data(from: .module, for: "keys", in: "MockResponses"), contentType: "application/json") - urlSession.expect("https://example.okta.com/oauth2/v1/oob-authenticate", - data: try data(from: .module, for: "oob-authenticate", in: "MockResponses")) + urlSession.expect("https://example.okta.com/oauth2/v1/primary-authenticate", + data: try data(from: .module, for: "primary-authenticate", in: "MockResponses")) urlSession.expect("https://example.okta.com/oauth2/v1/token", data: try data(from: .module, for: "token", in: "MockResponses")) @@ -232,7 +232,7 @@ final class FactorStepHandlerTests: XCTestCase { case .success(let status): switch status { case .success(_): break - case .mfaRequired(_), .bindingUpdate(_): + case .mfaRequired(_), .bindingUpdate(_), .webAuthn(request: _): XCTFail("Did not receive a success response") } case .failure(let error): @@ -250,8 +250,8 @@ final class FactorStepHandlerTests: XCTestCase { urlSession.expect("https://example.okta.com/oauth2/v1/keys?client_id=clientId", data: try data(from: .module, for: "keys", in: "MockResponses"), contentType: "application/json") - urlSession.expect("https://example.okta.com/oauth2/v1/oob-authenticate", - data: try data(from: .module, for: "oob-authenticate-binding-transfer", in: "MockResponses")) + urlSession.expect("https://example.okta.com/oauth2/v1/primary-authenticate", + data: try data(from: .module, for: "primary-authenticate-binding-transfer", in: "MockResponses")) urlSession.expect("https://example.okta.com/oauth2/v1/token", data: try data(from: .module, for: "token", in: "MockResponses")) @@ -294,8 +294,8 @@ final class FactorStepHandlerTests: XCTestCase { urlSession.expect("https://example.okta.com/oauth2/v1/keys?client_id=clientId", data: try data(from: .module, for: "keys", in: "MockResponses"), contentType: "application/json") - urlSession.expect("https://example.okta.com/oauth2/v1/oob-authenticate", - data: try data(from: .module, for: "oob-authenticate-binding-transfer-missingCode", in: "MockResponses")) + urlSession.expect("https://example.okta.com/oauth2/v1/primary-authenticate", + data: try data(from: .module, for: "primary-authenticate-binding-transfer-missingCode", in: "MockResponses")) urlSession.expect("https://example.okta.com/oauth2/v1/token", data: try data(from: .module, for: "token", in: "MockResponses")) @@ -325,8 +325,8 @@ final class FactorStepHandlerTests: XCTestCase { urlSession.expect("https://example.okta.com/oauth2/v1/keys?client_id=clientId", data: try data(from: .module, for: "keys", in: "MockResponses"), contentType: "application/json") - urlSession.expect("https://example.okta.com/oauth2/v1/oob-authenticate", - data: try data(from: .module, for: "oob-authenticate", in: "MockResponses")) + urlSession.expect("https://example.okta.com/oauth2/v1/primary-authenticate", + data: try data(from: .module, for: "primary-authenticate", in: "MockResponses")) urlSession.expect("https://example.okta.com/oauth2/v1/token", data: try data(from: .module, for: "token-mfa_required", in: "MockResponses"), statusCode: 400) @@ -342,7 +342,7 @@ final class FactorStepHandlerTests: XCTestCase { switch result { case .success(let status): switch status { - case .success(_), .bindingUpdate(_): + case .success(_), .bindingUpdate(_), .webAuthn(request: _): XCTFail("Did not receive a mfa_required response") case .mfaRequired(let context): XCTAssertEqual(context.mfaToken, "abcd1234") @@ -381,7 +381,7 @@ final class FactorStepHandlerTests: XCTestCase { case .success(let status): switch status { case .success(_): break - case .mfaRequired(_), .bindingUpdate(_): + case .mfaRequired(_), .bindingUpdate(_), .webAuthn(request: _): XCTFail("Did not receive a success response") } case .failure(let error): diff --git a/Tests/OktaDirectAuthTests/MockResponses/challenge-webauthn.json b/Tests/OktaDirectAuthTests/MockResponses/challenge-webauthn.json new file mode 100644 index 000000000..3e83f9fb5 --- /dev/null +++ b/Tests/OktaDirectAuthTests/MockResponses/challenge-webauthn.json @@ -0,0 +1,25 @@ +{ + "challengeType": "urn:okta:params:oauth:grant-type:webauthn", + "publicKey": { + "challenge": "", + "userVerification": "preferred", + "extensions": { + "appid": "https://example.okta.com" + }, + "allowCredentials": [ + { + "id": "", + "type": "public-key" + } + ] + }, + "authenticatorEnrollments": [ + { + "credentialId": "", + "displayName": "", + "profile": { + "aaguid": "" + } + } + ] +} diff --git a/Tests/OktaDirectAuthTests/MockResponses/oob-authenticate-binding-transfer-missingCode.json b/Tests/OktaDirectAuthTests/MockResponses/primary-authenticate-binding-transfer-missingCode.json similarity index 100% rename from Tests/OktaDirectAuthTests/MockResponses/oob-authenticate-binding-transfer-missingCode.json rename to Tests/OktaDirectAuthTests/MockResponses/primary-authenticate-binding-transfer-missingCode.json diff --git a/Tests/OktaDirectAuthTests/MockResponses/oob-authenticate-binding-transfer.json b/Tests/OktaDirectAuthTests/MockResponses/primary-authenticate-binding-transfer.json similarity index 100% rename from Tests/OktaDirectAuthTests/MockResponses/oob-authenticate-binding-transfer.json rename to Tests/OktaDirectAuthTests/MockResponses/primary-authenticate-binding-transfer.json diff --git a/Tests/OktaDirectAuthTests/MockResponses/oob-authenticate.json b/Tests/OktaDirectAuthTests/MockResponses/primary-authenticate.json similarity index 100% rename from Tests/OktaDirectAuthTests/MockResponses/oob-authenticate.json rename to Tests/OktaDirectAuthTests/MockResponses/primary-authenticate.json diff --git a/Tests/OktaDirectAuthTests/RequestTests.swift b/Tests/OktaDirectAuthTests/RequestTests.swift index 6dd0550e4..12e11584c 100644 --- a/Tests/OktaDirectAuthTests/RequestTests.swift +++ b/Tests/OktaDirectAuthTests/RequestTests.swift @@ -32,6 +32,7 @@ final class RequestTests: XCTestCase { clientConfiguration: try .init(domain: "example.com", clientId: "theClientId", scopes: "openid profile"), + currentStatus: nil, factor: DirectAuthenticationFlow.PrimaryFactor.password("password123")) XCTAssertEqual(request.bodyParameters as? [String: String], [ @@ -47,6 +48,7 @@ final class RequestTests: XCTestCase { clientId: "theClientId", scopes: "openid profile", authentication: .clientSecret("supersecret")), + currentStatus: nil, factor: DirectAuthenticationFlow.PrimaryFactor.password("password123")) XCTAssertEqual(request.bodyParameters as? [String: String], [