diff --git a/client/src/api/authUrl.ts b/client/src/api/authUrl.ts new file mode 100644 index 0000000..433f09d --- /dev/null +++ b/client/src/api/authUrl.ts @@ -0,0 +1,19 @@ +export async function getAuthUrl(url: string, purpose: 'download' | 'immich'): Promise { + const jwt = localStorage.getItem('auth_token') + if (!jwt || !url) return url + try { + const resp = await fetch('/api/auth/resource-token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${jwt}`, + }, + body: JSON.stringify({ purpose }), + }) + if (!resp.ok) return url + const { token } = await resp.json() + return `${url}${url.includes('?') ? '&' : '?'}token=${token}` + } catch { + return url + } +} diff --git a/client/src/api/websocket.ts b/client/src/api/websocket.ts index bde9815..757f953 100644 --- a/client/src/api/websocket.ts +++ b/client/src/api/websocket.ts @@ -12,6 +12,7 @@ const activeTrips = new Set() let currentToken: string | null = null let refetchCallback: RefetchCallback | null = null let mySocketId: string | null = null +let connecting = false export function getSocketId(): string | null { return mySocketId @@ -21,9 +22,28 @@ export function setRefetchCallback(fn: RefetchCallback | null): void { refetchCallback = fn } -function getWsUrl(token: string): string { +function getWsUrl(wsToken: string): string { const protocol = location.protocol === 'https:' ? 'wss' : 'ws' - return `${protocol}://${location.host}/ws?token=${token}` + return `${protocol}://${location.host}/ws?token=${wsToken}` +} + +async function fetchWsToken(jwt: string): Promise { + try { + const resp = await fetch('/api/auth/ws-token', { + method: 'POST', + headers: { 'Authorization': `Bearer ${jwt}` }, + }) + if (resp.status === 401) { + // JWT expired — stop reconnecting + currentToken = null + return null + } + if (!resp.ok) return null + const { token } = await resp.json() + return token as string + } catch { + return null + } } function handleMessage(event: MessageEvent): void { @@ -52,12 +72,23 @@ function scheduleReconnect(): void { reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY) } -function connectInternal(token: string, _isReconnect = false): void { +async function connectInternal(token: string, _isReconnect = false): Promise { + if (connecting) return if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) { return } - const url = getWsUrl(token) + connecting = true + const wsToken = await fetchWsToken(token) + connecting = false + + if (!wsToken) { + // currentToken may have been cleared on 401; only schedule reconnect if still active + if (currentToken) scheduleReconnect() + return + } + + const url = getWsUrl(wsToken) socket = new WebSocket(url) socket.onopen = () => { diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index 3fcf8e2..3dc6044 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -1,5 +1,5 @@ import ReactDOM from 'react-dom' -import { useState, useCallback, useRef } from 'react' +import { useState, useCallback, useRef, useEffect } from 'react' import { useDropzone } from 'react-dropzone' import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check } from 'lucide-react' import { useToast } from '../shared/Toast' @@ -9,11 +9,7 @@ import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../ty import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' -function authUrl(url: string): string { - const token = localStorage.getItem('auth_token') - if (!token || !url || url.includes('token=')) return url - return `${url}${url.includes('?') ? '&' : '?'}token=${token}` -} +import { getAuthUrl } from '../../api/authUrl' function isImage(mimeType) { if (!mimeType) return false @@ -49,6 +45,10 @@ interface ImageLightboxProps { function ImageLightbox({ file, onClose }: ImageLightboxProps) { const { t } = useTranslation() + const [imgSrc, setImgSrc] = useState('') + useEffect(() => { + getAuthUrl(file.url, 'download').then(setImgSrc) + }, [file.url]) return (
e.stopPropagation()}> {file.original_name}
{file.original_name}
- + @@ -76,6 +80,15 @@ function ImageLightbox({ file, onClose }: ImageLightboxProps) { ) } +// Authenticated image — fetches a short-lived download token and renders the image +function AuthedImg({ src, style }: { src: string; style?: React.CSSProperties }) { + const [authSrc, setAuthSrc] = useState('') + useEffect(() => { + getAuthUrl(src, 'download').then(setAuthSrc) + }, [src]) + return authSrc ? : null +} + // Source badge interface SourceBadgeProps { icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }> @@ -292,6 +305,14 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, } const [previewFile, setPreviewFile] = useState(null) + const [previewFileUrl, setPreviewFileUrl] = useState('') + useEffect(() => { + if (previewFile) { + getAuthUrl(previewFile.url, 'download').then(setPreviewFileUrl) + } else { + setPreviewFileUrl('') + } + }, [previewFile?.url]) const [assignFileId, setAssignFileId] = useState(null) const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => { @@ -322,8 +343,6 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, if (file.reservation_id) allLinkedResIds.add(file.reservation_id) for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid) const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean) - const fileUrl = authUrl(file.url) - return (
{/* Icon or thumbnail */}
!isTrash && openFile({ ...file, url: fileUrl })} + onClick={() => !isTrash && openFile(file)} style={{ flexShrink: 0, width: 36, height: 36, borderRadius: 8, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -345,7 +364,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, }} > {isImage(file.mime_type) - ? + ? : (() => { const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?' const isPdf = file.mime_type === 'application/pdf' @@ -366,7 +385,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, )} {!isTrash && file.starred ? : null} !isTrash && openFile({ ...file, url: fileUrl })} + onClick={() => !isTrash && openFile(file)} style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }} > {file.original_name} @@ -416,7 +435,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}> } - @@ -633,12 +652,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
{previewFile.original_name}
- e.currentTarget.style.color = 'var(--text-primary)'} - onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}> +

- PDF herunterladen +

diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index 74a72a0..1db08e9 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -3,6 +3,15 @@ import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPi import apiClient from '../../api/client' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' +import { getAuthUrl } from '../../api/authUrl' + +function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) { + const [src, setSrc] = useState('') + useEffect(() => { + getAuthUrl(baseUrl, 'immich').then(setSrc) + }, [baseUrl]) + return src ? : null +} // ── Types ─────────────────────────────────────────────────────────────────── @@ -57,6 +66,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const [lightboxUserId, setLightboxUserId] = useState(null) const [lightboxInfo, setLightboxInfo] = useState(null) const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false) + const [lightboxOriginalSrc, setLightboxOriginalSrc] = useState('') // ── Init ────────────────────────────────────────────────────────────────── @@ -167,13 +177,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // ── Helpers ─────────────────────────────────────────────────────────────── - const token = useAuthStore(s => s.token) - - const thumbnailUrl = (assetId: string, userId: number) => - `/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}&token=${token}` - - const originalUrl = (assetId: string, userId: number) => - `/api/integrations/immich/assets/${assetId}/original?userId=${userId}&token=${token}` + const thumbnailBaseUrl = (assetId: string, userId: number) => + `/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}` const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id) const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared) @@ -328,7 +333,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa outline: isSelected ? '3px solid var(--text-primary)' : 'none', outlineOffset: -3, }}> - {isSelected && (
{ setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null) + setLightboxOriginalSrc('') + getAuthUrl(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`, 'immich').then(setLightboxOriginalSrc) setLightboxInfoLoading(true) apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`) .then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false)) }}> - {/* Other user's avatar */} @@ -592,7 +599,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}> diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 6b78182..1fe4ace 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -1,10 +1,5 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' - -function authUrl(url: string): string { - const token = localStorage.getItem('auth_token') - if (!token || !url) return url - return `${url}${url.includes('?') ? '&' : '?'}token=${token}` -} +import { getAuthUrl } from '../../api/authUrl' import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react' import PlaceAvatar from '../shared/PlaceAvatar' import { mapsApi } from '../../api/client' @@ -587,11 +582,11 @@ export default function PlaceInspector({ {filesExpanded && placeFiles.length > 0 && ( )} diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 3c5e928..c682e75 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -44,6 +44,7 @@ interface OidcConfig { client_secret_set: boolean display_name: string oidc_only: boolean + discovery_url: string } interface UpdateInfo { @@ -84,7 +85,7 @@ export default function AdminPage(): React.ReactElement { useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, []) // OIDC config - const [oidcConfig, setOidcConfig] = useState({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false }) + const [oidcConfig, setOidcConfig] = useState({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false, discovery_url: '' }) const [savingOidc, setSavingOidc] = useState(false) // Registration toggle @@ -879,6 +880,17 @@ export default function AdminPage(): React.ReactElement { />

{t('admin.oidcIssuerHint')}

+
+ + setOidcConfig(c => ({ ...c, discovery_url: e.target.value }))} + placeholder='https://auth.example.com/application/o/trek/.well-known/openid-configuration' + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +

Override the auto-constructed discovery URL. Required for providers like Authentik where the endpoint is not at {'/.well-known/openid-configuration'}.

+
{ setSavingOidc(true) try { - const payload: Record = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, oidc_only: oidcConfig.oidc_only } + const payload: Record = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, oidc_only: oidcConfig.oidc_only, discovery_url: oidcConfig.discovery_url } if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret await adminApi.updateOidc(payload) toast.success(t('admin.oidcSaved')) diff --git a/server/.env.example b/server/.env.example index 490447c..8fad512 100644 --- a/server/.env.example +++ b/server/.env.example @@ -17,5 +17,6 @@ OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button OIDC_ONLY=true # Disable local password auth entirely (SSO only) OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role +OIDC_DISCOVERY_URL= # Override the auto-constructed discovery endpoint (e.g. Authentik: https://auth.example.com/application/o/trek/.well-known/openid-configuration) DEMO_MODE=false # Demo mode - resets data hourly diff --git a/server/src/index.ts b/server/src/index.ts index 8cde5c3..e588d30 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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); }); diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index e100165..421b8a1 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -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, diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index d1b211c..62922e0 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -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; diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts index 6da1b0a..13df3cd 100644 --- a/server/src/routes/files.ts +++ b/server/src/routes/files.ts @@ -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); diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index ef3891b..8bb3e8b 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -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 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 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); } diff --git a/server/src/routes/oidc.ts b/server/src/routes/oidc.ts index 0c3686f..a62d306 100644 --- a/server/src/routes/oidc.ts +++ b/server/src/routes/oidc.ts @@ -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', diff --git a/server/src/services/ephemeralTokens.ts b/server/src/services/ephemeralTokens.ts new file mode 100644 index 0000000..0d1c12b --- /dev/null +++ b/server/src/services/ephemeralTokens.ts @@ -0,0 +1,54 @@ +import crypto from 'crypto'; + +const TTL: Record = { + 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(); + +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 | 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; + } +} diff --git a/server/src/websocket.ts b/server/src/websocket.ts index 69b1d7e..3bc310c 100644 --- a/server/src/websocket.ts +++ b/server/src/websocket.ts @@ -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);