Feat/s3 storage #2
@@ -1,10 +1,19 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { PLATFORM_PORTS, LocalDevPorts } from './platform-ports';
|
||||
import { RealtimeTokenRestGuard } from './realtime-token.guard';
|
||||
|
||||
/** Binds the platform ports (P1: the permissive in-service default). */
|
||||
/**
|
||||
* Binds the platform ports (P1: the permissive in-service default), and registers the
|
||||
* realtime-delegation REST guard globally — a socket-scoped token must not drive REST on ANY
|
||||
* route, so it can't be per-controller (see realtime-token.guard).
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [{ provide: PLATFORM_PORTS, useClass: LocalDevPorts }],
|
||||
providers: [
|
||||
{ provide: PLATFORM_PORTS, useClass: LocalDevPorts },
|
||||
{ provide: APP_GUARD, useClass: RealtimeTokenRestGuard },
|
||||
],
|
||||
exports: [PLATFORM_PORTS],
|
||||
})
|
||||
export class PlatformModule {}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user