Phase 2a: Cards-API JWT-Verify (additiv zum Dev-Stub)
Some checks are pending
CI / validate (push) Waiting to run

cards-api akzeptiert jetzt zwei Auth-Quellen:
  1. Authorization: Bearer <jwt>  — JWT aus mana-auth, gegen
     /api/auth/jwks gecached, sub→userId
  2. X-User-Id: <uuid>            — Dev-Stub, bleibt aktiv

Schalter: CARDS_AUTH_DEV_STUB=false deaktiviert den Dev-Stub
hart (für die spätere Phase-10b-Cutover-Stufe). Default behält
beide Pfade — Tests + Anki-Importer + Sprint-9-User-Smokes
laufen unverändert weiter.

Cards-App ist heute in mana-auth (auth.mana.how) registriert
(app_id='cards', ownership_kind='verein', status='active'),
Service-Key generiert + per direktem DB-Insert in
auth.app_service_keys gelegt (Migrations-Tabellen waren in
prod-DB noch nicht angewendet, jetzt nachgezogen).

56 API-Tests grün, jose als neue Dependency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-08 20:41:09 +02:00
parent 6ea96dddda
commit 506aec3357
3 changed files with 74 additions and 12 deletions

View file

@ -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",

View file

@ -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 <jwt>` 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: <uuid>` 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<typeof createRemoteJWKSet> | 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<string | null> {
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 <jwt> oder X-User-Id-Header erforderlich'
: 'Authorization: Bearer <jwt> erforderlich',
},
401
);
};
/** Helper zum Auslesen des userId aus dem Context (typed). */

8
pnpm-lock.yaml generated
View file

@ -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