Dgraph Firebase Authentication - Role Based Access Control

jdgamble555

Jonathan Gamble

Posted on June 4, 2021

Dgraph Firebase Authentication - Role Based Access Control

Note: Since version 21.03 of Dgraph, you can login with Firebase and you don't need a firebase function!

There is no official documentation on using Firebase with Dgraph, so I figure I could help on this. I have been using Firebase for years, and personally spent hours getting it to work with Dgraph. I will also try to answer some common questions that I had, and be as thorough as possible. There will be the custom claims version for advanced users, and standard claims if you just want to get going.

You will need a Firebase Project regardless.

Custom Claims Version (with Firebase Functions)


Step 1 - Create a Firebase Project

https://firebase.google.com/

Very self explanatory. Firebase is a set of products. Firebase Realtime Database and Firestore are not used by default unless you set them up. Obviously, we are using Dgraph instead as the database.

Step 2 - Edit your Dgraph Schema, add Firebase Project ID

Create an account at cloud.dgraph.com, and add this at the bottom of your schema with your Firebase Project ID:

# Dgraph.Authorization {"Header":"X-Auth-Token","Namespace":"https://dgraph.io/jwt/claims","JWKURL":"https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com","Audience":["YOUR_PROJECT_ID"]}
Enter fullscreen mode Exit fullscreen mode

You technically don't need a namespace, but you may decide you prefer your own custom claims later.

Step 3 - Create Firebase Function for Custom Claims

Remeber that you don't actually need to create a firebase function to use RBAC. This is just in case you want to use custom claims. Everything you need is already in standard claims.

Go to your project root...

a) - install firebase cli

npm i -g firebase-tools

b) - setup firebase

  • firebase init
  • just select Functions and continue... (If you get an issue, run firebase use --add and select your project.)
  • select typescript if you want and install dependencies, but skip eslint as I have problems with it working as expected sometimes

c) - create function

  • cd functions
  • navigate to functions/src/index.ts

Create a config.ts file with the following in your functions folder:

export const config = {
  firebase: {
    ... firbase key information
  },
  uri: 'YOUR DGRAPH GRAPHQL URL',
  admin_email: 'YOUR EMAIL'
};
Enter fullscreen mode Exit fullscreen mode

Edit index.ts to this:

import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import firebase from "firebase/app";
import "firebase/auth";
import fetch from 'node-fetch';
import { config } from "./config";

admin.initializeApp();
firebase.initializeApp(config.firebase);

exports.addUser = functions.auth
  .user()
  .onCreate(async (user: admin.auth.UserRecord) => {
    const claims = {
      "https://dgraph.io/jwt/claims": {
        "EMAIL": user.email,
        "ROLE": user.email === config.admin_email ? 'ADMIN' : 'USER'
      }
    };
    return admin
      .auth()
      .setCustomUserClaims(user.uid, claims)
      // create user in dgraph
      .then(async () => {

        // get firebase token
        const token: any = await admin.auth().createCustomToken(user.uid, claims)
          .then((customToken: string) =>
            // use temp custom token to get firebase token
            firebase.auth().signInWithCustomToken(customToken)
              .then((cred: firebase.auth.UserCredential) => cred.user?.getIdToken()))
          .catch((e: string) => console.error(e));

        // add the user to dgraph
        return await fetch('http://' + config.uri, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'X-Auth-Token': token
          },
          body: JSON.stringify({
            query: `mutation addUser($user: AddUserInput!) {
              addUser(input: [$user]) {
                user {
                  email
                  displayName
                  createdAt
                }
              }
            }`,
            variables: {
              user: {
                email: user.email,
                displayName: user.displayName,
                createdAt: new Date().toISOString()
              }
            }
          })
        })
          .then((r) => r.json())
          .then((r) => console.log(JSON.stringify(r)))
          .catch((e: string) => console.error(e));
      });
  });
Enter fullscreen mode Exit fullscreen mode

This function with automatically create a custom claim when a user is created. This custom claim persists forever. It will give your personal email the ADMIN role on create. This will guarantee data consistency in your database.

Remember to edit the mutation depending on your user schema.

Note: You have to import firebase as well as firebase-admin in order to load the user data from the custom token to a firebase token. You could technically do this by the REST api if you don't want to load firebase.

d) - deploy the function

firebase deploy

Note: You should be able to see your live function in the firebase console under Functions. If you have issues, click the logs tab and select your function name.

Step 4 - Logging in the User

There are many different ways to login the user depending on your framework. Youtube has thousands of videos to get you started. For some basics:

This Repository or This One

Firebase can do regular login, google, etc. This should get you started as well.

https://fireship.io/lessons/angularfire-google-oauth/

Tip: You can use pipe operator to merge your login state with your User Type in the database...

Step 5 - Dealing with the token

While you may call dgraph graphql from Apollo, URQL, or a simple fetch, you must post the token as X-Auth-Token=token info in your header.

A few things:

  • All Firebase Token's expire after 1 hour, but user sessions persist
  • You don't have to refresh the token at that point, as the session will automatically do this.
  • When you first login, the custom claim will not yet be present. My code below simply checks for the custom claim, and automatically refreshes when necessary.
  async getToken(): Promise<any> {
    return await new Promise((resolve: any, reject: any) =>
      this.afa.onAuthStateChanged((user: firebase.User | null) => {
        if (user) {
          user?.getIdTokenResult()
            .then(async (r: firebase.auth.IdTokenResult) => {
              const token = (r.claims["https://dgraph.io/jwt/claims"])
                ? r.token
                : await user.getIdToken(true);
              resolve(token);
            }, (e: any) => reject(e));
        }
      })
    );
  }
Enter fullscreen mode Exit fullscreen mode
  • The firebase.auth() on onAuthStateChanged object will be different, depending on your framework. This checks for changes in the user object (logged in, new token, etc).

Remember, we need a promise since we are getting this data for graphql.

You need to attach this token to every query or mutation sent to graphql. This means, you must have an async header, or call localstorage. Here is an URQL and Svelte example. URQL is recommended over Apollo as it is faster in all cases, and handles caching better. React should be similar to this. Every step you add makes your configuration more complicated (async, subscriptions, ssr, etc), so this is the extreme case.

Step 6 - Changing the Role: Custom Claims

If you want to be able to edit a user's role, you could create a callable function like this:

exports.changeRole = functions.https
  .onCall(async (data: any, context: functions.https.CallableContext) => {

    const userId = data.userId;
    const newRole = data.role;

    // get logged in user
    const currentUser = await admin.auth().getUser(context.auth?.uid as string);
    const currentClaims: any = currentUser.customClaims;

    // user to edit
    const editUser = await admin.auth().getUser(userId);
    const editClaims: any = editUser.customClaims;

    // must already be an admin to change role
    if (currentClaims['ROLE'] === 'ADMIN') {

      // you could also check for allowed Roles

      // add new claims, new user role
      admin.auth().setCustomUserClaims(userId, {
        "https://dgraph.io/jwt/claims": {
          "USER": editUser.email,
          "ROLE": newRole,
          ...editClaims
        }
      }).catch((e: string) => console.error(e));
    }
  });
Enter fullscreen mode Exit fullscreen mode

Firebase makes it easy to call external functions...

const changeRole = firebase.functions().httpsCallable('changeRole');
changeRole({ role: 'MODERATOR' });
changeRole({ userId: 'sleisllekt', role: 'MODERATOR' });
Enter fullscreen mode Exit fullscreen mode

Step 7 - Security and Data Integrity - Current and the Future

You need to use the @auth directive in order to secure your data.

type User @auth(
    add: { rule:  "{$ROLE: { eq: \"ADMIN\" } }"}
    delete: { rule:  "{$ROLE: { eq: \"ADMIN\" } }"}
) { 
Enter fullscreen mode Exit fullscreen mode

The add rule is evaluated as if the user has been added to the database. So in this case, you can't create an admin, unless the user is an ADMIN.

DGraph is a child in its ability to do backend validation, but hopefully it will fix these problems later.

+ 1 for pre-hooks and other backend security options

For the moment, if you need more validation, the only current way is to use a lambda mutation, or a post-hook.

Standard Claims Version


If you're using standard claims, simply login with firebase in your framework, then run:

user.getIdToken();
Enter fullscreen mode Exit fullscreen mode

That's it!

Here are some more examples.

If you want to be 100% sure the user is logged in (there are many ways to get the user object, but some can return the incorrect data in some cases), you could use my complicated code from above minus the custom claims checks:

export async function getToken(): Promise<any> {
  return await new Promise((resolve: any, reject: any) =>
    auth.onAuthStateChanged(async (user: firebase.User | null) => {
      if (user) { resolve(await user.getIdToken()); }
    }, (e: any) => reject(e)));
}
Enter fullscreen mode Exit fullscreen mode

Remember to attach the token to the every query like from above.

Add user from client

You can simply create the user IFF the user is a new user:

 .signInWithPopup(provider)
      .then((credential: firebase.auth.UserCredential | any) => {
        // check for first signin
        if (credential.additionalUserInfo.isNewUser) {

        // execute dgraph mutation here to create the user
        // this depends on your schema

        }
        return null;
      });
Enter fullscreen mode Exit fullscreen mode

Obviously this depends on your framework etc.

Here is an angular example with HttpClient, but you may want to use fetch in other frameworks:

// execute dgraph mutation here to create the user
// this depends on your schema
const gql = new Dgraph('user').set({
  email: credential.user?.email,
  displayName: credential.user?.displayName,
  createdAt: new Date().toISOString()
}).add().build();

const data = await this.http.post(
  'https://' + environment.uri,
  JSON.stringify({ query: gql }),
  { headers: { 'Content-Type': 'application/json' } }
).toPromise();
Enter fullscreen mode Exit fullscreen mode

Here I am using my easy-dgraph package to quickly create the user.

Note: You probably do not want to use URQL or APOLLO here for this one instance. You do not want to import your module that is dependent on your firebase module etc, causing an infinite loop.

Schema

For the moment, you cannot lock the field instead of the whole type. However, you can create a new type for the role. Now in this case, you need a user and role schema:

You should add your ADMIN user as an ADMIN before you create theses rules, so one user can have access at all times.

Note: $email is in the Firebase standard claim.

type User {
  role: Role!
  email: String!
  ...
}
type Role {
  name: String!
  users: [User] @hasInverse(field: role)
  ...
}
Enter fullscreen mode Exit fullscreen mode

By creating a Role Type, you allow a user to update his or her fields, but can set your own auth rules on the Role Type.

Firebase Demo Apps:

There is really not much difference in the apps, what is important is the lack of firebase functions.

I hope this helps some people, as a Secure Dgraph with Firebase can be complicated. If I missed something, or if you know of a better way to do things, or if this information becomes out of date, please let us know here.

Dgraph is still a baby when it comes to @auth rules, but I foresee the future in just the next year bringing simplicity to all of this!

Let me know if I missed something,

J

💖 💪 🙅 🚩
jdgamble555
Jonathan Gamble

Posted on June 4, 2021

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

Sign up to receive the latest update from our blog.

Related