Files
iios/packages/iios-service/src/platform/realtime-token.guard.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

44 lines
2.2 KiB
TypeScript

import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import jwt from 'jsonwebtoken';
import type { Request } from 'express';
/**
* Realtime delegation — the REST half.
*
* The browser's socket token is deliberately narrow: `aud = IIOS_REALTIME_AUDIENCE` (e.g.
* iios-message), minutes-long, and good for the /message socket ONLY. The gateway enforces the
* mirror of this rule (it accepts ONLY that audience). Without this guard the narrowing is
* one-directional: REST doesn't check the actor token's audience, so a leaked socket token would
* still drive privileged REST — i.e. it would be a full actor token with extra steps.
*
* Applied GLOBALLY (APP_GUARD) on purpose: every REST route is a target, not just the ones behind
* ContextAttestationGuard (inbox, media, interactions, ai, … would otherwise stay open).
*
* It is a decode-only REJECT filter, never an authenticator:
* - it does not verify signatures (each controller still calls SessionVerifier) — cheap, and it
* can't be bypassed by stripping `aud`, because that invalidates the signature downstream;
* - no bearer token → pass through (health, metrics, HMAC adapter webhooks are not its business);
* - unset IIOS_REALTIME_AUDIENCE → no-op (same switch that turns on the socket half).
*/
@Injectable()
export class RealtimeTokenRestGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
if (context.getType() !== 'http') return true; // the socket enforces its own (mirror) rule
const socketAudience = process.env.IIOS_REALTIME_AUDIENCE?.trim();
if (!socketAudience) return true;
const auth = context.switchToHttp().getRequest<Request>().headers['authorization'];
const raw = Array.isArray(auth) ? auth[0] : auth;
if (typeof raw !== 'string' || !/^Bearer\s+/i.test(raw)) return true;
const decoded = jwt.decode(raw.replace(/^Bearer\s+/i, ''), { json: true });
const aud = decoded?.aud;
const isSocketToken = aud === socketAudience || (Array.isArray(aud) && aud.includes(socketAudience));
if (isSocketToken) {
throw new ForbiddenException(`socket-scoped token (aud="${socketAudience}") cannot be used for REST`);
}
return true;
}
}