Archived
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:
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Generated
+10
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user