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): JWK Auth Middleware #3826

Open
wants to merge 24 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e1b65a3
Update cookie.ts
Beyondo Dec 22, 2024
3f0e470
Integrated `priority` option into setCookie serialization tests
Beyondo Dec 22, 2024
f90407c
Merge branch 'honojs:main' into main
Beyondo Jan 2, 2025
c16eb91
Add kid' to TokenHeader, fix Jwt.sign ignoring privateKey.alg with ke…
Beyondo Jan 3, 2025
82ba1da
Add ./src/middleware/jwk/jwk.ts to jsr.json
Beyondo Jan 3, 2025
298f5f0
Add hono/jwk to exports
Beyondo Jan 3, 2025
b2e5b53
feat(hono/jwk)
Beyondo Jan 3, 2025
9453a9d
(feat/Jwt.verifyFromJwks) / batteries included util
Beyondo Jan 12, 2025
df0d32a
Update index.ts
Beyondo Jan 12, 2025
e809fae
add JwtHeaderRequiresKid exception
Beyondo Jan 12, 2025
be92784
using Jwt.verifyFromJwks now
Beyondo Jan 12, 2025
69fe514
Merge branch 'honojs:main' into main
Beyondo Jan 13, 2025
5082072
improved jsdoc and formatting
Beyondo Jan 13, 2025
47f9d46
jsdoc update
Beyondo Jan 13, 2025
28a7b97
formatting
Beyondo Jan 13, 2025
abbf23c
testing jwk's `keys` receiving an async function
Beyondo Jan 13, 2025
763f1fd
removed redundancy
Beyondo Jan 13, 2025
60f10be
add 'Should authorize Keys function' test
Beyondo Jan 13, 2025
c3d054c
added jwks_uri test + improved test descriptions
Beyondo Jan 13, 2025
485b3dd
test naming consistency
Beyondo Jan 13, 2025
d6ec5ef
explicit return fix + moving global declaration merging to own interface
Beyondo Jan 13, 2025
ff2e8ac
cleaner jsdoc @example
Beyondo Jan 14, 2025
bb1cad8
removed commented-out tests unnecessarily inflating changes
Beyondo Jan 14, 2025
582334c
ExtendedJsonWebKey -> HonoJsonWebKey
Beyondo Jan 15, 2025
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
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
306 changes: 306 additions & 0 deletions src/middleware/jwk/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
import { serve } from '@hono/node-server'
import type { AddressInfo } from 'net'
import { Hono } from '../../hono'
import { HTTPException } from '../../http-exception'
import { Jwt } from '../../utils/jwt'
import * as test_keys from './keys.test.json'
import { jwk } from '.'

const verify_keys = test_keys.public_keys

describe('JWK', () => {
const resource_server = new Hono()
resource_server.get('/.well-known/jwks.json', (c) => c.json({ keys: verify_keys }))
const server = serve({ fetch: resource_server.fetch })
const port = (server.address() as AddressInfo).port

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

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

const app = new Hono()

app.use('/auth-with-keys/*', jwk({ keys: verify_keys }))
app.use('/auth-with-keys-unicode/*', jwk({ keys: verify_keys }))
app.use('/auth-with-keys-nested/*', async (c, next) => {
const auth = jwk({ keys: verify_keys })
return auth(c, next)
})
app.use(
'/auth-with-keys-fn/*',
jwk({
keys: async () => {
const response = await fetch(`http://localhost:${port}/.well-known/jwks.json`)
const data = await response.json()
return data.keys
},
})
)
app.use(
'/auth-with-jwks_uri/*',
jwk({
jwks_uri: `http://localhost:${port}/.well-known/jwks.json`,
})
)

app.get('/auth-with-keys/*', (c) => {
handlerExecuted = true
const payload = c.get('jwtPayload')
return c.json(payload)
})
app.get('/auth-with-keys-unicode/*', (c) => {
handlerExecuted = true
const payload = c.get('jwtPayload')
return c.json(payload)
})
app.get('/auth-with-keys-nested/*', (c) => {
handlerExecuted = true
const payload = c.get('jwtPayload')
return c.json(payload)
})
app.get('/auth-with-keys-fn/*', (c) => {
handlerExecuted = true
const payload = c.get('jwtPayload')
return c.json(payload)
})
app.get('/auth-with-jwks_uri/*', (c) => {
handlerExecuted = true
const payload = c.get('jwtPayload')
return c.json(payload)
})

it('Should not authorize requests with missing access token', async () => {
const req = new Request('http://localhost/auth-with-keys/a')
const res = await app.request(req)
expect(res).not.toBeNull()
expect(res.status).toBe(401)
expect(await res.text()).toBe('Unauthorized')
expect(handlerExecuted).toBeFalsy()
})

it('Should authorize from a static array passed to options.keys (key 1)', async () => {
const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[0])
const req = new Request('http://localhost/auth-with-keys/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 authorize from a static array passed to options.keys (key 2)', async () => {
const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[1])
const req = new Request('http://localhost/auth-with-keys/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 authorize with Unicode payload from a static array passed to options.keys', async () => {
const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[0])
const req = new Request('http://localhost/auth-with-keys-unicode/a')
req.headers.set('Authorization', `Basic ${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 authorize from a function passed to options.keys', async () => {
const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[0])
const req = new Request('http://localhost/auth-with-keys-fn/a')
req.headers.set('Authorization', `Basic ${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 authorize from a URI remotely fetched from options.jwks_uri', async () => {
const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[0])
const req = new Request('http://localhost/auth-with-jwks_uri/a')
req.headers.set('Authorization', `Basic ${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 requests with invalid Unicode payload in header', async () => {
const invalidToken =
'ssyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXNzYWdlIjoiaGVsbG8gd29ybGQifQ.B54pAqIiLbu170tGQ1rY06Twv__0qSHTA0ioQPIOvFE'
const url = 'http://localhost/auth-with-keys-unicode/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()
})

it('Should not authorize requests with malformed token structure in header', async () => {
const invalid_token = 'invalid token'
const url = 'http://localhost/auth-with-keys/a'
const req = new Request(url)
req.headers.set('Authorization', `Bearer ${invalid_token}`)
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_request",error_description="invalid credentials structure"`
)
expect(handlerExecuted).toBeFalsy()
})

it('Should not authorize requests without authorization in nested JWK middleware', async () => {
const req = new Request('http://localhost/auth-with-keys-nested/a')
const res = await app.request(req)
expect(res).not.toBeNull()
expect(res.status).toBe(401)
expect(await res.text()).toBe('Unauthorized')
expect(handlerExecuted).toBeFalsy()
})

it('Should authorize requests with authorization in nested JWK middleware', async () => {
const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[0])
const req = new Request('http://localhost/auth-with-keys-nested/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()
})
})

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

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

const app = new Hono()

app.use('/auth-with-keys/*', jwk({ keys: verify_keys, cookie: 'access_token' }))
app.use('/auth-with-keys-unicode/*', jwk({ keys: verify_keys, cookie: 'access_token' }))

app.get('/auth-with-keys/*', (c) => {
handlerExecuted = true
const payload = c.get('jwtPayload')
return c.json(payload)
})
app.get('/auth-with-keys-unicode/*', (c) => {
handlerExecuted = true
const payload = c.get('jwtPayload')
return c.json(payload)
})

it('Should not authorize requests with missing access token', async () => {
const req = new Request('http://localhost/auth-with-keys/a')
const res = await app.request(req)
expect(res).not.toBeNull()
expect(res.status).toBe(401)
expect(await res.text()).toBe('Unauthorized')
expect(handlerExecuted).toBeFalsy()
})

it('Should authorize from a static array passed to options.keys', async () => {
const url = 'http://localhost/auth-with-keys/a'
const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[0])
const req = new Request(url, {
headers: new Headers({
Cookie: `access_token=${credential}`,
}),
})
const res = await app.request(req)
expect(res).not.toBeNull()
expect(await res.json()).toEqual({ message: 'hello world' })
expect(res.status).toBe(200)
expect(handlerExecuted).toBeTruthy()
})

it('Should authorize with Unicode payload from a static array passed to options.keys', async () => {
const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[0])
const req = new Request('http://localhost/auth-with-keys-unicode/a', {
headers: new Headers({
Cookie: `access_token=${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 requests with invalid Unicode payload in cookie', async () => {
const invalidToken =
'ssyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXNzYWdlIjoiaGVsbG8gd29ybGQifQ.B54pAqIiLbu170tGQ1rY06Twv__0qSHTA0ioQPIOvFE'

const url = 'http://localhost/auth-with-keys-unicode/a'
const req = new Request(url)
req.headers.set('Cookie', `access_token=${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()
})

it('Should not authorize requests with malformed token structure in cookie', async () => {
const invalidToken = 'invalid token'
const url = 'http://localhost/auth-with-keys/a'
const req = new Request(url)
req.headers.set('Cookie', `access_token=${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('Error handling with `cause`', () => {
const app = new Hono()

app.use('/auth-with-keys/*', jwk({ keys: verify_keys }))
app.get('/auth-with-keys/*', (c) => c.text('Authorized'))

app.onError((e, c) => {
if (e instanceof HTTPException && e.cause instanceof Error) {
return c.json({ name: e.cause.name, message: e.cause.message }, 401)
}
return c.text(e.message, 401)
})

it('Should not authorize', async () => {
const credential = 'abc.def.ghi'
const req = new Request('http://localhost/auth-with-keys')
req.headers.set('Authorization', `Bearer ${credential}`)
const res = await app.request(req)
expect(res.status).toBe(401)
expect(await res.json()).toEqual({
name: 'JwtTokenInvalid',
message: `invalid JWT token: ${credential}`,
})
})
})
})
2 changes: 2 additions & 0 deletions src/middleware/jwk/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { jwk } from './jwk'
import type {} from '../..'
Loading
Loading