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.
162 lines
6.2 KiB
TypeScript
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');
|
|
});
|
|
});
|