From 71628beff4a0b6ebc7c2c89b99ca0ef5e15e3ac0 Mon Sep 17 00:00:00 2001 From: Justin Ludlow Date: Thu, 28 Aug 2025 17:10:09 -0700 Subject: [PATCH 01/20] Add strongly-typed support for remote JWKs that doesn't collide with existing 'secret' implementation --- src/index.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 566a6a4..0eca6a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,8 @@ import { type JWK, type KeyObject, type JoseHeaderParameters, - type JWTVerifyOptions + type JWTVerifyOptions, + type JWTVerifyGetKey } from 'jose' import { Type as t } from '@sinclair/typebox' @@ -185,6 +186,10 @@ export interface JWTOption< * JWT Secret */ secret: string | Uint8Array | CryptoKey | JWK | KeyObject + /** + * Remote JWKS + */ + remoteJwks?: JWTVerifyGetKey /** * Type strict validation for JWT payload */ @@ -197,6 +202,7 @@ export const jwt = < >({ name = 'jwt' as Name, secret, + remoteJwks, schema, ...defaultValues }: // End JWT Payload @@ -233,6 +239,7 @@ JWTOption) => { seed: { name, secret, + remoteJwks, schema, ...defaultValues } @@ -372,11 +379,20 @@ JWTOption) => { if (!jwt) return false try { - const data: any = ( + let data: any; + if (remoteJwks) { + data = ( + await (options + ? jwtVerify(jwt, remoteJwks, options) + : jwtVerify(jwt, remoteJwks)) + ).payload + } else { + data = ( await (options ? jwtVerify(jwt, key, options) : jwtVerify(jwt, key)) ).payload + } if (validator && !validator.Check(data)) throw new ValidationError('JWT', validator, data) From f1bebfec3d0e2c85d53db75faca1536e20fcc493 Mon Sep 17 00:00:00 2001 From: Justin Ludlow Date: Thu, 28 Aug 2025 17:40:28 -0700 Subject: [PATCH 02/20] Clean up formatting --- src/index.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0eca6a9..5983aec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -381,17 +381,17 @@ JWTOption) => { try { let data: any; if (remoteJwks) { - data = ( - await (options - ? jwtVerify(jwt, remoteJwks, options) - : jwtVerify(jwt, remoteJwks)) - ).payload + data = ( + await (options + ? jwtVerify(jwt, remoteJwks, options) + : jwtVerify(jwt, remoteJwks)) + ).payload } else { - data = ( - await (options - ? jwtVerify(jwt, key, options) - : jwtVerify(jwt, key)) - ).payload + data = ( + await (options + ? jwtVerify(jwt, key, options) + : jwtVerify(jwt, key)) + ).payload } if (validator && !validator.Check(data)) From 5c542353ae08c63146515a59d86fc4ad1f695c1c Mon Sep 17 00:00:00 2001 From: Justin Ludlow Date: Thu, 28 Aug 2025 18:48:10 -0700 Subject: [PATCH 03/20] Improve collision handling between 'secret' and 'remoteJwks' for plugin security (Implemented from CodeRabbit feedback) --- src/index.ts | 179 ++++++++++++++++++++++++++++----------------------- 1 file changed, 98 insertions(+), 81 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5983aec..f023aa5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { import { SignJWT, jwtVerify, + decodeProtectedHeader, type CryptoKey, type JWK, type KeyObject, @@ -158,43 +159,60 @@ export interface JWTHeaderParameters extends JoseHeaderParameters { crit?: string[] } -export interface JWTOption< +type BaseJWTOption = + JWTHeaderParameters & JWTPayloadInput & { + /** + * Name to decorate method as + * + * --- + * @example + * For example, `jwt` will decorate Context with `Context.jwt` + * + * ```typescript + * app + * .decorate({ + * name: 'myJWTNamespace', + * secret: process.env.JWT_SECRETS + * }) + * .get('/sign/:name', ({ myJWTNamespace, params }) => { + * return myJWTNamespace.sign(params) + * }) + * ``` + */ + name?: Name + /** + * Type strict validation for JWT payload + */ + schema?: Schema + } + +export type JWTOption< Name extends string | undefined = 'jwt', Schema extends TSchema | undefined = undefined -> extends JWTHeaderParameters, - JWTPayloadInput { - /** - * Name to decorate method as - * - * --- - * @example - * For example, `jwt` will decorate Context with `Context.jwt` - * - * ```typescript - * app - * .decorate({ - * name: 'myJWTNamespace', - * secret: process.env.JWT_SECRETS - * }) - * .get('/sign/:name', ({ myJWTNamespace, params }) => { - * return myJWTNamespace.sign(params) - * }) - * ``` - */ - name?: Name - /** - * JWT Secret - */ - secret: string | Uint8Array | CryptoKey | JWK | KeyObject - /** - * Remote JWKS - */ - remoteJwks?: JWTVerifyGetKey - /** - * Type strict validation for JWT payload - */ - schema?: Schema -} +> = + | (BaseJWTOption & { + /** + * JWT Secret + */ + secret: string | Uint8Array | CryptoKey | JWK | KeyObject + /** + * Remote JWKS + * Use jose's `createRemoteJWKSet(new URL(...))` to create the JWKS function + */ + remoteJwks?: JWTVerifyGetKey + }) + | (BaseJWTOption & { + /** + * JWT Secret + */ + secret?: string | Uint8Array | CryptoKey | JWK | KeyObject + /** + * Remote JWKS + * Use jose's `createRemoteJWKSet(new URL(...))` to create the JWKS function + */ + remoteJwks: JWTVerifyGetKey + }) + export const jwt = < const Name extends string = 'jwt', @@ -234,20 +252,42 @@ JWTOption) => { ) : undefined - return new Elysia({ - name: '@elysiajs/jwt', - seed: { - name, - secret, - remoteJwks, - schema, - ...defaultValues - } - }).decorate(name as Name extends string ? Name : 'jwt', { - sign( + let jwtDecoration: any = {}; + jwtDecoration.verify = async ( + jwt?: string, + options?: JWTVerifyOptions + ): Promise< + | (UnwrapSchema & + Omit>) + | false + > => { + if (!jwt) return false + + try { + const { alg } = decodeProtectedHeader(jwt) + const isSymmetric = typeof alg === 'string' && alg.startsWith('HS') + // Prefer local secret for HS*; prefer remote for asymmetric algs when available + let data: any + if (remoteJwks && !isSymmetric) { + data = (await jwtVerify(jwt, remoteJwks, options)).payload + } else { + data = (await jwtVerify(jwt, (key as Exclude), options)).payload + } + + if (validator && !validator.Check(data)) + throw new ValidationError('JWT', validator, data) + + return data + } catch (_) { + return false + } + } + + if (secret) { + jwtDecoration.sign = ( signValue: Omit, NormalizedClaim> & JWTPayloadInput - ) { + ) => { const { nbf, exp, iat, ...data } = signValue /** @@ -367,42 +407,19 @@ JWTOption) => { } return jwt.sign(key) - }, - async verify( - jwt?: string, - options?: JWTVerifyOptions - ): Promise< - | (UnwrapSchema & - Omit>) - | false - > { - if (!jwt) return false - - try { - let data: any; - if (remoteJwks) { - data = ( - await (options - ? jwtVerify(jwt, remoteJwks, options) - : jwtVerify(jwt, remoteJwks)) - ).payload - } else { - data = ( - await (options - ? jwtVerify(jwt, key, options) - : jwtVerify(jwt, key)) - ).payload - } - - if (validator && !validator.Check(data)) - throw new ValidationError('JWT', validator, data) + } + } - return data - } catch (_) { - return false - } + return new Elysia({ + name: '@elysiajs/jwt', + seed: { + name, + secret, + remoteJwks, + schema, + ...defaultValues } - }) + }).decorate(name as Name extends string ? Name : 'jwt', jwtDecoration) } export default jwt From 74f44f214a2e5ae9c1a190cb56aba144b9aab079 Mon Sep 17 00:00:00 2001 From: Justin Ludlow Date: Thu, 28 Aug 2025 19:11:20 -0700 Subject: [PATCH 04/20] Fix error handling and 'key' assignment with optional 'secret' --- src/index.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index f023aa5..134682a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -213,7 +213,6 @@ export type JWTOption< remoteJwks: JWTVerifyGetKey }) - export const jwt = < const Name extends string = 'jwt', const Schema extends TSchema | undefined = undefined @@ -225,10 +224,12 @@ export const jwt = < ...defaultValues }: // End JWT Payload JWTOption) => { - if (!secret) throw new Error("Secret can't be empty") + if (!secret && !remoteJwks) throw new Error ('Either "secret" or "remoteJwks" must be provided') - const key = - typeof secret === 'string' ? new TextEncoder().encode(secret) : secret + let jwtDecoration: any = {} + const key = secret + ? (typeof secret === 'string' ? new TextEncoder().encode(secret) : secret) + : undefined const validator = schema ? getSchemaValidator( @@ -252,7 +253,6 @@ JWTOption) => { ) : undefined - let jwtDecoration: any = {}; jwtDecoration.verify = async ( jwt?: string, options?: JWTVerifyOptions @@ -267,7 +267,7 @@ JWTOption) => { const { alg } = decodeProtectedHeader(jwt) const isSymmetric = typeof alg === 'string' && alg.startsWith('HS') // Prefer local secret for HS*; prefer remote for asymmetric algs when available - let data: any + let data if (remoteJwks && !isSymmetric) { data = (await jwtVerify(jwt, remoteJwks, options)).payload } else { @@ -406,7 +406,7 @@ JWTOption) => { jwt = jwt.setIssuedAt(new Date()) } - return jwt.sign(key) + return jwt.sign((key as Exclude) ) } } From 956e3d38b396afce91451d5c06dbfdc9ce1d391d Mon Sep 17 00:00:00 2001 From: Justin Ludlow Date: Thu, 28 Aug 2025 19:13:15 -0700 Subject: [PATCH 05/20] Add explicit any for 'data' (causes build failure when absent) --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 134682a..38f8ddb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -267,7 +267,7 @@ JWTOption) => { const { alg } = decodeProtectedHeader(jwt) const isSymmetric = typeof alg === 'string' && alg.startsWith('HS') // Prefer local secret for HS*; prefer remote for asymmetric algs when available - let data + let data: any if (remoteJwks && !isSymmetric) { data = (await jwtVerify(jwt, remoteJwks, options)).payload } else { From 480dfd983651f8eb346aeb5f1c02edce54f539f9 Mon Sep 17 00:00:00 2001 From: Justin Ludlow Date: Thu, 28 Aug 2025 19:36:53 -0700 Subject: [PATCH 06/20] Implement security railguards for asymmetric encryption; Clean up typing --- src/index.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 38f8ddb..0508676 100644 --- a/src/index.ts +++ b/src/index.ts @@ -213,6 +213,13 @@ export type JWTOption< remoteJwks: JWTVerifyGetKey }) + const ASYMMETRIC_VERIFICATION_ALGS = [ + 'RS256','RS384','RS512', + 'PS256','PS384','PS512', + 'ES256','ES384','ES512', + 'EdDSA' + ] + export const jwt = < const Name extends string = 'jwt', const Schema extends TSchema | undefined = undefined @@ -266,13 +273,21 @@ JWTOption) => { try { const { alg } = decodeProtectedHeader(jwt) const isSymmetric = typeof alg === 'string' && alg.startsWith('HS') + const remoteOnly = remoteJwks && !key + if (isSymmetric && remoteOnly) throw new Error('HS* algorithm requires a local secret') // Prefer local secret for HS*; prefer remote for asymmetric algs when available - let data: any + let payload if (remoteJwks && !isSymmetric) { - data = (await jwtVerify(jwt, remoteJwks, options)).payload + payload = (await jwtVerify(jwt, remoteJwks, + !options?.algorithms + ? { ...options, algorithms: ASYMMETRIC_VERIFICATION_ALGS } + : options) + ).payload } else { - data = (await jwtVerify(jwt, (key as Exclude), options)).payload + payload = (await jwtVerify(jwt, (key as Exclude), options)).payload } + const data = payload as UnwrapSchema & + Omit> if (validator && !validator.Check(data)) throw new ValidationError('JWT', validator, data) From fb9fa879ece1273c27d479f25f414c639bda26b7 Mon Sep 17 00:00:00 2001 From: Justin Ludlow Date: Thu, 28 Aug 2025 20:16:21 -0700 Subject: [PATCH 07/20] Improve encryption algorithm handling; Strongly type jwtDecoration --- src/index.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0508676..414d6e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -218,7 +218,9 @@ export type JWTOption< 'PS256','PS384','PS512', 'ES256','ES384','ES512', 'EdDSA' - ] + ] as const + + const SYMMETRIC_VERIFICATION_ALGS = ['HS256', 'HS384', 'HS512'] as const export const jwt = < const Name extends string = 'jwt', @@ -233,7 +235,17 @@ export const jwt = < JWTOption) => { if (!secret && !remoteJwks) throw new Error ('Either "secret" or "remoteJwks" must be provided') - let jwtDecoration: any = {} + let jwtDecoration: { + verify: (jwt?: string, options?: JWTVerifyOptions) => + Promise< + | (UnwrapSchema & Omit>) + | false + > + sign?: ( + signValue: Omit, NormalizedClaim> & JWTPayloadInput + ) => Promise + } = {} as any + const key = secret ? (typeof secret === 'string' ? new TextEncoder().encode(secret) : secret) : undefined @@ -280,7 +292,7 @@ JWTOption) => { if (remoteJwks && !isSymmetric) { payload = (await jwtVerify(jwt, remoteJwks, !options?.algorithms - ? { ...options, algorithms: ASYMMETRIC_VERIFICATION_ALGS } + ? { ...options, algorithms: (ASYMMETRIC_VERIFICATION_ALGS as unknown as string[]) } : options) ).payload } else { From 6ac542171ccda25e752d07894526902ef471c881 Mon Sep 17 00:00:00 2001 From: Justin Ludlow Date: Thu, 28 Aug 2025 21:02:06 -0700 Subject: [PATCH 08/20] Refactor remoteJwks configuration to remoteJwksUrl for cleaner seed; Address nitpicks --- src/index.ts | 57 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/src/index.ts b/src/index.ts index 414d6e9..98cf91c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,13 +9,13 @@ import { import { SignJWT, jwtVerify, + createRemoteJWKSet, decodeProtectedHeader, type CryptoKey, type JWK, type KeyObject, type JoseHeaderParameters, - type JWTVerifyOptions, - type JWTVerifyGetKey + type JWTVerifyOptions } from 'jose' import { Type as t } from '@sinclair/typebox' @@ -172,7 +172,7 @@ type BaseJWTOption { * return myJWTNamespace.sign(params) @@ -199,7 +199,7 @@ export type JWTOption< * Remote JWKS * Use jose's `createRemoteJWKSet(new URL(...))` to create the JWKS function */ - remoteJwks?: JWTVerifyGetKey + remoteJwksUrl?: string | URL }) | (BaseJWTOption & { /** @@ -210,7 +210,7 @@ export type JWTOption< * Remote JWKS * Use jose's `createRemoteJWKSet(new URL(...))` to create the JWKS function */ - remoteJwks: JWTVerifyGetKey + remoteJwksUrl: string | URL }) const ASYMMETRIC_VERIFICATION_ALGS = [ @@ -228,24 +228,20 @@ export const jwt = < >({ name = 'jwt' as Name, secret, - remoteJwks, + remoteJwksUrl, schema, ...defaultValues }: // End JWT Payload JWTOption) => { - if (!secret && !remoteJwks) throw new Error ('Either "secret" or "remoteJwks" must be provided') + if (!secret && !remoteJwksUrl) throw new Error('Either "secret" or "remoteJwksUrl" must be provided') + + const remoteJwks = remoteJwksUrl + ? createRemoteJWKSet( + typeof remoteJwksUrl === 'string' + ? new URL(remoteJwksUrl) + : remoteJwksUrl) + : undefined - let jwtDecoration: { - verify: (jwt?: string, options?: JWTVerifyOptions) => - Promise< - | (UnwrapSchema & Omit>) - | false - > - sign?: ( - signValue: Omit, NormalizedClaim> & JWTPayloadInput - ) => Promise - } = {} as any - const key = secret ? (typeof secret === 'string' ? new TextEncoder().encode(secret) : secret) : undefined @@ -272,7 +268,17 @@ JWTOption) => { ) : undefined - jwtDecoration.verify = async ( + let jwtDecoration: { + verify: (jwt?: string, options?: JWTVerifyOptions) => + Promise< + | (UnwrapSchema & Omit>) + | false + > + sign?: ( + signValue: Omit, NormalizedClaim> & JWTPayloadInput + ) => Promise + } = { + verify: async ( jwt?: string, options?: JWTVerifyOptions ): Promise< @@ -285,15 +291,17 @@ JWTOption) => { try { const { alg } = decodeProtectedHeader(jwt) const isSymmetric = typeof alg === 'string' && alg.startsWith('HS') - const remoteOnly = remoteJwks && !key + const remoteOnly = remoteJwksUrl && !key if (isSymmetric && remoteOnly) throw new Error('HS* algorithm requires a local secret') // Prefer local secret for HS*; prefer remote for asymmetric algs when available let payload - if (remoteJwks && !isSymmetric) { - payload = (await jwtVerify(jwt, remoteJwks, - !options?.algorithms - ? { ...options, algorithms: (ASYMMETRIC_VERIFICATION_ALGS as unknown as string[]) } + if (remoteJwksUrl && !isSymmetric) { + const remoteVerifyOptions: JWTVerifyOptions = !options + ? { algorithms: [...ASYMMETRIC_VERIFICATION_ALGS] } + : (!options.algorithms + ? { ...options, algorithms: [...ASYMMETRIC_VERIFICATION_ALGS] } : options) + payload = (await jwtVerify(jwt, remoteJwks!, remoteVerifyOptions) ).payload } else { payload = (await jwtVerify(jwt, (key as Exclude), options)).payload @@ -308,6 +316,7 @@ JWTOption) => { } catch (_) { return false } + } } if (secret) { From 3df6f484e543f0a32d74428e5d53ca279b8bb540 Mon Sep 17 00:00:00 2001 From: Justin Ludlow Date: Thu, 28 Aug 2025 21:41:01 -0700 Subject: [PATCH 09/20] Revert remoteJwksUrl -> remoteJwks to keep plugin interface simpler for testing and internal implementation --- src/index.ts | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/index.ts b/src/index.ts index 98cf91c..9d620c7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,13 +9,13 @@ import { import { SignJWT, jwtVerify, - createRemoteJWKSet, decodeProtectedHeader, type CryptoKey, type JWK, type KeyObject, type JoseHeaderParameters, - type JWTVerifyOptions + type JWTVerifyOptions, + type JWTVerifyGetKey } from 'jose' import { Type as t } from '@sinclair/typebox' @@ -199,7 +199,7 @@ export type JWTOption< * Remote JWKS * Use jose's `createRemoteJWKSet(new URL(...))` to create the JWKS function */ - remoteJwksUrl?: string | URL + remoteJwks?: JWTVerifyGetKey }) | (BaseJWTOption & { /** @@ -210,7 +210,7 @@ export type JWTOption< * Remote JWKS * Use jose's `createRemoteJWKSet(new URL(...))` to create the JWKS function */ - remoteJwksUrl: string | URL + remoteJwks: JWTVerifyGetKey }) const ASYMMETRIC_VERIFICATION_ALGS = [ @@ -228,19 +228,12 @@ export const jwt = < >({ name = 'jwt' as Name, secret, - remoteJwksUrl, + remoteJwks, schema, ...defaultValues }: // End JWT Payload JWTOption) => { - if (!secret && !remoteJwksUrl) throw new Error('Either "secret" or "remoteJwksUrl" must be provided') - - const remoteJwks = remoteJwksUrl - ? createRemoteJWKSet( - typeof remoteJwksUrl === 'string' - ? new URL(remoteJwksUrl) - : remoteJwksUrl) - : undefined + if (!secret && !remoteJwks) throw new Error('Either "secret" or "remoteJwks" must be provided') const key = secret ? (typeof secret === 'string' ? new TextEncoder().encode(secret) : secret) @@ -291,11 +284,11 @@ JWTOption) => { try { const { alg } = decodeProtectedHeader(jwt) const isSymmetric = typeof alg === 'string' && alg.startsWith('HS') - const remoteOnly = remoteJwksUrl && !key + const remoteOnly = remoteJwks && !key if (isSymmetric && remoteOnly) throw new Error('HS* algorithm requires a local secret') // Prefer local secret for HS*; prefer remote for asymmetric algs when available let payload - if (remoteJwksUrl && !isSymmetric) { + if (remoteJwks && !isSymmetric) { const remoteVerifyOptions: JWTVerifyOptions = !options ? { algorithms: [...ASYMMETRIC_VERIFICATION_ALGS] } : (!options.algorithms From 82333fb368015fb66480eeea4b1f1276f16b4d48 Mon Sep 17 00:00:00 2001 From: Justin Ludlow Date: Thu, 28 Aug 2025 22:01:52 -0700 Subject: [PATCH 10/20] Remove unnecessary 'remoteJwks!' assertion --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 9d620c7..bdae9a3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -294,7 +294,7 @@ JWTOption) => { : (!options.algorithms ? { ...options, algorithms: [...ASYMMETRIC_VERIFICATION_ALGS] } : options) - payload = (await jwtVerify(jwt, remoteJwks!, remoteVerifyOptions) + payload = (await jwtVerify(jwt, remoteJwks, remoteVerifyOptions) ).payload } else { payload = (await jwtVerify(jwt, (key as Exclude), options)).payload From 0f69510418fe79ea08587ae56030411ea535e302 Mon Sep 17 00:00:00 2001 From: Justin Ludlow Date: Thu, 28 Aug 2025 22:08:42 -0700 Subject: [PATCH 11/20] Clarify documentation/types for remote verify-only config --- src/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index bdae9a3..30ea0f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -204,8 +204,10 @@ export type JWTOption< | (BaseJWTOption & { /** * JWT Secret + * If missing, only asymmetric algorithms will be allowed for verification + * Also, signing will be disabled */ - secret?: string | Uint8Array | CryptoKey | JWK | KeyObject + secret?: never /** * Remote JWKS * Use jose's `createRemoteJWKSet(new URL(...))` to create the JWKS function From 38b8d28743a3ec0b8879fef8138ac11bf664f05f Mon Sep 17 00:00:00 2001 From: Justin Ludlow Date: Thu, 28 Aug 2025 22:16:28 -0700 Subject: [PATCH 12/20] Fix setIat logic issue --- src/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 30ea0f6..6a80379 100644 --- a/src/index.ts +++ b/src/index.ts @@ -128,7 +128,7 @@ export interface JWTPayloadInput extends Omit { * * @see {@link https://www.rfc-editor.org/rfc/rfc7519#section-4.1.6 RFC7519#section-4.1.6} */ - iat?: boolean + iat?: true | number | string | Date } /** @@ -433,8 +433,10 @@ JWTOption) => { // Define 'iat' (Issued At). If a specific value is provided, use it. // Otherwise, if the claim is just marked as true, set it to the current time. const setIat = 'iat' in signValue ? iat : defaultValues.iat - if (setIat !== false) { + if (setIat === true) { jwt = jwt.setIssuedAt(new Date()) + } else { + jwt = jwt.setIssuedAt(new Date(setIat as string | number | Date)) } return jwt.sign((key as Exclude) ) From 53d00d6a8f6b0dfdbd871b03d0a94b454d78c5fc Mon Sep 17 00:00:00 2001 From: Justin Ludlow Date: Thu, 28 Aug 2025 22:28:53 -0700 Subject: [PATCH 13/20] Set `iat=true` when missing default or specific config to pass tests as part of fix --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 6a80379..e1881ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -432,7 +432,7 @@ JWTOption) => { // Define 'iat' (Issued At). If a specific value is provided, use it. // Otherwise, if the claim is just marked as true, set it to the current time. - const setIat = 'iat' in signValue ? iat : defaultValues.iat + const setIat = 'iat' in signValue ? iat : (defaultValues.iat ?? true) if (setIat === true) { jwt = jwt.setIssuedAt(new Date()) } else { From c69cae398b4d0ed3bf5701a94f266ce374051820 Mon Sep 17 00:00:00 2001 From: Justin Ludlow Date: Thu, 28 Aug 2025 22:36:47 -0700 Subject: [PATCH 14/20] Update test to account for conditional 'sign()' decoration --- test/index.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/index.test.ts b/test/index.test.ts index 3b39cce..69dbc21 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -28,7 +28,7 @@ describe('JWT Plugin', () => { .post( '/sign-token', ({ jwt, body }) => - jwt.sign({ + jwt.sign!({ name: body.name, exp: '30m' }), @@ -41,7 +41,7 @@ describe('JWT Plugin', () => { .post( '/sign-token-disable-exp-and-iat', ({ jwt, body }) => - jwt.sign({ + jwt.sign!({ name: body.name, // nbf: undefined, exp: undefined, From 730318166c330b945609dc7ee054b5df2915a64d Mon Sep 17 00:00:00 2001 From: Justin Ludlow Date: Thu, 28 Aug 2025 22:37:02 -0700 Subject: [PATCH 15/20] Allow disabling 'iat' when set to 'false' --- src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index e1881ce..6b0a827 100644 --- a/src/index.ts +++ b/src/index.ts @@ -128,7 +128,7 @@ export interface JWTPayloadInput extends Omit { * * @see {@link https://www.rfc-editor.org/rfc/rfc7519#section-4.1.6 RFC7519#section-4.1.6} */ - iat?: true | number | string | Date + iat?: boolean | number | string | Date } /** @@ -435,7 +435,7 @@ JWTOption) => { const setIat = 'iat' in signValue ? iat : (defaultValues.iat ?? true) if (setIat === true) { jwt = jwt.setIssuedAt(new Date()) - } else { + } else if (setIat) { jwt = jwt.setIssuedAt(new Date(setIat as string | number | Date)) } From 13849fdc44c15224b264bf9eacd7d862e65adece Mon Sep 17 00:00:00 2001 From: Justin Ludlow Date: Thu, 28 Aug 2025 23:18:00 -0700 Subject: [PATCH 16/20] Handle JWK with async 'sign()' and alg-aware key --- src/index.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6b0a827..45059b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { import { SignJWT, jwtVerify, + importJWK, decodeProtectedHeader, type CryptoKey, type JWK, @@ -236,9 +237,18 @@ export const jwt = < }: // End JWT Payload JWTOption) => { if (!secret && !remoteJwks) throw new Error('Either "secret" or "remoteJwks" must be provided') + + const getKeyForAlg = (alg: string) => { + return importJWK(secret as JWK, alg) + } const key = secret - ? (typeof secret === 'string' ? new TextEncoder().encode(secret) : secret) + ? (typeof secret === 'object' + && ('kty' in (secret as Record)) + ? undefined + : typeof secret === 'string' + ? new TextEncoder().encode(secret) + : secret) : undefined const validator = schema @@ -315,7 +325,7 @@ JWTOption) => { } if (secret) { - jwtDecoration.sign = ( + jwtDecoration.sign = async ( signValue: Omit, NormalizedClaim> & JWTPayloadInput ) => { @@ -407,8 +417,8 @@ JWTOption) => { | Record let jwt = new SignJWT({ ...JWTPayload }).setProtectedHeader({ - alg: JWTHeader.alg!, - ...JWTHeader + ...JWTHeader, + alg: JWTHeader.alg! }) /** @@ -434,12 +444,12 @@ JWTOption) => { // Otherwise, if the claim is just marked as true, set it to the current time. const setIat = 'iat' in signValue ? iat : (defaultValues.iat ?? true) if (setIat === true) { - jwt = jwt.setIssuedAt(new Date()) + jwt = jwt.setIssuedAt() } else if (setIat) { - jwt = jwt.setIssuedAt(new Date(setIat as string | number | Date)) + jwt = jwt.setIssuedAt(setIat as string | number | Date) } - return jwt.sign((key as Exclude) ) + return jwt.sign((key ?? await getKeyForAlg(JWTHeader.alg!)) ) } } From a761d4636c4f2815a68d792e8483ce6860d7d411 Mon Sep 17 00:00:00 2001 From: Justin Ludlow Date: Thu, 28 Aug 2025 23:35:42 -0700 Subject: [PATCH 17/20] Improve 'setIat' checking; Remove sensitive data from checksum --- src/index.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 45059b5..b677b57 100644 --- a/src/index.ts +++ b/src/index.ts @@ -309,7 +309,7 @@ JWTOption) => { payload = (await jwtVerify(jwt, remoteJwks, remoteVerifyOptions) ).payload } else { - payload = (await jwtVerify(jwt, (key as Exclude), options)).payload + payload = (await jwtVerify(jwt, key!, options)).payload } const data = payload as UnwrapSchema & Omit> @@ -445,7 +445,10 @@ JWTOption) => { const setIat = 'iat' in signValue ? iat : (defaultValues.iat ?? true) if (setIat === true) { jwt = jwt.setIssuedAt() - } else if (setIat) { + } else if (typeof setIat === 'number' + || typeof setIat === 'string' + || setIat instanceof Date) + { jwt = jwt.setIssuedAt(setIat as string | number | Date) } @@ -457,8 +460,6 @@ JWTOption) => { name: '@elysiajs/jwt', seed: { name, - secret, - remoteJwks, schema, ...defaultValues } From ceee3dd018fca3dc4cb3bac9ac557c54119540b9 Mon Sep 17 00:00:00 2001 From: Justin Ludlow Date: Fri, 29 Aug 2025 00:56:58 -0700 Subject: [PATCH 18/20] Generalize jwks to support local or remote (but still only asymmetric algs) --- src/index.ts | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/index.ts b/src/index.ts index b677b57..ed10f36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -194,26 +194,31 @@ export type JWTOption< | (BaseJWTOption & { /** * JWT Secret + * Signing always uses `secret` */ secret: string | Uint8Array | CryptoKey | JWK | KeyObject /** - * Remote JWKS - * Use jose's `createRemoteJWKSet(new URL(...))` to create the JWKS function + * Local or Remote JWKS + * Use jose's `createRemoteJWKSet(new URL(...))` to create the remote JWKS function + * Use jose's `createLocalJWKSet(...)` to create the local JWKS function + * If both `secret` and `jwks` are provided, `jwks` will be used for verifying asymmetric algorithms + * and `secret` for verifying symmetric algorithms */ - remoteJwks?: JWTVerifyGetKey + jwks?: JWTVerifyGetKey }) | (BaseJWTOption & { /** * JWT Secret - * If missing, only asymmetric algorithms will be allowed for verification - * Also, signing will be disabled + * If missing, signing will be disabled + * Also, only asymmetric algorithms will be allowed for verification through jwks */ secret?: never /** - * Remote JWKS + * Local or Remote JWKS * Use jose's `createRemoteJWKSet(new URL(...))` to create the JWKS function + * Use jose's `createLocalJWKSet(...)` to create the local JWKS function */ - remoteJwks: JWTVerifyGetKey + jwks: JWTVerifyGetKey }) const ASYMMETRIC_VERIFICATION_ALGS = [ @@ -231,12 +236,12 @@ export const jwt = < >({ name = 'jwt' as Name, secret, - remoteJwks, + jwks, schema, ...defaultValues }: // End JWT Payload JWTOption) => { - if (!secret && !remoteJwks) throw new Error('Either "secret" or "remoteJwks" must be provided') + if (!secret && !jwks) throw new Error('Either "secret" or "jwks" must be provided') const getKeyForAlg = (alg: string) => { return importJWK(secret as JWK, alg) @@ -296,17 +301,17 @@ JWTOption) => { try { const { alg } = decodeProtectedHeader(jwt) const isSymmetric = typeof alg === 'string' && alg.startsWith('HS') - const remoteOnly = remoteJwks && !key - if (isSymmetric && remoteOnly) throw new Error('HS* algorithm requires a local secret') + const asymmetricOnly = jwks && !key + if (isSymmetric && asymmetricOnly) throw new Error('HS* algorithm requires a local secret') // Prefer local secret for HS*; prefer remote for asymmetric algs when available let payload - if (remoteJwks && !isSymmetric) { - const remoteVerifyOptions: JWTVerifyOptions = !options + if (jwks && !isSymmetric) { + const jwksVerifyOptions: JWTVerifyOptions = !options ? { algorithms: [...ASYMMETRIC_VERIFICATION_ALGS] } : (!options.algorithms ? { ...options, algorithms: [...ASYMMETRIC_VERIFICATION_ALGS] } : options) - payload = (await jwtVerify(jwt, remoteJwks, remoteVerifyOptions) + payload = (await jwtVerify(jwt, jwks, jwksVerifyOptions) ).payload } else { payload = (await jwtVerify(jwt, key!, options)).payload From 3c31c1e87753ecc91be4ced38caba72a3f04790c Mon Sep 17 00:00:00 2001 From: Justin Ludlow Date: Fri, 29 Aug 2025 00:59:40 -0700 Subject: [PATCH 19/20] Add test for jwks and secret implementations co-existing in plugin --- test/index.test.ts | 54 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/test/index.test.ts b/test/index.test.ts index 69dbc21..cc766dc 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,8 +1,9 @@ import { Elysia, t } from 'elysia' import { jwt } from '../src' -import { SignJWT } from 'jose' +import { createLocalJWKSet, decodeProtectedHeader, exportJWK, generateKeyPair, SignJWT } from 'jose' import { describe, expect, it } from 'bun:test' +import { inferBodyReference } from 'elysia/dist/sucrose' const post = (path: string, body = {}) => new Request(`http://localhost${path}`, { @@ -15,6 +16,7 @@ const post = (path: string, body = {}) => const TEST_SECRET = 'A' + describe('JWT Plugin', () => { const app = new Elysia() .use( @@ -86,14 +88,14 @@ describe('JWT Plugin', () => { return { success: false, data: null, - message: 'exp was not setted on jwt' + message: 'exp was not set on jwt' } } if (!verifiedPayload.iat) { return { success: false, data: null, - message: 'iat was not setted on jwt' + message: 'iat was not set on jwt' } } return { success: true, data: verifiedPayload } @@ -195,4 +197,50 @@ describe('JWT Plugin', () => { expect(verifiedResult.data?.exp).toBeUndefined() expect(verifiedResult.data?.iat).toBeUndefined() }) + + // Basic JWKS test + it('Should verify RS256 via jwks and HS256 via local secret when both are configured', + async () => { + // RS256 key pair + jwks + const { publicKey, privateKey } = await generateKeyPair('RS256') + const pubJwk = await exportJWK(publicKey) + Object.assign(pubJwk, { alg: 'RS256', kid: 'test' }) + const getKey = createLocalJWKSet({ keys: [pubJwk] }) + + const jwksApp = new Elysia() + .use(jwt({ name: 'jwt', secret: TEST_SECRET, jwks: getKey })) + .post('/verify', async ({ jwt, body }) => { + const token = await jwt.verify(body.token) + return { + token, + ok: !!token + } + }, { + body: t.Object({ token: t.String() }) + }) + .post('/sign', async ({ body, jwt }) => await jwt.sign!({ + name: body.name, + exp: undefined, + iat: false, + }), { + body: t.Object({ name: t.String() }) + }) + + // RS256 token -> jwks + const rsToken = await new SignJWT({ role: 'local' }) + .setProtectedHeader({ alg: 'RS256', kid: 'test' }) + .setExpirationTime('5m') + .sign(privateKey) + const rsResp = await jwksApp.handle(post('/verify', { token: rsToken })) + const rsRespJson = await rsResp.json() + expect((rsRespJson.ok)).toBe(true) + + // HS256 token -> local secret + const hsSignResp = await jwksApp.handle(post('/sign', { name: 'test' })) + const hsToken = await hsSignResp.text() + expect(decodeProtectedHeader(hsToken).alg).toBe('HS256') + const hsResp = await jwksApp.handle(post('/verify', { token: hsToken })) + const hsRespJson = await hsResp.json() + expect(hsRespJson.ok).toBe(true) + }) }) From 1dff1ec865f5ef8bb1dc2ff41efc906f498ff63d Mon Sep 17 00:00:00 2001 From: Justin Ludlow Date: Fri, 29 Aug 2025 01:18:17 -0700 Subject: [PATCH 20/20] Revert conditional decorations & throw error in 'sign()' if missing 'secret' --- src/index.ts | 41 ++++++++++++++--------------------------- 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/src/index.ts b/src/index.ts index ed10f36..f70acf8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -278,16 +278,14 @@ JWTOption) => { ) : undefined - let jwtDecoration: { - verify: (jwt?: string, options?: JWTVerifyOptions) => - Promise< - | (UnwrapSchema & Omit>) - | false - > - sign?: ( - signValue: Omit, NormalizedClaim> & JWTPayloadInput - ) => Promise - } = { + return new Elysia({ + name: '@elysiajs/jwt', + seed: { + name, + schema, + ...defaultValues + } + }).decorate(name as Name extends string ? Name : 'jwt', { verify: async ( jwt?: string, options?: JWTVerifyOptions @@ -301,7 +299,7 @@ JWTOption) => { try { const { alg } = decodeProtectedHeader(jwt) const isSymmetric = typeof alg === 'string' && alg.startsWith('HS') - const asymmetricOnly = jwks && !key + const asymmetricOnly = jwks && !secret if (isSymmetric && asymmetricOnly) throw new Error('HS* algorithm requires a local secret') // Prefer local secret for HS*; prefer remote for asymmetric algs when available let payload @@ -314,7 +312,7 @@ JWTOption) => { payload = (await jwtVerify(jwt, jwks, jwksVerifyOptions) ).payload } else { - payload = (await jwtVerify(jwt, key!, options)).payload + payload = (await jwtVerify(jwt, key ?? await getKeyForAlg(alg!), options)).payload } const data = payload as UnwrapSchema & Omit> @@ -326,14 +324,12 @@ JWTOption) => { } catch (_) { return false } - } - } - - if (secret) { - jwtDecoration.sign = async ( + }, + sign: async ( signValue: Omit, NormalizedClaim> & JWTPayloadInput ) => { + if (!secret) throw new Error('Signing requires a local "secret" to be provided') const { nbf, exp, iat, ...data } = signValue /** @@ -459,16 +455,7 @@ JWTOption) => { return jwt.sign((key ?? await getKeyForAlg(JWTHeader.alg!)) ) } - } - - return new Elysia({ - name: '@elysiajs/jwt', - seed: { - name, - schema, - ...defaultValues - } - }).decorate(name as Name extends string ? Name : 'jwt', jwtDecoration) + }) } export default jwt