Ultimate Guide to User Authorization with Identity Platform

michalmoravik

mm

Posted on July 29, 2023

Ultimate Guide to User Authorization with Identity Platform

Recently, I added user authentication and role-based authorization (a.k.a RBAC) to one of my projects so that certain users could be granted the admin role and access our internal tools.

As my servers run on Google Cloud Platform, I decided to go with Google Identity Platform (GIP), which is essentially the same service as Firebase Authentication with Identity Platform (think Firebase Authentication on steroids).

Instead of a proper documentation or coherent guide covering this commonly sought-after flow, Google handed me a mess of concepts and overlapping libraries.

So, I decided to save you some time by providing you the desired (ultimate) guide covering the full user auth/authz flow.

 

Agenda

 

Plan

Full Diagram

 

I’m gonna show you the implementation of the following flow:

  1. User signs up (or in) via the web client
  2. Google Identity Platform (GIP) verifies the request and triggers blocking functions
  3. Blocking functions assign the role (admin or user) based on the email address
  4. If the user is an admin, they’ll be redirected to the Admin page
  5. If an unauthorized user comes straight to the Admin page →
  6. redirect them Home
  7. A request is made against the backend route
  8. The route triggers the Auth middleware
  9. The Auth middleware verifies the user against GIP
  10. If the user exists, the Authz (authorization) middleware is called
  11. If the user’s role is sufficient to continue, the handler is triggered
  12. Handler responds

 

Choices Matter

As I have no clue what tech stack you use, I decided to make several implementations. You’ll be presented with different choices that will affect what you read and better fit your exact case.

Code

Regardless of your choices, all code is available in this repo.

You can choose from React (Next 13) or Vanilla JavaScript for frontend and Go or Node for backend middlewares.

If you experience any issues while following this guide, slide to my DMs on x.com (Twitter) or open a GitHub issue, I’ll help.

 

Set Up

First, enable Identity Platform, and add your first provider.

Provider
A provider is a service, often third-party, that your users can choose to sign up or sign in with. Examples: Google, Email / Password, Apple, …

  1. Go to your GCP console, and open or add a project
  2. Enable the Identity Platform
  3. Click the Providers tab
  4. Click the ADD A PROVIDER button

For this guide, I've created two implementations: Google and Email/Password, to illustrate the flow for the most common cases. If your chosen provider is different, you can still follow along with the Google implementation, as the flow is similar for other providers.

 

Choose the Provider
Sign in with Google
Sign in with Email / Password

 

Sign in with Google

Add Provider

  1. From the dropdown, select Google as the provider
  2. Under Web SDK configuration section, you’ll have to supply the ID and secret
    1. Either click the APIs and services page link or search (by pressing /) for APIs and services
    2. Under Credentials tab, you’ll see OAuth 2.0 Client IDs section. Click on Web client
    3. Find and paste Client ID and secret on the right side
  3. Skip Allowed client IDs
  4. Click CONFIGURE SCREEN button. This will open OAuth consent screen
    1. Under User Type choose External, click CREATE
    2. Fill in User support email
    3. Under Developer contact information, add your email address
    4. You can ignore everything else (incl. the app name). At any point you can come back and edit things
    5. Click SAVE AND CONTINUE
    6. You don’t have to continue; only step #1 was required. When you’re ready for the public release later on, you come back and fill out everything properly. Now, return to the provider setup
  5. Add the domains you'll use when developing your client app to the Authorized Domains section (located on the right side of the page). By default, localhost is already present, but if you use 127.0.0.1 during development, make sure to add it. Additionally, if you already know your public (prod) domain, add it now
  6. Click SAVE. You’ll now see the Google provider enabled

 

Add Client App

Let’s add a simple client so users can sign up and in. Now, this is where the overlapping of Google Cloud Platform and Firebase begins.

Intersection GCP and Firebase

Google Identity Platform (GIP) shares the same backend as Firebase Authentication with Identity Platform.

Since these two are identical products, Google decided not to duplicate themselves in certain places and ask you to use their Firebase client SDK. I've prepared two different implementations for you, one in React (using Next 13) and one in Vanilla JS. Pick one below.

 

Pick Client
React / Next Client
Vanilla JS Client

 

React / Next Client

I'll use React with Next.js 13 as an example here, but you can use the code without Next and achieve the same functionality. In the end, it is just a simple React JavaScript code.

  1. Run npx create-next-app@latest
  2. Install the Firebase client SDK npm i firebase
  3. Add a file firebaseConfig.js under app folder
  4. Go back to the Identity Platform’s dashboard and under Providers tab click APPLICATION SETUP DETAILS Copy apiKey and authDomain

  5. Add the following to your firebaseConfig.js

    import { initializeApp } from 'firebase/app';
    import { getAuth } from 'firebase/auth';
    
    const firebaseConfig = {
      apiKey: '<your apiKey>',
      authDomain: '<your authDomain>',
    }
    
    const app = initializeApp(firebaseConfig);
    export const auth = getAuth(app);
    
  6. Change your page.js to include this function

    🔨 If you get Module not found: Can’t resolve ‘encoding’ … error, run npm i -D encoding

    'use client'; // <--- Next.js 13 Client Component declaration. Don't add if you don't use Next
    import { auth } from './firebaseConfig';
    import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth';
    import { useRouter } from 'next/navigation'; // <--- Next-specific Router. Use your own React Router if you don't use Next
    
    export default function Home() {
      const provider = new GoogleAuthProvider();
      const router = useRouter();
    
        const signInWithGoogle = async () => {
        try {
          await signInWithPopup(auth, provider);
    
          // Print token to test the middlewares later on via HTTP client
          // console.log(await auth.currentUser.getIdToken(true));
    
          const { claims } = await auth.currentUser.getIdTokenResult(true);
          if (claims.role === 'admin') router.push('/admin');
        } catch (error) {
          console.log(error);
        }
      }
    
      return (
        <div>
          <button onClick={signInWithGoogle}>Sign in with Google</button>
        </div>
      )
    }
    
    1. Press the button and you’ll be redirected to the Google popup, where you can choose your account. The user is then created and signed in simultaneously
    2. Completing the sign-up populates auth.currentUser. You can then obtain the current user’s (JWT) token and decode it to retrieve the user’s data, including claims

      Claims
      Claims are simply data about the user. They are relevant to the authorization flow because, during backend authentication, you can set custom claims on the user that can later be retrieved to determine their level of access.

    3. The role in the code snippet above is a custom claim that you'll set later on in this guide. If the user's role claim is set to admin, they will be redirected to the React page dedicated to admins

  7. Create a new directory called admin and a new file named page.js inside it. Then, add the following code

    'use client'; // <--- Next.js 13 Client Component declaration. Don't add if you don't use Next
    import { auth } from '../firebaseConfig';
    import { useRouter } from 'next/navigation';
    import { useEffect } from 'react';
    
    export default function Admin() {
      const router = useRouter();
    
      useEffect(() => {
        auth.onAuthStateChanged(async (user) => {
          if (!user) router.push('/');
    
          const { claims } = await user.getIdTokenResult(true);
          if (claims.role !== 'admin') router.push('/');
        });
      }, []);
    
      return (
        <div>
          <h1>Admin</h1>
        </div>
      )
    }
    

    The moment Firebase Auth triggers the callback function, you simply check if the user exists and if the role claim is set to admin. If not, you want to redirect such a visitor to the Home page, not displaying the admin view. This is mainly used to redirect visitors coming straight to this page without authenticating first.

Now, jump to the section where you’ll discover how to assign roles to your users.

 

Jump to Assign Roles Section

 

Vanilla JS Client

Let’s add a simple client using vanilla JS embedded as a script tag inside HTML

  1. Go back to the Identity Platform’s dashboard and under Providers tab click APPLICATION SETUP DETAILS. Copy apiKey and authDomain
  2. Create a new HTML file called auth-google.html and insert the following scripts just before the closing body tag

    <!-- ... -->
    <body>      
            <!-- ... your other tags should be above ... -->
        <button onclick="signInWithGoogle()">Sign in with Google</button>
    
        <script src="https://www.gstatic.com/firebasejs/8.0/firebase.js"></script>
        <script>
            var config = {
                apiKey: "<your apiKey>",
                authDomain: "<your authDomain>",
            };
            firebase.initializeApp(config);
        </script>
        <script>
            function signInWithGoogle() {
                const provider = new firebase.auth.GoogleAuthProvider();
    
                firebase.auth().signInWithPopup(provider)
                // Print token to test the middlewares later on via HTTP client
                /* .then(() => {
                    firebase.auth().currentUser.getIdToken(true)
                    .then(token => console.log(token))
                }) */
                .then(() =>
                    firebase.auth().currentUser.getIdTokenResult(true)
                    .then(result => {
                        if (result.claims.role === 'admin') {
                            window.location.href = 'auth-google-admin.html';
                        }
                    })
                    .catch(error => console.log(error))
                )
                .catch(error => console.log(error));
            }
        </script>
    </body>
    <!-- ... -->
    
    1. Press the button and you’ll be redirected to the Google popup, where you can choose your account. The user is then created and signed in simultaneously
    2. Completing the sign-up populates auth.currentUser. You can then obtain the current user’s (JWT) token and decode it to retrieve the user’s data (result in the code snippet above), including claims

      Claims
      Claims are just data about the user. They’re relevant to the authorization flow as, while authenticating the user in the backend, you can set custom claims on the user that can later be retrieved to determine the level of access.

    3. The role in the code snippet above is a custom claim that you'll set later on in this guide. If the user's role claim is set to admin, they will be redirected to the page dedicated to admins

  3. In the same directory, create a new file called auth-google-admin.html, and add the following code

    <!-- ... -->
    <body>
        <h1>Admin</h1>
    
        <script src="https://www.gstatic.com/firebasejs/8.0/firebase.js"></script>
        <script>
            var config = {
                apiKey: "<your apiKey>",
                authDomain: "<your authDomain>",
            };
            firebase.initializeApp(config);
        </script>
        <script>
            function checkRole() {
                firebase.auth().onAuthStateChanged(user => {
                    if (!user) {
                        window.location.href = 'auth-google.html';
                    } else {
                        // Print token to test the middlewares later on via HTTP client
                        /* firebase.auth().currentUser.getIdToken(true)
                        .then(token => console.log(token))
                        .catch(error => console.log(error)); */
    
                        user.getIdTokenResult(true)
                        .then(result => {
                            if (result.claims.role !== 'admin') {
                                window.location.href = 'auth-google.html';
                            }
                        })
                        .catch(error => console.log(error));
                    }
                });
            }
            window.onload = checkRole;
        </script>
    </body>
    <!-- ... -->
    

    The moment Firebase Auth triggers the callback function, you simply check if the user exists and if the role claim is set to admin. If not, you want to redirect such a visitor to the auth-google.html page, not displaying the admin view. This is mainly used to redirect visitors coming straight to this page without authenticating first.

Now, jump to the section where you’ll discover how to assign roles to your users.

 

Jump to Assign Roles Section

 

Sign in with Email / Password

Add Provider

  1. From the dropdown, select and enable Email / Password as the provider
  2. Uncheck "Allow passwordless login" as we want to enable regular password login
  3. Ignore the templates as you can tweak their content anytime later on
  4. Add the domains you'll use when developing your client app to the Authorized Domains section (located on the right side of the page). By default, localhost is already present, but if you use 127.0.0.1 during development, make sure to add it. Additionally, if you already know your public (prod) domain, add it now
  5. Click SAVE. You’ll now see the Email / Password provider enabled

 

Add Client App

Let’s add a simple client so users can sign up and in. Now, this is where the overlapping of Google Cloud Platform and Firebase begins.

Intersection GCP and Firebase

Google Identity Platform (GIP) shares the same backend as Firebase Authentication with Identity Platform.

Since these two are identical products, Google decided not to duplicate themselves in certain places and ask you to use their Firebase client SDK. I've prepared two different implementations for you, one in React (using Next 13) and one in Vanilla JS. Pick one below.

 

Pick the Client
React / Next Client
Vanilla JS Client

 

React / Next Client

I'll use React with Next.js 13 as an example here, but you can use the code without Next and achieve the same functionality. In the end, it is just a simple React JavaScript code.

  1. Run npx create-next-app@latest
  2. Install the Firebase client SDK npm i firebase
  3. Add a file firebaseConfig.js under app folder
  4. Go back to the Identity Platform’s dashboard and under Providers tab click APPLICATION SETUP DETAILS. Copy apiKey and authDomain

  5. Add the following to your firebaseConfig.js

    import { initializeApp } from 'firebase/app';
    import { getAuth } from 'firebase/auth';
    
    const firebaseConfig = {
      apiKey: '<your apiKey>',
      authDomain: '<your authDomain>',
    }
    
    const app = initializeApp(firebaseConfig);
    export const auth = getAuth(app);
    
  6. Change your page.js to include this simple function

    🔨 If you get Module not found: Can’t resolve ‘encoding’ … error, run npm i -D encoding

    'use client'; // <--- Next.js 13 Client Component declaration. Don't add if you don't use Next
    import { useState } from 'react';
    import { auth } from './firebaseConfig';
    import {
      createUserWithEmailAndPassword,
      signInWithEmailAndPassword,
      sendEmailVerification,
      signOut
    } from 'firebase/auth';
    import { useRouter } from 'next/navigation'; // <--- Next-specific Router. Use your own React Router if you don't use Next
    
    export default function Home() {
      const router = useRouter();
    
      // ... States omitted (code too long). [Click here for full code](https://github.com/MichalMoravik/google-identity-guide/blob/main/client-react-next/auth-email/app/page.js)
    
      const registerWithEmail = async (email, password) => {
        try {
          const cred = await createUserWithEmailAndPassword(auth, email, password);
    
                // Change the redirect URL to env var if you want
          await sendEmailVerification(cred.user, { url: 'http://localhost:3000' });
          setMsg('Please verify your email before signing in');
    
          await signOut(auth);
        } catch (err) {
          console.log('Unexpected error: ', err);
        }
      }
    
      const signInWithEmail = async (email, password) => {
        try {
          await signInWithEmailAndPassword(auth, email, password);
    
          // Print token to test the middlewares later on via HTTP client
          // console.log(await auth.currentUser.getIdToken(true));
    
          const { claims } = await auth.currentUser.getIdTokenResult(true);
    
          if (!claims.email_verified) {
                    // Change the redirect URL to env var if you want
            await sendEmailVerification(auth.currentUser, { url: 'http://localhost:3000' });
            setMsg('Please verify your email before signing in. We have sent you another verification email');
            await signOut(auth);
            return;
          }
    
          console.log('Signed in as: ', claims.email);
    
          if (claims.role === 'admin') router.push('/admin');
        } catch (err) {
          console.log('Unexpected error: ', err);
        }
      }
    
      return (
        <div>
            { //... HTML tags omitted (code too long). [Click here for full code](https://github.com/MichalMoravik/google-identity-guide/blob/main/client-react-next/auth-email/app/page.js) }
        </div>
      )
    }
    

    registerWithEmail

    1. When createUserWithEmailAndPassword() is executed, the user is created and signed in simultaneously
    2. Right after that, sendEmailVerification() sends an email to verify their address. You can alter its content by editing the provider settings

      Sending Verification Email before First Sign-in
      You might ask, “Can I send the verification email before the user is first signed in?” The answer is no. What I present above is the only way to achieve this result. I elaborated on this problem in this StackOverflow question.

    3. Often, you want the user to verify their email address before signing them in. Since they are already signed in (due to registration in step #1), you must sign them out

    💡 TIP: In the future, you might want to consider adding reCAPTCHA

    signInWithEmail

    1. Press the button and your user will be logged in. Completing the sign-up populates auth.currentUser
    2. You can then obtain the current user’s (JWT) token and decode it using getIdTokenResult() to retrieve the user’s data, including claims

      Claims
      Claims are simply data about the user. They are relevant to the authorization flow because, during backend authentication, you can set custom claims on the user that can later be retrieved to determine their level of access.

    3. In case the user has not verified their address, you send them the email again and let them know they have to verify it first

      📕 Note
      Anyone can modify your client code, so checking if the address has been verified doesn’t provide any security at this level; the client implementation has solely the UX purpose. You’re going to protect your data in the latter section (Protect Data).

    4. Lastly, you check if the user’s role is set to admin. This role is a custom claim that you’ll set later on in this guide. If the user’s role is admin, they’ll be redirected to the React page dedicated to admins

  7. Create a new directory called admin and a new file named page.js inside it. Then, add the following code

    'use client'; // <--- Next.js 13 Client Component declaration. Don't add if you don't use Next
    import { auth } from '../firebaseConfig';
    import { useRouter } from 'next/navigation';
    import { useEffect } from 'react';
    
    export default function Admin() {
      const router = useRouter();
    
      useEffect(() => {
        auth.onAuthStateChanged(async (user) => {
          if (!user) router.push('/');
    
          const { claims } = await user.getIdTokenResult(true);
          if (claims.role !== 'admin') router.push('/');
        });
      }, []);
    
      return (
        <div>
          <h1>Admin</h1>
        </div>
      )
    }
    

    The moment Firebase Auth triggers the callback function, you simply check if the user exists and if the role claim is set to admin. If not, you want to redirect such a visitor to the Home page, not displaying the admin view. This is mainly used to redirect visitors coming straight to this page without authenticating first.

Now, jump to the section where you’ll discover how to assign roles to your users.

 

Jump to Assign Roles Section

 

Vanilla JS Client

Let’s add a simple client using vanilla JS embedded as a script tag inside HTML

  1. Go back to the Identity Platform’s dashboard and under Providers tab click APPLICATION SETUP DETAILS. Copy apiKey and authDomain

  2. Create a new HTML file called auth-email.html and insert the following scripts just before the closing body tag

    <!-- ... -->
    <body>      
            <!-- ... HTML tags omitted (code too long). [Click here for full code](https://github.com/MichalMoravik/google-identity-guide/blob/main/client-js/auth-email/auth-email.html) ... -->
    
        <script src="https://www.gstatic.com/firebasejs/8.0/firebase.js"></script>
        <script>
          var config = {
            apiKey: "<your apiKey>",
            authDomain: "<your authDomain>",
          };
          firebase.initializeApp(config);
        </script>
        <script>
            // ... Listeners omitted (code too long). [Click here for full code](https://github.com/MichalMoravik/google-identity-guide/blob/main/client-js/auth-email/auth-email.html)
    
            function registerWithEmail(email, password) {
                firebase.auth().createUserWithEmailAndPassword(email, password)
                .then(cred => {
                                    // Change the redirect URL to env var if you want
                    return cred.user.sendEmailVerification({
                        url: 'http://127.0.0.1:8080/auth-email.html'
                    })
                })
                .then(() => {
                    const msgTag = document.querySelector('#msg');
                    msgTag.innerHTML = `Please verify your email before signing in`;
    
                    return firebase.auth().signOut()
                })
                .catch(err => console.log('Unexpected error: ', err));
            }
    
            function signInWithEmail(email, password) {
                firebase.auth().signInWithEmailAndPassword(email, password)
                // Print token to test the middlewares later on via HTTP client
                /*.then(() => {
                    firebase.auth().currentUser.getIdToken(true)
                    .then(token => console.log(token))
                })*/
                .then(() => firebase.auth().currentUser.getIdTokenResult(true))
                .then(result => {
                    if (!result.claims.email_verified) {
                        return firebase.auth().currentUser.sendEmailVerification({
                                                    // Change the redirect URL to env var if you want
                            url: 'http://127.0.0.1:8080/auth-email.html'
                        })
                        .then(() => {
                            const msgTag = document.querySelector('#msg');
                            msgTag.innerHTML = `Please verify your email before
                            signing in. We have sent you another verification email`;
    
                            return firebase.auth().signOut()
                        })
                    }
    
                    console.log('Signed in as: ', result.claims.email);
    
                    if (result.claims.role === 'admin') {
                        window.location.href = 'auth-email-admin.html';
                    }
                })
                .catch(err => console.log('Unexpected error: ', err));
            }
        </script>
    </body>
    <!-- ... -->
    

    registerWithEmail

    1. When createUserWithEmailAndPassword() is executed, the user is created and signed in simultaneously
    2. Right after that, sendEmailVerification() sends an email to verify their address. You can alter its content by editing the provider settings

      Sending Verification Email before First Sign-in
      You might ask, “Can I send the verification email before the user is first signed in?” The answer is no. What I present above is the only way to achieve this result. I elaborated on this problem in this StackOverflow question.

    3. Often, you want the user to verify their email address before signing them in. Since they are already signed in (due to registration in step #1), you must sign them out

    💡 TIP: In the future, you might want to consider adding reCAPTCHA

    signInWithEmail

    1. Press the button and your user will be logged in. Completing the sign-up populates auth.currentUser
    2. You can then obtain the current user’s (JWT) token and decode it using getIdTokenResult() to retrieve the user’s data, including claims

      Claims
      Claims are simply data about the user. They are relevant to the authorization flow because, during backend authentication, you can set custom claims on the user that can later be retrieved to determine their level of access.

    3. In case the user has not verified their address, you send them the email again and let them know they have to verify it first

      📕 Note
      Anyone can modify your client code, so checking if the address has been verified doesn’t provide any security at this level; the client implementation has solely the UX purpose. You’re going to protect your data in the latter section (Protect Data).

    4. Lastly, you check if the user’s role is set to admin. This role is a custom claim that you’ll set later on in this guide. If the user’s role is admin, they’ll be redirected to the React page dedicated to admins

  3. In the same directory, create a new file called auth-email-admin.html, and add the following code

    <!-- ... -->
    <body>
        <h1>Admin</h1>
    
        <script src="https://www.gstatic.com/firebasejs/8.0/firebase.js"></script>
        <script>
            var config = {
                apiKey: '<your apiKey>',
                authDomain: '<your authDomain>',
            };
            firebase.initializeApp(config);
        </script>
        <script>
            function checkRole() {
                firebase.auth().onAuthStateChanged(user => {
                    if (!user) {
                        window.location.href = 'auth-email.html';
                    } else {
                        // Print token to test the middlewares later on via HTTP client
                        /* firebase.auth().currentUser.getIdToken(true)
                        .then(token => console.log(token))
                        .catch(error => console.log(error)); */
    
                        user.getIdTokenResult(true)
                        .then(result => {
                            if (result.claims.role !== 'admin') {
                                window.location.href = 'auth-email.html';
                            }
                        })
                        .catch(error => console.log(error));
                    }
                });
            }
            window.onload = checkRole;
        </script>
    </body>
    <!-- ... -->
    

    The moment Firebase Auth triggers the callback function, you simply check if the user exists and if the role claim is set to admin. If not, you want to redirect such a visitor to the auth-email.html page, not displaying the admin view. This is mainly used to redirect visitors coming straight to this page without authenticating first.

In the next section, you’ll discover how to assign roles to your users.

 

Assign Roles

Blocking Functions

The diagram above illustrates the parts you've covered so far.

Your focus now is on assigning a custom role claim to users so you could control who has access to what data and services.

Obviously, the assignment has to be done in a secure environment (backend) to ensure that only you are able to manage the logic.

The way to implement this is by using triggers. When a certain event happens in GIP, a serverless function gets triggered and executes the logic of assigning the claim.

Originally, there used to be only one trigger useful in this situation - onCreate. This is still available (only v1) for the basic Firebase Authentication (legacy). However, the feature is limiting as it is triggered only after the new user is created. The main implication is that you cannot execute custom logic before a visitor signs up or signs in (e.g. you’re unable to restrict certain visitors from creating an account).

Due to the limitations, Google released Blocking Functions

 

Add Blocking Functions

Later on, Google added two new triggers - beforeCreate and beforeSignIn. These are used in combination with serverless Cloud Functions that can execute any custom logic before the user is created or signed in. They call them Blocking Functions because they can block visitors from creating an account or signing in based on conditions you specify within the functions’ body.

Google lets you choose from two distinct libraries to implement the identical function behaviour. One can be found in the GIP docs, while the other one is in the Firebase docs. The only difference is that the Firebase library recently introduced a Python version (currently in preview) alongside its Node counterpart, whereas the GIP library currently supports only the Node implementation. I chose the one recommended by the GIP docs.

I don’t use the inline editor in GCP for many reasons (no git, tracking, …), so the following are the steps I took to develop and deploy the beforeSignIn function from my machine.

Implement

  1. Run npm init
  2. Run npm i gcip-cloud-functions
  3. I prefer ES modules so I added "type": "module" to my package.json configuration

  4. Add a new file index.js with the following code

    import gcipCloudFunctions from 'gcip-cloud-functions';
    import { beforeSignInHandler } from './handlers.js';
    
    const adminEmails = process.env.ADMIN_EMAILS.split(',') || [];
    const authClient = new gcipCloudFunctions.Auth();
    
    export const beforeSignIn = authClient.functions()
      .beforeSignInHandler((user, context) => beforeSignInHandler(user, context, adminEmails));
    

    You can specify the admin email addresses using the environment variable, which will be passed to the beforeSignInHandler function.

  5. Add a new file handlers.js with the following code

    export const beforeSignInHandler = (user, context, adminEmails) => {
      const role = adminEmails.includes(user.email) ? 'admin' : 'user';
    
      console.log(`Signing in user: ${user.email} with role: ${role}`);
      return {
        customClaims: { role },
      }
    };
    

    You can modify the user data by simply returning an object from the handler function. In the code above, we add a new custom role claim that can be set to either admin or user.

    I chose to use beforeSignIn trigger instead of the beforeCreate trigger to assign the role claim, as I want to have the ability to change the user’s access with each sign-in. GCP’s free tier allows me to ignore the resources here, but consider your own use case.

🔨 See Full Code in GitHub Repo
There are more files, mostly related to unit tests. The babel package and .babelrc file are included to enable running Jest unit tests using ES modules.

Deploy

For deploying the function, I recommend using the gcloud CLI and executing commands from your terminal or CI tool.

  1. Run gcloud config set project <your project id> to set the right project

  2. Below is the command I use to deploy my function. Alter the flags for your case, especially the admin emails in the env variable (separate them using comma)

    gcloud functions deploy beforeSignIn --runtime nodejs20 --trigger-http --allow-unauthenticated --region=europe-central2 --set-env-vars ADMIN_EMAILS='m@gmail.com'
    

For convenience, I suggest adding the command to scripts in the package.json file.

 

Set Triggers

After you deploy the function, you must hook this function up with the GIP beforeSignIn trigger.

  1. Go to GCP Console (UI) → Identity Platform → Settings → Triggers
  2. Hook up the function you just deployed to beforeSignIn and SAVE

 

Optional: Restrict Registration

While I was writing the guide, I realized the function’s logic is so short that it doesn’t even reflect the useful blocking ability of the blocking functions. So I decided to showcase the blocking in action by adding this bonus whitelisting feature, allowing only specific emails to register.

Now, you can choose anything as the whitelisting condition. Google supplies user and context arguments to your function that include handy data (e.g. user’s IP address, provider, email, …). The code below shows whitelisting based on the email address.

  1. Create a new handler

    import gcipCloudFunctions from 'gcip-cloud-functions';
    
    export const restrictRegistration = (user, context, whitelistEmails) => {
      console.log('New request to register user:', user.email);
      console.log('User Object:', user);
      console.log('Context Object:', context);
    
      if (!whitelistEmails.includes(user.email)) {
        console.log(`Unauthorized email "${user.email}"`);
        throw new gcipCloudFunctions.https.HttpsError(
          'permission-denied',
          `Unauthorized email "${user.email}"`
        );
      }
    
      console.log(`User with email: ${user.email} is authorized to register`);
    };
    
  2. In index.js, import the handler and export the function

    const whitelistEmails = process.env.WHITELIST_EMAILS.split(',') || [];
    
    export const beforeCreate = authClient.functions()
      .beforeCreateHandler((user, context) => restrictRegistration(user, context, whitelistEmails));
    
  3. Change WHITELIST_EMAILS env var and deploy the function as beforeCreate

    gcloud functions deploy beforeCreate --runtime nodejs20 --trigger-http --allow-unauthenticated --region=europe-central2 --set-env-vars WHITELIST_EMAILS='m@gmail.com,k@gmail.com'
    
  4. Set the beforeCreate trigger in Identity Platform’s settings

  5. Handle the permission-denied error in the client

    // or try-catch
    .catch(err => {
        if (err.code !== 'auth/internal-error') {
            console.log('Unexpected error: ', err);
        } else {
            const regStatus = err.message.match(/"status":"(.*?)"/);
            const status = regStatus ? regStatus[1] : '';
    
            if (status === 'PERMISSION_DENIED') {
                            // handle the returned error (e.g. display message)
            } else {
                    console.log('Unhandled blocking error: ', err);
            }
        }
    });
    

 

Test Client Flow

Client using Blocking Functions

The diagram illustrates the current state of your implementation.

At this point, when signing in from your client using the admin email address, you’ll get redirected to the Admin page. Signing in using a non-admin email address will not trigger the redirection.

📕 Note
Anyone can modify your client code, so the client-side redirection doesn’t provide any security; the client implementation serves solely for UX purposes. You’ll protect your data in the next section (Protect Data).

If you experience different behaviour, debug the client and inspect the Cloud Function logs in GCP, or slide to my DMs on x.com (Twitter).

 

Protect Data

The diagram you saw in the previous section contains a greyed-out part. That is the part you'll focus on next, with the goal of protecting your data against unauthorized users.

Once your user signs in on the client, the (JWT) token included in the response gets stored locally in their browser. The token has to be sent whenever you make a request to the backend to verify its validity and extract the user data.

To make your life easier, whenever you use Firebase services such as Firestore, Realtime DB, or Storage, the token will be automatically sent with every request. If you use anything else, you'll have to include the token with the request.

The following sections will cover both cases, but feel free to jump straight to middlewares if you don’t plan to use any Firebase products.

 

Control Access in Firebase

In this case, when the sent token arrives, it gets automatically decoded by Firebase without you having to do anything. Then, the request proceeds to Firebase Rules where you specify conditions that have to be met in order for the user to access the data.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    function verifiedAdmin() {
        return request.auth != null && request.auth.token.email_verified && request.auth.token.role == "admin";
    }

    match /admin/{document=**} {
        // only allow to read the admin content if claim "role" is set "admin"
      allow read: if verifiedAdmin();
      allow write: if false;
    }
    match /{document=**} {
      allow read: if false;
      allow write: if false;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The Firebase Rules example above showcases how you could make the data inside the admin collection only accessible for reading if the user is a verified admin (see the verifiedAdmin() function). In the rules, the custom claims are accessible under request.auth.token.

The condition of email_verified to be true is only needed for untrusted providers, but I suggest you keep the condition as a precaution.

 

Control Access using Middlewares

In this case, you’ll protect route handlers in your REST API using authentication (Auth) and authorization (AuthZ) middlewares. See the steps #7 to #12 in the diagram below.

Middlewares

First, I’ll first walk you through the implementation of the middlewares, and then, at the end, I’ll show you how you can test their functionality using a real (JWT) token.

I’ve created two logically identical implementations, one in Go (v1.20), and the other in Node (v20). Yes, the versions are very fresh, you’re welcome.

The full code (including unit tests, docker files, etc.) for both implementations is available in the repo.

In this guide, I’ll show you only the relevant code parts, but at the end, I’ll explain how to run and deploy such services.

 

Choose your Backend
Go Middlewares
Node Middlewares

 

Go Middlewares

Set Up

To make my life easier, I added Fiber, a popular lightweight framework. Regardless of which package you use, the process and most of the code will remain unchanged.

Next, you’ll need to get the Firebase Admin SDK - go get firebase.google.com/go.

Firebase Admin SDK
You’ll use this SDK anytime you want your backend to communicate with Google Identity Platform. Again, in this case, Google decided to reuse the already developed Firebase library rather than making a new one specifically for Identity Platform.

Implement

  1. Let the SDK know what project you are using by setting up Firebase App. Then initialize the Auth Client

    package config
    
    import (
        "context"
        "os"
        "log"
    
        firebase "firebase.google.com/go"
        "firebase.google.com/go/auth"
    )
    
    func InitFirebase() *firebase.App {
        conf := &firebase.Config{ProjectID: os.Getenv("GCP_PROJECT_ID")}
        app, err := firebase.NewApp(context.Background(), conf)
        if err != nil {
            log.Printf("error initializing app: %v\n", err)
            return nil
        }
    
        return app
    }
    
    func InitFirebaseAuth(app *firebase.App) *auth.Client {
        client, err := app.Auth(context.Background())
        if err != nil {
            log.Printf("error initializing firebase auth client: %v\n", err)
            return nil
        }
    
        return client
    }
    

    In some cases, you might not have to specify ProjectID, but I strongly recommend you do to avoid inter-project accidents.

  2. Next, add the Auth middleware as shown below

    package middlewares
    
    import (
        "log"
        "strings"
        "context"
    
        m "middleware-go/src/models"
    
        "github.com/gofiber/fiber/v2"
        "firebase.google.com/go/auth"
    )
    
    type AuthClient interface {
        VerifyIDToken(ctx context.Context, idToken string) (*auth.Token, error)
    }
    
    func UseAuth(authClient AuthClient) fiber.Handler {
        return func(c *fiber.Ctx) error {
            log.Println("In Auth middleware")
    
            authHeader := c.Get("Authorization")
            if authHeader == "" {
                log.Println("Missing Authorization header")
                return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
                    "message": "Missing Authorization header",
                })
            }
    
            // Bearer token split to get the token without "Bearer" in front
            val := strings.Split(authHeader, " ")
            if len(val) != 2 {
                log.Println("Invalid Authorization header")
                return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
                    "message": "Invalid Authorization header",
                })
            }
            token := val[1]
    
            decodedToken, err := authClient.VerifyIDToken(c.Context(), token)
            if err != nil {
                log.Printf("Error verifying token. Error: %v\n", err)
                return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
                    "message": "Invalid authentication token",
                })
            }
    
            // Only needed for untrusted providers (e.g. email/password)
            // But I suggest verifying it for all providers
            if decodedToken.Claims["email_verified"] != true {
                log.Println("Email not verified")
                return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
                    "message": "Email not verified",
                })
            }
    
            if _, ok := decodedToken.Claims["role"]; !ok {
                log.Println("Role not present in token")
                return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
                    "message": "Role not present in token",
                })
            }
    
            user := &m.User{
                UID: decodedToken.UID,
                Email: decodedToken.Claims["email"].(string),
                Role: decodedToken.Claims["role"].(string),
            }
            c.Locals("user", user)
    
            log.Println("Successfully authenticated")
            log.Printf("Email: %v\n", user.Email)
            log.Printf("Role: %v\n", user.Role)
    
            return c.Next()
        }
    }
    
    1. Extract the token from the Authorization header
    2. Pass the token to the authClient.VerifyIDToken() interface method for verification (AuthClient will shortly be substituted by Firebase’s Auth Client)
    3. The user data, including the role claim, are stored in the Locals for the next middleware or handler to use
  3. Add the AuthZ (authorization) middleware

    package middlewares
    
    import (
        "log"
    
        m "middleware-go/src/models"
    
        "github.com/gofiber/fiber/v2"
    )
    
    func UseAuthZ(requiredRole string) fiber.Handler {
        return func(c *fiber.Ctx) error {
            log.Println("In AuthZ middleware")
    
            user, ok := c.Locals("user").(*m.User)
            if !ok {
                log.Println("User not found in context")
                return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
                    "message": "Unauthorized",
                })
            }
    
            if user.Role == "" {
                log.Println("User role not set")
                return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
                    "message": "Unauthorized",
                })
            }
    
            if user.Role != requiredRole {
                log.Printf("User with email %s and role %s tried to access " +
                    "a route that was for the %s role only",
                    user.Email, user.Role, requiredRole)
                return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
                    "message": "Forbidden",
                })
            }
    
            log.Printf("User with email %s and role %s authorized",
                user.Email, user.Role)
    
            return c.Next()
        }
    }
    

    The if user.Role != requiredRole implementation is quite primitive. It is up to you to define the logical authorization rules at this point (e.g. you might want to allow the admin to access any resource).

  4. Lastly, we have to put the flow together in the main.go

    package main
    
    import (
        "os"
        "log"
    
        c "middleware-go/src/config"
        mw "middleware-go/src/middlewares"
        m "middleware-go/src/models"
    
        "github.com/gofiber/fiber/v2"
    )
    
    func userHandler(c *fiber.Ctx) error {
        user := c.Locals("user").(*m.User)
        return c.JSON(user)
    }
    
    func main() {
        app := fiber.New()
    
        firebaseApp := c.InitFirebase()
        authClient := c.InitFirebaseAuth(firebaseApp)
    
        // only authenticated admins can access this route
        app.Get("/user", mw.UseAuth(authClient), mw.UseAuthZ("admin"), userHandler)
    
        log.Fatal(app.Listen(":" + os.Getenv("PORT")))
    }
    
    1. Initialize the Firebase auth client at this level
    2. Pass the client to the Auth middleware
    3. Specify the role you want your AuthZ middleware to allow for this route
    4. If the entire flow passes (the user is a verified admin), execute the handler logic

Run Locally

God bless Docker, because with docker-compose, you simply:

  1. pull the repo
  2. cd middleware-go
  3. change the environment variables in the docker-compose.yml file

    The GOOGLE_APPLICATION_CREDENTIALS have to be mapped to the container. There is a quite good Google docs page explaining how to obtain the file. I use the default credentials to avoid hassle.

  4. run docker-compose up, and the service should be up and running, ready to receive requests

It is 2023; I’m not gonna explain any other way of running this.

Test with Token

  1. To get the token, go to your Client code and search for commented-out lines in React / Next client → app/page.js in Vanilla JS → both files
  2. Uncomment the lines and sign in (try both - admin and non-admin accounts)
  3. Copy the token printed in the browser console
  4. Open HTTP client (curl, Postman, Insomnia, …)
  5. Set Authorization header with the value Bearer
  6. Send a GET request to http://127.0.0.1:8089/user
  7. When testing with the admin token, you should see the user data in the response

Deploy

The quickest way is to deploy to Cloud Run. The service will use Dockerfile to build the production image. You can even omit the GOOGLE_APPLICATION_CREDENTIALS env var as these are in GCP’s projects by default.

 

Jump to Wrap up Section

 

Node Middlewares

Set Up

Install Express and Firebase Admin SDK (npm i firebase-admin).

Firebase Admin SDK
You’ll use this SDK anytime you want your backend to communicate with Google Identity Platform. Again, in this case, Google decided to reuse the already developed Firebase library rather than making a new one specifically for Identity Platform.

Implement

  1. Let the SDK know what project you are using by setting up Firebase App. Then export the Auth Client

    import admin from 'firebase-admin';
    
    admin.initializeApp({
      credential: admin.credential.applicationDefault(),
      projectId: process.env.GCP_PROJECT_ID,
    });
    
    const authClient = admin.auth();
    
    export { authClient };
    

    In some cases, you might not have to specify ProjectID, but I strongly recommend you do to avoid inter-project accidents.

  2. Next, add the Auth middleware as shown below

    function useAuth(authClient) {
      return async function(req, res, next) {
        console.log('In Auth middleware');
    
        const authHeader = req.headers.authorization;
        if (!authHeader) {
          console.log('Missing Authorization header');
          return res.status(400).json({ message: 'Missing Authorization header' });
        }
    
        const [bearer, token] = authHeader.split(' ');
        if (bearer !== 'Bearer' || !token) {
          console.log('Invalid Authorization header');
          return res.status(400).json({ message: 'Invalid Authorization header' });
        }
    
        let decodedToken;
        try {
          decodedToken = await authClient.verifyIdToken(token);
        } catch (error) {
          console.log(`Error verifying token. Error: ${error}`);
          return res.status(401).json({ message: 'Invalid authentication token' });
        }
    
        // Only needed for untrusted providers (e.g. email/password)
        // But I suggest verifying it for all providers
        if (!decodedToken.email_verified) {
          console.log('Email not verified');
          return res.status(401).json({ message: 'Email not verified' });
        }
    
        if (!decodedToken.role) {
          console.log('Role not present in token');
          return res.status(401).json({ message: 'Role not present in token' });
        }
    
        const user = {
          uid: decodedToken.uid,
          email: decodedToken.email,
          role: decodedToken.role,
        };
        res.locals.user = user;
    
        console.log('Successfully authenticated');
        console.log(`Email: ${user.email}`);
        console.log(`Role: ${user.role}`);
    
        next();
      };
    }
    
    export { useAuth };
    
    1. Extract the token from the Authorization header
    2. Pass the token to the authClient.verifyIdToken() function for verification
    3. The decoded user data, including the role claim, are stored in the locals for the next middleware or handler to use
  3. Add the AuthZ (authorization) middleware

    function useAuthZ(requiredRole) {
      return function(req, res, next) {
        console.log('In AuthZ middleware');
    
        const user = res.locals.user;
        if (!user) {
          console.log('User not found in context');
          return res.status(401).json({ message: 'Unauthorized' });
        }
    
        if (!user.role) {
          console.log('User role not set');
          return res.status(401).json({ message: 'Unauthorized' });
        }
    
        if (user.role !== requiredRole) {
          console.log(`User with email ${user.email} and role ${user.role} tried to access a route that was for the ${requiredRole} role only`);
          return res.status(403).json({ message: 'Forbidden' });
        }
    
        console.log(`User with email ${user.email} and role ${user.role} authorized`);
    
        next();
      };
    }
    
    export { useAuthZ };
    

    The if (user.role !== requiredRole) implementation is quite primitive. It is up to you to define the logical authorization rules at this point (e.g. you might want to allow the admin to access any resource).

  4. Lastly, we have to put the flow together in the index.js

    import express from 'express';
    import { authClient } from './firebase.js';
    import { useAuth } from './auth.js';
    import { useAuthZ } from './authz.js';
    
    const app = express();
    
    app.get('/user', useAuth(authClient), useAuthZ('admin'), (req, res) => {
      const user = res.locals.user;
      res.json(user);
    });
    
    const port = process.env.PORT;
    app.listen(port, () => {
      console.log(`Server is running on port ${port}`);
    });
    
    1. Pass the client to the Auth middleware
    2. Specify the role you want your AuthZ middleware to allow for this route
    3. If the entire flow passes (the user is a verified admin), execute the handler logic

Run Locally

God bless Docker, because with docker-compose, you simply:

  1. pull the repo
  2. cd middleware-node
  3. change the environment variables in the docker-compose.yml file

    The GOOGLE_APPLICATION_CREDENTIALS have to be mapped to the container. There is a quite good Google docs page explaining how to obtain the file. I use the default credentials to avoid hassle.

  4. run docker-compose up, and the service should be up and running, ready to receive requests

It is 2023; I’m not gonna explain any other way of running this.

Test with Token

  1. To get the token, go to your Client code and search for commented-out lines in React / Next client → app/page.js in Vanilla JS → both files
  2. Uncomment the lines and sign in (try both - admin and non-admin accounts)
  3. Copy the token printed in the browser console
  4. Use HTTP client (curl, Postman, Insomnia, …)
  5. Set Authorization header with the value Bearer
  6. Send a GET request to http://localhost:8088/user
  7. When testing with the admin’s token, you’ll see the user data in the response

Deploy

The quickest way is to deploy to Cloud Run. The service will use Dockerfile to build the production image. You can even omit GOOGLE_APPLICATION_CREDENTIALS env var as these are in GCP’s projects by default.

 

Wrap Up

Full Flow Diagram

You have now the full overview of the flow Google recommends using whenever you want to control access using Identity Platform.

My plan for 2023 is to build projects in public and write articles. If you want to see my next moves, follow me on x.com (Twitter).

💖 💪 🙅 🚩
michalmoravik
mm

Posted on July 29, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related