308 lines
11 KiB
Swift
308 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?
|
||
|
|
}
|