diff --git a/src/shared/bus/nats.ts b/src/shared/bus/nats.ts index bad0594..85fd64f 100644 --- a/src/shared/bus/nats.ts +++ b/src/shared/bus/nats.ts @@ -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 | null = null; export async function getNats(): Promise { 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'> { diff --git a/src/shared/keycloak-auth.ts b/src/shared/keycloak-auth.ts index 54f452b..de072c5 100644 --- a/src/shared/keycloak-auth.ts +++ b/src/shared/keycloak-auth.ts @@ -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') { diff --git a/src/shared/service-token.ts b/src/shared/service-token.ts index 0108bd2..eab9d5f 100644 --- a/src/shared/service-token.ts +++ b/src/shared/service-token.ts @@ -1,5 +1,6 @@ /** client_credentials 换服务账号 token(指南 8.2);缓存到 exp-30s;secret 只从环境读 */ +import { config } from './config'; import { PermanentFailure } from './errors'; export type TokenProvider = () => Promise; @@ -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'); diff --git a/test/unit/keycloak-auth.test.ts b/test/unit/keycloak-auth.test.ts index 6fd4e8a..c5cf3b6 100644 --- a/test/unit/keycloak-auth.test.ts +++ b/test/unit/keycloak-auth.test.ts @@ -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[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',