How to verify a Firebase ID token using jose in node?
Asked Answered
P

3

2

I'm trying to verify a JWT (session cookie) following the instructions here guided by this sample implementation in Python using the jose package (although I'm open to other node packages).

Why?

I'm aware that I can use Firebase's verifySessionCookie to do this. In fact, that's what I'm doing currently and it works..

export async function getDecodedSessionCookie() {
  // Get the sessionCookie
  const sessionCookie = cookies().get("sessionCookie")
  if (sessionCookie === undefined) return null

  // Verify the cookie but don't check if the cookie has
  // been revoked not sure if this is a security risk,
  // but it appears to add significant latency
  return (
    adminAuth
      .verifySessionCookie(sessionCookie.value, false)

      // If the cookie is verified, return the decodedClaims
      .then((decodedClaims) => {
        return decodedClaims
      })
      .catch((e) => console.log("error", e))
  )
}

BUT it's annoyingly slow and it can't be executed in Vercel's Edge runtime.

What I've Tried

This topic is a little above my head, but here's what I've tried..

export async function getDecodedSessionCookie2() {
  // Return null if the cookie doesn't exist or it's invalid
  const sessionCookie = cookies().get("sessionCookie")
  if (sessionCookie === undefined) return null

  // Decode the header (this works)
  const header = jose.decodeProtectedHeader(sessionCookie.value)
  console.log("header", header)

  // Decode the cookie (this works)
  const sessionCookieDecoded = jose.decodeJwt(sessionCookie.value)
  console.log("sessionCookieDecoded", sessionCookieDecoded)

  // Create the remote key set 
  // (This errors with message: JSON Web Key Set malformed)
  const JWKS = jose.createRemoteJWKSet(
    new URL(
      "https://www.googleapis.com/robot/v1/metadata/x509/[email protected]"
    )
  )
  const keyset = await JWKS()
  console.log("keyset", keyset)

  // Never made it here
  const audience = process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID
  const issuer = `https://securetoken.google.com/${audience}`

  // Never made it here
  const { payload, protectedHeader } = await jose.jwtVerify(
    sessionCookie.value,
    JWKS,
    {
      issuer,
      audience,
    }
  )
  console.log("protectedHeader", protectedHeader)
  console.log("payload", payload)

  // Not sure if this is needed?
  // const x509 = certificates["7cf7f8727091e4c77aa995db60743b7dd2bb70b5"]
  // const ecPublicKey = await jose.importX509(x509, algorithm)

  return sessionCookieDecoded
}

Mostly this is just a lot of tinkering and exploration, but I think I need to create a remote keyset with createRemoteJWKSet and this is the step I can't get past.

Additional notes

  1. Firebase tokens should have a kid in the header.
    1. In production, I see kid: lk02Aw. As far as I can tell, this does not correspond to any of the public keys
    2. In local development with the Auth Emulator, kid does not exist.
  2. Do the public certificates change frequently?

Updates

  • I was able to get past the error above with some guidance from the author of the jose package. Will update with complete details if/when I finish implementing token verification.

  • I found this post by John Hanley noting that Google's public keys rotate every 12 hours.

Pyrogen answered 13/1 at 17:14 Comment(4)
If you inspect the JWT at jwt.io you can see that it's not signed (see none for alg), or encrypted. Therefore there is no verifying it bcs there's no signature to examine.Arrest
Alg is RS256. It should be verifiable according to Firebase docs. Also, hello from a fellow New Orleanian.Pyrogen
Cool! Do you have the public key? If so, here's how you verify using Node's crypto library. I'm sure it could be done in the browser w the subtle.crypto API. gist.github.com/ronnieroyston/b15bc38341b70e5e63da6327375555d0Arrest
According to the docs, public keys are listed here: googleapis.com/robot/v1/metadata/x509/… ...but I'd rather not use Node's crypto library as it's been discontinued.Pyrogen
P
2

I was able to work this out with a lot of help from @panva (the author of jose). So, shout out to him!


Code

import * as jose from "jose"
import { cookies } from "next/headers"

let publicKeys

async function sessionPublicKeyResolver(protectedHeader) {
  const { kid, alg } = protectedHeader
  if (!publicKeys || !(kid in publicKeys)) {
    publicKeys = await fetch(
      "https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys"
    ).then((response) => {
      return response.json()
    })
  }

  // key object was not cached yet
  if (typeof publicKeys[kid] === "string") {
    publicKeys[kid] = await jose.importX509(publicKeys[kid], alg)
  }

  return publicKeys[kid]
}

export async function getDecodedSessionCookie() {
  // Get the decoded session cookie (JWT)

  // Return null if the cookie doesn't exist or it's invalid
  const sessionCookie = cookies().get("sessionCookie")
  if (sessionCookie === undefined) return null

  let payload
  let header

  if (process.env.NODE_ENV === "development") {
    // ONLY IN DEVELOPMENT ENVIRONMENTS
    const verifiedToken = jose.UnsecuredJWT.decode(sessionCookie.value)
    payload = verifiedToken.payload
    header = verifiedToken.header
  } else {
    const audience = process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID
    const issuer = `https://session.firebase.google.com/${audience}`

    // https://github.com/panva/jose/discussions/448
    const verifiedToken = await jose.jwtVerify(
      sessionCookie.value,
      sessionPublicKeyResolver,
      {
        issuer,
        audience,
      }
    )
    payload = verifiedToken.payload
    header = verifiedToken.protectedHeader
  }

  // Complete the rest of the checks
  // https://firebase.google.com/docs/auth/admin/manage-cookies#verify_session_cookies_using_a_third-party_jwt_library

  // sub (subject) Must be a non-empty string and must be the uid of the user or device.
  if (payload.sub !== payload.user_id) {
    throw new Error("JWT sub does not equal user_id")
  }

  // exp (expiration) must be in the future. The time is measured in seconds since the UNIX epoch.
  // The expiration is set based on the custom duration provided when the cookie is created.
  if (payload.exp <= Date.now() / 1000) {
    throw new Error("JWT exp must be in the future")
  }

  // iat (Issued-at time) Must be in the past.
  // The time is measured in seconds since the UNIX epoch.
  if (payload.iat >= Date.now() / 1000) {
    throw new Error("JWT iat must be in the past")
  }

  // auth_time (Authentication time) Must be in the past. The time when the user authenticated.
  // This matches the auth_time of the ID token used to create the session cookie.
  if (payload.auth_time >= Date.now() / 1000) {
    throw new Error("JWT auth_time must be in the past")
  }

  return payload
}
Pyrogen answered 15/1 at 22:5 Comment(3)
Node is capable w/o a package.Arrest
@RonnieRoyston I see, that's really cool and thank you for taking the time to post an answer. The downside is that node:crypto can't be loaded on Next.js's Edge runtime, last time I checked.Pyrogen
Web Crypto (Subtle) API is supported, which can do it too in case you needed it. nextjs.org/docs/pages/api-reference/edge#crypto-apisArrest
A
1

Here's how to do it with native Node.

Get Token

I was able to get my token from the browser by opening Developer Tools (cntrl+shift+i) and entering:

firebase.auth().signInAnonymously
firebase.auth().currentUser.getIdToken().then(function(idToken) {console.log(idToken)}).

Get Public Key(s)

Google's Public Keys are published at https://www.googleapis.com/robot/v1/metadata/x509/[email protected] passed as a string publicKeyGoogle to script below.

Run Node Script

const {createVerify, createPublicKey} = await import('node:crypto');
(async function () {
  console.log(await validateJWS(myJWT));
})();
async function validateJWS(jwt){
  try {
    let jwtParts = jwt.split('.');
    let jwtHeader = jwtParts[0];
    let jwtPayload = jwtParts[1];
    let jwtSignature = jwtParts[2];
    let valid = false;
    let header = JSON.parse(Buffer.from(jwtHeader, 'base64url').toString('utf-8'));
    let alg = header.alg;
    if(alg === "RS256"){ // MUST verify alg is not set to none
      let verify = createVerify('SHA256');
      verify.write(jwtHeader + '.' + jwtPayload);
      verify.end();
      let googleKey = publicKeyGoogle[Object.keys(publicKeyGoogle)[0]];
      valid = verify.verify(googleKey, jwtSignature, 'base64url');
      if(valid){
        return JSON.parse(Buffer.from(jwtPayload, 'base64url').toString('utf-8'));
      } else {
        throw (error)
      }
    } else {
      throw (error)     
    }
  } catch (e) {
    //console.log (e);
    return "\x1b[31mInvalid Token!\x1b[37m";
  }
}
Arrest answered 14/1 at 2:37 Comment(2)
I wasnt able to get this to work. I kep getting valid as false. Additionally, what is the purpose of createPublicKey here?Tonne
createPublicKey has no effect as it's not used. It's in there from a paste.Arrest
E
0

I used this code found here: https://zuplo.com/blog/2023/04/05/using-jose-to-validate-a-firebase-jwt,

let publicKeys: any;
 
const getPublicKeys = async () => {
  if (publicKeys) {
    return publicKeys;
  }
  const res = await fetch(
    `https://www.googleapis.com/service_accounts/v1/metadata/x509/[email protected]`,
  );
  publicKeys = await res.json();
  return publicKeys;
};
 
// This goes
// inside your auth function or middleware
 
const authHeader = request.headers.get("authorization");
const token = authHeader.substring("bearer ".length);
 
const firebaseProjectId = "your-project-id";
 
const verifyFirebaseJwt = async (firebaseJwt) => {
  const publicKeys = await getPublicKeys();
  const decodedToken = await jwtVerify(
    firebaseJwt,
    async (header, _alg) => {
      const x509Cert = publicKeys[header.kid];
      const publicKey = await importX509(x509Cert, "RS256");
      return publicKey;
    },
    {
      issuer: `https://securetoken.google.com/${firebaseProjectId}`,
      audience: firebaseProjectId,
      algorithms: ["RS256"],
    },
  );
  return decodedToken.payload;
};
 

It worth nothing that tokens in the emulator aren't signed so you just need to use the decodeJwt to get basic data, somthing like this:

const verifyFirebaseJwt = async (firebaseJwt) => {
  const publicKeys = await getPublicKeys();
  if (__DEV__) {
    const res = jose.decodeJwt(firebaseJwt);
    return res;
  }

  const decodedToken = await jose.jwtVerify(
    firebaseJwt,
    async (header) => {
      const x509Cert = publicKeys[header.kid || ""];
      const publicKey = await jose.importX509(x509Cert, "RS256");
      return publicKey;
    },
    {
      issuer: `https://securetoken.google.com/${firebaseProjectId}`,
      audience: firebaseProjectId,
      algorithms: ["RS256"],
    }
  );
  return decodedToken.payload;
};
Earwitness answered 25/2 at 13:3 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.