Skip to content

Add a backend ​

Your first module step 5 shows the shape of a backend route — @ModuleCapability, @RequiresLicense — but not how to secure it. This page is the rest of it: a minimal NestJS backend that verifies the signed-in user's token, accepts calls from your sandbox shell, and calls tv-api with that same token. Budget: 20 minutes.

The SDK scaffolds a frontend only; init-module has no backend option today. There is no scaffold to check this against, so instead every snippet below is checked directly: it type-checks under TypeScript 5.8 with NestJS 11.2.1 against the published @tv/extension-sdk 1.15.1, and the guard is the same shape already running in production for several modules (tv-module-service-desk, tv-module-checklists and others) — copied and trimmed, not written from memory. @tv/extension-sdk/nestjs ships no token-verification helper of its own; this is what to write instead.

1. Install ​

bash
pnpm add @nestjs/common @nestjs/core @nestjs/platform-express reflect-metadata rxjs
pnpm add -D typescript @types/express

@tv/extension-sdk is already a dependency from Your first module; this reuses its @ModuleCapability and @RequiresLicense decorators.

A tsconfig.json gotcha

@tv/extension-sdk/nestjs is a subpath export, and TypeScript's node module resolution can't see it without help:

json
{
  "compilerOptions": {
    "moduleResolution": "node",
    "baseUrl": "./",
    "paths": {
      "@tv/extension-sdk/nestjs": ["node_modules/@tv/extension-sdk/dist/nestjs/index.d.ts"]
    }
  }
}

Every module backend that imports it carries this same paths entry. Without it: Cannot find module '@tv/extension-sdk/nestjs'.

2. Verify the caller's token ​

Your sandbox authenticates through one shared Keycloak realm, sandboxes (see On-demand sandboxes). Your backend never sees the shell's session — it only sees whatever Authorization header the frontend sends it — so it must verify that token itself: signature, algorithm and issuer, against the realm's own keys.

ts
// src/jwt-auth.guard.ts
import {
  CanActivate,
  ExecutionContext,
  Injectable,
  Logger,
  UnauthorizedException,
} from '@nestjs/common';
import { createPublicKey, createVerify, type KeyObject } from 'node:crypto';

/** Keycloak signs realm tokens RS256. Anything else is refused outright. */
const ALLOWED_ALG = 'RS256';

/** Floor between JWKS refetches, so an unknown `kid` can't be used to hammer the realm. */
const REFETCH_COOLDOWN_MS = 10_000;

/** Tolerance for clock drift between this process and Keycloak. */
const CLOCK_SKEW_SEC = 30;

export interface VerifiedCaller {
  sub?: string;
  iss?: string;
  exp?: number;
  nbf?: number;
  tenant_id?: string;
  realm_access?: { roles?: string[] };
  [claim: string]: unknown;
}

interface Jwk {
  kid?: string;
  kty?: string;
  alg?: string;
  use?: string;
}

function decodeSegment(segment: string): unknown {
  return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8'));
}

export class TokenInvalidError extends Error {}

/** Fetches the realm's public keys and verifies RS256 JWTs against them. */
export class JwksVerifier {
  private keys = new Map<string, KeyObject>();
  private lastFetch = 0;
  private inFlight: Promise<void> | undefined;

  constructor(
    private readonly jwksUri: string,
    private readonly issuer: string,
    private readonly fetchImpl: typeof fetch = fetch,
    private readonly now: () => number = Date.now,
  ) {}

  async verify(token: string): Promise<VerifiedCaller> {
    const parts = token.split('.');
    if (parts.length !== 3) throw new TokenInvalidError('malformed token');
    const [rawHeader, rawPayload, rawSignature] = parts;

    let header: { alg?: string; kid?: string };
    try {
      header = decodeSegment(rawHeader) as { alg?: string; kid?: string };
    } catch {
      throw new TokenInvalidError('unreadable header');
    }

    // Checked before any key lookup: this is the alg-confusion guard, and
    // `none` must never reach the verify step.
    if (header.alg !== ALLOWED_ALG) {
      throw new TokenInvalidError(`unsupported alg ${header.alg ?? '<none>'}`);
    }
    if (!header.kid) throw new TokenInvalidError('no kid');

    const key = await this.keyFor(header.kid);

    const ok = createVerify('RSA-SHA256')
      .update(`${rawHeader}.${rawPayload}`)
      .verify(key, Buffer.from(rawSignature, 'base64url'));
    if (!ok) throw new TokenInvalidError('bad signature');

    let payload: VerifiedCaller;
    try {
      payload = decodeSegment(rawPayload) as VerifiedCaller;
    } catch {
      throw new TokenInvalidError('unreadable payload');
    }

    // Only meaningful after the signature checks out.
    if (payload.iss !== this.issuer) {
      throw new TokenInvalidError('wrong issuer');
    }
    const nowSec = Math.floor(this.now() / 1000);
    if (typeof payload.exp === 'number' && payload.exp + CLOCK_SKEW_SEC < nowSec) {
      throw new TokenInvalidError('expired');
    }
    if (typeof payload.nbf === 'number' && payload.nbf - CLOCK_SKEW_SEC > nowSec) {
      throw new TokenInvalidError('not yet valid');
    }
    // No `aud` check: Keycloak stamps it per client, so pinning one would
    // reject legitimate callers the next time a client is added to the
    // realm. Issuer + signature is what establishes "minted by our realm".

    return payload;
  }

  private async keyFor(kid: string): Promise<KeyObject> {
    const cached = this.keys.get(kid);
    if (cached) return cached;
    await this.refresh();
    const key = this.keys.get(kid);
    if (!key) throw new TokenInvalidError(`unknown kid ${kid}`);
    return key;
  }

  /** Refetches the key set, coalescing concurrent callers and rate-limiting. */
  private async refresh(): Promise<void> {
    if (this.inFlight) return this.inFlight;
    if (this.now() - this.lastFetch < REFETCH_COOLDOWN_MS) return;

    this.inFlight = (async () => {
      try {
        const response = await this.fetchImpl(this.jwksUri);
        if (!response.ok) {
          throw new TokenInvalidError(`JWKS fetch failed: ${response.status}`);
        }
        const body = (await response.json()) as { keys?: Jwk[] };
        const next = new Map<string, KeyObject>();
        for (const jwk of body.keys ?? []) {
          if (!jwk.kid || jwk.kty !== 'RSA') continue;
          if (jwk.use && jwk.use !== 'sig') continue;
          if (jwk.alg && jwk.alg !== ALLOWED_ALG) continue;
          try {
            next.set(jwk.kid, createPublicKey({ key: jwk as never, format: 'jwk' }));
          } catch {
            // A key we cannot import is skipped rather than failing the
            // whole set — the realm may publish keys for algorithms we
            // don't accept.
          }
        }
        this.keys = next;
        this.lastFetch = this.now();
      } finally {
        this.inFlight = undefined;
      }
    })();

    return this.inFlight;
  }
}

let verifier: JwksVerifier | undefined | null;

/** Built on first use so a missing env var surfaces as 401, not a boot crash. */
function getVerifier(): JwksVerifier | null {
  if (verifier !== undefined) return verifier;
  const jwksUri = process.env.KEYCLOAK_JWKS_URI;
  const issuer = process.env.KEYCLOAK_ISSUER;
  verifier = jwksUri && issuer ? new JwksVerifier(jwksUri, issuer) : null;
  return verifier;
}

/**
 * Fails CLOSED: if the JWKS URI or issuer is unset, or the realm is
 * unreachable, every guarded request is rejected. A backend that is
 * unusable during a Keycloak outage is the correct failure here; the
 * alternative silently reopens the hole this guard exists to close.
 */
@Injectable()
export class JwtAuthGuard implements CanActivate {
  private readonly logger = new Logger(JwtAuthGuard.name);

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();

    const jwks = getVerifier();
    if (!jwks) {
      this.logger.error(
        'KEYCLOAK_JWKS_URI / KEYCLOAK_ISSUER are unset — rejecting every request.',
      );
      throw new UnauthorizedException('Backend is not configured for token verification');
    }

    const authHeader: string | undefined = request?.headers?.authorization;
    if (!authHeader?.startsWith('Bearer ')) {
      throw new UnauthorizedException('Sign-in required');
    }

    try {
      request.user = await jwks.verify(authHeader.slice(7));
      return true;
    } catch (error) {
      this.logger.warn(
        `Token rejected: ${error instanceof Error ? error.message : 'unknown error'}`,
      );
      throw new UnauthorizedException('Invalid token');
    }
  }
}

Two env vars, both fixed values for every sandbox — they name the realm, not your tenant:

VariableValue
KEYCLOAK_ISSUERhttps://auth.k8s.tangovision.dev/realms/sandboxes
KEYCLOAK_JWKS_URIhttps://auth.k8s.tangovision.dev/realms/sandboxes/protocol/openid-connect/certs

This does not separate your sandbox from others

The sandboxes realm is shared, and — as of 2026-09-26 — Keycloak does not yet enforce that a login belongs to your sandbox's own tenant_id. A verified token proves it was minted by our realm, not that it belongs to your organisation. See the warning in On-demand sandboxes — this is the same gap. Keep synthetic data only, as that page asks.

3. Wire it into a route ​

ts
// src/hello.controller.ts
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { ModuleCapability, RequiresLicense } from '@tv/extension-sdk/nestjs';
import { JwtAuthGuard, VerifiedCaller } from './jwt-auth.guard';

interface AuthedRequest extends Request {
  user?: VerifiedCaller;
}

@ModuleCapability({ id: 'hello.greeting', version: '1.0.0' })
@Controller('api/v1/buildings/:buildingId/hello')
@UseGuards(JwtAuthGuard)
export class HelloController {
  // @RequiresLicense() is metadata only — see the warning below. It does
  // not stand in for @UseGuards(JwtAuthGuard) above.
  @RequiresLicense('@acme/module-hello')
  @Get()
  greet(@Req() req: AuthedRequest) {
    return { message: `hello, ${req.user?.sub ?? 'stranger'}` };
  }
}
ts
// src/health.controller.ts
import { Controller, Get } from '@nestjs/common';

@Controller()
export class HealthController {
  @Get('health')
  health(): { status: string; timestamp: string } {
    return { status: 'ok', timestamp: new Date().toISOString() };
  }
}
ts
// src/app.module.ts
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { HelloController } from './hello.controller';

@Module({ controllers: [HealthController, HelloController] })
export class AppModule {}

@RequiresLicense() does not enforce anything

Same warning as Your first module: it only records, as metadata, which module's licence a route needs. Nothing checks it when a request arrives — JwtAuthGuard above is what answers "who is this caller"; a licence check is a separate thing you would still have to write.

4. Accept calls from your sandbox shell ​

Your local backend and the sandbox shell are different origins — the shell is https://<slug>.sandbox.k8s.tangovision.dev, your backend is http://localhost:<port> — and the shell's policy lets your frontend call that loopback port directly (see The rules for loading a module from localhost, rule 9). The browser sends your Authorization header on that cross-origin call, so CORS needs an explicit origin, not a wildcard:

ts
// src/main.ts
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.enableCors({
    origin: process.env.CORS_ORIGINS?.split(',') ?? [],
    credentials: true,
  });

  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
bash
CORS_ORIGINS=https://<slug>.sandbox.k8s.tangovision.dev

<slug> is the subdomain of your sandbox's apiUrl — the same one from On-demand sandboxes.

5. Call tv-api with the user's token ​

Your backend holds no credential of its own for reading tenant data. Forward the same Authorization header the caller sent you — verified in step 2 — rather than reaching for sandbox connect's administrator token, which is for scripts and seeding, never for answering a specific user's request:

ts
async function fetchSpaces(buildingId: string, authorization: string) {
  const apiUrl = process.env.SANDBOX_API_URL; // https://<slug>.sandbox.k8s.tangovision.dev
  const res = await fetch(
    `${apiUrl}/api/v1/buildings/${encodeURIComponent(buildingId)}/spaces`,
    { headers: { authorization } },
  );
  if (!res.ok) throw new Error(`tv-api answered ${res.status} for spaces`);
  return res.json();
}

SANDBOX_API_URL is your sandbox's apiUrl again — the same host as CORS_ORIGINS above, without the scheme change. This mirrors what your module's frontend does with authorizationHeader() in Calling the API from a module; the difference is that here your own code reads the header instead of the SDK reading it for you, because a NestJS request is not the browser fetch the SDK helper wraps.

A 401 or 403 from tv-api at this point means something is wrong with your deployment (clock skew, the wrong SANDBOX_API_URL), not with this specific caller: your JwtAuthGuard accepted the same token moments ago. Treat it as a fault, not as "this user is unauthorized."

What this does not cover ​

  • MCP endpoints (tools Copilot calls) use a different, mandatory scheme — an HMAC signature or a realm JWT rewritten into the MCP context, never this guard as-is. See Authenticating your MCP endpoint.
  • Scoping data to one tenant, building or occupant beyond "is this a valid token" is your module's own concern once the caller is verified — this page stops at authentication.
  • Your backend is not deployed by any sandbox command. It runs on your machine and reaches the sandbox's tv-api over the network like any other client — see What to expect, and what not to.

Built on the Tango Vision platform. Questions? developers@tango.vision