Implemented phase 5 for the POC prototype in the Website service.

This commit is contained in:
javier committed 2026-09-21 13:32:05 +02:00
1 parent 89a7a7b8e5
commit 31d2d98d49
46 files changed
+4039 -162

No files matched your search

@@ -8,8 +8,8 @@ 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.
/// 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<Context: RequestContext> {
@@ -134,7 +134,35 @@ private extension PasskeyController {
user = joined
return try await signedIn(user, returningTo: answer.return)
// 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<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))
}
)
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<AuthenticationCredential>.self, from: body)
let credentialID = Self.base64URL(answer.credential.id.asString())
@@ -167,8 +195,23 @@ private extension PasskeyController {
request: Request,
context: Context
) async throws -> Response {
let typed = (try? await request.decode(as: Start.self, context: context))?.email?
.trimmingCharacters(in: .whitespaces) ?? ""
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 {
@@ -299,6 +342,7 @@ private extension PasskeyController {
/// 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.