Firebase Admin and Google API helpers in the browser
Some firebase-admin functionality that is not available in the browser, and some helpers for related Google API functionality.
Allows you to automate use of Firebase using a Service account. Also allows you to verify other Firebase user tokens, and mint Google access_tokens within the browser.
You need some way of securing the Google Service Account of course. You could run a browser in a secure environment (e.g. serverside cells) or password-protect it.
verifyIdToken
Check the validity of a Firebase ID token
~~~js
import {verifyIdToken} from '@tomlarkworthy/firebase-admin'
~~~
/*!
* Copyright 2018 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const tokenValidator = () => {
// Audience to use for Firebase Auth Custom tokens
const FIREBASE_AUDIENCE = 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit';
const ALGORITHM_RS256 = 'RS256';
// URL containing the public keys for the Google certs (whose private keys are used to sign Firebase
// Auth ID tokens)
const CLIENT_CERT_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com';
// URL containing the public keys for Firebase session cookies. This will be updated to a different URL soon.
const SESSION_COOKIE_CERT_URL = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys';
/** User facing token information related to the Firebase ID token. */
const ID_TOKEN_INFO = {
url: 'https://firebase.google.com/docs/auth/admin/verify-id-tokens',
verifyApiName: 'verifyIdToken()',
jwtName: 'Firebase ID token',
shortName: 'ID token'
};
/** User facing token information related to the Firebase session cookie. */
const SESSION_COOKIE_INFO = {
url: 'https://firebase.google.com/docs/auth/admin/manage-cookies',
verifyApiName: 'verifySessionCookie()',
jwtName: 'Firebase session cookie',
shortName: 'session cookie'
};
/**
* Class for verifying general purpose Firebase JWTs. This verifies ID tokens and session cookies.
*/
class FirebaseTokenVerifier {
constructor(clientCertUrl, algorithm,
issuer, tokenInfo,
app) {
this.clientCertUrl = clientCertUrl;
this.algorithm = algorithm;
this.issuer = issuer;
this.tokenInfo = tokenInfo;
this.app = app;
this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a';
// For backward compatibility, the project ID is validated in the verification call.
}
verifyJWT(jwtToken) {
const projectId = this.app.options.projectId
const fullDecodedToken = jwt.decode(jwtToken, {
complete: true,
});
const header = fullDecodedToken && fullDecodedToken.header;
const payload = fullDecodedToken && fullDecodedToken.payload;
const projectIdMatchMessage = ` Make sure the ${this.tokenInfo.shortName} comes from the same ` +
'Firebase project as the service account used to authenticate this SDK.';
const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` +
`for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`;
let errorMessage;
if (!fullDecodedToken) {
errorMessage = `Decoding ${this.tokenInfo.jwtName} failed. Make sure you passed the entire string JWT ` +
`which represents ${this.shortNameArticle} ${this.tokenInfo.shortName}.` + verifyJwtTokenDocsMessage;
} else if (typeof header.kid === 'undefined' && this.algorithm !== 'none') {
const isCustomToken = (payload.aud === FIREBASE_AUDIENCE);
if (isCustomToken) {
errorMessage = `${this.tokenInfo.verifyApiName} expects ${this.shortNameArticle} ` +
`${this.tokenInfo.shortName}, but was given a custom token.`;
} else {
errorMessage = 'Firebase ID token has no "kid" claim.';
}
errorMessage += verifyJwtTokenDocsMessage;
} else if (header.alg !== this.algorithm) {
errorMessage = `${this.tokenInfo.jwtName} has incorrect algorithm. Expected "` + this.algorithm + '" but got ' +
'"' + header.alg + '".' + verifyJwtTokenDocsMessage;
} else if (payload.aud !== projectId) {
errorMessage = `${this.tokenInfo.jwtName} has incorrect "aud" (audience) claim. Expected "` +
projectId + '" but got "' + payload.aud + '".' + projectIdMatchMessage +
verifyJwtTokenDocsMessage;
} else if (payload.iss !== this.issuer + projectId) {
errorMessage = `${this.tokenInfo.jwtName} has incorrect "iss" (issuer) claim. Expected ` +
`"${this.issuer}"` + projectId + '" but got "' +
payload.iss + '".' + projectIdMatchMessage + verifyJwtTokenDocsMessage;
} else if (typeof payload.sub !== 'string') {
errorMessage = `${this.tokenInfo.jwtName} has no "sub" (subject) claim.` + verifyJwtTokenDocsMessage;
} else if (payload.sub === '') {
errorMessage = `${this.tokenInfo.jwtName} has an empty string "sub" (subject) claim.` + verifyJwtTokenDocsMessage;
} else if (payload.sub.length > 128) {
errorMessage = `${this.tokenInfo.jwtName} has "sub" (subject) claim longer than 128 characters.` +
verifyJwtTokenDocsMessage;
}
if (errorMessage) {
return Promise.reject(new Error("INVALID_ARGUMENT" + errorMessage));
}
return this.fetchPublicKeys().then((publicKeys) => {
if (!Object.prototype.hasOwnProperty.call(publicKeys, header.kid)) {
return Promise.reject(
new Error(
"INVALID_ARGUMENT" +
`${this.tokenInfo.jwtName} has "kid" claim which does not correspond to a known public key. ` +
`Most likely the ${this.tokenInfo.shortName} is expired, so get a fresh token from your ` +
'client app and try again.',
),
);
} else {
return this.verifyJwtSignatureWithKey(jwtToken, publicKeys[header.kid]);
}
});
}
/**
* Verifies the JWT signature using the provided public key.
* @param {string} jwtToken The JWT token to verify.
* @param {string} publicKey The public key certificate.
* @return {Promise<DecodedIdToken>} A promise that resolves with the decoded JWT claims on successful
* verification.
*/
verifyJwtSignatureWithKey(jwtToken, publicKey) {
const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` +
`for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`;
return new Promise((resolve, reject) => {
jwt.verify(jwtToken, publicKey || '', {
algorithms: [this.algorithm],
}, (error, decodedToken) => {
if (error) {
if (error.name === 'TokenExpiredError') {
const errorMessage = `${this.tokenInfo.jwtName} has expired. Get a fresh ${this.tokenInfo.shortName}` +
` from your client app and try again (auth/${this.tokenInfo.expiredErrorCode.code}).` +
verifyJwtTokenDocsMessage;
return reject(new Error(this.tokenInfo.expiredErrorCode, errorMessage));
} else if (error.name === 'JsonWebTokenError') {
const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage;
return reject(new Error('INVALID_ARGUMENT' + errorMessage));
}
return reject(new Error('INVALID_ARGUMENT' + error.message));
} else {
const decodedIdToken = decodedToken;
decodedIdToken.uid = decodedIdToken.sub;
resolve(decodedIdToken);
}
});
});
}
/**
* Fetches the public keys for the Google certs.
*
* @return {Promise<object>} A promise fulfilled with public keys for the Google certs.
*/
fetchPublicKeys() {
const publicKeysExist = (typeof this.publicKeys !== 'undefined');
const publicKeysExpiredExists = (typeof this.publicKeysExpireAt !== 'undefined');
const publicKeysStillValid = (publicKeysExpiredExists && Date.now() < this.publicKeysExpireAt);
if (publicKeysExist && publicKeysStillValid) {
return Promise.resolve(this.publicKeys);
}
return fetch(this.clientCertUrl).then(async (resp) => {
resp.data = await resp.json()
if (resp.headers.get('cache-control')) {
const cacheControlHeader = resp.headers.get('cache-control');
const parts = cacheControlHeader.split(',');
parts.forEach((part) => {
const subParts = part.trim().split('=');
if (subParts[0] === 'max-age') {
const maxAge = +subParts[1];
this.publicKeysExpireAt = Date.now() + (maxAge * 1000);
}
});
}
this.publicKeys = resp.data;
return resp.data;
}).catch((err) => {
let errorMessage = 'Error fetching public keys for Google certs: ';
const resp = err.response;
if (resp.isJson() && resp.data.error) {
errorMessage += `${resp.data.error}`;
if (resp.data.error_description) {
errorMessage += ' (' + resp.data.error_description + ')';
}
} else {
errorMessage += `${resp.text}`;
}
throw new Error('INTERNAL_ERROR', errorMessage);
});
}
}
return {
/**
* Creates a new FirebaseTokenVerifier to verify Firebase ID tokens.
*
* @param {FirebaseApp} app Firebase app instance.
* @return {FirebaseTokenVerifier}
*/
createIdTokenVerifier: (app) => new FirebaseTokenVerifier(
CLIENT_CERT_URL,
ALGORITHM_RS256,
'https://securetoken.google.com/',
ID_TOKEN_INFO,
app
),
/**
* Creates a new FirebaseTokenVerifier to verify Firebase session cookies.
*
* @param {FirebaseApp} app Firebase app instance.
* @return {FirebaseTokenVerifier}
*/
createSessionCookieVerifier: (app) => new FirebaseTokenVerifier(
SESSION_COOKIE_CERT_URL,
ALGORITHM_RS256,
'https://session.firebase.google.com/',
SESSION_COOKIE_INFO,
app
)
};
}
Create Custom Token
Creating custom tokens Firebase docs
~~~js
import {createCustomToken} from '@tomlarkworthy/firebase-admin'
~~~
async function createCustomToken({
private_key,
client_email
} = {}, uid, additionalClaims, additionalFields = {}) {
const now_secs = Math.floor(Date.now() / 1000);
const sHeader = JSON.stringify({alg: 'RS256', typ: 'JWT'});
const sPayload = JSON.stringify({
"iss": client_email,
"sub": client_email,
"aud": "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit",
"iat": now_secs,
"exp": now_secs + 60 * 60,
"uid": uid,
...(additionalClaims && {"claims": additionalClaims}),
...additionalFields,
});
return jsrsasign.KJUR.jws.JWS.sign("RS256", sHeader, sPayload, private_key);
}
Verify Custom Token
~~~js
import {verifyCustomToken} from '@tomlarkworthy/firebase-admin'
~~~
async function verifyCustomToken(firebase, token) {
const API_KEY = firebase.options_.apiKey;
if (!token) throw new Error("No token specified");
if (!API_KEY) throw new Error("Cannot find API_KEY");
// Simplest way is to try to exchange for ID token
// https://cloud.google.com/identity-platform/docs/use-rest-api
const response = await fetch(
`https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${API_KEY}`,
{
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: `{"token":"${token}","returnSecureToken":true}`
}
);
if (response.status !== 200) throw new Error(await response.text());
else {
return JSON.parse(atob(token.split('.')[1]));
}
}
Mint access_token from Google Service Account
Combine this with Google API Client to call Google Services
async function getAccessTokenFromServiceAccount(
serviceAccountKey,
{
scope = "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/cloud-platform"
} = {}
) {
if (typeof serviceAccountKey === "string") {
serviceAccountKey = JSON.parse(serviceAccountKey);
}
// First create a JWT from the credentials
const tNow = Math.floor(new Date().getTime() / 1000);
const sHeader = JSON.stringify({ alg: "RS256", typ: "JWT" });
const sPayload = JSON.stringify({
iss: serviceAccountKey.client_email,
scope: scope,
iat: tNow,
exp: tNow + 600,
aud: "https://oauth2.googleapis.com/token"
});
const JWT = jsrsasign.KJUR.jws.JWS.sign(
"RS256",
sHeader,
sPayload,
serviceAccountKey.private_key
);
// Swap JWT for access_token
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${JWT}`
});
if (tokenResponse.status != 200) {
throw new Error(await tokenResponse.text());
}
return (await tokenResponse.json()).access_token;
}
Signin with access_token
You can use an access_token to authenticate as a user to the Firebase API (requires enabled Google Login).
You can use service account from different project if you whitelist the service account's client_id
async function signinWithAccessToken(firebase, access_token) {
const credential = firebase.firebase_.auth.GoogleAuthProvider.credential(
null,
access_token
);
return await firebase.auth().signInWithCredential(credential);
}
Support utilities
//const jwt = require("https://bundle.run/jsonwebtoken@8.5.1")
import jwt from "https://bundle.run/jsonwebtoken@8.5.1";
//const jsrsasign = require('https://bundle.run/jsrsasign@10.1.4')
import jsrsasign from "https://bundle.run/jsrsasign@10.1.4";