import Foundation import Hummingbird import Infrastructure import Persistence import Utility import WebAuthn /// Serves the passkey ceremonies behind the sign-in screen, as JSON for `js/passkey.js`. /// /// One step both signs in and joins: an unknown email registers a passkey and creates its account together; anything else authenticates with a /// passkey registered earlier — a discoverable one when no email is given. A passkey is added to an existing account only from that account's /// own session: signed out there is no proof of who is asking, so it would hand the account over. /// /// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to. public struct PasskeyController { // MARK: Properties /// The repository accounts and their passkeys are read and created through. private let accounts: AccountRepository /// The ceremonies under way. private let ceremonies: PasskeyCeremonies /// Whether the cookies are limited to HTTPS. private let isSecure: Bool /// The origin the sign-in screen is served at, which passkeys are bound to. private let origin: String /// The store sessions are started in. private let sessions: SessionStore // MARK: Initializers /// Creates a passkey controller. /// - Parameters: /// - origin: the origin the sign-in screen is served at, e.g. `https://ccn.example`. Browsers bind a passkey to it, and an answer /// signed for any other origin is turned down — so it is configuration, never read off a request. /// - accounts: the repository accounts and their passkeys are read and created through. /// - sessions: the store sessions are started in. /// - ceremonies: the ceremonies under way; a set of its own by default. public init( origin: String, accounts: AccountRepository, sessions: SessionStore, ceremonies: PasskeyCeremonies = .init() ) { self.accounts = accounts self.ceremonies = ceremonies self.isSecure = origin.hasPrefix("https://") self.origin = origin self.sessions = sessions } } // MARK: - RouterController extension PasskeyController: RouterController { // MARK: Properties public var routes: RouteCollection { let routes = RouteCollection(context: Context.self) routes.post( .init("\(String.Route.signIn)/passkey/options"), use: options ) routes.post( .init("\(String.Route.signIn)/passkey/finish"), use: finish ) return routes } } // MARK: - Helpers private extension PasskeyController { // MARK: Computed /// The WebAuthn relying party the ceremonies are run as. Built per request: the manager is not `Sendable`, and holds no state. var manager: WebAuthnManager { .init(configuration: .init( relyingPartyID: URLComponents(string: origin)?.host ?? "localhost", relyingPartyName: "CCN", relyingPartyOrigin: origin )) } // MARK: Methods /// Handles the browser's signed answer: checks it against the ceremony it belongs to, then starts the session. @Sendable func finish( request: Request, context: Context ) async throws -> Response { guard let token = request.cookies[PasskeyCeremonies.cookieName]?.value, let ceremony = await ceremonies.take(token) else { return Self.refusal(.badRequest, "That sign-in ran out. Try again.") } let body = try await request.body.collect(upTo: 64 * 1_024) let user: User do { switch ceremony.kind { case .registration(let email): let answer = try JSONDecoder().decode(Answer.self, from: body) let credential = try await manager.finishRegistration( challenge: ceremony.challenge, credentialCreationData: answer.credential, confirmCredentialIDNotRegisteredYet: { [accounts] in try await accounts.isUnregistered(credentialID: Self.base64URL($0)) } ) guard let joined = try await accounts.join( email: email, credentialID: Self.base64URL(credential.id), publicKey: credential.publicKey, signCount: credential.signCount ) else { return Self.refusal(.conflict, "That email already has an account.") } user = joined // A new account picks its display name before going on. return try await signedIn(user, returningTo: .Route.welcome(returningTo: .Route.safeReturn(answer.return))) case .addition(let userID): guard let owner = await sessions.user(for: request), owner.id == userID else { return Self.refusal(.unauthorized, "Sign in again to add a passkey.") } let answer = try JSONDecoder().decode(Answer.self, from: body) let credential = try await manager.finishRegistration( challenge: ceremony.challenge, credentialCreationData: answer.credential, confirmCredentialIDNotRegisteredYet: { [accounts] in try await accounts.isUnregistered(credentialID: Self.base64URL($0)) } ) try await accounts.addPasskey( to: owner, credentialID: Self.base64URL(credential.id), publicKey: credential.publicKey, signCount: credential.signCount ) // The bare path: from the account screen, one ending in a fragment would not reload it. var response = Self.json(.ok, #"{"location":"\#(String.Route.account)"}"#) response.setCookie(PasskeyCeremonies.cookie("", isSecure: isSecure)) return response case .authentication: let answer = try JSONDecoder().decode(Answer.self, from: body) let credentialID = Self.base64URL(answer.credential.id.asString()) guard let passkey = try await accounts.passkey(credentialID: credentialID) else { return Self.refusal(.unauthorized, "That passkey isn't registered with CCN.") } let verified = try manager.finishAuthentication( credential: answer.credential, expectedChallenge: ceremony.challenge, credentialPublicKey: passkey.publicKey, credentialCurrentSignCount: passkey.signCount ) try await accounts.recordUse(ofPasskey: credentialID, signCount: verified.newSignCount) return try await signedIn(passkey.user, returningTo: answer.return) } } catch is DecodingError { return Self.refusal(.badRequest, "That answer couldn't be read.") } catch is WebAuthnError { return Self.refusal(.unauthorized, "That passkey couldn't be verified.") } } /// Handles the start of a ceremony: issues the challenge, and tells the script whether to create a passkey or use one. @Sendable func options( request: Request, context: Context ) async throws -> Response { let start = try? await request.decode(as: Start.self, context: context) let typed = start?.email?.trimmingCharacters(in: .whitespaces) ?? "" // From the account screen: another passkey for the account signed in. if start?.add == true { guard let owner = await sessions.user(for: request) else { return Self.refusal(.unauthorized, "Sign in again to add a passkey.") } let options = manager.beginRegistration(user: .init( id: Array(owner.id.uuidString.utf8), name: owner.email, displayName: owner.displayName )) return try await begin(.addition(userID: owner.id), options: options, challenge: options.challenge, mode: "create") } // No email: whichever passkey the browser holds for this site. guard !typed.isEmpty else { return try await begin(.authentication, options: manager.beginAuthentication(), mode: "get") } guard let email = NormalizeEmail()(typed) else { return Self.refusal(.unprocessableContent, "That doesn't look like an email address.") } guard try await accounts.existingAccount(email: email) != nil else { let options = manager.beginRegistration(user: .init( id: Array(UUID().uuidString.utf8), name: email, displayName: String(email.prefix { $0 != "@" }) )) return try await begin(.registration(email: email), options: options, challenge: options.challenge, mode: "create") } let passkeys = try await accounts.passkeys(email: email) guard !passkeys.isEmpty else { return Self.refusal(.conflict, "That account has no passkey. The prototype's demo accounts sign in with the demo sign-in below.") } return try await begin( .authentication, options: manager.beginAuthentication(allowCredentials: passkeys.compactMap { passkey in Self.bytes(base64URL: passkey.credentialID).map { .init(type: .publicKey, id: $0) } }), mode: "get" ) } /// Starts an authentication ceremony and answers with its options. func begin( _ kind: PasskeyCeremonies.Ceremony.Kind, options: PublicKeyCredentialRequestOptions, mode: String ) async throws -> Response { try await begin(kind, options: options, challenge: options.challenge, mode: mode) } /// Starts a ceremony and answers with its options, as `{"mode": …, "options": …}`, and the ceremony's cookie. func begin( _ kind: PasskeyCeremonies.Ceremony.Kind, options: some Encodable, challenge: [UInt8], mode: String ) async throws -> Response { let token = await ceremonies.start(kind, challenge: challenge) let options = String(decoding: try JSONEncoder().encode(options), as: UTF8.self) var response = Self.json(.ok, #"{"mode":"\#(mode)","options":\#(options)}"#) response.setCookie(PasskeyCeremonies.cookie(token, isSecure: isSecure)) return response } /// Starts the session of a signed-in account, and answers with where to go next. func signedIn( _ user: User, returningTo path: String? ) async throws -> Response { let token = await sessions.start(for: user) let location = String(decoding: try JSONEncoder().encode(String.Route.safeReturn(path)), as: UTF8.self) var response = Self.json(.ok, #"{"location":\#(location)}"#) response.setCookie(SessionStore.cookie(token, isSecure: isSecure)) response.setCookie(PasskeyCeremonies.cookie("", isSecure: isSecure)) return response } /// A base64 or base64url string as base64url without padding: the one form credential identifiers are kept and compared in. static func base64URL( _ string: String ) -> String { string .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } /// The bytes a base64url string encodes, or `nil` when it encodes none. static func bytes( base64URL string: String ) -> [UInt8]? { var base64 = string .replacingOccurrences(of: "-", with: "+") .replacingOccurrences(of: "_", with: "/") base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) return Data(base64Encoded: base64).map(Array.init) } /// A JSON response, never stored. static func json( _ status: HTTPResponse.Status, _ body: String ) -> Response { .init( status: status, headers: [ .contentType: "application/json; charset=utf-8", .cacheControl: "no-store", ], body: .init(byteBuffer: .init(string: body)) ) } /// A refusal, as `{"error": …}`, for the script to show. static func refusal( _ status: HTTPResponse.Status, _ message: String ) -> Response { let message = (try? JSONEncoder().encode(message)).map { String(decoding: $0, as: UTF8.self) } ?? "\"\"" return json(status, #"{"error":\#(message)}"#) } } // MARK: - Structs /// What the script posts to start a ceremony. private struct Start: Decodable { let email: String? let add: Bool? } /// What the script posts to finish a ceremony: the browser's credential, and where to go afterwards. private struct Answer: Decodable { let credential: Credential let `return`: String? }