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:*",
|
"@cards/domain": "workspace:*",
|
||||||
"drizzle-orm": "0.38",
|
"drizzle-orm": "0.38",
|
||||||
"hono": "^4.6.0",
|
"hono": "^4.6.0",
|
||||||
|
"jose": "^6.2.3",
|
||||||
"minio": "^8.0.7",
|
"minio": "^8.0.7",
|
||||||
"postgres": "^3.4.0",
|
"postgres": "^3.4.0",
|
||||||
"zod": "3",
|
"zod": "3",
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,77 @@
|
||||||
import type { Context, MiddlewareHandler } from 'hono';
|
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.
|
* Auth-Quellen (in Reihenfolge):
|
||||||
* Phase 2 (echt): validiert User-JWT gegen mana-auth JWKS und extrahiert
|
* 1. `Authorization: Bearer <jwt>` — User-JWT aus mana-auth, validiert
|
||||||
* `sub`-Claim als userId.
|
* 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
|
* Implementations-Notiz: jose ist die offizielle Lib auch von mana-auth
|
||||||
* `authMiddleware()` um, das den JWKS-Cache verwaltet.
|
* (siehe services/mana-auth/src/middleware/jwt-auth.ts).
|
||||||
*/
|
*/
|
||||||
export type AuthVars = { userId: string };
|
export type AuthVars = { userId: string };
|
||||||
|
|
||||||
export const authMiddleware: MiddlewareHandler<{ Variables: AuthVars }> = async (c, next) => {
|
const MANA_AUTH_URL = process.env.MANA_AUTH_URL ?? 'https://auth.mana.how';
|
||||||
const userId = c.req.header('X-User-Id');
|
const ALLOW_DEV_STUB = process.env.CARDS_AUTH_DEV_STUB !== 'false';
|
||||||
if (!userId) {
|
|
||||||
return c.json({ error: 'unauthenticated', detail: 'X-User-Id header missing (dev stub)' }, 401);
|
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);
|
return jwksCache;
|
||||||
await next();
|
}
|
||||||
|
|
||||||
|
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). */
|
/** 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:
|
hono:
|
||||||
specifier: ^4.6.0
|
specifier: ^4.6.0
|
||||||
version: 4.12.18
|
version: 4.12.18
|
||||||
|
jose:
|
||||||
|
specifier: ^6.2.3
|
||||||
|
version: 6.2.3
|
||||||
minio:
|
minio:
|
||||||
specifier: ^8.0.7
|
specifier: ^8.0.7
|
||||||
version: 8.0.7
|
version: 8.0.7
|
||||||
|
|
@ -1301,6 +1304,9 @@ packages:
|
||||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jose@6.2.3:
|
||||||
|
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
||||||
|
|
||||||
jszip@3.10.1:
|
jszip@3.10.1:
|
||||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||||
|
|
||||||
|
|
@ -2581,6 +2587,8 @@ snapshots:
|
||||||
|
|
||||||
jiti@2.7.0: {}
|
jiti@2.7.0: {}
|
||||||
|
|
||||||
|
jose@6.2.3: {}
|
||||||
|
|
||||||
jszip@3.10.1:
|
jszip@3.10.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
lie: 3.3.0
|
lie: 3.3.0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue