Files

103 lines
4.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Hono['use']>[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);
});
});