Files
iios/packages/iios-service/src/platform/realtime-token.guard.spec.ts
T
maaz519 6dc9e4ffee feat: reject socket-scoped tokens on REST (the other half of realtime delegation)
The delegated socket token (aud=IIOS_REALTIME_AUDIENCE) was only narrow in one direction: the
gateway accepts ONLY that audience, but REST never checked the actor token's audience — so a
leaked browser socket token still drove privileged REST, i.e. a full actor token with extra steps.

RealtimeTokenRestGuard closes it, registered GLOBALLY (APP_GUARD) because every REST route is a
target — only 2 of 15 controllers sit behind ContextAttestationGuard, so inbox/media/interactions/
ai/... would otherwise stay open.

It is a decode-only REJECT filter, never an authenticator:
- does not verify signatures (controllers still call SessionVerifier); stripping `aud` to bypass it
  invalidates the signature downstream,
- no bearer -> pass through (health, metrics, HMAC adapter webhooks),
- ws context -> pass through (the gateway enforces the mirror rule),
- unset IIOS_REALTIME_AUDIENCE -> no-op, the same switch that turns on the socket half.

So one env now enables the whole boundary: socket accepts only iios-message, REST refuses it.
8 tests; typecheck + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 20:29:59 +05:30

69 lines
3.1 KiB
TypeScript

import { describe, it, expect, afterEach } from 'vitest';
import { ForbiddenException, type ExecutionContext } from '@nestjs/common';
import jwt from 'jsonwebtoken';
import { RealtimeTokenRestGuard } from './realtime-token.guard';
// Realtime delegation, REST half: a socket-scoped token (aud=iios-message) opens the /message
// socket and NOTHING else. The gateway enforces the mirror rule; this guard is the REST side.
const SECRET = 'dev-crm-secret';
const token = (payload: Record<string, unknown>) => jwt.sign(payload, SECRET, { algorithm: 'HS256', expiresIn: '5m' });
/** An HTTP ExecutionContext carrying the given authorization header. */
function httpCtx(authorization?: string): ExecutionContext {
return {
getType: () => 'http',
switchToHttp: () => ({ getRequest: () => ({ headers: authorization ? { authorization } : {} }) }),
} as unknown as ExecutionContext;
}
const wsCtx = () => ({ getType: () => 'ws' }) as unknown as ExecutionContext;
const guard = new RealtimeTokenRestGuard();
describe('RealtimeTokenRestGuard (socket tokens must not drive REST)', () => {
afterEach(() => { delete process.env.IIOS_REALTIME_AUDIENCE; });
it('rejects a socket-scoped token (aud=iios-message) on REST', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
const ctx = httpCtx(`Bearer ${token({ sub: 'pp_1', appId: 'crm-web', aud: 'iios-message' })}`);
expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException);
});
it('allows a normal actor token (no aud)', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
expect(guard.canActivate(httpCtx(`Bearer ${token({ sub: 'pp_1', appId: 'crm-web' })}`))).toBe(true);
});
it('allows a REST-audience token (aud=iios-core)', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
expect(guard.canActivate(httpCtx(`Bearer ${token({ sub: 'pp_1', aud: 'iios-core' })}`))).toBe(true);
});
it('rejects when the socket audience is one of several in an aud array', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
const ctx = httpCtx(`Bearer ${token({ sub: 'pp_1', aud: ['iios-core', 'iios-message'] })}`);
expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException);
});
it('is a no-op when IIOS_REALTIME_AUDIENCE is unset (same switch as the socket half)', () => {
const ctx = httpCtx(`Bearer ${token({ sub: 'pp_1', aud: 'iios-message' })}`);
expect(guard.canActivate(ctx)).toBe(true);
});
it('passes through unauthenticated routes (health, metrics, HMAC adapter webhooks)', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
expect(guard.canActivate(httpCtx())).toBe(true);
expect(guard.canActivate(httpCtx('Hmac abc123'))).toBe(true); // non-bearer scheme
});
it('never applies to the socket itself (ws context)', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
expect(guard.canActivate(wsCtx())).toBe(true);
});
it('passes a malformed bearer through (auth is the controller\'s job, not this filter\'s)', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
expect(guard.canActivate(httpCtx('Bearer not-a-jwt'))).toBe(true);
});
});