Опирается на правила:
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 Flow —
grant_type=client_credentials; outbound-клиент (axios/undici) получает и кеширует токен, добавляетAuthorization: Bearerк каждому запросу.- Анонимный inter-service трафик — критическое нарушение. Любой
axios.postбез mTLS или Bearer — на ревью.- Scope — per operation (
payment:charge,inventory:reserve), не generalservice. Даёт 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-out | AUTH-14 | interceptor с TokenCacheService |
clientSecret в app.config.ts или app.module.ts | AUTH-17 | env через Vault / SealedSecrets |
Один scope (service) на все межсервисные вызовы | AUTH-13 | scope per operation |
Ручное setTimeout для обновления токена | AUTH-13 | expiresAt > Date.now() + 30_000 с proactive refresh |
| mTLS только для public-facing endpoint | AUTH-13 | zero trust внутри кластера тоже |
Bearer вручную в каждом методе клиента | AUTH-14 | interceptor централизованно |
| Токен хранится в модульной переменной без TTL | AUTH-13 | TokenCacheService с 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.