Some checks are pending
CI / validate (push) Waiting to run
Cross-App-Hook nach Pageta-Pattern. context.app='wordeck' im Payload. Bewusst NICHT im dev-stub-Branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
140 lines
4.2 KiB
TypeScript
140 lines
4.2 KiB
TypeScript
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 <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: 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<Tier, number> = {
|
|
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<typeof createRemoteJWKSet> | 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<VerifiedClaims | null> {
|
|
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 <jwt> oder X-User-Id-Header erforderlich'
|
|
: 'Authorization: Bearer <jwt> 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();
|
|
};
|
|
}
|