Admin Authorization

For our RSVP app, only users with admin privileges should be able to create, edit, and delete events. All other users will only be able to RSVP to these events. In order to implement this, we'll need to assign and then utilize user roles in both our Node.js API and our Angular app.

First, let's take a look at the steps involved:

Use Auth0 Rules to establish user roles and then add them to the ID (client) and access (API) tokens. Implement middleware in our Node.js API to ensure only admin users can access certain API routes. Use the role information in the Angular app to restrict access to certain routes and features.

Let's get started!

Use an Auth0 Rule for Admin Authorization

Rules are JavaScript functions that Auth0 executes each time a user authenticates. They provide an easy way to extend authentication functionality.

The first step is to log into your Auth0 dashboard and create a new Rule. Select the "Set roles to a user" rule template:

This opens up a JavaScript template. We only want to assign an admin role to our own account at this time. It'd be a good idea to change the name of this rule so we can see at a glance what it does. I changed the name of the rule to Set admin role for me . We can easily modify line 6 of the template where it checks the user's email for indexOf() a specific email domain.

I'll change this to my full Google email address because that is the OAuth account to which I want to assign the admin role. I'm also using social connections with Facebook and Twitter, and it's important to keep in mind that some connections don't return an email field. Therefore, I need to modify the rule so that it checks that user.email exists before using indexOf() . I'll modify the addRolesToUser() function to use the following:

if (user.email && user.email.indexOf('[MY_FULL_GOOGLE_ACCOUNT_EMAIL]') > -1) {

Replace [MY_FULL_GOOGLE_ACCOUNT_EMAIL] with your own credentials. The rule template should then look like this:

Note: If you want to use a non-Google account, make sure you identify the account by an appropriate property. Not all properties are returned by all connection types. You can also be more explicit regarding the details of the account if you want all accounts with that email to be set as administrators, or if you want only a Google account versus a username/password account to match the check. You can check your Auth0 Users or test your Auth0 Social Connections to see what kind of data is returned and stored from logins from different identity providers.

When finished, click the "Save" button at the bottom of the page.

Create an Auth0 Rule to Add Claims to Tokens

Now we'll create a second rule that will add this metadata from the Auth0 database to the ID token that is returned to the Angular app upon successful authentication, as well as the access token that is sent to the API to authorize endpoints.

Create another new Rule in your Auth0 dashboard. This time, as we won't be using an existing template, click the "empty rule" button:

We added app_metadata with a roles array to our users in the previous rule, but since this isn't part of the OpenID standard claims, we need to add custom claims in order to include roles data in the ID and access tokens.

In the JavaScript template, enter a name for this rule, such as Add user role to tokens . Then add the following code:

function (user, context, callback) { var namespace = 'http://myapp.com/roles'; var userRoles = user.app_metadata.roles; context.idToken[namespace] = userRoles; context.accessToken[namespace] = userRoles; callback(null, user, context); }

The namespace identifier can be any non-Auth0 HTTP or HTTPS URL and does not have to point to an actual resource. Auth0 enforces this recommendation from OIDC regarding additional claims and will silently exclude any claims that do not have a namespace.

The key for our custom claim will be http://myapp.com/roles . This is how we'll retrieve the roles array from the ID and access tokens in our Angular app and Node API. Our rule assigns the Auth0 user's app_metadata.roles to this property.

When finished, click the "Save" button to save this rule.

Sign In With Admin Account

The next thing we need to do is sign in with our intended admin user. This will trigger the rules to execute and the app metadata will be added to our targeted account. Then the roles data will also be available in the tokens whenever the user logs in.

Since we've implemented login in our Angular app already, all we need to do is sign in with the account we specified in our Set admin role for me rule. Visit your Angular app in the browser at http://localhost:4200 and click the "Log In" link we added in the header.

Note: Recall that I used a Google account when setting up the Set admin role for me rule, so I'll log in using Google OAuth.

Once you've logged in, you can check to verify that the appropriate role was added to your user account in the Auth0 Dashboard Users section. Find the user you just logged in with and click the name to view details. This user's Metadata section should now look like this:

Admin Middleware in Node API

Now that we have role support with our authentication, we can use this to protect API routes that require administrator access.

Open the server config.js file and add a NAMESPACE property with the namespace we used when creating our Add user role to tokens rule:

// server/config.js module.exports = { ..., NAMESPACE: 'http://myapp.com/roles' };

We can now implement middleware that will verify that a user is authenticated and has admin privileges to access API endpoints.

Make the following additions to the server api.js file:

// server/api.js ... module.exports = function(app, config) { // Authentication middleware const jwtCheck = jwt({ ... }); // Check for an authenticated admin user const adminCheck = (req, res, next) => { const roles = req.user[config.NAMESPACE] || []; if (roles.indexOf('admin') > -1) { next(); } else { res.status(401).send({message: 'Not authorized for admin access'}); } } ...

Our Add user role to tokens rule added the following key/value pair to our ID and access tokens:

"http://myapp.com/roles": ["admin"]

The express-jwt package adds the decoded token to req.user by default. The adminCheck middleware finds this property and looks for a value of admin in the array. If found, the request proceeds. If not, a 401 Unauthorized status is returned with a short error message.

Now our API is set up to handle admin roles.

Admin Authorization in Angular App

We also want the front-end to know if the user is an admin or not, so let's update our AuthService to get and store this information.

First, we need to store the namespace in our AUTH_CONFIG . Open the auth.config.ts file and add a NAMESPACE key:

// src/app/auth/auth.config.ts ... interface AuthConfig { ..., NAMESPACE: string; }; export const AUTH_CONFIG: AuthConfig = { ..., NAMESPACE: 'http://myapp.com/roles' };

Now that we have the namespace stored, let's add support for storing admin status in the auth.service.ts file:

// src/app/auth/auth.service.ts ... export class AuthService { ... isAdmin: boolean; ... constructor(private router: Router) { // If authenticated, set local profile property, // admin status, and update login status subject. // If token is expired but user data still in localStorage, log out if (this.tokenValid) { this.userProfile = JSON.parse(localStorage.getItem('profile')); this.isAdmin = localStorage.getItem('isAdmin') === 'true'; this.setLoggedIn(true); } } ... private _setSession(authResult, profile) { // Save session data and update login status subject ... this.isAdmin = this._checkAdmin(profile); localStorage.setItem('isAdmin', this.isAdmin.toString()); this.setLoggedIn(true); } private _checkAdmin(profile) { // Check if the user has admin role const roles = profile[AUTH_CONFIG.NAMESPACE] || []; return roles.indexOf('admin') > -1; } logout() { // Ensure all auth items removed from localStorage ... localStorage.removeItem('isAdmin'); // Reset local properties, update loggedIn$ stream this.userProfile = undefined; this.isAdmin = undefined; this.setLoggedIn(false); } ...

First, we'll add a new property called isAdmin: boolean . This will store the user's admin status so we can use it in the front-end.

In the constructor, if the user is authenticated, we'll look for an isAdmin key in local storage. Local storage stores values as strings, so we'll cast it as a boolean.

Next, we'll update the _setSession() function. After setting the local userProfile property, we'll use a private _checkAdmin() method to determine whether the user has admin in their roles.

Note: We have to cast isAdmin to a string because its type is boolean , but local storage expects strings.

Finally, we'll remove isAdmin data from local storage and the service in the logout() method.

We now have the ability to check whether or not a user has admin privileges on the client side.

Security Note: This should never be done on the client-side alone. Always ensure that API routes are protected as well, as we've done in the API middleware section above.

We now have admin authorization set up on both our API and in our Angular app. We'll do a lot more with this as we develop our application!

Planning App Features

We have our database, Angular app, authentication, and secured Node API structurally ready for further development. Now it's time to do some feature planning and data modeling. It's vitally important to plan an application's data structure before diving straight into writing endpoints and business logic.

Let's consider our RSVP app's intended features at a high level, then we'll extrapolate what our database schema models should look like in order to bring these features to life.

Events

The main listing of public events available on the homepage with search feature; this should only show future events.

A full listing of all events (public, private, future, past) available for admins.

Detail view of the event allows authenticated users to RSVP and to view who else has RSVPed.

Events can only be created, edited, and deleted by admins.

Deleting an event should also delete all RSVPs for that event.

Events can be listed publicly on the homepage, or excluded from the listing and accessed directly with a link.

Event RSVPs should be retrieved by event ID.

Event Fields

Event ID (automatically generated by MongoDB, also serves as a direct link).

The title of the event.

Location.

Start date and time.

End date and time.

Description.

Public listing vs. requires a link to view.

RSVPs

Any authenticated user can RSVP for an event that is in the future, either via a direct link or from the homepage listing.

Users cannot add or edit an RSVP for an event that has ended.

Users can update their own existing RSVP responses, but not delete them (RSVPs are deleted if the associated event is deleted).

RSVP Fields

RSVP ID (automatically generated by MongoDB).

User ID.

Name.

Event ID.

Attending/Not Attending.

The number of additional (+1) guests (only applicable if attending).

Comments.

Users

Users should be able to view a list of all their own RSVPs in their profile.

User data handled through Auth0 authentication and profile retrieval; users aren't stored in MongoDB.

Users are associated with their RSVPs by user ID.

In order to edit an RSVP, the user's ID must be verified with the user ID in the RSVP.

Admin users can perform CRUD operations on events.

Data Modeling

We now have an idea about what features our events and RSVPs need to support. Let's create both the server and client-side models necessary to support our application.

Create Schema

First, we'll create the necessary schema to leverage our database. Create a new folder in the server directory called models . In this folder, add a file called Event.js and a file called Rsvp.js . These will contain our Event and RSVP models. We're using mongoose for MongoDB object modeling. Each mongoose schema maps to a MongoDB collection and defines the shape of the documents within that collection.

We'll start with Event.js . Open this file and add the following event schema:

// server/models/Event.js /* |-------------------------------------- | Event Model |-------------------------------------- */ const mongoose = require('mongoose'); // FIX promise deprecation warning: // https://github.com/Automattic/mongoose/issues/4291 mongoose.Promise = global.Promise; const Schema = mongoose.Schema; const eventSchema = new Schema({ title: { type: String, required: true }, location: { type: String, required: true }, startDatetime: { type: Date, required: true }, endDatetime: { type: Date, required: true }, description: String, viewPublic: { type: Boolean, required: true } }); module.exports = mongoose.model('Event', eventSchema);

This schema maps to our outlined features for events.

Note: We don't need to create an ID field because MongoDB object IDs will be generated automatically.

Now let's write the RSVP schema in the Rsvp.js file:

// server/models/Rsvp.js /* |-------------------------------------- | Rsvp Model |-------------------------------------- */ const mongoose = require('mongoose'); // FIX promise deprecation warning: // https://github.com/Automattic/mongoose/issues/4291 mongoose.Promise = global.Promise; const Schema = mongoose.Schema; const rsvpSchema = new Schema({ userId: { type: String, required: true }, name: { type: String, required: true }, eventId: { type: String, required: true }, attending: { type: Boolean, required: true }, guests: Number, comments: String }); module.exports = mongoose.model('Rsvp', rsvpSchema);

Note: We'll set mongoose.Promise = global.Promise; before declaring each Schema in order to address a bug in mongoose that produces a promise deprecation warning. When this issue is resolved, we can remove this line from our models.

Now we need to require our models in the API. Open the server api.js file and add the following:

// server/api.js /* |-------------------------------------- | Dependencies |-------------------------------------- */ ... const Event = require('./models/Event'); const Rsvp = require('./models/Rsvp'); ...

We'll use these models to retrieve data from MongoDB in our endpoints, but first, let's model the data on the front-end as well.

Add Models to Our Angular App

We'll also add event and RSVP models in the front-end to define the shape of the data we expect to retrieve when making API calls. Create two new class files with the CLI:

$ ng g class core/models/event.model $ ng g class core/models/rsvp.model

Let's add the event model code in event.model.ts :

// src/app/core/models/event.model.ts export class EventModel { constructor( public title: string, public location: string, public startDatetime: Date, public endDatetime: Date, public viewPublic: boolean, public description?: string, public _id?: string, ) { } }

We're naming the models EventModel (and RsvpModel ) to avoid conflicts with the existing Event constructors if your editor or IDE uses intelligent code completion. Optional members must be listed after required members. The _id property is optional because it only exists if retrieving data from the database, but not if we're creating new records.

Now add the RSVP model in rsvp.model.ts :

// src/app/core/models/rsvp.model.ts export class RsvpModel { constructor( public userId: string, public name: string, public eventId: string, public attending: boolean, public guests?: number, public comments?: string, public _id?: string ) { } }

Create and Seed Collections in MongoDB

In order to query the database, we first need to create the necessary collections and provide a little bit of seed data. There are a couple of ways we could do this: either through mLab or in MongoBooster (with the Mongo shell), which we set up earlier. Let's use MongoBooster because this method can be used with any MongoDB database, not just mLab.

Create Collections

Open your MongoBooster app to the mLab connection we created during our MongoDB Setup.

Once you've connected, right-click the database in the left sidebar and select "Create Collection..." When prompted, enter the collection name events . Click "Ok" and then create a second collection called rsvps . You should now have two empty collections listed in your database.

Add Event Seed Documents

Now we'll add some documents to the events collection for seed data. Right-click the collection name in the sidebar and select "Insert Documents..." The Mongo shell will open with db.events.insert([{}]) prompting you to add data. Replace this with the following:

db.events.insert([{ "title": "Test Event Past", "location": "Home", "startDatetime": ISODate("2017-05-04T18:00:00.000-04:00"), "endDatetime": ISODate("2017-05-04T20:00:00.000-04:00"), "viewPublic": true }, { "title": "MongoBooster Test", "location": "Seattle, WA", "startDatetime": ISODate("2017-08-12T20:00:00.000-04:00"), "endDatetime": ISODate("2017-08-13T10:00:00.000-04:00"), "viewPublic": true }, { "title": "Bob's Private Event", "location": "Bob's House", "startDatetime": ISODate("2017-10-05T12:30:00.000-04:00"), "endDatetime": ISODate("2017-10-05T14:30:00.000-04:00"), "viewPublic": false }])

Note: Make sure you update the seed data dates so that most of them are in the future and at least one is in the past. You may need to make changes depending on the current date versus the publication date of this tutorial. You'll also want at least one to have a viewPublic value of false .

When finished, click "Run" in the top bar. A console tab and a result tab should appear. You can then double-click on the events collection again to see your new documents listed. They should each have an _id property containing the automatically-generated object ID and should look something like this:

Add RSVP Seed Documents

Let's add a few documents to the rsvps collection as well. We'll assign the RSVPs to the seed events, so make sure you copy the object ID string (for the RSVP eventId ) from a couple of event documents. You can do this by right-clicking an event document and selecting "View Document..." You can then copy the string from the "_id" : ObjectId(string) in the JSON Viewer.

We also need to assign RSVPs to user IDs. You can get specific user IDs by going to the Auth0 Dashboard Users and copying the user_id from the Identity Provider Attributes of whichever accounts you'd like to associate RSVPs with. Then when you log into the app with any of those accounts, the seed data RSVPs will be associated with the authentication service's userProfile.sub property.

Using this information, we can create some seed data for RSVPs. Let's add a couple of documents to the rsvps collection in our MongoDB database:

db.rsvps.insert([{ "userId": "<IDP>|<USER_ID>", "eventId": "<EVENT_OBJECT_ID_STRING>", "attending": true, "guests": 3, "comments": "Really looking forward to this!" }, { "userId": "<IDP>|<USER_ID>", "eventId": "<EVENT_OBJECT_ID_STRING>", "attending": false, "comments": "Regretfully, I can't make it." }, { "userId": "<IDP>|<USER_ID>", "eventId": "<EVENT_OBJECT_ID_STRING>", "attending": true, "guests": 2 }])

Replace the information in angle brackets < > with user_id s from Auth0 and event IDs from the data we entered previously. This will set up the appropriate relationships.

The database should then look something like this in MongoBooster:

MongoBooster makes it simple to manipulate collections and documents as well as query the database with both the Mongo shell and a GUI. It's a handy tool to have at your disposal for any MongoDB project, and particularly useful if you need to work with a database that is not hosted on your local machine.

We now have some seed documents to work with so we can get our API and Angular app up and running with data available right off the bat.

Summary

In Part 2 of our Real-World Angular Series, we've covered authentication and authorization, feature planning, and data modeling for our MEAN stack application. In the next part of the tutorial series, we'll tackle fetching data from the database with a Node API and how to display data with Angular, complete with filtering and sorting.