Client Assertion: private_key_jwt

Метод client assertion с использованием private_key_jwt в OAuth 2.0 применяется для аутентификации клиента без передачи client_secret, заменяя его подписанным JWT, который доказывает владение приватным ключом. В контексте библиотеки jose в JavaScript этот механизм реализуется через создание и криптографическую подпись JWT с последующей отправкой его в token endpoint как часть запроса.

В классической схеме OAuth 2.0 клиент часто аутентифицируется с помощью client_id и client_secret. Такой подход имеет ограничения: секрет должен храниться на стороне приложения, что повышает риск компрометации.

private_key_jwt решает эту проблему иначе:

  • клиент использует асимметричную криптографию (RSA или EC ключи)
  • приватный ключ остаётся только у клиента
  • сервер авторизации хранит публичный ключ (обычно через JWKS)
  • аутентификация происходит через подписанный JWT (client assertion)

Таким образом, вместо передачи секрета передаётся доказательство владения ключом.

Роль библиотеки jose

Библиотека jose предоставляет инструменты для работы с:

  • JWS (подпись JWT)
  • JWE (шифрование)
  • JWKS (работа с ключами)
  • JWT (создание и валидация токенов)

Для private_key_jwt используются:

  • SignJWT — создание JWT
  • importPKCS8 или importJWK — импорт приватного ключа
  • криптографические алгоритмы RS256, ES256 и другие

Структура client assertion

JWT, используемый как client assertion, содержит стандартные claims:

  • iss — идентификатор клиента (client_id)
  • sub — также client_id
  • aud — URL token endpoint сервера авторизации
  • exp — время истечения (обычно короткое, 1–5 минут)
  • iat — время выпуска токена
  • jti — уникальный идентификатор для предотвращения повторного использования

Ключевой момент — строгое соответствие aud адресу token endpoint, иначе сервер отклонит запрос.

Формирование JWT с использованием jose

Процесс создания client assertion включает загрузку приватного ключа и подпись payload.

import { SignJWT, importPKCS8 } from 'jose';

const privateKeyPem = `
-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----
`;

const privateKey = await importPKCS8(privateKeyPem, 'RS256');

const clientAssertion = await new SignJWT({})
  .setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
  .setIssuer('your_client_id')
  .setSubject('your_client_id')
  .setAudience('https://auth.server.com/oauth/token')
  .setJti(crypto.randomUUID())
  .setIssuedAt()
  .setExpirationTime('2m')
  .sign(privateKey);

В данном случае payload может быть пустым объектом, поскольку все необходимые данные находятся в claims.

Отправка client assertion на token endpoint

После генерации JWT он передаётся в запросе получения токена:

const params = new URLSearchParams();

params.append('grant_type', 'client_credentials');
params.append('client_id', 'your_client_id');
params.append('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
params.append('client_assertion', clientAssertion);

const response = await fetch('https://auth.server.com/oauth/token', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded'
  },
  body: params.toString()
});

const data = await response.json();

Параметр client_assertion_type фиксирован и указывает серверу, что используется JWT bearer assertion.

Проверка сервером авторизации

Сервер выполняет несколько проверок:

  • подпись JWT валидна (с использованием публичного ключа клиента)
  • exp не истёк
  • iat допустим по времени
  • aud совпадает с token endpoint
  • iss и sub соответствуют зарегистрированному client_id
  • jti не использовался ранее (защита от replay атак)

Если хотя бы одно условие нарушено, запрос отклоняется.

Выбор алгоритма подписи

На практике чаще всего используются:

  • RS256 — RSA + SHA-256
  • ES256 — ECDSA P-256 + SHA-256

RS256 проще в интеграции и шире поддерживается, ES256 даёт меньший размер подписи и более современную криптографию.

Пример с ES256:

import { importPKCS8, SignJWT } from 'jose';

const privateKey = await importPKCS8(ecPrivateKeyPem, 'ES256');

const assertion = await new SignJWT({})
  .setProtectedHeader({ alg: 'ES256' })
  .setIssuer(clientId)
  .setSubject(clientId)
  .setAudience(tokenEndpoint)
  .setExpirationTime('120s')
  .sign(privateKey);

Типичные ошибки реализации

Часто встречающиеся проблемы:

Несоответствие aud Если указан не точный URL token endpoint, сервер отклоняет JWT даже при корректной подписи.

Слишком длинный exp JWT с большим временем жизни увеличивает риск повторного использования.

Отсутствие jti Без уникального идентификатора невозможно эффективно защищаться от replay атак.

Неверный алгоритм Если сервер ожидает RS256, а клиент подписывает ES256, проверка провалится.

Практические особенности использования jose

Библиотека jose строго следует спецификациям JWT RFC 7519 и JWS RFC 7515, поэтому:

  • не допускает неявных преобразований ключей
  • требует явного указания алгоритма
  • работает в async-режиме для криптографических операций
  • поддерживает импорт ключей в формате PEM и JWK

При работе в production важно:

  • хранить приватные ключи в secure storage (KMS, Vault)
  • ротировать ключи через JWKS endpoint
  • ограничивать время жизни assertion до минимального значения
  • логировать jti для аудита

Формирование безопасной архитектуры

Схема использования private_key_jwt обычно выглядит так:

  • клиент регистрируется в Authorization Server
  • загружается публичный ключ (JWKS)
  • клиент генерирует JWT assertion при каждом запросе токена
  • сервер проверяет подпись через JWKS
  • токен выдаётся только при успешной валидации

Такой подход полностью исключает необходимость хранения client_secret и повышает устойчивость к утечкам конфигурации.