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

@@ -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<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false })
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false, discovery_url: '' })
const [savingOidc, setSavingOidc] = useState<boolean>(false)
// Registration toggle
@@ -879,6 +880,17 @@ export default function AdminPage(): React.ReactElement {
/>
<p className="text-xs text-slate-400 mt-1">{t('admin.oidcIssuerHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Discovery URL <span className="text-slate-400 font-normal">(optional)</span></label>
<input
type="url"
value={oidcConfig.discovery_url}
onChange={e => 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"
/>
<p className="text-xs text-slate-400 mt-1">Override the auto-constructed discovery URL. Required for providers like Authentik where the endpoint is not at <code className="bg-slate-100 px-1 rounded">{'<issuer>/.well-known/openid-configuration'}</code>.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Client ID</label>
<input
@@ -920,7 +932,7 @@ export default function AdminPage(): React.ReactElement {
onClick={async () => {
setSavingOidc(true)
try {
const payload: Record<string, unknown> = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, oidc_only: oidcConfig.oidc_only }
const payload: Record<string, unknown> = { 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'))