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
+42
View File
@@ -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);
}
+85
View File
@@ -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);
});
});