Files
ccn/Services/Website/Sources/Library/Public/Controllers/PasskeyController.swift
T

309 lines
11 KiB
Swift

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 never added to an existing account: the prototype has
/// no other proof of who is asking, so that 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<Context: RequestContext> {
// 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<Context> {
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<RegistrationCredential>.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
return try await signedIn(user, returningTo: answer.return)
case .authentication:
let answer = try JSONDecoder().decode(Answer<AuthenticationCredential>.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 typed = (try? await request.decode(as: Start.self, context: context))?.email?
.trimmingCharacters(in: .whitespaces) ?? ""
// 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?
}
/// What the script posts to finish a ceremony: the browser's credential, and where to go afterwards.
private struct Answer<Credential: Decodable>: Decodable {
let credential: Credential
let `return`: String?
}