From c6cdc11693c7e9eecb452f4d7a7d856e5ab14a59 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sun, 26 Apr 2026 13:25:48 +0200 Subject: [PATCH] feat(auth): D5 cleanup + W3 web refresh UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D5 — backend cleanup: - Migration 000029 drops legacy token_hash column from sessions - JWT_SECRET renamed to APP_SECRET (fallback + deprecation warning) W3 — web session UX: - AuthData type: session_token→refresh_token, remove expires_in - cookies.ts: refresh_token cookie, non-HttpOnly access_expires_at - client.server.ts: sends X-Refresh-Token header (not JSON body) - hooks.server.ts: simplified two-path SSR refresh logic - refresh.ts: single-flight client-side refresh - client.ts: proactive refresh + 401 retry on non-auth paths - /api/auth/refresh: SvelteKit proxy for HttpOnly cookie refresh - OAuth callback, Datenschutz page updated to new cookie names --- backend/internal/config/config.go | 16 +++-- .../000029_drop_legacy_token_hash.down.sql | 1 + .../000029_drop_legacy_token_hash.up.sql | 3 + web/src/hooks.server.ts | 37 ++++++------ web/src/lib/api/client.server.ts | 7 ++- web/src/lib/api/client.ts | 59 ++++++++++++++----- web/src/lib/api/refresh.ts | 33 +++++++++++ web/src/lib/api/types.ts | 12 +++- web/src/lib/auth/cookies.ts | 27 ++++++--- web/src/routes/api/auth/refresh/+server.ts | 16 +++++ .../auth/oauth/callback/+page.server.ts | 11 +--- web/src/routes/datenschutz/+page.svelte | 14 ++++- 12 files changed, 173 insertions(+), 63 deletions(-) create mode 100644 backend/migrations/000029_drop_legacy_token_hash.down.sql create mode 100644 backend/migrations/000029_drop_legacy_token_hash.up.sql create mode 100644 web/src/lib/api/refresh.ts create mode 100644 web/src/routes/api/auth/refresh/+server.ts diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index bd914fb..d34784f 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -230,9 +230,17 @@ func Load() (*Config, error) { return nil, fmt.Errorf("DISCOVERY_CRAWLER_MANUAL_RATE_LIMIT_PER_HOUR: %w", err) } - jwtSecret := envStr("JWT_SECRET", "") - if jwtSecret == "" { - return nil, fmt.Errorf("JWT_SECRET is required") + // APP_SECRET is used for AES-256-GCM encryption of settings (API keys, etc.). + // JWT_SECRET is accepted as a fallback so existing deployments don't break on upgrade. + appSecret := envStr("APP_SECRET", "") + if appSecret == "" { + appSecret = envStr("JWT_SECRET", "") + if appSecret != "" { + slog.Warn("JWT_SECRET is deprecated; rename to APP_SECRET") + } + } + if appSecret == "" { + return nil, fmt.Errorf("APP_SECRET is required") } return &Config{ @@ -257,7 +265,7 @@ func Load() (*Config, error) { DB: valkeyDB, }, JWT: JWTConfig{ - Secret: jwtSecret, + Secret: appSecret, }, Auth: AuthConfig{ AccessTTL: authAccessTTL, diff --git a/backend/migrations/000029_drop_legacy_token_hash.down.sql b/backend/migrations/000029_drop_legacy_token_hash.down.sql new file mode 100644 index 0000000..63f3cef --- /dev/null +++ b/backend/migrations/000029_drop_legacy_token_hash.down.sql @@ -0,0 +1 @@ +ALTER TABLE sessions ADD COLUMN IF NOT EXISTS token_hash TEXT; diff --git a/backend/migrations/000029_drop_legacy_token_hash.up.sql b/backend/migrations/000029_drop_legacy_token_hash.up.sql new file mode 100644 index 0000000..e47285b --- /dev/null +++ b/backend/migrations/000029_drop_legacy_token_hash.up.sql @@ -0,0 +1,3 @@ +-- D5: drop the legacy token_hash column that was kept through D2/D3 for safe rollout. +-- All sessions now use access_token_hash and refresh_token_hash. +ALTER TABLE sessions DROP COLUMN IF EXISTS token_hash; diff --git a/web/src/hooks.server.ts b/web/src/hooks.server.ts index 5b0b81f..36fd527 100644 --- a/web/src/hooks.server.ts +++ b/web/src/hooks.server.ts @@ -9,11 +9,9 @@ export const handle: Handle = async ({ event, resolve }) => { return new Response('ok', { status: 200 }); } - const accessToken = event.cookies.get('access_token'); - const sessionToken = event.cookies.get('session_token'); - event.locals.user = null; + const accessToken = event.cookies.get('access_token'); if (accessToken) { try { const res = await apiFetch('/users/me', { @@ -22,26 +20,25 @@ export const handle: Handle = async ({ event, resolve }) => { }); event.locals.user = res.data; } catch { - // Access token expired — try refresh - if (sessionToken) { - const refreshed = await refreshTokens(event.cookies, event.fetch); - if (refreshed) { - const newToken = event.cookies.get('access_token'); - if (newToken) { - try { - const res = await apiFetch('/users/me', { - headers: { Authorization: `Bearer ${newToken}` }, - fetch: event.fetch - }); - event.locals.user = res.data; - } catch { - // Token invalid even after refresh - } + // Access token expired or invalid — attempt silent refresh. + const refreshed = await refreshTokens(event.cookies, event.fetch); + if (refreshed) { + const newToken = event.cookies.get('access_token'); + if (newToken) { + try { + const res = await apiFetch('/users/me', { + headers: { Authorization: `Bearer ${newToken}` }, + fetch: event.fetch + }); + event.locals.user = res.data; + } catch { + // Refresh succeeded but /users/me still fails — treat as logged out. } } } } - } else if (sessionToken) { + } else { + // No access token — attempt refresh in case only the refresh_token cookie survived. const refreshed = await refreshTokens(event.cookies, event.fetch); if (refreshed) { const newToken = event.cookies.get('access_token'); @@ -53,7 +50,7 @@ export const handle: Handle = async ({ event, resolve }) => { }); event.locals.user = res.data; } catch { - // Failed after refresh + // no-op } } } diff --git a/web/src/lib/api/client.server.ts b/web/src/lib/api/client.server.ts index e399f7b..c5ff20a 100644 --- a/web/src/lib/api/client.server.ts +++ b/web/src/lib/api/client.server.ts @@ -35,13 +35,14 @@ export async function refreshTokens( cookies: Cookies, fetchFn: typeof globalThis.fetch ): Promise { - const sessionToken = cookies.get('session_token'); - if (!sessionToken) return false; + const refreshToken = cookies.get('refresh_token'); + if (!refreshToken) return false; try { const res = await apiFetch('/auth/refresh', { method: 'POST', - body: JSON.stringify({ session_token: sessionToken }), + baseURL: SERVER_API_BASE, + headers: { 'X-Refresh-Token': refreshToken }, fetch: fetchFn }); setAuthCookies(cookies, res.data); diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index deaa290..52fcf21 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -1,5 +1,7 @@ +import { browser } from '$app/environment'; import { PUBLIC_API_BASE_URL } from '$env/static/public'; import type { ApiError, ApiResponse } from './types.js'; +import { needsProactiveRefresh, refreshOnce } from './refresh.js'; const API_BASE = `${PUBLIC_API_BASE_URL}/api/v1`; @@ -14,24 +16,16 @@ export class ApiClientError extends Error { } } -export async function apiFetch( - path: string, - init?: RequestInit & { fetch?: typeof globalThis.fetch; baseURL?: string } +async function doFetch( + fetchFn: typeof globalThis.fetch, + url: string, + init: RequestInit ): Promise> { - const fetchFn = init?.fetch ?? globalThis.fetch; - const { fetch: _, baseURL, ...restInit } = init ?? {}; - const base = baseURL ?? API_BASE; - - const res = await fetchFn(`${base}${path}`, { - headers: { - 'Content-Type': 'application/json', - ...restInit.headers - }, - ...restInit + const res = await fetchFn(url, { + headers: { 'Content-Type': 'application/json', ...init.headers }, + ...init }); - const body = await res.json(); - if (!res.ok) { const err = body.error as ApiError | undefined; throw new ApiClientError( @@ -40,10 +34,43 @@ export async function apiFetch( err?.message ?? `Request failed with status ${res.status}` ); } - return body as ApiResponse; } +export async function apiFetch( + path: string, + init?: RequestInit & { fetch?: typeof globalThis.fetch; baseURL?: string } +): Promise> { + const { fetch: customFetch, baseURL, ...restInit } = init ?? {}; + const fetchFn = customFetch ?? globalThis.fetch; + const base = baseURL ?? API_BASE; + const url = `${base}${path}`; + + // Client-side only: proactively refresh before the token expires. + const isClientCall = browser && !customFetch; + if (isClientCall && needsProactiveRefresh()) { + await refreshOnce(); + } + + try { + return await doFetch(fetchFn, url, restInit); + } catch (err) { + // On 401, attempt one silent refresh and retry (client-side only, non-auth paths). + if ( + isClientCall && + err instanceof ApiClientError && + err.status === 401 && + !path.startsWith('/auth/') + ) { + const refreshed = await refreshOnce(); + if (refreshed) { + return await doFetch(fetchFn, url, restInit); + } + } + throw err; + } +} + export function buildSearchQuery(params: Record): string { const sp = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { diff --git a/web/src/lib/api/refresh.ts b/web/src/lib/api/refresh.ts new file mode 100644 index 0000000..72da4c7 --- /dev/null +++ b/web/src/lib/api/refresh.ts @@ -0,0 +1,33 @@ +// Single-flight refresh: concurrent 401s share one refresh call instead of +// racing each other and causing reuse-detection false positives. + +const PROACTIVE_MARGIN_MS = 60_000; // refresh 60s before expiry + +let inflight: Promise | null = null; + +export function needsProactiveRefresh(): boolean { + const raw = document.cookie + .split(';') + .map((c) => c.trim()) + .find((c) => c.startsWith('access_expires_at=')); + if (!raw) return false; + const expiresAt = parseInt(raw.split('=')[1], 10); + return !isNaN(expiresAt) && Date.now() > expiresAt - PROACTIVE_MARGIN_MS; +} + +export async function refreshOnce(): Promise { + if (inflight) return inflight; + + inflight = (async () => { + try { + const res = await fetch('/api/auth/refresh', { method: 'POST' }); + return res.ok; + } catch { + return false; + } finally { + inflight = null; + } + })(); + + return inflight; +} diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index cecd25a..a7fba81 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -83,8 +83,16 @@ export interface EditionBrief { // Auth types export interface AuthData { access_token: string; - session_token: string; - expires_in: number; + refresh_token: string; +} + +export interface SessionInfo { + id: string; + ip_address: string; + user_agent: string; + last_used_at: string; + created_at: string; + current: boolean; } export interface TOTPSetupData { diff --git a/web/src/lib/auth/cookies.ts b/web/src/lib/auth/cookies.ts index ade877c..487f948 100644 --- a/web/src/lib/auth/cookies.ts +++ b/web/src/lib/auth/cookies.ts @@ -2,7 +2,10 @@ import { dev } from '$app/environment'; import type { Cookies } from '@sveltejs/kit'; import type { AuthData } from '$lib/api/types.js'; -const COOKIE_OPTS = { +const ACCESS_TTL_SECONDS = 30 * 60; // 30 minutes — must match AUTH_ACCESS_TTL +const REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days absolute + +const SECURE_OPTS = { path: '/', httpOnly: true, secure: !dev, @@ -11,16 +14,26 @@ const COOKIE_OPTS = { export function setAuthCookies(cookies: Cookies, auth: AuthData): void { cookies.set('access_token', auth.access_token, { - ...COOKIE_OPTS, - maxAge: auth.expires_in + ...SECURE_OPTS, + maxAge: ACCESS_TTL_SECONDS }); - cookies.set('session_token', auth.session_token, { - ...COOKIE_OPTS, - maxAge: 30 * 24 * 60 * 60 // 30 days + cookies.set('refresh_token', auth.refresh_token, { + ...SECURE_OPTS, + maxAge: REFRESH_TTL_SECONDS + }); + // Non-HttpOnly so client-side JS can decide when to proactively refresh. + const expiresAt = Date.now() + ACCESS_TTL_SECONDS * 1000; + cookies.set('access_expires_at', String(expiresAt), { + path: '/', + httpOnly: false, + secure: !dev, + sameSite: 'lax', + maxAge: ACCESS_TTL_SECONDS }); } export function clearAuthCookies(cookies: Cookies): void { cookies.delete('access_token', { path: '/' }); - cookies.delete('session_token', { path: '/' }); + cookies.delete('refresh_token', { path: '/' }); + cookies.delete('access_expires_at', { path: '/' }); } diff --git a/web/src/routes/api/auth/refresh/+server.ts b/web/src/routes/api/auth/refresh/+server.ts new file mode 100644 index 0000000..011669d --- /dev/null +++ b/web/src/routes/api/auth/refresh/+server.ts @@ -0,0 +1,16 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; +import { refreshTokens } from '$lib/api/client.server.js'; + +// Client-side refresh proxy: browser JS cannot read HttpOnly cookies, so it +// POSTs here and we read/write the refresh_token cookie server-side. +export const POST: RequestHandler = async ({ cookies, fetch }) => { + const ok = await refreshTokens(cookies, fetch); + if (!ok) { + return json( + { error: { code: 'auth.refresh_failed', message: 'refresh failed' } }, + { status: 401 } + ); + } + return json({ ok: true }); +}; diff --git a/web/src/routes/auth/oauth/callback/+page.server.ts b/web/src/routes/auth/oauth/callback/+page.server.ts index 463976c..5c394f1 100644 --- a/web/src/routes/auth/oauth/callback/+page.server.ts +++ b/web/src/routes/auth/oauth/callback/+page.server.ts @@ -4,19 +4,14 @@ import { setAuthCookies } from '$lib/auth/cookies.js'; export const load: PageServerLoad = async ({ url, cookies }) => { const accessToken = url.searchParams.get('access_token'); - const sessionToken = url.searchParams.get('session_token'); - const expiresIn = url.searchParams.get('expires_in'); + const refreshToken = url.searchParams.get('refresh_token'); - if (!accessToken || !sessionToken) { + if (!accessToken || !refreshToken) { const errMsg = url.searchParams.get('error'); error(400, { message: errMsg ?? 'OAuth-Anmeldung fehlgeschlagen.' }); } - setAuthCookies(cookies, { - access_token: accessToken, - session_token: sessionToken, - expires_in: expiresIn ? parseInt(expiresIn, 10) : 900 - }); + setAuthCookies(cookies, { access_token: accessToken, refresh_token: refreshToken }); redirect(302, '/'); }; diff --git a/web/src/routes/datenschutz/+page.svelte b/web/src/routes/datenschutz/+page.svelte index f4927fd..6dcafb4 100644 --- a/web/src/routes/datenschutz/+page.svelte +++ b/web/src/routes/datenschutz/+page.svelte @@ -209,17 +209,25 @@ access_token JWT-Zugriffstoken für authentifizierte AnfragenZugriffstoken für authentifizierte Anfragen - 15 Minuten + 30 Minuten - session_token + refresh_token Sitzungstoken zur Erneuerung des Zugriffstokens 30 Tage + + access_expires_at + Ablaufzeitpunkt des Zugriffstokens (kein HttpOnly) + 30 Minuten +