Skip to content

Latest commit

 

History

History
817 lines (634 loc) · 29.1 KB

File metadata and controls

817 lines (634 loc) · 29.1 KB

Build Node.js Express apps with Microsoft Graph

In this lab you will create a Node.js Express web application using the Azure AD v2 authentication endpoint to access data in Office 365 using Microsoft Graph.

In this lab

Prerequisites

To complete this lab, you need the following:

  • Node.js installed on your development machine. If you do not have Node.js, visit the previous link for download options. (Note: This tutorial was written with Node version 10.7.0. The steps in this guide may work with other versions, but that has not been tested.)
  • Either a personal Microsoft account with a mailbox on Outlook.com, or a Microsoft work or school account.

If you don't have a Microsoft account, there are a couple of options to get a free account:

Exercise 1: Create a Node.js Express web app

In this exercise you will use Express to build a web app. If you don't already have the Express generator installed, you can install it from your command-line interface (CLI) with the following command.

npm install express-generator -g

Open your CLI, navigate to a directory where you have rights to create files, and run the following command to create a new Express app that uses Handlebars as the rendering engine.

express --hbs graph-tutorial

The Express generator creates a new directory called graph-tutorial and scaffolds an Express app. Navigate to this new directory and enter the following command to install dependencies.

npm install

Once that command completes, use the following command to start a local web server.

npm start

Open your browser and navigate to http://localhost:3000. If everything is working, you will see a "Welcome to Express" message. If you don't see that message, check the Express getting started guide.

Before moving on, install some additional gems that you will use later:

Run the following command in your CLI.

npm install dotenv moment connect-flash express-session passport-azure-ad simple-oauth2 @microsoft/microsoft-graph-client --save

Now update the application to use the connect-flash and express-session middleware. Open the ./app.js file and add the following require statement to the top of the file.

var session = require('express-session');
var flash = require('connect-flash');

Add the following code immediately after the var app = express(); line.

// Session middleware
// NOTE: Uses default in-memory session store, which is not
// suitable for production
app.use(session({
  secret: 'your_secret_value_here',
  resave: false,
  saveUninitialized: false,
  unset: 'destroy'
}));

// Flash middleware
app.use(flash());

// Set up local vars for template layout
app.use(function(req, res, next) {
  // Read any flashed errors and save
  // in the response locals
  res.locals.error = req.flash('error_msg');

  // Check for simple error string and
  // convert to layout's expected format
  var errs = req.flash('error');
  for (var i in errs){
    res.locals.error.push({message: 'An error occurred', debug: errs[i]});
  }

  next();
});

Design the app

Start by creating the global layout for the app. Open the ./views/layout.hbs file and replace the entire contents with the following code.

<!DOCTYPE html>
<html>
  <head>
    <title>Node.js Graph Tutorial</title>

    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css" integrity="sha384-WskhaSGFgHYWDcbwN70/dfYBj47jz9qbsMId/iRN3ewGhXQFZCSftd1LZCfmhktB" crossorigin="anonymous">
    <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.1.0/css/all.css" integrity="sha384-lKuwvrZot6UHsBSfcMvOkWwlCMgc0TaWr+30HWe3a4ltaBwTZhyTEggF5tJv8tbt" crossorigin="anonymous">
    <link rel='stylesheet' href='/stylesheets/style.css' />
    <script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/js/bootstrap.min.js" integrity="sha384-smHYKdLADwkXOn1EmN1qk/HfnUcbVRZyYmZ4qpPea6sjB/pTJ0euyQp0Mk8ck+5T" crossorigin="anonymous"></script>
  </head>

  <body>
    <nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
      <div class="container">
        <a href="/" class="navbar-brand">Node.js Graph Tutorial</a>
        <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse" aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
          <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarCollapse">
          <ul class="navbar-nav mr-auto">
            <li class="nav-item">
              <a href="/" class="nav-link{{#if active.home}} active{{/if}}">Home</a>
            </li>
            {{#if user}}
              <li class="nav-item" data-turbolinks="false">
                <a href="/calendar" class="nav-link{{#if active.calendar}} active{{/if}}">Calendar</a>
              </li>
            {{/if}}
          </ul>
          <ul class="navbar-nav justify-content-end">
            <li class="nav-item">
              <a class="nav-link" href="https://developer.microsoft.com/graph/docs/concepts/overview" target="_blank"><i class="fas fa-external-link-alt mr-1"></i>Docs</a>
            </li>
            {{#if user}}
              <li class="nav-item dropdown">
                <a class="nav-link dropdown-toggle" data-toggle="dropdown" href="#" role="button" aria-haspopup="true" aria-expanded="false">
                  {{#if user.avatar}}
                    <img src="{{ user.avatar }}" class="rounded-circle align-self-center mr-2" style="width: 32px;">
                  {{else}}
                    <i class="far fa-user-circle fa-lg rounded-circle align-self-center mr-2" style="width: 32px;"></i>
                  {{/if}}
                </a>
                <div class="dropdown-menu dropdown-menu-right">
                  <h5 class="dropdown-item-text mb-0">{{ user.displayName }}</h5>
                  <p class="dropdown-item-text text-muted mb-0">{{ user.email }}</p>
                  <div class="dropdown-divider"></div>
                  <a href="/auth/signout" class="dropdown-item">Sign Out</a>
                </div>
              </li>
            {{else}}
              <li class="nav-item">
                <a href="/auth/signin" class="nav-link">Sign In</a>
              </li>
            {{/if}}
          </ul>
        </div>
      </div>
    </nav>
    <main role="main" class="container">
      {{#each error}}
        <div class="alert alert-danger" role="alert">
          <p class="mb-3">{{ this.message }}</p>
          {{#if this.debug }}
            <pre class="alert-pre border bg-light p-2"><code>{{ this.debug }}</code></pre>
          {{/if}}
        </div>
      {{/each}}

      {{{body}}}
    </main>
  </body>
</html>

This code adds Bootstrap for simple styling, and Font Awesome for some simple icons. It also defines a global layout with a nav bar.

Now open ./public/stylesheets/style.css and replace its entire contents with the following.

body {
  padding-top: 4.5rem;
}

.alert-pre {
  word-wrap: break-word;
  word-break: break-all;
  white-space: pre-wrap;
}

Now update the default page. Open the ./views/index.hbs file and replace its contents with the following.

<div class="jumbotron">
  <h1>Node.js Graph Tutorial</h1>
  <p class="lead">This sample app shows how to use the Microsoft Graph API to access Outlook and OneDrive data from Node.js</p>
  {{#if user}}
    <h4>Welcome {{ user.displayName }}!</h4>
    <p>Use the navigation bar at the top of the page to get started.</p>
  {{else}}
    <a href="/auth/signin" class="btn btn-primary btn-large">Click here to sign in</a>
  {{/if}}
</div>

Open the ./routes/index.js file and replace the existing code with the following.

var express = require('express');
var router = express.Router();

/* GET home page. */
router.get('/', function(req, res, next) {
  let params = {
    active: { home: true }
  };

  res.render('index', params);
});

module.exports = router;

Save all of your changes and restart the server. Now, the app should look very different.

A screenshot of the redesigned home page

Exercise 2: Register a web application with the Application Registration Portal

In this exercise, you will create a new Azure AD web application registration using the Application Registry Portal (ARP).

  1. Open a browser and navigate to the Application Registration Portal. Login using a personal account (aka: Microsoft Account) or Work or School Account.

  2. Select Add an app at the top of the page.

    Note: If you see more than one Add an app button on the page, select the one that corresponds to the Converged apps list.

  3. On the Register your application page, set the Application Name to Node.js Graph Tutorial and select Create.

    Screenshot of creating a new app in the App Registration Portal website

  4. On the Node.js Graph Tutorial Registration page, under the Properties section, copy the Application Id as you will need it later.

    Screenshot of newly created application's ID

  5. Scroll down to the Application Secrets section.

    1. Select Generate New Password.

    2. In the New password generated dialog, copy the contents of the box as you will need it later.

      Important: This password is never shown again, so make sure you copy it now.

    Screenshot of newly created application's password

  6. Scroll down to the Platforms section.

    1. Select Add Platform.

    2. In the Add Platform dialog, select Web.

      Screenshot creating a platform for the app

    3. In the Web platform box, enter the URL http://localhost:3000/auth/callback for the Redirect URLs.

      Screenshot of the newly added Web platform for the application

  7. Scroll to the bottom of the page and select Save.

Exercise 3: Extend the app for Azure AD Authentication

In this exercise you will extend the application from the previous exercise to support authentication with Azure AD. This is required to obtain the necessary OAuth access token to call the Microsoft Graph. In this step you will integrate the passport-azure-ad library into the application.

Create a new file named .env file in the root of your application, and add the following code.

OAUTH_APP_ID=YOUR_APP_ID_HERE
OAUTH_APP_PASSWORD=YOUR_APP_PASSWORD_HERE
OAUTH_REDIRECT_URI=http://localhost:3000/auth/callback
OAUTH_SCOPES='profile offline_access user.read calendars.read'
OAUTH_AUTHORITY=https://login.microsoftonline.com/common
OAUTH_ID_METADATA=/v2.0/.well-known/openid-configuration
OAUTH_AUTHORIZE_ENDPOINT=/oauth2/v2.0/authorize
OAUTH_TOKEN_ENDPOINT=/oauth2/v2.0/token

Replace YOUR APP ID HERE with the application ID from the Application Registration Portal, and replace YOUR APP SECRET HERE with the password you generated.

Important: If you're using source control such as git, now would be a good time to exclude the .env file from source control to avoid inadvertently leaking your app ID and password.

Open ./app.js and add the following line to the top of the file to load the .env file.

require('dotenv').config();

Implement sign-in

Locate the line var indexRouter = require('./routes/index'); in ./app.js. Insert the following code before that line.

var passport = require('passport');
var OIDCStrategy = require('passport-azure-ad').OIDCStrategy;

// Configure passport

// In-memory storage of logged-in users
// For demo purposes only, production apps should store
// this in a reliable storage
var users = {};

// Passport calls serializeUser and deserializeUser to
// manage users
passport.serializeUser(function(user, done) {
  // Use the OID property of the user as a key
  users[user.profile.oid] = user;
  done (null, user.profile.oid);
});

passport.deserializeUser(function(id, done) {
  done(null, users[id]);
});

// Callback function called once the sign-in is complete
// and an access token has been obtained
async function signInComplete(iss, sub, profile, accessToken, refreshToken, params, done) {
  if (!profile.oid) {
    return done(new Error("No OID found in user profile."), null);
  }

  // Save the profile and tokens in user storage
  users[profile.oid] = { profile, accessToken };
  return done(null, users[profile.oid]);
}

// Configure OIDC strategy
passport.use(new OIDCStrategy(
  {
    identityMetadata: `${process.env.OAUTH_AUTHORITY}${process.env.OAUTH_ID_METADATA}`,
    clientID: process.env.OAUTH_APP_ID,
    responseType: 'code id_token',
    responseMode: 'form_post',
    redirectUrl: process.env.OAUTH_REDIRECT_URI,
    allowHttpForRedirectUrl: true,
    clientSecret: process.env.OAUTH_APP_PASSWORD,
    validateIssuer: false,
    passReqToCallback: false,
    scope: process.env.OAUTH_SCOPES.split(' ')
  },
  signInComplete
));

This code initializes the Passport.js library to use the passport-azure-ad library, and configures it with the app ID and password for the app.

Now pass the passport object to the Express app. Locate the line app.use('/', indexRouter); in ./app.js. Insert the following code before that line.

// Initialize passport
app.use(passport.initialize());
app.use(passport.session());

Create a new file in the ./routes directory named auth.js and add the following code.

var express = require('express');
var passport = require('passport');
var router = express.Router();

/* GET auth callback. */
router.get('/signin',
  function  (req, res, next) {
    passport.authenticate('azuread-openidconnect',
      {
        response: res,
        prompt: 'login',
        failureRedirect: '/',
        failureFlash: true
      }
    )(req,res,next);
  },
  function(req, res) {
    res.redirect('/');
  }
);

router.post('/callback',
  function(req, res, next) {
    passport.authenticate('azuread-openidconnect',
      {
        response: res,
        failureRedirect: '/',
        failureFlash: true
      }
    )(req,res,next);
  },
  function(req, res) {
    // TEMPORARY!
    // Flash the access token for testing purposes
    req.flash('error_msg', {message: 'Access token', debug: req.user.accessToken});
    res.redirect('/');
  }
);

router.get('/signout',
  function(req, res) {
    req.session.destroy(function(err) {
      req.logout();
      res.redirect('/');
    });
  }
);

module.exports = router;

This defines a router with three routes: signin, callback, and signout.

The signin route calls the passport.authenticate method, causing the app to redirect to the Azure login page.

The callback route is where Azure redirects after the signin is complete. The code calls the passport.authenticate method again, causing the passport-azure-ad strategy to request an access token. Once the token is obtained, the next handler is called, which redirects back to the home page with the access token in the temporary error value. We'll use this to verify that our sign-in is working before moving on. Before we test, we need to configure the Express app to use the new router from ./routes/auth.js.

The signout method logs the user out and destroys the session.

Insert the following code before the var app = express(); line.

var authRouter = require('./routes/auth');

Then insert the following code after the app.use('/', indexRouter); line.

app.use('/auth', authRouter);

Start the server and browse to https://localhost:3000. Click the sign-in button and you should be redirected to https://login.microsoftonline.com. Login with your Microsoft account and consent to the requested permissions. The browser redirects to the app, showing the token.

Get user details

Start by creating a new file to hold all of your Microsoft Graph calls. Create a new file in the root of the project named graph.js and add the following code.

var graph = require('@microsoft/microsoft-graph-client');

module.exports = {
  getUserDetails: async function(accessToken) {
    const client = getAuthenticatedClient(accessToken);

    const user = await client.api('/me').get();
    return user;
  }
};

function getAuthenticatedClient(accessToken) {
  // Initialize Graph client
  const client = graph.Client.init({
    // Use the provided access token to authenticate
    // requests
    authProvider: (done) => {
      done(null, accessToken);
    }
  });

  return client;
}

This exports the getUserDetails function, which uses the Microsoft Graph SDK to call the /me endpoint and return the result.

Update the signInComplete method in /app.s to call this function. First, add the following require statements to the top of the file.

var graph = require('./graph');

Replace the existing signInComplete function with the following code.

async function signInComplete(iss, sub, profile, accessToken, refreshToken, params, done) {
  if (!profile.oid) {
    return done(new Error("No OID found in user profile."), null);
  }

  try{
    const user = await graph.getUserDetails(accessToken);

    if (user) {
      // Add properties to profile
      profile['email'] = user.mail ? user.mail : user.userPrincipalName;
    }
  } catch (err) {
    done(err, null);
  }

  // Save the profile and tokens in user storage
  users[profile.oid] = { profile, accessToken };
  return done(null, users[profile.oid]);
}

The new code updates the profile provided by Passport to add an email property, using the data returned by Microsoft Graph.

Finally, add code to ./app.js to load the user profile into the locals property of the response. This will make it available to all of the views in the app.

Add the following after the app.use(passport.session()); line.

app.use(function(req, res, next) {
  // Set the authenticated user in the
  // template locals
  if (req.user) {
    res.locals.user = req.user.profile;
  }
  next();
});

Storing the tokens

Now that you can get tokens, it's time to implement a way to store them in the app. Currently, the app is storing the raw access token in the in-memory user storage. Since this is a sample app, for simplicity's sake, you'll continue to store them there. A real-world app would use a more reliable secure storage solution, like a database.

However, storing just the access token doesn't allow you to check expiration or refresh the token. In order to enable that, update the sample to wrap the tokens in an AccessToken object from the simple-oauth2 library.

First, in ./app.js, add the following code before the signInComplete function.

// Configure simple-oauth2
const oauth2 = require('simple-oauth2').create({
  client: {
    id: process.env.OAUTH_APP_ID,
    secret: process.env.OAUTH_APP_PASSWORD
  },
  auth: {
    tokenHost: process.env.OAUTH_AUTHORITY,
    authorizePath: process.env.OAUTH_AUTHORIZE_ENDPOINT,
    tokenPath: process.env.OAUTH_TOKEN_ENDPOINT
  }
});

Then, update the signInComplete function to create an AccessToken from the raw tokens passed in and store that in the user storage. Replace the existing signInComplete function with the following.

async function signInComplete(iss, sub, profile, accessToken, refreshToken, params, done) {
  if (!profile.oid) {
    return done(new Error("No OID found in user profile."), null);
  }

  try{
    const user = await graph.getUserDetails(accessToken);

    if (user) {
      // Add properties to profile
      profile['email'] = user.mail ? user.mail : user.userPrincipalName;
    }
  } catch (err) {
    done(err, null);
  }

  // Create a simple-oauth2 token from raw tokens
  let oauthToken = oauth2.accessToken.create(params);

  // Save the profile and tokens in user storage
  users[profile.oid] = { profile, oauthToken };
  return done(null, users[profile.oid]);
}

Update the callback route in ./routes/auth.js to remove the req.flash line with the access token. The callback route should look like the following.

router.post('/callback',
  function(req, res, next) {
    passport.authenticate('azuread-openidconnect',
      {
        response: res,
        failureRedirect: '/',
        failureFlash: true
      }
    )(req,res,next);
  },
  function(req, res) {
    res.redirect('/');
  }
);

Restart the server and go through the sign-in process. You should end up back on the home page, but the UI should change to indicate that you are signed-in.

A screenshot of the home page after signing in

Click the user avatar in the top right corner to access the Sign Out link. Clicking Sign Out resets the session and returns you to the home page.

A screenshot of the dropdown menu with the Sign Out link

Refreshing tokens

At this point your application has an access token, which is sent in the Authorization header of API calls. This is the token that allows the app to access the Microsoft Graph on the user's behalf.

However, this token is short-lived. The token expires an hour after it is issued. This is where the refresh token becomes useful. The refresh token allows the app to request a new access token without requiring the user to sign in again.

To manage this, create a new file in the root of the project named tokens.js to hold token management functions. Add the following code.

module.exports = {
  getAccessToken: async function(req) {
    if (req.user) {
      // Get the stored token
      var storedToken = req.user.oauthToken;

      if (storedToken) {
        if (storedToken.expired()) {
          // refresh token
          var newToken = await storedToken.refresh();

          // Update stored token
          req.user.oauthToken = newToken;
          return newToken.token.access_token;
        }

        // Token still valid, just return it
        return storedToken.token.access_token;
      }
    }
  }
};

This method first checks if the access token is expired or close to expiring. If it is, then it uses the refresh token to get new tokens, then updates the cache and returns the new access token. You'll use this method whenever you need to get the access token out of storage.

Exercise 4: Extend the app for Microsoft Graph

In this exercise you will incorporate the Microsoft Graph into the application. For this application, you will use the microsoft-graph-client library to make calls to Microsoft Graph.

Get calendar events from Outlook

Start by adding a new method to the ./graph.js file to get the events from the calendar. Add the following function inside the module.exports in ./graph.js.

getEvents: async function(accessToken) {
  const client = getAuthenticatedClient(accessToken);

  const events = await client
    .api('/me/events')
    .select('subject,organizer,start,end')
    .orderby('createdDateTime DESC')
    .get();

  return events;
}

Consider what this code is doing.

  • The URL that will be called is /me/events.
  • The select method limits the fields returned for each events to just those the view will actually use.
  • The orderby method sorts the results by the date and time they were created, with the most recent item being first.

Create a new file in the ./routes directory named calendar.js, and add the following code.

var express = require('express');
var router = express.Router();
var tokens = require('../tokens.js');
var graph = require('../graph.js');

/* GET /calendar */
router.get('/',
  async function(req, res) {
    if (!req.isAuthenticated()) {
      // Redirect unauthenticated requests to home page
      res.redirect('/')
    } else {
      let params = {
        active: { calendar: true }
      };

      // Get the access token
      var accessToken;
      try {
        accessToken = await tokens.getAccessToken(req);
      } catch (err) {
        res.json(err);
      }

      if (accessToken && accessToken.length > 0) {
        try {
          // Get the events
          var events = await graph.getEvents(accessToken);

          res.json(events.value);
        } catch (err) {
          res.json(err);
        }
      }
    }
  }
);

module.exports = router;

Update ./app.js to use this new route. Add the following line before the var app = express(); line.

var calendarRouter = require('./routes/calendar');

Then add the following line after the app.use('/auth', authRouter); line.

app.use('/calendar', calendarRouter);

Now you can test this. Sign in and click the Calendar link in the nav bar. If everything works, you should see a JSON dump of events on the user's calendar.

Display the results

Now you can add a view to display the results in a more user-friendly manner. First, add the following code in ./app.js after the app.set('view engine', 'hbs'); line.

var hbs = require('hbs');
var moment = require('moment');
// Helper to format date/time sent by Graph
hbs.registerHelper('eventDateTime', function(dateTime){
  return moment(dateTime).format('M/D/YY h:mm A');
});

This implements a Handlebars helper to format the ISO 8601 date returned by Microsoft Graph into something more human-friendly.

Create a new file in the ./views directory named calendar.hbs and add the following code.

<h1>Calendar</h1>
<table class="table">
  <thead>
    <tr>
      <th scope="col">Organizer</th>
      <th scope="col">Subject</th>
      <th scope="col">Start</th>
      <th scope="col">End</th>
    </tr>
  </thead>
  <tbody>
    {{#each events}}
      <tr>
        <td>{{this.organizer.emailAddress.name}}</td>
        <td>{{this.subject}}</td>
        <td>{{eventDateTime this.start.dateTime}}</td>
        <td>{{eventDateTime this.end.dateTime}}</td>
      </tr>
    {{/each}}
  </tbody>
</table>

That will loop through a collection of events and add a table row for each one. Now update the route in ./routes/calendar.js to use this view. Replace the existing route with the following code.

router.get('/',
  async function(req, res) {
    if (!req.isAuthenticated()) {
      // Redirect unauthenticated requests to home page
      res.redirect('/')
    } else {
      let params = {
        active: { calendar: true }
      };

      // Get the access token
      var accessToken;
      try {
        accessToken = await tokens.getAccessToken(req);
      } catch (err) {
        req.flash('error_msg', {
          message: 'Could not get access token. Try signing out and signing in again.',
          debug: JSON.stringify(err)
        });
      }

      if (accessToken && accessToken.length > 0) {
        try {
          // Get the events
          var events = await graph.getEvents(accessToken);
          params.events = events.value;
        } catch (err) {
          req.flash('error_msg', {
            message: 'Could not fetch events',
            debug: JSON.stringify(err)
          });
        }
      }

      res.render('calendar', params);
    }
  }
);

Save your changes, restart the server, and sign in to the app. Click on the Calendar link and the app should now render a table of events.

A screenshot of the table of events