Reputation: 21615
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).
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.
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.
kid
in the header.
kid: lk02Aw
. As far as I can tell, this does not correspond to any of the public keyskid
does not exist.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.
Upvotes: 2
Views: 846
Reputation: 11
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;
};
Upvotes: 0
Reputation: 18535
Here's how to do it with native Node.
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)}).
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.
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";
}
}
Upvotes: 1
Reputation: 21615
I was able to work this out with a lot of help from @panva (the author of jose). So, shout out to him!
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
}
Upvotes: 2