Files
hedena_gold/apps/api/src/modules/iam/iam-logout.service.spec.ts
T
s.esmaeili ea86636027 Initial commit: Hedena Gold monorepo.
First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
2026-10-15 17:19:10 +03:30

162 lines
6.2 KiB
TypeScript

import { createSign, generateKeyPairSync, type KeyObject } from 'node:crypto';
import { IamLogoutService, IamLogoutTokenError } from './iam-logout.service';
import type { IamService } from './iam.service';
/**
* Back-channel logout token verification.
*
* This endpoint is unauthenticated by design: the token's signature is the only
* thing standing between a stranger and "sign out every admin of this system".
* Each test here is one of the checks that keeps that true.
*/
describe('IamLogoutService', () => {
const ISSUER = 'https://auth.test.local';
const CLIENT_ID = 'hedena_gold_api';
const EVENT = 'http://schemas.openid.net/event/backchannel-logout';
let keyPair: { publicKey: KeyObject; privateKey: KeyObject };
let otherKeyPair: { publicKey: KeyObject; privateKey: KeyObject };
let service: IamLogoutService;
let fetchMock: jest.Mock;
const jwk = (key: KeyObject, kid: string) => ({ ...key.export({ format: 'jwk' }), kid, alg: 'RS256', use: 'sig' });
const sign = (
payload: Record<string, unknown>,
options: { kid?: string; alg?: string; key?: KeyObject } = {},
): string => {
const header = { alg: options.alg ?? 'RS256', typ: 'logout+jwt', kid: options.kid ?? 'key-1' };
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url');
const body = `${encode(header)}.${encode(payload)}`;
const signer = createSign('RSA-SHA256');
signer.update(body);
return `${body}.${signer.sign(options.key ?? keyPair.privateKey).toString('base64url')}`;
};
const validPayload = (overrides: Record<string, unknown> = {}) => ({
iss: ISSUER,
aud: CLIENT_ID,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 120,
jti: 'logout-1',
sub: 'sub-1',
sid: 'sid-1',
events: { [EVENT]: {} },
...overrides,
});
beforeAll(() => {
keyPair = generateKeyPairSync('rsa', { modulusLength: 2048 });
otherKeyPair = generateKeyPairSync('rsa', { modulusLength: 2048 });
});
beforeEach(() => {
service = new IamLogoutService({ issuer: ISSUER, clientId: CLIENT_ID } as IamService);
fetchMock = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ keys: [jwk(keyPair.publicKey, 'key-1')] }),
});
global.fetch = fetchMock as unknown as typeof fetch;
});
/* ─────────────── the happy path ─────────────── */
it('accepts a properly signed logout token and reports the session', async () => {
const result = await service.verify(sign(validPayload()));
expect(result).toEqual({ sessionId: 'sid-1', subject: 'sub-1' });
expect(fetchMock.mock.calls[0][0]).toBe(`${ISSUER}/.well-known/jwks.json`);
});
it('accepts a token that names only the identity', async () => {
const result = await service.verify(sign(validPayload({ sid: undefined })));
expect(result).toEqual({ sessionId: null, subject: 'sub-1' });
});
it('caches the keys instead of fetching them for every notification', async () => {
await service.verify(sign(validPayload()));
await service.verify(sign(validPayload({ jti: 'logout-2' })));
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('refetches the keys when an unknown key id appears, so rotation needs no restart', async () => {
await service.verify(sign(validPayload()));
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ keys: [jwk(otherKeyPair.publicKey, 'key-2')] }),
});
const rotated = sign(validPayload({ jti: 'logout-3' }), { kid: 'key-2', key: otherKeyPair.privateKey });
await expect(service.verify(rotated)).resolves.toMatchObject({ sessionId: 'sid-1' });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
/* ─────────────── what must be refused ─────────────── */
it('refuses a token signed with an unknown key', async () => {
const forged = sign(validPayload(), { key: otherKeyPair.privateKey });
await expect(service.verify(forged)).rejects.toBeInstanceOf(IamLogoutTokenError);
});
it('refuses an unsigned token however it describes itself', async () => {
const parts = sign(validPayload()).split('.');
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'logout+jwt' })).toString('base64url');
await expect(service.verify(`${header}.${parts[1]}.`)).rejects.toBeInstanceOf(IamLogoutTokenError);
});
it('refuses a token from another issuer', async () => {
await expect(
service.verify(sign(validPayload({ iss: 'https://evil.test.local' }))),
).rejects.toThrow('another identity service');
});
it('refuses a token addressed to another client', async () => {
await expect(service.verify(sign(validPayload({ aud: 'someone_else' })))).rejects.toThrow(
'not addressed to this client',
);
});
it('refuses an ID token replayed as a logout token', async () => {
// Without the events check, an ID token from a normal login would end sessions
await expect(service.verify(sign(validPayload({ events: undefined })))).rejects.toThrow(
'back-channel logout event',
);
});
it('refuses a logout token carrying a nonce', async () => {
await expect(service.verify(sign(validPayload({ nonce: 'n-1' })))).rejects.toThrow('nonce');
});
it('refuses an expired token', async () => {
const stale = validPayload({
iat: Math.floor(Date.now() / 1000) - 3600,
exp: Math.floor(Date.now() / 1000) - 3000,
});
await expect(service.verify(sign(stale))).rejects.toThrow('expired');
});
it('refuses a token that names neither a session nor an identity', async () => {
await expect(
service.verify(sign(validPayload({ sid: undefined, sub: undefined }))),
).rejects.toThrow('neither a session nor a subject');
});
it('refuses a malformed token', async () => {
await expect(service.verify('not-a-token')).rejects.toBeInstanceOf(IamLogoutTokenError);
});
it('reports an unreachable identity service as an availability problem', async () => {
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
await expect(service.verify(sign(validPayload()))).rejects.toThrow('could not reach');
});
});