feat: Keycloak jose 验签中间件(requireAuth/requireRealmRole/requireService)+本地签发测试

This commit is contained in:
lovefyf
2026-08-13 01:02:15 +08:00
parent 85e944cdcb
commit 3070cbe52f
3 changed files with 246 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
/**
* Keycloak 验签(照 docs/keycloak-agent-guide.md §5:jose 本地验签、RS256 写死、
* aud 校验本服务 clientId、禁 keycloak-connect、禁逐请求 introspect)。
* KEYCLOAK_TEST_JWKS 仅测试用:本地 JWKS JSON,生产绝不设置。
*/
import { createLocalJWKSet, createRemoteJWKSet, jwtVerify, errors as joseErrors } from 'jose';
import type { JWTPayload, JWTVerifyGetKey } from 'jose';
import type { MiddlewareHandler } from 'hono';
const KEYCLOAK_URL = process.env.KEYCLOAK_URL ?? 'https://keycloak.akhr-ai.com';
const REALM = process.env.KEYCLOAK_REALM ?? 'corp';
const CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID ?? '';
export const ISSUER = `${KEYCLOAK_URL}/realms/${REALM}`;
let keySource: JWTVerifyGetKey | null = null;
function jwks(): JWTVerifyGetKey {
if (keySource) return keySource;
const testJwks = process.env.KEYCLOAK_TEST_JWKS;
keySource = testJwks
? createLocalJWKSet(JSON.parse(testJwks))
: createRemoteJWKSet(new URL(`${ISSUER}/protocol/openid-connect/certs`), {
cooldownDuration: 30_000,
cacheMaxAge: 10 * 60_000,
});
return keySource;
}
export interface AuthUser {
id: string;
username: string;
roles: string[];
groups: string[];
azp?: string;
isService: boolean;
}
declare module 'hono' {
interface ContextVariableMap {
user: AuthUser;
}
}
export async function verifyAccessToken(token: string): Promise<JWTPayload> {
const { payload } = await jwtVerify(token, jwks(), {
algorithms: ['RS256'], // 写死,绝不读 header 里的 alg
issuer: ISSUER,
audience: CLIENT_ID,
clockTolerance: 30,
});
return payload;
}
export function toAuthUser(p: JWTPayload): AuthUser {
const username = (p['preferred_username'] as string | undefined) ?? '';
const user: AuthUser = {
id: p.sub ?? '',
username,
roles: (p['realm_access'] as { roles?: string[] } | undefined)?.roles ?? [],
groups: (p['groups'] as string[] | undefined) ?? [],
isService: username.startsWith('service-account-'),
};
const azp = p['azp'];
if (typeof azp === 'string') user.azp = azp;
return user;
}
export function requireAuth(): MiddlewareHandler {
return async (c, next) => {
const h = c.req.header('authorization') ?? '';
if (!/^bearer /i.test(h)) {
return c.json({ error: 'unauthorized', message: '缺少 Bearer token' }, 401);
}
try {
c.set('user', toAuthUser(await verifyAccessToken(h.slice(7).trim())));
return await next();
} catch (err) {
return c.json({ error: 'invalid_token', message: tokenErrorMessage(err) }, 401);
}
};
}
function tokenErrorMessage(err: unknown): string {
if (err instanceof joseErrors.JWTExpired) return 'token 已过期';
if (err instanceof joseErrors.JWTClaimValidationFailed && err.claim === 'aud') {
return `token 的 aud 不含 "${CLIENT_ID}" —— 给签发方 client 加 audience mapper`;
}
if (err instanceof joseErrors.JWTClaimValidationFailed && err.claim === 'iss') {
return `iss 与 "${ISSUER}" 不符 —— 检查 realm 名`;
}
return err instanceof Error ? err.message : 'token 校验失败';
}
export function requireRealmRole(...roles: string[]): MiddlewareHandler {
return async (c, next) => {
const have = c.get('user')?.roles ?? [];
if (roles.some((r) => have.includes(r))) return next();
return c.json(
{ error: 'forbidden', message: `需要角色之一: ${roles.join(', ')};当前: ${have.join(', ') || '无'}` },
403,
);
};
}
/** 服务间调用面:service realm 角色+可选 azp 白名单(哪个 client 的服务账号) */
export function requireService(allowedAzp?: string[]): MiddlewareHandler {
return async (c, next) => {
const user = c.get('user');
if (!user?.roles.includes('service')) {
return c.json({ error: 'forbidden', message: '仅限服务账号(需 service 角色)' }, 403);
}
if (allowedAzp && (!user.azp || !allowedAzp.includes(user.azp))) {
return c.json(
{ error: 'forbidden', message: `调用方 azp="${user.azp ?? '无'}" 不在许可名单 [${allowedAzp.join(', ')}]` },
403,
);
}
return next();
};
}