diff --git a/jsr.json b/jsr.json index c3b3559a6..a5b1ee0ca 100644 --- a/jsr.json +++ b/jsr.json @@ -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", diff --git a/package.json b/package.json index b3a6cfefa..84874aa4b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/middleware/jwk/index.test.ts b/src/middleware/jwk/index.test.ts new file mode 100644 index 000000000..b8e906466 --- /dev/null +++ b/src/middleware/jwk/index.test.ts @@ -0,0 +1,310 @@ +import { HttpResponse, http } from 'msw' +import { setupServer } from 'msw/node' +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 server = setupServer( + http.get('http://localhost/.well-known/jwks.json', () => { + return HttpResponse.json({ keys: verify_keys }) + }) + ) + beforeAll(() => server.listen()) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + 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/.well-known/jwks.json') + const data = await response.json() + return data.keys + }, + }) + ) + app.use( + '/auth-with-jwks_uri/*', + jwk({ + jwks_uri: 'http://localhost/.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}`, + }) + }) + }) +}) diff --git a/src/middleware/jwk/index.ts b/src/middleware/jwk/index.ts new file mode 100644 index 000000000..f4066e007 --- /dev/null +++ b/src/middleware/jwk/index.ts @@ -0,0 +1 @@ +export { jwk } from './jwk' diff --git a/src/middleware/jwk/jwk.ts b/src/middleware/jwk/jwk.ts new file mode 100644 index 000000000..f6924d61e --- /dev/null +++ b/src/middleware/jwk/jwk.ts @@ -0,0 +1,173 @@ +/** + * @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)} [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) + 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"') + } + + 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 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('JWK auth middleware requires options for either "keys" or "jwks_uri"') + } + + let payload + let cause + try { + payload = await Jwt.verifyFromJwks(token, keys) + } catch (e) { + cause = e + } + + if (!payload) { + 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}"`, + }, + }) +} diff --git a/src/middleware/jwk/keys.test.json b/src/middleware/jwk/keys.test.json new file mode 100644 index 000000000..327bbf1ae --- /dev/null +++ b/src/middleware/jwk/keys.test.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/src/middleware/request-id/index.ts b/src/middleware/request-id/index.ts index 040ec6612..055277d17 100644 --- a/src/middleware/request-id/index.ts +++ b/src/middleware/request-id/index.ts @@ -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 {} diff --git a/src/utils/jwt/index.ts b/src/utils/jwt/index.ts index f994cc81e..d3289aa47 100644 --- a/src/utils/jwt/index.ts +++ b/src/utils/jwt/index.ts @@ -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 } diff --git a/src/utils/jwt/jws.ts b/src/utils/jwt/jws.ts index de21a1e78..3a4f462fa 100644 --- a/src/utils/jwt/jws.ts +++ b/src/utils/jwt/jws.ts @@ -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, diff --git a/src/utils/jwt/jwt.ts b/src/utils/jwt/jwt.ts index b470e5b69..d5c86bc29 100644 --- a/src/utils/jwt/jwt.ts +++ b/src/utils/jwt/jwt.ts @@ -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, @@ -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 { @@ -50,7 +52,13 @@ export const sign = async ( alg: SignatureAlgorithm = 'HS256' ): Promise => { 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}` @@ -99,6 +107,27 @@ export const verify = async ( return payload } +export const verifyFromJwks = async ( + token: string, + keys: HonoJsonWebKey[] +): Promise => { + const header = decodeHeader(token) + + if (!isTokenHeader(header)) { + throw new JwtHeaderInvalid(header) + } + if (!header.kid) { + throw new JwtHeaderRequiresKid(header) + } + + 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('.') @@ -112,3 +141,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) + } +} diff --git a/src/utils/jwt/types.ts b/src/utils/jwt/types.ts index 208b50b12..6288fc05c 100644 --- a/src/utils/jwt/types.ts +++ b/src/utils/jwt/types.ts @@ -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`)