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: security handlers #11

Merged
merged 1 commit into from
Feb 21, 2024
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
107 changes: 90 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
54 changes: 54 additions & 0 deletions example/README.md
Original file line number Diff line number Diff line change
@@ -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 <JWT_TOKEN>'
```
14 changes: 14 additions & 0 deletions example/basic/index.js
Original file line number Diff line number Diff line change
@@ -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') }
})
}
5 changes: 3 additions & 2 deletions example/package.json → example/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
7 changes: 7 additions & 0 deletions example/basic/routes/handlers/getFoo/index.js
Original file line number Diff line number Diff line change
@@ -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' }
27 changes: 27 additions & 0 deletions example/basic/routes/spec/openapi.yaml
Original file line number Diff line number Diff line change
@@ -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

14 changes: 0 additions & 14 deletions example/index.js

This file was deleted.

82 changes: 82 additions & 0 deletions example/jwt/index.js
Original file line number Diff line number Diff line change
@@ -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
}
}
4 changes: 4 additions & 0 deletions example/jwt/local-certs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore
16 changes: 16 additions & 0 deletions example/jwt/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
12 changes: 12 additions & 0 deletions example/jwt/routes/handlers/getFoo/index.js
Original file line number Diff line number Diff line change
@@ -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' }
Loading