2.0.0 • Published 9 months ago

jwt-test-helper v2.0.0

Weekly downloads
-
License
MIT
Repository
github
Last release
9 months ago

JWT Test Helper

CI Status Coverage Status npm version

As a developer we provide APIs to protected resources. A lot of APIs require JSON Web Tokens for access and validating tokens correctly becomes vital.

No matter whether you use a library to validate your tokens (like jsonwebtoken or aws-jwt-verify) or you have written your own code, testing out different scenarios can be really hard.

Let's assume the following question: "Does my code reject tokens that have a correct signature, but are already expired?"

The problem: How can you call your function with a valid token (a token with a valid signature) when you cannot control the token issuer (e.g. your EntraID/AzureAD Tenant, AWS Cognito, etc) and as a result cannot sign a token yourself?

As a result you may only test the happy-path of your validation function or you do not test it at all. But does your code accept a token that specifies a signature of none and is not signed at all? You should test that!

How does this library help? It can

  • Allows you to easily create tokens with arbitrary header and payload or different properties (e.g. expired tokens, not-yet-valid tokens, etc)
  • create a public/private key to sign different tokens
  • mock HTTP calls against the JWKS endpoint of your token issuer so your code under test can validate the generated tokens correctly (your code under test can map the kid value of the fake tokens to actual signing keys)

Installation

Installing the package is easy. Just run

npm install --save-dev jwt-test-helper

Example

We first need a token validation function you want to test.

Let's assume you validate incoming API tokens with jsonwebtoken and you use jwks-rsa to fetch the public keys of your token issuer.

While the token issuer does not really matter the below example assumes you want to validete tokens against a specific (non-existing) EntraID tenant with a tenant Id of bc060424-c7f9-46d9-b0df-41e1dc387823.

// validate.ts
import { decode, verify } from "jsonwebtoken";
import { JwksClient } from "jwks-rsa";

let jwksRsa: JwksClient;

async function getSigningKey(kid: string): Promise<string> {
  if (!jwksRsa) {
    jwksRsa = new JwksClient({
      cache: true,
      jwksUri: "https://login.microsoftonline.com/bc060424-c7f9-46d9-b0df-41e1dc387823/discovery/v2.0/keys",
      rateLimit: true
    });
  }
  const key = await jwksRsa.getSigningKey(kid)
  if ('rsaPublicKey' in key) {
    return key.rsaPublicKey
  }
  return key.publicKey
}

export async function validate(
  jwtToken: string,
  issuer: string,
  audience: string
) {
  const decodedToken = decode(jwtToken, { complete: true })
  if (!decodedToken) {
    throw new Error("Cannot parse JWT token");
  }

  const kid = decodedToken["header"]["kid"];
  if (!kid) {
    throw new Error("Missing key id of token. Unable to verify")
  }
  const jwk = await getSigningKey(kid);

  // Verify the JWT
  // This either rejects (JWT not valid), or resolves (JWT valid)
  const verificationOptions = {
    audience,
    issuer,
  };

  return new Promise((resolve, reject) =>
    verify(jwtToken, jwk, verificationOptions, (err, decoded) =>
      err ? reject(err) : resolve(decoded)
    )
  );
}

We can now run a test that emulates the EntraID tenant and test your validate function against an expired token. In this example we'll use vitest as a testing framework.

// validate.test.ts
import nock from 'nock'
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { Issuer } from 'jwt-test-helper'

// This  is the method we want to test
import { validate } from './validate.js'

let fakeIssuer: Issuer

describe("validate", () => {
  beforeAll(() => {
    fakeIssuer = new Issuer(
      "https://login.microsoftonline.com/bc060424-c7f9-46d9-b0df-41e1dc387823/discovery/v2.0/keys"
    )
    fakeIssuer.generateKey()
  })

  beforeEach(() => {
    nock.disableNetConnect()
    fakeIssuer.mockJwksUri()
    return (() => {
      nock.cleanAll()
      nock.enableNetConnect()
    })
  })

  it("should reject expired tokens", async () => {
    const jwt = fakeIssuer
      .createSampleJwt(
        {},
        {
          iss: 'https://login.microsoftonline.com/bc060424-c7f9-46d9-b0df-41e1dc387823/v2.0',
          aud: '6e74172b-be56-4843-9ff4-e66a39bb12e3'
        }
      )
      .expired()
      .sign()

    // The key is signed and has the correct audience and issuer, but
    // it should complain about an expired key
    const promise = validate(
      jwt.toString(),
      'https://login.microsoftonline.com/bc060424-c7f9-46d9-b0df-41e1dc387823/v2.0',
      "6e74172b-be56-4843-9ff4-e66a39bb12e3",
    )

    await expect(promise).rejects.toThrow(/jwt expired/)
  })
})

Usage

This library helps with two steps:

  • Emulate a token issuer (e.g. create signing keys, mocking the JWKS endpoint to present the signing keys)
  • Create JWT Tokens from the emulated token issuer with arbitrary content and possibly sign them

Emulate a token issuer

The first thing we have to do is emulate the token issuer that you actually use in your code (or emulate another token issuer to test out scenarios whether you reject valid tokens that come from unexpected token issuers)

The following issuer represents an EntraID V2 token issuer but you can emulate any token issuer that uses RSA keys to sign tokens.

import { Issuer } from 'jwt-test-helper'
const fakeIssuer = new Issuer("https://login.microsoftonline.com/bc060424-c7f9-46d9-b0df-41e1dc387823/discovery/v2.0/keys")

For some issuers there are more specific subclasses. This allows to more accurately emulate them, e.g. some issuers use specific JWT claims or include more data in the JWK endpoint.

import { EntraIdIssuer, EntraIdV2Issuer } from 'jwt-test-helper/issuer/entraid.js'

// The entra id issuer takes a tenantId and generates the jwkUrl
const fakeIssuer = new EntraIdIssuer("bc060424-c7f9-46d9-b0df-41e1dc387823")

// If your code uses the v2.0 endpoint, use an EntraIDV2 issuer instead
const fakeIssuer = new EntraIdV2Issuer("bc060424-c7f9-46d9-b0df-41e1dc387823")

One you have a fake token issuer you can generate a keypair. The issuer will automatically generate a key Id for the new key. A token can reference the key Id later with a kid header.

fakeIssuer.generateKey() // New key with generated KID
fakeIssuer.generateKey("KEYFOO") // Force a key id of KEYFOO

// Retrieve the KID of the first key
const kid = fakeIssuer.kid()

// Return the KID of a specific key
const kid = fakeIssuer.kid(1) // returns the kid of the second key

While the new key can be used to sign tokens, we also have to ensure your code under test talks to your fake issuer to map a kid of an incoming token to a public key of the fake issuer.

We do this by mocking calls to the JWK endpoint to the original issuer. We assume your code will use the JWK endpoint of your token provider to fetch public keys)

// validate.test.ts

import nock from 'nock' // you have to install nock

// Ensure we don't call any real endpoint
nock.disableNetConnect()

// Mock calls to the Jwks endpoint
fakeIssuer.mockJwksUri()

// Later: Enable network connections again
nock.enableNetConnect()

Create tokens

Now lets create a valid token with a kid that matches a previously generated key. The fake issuer will use the kid when you run the sign() method. We have two ways to create a token with header and payload: createJwt will create a token with the provided header and payload. The method createSampleJwt() creates a token with some predefined values. The issuer subclasses (e.g. EntraIdIssuer) also overwrite createSampleJwt() so include standard claims of the more specific issuer.

// The sample JWT will have a kid of the first generated keypair
// and an alg of RS256
const validToken = fakeIssuer
  .createJwt(
    {
      alg: 'RS256',
      kid: fakeIssuer.kid(0),
    },
    {
      sub: 'Bob'
    }
  )

// The above can also be written as
const validToken = fakeIssuer.createSampleJwt()

Create a signed token

You can run sign() on your token to add a signature based on the current header and payload

const validToken = fakeIssuer.createSampleJwt().sign()

Create tampered token

If we sign a token and later change the header or payload the token should be rejected by your code since the signature will no longer match:

// Simulate a subject change from Alice to Bob
const tamperedToken = fakeIssuer
  .createSampleJwt()
  .updateClaims({"sub": "Alice"})
  .sign()
  .updateClaims({"sub": "Bob"})

Create an expired token

Or create an expired token or a token with a not-before-time in the future:

const expiredToken = fakeIssuer.createSampleJwt().expired().sign()
const invalidToken = fakeIssuer.createSampleJwt().becomesValidInSecond(10).sign()

Create an unsigned token

We can also create unsigned tokens with an algorithm of none. In general your code should reject tokens specifying this algorithm. We can also create a token that should have a signature (alg of RS256 is the default) but is still not signed:

const missingSignature = fakeIssuer.createSampleJwt({"sub": "Alice"})
const unsignedToken = fakeIssuer.createSampleJwt({alg: "none"}, {"sub": "Alice"})

Convert Token to a string

Once we are happy with our token properties we can transform it to a JWT string we can pass to our validate function.

You can also use prettyPrint() to print the content of the JWT in human readable form.

console.log(expiredToken.prettyPrint())
const jwt = expiredToken.toString()

For a full example look into the above Example section.

Enhance Issuer

You may have an issuer that serves specific fields in the JWK endpoint that you want to mimic as well. Or you want to change what a sample JWT looks like. This can be creating a subclass of BaseIssuer.

2.0.0

9 months ago

1.1.0

1 year ago

1.0.0

1 year ago

0.10.0

1 year ago

0.9.3

1 year ago

0.9.2

1 year ago