Skip to content

Добавьте бэкенд ​

Шаг 5 в разделе Ваш первый модуль показывает форму маршрута бэкенда — @ModuleCapability, @RequiresLicense — но не то, как его защитить. Эта страница — недостающая часть: минимальный бэкенд на NestJS, который проверяет токен вошедшего пользователя, принимает запросы из оболочки вашей песочницы и вызывает tv-api с этим же токеном. Бюджет: 20 минут.

SDK генерирует только фронтенд; у init-module сегодня нет опции для бэкенда. Каркаса, на котором это можно было бы проверить, не существует, поэтому каждый фрагмент ниже проверен напрямую: он проходит проверку типов под TypeScript 5.8 с NestJS 11.2.1 против опубликованного @tv/extension-sdk1.15.1, а guard — та же форма, что уже работает в продакшене у нескольких модулей (tv-module-service-desk, tv-module-checklists и другие) — скопирована и сокращена, а не написана по памяти. @tv/extension-sdk/nestjs не поставляет собственного помощника для проверки токена — вот что написать вместо него.

1. Установите зависимости ​

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

@tv/extension-sdk уже есть в зависимостях из Вашего первого модуля; здесь используются те же декораторы @ModuleCapability и @RequiresLicense.

Ловушка в tsconfig.json

@tv/extension-sdk/nestjs — это subpath-экспорт, и node-разрешение модулей в TypeScript не видит его без подсказки:

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

Эта запись paths есть в каждом бэкенде модуля, который его импортирует. Без неё: Cannot find module '@tv/extension-sdk/nestjs'.

2. Проверьте токен вызывающего ​

Ваша песочница аутентифицируется через один общий realm Keycloak, sandboxes (см. Песочницы по запросу). Ваш бэкенд никогда не видит сессию оболочки — он видит только тот заголовок Authorization, который ему прислал фронтенд, — поэтому обязан проверить токен сам: подпись, алгоритм и издателя (issuer), по собственным ключам realm'а.

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

/** Keycloak подписывает токены realm'а RS256. Всё остальное отклоняется сразу. */
const ALLOWED_ALG = 'RS256';

/** Минимальный интервал между перезапросами JWKS, чтобы неизвестный `kid` нельзя было использовать для нагрузки на realm. */
const REFETCH_COOLDOWN_MS = 10_000;

/** Допуск на рассинхронизацию часов между этим процессом и 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 {}

/** Забирает публичные ключи realm'а и проверяет по ним RS256-токены. */
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');
    }

    // Проверяется до поиска ключа: это защита от alg-confusion, и
    // `none` не должен дойти до шага проверки.
    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');
    }

    // Имеет смысл только после того, как подпись сошлась.
    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');
    }
    // Без проверки `aud`: Keycloak штампует его отдельно для каждого
    // клиента, и жёсткая привязка к одному отклоняла бы легитимных
    // вызывающих при добавлении в realm нового клиента. Issuer и подпись —
    // вот что устанавливает «выпущено нашим 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;
  }

  /** Перезапрашивает набор ключей, объединяя параллельных вызывающих и ограничивая частоту. */
  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 {
            // Ключ, который не получилось импортировать, пропускается, а не
            // валит весь набор целиком — realm может публиковать ключи и
            // для алгоритмов, которые мы не принимаем.
          }
        }
        this.keys = next;
        this.lastFetch = this.now();
      } finally {
        this.inFlight = undefined;
      }
    })();

    return this.inFlight;
  }
}

let verifier: JwksVerifier | undefined | null;

/** Строится при первом обращении, чтобы отсутствующая переменная окружения проявлялась как 401, а не как падение при старте. */
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;
}

/**
 * Отказывает ЗАКРЫТО: если не заданы JWKS URI или issuer, либо realm
 * недоступен, отклоняется каждый защищённый запрос. Бэкенд, неработающий во
 * время простоя Keycloak, — правильный отказ здесь; альтернатива молча
 * открывает ту самую дыру, для закрытия которой существует этот guard.
 */
@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');
    }
  }
}

Две переменные окружения, у обеих фиксированное значение для любой песочницы — они называют realm, а не ваш тенант:

ПеременнаяЗначение
KEYCLOAK_ISSUERhttps://auth.k8s.tangovision.dev/realms/sandboxes
KEYCLOAK_JWKS_URIhttps://auth.k8s.tangovision.dev/realms/sandboxes/protocol/openid-connect/certs

Это не отделяет вашу песочницу от чужих

Realm sandboxes общий, и — по состоянию на 26.09.2026 — Keycloak пока не проверяет, что вход принадлежит tenant_id именно вашей песочницы. Проверенный токен доказывает, что он выпущен нашим realm'ом, а не то, что он принадлежит вашей организации. См. предупреждение в разделе Песочницы по запросу — это тот же самый пробел. Держите в песочнице только синтетические данные, как просит эта страница.

3. Подключите к маршруту ​

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() — это только метаданные, см. предупреждение ниже.
  // Он не заменяет @UseGuards(JwtAuthGuard) выше.
  @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() ничего не проверяет

То же предупреждение, что в разделе Ваш первый модуль: декоратор только записывает в метаданные, лицензия какого модуля нужна маршруту. При поступлении запроса это никто не проверяет — на вопрос «кто этот вызывающий» отвечает JwtAuthGuard выше; проверку лицензии всё равно пришлось бы писать отдельно.

4. Принимайте запросы из оболочки песочницы ​

Ваш локальный бэкенд и оболочка песочницы — разные origin'ы: оболочка на https://<slug>.sandbox.k8s.tangovision.dev, ваш бэкенд на http://localhost:<порт>, — а политика оболочки разрешает фронтенду обращаться к этому loopback-порту напрямую (см. Правила загрузки модуля с localhost, правило 9). Браузер отправляет ваш заголовок Authorization и при вызове с другого origin, поэтому CORS нужен с явным origin, а не с 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> — это поддомен из apiUrl вашей песочницы, тот же, что в разделе Песочницы по запросу.

5. Вызывайте tv-api с токеном пользователя ​

У вашего бэкенда нет собственных прав на чтение данных тенанта. Пересылайте тот же заголовок Authorization, что прислал вызывающий, — проверенный на шаге 2, — а не тянитесь за administrator-токеном из sandbox connect: он для скриптов и заполнения данных, а не для ответа на запрос конкретного пользователя:

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 — это тот же apiUrl вашей песочницы, тот же хост, что и в CORS_ORIGINS выше, без смены схемы. Это зеркалит то, что делает фронтенд вашего модуля через authorizationHeader() в разделе Вызов API из модуля; разница в том, что здесь заголовок читает ваш собственный код, а не SDK, потому что запрос NestJS — не тот fetch браузера, который оборачивает помощник SDK.

401 или 403 от tv-api на этом шаге означает, что что-то не так с вашим развёртыванием (рассинхронизация часов, неверный SANDBOX_API_URL), а не с конкретным вызывающим: ваш JwtAuthGuard только что принял этот же токен. Относитесь к этому как к сбою, а не как к «этому пользователю нельзя».

Что эта страница не покрывает ​

  • MCP-эндпоинты (инструменты, которые вызывает Copilot) используют другую, обязательную схему — HMAC-подпись или realm-JWT, переписанный в контекст MCP, а не этот guard как есть. См. Аутентификация вашего MCP-эндпоинта.
  • Ограничение данных одним тенантом, зданием или жителем сверх «токен валиден» — это забота вашего модуля уже после проверки вызывающего; эта страница останавливается на аутентификации.
  • Ваш бэкенд не разворачивается никакой командой песочницы. Он работает на вашей машине и обращается к tv-api песочницы по сети, как любой другой клиент, — см. Чего ожидать, а чего нет.

Создано на платформе Tango Vision. Вопросы? developers@tango.vision