diff --git a/apps/api/package.json b/apps/api/package.json index f34e956..f8f2a0c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -21,6 +21,7 @@ "@cards/domain": "workspace:*", "drizzle-orm": "0.38", "hono": "^4.6.0", + "jose": "^6.2.3", "minio": "^8.0.7", "postgres": "^3.4.0", "zod": "3", diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 94467c9..5529bc7 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -1,24 +1,77 @@ import type { Context, MiddlewareHandler } from 'hono'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; /** - * Auth-Middleware-Stub für Phase 3. + * Auth-Middleware der Cards-API (Phase 2 Auth-Föderation). * - * Heute (Dev): liest `X-User-Id`-Header. - * Phase 2 (echt): validiert User-JWT gegen mana-auth JWKS und extrahiert - * `sub`-Claim als userId. + * 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: Phase 2 schwenkt auf `@mana/shared-hono`'s - * `authMiddleware()` um, das den JWKS-Cache verwaltet. + * Implementations-Notiz: jose ist die offizielle Lib auch von mana-auth + * (siehe services/mana-auth/src/middleware/jwt-auth.ts). */ export type AuthVars = { userId: string }; -export const authMiddleware: MiddlewareHandler<{ Variables: AuthVars }> = async (c, next) => { - const userId = c.req.header('X-User-Id'); - if (!userId) { - return c.json({ error: 'unauthenticated', detail: 'X-User-Id header missing (dev stub)' }, 401); +const MANA_AUTH_URL = process.env.MANA_AUTH_URL ?? 'https://auth.mana.how'; +const ALLOW_DEV_STUB = process.env.CARDS_AUTH_DEV_STUB !== 'false'; + +let jwksCache: ReturnType | null = null; +function getJwks() { + if (!jwksCache) { + jwksCache = createRemoteJWKSet(new URL('/api/auth/jwks', MANA_AUTH_URL)); } - c.set('userId', userId); - await next(); + return jwksCache; +} + +async function verifyBearer(token: string): Promise { + try { + const { payload } = await jwtVerify(token, getJwks(), { + // mana-auth setzt `iss` auf BASE_URL — wir akzeptieren alles vom + // konfigurierten Auth-Host. Strenger Check kann später hier rein. + }); + const sub = typeof payload.sub === 'string' ? payload.sub : null; + return sub; + } 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 sub = await verifyBearer(token); + if (sub) { + c.set('userId', 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); + 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). */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17e5096..6b4f878 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: hono: specifier: ^4.6.0 version: 4.12.18 + jose: + specifier: ^6.2.3 + version: 6.2.3 minio: specifier: ^8.0.7 version: 8.0.7 @@ -1301,6 +1304,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} @@ -2581,6 +2587,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.3: {} + jszip@3.10.1: dependencies: lie: 3.3.0