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
+17 -8
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);