feat: add token revocation endpoint and enhance ciphers import request structure

This commit is contained in:
shuaiplus
2026-02-20 18:16:07 +08:00
parent 76d766d5d6
commit aaf5078c8a
5 changed files with 98 additions and 92 deletions
+33 -4
View File
@@ -18,9 +18,7 @@ function twoFactorRequiredResponse(message: string = 'Two factor required.'): Re
error_description: message, error_description: message,
TwoFactorProviders: [0], TwoFactorProviders: [0],
TwoFactorProviders2: { TwoFactorProviders2: {
'0': { '0': null,
Priority: 1,
},
}, },
ErrorModel: { ErrorModel: {
Message: message, Message: message,
@@ -115,6 +113,10 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Optional 2FA: enabled only when TOTP_SECRET is configured in Workers env. // Optional 2FA: enabled only when TOTP_SECRET is configured in Workers env.
let trustedTwoFactorTokenToReturn: string | undefined; let trustedTwoFactorTokenToReturn: string | undefined;
if (isTotpEnabled(env.TOTP_SECRET)) { if (isTotpEnabled(env.TOTP_SECRET)) {
if (twoFactorProvider !== undefined && String(twoFactorProvider) !== '0') {
return identityErrorResponse('Unsupported two-factor provider', 'invalid_grant', 400);
}
const rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim()); const rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
// Bitwarden may reuse twoFactorToken as a remembered-device token on subsequent logins. // Bitwarden may reuse twoFactorToken as a remembered-device token on subsequent logins.
@@ -142,7 +144,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
429 429
); );
} }
return identityErrorResponse('Invalid two-factor token', 'invalid_grant', 400); return twoFactorRequiredResponse();
} }
} }
@@ -287,3 +289,30 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
kdfParallelism: kdfParallelism, kdfParallelism: kdfParallelism,
}); });
} }
// POST /identity/connect/revocation
// Best-effort OAuth token revocation endpoint.
// RFC 7009 allows returning 200 even if token is unknown.
export async function handleRevocation(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
let body: Record<string, string>;
const contentType = request.headers.get('content-type') || '';
try {
if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
body = Object.fromEntries(formData.entries()) as Record<string, string>;
} else {
body = await request.json();
}
} catch {
return new Response(null, { status: 200 });
}
const token = String(body.token || '').trim();
if (token) {
await storage.deleteRefreshToken(token);
}
return new Response(null, { status: 200 });
}
+44 -37
View File
@@ -8,10 +8,12 @@ import { LIMITS } from '../config/limits';
interface CiphersImportRequest { interface CiphersImportRequest {
ciphers: Array<{ ciphers: Array<{
type: number; type: number;
name: string; name?: string | null;
notes?: string | null; notes?: string | null;
favorite?: boolean; favorite?: boolean;
reprompt?: number; reprompt?: number;
sshKey?: any | null;
key?: string | null;
login?: { login?: {
uris?: Array<{ uri: string | null; match?: number | null }> | null; uris?: Array<{ uri: string | null; match?: number | null }> | null;
username?: string | null; username?: string | null;
@@ -62,6 +64,7 @@ interface CiphersImportRequest {
password: string; password: string;
lastUsedDate: string; lastUsedDate: string;
}> | null; }> | null;
[key: string]: any;
}>; }>;
folders: Array<{ folders: Array<{
name: string; name: string;
@@ -153,61 +156,65 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
userId: userId, userId: userId,
type: c.type as CipherType, type: c.type as CipherType,
folderId: folderId, folderId: folderId,
name: c.name || 'Untitled', name: c.name ?? 'Untitled',
notes: c.notes || null, notes: c.notes ?? null,
favorite: c.favorite || false, favorite: c.favorite ?? false,
login: c.login ? { login: c.login ? {
...c.login, ...c.login,
username: c.login.username || null, username: c.login.username ?? null,
password: c.login.password || null, password: c.login.password ?? null,
uris: c.login.uris?.map(u => ({ uris: c.login.uris?.map(u => ({
uri: u.uri || null, ...u,
uri: u.uri ?? null,
uriChecksum: null, uriChecksum: null,
match: u.match ?? null, match: u.match ?? null,
})) || null, })) || null,
totp: c.login.totp || null, totp: c.login.totp ?? null,
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null, autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
fido2Credentials: c.login.fido2Credentials ?? null, fido2Credentials: c.login.fido2Credentials ?? null,
uri: c.login.uri ?? null, uri: c.login.uri ?? null,
passwordRevisionDate: c.login.passwordRevisionDate ?? null, passwordRevisionDate: c.login.passwordRevisionDate ?? null,
} : null, } : null,
card: c.card ? { card: c.card ? {
cardholderName: c.card.cardholderName || null, ...c.card,
brand: c.card.brand || null, cardholderName: c.card.cardholderName ?? null,
number: c.card.number || null, brand: c.card.brand ?? null,
expMonth: c.card.expMonth || null, number: c.card.number ?? null,
expYear: c.card.expYear || null, expMonth: c.card.expMonth ?? null,
code: c.card.code || null, expYear: c.card.expYear ?? null,
code: c.card.code ?? null,
} : null, } : null,
identity: c.identity ? { identity: c.identity ? {
title: c.identity.title || null, ...c.identity,
firstName: c.identity.firstName || null, title: c.identity.title ?? null,
middleName: c.identity.middleName || null, firstName: c.identity.firstName ?? null,
lastName: c.identity.lastName || null, middleName: c.identity.middleName ?? null,
address1: c.identity.address1 || null, lastName: c.identity.lastName ?? null,
address2: c.identity.address2 || null, address1: c.identity.address1 ?? null,
address3: c.identity.address3 || null, address2: c.identity.address2 ?? null,
city: c.identity.city || null, address3: c.identity.address3 ?? null,
state: c.identity.state || null, city: c.identity.city ?? null,
postalCode: c.identity.postalCode || null, state: c.identity.state ?? null,
country: c.identity.country || null, postalCode: c.identity.postalCode ?? null,
company: c.identity.company || null, country: c.identity.country ?? null,
email: c.identity.email || null, company: c.identity.company ?? null,
phone: c.identity.phone || null, email: c.identity.email ?? null,
ssn: c.identity.ssn || null, phone: c.identity.phone ?? null,
username: c.identity.username || null, ssn: c.identity.ssn ?? null,
passportNumber: c.identity.passportNumber || null, username: c.identity.username ?? null,
licenseNumber: c.identity.licenseNumber || null, passportNumber: c.identity.passportNumber ?? null,
licenseNumber: c.identity.licenseNumber ?? null,
} : null, } : null,
secureNote: c.secureNote || null, secureNote: c.secureNote ?? null,
fields: c.fields?.map(f => ({ fields: c.fields?.map(f => ({
name: f.name || null, ...f,
value: f.value || null, name: f.name ?? null,
value: f.value ?? null,
type: f.type, type: f.type,
linkedId: f.linkedId ?? null, linkedId: f.linkedId ?? null,
})) || null, })) || null,
passwordHistory: c.passwordHistory || null, passwordHistory: c.passwordHistory ?? null,
reprompt: c.reprompt || 0, reprompt: c.reprompt ?? 0,
sshKey: (c as any).sshKey ?? null, sshKey: (c as any).sshKey ?? null,
key: (c as any).key ?? null, key: (c as any).key ?? null,
createdAt: now, createdAt: now,
-19
View File
@@ -7,21 +7,6 @@ let dbInitialized = false;
let dbInitError: string | null = null; let dbInitError: string | null = null;
let dbInitPromise: Promise<void> | null = null; let dbInitPromise: Promise<void> | null = null;
function shouldSkipDatabaseInit(request: Request): boolean {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;
if (method === 'OPTIONS') return true;
if (method === 'GET' && (path === '/favicon.ico' || path === '/favicon.svg')) return true;
if (method === 'GET' && path === '/.well-known/appspecific/com.chrome.devtools.json') return true;
if (method === 'GET' && path.startsWith('/icons/')) return true;
if (path.startsWith('/notifications/')) return true;
if (method === 'GET' && (path === '/config' || path === '/api/config' || path === '/api/version')) return true;
return false;
}
async function ensureDatabaseInitialized(env: Env): Promise<void> { async function ensureDatabaseInitialized(env: Env): Promise<void> {
if (dbInitialized) return; if (dbInitialized) return;
@@ -47,9 +32,6 @@ async function ensureDatabaseInitialized(env: Env): Promise<void> {
export default { export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
void ctx; void ctx;
const requiresDatabase = !shouldSkipDatabaseInit(request);
if (requiresDatabase) {
await ensureDatabaseInitialized(env); await ensureDatabaseInitialized(env);
if (dbInitError) { if (dbInitError) {
const resp = jsonResponse( const resp = jsonResponse(
@@ -65,7 +47,6 @@ export default {
); );
return applyCors(request, resp); return applyCors(request, resp);
} }
}
const resp = await handleRequest(request, env); const resp = await handleRequest(request, env);
return applyCors(request, resp); return applyCors(request, resp);
+5 -1
View File
@@ -5,7 +5,7 @@ import { handleCors, errorResponse, jsonResponse } from './utils/response';
import { LIMITS } from './config/limits'; import { LIMITS } from './config/limits';
// Identity handlers // Identity handlers
import { handleToken, handlePrelogin } from './handlers/identity'; import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
// Account handlers // Account handlers
import { handleRegister, handleGetProfile, handleUpdateProfile, handleSetKeys, handleGetRevisionDate, handleVerifyPassword } from './handlers/accounts'; import { handleRegister, handleGetProfile, handleUpdateProfile, handleSetKeys, handleGetRevisionDate, handleVerifyPassword } from './handlers/accounts';
@@ -229,6 +229,10 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return handleToken(request, env); return handleToken(request, env);
} }
if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') {
return handleRevocation(request, env);
}
if (path === '/identity/accounts/prelogin' && method === 'POST') { if (path === '/identity/accounts/prelogin' && method === 'POST') {
return handlePrelogin(request, env); return handlePrelogin(request, env);
} }
+1 -16
View File
@@ -2,7 +2,6 @@ import { User, Cipher, Folder, Attachment, Device } from '../types';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const SCHEMA_HASH_CONFIG_KEY = 'schema_hash';
// IMPORTANT: // IMPORTANT:
// Keep this schema list in sync with migrations/0001_init.sql. // Keep this schema list in sync with migrations/0001_init.sql.
@@ -123,31 +122,17 @@ export class StorageService {
// --- Database initialization --- // --- Database initialization ---
// Strategy: // Strategy:
// - Run only once per isolate. // - Run only once per isolate.
// - Persist schema hash in DB config; if unchanged, skip all schema SQL. // - Execute idempotent schema SQL on first DB use in each isolate.
// - Keep statements idempotent so updates are safe. // - Keep statements idempotent so updates are safe.
async initializeDatabase(): Promise<void> { async initializeDatabase(): Promise<void> {
if (StorageService.schemaVerified) return; if (StorageService.schemaVerified) return;
await this.db.prepare('PRAGMA foreign_keys = ON').run(); await this.db.prepare('PRAGMA foreign_keys = ON').run();
await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run(); await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
const schemaHash = await this.sha256Hex(SCHEMA_STATEMENTS.join('\n'));
const current = await this.db.prepare('SELECT value FROM config WHERE key = ?')
.bind(SCHEMA_HASH_CONFIG_KEY)
.first<{ value: string }>();
if (current?.value !== schemaHash) {
for (const stmt of SCHEMA_STATEMENTS) { for (const stmt of SCHEMA_STATEMENTS) {
await this.executeSchemaStatement(stmt); await this.executeSchemaStatement(stmt);
} }
await this.db.prepare(
'INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
)
.bind(SCHEMA_HASH_CONFIG_KEY, schemaHash)
.run();
}
StorageService.schemaVerified = true; StorageService.schemaVerified = true;
} }