Опирается на правила:
AUTH-4…AUTH-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, отсутствует Authorization | Refresh-token flow или relogin |
| 403 Forbidden | JWT валиден, но прав не хватает: 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-4 | JwtStrategy через passport-jwt |
| Самописный Guard с ручной криптологикой | AUTH-4 | AuthGuard('jwt') из @nestjs/passport |
Распаковка JWK вручную через jose | AUTH-5 | passportJwtSecret из jwks-rsa |
Хранение public key в .env / файле | AUTH-5 | загрузка из IdP по jwksUri runtime |
alg: none принят (не указать algorithms) | AUTH-4 | algorithms: ['RS256'] в super(...) |
cache: false у jwks-rsa | AUTH-5 | cache: true, cacheMaxAge: 300_000 |
| 401 вместо 403 (или наоборот) | AUTH-6 | 401 — bad auth, 403 — bad perm |
Endpoint без @Roles(...) и без @Public() | AUTH-9 | каждый endpoint — явная декларация |
Guard без @Public() на health-check | AUTH-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, pinoredact. - Аудит admin-команд —
NestInterceptorдля*_audit_log. - Хранение токенов на клиенте — HttpOnly cookie, RT rotation.
- Идемпотентность —
Idempotency-Keyдля money-команд.