oven-sh / bun

Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one
https://bun.sh
Other
74.52k stars 2.79k forks source link

jwks-rsa and/or express-jwt not working with bun #10511

Open henrikericsson opened 7 months ago

henrikericsson commented 7 months ago

What version of Bun is running?

1.1.4+fbe2fe0c3

What platform is your computer?

Linux 5.15.146.1-microsoft-standard-WSL2 x86_64 x86_64

What steps can reproduce the bug?

Create an express middleware and apply it to any route served via express using https module.

Middleware:


import { Request, Response, NextFunction } from 'express'
import jwt from 'express-jwt'
import jwksRsa from 'jwks-rsa'

function requireAuth(req: Request, res: Response, next: NextFunction) {
  const jwksUri = 'https://login.microsoftonline.com/<TENANT_ID>/discovery/v2.0/keys'
  const issuer = 'https://login.microsoftonline.com/<TENANT_ID>/v2.0'
  const audience = '<CLIENT_ID>'

  const client = jwksRsa({
    cache: true,
    rateLimit: true,
    jwksRequestsPerMinute: 5,
    jwksUri: jwksUri,
  })

  client
    .getSigningKeys()
    .then(keys => console.log('keys:', keys))
    .catch(err => console.log('error:', err))

  const expressJwtSecret = jwksRsa.expressJwtSecret({
    cache: true,
    rateLimit: true,
    jwksRequestsPerMinute: 5,
    jwksUri: jwksUri,
  }) as jwksRsa.GetVerificationKey

  const expressJwt = jwt.expressjwt({
    secret: expressJwtSecret,
    audience: audience,
    issuer: issuer,
    algorithms: ['RS256'],
  })

  expressJwt(req, res, (error: unknown) => {
    if (error) {
      return next(error)
    }

    return next()
  })
}

export default requireAuth

What is the expected behavior?

A collection of signing keys should be returned from the jwksUri using the jwks-rsa package.

What do you see instead?

No keys are returned and fails with error:

JwksError {
  name: "JwksError",
  message: "The JWKS endpoint did not contain any signing keys",
  toString: [Function: toString],
}

Additional information

The same flow works just fine running it with node

Middleware:


const jwt = require("express-jwt");
const jwksRsa = require("jwks-rsa");

function requireAuth(req, res, next) {
  const jwksUri = "https://login.microsoftonline.com/<TENANT_ID>/discovery/v2.0/keys";
  const issuer = "https://login.microsoftonline.com/<TENANT_ID>/v2.0";
  const audience = "<CLIENT_ID>";

  const client = jwksRsa({
    cache: true,
    rateLimit: true,
    jwksRequestsPerMinute: 5,
    jwksUri: jwksUri,
  });

  client
    .getSigningKeys()
    .then((keys) => console.log("keys:", keys))
    .catch((err) => console.log("error:", err));

  const expressJwtSecret = jwksRsa.expressJwtSecret({
    cache: true,
    rateLimit: true,
    jwksRequestsPerMinute: 5,
    jwksUri: jwksUri,
  });

  const expressJwt = jwt.expressjwt({
    secret: expressJwtSecret,
    audience: audience,
    issuer: issuer,
    algorithms: ["RS256"],
  });

  expressJwt(req, res, (error) => {
    if (error) {
      return next(error);
    }

    return next();
  });
}

module.exports = requireAuth;
elliots commented 7 months ago

this is the error jose is throwing that jwks isn't logging... at least in my case


TypeError: CryptoKey is not extractable
      at /opt/core/api/node_modules/jwks-rsa/node_modules/jose/dist/browser/runtime/asn1.js:12:15
      at genericExport (/opt/core/api/node_modules/jwks-rsa/node_modules/jose/dist/browser/runtime/asn1.js:7:30)
      at exportSPKI (/opt/core/api/node_modules/jwks-rsa/node_modules/jose/dist/browser/key/export.js:4:34)
      at /opt/core/api/node_modules/jwks-rsa/src/utils.js:53:30```
elliots commented 7 months ago

It's using the browser runtime, not node. Not sure if that's whats supposed to be happening? Or maybe theres a bun one. Anyway, when using browser the ext field is looked at in the jwt. This gets past the issue for me. I think.


diff --git a/node_modules/jwks-rsa/src/utils.js b/node_modules/jwks-rsa/src/utils.js
index dcaf146..e40e613 100644
--- a/node_modules/jwks-rsa/src/utils.js
+++ b/node_modules/jwks-rsa/src/utils.js
@@ -43,6 +43,7 @@ async function retrieveSigningKeys(jwks) {

   for (const jwk of jwks) {
     try {
+      jwk.ext = true
       const key = await jose.importJWK(jwk, resolveAlg(jwk));
       if (key.type !== 'public') {
         continue;
henrikericsson commented 7 months ago

Thank you for your responses and for looking into this issue. I appreciate the time and effort you’ve put into investigating this.

I agree that the error seems to be related to the crypto library, specifically with the CryptoKey not being extractable. However, I would like to point out that the same code works perfectly fine when running in a Node.js environment. This suggests that the issue might not be with the jwks-rsa library itself, but rather with how it’s being used or configured in the Bun environment.

The jwks-rsa library is as far as I know designed for Node.js, and it seems to be functioning as expected in that context. Modifying the library directly might not be the best approach, as it could potentially introduce other issues or side effects. Instead, I believe we should focus on understanding why the library is not working as expected in the Bun environment.

It’s possible that there might be some configuration changes needed in Bun, or perhaps some adjustments within the framework itself. I think it would be beneficial to explore these possibilities further.

elliots commented 7 months ago

No worries, I’m hitting the same issue.

I’m not suggesting the change is an actual fix, just working out where the problem is :)

Looks like it is supposed to use the browser runtime: https://github.com/panva/jose/blob/main/package.json#L80

elliots commented 7 months ago

Related: https://github.com/auth0/node-jwks-rsa/issues/373

guidoffm commented 7 months ago

Version 2.1.5 works for me, version 3.1.0 not.

henrikericsson commented 7 months ago

A few months ago, I experimented with the 2.x version of the jwks-rsa library, using an older version of Bun but encountered the same issue then as I am with the latest version of Bun and the 3.x version of jwks-rsa.

But I haven't tried using the latest version of bun with an older version of jwks-rsa.

guidoffm commented 7 months ago

It is better to use the Jose library since it is certified for the use with Bun.

fjdvchain commented 3 months ago

Okay I want to bring attention to this again. I'm having similar issues with Bun v1.1.22. I spending hours tweaking my code in hopes it was application related, I came across this issue and ported my code to node and it verified the jwt just fine. Do you need another repro snippet or is this issue enough? @Jarred-Sumner

fjdvchain commented 3 months ago

This is what I'm running and azureActiveDirectory fails in bun but succeeds in node. The failure message is

JsonWebTokenError {
  stack: "Error\n    at <anonymous> (.../node_modules/jsonwebtoken/verify.js:103:19)\n    at <anonymous> (.../src/jwt.ts:27:7)\n    at processTicksAndRejections (native)",
  name: "JsonWebTokenError",
  message: "error in secret or public key callback: The JWKS endpoint did not contain any signing keys",
  toString: [Function: toString],
}

import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
import { AZURE_AD_TENANT_ID } from './secrets';

const JWKS_URI = `https://login.microsoftonline.com/${AZURE_AD_TENANT_ID}/discovery/v2.0/keys`;
const UNAUTHORIZED = 'Unauthorized';

// Create a JWKS client to retrieve JWKS URI signing keys.
const client = jwksClient({ jwksUri: JWKS_URI });

function getKey(header: any, callback: jwt.SigningKeyCallback) {
  if (typeof callback !== 'function') {
    console.log('Callback is not a function');
    return;
  }

  if (!header) {
    callback(new Error('Header is not provided'));
    return;
  }
  // client.getKeys().then(r => console.log(r))
  client.getSigningKey(header.kid, (err, key) => {
    if (err || !key) {
      console.log("ERROR HERE")
      callback(err);
      return;
    }
    if (!key) return

    const signingKey = key.getPublicKey();
    callback(null, signingKey);
  });
}

export const azureActiveDirectory = (token: string) => {
  // Check if the token is present.
  if (!token) {
    return Promise.reject(`${UNAUTHORIZED}: No token provided`)
  }

  return new Promise((resolve,reject) => {
    jwt.verify(
      token,
      getKey,
      {
        algorithms: ['RS256']
      },
      (err, decode) => {
        if (err) {
          console.log('Error in token verification:', err);
          reject(UNAUTHORIZED)
          return;
        }
        resolve(decode)
      }
    );
  })
};
`
klippx commented 2 months ago

I am using a Bun backed server that works in Node but not in Bun. This code has different code paths for the runtimes:

  for (const jwk of jwks) {
    try {
      jwk.ext = true
      const key = await jose.importJWK(jwk, resolveAlg(jwk));
      console.log('jwks-rsa key.type:', key.type);
      if (key.type !== 'public') {
        continue;
      }
      let getSpki;
      console.log('jwks-rsa key[Symbol.toStringTag]:', key[Symbol.toStringTag]);

      switch (key[Symbol.toStringTag]) {
        case 'CryptoKey': {
          // 👉 BUN WILL GO THIS CODE PATH
          const spki = await jose.exportSPKI(key);
          getSpki = () => spki;
          break;
        }
        case 'KeyObject':
          // Assume legacy Node.js version without the Symbol.toStringTag backported
          // Fall through
        default: {
          // 👉 NODE WILL GO THIS CODE PATH
          getSpki = () => key.export({ format: 'pem', type: 'spki' });
        }
      }
      console.log('jwks-rsa results.push...', {
        get publicKey() { return getSpki(); },
        get rsaPublicKey() { return getSpki(); },
        getPublicKey() { return getSpki(); },
        ...(typeof jwk.kid === 'string' && jwk.kid ? { kid: jwk.kid } : undefined),
        ...(typeof jwk.alg === 'string' && jwk.alg ? { alg: jwk.alg } : undefined)
      });
      results.push({
        get publicKey() { return getSpki(); },
        get rsaPublicKey() { return getSpki(); },
        getPublicKey() { return getSpki(); },
        ...(typeof jwk.kid === 'string' && jwk.kid ? { kid: jwk.kid } : undefined),
        ...(typeof jwk.alg === 'string' && jwk.alg ? { alg: jwk.alg } : undefined)
      });
    } catch (err) {
      console.error('jwks-rsa error!');
      console.error(err)
      continue;
    }
  }

Debug logs show that for Node:

jwks-rsa key[Symbol.toStringTag]: KeyObject
jwks-rsa fallthrough
jwks-rsa results.push... {
  publicKey: [Getter],
  rsaPublicKey: [Getter],
  getPublicKey: [Function: getPublicKey],
  kid: '***'
}

And for Bun:

jwks-rsa key[Symbol.toStringTag]: CryptoKey
jwks-rsa error!
 7 | const genericExport = async (keyType, keyFormat, key) => {
 8 |     if (!isCryptoKey(key)) {
 9 |         throw new TypeError(invalidKeyInput(key, ...types));
10 |     }
11 |     if (!key.extractable) {
12 |         throw new TypeError('CryptoKey is not extractable');

It's using the browser runtime, not node. Not sure if that's whats supposed to be happening? Or maybe theres a bun one. Anyway, when using browser the ext field is looked at in the jwt. This gets past the issue for me. I think.

This jwk.ext = true "fix" works in Bun backend too:

jwks-rsa key[Symbol.toStringTag]: CryptoKey
jwks-rsa results.push... {
  publicKey: [Getter],
  rsaPublicKey: [Getter],
  getPublicKey: [Function: getPublicKey],
  kid: "***",
}

What exactly does ext = true do that makes this work? Is it insecure to use this "fix", or can it be accepted? 🤔