Интеграции — Basic, Bearer и mTLS на практике
Теория схем — в Basic, Bearer и mTLS. Здесь — что реально крутят в интеграциях: где проверяют учётные данные, какие заголовки прокидывают внутрь, как ротируют секреты и сертификаты и где чаще всего ломается безопасность.
Basic — legacy B2B, внутренние шины, «быстрый» партнёрский доступ за reverse proxy. Bearer — публичные и корпоративные API, OAuth 2.0, JWT access token. mTLS — регулируемый B2B, банки, гос. контуры; часто вместе с Bearer, а не вместо него.
Где проверяют — общая топология
| Слой | Basic | Bearer | mTLS |
|---|---|---|---|
| Ingress / Nginx / Kong | auth_basic, плагин basic-auth | JWT plugin, auth_request к introspection | ssl_verify_client, auth-tls в K8s Ingress |
| Service mesh | редко | policy по SPIFFE + JWT | Istio/Linkerd mTLS между pod |
| Приложение | дублировать только если нет gateway | aud, iss, scope, IDOR | маппинг CN/SAN → partnerId |
Правило: тяжёлую проверку выносят на gateway; backend получает уже нормализованный контекст (X-Partner-Id, X-Token-Sub), а не сырой Authorization из интернета.
Basic Auth — практическая настройка
Когда оставляют
- Старый ERP/1С/скрипт партнёра умеет только логин/пароль.
- Внутренний контур за VPN, срок жизни интеграции короткий, миграция на OAuth запланирована.
- Dev/stage для ручных проверок (
curl -u).
Что подкручивают в продакшене
| Настройка | Зачем |
|---|---|
| Отдельный technical user на интеграцию | Не LDAP-логин сотрудника; отзыв = отключить одну запись |
| Минимальные права | Роль «только POST /v1/orders/import», не admin |
| Только HTTPS + HSTS | Base64 не шифрует пароль |
| Rate limit по IP + по user | Защита от перебора |
| Запрет Basic в query | Пароль попадёт в access-log и Referer |
| Ротация пароля по календарю | 90 дней или при увольнении интегратора у партнёра |
Nginx на краю
Проверка до попадания в приложение — партнёр не знает внутреннюю топологию:
location /integr/erp/ {
auth_basic "ERP partner zone";
auth_basic_user_file /etc/nginx/secrets/integr-erp.htpasswd;
limit_req zone=partner_erp burst=20 nodelay;
proxy_pass http://orders-backend;
proxy_set_header X-Integration-Id erp_legacy_01;
proxy_set_header Authorization "";
}
Разбор кода (Nginx, Basic на краю):
location /integr/erp/— префикс URL, на который действуют директивы ниже (только интеграционный путь ERP).auth_basic "ERP partner zone"— включает Basic Auth; строка в кавычках — текст realm в ответе401и в диалоге браузера.auth_basic_user_file— путь к файлуhtpasswd(логин + хеш пароля); генерируют offline, монтируют из secret store, не кладут в git.limit_req zone=partner_erp burst=20 nodelay— ограничение частоты запросов по зонеpartner_erp;burst— краткий всплеск,nodelay— без искусственной задержки в очереди.proxy_pass http://orders-backend— проксирование на внутренний upstream после успешной аутентификации.proxy_set_header X-Integration-Id erp_legacy_01— служебный заголовок для backend: какая интеграция прошла проверку (аудит, rate limit по партнёру).proxy_set_header Authorization ""— обнуление внешнего Basic перед приложением; внутри кластера доверяют mTLS или internal JWT, пароль не утекает в логи сервиса.
Учётная запись только для машины
В Keycloak / AD заводят svc_erp_partner@integr.local с одной ролью integr.erp.push. Пароль хранит партнёр в своём vault; у вас — только хеш в htpasswd или bind к LDAP с фильтром «только этот DN».
Типичные ошибки
- Один общий
api:apiна всех партнёров — при утечке страдают все. - Логирование заголовка
Authorizationв SIEM «для отладки». - Basic на публичном URL без WAF и без lockout.
- «Временно» отключили TLS на staging — конфиг уехал в prod (устаревшие подходы).
Миграция: выдать партнёру client_id / client_secret → Client Credentials → Bearer; Basic оставить на переходный период с разными URL (/v1 vs /v1-legacy).
Bearer — практическая настройка
Поток machine-to-machine (самый частый)
Что подкручивают
| Параметр | Практичное значение | Комментарий |
|---|---|---|
exp access token | 5–15 мин (M2M), до 1 ч (редко) | Короткий срок снижает ущерб утечки |
scope | orders:read, не * | Проверять на каждом эндпоинте |
aud / iss | жёстко в middleware | Иначе токен с другого API принимается |
| JWKS cache | 5–15 мин + fallback | Не бить auth-сервер на каждый запрос |
client_secret | только в Vault/K8s Secret | Ротация без передеплоя приложения |
| Логи | маскировать Bearer | В 128 — утечка токена = полный доступ |
Запрос токена (партнёр)
POST /realms/corp/protocol/openid-connect/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=partner_acme_erp
&client_secret=***
&scope=orders:import
Разбор кода (запрос токена):
POST— метод обмена учётных данных клиента на токен (тело в запросе, не в URL)./realms/corp/protocol/openid-connect/token— endpoint token у Keycloak (OIDC); у других AS путь свой (/oauth/token).Content-Type: application/x-www-form-urlencoded— параметры grant передаются как поля формы, не JSON.grant_type=client_credentials— поток M2M без участия пользователя; толькоclient_id+client_secret.client_id— публичный идентификатор приложения-партнёра в реестре AS.client_secret— секрет (в примере***); только HTTPS и vault, не в логах.scope=orders:import— запрашиваемые права; AS может выдать меньше, но не больше разрешённого для клиента.
Ответ:
{
"access_token": "eyJhbGciOiJSUzI1NiIs…",
"token_type": "Bearer",
"expires_in": 900,
"scope": "orders:import"
}
Разбор кода (ответ AS):
access_token— строка JWT или opaque token; её кладут вAuthorization: Bearer ….token_type: "Bearer"— подсказка клиенту формата заголовка (RFC 6750).expires_in: 900— срок жизни в секундах (15 мин); кэшировать доexpires_in - 60, затем запросить новый.scope— фактически выданные права; backend должен сверять с требуемой операцией.
Партнёр кэширует токен до expires_in - 60 секунд и шлёт:
GET /v1/orders/PO-1042 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIs…
Accept: application/json
Разбор кода (вызов API с Bearer):
GET /v1/orders/PO-1042— бизнес-запрос к resource API (идемпотентное чтение по id заказа).Host: api.example.com— виртуальный хост gateway или балансировщика.Authorization: Bearer eyJ…— access token из предыдущего шага; схемаBearerобязательна перед значением.Accept: application/json— ожидаемый формат ответа; не влияет на авторизацию, но фиксирует контракт.
Gateway vs приложение
На gateway (Kong JWT, NGINX auth_jwt, Envoy JWT filter):
- проверка подписи и
exp; - отсечение запросов без токена до backend;
- rate limit по
sub/client_id.
В приложении (обязательно дополнительно):
scopeна операцию (POST /ordersтребуетorders:import);- IDOR:
subили claimtenant_idсовпадает с владельцем ресурса — см. 128.
API-ключ в формате Bearer sk_live_… настраивают так же на краю, но проверка идёт lookup в Redis/БД, не JWKS — не смешивать middleware для JWT и для ключей в одной «слепой» проверке подписи.
Нормализация на ingress (псевдоконфиг)
После валидации JWT gateway подменяет контекст:
# Kong / аналог: после jwt плагина
headers:
X-Auth-Sub: "{{ jwt.sub }}"
X-Auth-Scopes: "{{ jwt.scope }}"
X-Auth-Client: "{{ jwt.azp }}"
Разбор кода (нормализация на gateway):
headers— блок подстановки исходящих заголовков к upstream после успешной JWT-проверки.X-Auth-Sub: "{{ jwt.sub }}"— subject из payload (кто или какой сервис вызвал API).X-Auth-Scopes: "{{ jwt.scope }}"— выданные scopes одной строкой или списком — backend проверяет операцию без повторного разбора JWT.X-Auth-Client: "{{ jwt.azp }}"— authorized party /client_idприложения; удобно для аудита и лимитов по партнёру.{{ jwt.* }}— шаблон плагина (Kong, Envoy и др.); синтаксис зависит от продукта.
Backend не парсит внешний Bearer повторно — доверяет только внутренней сети и заголовкам от gateway (или своему internal JWT).
Типичные ошибки
- Принимать
alg: noneили смену алгоритма HS256/RS256 без whitelist. - Хранить access token в браузерном
localStorageдля «интеграционной» SPA (утечка XSS). - Один scope на все методы API.
- Не отзывать
client_idпри расторжении договора — только смена пароля у людей.
mTLS — практическая настройка
Когда включают
- Договор: «доступ только с сертификата организации X».
- Закрытый контур без публичного OAuth (гос., финтех).
- Service mesh: автоматический mTLS между сервисами (сертификация и TLS).
Сквозной сценарий с webhook и outbox — 1172.
Терминация на Ingress (Nginx)
server {
listen 443 ssl;
server_name api.payments.example;
ssl_certificate /etc/nginx/tls/server.crt;
ssl_certificate_key /etc/nginx/tls/server.key;
ssl_client_certificate /etc/nginx/tls/partners-ca.crt;
ssl_verify_client on;
ssl_verify_depth 2;
location /v1/ {
if ($ssl_client_s_dn = "") { return 403; }
proxy_set_header X-Client-Cert-Subject $ssl_client_s_dn;
proxy_pass http://payments-api;
}
}
Разбор кода (Nginx, mTLS):
listen 443 ssl— HTTPS; mTLS работает поверх TLS-рукопожатия.ssl_certificate/ssl_certificate_key— серверный cert и ключ (партнёр проверяет вашу сторону).ssl_client_certificate— PEM доверенного CA, которым подписаны клиентские сертификаты партнёров.ssl_verify_client on— требовать и проверять client cert; без cert — handshake не завершится успешно для API.ssl_verify_depth 2— глубина цепочки CA при проверке client cert (корневой → промежуточный → leaf).if ($ssl_client_s_dn = "") { return 403; }— запрет запросов без валидного client cert (subject DN пустой).proxy_set_header X-Client-Cert-Subject $ssl_client_s_dn— DN клиента в заголовке для маппинга наpartnerIdв приложении.proxy_pass http://payments-api— проксирование только после успешного mTLS.
Приложение читает X-Client-Cert-Subject, сопоставляет с таблицей partnerId (как в 1172). PEM в handler не разбирают — это уже сделал ingress.
Kubernetes Ingress (nginx-controller)
Аннотации (смысл, не полный манифест):
metadata:
annotations:
nginx.ingress.kubernetes.io/auth-tls-verify-client: "on"
nginx.ingress.kubernetes.io/auth-tls-secret: "prod/partners-ca"
nginx.ingress.kubernetes.io/auth-tls-verify-depth: "2"
Разбор кода (Ingress, auth-tls):
metadata.annotations— способ передать настройки nginx-ingress без своего ConfigMap.auth-tls-verify-client: "on"— аналогssl_verify_client onна Ingress.auth-tls-secret: "prod/partners-ca"— Secret в namespace с CA для проверки client cert (форматnamespace/name).auth-tls-verify-depth: "2"— максимальная глубина цепочки client cert.
CA для sandbox и prod разные Secret — партнёр не может случайно бить prod cert'ом из теста.
Service mesh (Istio / Linkerd)
- Sidecar поднимает mTLS между pod без правок кода.
- Внешний партнёр всё равно терминируется на Ingress Gateway с client cert.
- Миграция: сначала
PERMISSIVE, затемSTRICTпосле обновления всех клиентов.
Ротация сертификатов
| Шаг | Действие |
|---|---|
| T−90 | Партнёр получает новый cert, fingerprint в документации |
| T−30 | Оба cert принимаются (overlap) |
| T | Алерт по notAfter за 14 дней |
| T+ | Отзыв старого в CA / удаление из trust store |
Партнёру отдают не только PEM, но и SHA-256 fingerprint — сверка перед cutover.
mTLS + Bearer (два слоя)
- mTLS — «это инфраструктура банка X».
- Bearer — «этому
client_idразрешён scopepayouts:read».
Отключение только Bearer при живом mTLS оставляет риск украденного cert; только mTLS без scope — риск избыточных операций при компрометации cert.
Клиентский код по языкам
Ниже — исходящие вызовы партнёрского API (ваш сервис — клиент). Для приёма запросов на сервере см. колонку «Сервер (проверка)».
Сводка — библиотеки и ключевые типы
| Язык | Basic (клиент) | Bearer / OAuth2 Client Credentials | mTLS (клиент) | Сервер (проверка) |
|---|---|---|---|---|
| C# | AuthenticationHeaderValue, NetworkCredential + HttpClientHandler.Credentials | IdentityModel — ClientCredentialsTokenRequest, TokenClient; ASP.NET Core — AddJwtBearer, JwtBearerHandler | HttpClientHandler.ClientCertificates, X509Certificate2 | AddAuthentication().AddJwtBearer(), middleware, Kestrel client cert |
| Java | HttpRequest + Base64, Spring BasicAuthenticationInterceptor | Spring Security OAuth2AuthorizedClientManager; java.net.http + JSON token; Nimbus JOSE JWT | SSLContext, KeyManagerFactory, OkHttp HandshakeCertificates | Spring Security oauth2ResourceServer().jwt(), servlet filters |
| Python | requests / httpx — HTTPBasicAuth, auth=(user, pass) | requests-oauthlib OAuth2Session; authlib OAuth2Client; заголовок вручную | cert=(client.pem, key.pem), verify=ca.pem | FastAPI HTTPBearer, OAuth2PasswordBearer; Django DRF authentication_classes |
| TypeScript | axios auth: { username, password }; fetch + btoa | axios headers.Authorization; oauth4webapi / openid-client | node:https Agent({ cert, key, ca }) + axios/fetch | Express middleware; NestJS @nestjs/jwt, PassportStrategy |
| Go | req.SetBasicAuth(user, pass) | golang.org/x/oauth2/clientcredentials + oauth2.NewClient; заголовок вручную | tls.Config{ Certificates: []tls.Certificate{…} } в http.Transport | gin / echo middleware JWT; tls.RequireAndVerifyClientCert |
| PHP | Guzzle 'auth' => [user, pass] | Guzzle заголовок Authorization; league/oauth2-client ClientCredentials | Guzzle 'cert' => [pem, key], 'verify' => ca.pem | Laravel auth:api, Sanctum; Symfony access_token firewall |
Секреты (client_secret, пароли, ключи PEM) — только из env / Vault, не из репозитория. Подробнее про HTTP-клиенты в экосистемах — C# интеграции, Python requests, Java HttpClient.
Basic Auth
C#
using System.Net.Http.Headers;
using System.Text;
var credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes("svc_erp:secret_from_vault"));
var client = httpClientFactory.CreateClient("ErpPartner");
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
// Альтернатива — встроенный handler (удобно для IIS/legacy)
var handler = new HttpClientHandler
{
Credentials = new NetworkCredential("svc_erp", "secret_from_vault")
};
Разбор кода (C#, Basic):
AuthenticationHeaderValue— тип заголовкаAuthorizationвSystem.Net.Http.Headers.Convert.ToBase64String+Encoding.UTF8.GetBytes("login:password")— кодирование пары логин:пароль в Base64 по RFC 7617 (без префиксаBasic— его добавляет конструктор).httpClientFactory.CreateClient("ErpPartner")— именованный клиент из DI с base address и политиками Polly.DefaultRequestHeaders.Authorization— заголовок на все запросы этого экземпляраHttpClient.HttpClientHandler+NetworkCredential— альтернатива: handler сам формирует Basic и обрабатывает401challenge (удобно для legacy IIS).
Java
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.util.Base64;
String basic = Base64.getEncoder()
.encodeToString("svc_erp:secret".getBytes(StandardCharsets.UTF_8));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/v1/orders"))
.header("Authorization", "Basic " + basic)
.GET()
.build();
Разбор кода (Java, Basic):
Base64.getEncoder().encodeToString(...)— Base64 для строкиlogin:passwordв UTF-8.HttpRequest.newBuilder()— fluent-сборка immutable-запроса (Java 11+ HTTP Client API)..uri(URI.create(...))— абсолютный HTTPS URL ресурса..header("Authorization", "Basic " + basic)— схемаBasic+ пробел + payload (не только Base64)..GET()— HTTP-метод;.build()— финализацияHttpRequest.
Spring — new BasicAuthenticationInterceptor("svc_erp", secret) в RestTemplate / RestClient.
Python
import requests
from requests.auth import HTTPBasicAuth
response = requests.get(
"https://api.example.com/v1/orders",
auth=HTTPBasicAuth("svc_erp", os.environ["ERP_PASSWORD"]),
timeout=30,
)
Разбор кода (Python, Basic):
requests.get— синхронный HTTP GET; возвращаетResponseс.status_code,.json().auth=HTTPBasicAuth(...)— библиотека сама кодирует логин/пароль и ставит заголовокAuthorization.os.environ["ERP_PASSWORD"]— секрет из окружения, не из кода.timeout=30— обрыв зависшего соединения через 30 с (интеграции без таймаута опасны).
httpx — httpx.BasicAuth("svc_erp", password) или auth=(user, pass).
TypeScript (Node)
import axios from 'axios';
const client = axios.create({
baseURL: 'https://api.example.com',
auth: { username: 'svc_erp', password: process.env.ERP_PASSWORD! },
});
Разбор кода (TypeScript, Basic):
axios.create— фабрика клиента с общими настройками (base URL, auth, interceptors).baseURL— префикс для относительных путей (/v1/orders→ полный URL).auth: { username, password }— axios формирует Basic Auth на каждый запрос.process.env.ERP_PASSWORD!— non-null assertion TypeScript; значение должно быть задано при старте процесса.
fetch — `Authorization: Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`.
Go
req, _ := http.NewRequest(http.MethodGet, "https://api.example.com/v1/orders", nil)
req.SetBasicAuth("svc_erp", os.Getenv("ERP_PASSWORD"))
resp, err := http.DefaultClient.Do(req)
Разбор кода (Go, Basic):
http.NewRequest— создание запроса; первый аргумент — метод (http.MethodGet).SetBasicAuth(user, pass)— установка заголовкаAuthorization: Basic …(кодирование внутри stdlib).os.Getenv("ERP_PASSWORD")— чтение секрета из окружения.http.DefaultClient.Do(req)— выполнение;err— сеть/TLS; статус смотрят вresp.StatusCode.
PHP (Guzzle)
use GuzzleHttp\Client;
$client = new Client([
'base_uri' => 'https://api.example.com',
'auth' => ['svc_erp', getenv('ERP_PASSWORD')],
]);
$client->get('/v1/orders');
Разбор кода (PHP, Basic):
GuzzleHttp\Client— HTTP-клиент с пулом соединений и middleware.'base_uri'— базовый URL; путь вget('/v1/orders')дописывается к нему.'auth' => [user, pass]— короткая форма Basic Auth в Guzzle (массив из двух строк).getenv('ERP_PASSWORD')— пароль из env; в production предпочтительнее$_ENVчерез dotenv с кешем.
Bearer (OAuth 2.0 Client Credentials)
Общий шаблон — POST /token → кэш access_token → Authorization: Bearer … до expires_in.
C#
Пакет Duende.IdentityModel / IdentityModel:
using Duende.IdentityModel.Client;
var tokenResponse = await httpClient.RequestClientCredentialsTokenAsync(
new ClientCredentialsTokenRequest
{
Address = "https://auth.example.com/realms/corp/protocol/openid-connect/token",
ClientId = "partner_acme_erp",
ClientSecret = configuration["OAuth:ClientSecret"],
Scope = "orders:import",
});
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken);
Разбор кода (C#, Bearer):
RequestClientCredentialsTokenAsync— обёртка IdentityModel над POST/tokenсgrant_type=client_credentials.ClientCredentialsTokenRequest— DTO:Address(token endpoint),ClientId,ClientSecret,Scope.configuration["OAuth:ClientSecret"]— секрет из конфигурации/vault, не литерал в коде.tokenResponse.AccessToken— строка access token из JSON-ответа AS.AuthenticationHeaderValue("Bearer", …)— заголовок для последующих вызовов API.
На resource server — services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(...), проверка iss, aud, scope в policy.
Java
Spring Boot 3 — OAuth2AuthorizedClientManager + @RegisteredClient; для чистого JDK:
// После POST token — парсинг JSON (Jackson / Gson)
HttpRequest apiCall = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/v1/orders/PO-1"))
.header("Authorization", "Bearer " + accessToken)
.GET()
.build();
Разбор кода (Java, Bearer):
- Комментарий «После POST token» — отдельный шаг:
HttpClientк AS, парсинг JSON → переменнаяaccessToken. .header("Authorization", "Bearer " + accessToken)— схема Bearer; пробел послеBearerобязателен.- Остальное — тот же
HttpRequest.newBuilder()/.uri/.GET()/.build(), что и для Basic.
Проверка входящих JWT — spring-boot-starter-oauth2-resource-server, класс конфигурации с JwtDecoder / JwtAuthenticationConverter.
Python
from requests_oauthlib import OAuth2Session
from oauthlib.oauth2 import BackendApplicationClient
client = BackendApplicationClient(client_id=os.environ["CLIENT_ID"])
oauth = OAuth2Session(client=client)
token = oauth.fetch_token(
token_url="https://auth.example.com/oauth/token",
client_id=os.environ["CLIENT_ID"],
client_secret=os.environ["CLIENT_SECRET"],
scope=["orders:import"],
)
r = oauth.get("https://api.example.com/v1/orders/PO-1")
Разбор кода (Python, Bearer):
BackendApplicationClient— oauthlib-клиент только для grantclient_credentials(без браузера).OAuth2Session(client=client)— сессия requests с автоматической подстановкой токена послеfetch_token.fetch_token(token_url=..., client_secret=..., scope=[...])— POST на AS; возвращает dict сaccess_token,expires_in.oauth.get(url)— GET с уже прикреплённымAuthorization: Bearer ….
FastAPI (сервер) — OAuth2PasswordBearer, Depends(get_current_user); см. 3432.
TypeScript
import axios from 'axios';
async function getAccessToken(): Promise<string> {
const { data } = await axios.post(
'https://auth.example.com/oauth/token',
new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.CLIENT_ID!,
client_secret: process.env.CLIENT_SECRET!,
scope: 'orders:import',
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
);
return data.access_token;
}
const api = axios.create({ baseURL: 'https://api.example.com' });
api.interceptors.request.use(async (config) => {
config.headers.Authorization = `Bearer ${await getAccessToken()}`;
return config;
});
Разбор кода (TypeScript, Bearer):
getAccessToken— отдельная функция получения токена; её результат кэшируют в памяти с учётомexpires_in.axios.post+URLSearchParams— телоapplication/x-www-form-urlencodedдля OAuth token endpoint.grant_type: 'client_credentials'— M2M-поток;client_id/client_secretизprocess.env.data.access_token— поле ответа AS.api.interceptors.request.use— middleware axios: перед каждым запросом подставляет свежий Bearer (нужен кэш токена, иначе лишние POST/token).
NestJS — @nestjs/passport, JwtStrategy, AuthGuard('jwt').
Go
import (
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
)
cfg := clientcredentials.Config{
ClientID: os.Getenv("CLIENT_ID"),
ClientSecret: os.Getenv("CLIENT_SECRET"),
TokenURL: "https://auth.example.com/oauth/token",
Scopes: []string{"orders:import"},
}
client := cfg.Client(ctx)
resp, err := client.Get("https://api.example.com/v1/orders/PO-1")
Разбор кода (Go, Bearer):
clientcredentials.Config— структура сClientID,ClientSecret,TokenURL,Scopes.cfg.Client(ctx)— возвращает*http.Client, который перед запросом получает/обновляет token (встроенный кэш oauth2).client.Get(url)— обычный GET; transport сам добавляетAuthorization: Bearer ….
PHP
league/oauth2-client:
use League\OAuth2\Client\Provider\GenericProvider;
$provider = new GenericProvider([
'clientId' => getenv('CLIENT_ID'),
'clientSecret' => getenv('CLIENT_SECRET'),
'urlAccessToken' => 'https://auth.example.com/oauth/token',
]);
$token = $provider->getAccessToken('client_credentials', ['scope' => 'orders:import']);
$client = new Client([
'headers' => ['Authorization' => 'Bearer ' . $token->getToken()],
]);
Разбор кода (PHP, Bearer):
GenericProvider— универсальный провайдер league/oauth2-client для кастомных AS.'urlAccessToken'— endpoint выдачи токена.getAccessToken('client_credentials', ['scope' => ...])— POST grant; возвращает объектAccessToken.$token->getToken()— строка access token для заголовка.- Второй
Client(Guzzle) — уже с фиксированнымAuthorizationна все запросы.
Laravel (исходящий) — Http::withToken($token); входящий API — Sanctum / Passport.
mTLS
Клиент предъявляет свой сертификат; CA цепочки сервера проверяется отдельно (verify / ServerCertificateCustomValidationCallback только в dev с осторожностью).
C#
var handler = new HttpClientHandler();
handler.ClientCertificates.Add(
new X509Certificate2(
"/secrets/partner-client.pfx",
configuration["Cert:Password"],
X509KeyStorageFlags.MachineKeySet));
handler.ServerCertificateCustomValidationCallback = null; // prod — стандартная проверка
var client = new HttpClient(handler) { BaseAddress = new Uri("https://api.payments.example") };
Разбор кода (C#, mTLS):
HttpClientHandler— низкоуровневый handler для TLS, сертификатов и прокси.ClientCertificates.Add— коллекция client cert, отправляемых при handshake.X509Certificate2(path, password, flags)— загрузка PFX/PEM;MachineKeySet— ключ в хранилище машины (типично для Windows-сервисов).ServerCertificateCustomValidationCallback = null— в prod стандартная проверка сервера (не отключать проверку в бою).BaseAddress— базовый URL API; относительные пути в запросах дописываются к нему.
Java
KeyStore ks = KeyStore.getInstance("PKCS12");
ks.load(new FileInputStream("client.p12"), password);
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(ks, password);
SSLContext ssl = SSLContext.getInstance("TLS");
ssl.init(kmf.getKeyManagers(), null, null);
HttpClient client = HttpClient.newBuilder().sslContext(ssl).build();
Разбор кода (Java, mTLS):
KeyStore.getInstance("PKCS12")— хранилище в формате.p12с client cert и ключом.ks.load(stream, password)— загрузка keystore из файла.KeyManagerFactory— фабрика ключей для исходящего client cert (кто мы для сервера).SSLContext.getInstance("TLS")+ssl.init(kmf.getKeyManagers(), null, null)— контекст только с client auth; trust store сервера — по умолчанию JVM cacerts (для кастомного CA нуженTrustManager)..sslContext(ssl)наHttpClient.Builder— все запросы этого клиента используют mTLS.
OkHttp — HandshakeCertificates с heldCertificate + addTrustedCertificate.
Python
response = requests.get(
"https://api.payments.example/v1/payouts/po_1",
cert=("/secrets/client.crt", "/secrets/client.key"),
verify="/secrets/partners-ca.pem",
)
Разбор кода (Python, mTLS):
cert=(client.crt, client.key)— кортеж: публичный cert и приватный ключ клиента для TLS handshake.verify="/secrets/partners-ca.pem"— доверенный CA (или цепочка) для проверки серверного cert; без этого возможна MITM.requests.get— один запрос; для серии вызовов лучшеrequests.Session()с теми жеcertиverify.
httpx — те же параметры cert= и verify=.
TypeScript (Node)
import https from 'node:https';
import fs from 'node:fs';
import axios from 'axios';
const agent = new https.Agent({
cert: fs.readFileSync('/secrets/client.crt'),
key: fs.readFileSync('/secrets/client.key'),
ca: fs.readFileSync('/secrets/partners-ca.pem'),
});
const client = axios.create({
baseURL: 'https://api.payments.example',
httpsAgent: agent,
});
Разбор кода (TypeScript, mTLS):
https.Agent— низкоуровневый TLS-агент Node.js дляhttpsи axios.cert/key— буферы client cert и приватного ключа (readFileSync).ca— буфер CA для проверки сервера API.axios.create({ httpsAgent: agent })— все HTTPS-запросы клиента идут через этот TLS-контекст (не путать с HTTP-прокси).
Go
cert, err := tls.LoadX509KeyPair("client.crt", "client.key")
transport := &http.Transport{
TLSClientConfig: &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
RootCAs: caCertPool, // загрузить partners-ca.pem
},
}
client := &http.Client{Transport: transport}
Разбор кода (Go, mTLS):
tls.LoadX509KeyPair— загрузка client cert + key из PEM-файлов вtls.Certificate.http.Transport— настраиваемый транспорт; подменяет TLS уhttp.Client.TLSClientConfig.Certificates— client cert для handshake.MinVersion: tls.VersionTLS12— запрет устаревших TLS 1.0/1.1.RootCAs: caCertPool— пул доверенных CA для проверки сервера (загружают изpartners-ca.pem).
Сервер — tls.Config{ ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: pool } на http.Server.
PHP (Guzzle)
$client = new Client([
'base_uri' => 'https://api.payments.example',
'cert' => ['/secrets/client.crt', '/secrets/client.key'],
'verify' => '/secrets/partners-ca.pem',
]);
Разбор кода (PHP, mTLS):
'cert' => [crt, key]— массив из двух путей: client certificate и private key (аналог Pythoncert=).'verify' => ca.pem— путь к CA для проверки сервера;true— системные CA,false— не использовать в prod (отключает проверку).base_uri— как в Basic-примере; mTLS и Bearer можно комбинировать в одномClient+ заголовокAuthorization.
Basic + Bearer + mTLS в одном клиенте
Типичная связка для банковского API — mTLS на транспорте и Bearer в заголовке:
- Собрать
HttpClient/requests.Session/ Guzzle с client cert. - Получить access token (Client Credentials).
- Добавить
Authorization: Bearer …к каждому бизнес-запросу.
В C# удобно разделить — DelegatingHandler, который подставляет Bearer поверх HttpClient с mTLS-handler (интеграции C#).
Разбор логики (три слоя в одном клиенте):
- Шаг 1 — transport с client cert (mTLS): «подключение разрешено только с нашей инфраструктуры».
- Шаг 2 — OAuth Client Credentials: короткоживущий
access_tokenсscope. - Шаг 3 — каждый бизнес-запрос несёт
Authorization: Bearer …поверх уже установленного TLS-соединения. DelegatingHandlerв .NET — цепочка: внешний handler добавляет Bearer, внутренний handler держит mTLS; порядок регистрации важен.
Сводный чек-лист перед продом
Basic
- Отдельная учётка/файл htpasswd на интеграцию
- HTTPS, HSTS, rate limit
-
Authorizationне логируется - План миграции на Bearer/OAuth с датой
Bearer
-
iss,aud,exp,scopeпроверяются - JWKS/introspection на gateway
- Секреты в vault, ротация
client_secret - IDOR-тесты с двумя токенами разных клиентов
mTLS
- Trust store и SAN/CN согласованы с реестром партнёров
- Overlap при ротации
- 403 без cert, единый формат ошибки
- При необходимости — второй слой (scope/JWS)
Наблюдаемость и инциденты
| Событие | Что логировать | Чего избегать |
|---|---|---|
| Неуспешный Basic | integration_id, IP, счётчик | пароль, base64 целиком |
| Invalid JWT | iss, kid, error | полный токен |
| mTLS fail | ssl_client_verify, subject DN | private key |
| Успешный вызов | partner_id, scope, correlation-id | избыточные PII |
Алерты: всплеск 401/403 с одного IP, истечение cert за 14 дней, рост invalid_token после деплоя JWKS.
Связанные материалы
- Basic, Bearer и mTLS — теория
- 12 советов по безопасности API
- Уязвимости и атаки на API
- Сертификация и сертификаты
- Устаревшие подходы — Basic без TLS, слабые протоколы
- curl — Basic и Bearer
- Клиентский код по языкам — таблица библиотек в этой статье
Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.