diff --git a/README.md b/README.md index 8a2104a..4e66e39 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,33 @@ Example of options: ## JWT and Webhook In case both `jwt` and `webhook` options are specified, the plugin will try to populate `request.user` from the JWT token first. If the token is not valid, it will try to populate `request.user` from the webhook. + +## Custom auth strategies + +In case if you want to use your own auth strategy, you can pass it as an option to the plugin. All custom auth strategies should have `createSession` method, which will be called on every request. This method should set `request.user` object. All custom strategies will be executed after `jwt` and `webhook` strategies. + +```js +{ + authStrategies: [{ + name: 'myAuthStrategy', + createSession: async function (request, reply) { + req.user = { id: 42, role: 'admin' } + } + }] +} +``` + +or you can add it via `addAuthStrategy` method: + +```js +app.addAuthStrategy({ + name: 'myAuthStrategy', + createSession: async function (request, reply) { + req.user = { id: 42, role: 'admin' } + } +}) +``` + ## Run Tests ``` diff --git a/index.js b/index.js index 9484289..b30f35b 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,5 @@ +'use strict' + const fp = require('fastify-plugin') /** @@ -9,38 +11,54 @@ const fp = require('fastify-plugin') async function fastifyUser (app, options, done) { const { webhook, - jwt + jwt, + authStrategies } = options + const strategies = [] + if (jwt) { await app.register(require('./lib/jwt'), { jwt }) + strategies.push({ + name: 'jwt', + createSession: (req) => req.createJWTSession() + }) } if (webhook) { await app.register(require('./lib/webhook'), { webhook }) + strategies.push({ + name: 'webhook', + createSession: (req) => req.createWebhookSession() + }) } - if (jwt && webhook) { - app.decorateRequest('createSession', async function () { - try { - // `createSession` actually exists only if jwt or webhook are enabled - // and creates a new `request.user` object - await this.createJWTSession() - } catch (err) { - this.log.trace({ err }) + for (const strategy of authStrategies || []) { + strategies.push(strategy) + } - await this.createWebhookSession() + app.decorate('addAuthStrategy', (strategy) => { + strategies.push(strategy) + }) + + app.decorateRequest('createSession', async function () { + const errors = [] + for (const strategy of strategies) { + try { + return await strategy.createSession(this) + } catch (error) { + errors.push({ strategy: strategy.name, error }) + this.log.trace({ strategy: strategy.name, error }) } - }) - } else if (jwt) { - app.decorateRequest('createSession', function () { - return this.createJWTSession() - }) - } else if (webhook) { - app.decorateRequest('createSession', function () { - return this.createWebhookSession() - }) - } + } + + if (errors.length === 1) { + throw new Error(errors[0].error) + } + + const errorsMessage = errors.map(({ strategy, error }) => `${strategy}: ${error}`).join('; ') + throw new Error(`No auth strategy succeeded. ${errorsMessage}`) + }) const extractUser = async function () { const request = this diff --git a/test/custom-strategy.test.js b/test/custom-strategy.test.js new file mode 100644 index 0000000..ab2627f --- /dev/null +++ b/test/custom-strategy.test.js @@ -0,0 +1,191 @@ +'use strict' + +const fastify = require('fastify') +const { test } = require('tap') +const { Agent, setGlobalDispatcher } = require('undici') +const fastifyUser = require('..') + +const { buildAuthorizer } = require('./helper') + +const agent = new Agent({ + keepAliveTimeout: 10, + keepAliveMaxTimeout: 10 +}) +setGlobalDispatcher(agent) + +test('custom auth strategy', async ({ teardown, strictSame, equal }) => { + const app = fastify({ + forceCloseConnections: true + }) + + app.register(fastifyUser, { + authStrategies: [{ + name: 'myStrategy', + createSession: async function (req) { + req.user = { id: 42, role: 'user' } + } + }] + }) + + app.addHook('preHandler', async (request, reply) => { + await request.extractUser() + }) + + app.get('/', async function (request, reply) { + return request.user + }) + + teardown(app.close.bind(app)) + + await app.ready() + + { + const res = await app.inject({ method: 'GET', url: '/' }) + equal(res.statusCode, 200) + strictSame(res.json(), { id: 42, role: 'user' }) + } +}) + +test('multiple custom strategies', async ({ teardown, strictSame, equal }) => { + const app = fastify({ + forceCloseConnections: true + }) + + app.register(fastifyUser, { + authStrategies: [ + { + name: 'myStrategy1', + createSession: function () { + throw new Error('myStrategy1 failed') + } + }, + { + name: 'myStrategy2', + createSession: async function (req) { + req.user = { id: 43, role: 'user' } + } + } + ] + }) + + app.addHook('preHandler', async (request, reply) => { + await request.extractUser() + }) + + app.get('/', async function (request, reply) { + return request.user + }) + + teardown(app.close.bind(app)) + + await app.ready() + + { + const res = await app.inject({ method: 'GET', url: '/' }) + equal(res.statusCode, 200) + strictSame(res.json(), { id: 43, role: 'user' }) + } +}) + +test('webhook + custom strategy', async ({ teardown, strictSame, equal }) => { + const authorizer = await buildAuthorizer() + teardown(() => authorizer.close()) + + const app = fastify({ + forceCloseConnections: true + }) + + app.register(fastifyUser, { + webhook: { + url: `http://localhost:${authorizer.server.address().port}/authorize` + }, + authStrategies: [ + { + name: 'myStrategy1', + createSession: function (req) { + if (req.headers['x-custom-auth'] !== undefined) { + req.user = { id: 42, role: 'user' } + } else { + throw new Error('myStrategy1 failed') + } + } + } + ] + }) + + app.addHook('preHandler', async (request, reply) => { + await request.extractUser() + }) + + app.get('/', async function (request, reply) { + return request.user + }) + + teardown(app.close.bind(app)) + teardown(() => authorizer.close()) + + await app.ready() + + { + const cookie = await authorizer.getCookie({ + 'USER-ID-FROM-WEBHOOK': 42 + }) + + const res = await app.inject({ + method: 'GET', + url: '/', + headers: { + cookie + } + }) + equal(res.statusCode, 200) + strictSame(res.json(), { + 'USER-ID-FROM-WEBHOOK': 42 + }) + } + + { + const res = await app.inject({ + method: 'GET', + url: '/', + headers: { + 'x-custom-auth': 'true' + } + }) + equal(res.statusCode, 200) + strictSame(res.json(), { id: 42, role: 'user' }) + } +}) + +test('add custom strategy via addCustomStrategy hook', async ({ teardown, strictSame, equal }) => { + const app = fastify({ + forceCloseConnections: true + }) + + await app.register(fastifyUser) + + app.addAuthStrategy({ + name: 'myStrategy', + createSession: async function (req) { + req.user = { id: 42, role: 'user' } + } + }) + + app.addHook('preHandler', async (request, reply) => { + await request.extractUser() + }) + + app.get('/', async function (request, reply) { + return request.user + }) + + teardown(app.close.bind(app)) + + await app.ready() + + { + const res = await app.inject({ method: 'GET', url: '/' }) + equal(res.statusCode, 200) + strictSame(res.json(), { id: 42, role: 'user' }) + } +}) diff --git a/test/helper.js b/test/helper.js new file mode 100644 index 0000000..6554a0d --- /dev/null +++ b/test/helper.js @@ -0,0 +1,81 @@ +'use strict' + +const { createPublicKey, generateKeyPairSync } = require('crypto') +const { request } = require('undici') +const fastify = require('fastify') + +async function buildJwksEndpoint (jwks, fail = false) { + const app = fastify() + app.get('/.well-known/jwks.json', async () => { + if (fail) { + throw Error('JWKS ENDPOINT ERROR') + } + return jwks + }) + await app.listen({ port: 0 }) + return app +} + +function generateKeyPair () { + // creates a RSA key pair for the test + const { publicKey, privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'pkcs1', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs1', format: 'pem' } + }) + const publicJwk = createPublicKey(publicKey).export({ format: 'jwk' }) + return { publicKey, publicJwk, privateKey } +} + +async function buildAuthorizer (opts = {}) { + const app = fastify() + app.register(require('@fastify/cookie')) + app.register(require('@fastify/session'), { + cookieName: 'sessionId', + secret: 'a secret with minimum length of 32 characters', + cookie: { secure: false } + }) + + app.post('/login', async (request, reply) => { + request.session.user = request.body + return { + status: 'ok' + } + }) + + app.post('/authorize', async (request, reply) => { + if (typeof opts.onAuthorize === 'function') { + await opts.onAuthorize(request) + } + + const user = request.session.user + if (!user) { + return reply.code(401).send({ error: 'Unauthorized' }) + } + return user + }) + + app.decorate('getCookie', async (cookie) => { + const res = await request(`http://localhost:${app.server.address().port}/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(cookie) + }) + + res.body.resume() + + return res.headers['set-cookie'].split(';')[0] + }) + + await app.listen({ port: 0 }) + + return app +} + +module.exports = { + generateKeyPair, + buildJwksEndpoint, + buildAuthorizer +} diff --git a/test/jwt-webhook.test.js b/test/jwt-webhook.test.js index 5f051c3..9ce1c11 100644 --- a/test/jwt-webhook.test.js +++ b/test/jwt-webhook.test.js @@ -2,76 +2,29 @@ const fastify = require('fastify') const { test } = require('tap') -const { createPublicKey, generateKeyPairSync } = require('crypto') -const { request, Agent, setGlobalDispatcher } = require('undici') +const { Agent, setGlobalDispatcher } = require('undici') const { createSigner } = require('fast-jwt') const fastifyUser = require('..') +const { + generateKeyPair, + buildJwksEndpoint, + buildAuthorizer +} = require('./helper') + +const { publicJwk, privateKey } = generateKeyPair() + const agent = new Agent({ keepAliveTimeout: 10, keepAliveMaxTimeout: 10 }) setGlobalDispatcher(agent) -async function buildAuthorizer (opts = {}) { - const app = fastify({ - forceCloseConnections: true - }) - app.register(require('@fastify/cookie')) - app.register(require('@fastify/session'), { - cookieName: 'sessionId', - secret: 'a secret with minimum length of 32 characters', - cookie: { secure: false } - }) - - app.post('/login', async (request, reply) => { - request.session.user = request.body - return { - status: 'ok' - } - }) - - app.post('/authorize', async (request, reply) => { - if (typeof opts.onAuthorize === 'function') { - await opts.onAuthorize(request) - } - - const user = request.session.user - if (!user) { - return reply.code(401).send({ error: 'Unauthorized' }) - } - return user - }) - - await app.listen({ port: 0 }) - return app -} - -// creates a RSA key pair for the test -const { publicKey, privateKey } = generateKeyPairSync('rsa', { - modulusLength: 2048, - publicKeyEncoding: { type: 'pkcs1', format: 'pem' }, - privateKeyEncoding: { type: 'pkcs1', format: 'pem' } -}) -const jwtPublicKey = createPublicKey(publicKey).export({ format: 'jwk' }) - -async function buildJwksEndpoint (jwks, fail = false) { - const app = fastify() - app.get('/.well-known/jwks.json', async (request, reply) => { - if (fail) { - throw Error('JWKS ENDPOINT ERROR') - } - return jwks - }) - await app.listen({ port: 0 }) - return app -} - test('JWT + cookies with WebHook', async ({ pass, teardown, same, equal }) => { const authorizer = await buildAuthorizer() teardown(() => authorizer.close()) - const { n, e, kty } = jwtPublicKey + const { n, e, kty } = publicJwk const kid = 'TEST-KID' const alg = 'RS256' const jwksEndpoint = await buildJwksEndpoint( @@ -123,26 +76,12 @@ test('JWT + cookies with WebHook', async ({ pass, teardown, same, equal }) => { await app.ready() - async function getCookie (userId, role) { - const res = await request(`http://localhost:${authorizer.server.address().port}/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - 'USER-ID-FROM-WEBHOOK': userId - }) - }) - - res.body.resume() - - const cookie = res.headers['set-cookie'].split(';')[0] - return cookie - } - // Must use webhooks to get user { - const cookie = await getCookie(42, 'user') + const cookie = await authorizer.getCookie({ + 'USER-ID-FROM-WEBHOOK': 42 + }) + const res = await app.inject({ method: 'GET', url: '/', @@ -210,7 +149,7 @@ test('Authorization both with JWT and WebHook', async ({ pass, teardown, same, e }) teardown(() => authorizer.close()) - const { n, e, kty } = jwtPublicKey + const { n, e, kty } = publicJwk const kid = 'TEST-KID' const alg = 'RS256' const jwksEndpoint = await buildJwksEndpoint( diff --git a/test/jwt.test.js b/test/jwt.test.js index b491611..c19484e 100644 --- a/test/jwt.test.js +++ b/test/jwt.test.js @@ -1,29 +1,12 @@ const fastify = require('fastify') const { test } = require('tap') -const { createPublicKey, generateKeyPairSync } = require('crypto') const { createSigner } = require('fast-jwt') const fastifyUser = require('..') -// creates a RSA key pair for the test -const { publicKey, privateKey } = generateKeyPairSync('rsa', { - modulusLength: 2048, - publicKeyEncoding: { type: 'pkcs1', format: 'pem' }, - privateKeyEncoding: { type: 'pkcs1', format: 'pem' } -}) -const jwtPublicKey = createPublicKey(publicKey).export({ format: 'jwk' }) +const { generateKeyPair, buildJwksEndpoint } = require('./helper') -async function buildJwksEndpoint (jwks, fail = false) { - const app = fastify() - app.get('/.well-known/jwks.json', async (request, reply) => { - if (fail) { - throw Error('JWKS ENDPOINT ERROR') - } - return jwks - }) - await app.listen({ port: 0 }) - return app -} +const { publicJwk, privateKey } = generateKeyPair() test('JWT verify OK using shared secret', async ({ same, teardown }) => { const payload = { @@ -67,7 +50,7 @@ test('JWT verify OK using shared secret', async ({ same, teardown }) => { }) test('JWT verify OK getting public key from jwks endpoint', async ({ same, teardown }) => { - const { n, e, kty } = jwtPublicKey + const { n, e, kty } = publicJwk const kid = 'TEST-KID' const alg = 'RS256' const jwksEndpoint = await buildJwksEndpoint( @@ -198,7 +181,7 @@ test('jwt verify fails if getting public key from jwks endpoint fails, so no use }) test('jwt verify fail if jwks succeed but kid is not found', async ({ pass, teardown, same, equal }) => { - const { n, e, kty } = jwtPublicKey + const { n, e, kty } = publicJwk const kid = 'TEST-KID' const alg = 'RS256' @@ -270,7 +253,7 @@ test('jwt verify fail if jwks succeed but kid is not found', async ({ pass, tear }) test('jwt verify fails if the domain is not allowed', async ({ pass, teardown, same, equal }) => { - const { n, e, kty } = jwtPublicKey + const { n, e, kty } = publicJwk const kid = 'TEST-KID' const alg = 'RS256' @@ -344,7 +327,7 @@ test('jwt verify fails if the domain is not allowed', async ({ pass, teardown, s }) test('jwt skips namespace in custom claims', async ({ pass, teardown, same, equal }) => { - const { n, e, kty } = jwtPublicKey + const { n, e, kty } = publicJwk const kid = 'TEST-KID' const alg = 'RS256' const jwksEndpoint = await buildJwksEndpoint( diff --git a/test/webhook.test.js b/test/webhook.test.js index 819ee90..1ea0151 100644 --- a/test/webhook.test.js +++ b/test/webhook.test.js @@ -2,48 +2,17 @@ const fastify = require('fastify') const { test } = require('tap') -const { request, Agent, setGlobalDispatcher } = require('undici') +const { Agent, setGlobalDispatcher } = require('undici') const fastifyUser = require('..') +const { buildAuthorizer } = require('./helper') + const agent = new Agent({ keepAliveTimeout: 10, keepAliveMaxTimeout: 10 }) setGlobalDispatcher(agent) -async function buildAuthorizer (opts = {}) { - const app = fastify() - app.register(require('@fastify/cookie')) - app.register(require('@fastify/session'), { - cookieName: 'sessionId', - secret: 'a secret with minimum length of 32 characters', - cookie: { secure: false } - }) - - app.post('/login', async (request, reply) => { - request.session.user = request.body - return { - status: 'ok' - } - }) - - app.post('/authorize', async (request, reply) => { - if (typeof opts.onAuthorize === 'function') { - await opts.onAuthorize(request) - } - - const user = request.session.user - if (!user) { - return reply.code(401).send({ error: 'Unauthorized' }) - } - return user - }) - - await app.listen({ port: 0 }) - - return app -} - test('Webhook verify OK', async ({ pass, teardown, same, equal }) => { const authorizer = await buildAuthorizer() const app = fastify() @@ -71,24 +40,7 @@ test('Webhook verify OK', async ({ pass, teardown, same, equal }) => { await app.ready() - async function getCookie (userId, role) { - const res = await request(`http://localhost:${authorizer.server.address().port}/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - 'USER-ID': userId - }) - }) - - res.body.resume() - - const cookie = res.headers['set-cookie'].split(';')[0] - return cookie - } - - const cookie = await getCookie(42, 'user') + const cookie = await authorizer.getCookie({ 'USER-ID': 42 }) { const res = await app.inject({