Опирается на правила: AUTH-4AUTH-6 из Auth Patterns Style Guide → раздел 2. JWT validation.

Важно знать

  • JwtStrategy через passport-jwt + jwks-rsa — стандарт NestJS. Самописный jwt.decode() без проверки подписи — запрещён.
  • JWK Set тянется из IdP по jwksUri с кешем 5 мин (cache: true, cacheMaxAge: 300_000). Распаковывать ключи вручную запрещено.
  • validate(claims) вызывается только после того, как passport-jwt + jwks-rsa уже проверили подпись, exp, iss, aud — не нужно проверять их повторно.
  • 401 — невалидная подпись или просроченный exp. JwtAuthGuard кидает UnauthorizedException.
  • 403 — JWT валиден, но прав не хватает. RolesGuard кидает ForbiddenException.
  • Путать 401/403 запрещено — разные сценарии: один требует refresh-flow, второй нет.
  • Глобальные APP_GUARD в правильном порядке: сначала JwtAuthGuard (401), потом RolesGuard (403). Публичные endpoints — явный @Public(), а не отсутствие Guard.

JWT validation — точка, где сервис убеждается: «запрос пришёл от реального пользователя, а не от подделки». Один пропущенный шаг в проверке — и attacker выпускает токен со своим ключом, объявляет себя admin, делает всё что угодно. В NestJS эту точку закрывает конвейер passport-jwt + jwks-rsa: один Guard на входе, ноль самописного криптокода.

JwtStrategy: стандартный конвейер

AUTH-4 — JWT валидируется стратегией на проверенных библиотеках, не самописным jwt.decode без проверки подписи.

// 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,   // AUTH-5: 5 мин
        rateLimit: true,
      }),
    });
  }

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

Что делает конвейер за вас:

  • Парсит Authorization: Bearer <jwt> header.
  • Загружает JWK Set из IdP по jwksUri, кеширует 5 минут.
  • Проверяет подпись (RS256 через public key из JWK).
  • Проверяет exp — токен не просрочен.
  • Проверяет iss — issuer совпадает с config.auth.issuer.
  • Проверяет aud — audience совпадает с config.auth.audience.
  • Парсит claims и передаёт в validate().

В validate() писать криптологику не нужно. Если кажется, что нужно — это сигнал, разбираемся, чего не хватает в конфигурации super(...).

Самописный jwt.decode — запрещён

// КАТАСТРОФА — AUTH-4 нарушен
@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(ctx: ExecutionContext): boolean {
    const req = ctx.switchToHttp().getRequest();
    const token = req.headers.authorization?.split(' ')[1];
    const decoded = jwt.decode(token);               // decode — не verify!
    if (decoded.exp < Date.now() / 1000) return false;
    req.user = decoded;
    return true;
  }
}

Что ломается:

  • jwt.decode не проверяет подпись — attacker подписывает токен своим ключом.
  • Забыт iss — принят токен от стороннего IdP.
  • Забыт aud — токен другого микросервиса прошёл валидацию.
  • Нет clock skew — exp сломан у клиентов с часами ±30 с.
  • alg: none — принят неподписанный токен.
  • JWK не кешируется — каждый запрос дёргает IdP.

Каждая ошибка — критическая уязвимость. passport-jwt + jwks-rsa уже решили это в production-tested коде.

JWK Set и кеш

AUTH-5 — JWK Set тянется из IdP по jwksUri. Вручную распаковывать ключи запрещено.

// config/app.config.ts
export interface AuthConfig {
  jwksUri: string;    // https://idp.example.com/realms/sber/protocol/openid-connect/certs
  issuer: string;     // https://idp.example.com/realms/sber
  audience: string;   // order-service
}

При получении JWT с kid, которого нет в кеше, jwks-rsa автоматически перезапрашивает JWK Set (rateLimit: true защищает от flood-атаки на IdP). Это покрывает key rotation IdP без перезапуска сервиса.

Не нужно:

  • Хранить public key в application.yml или файле.
  • Распаковывать JWK через jose / node-forge вручную.
  • Писать собственный refresh-механизм ключей.

Если IdP недоступен при первом запросе к закрытому endpoint — Guard вернёт 401 (не 500): jwks-rsa прокинет ошибку в passport, JwtAuthGuard поймает UnauthorizedException.

Guards: глобальный конвейер

APP_GUARD в правильном порядке — JwtAuthGuard первым (401), RolesGuard вторым (403):

// adapters/in/http/security/jwt-auth.guard.ts
import { Injectable, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from './public.decorator';

@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);   // AUTH-4: проверка через passport-jwt
  }
}
// adapters/in/http/security/roles.guard.ts
import { Injectable, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CanActivate } from '@nestjs/common';
import { Roles } from './roles.decorator';
import { Principal } from '../../../../core/domain/principal';

@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) return true;
    const { user } = ctx.switchToHttp().getRequest<{ user: Principal }>();
    if (!required.some((r) => user.roles.includes(r))) {
      throw new ForbiddenException();   // AUTH-6: 403, не 401
    }
    return true;
  }
}
// app.module.ts
providers: [
  { provide: APP_GUARD, useClass: JwtAuthGuard },   // 401 — аутентификация
  { provide: APP_GUARD, useClass: RolesGuard },      // 403 — авторизация
],
// adapters/in/http/security/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

// adapters/in/http/security/roles.decorator.ts
import { Reflector } from '@nestjs/core';
export const Roles = Reflector.createDecorator<string[]>();

Публичный endpoint — явный @Public(), не отсутствие Guard. Endpoint без @Roles(...) и без @Public() — прошёл аутентификацию, но не прошёл авторизацию (критично, AUTH-9).

401 vs 403

AUTH-6 — семантика кодов критична для клиента.

КодКогдаЧто должен сделать клиент
401 UnauthorizedНевалидный JWT: bad signature, expired exp, отсутствует AuthorizationRefresh-token flow или relogin
403 ForbiddenJWT валиден, но прав не хватает: RolesGuard отказал, ABAC отказалПоказать «доступ запрещён», не делать refresh

Сценарии:

  • 401 на истёкший токен → клиент дёргает POST /oauth/token с refresh-token, получает новый access-token, повторяет запрос.
  • 403 на POST /orders/123/cancel от роли customer у заказа другого пользователя → клиент показывает «нет доступа», refresh не поможет (новая роль не выдаётся автоматически).

Типичная ошибка — везде вернуть 403:

// ПЛОХО — AUTH-6 нарушен
handleRequest(err: unknown, user: Principal) {
  if (err || !user) throw new ForbiddenException();   // перехватывает UnauthorizedException
}

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

Если нужен кастомный ExceptionFilter, убедитесь, что UnauthorizedException → 401, ForbiddenException → 403:

// adapters/in/http/filters/http-exception.filter.ts
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const status = exception.getStatus();   // 401 или 403 из самого исключения
    ctx.getResponse<Response>().status(status).json({
      status,
      title: exception.message,
    });
  }
}

Маппинг ролей из claims

AUTH-7 — роли из claim (realm_access.roles Keycloak / scope OAuth2) маппятся в Principal.roles внутри JwtStrategy.validate, не в Guard:

// 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
import { JwtClaims } from './jwt-claims';

const ALLOWED_ROLES = new Set(['customer', 'seller', 'admin', 'system']);   // AUTH-8

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

AUTH-8 — разрешённые роли: customer, seller, admin, system. Любая другая — только через пересмотр Bounded Context.

Использование на endpoint:

// adapters/in/http/order.controller.ts
@Controller('orders')
export class OrderController {
  @Post()
  @Roles(['customer', 'seller'])
  async createOrder(
    @Body() dto: CreateOrderDto,
    @Req() req: Request & { user: Principal },
  ): Promise<OrderView> {
    return this.createOrderUseCase.execute(dto, req.user);
  }

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

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

Что запрещено

АнтипаттернПравилоЧто взамен
jwt.decode() без проверки подписиAUTH-4JwtStrategy через passport-jwt
Самописный Guard с ручной криптологикойAUTH-4AuthGuard('jwt') из @nestjs/passport
Распаковка JWK вручную через joseAUTH-5passportJwtSecret из jwks-rsa
Хранение public key в .env / файлеAUTH-5загрузка из IdP по jwksUri runtime
alg: none принят (не указать algorithms)AUTH-4algorithms: ['RS256'] в super(...)
cache: false у jwks-rsaAUTH-5cache: true, cacheMaxAge: 300_000
401 вместо 403 (или наоборот)AUTH-6401 — bad auth, 403 — bad perm
Endpoint без @Roles(...) и без @Public()AUTH-9каждый endpoint — явная декларация
Guard без @Public() на health-checkAUTH-6@Public() на публичных endpoint

Куда дальше

  • Где какая проверка — Gateway vs BFF vs Domain Handler.
  • RBAC: маппинг ролей — extractRoles, Roles декоратор, разрешённые значения.
  • ABAC: владение ресурсом — order.customerId === principal.sub в Handler.
  • Service-to-service — mTLS и Client Credentials как альтернатива JWT для internal.
  • PII и секреты — client-secret через env / Vault, pino redact.
  • Аудит admin-команд — NestInterceptor для *_audit_log.
  • Хранение токенов на клиенте — HttpOnly cookie, RT rotation.
  • Идемпотентность — Idempotency-Key для money-команд.