From cc1717c4479a3c5c57be68ed75924f2a67d76591 Mon Sep 17 00:00:00 2001 From: Shawn Carr Date: Mon, 9 Dec 2024 12:42:12 -0500 Subject: [PATCH] feat: implement jwks via jose --- .changeset/hip-oranges-work.md | 5 ++ package.json | 1 + packages/jwks/README.md | 79 +++++++++++++++++++++++++++ packages/jwks/package.json | 49 +++++++++++++++++ packages/jwks/src/index.ts | 97 ++++++++++++++++++++++++++++++++++ packages/jwks/tsconfig.json | 10 ++++ packages/jwks/vitest.config.ts | 8 +++ yarn.lock | 21 ++++++++ 8 files changed, 270 insertions(+) create mode 100644 .changeset/hip-oranges-work.md create mode 100644 packages/jwks/README.md create mode 100644 packages/jwks/package.json create mode 100644 packages/jwks/src/index.ts create mode 100644 packages/jwks/tsconfig.json create mode 100644 packages/jwks/vitest.config.ts diff --git a/.changeset/hip-oranges-work.md b/.changeset/hip-oranges-work.md new file mode 100644 index 000000000..582ee5ea1 --- /dev/null +++ b/.changeset/hip-oranges-work.md @@ -0,0 +1,5 @@ +--- +'@hono/jwks': minor +--- + +implement jwks solution diff --git a/package.json b/package.json index ac12e8afa..0a94becb6 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "build:casbin": "yarn workspace @hono/casbin build", "build:ajv-validator": "yarn workspace @hono/ajv-validator build", "build:tsyringe": "yarn workspace @hono/tsyringe build", + "build:jwks": "yarn workspace @hono/jwks build", "build": "run-p 'build:*'", "lint": "eslint 'packages/**/*.{ts,tsx}'", "lint:fix": "eslint --fix 'packages/**/*.{ts,tsx}'", diff --git a/packages/jwks/README.md b/packages/jwks/README.md new file mode 100644 index 000000000..0e65e9e29 --- /dev/null +++ b/packages/jwks/README.md @@ -0,0 +1,79 @@ +# jwks middleware for Hono + +Using [`jose`](https://github.com/panva/jose) to verify JWT based on a public key from a JWKS endpoint. + +## Installation + +```bash +npm i hono @hono/jwks jose +``` + +## Configuration + +The `jwks` middleware has the following options: + +```ts + certsUrl: string | URL // The URL to the JWKS endpoint + policyIssuer: string // The issuer of the policy (iss claim) + policyAudience: string // The audience of the policy (aud claim) + tokenSource?: (c: Context) => string | undefined // A optional function to get the token from the context. Defaults to the cloudflare header + headerName?: string // The header name to get the token from. Defaults to 'cf-access-jwt-assertion' +``` + +## Usage + +### Output + +If it is a valid JWT, the middleware will inject the decoded payload into the `Context` and will be available in the `jwks` variable with the following structure: + +```ts + payload: any // The decoded JWT payload + token: any // The token string used to verify the JWT +``` + +If the token is not found in the provided source, the middleware will throw an error with the status code `403` and the message `Forbidden`. + +If token is unable to be verified, the middleware will throw an error with the status code `401` and the message `Unauthorized`. + +### Cloudflare Workers and Cloudflare Zero Trust Example + +```ts +import { hello } from '@hono/jwks' +import { Hono } from 'hono' + +const app = new Hono<{Binding: { TEAM_NAME: string, POLICY_AUD: string }}>() + +app.use((c, next) => { + // Set the TEAM_NAME and POLICY_AUD from the Cloudflare Variables and Secrets + const { TEAM_NAME, POLICY_AUD } = c.env; + + // The team domain is used to build the JWKS URL and the policy issuer + const teamDomain = `https://${TEAM_NAME}.cloudflareaccess.com`; + + const jwksWorker = jwks({ + certsUrl: `${teamDomain}/cdn-cgi/access/certs`, + policyIssuer: teamDomain, + policyAudience: POLICY_AUD, + }); + + return jwksWorker(c, next); +}); + +app.get("/test-jwks", (c) => { + return c.json(c.var.jwks); +}); + +export default app +``` + +## Notes + +This middleware was built to be used with multiple different providers, such as Cloudflare Zero Trust, but exhaustive testing has not been done. Please open an issue if you find a bug or have a feature request. + +## Author + +Shawn Carr + +## License + +MIT diff --git a/packages/jwks/package.json b/packages/jwks/package.json new file mode 100644 index 000000000..f968964a9 --- /dev/null +++ b/packages/jwks/package.json @@ -0,0 +1,49 @@ +{ + "name": "@hono/jwks", + "version": "0.0.0", + "description": "Validator for JWTs using JWKS", + "type": "module", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "test": "vitest --run", + "build": "tsup ./src/index.ts --format esm,cjs --dts", + "publint": "publint", + "release": "yarn build && yarn test && yarn publint && yarn publish" + }, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "license": "MIT", + "publishConfig": { + "registry": "https://registry.npmjs.org", + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/honojs/middleware.git" + }, + "homepage": "https://github.com/honojs/middleware", + "peerDependencies": { + "hono": "*", + "jose": "^5.9.6" + }, + "devDependencies": { + "hono": "^4.4.12", + "jose": "^5.9.6", + "tsup": "^8.1.0", + "vitest": "^1.6.0" + } +} diff --git a/packages/jwks/src/index.ts b/packages/jwks/src/index.ts new file mode 100644 index 000000000..023adf600 --- /dev/null +++ b/packages/jwks/src/index.ts @@ -0,0 +1,97 @@ +import type { Context } from 'hono' +import { createMiddleware } from 'hono/factory' +import { HTTPException } from 'hono/http-exception' +import jose from 'jose' + +type JWKSResult = { + readonly payload: any + readonly token: any +} + +// This is a declaration merging for the ContextVariableMap interface to add a new key 'jwks' with the type JWKSResult +declare module 'hono' { + interface ContextVariableMap { + jwks: JWKSResult + } +} + +type JWKSOptions = { + readonly certsUrl: string | URL + readonly policyIssuer: string + readonly policyAudience: string + readonly tokenSource?: (c: Context) => string | undefined + readonly headerName?: string +} + +/** + * Middleware function to validate JSON Web Tokens (JWT) using JSON Web Key Sets (JWKS). + * + * @param {JWKSOptions} options - Configuration options for the JWKS middleware. + * @param {string | URL} options.certsUrl - The URL to fetch the JWKS from. + * @param {string} options.policyIssuer - The expected issuer of the JWT. + * @param {string} options.policyAudience - The expected audience of the JWT. + * @param {string} [options.headerName='cf-access-jwt-assertion'] - The name of the header containing the JWT. + * @param {function} [options.tokenSource] - A function to extract the token from the request context. + * + * @throws {Error} Throws an error if required options are missing. + * @throws {HTTPException} Throws an HTTPException if the JWT is missing or invalid. + * + * @returns {function} Returns a middleware function to be used in the request pipeline. + */ +export const jwks = (options: JWKSOptions) => { + if (!options) { + throw new Error('Missing options') + } + + if (!options.certsUrl) { + throw new Error('Missing certsUrl') + } + + if (!options.policyIssuer) { + throw new Error('Missing policyIssuer') + } + + if (!options.policyAudience) { + throw new Error('Missing policyAudience') + } + + let certsUrl: URL + if (typeof options.certsUrl === 'string') { + certsUrl = new URL(options.certsUrl) + } else { + certsUrl = options.certsUrl + } + + const JWKS = jose.createRemoteJWKSet(certsUrl) + + return createMiddleware(async (c, next) => { + const headerName = options.headerName || 'cf-access-jwt-assertion' + const tokenSource = options.tokenSource || ((c) => c.req.header(headerName)) + + const token = tokenSource(c) + if (!token) { + const res = new Response('Missing Required Authorization Token', { + headers: c.res.headers, + status: 403, + }) + throw new HTTPException(403, { res }) + } + + try { + const { payload } = await jose.jwtVerify(token, JWKS, { + issuer: options.policyIssuer, + audience: options.policyAudience, + }) + c.set('jwks', { payload, token }) + } catch (e) { + const error = e instanceof Error ? e : new Error('Unknown Error') + const res = new Response(error.message, { + headers: c.res.headers, + status: 401, + }) + throw new HTTPException(401, { res }) + } + + await next() + }) +} diff --git a/packages/jwks/tsconfig.json b/packages/jwks/tsconfig.json new file mode 100644 index 000000000..acfcd8430 --- /dev/null +++ b/packages/jwks/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + }, + "include": [ + "src/**/*.ts" + ], +} \ No newline at end of file diff --git a/packages/jwks/vitest.config.ts b/packages/jwks/vitest.config.ts new file mode 100644 index 000000000..17b54e485 --- /dev/null +++ b/packages/jwks/vitest.config.ts @@ -0,0 +1,8 @@ +/// +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + }, +}) diff --git a/yarn.lock b/yarn.lock index f18bd75fd..089dcd0c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2648,6 +2648,20 @@ __metadata: languageName: unknown linkType: soft +"@hono/jwks@workspace:packages/jwks": + version: 0.0.0-use.local + resolution: "@hono/jwks@workspace:packages/jwks" + dependencies: + hono: "npm:^4.4.12" + jose: "npm:^5.9.6" + tsup: "npm:^8.1.0" + vitest: "npm:^1.6.0" + peerDependencies: + hono: "*" + jose: ^5.9.6 + languageName: unknown + linkType: soft + "@hono/medley-router@workspace:packages/medley-router": version: 0.0.0-use.local resolution: "@hono/medley-router@workspace:packages/medley-router" @@ -13458,6 +13472,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^5.9.6": + version: 5.9.6 + resolution: "jose@npm:5.9.6" + checksum: d6bcd8c7d655b5cda8e182952a76f0c093347f5476d74795405bb91563f7ab676f61540310dd4b1531c60d685335ceb600571a409551d2cbd2ab3e9f9fbf1e4d + languageName: node + linkType: hard + "joycon@npm:^3.0.1, joycon@npm:^3.1.1": version: 3.1.1 resolution: "joycon@npm:3.1.1"