fix: 终审识别文件扫——JWKS 失败→503、getNats 在途去重、service-token clientId 取自 config

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
lovefyf
2026-08-13 14:16:32 +08:00
co-authored by Claude Opus 4.8
parent 203815d6a2
commit e13d86fe4f
4 changed files with 71 additions and 4 deletions
+20 -2
View File
@@ -3,11 +3,29 @@ import type { NatsConnection } from 'nats';
import { config } from '../config';
let nc: NatsConnection | null = null;
/**
* 在途去重:nc 只在 connect() **resolve 之后**才被赋值,所以并发的首批调用者读到的
* 都还是 null,于是各自拨一条连接;最后一个赋值进 nc,前面那些谁也够不着——drain 不掉、
* 也不计入 closeNats,就那么挂在服务端的连接表上直到进程死。记住这次在途的 promise,
* 让并发调用者共享同一次拨号;成功与失败都清空,失败后下一次调用可以重新拨。
*/
let connecting: Promise<NatsConnection> | null = null;
export async function getNats(): Promise<NatsConnection> {
if (nc && !nc.isClosed()) return nc;
nc = await connect({ servers: config.natsUrl, name: config.serviceName, timeout: 5000 });
return nc;
if (connecting) return await connecting;
const dial = connect({ servers: config.natsUrl, name: config.serviceName, timeout: 5000 })
.then((c) => {
nc = c;
connecting = null;
return c;
})
.catch((err: unknown) => {
connecting = null;
throw err;
});
connecting = dial;
return await dial;
}
export async function checkNats(): Promise<'ok' | 'fail'> {
+27
View File
@@ -88,11 +88,38 @@ export function requireAuth(): MiddlewareHandler {
c.set('user', toAuthUser(await verifyAccessToken(h.slice(7).trim())));
return await next();
} catch (err) {
if (classifyAuthError(err) === 503) {
return c.json({ error: 'auth_unavailable', message: '鉴权服务暂不可达——稍后重试' }, 503);
}
return c.json({ error: 'invalid_token', message: tokenErrorMessage(err) }, 401);
}
};
}
/**
* I4:401 与 503 是两件事。token 本身不对(过期/签名/aud/iss/根本不是 JWT)是调用方
* 的问题,401 让它换票;远端 JWKS 拉不动(DNS、连不上、超时、Keycloak 5xx)是**我们这边**
* 的基础设施故障,回 401 等于把停机说成"你的凭据无效"——客户端会去重新登录,而重新登录
* 打的还是同一个连不上的 Keycloak,并且监控上看到的是一片鉴权失败而非依赖不可用。
*
* 判据取白名单而非黑名单:只有 jose 明确的 token 类错误算 401,其余一律 503。这样
* 非 JOSEError(fetch 的 TypeError)、JWKSTimeout、JWKS 响应非 200 抛的 JOSEError 基类,
* 以及将来 jose 新增的任何基础设施类错误,默认都落在"稍后重试"这一侧——失败方向朝安全。
*/
export function classifyAuthError(err: unknown): 401 | 503 {
const tokenClass =
err instanceof joseErrors.JWTExpired ||
err instanceof joseErrors.JWTClaimValidationFailed ||
err instanceof joseErrors.JWTInvalid ||
err instanceof joseErrors.JWSInvalid ||
err instanceof joseErrors.JWSSignatureVerificationFailed ||
err instanceof joseErrors.JOSEAlgNotAllowed ||
err instanceof joseErrors.JOSENotSupported ||
err instanceof joseErrors.JWKSNoMatchingKey ||
err instanceof joseErrors.JWKSMultipleMatchingKeys;
return tokenClass ? 401 : 503;
}
function tokenErrorMessage(err: unknown): string {
if (err instanceof joseErrors.JWTExpired) return 'token 已过期';
if (err instanceof joseErrors.JWTClaimValidationFailed && err.claim === 'aud') {
+6 -1
View File
@@ -1,5 +1,6 @@
/** client_credentials 换服务账号 token(指南 8.2);缓存到 exp-30s;secret 只从环境读 */
import { config } from './config';
import { PermanentFailure } from './errors';
export type TokenProvider = () => Promise<string>;
@@ -10,7 +11,11 @@ export function createKeycloakTokenProvider(): TokenProvider {
if (cached && Date.now() < cached.expiresAt) return cached.token;
const url = process.env.KEYCLOAK_URL ?? 'https://keycloak.akhr-ai.com';
const realm = process.env.KEYCLOAK_REALM ?? 'corp';
const clientId = process.env.KEYCLOAK_CLIENT_ID ?? 'task-dispatcher';
// I3:这份文件三仓同源,写死 'task-dispatcher' 的兜底到了网关就是错的 client——
// 换 token 会拿到别人的服务账号(或直接被拒)。clientId 归 config(每仓一份,非同一性
// 文件)来定,本文件就能保持逐字节一致。URL/realm/secret 仍走 process.env:service-token
// 的用例在 beforeAll(模块求值之后)才改 KEYCLOAK_URL,搬进 config 会读到求值那一刻的旧值。
const clientId = config.keycloak.clientId;
const secret = process.env.KEYCLOAK_CLIENT_SECRET;
// 漏配的 secret 不会因为重试而长出来 → 永久失败
if (!secret) throw new PermanentFailure('缺 KEYCLOAK_CLIENT_SECRET——服务间调用需要 client_credentials,检查 .env');
+18 -1
View File
@@ -1,7 +1,8 @@
import { Hono } from 'hono';
import { errors as joseErrors } from 'jose';
import { beforeAll, describe, expect, it } from 'vitest';
import { makeToken, setupTestJwks } from '../support/token';
import { requireAuth, requireRealmRole, requireService } from '../../src/shared/keycloak-auth';
import { classifyAuthError, requireAuth, requireRealmRole, requireService } from '../../src/shared/keycloak-auth';
function appWith(...mw: Parameters<Hono['use']>[1][]): Hono {
const app = new Hono();
@@ -66,6 +67,22 @@ describe('keycloak-auth', () => {
expect(res.status).toBe(200);
});
it('I4:token 类失败 → 401;JWKS/网络类失败 → 503(不把停机说成凭据无效)', () => {
// token 类:换张票才有意义
expect(classifyAuthError(new joseErrors.JWTExpired('exp', {}))).toBe(401);
expect(classifyAuthError(new joseErrors.JWTClaimValidationFailed('aud', {}, 'aud'))).toBe(401);
expect(classifyAuthError(new joseErrors.JWTInvalid('不是 JWT'))).toBe(401);
expect(classifyAuthError(new joseErrors.JWSSignatureVerificationFailed())).toBe(401);
expect(classifyAuthError(new joseErrors.JWKSNoMatchingKey())).toBe(401);
// 基础设施类:只有重试才有意义。回 401 会把客户端赶去重新登录,而登录打的是同一个
// 连不上的 Keycloak——排障时看到的也只是一片"凭据无效",而非依赖不可用。
expect(classifyAuthError(new TypeError('fetch failed'))).toBe(503);
expect(classifyAuthError(new joseErrors.JWKSTimeout())).toBe(503);
expect(classifyAuthError(new joseErrors.JWKSInvalid('JWKS 结构不对'))).toBe(503);
expect(classifyAuthError(new joseErrors.JOSEError('Expected 200 OK from the JSON Web Key Set HTTP response'))).toBe(503);
expect(classifyAuthError('连字符串都不是')).toBe(503);
});
it('requireService:service 角色+azp 白名单', async () => {
const svc = await makeToken({
roles: ['service'], azp: 'task-dispatcher', username: 'service-account-task-dispatcher',