Skip to content

Commit

Permalink
Implement WebAuthn authenticator support in DirectAuth
Browse files Browse the repository at this point in the history
  • Loading branch information
mikenachbaur-okta committed Feb 14, 2024
1 parent 5a858e0 commit d4daa4d
Show file tree
Hide file tree
Showing 27 changed files with 900 additions and 36 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<dict>
<key>FILEHEADER</key>
<string>
// 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.
Expand Down
6 changes: 5 additions & 1 deletion Sources/AuthFoundation/Responses/GrantType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public enum GrantType: Codable, Hashable {
case oob
case otpMFA
case oobMFA
case webAuthn
case other(_ type: String)
}

Expand All @@ -36,7 +37,8 @@ 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

]

Expand Down Expand Up @@ -75,6 +77,8 @@ 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"
}
}
}
172 changes: 172 additions & 0 deletions Sources/AuthFoundation/Utilities/JSONValue.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
28 changes: 27 additions & 1 deletion Sources/OktaDirectAuth/DirectAuthFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public enum DirectAuthenticationFlowError: Error {
/// This enables developers to build native sign-in workflows into their applications, while leveraging MFA to securely authenticate users, without the need to present a browser. Furthermore, this enables passwordless authentication scenarios by giving developers the power to choose which primary and secondary authentication factors to use when challenging a user for their credentials.
public class DirectAuthenticationFlow: AuthenticationFlow {
/// Enumeration defining the list of possible primary authentication factors.
///
///

Check failure on line 42 in Sources/OktaDirectAuth/DirectAuthFlow.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)
/// These values are used by the ``DirectAuthenticationFlow/start(_:with:)`` function.
public enum PrimaryFactor: Equatable {
/// Authenticate the user with the given password.
Expand Down Expand Up @@ -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("[email protected]", with: .webAuthn)
/// ```
case webAuthn
}

/// Enumeration defining the list of possible secondary authentication factors.
Expand All @@ -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("[email protected]", 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.
Expand Down Expand Up @@ -140,6 +163,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(request: WebAuthn.CredentialRequestOptions)
}

/// The OAuth2Client this authentication flow will use.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,17 @@
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.
var tokenParameters: [String: Any]? { get }
}

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

/// Returns a step handler capable of handling this authentication factor.
/// - Parameters:
/// - flow: The current flow for this authentication step.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,6 @@ extension DirectAuthenticationFlow.PrimaryFactor: AuthenticationFactor {
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:
Expand All @@ -45,13 +41,25 @@ 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,
loginHint: loginHint,
mfaToken: currentStatus?.mfaToken,
channel: channel,
factor: factor,
bindingContext: bindingContext)
case .webAuthn:
let request = try WebAuthnChallengeRequest(openIdConfiguration: openIdConfiguration,
clientConfiguration: flow.client.configuration,
loginHint: loginHint,
mfaToken: currentStatus?.mfaToken)
return ChallengeStepHandler(flow: flow, request: request) {
.webAuthn(request: $0)
}
}
}

Expand All @@ -67,7 +75,7 @@ extension DirectAuthenticationFlow.PrimaryFactor: AuthenticationFactor {
"grant_type": grantType.rawValue,
"password": password
]
case .oob:
case .oob, .webAuthn:
return nil
}

Expand All @@ -81,6 +89,8 @@ extension DirectAuthenticationFlow.PrimaryFactor: AuthenticationFactor {
return .password
case .oob:
return .oob
case .webAuthn:
return .webAuthn
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,23 @@ extension DirectAuthenticationFlow.SecondaryFactor: AuthenticationFactor {
channel: channel,
factor: factor,
bindingContext: bindingContext)
case .webAuthn:
let request = try WebAuthnChallengeRequest(openIdConfiguration: openIdConfiguration,
clientConfiguration: flow.client.configuration,
loginHint: loginHint,
mfaToken: currentStatus?.mfaToken)
return ChallengeStepHandler(flow: flow, request: request) {
.webAuthn(request: $0)
}
case .webAuthnAssertion(let response):
let request = TokenRequest(openIdConfiguration: openIdConfiguration,
clientConfiguration: flow.client.configuration,
loginHint: loginHint,
factor: factor,
mfaToken: currentStatus?.mfaToken,
parameters: response,
grantTypesSupported: flow.supportedGrantTypes)
return TokenStepHandler(flow: flow, request: request)
}
}

Expand All @@ -51,8 +68,12 @@ extension DirectAuthenticationFlow.SecondaryFactor: AuthenticationFactor {
"grant_type": grantType.rawValue,
"otp": code
]
case .oob:
case .oob, .webAuthn:
return nil
case .webAuthnAssertion(_):
return [
"grant_type": grantType.rawValue
]
}

}
Expand All @@ -63,6 +84,8 @@ extension DirectAuthenticationFlow.SecondaryFactor: AuthenticationFactor {
return .otpMFA
case .oob:
return .oobMFA
case .webAuthn, .webAuthnAssertion(_):
return .webAuthn
}
}
}
Loading

0 comments on commit d4daa4d

Please sign in to comment.