130 lines
4.7 KiB
TypeScript
130 lines
4.7 KiB
TypeScript
import { describe, expect, it, beforeEach } from 'vitest';
|
|
import { createHmac } from 'node:crypto';
|
|
import {
|
|
ImpersonationJwtError,
|
|
verifyImpersonationJwt,
|
|
impersonationReplayCache,
|
|
} from '@/lib/impersonation/jwt';
|
|
|
|
const SECRET = 'a'.repeat(64);
|
|
const ISSUER = 'platform-api/webmail';
|
|
|
|
function base64Url(input: Buffer | string): string {
|
|
return Buffer.from(input)
|
|
.toString('base64')
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=+$/, '');
|
|
}
|
|
|
|
function sign(payload: Record<string, unknown>, secret: string = SECRET, header: Record<string, unknown> = { alg: 'HS256', typ: 'JWT' }): string {
|
|
const h = base64Url(JSON.stringify(header));
|
|
const p = base64Url(JSON.stringify(payload));
|
|
const sig = createHmac('sha256', secret).update(`${h}.${p}`).digest();
|
|
return `${h}.${p}.${base64Url(sig)}`;
|
|
}
|
|
|
|
function basePayload(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
return {
|
|
iss: ISSUER,
|
|
iat: now,
|
|
exp: now + 120,
|
|
jti: 'jti-' + Math.random().toString(36).slice(2),
|
|
mailbox: 'alice@example.test',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('verifyImpersonationJwt', () => {
|
|
beforeEach(() => {
|
|
impersonationReplayCache.clear();
|
|
});
|
|
|
|
it('accepts a valid HS256 token', () => {
|
|
const token = sign(basePayload());
|
|
const claims = verifyImpersonationJwt(token, SECRET, { expectedIssuer: ISSUER });
|
|
expect(claims.mailbox).toBe('alice@example.test');
|
|
});
|
|
|
|
it('rejects non-HS256 algorithms', () => {
|
|
const header = { alg: 'none', typ: 'JWT' };
|
|
const h = base64Url(JSON.stringify(header));
|
|
const p = base64Url(JSON.stringify(basePayload()));
|
|
const token = `${h}.${p}.`;
|
|
expect(() => verifyImpersonationJwt(token, SECRET)).toThrow(ImpersonationJwtError);
|
|
});
|
|
|
|
it('rejects tokens with a forged signature', () => {
|
|
const token = sign(basePayload(), 'a-different-secret-that-is-also-long-enough-32');
|
|
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/signature/i);
|
|
});
|
|
|
|
it('rejects when secret is too short', () => {
|
|
const token = sign(basePayload());
|
|
expect(() => verifyImpersonationJwt(token, 'short')).toThrowError(/32 characters/);
|
|
});
|
|
|
|
it('rejects expired tokens', () => {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const token = sign(basePayload({ iat: now - 600, exp: now - 300 }));
|
|
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/expired/i);
|
|
});
|
|
|
|
it('rejects tokens with lifetime over the 300s ceiling', () => {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const token = sign(basePayload({ iat: now, exp: now + 3600 }));
|
|
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/lifetime/i);
|
|
});
|
|
|
|
it('rejects tokens with iss mismatch when expectedIssuer is set', () => {
|
|
const token = sign(basePayload({ iss: 'someone-else' }));
|
|
expect(() =>
|
|
verifyImpersonationJwt(token, SECRET, { expectedIssuer: ISSUER }),
|
|
).toThrowError(/issuer/i);
|
|
});
|
|
|
|
it("rejects mailbox containing '%'", () => {
|
|
const token = sign(basePayload({ mailbox: 'a%b@example.test' }));
|
|
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/'%'/);
|
|
});
|
|
|
|
it("rejects mailbox containing ':'", () => {
|
|
const token = sign(basePayload({ mailbox: 'a:b@example.test' }));
|
|
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/':'/);
|
|
});
|
|
|
|
it('rejects malformed tokens', () => {
|
|
expect(() => verifyImpersonationJwt('not.a.jwt.extra', SECRET)).toThrow();
|
|
expect(() => verifyImpersonationJwt('', SECRET)).toThrow();
|
|
});
|
|
|
|
it('honours nbf with skew', () => {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const token = sign(basePayload({ nbf: now + 600 }));
|
|
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/not yet valid/i);
|
|
});
|
|
});
|
|
|
|
describe('impersonationReplayCache', () => {
|
|
beforeEach(() => {
|
|
impersonationReplayCache.clear();
|
|
});
|
|
|
|
it('accepts a jti once and rejects it on second use', () => {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
expect(impersonationReplayCache.consume('jti-1', now + 60, now)).toBe(true);
|
|
expect(impersonationReplayCache.consume('jti-1', now + 60, now)).toBe(false);
|
|
});
|
|
|
|
it('prunes expired jtis on next consume', () => {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
impersonationReplayCache.consume('jti-old', now - 600, now - 600);
|
|
// Far in the future — pruning should clear the old entry.
|
|
expect(impersonationReplayCache.consume('jti-new', now + 60, now + 1000)).toBe(true);
|
|
// Re-using the old jti is allowed after pruning (security irrelevant since
|
|
// the token would fail signature/exp validation upstream).
|
|
expect(impersonationReplayCache.consume('jti-old', now + 60, now + 1000)).toBe(true);
|
|
});
|
|
});
|