**1. The cached PIN could never be cleared after a host reset.** `/recover` clears a rejected cached PIN only when the submitted name is the one this device belongs to — narrowed on this branch so a guest who mistypes their own name does not lose the only copy of their PIN (the server keeps just the bcrypt). But it compared against `DISPLAY_NAME_KEY`, which `clearAuth` deletes for shared-device privacy — one step BEFORE the guest ever reaches that screen: host taps "PIN zurücksetzen" -> the backend also revokes every session for that user -> the guest's next request 401s -> clearAuth -> redirect to /join -> they go to /recover, where the field is pre-filled with the dead PIN and the guard can never fire again Since a 4-digit value auto-submits, every correction burns another of the four wrong-PIN attempts the shared venue IP allows per 15 minutes. The PIN's owner is now stored WITH the PIN and survives alongside it, with a fallback to the auth display name for devices that cached a PIN before this key existed. **2. Every layout-level SSE handler waited on the `/me/context` retry.** The retry was awaited inside the same `onMount` that registers `pin-reset`, `user-hidden`/`user-shown`, `event-closed`/`event-opened` and `event-updated`. Worst case is a 20s timeout + 2s backoff + a second 20s timeout: ~42s with an empty handler list, on exactly the wifi the retry exists for. Five of the six self-heal; `pin-reset` does not, and a missed one leaves a dead PIN displayed in "Mein Konto" and pre-filling /recover — the same state as (1), reached from the other end. Detached, since nothing below reads its result. **3. `crypto.randomUUID` was on the join critical path.** It needs Safari >= 15.4 / Chrome >= 92 AND a secure context. The queue already depended on it, so an old phone previously joined and browsed and only failed at upload — degraded but survivable. Minting an idempotency key at join turned that into a `TypeError` caught by the generic handler and rendered as "Ein Fehler ist aufgetreten." on every retry: cannot join, cannot browse, and /recover is no help because there is no account yet. The one screen where a hard failure has no way out at all. Falls back to `crypto.getRandomValues` with the RFC 4122 version and variant bits set; `Math.random` is deliberately NOT a further fallback, since a collision between two guests would replay one guest's join or upload onto another. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
216 lines
8.1 KiB
TypeScript
216 lines
8.1 KiB
TypeScript
import { writable } from 'svelte/store';
|
|
import { browser } from '$app/environment';
|
|
|
|
const TOKEN_KEY = 'eventsnap_jwt';
|
|
const PIN_KEY = 'eventsnap_pin';
|
|
/**
|
|
* Whose PIN `PIN_KEY` holds — and it is a SEPARATE key from `DISPLAY_NAME_KEY` on purpose.
|
|
*
|
|
* `/recover` only clears a rejected cached PIN when the name submitted is the one this device
|
|
* belongs to, so that a guest who mistypes their own name does not lose the only copy of their PIN
|
|
* (the server keeps just the bcrypt). That check read `DISPLAY_NAME_KEY` — which `clearAuth`
|
|
* deletes, for shared-device privacy, one step BEFORE the guest ever reaches `/recover`:
|
|
*
|
|
* host taps "PIN zurücksetzen" → the backend also revokes every session for that user
|
|
* (`host.rs`, `Session::delete_all_for_user`) → the guest's next request 401s → `clearAuth`
|
|
* → redirect to /join → the guest goes to /recover, where the field is PRE-FILLED with the
|
|
* dead PIN and can never be cleared, because the name it would be compared against is gone
|
|
*
|
|
* Since `clearAuth` deliberately keeps the PIN so the guest can recover, it must keep the PIN's
|
|
* owner too, or the pair is inconsistent and the guard is unreachable exactly when it is needed.
|
|
*/
|
|
const PIN_OWNER_KEY = 'eventsnap_pin_owner';
|
|
const USER_ID_KEY = 'eventsnap_user_id';
|
|
const DISPLAY_NAME_KEY = 'eventsnap_display_name';
|
|
|
|
export const isAuthenticated = writable(false);
|
|
|
|
/**
|
|
* Reactive mirror of `localStorage[PIN_KEY]`. Subscribers (the My Account page) see
|
|
* the PIN change immediately when an SSE `pin-reset` event invalidates it from any
|
|
* route — keeps the displayed PIN consistent with the server hash.
|
|
*/
|
|
export const currentPin = writable<string | null>(null);
|
|
|
|
// Guest auth lives in localStorage (persists across sessions — a guest returning to the
|
|
// event days later stays signed in). The ADMIN token lives in sessionStorage instead
|
|
// (USER_JOURNEYS §11.1): its tighter 1-day lifetime is meant to bound exposure on a shared
|
|
// "event laptop", and sessionStorage clears on tab/browser close, which localStorage defeats.
|
|
// Reads check sessionStorage FIRST so an active admin token wins over any leftover guest
|
|
// token on the same device.
|
|
function readAuth(key: string): string | null {
|
|
if (!browser) return null;
|
|
return sessionStorage.getItem(key) ?? localStorage.getItem(key);
|
|
}
|
|
|
|
export function getToken(): string | null {
|
|
return readAuth(TOKEN_KEY);
|
|
}
|
|
|
|
export function getPin(): string | null {
|
|
if (!browser) return null;
|
|
return localStorage.getItem(PIN_KEY);
|
|
}
|
|
|
|
/**
|
|
* Clear the locally-cached recovery PIN. Called when the server resets it (host
|
|
* action) — the cached plaintext no longer matches the bcrypt hash, so showing it
|
|
* would mislead the user.
|
|
*/
|
|
export function clearPin(): void {
|
|
if (!browser) return;
|
|
localStorage.removeItem(PIN_KEY);
|
|
localStorage.removeItem(PIN_OWNER_KEY);
|
|
currentPin.set(null);
|
|
}
|
|
|
|
/**
|
|
* The display name the cached PIN belongs to, or `null` if there is no cached PIN.
|
|
*
|
|
* Survives `clearAuth` alongside the PIN itself — see [`PIN_OWNER_KEY`]. Falls back to the auth
|
|
* display name for devices that cached a PIN before this key existed.
|
|
*/
|
|
export function getPinOwner(): string | null {
|
|
if (!browser) return null;
|
|
if (localStorage.getItem(PIN_KEY) === null) return null;
|
|
return localStorage.getItem(PIN_OWNER_KEY) ?? readAuth(DISPLAY_NAME_KEY);
|
|
}
|
|
|
|
export function getUserId(): string | null {
|
|
return readAuth(USER_ID_KEY);
|
|
}
|
|
|
|
export function getDisplayName(): string | null {
|
|
return readAuth(DISPLAY_NAME_KEY);
|
|
}
|
|
|
|
export function getExpiry(): Date | null {
|
|
const token = getToken();
|
|
if (!token) return null;
|
|
try {
|
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
return payload.exp ? new Date(payload.exp * 1000) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function setAuth(
|
|
jwt: string,
|
|
pin: string | null,
|
|
userId: string,
|
|
displayName?: string
|
|
): void {
|
|
if (!browser) return;
|
|
// DISPLACE any resident admin session: reads are sessionStorage-first, so a leftover
|
|
// admin token there would otherwise shadow this guest login and hand the guest admin
|
|
// rights on a shared device. Clear the sessionStorage identity so exactly one is resident.
|
|
sessionStorage.removeItem(TOKEN_KEY);
|
|
sessionStorage.removeItem(USER_ID_KEY);
|
|
sessionStorage.removeItem(DISPLAY_NAME_KEY);
|
|
localStorage.setItem(TOKEN_KEY, jwt);
|
|
if (pin) {
|
|
localStorage.setItem(PIN_KEY, pin);
|
|
// Stored with the PIN, not derived from it later — see `PIN_OWNER_KEY`.
|
|
if (displayName) localStorage.setItem(PIN_OWNER_KEY, displayName);
|
|
currentPin.set(pin);
|
|
}
|
|
localStorage.setItem(USER_ID_KEY, userId);
|
|
if (displayName) localStorage.setItem(DISPLAY_NAME_KEY, displayName);
|
|
isAuthenticated.set(true);
|
|
fireSetAuthHooks();
|
|
}
|
|
|
|
/**
|
|
* Persist an ADMIN session in sessionStorage (USER_JOURNEYS §11.1) rather than localStorage,
|
|
* so the elevated credential does not survive a tab/browser close on a shared device — the
|
|
* point of the tighter 1-day admin token. Admins have no recovery PIN. `getToken` reads
|
|
* sessionStorage first, so this wins over any leftover guest token on the same device.
|
|
*/
|
|
export function setAdminAuth(jwt: string, userId: string, displayName?: string): void {
|
|
if (!browser) return;
|
|
// DISPLACE any resident guest session so exactly one identity is resident (symmetric with
|
|
// setAuth). Keep the guest PIN — it's deliberately preserved for later recovery, and the
|
|
// admin has none. Reads are sessionStorage-first, so the admin token now wins cleanly.
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
localStorage.removeItem(USER_ID_KEY);
|
|
localStorage.removeItem(DISPLAY_NAME_KEY);
|
|
sessionStorage.setItem(TOKEN_KEY, jwt);
|
|
sessionStorage.setItem(USER_ID_KEY, userId);
|
|
if (displayName) sessionStorage.setItem(DISPLAY_NAME_KEY, displayName);
|
|
isAuthenticated.set(true);
|
|
fireSetAuthHooks();
|
|
}
|
|
|
|
// Hook registry: cross-cutting stores (export-status, etc.) register a callback
|
|
// here at import-time so they get reset on every clearAuth path — both the
|
|
// explicit "Abmelden" button and the api.ts 401 auto-clear. Keeps
|
|
// clearAuth the single source of truth without baking dependencies on every
|
|
// downstream store into this module (which would create circular imports).
|
|
const clearAuthHooks: Array<() => void> = [];
|
|
export function onClearAuth(fn: () => void): void {
|
|
clearAuthHooks.push(fn);
|
|
}
|
|
|
|
// The mirror of `onClearAuth`, for stores that must be RE-SEEDED when a new identity
|
|
// arrives rather than merely cleared. Without it, anything derived from the token
|
|
// survives a logout→login in the same tab: `goto()` is a client-side navigation, so no
|
|
// module is re-imported and no `onMount` re-runs, and the previous user's value simply
|
|
// stays. Fires after the new token is resident, so hooks can read it.
|
|
const setAuthHooks: Array<() => void> = [];
|
|
export function onSetAuth(fn: () => void): void {
|
|
setAuthHooks.push(fn);
|
|
}
|
|
|
|
function fireSetAuthHooks(): void {
|
|
for (const fn of setAuthHooks) {
|
|
try {
|
|
fn();
|
|
} catch {
|
|
/* hook failure is non-fatal */
|
|
}
|
|
}
|
|
}
|
|
|
|
export function clearAuth(): void {
|
|
if (!browser) return;
|
|
// Clear from BOTH stores — a guest token lives in localStorage, an admin token in
|
|
// sessionStorage; either could be present, and a stale one must not linger.
|
|
for (const store of [localStorage, sessionStorage]) {
|
|
store.removeItem(TOKEN_KEY);
|
|
store.removeItem(USER_ID_KEY);
|
|
// Clear the display name too — on a shared device the next user shouldn't see
|
|
// the previous guest's name pre-filled on /join.
|
|
store.removeItem(DISPLAY_NAME_KEY);
|
|
}
|
|
// PIN is intentionally kept so the user can recover
|
|
isAuthenticated.set(false);
|
|
// Hooks fire in registration order. Keep them dependency-free of each other —
|
|
// if you ever need ordering, introduce a priority field rather than relying
|
|
// on import-load timing, which is fragile across refactors.
|
|
for (const fn of clearAuthHooks) {
|
|
try {
|
|
fn();
|
|
} catch {
|
|
/* hook failure is non-fatal */
|
|
}
|
|
}
|
|
}
|
|
|
|
export function getRole(): 'guest' | 'host' | 'admin' | null {
|
|
const token = getToken();
|
|
if (!token) return null;
|
|
try {
|
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
return payload.role ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function initAuth(): void {
|
|
if (!browser) return;
|
|
isAuthenticated.set(!!getToken());
|
|
currentPin.set(getPin());
|
|
}
|