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:
@@ -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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS token_hash TEXT;
|
||||
3
backend/migrations/000029_drop_legacy_token_hash.up.sql
Normal file
3
backend/migrations/000029_drop_legacy_token_hash.up.sql
Normal 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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
33
web/src/lib/api/refresh.ts
Normal file
33
web/src/lib/api/refresh.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: '/' });
|
||||
}
|
||||
|
||||
16
web/src/routes/api/auth/refresh/+server.ts
Normal file
16
web/src/routes/api/auth/refresh/+server.ts
Normal 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 });
|
||||
};
|
||||
@@ -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, '/');
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user