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(hono/jwk): Extended with allow_anon option & passing Context to callbacks #3961

Open
wants to merge 4 commits 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
55 changes: 54 additions & 1 deletion src/middleware/jwk/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,59 @@ describe('JWK', () => {
})
})

describe('options.allow_anon = true', () => {
let handlerExecuted: boolean

beforeEach(() => {
handlerExecuted = false
})

const app = new Hono()

app.use('/backend-auth-or-anon/*', jwk({ keys: verify_keys, allow_anon: true }))

app.get('/backend-auth-or-anon/*', (c) => {
handlerExecuted = true
const payload = c.get('jwtPayload')
return c.json(payload ?? { message: 'hello anon' })
})

it('Should skip JWK if no token is present', async () => {
const req = new Request('http://localhost/backend-auth-or-anon/a')
const res = await app.request(req)
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ message: 'hello anon' })
expect(handlerExecuted).toBeTruthy()
})

it('Should authorize if token is present', async () => {
const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[0])
const req = new Request('http://localhost/backend-auth-or-anon/a')
req.headers.set('Authorization', `Bearer ${credential}`)
const res = await app.request(req)
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ message: 'hello world' })
expect(handlerExecuted).toBeTruthy()
})

it('Should not authorize if bad token is present', async () => {
const invalidToken =
'ssyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXNzYWdlIjoiaGVsbG8gd29ybGQifQ.B54pAqIiLbu170tGQ1rY06Twv__0qSHTA0ioQPIOvFE'
const url = 'http://localhost/backend-auth-or-anon/a'
const req = new Request(url)
req.headers.set('Authorization', `Basic ${invalidToken}`)
const res = await app.request(req)
expect(res).not.toBeNull()
expect(res.status).toBe(401)
expect(res.headers.get('www-authenticate')).toEqual(
`Bearer realm="${url}",error="invalid_token",error_description="token verification failure"`
)
expect(handlerExecuted).toBeFalsy()
})
})

describe('Credentials in header', () => {
let handlerExecuted: boolean

Expand Down Expand Up @@ -78,7 +131,7 @@ describe('JWK', () => {
'/auth-with-keys-and-jwks_uri/*',
jwk({
keys: verify_keys,
jwks_uri: 'http://localhost/.well-known/jwks.json',
jwks_uri: () => 'http://localhost/.well-known/jwks.json',
})
)
app.use(
Expand Down
24 changes: 16 additions & 8 deletions src/middleware/jwk/jwk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,18 @@ import type { HonoJsonWebKey } from '../../utils/jwt/jws'
* @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.
* @param {HonoJsonWebKey[] | ((ctx: Context) => Promise<HonoJsonWebKey[]> | HonoJsonWebKey[])} [options.keys] - The public keys used for JWK verification, or a function that returns them.
* @param {string | ((ctx: Context) => Promise<string> | string)} [options.jwks_uri] - If set to a URI string or a function that returns a URI string, attempt to fetch JWKs from it. The response must be a JSON object containing a `keys` array, which will be merged with the `keys` option.
* @param {boolean} [options.allow_anon] - If set to `true`, the middleware allows requests without a token to proceed without authentication.
* @param {string} [options.cookie] - If set, the middleware attempts to retrieve the token from a cookie with these options (optionally signed) only if no token is found in the header.
* @param {RequestInit} [init] - Optional init 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.use("/auth/*", jwk({ jwks_uri: (c) => `https://${c.env.authServer}/.well-known/jwks.json` }))
*
* app.get('/auth/page', (c) => {
* return c.text('You are authorized')
Expand All @@ -38,8 +39,9 @@ import type { HonoJsonWebKey } from '../../utils/jwt/jws'

export const jwk = (
options: {
keys?: HonoJsonWebKey[] | (() => Promise<HonoJsonWebKey[]>)
jwks_uri?: string
keys?: HonoJsonWebKey[] | ((ctx: Context) => Promise<HonoJsonWebKey[]> | HonoJsonWebKey[])
jwks_uri?: string | ((ctx: Context) => Promise<string> | string)
allow_anon?: boolean
cookie?:
| string
| { key: string; secret?: string | BufferSource; prefixOptions?: CookiePrefixOptions }
Expand Down Expand Up @@ -96,6 +98,9 @@ export const jwk = (
}

if (!token) {
if (options.allow_anon) {
return next()
}
const errDescription = 'no authorization included in request'
throw new HTTPException(401, {
message: errDescription,
Expand All @@ -110,7 +115,10 @@ export const jwk = (
let payload
let cause
try {
payload = await Jwt.verifyFromJwks(token, options, init)
const keys = typeof options.keys === 'function' ? await options.keys(ctx) : options.keys
const jwks_uri =
typeof options.jwks_uri === 'function' ? await options.jwks_uri(ctx) : options.jwks_uri
payload = await Jwt.verifyFromJwks(token, { keys, jwks_uri }, init)
} catch (e) {
cause = e
}
Expand Down
15 changes: 6 additions & 9 deletions src/utils/jwt/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@

const encodeJwtPart = (part: unknown): string =>
encodeBase64Url(utf8Encoder.encode(JSON.stringify(part)).buffer).replace(/=/g, '')

const encodeSignaturePart = (buf: ArrayBufferLike): string => encodeBase64Url(buf).replace(/=/g, '')

const decodeJwtPart = (part: string): TokenHeader | JWTPayload | undefined =>
Expand Down Expand Up @@ -111,7 +110,7 @@
export const verifyFromJwks = async (
token: string,
options: {
keys?: HonoJsonWebKey[] | (() => Promise<HonoJsonWebKey[]>)
keys?: HonoJsonWebKey[]

Check warning on line 113 in src/utils/jwt/jwt.ts

View check run for this annotation

Codecov / codecov/patch

src/utils/jwt/jwt.ts#L113

Added line #L113 was not covered by tests
jwks_uri?: string
},
init?: RequestInit
Expand All @@ -125,8 +124,6 @@
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) {
Expand All @@ -139,16 +136,16 @@
if (!Array.isArray(data.keys)) {
throw new Error('invalid JWKS response. "keys" field is not an array')
}
if (keys) {
keys.push(...data.keys)
if (options.keys) {
options.keys.push(...data.keys)
} else {
keys = data.keys
options.keys = data.keys
}
} else if (!keys) {
} else if (!options.keys) {
throw new Error('verifyFromJwks requires options for either "keys" or "jwks_uri" or both')
}

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