fastify / fastify-jwt

JWT utils for Fastify
MIT License
498 stars 97 forks source link

Using fastify-jwt along with Auth0 #72

Closed mattduffield closed 4 years ago

mattduffield commented 4 years ago

Hello,

I have used fastify-jwt in the past where my servers were in charge of issuing JWT tokens, etc. However, I am now trying to use Auth0 as the authority. I want to use Fastify as my server and verify tokens sent from clients and devices that have previously authenticate using Auth0 directly.

My question/request is how is this achieved using fastify-jwt? I have seen several other node packages but all based on the premise of using Express. I don't want to use Express and want to have everything working the "Fastify" way. Would it be possible to provide a working repo that uses RS256 and allow us to mark up our endpoints using preValidation: [fastify.authenticate]?

Thanks in advanced!

mcollina commented 4 years ago

Thanks for reaching out. Would you like to send a PR with that example? We are happy to review!

jsumners commented 4 years ago

I could not figure out how to do it with fastify-jwt and ended up writing my own in-app plugin:

'use strict';

const jwksFactory = require('jwks-rsa');
const jwt = require('jsonwebtoken');

module.exports = function jwtPlugin(instance, opts, done) {
  if (!process.env.JWKS_URL) {
    instance.decorateRequest('jwtVerify', () => {
      throw Error('missing env var JWKS_URL');
    });
    return done();
  }

  const jwks = jwksFactory({
    cache: true,
    rateLimit: true,
    jwksUri: process.env.JWKS_URL
  });

  instance.decorateRequest('jwtVerify', (token, options) => {
    return new Promise(promise);
    function promise(resolve, reject) {
      verifyWrapper(token, jwks, options, (err, decoded) => {
        if (err) {
          return reject(err);
        }
        resolve(decoded);
      });
    }
  });

  done();
};
module.exports[Symbol.for('skip-override')] = true;

function verifyWrapper(token, jwks, options, cb) {
  jwt.verify(token, getKey, options, (err, decoded) => {
    if (err) {
      return cb(err);
    }
    cb(null, decoded);
  });

  function getKey(header, callback) {
    jwks.getSigningKey(header.kid, (err, key) => {
      if (err) {
        return callback(err);
      }
      callback(null, key.publicKey || key.rsaPublicKey);
    });
  }
}
mattduffield commented 4 years ago

Hi @jsumners, thanks for the plugin. Can you show how you are using it in an endpoint?

jsumners commented 4 years ago

https://www.fastify.io/docs/latest/Hooks/#prevalidation

https://www.fastify.io/docs/latest/Getting-Started/

mattduffield commented 4 years ago

Thanks @jsumners, I found a way to do it exactly like fastify-jwt with an extra package. I wanted the same signature so that it was consistent.

mcollina commented 4 years ago

I’m reopening this, as it should be easy to setup.

mattduffield commented 4 years ago

Thanks, I was going to ask about that. I think having it consolidated would be really awesome!

ShogunPanda commented 4 years ago

I have a working example with validates Auth0 issued tokens, both HS256 and RS256 using fastify-jwt.

The dependencies are:

{
  "dependencies": {
    "auth0": "^2.20.0",
    "boom": "^7.3.0",
    "dotenv": "^8.2.0",
    "fastify": "^2.10.0",
    "fastify-jwt": "^1.2.0",
    "got": "^9.6.0",
    "http-status-codes": "^1.4.0"
  }
}

It uses environment variable for configuration, here's the required ones:

AUTH0_DOMAIN=YOURDOMAIN.auth0.com # Can be omitted if you're verifying HS256 tokens and you're providing AUTH0_AUDIENCE. If AUTH0_AUDIENCE. it should match the audience of the tokens
AUTH0_AUDIENCE=YOUR_AUTH0_API_URL # Can be omitted. Anyway should match the audience of the tokens
AUTH0_CLIENT_SECRET=... # Can be omitted. Only needed when verifying HS256 tokens

And here's the code:

require('dotenv/config')

const { AuthenticationClient } = require('auth0')
const { badData, forbidden, internal, notFound, unauthorized } = require('boom')
const fastify = require('fastify')
const fastifyJwt = require('fastify-jwt')
const { FORBIDDEN, INTERNAL_SERVER_ERROR, NOT_FOUND, OK, UNAUTHORIZED } = require('http-status-codes')
const got = require('got')

function handleErrors(error, _r, reply) {
  let boom = error

  if (!boom.isBoom) {
    // Serialize the error stack
    const cwd = process.cwd()
    let stack = []

    if (boom.stack) {
      stack = boom.stack
        .split('\n')
        .slice(1)
        .map(s =>
          s
            .trim()
            .replace(/^at /, '')
            .replace(cwd, '$ROOT')
        )
    }

    // Message must be passed as data otherwise Boom will hide it
    boom = internal('', { message: `[${boom.code || boom.name}] ${boom.message}`, stack })
  }

  reply
    .code(boom.output.statusCode)
    .type('application/json')
    .headers(boom.output.headers)
    .send({ ...boom.output.payload, ...boom.data })
}

function handleNotFoundError(_r, reply) {
  reply.code(NOT_FOUND).send(notFound('Not found.'))
}

async function getSecret(request, reply, cb) {
  try {
    const { header } = request.jwtDecode()

    // If the algorithm is not using RS256, the encryption key is Auth0 client secret
    if (header.alg.startsWith('HS')) {
      return cb(null, process.env.AUTH0_CLIENT_SECRET)
    }

    // Hit the well-known URL in order to get the key
    const response = await got(`${request.auth0Domain}.well-known/jwks.json`, { json: true })

    // Find the key with ID and algorithm matching the JWT token header
    const key = response.body.keys.find(k => k.alg == header.alg && k.kid === header.kid)

    if (!key) {
      throw new Error('No matching key found in the set.')
    }

    // certToPEM extracted from https://github.com/auth0/node-jwks-rsa/blob/master/src/utils.js
    cb(null, '-----BEGIN CERTIFICATE-----\n@\n-----END CERTIFICATE-----\n'.replace('@', key.x5c[0]))
  } catch (e) {
    let message = e.response
      ? `Unable to get the JWS: [HTTP ${e.response.statusCode}] ${JSON.stringify(e)}`
      : `Unable to get the JWS: ${e.message}`

    cb(internal('', { message }))
  }
}

async function start() {
  const server = fastify({ logger: true })

  try {
    // Error handling
    server.setErrorHandler(handleErrors)
    server.setNotFoundHandler(handleNotFoundError)

    // Normalize the domain in order to get a good URL for JWKS
    const domain = new URL(`https://${process.env.AUTH0_DOMAIN || 'localhost'}`).toString()

    // Setup Fastify-JWT
    server.register(fastifyJwt, {
      secret: getSecret,
      verify: {
        aud: process.env.AUTH0_AUDIENCE || domain,
        issuer: domain,
        algorithms: ['HS256', 'RS256']
      }
    })

    server.decorateRequest('auth0Domain', domain)

    server.decorateRequest('jwtDecode', function() {
      if (!this.headers && !this.headers.authorization) {
        throw unauthorized('Missing Authorization HTTP header.')
      } else if (!this.headers.authorization.match(/^Bearer\s+/)) {
        throw badData('Authorization header should be in format: Bearer [token].')
      }

      return server.jwt.decode(this.headers.authorization.split(/\s+/)[1], { complete: true })
    })

    server.decorate('authenticate', async function(request, reply) {
      try {
        await request.jwtVerify({ complete: true })
      } catch (e) {
        if (e.isBoom) {
          throw e
        } else if (e.message == 'No Authorization was found in request.headers') {
          throw unauthorized('Missing Authorization HTTP header.')
        }

        throw forbidden(e.message)
      }
    })

    server.route({
      method: 'GET',
      url: '/verify',
      schema: {
        response: {
          [OK]: {
            type: 'object',
            properties: {
              token: { type: 'object', additionalProperties: true }
            },
            additionalProperties: false,
            required: ['token']
          },
          [UNAUTHORIZED]: {
            type: 'object',
            properties: {
              statusCode: { type: 'number', enum: [UNAUTHORIZED] },
              error: { type: 'string', enum: ['Unauthorized'] },
              message: { type: 'string', pattern: '.+' }
            },
            required: ['statusCode', 'error', 'message'],
            additionalProperties: false
          },
          [FORBIDDEN]: {
            type: 'object',
            properties: {
              statusCode: { type: 'number', enum: [FORBIDDEN] },
              error: { type: 'string', enum: ['Forbidden'] },
              message: { type: 'string', pattern: '.+' }
            },
            required: ['statusCode', 'error', 'message'],
            additionalProperties: false
          },
          [INTERNAL_SERVER_ERROR]: {
            type: 'object',
            properties: {
              statusCode: {
                type: 'number',
                enum: [INTERNAL_SERVER_ERROR]
              },
              error: {
                type: 'string',
                enum: ['Internal Server Error']
              },
              message: { type: 'string', pattern: '.+' },
              stack: { type: 'array', items: { type: 'string' } }
            },
            required: ['statusCode', 'error', 'message'],
            additionalProperties: false
          }
        }
      },
      handler(request, reply) {
        reply.send({ token: request.user })
      },
      preValidation: server.authenticate
    })

    await server.listen(parseInt(process.env.PORT || '3000', 10), '0.0.0.0')
  } catch (err) {
    server.log.error(err)
    process.exit(1)
  }
}

start().catch(e => {
  console.error(e)
  process.exit(1)
})
Eomm commented 4 years ago

Wow! we should think about how to add some feature in this plugin to use it more flawlessly.

Some consideration on code:

mcollina commented 4 years ago

We are working on a new module at NearForm to automate some of this! Il 19 nov 2019, 23:01 +0100, Manuel Spigolon notifications@github.com, ha scritto:

Wow! we should think about how to add some feature in this plugin to use it more flawlessly. Some consideration on code:

• require('auth0') is unused • could ${request.auth0Domain}.well-known/jwks.json be cached? • instead of (deprecated) boom you could use fastify-sensible

— You are receiving this because you modified the open/close state. Reply to this email directly, view it on GitHub, or unsubscribe.