Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: explicit hook configuration via addHook #212

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 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
11 changes: 6 additions & 5 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,10 @@ Defaults to `rfc6750`.
to one of these values. If the function returns or resolves to any other value, rejects, or throws,
an HTTP status of `500` will be sent. `req` is the Fastify request object. If `auth` is a function,
`keys` will be ignored. If `auth` is not a function or `undefined`, `keys` will be used
* `addHook`: If `false`, this plugin will not register `onRequest` hook automatically.
Instead it provides two decorations: `fastify.verifyBearerAuth` and
`fastify.verifyBearerAuthFactory`
* `addHook`: If `false`, this plugin will not register any hook automatically. Instead, it provides two decorations: `fastify.verifyBearerAuth` and
Fdawgs marked this conversation as resolved.
Show resolved Hide resolved
`fastify.verifyBearerAuthFactory`. If `true` or nullish, it will default to `onRequest`. You can also specify `onRequest` or `preParsing` to register the respective hook
* `addHook`: If `false`, no hook is registered automatically, and instead the `fastify.verifyBearerAuth` and `fastify.verifyBearerAuthFactory` decorators are exposed. If `true` or
nullish, defaults to `onRequest`. `onRequest` or `preParsing` can also be used to register the respective hook
* `verifyErrorLogLevel`: An optional string specifying the log level for verification errors.
It must be a valid log level supported by Fastify, or an exception will be thrown when
registering the plugin. By default, this option is set to `error`
Expand All @@ -102,14 +103,14 @@ The default configuration object is:
}
```

The plugin registers a standard Fastify [preHandler hook][prehook] to inspect the request's
The plugin registers a standard Fastify [onRequest hook][onrequesthook] to inspect the request's
headers for an `authorization` header in the format `bearer key`. The `key` is matched against
the configured `keys` object using a [constant time algorithm](https://en.wikipedia.org/wiki/Time_complexity#Constant_time)
to prevent [timing-attacks](https://snyk.io/blog/node-js-timing-attack-ccc-ctf/). If the
`authorization` header is missing, malformed, or the `key` does not validate, a 401 response
is sent with a `{error: message}` body, and no further request processing is performed.

[prehook]: https://github.com/fastify/fastify/blob/main/docs/Reference/Hooks.md
[onrequesthook]: https://github.com/fastify/fastify/blob/main/docs/Reference/Hooks.md#onrequest

## Integration with `@fastify/auth`

Expand Down
28 changes: 24 additions & 4 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,19 @@

const fp = require('fastify-plugin')
const verifyBearerAuthFactory = require('./lib/verify-bearer-auth-factory')
const { FST_BEARER_AUTH_INVALID_LOG_LEVEL } = require('./lib/errors')
const { FST_BEARER_AUTH_INVALID_HOOK, FST_BEARER_AUTH_INVALID_LOG_LEVEL } = require('./lib/errors')

/**
* Hook type limited to 'onRequest' and 'preParsing' to protect against DoS attacks.
* @see {@link https://github.com/fastify/fastify-auth?tab=readme-ov-file#hook-selection | fastify-auth hook selection}
*/
const validHooks = new Set(['onRequest', 'preParsing'])

function fastifyBearerAuth (fastify, options, done) {
options = { addHook: true, verifyErrorLogLevel: 'error', ...options }
options = { verifyErrorLogLevel: 'error', ...options }
if (options.addHook === true || options.addHook == null) {
options.addHook = 'onRequest'
}

if (
Object.hasOwn(fastify.log, 'error') === false ||
Expand All @@ -26,8 +35,19 @@ function fastifyBearerAuth (fastify, options, done) {
}

try {
if (options.addHook === true) {
fastify.addHook('onRequest', verifyBearerAuthFactory(options))
if (options.addHook) {
if (!validHooks.has(options.addHook)) {
done(new FST_BEARER_AUTH_INVALID_HOOK())
}

if (options.addHook === 'preParsing') {
const verifyBearerAuth = verifyBearerAuthFactory(options)
fastify.addHook('preParsing', (request, reply, _payload, done) => {
verifyBearerAuth(request, reply, done)
})
} else {
fastify.addHook(options.addHook, verifyBearerAuthFactory(options))
}
} else {
fastify.decorate('verifyBearerAuthFactory', verifyBearerAuthFactory)
fastify.decorate('verifyBearerAuth', verifyBearerAuthFactory(options))
Expand Down
2 changes: 2 additions & 0 deletions lib/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
const { createError } = require('@fastify/error')

const FST_BEARER_AUTH_INVALID_KEYS_OPTION_TYPE = createError('FST_BEARER_AUTH_INVALID_KEYS_OPTION_TYPE', 'options.keys has to be an Array or a Set')
const FST_BEARER_AUTH_INVALID_HOOK = createError('FST_BEARER_AUTH_INVALID_HOOK', 'options.addHook must be either "onRequest" or "preParsing"')
const FST_BEARER_AUTH_INVALID_LOG_LEVEL = createError('FST_BEARER_AUTH_INVALID_LOG_LEVEL', 'fastify.log does not have level \'%s\'')
const FST_BEARER_AUTH_KEYS_OPTION_INVALID_KEY_TYPE = createError('FST_BEARER_AUTH_KEYS_OPTION_INVALID_KEY_TYPE', 'options.keys has to contain only string entries')
const FST_BEARER_AUTH_INVALID_SPEC = createError('FST_BEARER_AUTH_INVALID_SPEC', 'options.specCompliance has to be set to \'rfc6750\' or \'rfc6749\'')
Expand All @@ -11,6 +12,7 @@ const FST_BEARER_AUTH_INVALID_AUTHORIZATION_HEADER = createError('FST_BEARER_AUT

module.exports = {
FST_BEARER_AUTH_INVALID_KEYS_OPTION_TYPE,
FST_BEARER_AUTH_INVALID_HOOK,
FST_BEARER_AUTH_INVALID_LOG_LEVEL,
FST_BEARER_AUTH_KEYS_OPTION_INVALID_KEY_TYPE,
FST_BEARER_AUTH_INVALID_SPEC,
Expand Down
106 changes: 106 additions & 0 deletions test/hooks.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
'use strict'

const { test } = require('node:test')
const Fastify = require('fastify')
const kFastifyContext = require('fastify/lib/symbols').kRouteContext
const plugin = require('../')

const keys = new Set(['123456'])
const authorization = 'Bearer 123456'

test('onRequest hook used by default', async (t) => {
t.plan(9)
const fastify = Fastify()
fastify.register(plugin, { keys, addHook: undefined }).get('/test', (_req, res) => {
res.send({ hello: 'world' })
})

fastify.addHook('onResponse', (request, _reply, done) => {
t.assert.strictEqual(request[kFastifyContext].onError, null)
t.assert.strictEqual(request[kFastifyContext].onRequest.length, 1)
t.assert.strictEqual(request[kFastifyContext].onSend, null)
t.assert.strictEqual(request[kFastifyContext].preHandler, null)
t.assert.strictEqual(request[kFastifyContext].preParsing, null)
t.assert.strictEqual(request[kFastifyContext].preSerialization, null)
t.assert.strictEqual(request[kFastifyContext].preValidation, null)
done()
})

const response = await fastify.inject({
method: 'GET',
url: '/test',
headers: {
authorization
}
})
t.assert.strictEqual(response.statusCode, 200)
t.assert.deepStrictEqual(JSON.parse(response.body), { hello: 'world' })
})

test('preParsing hook used when specified', async (t) => {
t.plan(9)
const fastify = Fastify()
fastify.register(plugin, { keys, addHook: 'preParsing' }).get('/test', (_req, res) => {
res.send({ hello: 'world' })
})

fastify.addHook('onResponse', (request, _reply, done) => {
t.assert.strictEqual(request[kFastifyContext].onError, null)
t.assert.strictEqual(request[kFastifyContext].onRequest, null)
t.assert.strictEqual(request[kFastifyContext].onSend, null)
t.assert.strictEqual(request[kFastifyContext].preHandler, null)
t.assert.strictEqual(request[kFastifyContext].preParsing.length, 1)
t.assert.strictEqual(request[kFastifyContext].preSerialization, null)
t.assert.strictEqual(request[kFastifyContext].preValidation, null)
done()
})

const response = await fastify.inject({
method: 'GET',
url: '/test',
headers: {
authorization
}
})
t.assert.strictEqual(response.statusCode, 200)
t.assert.deepStrictEqual(JSON.parse(response.body), { hello: 'world' })
})

test('onRequest hook used when specified', async (t) => {
t.plan(9)
const fastify = Fastify()
fastify.register(plugin, { keys, addHook: 'onRequest' }).get('/test', (_req, res) => {
res.send({ hello: 'world' })
})

fastify.addHook('onResponse', (request, _reply, done) => {
t.assert.strictEqual(request[kFastifyContext].onError, null)
t.assert.strictEqual(request[kFastifyContext].onRequest.length, 1)
t.assert.strictEqual(request[kFastifyContext].onSend, null)
t.assert.strictEqual(request[kFastifyContext].preHandler, null)
t.assert.strictEqual(request[kFastifyContext].preParsing, null)
t.assert.strictEqual(request[kFastifyContext].preSerialization, null)
t.assert.strictEqual(request[kFastifyContext].preValidation, null)
done()
})

const response = await fastify.inject({
method: 'GET',
url: '/test',
headers: {
authorization
}
})
t.assert.strictEqual(response.statusCode, 200)
t.assert.deepStrictEqual(JSON.parse(response.body), { hello: 'world' })
})

test('error when invalid hook specified', async (t) => {
t.plan(1)
const fastify = Fastify()
try {
await fastify.register(plugin, { keys, addHook: 'onResponse' })
} catch (err) {
t.assert.strictEqual(err.message, 'options.addHook must be either "onRequest" or "preParsing"')
}
})
2 changes: 1 addition & 1 deletion types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ declare namespace fastifyBearerAuth {
contentType?: string | undefined;
bearerType?: string;
specCompliance?: 'rfc6749' | 'rfc6750';
addHook?: boolean | undefined;
addHook?: boolean | 'onRequest' | 'preParsing' | undefined;
verifyErrorLogLevel?: string;
}

Expand Down
7 changes: 4 additions & 3 deletions types/index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ const pluginOptionsAuthPromise: FastifyBearerAuthOptions = {
auth: (_key: string, _req: FastifyRequest) => { return Promise.resolve(true) },
errorResponse: (err: Error) => { return { error: err.message } },
contentType: '',
bearerType: ''
bearerType: '',
addHook: 'onRequest'
}

const pluginOptionsKeyArray: FastifyBearerAuthOptions = {
Expand Down Expand Up @@ -50,7 +51,7 @@ expectAssignable<{
errorResponse?: (err: Error) => { error: string };
contentType?: string | undefined;
bearerType?: string;
addHook?: boolean | undefined;
addHook?: boolean | 'onRequest' | 'preParsing' | undefined;
}>(pluginOptionsKeyArray)

expectAssignable<{
Expand All @@ -59,7 +60,7 @@ expectAssignable<{
errorResponse?: (err: Error) => { error: string };
contentType?: string | undefined;
bearerType?: string;
addHook?: boolean | undefined;
addHook?: boolean | 'onRequest' | 'preParsing' | undefined;
}>(pluginOptionsUndefined)

expectAssignable<{
Expand Down