org adapter:jose 验签 —— RS256 写死、校 iss/aud、30s 容差、错误译人话(ADR-012;新依赖 jose,用户已批)

四项校验顺序即安全要点:algorithms 写死 RS256(绝不读 token header 的 alg,否则
alg:none 可绕过)、issuer、audience、clockTolerance 30 秒;jose 的错误经
explainJoseError 翻成人话后包成 TokenRejectedError,排错省时间。测试用
createLocalJWKSet 造本地 JWKS 当 Keycloak 替身,不联网,验签逻辑本身走同一个
jwtVerify。zod 的 .optional() 与本仓 exactOptionalPropertyTypes 有摩擦,
按 claims.ts::identityOf 已有的手法(undefined 就不带键)加了 toVerifiedClaims
做收窄,详见 task-3-report.md。
This commit is contained in:
xcs
2026-08-25 16:21:17 +08:00
parent 5108d7a580
commit cb143d6413
6 changed files with 228 additions and 0 deletions
+2
View File
@@ -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';
+1
View File
@@ -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"
@@ -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<ReturnType<typeof generateKeyPair>>['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<string, unknown>;
};
const nowSec = (): number => Math.floor(Date.now() / 1000);
async function mint(o: Mint = {}): Promise<string> {
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);
});
});
+3
View File
@@ -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';
@@ -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<typeof claimsSchema>): 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<VerifiedClaims> {
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);
},
};
}
+10
View File
@@ -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: {}