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

19
client/src/api/authUrl.ts Normal file
View File

@@ -0,0 +1,19 @@
export async function getAuthUrl(url: string, purpose: 'download' | 'immich'): Promise<string> {
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
}
}

View File

@@ -12,6 +12,7 @@ const activeTrips = new Set<string>()
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<string | null> {
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<void> {
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 = () => {