Firebase client side auth
- 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.
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
getIdToken: () => activeUser ? activeUser.getIdToken() : Promise.resolve(null)
List of packages you’ll need
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'
Handling auth server side
Express middleware
app.use('/', // this checks the api key is valid and bums out if not mw.registerView() );
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
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
const verifyToken = (userIDToken) => admin.auth().verifyIdToken(userIDToken);
Accessing in the query context
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
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
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; } }
const spoofUserIDToken = async ({ proxy }) => proxy ? auth.generateIDToken(proxy) : null;
Generating a custom ID token
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=