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, 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 = {}) => ({ 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'); }); });