feat(web): Auth-Session über @mana/shared-auth-sso
Some checks are pending
CI / validate (push) Waiting to run
Some checks are pending
CI / validate (push) Waiting to run
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) <noreply@anthropic.com>
This commit is contained in:
parent
633f051f2d
commit
55bf722761
3 changed files with 103 additions and 170 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
user = $state<AuthUser | null>(null);
|
||||
stubId = $state<string | null>(null); // Phase-2-Übergangs-Fallback (?stub=<uuid>)
|
||||
private refreshing: Promise<boolean> | 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<string | null>(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<boolean> {
|
||||
return this.base.tryRefresh();
|
||||
}
|
||||
ensureFreshToken(): Promise<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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';
|
||||
|
|
|
|||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue