Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
58 changes: 38 additions & 20 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use strict'

const fp = require('fastify-plugin')

/**
Expand All @@ -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
Expand Down
191 changes: 191 additions & 0 deletions test/custom-strategy.test.js
Original file line number Diff line number Diff line change
@@ -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' })
}
})
81 changes: 81 additions & 0 deletions test/helper.js
Original file line number Diff line number Diff line change
@@ -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
}
Loading