import type { Context, MiddlewareHandler } from 'hono'; import { createRemoteJWKSet, jwtVerify } from 'jose'; import { fireFirstUseOnce } from '../services/aura-client.ts'; /** * Auth-Middleware der Cards-API (Phase 2 Auth-Föderation). * * Auth-Quellen (in Reihenfolge): * 1. `Authorization: Bearer ` — User-JWT aus mana-auth, validiert * gegen das remote JWKS-Set unter $MANA_AUTH_URL/api/auth/jwks. * `sub`-Claim wird als `userId` gesetzt. JWKS wird gecacht. * 2. `X-User-Id: ` — Dev-Stub-Fallback. Bleibt aktiv für lokale * Tests + den Anki-Importer (nutzt session-Storage-User-ID). Wird * in Phase 10b deaktiviert (env CARDS_AUTH_DEV_STUB=false). * * Implementations-Notiz: jose ist die offizielle Lib auch von mana-auth * (siehe services/mana-auth/src/middleware/jwt-auth.ts). */ export type Tier = 'guest' | 'public' | 'beta' | 'alpha' | 'founder'; export type AuthVars = { userId: string; tier: Tier; authMode: 'jwt' | 'dev-stub'; }; const TIER_RANK: Record = { guest: 0, public: 1, beta: 2, alpha: 3, founder: 4, }; export function tierAtLeast(have: Tier, need: Tier): boolean { return TIER_RANK[have] >= TIER_RANK[need]; } const MANA_AUTH_URL = process.env.MANA_AUTH_URL ?? 'https://auth.mana.how'; // Fail-secure: opt-in, nicht opt-out. Vergessene env-var ⇒ Bypass AUS. // Tests setzen die Variable via vitest.config.ts → tests/setup.ts. const ALLOW_DEV_STUB = process.env.WORDECK_AUTH_DEV_STUB === 'true'; let jwksCache: ReturnType | null = null; function getJwks() { if (!jwksCache) { jwksCache = createRemoteJWKSet(new URL('/api/auth/jwks', MANA_AUTH_URL)); } return jwksCache; } interface VerifiedClaims { sub: string; tier: Tier; } function tierFromClaim(raw: unknown): Tier { if (typeof raw !== 'string') return 'public'; if (raw === 'guest' || raw === 'public' || raw === 'beta' || raw === 'alpha' || raw === 'founder') { return raw; } return 'public'; } async function verifyBearer(token: string): Promise { try { const { payload } = await jwtVerify(token, getJwks(), {}); const sub = typeof payload.sub === 'string' ? payload.sub : null; if (!sub) return null; return { sub, tier: tierFromClaim(payload.tier) }; } catch { return null; } } export const authMiddleware: MiddlewareHandler<{ Variables: AuthVars }> = async (c, next) => { const authHeader = c.req.header('Authorization'); if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.slice(7).trim(); const claims = await verifyBearer(token); if (claims) { c.set('userId', claims.sub); c.set('tier', claims.tier); c.set('authMode', 'jwt'); // Cross-App-Aura-Hook: erste Wordeck-Aktion → 25 Aura einmalig. // Reason-Katalog: mana/docs/playbooks/AURA_PER_APP_HOOKS.md. fireFirstUseOnce(claims.sub); await next(); return; } return c.json({ error: 'unauthenticated', detail: 'invalid_jwt' }, 401); } if (ALLOW_DEV_STUB) { const userId = c.req.header('X-User-Id'); if (userId) { c.set('userId', userId); c.set('tier', 'founder'); // Dev-Stub bekommt founder-Tier — Tests c.set('authMode', 'dev-stub'); await next(); return; } } return c.json( { error: 'unauthenticated', detail: ALLOW_DEV_STUB ? 'Authorization: Bearer oder X-User-Id-Header erforderlich' : 'Authorization: Bearer erforderlich', }, 401 ); }; /** Helper zum Auslesen des userId aus dem Context (typed). */ export function getUserId(c: Context<{ Variables: AuthVars }>): string { return c.get('userId'); } /** * Tier-Gate für Premium-Features. Nutze als Hono-Middleware: * r.post('/premium-thing', requireTier('beta'), async (c) => …) * Liefert 403 wenn der eingeloggte User nicht mindestens den * verlangten Tier hat. Aktuell auf wordeck-api nicht aktiv genutzt * (Cards-MVP ist tier-frei für authentifizierte User), aber das * Plumbing ist da für künftige Premium-Pfade (z.B. AI-Bulk-Convert). */ export function requireTier(min: Tier): MiddlewareHandler<{ Variables: AuthVars }> { return async (c, next) => { const have = c.get('tier'); if (!tierAtLeast(have, min)) { return c.json( { error: 'tier_required', need: min, have }, 403 ); } await next(); }; }