From 2865405552d755ae5124afcb8e24dbbab1133629 Mon Sep 17 00:00:00 2001 From: Meg Hein <64171419+meghein@users.noreply.github.com> Date: Thu, 11 Jan 2024 15:43:15 -0800 Subject: [PATCH] feat: security handlers --- README.md | 107 +++++++++++++++--- example/README.md | 54 +++++++++ example/basic/index.js | 14 +++ example/{ => basic}/package.json | 5 +- example/basic/routes/handlers/getFoo/index.js | 7 ++ example/basic/routes/spec/openapi.yaml | 27 +++++ example/index.js | 14 --- example/jwt/index.js | 82 ++++++++++++++ example/jwt/local-certs/.gitignore | 4 + example/jwt/package.json | 16 +++ example/jwt/routes/handlers/getFoo/index.js | 12 ++ example/jwt/routes/spec/openapi.yaml | 74 ++++++++++++ index.js | 9 +- jwtJwks.js | 1 + lib/jwtJwks.js | 69 +++++++++++ package.json | 12 +- test/error.test.js | 22 ++-- test/fixtures/handlers/getFoo/index.js | 9 +- test/fixtures/spec-with-security.yaml | 74 ++++++++++++ test/index.test.js | 35 +++++- test/jwtJwks.test.js | 104 +++++++++++++++++ types/index.d.ts | 4 +- types/index.test-d.ts | 27 +++++ types/jwtJwks.d.ts | 11 ++ types/jwtJwks.test-d.ts | 28 +++++ 25 files changed, 770 insertions(+), 51 deletions(-) create mode 100644 example/README.md create mode 100644 example/basic/index.js rename example/{ => basic}/package.json (67%) create mode 100644 example/basic/routes/handlers/getFoo/index.js create mode 100644 example/basic/routes/spec/openapi.yaml delete mode 100644 example/index.js create mode 100644 example/jwt/index.js create mode 100644 example/jwt/local-certs/.gitignore create mode 100644 example/jwt/package.json create mode 100644 example/jwt/routes/handlers/getFoo/index.js create mode 100644 example/jwt/routes/spec/openapi.yaml create mode 100644 jwtJwks.js create mode 100644 lib/jwtJwks.js create mode 100644 test/fixtures/spec-with-security.yaml create mode 100644 test/jwtJwks.test.js create mode 100644 types/jwtJwks.d.ts create mode 100644 types/jwtJwks.test-d.ts diff --git a/README.md b/README.md index c65f453..4fbf669 100644 --- a/README.md +++ b/README.md @@ -38,22 +38,7 @@ export default async function app (fastify, options) { } ``` -### Running the Example - -Start the example server: - -```sh -cd ./example -npm i && npm start -``` - -To confirm the spec provided in the example is processed, make the following requests: - -```sh -http GET :3000/foo -http GET :3000/bar -http POST :3000/baz -``` +To run an example app, see [this guide](./example/README.md) ## API Reference - Options @@ -91,7 +76,9 @@ OpenAPI-related options. Refer to [fastify-openapi-glue documentation](https://g By default, the `fastify-openapi-autoload` provides a standard resolver that locates a handler based on the operation ID, looking for a matching decorator method in the Fastify instance. However, if your application requires a different mapping strategy or additional logic for resolving operations, you can provide a custom resolver function. -The custom resolver function should be a factory function that accepts the Fastify instance as an argument and returns another function. This returned function should be the operation resolver. See the [`fastify-openapi-glue operation resolver docs`](https://github.com/seriousme/fastify-openapi-glue/blob/master/docs/operationResolver.md). +The custom resolver should be a factory function that receives the Fastify instance as an argument and returns an operation resolver function. This resolver function, when invoked with an `operationId`, should return the corresponding handler function for that specific operation. + +For more information on the operation resolver, refer to the [`fastify-openapi-glue operation resolver documentation`](https://github.com/seriousme/fastify-openapi-glue/blob/master/docs/operationResolver.md). ```js // example @@ -109,6 +96,92 @@ export default async function app (fastify, options) { } ``` +### `makeSecurityHandlers` (optional) + +If your application requires custom security handlers for your OpenAPI handlers, you can provide a factory function similar to the `makeOperationResolver` option. + +This factory function should take the Fastify instance as an argument and return an object containing the security handlers. Each handler within this object should implement the logic for handling security aspects as defined in your OpenAPI specification. + +For guidance on implementing security handlers, see the [`fastify-openapi-glue security handlers documentation`](https://github.com/seriousme/fastify-openapi-glue/blob/master/docs/securityHandlers.md). + +Example usage: + +```js +// example +export default async function app (fastify, options) { + fastify.register(openapiAutoload, { + makeSecurityHandlers: (fastify) => { + // Custom logic for security handlers + return { + someSecurityHandler: (notOk) => { + if (notOk) { + throw new Error('not ok') + } + } + } + }, + // Other configuration options... + }) +} +``` + +## JSON Web Token Security Handler + +The `jwtJwksHandler` function, exported with the `fastify-openapi-autoload` plugin, allows you to integrate JWT/JWKS authentication as security handlers. + +To use this function, you need to install the following dependencies: + +```sh +npm i @autotelic/fastify-openapi-autoload @fastify/jwt get-jwks +``` + +### Options + +When configuring `jwtJwksHandler`, you can customize its behavior with the following options: + +- `jwksOpts` (optional): See [`get-jwks` documentation](https://github.com/nearform/get-jwks) for details. +- `issuer` (*required): The issuer URL of the JWT tokens. This is typically the base URL of the token provider. Required option if `jwksOpts.issuersWhitelist` & `jwksOpts.checkIssuer` options are not provided. +- `authRequestDecorator` (optional - default provided): A function to decorate the Fastify request with custom JWT authentication logic. +- `securityHandlers` (optional - default provided): An object containing Fastify security handlers. + +### Example Usage + +```js +import fastify from 'fastify' +import openapiAutoload from '@autotelic/fastify-openapi-autoload' +import { jwtJwksHandler } from '@autotelic/fastify-openapi-autoload/jwtJwks' + +export default async function app (fastify, options) { + const makeSecurityHandlers = jwtJwksHandler({ + issuer: 'https://your-issuer-url.com', + jwksOpts: { + max: 100, + ttl: 60000, + timeout: 5000 + // ...additional JWKS options + }, + // Custom authentication request decorator (optional) + authRequestDecorator: async (request) => { + try { + const decodedToken = await request.jwtVerify(request) + const { userId } = decodedToken + return userId + } catch (err) { + return null + } + } + }) + + fastify.register(openapiAutoload, { + handlersDir: '/path/to/handlers', + openapiOpts: { + specification: '/path/to/openapi/spec.yaml' + }, + makeSecurityHandlers + }) +} +``` + ## Plugin Development: Triggering a Release To trigger a new release: diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..caf3bd2 --- /dev/null +++ b/example/README.md @@ -0,0 +1,54 @@ +# Example App + +There are two example: + +- Basic App +- App with JWT/JWKS Security Handlers + +## Basic App + +Change directories to `example/basic` +Install dependencies with npm, then run the example server: + +```sh +# from root: +cd ./example/basic +npm install +npm start +``` + +Send a request to /foo, for example with HTTPie: + +```sh +http GET :3000/foo +``` + +## App with Security Handlers + +1. First, you'll need to alias localhost to `autotelic.localhost` by updating `/etc/hosts` +2. Install [mkcert](https://github.com/FiloSottile/mkcert). You'll need to [set up your node.js environment to accept the certification](https://github.com/FiloSottile/mkcert?tab=readme-ov-file#using-the-root-with-nodejs) as this doesn't register automatically. +3. Create a certificate in `./example/local-certs`: + + ```sh + # from root: + cd ./example/jwt/local-certs + mkcert "autotelic.localhost" + ``` + +4. Ensure the certificate filenames match those in `./example/jwt/index.js`. + +### Starting the Example Server + +Launch the server with: + +```sh +# from root: +cd ./example/jwt +npm i && npm start +``` + +Once the server is running, a request using HTTPie will print to the console. You can copy and use to test a protected route: + +```sh +http https://autotelic.localhost:3000/foo 'Authorization:Bearer ' +``` diff --git a/example/basic/index.js b/example/basic/index.js new file mode 100644 index 0000000..c93bfd9 --- /dev/null +++ b/example/basic/index.js @@ -0,0 +1,14 @@ +import { dirname, join } from 'path' +import { fileURLToPath } from 'url' + +import openapiAutoload from '../../index.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const fixturesDir = join(__dirname, 'routes') + +export default async function app (fastify, opts) { + fastify.register(openapiAutoload, { + handlersDir: join(fixturesDir, 'handlers'), + openapiOpts: { specification: join(fixturesDir, 'spec', 'openapi.yaml') } + }) +} diff --git a/example/package.json b/example/basic/package.json similarity index 67% rename from example/package.json rename to example/basic/package.json index 57c1ac9..79f0d33 100644 --- a/example/package.json +++ b/example/basic/package.json @@ -4,10 +4,11 @@ "main": "index.js", "type": "module", "scripts": { - "start": "fastify start index.js -l info -w" + "start": "fastify start -w -l info -P -o index.js" }, "dependencies": { "fastify": "^4.25.2", - "fastify-cli": "^6.0.0" + "fastify-cli": "^6.0.0", + "fastify-plugin": "^4.5.1" } } diff --git a/example/basic/routes/handlers/getFoo/index.js b/example/basic/routes/handlers/getFoo/index.js new file mode 100644 index 0000000..44bfc2b --- /dev/null +++ b/example/basic/routes/handlers/getFoo/index.js @@ -0,0 +1,7 @@ +export default async (fastify, { operationId }) => { + fastify.decorate(operationId, async (req, reply) => { + reply.code(200).send({ foo: 'bar' }) + }) +} + +export const autoConfig = { operationId: 'getFoo' } diff --git a/example/basic/routes/spec/openapi.yaml b/example/basic/routes/spec/openapi.yaml new file mode 100644 index 0000000..2f2ba2e --- /dev/null +++ b/example/basic/routes/spec/openapi.yaml @@ -0,0 +1,27 @@ +openapi: 3.1.0 +info: + version: 1.0.0 + title: Test Spec + license: + name: MIT + +paths: + /foo: + get: + summary: test GET route /foo + operationId: getFoo + security: + - bearerAuth: [] + tags: + - foo + responses: + '204': + description: test GET route /foo + content: + application/json: + schema: + type: object + properties: + foo: + type: string + diff --git a/example/index.js b/example/index.js deleted file mode 100644 index 10f23f2..0000000 --- a/example/index.js +++ /dev/null @@ -1,14 +0,0 @@ -import { dirname, join } from 'path' -import { fileURLToPath } from 'url' - -import openapiAutoload from '../index.js' - -const __dirname = dirname(dirname(fileURLToPath(import.meta.url))) -const fixturesDir = join(__dirname, 'test', 'fixtures') - -export default async function app (fastify, opts) { - fastify.register(openapiAutoload, { - handlersDir: join(fixturesDir, 'handlers'), - openapiOpts: { specification: join(fixturesDir, 'spec', 'test-spec.yaml') } - }) -} diff --git a/example/jwt/index.js b/example/jwt/index.js new file mode 100644 index 0000000..d10ea0c --- /dev/null +++ b/example/jwt/index.js @@ -0,0 +1,82 @@ +import { readFileSync } from 'fs' +import { dirname, join } from 'path' +import { fileURLToPath } from 'url' + +import { createSigner } from 'fast-jwt' +import fastifyPlugin from 'fastify-plugin' +import { generateKeyPair, importSPKI, exportJWK } from 'jose' + +import openapiAutoload from '../../index.js' +import { jwtJwksHandler } from '../../lib/jwtJwks.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const fixturesDir = join(__dirname, 'routes') + +const ISSUER = 'https://autotelic.localhost:3000/' + +export default async function app (fastify, opts) { + // mock jwks endpoint and jwt token: + const { jwk, jwtToken } = await generateKeys() + fastify.register(fastifyPlugin(async (fastify, options) => { + const { operationId = 'getJwks' } = options + fastify.decorate(operationId, async () => ({ keys: [jwk] })) + })) + console.log('\n\x1b[33m ========HTTPie test request:========\x1b[0m\n') + console.log(`\x1b[33mhttp https://autotelic.localhost:3000/foo 'Authorization:Bearer ${jwtToken}'\x1b[0m\n\n`) + + // use jwt/jwks as security handler: + const makeSecurityHandlers = jwtJwksHandler({ issuer: ISSUER, authRequestDecorator: auth }) + + // register openapiAutoload: + fastify.register(openapiAutoload, { + handlersDir: join(fixturesDir, 'handlers'), + openapiOpts: { specification: join(fixturesDir, 'spec', 'test-spec.yaml') }, + makeSecurityHandlers + }) +} + +export const options = { + https: { + key: readFileSync(join('local-certs', 'autotelic.localhost-key.pem')), + cert: readFileSync(join('local-certs', 'autotelic.localhost.pem')) + }, + maxParamLength: 500 +} + +// ==== HELPER FUNCTION TO GENERATE JWT TOKEN AND JWK ==== // +async function generateKeys () { + const JWT_SIGNING_ALGORITHM = 'RS256' + const { publicKey, privateKey } = await generateKeyPair(JWT_SIGNING_ALGORITHM) + + const JWT_KEY_ID = 'test-key-id' + const spki = await importSPKI(publicKey.export({ + format: 'pem', + type: 'spki' + }), JWT_SIGNING_ALGORITHM) + + const jwk = await exportJWK(spki) + jwk.alg = JWT_SIGNING_ALGORITHM + jwk.kid = JWT_KEY_ID + jwk.use = 'sig' + + const jwtSign = createSigner({ + key: privateKey.export({ format: 'pem', type: 'pkcs8' }), + iss: ISSUER, + kid: JWT_KEY_ID + }) + return { + jwtToken: jwtSign({ userId: '123' }), + jwk + } +} + +// ==== AUTH & SECURITY HANDLERS ==== // +async function auth (request) { + try { + const decodedToken = await request.jwtVerify(request) + const { userId } = decodedToken + return userId + } catch (err) { + return null + } +} diff --git a/example/jwt/local-certs/.gitignore b/example/jwt/local-certs/.gitignore new file mode 100644 index 0000000..5e7d273 --- /dev/null +++ b/example/jwt/local-certs/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore diff --git a/example/jwt/package.json b/example/jwt/package.json new file mode 100644 index 0000000..61e92b6 --- /dev/null +++ b/example/jwt/package.json @@ -0,0 +1,16 @@ +{ + "name": "fastify-openapi-autoload-example", + "description": "An example to configure @autotelic/fastify-openapi-autoload with jwt/jwks", + "main": "index.js", + "type": "module", + "scripts": { + "start": "fastify start -w -l info -P -o index.js" + }, + "dependencies": { + "fast-jwt": "^3.3.2", + "fastify": "^4.25.2", + "fastify-cli": "^6.0.0", + "fastify-plugin": "^4.5.1", + "jose": "^5.2.0" + } +} diff --git a/example/jwt/routes/handlers/getFoo/index.js b/example/jwt/routes/handlers/getFoo/index.js new file mode 100644 index 0000000..b6b0919 --- /dev/null +++ b/example/jwt/routes/handlers/getFoo/index.js @@ -0,0 +1,12 @@ +export default async (fastify, { operationId }) => { + fastify.decorate(operationId, async (req, reply) => { + try { + const userId = await req.authenticate(req) + reply.code(200).send({ userId }) + } catch (err) { + reply.code(401).send({ error: 'Unauthorized' }) + } + }) +} + +export const autoConfig = { operationId: 'getFoo' } diff --git a/example/jwt/routes/spec/openapi.yaml b/example/jwt/routes/spec/openapi.yaml new file mode 100644 index 0000000..feec35c --- /dev/null +++ b/example/jwt/routes/spec/openapi.yaml @@ -0,0 +1,74 @@ +openapi: 3.1.0 +info: + version: 1.0.0 + title: Test Spec + license: + name: MIT + +paths: + /foo: + get: + summary: test GET route /foo + operationId: getFoo + security: + - bearerAuth: [] + tags: + - foo + responses: + '204': + description: test GET route /foo + content: + application/json: + schema: + type: object + properties: + foo: + type: string + + /.well-known/jwks.json: + get: + summary: JSON Web Key Set + operationId: getJwks + tags: + - jwks + responses: + '200': + description: An array of JSON Web Key (JWK) objects + content: + application/json: + schema: + $ref: "#/components/schemas/JWKS" + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + schemas: + JWKS: + title: JSON Web Key Set + type: object + properties: + keys: + description: This is just RSA for now. + type: array + items: + type: object + properties: + kty: + type: string + 'n': + type: string + 'e': + type: string + kid: + type: string + use: + type: string + alg: + type: string + required: + - kty + required: + - keys diff --git a/index.js b/index.js index ac29077..e3b7bd7 100644 --- a/index.js +++ b/index.js @@ -5,7 +5,7 @@ import openapiGlue from 'fastify-openapi-glue' import fastifyPlugin from 'fastify-plugin' async function openapiAutoload (fastify, options = {}) { - const { handlersDir, openapiOpts = {}, makeOperationResolver } = options + const { handlersDir, openapiOpts = {}, makeSecurityHandlers, makeOperationResolver } = options const { specification, operationResolver = null } = openapiOpts // Validate handlers directory @@ -19,7 +19,7 @@ async function openapiAutoload (fastify, options = {}) { } try { - // Register fastifyAutoload for handlers + // Register handlers with fastifyAutoload fastify.register(fastifyAutoload, { dir: handlersDir, maxDepth: 1, @@ -32,6 +32,11 @@ async function openapiAutoload (fastify, options = {}) { ...openapiOpts } + // Factory/creator functions for security handlers & operation resolver + if (makeSecurityHandlers) { + openapiGlueOpts.securityHandlers = makeSecurityHandlers(fastify) + } + if (makeOperationResolver) { openapiGlueOpts.operationResolver = makeOperationResolver(fastify) } diff --git a/jwtJwks.js b/jwtJwks.js new file mode 100644 index 0000000..9cad41a --- /dev/null +++ b/jwtJwks.js @@ -0,0 +1 @@ +export { jwtJwksHandler } from './lib/jwtJwks.js' diff --git a/lib/jwtJwks.js b/lib/jwtJwks.js new file mode 100644 index 0000000..dc3c58f --- /dev/null +++ b/lib/jwtJwks.js @@ -0,0 +1,69 @@ +import https from 'node:https' + +import fastifyJwt from '@fastify/jwt' +import buildGetJwks from 'get-jwks' + +export function jwtJwksHandler ({ + jwksOpts = {}, + issuer, + authRequestDecorator = defaultAuth, + securityHandlers = defaultHandlers +} = {}) { + const { + max = 100, + ttl = 60 * 1000, + timeout = 5000, + providerDiscovery = false, + agent = new https.Agent({ keepAlive: true }), + ...opts + } = jwksOpts + + const getJwks = buildGetJwks({ + max, + ttl, + timeout, + issuersWhitelist: [issuer], + checkIssuer: (iss) => iss === issuer, + providerDiscovery, + agent, + ...opts + }) + + return function makeSecurityHandler (fastify) { + // Register JWT verify + fastify.register(fastifyJwt, { + decode: { complete: true }, + secret: (_request, token, callback) => { + const { header: { kid, alg }, payload: { iss } } = token + return getJwks.getPublicKey({ kid, domain: iss, alg }) + .then(publicKey => callback(null, publicKey), callback) + } + }) + + // Decorate request with authenticate method + fastify.decorateRequest('authenticate', authRequestDecorator) + + return securityHandlers + } +} + +async function defaultAuth (request) { + try { + const decodedToken = await request.jwtVerify(request) + const { userId } = decodedToken + return userId + } catch (err) { + return null + } +} + +const defaultHandlers = { + async bearerAuth (request, reply, params) { + try { + const userId = await request.authenticate(request) + if (userId == null) throw new Error('no user id') + } catch (e) { + throw new Error(e) + } + } +} diff --git a/package.json b/package.json index 6059e5b..747cece 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,10 @@ "type": "module", "files": [ "index.js", - "types/index.d.ts" + "types/index.d.ts", + "jwtJwks.js", + "lib/jwtJwks.js", + "types/jwtJwks.d.ts" ], "engines": { "node": ">=18" @@ -41,11 +44,18 @@ "@typescript-eslint/parser": "^6.18.1", "eslint": "^8.56.0", "eslint-import-resolver-typescript": "^3.6.1", + "fast-jwt": "^3.3.2", "fastify": "^4.25.2", + "jose": "^5.2.0", + "nock": "^13.5.0", "tap": "^18.6.1", "tsd": "^0.30.3", "typescript": "^5.3.3" }, + "peerDependencies": { + "@fastify/jwt": "^8.0.0", + "get-jwks": "^9.0.0" + }, "tsd": { "directory": "types" } diff --git a/test/error.test.js b/test/error.test.js index f9a9fb6..b2990ac 100644 --- a/test/error.test.js +++ b/test/error.test.js @@ -43,36 +43,40 @@ test('Should throw an error if the `handlersDir` param is missing', async ({ rej const appError = new Error('fastify-openapi-autoload: Missing or invalid `handlersDir`. Please specify a valid directory where your handlers are located.') - await rejects(fastify.ready(), appError) + rejects(fastify.ready(), appError) }) -test('Should throw an error if the `handlersDir` path is invalid', async ({ rejects }) => { +test('Should throw an error if the `handlersDir` path is invalid', async ({ rejects, teardown }) => { + teardown(async () => fastify.close()) const fastify = buildApp({ handlersDir: join(fixturesDir, 'invalidPath') }) const appError = new Error('fastify-openapi-autoload: Missing or invalid `handlersDir`. Please specify a valid directory where your handlers are located.') - await rejects(fastify.ready(), appError) + rejects(fastify.ready(), appError) }) -test('Should throw an error if the `specification` param is missing', async ({ rejects }) => { +test('Should throw an error if the `specification` param is missing', async ({ rejects, teardown }) => { + teardown(async () => fastify.close()) const fastify = buildApp({ hasSpec: false }) const appError = new Error('fastify-openapi-autoload: Missing or invalid `openapi.specification`. Please provide a valid OpenAPI specification file.') - await rejects(fastify.ready(), appError) + rejects(fastify.ready(), appError) }) -test('Should throw an error if the `specification` path is invalid', async ({ rejects }) => { +test('Should throw an error if the `specification` path is invalid', async ({ rejects, teardown }) => { + teardown(async () => fastify.close()) const fastify = buildApp({ specification: 'invalidPath' }) const appError = new Error('fastify-openapi-autoload: Missing or invalid `openapi.specification`. Please provide a valid OpenAPI specification file.') - await rejects(fastify.ready(), appError) + rejects(fastify.ready(), appError) }) -test('Plugin error handling', async ({ equal, rejects }) => { +test('Plugin error handling', async ({ equal, rejects, teardown }) => { + teardown(async () => fastify.close()) const fastify = buildApp({ hasPluginError: true }) fastify.log.error = (msg) => { equal(msg, 'fastify-openapi-autoload: Error registering plugins - fastify-plugin expects a function, instead got a \'object\'') } - await rejects(fastify.ready(), new Error('fastify-openapi-autoload: Error registering plugins - fastify-plugin expects a function, instead got a \'object\'')) + rejects(fastify.ready(), new Error('fastify-openapi-autoload: Error registering plugins - fastify-plugin expects a function, instead got a \'object\'')) }) diff --git a/test/fixtures/handlers/getFoo/index.js b/test/fixtures/handlers/getFoo/index.js index 5396d88..b6b0919 100644 --- a/test/fixtures/handlers/getFoo/index.js +++ b/test/fixtures/handlers/getFoo/index.js @@ -1,6 +1,11 @@ export default async (fastify, { operationId }) => { - fastify.decorate(operationId, async (_req, reply) => { - reply.code(200).send({ foo: 'bar' }) + fastify.decorate(operationId, async (req, reply) => { + try { + const userId = await req.authenticate(req) + reply.code(200).send({ userId }) + } catch (err) { + reply.code(401).send({ error: 'Unauthorized' }) + } }) } diff --git a/test/fixtures/spec-with-security.yaml b/test/fixtures/spec-with-security.yaml new file mode 100644 index 0000000..feec35c --- /dev/null +++ b/test/fixtures/spec-with-security.yaml @@ -0,0 +1,74 @@ +openapi: 3.1.0 +info: + version: 1.0.0 + title: Test Spec + license: + name: MIT + +paths: + /foo: + get: + summary: test GET route /foo + operationId: getFoo + security: + - bearerAuth: [] + tags: + - foo + responses: + '204': + description: test GET route /foo + content: + application/json: + schema: + type: object + properties: + foo: + type: string + + /.well-known/jwks.json: + get: + summary: JSON Web Key Set + operationId: getJwks + tags: + - jwks + responses: + '200': + description: An array of JSON Web Key (JWK) objects + content: + application/json: + schema: + $ref: "#/components/schemas/JWKS" + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + schemas: + JWKS: + title: JSON Web Key Set + type: object + properties: + keys: + description: This is just RSA for now. + type: array + items: + type: object + properties: + kty: + type: string + 'n': + type: string + 'e': + type: string + kid: + type: string + use: + type: string + alg: + type: string + required: + - kty + required: + - keys diff --git a/test/index.test.js b/test/index.test.js index 5276755..6c19957 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -77,7 +77,8 @@ test('should use default operation resolvers', async ({ ok, teardown }) => { ok(fastify.postBaz) }) -test('should use a custom operation resolver if provided', async ({ equal }) => { +test('should use a custom operation resolver if provided', async ({ equal, teardown }) => { + teardown(async () => fastify.close()) const fastify = buildApp({ operationResolver: (operationId) => { if (operationId === 'getFoo') { @@ -99,7 +100,8 @@ test('should use a custom operation resolver if provided', async ({ equal }) => equal(response.body, 'this is a test') }) -test('should use a custom operation resolver factory if provided', async ({ equal }) => { +test('should use a custom operation resolver factory if provided', async ({ equal, teardown }) => { + teardown(async () => fastify.close()) const fastify = buildApp({ makeOperationResolver: (fastify) => (operationId) => { if (operationId === 'getFoo') { @@ -129,3 +131,32 @@ test('should use a custom operation resolver factory if provided', async ({ equa equal(barResponse.statusCode, 200) equal(barResponse.body, 'bar') }) + +test('should use a custom security handlers factory if provided', async ({ equal, same, teardown }) => { + teardown(async () => fastify.close()) + const fastify = buildApp({ + makeSecurityHandlers: (fastify) => { + fastify.decorateRequest('authenticate', async () => 123) + return { + async bearerAuth (request, reply, params) { + try { + const userId = await request.authenticate(request) + if (userId == null) throw new Error('no user id') + } catch (e) { + throw new Error(e) + } + } + } + } + }) + + await fastify.ready() + + const fooResponse = await fastify.inject({ + method: 'GET', + url: '/foo' + }) + + equal(fooResponse.statusCode, 200) + same(fooResponse.json(), { userId: 123 }) +}) diff --git a/test/jwtJwks.test.js b/test/jwtJwks.test.js new file mode 100644 index 0000000..c248e06 --- /dev/null +++ b/test/jwtJwks.test.js @@ -0,0 +1,104 @@ +import { dirname, join } from 'path' +import { fileURLToPath } from 'url' + +import { createSigner } from 'fast-jwt' +import Fastify from 'fastify' +import { generateKeyPair, importSPKI, exportJWK } from 'jose' +import nock from 'nock' +import { test, beforeEach, afterEach } from 'tap' + +import openapiAutoload from '../index.js' +import { jwtJwksHandler } from '../lib/jwtJwks.js' + +beforeEach(() => { + nock.disableNetConnect() +}) + +afterEach(() => { + nock.cleanAll() + nock.enableNetConnect() +}) + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const fixtureDir = join(__dirname, 'fixtures') + +const ISSUER = 'https://autotelic.localhost:3000/' + +async function buildApp () { + const fastify = Fastify() + + const JWT_SIGNING_ALGORITHM = 'RS256' + const JWT_KEY_ID = 'KEY_0' + + const { publicKey, privateKey } = await generateKeyPair(JWT_SIGNING_ALGORITHM) + + const spki = await importSPKI(publicKey.export({ + format: 'pem', + type: 'spki' + }), JWT_SIGNING_ALGORITHM) + + const jwk = await exportJWK(spki) + jwk.alg = JWT_SIGNING_ALGORITHM + jwk.kid = JWT_KEY_ID + jwk.use = 'sig' + + const jwtSign = createSigner({ + key: privateKey.export({ format: 'pem', type: 'pkcs8' }), + iss: ISSUER, + kid: JWT_KEY_ID + }) + const jwtToken = jwtSign({ userId: 123 }) + + // nock endpoint for jwks + nock(ISSUER).get('/.well-known/jwks.json').reply(200, { keys: [jwk] }) + + const makeSecurityHandlers = jwtJwksHandler({ issuer: ISSUER }) + + fastify.register(openapiAutoload, { + handlersDir: join(fixtureDir, 'handlers'), + openapiOpts: { specification: join(fixtureDir, 'spec-with-security.yaml') }, + makeSecurityHandlers + }) + + return { fastify, jwtToken } +} + +test('fastify-openapi-autoload with jwtJwks handlers should register @fastify/jwt', async ({ ok, teardown }) => { + teardown(async () => fastify.close()) + const { fastify } = await buildApp() + await fastify.ready() + + ok(fastify.hasPlugin('@fastify/jwt')) +}) + +test('fastify-openapi-autoload with jwtJwks handlers should reject secure routes without a bearer token', async ({ same, teardown }) => { + teardown(async () => fastify.close()) + const { fastify } = await buildApp() + + const results = await fastify.inject({ + method: 'GET', + url: '/foo' + }) + + same(results.json(), { + statusCode: 401, + error: 'Unauthorized', + message: 'None of the security schemes (bearerAuth) successfully authenticated this request.' + }) +}) + +test('fastify-openapi-autoload with jwtJwks handlers should pass secure routes with a bearer token', async ({ same, teardown }) => { + teardown(async () => fastify.close()) + const { fastify, jwtToken } = await buildApp() + await fastify.ready() + + const results = await fastify.inject({ + method: 'GET', + url: '/foo', + headers: { + authorization: `Bearer ${jwtToken}` + } + }) + + same(results.json(), { userId: 123 }) +}) diff --git a/types/index.d.ts b/types/index.d.ts index 9eda102..d376f1e 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,10 +1,10 @@ import type { FastifyInstance, FastifyPluginCallback } from 'fastify' import type { FastifyOpenapiGlueOptions } from 'fastify-openapi-glue' - export interface OpenapiAutoloadOptions { handlersDir: string openapiOpts: FastifyOpenapiGlueOptions, - makeOperationResolver?: (fastify: FastifyInstance) => FastifyOpenapiGlueOptions['operationResolver'] + makeOperationResolver?: (fastify: FastifyInstance) => FastifyOpenapiGlueOptions['operationResolver'], + makeSecurityHandlers?: (fastify: FastifyInstance) => FastifyOpenapiGlueOptions['securityHandlers'] } declare const fastifyOpenapiAutoload: FastifyPluginCallback diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 4b1c864..10d35d3 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -44,10 +44,19 @@ const opt4 = { makeOperationResolver: () => (operationId: string) => async () => operationId } +const opt5 = { + handlersDir: '/path/to/handlers', + openapiOpts: { + specification: '/path/to/openapi/spec.yaml' + }, + makeSecurityHandlers: () => ({ bearerAuth: [] }) +} + expectAssignable(fastify().register(fastifyOpenapiAutoload, opt1)) expectAssignable(fastify().register(fastifyOpenapiAutoload, opt2)) expectAssignable(fastify().register(fastifyOpenapiAutoload, opt3)) expectAssignable(fastify().register(fastifyOpenapiAutoload, opt4)) +expectAssignable(fastify().register(fastifyOpenapiAutoload, opt5)) const errOpt1 = {} @@ -60,6 +69,24 @@ const errOpt3 = { openapiOpts: {} } +const errOpt4 = { + handlersDir: '/path/to/handlers', + openapiOpts: { + specification: '/path/to/openapi/spec.yaml' + }, + makeOperationResolver: {} +} + +const errOpt5 = { + handlersDir: '/path/to/handlers', + openapiOpts: { + specification: '/path/to/openapi/spec.yaml' + }, + makeSecurityHandlers: {} +} + expectError(fastify().register(fastifyOpenapiAutoload, errOpt1)) expectError(fastify().register(fastifyOpenapiAutoload, errOpt2)) expectError(fastify().register(fastifyOpenapiAutoload, errOpt3)) +expectError(fastify().register(fastifyOpenapiAutoload, errOpt4)) +expectError(fastify().register(fastifyOpenapiAutoload, errOpt5)) diff --git a/types/jwtJwks.d.ts b/types/jwtJwks.d.ts new file mode 100644 index 0000000..82e013a --- /dev/null +++ b/types/jwtJwks.d.ts @@ -0,0 +1,11 @@ +import { FastifyRequest } from 'fastify' +import { GetJwksOptions } from 'get-jwks' + +export interface JwtJwksOptions { + issuer: string + jwksOpts?: GetJwksOptions + authRequestDecorator?: (request: FastifyRequest) => Promise + securityHandlers?: object +} + +export function jwtJwksHandler(options: JwtJwksOptions): object diff --git a/types/jwtJwks.test-d.ts b/types/jwtJwks.test-d.ts new file mode 100644 index 0000000..0dc7f7f --- /dev/null +++ b/types/jwtJwks.test-d.ts @@ -0,0 +1,28 @@ +// eslint-disable-next-line import/no-unresolved +import { expectType, expectError } from 'tsd' + +import { jwtJwksHandler } from '../lib/jwtJwks' + +const options = { + issuer: 'https://example.com', + jwksOpts: { + cache: true, + rateLimit: true + }, + authRequestDecorator: async () => ({ user: 'test' }) +} + +expectType(jwtJwksHandler(options)) + +expectError(jwtJwksHandler({ + issuer: 123, + jwksOpts: { + cache: 'yes', + rateLimit: 'no' + } +})) + +const minimalOptions = { + issuer: 'https://example.com' +} +expectType(jwtJwksHandler(minimalOptions))