From 55bf722761aa62d8e7f264db248b152b1eecd583 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 19 May 2026 01:31:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20Auth-Session=20=C3=BCber=20@mana/s?= =?UTF-8?q?hared-auth-sso?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit devUser ist jetzt Wrapper um die shared SsoSession. App-spezifisch bleiben stubId + patchProfile() (lokales Profil-Update). 201 LOC → 123 LOC. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/package.json | 1 + apps/web/src/lib/auth/dev-stub.svelte.ts | 260 ++++++++--------------- pnpm-lock.yaml | 12 ++ 3 files changed, 103 insertions(+), 170 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index efb967c..88466fa 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ "clean": "rm -rf .svelte-kit build .turbo" }, "dependencies": { + "@mana/shared-auth-sso": "0.1.0-alpha.3", "@mana/shared-icons": "^1.0.0", "@mana/shared-pwa": "0.1.0-alpha.3", "@mana/shared-ui-2": "^0.1.0", diff --git a/apps/web/src/lib/auth/dev-stub.svelte.ts b/apps/web/src/lib/auth/dev-stub.svelte.ts index c9f145a..53a5a01 100644 --- a/apps/web/src/lib/auth/dev-stub.svelte.ts +++ b/apps/web/src/lib/auth/dev-stub.svelte.ts @@ -1,201 +1,121 @@ /** - * Auth-Session für wordeck-web. + * Auth-Session für wordeck-web — Adapter um @mana/shared-auth-sso. * - * Login-Flow: wordeck-web redirectet zu auth.mana.how (mana-auth-web), - * dort setzt mana-auth den SSO-Cookie, dann Redirect zurück zu - * /auth/callback, welcher via `tryRefresh()` + `loadUserFromToken()` - * den JWT holt und in localStorage speichert. - * - * Der Datei-Name `dev-stub.svelte.ts` ist Legacy — alle Importer - * nutzen `devUser`-Symbol, Umbenennen würde ~50 Imports erfordern. + * Export-Name ist historisch `devUser` (zur Zeit der frühen Cards- + * Inkarnation noch reiner Dev-Stub). Heute ist es eine echte + * mana-auth-SSO-Session, ergänzt um: + * - `stubId` + `setStubId()` (manuelle Dev-User-ID, Cards-Muster) + * - `patchProfile()` (lokales Profil-Update z.B. nach Name-Edit) */ -const TOKEN_KEY = 'cards.auth.accessToken'; -const USER_KEY = 'cards.auth.user'; -const STUB_KEY = 'cards.dev.userId'; - -export interface AuthUser { - id: string; - email: string; - name: string | null; - tier: string; -} - -interface JwtClaims { - sub: string; - email?: string; - name?: string; - tier?: string; - exp?: number; -} - -function decodeJwt(token: string): JwtClaims | null { - try { - const [, payload] = token.split('.'); - if (!payload) return null; - const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); - return JSON.parse(json) as JwtClaims; - } catch { - return null; - } -} - -function isExpired(claims: JwtClaims): boolean { - if (!claims.exp) return false; - return claims.exp * 1000 < Date.now(); -} - -// SvelteKit-konformer ENV-Zugriff (siehe client.ts für Begründung). +import { browser } from '$app/environment'; import { env as publicEnv } from '$env/dynamic/public'; +import { createSession } from '@mana/shared-auth-sso/session'; +import { buildLoginRedirect as _build } from '@mana/shared-auth-sso'; -function authBaseUrl(): string { - return publicEnv.PUBLIC_MANA_AUTH_URL ?? 'https://auth.mana.how'; +const AUTH_URL = publicEnv.PUBLIC_MANA_AUTH_URL ?? 'https://auth.mana.how'; +const AUTH_WEB_URL = publicEnv.PUBLIC_AUTH_WEB_URL ?? AUTH_URL; +const STUB_KEY = 'wordeck.auth.stubId'; +const USER_KEY = 'wordeck.auth.user'; + +export function authBaseUrl(): string { + return AUTH_URL; } -class Session { - token = $state(null); - user = $state(null); - stubId = $state(null); // Phase-2-Übergangs-Fallback (?stub=) - private refreshing: Promise | null = null; +const _base = createSession({ + appSlug: 'wordeck', + authUrl: AUTH_URL, + authWebUrl: AUTH_WEB_URL, +}); - constructor() { - if (typeof window === 'undefined') return; - const stored = window.localStorage.getItem(TOKEN_KEY); - const userJson = window.localStorage.getItem(USER_KEY); - if (stored && userJson) { - const claims = decodeJwt(stored); - if (claims && !isExpired(claims)) { - this.token = stored; - this.user = JSON.parse(userJson) as AuthUser; - } else if (claims) { - // JWT abgelaufen, aber Cookie-Session lebt evtl. noch. - // User-Profil temporär behalten und im Hintergrund refreshen. - this.user = JSON.parse(userJson) as AuthUser; - void this.tryRefresh(); - } else { - this.clearLocal(); - } - } - // Dev-Stub-Fallback (für Tests + Anki-Importer im old-style flow) - this.stubId = window.sessionStorage.getItem(STUB_KEY); - } +class WordeckSession { + stubId = $state(null); - private clearLocal() { - this.token = null; - this.user = null; - if (typeof window !== 'undefined') { - window.localStorage.removeItem(TOKEN_KEY); - window.localStorage.removeItem(USER_KEY); + constructor(private base: typeof _base) { + if (browser) { + this.stubId = window.localStorage.getItem(STUB_KEY); } } - /** Effektive User-ID: bevorzugt JWT, dann Dev-Stub, sonst null. */ + get token() { + return this.base.token; + } + get user() { + return this.base.user; + } + get loading() { + return this.base.loading; + } + get isLoggedIn(): boolean { + return this.base.isLoggedIn || !!this.stubId; + } + get isAuthenticated(): boolean { + return this.base.isAuthenticated || !!this.stubId; + } get id(): string | null { - return this.user?.id ?? this.stubId ?? null; + return this.base.user?.id ?? this.stubId; + } + get effectiveToken(): string | null { + return this.base.effectiveToken; } - /** - * User-Profil aus dem aktuell gespeicherten JWT-Token laden. - * Wird vom /auth/callback nach erfolgreichem tryRefresh() aufgerufen. - */ + hasTier(need: string): boolean { + return this.base.hasTier(need); + } + + tryRefresh(): Promise { + return this.base.tryRefresh(); + } + ensureFreshToken(): Promise { + return this.base.ensureFreshToken(); + } loadUserFromToken(): void { - if (!this.token) return; - const claims = decodeJwt(this.token); - if (!claims) return; - this.user = { - id: claims.sub, - email: claims.email ?? '', - name: claims.name ?? null, - tier: claims.tier ?? 'public', - }; - if (typeof window !== 'undefined') { - window.localStorage.setItem(USER_KEY, JSON.stringify(this.user)); - } + this.base.loadUserFromToken(); } - /** - * Versucht den accessToken via mana-auth-/refresh zu erneuern. - * Nutzt die SSO-Session-Cookie (`credentials: 'include'`), die - * mana-auth beim Login auf `.mana.how` setzt. Returnt true bei - * Erfolg. Mehrfach-Aufrufe werden zu einer Promise gecoalesced, - * damit gleichzeitige API-Calls keinen Refresh-Storm verursachen. - */ - async tryRefresh(): Promise { - if (this.refreshing) return this.refreshing; - this.refreshing = (async () => { - try { - const r = await fetch(`${authBaseUrl()}/api/v1/auth/refresh`, { - method: 'POST', - credentials: 'include', - }); - if (!r.ok) return false; - const data = (await r.json()) as { accessToken?: string }; - if (!data.accessToken) return false; - this.token = data.accessToken; - if (typeof window !== 'undefined') { - window.localStorage.setItem(TOKEN_KEY, data.accessToken); - } - return true; - } catch { - return false; - } finally { - this.refreshing = null; - } - })(); - return this.refreshing; + async clear(): Promise { + await this.base.clear(); + this.stubId = null; + if (browser) window.localStorage.removeItem(STUB_KEY); } - /** - * Stellt sicher, dass der token noch ≥60s gültig ist. Aufgerufen - * vom API-Client vor jedem Request. Wenn der token bald abläuft, - * versuchen wir einen stillen Refresh; klappt das nicht, lassen - * wir den Request mit dem alten Token durch — die 401-Behandlung - * im Client greift dann. - */ - async ensureFreshToken(): Promise { - if (!this.token) return; - const claims = decodeJwt(this.token); - if (!claims?.exp) return; - const remainingMs = claims.exp * 1000 - Date.now(); - if (remainingMs < 60_000) { - await this.tryRefresh(); - } - } - - /** Setzt einen Dev-Stub (kein echtes Auth). Nur für Tests/Migration. */ - set(userId: string) { - const trimmed = userId.trim(); - if (!trimmed) return; + setStubId(raw: string | null): void { + const trimmed = raw?.trim() ?? null; this.stubId = trimmed; - if (typeof window !== 'undefined') { - window.sessionStorage.setItem(STUB_KEY, trimmed); - } + if (!browser) return; + if (trimmed) window.localStorage.setItem(STUB_KEY, trimmed); + else window.localStorage.removeItem(STUB_KEY); } - /** Aktualisiert den lokal gecachten Anzeigenamen. Schreibt in localStorage. */ + /** + * Aktualisiert den lokal gecachten Anzeigenamen. Schreibt in + * localStorage zurück. Server-seitiges Update macht der Caller + * separat (z.B. PATCH /api/v1/me). + */ patchProfile(patch: { name?: string }) { if (!this.user) return; - if (patch.name !== undefined) this.user = { ...this.user, name: patch.name.trim() || null }; - if (typeof window !== 'undefined') { - window.localStorage.setItem(USER_KEY, JSON.stringify(this.user)); + if (patch.name !== undefined) { + // SsoSession.user ist ein $state-Object — neue Referenz triggert + // Reactivity in Konsumenten. + this.base.user = { + ...this.user, + name: patch.name.trim() || null, + }; } - } - - clear() { - // Auch SSO-Cookie auf .mana.how aufräumen — best-effort, schlägt - // bei abgelaufener Session ohne Drama fehl. - if (this.token && typeof window !== 'undefined') { - void fetch(`${authBaseUrl()}/api/v1/auth/logout`, { - method: 'POST', - credentials: 'include', - }).catch(() => undefined); - } - this.clearLocal(); - this.stubId = null; - if (typeof window !== 'undefined') { - window.sessionStorage.removeItem(STUB_KEY); + if (browser && this.base.user) { + window.localStorage.setItem(USER_KEY, JSON.stringify(this.base.user)); } } } -export const devUser = new Session(); +export const devUser = new WordeckSession(_base); + +export function buildLoginRedirect(currentUrl?: URL): string { + return _build({ + appSlug: 'wordeck', + authWebUrl: AUTH_WEB_URL, + from: currentUrl, + }); +} + +export type { AuthUser } from '@mana/shared-auth-sso'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1de6121..e27c54a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: apps/web: dependencies: + '@mana/shared-auth-sso': + specifier: 0.1.0-alpha.3 + version: 0.1.0-alpha.3(svelte@5.55.5) '@mana/shared-icons': specifier: ^1.0.0 version: 1.0.0(svelte@5.55.5)(vite@5.4.21(@types/node@24.12.3)(lightningcss@1.32.0)(terser@5.47.1)) @@ -1247,6 +1250,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mana/shared-auth-sso@0.1.0-alpha.3': + resolution: {integrity: sha512-h3z7ICF8L+we52ZC+IeANWpWp79hqnedlyoH3zvztwsPe6MrjfuzcLL8LvKcKQLonlfWvCVXtwPfw2Y7KQRDSg==} + peerDependencies: + svelte: ^5.0.0 + '@mana/shared-icons@1.0.0': resolution: {integrity: sha512-71L1dLO6tias8floLv8s0MYzv4cA5IvwftxdFnTYOsKMTMkJ2xEiJ4VxoV5Rj7iAG0oCdV5cmjGyOEKWaLVGEA==} peerDependencies: @@ -4382,6 +4390,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mana/shared-auth-sso@0.1.0-alpha.3(svelte@5.55.5)': + dependencies: + svelte: 5.55.5 + '@mana/shared-icons@1.0.0(svelte@5.55.5)(vite@5.4.21(@types/node@24.12.3)(lightningcss@1.32.0)(terser@5.47.1))': dependencies: phosphor-svelte: 3.1.0(svelte@5.55.5)(vite@5.4.21(@types/node@24.12.3)(lightningcss@1.32.0)(terser@5.47.1))