Compare commits

...
5 Commits
Author SHA1 Message Date
shuaiplus b731a014f1 fix(auth): prevent unexpected session logout 2026-07-13 02:11:12 +08:00
shuaiplus e25ec159bb feat: enhance navigation layout and improve mobile settings UI 2026-07-12 23:16:01 +08:00
shuaiplus b093c01fd7 chore: update version to 1.7.4 in package.json, package-lock.json, and app-version.ts 2026-07-12 22:13:33 +08:00
shuaiplus fa611dc843 fix: require master password for admin and wipe-device actions
Gate invite management, user ban/delete, and delete-all-devices behind
masterPasswordHash verification, matching backup step-up auth. The web UI
prompts for the master password in the shared confirm dialog.
2026-07-12 20:43:27 +08:00
shuaiplus 3c581d1fb1 fix: block IPv6 loopback in backup destination URL checks
Expand compressed IPv6 hostnames before the private-address allowlist so
forms like ::1 cannot bypass SSRF protection for WebDAV/S3 backup endpoints.
Also reject IPv4-mapped addresses written as ::ffff:hex:hex.
2026-07-12 20:21:45 +08:00
38 changed files with 917 additions and 447 deletions
+5
View File
@@ -132,6 +132,11 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
expires_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
device_identifier TEXT, device_identifier TEXT,
device_session_stamp TEXT, device_session_stamp TEXT,
security_stamp TEXT,
created_at INTEGER,
last_used_at INTEGER,
absolute_expires_at INTEGER,
client_type TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.7.3", "version": "1.7.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nodewarden", "name": "nodewarden",
"version": "1.7.3", "version": "1.7.4",
"license": "LGPL-3.0", "license": "LGPL-3.0",
"dependencies": { "dependencies": {
"@noble/hashes": "^2.2.0", "@noble/hashes": "^2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.7.3", "version": "1.7.4",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers", "description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus", "author": "shuaiplus",
"license": "LGPL-3.0", "license": "LGPL-3.0",
@@ -0,0 +1,38 @@
import { normalizeBackupEndpointUrl } from '../src/services/backup-config.ts';
import fs from 'node:fs';
const scratch = process.env.SCRATCH || '.';
const cases = [
'http://127.0.0.1',
'http://169.254.169.254',
'http://[::1]',
'http://[0:0:0:0:0:0:0:1]',
'http://[::2]',
'http://[::]',
'http://[fe80::1]',
'http://[fc00::1]',
'https://example.com',
];
const out = [];
for (const url of cases) {
try {
const normalized = normalizeBackupEndpointUrl(url, 'WebDAV server URL');
out.push({ url, allowed: true, normalized });
} catch (e) {
out.push({ url, allowed: false, error: e instanceof Error ? e.message : String(e) });
}
}
const path = `${scratch}/poc-normalizeBackupEndpointUrl.json`;
fs.writeFileSync(path, JSON.stringify(out, null, 2));
console.log(JSON.stringify(out, null, 2));
// Security expectation: IPv6 loopback must NOT be allowed.
const loopback = out.find((row) => row.url === 'http://[::1]');
if (loopback?.allowed) {
console.error('FINDING_CONFIRMED: normalizeBackupEndpointUrl accepts http://[::1]');
process.exitCode = 2;
} else {
console.log('IPv6 loopback rejected as expected');
}
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.7.3'; export const APP_VERSION = '1.7.4';
+18 -6
View File
@@ -3,12 +3,14 @@
// Access token lifetime in seconds. // Access token lifetime in seconds.
// 访问令牌有效期(秒)。 // 访问令牌有效期(秒)。
accessTokenTtlSeconds: 7200, accessTokenTtlSeconds: 7200,
// Refresh token lifetime in milliseconds. // Refresh sessions use a reusable opaque token with a sliding idle lifetime.
// 刷新令牌有效期(毫秒) // 刷新会话使用可复用的随机令牌,并按客户端采用滑动空闲期限
refreshTokenTtlMs: 365 * 24 * 60 * 60 * 1000, refreshTokenWebSlidingTtlMs: 30 * 24 * 60 * 60 * 1000,
// Grace window for previous refresh token after rotation (ms). refreshTokenDefaultSlidingTtlMs: 30 * 24 * 60 * 60 * 1000,
// 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。 refreshTokenMobileSlidingTtlMs: 90 * 24 * 60 * 60 * 1000,
refreshTokenOverlapGraceMs: 30 * 60 * 1000, // Hard upper bound for one login session, regardless of sliding refreshes.
// 单次登录会话的绝对最长寿命,不因滑动续期突破该上限。
refreshTokenAbsoluteTtlMs: 365 * 24 * 60 * 60 * 1000,
// Refresh token random byte length. // Refresh token random byte length.
// 刷新令牌随机字节长度。 // 刷新令牌随机字节长度。
refreshTokenRandomBytes: 32, refreshTokenRandomBytes: 32,
@@ -62,6 +64,9 @@
// Refresh-token grant budget per IP per minute. // Refresh-token grant budget per IP per minute.
// refresh_token 授权每 IP 每分钟请求配额。 // refresh_token 授权每 IP 每分钟请求配额。
refreshTokenRequestsPerMinute: 30, refreshTokenRequestsPerMinute: 30,
// Coarser IP budget; the per-session budget above remains the primary guard.
// 更宽松的 IP 总预算;主要保护仍由每个 refresh session 的预算承担。
refreshTokenRequestsPerIpMinute: 300,
// Passwordless/auth-request creation budget per IP/email/device per minute. // Passwordless/auth-request creation budget per IP/email/device per minute.
// 免密/设备审批请求创建接口每 IP/邮箱/设备每分钟配额。 // 免密/设备审批请求创建接口每 IP/邮箱/设备每分钟配额。
authRequestRequestsPerMinute: 5, authRequestRequestsPerMinute: 5,
@@ -159,3 +164,10 @@
cipherKeyEncryptionFeatureEnabled: true, cipherKeyEncryptionFeatureEnabled: true,
}, },
} as const; } as const;
export function getRefreshTokenSlidingTtlMs(clientType?: string | null): number {
const normalized = String(clientType || '').trim().toLowerCase();
if (normalized === 'web') return LIMITS.auth.refreshTokenWebSlidingTtlMs;
if (normalized === 'mobile') return LIMITS.auth.refreshTokenMobileSlidingTtlMs;
return LIMITS.auth.refreshTokenDefaultSlidingTtlMs;
}
+47 -14
View File
@@ -9,6 +9,34 @@ function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active'; return user.role === 'admin' && user.status === 'active';
} }
async function requireMasterPasswordHash(
env: Env,
actorUser: User,
masterPasswordHash: unknown
): Promise<Response | null> {
const normalized = String(masterPasswordHash || '').trim();
if (!normalized) {
return errorResponse('masterPasswordHash is required', 400);
}
const auth = new AuthService(env);
const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email);
if (!valid) {
return errorResponse('Invalid password', 400);
}
return null;
}
async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
try {
const body = await request.json();
return body && typeof body === 'object' && !Array.isArray(body)
? body as Record<string, unknown>
: {};
} catch {
return {};
}
}
function randomHex(bytes: number): string { function randomHex(bytes: number): string {
const data = crypto.getRandomValues(new Uint8Array(bytes)); const data = crypto.getRandomValues(new Uint8Array(bytes));
return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join(''); return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join('');
@@ -204,14 +232,11 @@ export async function handleAdminCreateInvite(
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
let body: { expiresInHours?: number } = {}; const body = await readJsonBody(request);
try { const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
body = await request.json(); if (passwordError) return passwordError;
} catch {
body = {};
}
const expiresInHours = Number.isFinite(body.expiresInHours) const expiresInHours = Number.isFinite(Number(body.expiresInHours))
? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours)))) ? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours))))
: 24 * 7; : 24 * 7;
const now = new Date(); const now = new Date();
@@ -266,6 +291,10 @@ export async function handleAdminDeleteInvite(
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const deleted = await storage.deleteInvite(code); const deleted = await storage.deleteInvite(code);
if (!deleted) { if (!deleted) {
@@ -288,6 +317,10 @@ export async function handleAdminDeleteAllInvites(
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const url = new URL(request.url); const url = new URL(request.url);
if (url.searchParams.get('scope') === 'invalid') { if (url.searchParams.get('scope') === 'invalid') {
@@ -318,12 +351,9 @@ export async function handleAdminSetUserStatus(
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
let body: { status?: string }; const body = await readJsonBody(request);
try { const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
body = await request.json(); if (passwordError) return passwordError;
} catch {
return errorResponse('Invalid JSON', 400);
}
const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null; const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null;
if (!nextStatus) { if (!nextStatus) {
@@ -366,7 +396,6 @@ export async function handleAdminDeleteUser(
actorUser: User, actorUser: User,
targetUserId: string targetUserId: string
): Promise<Response> { ): Promise<Response> {
void request;
if (!isAdmin(actorUser)) { if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
@@ -374,6 +403,10 @@ export async function handleAdminDeleteUser(
return errorResponse('You cannot delete yourself', 400); return errorResponse('You cannot delete yourself', 400);
} }
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const target = await storage.getUserById(targetUserId); const target = await storage.getUserById(targetUserId);
if (!target) { if (!target) {
+16 -1
View File
@@ -464,11 +464,26 @@ export async function handleUpdateDeviceName(
// DELETE /api/devices // DELETE /api/devices
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> { export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
let masterPasswordHash = '';
try {
const body = await request.json() as { masterPasswordHash?: string };
masterPasswordHash = String(body?.masterPasswordHash || '').trim();
} catch {
masterPasswordHash = '';
}
if (!masterPasswordHash) {
return errorResponse('masterPasswordHash is required', 400);
}
const auth = new AuthService(env);
const passwordValid = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
if (!passwordValid) {
return errorResponse('Invalid password', 400);
}
const [removedTrusted, removedSessions, removedDevices] = await Promise.all([ const [removedTrusted, removedSessions, removedDevices] = await Promise.all([
storage.deleteTrustedTwoFactorTokensByUserId(userId), storage.deleteTrustedTwoFactorTokensByUserId(userId),
storage.deleteRefreshTokensByUserId(userId), storage.deleteRefreshTokensByUserId(userId),
+138 -99
View File
@@ -3,7 +3,7 @@ import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response'; import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
import { LIMITS } from '../config/limits'; import { getRefreshTokenSlidingTtlMs, LIMITS } from '../config/limits';
import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp'; import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
import { createRefreshToken } from '../utils/jwt'; import { createRefreshToken } from '../utils/jwt';
import { readAuthRequestDeviceInfo } from '../utils/device'; import { readAuthRequestDeviceInfo } from '../utils/device';
@@ -42,6 +42,10 @@ const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1'; const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100; const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100;
function identityJsonResponse(data: unknown, status: number = 200): Response {
return jsonResponse(data, status, { 'Cache-Control': 'no-store', Pragma: 'no-cache' });
}
function resolveTotpSecret(userSecret: string | null): string | null { function resolveTotpSecret(userSecret: string | null): string | null {
if (userSecret && isTotpEnabled(userSecret)) { if (userSecret && isTotpEnabled(userSecret)) {
return userSecret; return userSecret;
@@ -60,6 +64,33 @@ async function resolveDeviceSession(
return { identifier: deviceInfo.deviceIdentifier, sessionStamp }; return { identifier: deviceInfo.deviceIdentifier, sessionStamp };
} }
function resolveRefreshClientType(request: Request, body: Record<string, string>): string {
if (shouldUseWebSession(request)) return 'web';
const clientId = String(body.client_id || '').trim().toLowerCase();
if (clientId === 'mobile') return 'mobile';
if (clientId === 'browser' || clientId === 'desktop' || clientId === 'cli') return clientId;
return clientId || 'other';
}
async function persistAndResolveDeviceSession(
storage: StorageService,
userId: string,
deviceInfo: ReturnType<typeof readAuthRequestDeviceInfo>
): Promise<{ identifier: string; sessionStamp: string } | null> {
const candidate = await resolveDeviceSession(storage, userId, deviceInfo);
if (!candidate) return null;
await storage.upsertDevice(
userId,
candidate.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
candidate.sessionStamp
);
const persisted = await storage.getDevice(userId, candidate.identifier);
if (!persisted?.sessionStamp) throw new Error('Failed to persist device session');
return { identifier: persisted.deviceIdentifier, sessionStamp: persisted.sessionStamp };
}
function readDevicePushToken(body: Record<string, string>): string { function readDevicePushToken(body: Record<string, string>): string {
return String(readBodyValue(body, ['devicePushToken', 'DevicePushToken', 'device_push_token']) || '').trim(); return String(readBodyValue(body, ['devicePushToken', 'DevicePushToken', 'device_push_token']) || '').trim();
} }
@@ -163,7 +194,7 @@ function withWebRefreshCookie(request: Request, response: Response, refreshToken
headers.append( headers.append(
'Set-Cookie', 'Set-Cookie',
refreshToken refreshToken
? buildRefreshCookie(request, refreshToken, Math.floor(LIMITS.auth.refreshTokenTtlMs / 1000)) ? buildRefreshCookie(request, refreshToken, Math.floor(getRefreshTokenSlidingTtlMs('web') / 1000))
: buildClearedRefreshCookie(request) : buildClearedRefreshCookie(request)
); );
return new Response(response.body, { return new Response(response.body, {
@@ -173,30 +204,6 @@ function withWebRefreshCookie(request: Request, response: Response, refreshToken
}); });
} }
async function revokePresentedAccessTokenSession(request: Request, env: Env, storage: StorageService): Promise<void> {
const authHeader = request.headers.get('Authorization');
if (!authHeader) return;
const auth = new AuthService(env);
const verified = await auth.verifyAccessTokenWithUser(authHeader);
if (!verified) return;
const deviceIdentifier = String(verified.payload.did || '').trim();
if (deviceIdentifier) {
const nextSessionStamp = generateUUID();
await storage.rotateDeviceSessionStamp(verified.user.id, deviceIdentifier, nextSessionStamp);
await storage.deleteRefreshTokensByDevice(verified.user.id, deviceIdentifier);
AuthService.invalidateDeviceCache(verified.user.id, deviceIdentifier);
return;
}
verified.user.securityStamp = generateUUID();
verified.user.updatedAt = new Date().toISOString();
await storage.saveUser(verified.user);
await storage.deleteRefreshTokensByUserId(verified.user.id);
AuthService.invalidateUserCache(verified.user.id);
}
function buildPreloginResponse( function buildPreloginResponse(
email: string, email: string,
kdfType: number, kdfType: number,
@@ -267,7 +274,7 @@ async function twoFactorRequiredResponse(
}; };
// Bitwarden clients rely on these fields to trigger the 2FA UI flow. // Bitwarden clients rely on these fields to trigger the 2FA UI flow.
return jsonResponse( return identityJsonResponse(
{ {
error: 'invalid_grant', error: 'invalid_grant',
error_description: message, error_description: message,
@@ -341,8 +348,20 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const grantType = body.grant_type; const grantType = body.grant_type;
const clientIdentifier = getClientIdentifier(request); const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) { if (!clientIdentifier && grantType !== 'refresh_token') {
return identityErrorResponse('Client IP is required', 'invalid_request', 403); await safeWriteAuditEvent(env, {
action: 'auth.client_ip.missing',
category: 'auth',
level: 'error',
targetType: 'tokenEndpoint',
metadata: { grantType, reason: 'client_ip_missing', ...auditRequestMetadata(request) },
});
return identityErrorResponse(
'Authentication is temporarily unavailable',
'temporarily_unavailable',
503,
{ 'Retry-After': '5' }
);
} }
if (grantType === 'password') { if (grantType === 'password') {
@@ -359,7 +378,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Bitwarden clients expect OAuth-style error fields. // Bitwarden clients expect OAuth-style error fields.
return identityErrorResponse('Email and password are required', 'invalid_request', 400); return identityErrorResponse('Email and password are required', 'invalid_request', 400);
} }
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, email); const loginIdentifier = await loginRateLimitKey(clientIdentifier!, grantType, email);
// Check login lockout before user lookup to reduce user-enumeration signal // Check login lockout before user lookup to reduce user-enumeration signal
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
@@ -550,15 +569,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
// Persist device only after successful password + (optional) 2FA verification. // Persist device only after successful password + (optional) 2FA verification.
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo); const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) { if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
} }
@@ -569,7 +581,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
const accessToken = await auth.generateAccessToken(user, deviceSession); const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user, deviceSession, resolveRefreshClientType(request, body));
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user); const userDecryptionOptions = buildUserDecryptionOptions(user);
await safeWriteAuditEvent(env, { await safeWriteAuditEvent(env, {
@@ -612,14 +624,14 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
userDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions,
}; };
const baseResponse = jsonResponse(response); const baseResponse = identityJsonResponse(response);
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken) ? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse; : baseResponse;
} else if (grantType === 'webauthn') { } else if (grantType === 'webauthn') {
const token = String(body.token || '').trim(); const token = String(body.token || '').trim();
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, token || 'missing-token'); const loginIdentifier = await loginRateLimitKey(clientIdentifier!, grantType, token || 'missing-token');
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
if (!loginCheck.allowed) { if (!loginCheck.allowed) {
return identityErrorResponse( return identityErrorResponse(
@@ -673,22 +685,15 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
const deviceInfo = readAuthRequestDeviceInfo(body, request); const deviceInfo = readAuthRequestDeviceInfo(body, request);
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo); const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) { if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
} }
await rateLimit.clearLoginAttempts(loginIdentifier); await rateLimit.clearLoginAttempts(loginIdentifier);
const accessToken = await auth.generateAccessToken(user, deviceSession); const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user, deviceSession, resolveRefreshClientType(request, body));
const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair'); const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair');
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential); const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
@@ -734,7 +739,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
userDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions,
}; };
const baseResponse = jsonResponse(response); const baseResponse = identityJsonResponse(response);
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken) ? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse; : baseResponse;
@@ -751,7 +756,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Parameter error', 'invalid_request', 400); return identityErrorResponse('Parameter error', 'invalid_request', 400);
} }
const uid = clientId.slice(5); const uid = clientId.slice(5);
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, uid); const loginIdentifier = await loginRateLimitKey(clientIdentifier!, grantType, uid);
// Check login lockout before user lookup to reduce user-enumeration signal // Check login lockout before user lookup to reduce user-enumeration signal
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
@@ -805,15 +810,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
// Persist device only after successful client credential verification. // Persist device only after successful client credential verification.
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo); const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) { if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
} }
@@ -821,7 +819,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
await rateLimit.clearLoginAttempts(loginIdentifier); await rateLimit.clearLoginAttempts(loginIdentifier);
const accessToken = await auth.generateAccessToken(user, deviceSession); const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user, deviceSession, resolveRefreshClientType(request, body));
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user); const userDecryptionOptions = buildUserDecryptionOptions(user);
await safeWriteAuditEvent(env, { await safeWriteAuditEvent(env, {
@@ -863,7 +861,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
userDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions,
}; };
const baseResponse = jsonResponse(response); const baseResponse = identityJsonResponse(response);
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken) ? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse; : baseResponse;
@@ -880,7 +878,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const sendId = String(body.send_id || body.sendId || '').trim(); const sendId = String(body.send_id || body.sendId || '').trim();
if (!sendId) { if (!sendId) {
return jsonResponse( return identityJsonResponse(
{ {
error: 'invalid_request', error: 'invalid_request',
error_description: 'send_id is required', error_description: 'send_id is required',
@@ -905,13 +903,13 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
passwordHashB64, passwordHashB64,
password, password,
rateLimit, rateLimit,
clientIdentifier clientIdentifier || undefined
); );
if ('error' in result) { if ('error' in result) {
return result.error; return result.error;
} }
return jsonResponse({ return identityJsonResponse({
access_token: result.token, access_token: result.token,
expires_in: LIMITS.auth.sendAccessTokenTtlSeconds, expires_in: LIMITS.auth.sendAccessTokenTtlSeconds,
token_type: 'Bearer', token_type: 'Bearer',
@@ -919,19 +917,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
unofficialServer: true, unofficialServer: true,
}); });
} else if (grantType === 'refresh_token') { } else if (grantType === 'refresh_token') {
const refreshLimit = await rateLimit.consumeBudget(
`${clientIdentifier}:identity-refresh`,
LIMITS.rateLimit.refreshTokenRequestsPerMinute
);
if (!refreshLimit.allowed) {
return identityErrorResponse(
`Rate limit exceeded. Try again in ${refreshLimit.retryAfterSeconds} seconds.`,
'TooManyRequests',
429
);
}
// Refresh token
const refreshToken = String(body.refresh_token || '').trim() || ( const refreshToken = String(body.refresh_token || '').trim() || (
shouldUseWebSession(request) shouldUseWebSession(request)
? parseCookieValue(request, WEB_REFRESH_COOKIE) ? parseCookieValue(request, WEB_REFRESH_COOKIE)
@@ -941,7 +926,72 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Refresh token is required', 'invalid_request', 400); return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
} }
const result = await auth.refreshAccessTokenDetailed(refreshToken); const refreshTokenHash = await sha256Hex(refreshToken);
try {
const sessionLimit = await rateLimit.consumeBudget(
`refresh-session:${refreshTokenHash}`,
LIMITS.rateLimit.refreshTokenRequestsPerMinute
);
const ipLimit = clientIdentifier
? await rateLimit.consumeBudget(
`refresh-ip:${clientIdentifier}`,
LIMITS.rateLimit.refreshTokenRequestsPerIpMinute
)
: null;
const rejected = !sessionLimit.allowed ? sessionLimit : (ipLimit && !ipLimit.allowed ? ipLimit : null);
if (rejected) {
const retryAfter = Math.max(1, rejected.retryAfterSeconds || 1);
return identityErrorResponse(
`Rate limit exceeded. Try again in ${retryAfter} seconds.`,
'temporarily_unavailable',
429,
{ 'Retry-After': String(retryAfter) }
);
}
} catch (error) {
await safeWriteAuditEvent(env, {
action: 'auth.refresh.failed.rate_limit_unavailable',
category: 'auth',
level: 'error',
targetType: 'refreshToken',
metadata: { grantType, reason: 'rate_limit_unavailable', error: error instanceof Error ? error.message : String(error), ...auditRequestMetadata(request) },
});
return identityErrorResponse(
'Session refresh is temporarily unavailable',
'temporarily_unavailable',
503,
{ 'Retry-After': '5' }
);
}
if (!clientIdentifier) {
await safeWriteAuditEvent(env, {
action: 'auth.client_ip.missing',
category: 'auth',
level: 'warn',
targetType: 'refreshToken',
metadata: { grantType, reason: 'client_ip_missing', webSession: shouldUseWebSession(request), ...auditRequestMetadata(request) },
});
}
let result: Awaited<ReturnType<AuthService['refreshAccessTokenDetailed']>>;
try {
result = await auth.refreshAccessTokenDetailed(refreshToken);
} catch (error) {
await safeWriteAuditEvent(env, {
action: 'auth.refresh.failed.temporarily_unavailable',
category: 'auth',
level: 'error',
targetType: 'refreshToken',
metadata: { grantType, reason: 'storage_or_worker_error', error: error instanceof Error ? error.message : String(error), webSession: shouldUseWebSession(request), ...auditRequestMetadata(request) },
});
return identityErrorResponse(
'Session refresh is temporarily unavailable',
'temporarily_unavailable',
503,
{ 'Retry-After': '5' }
);
}
if (!result.ok) { if (!result.ok) {
await safeWriteAuditEvent(env, { await safeWriteAuditEvent(env, {
actorUserId: result.userId ?? null, actorUserId: result.userId ?? null,
@@ -963,18 +1013,10 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
: invalidResponse; : invalidResponse;
} }
// Keep a short overlap window for old refresh token to absorb
// concurrent refresh requests from multiple client contexts.
await storage.constrainRefreshTokenExpiry(
refreshToken,
Date.now() + LIMITS.auth.refreshTokenOverlapGraceMs
);
const { accessToken, user, device } = result; const { accessToken, user, device } = result;
if (device?.identifier) { if (device?.identifier) {
await storage.touchDeviceLastSeen(user.id, device.identifier); await storage.touchDeviceLastSeen(user.id, device.identifier);
} }
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user); const userDecryptionOptions = buildUserDecryptionOptions(user);
@@ -982,7 +1024,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
access_token: accessToken, access_token: accessToken,
expires_in: LIMITS.auth.accessTokenTtlSeconds, expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer', token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }), ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
Key: user.key, Key: user.key,
PrivateKey: user.privateKey, PrivateKey: user.privateKey,
AccountKeys: accountKeys, AccountKeys: accountKeys,
@@ -1001,9 +1043,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
userDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions,
}; };
const baseResponse = jsonResponse(response); const baseResponse = identityJsonResponse(response);
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, newRefreshToken) ? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse; : baseResponse;
} }
@@ -1036,7 +1078,7 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
const kdfMemory = user?.kdfMemory ?? null; const kdfMemory = user?.kdfMemory ?? null;
const kdfParallelism = user?.kdfParallelism ?? null; const kdfParallelism = user?.kdfParallelism ?? null;
return jsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism)); return identityJsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism));
} }
// POST /identity/connect/revocation // POST /identity/connect/revocation
@@ -1044,12 +1086,6 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
// RFC 7009 allows returning 200 even if token is unknown. // RFC 7009 allows returning 200 even if token is unknown.
export async function handleRevocation(request: Request, env: Env): Promise<Response> { export async function handleRevocation(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
try {
await revokePresentedAccessTokenSession(request, env, storage);
} catch {
// RFC 7009 revocation is best-effort and should not reveal token state.
}
let body: Record<string, string>; let body: Record<string, string>;
const contentType = request.headers.get('content-type') || ''; const contentType = request.headers.get('content-type') || '';
try { try {
@@ -1060,7 +1096,7 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
body = await request.json(); body = await request.json();
} }
} catch { } catch {
return new Response(null, { status: 200 }); return new Response(null, { status: 200, headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } });
} }
const token = String(body.token || '').trim() || ( const token = String(body.token || '').trim() || (
@@ -1072,7 +1108,10 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
await storage.deleteRefreshToken(token); await storage.deleteRefreshToken(token);
} }
const baseResponse = new Response(null, { status: 200 }); const baseResponse = new Response(null, {
status: 200,
headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' },
});
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, null) ? withWebRefreshCookie(request, baseResponse, null)
: baseResponse; : baseResponse;
+41 -8
View File
@@ -1,5 +1,6 @@
import { Env, JWTPayload, User } from '../types'; import { Env, JWTPayload, User } from '../types';
import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt'; import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt';
import { getRefreshTokenSlidingTtlMs, LIMITS } from '../config/limits';
import { StorageService } from './storage'; import { StorageService } from './storage';
// Server-side iterations for second-layer hashing. // Server-side iterations for second-layer hashing.
@@ -28,11 +29,12 @@ export type RefreshAccessTokenFailureReason =
| 'token_not_found_or_expired' | 'token_not_found_or_expired'
| 'user_missing' | 'user_missing'
| 'user_inactive' | 'user_inactive'
| 'security_stamp_mismatch'
| 'device_missing' | 'device_missing'
| 'device_session_mismatch'; | 'device_session_mismatch';
export type RefreshAccessTokenResult = export type RefreshAccessTokenResult =
| { ok: true; accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | { ok: true; accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null; expiresAt: number }
| { | {
ok: false; ok: false;
reason: RefreshAccessTokenFailureReason; reason: RefreshAccessTokenFailureReason;
@@ -190,9 +192,23 @@ export class AuthService {
} }
// Generate refresh token // Generate refresh token
async generateRefreshToken(userId: string, device?: { identifier: string; sessionStamp: string } | null): Promise<string> { async generateRefreshToken(
user: User,
device?: { identifier: string; sessionStamp: string } | null,
clientType: string = 'other'
): Promise<string> {
const token = createRefreshToken(); const token = createRefreshToken();
await this.storage.saveRefreshToken(token, userId, undefined, device?.identifier ?? null, device?.sessionStamp ?? null); const now = Date.now();
await this.storage.saveRefreshToken(
token,
user.id,
now + getRefreshTokenSlidingTtlMs(clientType),
device?.identifier ?? null,
device?.sessionStamp ?? null,
user.securityStamp,
clientType,
now + LIMITS.auth.refreshTokenAbsoluteTtlMs
);
return token; return token;
} }
@@ -251,25 +267,42 @@ export class AuthService {
return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier }; return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier };
} }
let device: { identifier: string; sessionStamp: string } | null = null; if (record.securityStamp && record.securityStamp !== user.securityStamp) {
if (!record.deviceIdentifier || !record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken); await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier }; return { ok: false, reason: 'security_stamp_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
if (!record.securityStamp) {
await this.storage.bindRefreshTokenSecurityStamp(refreshToken, user.securityStamp);
} }
let device: { identifier: string; sessionStamp: string } | null = null;
if (record.deviceIdentifier) {
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier); const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
if (!boundDevice) { if (!boundDevice) {
await this.storage.deleteRefreshToken(refreshToken); await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier }; return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
} }
if (boundDevice.sessionStamp !== record.deviceSessionStamp) { if (record.deviceSessionStamp && boundDevice.sessionStamp !== record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken); await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier }; return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
} }
if (!record.deviceSessionStamp) {
await this.storage.bindRefreshTokenDeviceStamp(refreshToken, boundDevice.sessionStamp);
}
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp }; device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
}
const now = Date.now();
const expiresAt = Math.min(
now + getRefreshTokenSlidingTtlMs(record.clientType),
record.absoluteExpiresAt || (now + LIMITS.auth.refreshTokenAbsoluteTtlMs)
);
const extended = await this.storage.extendRefreshTokenExpiry(refreshToken, expiresAt, now);
if (!extended) {
return { ok: false, reason: 'token_not_found_or_expired', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
const accessToken = await this.generateAccessToken(user, device); const accessToken = await this.generateAccessToken(user, device);
return { ok: true, accessToken, user, device }; return { ok: true, accessToken, user, device, expiresAt };
} }
async refreshAccessToken( async refreshAccessToken(
+54 -5
View File
@@ -99,23 +99,72 @@ function isBlockedIpv4Address(octets: number[]): boolean {
); );
} }
/**
* Expand a hostname-form IPv6 literal to eight 4-digit hextets.
* Needed so compressed forms like "::1" are not misclassified by a naive
* "first non-empty hextet" check (which would read "1" and miss loopback).
*/
function expandIpv6Address(hostname: string): string[] | null {
const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, '');
if (!normalized.includes(':')) return null;
if (normalized.includes('.')) {
// IPv4-embedded forms are handled separately by the caller.
return null;
}
if ((normalized.match(/::/g) || []).length > 1) return null;
const sides = normalized.split('::');
const left = sides[0] ? sides[0].split(':').filter((part) => part.length > 0) : [];
const right = sides.length > 1 && sides[1] ? sides[1].split(':').filter((part) => part.length > 0) : [];
if (left.length + right.length > 8) return null;
if (sides.length === 1 && left.length !== 8) return null;
const missing = 8 - left.length - right.length;
if (sides.length > 1 && missing < 0) return null;
const middle = sides.length > 1 ? Array.from({ length: missing }, () => '0') : [];
const parts = [...left, ...middle, ...right];
if (parts.length !== 8) return null;
const hextets: string[] = [];
for (const part of parts) {
if (!/^[0-9a-f]{1,4}$/i.test(part)) return null;
hextets.push(part.padStart(4, '0'));
}
return hextets;
}
function isBlockedIpv6Address(hostname: string): boolean { function isBlockedIpv6Address(hostname: string): boolean {
if (!hostname.includes(':')) return false; if (!hostname.includes(':')) return false;
const normalized = hostname.toLowerCase(); const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
// IPv4-mapped dotted form: ::ffff:127.0.0.1
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i);
if (mappedIpv4) { if (mappedIpv4) {
const octets = parseIpv4Address(mappedIpv4[1]); const octets = parseIpv4Address(mappedIpv4[1]);
return !octets || isBlockedIpv4Address(octets); return !octets || isBlockedIpv4Address(octets);
} }
const firstHextetText = normalized.split(':').find((part) => part.length > 0) || '0';
const firstHextet = Number.parseInt(firstHextetText, 16); // IPv4-mapped hex form produced by some URL parsers: ::ffff:7f00:1
const mappedHex = normalized.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
if (mappedHex) {
const hi = Number.parseInt(mappedHex[1], 16);
const lo = Number.parseInt(mappedHex[2], 16);
if (!Number.isFinite(hi) || !Number.isFinite(lo)) return true;
const octets = [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff];
return isBlockedIpv4Address(octets);
}
const hextets = expandIpv6Address(normalized);
if (!hextets) return true;
const firstHextet = Number.parseInt(hextets[0], 16);
if (!Number.isFinite(firstHextet)) return true; if (!Number.isFinite(firstHextet)) return true;
// After expansion, loopback (::1) and unspecified (::) have first hextet 0.
return ( return (
firstHextet === 0 || firstHextet === 0 ||
(firstHextet & 0xfe00) === 0xfc00 || (firstHextet & 0xfe00) === 0xfc00 ||
(firstHextet & 0xffc0) === 0xfe80 || (firstHextet & 0xffc0) === 0xfe80 ||
(firstHextet & 0xff00) === 0xff00 || (firstHextet & 0xff00) === 0xff00 ||
normalized.startsWith('2001:db8:') hextets.join(':').startsWith('2001:0db8:')
); );
} }
+4 -2
View File
@@ -45,7 +45,8 @@ export async function upsertDevice(
await db await db
.prepare( .prepare(
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' + 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' + 'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, ' +
'session_stamp=CASE WHEN devices.session_stamp IS NULL OR devices.session_stamp = ? THEN excluded.session_stamp ELSE devices.session_stamp END, ' +
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' + 'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' + 'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' + 'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
@@ -66,7 +67,8 @@ export async function upsertDevice(
existingDevice?.deviceNote ?? null, existingDevice?.deviceNote ?? null,
now, now,
now, now,
now now,
''
) )
.run(); .run();
} }
+91 -34
View File
@@ -11,16 +11,34 @@ export async function saveRefreshToken(
userId: string, userId: string,
expiresAtMs: number, expiresAtMs: number,
deviceIdentifier?: string | null, deviceIdentifier?: string | null,
deviceSessionStamp?: string | null deviceSessionStamp?: string | null,
securityStamp?: string | null,
clientType?: string | null,
absoluteExpiresAtMs?: number | null
): Promise<void> { ): Promise<void> {
await maybeCleanupExpiredRefreshTokens(Date.now()); await maybeCleanupExpiredRefreshTokens(Date.now());
const tokenKey = await refreshTokenKey(token); const tokenKey = await refreshTokenKey(token);
const now = Date.now();
await db await db
.prepare( .prepare(
'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp) VALUES(?, ?, ?, ?, ?) ' + 'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp, security_stamp, created_at, last_used_at, absolute_expires_at, client_type) ' +
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, device_session_stamp=excluded.device_session_stamp' 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, ' +
'device_session_stamp=excluded.device_session_stamp, security_stamp=excluded.security_stamp, last_used_at=excluded.last_used_at, ' +
'absolute_expires_at=excluded.absolute_expires_at, client_type=excluded.client_type'
)
.bind(
tokenKey,
userId,
expiresAtMs,
deviceIdentifier ?? null,
deviceSessionStamp ?? null,
securityStamp ?? null,
now,
now,
absoluteExpiresAtMs ?? null,
clientType ?? null
) )
.bind(tokenKey, userId, expiresAtMs, deviceIdentifier ?? null, deviceSessionStamp ?? null)
.run(); .run();
} }
@@ -36,12 +54,25 @@ export async function getRefreshTokenRecord(
const tokenKey = await refreshTokenKey(token); const tokenKey = await refreshTokenKey(token);
const row = await db const row = await db
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?') .prepare(
'SELECT user_id, expires_at, device_identifier, device_session_stamp, security_stamp, created_at, last_used_at, absolute_expires_at, client_type ' +
'FROM refresh_tokens WHERE token = ?'
)
.bind(tokenKey) .bind(tokenKey)
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>(); .first<{
user_id: string;
expires_at: number;
device_identifier: string | null;
device_session_stamp: string | null;
security_stamp: string | null;
created_at: number | null;
last_used_at: number | null;
absolute_expires_at: number | null;
client_type: string | null;
}>();
if (!row) return null; if (!row) return null;
if (row.expires_at && row.expires_at < now) { if ((row.expires_at && row.expires_at < now) || (row.absolute_expires_at && row.absolute_expires_at < now)) {
await deleteRefreshTokenRecord(token); await deleteRefreshTokenRecord(token);
return null; return null;
} }
@@ -50,9 +81,62 @@ export async function getRefreshTokenRecord(
expiresAt: row.expires_at, expiresAt: row.expires_at,
deviceIdentifier: row.device_identifier ?? null, deviceIdentifier: row.device_identifier ?? null,
deviceSessionStamp: row.device_session_stamp ?? null, deviceSessionStamp: row.device_session_stamp ?? null,
securityStamp: row.security_stamp ?? null,
createdAt: row.created_at ?? null,
lastUsedAt: row.last_used_at ?? null,
absoluteExpiresAt: row.absolute_expires_at ?? null,
clientType: row.client_type ?? null,
}; };
} }
export async function extendRefreshTokenExpiry(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
token: string,
requestedExpiresAtMs: number,
nowMs: number
): Promise<boolean> {
const tokenKey = await refreshTokenKey(token);
const result = await db
.prepare(
'UPDATE refresh_tokens SET ' +
'expires_at = CASE ' +
'WHEN absolute_expires_at IS NOT NULL AND absolute_expires_at < ? THEN absolute_expires_at ' +
'ELSE ? END, ' +
'last_used_at = ? ' +
'WHERE token = ? AND expires_at >= ? AND (absolute_expires_at IS NULL OR absolute_expires_at >= ?)'
)
.bind(requestedExpiresAtMs, requestedExpiresAtMs, nowMs, tokenKey, nowMs, nowMs)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function bindRefreshTokenSecurityStamp(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
token: string,
securityStamp: string
): Promise<void> {
const tokenKey = await refreshTokenKey(token);
await db
.prepare('UPDATE refresh_tokens SET security_stamp = ? WHERE token = ? AND (security_stamp IS NULL OR security_stamp = ?)')
.bind(securityStamp, tokenKey, '')
.run();
}
export async function bindRefreshTokenDeviceStamp(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
token: string,
deviceSessionStamp: string
): Promise<void> {
const tokenKey = await refreshTokenKey(token);
await db
.prepare('UPDATE refresh_tokens SET device_session_stamp = ? WHERE token = ? AND (device_session_stamp IS NULL OR device_session_stamp = ?)')
.bind(deviceSessionStamp, tokenKey, '')
.run();
}
export async function deleteRefreshToken(db: D1Database, refreshTokenKey: RefreshTokenKeyFn, token: string): Promise<void> { export async function deleteRefreshToken(db: D1Database, refreshTokenKey: RefreshTokenKeyFn, token: string): Promise<void> {
const tokenKey = await refreshTokenKey(token); const tokenKey = await refreshTokenKey(token);
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run(); await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
@@ -71,30 +155,3 @@ export async function deleteRefreshTokensByDevice(db: D1Database, userId: string
.run(); .run();
return Number(result.meta.changes ?? 0); return Number(result.meta.changes ?? 0);
} }
export async function constrainRefreshTokenExpiry(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
token: string,
maxExpiresAtMs: number
): Promise<void> {
const tokenKey = await refreshTokenKey(token);
await db
.prepare(
'UPDATE refresh_tokens ' +
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
'WHERE token = ?'
)
.bind(maxExpiresAtMs, maxExpiresAtMs, tokenKey)
.run();
await db
.prepare(
'UPDATE refresh_tokens ' +
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
'WHERE token = ?'
)
.bind(maxExpiresAtMs, maxExpiresAtMs, token)
.run();
}
+12 -1
View File
@@ -74,11 +74,20 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'ALTER TABLE sends ADD COLUMN emails TEXT', 'ALTER TABLE sends ADD COLUMN emails TEXT',
'CREATE TABLE IF NOT EXISTS refresh_tokens (' + 'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, ' + 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, security_stamp TEXT, created_at INTEGER, last_used_at INTEGER, absolute_expires_at INTEGER, client_type TEXT, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)', 'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)',
'ALTER TABLE refresh_tokens ADD COLUMN device_identifier TEXT', 'ALTER TABLE refresh_tokens ADD COLUMN device_identifier TEXT',
'ALTER TABLE refresh_tokens ADD COLUMN device_session_stamp TEXT', 'ALTER TABLE refresh_tokens ADD COLUMN device_session_stamp TEXT',
'ALTER TABLE refresh_tokens ADD COLUMN security_stamp TEXT',
'ALTER TABLE refresh_tokens ADD COLUMN created_at INTEGER',
'ALTER TABLE refresh_tokens ADD COLUMN last_used_at INTEGER',
'ALTER TABLE refresh_tokens ADD COLUMN absolute_expires_at INTEGER',
'ALTER TABLE refresh_tokens ADD COLUMN client_type TEXT',
"UPDATE refresh_tokens SET security_stamp = (SELECT users.security_stamp FROM users WHERE users.id = refresh_tokens.user_id) WHERE security_stamp IS NULL OR security_stamp = ''",
"UPDATE refresh_tokens SET created_at = CAST(strftime('%s','now') AS INTEGER) * 1000 WHERE created_at IS NULL",
"UPDATE refresh_tokens SET last_used_at = created_at WHERE last_used_at IS NULL",
'UPDATE refresh_tokens SET absolute_expires_at = expires_at WHERE absolute_expires_at IS NULL',
'CREATE TABLE IF NOT EXISTS invites (' + 'CREATE TABLE IF NOT EXISTS invites (' +
'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
@@ -118,6 +127,8 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT', 'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)', 'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
'CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)', 'CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)',
"UPDATE refresh_tokens SET device_session_stamp = (SELECT devices.session_stamp FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier) WHERE device_identifier IS NOT NULL AND (device_session_stamp IS NULL OR device_session_stamp = '') AND EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier)",
"UPDATE refresh_tokens SET client_type = CASE WHEN EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier AND devices.type IN (0, 1)) THEN 'mobile' WHEN EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier AND devices.type = 14) THEN 'web' ELSE 'other' END WHERE client_type IS NULL OR client_type = ''",
'CREATE TABLE IF NOT EXISTS auth_requests (' + 'CREATE TABLE IF NOT EXISTS auth_requests (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' +
+24 -10
View File
@@ -87,10 +87,12 @@ import {
saveSend as saveStoredSend, saveSend as saveStoredSend,
} from './storage-send-repo'; } from './storage-send-repo';
import { import {
constrainRefreshTokenExpiry as constrainStoredRefreshTokenExpiry, bindRefreshTokenDeviceStamp as bindStoredRefreshTokenDeviceStamp,
bindRefreshTokenSecurityStamp as bindStoredRefreshTokenSecurityStamp,
deleteRefreshToken as deleteStoredRefreshToken, deleteRefreshToken as deleteStoredRefreshToken,
deleteRefreshTokensByDevice as deleteStoredRefreshTokensByDevice, deleteRefreshTokensByDevice as deleteStoredRefreshTokensByDevice,
deleteRefreshTokensByUserId as deleteStoredRefreshTokensByUserId, deleteRefreshTokensByUserId as deleteStoredRefreshTokensByUserId,
extendRefreshTokenExpiry as extendStoredRefreshTokenExpiry,
getRefreshTokenRecord as findStoredRefreshTokenRecord, getRefreshTokenRecord as findStoredRefreshTokenRecord,
saveRefreshToken as saveStoredRefreshToken, saveRefreshToken as saveStoredRefreshToken,
} from './storage-refresh-token-repo'; } from './storage-refresh-token-repo';
@@ -162,7 +164,7 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value // changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// differs from config.schema.version. // differs from config.schema.version.
const STORAGE_SCHEMA_VERSION = '2026-07-05-passkey-2fa'; const STORAGE_SCHEMA_VERSION = '2026-07-13-refresh-session-reuse';
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const; const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
// D1-backed storage. // D1-backed storage.
@@ -632,9 +634,13 @@ export class StorageService {
userId: string, userId: string,
expiresAtMs?: number, expiresAtMs?: number,
deviceIdentifier?: string | null, deviceIdentifier?: string | null,
deviceSessionStamp?: string | null deviceSessionStamp?: string | null,
securityStamp?: string | null,
clientType?: string | null,
absoluteExpiresAtMs?: number | null
): Promise<void> { ): Promise<void> {
const expiresAt = expiresAtMs ?? (Date.now() + LIMITS.auth.refreshTokenTtlMs); const now = Date.now();
const expiresAt = expiresAtMs ?? (now + LIMITS.auth.refreshTokenDefaultSlidingTtlMs);
await saveStoredRefreshToken( await saveStoredRefreshToken(
this.db, this.db,
this.refreshTokenKey.bind(this), this.refreshTokenKey.bind(this),
@@ -643,7 +649,10 @@ export class StorageService {
userId, userId,
expiresAt, expiresAt,
deviceIdentifier, deviceIdentifier,
deviceSessionStamp deviceSessionStamp,
securityStamp,
clientType,
absoluteExpiresAtMs ?? (now + LIMITS.auth.refreshTokenAbsoluteTtlMs)
); );
} }
@@ -717,11 +726,16 @@ export class StorageService {
return deleteStoredRefreshTokensByDevice(this.db, userId, deviceIdentifier); return deleteStoredRefreshTokensByDevice(this.db, userId, deviceIdentifier);
} }
// Keep a short overlap window for rotated refresh token to reduce async extendRefreshTokenExpiry(token: string, requestedExpiresAtMs: number, nowMs: number = Date.now()): Promise<boolean> {
// multi-context refresh races (e.g. browser extension popup/background). return extendStoredRefreshTokenExpiry(this.db, this.refreshTokenKey.bind(this), token, requestedExpiresAtMs, nowMs);
// Expiry is only tightened, never extended. }
async constrainRefreshTokenExpiry(token: string, maxExpiresAtMs: number): Promise<void> {
await constrainStoredRefreshTokenExpiry(this.db, this.refreshTokenKey.bind(this), token, maxExpiresAtMs); async bindRefreshTokenSecurityStamp(token: string, securityStamp: string): Promise<void> {
await bindStoredRefreshTokenSecurityStamp(this.db, this.refreshTokenKey.bind(this), token, securityStamp);
}
async bindRefreshTokenDeviceStamp(token: string, deviceSessionStamp: string): Promise<void> {
await bindStoredRefreshTokenDeviceStamp(this.db, this.refreshTokenKey.bind(this), token, deviceSessionStamp);
} }
private async trustedTwoFactorTokenKey(token: string): Promise<string> { private async trustedTwoFactorTokenKey(token: string): Promise<string> {
+5
View File
@@ -402,6 +402,11 @@ export interface RefreshTokenRecord {
expiresAt: number; expiresAt: number;
deviceIdentifier: string | null; deviceIdentifier: string | null;
deviceSessionStamp: string | null; deviceSessionStamp: string | null;
securityStamp: string | null;
createdAt: number | null;
lastUsedAt: number | null;
absoluteExpiresAt: number | null;
clientType: string | null;
} }
export interface TrustedDeviceTokenSummary { export interface TrustedDeviceTokenSummary {
+8 -2
View File
@@ -144,7 +144,12 @@ export function unsupportedResponse(message: string = 'This feature is not suppo
} }
// Identity endpoint error response (for /identity/connect/token) // Identity endpoint error response (for /identity/connect/token)
export function identityErrorResponse(message: string, error: string = 'invalid_grant', status: number = 400): Response { export function identityErrorResponse(
message: string,
error: string = 'invalid_grant',
status: number = 400,
headers: Record<string, string> = {}
): Response {
return jsonResponse( return jsonResponse(
{ {
error: error, error: error,
@@ -154,7 +159,8 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
Object: 'error', Object: 'error',
}, },
}, },
status status,
{ 'Cache-Control': 'no-store', Pragma: 'no-cache', ...headers }
); );
} }
+49 -2
View File
@@ -253,6 +253,8 @@ export default function App() {
const [lockTimeoutMinutes, setLockTimeoutMinutesState] = useState<LockTimeoutMinutes>(() => readLockTimeoutMinutes()); const [lockTimeoutMinutes, setLockTimeoutMinutesState] = useState<LockTimeoutMinutes>(() => readLockTimeoutMinutes());
const [sessionTimeoutAction, setSessionTimeoutActionState] = useState<SessionTimeoutAction>(() => readSessionTimeoutAction()); const [sessionTimeoutAction, setSessionTimeoutActionState] = useState<SessionTimeoutAction>(() => readSessionTimeoutAction());
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialBootstrap.session?.email); const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialBootstrap.session?.email);
const [lockedSessionRefreshError, setLockedSessionRefreshError] = useState('');
const [lockedSessionRetryKey, setLockedSessionRetryKey] = useState(0);
const [confirm, setConfirm] = useState<AppConfirmState | null>(null); const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
const [mobileLayout, setMobileLayout] = useState(false); const [mobileLayout, setMobileLayout] = useState(false);
@@ -269,6 +271,7 @@ export default function App() {
const [vaultDecryptError, setVaultDecryptError] = useState(''); const [vaultDecryptError, setVaultDecryptError] = useState('');
const [sendsDecryptDone, setSendsDecryptDone] = useState(false); const [sendsDecryptDone, setSendsDecryptDone] = useState(false);
const sessionRef = useRef<SessionState | null>(initialBootstrap.session); const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
const lockedSessionRetryAttemptRef = useRef(0);
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {}); const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {}); const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {});
@@ -503,13 +506,15 @@ export default function App() {
if (phase !== 'locked' || !session) return; if (phase !== 'locked' || !session) return;
if (IS_DEMO_MODE) return; if (IS_DEMO_MODE) return;
let cancelled = false; let cancelled = false;
let retryTimerId: number | null = null;
void (async () => { void (async () => {
const result = await hydrateLockedSession(session, profile); const result = await hydrateLockedSession(session, profile);
if (cancelled) return; if (cancelled) return;
if (!result.session) { if (result.kind === 'expired') {
setSession(null); setSession(null);
setProfile(null); setProfile(null);
setUnlockPreparing(false); setUnlockPreparing(false);
setLockedSessionRefreshError('');
setPhase('login'); setPhase('login');
if (location !== '/login') navigate('/login'); if (location !== '/login') navigate('/login');
return; return;
@@ -518,11 +523,43 @@ export default function App() {
if (result.profile) { if (result.profile) {
setProfile(stripProfileSecrets(result.profile)); setProfile(stripProfileSecrets(result.profile));
} }
if (result.kind === 'transient') {
setUnlockPreparing(false);
setLockedSessionRefreshError(result.message || t('txt_session_refresh_temporarily_unavailable'));
const retrySchedule = [2_000, 5_000, 15_000, 30_000, 60_000];
const scheduledDelay = retrySchedule[Math.min(lockedSessionRetryAttemptRef.current, retrySchedule.length - 1)];
lockedSessionRetryAttemptRef.current += 1;
const retryAfterMs = Math.min(60_000, Math.max(scheduledDelay, result.retryAfterMs || 0));
retryTimerId = window.setTimeout(() => {
setLockedSessionRetryKey((value) => value + 1);
}, retryAfterMs);
return;
}
lockedSessionRetryAttemptRef.current = 0;
setLockedSessionRefreshError('');
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
if (retryTimerId !== null) window.clearTimeout(retryTimerId);
}; };
}, [phase, session?.email, location, navigate]); }, [phase, session?.email, location, navigate, lockedSessionRetryKey]);
useEffect(() => {
if (!lockedSessionRefreshError || phase !== 'locked') return;
const retryNow = () => {
lockedSessionRetryAttemptRef.current = 0;
setLockedSessionRetryKey((value) => value + 1);
};
const handleVisibility = () => {
if (document.visibilityState === 'visible') retryNow();
};
window.addEventListener('online', retryNow);
document.addEventListener('visibilitychange', handleVisibility);
return () => {
window.removeEventListener('online', retryNow);
document.removeEventListener('visibilitychange', handleVisibility);
};
}, [lockedSessionRefreshError, phase]);
async function finalizeLogin(login: CompletedLogin) { async function finalizeLogin(login: CompletedLogin) {
loginScopedBackupRepairAuthRef.current = loginScopedBackupRepairAuthRef.current =
@@ -536,6 +573,7 @@ export default function App() {
setSession(login.session); setSession(login.session);
setProfile(login.profile); setProfile(login.profile);
setUnlockPreparing(false); setUnlockPreparing(false);
setLockedSessionRefreshError('');
setPendingTotp(null); setPendingTotp(null);
setPendingTotpMode(null); setPendingTotpMode(null);
setPendingPasskeyPassword(null); setPendingPasskeyPassword(null);
@@ -884,6 +922,7 @@ export default function App() {
setPendingTotpMode(null); setPendingTotpMode(null);
setTotpCode(''); setTotpCode('');
setUnlockPreparing(false); setUnlockPreparing(false);
setLockedSessionRefreshError('');
setPhase('locked'); setPhase('locked');
navigate('/lock'); navigate('/lock');
} }
@@ -1856,6 +1895,8 @@ export default function App() {
}); });
const adminActions = useAdminActions({ const adminActions = useAdminActions({
authedFetch, authedFetch,
email: String(profile?.email || session?.email || ''),
defaultKdfIterations,
onNotify: pushToast, onNotify: pushToast,
onSetConfirm: setConfirm, onSetConfirm: setConfirm,
refetchUsers: usersQuery.refetch, refetchUsers: usersQuery.refetch,
@@ -2219,6 +2260,7 @@ export default function App() {
unlockPlaceholder={IS_DEMO_MODE ? t('txt_demo_unlock_placeholder') : undefined} unlockPlaceholder={IS_DEMO_MODE ? t('txt_demo_unlock_placeholder') : undefined}
unlockReady={!!session?.email} unlockReady={!!session?.email}
unlockPreparing={unlockPreparing} unlockPreparing={unlockPreparing}
sessionRefreshError={lockedSessionRefreshError}
loginValues={loginValues} loginValues={loginValues}
pendingPasskeyPasswordEmail={pendingPasskeyPassword?.email || null} pendingPasskeyPasswordEmail={pendingPasskeyPassword?.email || null}
passkeyPassword={passkeyPassword} passkeyPassword={passkeyPassword}
@@ -2259,6 +2301,11 @@ export default function App() {
onLogout={logoutNow} onLogout={logoutNow}
onTogglePasswordHint={() => void handleTogglePasswordHint()} onTogglePasswordHint={() => void handleTogglePasswordHint()}
onShowLockedPasswordHint={handleShowLockedPasswordHint} onShowLockedPasswordHint={handleShowLockedPasswordHint}
onRetrySessionRefresh={() => {
lockedSessionRetryAttemptRef.current = 0;
setLockedSessionRefreshError('');
setLockedSessionRetryKey((value) => value + 1);
}}
/> />
<AppGlobalOverlays <AppGlobalOverlays
toasts={toasts} toasts={toasts}
+57 -157
View File
@@ -1,6 +1,6 @@
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldCheck, ShieldUser, SlidersHorizontal, Sparkles, Users } from 'lucide-preact'; import { ArrowUpDown, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldCheck, ShieldUser, Sparkles, Users } from 'lucide-preact';
import type { ComponentChildren } from 'preact'; import type { ComponentChildren } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { Link } from 'wouter'; import { Link } from 'wouter';
import AppMainRoutes from '@/components/AppMainRoutes'; import AppMainRoutes from '@/components/AppMainRoutes';
import NetworkStatusBadge from '@/components/NetworkStatusBadge'; import NetworkStatusBadge from '@/components/NetworkStatusBadge';
@@ -28,19 +28,32 @@ interface AppAuthenticatedShellProps {
mainRoutesProps: AppMainRoutesProps; mainRoutesProps: AppMainRoutesProps;
} }
type NavLayoutMode = 'flat' | 'grouped-expanded' | 'grouped-smart'; const NAV_GROUPS_STORAGE_KEY = 'nodewarden.navGroups';
const NAV_LAYOUT_STORAGE_KEY = 'nodewarden.navLayoutMode'; const DEFAULT_EXPANDED_GROUPS = {
tools: true,
settings: true,
management: true,
};
function readNavLayoutMode(): NavLayoutMode { type NavGroup = keyof typeof DEFAULT_EXPANDED_GROUPS;
if (typeof window === 'undefined') return 'flat'; type ExpandedGroups = Record<NavGroup, boolean>;
function readExpandedGroups(): ExpandedGroups {
if (typeof window === 'undefined') return DEFAULT_EXPANDED_GROUPS;
try { try {
const saved = window.localStorage.getItem(NAV_LAYOUT_STORAGE_KEY); const saved = window.localStorage.getItem(NAV_GROUPS_STORAGE_KEY);
if (saved === 'flat' || saved === 'grouped-expanded' || saved === 'grouped-smart') return saved; if (!saved) return DEFAULT_EXPANDED_GROUPS;
const parsed = JSON.parse(saved) as Partial<ExpandedGroups>;
return {
tools: typeof parsed.tools === 'boolean' ? parsed.tools : DEFAULT_EXPANDED_GROUPS.tools,
settings: typeof parsed.settings === 'boolean' ? parsed.settings : DEFAULT_EXPANDED_GROUPS.settings,
management: typeof parsed.management === 'boolean' ? parsed.management : DEFAULT_EXPANDED_GROUPS.management,
};
} catch { } catch {
// Ignore local preference read failures. // Ignore local preference read failures.
} }
return 'flat'; return DEFAULT_EXPANDED_GROUPS;
} }
function isAdminProfile(profile: Profile | null): boolean { function isAdminProfile(profile: Profile | null): boolean {
@@ -55,58 +68,19 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
const isDomainRulesRoute = props.location === '/settings/domain-rules'; const isDomainRulesRoute = props.location === '/settings/domain-rules';
const isLogRoute = props.location === '/logs'; const isLogRoute = props.location === '/logs';
const isAdmin = isAdminProfile(props.profile); const isAdmin = isAdminProfile(props.profile);
const vaultActive = props.location === '/vault' || props.location === '/vault/totp' || props.location === '/security/password-health';
const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE; const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
const settingsActive = props.location === '/settings' || props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules' || deviceManagementActive; const [expandedGroups, setExpandedGroups] = useState<ExpandedGroups>(readExpandedGroups);
const flatSettingsActive = settingsActive && !deviceManagementActive;
const dataActive = props.location === '/backup' || props.isImportRoute;
const managementActive = props.location === '/admin' || props.location === '/logs';
const [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode);
const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false);
const navLayoutPickerRef = useRef<HTMLDivElement | null>(null);
const [expandedGroups, setExpandedGroups] = useState({
vault: true,
settings: false,
data: false,
management: false,
});
useEffect(() => { function toggleGroup(group: NavGroup): void {
const onPointerDown = (event: Event) => { setExpandedGroups((current) => {
if (!navLayoutPickerOpen) return; const next = { ...current, [group]: !current[group] };
const target = event.target as Node | null;
if (navLayoutPickerRef.current && target && !navLayoutPickerRef.current.contains(target)) {
setNavLayoutPickerOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setNavLayoutPickerOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [navLayoutPickerOpen]);
function setNavMode(mode: NavLayoutMode): void {
setNavLayoutMode(mode);
setNavLayoutPickerOpen(false);
try { try {
window.localStorage.setItem(NAV_LAYOUT_STORAGE_KEY, mode); window.localStorage.setItem(NAV_GROUPS_STORAGE_KEY, JSON.stringify(next));
} catch { } catch {
// Ignore local preference write failures. // Ignore local preference write failures.
} }
} return next;
});
function toggleGroup(group: keyof typeof expandedGroups): void {
setExpandedGroups((current) => ({ ...current, [group]: !current[group] }));
}
function groupOpen(group: keyof typeof expandedGroups, active: boolean): boolean {
if (navLayoutMode === 'grouped-expanded') return true;
return expandedGroups[group] || active;
} }
function renderSideLink(href: string, active: boolean, icon: ComponentChildren, label: string) { function renderSideLink(href: string, active: boolean, icon: ComponentChildren, label: string) {
@@ -127,18 +101,17 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
} }
function renderNavGroup( function renderNavGroup(
group: keyof typeof expandedGroups, group: NavGroup,
title: string, title: string,
icon: ComponentChildren, icon: ComponentChildren,
active: boolean,
children: ComponentChildren children: ComponentChildren
) { ) {
const open = groupOpen(group, active); const open = expandedGroups[group];
return ( return (
<div className={`side-nav-group ${open ? 'open' : ''}`}> <div className={`side-nav-group ${open ? 'open' : ''}`}>
<button <button
type="button" type="button"
className={`side-group-trigger ${active ? 'active' : ''}`} className="side-group-trigger"
aria-expanded={open} aria-expanded={open}
onClick={() => toggleGroup(group)} onClick={() => toggleGroup(group)}
> >
@@ -155,82 +128,40 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
); );
} }
const navLayoutOptions: Array<{ mode: NavLayoutMode; label: string }> = [
{
mode: 'flat',
label: t('txt_nav_layout_flat'),
},
{
mode: 'grouped-expanded',
label: t('txt_nav_layout_grouped_expanded'),
},
{
mode: 'grouped-smart',
label: t('txt_nav_layout_grouped_smart'),
},
];
const navLayoutLabel = navLayoutOptions.find((option) => option.mode === navLayoutMode)?.label || t('txt_nav_layout_flat');
const flatNav = (
<>
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
{renderSideLink('/vault/totp', props.location === '/vault/totp', <Clock3 size={16} />, t('txt_verification_code'))}
{renderSideLink('/security/password-health', props.location === '/security/password-health', <ShieldCheck size={16} />, t('nav_password_security'))}
{renderSideLink('/generator', props.location === '/generator', <Sparkles size={16} />, t('nav_generator'))}
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
{renderSideLink('/settings', flatSettingsActive, <SettingsIcon size={16} />, t('txt_settings'))}
{renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
{isAdmin && renderSideLink('/backup', props.location === '/backup', <Cloud size={16} />, t('nav_backup_strategy'))}
{renderSideLink(props.importRoute, props.isImportRoute, <ArrowUpDown size={16} />, t('nav_import_export'))}
{isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))}
{isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))}
</>
);
const groupedNav = ( const groupedNav = (
<> <>
{renderNavGroup( {renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
'vault',
t('nav_my_vault'),
<KeyRound size={16} />,
vaultActive,
<>
{renderSubLink('/vault', props.location === '/vault', t('nav_vault_items'))}
{renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
{renderSubLink('/security/password-health', props.location === '/security/password-health', t('nav_password_security'))}
</>
)}
{renderSideLink('/generator', props.location === '/generator', <Sparkles size={16} />, t('nav_generator'))}
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))} {renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
{renderNavGroup( {renderNavGroup(
'settings', 'tools',
t('txt_settings'), t('nav_group_tools'),
<SettingsIcon size={16} />, <Sparkles size={16} />,
settingsActive,
<> <>
{renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))} {renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))} {renderSubLink('/generator', props.location === '/generator', t('nav_generator'))}
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))} {renderSubLink('/security/password-health', props.location === '/security/password-health', t('nav_password_security'))}
</>
)}
{renderNavGroup(
'data',
t('nav_group_data_backup'),
<Cloud size={16} />,
dataActive,
<>
{isAdmin && renderSubLink('/backup', props.location === '/backup', t('nav_backup_strategy'))}
{renderSubLink(props.importRoute, props.isImportRoute, t('nav_import_export'))} {renderSubLink(props.importRoute, props.isImportRoute, t('nav_import_export'))}
</> </>
)} )}
{renderNavGroup( {renderNavGroup(
'management', 'settings',
t('nav_group_management'), t('txt_settings'),
<ShieldUser size={16} />, <SettingsIcon size={16} />,
managementActive,
<> <>
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))} {renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))}
{isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))} {renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))}
</>
)}
{isAdmin &&
renderNavGroup(
'management',
t('nav_group_system_management'),
<ShieldUser size={16} />,
<>
{renderSubLink('/backup', props.location === '/backup', t('nav_backup_strategy'))}
{renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
{renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
</> </>
)} )}
</> </>
@@ -281,38 +212,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<div className="app-main"> <div className="app-main">
<aside className="app-side"> <aside className="app-side">
<div className="side-nav-main"> <div className="side-nav-main">
{navLayoutMode === 'flat' ? flatNav : groupedNav} {groupedNav}
</div>
<div className="nav-layout-control" ref={navLayoutPickerRef}>
{navLayoutPickerOpen && (
<div className="nav-layout-menu" role="menu">
{navLayoutOptions.map((option) => (
<button
key={option.mode}
type="button"
className={`nav-layout-option ${navLayoutMode === option.mode ? 'active' : ''}`}
onClick={() => setNavMode(option.mode)}
role="menuitemradio"
aria-checked={navLayoutMode === option.mode}
>
<span className="nav-layout-option-text">
<strong>{option.label}</strong>
</span>
{navLayoutMode === option.mode && <Check size={15} className="nav-layout-check" />}
</button>
))}
</div>
)}
<button
type="button"
className={`nav-layout-trigger ${navLayoutPickerOpen ? 'active' : ''}`}
aria-haspopup="menu"
aria-expanded={navLayoutPickerOpen}
onClick={() => setNavLayoutPickerOpen((open) => !open)}
title={t('txt_nav_layout')}
>
<SlidersHorizontal size={15} />
</button>
</div> </div>
</aside> </aside>
<main className="content"> <main className="content">
+32 -3
View File
@@ -12,7 +12,9 @@ export interface AppConfirmState {
confirmText?: string; confirmText?: string;
cancelText?: string; cancelText?: string;
hideCancel?: boolean; hideCancel?: boolean;
onConfirm: () => void; /** When true, dialog shows a master-password field and passes it to onConfirm. */
requireMasterPassword?: boolean;
onConfirm: (masterPassword?: string) => void;
onCancel?: () => void; onCancel?: () => void;
} }
@@ -63,6 +65,7 @@ function twoFactorProviderLabel(providerType: number): string {
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) { export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
const [methodChooserOpen, setMethodChooserOpen] = useState(false); const [methodChooserOpen, setMethodChooserOpen] = useState(false);
const [confirmPassword, setConfirmPassword] = useState('');
const availableProviders = useMemo( const availableProviders = useMemo(
() => uniqueSupportedProviders(props.pendingTotpAvailableProviders), () => uniqueSupportedProviders(props.pendingTotpAvailableProviders),
[props.pendingTotpAvailableProviders] [props.pendingTotpAvailableProviders]
@@ -70,11 +73,16 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType); const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType);
const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY; const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY;
const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN; const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN;
const requireMasterPassword = !!props.confirm?.requireMasterPassword;
useEffect(() => { useEffect(() => {
setMethodChooserOpen(false); setMethodChooserOpen(false);
}, [props.pendingTotpOpen, props.pendingTotpProviderType]); }, [props.pendingTotpOpen, props.pendingTotpProviderType]);
useEffect(() => {
setConfirmPassword('');
}, [props.confirm?.title, props.confirm?.message, requireMasterPassword]);
return ( return (
<> <>
<ConfirmDialog <ConfirmDialog
@@ -86,9 +94,30 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
confirmText={props.confirm?.confirmText} confirmText={props.confirm?.confirmText}
cancelText={props.confirm?.cancelText} cancelText={props.confirm?.cancelText}
hideCancel={props.confirm?.hideCancel} hideCancel={props.confirm?.hideCancel}
onConfirm={() => props.confirm?.onConfirm()} confirmDisabled={requireMasterPassword && !confirmPassword.trim()}
onCancel={props.confirm?.onCancel || props.onCancelConfirm} onConfirm={() => {
if (requireMasterPassword && !confirmPassword.trim()) return;
props.confirm?.onConfirm(requireMasterPassword ? confirmPassword : undefined);
setConfirmPassword('');
}}
onCancel={() => {
setConfirmPassword('');
(props.confirm?.onCancel || props.onCancelConfirm)();
}}
>
{requireMasterPassword && (
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
autoComplete="current-password"
value={confirmPassword}
onInput={(e) => setConfirmPassword((e.currentTarget as HTMLInputElement).value)}
/> />
</label>
)}
</ConfirmDialog>
<ConfirmDialog <ConfirmDialog
open={props.pendingTotpOpen} open={props.pendingTotpOpen}
+26 -18
View File
@@ -210,9 +210,19 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
return ( return (
<Switch> <Switch>
<Route path="/security/password-health"> <Route path="/security/password-health">
<div className="stack">
{props.mobileLayout && (
<div className="mobile-settings-subhead">
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
<span className="btn-icon" aria-hidden="true">{"<"}</span>
{t('txt_back')}
</button>
</div>
)}
<Suspense fallback={<RouteContentFallback />}> <Suspense fallback={<RouteContentFallback />}>
<PasswordSecurityPage ciphers={props.decryptedCiphers} loading={props.ciphersLoading} /> <PasswordSecurityPage ciphers={props.decryptedCiphers} loading={props.ciphersLoading} />
</Suspense> </Suspense>
</div>
</Route> </Route>
<Route path="/generator"> <Route path="/generator">
<Suspense fallback={<RouteContentFallback />}> <Suspense fallback={<RouteContentFallback />}>
@@ -333,6 +343,19 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<Route path="/settings"> <Route path="/settings">
{props.profile ? ( {props.profile ? (
<section className="card mobile-settings-card settings-home-card"> <section className="card mobile-settings-card settings-home-card">
<div className="settings-home-section">
<h3>{t('nav_group_tools')}</h3>
<div className="mobile-settings-links">
<Link href="/security/password-health" className="mobile-settings-link">
<ShieldCheck size={18} />
<span>{t('nav_password_security')}</span>
</Link>
<Link href={props.importRoute} className="mobile-settings-link">
<ArrowUpDown size={18} />
<span>{t('nav_import_export')}</span>
</Link>
</div>
</div>
<div className="settings-home-section"> <div className="settings-home-section">
<h3>{t('txt_settings')}</h3> <h3>{t('txt_settings')}</h3>
<div className="mobile-settings-links"> <div className="mobile-settings-links">
@@ -340,10 +363,6 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<SettingsIcon size={18} /> <SettingsIcon size={18} />
<span>{t('nav_account_settings')}</span> <span>{t('nav_account_settings')}</span>
</Link> </Link>
<Link href="/security/password-health" className="mobile-settings-link">
<ShieldCheck size={18} />
<span>{t('nav_password_security')}</span>
</Link>
<Link href="/settings/security/device-management" className="mobile-settings-link"> <Link href="/settings/security/device-management" className="mobile-settings-link">
<Shield size={18} /> <Shield size={18} />
<span>{t('nav_device_management')}</span> <span>{t('nav_device_management')}</span>
@@ -354,25 +373,14 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
</Link> </Link>
</div> </div>
</div> </div>
<div className="settings-home-section">
<h3>{t('nav_group_data_backup')}</h3>
<div className="mobile-settings-links">
<Link href={props.importRoute} className="mobile-settings-link">
<ArrowUpDown size={18} />
<span>{t('nav_import_export')}</span>
</Link>
{isAdmin && ( {isAdmin && (
<div className="settings-home-section">
<h3>{t('nav_group_system_management')}</h3>
<div className="mobile-settings-links">
<Link href="/backup" className="mobile-settings-link"> <Link href="/backup" className="mobile-settings-link">
<Cloud size={18} /> <Cloud size={18} />
<span>{t('nav_backup_strategy')}</span> <span>{t('nav_backup_strategy')}</span>
</Link> </Link>
)}
</div>
</div>
{isAdmin && (
<div className="settings-home-section">
<h3>{t('nav_group_management')}</h3>
<div className="mobile-settings-links">
<Link href="/admin" className="mobile-settings-link"> <Link href="/admin" className="mobile-settings-link">
<ShieldUser size={18} /> <ShieldUser size={18} />
<span>{t('nav_admin_panel')}</span> <span>{t('nav_admin_panel')}</span>
+15
View File
@@ -27,6 +27,7 @@ interface AuthViewsProps {
pendingAction: 'login' | 'passkey' | 'register' | 'unlock' | null; pendingAction: 'login' | 'passkey' | 'register' | 'unlock' | null;
unlockReady: boolean; unlockReady: boolean;
unlockPreparing: boolean; unlockPreparing: boolean;
sessionRefreshError?: string;
loginValues: LoginValues; loginValues: LoginValues;
pendingPasskeyPasswordEmail?: string | null; pendingPasskeyPasswordEmail?: string | null;
passkeyPassword: string; passkeyPassword: string;
@@ -50,6 +51,7 @@ interface AuthViewsProps {
onLogout: () => void; onLogout: () => void;
onTogglePasswordHint: () => void; onTogglePasswordHint: () => void;
onShowLockedPasswordHint: () => void; onShowLockedPasswordHint: () => void;
onRetrySessionRefresh: () => void;
} }
function PasswordField(props: { function PasswordField(props: {
@@ -155,6 +157,19 @@ export default function AuthViews(props: AuthViewsProps) {
{props.unlockPreparing ? ( {props.unlockPreparing ? (
<p className="muted standalone-muted">{t('txt_loading')}</p> <p className="muted standalone-muted">{t('txt_loading')}</p>
) : null} ) : null}
{props.sessionRefreshError ? (
<div className="offline-mode-notice" role="alert" aria-live="polite">
<AlertTriangle size={18} />
<div>
<strong>{props.sessionRefreshError}</strong>
<div>
<button type="button" className="auth-link-btn" onClick={props.onRetrySessionRefresh}>
{t('txt_refresh')}
</button>
</div>
</div>
</div>
) : null}
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || passkeyBusy || props.unlockPreparing || !props.unlockReady}> <button type="submit" className="btn btn-primary full" disabled={unlockBusy || passkeyBusy || props.unlockPreparing || !props.unlockReady}>
<Unlock size={16} className="btn-icon" /> <Unlock size={16} className="btn-icon" />
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')} {unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
@@ -561,13 +561,18 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
openRemoveAllDevices() { openRemoveAllDevices() {
onSetConfirm({ onSetConfirm({
title: t('txt_remove_all_devices'), title: t('txt_remove_all_devices'),
message: t('txt_remove_all_devices_and_sign_out_all_sessions'), message: `${t('txt_remove_all_devices_and_sign_out_all_sessions')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteAllAuthorizedDevices(authedFetch); if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalizedPassword = String(masterPassword || '');
if (!normalizedPassword.trim()) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
await deleteAllAuthorizedDevices(authedFetch, derived.hash);
onNotify('success', t('txt_all_devices_removed')); onNotify('success', t('txt_all_devices_removed'));
onLogoutNow(); onLogoutNow();
} catch (error) { } catch (error) {
+68 -16
View File
@@ -1,5 +1,6 @@
import { useMemo } from 'preact/hooks'; import { useMemo } from 'preact/hooks';
import { createInvite, deleteAllInvites, deleteInvalidInvites, deleteInvite, deleteUser, setUserStatus } from '@/lib/api/admin'; import { createInvite, deleteAllInvites, deleteInvalidInvites, deleteInvite, deleteUser, setUserStatus } from '@/lib/api/admin';
import { deriveLoginHash } from '@/lib/api/auth';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { AppConfirmState } from '@/components/AppGlobalOverlays'; import type { AppConfirmState } from '@/components/AppGlobalOverlays';
import type { AuthedFetch } from '@/lib/api/shared'; import type { AuthedFetch } from '@/lib/api/shared';
@@ -8,6 +9,8 @@ type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
interface UseAdminActionsOptions { interface UseAdminActionsOptions {
authedFetch: AuthedFetch; authedFetch: AuthedFetch;
email: string;
defaultKdfIterations: number;
onNotify: Notify; onNotify: Notify;
onSetConfirm: (next: AppConfirmState | null) => void; onSetConfirm: (next: AppConfirmState | null) => void;
refetchUsers: () => Promise<unknown>; refetchUsers: () => Promise<unknown>;
@@ -15,7 +18,24 @@ interface UseAdminActionsOptions {
} }
export default function useAdminActions(options: UseAdminActionsOptions) { export default function useAdminActions(options: UseAdminActionsOptions) {
const { authedFetch, onNotify, onSetConfirm, refetchUsers, refetchInvites } = options; const {
authedFetch,
email,
defaultKdfIterations,
onNotify,
onSetConfirm,
refetchUsers,
refetchInvites,
} = options;
async function withMasterPasswordHash(masterPassword: string | undefined): Promise<string> {
const normalizedEmail = String(email || '').trim().toLowerCase();
const normalizedPassword = String(masterPassword || '');
if (!normalizedEmail) throw new Error(t('txt_profile_unavailable'));
if (!normalizedPassword.trim()) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(normalizedEmail, normalizedPassword, defaultKdfIterations);
return derived.hash;
}
return useMemo( return useMemo(
() => ({ () => ({
@@ -26,35 +46,61 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
}, },
async createInvite(hours: number) { async createInvite(hours: number) {
onSetConfirm({
title: t('txt_create_timed_invite'),
message: t('txt_enter_master_password_to_continue'),
requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null);
void (async () => {
try { try {
await createInvite(authedFetch, hours); const hash = await withMasterPasswordHash(masterPassword);
await createInvite(authedFetch, hours, hash);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_invite_created')); onNotify('success', t('txt_invite_created'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed'));
} }
})();
},
});
}, },
async toggleUserStatus(userId: string, status: 'active' | 'banned') { async toggleUserStatus(userId: string, status: 'active' | 'banned') {
const nextStatus = status === 'active' ? 'banned' : 'active';
onSetConfirm({
title: nextStatus === 'banned' ? t('txt_ban') : t('txt_unban'),
message: t('txt_enter_master_password_to_continue'),
danger: nextStatus === 'banned',
requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null);
void (async () => {
try { try {
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active'); const hash = await withMasterPasswordHash(masterPassword);
await setUserStatus(authedFetch, userId, nextStatus, hash);
await refetchUsers(); await refetchUsers();
onNotify('success', t('txt_user_status_updated')); onNotify('success', t('txt_user_status_updated'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed'));
} }
})();
},
});
}, },
async deleteInvite(code: string) { async deleteInvite(code: string) {
onSetConfirm({ onSetConfirm({
title: t('txt_delete_invite'), title: t('txt_delete_invite'),
message: t('txt_delete_invite_confirm_message'), message: `${t('txt_delete_invite_confirm_message')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteInvite(authedFetch, code); const hash = await withMasterPasswordHash(masterPassword);
await deleteInvite(authedFetch, code, hash);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_invite_deleted')); onNotify('success', t('txt_invite_deleted'));
} catch (error) { } catch (error) {
@@ -68,13 +114,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
async deleteInvalidInvites() { async deleteInvalidInvites() {
onSetConfirm({ onSetConfirm({
title: t('txt_delete_invalid_invites'), title: t('txt_delete_invalid_invites'),
message: t('txt_delete_invalid_invites_confirm_message'), message: `${t('txt_delete_invalid_invites_confirm_message')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteInvalidInvites(authedFetch); const hash = await withMasterPasswordHash(masterPassword);
await deleteInvalidInvites(authedFetch, hash);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_invalid_invites_deleted')); onNotify('success', t('txt_invalid_invites_deleted'));
} catch (error) { } catch (error) {
@@ -88,13 +136,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
async deleteAllInvites() { async deleteAllInvites() {
onSetConfirm({ onSetConfirm({
title: t('txt_delete_all_invites'), title: t('txt_delete_all_invites'),
message: t('txt_delete_all_invite_codes_active_inactive'), message: `${t('txt_delete_all_invite_codes_active_inactive')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteAllInvites(authedFetch); const hash = await withMasterPasswordHash(masterPassword);
await deleteAllInvites(authedFetch, hash);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_all_invites_deleted')); onNotify('success', t('txt_all_invites_deleted'));
} catch (error) { } catch (error) {
@@ -108,13 +158,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
async deleteUser(userId: string) { async deleteUser(userId: string) {
onSetConfirm({ onSetConfirm({
title: t('txt_delete_user'), title: t('txt_delete_user'),
message: t('txt_delete_this_user_and_all_user_data'), message: `${t('txt_delete_this_user_and_all_user_data')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteUser(authedFetch, userId); const hash = await withMasterPasswordHash(masterPassword);
await deleteUser(authedFetch, userId, hash);
await refetchUsers(); await refetchUsers();
onNotify('success', t('txt_user_deleted')); onNotify('success', t('txt_user_deleted'));
} catch (error) { } catch (error) {
@@ -125,6 +177,6 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
}); });
}, },
}), }),
[authedFetch, onNotify, onSetConfirm, refetchInvites, refetchUsers] [authedFetch, defaultKdfIterations, email, onNotify, onSetConfirm, refetchInvites, refetchUsers]
); );
} }
+29 -12
View File
@@ -15,45 +15,62 @@ export async function listAdminInvites(authedFetch: AuthedFetch): Promise<AdminI
return body?.data || []; return body?.data || [];
} }
export async function createInvite(authedFetch: AuthedFetch, hours: number): Promise<void> { export async function createInvite(authedFetch: AuthedFetch, hours: number, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/admin/invites', { const resp = await authedFetch('/api/admin/invites', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expiresInHours: hours }), body: JSON.stringify({ expiresInHours: hours, masterPasswordHash }),
}); });
if (!resp.ok) throw new Error('Create invite failed'); if (!resp.ok) throw new Error('Create invite failed');
} }
export async function deleteInvite(authedFetch: AuthedFetch, code: string): Promise<void> { export async function deleteInvite(authedFetch: AuthedFetch, code: string, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, { method: 'DELETE' }); const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete invite failed'); if (!resp.ok) throw new Error('Delete invite failed');
} }
export async function deleteInvalidInvites(authedFetch: AuthedFetch): Promise<void> { export async function deleteInvalidInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/admin/invites?scope=invalid', { method: 'DELETE' }); const resp = await authedFetch('/api/admin/invites?scope=invalid', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete invalid invites failed'); if (!resp.ok) throw new Error('Delete invalid invites failed');
} }
export async function deleteAllInvites(authedFetch: AuthedFetch): Promise<void> { export async function deleteAllInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/admin/invites', { method: 'DELETE' }); const resp = await authedFetch('/api/admin/invites', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete all invites failed'); if (!resp.ok) throw new Error('Delete all invites failed');
} }
export async function setUserStatus( export async function setUserStatus(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
userId: string, userId: string,
status: 'active' | 'banned' status: 'active' | 'banned',
masterPasswordHash: string
): Promise<void> { ): Promise<void> {
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}/status`, { const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}/status`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }), body: JSON.stringify({ status, masterPasswordHash }),
}); });
if (!resp.ok) throw new Error('Update user status failed'); if (!resp.ok) throw new Error('Update user status failed');
} }
export async function deleteUser(authedFetch: AuthedFetch, userId: string): Promise<void> { export async function deleteUser(authedFetch: AuthedFetch, userId: string, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }); const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete user failed'); if (!resp.ok) throw new Error('Delete user failed');
} }
+20 -7
View File
@@ -42,6 +42,7 @@ interface RefreshFailure {
ok: false; ok: false;
transient: boolean; transient: boolean;
error: string; error: string;
retryAfterMs?: number;
} }
interface RefreshSuccess { interface RefreshSuccess {
@@ -333,8 +334,8 @@ export async function loginWithAccountPasskeyAssertion(assertion: AccountPasskey
return json; return json;
} }
function isTransientRefreshStatus(status: number): boolean { function isPermanentRefreshFailure(status: number, errorCode: string | undefined): boolean {
return status === 0 || status === 429 || status >= 500; return status === 400 && (errorCode === 'invalid_grant' || errorCode === 'invalid_request');
} }
export async function refreshAccessToken(session: SessionState): Promise<RefreshResult> { export async function refreshAccessToken(session: SessionState): Promise<RefreshResult> {
@@ -346,6 +347,8 @@ export async function refreshAccessToken(session: SessionState): Promise<Refresh
try { try {
const resp = await fetch('/identity/connect/token', { const resp = await fetch('/identity/connect/token', {
method: 'POST', method: 'POST',
cache: 'no-store',
credentials: 'same-origin',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}), ...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
@@ -354,15 +357,19 @@ export async function refreshAccessToken(session: SessionState): Promise<Refresh
}); });
if (!resp.ok) { if (!resp.ok) {
const json = await parseJson<TokenError>(resp); const json = await parseJson<TokenError>(resp);
const retryAfterSeconds = Number(resp.headers.get('Retry-After') || 0);
return { return {
ok: false, ok: false,
transient: isTransientRefreshStatus(resp.status), transient: !isPermanentRefreshFailure(resp.status, json?.error),
error: translateServerError(json?.error_description || json?.error, t('txt_session_refresh_failed')), error: translateServerError(json?.error_description || json?.error, t('txt_session_refresh_temporarily_unavailable')),
...(Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
? { retryAfterMs: retryAfterSeconds * 1000 }
: {}),
}; };
} }
const json = await parseJson<TokenSuccess>(resp); const json = await parseJson<TokenSuccess>(resp);
if (!json?.access_token) { if (!json?.access_token) {
return { ok: false, transient: false, error: t('txt_session_refresh_failed') }; return { ok: false, transient: true, error: t('txt_session_refresh_temporarily_unavailable') };
} }
return { ok: true, token: json }; return { ok: true, token: json };
} catch (error) { } catch (error) {
@@ -400,6 +407,8 @@ export async function revokeCurrentSession(session: SessionState | null): Promis
} }
await fetch('/identity/connect/revocation', { await fetch('/identity/connect/revocation', {
method: 'POST', method: 'POST',
cache: 'no-store',
credentials: 'same-origin',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
...(session?.accessToken ? { Authorization: `Bearer ${session.accessToken}` } : {}), ...(session?.accessToken ? { Authorization: `Bearer ${session.accessToken}` } : {}),
@@ -1140,8 +1149,12 @@ export async function updateAuthorizedDeviceName(
if (!resp.ok) throw new Error(t('txt_update_device_note_failed')); if (!resp.ok) throw new Error(t('txt_update_device_note_failed'));
} }
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> { export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/devices', { method: 'DELETE' }); const resp = await authedFetch('/api/devices', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed')); if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
} }
+44 -9
View File
@@ -199,26 +199,43 @@ function decodeJwtExp(accessToken: string | undefined): number | null {
} }
} }
async function maybeRefreshSession(session: SessionState): Promise<SessionState | null> { type SessionRefreshOutcome =
if (!session.refreshToken && session.authMode !== 'web-cookie') return session.accessToken ? session : null; | { kind: 'success'; session: SessionState }
| { kind: 'transient'; session: SessionState; message: string; retryAfterMs?: number }
| { kind: 'expired' };
async function maybeRefreshSession(session: SessionState): Promise<SessionRefreshOutcome> {
if (!session.refreshToken && session.authMode !== 'web-cookie') {
return session.accessToken ? { kind: 'success', session } : { kind: 'expired' };
}
const exp = decodeJwtExp(session.accessToken); const exp = decodeJwtExp(session.accessToken);
const nowSeconds = Math.floor(Date.now() / 1000); const nowSeconds = Math.floor(Date.now() / 1000);
if (session.accessToken && exp !== null && exp - nowSeconds > 60) { if (session.accessToken && exp !== null && exp - nowSeconds > 60) {
return session; return { kind: 'success', session };
} }
const refreshed = await refreshAccessToken(session); const refreshed = await refreshAccessToken(session);
if (!refreshed.ok) { if (!refreshed.ok) {
if (refreshed.transient) return session; if (refreshed.transient) {
return session.accessToken && exp !== null && exp > nowSeconds ? session : null; return {
kind: 'transient',
session,
message: refreshed.error || t('txt_session_refresh_temporarily_unavailable'),
retryAfterMs: refreshed.retryAfterMs,
};
}
return { kind: 'expired' };
} }
return { return {
kind: 'success',
session: {
...session, ...session,
accessToken: refreshed.token.access_token, accessToken: refreshed.token.access_token,
refreshToken: refreshed.token.refresh_token || session.refreshToken, refreshToken: refreshed.token.refresh_token || session.refreshToken,
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'), authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
},
}; };
} }
@@ -388,25 +405,41 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
export async function hydrateLockedSession( export async function hydrateLockedSession(
session: SessionState, session: SessionState,
fallbackProfile: Profile | null = null fallbackProfile: Profile | null = null
): Promise<{ session: SessionState | null; profile: Profile | null }> { ): Promise<
| { kind: 'ready'; session: SessionState; profile: Profile | null }
| { kind: 'transient'; session: SessionState; profile: Profile | null; message: string; retryAfterMs?: number }
| { kind: 'expired'; session: null; profile: null }
> {
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email); const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
if (hasOfflineUnlock && browserReportsOffline()) { if (hasOfflineUnlock && browserReportsOffline()) {
return { return {
kind: 'ready',
session, session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email), profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
}; };
} }
const refreshedSession = await maybeRefreshSession(session); const refreshOutcome = await maybeRefreshSession(session);
if (!refreshedSession?.accessToken) { if (refreshOutcome.kind === 'expired') {
return { kind: 'expired', session: null, profile: null };
}
if (refreshOutcome.kind === 'transient') {
if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) { if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) {
return { return {
kind: 'ready',
session, session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email), profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
}; };
} }
return { session: null, profile: null }; return {
kind: 'transient',
session,
profile: fallbackProfile,
message: refreshOutcome.message,
retryAfterMs: refreshOutcome.retryAfterMs,
};
} }
const refreshedSession = refreshOutcome.session;
try { try {
const profile = await getProfile( const profile = await getProfile(
createAuthedFetch( createAuthedFetch(
@@ -415,11 +448,13 @@ export async function hydrateLockedSession(
) )
); );
return { return {
kind: 'ready',
session: refreshedSession, session: refreshedSession,
profile, profile,
}; };
} catch { } catch {
return { return {
kind: 'ready',
session: refreshedSession, session: refreshedSession,
profile: fallbackProfile, profile: fallbackProfile,
}; };
+3
View File
@@ -12,6 +12,8 @@ const de: Record<string, string> = {
"nav_import_export": "Import und Export", "nav_import_export": "Import und Export",
"nav_group_data_backup": "Daten & Backup", "nav_group_data_backup": "Daten & Backup",
"nav_group_management": "Verwaltung", "nav_group_management": "Verwaltung",
"nav_group_tools": "Werkzeuge",
"nav_group_system_management": "Systemverwaltung",
"txt_settings_appearance": "Erscheinungsbild", "txt_settings_appearance": "Erscheinungsbild",
"txt_theme": "Design", "txt_theme": "Design",
"txt_use_system_theme": "Systemdesign verwenden", "txt_use_system_theme": "Systemdesign verwenden",
@@ -979,6 +981,7 @@ const de: Record<string, string> = {
"txt_save_profile_failed": "Fehler beim Speichern des Profils", "txt_save_profile_failed": "Fehler beim Speichern des Profils",
"txt_search_sends": "Sendungen suchen...", "txt_search_sends": "Sendungen suchen...",
"txt_session_refresh_failed": "Sitzungsaktualisierung fehlgeschlagen. Bitte melden Sie sich erneut an.", "txt_session_refresh_failed": "Sitzungsaktualisierung fehlgeschlagen. Bitte melden Sie sich erneut an.",
"txt_session_refresh_temporarily_unavailable": "Die Sitzung kann vorübergehend nicht geprüft werden. Die Anmeldung bleibt erhalten und wird erneut versucht.",
"txt_search_your_secure_vault": "Ihren sicheren Tresor durchsuchen...", "txt_search_your_secure_vault": "Ihren sicheren Tresor durchsuchen...",
"txt_search_items_count": "In {count} Einträgen suchen...", "txt_search_items_count": "In {count} Einträgen suchen...",
"txt_clear_search": "Suche löschen", "txt_clear_search": "Suche löschen",
+3
View File
@@ -35,6 +35,8 @@ const en: Record<string, string> = {
"nav_import_export": "Import & Export", "nav_import_export": "Import & Export",
"nav_group_data_backup": "Data & Backup", "nav_group_data_backup": "Data & Backup",
"nav_group_management": "Management", "nav_group_management": "Management",
"nav_group_tools": "Tools",
"nav_group_system_management": "System Management",
"txt_settings_appearance": "Appearance", "txt_settings_appearance": "Appearance",
"txt_theme": "Theme", "txt_theme": "Theme",
"txt_use_system_theme": "Use system theme", "txt_use_system_theme": "Use system theme",
@@ -1002,6 +1004,7 @@ const en: Record<string, string> = {
"txt_save_profile_failed": "Save profile failed", "txt_save_profile_failed": "Save profile failed",
"txt_search_sends": "Search sends...", "txt_search_sends": "Search sends...",
"txt_session_refresh_failed": "Session refresh failed. Please sign in again.", "txt_session_refresh_failed": "Session refresh failed. Please sign in again.",
"txt_session_refresh_temporarily_unavailable": "Session verification is temporarily unavailable. Your login is preserved and will retry.",
"txt_search_your_secure_vault": "Search your secure vault...", "txt_search_your_secure_vault": "Search your secure vault...",
"txt_search_items_count": "Search within {count} items...", "txt_search_items_count": "Search within {count} items...",
"txt_clear_search": "Clear search", "txt_clear_search": "Clear search",
+3
View File
@@ -12,6 +12,8 @@ const es: Record<string, string> = {
"nav_import_export": "Importar y exportar", "nav_import_export": "Importar y exportar",
"nav_group_data_backup": "Datos y copias", "nav_group_data_backup": "Datos y copias",
"nav_group_management": "Gestión", "nav_group_management": "Gestión",
"nav_group_tools": "Herramientas",
"nav_group_system_management": "Administración del sistema",
"txt_settings_appearance": "Apariencia", "txt_settings_appearance": "Apariencia",
"txt_theme": "Tema", "txt_theme": "Tema",
"txt_use_system_theme": "Usar tema del sistema", "txt_use_system_theme": "Usar tema del sistema",
@@ -979,6 +981,7 @@ const es: Record<string, string> = {
"txt_save_profile_failed": "Error al guardar perfil", "txt_save_profile_failed": "Error al guardar perfil",
"txt_search_sends": "Buscar envíos...", "txt_search_sends": "Buscar envíos...",
"txt_session_refresh_failed": "Error al actualizar la sesión. Inicia sesión de nuevo.", "txt_session_refresh_failed": "Error al actualizar la sesión. Inicia sesión de nuevo.",
"txt_session_refresh_temporarily_unavailable": "La sesión no se puede verificar temporalmente. Tu inicio de sesión se conserva y se volverá a intentar.",
"txt_search_your_secure_vault": "Buscar en su bóveda segura...", "txt_search_your_secure_vault": "Buscar en su bóveda segura...",
"txt_search_items_count": "Buscar entre {count} elementos...", "txt_search_items_count": "Buscar entre {count} elementos...",
"txt_clear_search": "Limpiar búsqueda", "txt_clear_search": "Limpiar búsqueda",
+3
View File
@@ -12,6 +12,8 @@ const fi: Record<string, string> = {
"nav_import_export": "Tuonti ja Vienti", "nav_import_export": "Tuonti ja Vienti",
"nav_group_data_backup": "Data & Varmuuskopiointi", "nav_group_data_backup": "Data & Varmuuskopiointi",
"nav_group_management": "Hallinta", "nav_group_management": "Hallinta",
"nav_group_tools": "Työkalut",
"nav_group_system_management": "Järjestelmän hallinta",
"txt_settings_appearance": "Ulkoasu", "txt_settings_appearance": "Ulkoasu",
"txt_theme": "Teema", "txt_theme": "Teema",
"txt_use_system_theme": "Käytä järjestelmän teemaa", "txt_use_system_theme": "Käytä järjestelmän teemaa",
@@ -979,6 +981,7 @@ const fi: Record<string, string> = {
"txt_save_profile_failed": "Profiilin tallennus epäonnistui", "txt_save_profile_failed": "Profiilin tallennus epäonnistui",
"txt_search_sends": "Hae lähetyksiä...", "txt_search_sends": "Hae lähetyksiä...",
"txt_session_refresh_failed": "Istunnon päivitys epäonnistui. Kirjaudu sisään uudelleen.", "txt_session_refresh_failed": "Istunnon päivitys epäonnistui. Kirjaudu sisään uudelleen.",
"txt_session_refresh_temporarily_unavailable": "Istuntoa ei voida tarkistaa juuri nyt. Kirjautuminen säilytetään ja tarkistusta yritetään uudelleen.",
"txt_search_your_secure_vault": "Hae turvallisesta holvistasi...", "txt_search_your_secure_vault": "Hae turvallisesta holvistasi...",
"txt_search_items_count": "Hae {count} nimikkeen joukosta...", "txt_search_items_count": "Hae {count} nimikkeen joukosta...",
"txt_clear_search": "Tyhjennä haku", "txt_clear_search": "Tyhjennä haku",
+3
View File
@@ -12,6 +12,8 @@ const fr: Record<string, string> = {
"nav_import_export": "Importer & Exporter", "nav_import_export": "Importer & Exporter",
"nav_group_data_backup": "Données & Sauvegarde", "nav_group_data_backup": "Données & Sauvegarde",
"nav_group_management": "Gestion", "nav_group_management": "Gestion",
"nav_group_tools": "Outils",
"nav_group_system_management": "Administration système",
"txt_settings_appearance": "Apparence", "txt_settings_appearance": "Apparence",
"txt_theme": "Thème", "txt_theme": "Thème",
"txt_use_system_theme": "Utiliser le thème du système", "txt_use_system_theme": "Utiliser le thème du système",
@@ -979,6 +981,7 @@ const fr: Record<string, string> = {
"txt_save_profile_failed": "L'enregistrement du profil a échoué", "txt_save_profile_failed": "L'enregistrement du profil a échoué",
"txt_search_sends": "Rechercher des envois...", "txt_search_sends": "Rechercher des envois...",
"txt_session_refresh_failed": "L'actualisation de la session a échoué. Veuillez vous reconnecter.", "txt_session_refresh_failed": "L'actualisation de la session a échoué. Veuillez vous reconnecter.",
"txt_session_refresh_temporarily_unavailable": "La session ne peut pas être vérifiée temporairement. Votre connexion est conservée et une nouvelle tentative sera effectuée.",
"txt_search_your_secure_vault": "Recherchez dans votre coffre-fort sécurisé...", "txt_search_your_secure_vault": "Recherchez dans votre coffre-fort sécurisé...",
"txt_search_items_count": "Rechercher parmi {count} éléments...", "txt_search_items_count": "Rechercher parmi {count} éléments...",
"txt_clear_search": "Effacer la recherche", "txt_clear_search": "Effacer la recherche",
+3
View File
@@ -12,6 +12,8 @@ const it: Record<string, string> = {
"nav_import_export": "Importa ed Esporta", "nav_import_export": "Importa ed Esporta",
"nav_group_data_backup": "Dati e Backup", "nav_group_data_backup": "Dati e Backup",
"nav_group_management": "Gestione", "nav_group_management": "Gestione",
"nav_group_tools": "Strumenti",
"nav_group_system_management": "Gestione del sistema",
"txt_settings_appearance": "Aspetto", "txt_settings_appearance": "Aspetto",
"txt_theme": "Tema", "txt_theme": "Tema",
"txt_use_system_theme": "Usa il tema del sistema", "txt_use_system_theme": "Usa il tema del sistema",
@@ -979,6 +981,7 @@ const it: Record<string, string> = {
"txt_save_profile_failed": "Salvataggio Profilo fallito", "txt_save_profile_failed": "Salvataggio Profilo fallito",
"txt_search_sends": "Cerca invii...", "txt_search_sends": "Cerca invii...",
"txt_session_refresh_failed": "Aggiornamento della sessione fallito. Per favore, accedi di nuovo.", "txt_session_refresh_failed": "Aggiornamento della sessione fallito. Per favore, accedi di nuovo.",
"txt_session_refresh_temporarily_unavailable": "La sessione non può essere verificata temporaneamente. L'accesso viene mantenuto e verrà effettuato un nuovo tentativo.",
"txt_search_your_secure_vault": "Cerca nella tua cassaforte sicura...", "txt_search_your_secure_vault": "Cerca nella tua cassaforte sicura...",
"txt_search_items_count": "Cerca in {count} elementi...", "txt_search_items_count": "Cerca in {count} elementi...",
"txt_clear_search": "Cancella Ricerca", "txt_clear_search": "Cancella Ricerca",
+3
View File
@@ -13,6 +13,8 @@ const ru: Record<string, string> = {
"nav_import_export": "Импорт и экспорт", "nav_import_export": "Импорт и экспорт",
"nav_group_data_backup": "Данные и резервные копии", "nav_group_data_backup": "Данные и резервные копии",
"nav_group_management": "Управление", "nav_group_management": "Управление",
"nav_group_tools": "Инструменты",
"nav_group_system_management": "Управление системой",
"txt_settings_appearance": "Внешний вид", "txt_settings_appearance": "Внешний вид",
"txt_theme": "Тема", "txt_theme": "Тема",
"txt_use_system_theme": "Использовать системную тему", "txt_use_system_theme": "Использовать системную тему",
@@ -979,6 +981,7 @@ const ru: Record<string, string> = {
"txt_save_profile_failed": "Сохранить профиль не удалось", "txt_save_profile_failed": "Сохранить профиль не удалось",
"txt_search_sends": "Поиск отправляет...", "txt_search_sends": "Поиск отправляет...",
"txt_session_refresh_failed": "Не удалось обновить сеанс. Войдите снова.", "txt_session_refresh_failed": "Не удалось обновить сеанс. Войдите снова.",
"txt_session_refresh_temporarily_unavailable": "Сеанс временно не удаётся проверить. Вход сохранён, проверка будет повторена.",
"txt_search_your_secure_vault": "Найдите свое безопасное хранилище...", "txt_search_your_secure_vault": "Найдите свое безопасное хранилище...",
"txt_search_items_count": "Поиск по {count} элементам...", "txt_search_items_count": "Поиск по {count} элементам...",
"txt_clear_search": "Очистить поиск", "txt_clear_search": "Очистить поиск",
+3
View File
@@ -12,6 +12,8 @@ const sv: Record<string, string> = {
"nav_import_export": "Importera och Exportera", "nav_import_export": "Importera och Exportera",
"nav_group_data_backup": "Data och Säkerhetskopiering", "nav_group_data_backup": "Data och Säkerhetskopiering",
"nav_group_management": "Hantering", "nav_group_management": "Hantering",
"nav_group_tools": "Verktyg",
"nav_group_system_management": "Systemadministration",
"txt_settings_appearance": "Utseende", "txt_settings_appearance": "Utseende",
"txt_theme": "Tema", "txt_theme": "Tema",
"txt_use_system_theme": "Använd systemtema", "txt_use_system_theme": "Använd systemtema",
@@ -979,6 +981,7 @@ const sv: Record<string, string> = {
"txt_save_profile_failed": "Misslyckades med att spara profil", "txt_save_profile_failed": "Misslyckades med att spara profil",
"txt_search_sends": "Sök sändningar...", "txt_search_sends": "Sök sändningar...",
"txt_session_refresh_failed": "Sessionsuppdatering misslyckades. Vänligen logga in igen.", "txt_session_refresh_failed": "Sessionsuppdatering misslyckades. Vänligen logga in igen.",
"txt_session_refresh_temporarily_unavailable": "Sessionen kan inte verifieras tillfälligt. Inloggningen bevaras och ett nytt försök görs.",
"txt_search_your_secure_vault": "Sök i ditt säkra valv...", "txt_search_your_secure_vault": "Sök i ditt säkra valv...",
"txt_search_items_count": "Sök bland {count} objekt...", "txt_search_items_count": "Sök bland {count} objekt...",
"txt_clear_search": "Rensa sökning", "txt_clear_search": "Rensa sökning",
+3
View File
@@ -15,6 +15,8 @@ const zhCN: Record<string, string> = {
"nav_import_export": "导入导出", "nav_import_export": "导入导出",
"nav_group_data_backup": "数据与备份", "nav_group_data_backup": "数据与备份",
"nav_group_management": "管理", "nav_group_management": "管理",
"nav_group_tools": "工具",
"nav_group_system_management": "系统管理",
"txt_settings_appearance": "外观", "txt_settings_appearance": "外观",
"txt_theme": "主题", "txt_theme": "主题",
"txt_use_system_theme": "使用系统主题", "txt_use_system_theme": "使用系统主题",
@@ -982,6 +984,7 @@ const zhCN: Record<string, string> = {
"txt_save_profile_failed": "保存资料失败", "txt_save_profile_failed": "保存资料失败",
"txt_search_sends": "搜索 Send...", "txt_search_sends": "搜索 Send...",
"txt_session_refresh_failed": "会话刷新失败,请重新登录", "txt_session_refresh_failed": "会话刷新失败,请重新登录",
"txt_session_refresh_temporarily_unavailable": "暂时无法验证会话,登录状态已保留,稍后会自动重试",
"txt_search_your_secure_vault": "搜索你的密码库...", "txt_search_your_secure_vault": "搜索你的密码库...",
"txt_search_items_count": "共 {count} 项中搜索...", "txt_search_items_count": "共 {count} 项中搜索...",
"txt_clear_search": "清空搜索", "txt_clear_search": "清空搜索",
+3
View File
@@ -15,6 +15,8 @@ const zhTW: Record<string, string> = {
"nav_import_export": "導入導出", "nav_import_export": "導入導出",
"nav_group_data_backup": "資料與備份", "nav_group_data_backup": "資料與備份",
"nav_group_management": "管理", "nav_group_management": "管理",
"nav_group_tools": "工具",
"nav_group_system_management": "系統管理",
"txt_settings_appearance": "外觀", "txt_settings_appearance": "外觀",
"txt_theme": "主題", "txt_theme": "主題",
"txt_use_system_theme": "使用系統主題", "txt_use_system_theme": "使用系統主題",
@@ -982,6 +984,7 @@ const zhTW: Record<string, string> = {
"txt_save_profile_failed": "保存資料失敗", "txt_save_profile_failed": "保存資料失敗",
"txt_search_sends": "搜索 Send...", "txt_search_sends": "搜索 Send...",
"txt_session_refresh_failed": "會話刷新失敗,請重新登入", "txt_session_refresh_failed": "會話刷新失敗,請重新登入",
"txt_session_refresh_temporarily_unavailable": "暫時無法驗證會話,登入狀態已保留,稍後會自動重試",
"txt_search_your_secure_vault": "搜索你的密碼庫...", "txt_search_your_secure_vault": "搜索你的密碼庫...",
"txt_search_items_count": "在共 {count} 項中搜索...", "txt_search_items_count": "在共 {count} 項中搜索...",
"txt_clear_search": "清空搜索", "txt_clear_search": "清空搜索",
+1 -1
View File
@@ -677,7 +677,7 @@ h4 {
} }
.card { .card {
margin-bottom: 0px; margin-bottom: 10;
padding: 14px; padding: 14px;
} }