skip to Main Content

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.

2

Answers


  1. Chosen as BEST ANSWER

    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
    }
    

  2. 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";
      }
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search