← назад к разделу

Когда клиент отправляет запрос с токеном, сервис должен убедиться: этот токен настоящий, не просрочен и выдан именно тем центром авторизации, которому мы доверяем. Один пропущенный шаг — и злоумышленник подделывает токен, объявляет себя администратором и делает всё что хочет.

В NestJS эту задачу решает готовая связка библиотек: passport-jwt + jwks-rsa. Весь криптографический код уже написан и отлажен — вам остаётся только собрать конфигурацию правильно.

Что такое JWT и почему его надо проверять тщательно

JWT (JSON Web Token) — это строка из трёх частей: заголовок, тело с данными о пользователе, и цифровая подпись. Подпись создаётся центром авторизации (Identity Provider, IdP) его закрытым ключом. Ваш сервис проверяет подпись открытым ключом — если проверка прошла, значит токен не подделан.

Проблема в том, что «проверить токен» — это не один шаг, а несколько:

  • Проверить подпись — токен не изменён и подписан нужным ключом.
  • Проверить exp — токен не просрочен.
  • Проверить iss — токен выдан именно нашим IdP, а не чужим.
  • Проверить aud — токен предназначен именно этому сервису.

Пропустить любой из этих шагов — значит открыть уязвимость. Именно поэтому писать проверку токена самостоятельно опасно: легко забыть что-то одно.

JwtStrategy: как это работает в NestJS

NestJS использует библиотеку Passport для аутентификации. Для JWT есть готовая стратегия — passport-jwt. А для загрузки открытых ключей из IdP — jwks-rsa.

Вот как выглядит стандартная конфигурация:

// adapters/in/http/security/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { passportJwtSecret } from 'jwks-rsa';
import { AppConfig } from '../../../../config/app.config';
import { Principal } from '../../../../core/domain/principal';
import { JwtClaims } from './jwt-claims';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(config: AppConfig) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      algorithms: ['RS256'],
      audience: config.auth.audience,
      issuer: config.auth.issuer,
      secretOrKeyProvider: passportJwtSecret({
        jwksUri: config.auth.jwksUri,
        cache: true,
        cacheMaxAge: 300_000,   // кеш на 5 минут
        rateLimit: true,
      }),
    });
  }

  validate(claims: JwtClaims): Principal {
    // сюда попадаем только если подпись, exp, iss, aud уже проверены
    return {
      sub: claims.sub,
      roles: extractRoles(claims),
    };
  }
}

Что происходит при каждом запросе:

  1. passport-jwt извлекает токен из заголовка Authorization: Bearer <token>.
  2. jwks-rsa загружает открытые ключи IdP по адресу jwksUri (из кеша, если они там уже есть).
  3. Проверяется подпись, exp, iss, aud.
  4. Только если всё в порядке — вызывается ваш метод validate().

Метод validate() получает уже проверенные данные. Дублировать в нём проверку подписи или сроков не нужно.

Почему нельзя писать проверку вручную

Иногда разработчики пишут собственный Guard с jwt.decode(). Выглядит просто, но в нём скрыто несколько критических ошибок:

// НЕПРАВИЛЬНО — так делать нельзя
const token = req.headers.authorization?.split(' ')[1];
const decoded = jwt.decode(token);   // decode не проверяет подпись!
if (decoded.exp < Date.now() / 1000) return false;
req.user = decoded;
return true;

Что здесь сломано:

  • jwt.decode() только парсит токен, но не проверяет подпись. Злоумышленник подписывает токен своим ключом — и он проходит.
  • Нет проверки iss — токен от другого IdP принят.
  • Нет проверки aud — токен другого сервиса прошёл.
  • Нет учёта погрешности часов (clock skew) — у клиентов с часами ±30 секунд exp ломается.
  • Уязвимость alg: none — принят токен без подписи вообще.

Каждая из этих ошибок — серьёзная уязвимость. passport-jwt + jwks-rsa уже решили все эти проблемы в коде, который проходил аудит безопасности.

Кеш открытых ключей

IdP публикует открытые ключи в формате JWK Set по стандартному адресу. При каждой проверке токена тянуть ключи напрямую с IdP — плохая идея: это лишняя задержка на каждый запрос и нагрузка на IdP.

jwks-rsa автоматически кеширует ключи. Настройка cacheMaxAge: 300_000 держит их в кеше пять минут. Если IdP сменил ключи (ротация), а в кеше ещё старые — jwks-rsa заметит незнакомый kid в токене и запросит свежие ключи автоматически. rateLimit: true защищает от ситуации, когда злоумышленник специально шлёт запросы с неизвестными kid, чтобы перегрузить IdP.

Хранить открытый ключ в переменной окружения или файле конфигурации не нужно — jwks-rsa загружает его сам по jwksUri. Это также означает, что ротация ключей у IdP не требует перезапуска сервиса.

Guards: аутентификация и авторизация — разные шаги

В NestJS проверка происходит в два слоя:

JwtAuthGuard — проверяет, что токен настоящий. Если нет — возвращает 401.
RolesGuard — проверяет, что у пользователя есть нужная роль. Если нет — возвращает 403.

Порядок важен: сначала аутентификация, потом авторизация.

// adapters/in/http/security/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private readonly reflector: Reflector) {
    super();
  }

  canActivate(ctx: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      ctx.getHandler(),
      ctx.getClass(),
    ]);
    if (isPublic) return true;
    return super.canActivate(ctx);
  }
}
// adapters/in/http/security/roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(ctx: ExecutionContext): boolean {
    const required = this.reflector.get(Roles, ctx.getHandler());
    if (!required) throw new ForbiddenException();
    const { user } = ctx.switchToHttp().getRequest<{ user: Principal }>();
    if (!required.some((r) => user.roles.includes(r))) {
      throw new ForbiddenException();
    }
    return true;
  }
}

Оба Guard регистрируются глобально в app.module.ts:

providers: [
  { provide: APP_GUARD, useClass: JwtAuthGuard },   // 401 — аутентификация
  { provide: APP_GUARD, useClass: RolesGuard },      // 403 — авторизация
],

Публичные маршруты — например, GET /health — помечаются декоратором @Public(). Без него Guard будет требовать токен на каждом запросе.

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

В чём разница между 401 и 403

Это частая путаница, которая ломает клиентские приложения.

401 Unauthorized — токен невалидный. Либо подпись неверна, либо токен просрочен, либо заголовок Authorization вообще отсутствует. Клиент должен обновить токен (через refresh-token flow) или перенаправить пользователя на страницу входа.

403 Forbidden — токен валидный, пользователь известен, но у него нет прав на это действие. Обновлять токен бесполезно — роль не изменится. Клиент должен показать сообщение «доступ запрещён».

Типичная ошибка — переопределить handleRequest и возвращать 403 вместо 401:

// НЕПРАВИЛЬНО
handleRequest(err: unknown, user: Principal) {
  if (err || !user) throw new ForbiddenException();   // маскирует 401 как 403
}

Клиент видит 403 на просроченном токене, думает «нет прав», не делает refresh, зависает. Правильное поведение — оставить JwtAuthGuard как есть: он сам кидает UnauthorizedException (401) при проблемах с токеном.

Роли из JWT-токена

Роли пользователя живут внутри JWT в виде claims. Keycloak кладёт их в realm_access.roles, OAuth2 — в поле scope. Маппинг в объект Principal делается один раз — в методе validate() стратегии:

// adapters/in/http/security/jwt-claims.ts
export interface JwtClaims {
  sub: string;
  realm_access?: { roles: string[] };   // Keycloak
  scope?: string;                        // OAuth2 scope
}

// adapters/in/http/security/extract-roles.ts
const ALLOWED_ROLES = new Set(['customer', 'seller', 'admin', 'system']);

export function extractRoles(claims: JwtClaims): string[] {
  const raw = claims.realm_access?.roles ?? claims.scope?.split(' ') ?? [];
  return raw.filter((r) => ALLOWED_ROLES.has(r));
}

На конкретных маршрутах роли указываются через декоратор @Roles:

@Controller('orders')
export class OrderController {
  @Post()
  @Roles(['customer', 'seller'])
  async createOrder(@Body() dto: CreateOrderDto, @Req() req: Request & { user: Principal }) {
    return this.createOrderUseCase.execute(dto, req.user);
  }

  @Delete(':id')
  @Roles(['customer', 'admin'])
  async cancelOrder(@Param('id') id: string, @Req() req: Request & { user: Principal }) {
    return this.cancelOrderUseCase.execute(id, req.user);
  }

  @Get('health')
  @Public()
  healthCheck(): string {
    return 'ok';
  }
}

Каждый маршрут должен явно объявлять либо @Roles(...), либо @Public(). Маршрут без обоих декораторов пройдёт аутентификацию, но упадёт на авторизации — это полезное ограничение: забытый маршрут не окажется случайно открытым.

Коротко

  • JWT содержит цифровую подпись — её нужно проверять с помощью открытого ключа IdP. jwt.decode() подпись не проверяет.
  • passport-jwt + jwks-rsa проверяют подпись, срок действия, издателя и аудиторию — всё автоматически.
  • Открытые ключи IdP хранятся в кеше (cache: true, cacheMaxAge: 300_000) и обновляются при ротации без перезапуска сервиса.
  • В validate() попадают уже проверенные данные — дублировать криптологику там не нужно.
  • JwtAuthGuard даёт 401 (невалидный токен), RolesGuard даёт 403 (нет прав). Путать их нельзя — у клиента разные сценарии реакции.
  • Каждый маршрут явно помечается @Roles(...) или @Public().

Что почитать дальше