Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
"@typescript-eslint/parser": "^8.56.0",
"eslint": "^10.0.1",
"tailwindcss": "^4.2.0",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^3.2.4"
},
"dependencies": {
"pg": "^8.18.0",
Expand Down
216 changes: 216 additions & 0 deletions packages/auth/src/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/**
* Tests for @orch/auth/jwt — JwtValidator unit tests
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { JwtValidator } from './jwt.js';
import { JwtProviderManager } from './jwt-provider.js';
import { OrchestratorError, ErrorCode } from '@orch/shared';
import * as jose from 'jose';

// Mock dependencies
vi.mock('./jwt-provider.js');
vi.mock('jose');

describe('JwtValidator', () => {
let validator: JwtValidator;
let mockProviderManager: JwtProviderManager;

const mockProvider = {
id: 'provider-1',
issuer: 'https://auth.example.com',
jwks_url: 'https://auth.example.com/.well-known/jwks.json',
audience: 'my-audience',
created_at: '2023-01-01T00:00:00Z',
};

const validToken = 'valid.token.string';

beforeEach(() => {
vi.clearAllMocks();

// Setup mock provider manager
// Since the class is mocked, we can instantiate it freely
mockProviderManager = new JwtProviderManager({} as any);
// Ensure method is a spy
mockProviderManager.findByIssuer = vi.fn();

validator = new JwtValidator(mockProviderManager);
});

it('should validate a valid token and return claims', async () => {
// 1. Mock decodeJwt to return issuer
vi.mocked(jose.decodeJwt).mockReturnValue({ iss: mockProvider.issuer });

// 2. Mock findByIssuer to return provider
vi.mocked(mockProviderManager.findByIssuer).mockReturnValue(mockProvider);

// 3. Mock createRemoteJWKSet
const mockJWKS = vi.fn();
vi.mocked(jose.createRemoteJWKSet).mockReturnValue(mockJWKS as any);

// 4. Mock jwtVerify to return payload
const expectedPayload = {
sub: 'user-123',
roles: ['admin', 'editor'],
scope: 'read write',
exp: 1700000000,
iat: 1600000000,
iss: mockProvider.issuer,
};
vi.mocked(jose.jwtVerify).mockResolvedValue({
payload: expectedPayload,
protectedHeader: { alg: 'RS256' },
});

const result = await validator.validate(validToken);

expect(jose.decodeJwt).toHaveBeenCalledWith(validToken);
expect(mockProviderManager.findByIssuer).toHaveBeenCalledWith(mockProvider.issuer);
expect(jose.createRemoteJWKSet).toHaveBeenCalledWith(new URL(mockProvider.jwks_url));
expect(jose.jwtVerify).toHaveBeenCalledWith(validToken, mockJWKS, {
issuer: mockProvider.issuer,
audience: mockProvider.audience,
});

expect(result).toEqual({
sub: 'user-123',
roles: ['admin', 'editor'],
scope: 'read write',
exp: 1700000000,
iat: 1600000000,
iss: mockProvider.issuer,
});
});

it('should throw TOKEN_INVALID if issuer is missing in token', async () => {
vi.mocked(jose.decodeJwt).mockReturnValue({}); // No iss

let error: any;
try {
await validator.validate(validToken);
} catch (err: any) {
error = err;
}

expect(error).toBeInstanceOf(OrchestratorError);
expect(error.message).toContain('JWT missing required "iss" claim');
expect(error.code).toBe(ErrorCode.TOKEN_INVALID);
});

it('should throw AUTH_FAILED if provider is not found', async () => {
vi.mocked(jose.decodeJwt).mockReturnValue({ iss: 'unknown-issuer' });
vi.mocked(mockProviderManager.findByIssuer).mockReturnValue(undefined);

let error: any;
try {
await validator.validate(validToken);
} catch (err: any) {
error = err;
}

expect(error).toBeInstanceOf(OrchestratorError);
expect(error.message).toContain('is not a registered provider');
expect(error.code).toBe(ErrorCode.AUTH_FAILED);
});

it('should cache JWKS for the same issuer', async () => {
vi.mocked(jose.decodeJwt).mockReturnValue({ iss: mockProvider.issuer });
vi.mocked(mockProviderManager.findByIssuer).mockReturnValue(mockProvider);

const mockJWKS = vi.fn();
vi.mocked(jose.createRemoteJWKSet).mockReturnValue(mockJWKS as any);

vi.mocked(jose.jwtVerify).mockResolvedValue({
payload: { sub: 'user' },
protectedHeader: { alg: 'RS256' },
});

// First call
await validator.validate(validToken);
// Second call
await validator.validate(validToken);

expect(jose.createRemoteJWKSet).toHaveBeenCalledTimes(1);
});

it('should throw TOKEN_INVALID if sub claim is missing', async () => {
vi.mocked(jose.decodeJwt).mockReturnValue({ iss: mockProvider.issuer });
vi.mocked(mockProviderManager.findByIssuer).mockReturnValue(mockProvider);
vi.mocked(jose.jwtVerify).mockResolvedValue({
payload: { iss: mockProvider.issuer }, // Missing sub
protectedHeader: { alg: 'RS256' },
});

await expect(validator.validate(validToken)).rejects.toThrow(OrchestratorError);
await expect(validator.validate(validToken)).rejects.toThrow('JWT missing required "sub" claim');
});

it('should parse comma-separated roles string', async () => {
vi.mocked(jose.decodeJwt).mockReturnValue({ iss: mockProvider.issuer });
vi.mocked(mockProviderManager.findByIssuer).mockReturnValue(mockProvider);
vi.mocked(jose.jwtVerify).mockResolvedValue({
payload: { sub: 'user', roles: 'role1, role2 ' },
protectedHeader: { alg: 'RS256' },
});

const result = await validator.validate(validToken);
expect(result.roles).toEqual(['role1', 'role2']);
});

it('should handle missing roles gracefully', async () => {
vi.mocked(jose.decodeJwt).mockReturnValue({ iss: mockProvider.issuer });
vi.mocked(mockProviderManager.findByIssuer).mockReturnValue(mockProvider);
vi.mocked(jose.jwtVerify).mockResolvedValue({
payload: { sub: 'user' }, // No roles
protectedHeader: { alg: 'RS256' },
});

const result = await validator.validate(validToken);
expect(result.roles).toEqual([]);
});

it('should throw TOKEN_EXPIRED if token is expired', async () => {
vi.mocked(jose.decodeJwt).mockReturnValue({ iss: mockProvider.issuer });
vi.mocked(mockProviderManager.findByIssuer).mockReturnValue(mockProvider);

const error = new Error('token expired');
vi.mocked(jose.jwtVerify).mockRejectedValue(error);

try {
await validator.validate(validToken);
throw new Error('Should have thrown');
} catch (err: any) {
expect(err).toBeInstanceOf(OrchestratorError);
expect(err.code).toBe(ErrorCode.TOKEN_EXPIRED);
expect(err.message).toBe('JWT has expired.');
}
});

it('should throw TOKEN_INVALID for other validation errors', async () => {
vi.mocked(jose.decodeJwt).mockReturnValue({ iss: mockProvider.issuer });
vi.mocked(mockProviderManager.findByIssuer).mockReturnValue(mockProvider);

const error = new Error('signature verification failed');
vi.mocked(jose.jwtVerify).mockRejectedValue(error);

try {
await validator.validate(validToken);
throw new Error('Should have thrown');
} catch (err: any) {
expect(err).toBeInstanceOf(OrchestratorError);
expect(err.code).toBe(ErrorCode.TOKEN_INVALID);
expect(err.message).toContain('JWT validation failed');
}
});

it('should rethrow OrchestratorError if thrown inside try block', async () => {
// Force decodeJwt to throw OrchestratorError (e.g. somehow)
// Or easier: make jwtVerify throw one
const orchError = new OrchestratorError(ErrorCode.INTERNAL_ERROR, 'Internal error');
vi.mocked(jose.decodeJwt).mockReturnValue({ iss: mockProvider.issuer });
vi.mocked(mockProviderManager.findByIssuer).mockReturnValue(mockProvider);
vi.mocked(jose.jwtVerify).mockRejectedValue(orchError);

await expect(validator.validate(validToken)).rejects.toThrow(orchError);
});
});
2 changes: 1 addition & 1 deletion packages/shared/tsconfig.tsbuildinfo

Large diffs are not rendered by default.

24 changes: 12 additions & 12 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.