diff --git a/api-report/apps/api/src/modules/org/index.d.ts b/api-report/apps/api/src/modules/org/index.d.ts index 95bcc39..883af8b 100644 --- a/api-report/apps/api/src/modules/org/index.d.ts +++ b/api-report/apps/api/src/modules/org/index.d.ts @@ -18,3 +18,5 @@ export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claim export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts'; export { buildInFlightWork } from './internal/domain/in-flight.ts'; export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, TEST_POSITION_CODE } from './internal/domain/seed-data.ts'; +export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts'; +export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts'; diff --git a/apps/api/package.json b/apps/api/package.json index 48c9895..58571f6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -26,6 +26,7 @@ "ajv-formats": "^3.0.1", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", + "jose": "^5.10.0", "pg": "^8.22.0", "pg-boss": "^12.26.3", "zod": "^4.4.3" diff --git a/apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts b/apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts new file mode 100644 index 0000000..85187f3 --- /dev/null +++ b/apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts @@ -0,0 +1,121 @@ +/** + * Keycloak 是外界,替身只能在外界:这里用本地密钥对充当它的 JWKS(`createLocalJWKSet`), + * 验签逻辑本身一行不假 —— 与生产走的是同一个 `jwtVerify`。 + */ +import { beforeAll, describe, expect, it } from 'vitest'; +import { SignJWT, UnsecuredJWT, createLocalJWKSet, exportJWK, generateKeyPair } from 'jose'; +import type { JWK, JWTVerifyGetKey } from 'jose'; +import { TokenRejectedError } from '../contract.ts'; +import { createKeycloakTokenVerifier } from '../internal/adapter/keycloak-token-verifier.ts'; +import type { TokenVerifier } from '../port.ts'; + +const ISSUER = 'https://keycloak.test/realms/corp'; +const AUDIENCE = 'api-gateway'; + +let privateKey: Awaited>['privateKey']; +let getKey: JWTVerifyGetKey; +let verifier: TokenVerifier; + +beforeAll(async () => { + const pair = await generateKeyPair('RS256'); + privateKey = pair.privateKey; + const jwk: JWK = { ...(await exportJWK(pair.publicKey)), kid: 'k1', alg: 'RS256', use: 'sig' }; + getKey = createLocalJWKSet({ keys: [jwk] }); + verifier = createKeycloakTokenVerifier({ issuer: ISSUER, audience: AUDIENCE, getKey }); +}); + +type Mint = { + sub?: string; + issuer?: string; + audience?: string | string[]; + /** 秒级 unix 时刻;缺省 5 分钟后 */ + exp?: number; + kid?: string; + claims?: Record; +}; + +const nowSec = (): number => Math.floor(Date.now() / 1000); + +async function mint(o: Mint = {}): Promise { + const jwt = new SignJWT({ + preferred_username: 'alice', + name: '爱丽丝', + email: 'alice@akhr-ai.com', + realm_access: { roles: ['user', 'default-roles-corp'] }, + ...o.claims, + }) + .setProtectedHeader({ alg: 'RS256', kid: o.kid ?? 'k1' }) + .setSubject(o.sub ?? 'sub-alice') + .setIssuer(o.issuer ?? ISSUER) + .setAudience(o.audience ?? [AUDIENCE, 'account']) + .setIssuedAt() + .setExpirationTime(o.exp ?? nowSec() + 300); + return jwt.sign(privateKey); +} + +describe('Keycloak 验签', () => { + it('RS256 · iss · aud 都对 → 返回 claims', async () => { + const claims = await verifier.verify(await mint()); + expect(claims.sub).toBe('sub-alice'); + expect(claims.preferred_username).toBe('alice'); + expect(claims.name).toBe('爱丽丝'); + expect(claims.realm_access?.roles).toContain('user'); + }); + + it('过期 30 秒内(时钟容差)仍通过', async () => { + await expect(verifier.verify(await mint({ exp: nowSec() - 10 }))).resolves.toBeDefined(); + }); + + it('过期超过容差 → 拒,话是「已过期」', async () => { + await expect(verifier.verify(await mint({ exp: nowSec() - 120 }))).rejects.toThrow(/已过期/); + }); + + it('aud 不含本服务 → 拒,话点名 audience mapper', async () => { + await expect(verifier.verify(await mint({ audience: ['data-service'] }))).rejects.toThrow(/audience mapper/); + }); + + it('iss 不符 → 拒,话点名 KEYCLOAK_URL', async () => { + await expect(verifier.verify(await mint({ issuer: 'https://other.test/realms/corp' }))).rejects.toThrow(/KEYCLOAK_URL/); + }); + + it('kid 对不上 JWKS → 拒,话点名 JWKS', async () => { + await expect(verifier.verify(await mint({ kid: 'k-unknown' }))).rejects.toThrow(/JWKS/); + }); + + it('HS256 → 拒(算法写死 RS256)', async () => { + const secret = new TextEncoder().encode('0123456789abcdef0123456789abcdef'); + const token = await new SignJWT({ preferred_username: 'alice' }) + .setProtectedHeader({ alg: 'HS256', kid: 'k1' }) + .setSubject('sub-alice') + .setIssuer(ISSUER) + .setAudience(AUDIENCE) + .setExpirationTime(nowSec() + 300) + .sign(secret); + await expect(verifier.verify(token)).rejects.toThrow(TokenRejectedError); + }); + + it('alg: none → 拒', async () => { + const token = new UnsecuredJWT({ preferred_username: 'alice' }) + .setSubject('sub-alice') + .setIssuer(ISSUER) + .setAudience(AUDIENCE) + .setExpirationTime(nowSec() + 300) + .encode(); + await expect(verifier.verify(token)).rejects.toThrow(TokenRejectedError); + }); + + it('缺 preferred_username → 拒,话点名 client scope', async () => { + const token = await new SignJWT({ realm_access: { roles: ['user'] } }) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .setSubject('sub-x') + .setIssuer(ISSUER) + .setAudience(AUDIENCE) + .setExpirationTime(nowSec() + 300) + .sign(privateKey); + await expect(verifier.verify(token)).rejects.toThrow(/preferred_username/); + }); + + it('拒回的一律是 TokenRejectedError(中间件据此 401)', async () => { + await expect(verifier.verify('not-a-jwt')).rejects.toBeInstanceOf(TokenRejectedError); + }); +}); diff --git a/apps/api/src/modules/org/index.ts b/apps/api/src/modules/org/index.ts index 4a7679b..7aaef6b 100644 --- a/apps/api/src/modules/org/index.ts +++ b/apps/api/src/modules/org/index.ts @@ -60,3 +60,6 @@ export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claim export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts'; export { buildInFlightWork } from './internal/domain/in-flight.ts'; export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, TEST_POSITION_CODE } from './internal/domain/seed-data.ts'; + +export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts'; +export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts'; diff --git a/apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts b/apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts new file mode 100644 index 0000000..ba475e3 --- /dev/null +++ b/apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts @@ -0,0 +1,91 @@ +/** + * Keycloak 验签(手册 §4;ADR-012)—— 本地计算,不联网(公钥由 getKey 缓存)。 + * + * 四项校验,顺序即安全要点:`algorithms: ['RS256']` 写死(绝不读 header 的 alg)、 + * `issuer`、`audience`(本服务 clientId)、`clockTolerance: 30`。 + * jose 的错误翻成人话(手册 4.4)后包成 TokenRejectedError —— 排错时能省很多时间。 + */ +import { jwtVerify, errors as joseErrors } from 'jose'; +import type { JWTVerifyGetKey } from 'jose'; +import { z } from 'zod'; +import { TokenRejectedError } from '../../contract.ts'; +import type { TokenVerifier, VerifiedClaims } from '../../port.ts'; + +export type KeycloakVerifierOptions = { + readonly issuer: string; + readonly audience: string; + /** app 给 `createRemoteJWKSet(certsUrl, {...})`;测试给 `createLocalJWKSet(jwks)` */ + readonly getKey: JWTVerifyGetKey; + /** 秒;缺省 30 */ + readonly clockTolerance?: number; +}; + +const claimsSchema = z.object({ + sub: z.string().min(1), + preferred_username: z.string().min(1), + name: z.string().optional(), + email: z.string().optional(), + realm_access: z.object({ roles: z.array(z.string()).optional() }).optional(), +}); + +/** + * zod 的 `.optional()` 推出的类型是「键必在、值可为 undefined」, + * 而本仓 `exactOptionalPropertyTypes: true` 下 `VerifiedClaims` 的可选字段要求 + * 「键可不在,但若在则不是 undefined」—— 两边对不上,故显式收窄 + * (与同目录 `claims.ts` 的 `identityOf` 同一手法:undefined 就不带这个键)。 + */ +function toVerifiedClaims(claims: z.infer): VerifiedClaims { + return { + sub: claims.sub, + preferred_username: claims.preferred_username, + ...(claims.name === undefined ? {} : { name: claims.name }), + ...(claims.email === undefined ? {} : { email: claims.email }), + ...(claims.realm_access === undefined + ? {} + : { realm_access: claims.realm_access.roles === undefined ? {} : { roles: claims.realm_access.roles } }), + }; +} + +/** jose 的错误 → 人话(手册 4.4)。JWTExpired 是 JWTClaimValidationFailed 的子类,先判它 */ +export function explainJoseError(error: unknown, ctx: { readonly issuer: string; readonly audience: string }): string { + if (error instanceof joseErrors.JWTExpired) return 'token 已过期,用 refresh_token 换个新的'; + if (error instanceof joseErrors.JWTClaimValidationFailed) { + if (error.claim === 'aud') { + return `token 的 aud 不含 "${ctx.audience}" —— 去 Keycloak 给发 token 的那个 client 加 audience mapper`; + } + if (error.claim === 'iss') return `token 的 iss 与 "${ctx.issuer}" 不符 —— 检查 realm 名和 KEYCLOAK_URL`; + return `token 的 ${error.claim} 校验未过`; + } + if (error instanceof joseErrors.JWKSNoMatchingKey) { + return 'JWKS 里找不到匹配的公钥 —— token 可能来自别的 realm,或 Keycloak 刚轮换过密钥'; + } + if (error instanceof joseErrors.JWSSignatureVerificationFailed) { + return '签名校验失败 —— token 被篡改,或来自另一个 Keycloak 实例'; + } + if (error instanceof joseErrors.JOSEAlgNotAllowed) return 'token 的签名算法不被接受 —— 只认 RS256'; + if (error instanceof joseErrors.JWSInvalid || error instanceof joseErrors.JWTInvalid) return 'token 格式不合法'; + return error instanceof Error ? error.message : String(error); +} + +export function createKeycloakTokenVerifier(options: KeycloakVerifierOptions): TokenVerifier { + return { + async verify(token: string): Promise { + let payload: unknown; + try { + ({ payload } = await jwtVerify(token, options.getKey, { + algorithms: ['RS256'], + issuer: options.issuer, + audience: options.audience, + clockTolerance: options.clockTolerance ?? 30, + })); + } catch (error) { + throw new TokenRejectedError(explainJoseError(error, options)); + } + const parsed = claimsSchema.safeParse(payload); + if (!parsed.success) { + throw new TokenRejectedError('token 缺少 sub 或 preferred_username —— 检查 Keycloak 的 client scope 是否带 profile'); + } + return toVerifiedClaims(parsed.data); + }, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 518616e..553031b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,8 @@ importers: specifier: ^3.2.4 version: 3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(jsdom@30.0.1)(tsx@4.23.1)(yaml@2.9.0) + activities/brand-content-toolkit: {} + activities/crm-relationship-coverage: {} activities/quote-alternative: {} @@ -94,6 +96,9 @@ importers: hono: specifier: ^4.12.32 version: 4.12.32 + jose: + specifier: ^5.10.0 + version: 5.10.0 pg: specifier: ^8.22.0 version: 8.22.0 @@ -2591,6 +2596,9 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.2.4: resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==} @@ -6132,6 +6140,8 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jose@5.10.0: {} + jose@6.2.4: {} joycon@3.1.1: {}