diff --git a/src/index.ts b/src/index.ts index 566a6a4..f70acf8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,11 +9,14 @@ import { import { SignJWT, jwtVerify, + importJWK, + decodeProtectedHeader, type CryptoKey, type JWK, type KeyObject, type JoseHeaderParameters, - type JWTVerifyOptions + type JWTVerifyOptions, + type JWTVerifyGetKey } from 'jose' import { Type as t } from '@sinclair/typebox' @@ -126,7 +129,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?: boolean | number | string | Date } /** @@ -157,39 +160,75 @@ 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_SECRET + * }) + * .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 - /** - * Type strict validation for JWT payload - */ - schema?: Schema -} +> = + | (BaseJWTOption & { + /** + * JWT Secret + * Signing always uses `secret` + */ + secret: string | Uint8Array | CryptoKey | JWK | KeyObject + /** + * 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 + */ + jwks?: JWTVerifyGetKey + }) + | (BaseJWTOption & { + /** + * JWT Secret + * If missing, signing will be disabled + * Also, only asymmetric algorithms will be allowed for verification through jwks + */ + secret?: never + /** + * 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 + */ + jwks: JWTVerifyGetKey + }) + + const ASYMMETRIC_VERIFICATION_ALGS = [ + 'RS256','RS384','RS512', + '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', @@ -197,14 +236,25 @@ export const jwt = < >({ name = 'jwt' as Name, secret, + jwks, schema, ...defaultValues }: // End JWT Payload JWTOption) => { - if (!secret) throw new Error("Secret can't be empty") - - const key = - typeof secret === 'string' ? new TextEncoder().encode(secret) : secret + if (!secret && !jwks) throw new Error('Either "secret" or "jwks" must be provided') + + const getKeyForAlg = (alg: string) => { + return importJWK(secret as JWK, alg) + } + + const key = secret + ? (typeof secret === 'object' + && ('kty' in (secret as Record)) + ? undefined + : typeof secret === 'string' + ? new TextEncoder().encode(secret) + : secret) + : undefined const validator = schema ? getSchemaValidator( @@ -232,15 +282,54 @@ JWTOption) => { name: '@elysiajs/jwt', seed: { name, - secret, schema, ...defaultValues } }).decorate(name as Name extends string ? Name : 'jwt', { - sign( + 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') + 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 + if (jwks && !isSymmetric) { + const jwksVerifyOptions: JWTVerifyOptions = !options + ? { algorithms: [...ASYMMETRIC_VERIFICATION_ALGS] } + : (!options.algorithms + ? { ...options, algorithms: [...ASYMMETRIC_VERIFICATION_ALGS] } + : options) + payload = (await jwtVerify(jwt, jwks, jwksVerifyOptions) + ).payload + } else { + payload = (await jwtVerify(jwt, key ?? await getKeyForAlg(alg!), options)).payload + } + const data = payload as UnwrapSchema & + Omit> + + if (validator && !validator.Check(data)) + throw new ValidationError('JWT', validator, data) + + return data + } catch (_) { + return false + } + }, + 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 /** @@ -329,8 +418,8 @@ JWTOption) => { | Record let jwt = new SignJWT({ ...JWTPayload }).setProtectedHeader({ - alg: JWTHeader.alg!, - ...JWTHeader + ...JWTHeader, + alg: JWTHeader.alg! }) /** @@ -354,37 +443,17 @@ 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) { - jwt = jwt.setIssuedAt(new Date()) + const setIat = 'iat' in signValue ? iat : (defaultValues.iat ?? true) + if (setIat === true) { + jwt = jwt.setIssuedAt() + } else if (typeof setIat === 'number' + || typeof setIat === 'string' + || setIat instanceof Date) + { + jwt = jwt.setIssuedAt(setIat as string | number | Date) } - return jwt.sign(key) - }, - async verify( - jwt?: string, - options?: JWTVerifyOptions - ): Promise< - | (UnwrapSchema & - Omit>) - | false - > { - if (!jwt) return false - - try { - const data: any = ( - 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 jwt.sign((key ?? await getKeyForAlg(JWTHeader.alg!)) ) } }) } diff --git a/test/index.test.ts b/test/index.test.ts index 3b39cce..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( @@ -28,7 +30,7 @@ describe('JWT Plugin', () => { .post( '/sign-token', ({ jwt, body }) => - jwt.sign({ + jwt.sign!({ name: body.name, exp: '30m' }), @@ -41,7 +43,7 @@ describe('JWT Plugin', () => { .post( '/sign-token-disable-exp-and-iat', ({ jwt, body }) => - jwt.sign({ + jwt.sign!({ name: body.name, // nbf: undefined, exp: undefined, @@ -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) + }) })