Skip to content

Commit

Permalink
feat: add custom auth strategies (#24)
Browse files Browse the repository at this point in the history
* feat: add custom auth strategies

* feat: add addAuthStrategy method

* docs: add docs for custom strategies
  • Loading branch information
ivan-tymoshenko authored Jun 29, 2023
1 parent f775e57 commit efaba64
Show file tree
Hide file tree
Showing 7 changed files with 362 additions and 171 deletions.
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

0 comments on commit efaba64

Please sign in to comment.