e94063d96a
Password login ┌─────┬────────────────────────────────────────────────────┬────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L1 │ Login by username │ auth_login.hurl Case 1 │ 200 + access_token, user.email match │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L2 │ Login by email (dispatch on @) │ auth_login.hurl Case 2 │ 200, same session shape as L1 │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L3 │ Bad password on username path │ auth_login.hurl Case 3 │ 403 anti-enum │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L4 │ Bad password on email path │ auth_login.hurl Case 4 │ 403 anti-enum (same shape as L3) │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L5 │ Unknown username │ auth_login.hurl Case 5 │ 403 anti-enum (same shape as L3) │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L6 │ Unknown email │ auth_login.hurl Case 6 │ 403 anti-enum (same shape as L3) │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L7 │ /api/auth/oidc/providers reports methods correctly │ auth_login.hurl Case 7 │ password_login_enabled: true, magic_link_login_enabled: true, require_verified_email: false │ └─────┴────────────────────────────────────────────────────┴────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────┘ Password registration ┌─────┬───────────────────────────────────────────────────┬──────────────────────────────┬─────────────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤ │ R1 │ Classic username + email + password → uniform 200 │ registration.hurl Step 2 │ anti-enum message contains "request received" │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤ │ R2 │ Login after register works │ registration.hurl Step 2b │ 200 + session for the new user │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤ │ R3 │ Email collision → uniform 200 (no rewrite) │ registration.hurl Steps 8-10 │ attacker password doesn't work; original account intact │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ R4 │ Username collision → uniform 200 │ registration.hurl Step 11 │ same anti-enum shape │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ R5 │ Off-domain rejection │ registration.hurl Step 12 │ 403 RegistrationDomainNotAllowed │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ R6 │ Case-insensitive domain match │ registration.hurl Step 12b │ uniform 200 on charlie@EXAMPLE.COM │ └─────┴───────────────────────────────────────────────────┴──────────────────────────────┴────────────────────────────┘ Magic-link registration (email-only signup) ┌─────┬──────────────────────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR1 │ Email-only signup → welcome mail queued │ registration.hurl Step 3 │ uniform 200 + browser-binding cookie set │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR2 │ Welcome mail contains magic-link URL │ registration.hurl Step 4 │ captured from mock SMTP │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR3 │ PR 22 cross-browser confirmation page │ registration.hurl Step 5a │ 200 HTML "different browser" │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR4 │ Cookie-bound redemption lands on SPA │ registration.hurl Step 5b │ 302 → /files (SvelteKit route, post-migration) │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR5 │ email_verified_at stamped after redemption │ registration.hurl Step 6 │ field present on /api/auth/me │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR6 │ Second magic-link post-signup │ registration.hurl Step 7 │ uniform 200 │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR7 │ Profile PATCH — no-op, name set, empty-string rejected, username-taken 409, claim-once 409, etc. │ registration.hurl Steps 6a–6i │ full profile lifecycle │ └─────┴──────────────────────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────┘ Magic-link login (existing account) ┌─────┬──────────────────────────────────────────────────────────┬──────────────────────────────────────┬───────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML1 │ Baseline password login still works │ auth_magic_link_login.hurl Steps 1-2 │ 200 │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML2 │ magic-link/send with email identifier │ auth_magic_link_login.hurl Step 3 │ uniform 200 + cookie │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML3 │ magic-link/send with username identifier (dispatch on @) │ auth_magic_link_login.hurl Step 4 │ uniform 200 │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML4 │ Password-user policy: mail actually sent │ auth_magic_link_login.hurl Step 5 │ SMTP capture proves permit_magic_link_for_password_users in effect │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML5 │ Redemption creates a session │ auth_magic_link_login.hurl Steps 6-7 │ 302 → /files, /api/auth/me returns the same user │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML6 │ Anti-enum on unknown identifier │ auth_magic_link_login.hurl Step 8 │ same uniform 200 shape as ML3 │ └─────┴──────────────────────────────────────────────────────────┴──────────────────────────────────────┴───────────────────────────────────────┘ OIDC ┌─────┬────────────────────────────────────────────────────────────────────────┬───────────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O1 │ Setup local admin (bootstrap) │ oidc.hurl Step 1 │ 201 │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O2 │ Providers endpoint — OIDC visible │ oidc.hurl Step 2 │ enabled: true, provider_name: MockSSO, password_login_enabled: true, magic_link_login_enabled: false (OIDC-master rule) │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O2b │ Magic-link/send refused (endpoint layer) │ oidc.hurl Step 2b │ 403 MagicLinkLoginDisabled — proves the policy gate fires, not a 503 SMTP-unwired │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O3 │ Authorize redirect includes PKCE + state │ oidc.hurl Step 3 │ 307 to fake IdP │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O4 │ IdP round-trip + JIT provisioning │ oidc.hurl Step 4 │ Callback lands on /login?oidc_code=… │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O5 │ Code exchange → session cookies │ oidc.hurl Step 5 │ 200 + all three cookies │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O6 │ JIT profile mapping (name, given/family, picture, groups → admin role) │ oidc.hurl Step 6 │ every claim reflected on /api/auth/me │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O7 │ Refresh rotation on OIDC session │ oidc.hurl Step 7 │ new access/refresh/CSRF cookies │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O8 │ Refreshed cookies authenticate │ oidc.hurl Step 8 │ 200 on /api/auth/me │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O9 │ Repeat login = same local user (no dup) │ oidc.hurl Step 9 │ user_id stable │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O10 │ Anti-takeover: unverified email → refused │ oidc.hurl Step 10 │ 401/403 │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O11 │ One-time code replay refused │ oidc.hurl Step 11 │ second /exchange → 401 │ └─────┴────────────────────────────────────────────────────────────────────────┴───────────────────┴────────────────────────────────────────────────────────────────────────────────────────────┘ test
168 lines
5.3 KiB
TypeScript
168 lines
5.3 KiB
TypeScript
/**
|
|
* Typed API client with transparent 401 → token-refresh → retry.
|
|
*
|
|
* Ported from static/js/core/fetchWrapper.js. Unlike that wrapper, this does
|
|
* NOT monkeypatch `window.fetch`; every endpoint module calls `apiFetch`
|
|
* explicitly. The behavioural invariants are preserved exactly:
|
|
*
|
|
* - A captured raw `fetch` is used for the real network calls so the refresh
|
|
* request and the retry never re-enter the interceptor (no recursion).
|
|
* - Concurrent 401s collapse into a single in-flight `/api/auth/refresh`.
|
|
* - Cross-origin responses are passed through untouched.
|
|
* - Auth primitives (login/logout/refresh/register/setup/oidc/device) and
|
|
* public-share endpoints (/api/s/) bypass the refresh-and-retry path:
|
|
* a 401 there is genuine ("bad credentials" / "password required"), not an
|
|
* expired access token.
|
|
* - When refresh fails, the session-expired handler fires (clear + redirect)
|
|
* and the call rejects.
|
|
*/
|
|
|
|
import { getCsrfHeaders } from './csrf';
|
|
|
|
const REFRESH_ENDPOINT = '/api/auth/refresh';
|
|
|
|
/** Auth primitives — a 401 here is genuine, never an expired access token. */
|
|
const AUTH_PRIMITIVES = [
|
|
'/api/auth/login',
|
|
'/api/auth/logout',
|
|
'/api/auth/refresh',
|
|
'/api/auth/register',
|
|
'/api/auth/setup',
|
|
'/api/auth/oidc/',
|
|
'/api/auth/device/'
|
|
];
|
|
|
|
export type FetchFn = typeof fetch;
|
|
|
|
export interface ApiClientDeps {
|
|
/** Underlying fetch used for the real network call (bypasses the interceptor). */
|
|
rawFetch: FetchFn;
|
|
/** Invoked once when a refresh definitively fails (clear session + redirect). */
|
|
onSessionExpired: () => void;
|
|
/** Test seam for `window.location.origin`. */
|
|
origin?: string;
|
|
}
|
|
|
|
function urlString(input: RequestInfo | URL): string {
|
|
if (typeof input === 'string') return input;
|
|
if (input instanceof URL) return input.href;
|
|
return input.url ?? '';
|
|
}
|
|
|
|
function isCrossOrigin(urlStr: string, origin: string): boolean {
|
|
try {
|
|
return new URL(urlStr, origin).origin !== origin;
|
|
} catch {
|
|
// Unparseable URL — treat as cross-origin so we pass it through untouched.
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function bypassesRetry(urlStr: string): boolean {
|
|
return AUTH_PRIMITIVES.some((p) => urlStr.includes(p)) || urlStr.includes('/api/s/');
|
|
}
|
|
|
|
/**
|
|
* Build an isolated apiFetch with its own refresh-dedup state. Used directly in
|
|
* tests; the app uses the default singleton below.
|
|
*/
|
|
export function createApiFetch(deps: ApiClientDeps): FetchFn {
|
|
const { rawFetch, onSessionExpired } = deps;
|
|
let refreshInFlight: Promise<boolean> | null = null;
|
|
|
|
async function refresh(): Promise<boolean> {
|
|
if (refreshInFlight) return refreshInFlight;
|
|
refreshInFlight = (async () => {
|
|
try {
|
|
const r = await rawFetch(REFRESH_ENDPOINT, {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
|
body: '{}'
|
|
});
|
|
return r.ok;
|
|
} catch {
|
|
return false;
|
|
} finally {
|
|
refreshInFlight = null;
|
|
}
|
|
})();
|
|
return refreshInFlight;
|
|
}
|
|
|
|
const apiFetch: FetchFn = async (input, init) => {
|
|
const origin = deps.origin ?? globalThis.location?.origin ?? 'http://localhost';
|
|
const response = await rawFetch(input, init);
|
|
if (response.status !== 401) return response;
|
|
|
|
const urlStr = urlString(input as RequestInfo | URL);
|
|
if (isCrossOrigin(urlStr, origin)) return response;
|
|
if (bypassesRetry(urlStr)) return response;
|
|
|
|
const refreshed = await refresh();
|
|
if (!refreshed) {
|
|
onSessionExpired();
|
|
throw new Error('Session expired');
|
|
}
|
|
return rawFetch(input, init);
|
|
};
|
|
|
|
return apiFetch;
|
|
}
|
|
|
|
// ── Default singleton ──────────────────────────────────────────────────────
|
|
|
|
let sessionExpiredHandler: () => void = () => {
|
|
if (typeof window !== 'undefined') {
|
|
window.location.href = '/login?source=session_expired';
|
|
}
|
|
};
|
|
|
|
/** Wire the real session-expired behaviour (clear store + redirect) at startup. */
|
|
export function setSessionExpiredHandler(fn: () => void): void {
|
|
sessionExpiredHandler = fn;
|
|
}
|
|
|
|
const rawFetch: FetchFn =
|
|
typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : (undefined as never);
|
|
|
|
/** App-wide fetch — route every API call through this. */
|
|
export const apiFetch: FetchFn = createApiFetch({
|
|
rawFetch,
|
|
onSessionExpired: () => sessionExpiredHandler()
|
|
});
|
|
|
|
/** Convenience: fetch JSON, throwing on non-2xx. */
|
|
export async function apiJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
|
const res = await apiFetch(input, init);
|
|
if (!res.ok) {
|
|
throw new ApiError(res.status, res.statusText, input);
|
|
}
|
|
return (await res.json()) as T;
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
/**
|
|
* `error_type` field from the backend's `ErrorResponse` body, when
|
|
* present. Callers switch on this to render specific UX for
|
|
* distinguished failures (e.g. `EmailNotVerified` → "resend
|
|
* verification link" prompt). Falls back to `undefined` when the
|
|
* response body isn't parseable or the endpoint doesn't emit one.
|
|
*/
|
|
readonly errorType?: string;
|
|
|
|
constructor(
|
|
readonly status: number,
|
|
readonly statusText: string,
|
|
readonly resource: RequestInfo | URL,
|
|
errorType?: string,
|
|
serverMessage?: string
|
|
) {
|
|
super(
|
|
serverMessage ?? `API ${status} ${statusText} for ${urlString(resource as RequestInfo | URL)}`
|
|
);
|
|
this.name = 'ApiError';
|
|
this.errorType = errorType;
|
|
}
|
|
}
|