Skip to content

Commit

Permalink
feat(hono/jwk): JWK Auth Middleware (#3826)
Browse files Browse the repository at this point in the history
* Update cookie.ts

* Integrated `priority` option into setCookie serialization tests

* Add kid' to TokenHeader, fix Jwt.sign ignoring privateKey.alg with keyAlg fallback, add decodeHeaders utility

- Added "kid" (Key ID) for TokenHeader
- Fixed Jwt.sign() ignoring privateKey.alg
- Renamed `alg` parameter to `KEYAlg` to differentiate between privateKey.alg
- Added utility function `decodeHeaders` to decode only JWT headers

* Add ./src/middleware/jwk/jwk.ts to jsr.json

* Add hono/jwk to exports

* feat(hono/jwk)

Hono JWK Middleware main features:
- Ability to provide a list of public JWKs to the keys parameter as a simple javascript array []
- Ability to provide a URL in the jwks_uri parameter to fetch keys from + an optional RequestInit (useful for caching if your cloud provider has a modified fetch that supports it, or if you simply want to modify the request)
- Ability to provide an async function that returns an array to the keys parameter instead of a direct array, so that it is possible to implement own custom caching layer without a separate middleware
- Allows setting a keys directory for multi-key auth systems
- Allows auth endpoints to be always updated with the Auth provider's public JWKs directory (often `.well-known/jwks.json`) which makes key rotations without disruptions possible

Todo:
- More tests.

* (feat/Jwt.verifyFromJwks) / batteries included util

* Update index.ts

* add JwtHeaderRequiresKid exception

* using Jwt.verifyFromJwks now

* improved jsdoc and formatting

* jsdoc update

* formatting

* testing jwk's `keys` receiving an async function

* removed redundancy

* add 'Should authorize Keys function' test

Added /auth-keys-fn/* & /.well-known/jwks.json testing endpoints to the router.

* added jwks_uri test + improved test descriptions

* test naming consistency

* explicit return fix + moving global declaration merging to own interface

* cleaner jsdoc @example

* removed commented-out tests unnecessarily inflating changes

* ExtendedJsonWebKey -> HonoJsonWebKey

* Refactor test to use msw per @yusukebe's suggestion

* removed stray log + added minor validation w/ @Code-Hex

Also removed redundant "import type {} from '../..'" from 3 different files:
- jwk/index.ts
- jwt/index.ts
- request-id/index.ts

* Update index.ts

* add more test coverage

* more test coverage

* lint & format

* typo

* 100/100 test coverage

Note: Moved more code from `hono/jwk` to the `verifyFromJwks` function for backends that require JWK verification logic beyond just a middleware

* final touch

* added eslint-disable + type export
  • Loading branch information
Beyondo authored Feb 6, 2025
1 parent 426cad6 commit 6837649
Show file tree
Hide file tree
Showing 11 changed files with 935 additions and 6 deletions.
1 change: 1 addition & 0 deletions jsr.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"./jsx/dom/css": "./src/jsx/dom/css.ts",
"./jsx/dom/server": "./src/jsx/dom/server.ts",
"./jwt": "./src/middleware/jwt/jwt.ts",
"./jwk": "./src/middleware/jwk/jwk.ts",
"./timeout": "./src/middleware/timeout/index.ts",
"./timing": "./src/middleware/timing/timing.ts",
"./logger": "./src/middleware/logger/index.ts",
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,11 @@
"import": "./dist/middleware/jwt/index.js",
"require": "./dist/cjs/middleware/jwt/index.js"
},
"./jwk": {
"types": "./dist/types/middleware/jwk/index.d.ts",
"import": "./dist/middleware/jwk/index.js",
"require": "./dist/cjs/middleware/jwk/index.js"
},
"./timeout": {
"types": "./dist/types/middleware/timeout/index.d.ts",
"import": "./dist/middleware/timeout/index.js",
Expand Down
642 changes: 642 additions & 0 deletions src/middleware/jwk/index.test.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/middleware/jwk/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { jwk } from './jwk'
153 changes: 153 additions & 0 deletions src/middleware/jwk/jwk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* @module
* JWK Auth Middleware for Hono.
*/

import type { Context } from '../../context'
import { getCookie, getSignedCookie } from '../../helper/cookie'
import { HTTPException } from '../../http-exception'
import type { MiddlewareHandler } from '../../types'
import type { CookiePrefixOptions } from '../../utils/cookie'
import { Jwt } from '../../utils/jwt'
import '../../context'
import type { HonoJsonWebKey } from '../../utils/jwt/jws'

/**
* JWK Auth Middleware for Hono.
*
* @see {@link https://hono.dev/docs/middleware/builtin/jwk}
*
* @param {object} options - The options for the JWK middleware.
* @param {HonoJsonWebKey[] | (() => Promise<HonoJsonWebKey[]>)} [options.keys] - The values of your public keys, or a function that returns them.
* @param {string} [options.jwks_uri] - If this value is set, attempt to fetch JWKs from this URI, expecting a JSON response with `keys` which are added to the provided options.keys
* @param {string} [options.cookie] - If this value is set, then the value is retrieved from the cookie header using that value as a key, which is then validated as a token.
* @param {RequestInit} [init] - Optional initialization options for the `fetch` request when retrieving JWKS from a URI.
* @returns {MiddlewareHandler} The middleware handler function.
*
* @example
* ```ts
* const app = new Hono()
*
* app.use("/auth/*", jwk({ jwks_uri: "https://example-backend.hono.dev/.well-known/jwks.json" }))
*
* app.get('/auth/page', (c) => {
* return c.text('You are authorized')
* })
* ```
*/

export const jwk = (
options: {
keys?: HonoJsonWebKey[] | (() => Promise<HonoJsonWebKey[]>)
jwks_uri?: string
cookie?:
| string
| { key: string; secret?: string | BufferSource; prefixOptions?: CookiePrefixOptions }
},
init?: RequestInit
): MiddlewareHandler => {
if (!options || !(options.keys || options.jwks_uri)) {
throw new Error('JWK auth middleware requires options for either "keys" or "jwks_uri" or both')
}

if (!crypto.subtle || !crypto.subtle.importKey) {
throw new Error('`crypto.subtle.importKey` is undefined. JWK auth middleware requires it.')
}

return async function jwk(ctx, next) {
const credentials = ctx.req.raw.headers.get('Authorization')
let token
if (credentials) {
const parts = credentials.split(/\s+/)
if (parts.length !== 2) {
const errDescription = 'invalid credentials structure'
throw new HTTPException(401, {
message: errDescription,
res: unauthorizedResponse({
ctx,
error: 'invalid_request',
errDescription,
}),
})
} else {
token = parts[1]
}
} else if (options.cookie) {
if (typeof options.cookie == 'string') {
token = getCookie(ctx, options.cookie)
} else if (options.cookie.secret) {
if (options.cookie.prefixOptions) {
token = await getSignedCookie(
ctx,
options.cookie.secret,
options.cookie.key,
options.cookie.prefixOptions
)
} else {
token = await getSignedCookie(ctx, options.cookie.secret, options.cookie.key)
}
} else {
if (options.cookie.prefixOptions) {
token = getCookie(ctx, options.cookie.key, options.cookie.prefixOptions)
} else {
token = getCookie(ctx, options.cookie.key)
}
}
}

if (!token) {
const errDescription = 'no authorization included in request'
throw new HTTPException(401, {
message: errDescription,
res: unauthorizedResponse({
ctx,
error: 'invalid_request',
errDescription,
}),
})
}

let payload
let cause
try {
payload = await Jwt.verifyFromJwks(token, options, init)
} catch (e) {
cause = e
}

if (!payload) {
if (cause instanceof Error && cause.constructor === Error) {
throw cause
}
throw new HTTPException(401, {
message: 'Unauthorized',
res: unauthorizedResponse({
ctx,
error: 'invalid_token',
statusText: 'Unauthorized',
errDescription: 'token verification failure',
}),
cause,
})
}

ctx.set('jwtPayload', payload)

await next()
}
}

function unauthorizedResponse(opts: {
ctx: Context
error: string
errDescription: string
statusText?: string
}) {
return new Response('Unauthorized', {
status: 401,
statusText: opts.statusText,
headers: {
'WWW-Authenticate': `Bearer realm="${opts.ctx.req.url}",error="${opts.error}",error_description="${opts.errDescription}"`,
},
})
}
48 changes: 48 additions & 0 deletions src/middleware/jwk/keys.test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"public_keys": [
{
"kid": "hono-test-kid-1",
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"e": "AQAB",
"n": "2XGQh8VC_p8gRqfBLY0E3RycnfBl5g1mKyeiyRSPjdaR7fmNPuC3mHjVWXtyXWSvAuRYPYfL_pSi6erpxVv7NuPJbKaZ-I1MwdRPdG2qHu9mNYxniws73gvF3tUN9eSsQUIBL0sYEOnVMjniDcOxIr3Rgz_RxdLB_FxTDXYhzzG49L79wGV1udILGHq0lqlMtmUX6LRtbaoRt1fJB4rTCkYeQp9r5HYP79PKTR43vLIq0aZryI4CyBkPG_0vGEvnzasGdp-qE9Ywt_J2anQKt3nvVVR4Yhs2EIoPQkYoDnVySjeuRsUA5JQYKThrM4sFZSQsO82dHTvwKo2z2x6ZMw"
},
{
"kid": "hono-test-kid-2",
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"e": "AQAB",
"n": "uRVR5DkH22a_FM4RtqvnVxd6QAjdfj8oFYPaxIux7K8oTaBy5YagxTWN0qeKI5lI3nL20cx72XxD_UF4TETCFgfD-XB48cdjnSQlOXXbRPXUX0Rdte48naAt4przAb7ydUxrfvDlbSZe02Du-ZGRzEB6RW6KLFWUvTadI4w33qb2i8hauQuTcRmaIUESt8oytUGS44dXAw3Nqt_NL-e7TRgX5o1u_31Uvet1ofsv6Mx8vxJ6zMdM_AKvzLt2iuoK_8vL4R86CjD3dpal2BwO7RkRl2Wcuf5jxjM4pruJ2RBCpzBieEvSIH8kKHIm9SfTzTDJqRhoXd7KM5jL1GNzyw"
}
],
"private_keys": [
{
"kid": "hono-test-kid-1",
"alg": "RS256",
"d": "A5CR2gGPegHwOYUbUzylZvdgUFNWMetOUK7M3TClGdVgSkWpELrTLhpTa3m50KYlG446x03baxUGU4D_MoKx7GukX0-fGCzY17FvWNOwOLACcPMYT3ZwfAQ2_jkBimJxU7CNUtH18KQ-U1B3nQ1apHZc-1Xa6CKIY5nv32yfj6uTrERRLOs7Fn9xpOE4uMHEf-l1ppIEIqK5QkEoPRMCUBABsGBSfiJP2hQVa-R-nezX3kVSxKTxAjDEOkquzb-CKlJW7xN2xQ7p40Wi7lDWZkOapBNGr59Z4gcFfo6f8XpQrqoFjDfsGsdH5q9MH_3lEEtD14wymXNnCoRHNr_mwQ",
"dp": "WMq_BNbd3At-J9VzXgE-aLvPhztS1W8K9xlghITpwAyzhEfCp9mO7IOEVtNWKoEtVFEaZrWKuNWKd-dnzjvydltCkpJ7QhTmiFNFsEzKNJdGQ1Tfsj9658csbVLUOhI4oVcN6kiCa6OdH41Z_JMyN75cTgd4z5h_FRYRRgjoUEU",
"dq": "Lz9vM7L-aEsPJOM5K2PqInLP9HNwDl943S79d_aw6w-JnHPFcu95no6-6nRcd87eSWoTvHZeFgsle4oiV0UpAocEO7xraCBa_Z9o-jGbBfynOLyXMH2l70yWBdCGCzgc_Wg2sKJwiYYXXfGJ3CzSeIRet82Rn54Q9mMlB6Ie8LE",
"e": "AQAB",
"kty": "RSA",
"n": "2XGQh8VC_p8gRqfBLY0E3RycnfBl5g1mKyeiyRSPjdaR7fmNPuC3mHjVWXtyXWSvAuRYPYfL_pSi6erpxVv7NuPJbKaZ-I1MwdRPdG2qHu9mNYxniws73gvF3tUN9eSsQUIBL0sYEOnVMjniDcOxIr3Rgz_RxdLB_FxTDXYhzzG49L79wGV1udILGHq0lqlMtmUX6LRtbaoRt1fJB4rTCkYeQp9r5HYP79PKTR43vLIq0aZryI4CyBkPG_0vGEvnzasGdp-qE9Ywt_J2anQKt3nvVVR4Yhs2EIoPQkYoDnVySjeuRsUA5JQYKThrM4sFZSQsO82dHTvwKo2z2x6ZMw",
"p": "7K-X3xMf3xxdlHTRs17x4WkbFUq4ZCU9L1al88UW2tpoF8ZDLUvaKXeF0vkosKvYUsiHsV1fbGVo6Oy75iII-op-t6-tP3R61nkjaytyJ8p32nbxBI1UWpFxZYNxG_Od07kau3LwkgDh8Ogr6zqmq8-lKoBPio-4K7PY5FiyWzs",
"q": "6y__IKt1n1pTc-S9l1WfSuC96jX8iQhEsGSxnshyNZi59mH1AigkrAw9T5b7OFX7ulHXwuithsVi8cxkq2inNmemxD3koiiU-sv6vg6lRCoZsXFHiUCP-2HoK17sR1zUb6HQpp5MEHY8qoC3Mi3IpkNC7gAbAukbMQo3WlIGqmk",
"qi": "flgM56Nw2hzHHy0Lz8ewBtOkkzfq1r_n6SmSZdU0zWlEp1lLovpHmuwyVeXpQlLJUHqcNVRw0NlwV7EN0rPd4rG3hcMdogj_Jl-r52TYzx4kVpbMEIh4xKs5rFzxbb96A3F9Ox-muRWvfOUCpXxGXCCGqHRmjRUolxDxsiPznuk"
},
{
"kid": "hono-test-kid-2",
"alg": "RS256",
"d": "JCIL50TVClnQQyUJ40JDO0b7mGXCrCNzVWP1ATsOhNkbQrBozfOPDoEqi24m81U5GyiRlBraMPboJRizfhxMUdW5RkjVa8pT4blNRR8DrD5b9C9aJir5DYLYgm1itLwNBKZjNBieicUcbSL29KUdNCWAWW6_rfEVRS1U1zxIKgDUPVd6d7jiIwAKuKvGlMc11RGRZj5eKSNMQyLU5u8Qs_VQuoBRNAyWLZZcHMlAWbh3er7m0jkmUDRdVU0y_n1UAGsr9cAxPwf2HtS5j5R2ahEodatsJynnafYtj6jbOR6jvO3N2Vf-NJ7jVY2-kfv1rJd86KAxD-tIAGx2w1VRTQ",
"dp": "wQhiWfdvVxk7ERmYj7Fn04wqjP7o7-72bn3SznGyBSkvpkg1WX4j467vpRtXVn4qxSSMXCj2UMKCrovba2RWHp1cnkvT-TFTbONkBuhOBpbx3TVwgGd-IfDJVa_i89XjiYgtEApHz173kRodEENXxcOj_mbOGyBb9Yl2M45A-tU",
"dq": "ERdP5mdziJ46OsDHTdZ4hOX2ti0EljtVqGo1B4WKXey6DMH0JGHGU_3fFiF4Gomhy3nyGUI7Qhk3kf7lixAtSsk1lWAAeQLPt1r8yZkD5odLKXLyua_yZJ041d3O3wxRYXl3OvzoVy6rPhzRPIaxevNp-Pp5ZNoKfonQPz3bDGc",
"e": "AQAB",
"kty": "RSA",
"n": "uRVR5DkH22a_FM4RtqvnVxd6QAjdfj8oFYPaxIux7K8oTaBy5YagxTWN0qeKI5lI3nL20cx72XxD_UF4TETCFgfD-XB48cdjnSQlOXXbRPXUX0Rdte48naAt4przAb7ydUxrfvDlbSZe02Du-ZGRzEB6RW6KLFWUvTadI4w33qb2i8hauQuTcRmaIUESt8oytUGS44dXAw3Nqt_NL-e7TRgX5o1u_31Uvet1ofsv6Mx8vxJ6zMdM_AKvzLt2iuoK_8vL4R86CjD3dpal2BwO7RkRl2Wcuf5jxjM4pruJ2RBCpzBieEvSIH8kKHIm9SfTzTDJqRhoXd7KM5jL1GNzyw",
"p": "7cY_nFnn4w5pVi7wq_S9FJHIGsxCwogXqSSC_d7yWopbI2rW3Ugx21IMcWT2pnpsF_VYQx5FnNFviFufNOloREOguqci4lBinAilYBf3VXaN_YrxSk4flJmykwm_HBbXpHt_L3t4HBf-uuY-klJxFkeTbBErjxMS0U0EheEpDYU",
"q": "x0UidqgkzWPqXa7vZ5noYTY5e3TDQZ_l8A26lFDKAbB62lXvnp_MhnQYDAx9VgUGYYrXv7UmaH-ZCSzuMM9Uhuw0lXRyojF-TLowNjASMlWbkJsJus3zi_AI4pAKyYnhNADxZrT1kxseI8zHiq0_bQa8qLaleXBTdkpc3Z6M1Q8",
"qi": "x5VJcfnlX9ZhH6eMKx27rOGQrPjQ4BjZgmND7rrX-CSrE0M0RG4KuC4ZOu5XpQ-YsOC_bIzolBN2cHGn4ttPXeUc3y5bnqJYo7FxMdGn4gPRbXlVjCrE54JH_cdkl8cDqcaybjme1-ilNu-vHJWgHPdpbOguhRpicARkptAkOe0"
}
]
}
1 change: 0 additions & 1 deletion src/middleware/request-id/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { RequestIdVariables } from './request-id'
export type { RequestIdVariables }
export { requestId } from './request-id'
import type {} from '../..'

declare module '../..' {
interface ContextVariableMap extends RequestIdVariables {}
Expand Down
4 changes: 2 additions & 2 deletions src/utils/jwt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
* JWT utility.
*/

import { decode, sign, verify } from './jwt'
export const Jwt = { sign, verify, decode }
import { decode, sign, verify, verifyFromJwks } from './jwt'
export const Jwt = { sign, verify, decode, verifyFromJwks }
8 changes: 7 additions & 1 deletion src/utils/jwt/jws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ type KeyAlgorithm =
| (EcdsaParams & EcKeyImportParams)
| HmacImportParams

export type SignatureKey = string | JsonWebKey | CryptoKey
// Extending the JsonWebKey interface to include the "kid" property.
// https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.4
export interface HonoJsonWebKey extends JsonWebKey {
kid?: string
}

export type SignatureKey = string | HonoJsonWebKey | CryptoKey

export async function signing(
privateKey: SignatureKey,
Expand Down
69 changes: 67 additions & 2 deletions src/utils/jwt/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import { decodeBase64Url, encodeBase64Url } from '../../utils/encode'
import { AlgorithmTypes } from './jwa'
import type { SignatureAlgorithm } from './jwa'
import { signing, verifying } from './jws'
import type { SignatureKey } from './jws'
import type { HonoJsonWebKey, SignatureKey } from './jws'
import {
JwtHeaderInvalid,
JwtHeaderRequiresKid,
JwtTokenExpired,
JwtTokenInvalid,
JwtTokenIssuedAt,
Expand All @@ -30,6 +31,7 @@ const decodeJwtPart = (part: string): TokenHeader | JWTPayload | undefined =>
export interface TokenHeader {
alg: SignatureAlgorithm
typ?: 'JWT'
kid?: string
}

export function isTokenHeader(obj: unknown): obj is TokenHeader {
Expand All @@ -50,7 +52,13 @@ export const sign = async (
alg: SignatureAlgorithm = 'HS256'
): Promise<string> => {
const encodedPayload = encodeJwtPart(payload)
const encodedHeader = encodeJwtPart({ alg, typ: 'JWT' } satisfies TokenHeader)
let encodedHeader
if (typeof privateKey === 'object' && 'alg' in privateKey) {
alg = privateKey.alg as SignatureAlgorithm
encodedHeader = encodeJwtPart({ alg, typ: 'JWT', kid: privateKey.kid })
} else {
encodedHeader = encodeJwtPart({ alg, typ: 'JWT' })
}

const partialToken = `${encodedHeader}.${encodedPayload}`

Expand Down Expand Up @@ -99,6 +107,54 @@ export const verify = async (
return payload
}

export const verifyFromJwks = async (
token: string,
options: {
keys?: HonoJsonWebKey[] | (() => Promise<HonoJsonWebKey[]>)
jwks_uri?: string
},
init?: RequestInit
): Promise<JWTPayload> => {
const header = decodeHeader(token)

if (!isTokenHeader(header)) {
throw new JwtHeaderInvalid(header)
}
if (!header.kid) {
throw new JwtHeaderRequiresKid(header)
}

let keys = typeof options.keys === 'function' ? await options.keys() : options.keys

if (options.jwks_uri) {
const response = await fetch(options.jwks_uri, init)
if (!response.ok) {
throw new Error(`failed to fetch JWKS from ${options.jwks_uri}`)
}
const data = (await response.json()) as { keys?: JsonWebKey[] }
if (!data.keys) {
throw new Error('invalid JWKS response. "keys" field is missing')
}
if (!Array.isArray(data.keys)) {
throw new Error('invalid JWKS response. "keys" field is not an array')
}
if (keys) {
keys.push(...data.keys)
} else {
keys = data.keys
}
} else if (!keys) {
throw new Error('verifyFromJwks requires options for either "keys" or "jwks_uri" or both')
}

const matchingKey = keys.find((key) => key.kid === header.kid)
if (!matchingKey) {
throw new JwtTokenInvalid(token)
}

return await verify(token, matchingKey, matchingKey.alg as SignatureAlgorithm)
}

export const decode = (token: string): { header: TokenHeader; payload: JWTPayload } => {
try {
const [h, p] = token.split('.')
Expand All @@ -112,3 +168,12 @@ export const decode = (token: string): { header: TokenHeader; payload: JWTPayloa
throw new JwtTokenInvalid(token)
}
}

export const decodeHeader = (token: string): TokenHeader => {
try {
const [h] = token.split('.')
return decodeJwtPart(h) as TokenHeader
} catch {
throw new JwtTokenInvalid(token)
}
}
9 changes: 9 additions & 0 deletions src/utils/jwt/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ export class JwtHeaderInvalid extends Error {
}
}

export class JwtHeaderRequiresKid extends Error {
constructor(header: object) {
super(`required "kid" in jwt header: ${JSON.stringify(header)}`)
this.name = 'JwtHeaderRequiresKid'
}
}

export class JwtTokenSignatureMismatched extends Error {
constructor(token: string) {
super(`token(${token}) signature mismatched`)
Expand Down Expand Up @@ -81,3 +88,5 @@ export type JWTPayload = {
*/
iat?: number
}

export type { HonoJsonWebKey } from './jws'

0 comments on commit 6837649

Please sign in to comment.