Firebase Admin SDK also provides us the method to verify the token using the verifyIdToken() method:
admin.auth().verifyIdToken(idToken).then((claims) => {
if (claims.admin === true) {
// Allow access to admin resource.
}
});
We can also check whether the custom claim is available or not in the user object:
admin.auth().getUser(uid).then((userRecord) => {
console.log(userRecord.customClaims.admin);
});
Now, let's see how we can implement this in our existing application.
First, let's create a restful API in the Node Admin SDK backend server:
app.post('/setCustomClaims', (req, res) => {
// Get the ID token passed by the client app.
const idToken = req.body.idToken;
console.log("accepted",idToken,req.body);
// Verify the ID token
admin.auth().verifyIdToken(idToken).then((claims) => {
// Verify user is eligible for admin access or not
if (typeof claims.email !== 'undefined' &&
claims.email.indexOf('@adminhelpdesk.com') != -1) {
// Add custom claims for admin access.
admin.auth().setCustomUserClaims(claims.sub, {
admin: true,
}).then(function() {
// send back to the app to refresh token and shows the admin UI.
res.send(JSON.stringify({
status: 'success',
role:'admin'
}));
});
} else if (typeof claims.email !== 'undefined'){
// Add custom claims for admin access.
admin.auth().setCustomUserClaims(claims.sub, {
admin: false,
}).then(function() {
// Tell client to refresh token on user.
res.send(JSON.stringify({
status: 'success',
role:'employee'
}));
});
}
else{
// return nothing
res.send(JSON.stringify({status: 'ineligible'}));
}
})
});
I have manually created one admin user with harmeet@adminhelpdesk.com in Firebase Console with help of admin SDK; we need to verify and add the custom claims for admin.
Now, open App.JSX and add the following code snippet; set the initial state of the application based on the role:
constructor() {
super();
this.state = {
authenticated : false,
data:'',
userUid:'',
role:{
admin:false,
type:''
}
}
}
Now, calling the preceding API in the componentWillMount() component lifecycle method we need to get the idToken from user object from firebase.auth().onAuthStateChanged((user)) and send it to the server for verification:
this.getIdToken(user).then((idToken)=>{
console.log(idToken);
fetch('http://localhost:3000/setCustomClaims', {
method: 'POST', // or 'PUT'
body: JSON.stringify({idToken:idToken}),
headers: new Headers({
'Content-Type': 'application/json'
})
}).then(res => res.json())
.catch(error => console.error('Error:', error))
.then(res => {
console.log(res,"after token valid");
if(res.status === 'success' && res.role === 'admin'){
firebase.auth().currentUser.getIdToken(true);
this.setState({
authenticated:true,
data:user.providerData,
userUid:user.uid,
role:{
admin:true,
type:'admin'
}
})
}
else if (res.status === 'success' && res.role === 'employee'){
this.setState({
authenticated:true,
data:user.providerData,
userUid:user.uid,
role:{
admin:false,
type:'employee'
}
})
}
else{
ToastDanger('Invalid Token !!')
}
In the preceding code, we are using the fetch API to send the HTTP request. It's similar to XMLHttpRequest, but it has the new feature and is more powerful. Based on the response, we are setting the state of the component and registering the component into the router.
This is how our router component looks:
{
this.state.authenticated && !this.state.role.admin
?
(
<React.Fragment>
<Route path="/view-ticket" render={() => (
<ViewTicketTable userId = {this.state.userUid} />
)}/>
<Route path="/add-ticket" render={() => (
<AddTicketForm userId = {this.state.userUid} userInfo = {this.state.data} />
)}/>
<Route path="/user-profile" render={() => (
<ProfileUpdateForm userId = {this.state.userUid} userInfo = {this.state.data} />
)}/>
</React.Fragment>
)
:
(
<React.Fragment>
<Route path="/get-alluser" component = { AppUsers }/>
<Route path="/tickets" component = { GetAllTickets }/>
<Route path="/add-new-user" component = { NewUserForm }/>
</React.Fragment>
)
}
Here's the list of components that we are registering and rendering admin component if the user is an admin:
- AppUser: To get the list of user for application, which is also responsible for deleting the user and searching the user by different criteria
- Tickets: To see the list of all tickets and change the status of the ticket
- NewUserForm: To add the new user to the application
We are performing the preceding operation with Node.js Firebase Admin SDK server.
Create a folder with the name of admin and create a file in it, called getAllUser.jsx. In that, we will create a React component, which is responsible for fetching and displaying the list of the user into UI; we'll also add the functionality of searching the user by different criteria, such as email ID, phone number, and more.
In the getAllUser.jsx file, this is how our render method looks:
<form className="form-inline">
//Search Input
<div className="form-group" style={marginRight}>
<input type="text" id="search" className="form-control"
placeholder="Search user" value={this.state.search} required
/>
</div>
//Search by options
<select className="form-control" style={marginRight}>
<option value="email">Search by Email</option>
<option value="phone">Search by Phone Number</option>
</select>
<button className="btn btn-primary btn-sm">Search</button>
</form>
We have also added the table in the render method to display the list of users:
<tbody>
{
this.state.users.length > 0 ?
this.state.users.map((list,index) => {
return (
<tr key={list.uid}>
<td>{list.email}</td>
<td>{list.displayName}</td>
<td>{list.metadata.lastSignInTime}</td>
<td>{list.metadata.creationTime}</td>
<td>
<button className="btn btn-sm btn-primary" type="button" style={marginRight} onClick={()=> {this.deleteUser(list.uid)}}>Delete User</button>
<button className="btn btn-sm btn-primary" type="button" onClick={()=> {this.viewProfile(list.uid)}}>View Profile</button>
</td>
</tr>
)
}) :
<tr>
<td colSpan="5" className="text-center">No users found.</td>
</tr>
}
</tbody>
This is the table body, which is displaying the list of users with action buttons, and now we need to call the users API in the componentDidMount() method:
fetch('http://localhost:3000/users', {
method: 'GET', // or 'PUT'
headers: new Headers({
'Content-Type': 'application/json'
})
}).then(res => res.json())
.catch(error => console.error('Error:', error))
.then(response => {
console.log(response,"after token valid");
this.setState({
users:response
})
console.log(this.state.users,'All Users');
})
Similarly, we need to call other APIs to delete, View User Profile, and search:
deleteUser(uid){
fetch('http://localhost:3000/deleteUser', {
method: 'POST', // or 'PUT'
body:JSON.stringify({uid:uid}),
headers: new Headers({
'Content-Type': 'application/json'
})
}).then(res => res.json())
.catch(error => console.error('Error:', error))
}
//Fetch User Profile
viewProfile(uid){
fetch('http://localhost:3000/getUserProfile', {
method: 'POST', // or 'PUT'
body:JSON.stringify({uid:uid}),
headers: new Headers({
'Content-Type': 'application/json'
})
}).then(res => res.json())
.catch(error => console.error('Error:', error))
.then(response => {
console.log(response.data,"User Profile");
})
}
For searching, Firebase Admin SDK has built-in methods: getUserByEmail() and getUserByPhoneNumber(). We can implement these in the same way as delete() and fetch(), which we created in the Firebase Admin API:
//Search User by Email
searchByEmail(emailId){
fetch('http://localhost:3000/searchByEmail', {
method: 'POST', // or 'PUT'
body:JSON.stringify({email:emailId}),
headers: new Headers({
'Content-Type': 'application/json'
})
}).then(res => res.json())
.catch(error => console.error('Error:', error))
.then(response => {
console.log(response.data,"User Profile");
this.setState({
users:response
})
})
}
Look at the following node.js API Code Snippet:
function listAllUsers(req,res) {
var nextPageToken;
// List batch of users, 1000 at a time.
admin.auth().listUsers(1000,nextPageToken)
.then(function(data) {
data = data.users.map((el) => {
return el.toJSON();
})
res.send(data);
})
.catch(function(error) {
console.log("Error fetching the users from firebase:", error);
});
}
function deleteUser(req, res){
const userId = req.body.uid;
admin.auth().deleteUser(userId)
.then(function() {
console.log("Successfully deleted user"+userId);
res.send({status:"success", msg:"Successfully deleted user"})
})
.catch(function(error) {
console.log("Error deleting user:", error);
res.send({status:"error", msg:"Error deleting user:"})
});
}
function searchByEmail(req, res){
const searchType = req.body.email;
admin.auth().getUserByEmail(userId)
.then(function(userInfo) {
console.log("Successfully fetched user information associated with this email"+userId);
res.send({status:"success", data:userInfo})
})
.catch(function(error) {
console.log("Error fetching user info:", error);
res.send({status:"error", msg:"Error fetching user informaition"})
});
}
Now, we'll create an API to call the preceding functions based on the user's request:
app.get('/users', function (req, res) {
listAllUsers(req,res);
})
app.get('/deleteUser', function (req, res) {
deleteUser(req,res);
})
app.post('/searchByEmail', function (req, res){
searchByEmail(req, res)
})
Now, let's take a quick look at our application in browser, see how it looks, and try to log in with admin user:
A screenshot of our application when logged in with admin credentials; the purpose is to show the UI and console when we log in as admin
That looks amazing! Just take a look at the preceding screenshot; it's showing different navigation for admin, and if you can see in the console, it's showing the token with custom claim object, which we added to this user to admin access:
It looks great! We can see the users of the application with action button and search UI.
Now, consider that we delete the user from the listing and, at the same time that user session is active and using the application. In this scenario, we need to manage the session for the user and give the prompt to reauthenticate, because every time the user logs in, the user credentials are sent to the Firebase Authentication backend and exchanged for a Firebase ID token (a JWT) and refresh token.
These are the common scenarios where we need to manage the session of the user:
- User is deleted
- User is disabled
- Email address and password changed
The Firebase Admin SDK also gives the ability to revoke the specific user session using the revokeRefreshToken() method. It revokes active refresh tokens of a given user. If we reset the password, Firebase Authentication backend automatically revokes the user token.
Refer to the following code snippet of Firebase Cloud Function to revoke the user based on a specific uid:
const admin = require('firebase-admin');
admin.initializeApp(functions.config().firebase);
// Revoke all refresh tokens for a specified user for whatever reason.
function revokeUserTokens(uid){
return admin.auth().revokeRefreshTokens(uid)
.then(() => {
// Get user's tokensValidAfterTime.
return admin.auth().getUser(uid);
})
.then((userRecord) => {
// Convert to seconds as the auth_time in the token claims is in seconds too.
const utcRevocationTimeSecs = new Date(userRecord.tokensValidAfterTime).getTime() / 1000;
// Save the refresh token revocation timestamp. This is needed to track ID token
// revocation via Firebase rules.
const metadataRef = admin.database().ref("metadata/" + userRecord.uid);
return metadataRef.set({revokeTime: utcRevocationTimeSecs});
});
}
As we know, Firebase ID tokens are stateless JWT, which can only be verified by sending the request to Firebase Authentication backend server to check whether the token's status is revoked or not. For this reason, performing this check on your server is very costly and adds the extra effort, requiring an extra network request load. We can avoid this network request by setting up Firebase Rules that check for revocation, rather than sending the request to the Firebase Admin SDK.
This is the normal way to declare the rules with no client access to write to store revocation time per user:
{
"rules": {
"metadata": {
"$user_id": {
".read": "$user_id === auth.uid",
".write": "false",
}
}
}
}
However, if we want to allow only unrevoked and authenticated users to access the protected data, we must have the following rule configured:
{
"rules": {
"users": {
"$user_id": {
".read": "$user_id === auth.uid && auth.token.auth_time > (root.child('metadata').child(auth.uid).child('revokeTime').val() || 0)",
".write": "$user_id === auth.uid && auth.token.auth_time > (root.child('metadata').child(auth.uid).child('revokeTime').val() || 0)"
}
}
}
}
Any time a user's refresh on browser tokens are revoked, the tokensValidAfterTime UTC timestamp is saved in the database node.
When a user's ID token is to be verified, the additional check boolean flag has to be passed to the verifyIdToken() method. If the user's token is revoked, the user should be signed out from the app or asked to reauthenticate using reauthentication APIs provided by the Firebase Authentication client SDKs.
For example, we created one method above setCustomClaims in that method; just add the following code inside the catch method:
.catch(error => {
// Invalid token or token was revoked:
if (error.code == 'auth/id-token-revoked') {
//Shows the alert to user to reauthenticate
// Firebase Authentication API gives the API to reauthenticateWithCredential /reauthenticateWithPopup /reauthenticateWithRedirect
}
});
Also, if the token is revoked, send the notification to the client app to reauthenticate.
Consider this example for email/password Firebase authentication providers:
let password = prompt('Please provide your password for reauthentication');
let credential = firebase.auth.EmailAuthProvider.credential(
firebase.auth().currentUser.email, password);
firebase.auth().currentUser.reauthenticateWithCredential(credential)
.then(result => {
// User successfully reauthenticated.
})
.catch(error => {
// An error occurred.
});
Now, let's click on the All Tickets link to see the list of tickets submitted by all the users:
As an admin user, we can change the status of the ticket that will get updated in Firebase Realtime Database. Now if you click on Create New User, it will display the form to add user information.
Let's create one new component and add the following code to the render method:
<form className="form" onSubmit={this.handleSubmitEvent}>
<div className="form-group">
<input type="text" id="name" className="form-control"
placeholder="Enter Employee Name" value={this.state.name} required onChange={this.handleChange} />
</div>
<div className="form-group">
<input type="text" id="email" className="form-control"
placeholder="Employee Email ID" value={this.state.email} required onChange={this.handleChange} />
</div>
<div className="form-group">
<input type="password" id="password" className="form-control"
placeholder="Application Password" value={this.state.password} required onChange={this.handleChange} />
</div>
<div className="form-group">
<input type="text" id="phoneNumber" className="form-control"
placeholder="Employee Phone Number" value={this.state.phoneNumber} required onChange={this.handleChange} />
</div>
<div className="form-group">
<input
type="file"
ref={input => {
this.fileInput = input;
}}
/>
</div>
<button className="btn btn-primary btn-sm">Submit</button>
</form>
On handleSubmitEvent(e), we need to call the createNewUser() Firebase admin SDK method, passing the form data into it:
e.preventDefault();
//React form data object
var data = {
email:this.state.email,
emailVerified: false,
password:this.state.password,
displayName:this.state.name,
phoneNumber:this.state.phoneNumber,
profilePhoto:this.fileInput.files[0],
disabled: false
}
fetch('http://localhost:3000/createNewUser', {
method: 'POST', // or 'PUT'
body:JSON.stringify({data:data}),
headers: new Headers({
'Content-Type': 'application/json'
})
}).then(res => res.json())
.catch(error => {
ToastDanger(error)
})
.then(response => {
ToastSuccess(response.msg)
});
Start the server again and open the application in your browser. Let's try to create the new user in our application with admin credentials:
Create New User component; the purpose of the image is to show the alert message when we fill the form and submit to the Firebase to create a new user
That looks awesome; we have successfully created the new user in our application and returned the automatic generated uid by Firebase for a new user.
Now, let's move on further and log in with a normal user:
If you take a look at the preceding screenshot, once we logged into the app using any Firebase Auth provider, on the dashboard, it shows all the tickets of the users, but it should only display the ones associated with this email ID. For this, we need to change the data structure and Firebase node ref.
This is the most important part of the application where we need to plan how data will be saved and retrieved to make the process as easy as possible.