Практика: верификация JWT через JWKS

Верификация JWT через JWKS строится вокруг идеи динамического получения публичных ключей, используемых для подписи токенов, и последующей проверки подписи без необходимости заранее хранить ключи в приложении. Такой подход особенно важен в распределённых системах, где ключи регулярно ротируются.

JWT (JSON Web Token) обычно состоит из трёх частей: заголовка, полезной нагрузки и подписи. При использовании асимметричного алгоритма (например, RS256) подпись формируется закрытым ключом, а проверка выполняется соответствующим публичным ключом. Именно здесь появляется JWKS (JSON Web Key Set) — JSON-документ, содержащий набор публичных ключей, опубликованных сервером авторизации.

JWKS представляет собой объект вида:

{
  "keys": [
    {
      "kty": "RSA",
      "kid": "abc123",
      "use": "sig",
      "alg": "RS256",
      "n": "...",
      "e": "AQAB"
    }
  ]
}

Ключевой элемент — kid (Key ID). Он связывает JWT с конкретным публичным ключом. В заголовке токена всегда присутствует соответствующее значение:

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "abc123"
}

Алгоритм проверки всегда начинается с сопоставления kid из токена и ключа из JWKS.

Получение JWKS и подготовка к проверке

В реальных системах JWKS обычно доступен по URL:

https://auth.example.com/.well-known/jwks.json

Работа с библиотекой jsrsasign требует преобразования JWK-ключа в формат, пригодный для проверки подписи.

Базовый запрос JWKS

async function fetchJWKS(url) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error('Ошибка загрузки JWKS');
  }
  return await response.json();
}

После получения набора ключей выполняется выбор нужного по kid.

Извлечение ключа по kid

function getJWKByKid(jwks, kid) {
  return jwks.keys.find(key => key.kid === kid);
}

Если ключ не найден, проверка JWT невозможна, и токен считается недействительным.

Конвертация JWK в PEM через jsrsasign

Библиотека jsrsasign предоставляет утилиту KEYUTIL.getKey, которая умеет преобразовывать JWK в объект ключа.

import { KEYUTIL, KJUR } from 'jsrsasign';

function jwkToPem(jwk) {
  return KEYUTIL.getKey(jwk);
}

На выходе получается объект, который может быть использован для проверки подписи JWT.

Проверка JWT с использованием JWKS

Основной процесс включает:

  1. Разбор JWT
  2. Извлечение kid
  3. Загрузка JWKS
  4. Поиск соответствующего ключа
  5. Конвертация ключа
  6. Проверка подписи

Разбор токена

function decodeHeader(jwt) {
  const parts = jwt.split('.');
  if (parts.length !== 3) {
    throw new Error('Некорректный JWT');
  }
  return JSON.parse(atob(parts[0]));
}

Полная проверка JWT

import { KJUR, KEYUTIL } from 'jsrsasign';

async function verifyJwtWithJwks(token, jwksUrl) {
  const header = decodeHeader(token);

  if (!header.kid) {
    throw new Error('Отсутствует kid в JWT');
  }

  const jwks = await fetchJWKS(jwksUrl);
  const jwk = getJWKByKid(jwks, header.kid);

  if (!jwk) {
    throw new Error('Подходящий ключ не найден в JWKS');
  }

  const publicKey = KEYUTIL.getKey(jwk);

  const isValid = KJUR.jws.JWS.verifyJWT(token, publicKey, {
    alg: [header.alg]
  });

  return isValid;
}

Проверка payload после валидации

После успешной проверки подписи имеет смысл дополнительно проверить содержимое токена:

function parsePayload(jwt) {
  const payload = jwt.split('.')[1];
  return JSON.parse(atob(payload));
}

Типовые проверки включают:

  • exp — срок действия
  • iss — издатель
  • aud — аудитория
function validateClaims(payload, expectedIssuer, expectedAudience) {
  const now = Math.floor(Date.now() / 1000);

  if (payload.exp && now > payload.exp) {
    throw new Error('JWT истёк');
  }

  if (payload.iss !== expectedIssuer) {
    throw new Error('Неверный issuer');
  }

  if (payload.aud !== expectedAudience) {
    throw new Error('Неверная аудитория');
  }
}

Кэширование JWKS

Запрос JWKS на каждый токен является неэффективным. Обычно используется кэш:

const jwksCache = {
  data: null,
  timestamp: 0
};

async function getCachedJWKS(url, ttl = 3600000) {
  const now = Date.now();

  if (jwksCache.data && now - jwksCache.timestamp < ttl) {
    return jwksCache.data;
  }

  jwksCache.data = await fetchJWKS(url);
  jwksCache.timestamp = now;

  return jwksCache.data;
}

Обработка ротации ключей

При ротации ключей возможна ситуация, когда kid из токена отсутствует в кэше. В таком случае выполняется принудительное обновление:

async function resolveKey(jwksUrl, kid) {
  let jwks = await getCachedJWKS(jwksUrl);
  let key = getJWKByKid(jwks, kid);

  if (!key) {
    jwks = await fetchJWKS(jwksUrl);
    jwksCache.data = jwks;
    jwksCache.timestamp = Date.now();
    key = getJWKByKid(jwks, kid);
  }

  return key;
}

Частые ошибки при работе с JWKS и jsrsasign

Несовпадение алгоритма

Если JWT подписан RS256, а в проверке не указан алгоритм, верификация может завершиться неуспешно.

Неверный формат ключа

jsrsasign ожидает корректный JWK. Отсутствие полей n или e делает ключ непригодным.

Игнорирование kid

Попытка проверять JWT без сопоставления kid приводит к использованию неправильного ключа при ротации.

Отсутствие проверки claims

Подпись подтверждает подлинность токена, но не его актуальность или принадлежность.

Особенности jsrsasign при работе с JWT

jsrsasign предоставляет несколько уровней работы с JWT:

  • низкоуровневая проверка подписи (KJUR.jws.JWS.verify)
  • высокоуровневая проверка JWT (verifyJWT)
  • утилиты для работы с ключами (KEYUTIL)

Высокоуровневый метод предпочтителен:

KJUR.jws.JWS.verifyJWT(jwt, key, {
  alg: ['RS256']
});

Но при JWKS интеграции ключ всегда динамический, поэтому основная сложность заключается не в проверке, а в управлении ключами.

Итерация полной цепочки проверки

  1. Получение JWT
  2. Декодирование заголовка
  3. Извлечение kid
  4. Получение JWKS
  5. Поиск ключа
  6. Конвертация JWK → KeyObject
  7. Проверка подписи через jsrsasign
  8. Проверка claims

Такая последовательность формирует основу безопасной обработки токенов в клиентских и серверных JavaScript-приложениях.