Skip to content

Commit

Permalink
Add support for additional header claims in signJWT
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
cwirving committed Jul 21, 2024
1 parent 2e430eb commit 209767f
Show file tree
Hide file tree
Showing 6 changed files with 119 additions and 7 deletions.
52 changes: 51 additions & 1 deletion mod.test.ts
Original file line number Diff line number Diff line change
@@ -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"]) {
Expand Down Expand Up @@ -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,
);
});
4 changes: 2 additions & 2 deletions mod.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down
7 changes: 7 additions & 0 deletions src/options.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { JOSEHeader } from "./standardclaims.ts";

/**
* Options for customizing JWT creation and parsing behavior.
*/
Expand Down Expand Up @@ -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;
}

/**
Expand Down
8 changes: 6 additions & 2 deletions src/sign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)));

Expand Down
34 changes: 34 additions & 0 deletions src/standardclaims.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
21 changes: 19 additions & 2 deletions src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
}
}

0 comments on commit 209767f

Please sign in to comment.