feat(auth): D5 cleanup + W3 web refresh UX

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
This commit is contained in:
2026-04-26 13:25:48 +02:00
parent 515a72e6e8
commit c6cdc11693
12 changed files with 173 additions and 63 deletions

View File

@@ -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,

View File

@@ -0,0 +1 @@
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS token_hash TEXT;

View File

@@ -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;

View File

@@ -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<ProfileData>('/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<ProfileData>('/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<ProfileData>('/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
}
}
}

View File

@@ -35,13 +35,14 @@ export async function refreshTokens(
cookies: Cookies,
fetchFn: typeof globalThis.fetch
): Promise<boolean> {
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<AuthData>('/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);

View File

@@ -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<T>(
path: string,
init?: RequestInit & { fetch?: typeof globalThis.fetch; baseURL?: string }
async function doFetch<T>(
fetchFn: typeof globalThis.fetch,
url: string,
init: RequestInit
): Promise<ApiResponse<T>> {
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<T>(
err?.message ?? `Request failed with status ${res.status}`
);
}
return body as ApiResponse<T>;
}
export async function apiFetch<T>(
path: string,
init?: RequestInit & { fetch?: typeof globalThis.fetch; baseURL?: string }
): Promise<ApiResponse<T>> {
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<T>(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<T>(fetchFn, url, restInit);
}
}
throw err;
}
}
export function buildSearchQuery(params: Record<string, unknown>): string {
const sp = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {

View File

@@ -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<boolean> | 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<boolean> {
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;
}

View File

@@ -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 {

View File

@@ -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: '/' });
}

View File

@@ -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 });
};

View File

@@ -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, '/');
};

View File

@@ -209,17 +209,25 @@
<tr class="border-b border-stone-100 dark:border-stone-700">
<td class="py-2 pr-4 font-mono text-stone-700 dark:text-stone-300">access_token</td>
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300"
>JWT-Zugriffstoken für authentifizierte Anfragen</td
>Zugriffstoken für authentifizierte Anfragen</td
>
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300">15 Minuten</td>
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300">30 Minuten</td>
</tr>
<tr class="border-b border-stone-100 dark:border-stone-700">
<td class="py-2 pr-4 font-mono text-stone-700 dark:text-stone-300">session_token</td>
<td class="py-2 pr-4 font-mono text-stone-700 dark:text-stone-300">refresh_token</td>
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300"
>Sitzungstoken zur Erneuerung des Zugriffstokens</td
>
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300">30 Tage</td>
</tr>
<tr class="border-b border-stone-100 dark:border-stone-700">
<td class="py-2 pr-4 font-mono text-stone-700 dark:text-stone-300">access_expires_at</td
>
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300"
>Ablaufzeitpunkt des Zugriffstokens (kein HttpOnly)</td
>
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300">30 Minuten</td>
</tr>
</tbody>
</table>
</div>