Опирается на правила: AUTH-13, AUTH-14 из Auth Patterns → раздел 5. Service-to-service.

Важно знать

  • Два способа межсервисной аутентификации: mTLS (рекомендуется) или Client Credentials Flow.
  • mTLS — двусторонний TLS на K8s Service Mesh (Istio, Linkerd). Identity = SPIFFE-сертификат pod-а, NestJS-приложение кода аутентификации не видит.
  • Client Credentials Flowgrant_type=client_credentials; outbound-клиент (axios/undici) получает и кеширует токен, добавляет Authorization: Bearer к каждому запросу.
  • Анонимный inter-service трафик — критическое нарушение. Любой axios.post без mTLS или Bearer — на ревью.
  • Scope — per operation (payment:charge, inventory:reserve), не general service. Даёт fine-grained ACL с минимальным blast-radius.
  • Hard-coded clientSecret в коде — никогда. Только через env / Vault.
  • Ручное кеширование токена — антипаттерн: токен протухает раньше расчётного exp при clock skew; используй готовый клиент с proactive refresh.

Service-to-service вызовы внутри кластера кажутся «безопасными по определению» — внутренняя сеть, VPC. Это иллюзия: скомпрометированный pod, lateral movement, insider attack — реальные сценарии. Правило zero trust формулируется просто: каждый межсервисный вызов аутентифицирован, даже внутри кластера.

Способ 1: mTLS (рекомендуется)

AUTH-13: двусторонний TLS через Service Mesh.

order-service pod → istio-proxy sidecar → mTLS → istio-proxy → payment-service pod
                    (SPIFFE cert: order-service-prod)            (verifies cert)

Istio автоматически раздаёт каждому pod-у уникальный SPIFFE-сертификат, шифрует internal-трафик TLS 1.2+ и проверяет клиентский сертификат на receiver-side. NestJS-приложение не пишет никакого кода аутентификации — sidecar всё делает до TCP-сокета приложения.

// main.ts — сервис запускается как обычно, mTLS прозрачен для приложения
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();
# kubernetes/payment-service-peer-auth.yaml
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
  name: payment-service
  namespace: production
spec:
  selector:
    matchLabels:
      app: payment-service
  mtls:
    mode: STRICT

Преимущества mTLS:

  • Identity встроена в transport — нечего добавлять в headers вручную.
  • Rotation автоматическая — Istio обновляет сертификаты каждые 24 часа.
  • Cross-language — работает одинаково для NestJS, Java, Python, Go.

Недостатки:

  • Требует Service Mesh инфры (Istio/Linkerd) — серьёзная инфра-нагрузка.
  • В dev/test без Service Mesh нужна альтернатива: Client Credentials Flow или dev-режим с skip-verify.

Способ 2: Client Credentials Flow

AUTH-13: OAuth2 standard для окружений без mTLS.

order-service → IdP: POST /oauth/token
                grant_type=client_credentials
                client_id=order-service-prod
                client_secret=$ORDER_SERVICE_CLIENT_SECRET
                scope=payment:charge

← IdP: { "access_token": "...", "expires_in": 3600 }

order-service → payment-service:
                POST /charge
                Authorization: Bearer <token>

payment-service: JwtStrategy (passport-jwt + jwks-rsa)
                 валидирует токен, проверяет scope=payment:charge

Реализация outbound-клиента

Токен получает и кеширует отдельный сервис (TokenCacheService). Outbound-клиент (axios) не знает про OAuth2 — только про Bearer.

// adapters/out/payment/token-cache.service.ts
import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
import { AppConfig } from '../../../config/app.config';

interface TokenEntry {
  value: string;
  expiresAt: number;
}

@Injectable()
export class TokenCacheService {
  private readonly logger = new Logger(TokenCacheService.name);
  private cache = new Map<string, TokenEntry>();

  constructor(private readonly config: AppConfig) {}

  async token(scope: string): Promise<string> {
    const cached = this.cache.get(scope);
    if (cached && cached.expiresAt > Date.now() + 30_000) {
      return cached.value;
    }
    return this.fetch(scope);
  }

  private async fetch(scope: string): Promise<string> {
    const { data } = await axios.post<{ access_token: string; expires_in: number }>(
      this.config.auth.tokenUri,
      new URLSearchParams({
        grant_type: 'client_credentials',
        client_id: this.config.auth.clientId,
        client_secret: this.config.auth.clientSecret,
        scope,
      }),
      { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
    );

    this.cache.set(scope, {
      value: data.access_token,
      expiresAt: Date.now() + data.expires_in * 1000,
    });
    this.logger.debug(`token refreshed for scope=${scope}`);
    return data.access_token;
  }
}
// adapters/out/payment/payment.client.ts
import { Injectable } from '@nestjs/common';
import axios, { AxiosInstance } from 'axios';
import { AppConfig } from '../../../config/app.config';
import { TokenCacheService } from './token-cache.service';

@Injectable()
export class PaymentClient {
  private readonly http: AxiosInstance;

  constructor(
    private readonly tokens: TokenCacheService,
    config: AppConfig,
  ) {
    this.http = axios.create({ baseURL: config.payment.baseUrl });

    this.http.interceptors.request.use(async (req) => {
      const bearer = await this.tokens.token('payment:charge');
      req.headers['Authorization'] = `Bearer ${bearer}`;      // AUTH-14
      return req;
    });
  }

  async charge(orderId: string, amount: number): Promise<Receipt> {
    const { data } = await this.http.post<Receipt>('/charge', { orderId, amount });
    return data;
  }
}
// adapters/out/payment/payment.module.ts
import { Module } from '@nestjs/common';
import { TokenCacheService } from './token-cache.service';
import { PaymentClient } from './payment.client';

@Module({
  providers: [TokenCacheService, PaymentClient],
  exports: [PaymentClient],
})
export class PaymentModule {}

Scope per operation

AUTH-13: не один широкий токен на всё взаимодействие между сервисами.

// adapters/out/inventory/inventory.client.ts — отдельный клиент, отдельный scope
async reserve(productId: string, qty: number): Promise<void> {
  const bearer = await this.tokens.token('inventory:reserve');   // не 'payment:charge'
  await this.http.post('/reserve', { productId, qty }, {
    headers: { Authorization: `Bearer ${bearer}` },
  });
}

async release(reservationId: string): Promise<void> {
  const bearer = await this.tokens.token('inventory:release');
  await this.http.post(`/reservations/${reservationId}/release`, null, {
    headers: { Authorization: `Bearer ${bearer}` },
  });
}

order-service может зарезервировать (inventory:reserve) и освободить (inventory:release), но не удалить товар (catalog:delete). Blast-radius компрометации ограничен.

Запрет анонимного трафика

AUTH-14: axios без auth — критическое нарушение.

// НАРУШЕНИЕ AUTH-14
@Injectable()
export class CustomerClient {
  async profile(customerId: string): Promise<CustomerProfile> {
    const { data } = await axios.get(`${this.baseUrl}/customers/${customerId}`);
    return data;
  }
}

Любой скомпрометированный pod в сети получает профили клиентов без аутентификации. Один вектор атаки — весь customer-service.

Корректно: перехватчик запроса добавляет Bearer до каждого вызова, outbound-клиент никогда не делает запрос без него.

// ВЕРНО — перехватчик централизованно добавляет Bearer
@Injectable()
export class CustomerClient {
  private readonly http: AxiosInstance;

  constructor(tokens: TokenCacheService, config: AppConfig) {
    this.http = axios.create({ baseURL: config.customer.baseUrl });
    this.http.interceptors.request.use(async (req) => {
      req.headers['Authorization'] = `Bearer ${await tokens.token('customer:read')}`;
      return req;
    });
  }

  async profile(customerId: string): Promise<CustomerProfile> {
    const { data } = await this.http.get<CustomerProfile>(`/customers/${customerId}`);
    return data;
  }
}

Конфигурация секретов

AUTH-14 + AUTH-17: clientSecret не в коде и не в git.

// config/app.config.ts
import { IsString, IsUrl } from 'class-validator';

export class AuthConfig {
  @IsUrl()
  tokenUri: string;

  @IsString()
  clientId: string;

  @IsString()
  clientSecret: string;   // читается из process.env.CLIENT_SECRET, не hardcoded

  @IsUrl()
  jwksUri: string;

  @IsString()
  audience: string;

  @IsString()
  issuer: string;
}
# .env.example — только примеры, реальные значения через Vault / SealedSecrets
AUTH_TOKEN_URI=https://idp.internal/oauth/token
AUTH_CLIENT_ID=order-service-prod
AUTH_CLIENT_SECRET=<from-vault>

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

АнтипаттернПравилоЧто взамен
axios.get/post без Authorization в adapter-outAUTH-14interceptor с TokenCacheService
clientSecret в app.config.ts или app.module.tsAUTH-17env через Vault / SealedSecrets
Один scope (service) на все межсервисные вызовыAUTH-13scope per operation
Ручное setTimeout для обновления токенаAUTH-13expiresAt > Date.now() + 30_000 с proactive refresh
mTLS только для public-facing endpointAUTH-13zero trust внутри кластера тоже
Bearer вручную в каждом методе клиентаAUTH-14interceptor централизованно
Токен хранится в модульной переменной без TTLAUTH-13TokenCacheService с expiresAt

Куда дальше

  • JWT-валидация — payment-service валидирует входящий токен от order-service.
  • PII и секреты — clientSecret через Vault, redact-paths в pino.
  • Где какая проверка — роль system для S2S-вызовов в RBAC.
  • ABAC: владение ресурсом — domain-service проверяет principal.sub, не gateway.
  • Audit admin-команд — структура *_audit_log для admin-обхода.
  • Идемпотентность — Idempotency-Key для money-команд через S2S.
  • RBAC: маппинг ролей — system-роль для внутренних клиентов.
  • Хранение токенов на клиенте — HttpOnly cookie для SPA, не localStorage.