Skip to content

Commit

Permalink
refactor: separate jwt/jwks handler from plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
meghein committed Jan 22, 2024
1 parent 3cd577f commit 5013890
Show file tree
Hide file tree
Showing 16 changed files with 457 additions and 234 deletions.
22 changes: 22 additions & 0 deletions example/basic/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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', 'test-spec.yaml') }
})
}

// export const options = {
// https: {
// key: readFileSync(join('local-certs', 'autotelic.localhost-key.pem')),
// cert: readFileSync(join('local-certs', 'autotelic.localhost.pem'))
// },
// maxParamLength: 500
// }
14 changes: 14 additions & 0 deletions example/basic/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "fastify-openapi-autoload-example",
"description": "An example to configure @autotelic/fastify-openapi-autoload",
"main": "index.js",
"type": "module",
"scripts": {
"start": "fastify start -w -l info -P -o index.js"
},
"dependencies": {
"fastify": "^4.25.2",
"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/test-spec.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

74 changes: 51 additions & 23 deletions example/index.js → example/jwtJks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,44 @@ import { createSigner } from 'fast-jwt'
import fastifyPlugin from 'fastify-plugin'
import { generateKeyPair, importSPKI, exportJWK } from 'jose'

import openapiAutoload from '../index.js'
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)
Expand All @@ -39,27 +70,24 @@ async function generateKeys () {
}
}

export default async function app (fastify, opts) {
const { jwk, jwtToken } = await generateKeys()
fastify.register(fastifyPlugin(async (fastify, options) => {
const { operationId = 'getJwks' } = options
fastify.decorate(operationId, async () => ({ keys: [jwk] }))
}))

fastify.register(openapiAutoload, {
handlersDir: join(fixturesDir, 'handlers'),
jwksOpts: { whiteListedIssuer: ISSUER },
openapiOpts: { specification: join(fixturesDir, 'spec', 'test-spec.yaml') }
})

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`)
// ==== AUTH & SECURITY HANDLERS ==== //
async function auth (request) {
try {
const decodedToken = await request.jwtVerify(request)
const { userId } = decodedToken
return userId
} catch (err) {
return null
}
}

export const options = {
https: {
key: readFileSync(join('local-certs', 'autotelic.localhost-key.pem')),
cert: readFileSync(join('local-certs', 'autotelic.localhost.pem'))
},
maxParamLength: 500
}
// 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)
// }
// }
// }
File renamed without changes.
2 changes: 1 addition & 1 deletion example/package.json → example/jwtJks/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fastify-openapi-autoload-example",
"description": "An example to configure @autotelic/fastify-openapi-autoload",
"description": "An example to configure @autotelic/fastify-openapi-autoload with jwt/jwks",
"main": "index.js",
"type": "module",
"scripts": {
Expand Down
File renamed without changes.
File renamed without changes.
88 changes: 17 additions & 71 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import { existsSync } from 'fs'
import https from 'node:https'

import { fastifyAutoload } from '@fastify/autoload'
import fastifyJwt from '@fastify/jwt'
import openapiGlue from 'fastify-openapi-glue'
import fastifyPlugin from 'fastify-plugin'
import buildGetJwks from 'get-jwks'

async function openapiAutoload (fastify, options = {}) {
const { handlersDir, jwksOpts = {}, openapiOpts = {} } = options
const { specification, operationResolver = null, securityHandlers = null } = openapiOpts
const { handlersDir, openapiOpts = {}, makeSecurityHandlers, makeOperationResolver } = options
const { specification, operationResolver = null } = openapiOpts

// Validate handlers directory
if (!handlersDir || !existsSync(handlersDir)) {
Expand All @@ -22,31 +19,6 @@ async function openapiAutoload (fastify, options = {}) {
}

try {
// If whiteListed is provided, register fastifyJwt and decorate request with `authenticate`
if (jwksOpts.whiteListedIssuer) {
const getJwks = makeJwks(jwksOpts)
// 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
fastify.decorateRequest('authenticate', async (request) => {
try {
const decodedToken = await request.jwtVerify(request)
const { userId } = decodedToken
return userId
} catch (err) {
return null
}
})
}

// Register fastifyAutoload for handlers
fastify.register(fastifyAutoload, {
dir: handlersDir,
Expand All @@ -55,62 +27,36 @@ async function openapiAutoload (fastify, options = {}) {
encapsulate: false
})

// Register openapiGlue for OpenAPI integration
fastify.register(openapiGlue, {
const openapiGlueOpts = {
specification,
operationResolver: operationResolver || makeOperationResolver(fastify),
securityHandlers: securityHandlers || makeSecurityHandlers(fastify),
operationResolver: operationResolver || defaultResolverFactory(fastify),
...openapiOpts
})
}

// factory/creator function params for security handlers & operation resolver
if (makeSecurityHandlers) {
openapiGlueOpts.securityHandlers = makeSecurityHandlers(fastify)
}
if (makeOperationResolver) {
openapiGlueOpts.operationResolver = makeOperationResolver(fastify)
}

// Register openapiGlue for OpenAPI integration
fastify.register(openapiGlue, openapiGlueOpts)
} catch (error) {
const errorMessage = `fastify-openapi-autoload: Error registering plugins - ${error.message}`
fastify.log.error(errorMessage)
throw new Error(errorMessage)
}
}

function makeOperationResolver (fastify) {
function defaultResolverFactory (fastify) {
return (operationId) => {
fastify.log.info(`fastify-openapi-autoload - has '${operationId}' decorator: ${fastify.hasDecorator(operationId)}`)
return fastify[operationId]
}
}

// TODO(meghein): update securityHandler with reasonable defaults
function makeSecurityHandlers (fastify) {
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)
}
}
}
}

function makeJwks ({
max = 100,
ttl = 60 * 1000,
timeout = 5000,
whiteListedIssuer,
providerDiscovery = false,
agent = new https.Agent({ keepAlive: true }),
...opts
}) {
return buildGetJwks({
max,
ttl,
timeout,
issuersWhitelist: [whiteListedIssuer],
checkIssuer: (issuer) => issuer === whiteListedIssuer,
providerDiscovery,
agent,
...opts
})
}

const fastifyOpenapiAutoload = fastifyPlugin(openapiAutoload, {
name: 'fastify-openapi-autoload'
})
Expand Down
69 changes: 69 additions & 0 deletions lib/jwtJwks.js
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,8 @@
"homepage": "https://github.com/autotelic/fastify-openapi-autoload#readme",
"dependencies": {
"@fastify/autoload": "^5.8.0",
"@fastify/jwt": "^8.0.0",
"fastify-openapi-glue": "4.4.2",
"fastify-plugin": "^4.5.1",
"get-jwks": "^9.0.0"
"fastify-plugin": "^4.5.1"
},
"devDependencies": {
"@autotelic/eslint-config-js": "^0.2.1",
Expand All @@ -52,6 +50,10 @@
"tsd": "^0.30.3",
"typescript": "^5.3.3"
},
"peerDependencies": {
"@fastify/jwt": "^8.0.0",
"get-jwks": "^9.0.0"
},
"tsd": {
"directory": "types"
}
Expand Down
Loading

0 comments on commit 5013890

Please sign in to comment.