Phase 2a: Cards-API JWT-Verify (additiv zum Dev-Stub)
Some checks are pending
CI / validate (push) Waiting to run
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:
parent
6ea96dddda
commit
506aec3357
3 changed files with 74 additions and 12 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
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
8
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue