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

This commit is contained in:
javier committed 2026-09-21 13:53:13 +02:00
1 parent 256d22df40
commit f2f445827b
40 files changed
+1906 -37

No files matched your search

@@ -28,6 +28,9 @@ public struct PasskeyController<Context: RequestContext> {
/// The origin the sign-in screen is served at, which passkeys are bound to.
private let origin: String
/// The one-time recovery links a recovery ceremony answers to.
private let recoveryLinks: RecoveryLinks
/// The store sessions are started in.
private let sessions: SessionStore
@@ -40,12 +43,15 @@ public struct PasskeyController<Context: RequestContext> {
/// - 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.
/// - recoveryLinks: the one-time recovery links a recovery ceremony answers to; a set of its own by default.
public init(
origin: String,
accounts: AccountRepository,
sessions: SessionStore,
ceremonies: PasskeyCeremonies = .init()
ceremonies: PasskeyCeremonies = .init(),
recoveryLinks: RecoveryLinks = .init()
) {
self.recoveryLinks = recoveryLinks
self.accounts = accounts
self.ceremonies = ceremonies
self.isSecure = origin.hasPrefix("https://")
@@ -136,6 +142,29 @@ private extension PasskeyController {
// A new account picks its display name before going on.
return try await signedIn(user, returningTo: .Route.welcome(returningTo: .Route.safeReturn(answer.return)))
case .recovery(let token):
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))
}
)
// Used up only now: a prompt cancelled leaves the link good for another try.
guard let owner = await recoveryLinks.redeem(token) else {
return Self.refusal(.gone, "That recovery link has run out. Ask for a new one.")
}
try await accounts.replacePasskeys(
of: owner,
credentialID: Self.base64URL(credential.id),
publicKey: credential.publicKey,
signCount: credential.signCount
)
return try await signedIn(owner, returningTo: .Route.account)
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.")
@@ -198,6 +227,21 @@ private extension PasskeyController {
let start = try? await request.decode(as: Start.self, context: context)
let typed = start?.email?.trimmingCharacters(in: .whitespaces) ?? ""
// From a recovery link: a new passkey, replacing the account's others.
if let token = start?.recover {
guard let owner = await recoveryLinks.user(for: token) else {
return Self.refusal(.gone, "That recovery link has run out. Ask for a new one.")
}
let options = manager.beginRegistration(user: .init(
id: Array(UUID().uuidString.utf8),
name: owner.email,
displayName: owner.displayName
))
return try await begin(.recovery(token: token), options: options, challenge: options.challenge, mode: "create")
}
// From the account screen: another passkey for the account signed in.
if start?.add == true {
guard let owner = await sessions.user(for: request) else {
@@ -343,6 +387,7 @@ private extension PasskeyController {
private struct Start: Decodable {
let email: String?
let add: Bool?
let recover: String?
}
/// What the script posts to finish a ceremony: the browser's credential, and where to go afterwards.