Discovery-документ и получение JWKS URI

В экосистеме OpenID Connect (OIDC) discovery-документ представляет собой стандартный JSON-ресурс, который описывает конфигурацию провайдера аутентификации. Он публикуется по фиксированному пути:

https://<issuer>/.well-known/openid-configuration

Этот документ служит точкой входа для автоматического получения всех необходимых параметров взаимодействия с провайдером идентификации. Вместо ручной настройки endpoints клиент получает структурированное описание возможностей сервера.

Типичное содержимое discovery-документа включает:

  • issuer — идентификатор провайдера
  • authorization_endpoint — endpoint авторизации
  • token_endpoint — endpoint выдачи токенов
  • userinfo_endpoint — endpoint пользовательской информации
  • jwks_uri — URI набора публичных ключей (JWKS)
  • response_types_supported, subject_types_supported и другие метаданные

Особое значение в контексте проверки JWT имеет параметр jwks_uri.


JWKS и роль jwks_uri

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

Формат JWKS представляет собой JSON:

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

Каждый ключ содержит:

  • kid — идентификатор ключа (Key ID)
  • kty — тип ключа (RSA, EC и т.д.)
  • криптографические параметры (n, e для RSA)

Поле jwks_uri в discovery-документе указывает, где можно получить этот набор ключей.


Получение discovery-документа вручную

Перед использованием JWKS можно явно получить конфигурацию провайдера:

const issuer = 'https://auth.example.com';

const res = await fetch(`${issuer}/.well-known/openid-configuration`);
const config = await res.json();

const jwksUri = config.jwks_uri;

После этого jwks_uri используется для загрузки ключей.


Использование JWKS в библиотеке jose

Библиотека jose предоставляет встроенные механизмы работы с удалёнными JWKS через функцию createRemoteJWKSet.

Основная идея заключается в том, что библиотека самостоятельно:

  • загружает JWKS по указанному URI
  • кэширует ключи
  • обновляет их при ротации
  • выбирает ключ по kid

Базовая проверка JWT через remote JWKS

import { jwtVerify, createRemoteJWKSet } from 'jose';

const issuer = 'https://auth.example.com';

// JWKS URI берётся из discovery-документа
const jwksUri = new URL(
  'https://auth.example.com/.well-known/jwks.json'
);

const JWKS = createRemoteJWKSet(jwksUri);

const token = 'eyJ...';

const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
  issuer: 'https://auth.example.com',
  audience: 'my-client-id'
});

В этом сценарии createRemoteJWKSet принимает URL JWKS и возвращает функцию-резолвер ключей.


Связь kid и выбор ключа

JWT содержит заголовок:

{
  "alg": "RS256",
  "kid": "key-1"
}

При верификации происходит следующее:

  1. Из JWT извлекается kid
  2. JWKS загружается или берётся из кэша
  3. Среди keys[] выбирается ключ с совпадающим kid
  4. Этот ключ используется для проверки подписи

Если ключ с нужным kid отсутствует, библиотека инициирует повторную загрузку JWKS.


Автоматическое обновление ключей

Важная особенность подхода через createRemoteJWKSet заключается в том, что библиотека не требует ручного управления ротацией ключей.

Поведение при изменениях:

  • ключи кэшируются для ускорения верификации
  • при ошибке проверки происходит повторная загрузка JWKS
  • новые ключи подхватываются автоматически

Это критично для провайдеров, которые регулярно ротируют ключи подписи.


Использование discovery-документа для автоматизации

Полный поток интеграции обычно выглядит так:

const issuer = 'https://auth.example.com';

const configRes = await fetch(`${issuer}/.well-known/openid-configuration`);
const config = await configRes.json();

const JWKS = createRemoteJWKSet(new URL(config.jwks_uri));

Далее JWKS используется во всех проверках JWT.


Проверка токена с дополнительными параметрами

const { payload } = await jwtVerify(token, JWKS, {
  issuer: config.issuer,
  audience: 'api-service',
  clockTolerance: 5
});

Здесь:

  • issuer предотвращает подмену токенов между системами
  • audience ограничивает использование токена конкретным сервисом
  • clockTolerance компенсирует расхождение времени между системами

Обработка ошибок при загрузке JWKS

Типичные сценарии ошибок:

  • недоступность jwks_uri
  • отсутствие ключа с нужным kid
  • некорректный формат JWKS
  • несоответствие алгоритмов

Пример обработки:

try {
  const { payload } = await jwtVerify(token, JWKS);
} catch (err) {
  if (err.code === 'ERR_JWKS_NO_MATCHING_KEY') {
    // возможно ключ был ротирован
  }

  throw err;
}

Кэширование и производительность

createRemoteJWKSet внутри себя реализует кэширование ключей, что снижает количество сетевых запросов.

Особенности поведения:

  • повторное использование JWKS между проверками
  • обновление при изменении kid
  • минимизация latency при массовой верификации токенов

Для высоконагруженных систем это критично, так как проверка JWT выполняется на каждом запросе.


Работа с несколькими issuer

В системах с несколькими провайдерами идентификации используется отдельный JWKS для каждого issuer:

const jwksAuth0 = createRemoteJWKSet(new URL('https://auth0.com/.well-known/jwks.json'));
const jwksKeycloak = createRemoteJWKSet(new URL('https://keycloak.local/realms/main/protocol/openid-connect/certs'));

Далее выбор JWKS происходит на основе iss из токена.


Безопасные аспекты использования discovery и JWKS

При работе с discovery-документом и JWKS важны следующие ограничения:

  • обязательное использование HTTPS
  • проверка соответствия issuer
  • запрет доверия произвольным jwks_uri
  • контроль аудитории токена
  • защита от SSRF при динамической загрузке конфигураций

Особенно критично не подменять discovery-документ без валидации issuer, так как он определяет всю цепочку доверия.


Ручное извлечение ключа без createRemoteJWKSet

Хотя библиотека предоставляет автоматизацию, возможен низкоуровневый подход:

const jwksRes = await fetch(config.jwks_uri);
const jwks = await jwksRes.json();

const key = jwks.keys.find(k => k.kid === protectedHeader.kid);

Однако этот вариант требует самостоятельной реализации:

  • кэширования
  • обновления ключей
  • обработки ротации
  • выбора алгоритмов

Поэтому в реальных системах он используется редко.