Несколько JWKS-источников и их приоритизация

Работа с несколькими JWKS-источниками возникает в системах, где токены выпускаются разными провайдерами идентификации, либо когда один провайдер использует несколько наборов ключей для разных окружений, регионов или клиентов. В таких архитектурах проверка JWT перестаёт быть линейной задачей «один issuer — один JWKS URL» и превращается в задачу маршрутизации и приоритизации источников ключей.

JWKS (JSON Web Key Set) представляет собой набор публичных ключей, используемых для проверки подписи JWT. Обычно он доступен по URL вида:

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

В реальных системах появляется несколько таких источников:

  • разные identity provider’ы (Auth0, Keycloak, Cognito, Azure AD)
  • мультиарендные системы (tenant1, tenant2, tenant3)
  • разделение окружений (dev / staging / prod)
  • ротация ключей с перекрытием периодов
  • отдельные сервисы, подписывающие токены для разных доменов

Каждый JWT содержит минимум два критичных поля:

  • iss (issuer) — кто выпустил токен
  • kid (key id) — идентификатор ключа подписи

Именно их комбинация определяет, из какого JWKS нужно брать ключ.

Базовый инструмент jose для JWKS

Библиотека jose предоставляет готовую инфраструктуру для работы с JWT и JWKS:

Основной механизм проверки через удалённый JWKS:

import { jwtVerify, createRemoteJWKSet } from 'jose'

const JWKS = createRemoteJWKSet(
  new URL('https://auth.example.com/.well-known/jwks.json')
)

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

Но эта модель предполагает один источник. При нескольких JWKS требуется расширение логики.

Стратегия маршрутизации JWKS

При нескольких источниках ключей появляется слой выбора JWKS перед проверкой подписи.

Ключевая идея: сначала определить, какой JWKS использовать, затем выполнить verify.

Простейшая маршрутизация по issuer

import { jwtVerify, createRemoteJWKSet } from 'jose'

const jwksMap = {
  'https://issuer-a.example.com': createRemoteJWKSet(
    new URL('https://issuer-a.example.com/.well-known/jwks.json')
  ),
  'https://issuer-b.example.com': createRemoteJWKSet(
    new URL('https://issuer-b.example.com/.well-known/jwks.json')
  )
}

async function verify(token) {
  const { payload, protectedHeader } = await jwtVerify(
    token,
    (header, token) => {
      const issuer = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()).iss
      return jwksMap[issuer]
    }
  )

  return payload
}

Однако такой подход содержит проблему: декодирование payload до проверки подписи снижает безопасность.

Корректная приоритизация через header (kid-based routing)

Безопаснее опираться на kid из заголовка JWT, не расшифровывая payload:

import { jwtVerify, createRemoteJWKSet } from 'jose'

const jwksA = createRemoteJWKSet(new URL('https://a.example.com/.well-known/jwks.json'))
const jwksB = createRemoteJWKSet(new URL('https://b.example.com/.well-known/jwks.json'))

const sources = [jwksA, jwksB]

async function keySelector(protectedHeader) {
  for (const jwks of sources) {
    try {
      return await jwks(protectedHeader, undefined)
    } catch {
      continue
    }
  }
  throw new Error('No matching key found')
}

async function verify(token) {
  return jwtVerify(token, keySelector)
}

Здесь реализована приоритизация источников по порядку: первый JWKS имеет более высокий приоритет, но при неудаче происходит fallback.

Приоритизация по issuer + kid (гибридная модель)

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

  • сначала выбирается группа JWKS по iss
  • затем внутри группы подбирается ключ по kid
const jwksGroups = {
  'issuer-a': createRemoteJWKSet(new URL('https://a.example.com/jwks.json')),
  'issuer-b': createRemoteJWKSet(new URL('https://b.example.com/jwks.json'))
}

async function verify(token) {
  const { protectedHeader, payload } = await jwtVerify(token, async (header) => {
    const issuer = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()).iss
    const jwks = jwksGroups[issuer]

    if (!jwks) {
      throw new Error('Unknown issuer')
    }

    return jwks(header, undefined)
  })

  return payload
}

Хотя здесь снова используется payload-декодирование, на практике это допустимо при условии предварительной валидации формата JWT и строгого контроля доверенных issuer.

Кэширование JWKS и приоритет обновлений

createRemoteJWKSet уже содержит встроенный кеш, но при множественных источниках важно учитывать:

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

Расширенный контроль:

const jwks = createRemoteJWKSet(
  new URL('https://auth.example.com/.well-known/jwks.json'),
  {
    cooldownDuration: 5000,
    cacheMaxAge: 600000
  }
)

При нескольких источниках приоритет часто влияет и на стратегию обновления:

  • primary JWKS обновляется чаще
  • secondary используется как fallback и может кэшироваться дольше

Ошибки при работе с несколькими JWKS

1. Перепутанные issuer и ключи

Если нет жёсткой привязки iss -> JWKS, возможна ситуация, когда токен валидируется чужим ключом при совпадении kid.

Решение — обязательная проверка issuer до доверия JWKS.

2. SSRF через динамические JWKS URL

Если JWKS URL формируется динамически на основе токена, возникает риск SSRF-атаки.

Недопустимо:

createRemoteJWKSet(new URL(`https://${issuer}/jwks.json`))

Допустимо только через whitelist.

3. Конфликт kid между провайдерами

Разные identity provider’ы могут использовать одинаковые kid. Без разделения по issuer это приводит к неверной валидации.

Архитектура приоритизации JWKS

На практике используется многоуровневая модель:

  1. Определение issuer (из заранее доверенного списка)
  2. Выбор JWKS группы
  3. Проверка kid внутри JWKS
  4. Fallback на резервные источники
  5. Кэширование результата маршрутизации

Схематично:

  • issuer map (строгий whitelist)
  • JWKS pool per issuer
  • ordered fallback chain
  • cached resolution layer

Оптимизация производительности

При множественных JWKS источниках критично:

  • избегать последовательных сетевых запросов
  • использовать параллельный запрос нескольких JWKS при неизвестном issuer
  • кэшировать resolved key per kid

Пример параллельной стратегии:

async function resolveKey(header) {
  const results = await Promise.any(
    sources.map(jwks => jwks(header, undefined))
  )
  return results
}

Это сокращает latency при большом количестве провайдеров.

Контроль доверия между источниками

Приоритизация JWKS источников всегда должна учитывать уровень доверия:

  • internal identity provider > external
  • production > staging
  • explicit issuer mapping > heuristic matching

Иерархия источников часто фиксируется конфигурационно:

const priority = [
  'internal-auth',
  'partner-auth',
  'public-auth'
]

И используется для последовательного fallback.

Поведение при конфликте ключей

Если два JWKS источника содержат валидный ключ для одного kid, приоритет должен определяться не совпадением, а источником:

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

Это предотвращает атаки через подмену ключей в менее доверенном источнике.

Итоговая модель работы с несколькими JWKS

Комбинация jose и многослойной маршрутизации приводит к следующей логике:

  • JWT не проверяется «в лоб» одним JWKS
  • сначала выбирается источник доверия
  • затем применяется проверка подписи
  • fallback используется только при явной иерархии доверия
  • кеширование распределяется по источникам независимо