Source: JWKSet.js

'use strict'

/**
 * Dependencies
 * @ignore
 */
const { JWA } = require('@trust/jwa')
const crypto = require('@trust/webcrypto')
const fetch = require('node-fetch')
const sift = require('sift')
const fs = require('fs')

/**
 * Module Dependencies
 * @ignore
 */
const JWK = require('./JWK')
const { DataError, OperationError } = require('./errors')

/**
 * JWKSet
 * @ignore
 */
class JWKSet {

  /**
   * constructor
   *
   * @class JWKSet
   *
   * @description
   * JSON Web Key Set ([IETF RFC7517 Section 5.](https://tools.ietf.org/html/rfc7517#section-5))
   *
   * @param  {(Object|Array)} [data]
   */
  constructor (data = {}) {
    if (Array.isArray(data)) {
      this.keys = data
    } else {
      Object.assign(this, data)
    }

    if (!this.keys) {
      this.keys = []
    }
  }

  /**
   * generateKeys
   *
   * @description
   * Instantiate a new JWKSet and generate one or many JWK keypairs and secret keys.
   *
   * @example <caption>Simple RSA keypair</caption>
   * JWKSet.generateKeys('RS256')
   *   .then(console.log)
   * // => { keys: [
   * //      { d: '...',
   * //        kty: 'RSA',
   * //        alg: 'RS256',
   * //        kid: 'abcd',
   * //        ... },
   * //      { kty: 'RSA',
   * //        alg: 'RS256',
   * //        kid: 'abcd',
   * //        ... }
   * //    ] }
   *
   * @example <caption>Multiple keypairs</caption>
   * JWKSet.generateKeys(['RS256', 'ES256'])
   *   .then(console.log)
   * // => { keys: [
   * //      { ..., kty: 'RSA', alg: 'RS256' },
   * //      { ..., kty: 'RSA', alg: 'RS256' },
   * //      { ..., kty: 'EC', alg: 'ES256' },
   * //      { ..., kty: 'EC', alg: 'ES256' }] }
   *
   * @example <caption>Object descriptor RSA keypair</caption>
   * let keyDescriptor = {
   *   alg: 'RS256',
   *   kid: 'custom',
   *   modulusLength: 1024
   * }
   *
   * JWKSet.generateKeys(keyDescriptor)
   *   .then(console.log)
   * // => { keys: [
   * //      { ..., alg: 'RS256', kid: 'custom' },
   * //      { ..., alg: 'RS256', kid: 'custom' }] }
   *
   * @example <caption>Mixed input, multiple keypairs</caption>
   * let keyDescriptor = {
   *   alg: 'RS512',
   *   modulusLength: 1024
   * }
   *
   * JWKSet.generateKeys([keyDescriptor, 'ES256'])
   *   .then(console.log)
   * // => { keys: [
   * //      { ..., kty: 'RSA', alg: 'RS512' },
   * //      { ..., kty: 'RSA', alg: 'RS512' },
   * //      { ..., kty: 'EC', alg: 'ES256' },
   * //      { ..., kty: 'EC', alg: 'ES256' }] }
   *
   * @param  {(String|Object|Array)} data
   * @return {Promise.<JWKSet>} A promise that resolves a new JWKSet containing the generated key pairs.
   */
  static generateKeys (data) {
    return Promise.resolve(new JWKSet())
      .then(jwks => jwks.generateKeys(data).then(() => jwks))
  }

  /**
   * importKeys
   *
   * @description
   * Instantiate a new JWKSet and import keys from JSON string, JS object, remote URL or file path.
   *
   * @example <caption>Import keys from JSON string</caption>
   * let jsonJwkSet = '{"meta":"abcd","keys":[...]}'
   *
   * JWKSet.importKeys(jsonJwkSet)
   *   .then(console.log)
   * // => { meta: 'abcd', keys: [...] }
   *
   * @example <caption>Import keys from object</caption>
   * let jwkSet = {
   *   meta: 'abcd',
   *   keys: [...]
   * }
   *
   * JWKSet.importKeys(jwkSet)
   *   .then(console.log)
   * // => { meta: 'abcd', keys: [...] }
   *
   * @example <caption>Import keys from URL</caption>
   * let jwkSetUrl = 'https://idp.example.com/jwks'
   *
   * JWKSet.importKeys(jwkSetUrl)
   *   .then(console.log)
   * //
   * // HTTP/1.1 200 OK
   * // Content-Type: application/json
   * //
   * // {"meta":"abcd","keys":[...]}
   * //
   * // => { meta: 'abcd',
   * //      keys: [...] }
   *
   * @example <caption>Import keys from file path</caption>
   * let jwkSetPath = './path/to/my/file.json'
   *
   * JWKSet.importKeys(jwkSetPath)
   *   .then(console.log)
   * //
   * // Contents of ./path/to/my/file.json -
   * // {"meta":"abcd","keys":[...]}
   * //
   * // => { meta: 'abcd',
   * //      keys: [...] }
   *
   * @example <caption>Mixed input, multiple sources</caption>
   * let jwkSetPath = './path/to/my/file.json'
   * let jwkSet = { meta: 'abcd', keys: [...] }
   *
   * JWKSet.importKeys([jwkSet, jwkSetPath])
   *   .then(console.log)
   * //
   * // Contents of ./path/to/my/file.json -
   * // {"other":"efgh","keys":[...]}
   * //
   * // => { meta: 'abcd',
   * //      other: 'efgh',
   * //      keys: [...] }
   *
   * @param  {(String|Object|Array)} data
   * @return {Promise.<JWKSet>} A promise that resolves a new JWKSet containing the generated key pairs.
   */
  static importKeys (data) {
    return Promise.resolve(new JWKSet())
      .then(jwks => jwks.importKeys(data).then(() => jwks))
  }

  /**
   * generateKeys
   *
   * @description
   * Generate additional keys and include them in the JWKSet.
   *
   * @example <caption>Simple RSA keypair</caption>
   * jwks.generateKeys('RS256')
   *   .then(console.log)
   * // => [
   * //      { kty: 'RSA' },
   * //      { kty: 'RSA' }
   * //    ]
   *
   * @example <caption>Multiple keypairs</caption>
   * jwks.generateKeys(['RS256', 'ES256'])
   *   .then(console.log)
   * // => [
   * //      [ { kty: 'RSA' },
   * //        { kty: 'RSA' } ],
   * //      [ { kty: 'EC' },
   * //        { kty: 'EC' } ] ]
   *
   * @example <caption>Object descriptor RSA keypair</caption>
   * let keyDescriptor = {
   *   alg: 'RS256',
   *   kid: 'custom',
   *   modulusLength: 1024
   * }
   *
   * jwks.generateKeys(keyDescriptor)
   *   .then(console.log)
   * // => [ { kty: 'RSA', kid: 'custom' },
   * //      { kty: 'RSA', kid: 'custom' } ]
   *
   * @example <caption>Mixed input, multiple keypairs</caption>
   * let keyDescriptor = {
   *   alg: 'RS512',
   *   modulusLength: 1024
   * }
   *
   * jwks.generateKeys([keyDescriptor, 'ES256'])
   *   .then(console.log)
   * // => [
   * //      [ { kty: 'RSA' },
   * //        { kty: 'RSA' } ],
   * //      [ { kty: 'EC' },
   * //        { kty: 'EC' } ]
   * //    ]
   *
   * @param  {(String|Object|Array)} data
   * @return {Promise.<Array.<JWK>, Array.<Array.<JWK>>>} A promise that resolves the newly generated key pairs after they are added to the JWKSet instance.
   */
  generateKeys (data) {
    let cryptoKeyPromise, alg

    // Array of objects/alg strings
    if (Array.isArray(data)) {
      return Promise.all(data.map(item => this.generateKeys(item)))

    // JWA alg string
    } else if (typeof data === 'string' && data !== '') {
      alg = data

      // TODO this should not default to ['sign', 'verify'] and causes problems for generateing symmetric keys
      cryptoKeyPromise = JWA.generateKey(alg, { key_ops: ['sign', 'verify'], alg })

    // Key descriptor object
    } else if (typeof data === 'object' && data !== null) {
      let { alg: algorithm, key_ops } = data
      alg = algorithm

      if (!alg) {
        return Promise.reject(new DataError('Valid JWA algorithm required for generateKey'))
      }

      if (!key_ops) {
        data.key_ops = ['sign', 'verify']
      }

      cryptoKeyPromise = JWA.generateKey(alg, data)

    // Invalid input
    } else {
      return Promise.reject(new DataError('Invalid input'))
    }

    return cryptoKeyPromise
      .then(({ privateKey, publicKey }) => [privateKey, publicKey])
      .then(keys => Promise.all(keys.map(key => JWK.fromCryptoKey(key, { alg }))))
      .then(keys => {
        this.keys = this.keys.concat(keys)
        return keys
      })
  }

  /**
   * importKeys
   *
   * @description
   * Import additional keys and include them in the JWKSet.
   *
   * @example <caption>Import keys from JSON string</caption>
   * let jsonJwkSet = '{"meta":"abcd","keys":[...]}'
   *
   * jwks.importKeys(jsonJwkSet)
   *   .then(console.log)
   * // => [ {...},
   * //      {...} ]
   *
   * @example <caption>Import keys from object</caption>
   * let jwkSet = {
   *   meta: 'abcd',
   *   keys: [...]
   * }
   *
   * jwks.importKeys(jwkSet)
   *   .then(console.log)
   * // => [ {...},
   * //      {...} ]
   *
   * @example <caption>Import keys from URL</caption>
   * let jwkSetUrl = 'https://idp.example.com/jwks'
   *
   * jwks.importKeys(jwkSetUrl)
   *   .then(console.log)
   * //
   * // HTTP/1.1 200 OK
   * // Content-Type: application/json
   * //
   * // {"meta":"abcd","keys":[...]}
   * //
   * // => [ {...},
   * //      {...} ]
   *
   * @example <caption>Import keys from file path</caption>
   * let jwkSetPath = './path/to/my/file.json'
   *
   * jwks.importKeys(jwkSetPath)
   *   .then(console.log)
   * //
   * // Contents of ./path/to/my/file.json -
   * // {"meta":"abcd","keys":[...]}
   * //
   * // => [ {...},
   * //      {...} ]
   *
   * @example <caption>Mixed input, multiple sources</caption>
   * let jwkSetPath = './path/to/my/file.json'
   * let jwkSet = { meta: 'abcd', keys: [...] }
   *
   * jwks.importKeys([jwkSet, jwkSetPath])
   *   .then(console.log)
   * //
   * // Contents of ./path/to/my/file.json -
   * // {"other":"efgh","keys":[...]}
   * //
   * // => [ {...},
   * //      {...},
   * //      {...},
   * //      {...} ]
   *
   * @param  {(String|Object|Array)} data
   * @param  {JWK} [kek] - Key encryption key.
   * @return {Promise.<Array.<JWK>>} A promise that resolves the newly imported key pairs after they are added to the JWKSet instance.
   *
   * @todo Import encrypted JWKSet
   */
  importKeys (data) {
    if (!data) {
      return Promise.reject(new DataError('Invalid input'))
    }

    // Import Array of JWKs
    if (Array.isArray(data)) {
      return Promise.all(data.map(item => this.importKeys(item)))
    }

    if (typeof data === 'string') {
      // Stringified JWK or JWKSet
      if (data.startsWith('{') || data.startsWith('[')) {
        return Promise.resolve()
          .then(() => JSON.parse(data))
          .then(parsed => this.importKeys(parsed))
          .catch(error => Promise.reject(new DataError('Invalid JSON String')))

      // Import from URL
      } else if (data.startsWith('http')) {
        return fetch(data)
          .then(res => res.json())
          .then(json => this.importKeys(json))
          .catch(error => Promise.reject(new DataError(`Failed to fetch remote JWKSet ${data}`)))

      // Import from File
      } else {
        return Promise.resolve()
          .then(() => fs.readFileSync(data, 'utf8'))
          .then(file => this.importKeys(file))
          .catch(error => Promise.reject(new DataError(`Invalid file path ${data}`)))
      }
    }

    // Import JWKSet Object
    if (typeof data === 'object' && data !== null && data.keys) {
      // Assign non-keys property to the JWKSet
      let meta = Object.keys(data)
        .filter(key => key !== 'keys')
        .reduce((state, current) => {
          state[current] = data[current]
          return state
        }, {})
      Object.assign(this, meta)

      // Import keys
      return this.importKeys(data.keys)
    }

    return JWK.importKey(data).then(jwk => {
      this.keys.push(jwk)
      return jwk
    })
  }

  /**
   * filter
   *
   * @description
   * Execute a filter query on the JWKSet keys.
   *
   * @example <caption>Function predicate</caption>
   * let predicate = key => key.key_ops.includes('sign')
   *
   * let filtered = jwks.filter(predicate)
   * // => [ { ..., key_ops: ['sign'] } ]
   *
   * @example <caption>MongoDB-like object predicate (see [Sift]{@link https://github.com/crcn/sift.js})</caption>
   * let predicate = { key_ops: { $in: ['sign', 'verify'] } }
   *
   * let filtered = jwks.filter(predicate)
   * // => [ { ..., key_ops: ['sign'] },
   * //      { ..., key_ops: ['verify'] } ]
   *
   * @param  {(Function|Object)} predicate - Filter function or predicate object
   * @return {Array.<JWK>} An array of JWKs matching the filter predicate.
   */
  filter (predicate) {
    let { keys } = this

    // Function predicate
    if (typeof predicate === 'function') {
      return keys.filter(predicate)

    // Object
    } else if (typeof predicate === 'object') {
      return sift(predicate, keys)

    // Invalid input
    } else {
      throw new OperationError('Invalid predicate')
    }
  }

  /**
   * find
   *
   * @description
   * Execute a find query on the JWKSet keys.
   *
   * @example <caption>Function predicate</caption>
   * let predicate = key => key.key_ops.includes('sign')
   *
   * let filtered = jwks.find(predicate)
   * // => { ..., key_ops: ['sign'] }
   *
   * @example <caption>MongoDB-like object predicate (see [Sift]{@link https://github.com/crcn/sift.js})</caption>
   * let predicate = { key_ops: { $in: ['sign', 'verify'] } }
   *
   * let filtered = jwks.find()
   * // => { ..., key_ops: ['sign'] }
   *
   * @param  {(Function|Object)} predicate - Find function or predicate object
   * @return {JWK} The _first_ JWK matching the find predicate.
   */
  find (predicate) {
    let { keys } = this

    // Function predicate
    if (typeof predicate === 'function') {
      return keys.find(predicate)

    // Object
    } else if (typeof predicate === 'object') {
      let sifter = sift(predicate)
      return keys.find(sifter)

    // Invalid input
    } else {
      throw new OperationError('Invalid predicate')
    }
  }

  /**
   * rotate
   * @ignore
   *
   * @param  {(JWK|Array|Object|Function)} keys - jwk, array of jwks, filter predicate object or function.
   * @return {Promise}
   *
   * @todo rotate
   */
  rotate (keys) {
    return Promise.resolve(this)
  }

  /**
   * exportKeys
   *
   * @description
   * Serialize the JWKSet for storage or transmission.
   *
   * @param  {JWK} [kek] - optional encryption key
   * @return {String} The JSON serialized string of the JWKSet.
   *
   * @todo Encryption
   */
  exportKeys (kek) {
    return JSON.stringify(this)
  }

  /**
   * publicJwks
   *
   * @type {String}
   *
   * @description
   * The publishable JSON serialized string of the JWKSet. Returns _only public keys_.
   *
   * @todo Memoization
   */
  get publicJwks () {
    let keys = this.filter(key => key.cryptoKey.type === 'public')
    let metadata = Object.keys(this)
      .filter(field => field !== 'keys')
      .reduce((state, current) => {
        state[current] = this[current]
        return state
      }, {})
    let publish = Object.assign({}, metadata, { keys })

    return JSON.stringify(publish, null, 2)
  }
}

/**
 * Exports
 * @ignore
 */
module.exports = JWKSet