Firebase auth for graphql clients

Graphql with firebase client side auth posted on 25th July 2019


This'll be quite a long post, or maybe a series of them, as auth is always complicated. Lets first of all set the scene. This is a VueJs client that needs access, both authenticated and open, to a back end graphql apollo server.

Firebase client side auth

There are lots of approaches to auth with graphql. I'm using Firebase Auth (but we'll need to use the underlying Google Identity platform too - but more of that later). So here's the background.
  • There are multiple instances of the server as it's running in a kubernetes scaling cluster, so it's stateless - no session management or cookies.
  • The client app uses standard firebase auth and ends up with a uid for the currently logged on user.
  • This uid needs to be passed reliably and securely to the backend server so it can determine if the request is allowed.
  • Other clients, both server side and client side need to make authenticated and non authenticated requests to the server too. In my case these are cloud functions, cloud run containers, graphiql, Apps Script and various other node utilities
  • Various api keys are in use for tracking origin usage

Passing the uid safely to the server.

Both the apikey and the uid are passed in request headers. The simplest way to do this is to create apollolink which will add headers to every client request.

You'll need to install and require setContext like this, as its not in the regular apollo package.
import { setContext } from 'apollo-link-context'

then create your link middleware..
const authLink = setContext((request, { headers }) => new Promise((resolve, reject) => { // generate the id token FidAuth.getIdToken() .then(idToken => { resolve({ headers: { ...headers, 'x-fid-idtoken': idToken ? `${idToken}` : '', 'x-fid-apikey': getApiConfig().apiKey, 'x-fid-proxy': '' } }) }) .catch(err => reject(err)) }))

which you can add to your other apollo links when you instantiate the Apollo client. 
// create the apollo client
const clientOptions = {
  link: ApolloLink.from([
    stateLink,
    errorLink,
    authLink,
    httpLink
  ]),
  cache,
  connectToDevTools: isDevelopment,
  resolvers: stateResolvers
}
export const apolloClient = new ApolloClient(clientOptions)

Generating a jwt token for the Firebase Uid

Of course you don't want to send the uid in plain text, or in some way it can be tampered with so you can use firebase to generate a jwt token userIDtoken for you. Here activeUser is a firebase user object. 
 getIdToken: () =>  activeUser ? activeUser.getIdToken() : Promise.resolve(null)

List of packages you'll need

There are many variations of Apollo  bundle packages to choose from and this may not work for you depending on what other options you are using, but to save you some time, here's the full list I use to enable all this.
import { ApolloClient } from 'apollo-client'
import { HttpLink } from 'apollo-link-http'
import { InMemoryCache, defaultDataIdFromObject } from 'apollo-cache-inmemory'
import { setContext } from 'apollo-link-context'
import { withClientState } from 'apollo-link-state'
import { ApolloLink } from 'apollo-link'

And that's it for the client side!

Handling auth server side

Firebase provides a way to untangle the jwt to get back to the original firebase uid, so we'll just use some middleware to validate each request before it hits apollo. Ignore the stuff about proxy - we'll come to that in a moment.

Express middleware

    app.use('/',
      // this checks the api key is valid and bums out if not
      mw.registerView()
    );

This middleware will get called to have a look at every request and will reject it if its an unknown/ bad apikey or the jwt token doesn't check out. Note that you can use res.locals to store the results so it will be available further along the middleware chain.
  ns.registerView = () => async (req, res, next) => {
    const authPack = await gsServer.authorizeKeys({ req });
    if (authPack.error) {
      console.log(authPack.error, authPack.apiKey);
    } else {
      console.log(`query requested by ${authPack.found.name} at ${new Date().toUTCString()}`);
    }

    if (!authPack.error) {
      // we can use res.locals to store middleware data
      res.locals.authPack = authPack;
      // now we need to vers
      next();
    }
    else {
      res.setHeader('Content-Type', 'application/json');
      res.status(403)
        .send(JSON.stringify({
          errors: [{ message: authPack.error }]
        }));
    }
  };

This example api keeps a list of known api keys along with what they are expected to be able to do, so the apiKey received is checked against that list.

Extract the headers that should have been sent from a client.

  const getFidHeaders = (req) => ({
    userIDToken: req.headers && req.headers['x-fid-idtoken'],
    proxy: req.headers && req.headers['x-fid-proxy'],
    apiKey: req.headers && req.headers['x-fid-apikey']
  });

Check the apikey is good

const isAuthorized = ({ req }) => {
    const { userIDToken, proxy, apiKey } = auth.getFidHeaders(req);
    // only certain apiKeys, are allowed to do proxies
    const found = secrets.auth.apiKeys.find(f => f.key === apiKey && f.active);
    const foundProxy = proxy && found && found.canProxy;
    return {
      error: `${found ? '' : 'invalid apiKey'}${((proxy && foundProxy) || !proxy) ? '' : 'no permission to proxy'}`,
      found,
      userIDToken,
      proxy,
      apiKey
    };
  };

Wrapper

Once the apikey combination is verified in principle, it's time to verify and unpack the jwt.
  ns.authorizeKeys = async ({ req }) => {
    // get headers info and check all is valid
    const authPack = isAuthorized({ req });
    if (authPack.error) return authPack;

    // if there's a user id token that came from the client, then verify it
    if (authPack.userIDToken) {
      const { result: providerUser, error } = await till(auth.verifyToken(authPack.userIDToken));
      authPack.error = error || (providerUser ? null : 'no user token decoded');
      authPack.providerUser = providerUser;
      return authPack;
    }

    // if there's a proxy then deal with verifying that
    if (authPack.proxy) {
      // now we need to generate a fresh custom IDtoken for a proxy uid
      const { result: proxyIDToken, error } = await till(spoofUserIDToken(authPack));
      authPack.error = error || (proxyIDToken ? null : 'unable to generate proxy custom token');
      if (!authPack.error) {
        // store the generated token
        authPack.proxyIDToken = proxyIDToken;

        // verify it
        const { result: providerUser, error } = await till(auth.verifyToken(authPack.proxyIDToken));
        authPack.error = error || (providerUser ? null : 'no proxy provider found');
        authPack.providerUser = providerUser;
      }
    }

    return authPack;
  };

Verify the user ID token

This uses the firebase admin SDK
const verifyToken = (userIDToken) => admin.auth().verifyIdToken(userIDToken);

Accessing in the query context

Assuming that everything is in order, the middleware will pass through to the next stages. Eventually Graphql will handle the query, and the query context will need to be populated with a few things, including the auth information we've just validated.  The context function will include this in each resolver call. There's a few extra things here, but for this example I've cut out all but authPack.providerUser which will contain either null (if nobody was logged on in the client, or a firebase user object)
    const context = ({ req, res }) => {
      // this should always succeed as its been done with middleware already
      const { authPack } = res.locals;
      const { providerUser } = authPack;
      // add to the context
      return ({
        providerUser
      });
    };

And this is injected into the server options like this
    const server = new ApolloServer({
      schema: analysedSchema,
      context,
      playground: false
    });

Proxy

There will be times when the server needs to run something on behalf of a user who is not logged on at this moment. In my use cases, this would be where the user submits some long running or queued process. I can't use the userIDToken as it might expire by the time the cloud function or other process executes, and I don't really want to start storing firebase user IDS or using some kind of service accounts in all processes that might need to use this capability. So for server based functions that need to run processes on behalf of previously logged on users, the code we've just looked at also supports the idea of proxying.

How does proxying work

  • A logged on user will request, via a client mutation request to the API Graphql server, that the API should queue up a long running task to happen at some point.
  • That mutation will publish to a pubsub topic a message which will include the firebase user id of the user that made the request. 
  • The server side process (a kubernetes deployment, a cloud function, or a cloud run container) that has subscribed to that topic will act on behalf of the user, making proxy requests to the graphql api using an apikey that allows proxies, and passing the uid in the request header

API receives a proxy request

In this case the x-fid-idtoken header will not be used
  const getFidHeaders = (req) => ({
    userIDToken: req.headers && req.headers['x-fid-idtoken'],
    proxy: req.headers && req.headers['x-fid-proxy'],
    apiKey: req.headers && req.headers['x-fid-apikey']
  });

But to be able to get the user information from firebase, the api can convert that to a custom JWT and then verify it as in this snippet
    // if there's a proxy then deal with verifying that
    if (authPack.proxy) {
      // now we need to generate a fresh custom IDtoken for a proxy uid
      const { result: proxyIDToken, error } = await till(spoofUserIDToken(authPack));
      authPack.error = error || (proxyIDToken ? null : 'unable to generate proxy custom token');
      if (!authPack.error) {
        // store the generated token
        authPack.proxyIDToken = proxyIDToken;

        // verify it
        const { result: providerUser, error } = await till(auth.verifyToken(authPack.proxyIDToken));
        authPack.error = error || (providerUser ? null : 'no proxy provider found');
        authPack.providerUser = providerUser;
      }
    }
Using this function
const spoofUserIDToken = async ({ proxy }) => proxy ? auth.generateIDToken(proxy) : null;

Generating a custom ID token

The firebase admin sdk can create a custom JWT token, but this token is not decodable as a userIDToken by verifyUserToken, so you need to have the google identity api (enable it in your project first), do that for you as per this post. Thereafter you can treat the token just as if firebase had created a userIDToken
  const generateIDToken = uid =>
    admin.auth().createCustomToken(uid)
      .then(customToken => axios.post(`${secrets.firebase.idTokenConverter}${secrets.firebase.apiKey}`, {
        token: customToken,
        returnSecureToken: true
      }))
      .then(r => r.data.idToken);

The firebase apiKey is the api key for my firebase project which you'll find in your firebase console, and the url for the token converter is
https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyCustomToken?key=

Utilities

for error handling with await/async I'm using this little function
  const till = (waitingFor) =>
    waitingFor.then(result => ({result})).catch(error => ({error}));

Conclusion

Firebase (and Google cloud identity) is pretty awesome in taking care of the hard work of auth, gets rid of the responsibility of keeping databases of passwords and email addresses, avoids all the complications of cookie and session management in a stateless API environment and is still flexible enough to allow you to find ways of doing stuff like this.

Learning Apps Script, (and transitioning from VBA) are covered comprehensively in my my book, Going Gas - from VBA to Apps script, All formats are available from O'ReillyAmazon and all good bookshops. You can also read a preview on O'Reilly

If you prefer Video style learning I also have two courses available. also published by O'Reilly.
Google Apps Script for Developers and Google Apps Script for Beginners.

Comments