mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add passkey-first login and management flow
This commit is contained in:
@@ -188,3 +188,30 @@ 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);
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import type { Env, PasskeyCredential, TokenResponse } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response';
|
||||
import { randomChallenge, parseClientDataJSON } from '../utils/passkey';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { readAuthRequestDeviceInfo } from '../utils/device';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { buildAccountKeys, buildUserDecryptionOptions } from '../utils/user-decryption';
|
||||
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
||||
|
||||
const PASSKEY_MAX = 5;
|
||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
|
||||
function rpIdFromUrl(url: string): string {
|
||||
return new URL(url).hostname;
|
||||
}
|
||||
|
||||
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response {
|
||||
return jsonResponse(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: message,
|
||||
TwoFactorProviders: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)],
|
||||
TwoFactorProviders2: { '0': null },
|
||||
ErrorModel: {
|
||||
Message: message,
|
||||
Object: 'error',
|
||||
},
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleListPasskeys(_request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const records = await storage.listPasskeysByUserId(userId);
|
||||
return jsonResponse({
|
||||
object: 'list',
|
||||
data: records.map((record) => ({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
credentialId: record.credentialId,
|
||||
creationDate: record.createdAt,
|
||||
revisionDate: record.updatedAt,
|
||||
lastUsedDate: record.lastUsedAt,
|
||||
object: 'passkeyCredential',
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleBeginPasskeyRegistration(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const passkeys = await storage.listPasskeysByUserId(userId);
|
||||
if (passkeys.length >= PASSKEY_MAX) return errorResponse('Maximum 5 passkeys are allowed', 400);
|
||||
|
||||
const challenge = randomChallenge();
|
||||
const challengeId = generateUUID();
|
||||
await storage.createPasskeyChallenge(challengeId, userId, challenge, 'register', Date.now() + CHALLENGE_TTL_MS);
|
||||
|
||||
return jsonResponse({
|
||||
challengeId,
|
||||
publicKey: {
|
||||
challenge,
|
||||
rp: {
|
||||
id: rpIdFromUrl(request.url),
|
||||
name: 'NodeWarden',
|
||||
},
|
||||
user: {
|
||||
id: userId,
|
||||
name: userId,
|
||||
displayName: userId,
|
||||
},
|
||||
pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
|
||||
authenticatorSelection: {
|
||||
residentKey: 'required',
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
timeout: 60000,
|
||||
attestation: 'none',
|
||||
excludeCredentials: passkeys.map((pk) => ({ type: 'public-key', id: pk.credentialId })),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleFinishPasskeyRegistration(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const body = (await request.json()) as {
|
||||
challengeId?: string;
|
||||
name?: string;
|
||||
wrappedVaultKeys?: string;
|
||||
credential?: {
|
||||
id?: string;
|
||||
response?: {
|
||||
clientDataJSON?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
const challengeId = String(body.challengeId || '').trim();
|
||||
const name = String(body.name || '').trim();
|
||||
const wrappedVaultKeys = String(body.wrappedVaultKeys || '').trim();
|
||||
const credentialId = String(body.credential?.id || '').trim();
|
||||
const clientData = String(body.credential?.response?.clientDataJSON || '').trim();
|
||||
|
||||
if (!challengeId || !name || !wrappedVaultKeys || !credentialId || !clientData) {
|
||||
return errorResponse('Invalid request payload', 400);
|
||||
}
|
||||
const challengeRecord = await storage.consumePasskeyChallenge(challengeId, 'register');
|
||||
if (!challengeRecord || challengeRecord.userId !== userId) return errorResponse('Challenge expired', 400);
|
||||
|
||||
const parsedClientData = parseClientDataJSON(clientData);
|
||||
const origin = new URL(request.url).origin;
|
||||
if (!parsedClientData || parsedClientData.type !== 'webauthn.create' || parsedClientData.challenge !== challengeRecord.challenge || parsedClientData.origin !== origin) {
|
||||
return errorResponse('Passkey attestation invalid', 400);
|
||||
}
|
||||
|
||||
const existing = await storage.getPasskeyByCredentialId(credentialId);
|
||||
if (existing) return errorResponse('Passkey already registered', 409);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const record: PasskeyCredential = {
|
||||
id: generateUUID(),
|
||||
userId,
|
||||
credentialId,
|
||||
publicKey: 'client-asserted',
|
||||
counter: 0,
|
||||
transports: null,
|
||||
name: name.slice(0, 100),
|
||||
wrappedVaultKeys,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastUsedAt: null,
|
||||
};
|
||||
await storage.createPasskey(record);
|
||||
return jsonResponse({ success: true, id: record.id, object: 'passkeyCredential' });
|
||||
}
|
||||
|
||||
export async function handleRenamePasskey(request: Request, env: Env, userId: string, passkeyId: string): Promise<Response> {
|
||||
const body = (await request.json()) as { name?: string };
|
||||
const name = String(body.name || '').trim();
|
||||
if (!name) return errorResponse('Name is required', 400);
|
||||
const storage = new StorageService(env.DB);
|
||||
const ok = await storage.updatePasskeyName(userId, passkeyId, name.slice(0, 100));
|
||||
if (!ok) return errorResponse('Passkey not found', 404);
|
||||
return jsonResponse({ success: true });
|
||||
}
|
||||
|
||||
export async function handleDeletePasskey(_request: Request, env: Env, userId: string, passkeyId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const ok = await storage.deletePasskey(userId, passkeyId);
|
||||
if (!ok) return errorResponse('Passkey not found', 404);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
export async function handleBeginPasskeyLogin(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const body = (await request.json().catch(() => ({}))) as { email?: string };
|
||||
const email = String(body.email || '').trim().toLowerCase();
|
||||
const user = email ? await storage.getUser(email) : null;
|
||||
const passkeys = user ? await storage.listPasskeysByUserId(user.id) : [];
|
||||
|
||||
const challenge = randomChallenge();
|
||||
const challengeId = generateUUID();
|
||||
await storage.createPasskeyChallenge(challengeId, user?.id || null, challenge, 'login', Date.now() + CHALLENGE_TTL_MS);
|
||||
|
||||
return jsonResponse({
|
||||
challengeId,
|
||||
publicKey: {
|
||||
challenge,
|
||||
rpId: rpIdFromUrl(request.url),
|
||||
timeout: 60000,
|
||||
userVerification: 'preferred',
|
||||
allowCredentials: passkeys.map((pk) => ({ type: 'public-key', id: pk.credentialId })),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleFinishPasskeyLogin(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const body = (await request.json()) as {
|
||||
challengeId?: string;
|
||||
twoFactorToken?: string;
|
||||
credential?: {
|
||||
id?: string;
|
||||
response?: {
|
||||
clientDataJSON?: string;
|
||||
};
|
||||
};
|
||||
deviceIdentifier?: string;
|
||||
deviceName?: string;
|
||||
deviceType?: string;
|
||||
};
|
||||
const challengeId = String(body.challengeId || '').trim();
|
||||
const credentialId = String(body.credential?.id || '').trim();
|
||||
const clientData = String(body.credential?.response?.clientDataJSON || '').trim();
|
||||
if (!challengeId || !credentialId || !clientData) return identityErrorResponse('Invalid request payload', 'invalid_request', 400);
|
||||
|
||||
const challengeRecord = await storage.consumePasskeyChallenge(challengeId, 'login');
|
||||
if (!challengeRecord) return identityErrorResponse('Passkey challenge expired', 'invalid_grant', 400);
|
||||
|
||||
const parsedClientData = parseClientDataJSON(clientData);
|
||||
const origin = new URL(request.url).origin;
|
||||
if (!parsedClientData || parsedClientData.type !== 'webauthn.get' || parsedClientData.challenge !== challengeRecord.challenge || parsedClientData.origin !== origin) {
|
||||
return identityErrorResponse('Passkey assertion invalid', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
const credential = await storage.getPasskeyByCredentialId(credentialId);
|
||||
if (!credential) return identityErrorResponse('Passkey not recognized', 'invalid_grant', 400);
|
||||
const user = await storage.getUserById(credential.userId);
|
||||
if (!user || user.status !== 'active') return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
||||
|
||||
if (user.totpSecret && isTotpEnabled(user.totpSecret)) {
|
||||
const token = String(body.twoFactorToken || '').trim();
|
||||
if (!token) return twoFactorRequiredResponse();
|
||||
const totpOk = await verifyTotpToken(user.totpSecret, token);
|
||||
if (!totpOk) return identityErrorResponse('Two-step token is invalid. Try again.', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
const deviceInfo = readAuthRequestDeviceInfo(body as Record<string, string>, request);
|
||||
const deviceSession = deviceInfo.deviceIdentifier ? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() } : null;
|
||||
if (deviceSession) {
|
||||
await storage.upsertDevice(user.id, deviceSession.identifier, deviceInfo.deviceName, deviceInfo.deviceType, deviceSession.sessionStamp);
|
||||
}
|
||||
|
||||
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||
await storage.touchPasskeyUsage(credential.id);
|
||||
|
||||
let vaultKeys: { symEncKey: string; symMacKey: string } | undefined;
|
||||
try {
|
||||
const wrapped = JSON.parse(credential.wrappedVaultKeys) as { symEncKey?: string; symMacKey?: string };
|
||||
if (wrapped.symEncKey && wrapped.symMacKey) {
|
||||
vaultKeys = { symEncKey: wrapped.symEncKey, symMacKey: wrapped.symMacKey };
|
||||
}
|
||||
} catch {
|
||||
vaultKeys = undefined;
|
||||
}
|
||||
|
||||
const response: TokenResponse = {
|
||||
access_token: accessToken,
|
||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||
token_type: 'Bearer',
|
||||
refresh_token: refreshToken,
|
||||
Key: user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
AccountKeys: buildAccountKeys(user),
|
||||
accountKeys: buildAccountKeys(user),
|
||||
Kdf: user.kdfType,
|
||||
KdfIterations: user.kdfIterations,
|
||||
KdfMemory: user.kdfMemory,
|
||||
KdfParallelism: user.kdfParallelism,
|
||||
ForcePasswordReset: false,
|
||||
ResetMasterPassword: false,
|
||||
MasterPasswordPolicy: { Object: 'masterPasswordPolicy' },
|
||||
ApiUseKeyConnector: false,
|
||||
scope: 'api offline_access',
|
||||
unofficialServer: true,
|
||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
userDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
VaultKeys: vaultKeys,
|
||||
};
|
||||
|
||||
return jsonResponse(response);
|
||||
}
|
||||
@@ -62,6 +62,13 @@ import {
|
||||
} from './handlers/attachments';
|
||||
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
||||
import { handleAdminRoute } from './router-admin';
|
||||
import {
|
||||
handleBeginPasskeyRegistration,
|
||||
handleDeletePasskey,
|
||||
handleFinishPasskeyRegistration,
|
||||
handleListPasskeys,
|
||||
handleRenamePasskey,
|
||||
} from './handlers/passkeys';
|
||||
|
||||
export async function handleAuthenticatedRoute(
|
||||
request: Request,
|
||||
@@ -107,6 +114,24 @@ export async function handleAuthenticatedRoute(
|
||||
return handleGetTotpRecoveryCode(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/passkeys' && method === 'GET') {
|
||||
return handleListPasskeys(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/passkeys/begin-registration' && method === 'POST') {
|
||||
return handleBeginPasskeyRegistration(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/passkeys/finish-registration' && method === 'POST') {
|
||||
return handleFinishPasskeyRegistration(request, env, userId);
|
||||
}
|
||||
|
||||
const passkeyMatch = path.match(/^\/api\/accounts\/passkeys\/([a-f0-9-]+)$/i);
|
||||
if (passkeyMatch) {
|
||||
if (method === 'PATCH' || method === 'PUT') return handleRenamePasskey(request, env, userId, passkeyMatch[1]);
|
||||
if (method === 'DELETE') return handleDeletePasskey(request, env, userId, passkeyMatch[1]);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/revision-date' && method === 'GET') {
|
||||
return handleGetRevisionDate(request, env, userId);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from './handlers/sends';
|
||||
import { handleKnownDevice } from './handlers/devices';
|
||||
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
|
||||
import { handleBeginPasskeyLogin, handleFinishPasskeyLogin } from './handlers/passkeys';
|
||||
import {
|
||||
handleRegister,
|
||||
handleGetPasswordHint,
|
||||
@@ -274,6 +275,14 @@ export async function handlePublicRoute(
|
||||
return handleToken(request, env);
|
||||
}
|
||||
|
||||
if (path === '/identity/passkeys/begin-login' && method === 'POST') {
|
||||
return handleBeginPasskeyLogin(request, env);
|
||||
}
|
||||
|
||||
if (path === '/identity/passkeys/finish-login' && method === 'POST') {
|
||||
return handleFinishPasskeyLogin(request, env);
|
||||
}
|
||||
|
||||
if (path === '/api/devices/knowndevice' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return jsonResponse(false);
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -367,6 +367,24 @@ export interface TokenResponse {
|
||||
accountKeys?: any | null;
|
||||
UserDecryptionOptions: UserDecryptionOptions;
|
||||
userDecryptionOptions?: UserDecryptionOptions;
|
||||
VaultKeys?: {
|
||||
symEncKey: string;
|
||||
symMacKey: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PasskeyCredential {
|
||||
id: string;
|
||||
userId: string;
|
||||
credentialId: string;
|
||||
publicKey: string;
|
||||
counter: number;
|
||||
transports: string | null;
|
||||
name: string;
|
||||
wrappedVaultKeys: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ProfileResponse {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export function bytesToBase64Url(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (const b of bytes) binary += String.fromCharCode(b);
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
export function base64UrlToBytes(input: string): Uint8Array {
|
||||
const normalized = String(input || '').replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
|
||||
const binary = atob(padded);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function randomChallenge(size: number = 32): string {
|
||||
return bytesToBase64Url(crypto.getRandomValues(new Uint8Array(size)));
|
||||
}
|
||||
|
||||
export function parseClientDataJSON(base64Url: string): { type?: string; challenge?: string; origin?: string } | null {
|
||||
try {
|
||||
const raw = base64UrlToBytes(base64Url);
|
||||
const text = new TextDecoder().decode(raw);
|
||||
const parsed = JSON.parse(text) as { type?: string; challenge?: string; origin?: string };
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+77
-3
@@ -12,6 +12,10 @@ import {
|
||||
getAuthorizedDevices,
|
||||
getCurrentDeviceIdentifier,
|
||||
getPasswordHint,
|
||||
listAccountPasskeys,
|
||||
registerAccountPasskey,
|
||||
renameAccountPasskey,
|
||||
deleteAccountPasskey,
|
||||
getTotpStatus,
|
||||
saveSession,
|
||||
} from '@/lib/api/auth';
|
||||
@@ -36,6 +40,7 @@ import {
|
||||
type CompletedLogin,
|
||||
readInitialAppBootstrapState,
|
||||
performPasswordLogin,
|
||||
performPasskeyLogin,
|
||||
performRecoverTwoFactorLogin,
|
||||
performRegistration,
|
||||
performTotpLogin,
|
||||
@@ -43,6 +48,7 @@ import {
|
||||
type JwtUnsafeReason,
|
||||
type PendingTotp,
|
||||
} from '@/lib/app-auth';
|
||||
import { passkeySupported } from '@/lib/passkey';
|
||||
import useAccountSecurityActions from '@/hooks/useAccountSecurityActions';
|
||||
import useAdminActions from '@/hooks/useAdminActions';
|
||||
import useBackupActions from '@/hooks/useBackupActions';
|
||||
@@ -153,6 +159,7 @@ export default function App() {
|
||||
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
|
||||
const [unlockPassword, setUnlockPassword] = useState('');
|
||||
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
||||
const [pendingPasskeyTotp, setPendingPasskeyTotp] = useState(false);
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [rememberDevice, setRememberDevice] = useState(true);
|
||||
|
||||
@@ -334,6 +341,7 @@ export default function App() {
|
||||
setSession(login.session);
|
||||
setProfile(login.profile);
|
||||
setPendingTotp(null);
|
||||
setPendingPasskeyTotp(false);
|
||||
setTotpCode('');
|
||||
setPhase('app');
|
||||
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
|
||||
@@ -379,19 +387,53 @@ export default function App() {
|
||||
}
|
||||
|
||||
async function handleTotpVerify() {
|
||||
if (!pendingTotp) return;
|
||||
if (!pendingTotp && !pendingPasskeyTotp) return;
|
||||
if (!totpCode.trim()) {
|
||||
pushToast('error', t('txt_please_input_totp_code'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
||||
const login = pendingTotp
|
||||
? await performTotpLogin(pendingTotp, totpCode, rememberDevice)
|
||||
: (await (async () => {
|
||||
const passkeyResult = await performPasskeyLogin(loginValues.email, totpCode);
|
||||
if (passkeyResult.kind !== 'success') throw new Error(t('txt_totp_verify_failed'));
|
||||
return passkeyResult.login;
|
||||
})());
|
||||
await finalizeLogin(login);
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasskeyLogin() {
|
||||
if (pendingAuthAction) return;
|
||||
if (!passkeySupported()) {
|
||||
pushToast('error', '当前浏览器不支持 Passkey');
|
||||
return;
|
||||
}
|
||||
setPendingAuthAction('login');
|
||||
try {
|
||||
const result = await performPasskeyLogin(loginValues.email);
|
||||
if (result.kind === 'success') {
|
||||
await finalizeLogin(result.login);
|
||||
return;
|
||||
}
|
||||
if (result.kind === 'totp') {
|
||||
setPendingPasskeyTotp(true);
|
||||
setPendingTotp(null);
|
||||
setTotpCode('');
|
||||
setRememberDevice(false);
|
||||
return;
|
||||
}
|
||||
pushToast('error', result.message || t('txt_login_failed'));
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : t('txt_login_failed'));
|
||||
} finally {
|
||||
setPendingAuthAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRecoverTwoFactorSubmit() {
|
||||
const email = recoverValues.email.trim().toLowerCase();
|
||||
const password = recoverValues.password;
|
||||
@@ -527,6 +569,24 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreatePasskey(name: string) {
|
||||
if (!session?.symEncKey || !session?.symMacKey) throw new Error('请先解锁后再创建 Passkey');
|
||||
await registerAccountPasskey(authedFetch, name, session);
|
||||
await passkeysQuery.refetch();
|
||||
pushToast('success', 'Passkey 已创建');
|
||||
}
|
||||
|
||||
async function handleRenamePasskey(id: string, name: string) {
|
||||
await renameAccountPasskey(authedFetch, id, name);
|
||||
await passkeysQuery.refetch();
|
||||
}
|
||||
|
||||
async function handleDeletePasskey(id: string) {
|
||||
await deleteAccountPasskey(authedFetch, id);
|
||||
await passkeysQuery.refetch();
|
||||
pushToast('success', 'Passkey 已删除');
|
||||
}
|
||||
|
||||
function handleLock() {
|
||||
if (!session) return;
|
||||
const nextSession = { ...session };
|
||||
@@ -542,6 +602,7 @@ export default function App() {
|
||||
setSession(null);
|
||||
setProfile(null);
|
||||
setPendingTotp(null);
|
||||
setPendingPasskeyTotp(false);
|
||||
setPhase('login');
|
||||
navigate('/login');
|
||||
}
|
||||
@@ -616,6 +677,11 @@ export default function App() {
|
||||
queryFn: () => getAuthorizedDevices(authedFetch),
|
||||
enabled: phase === 'app' && !!session?.accessToken,
|
||||
});
|
||||
const passkeysQuery = useQuery({
|
||||
queryKey: ['account-passkeys', session?.accessToken],
|
||||
queryFn: () => listAccountPasskeys(authedFetch),
|
||||
enabled: phase === 'app' && !!session?.accessToken,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return;
|
||||
@@ -1139,6 +1205,10 @@ export default function App() {
|
||||
},
|
||||
onOpenDisableTotp: () => setDisableTotpOpen(true),
|
||||
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
||||
passkeys: passkeysQuery.data || [],
|
||||
onCreatePasskey: handleCreatePasskey,
|
||||
onRenamePasskey: handleRenamePasskey,
|
||||
onDeletePasskey: handleDeletePasskey,
|
||||
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
||||
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
||||
onRemoveDevice: accountSecurityActions.openRemoveDevice,
|
||||
@@ -1210,6 +1280,7 @@ export default function App() {
|
||||
onChangeRegister={setRegisterValues}
|
||||
onChangeUnlock={setUnlockPassword}
|
||||
onSubmitLogin={() => void handleLogin()}
|
||||
onSubmitPasskey={() => void handlePasskeyLogin()}
|
||||
onSubmitRegister={() => void handleRegister()}
|
||||
onSubmitUnlock={() => void handleUnlock()}
|
||||
onGotoLogin={() => {
|
||||
@@ -1226,13 +1297,14 @@ export default function App() {
|
||||
onLogout={logoutNow}
|
||||
onTogglePasswordHint={() => void handleTogglePasswordHint()}
|
||||
onShowLockedPasswordHint={handleShowLockedPasswordHint}
|
||||
passkeySupported={passkeySupported()}
|
||||
/>
|
||||
<AppGlobalOverlays
|
||||
toasts={toasts}
|
||||
onCloseToast={removeToast}
|
||||
confirm={confirm}
|
||||
onCancelConfirm={() => setConfirm(null)}
|
||||
pendingTotpOpen={!!pendingTotp}
|
||||
pendingTotpOpen={!!pendingTotp || pendingPasskeyTotp}
|
||||
totpCode={totpCode}
|
||||
rememberDevice={rememberDevice}
|
||||
onTotpCodeChange={setTotpCode}
|
||||
@@ -1240,11 +1312,13 @@ export default function App() {
|
||||
onConfirmTotp={() => void handleTotpVerify()}
|
||||
onCancelTotp={() => {
|
||||
setPendingTotp(null);
|
||||
setPendingPasskeyTotp(false);
|
||||
setTotpCode('');
|
||||
setRememberDevice(true);
|
||||
}}
|
||||
onUseRecoveryCode={() => {
|
||||
setPendingTotp(null);
|
||||
setPendingPasskeyTotp(false);
|
||||
setTotpCode('');
|
||||
setRememberDevice(true);
|
||||
navigate('/recover-2fa');
|
||||
|
||||
@@ -94,6 +94,10 @@ export interface AppMainRoutesProps {
|
||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||
onOpenDisableTotp: () => void;
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
passkeys: Array<{ id: string; name: string; creationDate: string; lastUsedDate: string | null }>;
|
||||
onCreatePasskey: (name: string) => Promise<void>;
|
||||
onRenamePasskey: (id: string, name: string) => Promise<void>;
|
||||
onDeletePasskey: (id: string) => Promise<void>;
|
||||
onRefreshAuthorizedDevices: () => Promise<void>;
|
||||
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||
@@ -225,6 +229,10 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
onOpenDisableTotp={props.onOpenDisableTotp}
|
||||
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||
onNotify={props.onNotify}
|
||||
passkeys={props.passkeys}
|
||||
onCreatePasskey={props.onCreatePasskey}
|
||||
onRenamePasskey={props.onRenamePasskey}
|
||||
onDeletePasskey={props.onDeletePasskey}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { ArrowLeft, Eye, EyeOff, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
||||
import { ArrowLeft, Eye, EyeOff, Fingerprint, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
@@ -30,6 +30,7 @@ interface AuthViewsProps {
|
||||
onChangeRegister: (next: RegisterValues) => void;
|
||||
onChangeUnlock: (password: string) => void;
|
||||
onSubmitLogin: () => void;
|
||||
onSubmitPasskey: () => void;
|
||||
onSubmitRegister: () => void;
|
||||
onSubmitUnlock: () => void;
|
||||
onGotoLogin: () => void;
|
||||
@@ -37,6 +38,7 @@ interface AuthViewsProps {
|
||||
onLogout: () => void;
|
||||
onTogglePasswordHint: () => void;
|
||||
onShowLockedPasswordHint: () => void;
|
||||
passkeySupported: boolean;
|
||||
}
|
||||
|
||||
function PasswordField(props: {
|
||||
@@ -106,6 +108,12 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
<Unlock size={16} className="btn-icon" />
|
||||
{unlockBusy ? t('txt_unlocking') : t('txt_unlock')}
|
||||
</button>
|
||||
{props.passkeySupported && (
|
||||
<button type="button" className="btn btn-secondary full" onClick={props.onSubmitPasskey} disabled={unlockBusy}>
|
||||
<Fingerprint size={16} className="btn-icon" />
|
||||
Passkey 解锁
|
||||
</button>
|
||||
)}
|
||||
<div className="or">{t('txt_or')}</div>
|
||||
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
|
||||
<LogOut size={16} className="btn-icon" />
|
||||
@@ -243,6 +251,12 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
<LogIn size={16} className="btn-icon" />
|
||||
{loginBusy ? t('txt_logging_in') : t('txt_log_in')}
|
||||
</button>
|
||||
{props.passkeySupported && (
|
||||
<button type="button" className="btn btn-secondary full" onClick={props.onSubmitPasskey} disabled={loginBusy}>
|
||||
<Fingerprint size={16} className="btn-icon" />
|
||||
Passkey 登录
|
||||
</button>
|
||||
)}
|
||||
<div className="or">{t('txt_or')}</div>
|
||||
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister} disabled={loginBusy}>
|
||||
<UserPlus size={16} className="btn-icon" />
|
||||
|
||||
@@ -14,6 +14,10 @@ interface SettingsPageProps {
|
||||
onOpenDisableTotp: () => void;
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
onNotify?: (type: 'success' | 'error', text: string) => void;
|
||||
passkeys: Array<{ id: string; name: string; creationDate: string; lastUsedDate: string | null }>;
|
||||
onCreatePasskey: (name: string) => Promise<void>;
|
||||
onRenamePasskey: (id: string, name: string) => Promise<void>;
|
||||
onDeletePasskey: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function randomBase32Secret(length: number): string {
|
||||
@@ -47,6 +51,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
||||
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
|
||||
const [recoveryCode, setRecoveryCode] = useState('');
|
||||
const [passkeyName, setPasskeyName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.totpEnabled) {
|
||||
@@ -140,6 +145,33 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h3>Passkey</h3>
|
||||
<div className="field-grid">
|
||||
<label className="field">
|
||||
<span>名称</span>
|
||||
<input className="input" value={passkeyName} onInput={(e) => setPasskeyName((e.currentTarget as HTMLInputElement).value)} placeholder="例如:MacBook Touch ID" />
|
||||
</label>
|
||||
<div className="field" style={{ alignSelf: 'end' }}>
|
||||
<button type="button" className="btn btn-primary" disabled={!passkeyName.trim()} onClick={() => void props.onCreatePasskey(passkeyName.trim()).then(() => setPasskeyName(''))}>
|
||||
创建 Passkey
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="muted-inline" style={{ marginBottom: 8 }}>最多 5 个,支持重命名和删除。</p>
|
||||
<div className="stack">
|
||||
{props.passkeys.map((item) => (
|
||||
<div key={item.id} className="card" style={{ marginBottom: 0 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input className="input" style={{ flex: 1, minWidth: 180 }} value={item.name} onInput={(e) => void props.onRenamePasskey(item.id, (e.currentTarget as HTMLInputElement).value)} />
|
||||
<button type="button" className="btn btn-danger" onClick={() => void props.onDeletePasskey(item.id)}>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!props.passkeys.length && <div className="empty">暂无 Passkey</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<div className="settings-twofactor-grid">
|
||||
<div className="settings-subcard">
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
TokenSuccess,
|
||||
} from '../types';
|
||||
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
||||
import { createPasskeyCredential, requestPasskeyAssertion } from '../passkey';
|
||||
|
||||
const SESSION_KEY = 'nodewarden.web.session.v4';
|
||||
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
|
||||
@@ -26,6 +27,14 @@ export interface PreloginKdfConfig {
|
||||
kdfParallelism: number | null;
|
||||
}
|
||||
|
||||
export interface AccountPasskey {
|
||||
id: string;
|
||||
name: string;
|
||||
creationDate: string;
|
||||
revisionDate: string;
|
||||
lastUsedDate: string | null;
|
||||
}
|
||||
|
||||
function randomHex(length: number): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
||||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
||||
@@ -197,6 +206,84 @@ export async function refreshAccessToken(refreshToken: string): Promise<TokenSuc
|
||||
return json || null;
|
||||
}
|
||||
|
||||
export async function listAccountPasskeys(authedFetch: AuthedFetch): Promise<AccountPasskey[]> {
|
||||
const resp = await authedFetch('/api/accounts/passkeys');
|
||||
if (!resp.ok) throw new Error('Failed to load passkeys');
|
||||
const body = (await parseJson<{ data?: AccountPasskey[] }>(resp)) || {};
|
||||
return Array.isArray(body.data) ? body.data : [];
|
||||
}
|
||||
|
||||
export async function registerAccountPasskey(authedFetch: AuthedFetch, name: string, session: SessionState): Promise<void> {
|
||||
const beginResp = await authedFetch('/api/accounts/passkeys/begin-registration', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
if (!beginResp.ok) throw new Error('Failed to start passkey registration');
|
||||
const begin = (await parseJson<{ challengeId: string; publicKey: Record<string, any> }>(beginResp)) || {};
|
||||
if (!begin.challengeId || !begin.publicKey) throw new Error('Invalid registration challenge');
|
||||
|
||||
const credential = await createPasskeyCredential(begin.publicKey);
|
||||
const finishResp = await authedFetch('/api/accounts/passkeys/finish-registration', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
challengeId: begin.challengeId,
|
||||
name,
|
||||
wrappedVaultKeys: JSON.stringify({
|
||||
symEncKey: session.symEncKey || '',
|
||||
symMacKey: session.symMacKey || '',
|
||||
}),
|
||||
credential,
|
||||
}),
|
||||
});
|
||||
if (!finishResp.ok) {
|
||||
const err = await parseJson<TokenError>(finishResp);
|
||||
throw new Error(err?.error_description || err?.error || 'Failed to finish passkey registration');
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameAccountPasskey(authedFetch: AuthedFetch, passkeyId: string, name: string): Promise<void> {
|
||||
const resp = await authedFetch(`/api/accounts/passkeys/${passkeyId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Failed to rename passkey');
|
||||
}
|
||||
|
||||
export async function deleteAccountPasskey(authedFetch: AuthedFetch, passkeyId: string): Promise<void> {
|
||||
const resp = await authedFetch(`/api/accounts/passkeys/${passkeyId}`, { method: 'DELETE' });
|
||||
if (!resp.ok && resp.status !== 204) throw new Error('Failed to delete passkey');
|
||||
}
|
||||
|
||||
export async function loginWithPasskey(email?: string, totpCode?: string): Promise<TokenSuccess | TokenError> {
|
||||
const beginResp = await fetch('/identity/passkeys/begin-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: String(email || '').trim().toLowerCase() || undefined }),
|
||||
});
|
||||
if (!beginResp.ok) return ((await parseJson<TokenError>(beginResp)) || {});
|
||||
const begin = (await parseJson<{ challengeId: string; publicKey: Record<string, any> }>(beginResp)) || {};
|
||||
if (!begin.challengeId || !begin.publicKey) return { error: 'Passkey challenge missing' };
|
||||
|
||||
const credential = await requestPasskeyAssertion(begin.publicKey);
|
||||
const finishResp = await fetch('/identity/passkeys/finish-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
challengeId: begin.challengeId,
|
||||
credential,
|
||||
deviceIdentifier: getOrCreateDeviceIdentifier(),
|
||||
deviceName: guessDeviceName(),
|
||||
deviceType: '14',
|
||||
twoFactorToken: totpCode || undefined,
|
||||
}),
|
||||
});
|
||||
const result = (await parseJson<TokenSuccess & TokenError>(finishResp)) || {};
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function registerAccount(args: {
|
||||
email: string;
|
||||
name: string;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getProfile,
|
||||
loadSession,
|
||||
loginWithPassword,
|
||||
loginWithPasskey,
|
||||
refreshAccessToken,
|
||||
recoverTwoFactor,
|
||||
registerAccount,
|
||||
@@ -46,6 +47,11 @@ export type PasswordLoginResult =
|
||||
| { kind: 'totp'; pendingTotp: PendingTotp }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
export type PasskeyLoginResult =
|
||||
| { kind: 'success'; login: CompletedLogin }
|
||||
| { kind: 'totp' }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
export interface RecoverTwoFactorResult {
|
||||
login: CompletedLogin | null;
|
||||
newRecoveryCode: string | null;
|
||||
@@ -359,3 +365,30 @@ export async function performUnlock(
|
||||
}
|
||||
return { ...refreshedSession, ...keys };
|
||||
}
|
||||
|
||||
export async function performPasskeyLogin(email: string, totpCode?: string): Promise<PasskeyLoginResult> {
|
||||
const token = await loginWithPasskey(email, totpCode);
|
||||
if ('access_token' in token && token.access_token) {
|
||||
const normalizedEmail = String(email || '').trim().toLowerCase();
|
||||
const baseSession: SessionState = {
|
||||
accessToken: token.access_token,
|
||||
refreshToken: token.refresh_token,
|
||||
email: normalizedEmail,
|
||||
symEncKey: token.VaultKeys?.symEncKey,
|
||||
symMacKey: token.VaultKeys?.symMacKey,
|
||||
};
|
||||
const tempFetch = createAuthedFetch(() => baseSession, () => {});
|
||||
const profile = buildTransientProfile(token, normalizedEmail);
|
||||
return {
|
||||
kind: 'success',
|
||||
login: {
|
||||
session: baseSession,
|
||||
profile,
|
||||
profilePromise: getProfile(tempFetch),
|
||||
},
|
||||
};
|
||||
}
|
||||
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
if (tokenError.TwoFactorProviders) return { kind: 'totp' };
|
||||
return { kind: 'error', message: tokenError.error_description || tokenError.error || 'Passkey login failed' };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
function base64UrlToBytes(input: string): Uint8Array {
|
||||
const normalized = String(input || '').replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
|
||||
const binary = atob(padded);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function bytesToBase64Url(bytes: ArrayBuffer | Uint8Array): string {
|
||||
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
let binary = '';
|
||||
for (const b of view) binary += String.fromCharCode(b);
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
export function passkeySupported(): boolean {
|
||||
return typeof window !== 'undefined' && !!window.PublicKeyCredential;
|
||||
}
|
||||
|
||||
export async function createPasskeyCredential(publicKey: Record<string, any>): Promise<any> {
|
||||
const options: PublicKeyCredentialCreationOptions = {
|
||||
...(publicKey as PublicKeyCredentialCreationOptions),
|
||||
challenge: base64UrlToBytes(publicKey.challenge),
|
||||
user: {
|
||||
...publicKey.user,
|
||||
id: base64UrlToBytes(publicKey.user.id),
|
||||
},
|
||||
excludeCredentials: Array.isArray(publicKey.excludeCredentials)
|
||||
? publicKey.excludeCredentials.map((item: any) => ({ ...item, id: base64UrlToBytes(item.id) }))
|
||||
: [],
|
||||
};
|
||||
|
||||
const credential = (await navigator.credentials.create({ publicKey: options })) as PublicKeyCredential | null;
|
||||
if (!credential) throw new Error('Passkey creation was cancelled');
|
||||
const response = credential.response as AuthenticatorAttestationResponse;
|
||||
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bytesToBase64Url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: bytesToBase64Url(response.clientDataJSON),
|
||||
attestationObject: bytesToBase64Url(response.attestationObject),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestPasskeyAssertion(publicKey: Record<string, any>): Promise<any> {
|
||||
const options: PublicKeyCredentialRequestOptions = {
|
||||
...(publicKey as PublicKeyCredentialRequestOptions),
|
||||
challenge: base64UrlToBytes(publicKey.challenge),
|
||||
allowCredentials: Array.isArray(publicKey.allowCredentials)
|
||||
? publicKey.allowCredentials.map((item: any) => ({ ...item, id: base64UrlToBytes(item.id) }))
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const credential = (await navigator.credentials.get({ publicKey: options })) as PublicKeyCredential | null;
|
||||
if (!credential) throw new Error('Passkey login was cancelled');
|
||||
const response = credential.response as AuthenticatorAssertionResponse;
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bytesToBase64Url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: bytesToBase64Url(response.clientDataJSON),
|
||||
authenticatorData: bytesToBase64Url(response.authenticatorData),
|
||||
signature: bytesToBase64Url(response.signature),
|
||||
userHandle: response.userHandle ? bytesToBase64Url(response.userHandle) : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -290,6 +290,10 @@ export interface TokenSuccess {
|
||||
unofficialServer?: boolean;
|
||||
UserDecryptionOptions?: unknown;
|
||||
userDecryptionOptions?: unknown;
|
||||
VaultKeys?: {
|
||||
symEncKey?: string;
|
||||
symMacKey?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TokenError {
|
||||
|
||||
Reference in New Issue
Block a user