Интеграционное тестирование JWKS-эндпоинта

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

  • генерация ключей
  • публикация JWKS
  • получение ключа клиентом
  • проверка подписи токена

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


Подготовка тестового окружения

Интеграционные тесты требуют изолированной среды. Основные компоненты:

  • HTTP-сервер с JWKS-эндпоинтом
  • механизм генерации ключей
  • тестовый клиент для верификации JWT

Пример генерации ключевой пары:

import { generateKeyPair } from 'jose'

const { publicKey, privateKey } = await generateKeyPair('RS256')

Экспорт публичного ключа в JWK:

import { exportJWK } from 'jose'

const jwk = await exportJWK(publicKey)
jwk.kid = 'test-key-id'
jwk.alg = 'RS256'
jwk.use = 'sig'

Реализация JWKS-эндпоинта

Минимальный сервер на Express:

import express from 'express'

const app = express()

app.get('/.well-known/jwks.json', (req, res) => {
  res.json({
    keys: [jwk]
  })
})

const server = app.listen(3000)

Ключевые требования:

  • Content-Type: application/json
  • наличие поля keys
  • каждый ключ должен содержать kid

Генерация JWT для тестирования

JWT подписывается приватным ключом:

import { SignJWT } from 'jose'

const token = await new SignJWT({ sub: '123' })
  .setProtectedHeader({ alg: 'RS256', kid: 'test-key-id' })
  .setIssuedAt()
  .setExpirationTime('2h')
  .sign(privateKey)

Важно:

  • kid должен совпадать с ключом в JWKS
  • алгоритм (alg) должен соответствовать

Подключение удалённого JWKS

Для верификации используется createRemoteJWKSet:

import { createRemoteJWKSet, jwtVerify } from 'jose'

const JWKS = createRemoteJWKSet(
  new URL('http://localhost:3000/.well-known/jwks.json')
)

Проверка токена:

const { payload } = await jwtVerify(token, JWKS)

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

jose автоматически кэширует JWKS. Это важно учитывать в тестах:

  • повторные запросы могут не обращаться к серверу
  • необходимо управлять временем жизни кэша

Пример контроля:

const JWKS = createRemoteJWKSet(
  new URL('http://localhost:3000/.well-known/jwks.json'),
  {
    cacheMaxAge: 0
  }
)

Это отключает кэширование для чистоты теста.


Сценарии интеграционного тестирования

1. Успешная верификация

  • JWKS доступен
  • ключ совпадает
  • подпись корректна

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


2. Несуществующий kid

JWT содержит kid, отсутствующий в JWKS:

.setProtectedHeader({ alg: 'RS256', kid: 'wrong-id' })

Ожидается ошибка:

  • JWKSNoMatchingKey

3. Недоступный JWKS-эндпоинт

Сервер выключен или URL неверный:

createRemoteJWKSet(new URL('http://localhost:9999/jwks'))

Ожидается ошибка сети.


4. Некорректный формат JWKS

Ответ сервера:

{ "invalid": true }

Ожидается ошибка парсинга.


5. Просроченный токен

.setExpirationTime('1s')

После задержки:

await new Promise(r => setTimeout(r, 2000))

Ожидается:

  • ошибка JWTExpired

Мокирование и контроль зависимостей

Интеграционные тесты должны быть детерминированными:

  • фиксированный kid
  • контролируемое время (sinon.useFakeTimers или аналог)
  • изолированный HTTP-сервер

Остановка сервера после теста:

afterAll(() => {
  server.close()
})

Параллельные тесты и изоляция

При параллельном запуске:

  • использовать уникальные порты
  • избегать глобального состояния
  • генерировать ключи на каждый тест

Проверка обновления ключей (rotation)

Сценарий:

  1. JWKS содержит ключ A
  2. токен подписан A
  3. JWKS обновляется → ключ B
  4. новый токен подписан B

Проверка:

  • старый токен должен валидироваться (если ключ A ещё в JWKS)
  • новый токен должен использовать B

Пример обновления:

let keys = [jwkA]

app.get('/jwks', (req, res) => {
  res.json({ keys })
})

// позже
keys = [jwkB]

Таймауты и устойчивость

createRemoteJWKSet поддерживает настройки:

createRemoteJWKSet(url, {
  timeoutDuration: 2000
})

Тестируется:

  • зависание сервера
  • медленные ответы

Безопасность в тестах

Даже в тестовой среде важно:

  • использовать реальные алгоритмы (RS256, ES256)
  • не упрощать структуру JWT
  • избегать отключения проверок

Проверка заголовков HTTP

JWKS-эндпоинт должен корректно отдавать:

  • Cache-Control
  • Content-Type

Тест:

const res = await fetch('/jwks')
expect(res.headers.get('content-type')).toContain('application/json')

Логирование и отладка

При ошибках верификации полезно:

  • логировать kid
  • логировать URL JWKS
  • фиксировать тело ответа

Типичные ошибки

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

Расширенные сценарии

Несколько ключей в JWKS

{
  "keys": [key1, key2, key3]
}

Тестируется выбор ключа по kid.


Поддержка разных алгоритмов

JWKS может содержать:

  • RSA
  • EC

Важно проверять:

  • корректный выбор ключа
  • соответствие alg

Проверка audience и issuer

jwtVerify(token, JWKS, {
  issuer: 'https://auth.example.com',
  audience: 'api'
})

Тестируются ошибки:

  • JWTClaimValidationFailed

Организация тестов

Рекомендуемая структура:

tests/
  jwks/
    setup.js
    success.test.js
    errors.test.js
    rotation.test.js

Производительность

При большом количестве тестов:

  • переиспользовать сервер
  • минимизировать генерацию ключей
  • контролировать кэш

Интеграция с CI

В CI важно:

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

Использование HTTPS

Для более реалистичных тестов:

  • запуск сервера с TLS
  • проверка сертификатов

Итоговая цель интеграционных тестов

Гарантировать, что:

  • JWKS корректно публикуется
  • клиент правильно получает ключ
  • JWT проходит полную проверку

Такой подход исключает ошибки, которые невозможно выявить на уровне unit-тестов, и обеспечивает надёжность аутентификации в распределённых системах.