|
| 1 | +//===----------------------------------------------------------------------===// |
| 2 | +// |
| 3 | +// This source file is part of the WebAuthn Swift open source project |
| 4 | +// |
| 5 | +// Copyright (c) 2024 the WebAuthn Swift project authors |
| 6 | +// Licensed under Apache License v2.0 |
| 7 | +// |
| 8 | +// See LICENSE.txt for license information |
| 9 | +// See CONTRIBUTORS.txt for the list of WebAuthn Swift project authors |
| 10 | +// |
| 11 | +// SPDX-License-Identifier: Apache-2.0 |
| 12 | +// |
| 13 | +//===----------------------------------------------------------------------===// |
| 14 | + |
| 15 | +import Foundation |
| 16 | +import Crypto |
| 17 | + |
| 18 | +/// A client implementation capable of interfacing between an ``AuthenticatorProtocol`` authenticator and the Web Authentication API. |
| 19 | +/// |
| 20 | +/// - Important: Unless you specifically need to implement a custom WebAuthn client, it is vastly preferable to reach for the built-in [AuthenticationServices](https://developer.apple.com/documentation/authenticationservices) framework instead, which provides out-of-the-box support for a user's [Passkey](https://developer.apple.com/documentation/authenticationservices/public-private_key_authentication/supporting_passkeys). However, this is not always possible or preferrable to use this credential, especially when you want to implement silent account creation, and wish to build it off of WebAuthn. For those cases, `WebAuthnClient` is available. |
| 21 | +/// |
| 22 | +/// Registration: To create a registration credential, first ask the relying party (aka the server) for ``PublicKeyCredentialCreationOptions``, then pass those to ``createRegistrationCredential(options:minTimeout:maxTimeout:origin:supportedPublicKeyCredentialParameters:attestRegistration:)`` along with a closure that can generate credentials from configured ``AuthenticatorProtocol`` types such as ``KeyPairAuthenticator`` by passing the provided ``AttestationRegistration`` to ``AuthenticatorProtocol/makeCredentials(with:)``, making sure to persist the resulting ``AuthenticatorProtocol/CredentialSource`` in some way. Finally, pass the resulting ``RegistrationCredential`` back to the relying party to finish registration. |
| 23 | +public struct WebAuthnClient { |
| 24 | + public init() {} |
| 25 | + |
| 26 | + public func createRegistrationCredential( |
| 27 | + options: PublicKeyCredentialCreationOptions, |
| 28 | + /// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout |
| 29 | + minTimeout: Duration = .seconds(300), |
| 30 | + maxTimeout: Duration = .seconds(600), |
| 31 | + origin: String, |
| 32 | + supportedPublicKeyCredentialParameters: Set<PublicKeyCredentialParameters> = .supported, |
| 33 | + attestRegistration: (_ registration: AttestationRegistrationRequest) async throws -> () |
| 34 | + ) async throws -> RegistrationCredential { |
| 35 | + /// Steps: https://w3c.github.io/webauthn/#sctn-createCredential |
| 36 | + |
| 37 | + /// Step 1. Assert: options.publicKey is present. |
| 38 | + // Skip. |
| 39 | + |
| 40 | + /// Step 2. If sameOriginWithAncestors is false: |
| 41 | + /// 1. If the relevant global object, as determined by the calling create() implementation, does not have transient activation: |
| 42 | + /// 1. Throw a "NotAllowedError" DOMException. |
| 43 | + /// 2. Consume user activation of the relevant global object. |
| 44 | + // Skip. |
| 45 | + |
| 46 | + /// Step 3. Let pkOptions be the value of options.publicKey. |
| 47 | + // Skip. |
| 48 | + |
| 49 | + /// Step 4. If pkOptions.timeout is present, check if its value lies within a reasonable range as defined by the client and if not, correct it to the closest value lying within that range. Set a timer lifetimeTimer to this adjusted value. If pkOptions.timeout is not present, then set lifetimeTimer to a client-specific default. |
| 50 | + /// |
| 51 | + /// See the recommended range and default for a WebAuthn ceremony timeout for guidance on deciding a reasonable range and default for pkOptions.timeout. |
| 52 | + let proposedTimeout = options.timeout ?? minTimeout |
| 53 | + let timeout = max(minTimeout, min(proposedTimeout, maxTimeout)) |
| 54 | + |
| 55 | + /// Step 5. If the length of pkOptions.user.id is not between 1 and 64 bytes (inclusive) then throw a TypeError. |
| 56 | + guard 1...64 ~= options.user.id.count |
| 57 | + else { throw WebAuthnError.invalidUserID } |
| 58 | + |
| 59 | + /// Step 6. Let callerOrigin be origin. If callerOrigin is an opaque origin, throw a "NotAllowedError" DOMException. |
| 60 | + let callerOrigin = origin |
| 61 | + |
| 62 | + /// Step 7. Let effectiveDomain be the callerOrigin’s effective domain. If effective domain is not a valid domain, then throw a "SecurityError" DOMException. |
| 63 | + // Skip. |
| 64 | + |
| 65 | + /// Step 8. If pkOptions.rp.id |
| 66 | + /// → is present |
| 67 | + /// If pkOptions.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, throw a "SecurityError" DOMException. |
| 68 | + /// → Is not present |
| 69 | + /// Set pkOptions.rp.id to effectiveDomain. |
| 70 | + // Skip. |
| 71 | + |
| 72 | + /// Step 11. Let credTypesAndPubKeyAlgs be a new list whose items are pairs of PublicKeyCredentialType and a COSEAlgorithmIdentifier. |
| 73 | + var publicKeyCredentialParameters: [PublicKeyCredentialParameters] = [] |
| 74 | + |
| 75 | + /// Step 12. If pkOptions.pubKeyCredParams’s size |
| 76 | + /// → is zero |
| 77 | + /// Append the following pairs of PublicKeyCredentialType and COSEAlgorithmIdentifier values to credTypesAndPubKeyAlgs: |
| 78 | + /// public-key and -7 ("ES256"). |
| 79 | + /// public-key and -257 ("RS256"). |
| 80 | + /// → is non-zero |
| 81 | + /// For each current of pkOptions.pubKeyCredParams: |
| 82 | + /// 1. If current.type does not contain a PublicKeyCredentialType supported by this implementation, then continue. |
| 83 | + /// 2. Let alg be current.alg. |
| 84 | + /// 3. Append the pair of current.type and alg to credTypesAndPubKeyAlgs. |
| 85 | + /// If credTypesAndPubKeyAlgs is empty, throw a "NotSupportedError" DOMException. |
| 86 | + if options.publicKeyCredentialParameters.isEmpty { |
| 87 | + publicKeyCredentialParameters = [ |
| 88 | + PublicKeyCredentialParameters(alg: .algES256), |
| 89 | +// PublicKeyCredentialParameters(alg: .algRS256), |
| 90 | + ] |
| 91 | + } else { |
| 92 | + for credentialParameter in options.publicKeyCredentialParameters { |
| 93 | + guard supportedPublicKeyCredentialParameters.contains(credentialParameter) |
| 94 | + else { continue } |
| 95 | + publicKeyCredentialParameters.append(credentialParameter) |
| 96 | + } |
| 97 | + guard !publicKeyCredentialParameters.isEmpty |
| 98 | + else { throw WebAuthnError.noSupportedCredentialParameters } |
| 99 | + } |
| 100 | + |
| 101 | + /// Step 15. Let clientExtensions be a new map and let authenticatorExtensions be a new map. |
| 102 | + // Skip. |
| 103 | + |
| 104 | + /// Step 16. If pkOptions.extensions is present, then for each extensionId → clientExtensionInput of pkOptions.extensions: |
| 105 | + /// 1. If extensionId is not supported by this client platform or is not a registration extension, then continue. |
| 106 | + /// 2. Set clientExtensions[extensionId] to clientExtensionInput. |
| 107 | + /// 3. If extensionId is not an authenticator extension, then continue. |
| 108 | + /// 4. Let authenticatorExtensionInput be the (CBOR) result of running extensionId’s client extension processing algorithm on clientExtensionInput. If the algorithm returned an error, continue. |
| 109 | + /// 5. Set authenticatorExtensions[extensionId] to the base64url encoding of authenticatorExtensionInput. |
| 110 | + // Skip. |
| 111 | + |
| 112 | + /// Step 17. Let collectedClientData be a new CollectedClientData instance whose fields are: |
| 113 | + let collectedClientData = CollectedClientData( |
| 114 | + /// type |
| 115 | + /// The string "webauthn.create". |
| 116 | + type: .create, |
| 117 | + /// challenge |
| 118 | + /// The base64url encoding of pkOptions.challenge. |
| 119 | + challenge: options.challenge.base64URLEncodedString(), |
| 120 | + /// origin |
| 121 | + /// The serialization of callerOrigin. |
| 122 | + origin: callerOrigin |
| 123 | + /// topOrigin |
| 124 | + /// The serialization of callerOrigin’s top-level origin if the sameOriginWithAncestors argument passed to this internal method is false, else undefined. |
| 125 | + // Skip. |
| 126 | + /// crossOrigin |
| 127 | + /// The inverse of the value of the sameOriginWithAncestors argument passed to this internal method. |
| 128 | + // Skip. |
| 129 | + ) |
| 130 | + |
| 131 | + /// Step 18. Let clientDataJSON be the JSON-compatible serialization of client data constructed from collectedClientData. |
| 132 | + let clientDataJSON = try JSONEncoder().encode(collectedClientData) |
| 133 | + |
| 134 | + /// Step 19. Let clientDataHash be the hash of the serialized client data represented by clientDataJSON. |
| 135 | + let clientDataHash = SHA256.hash(data: clientDataJSON) |
| 136 | + |
| 137 | + /// Step 20. If options.signal is present and aborted, throw the options.signal’s abort reason. |
| 138 | + // Skip. |
| 139 | + |
| 140 | + /// Step 21. Let issuedRequests be a new ordered set. |
| 141 | + // Skip. |
| 142 | + |
| 143 | + /// Step 22. Let authenticators represent a value which at any given instant is a set of client platform-specific handles, where each item identifies an authenticator presently available on this client platform at that instant. |
| 144 | + // Skip. |
| 145 | + |
| 146 | + /// Step 23. Consider the value of hints and craft the user interface accordingly, as the user-agent sees fit. |
| 147 | + // Skip. |
| 148 | + |
| 149 | + /// Step 24. Start lifetimeTimer. |
| 150 | + // Skip. |
| 151 | + |
| 152 | + /// Step 25. While lifetimeTimer has not expired, perform the following actions depending upon lifetimeTimer, and the state and response for each authenticator in authenticators: |
| 153 | + // Skip. |
| 154 | + |
| 155 | + throw WebAuthnError.unsupported |
| 156 | + } |
| 157 | +} |
| 158 | + |
| 159 | +// MARK: Convenience Registration and Authentication |
| 160 | + |
| 161 | +extension WebAuthnClient { |
| 162 | + @inlinable |
| 163 | + public func createRegistrationCredential<Authenticator: AuthenticatorProtocol & Sendable>( |
| 164 | + options: PublicKeyCredentialCreationOptions, |
| 165 | + /// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout |
| 166 | + minTimeout: Duration = .seconds(300), |
| 167 | + maxTimeout: Duration = .seconds(600), |
| 168 | + origin: String, |
| 169 | + supportedPublicKeyCredentialParameters: Set<PublicKeyCredentialParameters> = .supported, |
| 170 | + authenticator: Authenticator |
| 171 | + ) async throws -> (registrationCredential: RegistrationCredential, credentialSource: Authenticator.CredentialSource) { |
| 172 | + var credentialSource: Authenticator.CredentialSource? |
| 173 | + let registrationCredential = try await createRegistrationCredential( |
| 174 | + options: options, |
| 175 | + minTimeout: minTimeout, |
| 176 | + maxTimeout: maxTimeout, |
| 177 | + origin: origin, |
| 178 | + supportedPublicKeyCredentialParameters: supportedPublicKeyCredentialParameters |
| 179 | + ) { registration in |
| 180 | + credentialSource = try await authenticator.makeCredentials(with: registration) |
| 181 | + } |
| 182 | + |
| 183 | + guard let credentialSource |
| 184 | + else { throw WebAuthnError.missingCredentialSourceDespiteSuccess } |
| 185 | + |
| 186 | + return (registrationCredential, credentialSource) |
| 187 | + } |
| 188 | + |
| 189 | + @inlinable |
| 190 | + public func createRegistrationCredential<each Authenticator: AuthenticatorProtocol & Sendable>( |
| 191 | + options: PublicKeyCredentialCreationOptions, |
| 192 | + /// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout |
| 193 | + minTimeout: Duration = .seconds(300), |
| 194 | + maxTimeout: Duration = .seconds(600), |
| 195 | + origin: String, |
| 196 | + supportedPublicKeyCredentialParameters: Set<PublicKeyCredentialParameters> = .supported, |
| 197 | + authenticators: repeat each Authenticator |
| 198 | + ) async throws -> ( |
| 199 | + registrationCredential: RegistrationCredential, |
| 200 | + credentialSources: (repeat Result<(each Authenticator).CredentialSource, Error>) |
| 201 | + ) { |
| 202 | + /// Wrapper function since `repeat` doesn't currently support complex expressions |
| 203 | + @Sendable func register<LocalAuthenticator: AuthenticatorProtocol & Sendable>( |
| 204 | + authenticator: LocalAuthenticator, |
| 205 | + registration: AttestationRegistrationRequest |
| 206 | + ) -> Task<LocalAuthenticator.CredentialSource, Error> { |
| 207 | + Task { try await authenticator.makeCredentials(with: registration) } |
| 208 | + } |
| 209 | + |
| 210 | + var credentialSources: (repeat Result<(each Authenticator).CredentialSource, Error>)? |
| 211 | + let registrationCredential = try await createRegistrationCredential( |
| 212 | + options: options, |
| 213 | + minTimeout: minTimeout, |
| 214 | + maxTimeout: maxTimeout, |
| 215 | + origin: origin, |
| 216 | + supportedPublicKeyCredentialParameters: supportedPublicKeyCredentialParameters |
| 217 | + ) { registration in |
| 218 | + /// Run each authenticator in parallel as child tasks, so we can automatically propagate cancellation to each of them should it occur. |
| 219 | + let tasks = (repeat register( |
| 220 | + authenticator: each authenticators, |
| 221 | + registration: registration |
| 222 | + )) |
| 223 | + await withTaskCancellationHandler { |
| 224 | + credentialSources = (repeat await (each tasks).result) |
| 225 | + } onCancel: { |
| 226 | + repeat (each tasks).cancel() |
| 227 | + } |
| 228 | + } |
| 229 | + |
| 230 | + guard let credentialSources |
| 231 | + else { throw WebAuthnError.missingCredentialSourceDespiteSuccess } |
| 232 | + |
| 233 | + return (registrationCredential, credentialSources) |
| 234 | + } |
| 235 | +} |
0 commit comments