fix: replace JWT tokens in URL query params with short-lived ephemeral tokens

Addresses CWE-598: long-lived JWTs were exposed in WebSocket URLs, file
download links, and Immich asset proxy URLs, leaking into server logs,
browser history, and Referer headers.

- Add ephemeralTokens service: in-memory single-use tokens with per-purpose
  TTLs (ws=30s, download/immich=60s), max 10k entries, periodic cleanup
- Add POST /api/auth/ws-token and POST /api/auth/resource-token endpoints
- WebSocket auth now consumes an ephemeral token instead of verifying the JWT
  directly from the URL; client fetches a fresh token before each connect
- File download ?token= query param now accepts ephemeral tokens; Bearer
  header path continues to accept JWTs for programmatic access
- Immich asset proxy replaces authFromQuery JWT injection with ephemeral token
  consumption
- Client: new getAuthUrl() utility, AuthedImg/ImmichImg components, and async
  onClick handlers replace the synchronous authUrl() pattern throughout
  FileManager, PlaceInspector, and MemoriesPanel
- Add OIDC_DISCOVERY_URL env var and oidc_discovery_url DB setting to allow
  overriding the auto-constructed discovery endpoint (required for Authentik
  and similar providers); exposed in the admin UI and .env.example
This commit is contained in:
jubnl
2026-04-01 05:42:27 +02:00
parent 0ee53e7b38
commit 78695b4e03
15 changed files with 267 additions and 87 deletions

View File

@@ -278,6 +278,8 @@ const server = app.listen(PORT, () => {
scheduler.start();
scheduler.startTripReminders();
scheduler.startDemoReset();
const { startTokenCleanup } = require('./services/ephemeralTokens');
startTokenCleanup();
import('./websocket').then(({ setupWebSocket }) => {
setupWebSocket(server);
});

View File

@@ -240,17 +240,19 @@ router.get('/oidc', (_req: Request, res: Response) => {
client_secret_set: !!secret,
display_name: get('oidc_display_name'),
oidc_only: get('oidc_only') === 'true',
discovery_url: get('oidc_discovery_url'),
});
});
router.put('/oidc', (req: Request, res: Response) => {
const { issuer, client_id, client_secret, display_name, oidc_only } = req.body;
const { issuer, client_id, client_secret, display_name, oidc_only, discovery_url } = req.body;
const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || '');
set('oidc_issuer', issuer);
set('oidc_client_id', client_id);
if (client_secret !== undefined) set('oidc_client_secret', maybe_encrypt_api_key(client_secret) ?? '');
set('oidc_display_name', display_name);
set('oidc_only', oidc_only ? 'true' : 'false');
set('oidc_discovery_url', discovery_url);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,

View File

@@ -20,6 +20,7 @@ import { AuthRequest, OptionalAuthRequest, User } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../services/apiKeyCrypto';
import { startTripReminders } from '../scheduler';
import { createEphemeralToken } from '../services/ephemeralTokens';
authenticator.options = { window: 1 };
@@ -951,4 +952,24 @@ router.delete('/mcp-tokens/:id', authenticate, (req: Request, res: Response) =>
res.json({ success: true });
});
// Short-lived single-use token for WebSocket connections (avoids JWT in WS URL)
router.post('/ws-token', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const token = createEphemeralToken(authReq.user.id, 'ws');
if (!token) return res.status(503).json({ error: 'Service unavailable' });
res.json({ token });
});
// Short-lived single-use token for direct resource URLs (file downloads, Immich assets)
router.post('/resource-token', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { purpose } = req.body as { purpose?: string };
if (purpose !== 'download' && purpose !== 'immich') {
return res.status(400).json({ error: 'Invalid purpose' });
}
const token = createEphemeralToken(authReq.user.id, purpose);
if (!token) return res.status(503).json({ error: 'Service unavailable' });
res.json({ token });
});
export default router;

View File

@@ -6,6 +6,7 @@ import { v4 as uuidv4 } from 'uuid';
import jwt from 'jsonwebtoken';
import { JWT_SECRET } from '../config';
import { db, canAccessTrip } from '../db/database';
import { consumeEphemeralToken } from '../services/ephemeralTokens';
import { authenticate, demoUploadBlock } from '../middleware/auth';
import { requireTripAccess } from '../middleware/tripAccess';
import { broadcast } from '../websocket';
@@ -84,17 +85,25 @@ function getPlaceFiles(tripId: string | number, placeId: number) {
router.get('/:id/download', (req: Request, res: Response) => {
const { tripId, id } = req.params;
// Accept token from Authorization header or query parameter
// Accept token from Authorization header (JWT) or query parameter (ephemeral token)
const authHeader = req.headers['authorization'];
const token = (authHeader && authHeader.split(' ')[1]) || (req.query.token as string);
if (!token) return res.status(401).json({ error: 'Authentication required' });
const bearerToken = authHeader && authHeader.split(' ')[1];
const queryToken = req.query.token as string | undefined;
if (!bearerToken && !queryToken) return res.status(401).json({ error: 'Authentication required' });
let userId: number;
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
userId = decoded.id;
} catch {
return res.status(401).json({ error: 'Invalid or expired token' });
if (bearerToken) {
try {
const decoded = jwt.verify(bearerToken, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
userId = decoded.id;
} catch {
return res.status(401).json({ error: 'Invalid or expired token' });
}
} else {
const uid = consumeEphemeralToken(queryToken!, 'download');
if (!uid) return res.status(401).json({ error: 'Invalid or expired token' });
userId = uid;
}
const trip = verifyTripOwnership(tripId, userId);

View File

@@ -1,8 +1,9 @@
import express, { Request, Response } from 'express';
import express, { Request, Response, NextFunction } from 'express';
import { db } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { AuthRequest } from '../types';
import { consumeEphemeralToken } from '../services/ephemeralTokens';
const router = express.Router();
@@ -254,11 +255,16 @@ router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Resp
// ── Proxy Immich Assets ─────────────────────────────────────────────────────
// Asset proxy routes accept token via query param (for <img> src usage)
function authFromQuery(req: Request, res: Response, next: Function) {
const token = req.query.token as string;
if (token && !req.headers.authorization) {
req.headers.authorization = `Bearer ${token}`;
// Asset proxy routes accept ephemeral token via query param (for <img> src usage)
function authFromQuery(req: Request, res: Response, next: NextFunction) {
const queryToken = req.query.token as string | undefined;
if (queryToken) {
const userId = consumeEphemeralToken(queryToken, 'immich');
if (!userId) return res.status(401).send('Invalid or expired token');
const user = db.prepare('SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?').get(userId) as any;
if (!user) return res.status(401).send('User not found');
(req as AuthRequest).user = user;
return next();
}
return (authenticate as any)(req, res, next);
}

View File

@@ -60,22 +60,24 @@ function getOidcConfig() {
const clientId = process.env.OIDC_CLIENT_ID || get('oidc_client_id');
const clientSecret = process.env.OIDC_CLIENT_SECRET || decrypt_api_key(get('oidc_client_secret'));
const displayName = process.env.OIDC_DISPLAY_NAME || get('oidc_display_name') || 'SSO';
const discoveryUrl = process.env.OIDC_DISCOVERY_URL || get('oidc_discovery_url') || null;
if (!issuer || !clientId || !clientSecret) return null;
return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName };
return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName, discoveryUrl };
}
let discoveryCache: OidcDiscoveryDoc | null = null;
let discoveryCacheTime = 0;
const DISCOVERY_TTL = 60 * 60 * 1000; // 1 hour
async function discover(issuer: string) {
if (discoveryCache && Date.now() - discoveryCacheTime < DISCOVERY_TTL && discoveryCache._issuer === issuer) {
async function discover(issuer: string, discoveryUrl?: string | null) {
const url = discoveryUrl || `${issuer}/.well-known/openid-configuration`;
if (discoveryCache && Date.now() - discoveryCacheTime < DISCOVERY_TTL && discoveryCache._issuer === url) {
return discoveryCache;
}
const res = await fetch(`${issuer}/.well-known/openid-configuration`);
const res = await fetch(url);
if (!res.ok) throw new Error('Failed to fetch OIDC discovery document');
const doc = await res.json() as OidcDiscoveryDoc;
doc._issuer = issuer;
doc._issuer = url;
discoveryCache = doc;
discoveryCacheTime = Date.now();
return doc;
@@ -120,7 +122,7 @@ router.get('/login', async (req: Request, res: Response) => {
}
try {
const doc = await discover(config.issuer);
const doc = await discover(config.issuer, config.discoveryUrl);
const state = crypto.randomBytes(32).toString('hex');
const appUrl = process.env.APP_URL || (db.prepare("SELECT value FROM app_settings WHERE key = 'app_url'").get() as { value: string } | undefined)?.value;
if (!appUrl) {
@@ -172,7 +174,7 @@ router.get('/callback', async (req: Request, res: Response) => {
}
try {
const doc = await discover(config.issuer);
const doc = await discover(config.issuer, config.discoveryUrl);
const tokenRes = await fetch(doc.token_endpoint, {
method: 'POST',

View File

@@ -0,0 +1,54 @@
import crypto from 'crypto';
const TTL: Record<string, number> = {
ws: 30_000,
download: 60_000,
immich: 60_000,
};
const MAX_STORE_SIZE = 10_000;
interface TokenEntry {
userId: number;
purpose: string;
expiresAt: number;
}
const store = new Map<string, TokenEntry>();
export function createEphemeralToken(userId: number, purpose: string): string | null {
if (store.size >= MAX_STORE_SIZE) return null;
const token = crypto.randomBytes(32).toString('hex');
const ttl = TTL[purpose] ?? 60_000;
store.set(token, { userId, purpose, expiresAt: Date.now() + ttl });
return token;
}
export function consumeEphemeralToken(token: string, purpose: string): number | null {
const entry = store.get(token);
if (!entry) return null;
store.delete(token);
if (entry.purpose !== purpose || Date.now() > entry.expiresAt) return null;
return entry.userId;
}
let cleanupInterval: ReturnType<typeof setInterval> | null = null;
export function startTokenCleanup(): void {
if (cleanupInterval) return;
cleanupInterval = setInterval(() => {
const now = Date.now();
for (const [token, entry] of store) {
if (now > entry.expiresAt) store.delete(token);
}
}, 60_000);
// Allow process to exit even if interval is active
if (cleanupInterval.unref) cleanupInterval.unref();
}
export function stopTokenCleanup(): void {
if (cleanupInterval) {
clearInterval(cleanupInterval);
cleanupInterval = null;
}
}

View File

@@ -1,7 +1,6 @@
import { WebSocketServer, WebSocket } from 'ws';
import jwt from 'jsonwebtoken';
import { JWT_SECRET } from './config';
import { db, canAccessTrip } from './db/database';
import { consumeEphemeralToken } from './services/ephemeralTokens';
import { User } from './types';
import http from 'http';
@@ -70,27 +69,27 @@ function setupWebSocket(server: http.Server): void {
return;
}
let user: User | undefined;
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
user = db.prepare(
'SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?'
).get(decoded.id) as User | undefined;
if (!user) {
nws.close(4001, 'User not found');
return;
}
const requireMfa = (db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined)?.value === 'true';
const mfaOk = user.mfa_enabled === 1 || user.mfa_enabled === true;
if (requireMfa && !mfaOk) {
nws.close(4403, 'MFA required');
return;
}
} catch (err: unknown) {
const userId = consumeEphemeralToken(token, 'ws');
if (!userId) {
nws.close(4001, 'Invalid or expired token');
return;
}
let user: User | undefined;
user = db.prepare(
'SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?'
).get(userId) as User | undefined;
if (!user) {
nws.close(4001, 'User not found');
return;
}
const requireMfa = (db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined)?.value === 'true';
const mfaOk = user.mfa_enabled === 1 || user.mfa_enabled === true;
if (requireMfa && !mfaOk) {
nws.close(4403, 'MFA required');
return;
}
nws.isAlive = true;
const sid = nextSocketId++;
socketId.set(nws, sid);