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
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"]}
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, runfirebase 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'
};
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));
});
});
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:
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));
}
})
);
}
- 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));
}
});
Firebase makes it easy to call external functions...
const changeRole = firebase.functions().httpsCallable('changeRole');
changeRole({ role: 'MODERATOR' });
changeRole({ userId: 'sleisllekt', role: 'MODERATOR' });
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\" } }"}
) {
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();
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)));
}
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;
});
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();
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)
...
}
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