feat: add passkey-first login and management flow

This commit is contained in:
Shuai
2026-03-31 00:59:50 +08:00
parent 1184cb8d9a
commit 0f6da7d147
16 changed files with 799 additions and 6 deletions
+10
View File
@@ -98,6 +98,16 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)',
'CREATE TABLE IF NOT EXISTS passkey_credentials (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, credential_id TEXT NOT NULL UNIQUE, public_key TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, transports TEXT, name TEXT NOT NULL, wrapped_vault_keys TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_used_at TEXT, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_passkey_credentials_user ON passkey_credentials(user_id)',
'CREATE TABLE IF NOT EXISTS passkey_challenges (' +
'id TEXT PRIMARY KEY, user_id TEXT, challenge TEXT NOT NULL, action TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_passkey_challenges_expiry ON passkey_challenges(expires_at)',
];
async function executeSchemaStatement(db: D1Database, statement: string): Promise<void> {
+86 -2
View File
@@ -1,4 +1,4 @@
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord } from '../types';
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, PasskeyCredential } from '../types';
import { LIMITS } from '../config/limits';
import { ensureStorageSchema } from './storage-schema';
import {
@@ -106,7 +106,7 @@ import {
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
const STORAGE_SCHEMA_VERSION = '2026-03-23.1';
const STORAGE_SCHEMA_VERSION = '2026-03-30.1';
// D1-backed storage.
// Contract:
@@ -590,6 +590,90 @@ export class StorageService {
return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier);
}
// --- Passkeys ---
async createPasskeyChallenge(id: string, userId: string | null, challenge: string, action: 'register' | 'login', expiresAt: number): Promise<void> {
await this.db
.prepare('INSERT OR REPLACE INTO passkey_challenges(id, user_id, challenge, action, expires_at, created_at) VALUES(?, ?, ?, ?, ?, ?)')
.bind(id, userId, challenge, action, expiresAt, new Date().toISOString())
.run();
}
async consumePasskeyChallenge(id: string, action: 'register' | 'login'): Promise<{ challenge: string; userId: string | null } | null> {
const now = Date.now();
const row = await this.db
.prepare('SELECT challenge, user_id as userId FROM passkey_challenges WHERE id = ? AND action = ? AND expires_at > ?')
.bind(id, action, now)
.first<{ challenge: string; userId: string | null }>();
await this.db.prepare('DELETE FROM passkey_challenges WHERE id = ?').bind(id).run();
return row || null;
}
async listPasskeysByUserId(userId: string): Promise<PasskeyCredential[]> {
const rows = await this.db
.prepare('SELECT id, user_id, credential_id, public_key, counter, transports, name, wrapped_vault_keys, created_at, updated_at, last_used_at FROM passkey_credentials WHERE user_id = ? ORDER BY created_at ASC')
.bind(userId)
.all<Record<string, unknown>>();
return (rows.results || []).map((row) => ({
id: String(row.id),
userId: String(row.user_id),
credentialId: String(row.credential_id),
publicKey: String(row.public_key),
counter: Number(row.counter || 0),
transports: row.transports == null ? null : String(row.transports),
name: String(row.name || ''),
wrappedVaultKeys: String(row.wrapped_vault_keys || ''),
createdAt: String(row.created_at || ''),
updatedAt: String(row.updated_at || ''),
lastUsedAt: row.last_used_at == null ? null : String(row.last_used_at),
}));
}
async getPasskeyByCredentialId(credentialId: string): Promise<PasskeyCredential | null> {
const row = await this.db
.prepare('SELECT id, user_id, credential_id, public_key, counter, transports, name, wrapped_vault_keys, created_at, updated_at, last_used_at FROM passkey_credentials WHERE credential_id = ?')
.bind(credentialId)
.first<Record<string, unknown>>();
if (!row) return null;
return {
id: String(row.id),
userId: String(row.user_id),
credentialId: String(row.credential_id),
publicKey: String(row.public_key || ''),
counter: Number(row.counter || 0),
transports: row.transports == null ? null : String(row.transports),
name: String(row.name || ''),
wrappedVaultKeys: String(row.wrapped_vault_keys || ''),
createdAt: String(row.created_at || ''),
updatedAt: String(row.updated_at || ''),
lastUsedAt: row.last_used_at == null ? null : String(row.last_used_at),
};
}
async createPasskey(record: PasskeyCredential): Promise<void> {
await this.db
.prepare('INSERT INTO passkey_credentials(id, user_id, credential_id, public_key, counter, transports, name, wrapped_vault_keys, created_at, updated_at, last_used_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
.bind(record.id, record.userId, record.credentialId, record.publicKey, record.counter, record.transports, record.name, record.wrappedVaultKeys, record.createdAt, record.updatedAt, record.lastUsedAt)
.run();
}
async updatePasskeyName(userId: string, id: string, name: string): Promise<boolean> {
const result = await this.db
.prepare('UPDATE passkey_credentials SET name = ?, updated_at = ? WHERE id = ? AND user_id = ?')
.bind(name, new Date().toISOString(), id, userId)
.run();
return (result.meta?.changes || 0) > 0;
}
async deletePasskey(userId: string, id: string): Promise<boolean> {
const result = await this.db.prepare('DELETE FROM passkey_credentials WHERE id = ? AND user_id = ?').bind(id, userId).run();
return (result.meta?.changes || 0) > 0;
}
async touchPasskeyUsage(id: string): Promise<void> {
await this.db.prepare('UPDATE passkey_credentials SET last_used_at = ?, updated_at = ? WHERE id = ?').bind(new Date().toISOString(), new Date().toISOString(), id).run();
}
// --- Revision dates ---
async getRevisionDate(userId: string): Promise<string> {