import { Hono } from 'hono'; import { errors as joseErrors } from 'jose'; import { beforeAll, describe, expect, it } from 'vitest'; import { makeToken, setupTestJwks } from '../support/token'; import { classifyAuthError, requireAuth, requireRealmRole, requireService } from '../../src/shared/keycloak-auth'; function appWith(...mw: Parameters[1][]): Hono { const app = new Hono(); for (const m of mw) app.use('/p/*', m); app.get('/p/x', (c) => c.json({ user: c.get('user') })); return app; } describe('keycloak-auth', () => { beforeAll(async () => { await setupTestJwks(); }); it('缺 Bearer → 401 unauthorized', async () => { const res = await appWith(requireAuth()).request('/p/x'); expect(res.status).toBe(401); expect(((await res.json()) as { error: string }).error).toBe('unauthorized'); }); it('合法 token → 200 且 user 就位', async () => { const token = await makeToken({ sub: 'u-1', roles: ['user'] }); const res = await appWith(requireAuth()).request('/p/x', { headers: { authorization: `Bearer ${token}` }, }); expect(res.status).toBe(200); const body = (await res.json()) as { user: { id: string; roles: string[] } }; expect(body.user.id).toBe('u-1'); expect(body.user.roles).toContain('user'); }); it('aud 不含本服务 → 401 invalid_token 且 message 点名 audience mapper', async () => { const token = await makeToken({ aud: 'some-other-service' }); const res = await appWith(requireAuth()).request('/p/x', { headers: { authorization: `Bearer ${token}` }, }); expect(res.status).toBe(401); expect(((await res.json()) as { message: string }).message).toContain('audience mapper'); }); it('过期 token → 401 且 message 为 token 已过期', async () => { const token = await makeToken({ expiresIn: -60 }); const res = await appWith(requireAuth()).request('/p/x', { headers: { authorization: `Bearer ${token}` }, }); expect(res.status).toBe(401); expect(((await res.json()) as { message: string }).message).toContain('过期'); }); it('角色不够 → 403(401/403 语义分明)', async () => { const token = await makeToken({ roles: ['user'] }); const res = await appWith(requireAuth(), requireRealmRole('admin')).request('/p/x', { headers: { authorization: `Bearer ${token}` }, }); expect(res.status).toBe(403); }); it('admin 通过 requireRealmRole(user, admin) 任一命中', async () => { const token = await makeToken({ roles: ['admin'] }); const res = await appWith(requireAuth(), requireRealmRole('user', 'admin')).request('/p/x', { headers: { authorization: `Bearer ${token}` }, }); 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', }); const ok = await appWith(requireAuth(), requireService(['task-dispatcher'])).request('/p/x', { headers: { authorization: `Bearer ${svc}` }, }); expect(ok.status).toBe(200); const wrongAzp = await makeToken({ roles: ['service'], azp: 'agent-service', username: 'service-account-agent-service', }); const deny = await appWith(requireAuth(), requireService(['task-dispatcher'])).request('/p/x', { headers: { authorization: `Bearer ${wrongAzp}` }, }); expect(deny.status).toBe(403); }); });