mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ const activeTrips = new Set<string>()
|
|||||||
let currentToken: string | null = null
|
let currentToken: string | null = null
|
||||||
let refetchCallback: RefetchCallback | null = null
|
let refetchCallback: RefetchCallback | null = null
|
||||||
let mySocketId: string | null = null
|
let mySocketId: string | null = null
|
||||||
|
let connecting = false
|
||||||
|
|
||||||
export function getSocketId(): string | null {
|
export function getSocketId(): string | null {
|
||||||
return mySocketId
|
return mySocketId
|
||||||
@@ -21,9 +22,28 @@ export function setRefetchCallback(fn: RefetchCallback | null): void {
|
|||||||
refetchCallback = fn
|
refetchCallback = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWsUrl(token: string): string {
|
function getWsUrl(wsToken: string): string {
|
||||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
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 {
|
function handleMessage(event: MessageEvent): void {
|
||||||
@@ -52,12 +72,23 @@ function scheduleReconnect(): void {
|
|||||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
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)) {
|
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||||
return
|
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 = new WebSocket(url)
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { useState, useCallback, useRef } from 'react'
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { useDropzone } from 'react-dropzone'
|
||||||
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check } from 'lucide-react'
|
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check } from 'lucide-react'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
@@ -9,11 +9,7 @@ import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../ty
|
|||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
|
||||||
function authUrl(url: string): string {
|
import { getAuthUrl } from '../../api/authUrl'
|
||||||
const token = localStorage.getItem('auth_token')
|
|
||||||
if (!token || !url || url.includes('token=')) return url
|
|
||||||
return `${url}${url.includes('?') ? '&' : '?'}token=${token}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function isImage(mimeType) {
|
function isImage(mimeType) {
|
||||||
if (!mimeType) return false
|
if (!mimeType) return false
|
||||||
@@ -49,6 +45,10 @@ interface ImageLightboxProps {
|
|||||||
|
|
||||||
function ImageLightbox({ file, onClose }: ImageLightboxProps) {
|
function ImageLightbox({ file, onClose }: ImageLightboxProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const [imgSrc, setImgSrc] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
getAuthUrl(file.url, 'download').then(setImgSrc)
|
||||||
|
}, [file.url])
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
@@ -56,16 +56,20 @@ function ImageLightbox({ file, onClose }: ImageLightboxProps) {
|
|||||||
>
|
>
|
||||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
||||||
<img
|
<img
|
||||||
src={authUrl(file.url)}
|
src={imgSrc}
|
||||||
alt={file.original_name}
|
alt={file.original_name}
|
||||||
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }}
|
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }}
|
||||||
/>
|
/>
|
||||||
<div style={{ position: 'absolute', top: -40, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
<div style={{ position: 'absolute', top: -40, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
||||||
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '80%' }}>{file.original_name}</span>
|
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '80%' }}>{file.original_name}</span>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<a href={authUrl(file.url)} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }} title={t('files.openTab')}>
|
<button
|
||||||
|
onClick={async () => { const u = await getAuthUrl(file.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}
|
||||||
|
title={t('files.openTab')}
|
||||||
|
>
|
||||||
<ExternalLink size={16} />
|
<ExternalLink size={16} />
|
||||||
</a>
|
</button>
|
||||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
|
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -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 ? <img src={authSrc} alt="" style={style} /> : null
|
||||||
|
}
|
||||||
|
|
||||||
// Source badge
|
// Source badge
|
||||||
interface SourceBadgeProps {
|
interface SourceBadgeProps {
|
||||||
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
|
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 [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<number | null>(null)
|
const [assignFileId, setAssignFileId] = useState<number | null>(null)
|
||||||
|
|
||||||
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | 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)
|
if (file.reservation_id) allLinkedResIds.add(file.reservation_id)
|
||||||
for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid)
|
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 linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean)
|
||||||
const fileUrl = authUrl(file.url)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={file.id} style={{
|
<div key={file.id} style={{
|
||||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
||||||
@@ -337,7 +356,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
>
|
>
|
||||||
{/* Icon or thumbnail */}
|
{/* Icon or thumbnail */}
|
||||||
<div
|
<div
|
||||||
onClick={() => !isTrash && openFile({ ...file, url: fileUrl })}
|
onClick={() => !isTrash && openFile(file)}
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
|
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
|
||||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
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)
|
{isImage(file.mime_type)
|
||||||
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
? <AuthedImg src={file.url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
: (() => {
|
: (() => {
|
||||||
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||||
const isPdf = file.mime_type === 'application/pdf'
|
const isPdf = file.mime_type === 'application/pdf'
|
||||||
@@ -366,7 +385,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
)}
|
)}
|
||||||
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
|
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
|
||||||
<span
|
<span
|
||||||
onClick={() => !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' }}
|
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }}
|
||||||
>
|
>
|
||||||
{file.original_name}
|
{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)'}>
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</button>}
|
</button>}
|
||||||
<button onClick={() => openFile({ ...file, url: fileUrl })} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
<button onClick={() => openFile(file)} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<ExternalLink size={14} />
|
<ExternalLink size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -633,12 +652,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||||
<a href={authUrl(previewFile.url)} target="_blank" rel="noreferrer"
|
<button
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
|
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
|
||||||
|
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
||||||
<ExternalLink size={13} /> {t('files.openTab')}
|
<ExternalLink size={13} /> {t('files.openTab')}
|
||||||
</a>
|
</button>
|
||||||
<button onClick={() => setPreviewFile(null)}
|
<button onClick={() => setPreviewFile(null)}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
@@ -648,13 +668,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<object
|
<object
|
||||||
data={`${authUrl(previewFile.url)}#view=FitH`}
|
data={previewFileUrl ? `${previewFileUrl}#view=FitH` : undefined}
|
||||||
type="application/pdf"
|
type="application/pdf"
|
||||||
style={{ flex: 1, width: '100%', border: 'none' }}
|
style={{ flex: 1, width: '100%', border: 'none' }}
|
||||||
title={previewFile.original_name}
|
title={previewFile.original_name}
|
||||||
>
|
>
|
||||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||||
<a href={authUrl(previewFile.url)} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>PDF herunterladen</a>
|
<button onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>PDF herunterladen</button>
|
||||||
</p>
|
</p>
|
||||||
</object>
|
</object>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPi
|
|||||||
import apiClient from '../../api/client'
|
import apiClient from '../../api/client'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { useTranslation } from '../../i18n'
|
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 ? <img src={src} alt="" loading={loading} style={style} /> : null
|
||||||
|
}
|
||||||
|
|
||||||
// ── Types ───────────────────────────────────────────────────────────────────
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -57,6 +66,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
|
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
|
||||||
const [lightboxInfo, setLightboxInfo] = useState<any>(null)
|
const [lightboxInfo, setLightboxInfo] = useState<any>(null)
|
||||||
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
|
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
|
||||||
|
const [lightboxOriginalSrc, setLightboxOriginalSrc] = useState('')
|
||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -167,13 +177,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const token = useAuthStore(s => s.token)
|
const thumbnailBaseUrl = (assetId: string, userId: number) =>
|
||||||
|
`/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}`
|
||||||
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 ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id)
|
const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id)
|
||||||
const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared)
|
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',
|
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
|
||||||
outlineOffset: -3,
|
outlineOffset: -3,
|
||||||
}}>
|
}}>
|
||||||
<img src={thumbnailUrl(asset.id, currentUser!.id)} alt="" loading="lazy"
|
<ImmichImg baseUrl={thumbnailBaseUrl(asset.id, currentUser!.id)} loading="lazy"
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -470,12 +475,14 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
|
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
|
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)
|
setLightboxInfoLoading(true)
|
||||||
apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`)
|
apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`)
|
||||||
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
|
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
|
||||||
}}>
|
}}>
|
||||||
|
|
||||||
<img src={thumbnailUrl(photo.immich_asset_id, photo.user_id)} alt="" loading="lazy"
|
<ImmichImg baseUrl={thumbnailBaseUrl(photo.immich_asset_id, photo.user_id)} loading="lazy"
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10 }} />
|
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10 }} />
|
||||||
|
|
||||||
{/* Other user's avatar */}
|
{/* Other user's avatar */}
|
||||||
@@ -592,7 +599,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
</button>
|
</button>
|
||||||
<div onClick={e => e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}>
|
<div onClick={e => e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}>
|
||||||
<img
|
<img
|
||||||
src={originalUrl(lightboxId, lightboxUserId)}
|
src={lightboxOriginalSrc}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ maxWidth: lightboxInfo ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }}
|
style={{ maxWidth: lightboxInfo ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
|
import { getAuthUrl } from '../../api/authUrl'
|
||||||
function authUrl(url: string): string {
|
|
||||||
const token = localStorage.getItem('auth_token')
|
|
||||||
if (!token || !url) return url
|
|
||||||
return `${url}${url.includes('?') ? '&' : '?'}token=${token}`
|
|
||||||
}
|
|
||||||
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 { 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 PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
import { mapsApi } from '../../api/client'
|
import { mapsApi } from '../../api/client'
|
||||||
@@ -587,11 +582,11 @@ export default function PlaceInspector({
|
|||||||
{filesExpanded && placeFiles.length > 0 && (
|
{filesExpanded && placeFiles.length > 0 && (
|
||||||
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
{placeFiles.map(f => (
|
{placeFiles.map(f => (
|
||||||
<a key={f.id} href={authUrl(f.url)} target="_blank" rel="noopener noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer' }}>
|
<button key={f.id} onClick={async () => { const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer', background: 'none', border: 'none', width: '100%', textAlign: 'left' }}>
|
||||||
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
|
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||||
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
|
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
|
||||||
</a>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ interface OidcConfig {
|
|||||||
client_secret_set: boolean
|
client_secret_set: boolean
|
||||||
display_name: string
|
display_name: string
|
||||||
oidc_only: boolean
|
oidc_only: boolean
|
||||||
|
discovery_url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateInfo {
|
interface UpdateInfo {
|
||||||
@@ -84,7 +85,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
|
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
|
||||||
|
|
||||||
// OIDC config
|
// 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)
|
const [savingOidc, setSavingOidc] = useState<boolean>(false)
|
||||||
|
|
||||||
// Registration toggle
|
// 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>
|
<p className="text-xs text-slate-400 mt-1">{t('admin.oidcIssuerHint')}</p>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Client ID</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">Client ID</label>
|
||||||
<input
|
<input
|
||||||
@@ -920,7 +932,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setSavingOidc(true)
|
setSavingOidc(true)
|
||||||
try {
|
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
|
if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret
|
||||||
await adminApi.updateOidc(payload)
|
await adminApi.updateOidc(payload)
|
||||||
toast.success(t('admin.oidcSaved'))
|
toast.success(t('admin.oidcSaved'))
|
||||||
|
|||||||
@@ -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_ONLY=true # Disable local password auth entirely (SSO only)
|
||||||
OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users
|
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_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
|
DEMO_MODE=false # Demo mode - resets data hourly
|
||||||
|
|||||||
@@ -278,6 +278,8 @@ const server = app.listen(PORT, () => {
|
|||||||
scheduler.start();
|
scheduler.start();
|
||||||
scheduler.startTripReminders();
|
scheduler.startTripReminders();
|
||||||
scheduler.startDemoReset();
|
scheduler.startDemoReset();
|
||||||
|
const { startTokenCleanup } = require('./services/ephemeralTokens');
|
||||||
|
startTokenCleanup();
|
||||||
import('./websocket').then(({ setupWebSocket }) => {
|
import('./websocket').then(({ setupWebSocket }) => {
|
||||||
setupWebSocket(server);
|
setupWebSocket(server);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -240,17 +240,19 @@ router.get('/oidc', (_req: Request, res: Response) => {
|
|||||||
client_secret_set: !!secret,
|
client_secret_set: !!secret,
|
||||||
display_name: get('oidc_display_name'),
|
display_name: get('oidc_display_name'),
|
||||||
oidc_only: get('oidc_only') === 'true',
|
oidc_only: get('oidc_only') === 'true',
|
||||||
|
discovery_url: get('oidc_discovery_url'),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/oidc', (req: Request, res: Response) => {
|
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 || '');
|
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_issuer', issuer);
|
||||||
set('oidc_client_id', client_id);
|
set('oidc_client_id', client_id);
|
||||||
if (client_secret !== undefined) set('oidc_client_secret', maybe_encrypt_api_key(client_secret) ?? '');
|
if (client_secret !== undefined) set('oidc_client_secret', maybe_encrypt_api_key(client_secret) ?? '');
|
||||||
set('oidc_display_name', display_name);
|
set('oidc_display_name', display_name);
|
||||||
set('oidc_only', oidc_only ? 'true' : 'false');
|
set('oidc_only', oidc_only ? 'true' : 'false');
|
||||||
|
set('oidc_discovery_url', discovery_url);
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
writeAudit({
|
writeAudit({
|
||||||
userId: authReq.user.id,
|
userId: authReq.user.id,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { AuthRequest, OptionalAuthRequest, User } from '../types';
|
|||||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||||
import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../services/apiKeyCrypto';
|
import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../services/apiKeyCrypto';
|
||||||
import { startTripReminders } from '../scheduler';
|
import { startTripReminders } from '../scheduler';
|
||||||
|
import { createEphemeralToken } from '../services/ephemeralTokens';
|
||||||
|
|
||||||
authenticator.options = { window: 1 };
|
authenticator.options = { window: 1 };
|
||||||
|
|
||||||
@@ -951,4 +952,24 @@ router.delete('/mcp-tokens/:id', authenticate, (req: Request, res: Response) =>
|
|||||||
res.json({ success: true });
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { JWT_SECRET } from '../config';
|
import { JWT_SECRET } from '../config';
|
||||||
import { db, canAccessTrip } from '../db/database';
|
import { db, canAccessTrip } from '../db/database';
|
||||||
|
import { consumeEphemeralToken } from '../services/ephemeralTokens';
|
||||||
import { authenticate, demoUploadBlock } from '../middleware/auth';
|
import { authenticate, demoUploadBlock } from '../middleware/auth';
|
||||||
import { requireTripAccess } from '../middleware/tripAccess';
|
import { requireTripAccess } from '../middleware/tripAccess';
|
||||||
import { broadcast } from '../websocket';
|
import { broadcast } from '../websocket';
|
||||||
@@ -84,17 +85,25 @@ function getPlaceFiles(tripId: string | number, placeId: number) {
|
|||||||
router.get('/:id/download', (req: Request, res: Response) => {
|
router.get('/:id/download', (req: Request, res: Response) => {
|
||||||
const { tripId, id } = req.params;
|
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 authHeader = req.headers['authorization'];
|
||||||
const token = (authHeader && authHeader.split(' ')[1]) || (req.query.token as string);
|
const bearerToken = authHeader && authHeader.split(' ')[1];
|
||||||
if (!token) return res.status(401).json({ error: 'Authentication required' });
|
const queryToken = req.query.token as string | undefined;
|
||||||
|
|
||||||
|
if (!bearerToken && !queryToken) return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
|
||||||
let userId: number;
|
let userId: number;
|
||||||
try {
|
if (bearerToken) {
|
||||||
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
|
try {
|
||||||
userId = decoded.id;
|
const decoded = jwt.verify(bearerToken, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
|
||||||
} catch {
|
userId = decoded.id;
|
||||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
} 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);
|
const trip = verifyTripOwnership(tripId, userId);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response, NextFunction } from 'express';
|
||||||
import { db } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { broadcast } from '../websocket';
|
import { broadcast } from '../websocket';
|
||||||
import { AuthRequest } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
|
import { consumeEphemeralToken } from '../services/ephemeralTokens';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -254,11 +255,16 @@ router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Resp
|
|||||||
|
|
||||||
// ── Proxy Immich Assets ─────────────────────────────────────────────────────
|
// ── Proxy Immich Assets ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Asset proxy routes accept token via query param (for <img> src usage)
|
// Asset proxy routes accept ephemeral token via query param (for <img> src usage)
|
||||||
function authFromQuery(req: Request, res: Response, next: Function) {
|
function authFromQuery(req: Request, res: Response, next: NextFunction) {
|
||||||
const token = req.query.token as string;
|
const queryToken = req.query.token as string | undefined;
|
||||||
if (token && !req.headers.authorization) {
|
if (queryToken) {
|
||||||
req.headers.authorization = `Bearer ${token}`;
|
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);
|
return (authenticate as any)(req, res, next);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,22 +60,24 @@ function getOidcConfig() {
|
|||||||
const clientId = process.env.OIDC_CLIENT_ID || get('oidc_client_id');
|
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 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 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;
|
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 discoveryCache: OidcDiscoveryDoc | null = null;
|
||||||
let discoveryCacheTime = 0;
|
let discoveryCacheTime = 0;
|
||||||
const DISCOVERY_TTL = 60 * 60 * 1000; // 1 hour
|
const DISCOVERY_TTL = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
async function discover(issuer: string) {
|
async function discover(issuer: string, discoveryUrl?: string | null) {
|
||||||
if (discoveryCache && Date.now() - discoveryCacheTime < DISCOVERY_TTL && discoveryCache._issuer === issuer) {
|
const url = discoveryUrl || `${issuer}/.well-known/openid-configuration`;
|
||||||
|
if (discoveryCache && Date.now() - discoveryCacheTime < DISCOVERY_TTL && discoveryCache._issuer === url) {
|
||||||
return discoveryCache;
|
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');
|
if (!res.ok) throw new Error('Failed to fetch OIDC discovery document');
|
||||||
const doc = await res.json() as OidcDiscoveryDoc;
|
const doc = await res.json() as OidcDiscoveryDoc;
|
||||||
doc._issuer = issuer;
|
doc._issuer = url;
|
||||||
discoveryCache = doc;
|
discoveryCache = doc;
|
||||||
discoveryCacheTime = Date.now();
|
discoveryCacheTime = Date.now();
|
||||||
return doc;
|
return doc;
|
||||||
@@ -120,7 +122,7 @@ router.get('/login', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const doc = await discover(config.issuer);
|
const doc = await discover(config.issuer, config.discoveryUrl);
|
||||||
const state = crypto.randomBytes(32).toString('hex');
|
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;
|
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) {
|
if (!appUrl) {
|
||||||
@@ -172,7 +174,7 @@ router.get('/callback', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const doc = await discover(config.issuer);
|
const doc = await discover(config.issuer, config.discoveryUrl);
|
||||||
|
|
||||||
const tokenRes = await fetch(doc.token_endpoint, {
|
const tokenRes = await fetch(doc.token_endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-19
@@ -1,7 +1,6 @@
|
|||||||
import { WebSocketServer, WebSocket } from 'ws';
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
import jwt from 'jsonwebtoken';
|
|
||||||
import { JWT_SECRET } from './config';
|
|
||||||
import { db, canAccessTrip } from './db/database';
|
import { db, canAccessTrip } from './db/database';
|
||||||
|
import { consumeEphemeralToken } from './services/ephemeralTokens';
|
||||||
import { User } from './types';
|
import { User } from './types';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
|
|
||||||
@@ -70,27 +69,27 @@ function setupWebSocket(server: http.Server): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let user: User | undefined;
|
const userId = consumeEphemeralToken(token, 'ws');
|
||||||
try {
|
if (!userId) {
|
||||||
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) {
|
|
||||||
nws.close(4001, 'Invalid or expired token');
|
nws.close(4001, 'Invalid or expired token');
|
||||||
return;
|
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;
|
nws.isAlive = true;
|
||||||
const sid = nextSocketId++;
|
const sid = nextSocketId++;
|
||||||
socketId.set(nws, sid);
|
socketId.set(nws, sid);
|
||||||
|
|||||||
Reference in New Issue
Block a user