JWK и node-jose

После нескольких недель поиска документации и примеров использования node-jose для:

  • Создайте конечную точку / jwks, чтобы открыть открытую часть ключей.
  • Создайте конечную точку / tokens, которая возвращает подписанный JWT с этими ключами.
  • Подтвердите токен, выпущенный как клиент
  • Поверните ключи, отображаемые на конечной точке / jwks

Я нашел очень мало, вот как я это сделал

Конечная точка JWKs

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

const fs = require('fs');
const jose = require('node-jose');
const keyStore = jose.JWK.createKeyStore()
keyStore.generate('RSA', 2048, {alg: 'RS256', use: 'sig' })
.then(result => {
  fs.writeFileSync(
    'keys.json', 
    JSON.stringify(keyStore.toJSON(true), null, '  ')
  )
})

вам не нужно добавлять null и 'empty-space' в качестве 2-го и 3-го аргумента для JSON stringify, но мне очень нравится, чтобы мои файлы были доступны для чтения человеческому глазу, и я передаю true методу toJSON(true), потому что этот флаг вернет общедоступный, но также и закрытый раздел асимметричного ключа, и мы будем использовать закрытый ключ позже для подписи токенов.

Теперь, когда у нас есть keys.json файл, готовый к открытию, давайте вернем открытый ключ в форме json с помощью expressJS:

router.get('/jwks', async (req, res) => {
  const ks = fs.readFileSync('keys.json')
  const keyStore = await jose.JWK.asKeyStore(ks.toString())
  
  res.send(keyStore.toJSON())
})

на этот раз, в отличие от создания ключа, мы не собираемся использовать true внутри метода toJSON(), потому что мы хотим только раскрыть открытый ключ. В результате вы должны увидеть что-то вроде этого.

{
  "keys": [
    {
      "kty": "RSA",
      "kid": "IiI4ffge7LZXPztrZVOt26zgRt0EPsWPaxAmwhbJhDQ",
      "use": "sig",
      "alg": "RS256",
      "e": "AQAB",
      "n": "1Sn1X_y-RUzGna0hR00Wu64ZtY5N5BVzpRIby9wQ5EZVyWL9DRhU5PXqM3Y5gzgUVEQu548qQcMKOfs46PhOQudz-HPbwKWzcJCDUeNQsxdAEhW1uJR0EEV_SGJ-jTuKGqoEQc7bNrmhyXBMIeMkTeE_-ys75iiwvNjYphiOhsokC_vRTf_7TOPTe1UQasgxEVSLlTsen0vtK_FXcpbwdxZt02IysICcX5TcWX_XBuFP4cpwI9AS3M-imc01awc1t7FE5UWp62H5Ro2S5V9YwdxSjf4lX87AxYmawaWAjyO595XLuIXA3qt8-irzbCeglR1-cTB7a4I7_AclDmYrpw"
  }
}

поэтому позвольте мне объяснить несколько важных частей этого ответа json, начиная с kid, это ключевой идентификатор, который позволит вам позже, если в массиве более одного ключа (и будет, когда пора повернуть ключ), чтобы сопоставить подпись вашего JWT с соответствующим открытым ключом для проверки. Https://tools.ietf.org/html/rfc7517#section-4.5

/ tokens endpoint и как подписать

router.get('/tokens', async (req, res) => {
  const ks = fs.readFileSync('keys.json')
  const keyStore = await jose.JWK.asKeyStore(ks.toString())
  const [key] = keyStore.all({ use: 'sig' })
  
  const opt = { compact: true, jwk: key, fields: { typ: 'jwt' } }
  const payload = JSON.stringify({
    exp: Math.floor((Date.now() + ms('1d')) / 1000),
    iat: Math.floor(Date.now() / 1000),
    sub: 'test',
  })
  const token = await jose.JWS.createSign(opt, key)
    .update(payload)
    .final()
  res.send({ token })
})

Начальная часть очень проста: 1 получить ключи, 2 создать хранилище ключей и 3 предоставить ключ для подписи JWT. Теперь стоит обратить внимание на аргумент opt, который включает compact: true и поля typ: 'jwt', который помогает нам следовать стандарту JWT, и после того, как мы вернем токен, мы можем дважды проверить назначение этих полей в http://jwt.io. Это также стоит упомянуть, что iat и exp (для выданных_ат и истечения срока соответственно) включают пол по математике и деление более 1000, потому что стандарт JWT говорит о времени в секундах, а JS по умолчанию выставляет миллисекунды.

Подтвердите токен

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

const jwktopem = require('jwk-to-pem')
const jwt = require('jsonwebtoken')
router.post('/verify', async (req, res) => {
  const { token } = req.body
  const { data } = await axios.get('http://localhost:4040/jwks')
  const [ firstKey ] = data.keys
  const publicKey = jwktopem(firstKey)
  try {
    const decoded = jwt.verify(token, publicKey)
    res.send(decoded)
  } catch (e) {
    res.send({ error: e })
  }
})

давайте сначала проясним предположения, 1 ваша конечная точка получит json с токеном внутри, 2 у вас есть URL-адрес конечной точки jwks, 3 на данный момент конечная точка / jwks имеет только один ключ, поэтому вам не нужно перебирать массив, пытаясь сопоставить kid, который находится внутри заголовка вашего токена. С предыдущей частью кода вы можете поиграть, например, испуская JWT, срок действия которого уже истек.

Ключевое вращение

Наконец, мы дошли до сути статьи. давайте установим ожидания, мы хотим здесь иметь возможность подписывать JWT с другим ключом, но также позволять клиентам, которые ранее подписали JWT, проверять с помощью конечной точки / jwks, и, в конце концов, клиенты не могут иметь старый токен (по истечении 24 часов с учетом установленного нами срока действия) мы удалим неиспользуемый ключ.

Я собираюсь использовать здесь конечную точку для запуска действий добавления / удаления, но, вероятно, вам следует использовать chronjob или любой другой метод, который не отображается.

app.get('/add', async (req, res) => {
  const ks = fs.readFileSync('keys.json')
  const keyStore = await jose.JWK.asKeyStore(ks.toString())
  await keyStore.generate('RSA', 2048, { alg: 'RS256', use: 'sig' })
  const json = keyStore.toJSON(true)
  json.keys = json.keys.reverse()
  fs.writeFileSync('keys.json', JSON.stringify(json, null, '  '))
  res.send(keyStore.toJSON())
})

С помощью этой fn мы пытаемся добавить новый ключ к текущему массиву; когда вы выполняете метод .generate() для ранее созданного хранилища, как в этом случае (мы реплицируем существующее хранилище из keys.json), он добавит новый ключ в этот массив, и после этого я сделаю reverse порядок ключей перед сохранением, чтобы сначала сохранить самый последний ключ, поэтому, когда я пытаюсь подписать следующий токен, я могу продолжать использовать деструктуризацию как const [key] = keyStore.all..., чтобы получить первый ключ, не изменяя конечную точку / tokens чтобы продолжить подписывать ключи с последним сертификатом.

Теперь, чтобы реализовать часть ключа удаления (мы должны запустить это по истечении максимального времени, которое мы применяем к токенам в нашем случае 24 часа), все, что нам нужно, это простой JS, но я использую немного node-jose, чтобы вернуть результат и проверьте, что работает.

app.get('/del', async (req, res) => {
  const ks = JSON.parse(fs.readFileSync('keys.json'))
  if (ks.keys.length > 1) ks.keys.pop()
  fs.writeFileSync('keys1.json', JSON.stringify(ks, null, '  '))
  const keyStore = await jose.JWK.asKeyStore(JSON.stringify(ks))
  res.send(keyStore.toJSON())
})

помните, что в предыдущем разделе было json.keys.reverse()? эта часть кода позволяет нам теперь просто выполнить .pop() с массивом, чтобы удалить последний ключ, а затем мы снова сохраним его в файле keys.json, и для этой статьи / руководства мы создаем хранилище ключей и открываем публичная часть, чтобы перепроверить, что есть только один ключ, и это новый.

ПРИМЕЧАНИЯ: для времени, когда доступны два ключа, после того, как мы запускаем конечную точку / add, но до того, как мы запускаем / del; клиенту нужно будет выяснить, какой ключ соответствует его JWT, обычным решением является итерация по массиву и остановка проверки, когда kid совпадает.

Может быть, мне стоит опубликовать репо позже 😅