mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
merge: resolve conflicts with dev, fix 7 Snyk security issues
- Resolve translation conflicts (keep both journey + OAuth scope keys) - Resolve migrations.ts (dev OAuth migrations + journey migrations) - Fix hono directory traversal, response splitting, input validation (CVE-2026-39407/08/09/10) - Fix @hono/node-server directory traversal (CVE-2026-39406) - Fix nodemailer CRLF injection (upgrade to 8.0.5)
This commit is contained in:
@@ -7,7 +7,7 @@ import { User, Addon } from '../types';
|
||||
import { updateJwtSecret } from '../config';
|
||||
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
|
||||
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
|
||||
import { revokeUserSessions } from '../mcp';
|
||||
import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp';
|
||||
import { validatePassword } from './passwordPolicy';
|
||||
import { getPhotoProviderConfig } from './memories/helpersService';
|
||||
import { send as sendNotification } from './notificationService';
|
||||
@@ -603,6 +603,30 @@ export function deleteMcpToken(id: string) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// ── OAuth Sessions ─────────────────────────────────────────────────────────
|
||||
|
||||
export function listOAuthSessions() {
|
||||
const rows = db.prepare(`
|
||||
SELECT ot.id, ot.client_id, oc.name AS client_name, ot.user_id, u.username,
|
||||
ot.scopes, ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at
|
||||
FROM oauth_tokens ot
|
||||
JOIN oauth_clients oc ON ot.client_id = oc.client_id
|
||||
JOIN users u ON u.id = ot.user_id
|
||||
WHERE ot.revoked_at IS NULL
|
||||
AND ot.refresh_token_expires_at > CURRENT_TIMESTAMP
|
||||
ORDER BY ot.created_at DESC
|
||||
`).all() as (Record<string, unknown> & { scopes: string })[];
|
||||
return rows.map(r => ({ ...r, scopes: JSON.parse(r.scopes) }));
|
||||
}
|
||||
|
||||
export function revokeOAuthSession(id: string) {
|
||||
const row = db.prepare('SELECT id, user_id, client_id FROM oauth_tokens WHERE id = ?').get(id) as { id: number; user_id: number; client_id: string } | undefined;
|
||||
if (!row) return { error: 'Session not found', status: 404 };
|
||||
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(id);
|
||||
revokeUserSessionsForClient(row.user_id, row.client_id);
|
||||
return {};
|
||||
}
|
||||
|
||||
// ── JWT Rotation ───────────────────────────────────────────────────────────
|
||||
|
||||
export function rotateJwtSecret(): { error?: string; status?: number } {
|
||||
|
||||
@@ -19,26 +19,62 @@ import {
|
||||
SyncAlbumResult,
|
||||
AssetInfo
|
||||
} from './helpersService';
|
||||
import { send as sendNotification } from '../notificationService';
|
||||
|
||||
const SYNOLOGY_PROVIDER = 'synologyphotos';
|
||||
const SYNOLOGY_ENDPOINT_PATH = '/photo/webapi/entry.cgi';
|
||||
// Users provide the full base URL including the Photos app path (e.g. https://nas:5001/photo).
|
||||
// The API endpoint is always at {base_url}/webapi/entry.cgi.
|
||||
const SYNOLOGY_ENDPOINT_PATH = '/webapi/entry.cgi';
|
||||
|
||||
const SYNOLOGY_ERROR_MESSAGES: Record<number, string> = {
|
||||
101: 'Missing API, method, or version parameter.',
|
||||
102: 'Requested API does not exist.',
|
||||
103: 'Requested method does not exist.',
|
||||
104: 'Requested API version is not supported.',
|
||||
105: 'Insufficient privilege.',
|
||||
106: 'Connection timeout.',
|
||||
107: 'Multiple logins blocked from this IP.',
|
||||
117: 'Manager privilege required.',
|
||||
119: 'Session is invalid or expired.',
|
||||
400: 'Invalid credentials.',
|
||||
401: 'Session expired or account disabled.',
|
||||
402: 'No permission to use this account.',
|
||||
403: 'Two-factor authentication code required.',
|
||||
404: 'Two-factor authentication failed.',
|
||||
406: 'Two-factor authentication is enforced for this account.',
|
||||
407: 'Maximum login attempts reached.',
|
||||
408: 'Password expired.',
|
||||
409: 'Remote password expired.',
|
||||
410: 'Password must be changed before login.',
|
||||
412: 'Guest account cannot log in.',
|
||||
413: 'OTP system files are corrupted.',
|
||||
414: 'Unable to log in.',
|
||||
416: 'Unable to log in.',
|
||||
417: 'OTP system is full.',
|
||||
498: 'System is upgrading.',
|
||||
499: 'System is not ready.',
|
||||
};
|
||||
|
||||
interface SynologyUserRecord {
|
||||
synology_url?: string | null;
|
||||
synology_username?: string | null;
|
||||
synology_password?: string | null;
|
||||
synology_sid?: string | null;
|
||||
synology_did?: string | null;
|
||||
synology_skip_ssl?: number | null;
|
||||
};
|
||||
|
||||
interface SynologyCredentials {
|
||||
synology_url: string;
|
||||
synology_username: string;
|
||||
synology_password: string;
|
||||
synology_skip_ssl: boolean;
|
||||
}
|
||||
|
||||
interface SynologySettings {
|
||||
synology_url: string;
|
||||
synology_username: string;
|
||||
synology_skip_ssl: boolean;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
@@ -84,7 +120,7 @@ interface SynologyPhotoItem {
|
||||
|
||||
function _readSynologyUser(userId: number, columns: string[]): ServiceResult<SynologyUserRecord> {
|
||||
try {
|
||||
const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
|
||||
const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid, synology_did, synology_skip_ssl FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
|
||||
|
||||
if (!row) {
|
||||
return fail('User not found', 404);
|
||||
@@ -102,7 +138,7 @@ function _readSynologyUser(userId: number, columns: string[]): ServiceResult<Syn
|
||||
}
|
||||
|
||||
function _getSynologyCredentials(userId: number): ServiceResult<SynologyCredentials> {
|
||||
const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password']);
|
||||
const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password', 'synology_skip_ssl']);
|
||||
if (!user.success) return user as ServiceResult<SynologyCredentials>;
|
||||
if (!user?.data.synology_url || !user.data.synology_username || !user.data.synology_password) return fail('Synology not configured', 400);
|
||||
const password = decrypt_api_key(user.data.synology_password);
|
||||
@@ -111,6 +147,7 @@ function _getSynologyCredentials(userId: number): ServiceResult<SynologyCredenti
|
||||
synology_url: user.data.synology_url,
|
||||
synology_username: user.data.synology_username,
|
||||
synology_password: password,
|
||||
synology_skip_ssl: user.data.synology_skip_ssl !== 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -129,7 +166,7 @@ function _buildSynologyFormBody(params: ApiCallParams): URLSearchParams {
|
||||
return body;
|
||||
}
|
||||
|
||||
async function _fetchSynologyJson<T>(url: string, body: URLSearchParams): Promise<ServiceResult<T>> {
|
||||
async function _fetchSynologyJson<T>(url: string, body: URLSearchParams, skipSsl = true): Promise<ServiceResult<T>> {
|
||||
const endpoint = _buildSynologyEndpoint(url, `api=${body.get('api')}`);
|
||||
try {
|
||||
const resp = await safeFetch(endpoint, {
|
||||
@@ -139,12 +176,20 @@ async function _fetchSynologyJson<T>(url: string, body: URLSearchParams): Promis
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(30000) as any,
|
||||
});
|
||||
}, { rejectUnauthorized: !skipSsl });
|
||||
if (!resp.ok) {
|
||||
return fail('Synology API request failed with status ' + resp.status, resp.status);
|
||||
}
|
||||
const response = await resp.json() as SynologyApiResponse<T>;
|
||||
return response.success ? success(response.data) : fail('Synology failed with code ' + response.error.code, response.error.code);
|
||||
if (!response.success) {
|
||||
const code = response.error.code;
|
||||
const message = SYNOLOGY_ERROR_MESSAGES[code] ?? 'Synology API request failed (code ' + code + ')';
|
||||
// Preserve session error codes (106, 107, 119) for internal retry logic in _requestSynologyApi.
|
||||
// All other Synology app-level codes are mapped to HTTP 400 — they are not HTTP status codes.
|
||||
const httpStatus = [106, 107, 119].includes(code) ? code : 400;
|
||||
return fail(message, httpStatus);
|
||||
}
|
||||
return success(response.data);
|
||||
} catch (error) {
|
||||
if (error instanceof SsrfBlockedError) {
|
||||
return fail(error.message, 400);
|
||||
@@ -153,25 +198,41 @@ async function _fetchSynologyJson<T>(url: string, body: URLSearchParams): Promis
|
||||
}
|
||||
}
|
||||
|
||||
async function _loginToSynology(url: string, username: string, password: string): Promise<ServiceResult<string>> {
|
||||
const SYNOLOGY_DEVICE_NAME = 'trek';
|
||||
|
||||
async function _loginToSynology(
|
||||
url: string,
|
||||
username: string,
|
||||
password: string,
|
||||
opts: { otp?: string; deviceId?: string; skipSsl?: boolean } = {},
|
||||
): Promise<ServiceResult<{ sid: string; did?: string }>> {
|
||||
const { otp, deviceId, skipSsl = false } = opts;
|
||||
const body = new URLSearchParams({
|
||||
api: 'SYNO.API.Auth',
|
||||
method: 'login',
|
||||
version: '3',
|
||||
version: '6',
|
||||
account: username,
|
||||
passwd: password,
|
||||
format: 'sid',
|
||||
client: 'browser',
|
||||
device_name: SYNOLOGY_DEVICE_NAME,
|
||||
});
|
||||
if (otp && otp.trim()) {
|
||||
body.append('otp_code', otp.trim());
|
||||
body.append('enable_device_token', 'yes');
|
||||
}
|
||||
if (deviceId) {
|
||||
body.append('device_id', deviceId);
|
||||
}
|
||||
|
||||
const result = await _fetchSynologyJson<{ sid?: string }>(url, body);
|
||||
const result = await _fetchSynologyJson<{ sid?: string; did?: string }>(url, body, skipSsl);
|
||||
if (!result.success) {
|
||||
return result as ServiceResult<string>;
|
||||
return result as ServiceResult<{ sid: string; did?: string }>;
|
||||
}
|
||||
if (!result.data.sid) {
|
||||
return fail('Failed to get session ID from Synology', 500);
|
||||
}
|
||||
return success(result.data.sid);
|
||||
|
||||
|
||||
return success({ sid: result.data.sid, did: result.data.did });
|
||||
}
|
||||
|
||||
async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Promise<ServiceResult<T>> {
|
||||
@@ -185,8 +246,9 @@ async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Pr
|
||||
return session as ServiceResult<T>;
|
||||
}
|
||||
|
||||
const skipSsl = creds.data.synology_skip_ssl;
|
||||
const body = _buildSynologyFormBody({ ...params, _sid: session.data });
|
||||
const result = await _fetchSynologyJson<T>(creds.data.synology_url, body);
|
||||
const result = await _fetchSynologyJson<T>(creds.data.synology_url, body, skipSsl);
|
||||
// 106 = session timeout, 107 = duplicate login kicked us out, 119 = SID not found/invalid
|
||||
if ('error' in result && [106, 107, 119].includes(result.error.status)) {
|
||||
_clearSynologySID(userId);
|
||||
@@ -194,7 +256,7 @@ async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Pr
|
||||
if (!retrySession.success || !retrySession.data) {
|
||||
return retrySession as ServiceResult<T>;
|
||||
}
|
||||
return _fetchSynologyJson<T>(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data }));
|
||||
return _fetchSynologyJson<T>(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data }), skipSsl);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -232,6 +294,10 @@ function _clearSynologySID(userId: number): void {
|
||||
db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId);
|
||||
}
|
||||
|
||||
function _clearSynologySession(userId: number): void {
|
||||
db.prepare('UPDATE users SET synology_sid = NULL, synology_did = NULL WHERE id = ?').run(userId);
|
||||
}
|
||||
|
||||
function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } | null {
|
||||
// cache_key format from Synology is "{unit_id}_{timestamp}", e.g. "40808_1633659236".
|
||||
// The first segment must be a non-empty integer (the unit ID used for API calls).
|
||||
@@ -241,9 +307,9 @@ function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string;
|
||||
}
|
||||
|
||||
async function _getSynologySession(userId: number): Promise<ServiceResult<string>> {
|
||||
const cachedSid = _readSynologyUser(userId, ['synology_sid']);
|
||||
if (cachedSid.success && cachedSid.data?.synology_sid) {
|
||||
const decryptedSid = decrypt_api_key(cachedSid.data.synology_sid);
|
||||
const cached = _readSynologyUser(userId, ['synology_sid', 'synology_did']);
|
||||
if (cached.success && cached.data?.synology_sid) {
|
||||
const decryptedSid = decrypt_api_key(cached.data.synology_sid);
|
||||
if (decryptedSid) return success(decryptedSid);
|
||||
// Decryption failed (e.g. key rotation) — clear the stale SID and re-login
|
||||
_clearSynologySID(userId);
|
||||
@@ -254,15 +320,22 @@ async function _getSynologySession(userId: number): Promise<ServiceResult<string
|
||||
return creds as ServiceResult<string>;
|
||||
}
|
||||
|
||||
const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password);
|
||||
// Use stored device ID to skip OTP on re-login (trusted device flow)
|
||||
const storedDid = cached.success && cached.data?.synology_did
|
||||
? (decrypt_api_key(cached.data.synology_did) || undefined)
|
||||
: undefined;
|
||||
|
||||
const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password, {
|
||||
deviceId: storedDid,
|
||||
skipSsl: creds.data.synology_skip_ssl,
|
||||
});
|
||||
|
||||
if (!resp.success) {
|
||||
return resp as ServiceResult<string>;
|
||||
}
|
||||
|
||||
const encrypted = encrypt_api_key(resp.data);
|
||||
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypted, userId);
|
||||
return success(resp.data);
|
||||
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId);
|
||||
return success(resp.data.sid);
|
||||
}
|
||||
|
||||
export async function getSynologySettings(userId: number): Promise<ServiceResult<SynologySettings>> {
|
||||
@@ -272,11 +345,12 @@ export async function getSynologySettings(userId: number): Promise<ServiceResult
|
||||
return success({
|
||||
synology_url: creds.data.synology_url || '',
|
||||
synology_username: creds.data.synology_username || '',
|
||||
synology_skip_ssl: creds.data.synology_skip_ssl,
|
||||
connected: session.success,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string): Promise<ServiceResult<string>> {
|
||||
export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string, synologySkipSsl = false): Promise<ServiceResult<string>> {
|
||||
|
||||
const ssrf = await checkSsrf(synologyUrl);
|
||||
if (!ssrf.allowed) {
|
||||
@@ -291,24 +365,42 @@ export async function updateSynologySettings(userId: number, synologyUrl: string
|
||||
return fail('No stored password found. Please provide a password to save settings.', 400);
|
||||
}
|
||||
|
||||
// Only invalidate the session when the account itself changes (different URL or username).
|
||||
// If the user just tested the connection, testSynologyConnection already stored a fresh
|
||||
// sid + did — clearing them here would force an unnecessary re-login that may fail (MFA).
|
||||
const existing = _readSynologyUser(userId, ['synology_url', 'synology_username']);
|
||||
const urlChanged = existing.success && existing.data.synology_url !== synologyUrl;
|
||||
const userChanged = existing.success && existing.data.synology_username !== synologyUsername;
|
||||
const sessionCleared = urlChanged || userChanged;
|
||||
if (sessionCleared) {
|
||||
_clearSynologySession(userId);
|
||||
sendNotification({
|
||||
event: 'synology_session_cleared',
|
||||
actorId: null,
|
||||
params: {},
|
||||
scope: 'user',
|
||||
targetId: userId,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?').run(
|
||||
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ?, synology_skip_ssl = ? WHERE id = ?').run(
|
||||
synologyUrl,
|
||||
synologyUsername,
|
||||
synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword,
|
||||
synologySkipSsl ? 1 : 0,
|
||||
userId,
|
||||
);
|
||||
} catch {
|
||||
return fail('Failed to update Synology settings', 500);
|
||||
}
|
||||
|
||||
_clearSynologySID(userId);
|
||||
return success("settings updated");
|
||||
return success('settings updated');
|
||||
}
|
||||
|
||||
export async function getSynologyStatus(userId: number): Promise<ServiceResult<StatusResult>> {
|
||||
const sid = await _getSynologySession(userId);
|
||||
if ('error' in sid) return success({ connected: false, error: sid.error.status === 400 ? 'Invalid credentials' : sid.error.message });
|
||||
if ('error' in sid) return success({ connected: false, error: sid.error.message });
|
||||
if (!sid.data) return success({ connected: false, error: 'Not connected to Synology' });
|
||||
try {
|
||||
const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined;
|
||||
@@ -318,17 +410,25 @@ export async function getSynologyStatus(userId: number): Promise<ServiceResult<S
|
||||
}
|
||||
}
|
||||
|
||||
export async function testSynologyConnection(synologyUrl: string, synologyUsername: string, synologyPassword: string): Promise<ServiceResult<StatusResult>> {
|
||||
export async function testSynologyConnection(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword: string, synologyOtp?: string, synologySkipSsl = false): Promise<ServiceResult<StatusResult>> {
|
||||
|
||||
const ssrf = await checkSsrf(synologyUrl);
|
||||
if (!ssrf.allowed) {
|
||||
return fail(ssrf.error, 400);
|
||||
}
|
||||
|
||||
const resp = await _loginToSynology(synologyUrl, synologyUsername, synologyPassword);
|
||||
const resp = await _loginToSynology(synologyUrl, synologyUsername, synologyPassword, { otp: synologyOtp, skipSsl: synologySkipSsl });
|
||||
if ('error' in resp) {
|
||||
return success({ connected: false, error: resp.error.status === 400 ? 'Invalid credentials' : resp.error.message });
|
||||
return success({ connected: false, error: resp.error.message });
|
||||
}
|
||||
|
||||
// Persist the session so the OTP code is not required again on save.
|
||||
// The did (device token) allows future re-logins without OTP.
|
||||
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId);
|
||||
if (resp.data.did) {
|
||||
db.prepare('UPDATE users SET synology_did = ? WHERE id = ?').run(encrypt_api_key(resp.data.did), userId);
|
||||
}
|
||||
|
||||
return success({ connected: true, user: { name: synologyUsername } });
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ export type NotifEventType =
|
||||
| 'photos_shared'
|
||||
| 'collab_message'
|
||||
| 'packing_tagged'
|
||||
| 'version_available';
|
||||
| 'version_available'
|
||||
| 'synology_session_cleared';
|
||||
|
||||
export interface AvailableChannels {
|
||||
email: boolean;
|
||||
@@ -32,6 +33,7 @@ const IMPLEMENTED_COMBOS: Record<NotifEventType, NotifChannel[]> = {
|
||||
collab_message: ['inapp', 'email', 'webhook'],
|
||||
packing_tagged: ['inapp', 'email', 'webhook'],
|
||||
version_available: ['inapp', 'email', 'webhook'],
|
||||
synology_session_cleared: ['inapp'],
|
||||
};
|
||||
|
||||
/** Events that target admins only (shown in admin panel, not in user settings). */
|
||||
|
||||
@@ -112,6 +112,12 @@ const EVENT_NOTIFICATION_CONFIG: Record<string, EventNotifConfig> = {
|
||||
navigateTextKey: 'notif.action.view_admin',
|
||||
navigateTarget: () => '/admin',
|
||||
},
|
||||
synology_session_cleared: {
|
||||
inAppType: 'simple',
|
||||
titleKey: 'notifications.synologySessionCleared.title',
|
||||
textKey: 'notifications.synologySessionCleared.text',
|
||||
navigateTarget: () => null,
|
||||
},
|
||||
};
|
||||
|
||||
// ── Fallback config for unknown event types ────────────────────────────────
|
||||
|
||||
@@ -104,6 +104,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `New message in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Packing: ${p.category}`, body: `${p.actor} assigned you to the "${p.category}" packing category in "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'New TREK version available', body: `TREK ${p.version} is now available. Visit the admin panel to update.` }),
|
||||
synology_session_cleared: () => ({ title: 'Synology session cleared', body: 'Your Synology account or URL changed. You have been logged out of Synology Photos.' }),
|
||||
},
|
||||
de: {
|
||||
trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat ${p.invitee || 'ein Mitglied'} zur Reise "${p.trip}" eingeladen.` }),
|
||||
@@ -114,6 +115,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `Neue Nachricht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Packliste: ${p.category}`, body: `${p.actor} hat dich der Kategorie "${p.category}" in der Packliste von "${p.trip}" zugewiesen.` }),
|
||||
version_available: p => ({ title: 'Neue TREK-Version verfügbar', body: `TREK ${p.version} ist jetzt verfügbar. Besuche das Admin-Panel zum Aktualisieren.` }),
|
||||
synology_session_cleared: () => ({ title: 'Synology-Sitzung beendet', body: 'Dein Synology-Konto oder die URL hat sich geändert. Du wurdest von Synology Photos abgemeldet.' }),
|
||||
},
|
||||
fr: {
|
||||
trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} a invité ${p.invitee || 'un membre'} au voyage "${p.trip}".` }),
|
||||
@@ -124,6 +126,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `Nouveau message dans "${p.trip}"`, body: `${p.actor} : ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Bagages : ${p.category}`, body: `${p.actor} vous a assigné à la catégorie "${p.category}" dans "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nouvelle version TREK disponible', body: `TREK ${p.version} est maintenant disponible. Rendez-vous dans le panneau d'administration pour mettre à jour.` }),
|
||||
synology_session_cleared: () => ({ title: 'Session Synology effacée', body: 'Votre compte ou URL Synology a changé. Vous avez été déconnecté de Synology Photos.' }),
|
||||
},
|
||||
es: {
|
||||
trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} invitó a ${p.invitee || 'un miembro'} al viaje "${p.trip}".` }),
|
||||
@@ -134,6 +137,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `Nuevo mensaje en "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Equipaje: ${p.category}`, body: `${p.actor} te asignó a la categoría "${p.category}" en "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nueva versión de TREK disponible', body: `TREK ${p.version} ya está disponible. Visita el panel de administración para actualizar.` }),
|
||||
synology_session_cleared: () => ({ title: 'Sesión de Synology cerrada', body: 'Tu cuenta o URL de Synology ha cambiado. Has cerrado sesión en Synology Photos.' }),
|
||||
},
|
||||
nl: {
|
||||
trip_invite: p => ({ title: `Uitnodiging voor "${p.trip}"`, body: `${p.actor} heeft ${p.invitee || 'een lid'} uitgenodigd voor de reis "${p.trip}".` }),
|
||||
@@ -144,6 +148,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `Nieuw bericht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Paklijst: ${p.category}`, body: `${p.actor} heeft je toegewezen aan de categorie "${p.category}" in "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nieuwe TREK-versie beschikbaar', body: `TREK ${p.version} is nu beschikbaar. Bezoek het beheerderspaneel om bij te werken.` }),
|
||||
synology_session_cleared: () => ({ title: 'Synology-sessie gewist', body: 'Je Synology-account of URL is gewijzigd. Je bent uitgelogd bij Synology Photos.' }),
|
||||
},
|
||||
ru: {
|
||||
trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил ${p.invitee || 'участника'} в поездку "${p.trip}".` }),
|
||||
@@ -154,6 +159,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `Новое сообщение в "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Список вещей: ${p.category}`, body: `${p.actor} назначил вас в категорию "${p.category}" в "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Доступна новая версия TREK', body: `TREK ${p.version} теперь доступен. Перейдите в панель администратора для обновления.` }),
|
||||
synology_session_cleared: () => ({ title: 'Сессия Synology сброшена', body: 'Ваш аккаунт или URL Synology изменился. Вы вышли из Synology Photos.' }),
|
||||
},
|
||||
zh: {
|
||||
trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请了 ${p.invitee || '成员'} 加入旅行"${p.trip}"。` }),
|
||||
@@ -164,6 +170,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `"${p.trip}"中的新消息`, body: `${p.actor}:${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `行李清单:${p.category}`, body: `${p.actor} 将你分配到"${p.trip}"中的"${p.category}"类别。` }),
|
||||
version_available: p => ({ title: '新版 TREK 可用', body: `TREK ${p.version} 现已可用。请前往管理面板进行更新。` }),
|
||||
synology_session_cleared: () => ({ title: 'Synology 会话已清除', body: '您的 Synology 账户或 URL 已更改,您已退出 Synology Photos。' }),
|
||||
},
|
||||
'zh-TW': {
|
||||
trip_invite: p => ({ title: `邀請加入「${p.trip}」`, body: `${p.actor} 邀請了 ${p.invitee || '成員'} 加入行程「${p.trip}」。` }),
|
||||
@@ -173,6 +180,8 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
photos_shared: p => ({ title: `已分享 ${p.count} 張照片`, body: `${p.actor} 在「${p.trip}」中分享了 ${p.count} 張照片。` }),
|
||||
collab_message: p => ({ title: `「${p.trip}」中的新訊息`, body: `${p.actor}:${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `打包清單:${p.category}`, body: `${p.actor} 已將您指派到「${p.trip}」中的「${p.category}」分類。` }),
|
||||
version_available: p => ({ title: '新版 TREK 可用', body: `TREK ${p.version} 現已可用。請前往管理面板進行更新。` }),
|
||||
synology_session_cleared: () => ({ title: 'Synology 工作階段已清除', body: '您的 Synology 帳戶或 URL 已變更,您已登出 Synology Photos。' }),
|
||||
},
|
||||
ar: {
|
||||
trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعا ${p.invitee || 'عضو'} إلى الرحلة "${p.trip}".` }),
|
||||
@@ -183,6 +192,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `رسالة جديدة في "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `قائمة التعبئة: ${p.category}`, body: `${p.actor} عيّنك في فئة "${p.category}" في "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'إصدار TREK جديد متاح', body: `TREK ${p.version} متاح الآن. تفضل بزيارة لوحة الإدارة للتحديث.` }),
|
||||
synology_session_cleared: () => ({ title: 'تمت إعادة تعيين جلسة Synology', body: 'تغيّر حسابك أو رابط Synology. تم تسجيل خروجك من Synology Photos.' }),
|
||||
},
|
||||
br: {
|
||||
trip_invite: p => ({ title: `Convite para "${p.trip}"`, body: `${p.actor} convidou ${p.invitee || 'um membro'} para a viagem "${p.trip}".` }),
|
||||
@@ -193,6 +203,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `Nova mensagem em "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Bagagem: ${p.category}`, body: `${p.actor} atribuiu você à categoria "${p.category}" em "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nova versão do TREK disponível', body: `O TREK ${p.version} está disponível. Acesse o painel de administração para atualizar.` }),
|
||||
synology_session_cleared: () => ({ title: 'Sessão Synology encerrada', body: 'Sua conta ou URL do Synology foi alterada. Você foi desconectado do Synology Photos.' }),
|
||||
},
|
||||
cs: {
|
||||
trip_invite: p => ({ title: `Pozvánka do "${p.trip}"`, body: `${p.actor} pozval ${p.invitee || 'člena'} na výlet "${p.trip}".` }),
|
||||
@@ -203,6 +214,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `Nová zpráva v "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Balení: ${p.category}`, body: `${p.actor} vás přiřadil do kategorie "${p.category}" v "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nová verze TREK dostupná', body: `TREK ${p.version} je nyní dostupný. Navštivte administrátorský panel pro aktualizaci.` }),
|
||||
synology_session_cleared: () => ({ title: 'Relace Synology byla zrušena', body: 'Váš účet nebo URL Synology se změnil. Byli jste odhlášeni ze Synology Photos.' }),
|
||||
},
|
||||
hu: {
|
||||
trip_invite: p => ({ title: `Meghívó a(z) "${p.trip}" utazásra`, body: `${p.actor} meghívta ${p.invitee || 'egy tagot'} a(z) "${p.trip}" utazásra.` }),
|
||||
@@ -213,6 +225,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `Új üzenet a(z) "${p.trip}" utazásban`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Csomagolás: ${p.category}`, body: `${p.actor} hozzárendelte Önt a "${p.category}" csomagolási kategóriához a(z) "${p.trip}" utazásban.` }),
|
||||
version_available: p => ({ title: 'Új TREK verzió érhető el', body: `A TREK ${p.version} elérhető. Látogasson el az adminisztrációs panelre a frissítéshez.` }),
|
||||
synology_session_cleared: () => ({ title: 'Synology munkamenet törölve', body: 'A Synology fiókja vagy URL-je megváltozott. Kijelentkeztek a Synology Photos-ból.' }),
|
||||
},
|
||||
it: {
|
||||
trip_invite: p => ({ title: `Invito a "${p.trip}"`, body: `${p.actor} ha invitato ${p.invitee || 'un membro'} al viaggio "${p.trip}".` }),
|
||||
@@ -223,6 +236,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `Nuovo messaggio in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Bagagli: ${p.category}`, body: `${p.actor} ti ha assegnato alla categoria "${p.category}" in "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nuova versione TREK disponibile', body: `TREK ${p.version} è ora disponibile. Visita il pannello di amministrazione per aggiornare.` }),
|
||||
synology_session_cleared: () => ({ title: 'Sessione Synology rimossa', body: 'Il tuo account o URL Synology è cambiato. Sei stato disconnesso da Synology Photos.' }),
|
||||
},
|
||||
pl: {
|
||||
trip_invite: p => ({ title: `Zaproszenie do "${p.trip}"`, body: `${p.actor} zaprosił ${p.invitee || 'członka'} do podróży "${p.trip}".` }),
|
||||
@@ -233,6 +247,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `Nowa wiadomość w "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Pakowanie: ${p.category}`, body: `${p.actor} przypisał Cię do kategorii "${p.category}" w "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nowa wersja TREK dostępna', body: `TREK ${p.version} jest teraz dostępny. Odwiedź panel administracyjny, aby zaktualizować.` }),
|
||||
synology_session_cleared: () => ({ title: 'Sesja Synology wyczyszczona', body: 'Twoje konto lub URL Synology uległo zmianie. Zostałeś wylogowany z Synology Photos.' }),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
import crypto, { randomBytes, createHash, randomUUID } from 'crypto';
|
||||
import { db } from '../db/database';
|
||||
import { isAddonEnabled } from './adminService';
|
||||
import { validateScopes } from '../mcp/scopes';
|
||||
import { ADDON_IDS } from '../addons';
|
||||
import { User } from '../types';
|
||||
import { writeAudit, logWarn } from './auditLog';
|
||||
import { revokeUserSessionsForClient } from '../mcp/sessionManager';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ACCESS_TOKEN_TTL_S = 60 * 60; // 1 hour
|
||||
const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days rolling
|
||||
const AUTH_CODE_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
// PKCE format (RFC 7636)
|
||||
const CODE_CHALLENGE_RE = /^[A-Za-z0-9_-]{43}$/;
|
||||
const CODE_VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory auth code store (short-lived, no need for DB persistence)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PendingCode {
|
||||
clientId: string;
|
||||
userId: number;
|
||||
redirectUri: string;
|
||||
scopes: string[];
|
||||
codeChallenge: string;
|
||||
codeChallengeMethod: 'S256';
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const MAX_PENDING_CODES = 500;
|
||||
const pendingCodes = new Map<string, PendingCode>();
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of pendingCodes) {
|
||||
if (now > entry.expiresAt) pendingCodes.delete(key);
|
||||
}
|
||||
}, 60_000).unref();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB row types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface OAuthClientRow {
|
||||
id: string;
|
||||
user_id: number;
|
||||
name: string;
|
||||
client_id: string;
|
||||
client_secret_hash: string;
|
||||
redirect_uris: string; // JSON array
|
||||
allowed_scopes: string; // JSON array
|
||||
created_at: string;
|
||||
is_public: number; // 0 | 1 (SQLite boolean)
|
||||
created_via: string; // 'settings_ui' | 'browser-registration'
|
||||
}
|
||||
|
||||
interface OAuthTokenRow {
|
||||
id: number;
|
||||
client_id: string;
|
||||
user_id: number;
|
||||
access_token_hash: string;
|
||||
refresh_token_hash: string;
|
||||
scopes: string; // JSON array
|
||||
access_token_expires_at: string;
|
||||
refresh_token_expires_at: string;
|
||||
revoked_at: string | null;
|
||||
parent_token_id: number | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function hashToken(raw: string): string {
|
||||
return createHash('sha256').update(raw).digest('hex');
|
||||
}
|
||||
|
||||
/** Constant-time comparison of two hex-encoded SHA-256 hashes. */
|
||||
function timingSafeEqualHex(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function generateAccessToken(): string {
|
||||
return 'trekoa_' + randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
function generateRefreshToken(): string {
|
||||
return 'trekrf_' + randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client management (self-service, gated by MCP addon)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function listOAuthClients(userId: number): Record<string, unknown>[] {
|
||||
const rows = db.prepare(
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC'
|
||||
).all(userId) as OAuthClientRow[];
|
||||
return rows.map(r => ({
|
||||
...r,
|
||||
is_public: Boolean(r.is_public),
|
||||
redirect_uris: JSON.parse(r.redirect_uris),
|
||||
allowed_scopes: JSON.parse(r.allowed_scopes),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Returns true if the URI is a valid OAuth redirect target (HTTPS or localhost). */
|
||||
export function isValidRedirectUri(uri: string): boolean {
|
||||
try {
|
||||
const url = new URL(uri);
|
||||
return url.protocol === 'https:' || url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createOAuthClient(
|
||||
userId: number | null,
|
||||
name: string,
|
||||
redirectUris: string[],
|
||||
allowedScopes: string[],
|
||||
ip?: string | null,
|
||||
options?: { isPublic?: boolean; createdVia?: string },
|
||||
): { error?: string; status?: number; client?: Record<string, unknown> } {
|
||||
if (!name?.trim()) return { error: 'Name is required', status: 400 };
|
||||
if (name.trim().length > 100) return { error: 'Name must be 100 characters or less', status: 400 };
|
||||
if (!redirectUris || redirectUris.length === 0) return { error: 'At least one redirect URI is required', status: 400 };
|
||||
if (redirectUris.length > 10) return { error: 'Maximum 10 redirect URIs per client', status: 400 };
|
||||
|
||||
for (const uri of redirectUris) {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(uri);
|
||||
} catch {
|
||||
return { error: `Invalid redirect URI: ${uri}`, status: 400 };
|
||||
}
|
||||
if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
|
||||
return { error: `Redirect URI must use HTTPS (localhost exempt): ${uri}`, status: 400 };
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowedScopes || allowedScopes.length === 0) return { error: 'At least one scope is required', status: 400 };
|
||||
const { valid, invalid } = validateScopes(allowedScopes);
|
||||
if (!valid) return { error: `Invalid scopes: ${invalid.join(', ')}`, status: 400 };
|
||||
|
||||
if (userId !== null) {
|
||||
const count = (db.prepare('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id = ?').get(userId) as { count: number }).count;
|
||||
if (count >= 10) return { error: 'Maximum of 10 OAuth clients per user', status: 400 };
|
||||
} else {
|
||||
// Anonymous DCR clients: enforce a global cap to prevent unbounded registration abuse
|
||||
const count = (db.prepare('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id IS NULL').get() as { count: number }).count;
|
||||
if (count >= 500) return { error: 'server_error', status: 503 };
|
||||
}
|
||||
|
||||
const isPublic = options?.isPublic ?? false;
|
||||
const createdVia = options?.createdVia ?? 'settings_ui';
|
||||
const id = randomUUID();
|
||||
const clientId = randomUUID();
|
||||
// Public clients have no usable secret; store an opaque random value to satisfy NOT NULL.
|
||||
const rawSecret = isPublic ? null : 'trekcs_' + randomBytes(24).toString('hex');
|
||||
const secretHash = rawSecret ? hashToken(rawSecret) : randomBytes(32).toString('hex');
|
||||
|
||||
db.prepare(
|
||||
'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia);
|
||||
|
||||
const row = db.prepare(
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients WHERE id = ?'
|
||||
).get(id) as OAuthClientRow;
|
||||
|
||||
writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic }, ip });
|
||||
|
||||
return {
|
||||
client: {
|
||||
id: row.id,
|
||||
user_id: row.user_id,
|
||||
name: row.name,
|
||||
client_id: row.client_id,
|
||||
redirect_uris: JSON.parse(row.redirect_uris),
|
||||
allowed_scopes: JSON.parse(row.allowed_scopes),
|
||||
created_at: row.created_at,
|
||||
is_public: Boolean(row.is_public),
|
||||
created_via: row.created_via,
|
||||
// client_secret only present for confidential clients — shown once, not stored in plain text
|
||||
...(rawSecret ? { client_secret: rawSecret } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function rotateOAuthClientSecret(
|
||||
userId: number,
|
||||
clientRowId: string,
|
||||
ip?: string | null,
|
||||
): { error?: string; status?: number; client_secret?: string } {
|
||||
const row = db.prepare('SELECT id, client_id, is_public FROM oauth_clients WHERE id = ? AND user_id = ?').get(clientRowId, userId) as OAuthClientRow | undefined;
|
||||
if (!row) return { error: 'Client not found', status: 404 };
|
||||
if (row.is_public) return { error: 'Public clients do not use a client secret', status: 400 };
|
||||
|
||||
const rawSecret = 'trekcs_' + randomBytes(24).toString('hex');
|
||||
const secretHash = hashToken(rawSecret);
|
||||
|
||||
db.prepare('UPDATE oauth_clients SET client_secret_hash = ? WHERE id = ?').run(secretHash, clientRowId);
|
||||
|
||||
// Revoke all existing tokens for this client so old sessions are invalidated
|
||||
db.prepare("UPDATE oauth_tokens SET revoked_at = datetime('now') WHERE client_id = ? AND revoked_at IS NULL").run(row.client_id);
|
||||
|
||||
// Terminate active MCP sessions for this (user, client) pair
|
||||
|
||||
revokeUserSessionsForClient(userId, row.client_id);
|
||||
|
||||
writeAudit({ userId, action: 'oauth.client.rotate_secret', details: { client_id: row.client_id }, ip });
|
||||
|
||||
return { client_secret: rawSecret };
|
||||
}
|
||||
|
||||
export function deleteOAuthClient(
|
||||
userId: number,
|
||||
clientRowId: string,
|
||||
ip?: string | null,
|
||||
): { error?: string; status?: number; success?: boolean } {
|
||||
const row = db.prepare('SELECT id, client_id FROM oauth_clients WHERE id = ? AND user_id = ?').get(clientRowId, userId) as OAuthClientRow | undefined;
|
||||
if (!row) return { error: 'Client not found', status: 404 };
|
||||
db.prepare('DELETE FROM oauth_clients WHERE id = ?').run(clientRowId);
|
||||
writeAudit({ userId, action: 'oauth.client.delete', details: { client_id: row.client_id }, ip });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth code (in-memory, 2-minute TTL)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createAuthCode(params: {
|
||||
clientId: string;
|
||||
userId: number;
|
||||
redirectUri: string;
|
||||
scopes: string[];
|
||||
codeChallenge: string;
|
||||
codeChallengeMethod: 'S256';
|
||||
}): string | null {
|
||||
if (pendingCodes.size >= MAX_PENDING_CODES) return null;
|
||||
const rawCode = randomBytes(32).toString('hex');
|
||||
pendingCodes.set(rawCode, { ...params, expiresAt: Date.now() + AUTH_CODE_TTL_MS });
|
||||
return rawCode;
|
||||
}
|
||||
|
||||
export function consumeAuthCode(code: string): PendingCode | null {
|
||||
const entry = pendingCodes.get(code);
|
||||
if (!entry) return null;
|
||||
pendingCodes.delete(code);
|
||||
if (Date.now() > entry.expiresAt) return null;
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Consent management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getConsent(clientId: string, userId: number): string[] | null {
|
||||
const row = db.prepare(
|
||||
'SELECT scopes FROM oauth_consents WHERE client_id = ? AND user_id = ?'
|
||||
).get(clientId, userId) as { scopes: string } | undefined;
|
||||
return row ? JSON.parse(row.scopes) : null;
|
||||
}
|
||||
|
||||
export function saveConsent(clientId: string, userId: number, scopes: string[], ip?: string | null): void {
|
||||
// Union existing consent with newly approved scopes (M5: never narrow stored consent)
|
||||
const existing = getConsent(clientId, userId) ?? [];
|
||||
const merged = Array.from(new Set([...existing, ...scopes]));
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO oauth_consents (client_id, user_id, scopes, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)'
|
||||
).run(clientId, userId, JSON.stringify(merged));
|
||||
writeAudit({ userId, action: 'oauth.consent.grant', details: { client_id: clientId, scopes: merged }, ip });
|
||||
}
|
||||
|
||||
export function isConsentSufficient(existingScopes: string[], requestedScopes: string[]): boolean {
|
||||
return requestedScopes.every(s => existingScopes.includes(s));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token issuance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function issueTokens(
|
||||
clientId: string,
|
||||
userId: number,
|
||||
scopes: string[],
|
||||
parentTokenId: number | null = null,
|
||||
): {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: 'Bearer';
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
} {
|
||||
const rawAccess = generateAccessToken();
|
||||
const rawRefresh = generateRefreshToken();
|
||||
const accessHash = hashToken(rawAccess);
|
||||
const refreshHash = hashToken(rawRefresh);
|
||||
|
||||
const now = new Date();
|
||||
const accessExpiry = new Date(now.getTime() + ACCESS_TOKEN_TTL_S * 1000);
|
||||
const refreshExpiry = new Date(now.getTime() + REFRESH_TOKEN_TTL_MS);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO oauth_tokens
|
||||
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, access_token_expires_at, refresh_token_expires_at, parent_token_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(clientId, userId, accessHash, refreshHash, JSON.stringify(scopes), accessExpiry.toISOString(), refreshExpiry.toISOString(), parentTokenId);
|
||||
|
||||
return {
|
||||
access_token: rawAccess,
|
||||
refresh_token: rawRefresh,
|
||||
token_type: 'Bearer',
|
||||
expires_in: ACCESS_TOKEN_TTL_S,
|
||||
scope: scopes.join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token verification (used by MCP handler on every request)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface OAuthTokenInfo {
|
||||
user: User;
|
||||
scopes: string[];
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export function getUserByAccessToken(rawToken: string): OAuthTokenInfo | null {
|
||||
const hash = hashToken(rawToken);
|
||||
const row = db.prepare(`
|
||||
SELECT ot.scopes, ot.revoked_at, ot.access_token_expires_at,
|
||||
ot.user_id, ot.client_id, u.username, u.email, u.role
|
||||
FROM oauth_tokens ot
|
||||
JOIN users u ON ot.user_id = u.id
|
||||
WHERE ot.access_token_hash = ?
|
||||
`).get(hash) as (OAuthTokenRow & { username: string; email: string; role: string }) | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
if (row.revoked_at) return null;
|
||||
if (new Date(row.access_token_expires_at) < new Date()) return null;
|
||||
|
||||
return {
|
||||
user: { id: row.user_id, username: row.username, email: row.email, role: row.role as 'admin' | 'user' },
|
||||
scopes: JSON.parse(row.scopes),
|
||||
clientId: row.client_id,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token refresh (rotation + replay detection)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Walk parent_token_id upward to find the root token id of this rotation chain. */
|
||||
function findChainRoot(tokenId: number): number {
|
||||
let current = tokenId;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const row = db.prepare('SELECT id, parent_token_id FROM oauth_tokens WHERE id = ?').get(current) as { id: number; parent_token_id: number | null } | undefined;
|
||||
if (!row || row.parent_token_id === null) return current;
|
||||
current = row.parent_token_id;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** Revoke all tokens in the rotation chain rooted at rootId. Returns affected ids. */
|
||||
function revokeChain(rootId: number): number[] {
|
||||
const rows = db.prepare(`
|
||||
WITH RECURSIVE chain(id) AS (
|
||||
SELECT id FROM oauth_tokens WHERE id = ?
|
||||
UNION ALL
|
||||
SELECT t.id FROM oauth_tokens t JOIN chain c ON t.parent_token_id = c.id
|
||||
)
|
||||
SELECT id FROM chain
|
||||
`).all(rootId) as { id: number }[];
|
||||
const ids = rows.map(r => r.id);
|
||||
if (ids.length > 0) {
|
||||
db.prepare(
|
||||
`UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id IN (${ids.map(() => '?').join(',')}) AND revoked_at IS NULL`
|
||||
).run(...ids);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function refreshTokens(
|
||||
rawRefreshToken: string,
|
||||
clientId: string,
|
||||
clientSecret: string | undefined,
|
||||
ip?: string | null,
|
||||
): { error?: string; status?: number; tokens?: ReturnType<typeof issueTokens> } {
|
||||
const client = db.prepare('SELECT client_id, client_secret_hash, is_public FROM oauth_clients WHERE client_id = ?').get(clientId) as OAuthClientRow | undefined;
|
||||
if (!client) return { error: 'invalid_client', status: 401 };
|
||||
if (!client.is_public) {
|
||||
if (!clientSecret || !timingSafeEqualHex(hashToken(clientSecret), client.client_secret_hash)) {
|
||||
return { error: 'invalid_client', status: 401 };
|
||||
}
|
||||
}
|
||||
|
||||
const hash = hashToken(rawRefreshToken);
|
||||
const row = db.prepare(`
|
||||
SELECT id, client_id, user_id, scopes, refresh_token_expires_at, revoked_at, parent_token_id
|
||||
FROM oauth_tokens WHERE refresh_token_hash = ?
|
||||
`).get(hash) as OAuthTokenRow | undefined;
|
||||
|
||||
if (!row) return { error: 'invalid_grant', status: 400 };
|
||||
if (row.client_id !== clientId) return { error: 'invalid_grant', status: 400 };
|
||||
|
||||
// ---- Replay detection (C3) ----
|
||||
if (row.revoked_at) {
|
||||
// A revoked refresh token was replayed — assume token theft. Cascade-revoke the chain.
|
||||
const rootId = findChainRoot(row.id);
|
||||
revokeChain(rootId);
|
||||
|
||||
|
||||
revokeUserSessionsForClient(row.user_id, clientId);
|
||||
|
||||
writeAudit({
|
||||
userId: row.user_id,
|
||||
action: 'oauth.token.replay_detected',
|
||||
details: { client_id: clientId },
|
||||
ip,
|
||||
});
|
||||
logWarn(`[OAuth] Refresh token replay detected for user=${row.user_id} client=${clientId} ip=${ip ?? '-'}`);
|
||||
|
||||
return { error: 'invalid_grant', status: 400 };
|
||||
}
|
||||
|
||||
if (new Date(row.refresh_token_expires_at) < new Date()) return { error: 'invalid_grant', status: 400 };
|
||||
|
||||
// Revoke old pair immediately (rotation) and issue new pair linked to old row
|
||||
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(row.id);
|
||||
|
||||
// Terminate active MCP sessions for the old token's client so client must re-authenticate
|
||||
|
||||
revokeUserSessionsForClient(row.user_id, clientId);
|
||||
|
||||
const tokens = issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id);
|
||||
writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip });
|
||||
|
||||
return { tokens };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token revocation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function revokeToken(rawToken: string, clientId: string, userId?: number, ip?: string | null): void {
|
||||
const hash = hashToken(rawToken);
|
||||
|
||||
// Get the user_id for the token so we can revoke its MCP sessions
|
||||
const row = db.prepare(
|
||||
'SELECT user_id FROM oauth_tokens WHERE (access_token_hash = ? OR refresh_token_hash = ?) AND client_id = ?'
|
||||
).get(hash, hash, clientId) as { user_id: number } | undefined;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE oauth_tokens
|
||||
SET revoked_at = CURRENT_TIMESTAMP
|
||||
WHERE (access_token_hash = ? OR refresh_token_hash = ?) AND client_id = ?
|
||||
`).run(hash, hash, clientId);
|
||||
|
||||
const affectedUserId = row?.user_id ?? userId;
|
||||
if (affectedUserId) {
|
||||
|
||||
revokeUserSessionsForClient(affectedUserId, clientId);
|
||||
writeAudit({ userId: affectedUserId, action: 'oauth.token.revoke', details: { client_id: clientId, method: 'token' }, ip });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active session listing (for user settings page)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function listOAuthSessions(userId: number): Record<string, unknown>[] {
|
||||
const rows = db.prepare(`
|
||||
SELECT ot.id, ot.client_id, oc.name AS client_name, ot.scopes,
|
||||
ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at
|
||||
FROM oauth_tokens ot
|
||||
JOIN oauth_clients oc ON ot.client_id = oc.client_id
|
||||
WHERE ot.user_id = ?
|
||||
AND ot.revoked_at IS NULL
|
||||
AND ot.refresh_token_expires_at > CURRENT_TIMESTAMP
|
||||
ORDER BY ot.created_at DESC
|
||||
`).all(userId) as Record<string, unknown>[];
|
||||
return rows.map(r => ({ ...r, scopes: JSON.parse(r.scopes as string) }));
|
||||
}
|
||||
|
||||
export function revokeSession(
|
||||
userId: number,
|
||||
sessionId: number,
|
||||
ip?: string | null,
|
||||
): { error?: string; status?: number; success?: boolean } {
|
||||
const row = db.prepare('SELECT id, client_id FROM oauth_tokens WHERE id = ? AND user_id = ?').get(sessionId, userId) as { id: number; client_id: string } | undefined;
|
||||
if (!row) return { error: 'Session not found', status: 404 };
|
||||
|
||||
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(sessionId);
|
||||
|
||||
|
||||
revokeUserSessionsForClient(userId, row.client_id);
|
||||
|
||||
writeAudit({ userId, action: 'oauth.token.revoke', details: { client_id: row.client_id, method: 'session' }, ip });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authorize request validation (option A: called by SPA via GET /api/oauth/authorize/validate)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AuthorizeParams {
|
||||
response_type: string;
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
scope: string;
|
||||
state?: string;
|
||||
code_challenge: string;
|
||||
code_challenge_method: string;
|
||||
}
|
||||
|
||||
export interface ValidateAuthorizeResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
client?: { name: string; allowed_scopes: string[] };
|
||||
scopes?: string[];
|
||||
/** true when user is logged in but consent UI must be shown */
|
||||
consentRequired?: boolean;
|
||||
/** true when the request is valid but user is not authenticated */
|
||||
loginRequired?: boolean;
|
||||
/** true when the client was registered via machine DCR — user may adjust scopes on the consent screen */
|
||||
scopeSelectable?: boolean;
|
||||
}
|
||||
|
||||
export function validateAuthorizeRequest(
|
||||
params: AuthorizeParams,
|
||||
userId: number | null,
|
||||
): ValidateAuthorizeResult {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) {
|
||||
return { valid: false, error: 'mcp_disabled', error_description: 'MCP is not enabled on this server' };
|
||||
}
|
||||
|
||||
if (params.response_type !== 'code') {
|
||||
return { valid: false, error: 'unsupported_response_type', error_description: 'Only response_type=code is supported' };
|
||||
}
|
||||
|
||||
if (!params.code_challenge || params.code_challenge_method !== 'S256') {
|
||||
return { valid: false, error: 'invalid_request', error_description: 'PKCE with code_challenge_method=S256 is required (OAuth 2.1)' };
|
||||
}
|
||||
|
||||
// H1: Enforce code_challenge format (RFC 7636 §4.2)
|
||||
if (!CODE_CHALLENGE_RE.test(params.code_challenge)) {
|
||||
return { valid: false, error: 'invalid_request', error_description: 'code_challenge must be 43 base64url characters (S256)' };
|
||||
}
|
||||
|
||||
if (!params.client_id) {
|
||||
return { valid: false, error: 'invalid_request', error_description: 'client_id is required' };
|
||||
}
|
||||
|
||||
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(params.client_id) as OAuthClientRow | undefined;
|
||||
if (!client) {
|
||||
return { valid: false, error: 'invalid_client', error_description: 'Unknown client_id' };
|
||||
}
|
||||
|
||||
const allowedUris: string[] = JSON.parse(client.redirect_uris);
|
||||
if (!params.redirect_uri || !allowedUris.includes(params.redirect_uri)) {
|
||||
return { valid: false, error: 'invalid_redirect_uri', error_description: 'redirect_uri does not match any registered URI' };
|
||||
}
|
||||
|
||||
const requestedScopes = (params.scope || '').split(' ').filter(Boolean);
|
||||
if (requestedScopes.length === 0) {
|
||||
return { valid: false, error: 'invalid_scope', error_description: 'At least one scope is required' };
|
||||
}
|
||||
|
||||
const allowedScopes: string[] = JSON.parse(client.allowed_scopes);
|
||||
// Narrow to the intersection: drop scopes the client isn't permitted for rather
|
||||
// than rejecting the whole request (per OAuth 2.0 §3.3 scope narrowing).
|
||||
const grantedScopes = requestedScopes.filter(s => allowedScopes.includes(s));
|
||||
if (grantedScopes.length === 0) {
|
||||
return { valid: false, error: 'invalid_scope', error_description: 'None of the requested scopes are permitted for this client' };
|
||||
}
|
||||
|
||||
if (userId === null) {
|
||||
// H3: return only the minimum required fields — do NOT expose scopes, client.name, or
|
||||
// allowed_scopes to unauthenticated callers to prevent client enumeration.
|
||||
return { valid: true, loginRequired: true };
|
||||
}
|
||||
|
||||
const existingConsent = getConsent(params.client_id, userId);
|
||||
const consentRequired = !existingConsent || !isConsentSufficient(existingConsent, grantedScopes);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
client: { name: client.name, allowed_scopes: allowedScopes },
|
||||
scopes: grantedScopes,
|
||||
consentRequired,
|
||||
scopeSelectable: client.created_via === 'dcr',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PKCE verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function verifyPKCE(codeVerifier: string, codeChallenge: string): boolean {
|
||||
// H1: validate code_verifier format before hashing
|
||||
if (!CODE_VERIFIER_RE.test(codeVerifier)) return false;
|
||||
|
||||
const expected = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
||||
// Constant-time compare (both are base64url strings of equal length for S256)
|
||||
if (expected.length !== codeChallenge.length) return false;
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(codeChallenge));
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client authentication (for token endpoint)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function authenticateClient(clientId: string, clientSecret: string | undefined): OAuthClientRow | null {
|
||||
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(clientId) as OAuthClientRow | undefined;
|
||||
if (!client) return null;
|
||||
if (client.is_public) {
|
||||
// Public clients are identified by client_id alone — PKCE provides the security guarantee.
|
||||
return client;
|
||||
}
|
||||
// H4: constant-time comparison to prevent timing side-channel
|
||||
if (!clientSecret) return null;
|
||||
if (!timingSafeEqualHex(hashToken(clientSecret), client.client_secret_hash)) return null;
|
||||
return client;
|
||||
}
|
||||
Reference in New Issue
Block a user