feat: Keycloak jose 验签中间件(requireAuth/requireRealmRole/requireService)+本地签发测试
This commit is contained in:
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
|
||||
import type { GenerateKeyPairResult } from 'jose';
|
||||
|
||||
export interface TestIdentity {
|
||||
sub?: string;
|
||||
username?: string;
|
||||
roles?: string[];
|
||||
groups?: string[];
|
||||
azp?: string;
|
||||
aud?: string | string[];
|
||||
expiresIn?: number; // 秒;负数=已过期
|
||||
}
|
||||
|
||||
let keys: GenerateKeyPairResult | null = null;
|
||||
|
||||
/** 生成本地密钥对并写入 KEYCLOAK_TEST_JWKS(必须先于首次 verify 调用) */
|
||||
export async function setupTestJwks(): Promise<void> {
|
||||
if (keys) return;
|
||||
keys = await generateKeyPair('RS256');
|
||||
const jwk = await exportJWK(keys.publicKey);
|
||||
process.env.KEYCLOAK_TEST_JWKS = JSON.stringify({ keys: [{ ...jwk, alg: 'RS256', kid: 'test-key' }] });
|
||||
}
|
||||
|
||||
export async function makeToken(id: TestIdentity = {}): Promise<string> {
|
||||
await setupTestJwks();
|
||||
const issuer = `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}`;
|
||||
const clientId = process.env.KEYCLOAK_CLIENT_ID ?? '';
|
||||
if (!keys) throw new Error('test keys not ready');
|
||||
return new SignJWT({
|
||||
preferred_username: id.username ?? 'tester',
|
||||
realm_access: { roles: id.roles ?? ['user'] },
|
||||
groups: id.groups ?? [],
|
||||
azp: id.azp ?? 'web-portal',
|
||||
})
|
||||
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
|
||||
.setSubject(id.sub ?? 'test-sub-1')
|
||||
.setIssuer(issuer)
|
||||
.setAudience(id.aud ?? clientId)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(Math.floor(Date.now() / 1000) + (id.expiresIn ?? 300))
|
||||
.sign(keys.privateKey);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Hono } from 'hono';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { makeToken, setupTestJwks } from '../support/token';
|
||||
import { requireAuth, requireRealmRole, requireService } from '../../src/shared/keycloak-auth';
|
||||
|
||||
function appWith(...mw: Parameters<Hono['use']>[1][]): Hono {
|
||||
const app = new Hono();
|
||||
for (const m of mw) app.use('/p/*', m);
|
||||
app.get('/p/x', (c) => c.json({ user: c.get('user') }));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('keycloak-auth', () => {
|
||||
beforeAll(async () => {
|
||||
await setupTestJwks();
|
||||
});
|
||||
|
||||
it('缺 Bearer → 401 unauthorized', async () => {
|
||||
const res = await appWith(requireAuth()).request('/p/x');
|
||||
expect(res.status).toBe(401);
|
||||
expect(((await res.json()) as { error: string }).error).toBe('unauthorized');
|
||||
});
|
||||
|
||||
it('合法 token → 200 且 user 就位', async () => {
|
||||
const token = await makeToken({ sub: 'u-1', roles: ['user'] });
|
||||
const res = await appWith(requireAuth()).request('/p/x', {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { user: { id: string; roles: string[] } };
|
||||
expect(body.user.id).toBe('u-1');
|
||||
expect(body.user.roles).toContain('user');
|
||||
});
|
||||
|
||||
it('aud 不含本服务 → 401 invalid_token 且 message 点名 audience mapper', async () => {
|
||||
const token = await makeToken({ aud: 'some-other-service' });
|
||||
const res = await appWith(requireAuth()).request('/p/x', {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(((await res.json()) as { message: string }).message).toContain('audience mapper');
|
||||
});
|
||||
|
||||
it('过期 token → 401 且 message 为 token 已过期', async () => {
|
||||
const token = await makeToken({ expiresIn: -60 });
|
||||
const res = await appWith(requireAuth()).request('/p/x', {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(((await res.json()) as { message: string }).message).toContain('过期');
|
||||
});
|
||||
|
||||
it('角色不够 → 403(401/403 语义分明)', async () => {
|
||||
const token = await makeToken({ roles: ['user'] });
|
||||
const res = await appWith(requireAuth(), requireRealmRole('admin')).request('/p/x', {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('admin 通过 requireRealmRole(user, admin) 任一命中', async () => {
|
||||
const token = await makeToken({ roles: ['admin'] });
|
||||
const res = await appWith(requireAuth(), requireRealmRole('user', 'admin')).request('/p/x', {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('requireService:service 角色+azp 白名单', async () => {
|
||||
const svc = await makeToken({
|
||||
roles: ['service'], azp: 'task-dispatcher', username: 'service-account-task-dispatcher',
|
||||
});
|
||||
const ok = await appWith(requireAuth(), requireService(['task-dispatcher'])).request('/p/x', {
|
||||
headers: { authorization: `Bearer ${svc}` },
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
const wrongAzp = await makeToken({
|
||||
roles: ['service'], azp: 'agent-service', username: 'service-account-agent-service',
|
||||
});
|
||||
const deny = await appWith(requireAuth(), requireService(['task-dispatcher'])).request('/p/x', {
|
||||
headers: { authorization: `Bearer ${wrongAzp}` },
|
||||
});
|
||||
expect(deny.status).toBe(403);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user