From 209767fd93ff2b511aa9e91481b402edc7d0cc7b Mon Sep 17 00:00:00 2001 From: "Carl W. Irving" Date: Sun, 21 Jul 2024 14:59:44 -0500 Subject: [PATCH] Add support for additional header claims in signJWT In addition to providing token body claims, the options provided to `signJWT()` include additional header claims that can augment (or overwrite) the standard JWT header claims. Also added a couple more tests making it clear that `validateJWT()` does not trust the algorithm claim in the JWT header. --- mod.test.ts | 52 ++++++++++++++++++++++++++++++++++++++++++- mod.ts | 4 ++-- src/options.ts | 7 ++++++ src/sign.ts | 8 +++++-- src/standardclaims.ts | 34 ++++++++++++++++++++++++++++ src/validate.ts | 21 +++++++++++++++-- 6 files changed, 119 insertions(+), 7 deletions(-) diff --git a/mod.test.ts b/mod.test.ts index 3652014..c6bb9ca 100644 --- a/mod.test.ts +++ b/mod.test.ts @@ -1,8 +1,9 @@ import { assertEquals, assertRejects } from "@std/assert"; import { test } from "@cross/test"; -import { generateKey, generateKeyPair, signJWT, validateJWT } from "./mod.ts"; +import { generateKey, generateKeyPair, signJWT, unsafeParseJOSEHeader, unsafeParseJWT, validateJWT } from "./mod.ts"; import { JWTAmbiguousClaimError, JWTFormatError, JWTValidationError } from "./src/error.ts"; import type { SupportedKeyAlgorithms, SupportedKeyPairAlgorithms } from "./src/cryptokeys.ts"; +import type { JOSEHeader } from "./mod.ts"; test("signJWT() and validateJWT() with HMAC algorithms", async () => { for (const algorithm of ["HS256", "HS384", "HS512"]) { @@ -128,3 +129,52 @@ test("signJWT() works with 'notBefore' only", async () => { const decoded = await validateJWT(jwt, secret); assertEquals(typeof decoded.nbf, "number"); }); + +test("signJWT() supports additional header claims", async () => { + const algorithm: SupportedKeyPairAlgorithms = "RS256"; + const { privateKey, publicKey } = await generateKeyPair(algorithm); + const payload = { foo: "bar", baz: 42 }; + const jwtString = await signJWT(payload, privateKey, { + algorithm: algorithm, + additionalHeaderClaims: { typ: "JOSE", kid: "abc123" }, + }); + + const unsafeHeader = unsafeParseJOSEHeader(jwtString); + const unsafePayload = unsafeParseJWT(jwtString); + const decodedPayload = await validateJWT(jwtString, publicKey, { algorithm }); + + assertEquals(unsafePayload, payload); + assertEquals(decodedPayload, payload); + const expectedHeader: JOSEHeader = { alg: algorithm, typ: "JOSE", kid: "abc123" }; + assertEquals(unsafeHeader, expectedHeader); +}); + +test("validateJWT() ignores the `alg` header claim", async () => { + const algorithm: SupportedKeyPairAlgorithms = "RS256"; + const { privateKey, publicKey } = await generateKeyPair(algorithm); + const payload = { foo: "bar", baz: 42 }; + const jwtString = await signJWT(payload, privateKey, { + algorithm: algorithm, + additionalHeaderClaims: { alg: "none" }, // Note the algorithm mismatch + }); + + // Should use the algorithm present in the public key, not the algorithm claimed by the JWT. + const decodedPayload = await validateJWT(jwtString, publicKey); + assertEquals(decodedPayload, payload); +}); + +test("validateJWT() throws JWTValidationError on stripped token", async () => { + const algorithm: SupportedKeyPairAlgorithms = "RS256"; + const keyPair = await generateKeyPair(algorithm); + + const payload = { foo: "bar", baz: 42 }; + + // Simulates the scenario where the attacker tampered with the body and tries to pass an + // unsigned token as the real item. + const jwtString = await signJWT(payload, false); + + assertRejects( + () => validateJWT(jwtString, keyPair.publicKey), + JWTValidationError, + ); +}); diff --git a/mod.ts b/mod.ts index 1a93d8d..e274aff 100644 --- a/mod.ts +++ b/mod.ts @@ -1,6 +1,6 @@ // mod.ts export { signJWT } from "./src/sign.ts"; -export { unsafeParseJWT, validateJWT } from "./src/validate.ts"; +export { unsafeParseJOSEHeader, unsafeParseJWT, validateJWT } from "./src/validate.ts"; export { exportPEMKey, generateKey, generateKeyPair, importPEMKey } from "./src/cryptokeys.ts"; export type { ExportPEMKeyOptions, @@ -10,7 +10,7 @@ export type { SupportedKeyPairAlgorithms, } from "./src/cryptokeys.ts"; export type { JWTOptions } from "./src/options.ts"; -export type { JWTPayload } from "./src/standardclaims.ts"; +export type { JOSEHeader, JWTPayload } from "./src/standardclaims.ts"; //Aliases export { signJWT as createJWT } from "./src/sign.ts"; diff --git a/src/options.ts b/src/options.ts index 6cad7de..ac98c5a 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,3 +1,5 @@ +import type { JOSEHeader } from "./standardclaims.ts"; + /** * Options for customizing JWT creation and parsing behavior. */ @@ -40,6 +42,11 @@ export interface JWTOptions { * Cannot be used if the `nbf` claim is explicitly set in the payload. */ notBefore?: string; + + /** + * Additional claims to include as part of the JWT's JOSE header. + */ + additionalHeaderClaims?: JOSEHeader; } /** diff --git a/src/sign.ts b/src/sign.ts index ca2ef24..5386cfa 100644 --- a/src/sign.ts +++ b/src/sign.ts @@ -19,7 +19,7 @@ import { signWithHMAC } from "./sign-verify/hmac.ts"; import { signWithECDSA } from "./sign-verify/ecdsa.ts"; import { signWithRSAPSS } from "./sign-verify/rsapss.ts"; -import type { JWTPayload } from "./standardclaims.ts"; +import type { JOSEHeader, JWTPayload } from "./standardclaims.ts"; /** * Parses a duration string like "1d", "2h", "30m", or "15s" and returns the equivalent time in seconds. @@ -120,7 +120,11 @@ export async function signJWT( validateClaims(payload, options); - const header = { alg: algorithm, typ: "JWT" }; + const header: JOSEHeader = { alg: algorithm, typ: "JWT" }; + if (options?.additionalHeaderClaims !== undefined) { + Object.assign(header, options.additionalHeaderClaims); + } + const encodedHeader = encodeBase64Url(textEncode(JSON.stringify(header))); const encodedPayload = encodeBase64Url(textEncode(JSON.stringify(payload))); diff --git a/src/standardclaims.ts b/src/standardclaims.ts index 6e3a0d5..a322e82 100644 --- a/src/standardclaims.ts +++ b/src/standardclaims.ts @@ -1,3 +1,37 @@ +/** + * The the JOSE header part of a JWT, JWS or JWE structure. + * + * Note: only some of the more common claims are defined here. See RFC 7519 (https://tools.ietf.org/html/rfc7519), + * RFC 7515 (https://tools.ietf.org/html/rfc7515) and RFC 7516 (https://tools.ietf.org/html/rfc7516) for a + * full list of standard header claims and their explanations. + */ +export interface JOSEHeader { + /** + * The cryptographic algorithm used to secure the JWS/JWE structure. + * If unspecified, the `signJWT()` function will use the algorithm of the provided key + * as the value of this header claim. + * + * (See RFC 7515 section 4.1.1, RFC 7516 section 4.1.1) + */ + alg?: string; + + /** + * When the token is signed with a key from a JSON Web Key set (JWKS), this is the identifier + * of the key in the JWKS. + * + * (See RFC 7515 section 4.1.4, RFC 7516 section 4.1.6) + */ + kid?: string; + + /** + * The media type of the complete JWT/JWS/JWE. If unspecified, the `signJWT()` + * function will use "JWT" as the value of this header claim. + * + * (see RFC 7519 section 5.1, RFC 7515 section 4.1.9, RFC 7516 section 4.1.11) + */ + typ?: string; +} + /** * Represents the payload of a JWT. Includes optional standard claims and allows for the * addition of custom properties. diff --git a/src/validate.ts b/src/validate.ts index 067eb92..8bf6756 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -22,7 +22,7 @@ import { verifyWithHMAC } from "./sign-verify/hmac.ts"; import { verifyWithECDSA } from "./sign-verify/ecdsa.ts"; import { verifyWithRSAPSS } from "./sign-verify/rsapss.ts"; -import type { JWTPayload } from "./standardclaims.ts"; +import type { JOSEHeader, JWTPayload } from "./standardclaims.ts"; /** * Validates and parses a JWT, verifies it with the given key, and returns the contained payload. @@ -243,7 +243,7 @@ export async function verify(key: CryptoKey, data: string, signature: string, op * "unsafely" parse a JWT without cryptokey. * * @param {string} jwt - The encoded JWT string. - * @returns {JWTPayload} A promise resolving to the decoded JWT payload. + * @returns {JWTPayload} The decoded JWT payload. * @throws {JWTParseError} If the jwt string is not parsable. */ export function unsafeParseJWT(jwt: string): JWTPayload { @@ -255,3 +255,20 @@ export function unsafeParseJWT(jwt: string): JWTPayload { throw new JWTParseError(error); } } + +/** + * "unsafely" parse the JOSE header of a JWT without cryptokey. + * + * @param {string} jwt - The encoded JWT string. + * @returns {JOSEHeader} The decoded header portion of the JWT. + * @throws {JWTParseError} If the jwt string is not parsable. + */ +export function unsafeParseJOSEHeader(jwt: string): JOSEHeader { + try { + const jwtParts = validateParts(jwt); + const payload = JSON.parse(textDecode(decodeBase64Url(jwtParts[0]))); + return payload; + } catch (error) { + throw new JWTParseError(error); + } +}