awslabs / aws-jwt-verify

JS library for verifying JWTs signed by Amazon Cognito, and any OIDC-compatible IDP that signs JWTs with RS256, RS384, RS512, ES256, ES384, and ES512
Apache License 2.0
632 stars 45 forks source link

[QUESTION] Use with JWT from Firebase #152

Closed hffmnn closed 10 months ago

hffmnn commented 10 months ago

Question

I'd like to use this library to verify tokens coming from Google/Firebase. When doing that I get a Token not valid! Error: JWKS does not include keys. I guess this is because the provided jwksUri doesn't respond in the expected format. This is my current code :

const verifier = JwtRsaVerifier.create({
  issuer: "https://securetoken.google.com/the-issuer,
  audience: "the-audience",
  jwksUri: "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com",
});

I got the url from here. Any idea what the correct URL is? Or is it possible to transform the response somehow?

The internet also gave me this url: https://www.googleapis.com/oauth2/v3/certs but this in turn gives me a Token not valid! Error: JWK for kid "7cf7f8727091e4c77aa995db60743b7dd2bb70b5" not found in the JWKS.

Versions Which version of aws-jwt-verify are you using? Are you using the library in Node.js or in the Web browser? Node.js If Node.js, which version of Node.js are you using? (Should be at least 14) 18 If Web browser, which web browser and which version of it are you using? - If using TypeScript, which version of TypeScript are you using? (Should be at least 4) 5.3.3

ottokruse commented 10 months ago

Looks like the URL you use is in fact the URL Google recommends, but is not in standard JWKS form as per spec https://datatracker.ietf.org/doc/html/rfc7517#page-10 (which baffles me TBH)

You can work around by creating a custom JWKS fetcher, to transform the non standard JWKS to the standard upon this library fetching it.

The rough idea:

import { JwtRsaVerifier } from "aws-jwt-verify";
import { SimpleJwksCache } from "aws-jwt-verify/jwk";
import { JsonFetcher } from "aws-jwt-verify/https";

// Use axios to do the HTTPS fetches
class CustomFetcher implements JsonFetcher {
  public async fetch(uri: string) {
    return fetch(uri).then(res => res.json()).then(keyMap => { 
      keys: Object.entries(keyMap).map((kid, x509cert) => (
        { kid, kty: "RSA", n, e } // Add logic here to parse Google's X509 certificates to extract modulus (n) and exponent (e)
      ))
     })
  }
}

const verifier = JwtRsaVerifier.create(
  {
    issuer: "https://securetoken.google.com/the-issuer,
    audience: "the-audience",
    jwksUri: "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com",
  },
  {
    jwksCache: new SimpleJwksCache({
      fetcher: new CustomFetcher(),
    }),
  }
);

Sorry that's as close as I can get you in reasonable time.

hffmnn commented 10 months ago

Wow! Thanks a lot for your input and the snippet.

I duplicated the code from SimpleJsonFetcher and tried to make it work. Now I get a Token not valid! Error: Invalid signature, which seems to come from here: https://github.com/awslabs/aws-jwt-verify/blob/2a818d9a57cc3a6c0bdea8cc9cb2e04a92e2a95c/src/node-web-compat-node.ts#L48

Here is what I got so far

import { NonRetryableFetchError } from "aws-jwt-verify/error";
import { JsonFetcher, fetchJson } from "aws-jwt-verify/https";
import { Json } from "aws-jwt-verify/safe-json-parse.js";
import crypto from "crypto";

type FetchRequestOptions = Record<string, unknown>;

/**
 * HTTPS Fetcher for URIs with JSON body
 *
 * @param defaultRequestOptions - The default RequestOptions to use on individual HTTPS requests
 */
export class CustomFetcher implements JsonFetcher {
  defaultRequestOptions: FetchRequestOptions;
  constructor(props?: { defaultRequestOptions?: FetchRequestOptions }) {
    this.defaultRequestOptions = {
      timeout: 500,
      responseTimeout: 1500,
      ...props?.defaultRequestOptions,
    };
  }

  /**
   * Execute a HTTPS request (with 1 immediate retry in case of errors)
   * @param uri - The URI
   * @param requestOptions - The RequestOptions to use
   * @param data - Data to send to the URI (e.g. POST data)
   * @returns - The response as parsed JSON
   */
  public async fetch<ResultType extends Json>(
    uri: string,
    requestOptions?: FetchRequestOptions,
    data?: Uint8Array
  ): Promise<ResultType> {
    requestOptions = { ...this.defaultRequestOptions, ...requestOptions };
    try {
      const response = await fetchJson<ResultType>(uri, requestOptions, data);
      const keys = Object.entries(response as object).map(([kid, x509cert]) => {
        const cert = new crypto.X509Certificate(x509cert).toLegacyObject();
        return { kid, kty: "RSA", use: "sig", n: cert.modulus!, e: cert.exponent! };
      });
      // @ts-expect-error
      return { keys };
    } catch (err) {
      if (err instanceof NonRetryableFetchError) {
        throw err;
      }
      // Retry once, immediately
      return fetchJson<ResultType>(uri, requestOptions, data);
    }
  }
}

This fetcher returns something like this:

[
  {
    kid: '5b602e0ca1f47a8ebfd11604d9cbf06f4d45f82b',
    kty: 'RSA',
    use: 'sig',
    n: 'B5A73BD071A2E6CC99B26F31BA765CA48E31D1BFCC1BFBA75A5D0031A88973DAB0504162A7085393678C9EE8673AB32BFF122633CFB00C2FDED399ECA89FAC073B877D0D4097218466750BFCBCAC44777B1E16CD728F3F11D48E0A6E939C4EAC13848B0ED52B6F941F3FAFFDD4341E67105533BD1C67D44BEB77E954070DDBB5394921288C62E6C6ED54029C9DF25066646C6785A5FE1462BA2F0217DAF65EF360C4CF43CCFB7CC3A939E85A4EA9773AAF808E32501456B9A92DF32EBDA5961C1C6BB7013B609C318188E3B43907EC5AC76107EB44FCA7EB58BBB1592C19562E7BC888A95FF6B3269FEFD82725FF03BA1E0FA0074ED29AD434E8FCEF662B7995',
    e: '0x10001'
  },
  {
    kid: '7cf7f8727091e4c77aa995db60743b7dd2bb70b5',
    kty: 'RSA',
    use: 'sig',
    n: '947238EDA77EA33BD8CDD396BD194FE8E5CC353F9252CCEF93140E7399538D6681F00AB6015B2D08D9BD35C3AE3ACA74F5759B03A707D837DFE0385AC4904523D87734DD452E3B42D5E5CC5D39ED597BB717E225FFFB272F00A6F85BCF992BEB9A88C621A03C28F14FA1176ED4F230637031AF2D1BD3D4F6AD673A6968E5BFCDE56D48299CDE5633C9BF353DFD09F1B1F9DE9CCFE220A58CF0A7879F4D82E99F534B4E6735711EAF78BE450C1B51C714B34EFC99CF48E7DB40225F756D74F7BC4CFF904B60EB57AB21E19D71C425110A9E1BDEE7DAE0E75AB5C1C5F31ED6435EB421D40479399B23B195BC8F6DC5C439F668F98FC641F8A994E5DB9F619218D9',
    e: '0x10001'
  }
]

And from here I have no idea, what might be wrong.

hakanson commented 10 months ago

I was expecting to see something like "e": "AQAB" instead of e: '0x10001'

Based on https://stackoverflow.com/a/70023629, Can you convert from base16 to base64 and give another try?

Decimal 65537 => converts to hexadecimal 0x010001 => encodes to Base64 AQAB 65537 is commonly used as a public exponent in the RSA cryptosystem

hffmnn commented 10 months ago

Hi and thanks for your support.

Without going into details of weird JS/node decoding issues (see *) I hardcode the given value AQAB. Sadly the result is the same: Token not valid! Error: Invalid signature

*

console.log(Buffer.from("10001", "hex").toString("base64")); // => EAA=
console.log(Buffer.from("010001", "hex").toString("base64")); // => AQAB
console.log(Buffer.from("0010001", "hex").toString("base64")); // => AQAB

I also tried EAA= and it didn't work.

ottokruse commented 10 months ago

Thanks, didn't know it is so easy nowadays in Node.js to parse an x509 cert.

Looks like this is the easiest way to get n and e in the right format now:

new crypto.X509Certificate(keyMap["7cf7f8727091e4c77aa995db60743b7dd2bb70b5"]).publicKey.export({format:"jwk"})

Also good idea to reuse the SimpleJsonFetcher with the retry, maybe this is easiest then:

import { JwtRsaVerifier } from "aws-jwt-verify";
import { SimpleJwksCache } from "aws-jwt-verify/jwk";
import { JsonFetcher, SimpleJsonFetcher } from "aws-jwt-verify/https";

class CustomFetcher implements JsonFetcher {
  private fetcher = new SimpleJsonFetcher()
  public async fetch(uri: string) {
    return fetcher.fetch(uri).then(keyMap => { 
      keys: Object.entries(keyMap).map((kid, x509cert) => (
        { kid, kty: "RSA", ...crypto.X509Certificate(x509cert).publicKey.export({format:"jwk"}) }
      ))
     })
  }
}

const verifier = JwtRsaVerifier.create(
  {
    issuer: "https://securetoken.google.com/the-issuer,
    audience: "the-audience",
    jwksUri: "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com",
  },
  {
    jwksCache: new SimpleJwksCache({
      fetcher: new CustomFetcher(),
    }),
  }
);
hffmnn commented 10 months ago

Hi and once again thank you very much!

This time it works and this is the final code we came up with:

import { JsonFetcher, SimpleJsonFetcher } from "aws-jwt-verify/https";
import { Json } from "aws-jwt-verify/safe-json-parse";
import crypto from "crypto";

type FetchRequestOptions = Record<string, unknown>;

export class CustomFetcher implements JsonFetcher {
  private fetcher = new SimpleJsonFetcher();
  public async fetch<ResultType extends Json>(
    uri: string,
    requestOptions?: FetchRequestOptions,
    data?: Uint8Array
  ): Promise<ResultType> {
    // @ts-expect-error
    return this.fetcher.fetch<ResultType>(uri, requestOptions, data).then(response => {
      const keys = Object.entries(response as object).map(([kid, x509cert]) => {
        return {
          kid,
          kty: "RSA",
          use: "sig",
          ...new crypto.X509Certificate(x509cert).publicKey.export({ format: "jwk" }),
        };
      });
      return { keys };
    });
  }
}

There is only one (very small) thing which is the @ts-expect-error. Without it it gives a TS compiler error:

Type 'ResultType | { keys: { crv?: string | undefined; d?: string | undefined; dp?: string | undefined; dq?: string | undefined; e?: string | undefined; k?: string | undefined; ... 8 more ...; use: string; }[]; }' is not assignable to type 'ResultType'.
  'ResultType | { keys: { crv?: string | undefined; d?: string | undefined; dp?: string | undefined; dq?: string | undefined; e?: string | undefined; k?: string | undefined; ... 8 more ...; use: string; }[]; }' is assignable to the constraint of type 'ResultType', but 'ResultType' could be instantiated with a different subtype of constraint 'Json'.
    Type '{ keys: { crv?: string | undefined; d?: string | undefined; dp?: string | undefined; dq?: string | undefined; e?: string | undefined; k?: string | undefined; kty: string; n?: string | undefined; ... 6 more ...; use: string; }[]; }' is not assignable to type 'ResultType'.
      '{ keys: { crv?: string | undefined; d?: string | undefined; dp?: string | undefined; dq?: string | undefined; e?: string | undefined; k?: string | undefined; kty: string; n?: string | undefined; ... 6 more ...; use: string; }[]; }' is assignable to the constraint of type 'ResultType', but 'ResultType' could be instantiated with a different subtype of constraint 'Json'.ts(2322)

...but I guess I can live with that. šŸ˜„

Once again: Thanks a lot for your great support!

ottokruse commented 10 months ago

This time it works

Nice!

Here's how you can solve the TS issue:

import { JsonFetcher, SimpleJsonFetcher } from "aws-jwt-verify/https";
import crypto from "crypto";

type GoogleJwks = Record<string, string>;

export class GoogleJwksFetcher implements JsonFetcher {
  private fetcher = new SimpleJsonFetcher();
  public async fetch(...params: Parameters<JsonFetcher["fetch"]>) {
    return this.fetcher.fetch(...params).then((response) => {
      const keys = Object.entries(response as GoogleJwks).map(
        ([kid, x509cert]) => {
          return {
            kid,
            kty: "RSA",
            use: "sig",
            ...new crypto.X509Certificate(x509cert).publicKey.export({
              format: "jwk",
            }),
          };
        }
      );
      return { keys };
    });
  }
}
hffmnn commented 10 months ago

Here's how you can solve the TS issue:

Great! šŸ‘šŸ» Thanks a lot!