fix: 终审识别文件扫——JWKS 失败→503、getNats 在途去重、service-token clientId 取自 config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
203815d6a2
commit
e13d86fe4f
+20
-2
@@ -3,11 +3,29 @@ import type { NatsConnection } from 'nats';
|
|||||||
import { config } from '../config';
|
import { config } from '../config';
|
||||||
|
|
||||||
let nc: NatsConnection | null = null;
|
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> {
|
export async function getNats(): Promise<NatsConnection> {
|
||||||
if (nc && !nc.isClosed()) return nc;
|
if (nc && !nc.isClosed()) return nc;
|
||||||
nc = await connect({ servers: config.natsUrl, name: config.serviceName, timeout: 5000 });
|
if (connecting) return await connecting;
|
||||||
return nc;
|
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'> {
|
export async function checkNats(): Promise<'ok' | 'fail'> {
|
||||||
|
|||||||
@@ -88,11 +88,38 @@ export function requireAuth(): MiddlewareHandler {
|
|||||||
c.set('user', toAuthUser(await verifyAccessToken(h.slice(7).trim())));
|
c.set('user', toAuthUser(await verifyAccessToken(h.slice(7).trim())));
|
||||||
return await next();
|
return await next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (classifyAuthError(err) === 503) {
|
||||||
|
return c.json({ error: 'auth_unavailable', message: '鉴权服务暂不可达——稍后重试' }, 503);
|
||||||
|
}
|
||||||
return c.json({ error: 'invalid_token', message: tokenErrorMessage(err) }, 401);
|
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 {
|
function tokenErrorMessage(err: unknown): string {
|
||||||
if (err instanceof joseErrors.JWTExpired) return 'token 已过期';
|
if (err instanceof joseErrors.JWTExpired) return 'token 已过期';
|
||||||
if (err instanceof joseErrors.JWTClaimValidationFailed && err.claim === 'aud') {
|
if (err instanceof joseErrors.JWTClaimValidationFailed && err.claim === 'aud') {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** client_credentials 换服务账号 token(指南 8.2);缓存到 exp-30s;secret 只从环境读 */
|
/** client_credentials 换服务账号 token(指南 8.2);缓存到 exp-30s;secret 只从环境读 */
|
||||||
|
|
||||||
|
import { config } from './config';
|
||||||
import { PermanentFailure } from './errors';
|
import { PermanentFailure } from './errors';
|
||||||
|
|
||||||
export type TokenProvider = () => Promise<string>;
|
export type TokenProvider = () => Promise<string>;
|
||||||
@@ -10,7 +11,11 @@ export function createKeycloakTokenProvider(): TokenProvider {
|
|||||||
if (cached && Date.now() < cached.expiresAt) return cached.token;
|
if (cached && Date.now() < cached.expiresAt) return cached.token;
|
||||||
const url = process.env.KEYCLOAK_URL ?? 'https://keycloak.akhr-ai.com';
|
const url = process.env.KEYCLOAK_URL ?? 'https://keycloak.akhr-ai.com';
|
||||||
const realm = process.env.KEYCLOAK_REALM ?? 'corp';
|
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;
|
const secret = process.env.KEYCLOAK_CLIENT_SECRET;
|
||||||
// 漏配的 secret 不会因为重试而长出来 → 永久失败
|
// 漏配的 secret 不会因为重试而长出来 → 永久失败
|
||||||
if (!secret) throw new PermanentFailure('缺 KEYCLOAK_CLIENT_SECRET——服务间调用需要 client_credentials,检查 .env');
|
if (!secret) throw new PermanentFailure('缺 KEYCLOAK_CLIENT_SECRET——服务间调用需要 client_credentials,检查 .env');
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import { errors as joseErrors } from 'jose';
|
||||||
import { beforeAll, describe, expect, it } from 'vitest';
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { makeToken, setupTestJwks } from '../support/token';
|
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 {
|
function appWith(...mw: Parameters<Hono['use']>[1][]): Hono {
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -66,6 +67,22 @@ describe('keycloak-auth', () => {
|
|||||||
expect(res.status).toBe(200);
|
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 () => {
|
it('requireService:service 角色+azp 白名单', async () => {
|
||||||
const svc = await makeToken({
|
const svc = await makeToken({
|
||||||
roles: ['service'], azp: 'task-dispatcher', username: 'service-account-task-dispatcher',
|
roles: ['service'], azp: 'task-dispatcher', username: 'service-account-task-dispatcher',
|
||||||
|
|||||||
Reference in New Issue
Block a user