From a29eed32c3a68404c033f31445cc073f6ced766b Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Tue, 11 Jun 2024 05:33:51 -0700 Subject: [PATCH] Updated client to use task groups instead of custom cancellable child tasks --- .../AssertionAuthenticationRequest.swift | 47 ++-- .../AttestationRegistrationRequest.swift | 29 +- .../Protocol/AuthenticatorProtocol.swift | 49 +++- .../Registration/AttestationObject.swift | 2 +- .../Helpers/CancellableContinuationTask.swift | 92 ------- Sources/WebAuthn/WebAuthnClient.swift | 255 ++++++++++-------- Sources/WebAuthn/WebAuthnError.swift | 2 + 7 files changed, 197 insertions(+), 279 deletions(-) delete mode 100644 Sources/WebAuthn/Helpers/CancellableContinuationTask.swift diff --git a/Sources/WebAuthn/Authenticators/Protocol/AssertionAuthenticationRequest.swift b/Sources/WebAuthn/Authenticators/Protocol/AssertionAuthenticationRequest.swift index f6c0eaa..ae87e97 100644 --- a/Sources/WebAuthn/Authenticators/Protocol/AssertionAuthenticationRequest.swift +++ b/Sources/WebAuthn/Authenticators/Protocol/AssertionAuthenticationRequest.swift @@ -17,53 +17,36 @@ public struct AssertionAuthenticationRequest: Sendable { public var options: PublicKeyCredentialRequestOptions public var clientDataHash: SHA256Digest - public var attemptAuthentication: Callback init( options: PublicKeyCredentialRequestOptions, - clientDataHash: SHA256Digest, - attemptAuthentication: @Sendable @escaping (_ assertionResults: Results) async throws -> () + clientDataHash: SHA256Digest ) { self.options = options self.clientDataHash = clientDataHash - self.attemptAuthentication = Callback(callback: attemptAuthentication) } } extension AssertionAuthenticationRequest { - public struct Callback: Sendable { - /// The internal callback the attestation should call. - var callback: @Sendable (_ assertionResults: Results) async throws -> () + public struct Results: Sendable { + public var credentialID: [UInt8] + public var authenticatorData: [UInt8] + public var signature: [UInt8] + public var userHandle: [UInt8]? + public var authenticatorAttachment: AuthenticatorAttachment - /// Submit the results of asserting a user's authentication request. - /// - /// Authenticators should call this to submit a successful authentication and cancel any other pending authenticators. - /// - /// - SeeAlso: https://w3c.github.io/webauthn/#sctn-generating-an-attestation-object - public func submitAssertionResults( + public init( credentialID: [UInt8], authenticatorData: [UInt8], signature: [UInt8], - userHandle: [UInt8]?, + userHandle: [UInt8]? = nil, authenticatorAttachment: AuthenticatorAttachment - ) async throws { - try await callback(Results( - credentialID: credentialID, - authenticatorData: authenticatorData, - signature: signature, - userHandle: userHandle, - authenticatorAttachment: authenticatorAttachment - )) + ) { + self.credentialID = credentialID + self.authenticatorData = authenticatorData + self.signature = signature + self.userHandle = userHandle + self.authenticatorAttachment = authenticatorAttachment } } } - -extension AssertionAuthenticationRequest { - struct Results { - var credentialID: [UInt8] - var authenticatorData: [UInt8] - var signature: [UInt8] - var userHandle: [UInt8]? - var authenticatorAttachment: AuthenticatorAttachment - } -} diff --git a/Sources/WebAuthn/Authenticators/Protocol/AttestationRegistrationRequest.swift b/Sources/WebAuthn/Authenticators/Protocol/AttestationRegistrationRequest.swift index a53c23b..3ebe6ea 100644 --- a/Sources/WebAuthn/Authenticators/Protocol/AttestationRegistrationRequest.swift +++ b/Sources/WebAuthn/Authenticators/Protocol/AttestationRegistrationRequest.swift @@ -19,41 +19,14 @@ public struct AttestationRegistrationRequest: Sendable { var options: PublicKeyCredentialCreationOptions var publicKeyCredentialParameters: [PublicKeyCredentialParameters] var clientDataHash: SHA256Digest - var attemptRegistration: Callback init( options: PublicKeyCredentialCreationOptions, publicKeyCredentialParameters: [PublicKeyCredentialParameters], - clientDataHash: SHA256Digest, - attemptRegistration: @Sendable @escaping (_ attestationObject: AttestationObject) async throws -> () + clientDataHash: SHA256Digest ) { self.options = options self.publicKeyCredentialParameters = publicKeyCredentialParameters self.clientDataHash = clientDataHash - self.attemptRegistration = Callback(callback: attemptRegistration) - } -} - -extension AttestationRegistrationRequest { - public struct Callback: Sendable { - /// The internal callback the attestation should call. - var callback: @Sendable (_ attestationObject: AttestationObject) async throws -> () - - /// Generate an attestation object for registration and submit it. - /// - /// Authenticators should call this to submit a successful registration and cancel any other pending authenticators. - /// - /// - SeeAlso: https://w3c.github.io/webauthn/#sctn-generating-an-attestation-object - public func submitAttestationObject( - attestationFormat: AttestationFormat, - authenticatorData: AuthenticatorData, - attestationStatement: CBOR - ) async throws { - try await callback(AttestationObject( - authenticatorData: authenticatorData, - format: attestationFormat, - attestationStatement: attestationStatement - )) - } } } diff --git a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift index beaca06..05138f3 100644 --- a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift +++ b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift @@ -17,7 +17,34 @@ import SwiftCBOR public typealias CredentialStore = [A.CredentialSource.ID : A.CredentialSource] -public protocol AuthenticatorProtocol { + +public protocol AuthenticatorRegistrationConsumer: Sendable { + associatedtype CredentialOutput: Sendable + + /// Generate an attestation object for registration and submit it. + /// + /// Authenticators should call this to submit a successful registration and cancel any other pending authenticators. + /// + /// - SeeAlso: https://w3c.github.io/webauthn/#sctn-generating-an-attestation-object + func makeCredentials(with registration: AttestationRegistrationRequest) async throws -> (AttestationObject, CredentialOutput) +} + +public protocol AuthenticatorAssertionConsumer: Sendable { + associatedtype CredentialInput: Sendable + associatedtype CredentialOutput: Sendable + + /// Submit the results of asserting a user's authentication request. + /// + /// Authenticators should call this to submit a successful authentication and cancel any other pending authenticators. + /// + /// - SeeAlso: https://w3c.github.io/webauthn/#sctn-generating-an-attestation-object + func assertCredentials( + authenticationRequest: AssertionAuthenticationRequest, + credentials: CredentialInput + ) async throws -> (AssertionAuthenticationRequest.Results, CredentialOutput) +} + +public protocol AuthenticatorProtocol: AuthenticatorRegistrationConsumer, AuthenticatorAssertionConsumer { associatedtype CredentialSource: AuthenticatorCredentialSourceProtocol var attestationGloballyUniqueID: AAGUID { get } @@ -62,10 +89,10 @@ public protocol AuthenticatorProtocol { /// Make credentials for the specified registration request, returning the credential source that the caller should store for subsequent authentication. /// - /// - Important: Depending on the authenticator being used, the credential source may contain private keys, and must be stored sequirely, such as in the user's Keychain, or in a Hardware Security Module appropriate with the level of security you wish to secure your user's account with. + /// - Important: Depending on the authenticator being used, the credential source may contain private keys, and must be stored securely, such as in the user's Keychain, or in a Hardware Security Module appropriate with the level of security you wish to secure your user's account with. /// - SeeAlso: [WebAuthn Level 3 Editor's Draft §5.1.3. Create a New Credential - PublicKeyCredential’s Create(origin, options, sameOriginWithAncestors) Method, Step 25.]( https://w3c.github.io/webauthn/#CreateCred-async-loop) /// - SeeAlso: [WebAuthn Level 3 Editor's Draft §6.3.2. The authenticatorMakeCredential Operation](https://w3c.github.io/webauthn/#sctn-op-make-cred) - func makeCredentials(with registration: AttestationRegistrationRequest) async throws -> CredentialSource + func makeCredentials(with registration: AttestationRegistrationRequest) async throws -> (AttestationObject, CredentialSource) /// Filter the provided credential descriptors to determine which, if any, should be handled by this authenticator. /// @@ -106,7 +133,7 @@ public protocol AuthenticatorProtocol { func assertCredentials( authenticationRequest: AssertionAuthenticationRequest, credentials: CredentialStore - ) async throws -> CredentialSource + ) async throws -> (AssertionAuthenticationRequest.Results, CredentialSource) } // MARK: - Default Implementations @@ -142,7 +169,7 @@ extension AuthenticatorProtocol { extension AuthenticatorProtocol { public func makeCredentials( with registration: AttestationRegistrationRequest - ) async throws -> CredentialSource { + ) async throws -> (AttestationObject, CredentialSource) { /// See [WebAuthn Level 3 Editor's Draft §5.1.3. Create a New Credential - PublicKeyCredential’s Create(origin, options, sameOriginWithAncestors) Method, Step 25.]( https://w3c.github.io/webauthn/#CreateCred-async-loop) /// Step 1. This authenticator is now the candidate authenticator. /// Step 2. If pkOptions.authenticatorSelection is present: @@ -328,13 +355,13 @@ extension AuthenticatorProtocol { ) /// On successful completion of this operation, the authenticator returns the attestation object to the client. - try await registration.attemptRegistration.submitAttestationObject( - attestationFormat: attestationFormat, + let attestationObject = AttestationObject( authenticatorData: authenticatorData, + format: attestationFormat, attestationStatement: attestationStatement ) - return credentialSource + return (attestationObject, credentialSource) } } @@ -344,7 +371,7 @@ extension AuthenticatorProtocol { public func assertCredentials( authenticationRequest: AssertionAuthenticationRequest, credentials: CredentialStore - ) async throws -> CredentialSource { + ) async throws -> (AssertionAuthenticationRequest.Results, CredentialSource) { /// [WebAuthn Level 3 Editor's Draft §5.1.4.2. Issuing a Credential Request to an Authenticator](https://w3c.github.io/webauthn/#sctn-issuing-cred-request-to-authenticator) /// Step 1. If pkOptions.userVerification is set to required and the authenticator is not capable of performing user verification, return false. if authenticationRequest.options.userVerification == .required && !canPerformUserVerification { @@ -473,7 +500,7 @@ extension AuthenticatorProtocol { /// signature /// selectedCredential.userHandle /// NOTE: In cases where allowCredentialDescriptorList was supplied the returned userHandle value may be null, see: userHandleResult. - try await authenticationRequest.attemptAuthentication.submitAssertionResults( + let assertionResults = AssertionAuthenticationRequest.Results( credentialID: selectedCredential.id.bytes, authenticatorData: authenticatorData, signature: signature, @@ -484,6 +511,6 @@ extension AuthenticatorProtocol { /// If the authenticator cannot find any credential corresponding to the specified Relying Party that matches the specified criteria, it terminates the operation and returns an error. // Already done. - return selectedCredential + return (assertionResults, selectedCredential) } } diff --git a/Sources/WebAuthn/Ceremonies/Registration/AttestationObject.swift b/Sources/WebAuthn/Ceremonies/Registration/AttestationObject.swift index 171ff46..487d236 100644 --- a/Sources/WebAuthn/Ceremonies/Registration/AttestationObject.swift +++ b/Sources/WebAuthn/Ceremonies/Registration/AttestationObject.swift @@ -35,7 +35,7 @@ public struct AttestationObject: Sendable { self.attestationStatement = attestationStatement } - init( + public init( authenticatorData: AuthenticatorData, format: AttestationFormat, attestationStatement: CBOR diff --git a/Sources/WebAuthn/Helpers/CancellableContinuationTask.swift b/Sources/WebAuthn/Helpers/CancellableContinuationTask.swift deleted file mode 100644 index b250da8..0000000 --- a/Sources/WebAuthn/Helpers/CancellableContinuationTask.swift +++ /dev/null @@ -1,92 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the WebAuthn Swift open source project -// -// Copyright (c) 2023 the WebAuthn Swift project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of WebAuthn Swift project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -/// An internal type to assist kicking off work without primarily awaiting it, instead allowing that work to call into a continuation as needed. -/// Use ``withCancellableFirstSuccessfulContinuation()`` instead of invoking this directly. -actor CancellableContinuation: Sendable { - private var bodyTask: Task? - private var continuation: CheckedContinuation? - private var isCancelled = false - - private func cancelMainTask() { - continuation?.resume(throwing: CancellationError()) - continuation = nil - bodyTask?.cancel() - isCancelled = true - } - - private func isolatedResume(returning value: T) { - continuation?.resume(returning: value) - continuation = nil - cancelMainTask() - } - - nonisolated func cancel() { - Task { await cancelMainTask() } - } - - nonisolated func resume(returning value: T) { - Task { await isolatedResume(returning: value) } - } - - /// Wrap an asynchronous closure providing a continuation for when results are ready that can be called any number of times, but also allowing the closure to be cancelled at any time, including once the first successful value is provided. - fileprivate func wrap(_ body: Body) async throws -> T { - assert(bodyTask == nil, "A CancellableContinuationTask should only be used once.") - /// Register a cancellation callback that will: a) immediately cancel the continuation if we have one, b) unset it so it doesn't get called a second time, and c) cancel the main task. - return try await withTaskCancellationHandler { - let response: T = try await withCheckedThrowingContinuation { localContinuation in - /// Synchronously a) check if we've been cancelled, stopping early, b) save the contnuation, and c) assign the task, which runs immediately. - /// This works since we are guaranteed to hear back from the cancellation handler either immediately, since Task.isCancelled is already set, or after task is set, since we are executing on the actor's executor. - guard !Task.isCancelled else { - localContinuation.resume(throwing: CancellationError()) - return - } - - self.continuation = localContinuation - self.bodyTask = Task { [unowned self] in - /// If the continuation doesn't exist at this point, it's because we've already been cancelled. This is guaranteed to run after the task has been set and potentially cancelled since it also runs on the task executor. - guard let continuation = self.continuation else { return } - do { - try await body(self) - } catch { - /// If the main body fails for any reason, pass along the error. This will be a no-op if the continuation was already resumed or cancelled. - continuation.resume(throwing: error) - self.continuation = nil - } - } - } - /// Wait for the body to finish cancelling before continuing, so it doesn't run into any data races. - try? await bodyTask?.value - return response - } onCancel: { - cancel() - } - } - - /// A wrapper for the body, which will ever only be called once, in a non-escaping manner before the continuation resumes. - fileprivate struct Body: @unchecked Sendable { - var body: (_ continuation: CancellableContinuation) async throws -> () - - func callAsFunction(_ continuation: CancellableContinuation) async throws { - try await body(continuation) - } - } -} - -/// Execute an operation providing it a continuation for when results are ready that can be called any number of times, but also allowing the operation to be cancelled at any time, including once the first successful value is provided. -func withCancellableFirstSuccessfulContinuation(_ body: (_ continuation: CancellableContinuation) async throws -> ()) async throws -> T { - try await withoutActuallyEscaping(body) { escapingBody in - try await CancellableContinuation().wrap(.init { try await escapingBody($0) }) - } -} diff --git a/Sources/WebAuthn/WebAuthnClient.swift b/Sources/WebAuthn/WebAuthnClient.swift index 0220bd9..b25f506 100644 --- a/Sources/WebAuthn/WebAuthnClient.swift +++ b/Sources/WebAuthn/WebAuthnClient.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import Foundation -import Crypto +@preconcurrency import Crypto /// A client implementation capable of interfacing between an ``AuthenticatorProtocol`` authenticator and the Web Authentication API. /// @@ -25,15 +25,18 @@ import Crypto public struct WebAuthnClient { public init() {} - public func createRegistrationCredential( + public func createRegistrationCredential( options: PublicKeyCredentialCreationOptions, /// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout minTimeout: Duration = .seconds(300), maxTimeout: Duration = .seconds(600), origin: String, supportedPublicKeyCredentialParameters: Set = .supported, - attestRegistration: (_ registration: AttestationRegistrationRequest) async throws -> () - ) async throws -> RegistrationCredential { + authenticator: Authenticator + ) async throws -> ( + registrationCredential: RegistrationCredential, + credentialSource: Authenticator.CredentialOutput + ) { /// Steps: https://w3c.github.io/webauthn/#sctn-createCredential /// Step 1. Assert: options.publicKey is present. @@ -154,13 +157,17 @@ public struct WebAuthnClient { /// Step 25. While lifetimeTimer has not expired, perform the following actions depending upon lifetimeTimer, and the state and response for each authenticator in authenticators: do { /// Let the caller do what it needs to do to coordinate with authenticators, so long as at least one of them calls the attestation callback. - var attestationObjectResult: AttestationObject = try await withCancellableFirstSuccessfulContinuation { [attestRegistration, publicKeyCredentialParameters] continuation in + var (attestationObjectResult, credentialOutput) = try await withThrowingTaskGroup(of: (AttestationObject, Authenticator.CredentialOutput).self) { [publicKeyCredentialParameters, clientDataHash] group in /// → If lifetimeTimer expires, /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. - Task { + group.addTask(priority: .high) { /// Let the timer run in the background to cancel the continuation if it runs over. - await timeoutTask.value - continuation.cancel() // TODO: Should be a timeout error + await withTaskCancellationHandler { + await timeoutTask.value + } onCancel: { + timeoutTask.cancel() + } + throw WebAuthnError.timeoutError } /// → If the user exercises a user agent user-interface option to cancel the process, @@ -192,13 +199,21 @@ public struct WebAuthnClient { /// NOTE: This case does not imply user consent for the operation, so details about the error are hidden from the Relying Party in order to prevent leak of potentially identifying information. See § 14.5.1 Registration Ceremony Privacy for details. /// Kick off the attestation process, waiting for one to succeed before the timeout. - try await attestRegistration(AttestationRegistrationRequest( + let registrationRequest = AttestationRegistrationRequest( options: options, publicKeyCredentialParameters: publicKeyCredentialParameters, clientDataHash: clientDataHash - ) { attestationObject in - continuation.resume(returning: attestationObject) - }) + ) + group.addTask { + try await authenticator.makeCredentials(with: registrationRequest) + } + + /// The first results will always have the attestation object and credential output ready, or will throw on error, cancellation, or timeout. + /// If a timeout occurs, the actual work will be cancelled, though progress cannot move forwards until it actually wraps up its work. + guard let results = try await group.next() + else { throw WebAuthnError.missingCredentialSourceDespiteSuccess } + group.cancelAll() + return results } /// → If any authenticator indicates success, @@ -274,7 +289,7 @@ public struct WebAuthnClient { // Already performed. /// 5. Return constructCredentialAlg and terminate this algorithm. - return publicKeyCredential + return (publicKeyCredential, credentialOutput) } catch { /// Step 35. Throw a "NotAllowedError" DOMException. In order to prevent information leak that could identify the user without consent, this step MUST NOT be executed before lifetimeTimer has expired. See § 14.5.1 Registration Ceremony Privacy for details. /// During the above process, the user agent SHOULD show some UI to the user to guide them in the process of selecting and authorizing an authenticator. @@ -290,15 +305,19 @@ public struct WebAuthnClient { } } - public func assertAuthenticationCredential( + public func assertAuthenticationCredential( options: PublicKeyCredentialRequestOptions, /// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout minTimeout: Duration = .seconds(300), maxTimeout: Duration = .seconds(600), origin: String, // mediation: , - assertAuthentication: (_ authentication: AssertionAuthenticationRequest) async throws -> () - ) async throws -> AuthenticationCredential { + authenticator: Authenticator, + credentialStore: Authenticator.CredentialInput + ) async throws -> ( + authenticationCredential: AuthenticationCredential, + updatedCredentialSource: Authenticator.CredentialOutput + ) { /// See https://w3c.github.io/webauthn/#sctn-discover-from-external-source /// Step 1. Assert: options.publicKey is present. // Skip, already is. @@ -399,13 +418,17 @@ public struct WebAuthnClient { /// Step 20. While lifetimeTimer has not expired, perform the following actions depending upon lifetimeTimer, and the state and response for each authenticator in authenticators: do { /// Let the caller do what it needs to do to coordinate with authenticators, so long as at least one of them calls the assertion callback. - let assertionResults: AssertionAuthenticationRequest.Results = try await withCancellableFirstSuccessfulContinuation { [assertAuthentication] continuation in + let (assertionResults, credentialOutput) = try await withThrowingTaskGroup(of: (AssertionAuthenticationRequest.Results, Authenticator.CredentialOutput).self) { group in /// → If lifetimeTimer expires, /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. - Task { + group.addTask(priority: .high) { /// Let the timer run in the background to cancel the continuation if it runs over. - await timeoutTask.value - continuation.cancel() // TODO: Should be a timeout error + await withTaskCancellationHandler { + await timeoutTask.value + } onCancel: { + timeoutTask.cancel() + } + throw WebAuthnError.timeoutError } /// → If the user exercises a user agent user-interface option to cancel the process, @@ -451,13 +474,21 @@ public struct WebAuthnClient { /// If this returns false, continue. /// NOTE: This branch is taken if options.mediation is conditional and the authenticator does not support the silentCredentialDiscovery operation to allow use of such authenticators during a conditional user mediation request. /// 2. Append authenticator to issuedRequests. - try await assertAuthentication(AssertionAuthenticationRequest( + + let authenticationRequest = AssertionAuthenticationRequest( options: options, - clientDataHash: clientDataHash, - attemptAuthentication: { assertionResults in - continuation.resume(returning: assertionResults) - } - )) + clientDataHash: clientDataHash + ) + group.addTask { + try await authenticator.assertCredentials(authenticationRequest: authenticationRequest, credentials: credentialStore) + } + + /// The first results will always have the assertion results and credential output ready, or will throw on error, cancellation, or timeout. + /// If a timeout occurs, the actual work will be cancelled, though progress cannot move forwards until it actually wraps up its work. + guard let results = try await group.next() + else { throw WebAuthnError.missingCredentialSourceDespiteSuccess } + group.cancelAll() + return results } /// → If an authenticator ceases to be available on this client device, @@ -534,7 +565,7 @@ public struct WebAuthnClient { // Already performed. /// 7. Return constructAssertionAlg and terminate this algorithm. - return publicKeyCredential + return (publicKeyCredential, credentialOutput) } catch { /// Step 31. Throw a "NotAllowedError" DOMException. In order to prevent information leak that could identify the user without consent, this step MUST NOT be executed before lifetimeTimer has expired. See § 14.5.2 Authentication Ceremony Privacy for details. await withTaskCancellationHandler { @@ -550,118 +581,111 @@ public struct WebAuthnClient { } } -// MARK: Convenience Registration and Authentication +// MARK: Registration and Authentication With Multiple Authenticators -extension WebAuthnClient { - @inlinable - public func createRegistrationCredential( - options: PublicKeyCredentialCreationOptions, - /// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout - minTimeout: Duration = .seconds(300), - maxTimeout: Duration = .seconds(600), - origin: String, - supportedPublicKeyCredentialParameters: Set = .supported, - authenticator: Authenticator - ) async throws -> (registrationCredential: RegistrationCredential, credentialSource: Authenticator.CredentialSource) { - var credentialSource: Authenticator.CredentialSource? - let registrationCredential = try await createRegistrationCredential( - options: options, - minTimeout: minTimeout, - maxTimeout: maxTimeout, - origin: origin, - supportedPublicKeyCredentialParameters: supportedPublicKeyCredentialParameters - ) { registration in - credentialSource = try await authenticator.makeCredentials(with: registration) - } - - guard let credentialSource - else { throw WebAuthnError.missingCredentialSourceDespiteSuccess } - - return (registrationCredential, credentialSource) +/// Internal type to represent a group of authenticators as a single authenticator. +@available(macOS 14.0.0, *) +@usableFromInline +struct AuthenticatorRegistrationGroup: AuthenticatorRegistrationConsumer { + let authenticators: (repeat each Authenticator) + + @usableFromInline + init(authenticators: repeat each Authenticator) { + self.authenticators = (repeat each authenticators) } - @inlinable - public func createRegistrationCredential( - options: PublicKeyCredentialCreationOptions, - /// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout - minTimeout: Duration = .seconds(300), - maxTimeout: Duration = .seconds(600), - origin: String, - supportedPublicKeyCredentialParameters: Set = .supported, - authenticators: repeat each Authenticator - ) async throws -> ( - registrationCredential: RegistrationCredential, - credentialSources: (repeat Result<(each Authenticator).CredentialSource, Error>) - ) { - /// Wrapper function since `repeat` doesn't currently support complex expressions - @Sendable func register( - authenticator: LocalAuthenticator, - registration: AttestationRegistrationRequest - ) -> Task { - Task { try await authenticator.makeCredentials(with: registration) } - } - - var credentialSources: (repeat Result<(each Authenticator).CredentialSource, Error>)? - let registrationCredential = try await createRegistrationCredential( - options: options, - minTimeout: minTimeout, - maxTimeout: maxTimeout, - origin: origin, - supportedPublicKeyCredentialParameters: supportedPublicKeyCredentialParameters - ) { registration in - /// Run each authenticator in parallel as child tasks, so we can automatically propagate cancellation to each of them should it occur. - let tasks = (repeat register( + @usableFromInline + func makeCredentials(with registration: AttestationRegistrationRequest) async throws -> (AttestationObject, (repeat Result<(each Authenticator).CredentialOutput, Error>)) { + var parentTask: Task<(AttestationObject, (repeat Result<(each Authenticator).CredentialOutput, Error>)), Error>! + parentTask = Task { + let tasks = (repeat makeCredentials( authenticator: each authenticators, - registration: registration + registration: registration, + parentTask: parentTask )) - await withTaskCancellationHandler { - credentialSources = (repeat await (each tasks).result) + + return try await withTaskCancellationHandler { + var sharedAttestationObject: AttestationObject? = nil + let results = (repeat await (each tasks).result) + let credentials = (repeat groupResult(result: each results, sharedAttestationObject: &sharedAttestationObject)) + + guard let sharedAttestationObject + else { throw WebAuthnError.missingCredentialSourceDespiteSuccess } + + return (sharedAttestationObject, (repeat each credentials)) } onCancel: { repeat (each tasks).cancel() } } - - guard let credentialSources - else { throw WebAuthnError.missingCredentialSourceDespiteSuccess } - - return (registrationCredential, credentialSources) + return try await withTaskCancellationHandler { + try await parentTask.value + } onCancel: { [parentTask] in + parentTask!.cancel() + } + } + + /// Wrapper function since `repeat` doesn't currently support complex expressions + func makeCredentials( + authenticator: LocalAuthenticator, + registration: AttestationRegistrationRequest, + parentTask: Task<(AttestationObject, (repeat Result<(each Authenticator).CredentialOutput, Error>)), Error> + ) -> Task<(attestationObject: AttestationObject, credentialOutput: LocalAuthenticator.CredentialOutput), Error> { + Task { + let result = try await authenticator.makeCredentials(with: registration) + parentTask.cancel() + return result + } } + /// Wrapper function since `repeat` doesn't currently support complex expressions + func groupResult( + result: Result<(attestationObject: AttestationObject, credentialOutput: T), Error>, + sharedAttestationObject: inout AttestationObject? + ) -> Result { + switch result { + case .success(let success): + if sharedAttestationObject == nil { + sharedAttestationObject = success.attestationObject + return .success(success.credentialOutput) + } else { + return .failure(CancellationError()) + } + case .failure(let failure): + return .failure(failure) + } + } +} + +/* +extension WebAuthnClient { + @available(macOS 14.0.0, *) @inlinable - public func assertAuthenticationCredential( - options: PublicKeyCredentialRequestOptions, + public func createRegistrationCredential( + options: PublicKeyCredentialCreationOptions, /// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout minTimeout: Duration = .seconds(300), maxTimeout: Duration = .seconds(600), origin: String, -// mediation: , - authenticator: Authenticator, - credentialStore: CredentialStore + supportedPublicKeyCredentialParameters: Set = .supported, + authenticators: repeat each Authenticator ) async throws -> ( - authenticationCredential: AuthenticationCredential, - updatedCredentialSource: Authenticator.CredentialSource + registrationCredential: RegistrationCredential, + credentialSources: (repeat Result<(each Authenticator).CredentialOutput, Error>) ) { - var credentialSource: Authenticator.CredentialSource? - let authenticationCredential = try await assertAuthenticationCredential( + let result = try await createRegistrationCredential( options: options, minTimeout: minTimeout, maxTimeout: maxTimeout, - origin: origin - ) { authentication in - credentialSource = try await authenticator.assertCredentials( - authenticationRequest: authentication, - credentials: credentialStore - ) - } - - guard let credentialSource - else { throw WebAuthnError.missingCredentialSourceDespiteSuccess } - - return (authenticationCredential, credentialSource) + origin: origin, + supportedPublicKeyCredentialParameters: supportedPublicKeyCredentialParameters, + authenticator: AuthenticatorRegistrationGroup(authenticators: repeat each authenticators) + ) + /// Need to rebuild the return value due to: `Cannot convert return expression of type '(registrationCredential: RegistrationCredential, credentialSource: AuthenticatorGroup.CredentialOutput)' to return type '(registrationCredential: RegistrationCredential, credentialSources: (repeat Result<(each Authenticator).CredentialOutput, any Error>))'` + return (result.registrationCredential, result.credentialSource) } @inlinable - public func assertAuthenticationCredential( + public func assertAuthenticationCredential( options: PublicKeyCredentialRequestOptions, /// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout minTimeout: Duration = .seconds(300), @@ -714,3 +738,4 @@ extension WebAuthnClient { return (authenticationCredential, credentialSources) } } +*/ diff --git a/Sources/WebAuthn/WebAuthnError.swift b/Sources/WebAuthn/WebAuthnError.swift index 65476ac..05ef372 100644 --- a/Sources/WebAuthn/WebAuthnError.swift +++ b/Sources/WebAuthn/WebAuthnError.swift @@ -71,6 +71,7 @@ public struct WebAuthnError: Error, Hashable, Sendable { // MARK: WebAuthnClient case noSupportedCredentialParameters case missingCredentialSourceDespiteSuccess + case timeoutError // MARK: Authenticator case unsupportedCredentialPublicKeyType @@ -141,6 +142,7 @@ public struct WebAuthnError: Error, Hashable, Sendable { // MARK: WebAuthnClient public static let noSupportedCredentialParameters = Self(reason: .noSupportedCredentialParameters) public static let missingCredentialSourceDespiteSuccess = Self(reason: .missingCredentialSourceDespiteSuccess) + public static let timeoutError = Self(reason: .timeoutError) // MARK: Authenticator public static let unsupportedCredentialPublicKeyType = Self(reason: .unsupportedCredentialPublicKeyType)