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: implement jwks via jose #874

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
5 changes: 5 additions & 0 deletions .changeset/hip-oranges-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/jwks': minor
---

implement jwks solution
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}'",
Expand Down
79 changes: 79 additions & 0 deletions packages/jwks/README.md
Original file line number Diff line number Diff line change
@@ -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 <https://github.com/shawncarr>

## License

MIT
49 changes: 49 additions & 0 deletions packages/jwks/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
97 changes: 97 additions & 0 deletions packages/jwks/src/index.ts
Original file line number Diff line number Diff line change
@@ -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()
})
}
10 changes: 10 additions & 0 deletions packages/jwks/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
},
"include": [
"src/**/*.ts"
],
}
8 changes: 8 additions & 0 deletions packages/jwks/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/// <reference types="vitest" />
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
globals: true,
},
})
21 changes: 21 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down