From 3070cbe52fee462f2fa5a14b531f62b8a76f3e13 Mon Sep 17 00:00:00 2001 From: lovefyf Date: Thu, 13 Aug 2026 01:02:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Keycloak=20jose=20=E9=AA=8C=E7=AD=BE?= =?UTF-8?q?=E4=B8=AD=E9=97=B4=E4=BB=B6=EF=BC=88requireAuth/requireRealmRol?= =?UTF-8?q?e/requireService=EF=BC=89=EF=BC=8B=E6=9C=AC=E5=9C=B0=E7=AD=BE?= =?UTF-8?q?=E5=8F=91=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/keycloak-auth.ts | 119 ++++++++++++++++++++++++++++++++ test/support/token.ts | 42 +++++++++++ test/unit/keycloak-auth.test.ts | 85 +++++++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 src/shared/keycloak-auth.ts create mode 100644 test/support/token.ts create mode 100644 test/unit/keycloak-auth.test.ts diff --git a/src/shared/keycloak-auth.ts b/src/shared/keycloak-auth.ts new file mode 100644 index 0000000..0560221 --- /dev/null +++ b/src/shared/keycloak-auth.ts @@ -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 { + 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(); + }; +} diff --git a/test/support/token.ts b/test/support/token.ts new file mode 100644 index 0000000..e70aaf2 --- /dev/null +++ b/test/support/token.ts @@ -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 { + 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 { + 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); +} diff --git a/test/unit/keycloak-auth.test.ts b/test/unit/keycloak-auth.test.ts new file mode 100644 index 0000000..6fd4e8a --- /dev/null +++ b/test/unit/keycloak-auth.test.ts @@ -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[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); + }); +});