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.
1 |
import { setContext } from 'apollo-link-context' |
then create your link middleware..
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// 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
1 |
getIdToken: () => activeUser ? activeUser.getIdToken() : Promise.resolve(null) |
List of packages you’ll need
1 2 3 4 5 6 |
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
1 2 3 4 |
app.use('/', // this checks the api key is valid and bums out if not mw.registerView() ); |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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.
1 2 3 4 5 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
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
1 |
const verifyToken = (userIDToken) => admin.auth().verifyIdToken(userIDToken); |
Accessing in the query context
1 2 3 4 5 6 7 8 9 |
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
1 2 3 4 5 |
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
1 2 3 4 5 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// 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; } } |
1 |
const spoofUserIDToken = async ({ proxy }) => proxy ? auth.generateIDToken(proxy) : null; |
Generating a custom ID token
1 2 3 4 5 6 7 |
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
1 |
https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyCustomToken?key= |