SDK: Authorization code

Introduction

This article will guide you through creating a web application based on node.js express that will allow a user to authenticate using IBM Security Verify.

The full source code is available at the bottom of the page.

Prerequisites

You will need to have node.js installed on your system. Download node.js.

You will need a browser running on the same system as where the application runs (because it only listens on localhost by default).

You will need to have a custom application definition created in IBM Security Verify with the following settings:

  • Sign-on method: OpenID Connect 1.0
  • Enabled grant types: Authorization code
  • Application URL: http://localhost:3000
  • Redirect URL: http://localhost:3000/auth/callback

You can create this application as a tenant administrator or you can use the Developer Portal.

Set up node.js

Create a project directory and initialize node.js

Create a new directory on your system and then go into that directory. This is your project directory.
All subsequent commands in this guide should be run in the project directory.

To initialize the project directory for node.js, enter the following command:

npm init -y

Install dependencies

The following dependencies are required for the application:

This command will install them into the node_modules directory - and add them to the package.json file which is used to keep track of required packages:

npm install ibm-verify-sdk express express-session dotenv

Create .env file

The recommended way of storing environment-specific configuration (such as OAuth connection information) for a node.js application is to use an environment file. This is a file named .env in your project directory.

Using an environment file means that values are not hard coded into the source. Using the node package dotenv we can load the contents of the environment file into the process.env object.

Create a file named .env in your project directory and paste in the following content:

TENANT_URL=https://<tenant URL>
CLIENT_ID=<client id>
CLIENT_SECRET=<client secret>
APP_URL=http://localhost:3000
RESPONSE_TYPE=code
FLOW_TYPE=authorization
SCOPE=openid
SESSION_SECRET=somethinghardtoguess

You will need to complete the values for TENANT_URL, CLIENT_ID, and CLIENT_SECRET.

The TENANT_URL is the URL of your IBM Security Verify tenant. This will usually have the format: https://xxx.verify.ibm.com.

You will need to get the CLIENT_ID and CLIENT_SECRET from the application definition in IBM Security Verify.

Create server.js

Create the file server.js in the project directory. This will be your server application.

Import required packages

const express              = require('express');
const session              = require('express-session');
const OAuthContext         = require('ibm-verify-sdk').OAuthContext;

Load contents of .env into process.env

require('dotenv').config();

Setup express

Initialize the express server. Enable sessions. Listen on port 3000.

const app = express();

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: true
}));

let port = 3000;
app.listen(port, () => {
    console.log(`Server started.  Listening on port ${port}.`);
})

Instantiate Verify SDK OAuthContext

The context is generated by building a config object and then passing this to the OAuthContext constructor. Note that most of the configuration is being taken from variables that have been loaded from the environment file.

let config = {
    tenantUrl    : process.env.TENANT_URL,
    clientId     : process.env.CLIENT_ID,
    clientSecret : process.env.CLIENT_SECRET,
    redirectUri  : process.env.APP_URL + "/auth/callback",
    responseType : process.env.RESPONSE_TYPE,
    flowType     : process.env.FLOW_TYPE,
    scope        : process.env.SCOPE
};

let authClient = new OAuthContext(config);

Middleware function to require authentication

This middleware function checks for an authenticated session based on the existence of the token object in the session. If the token object is found, the next() call allows processing to continue.

If the token object is not found, The authenticate function is used to generate an authentication trigger URL and then the browser is redirected to this URL. This passes control to IBM Security Verify so that it can perform authentication. If successful, the browser will later be redirected back to the redirect route below.

The originally requested URL is stored in the session as target_url. The target_url will be used to redirect the user back to the requested target after the authentication flow completes.

async function authentication_required(req, res, next) {
  if (req.session.token) {
    next()
  } else {
    req.session.target_url = req.url;
    try {
      let url = await authClient.authenticate();
      res.redirect(url);
    } catch (error) {
      res.send(error);
    }
    return;
  }
}

OIDC redirect route

This route is called via a redirect after authentication at IBM Security Verify is complete.
The getToken function is used to retrieve tokens for the authenticated user from IBM Security Verify. The tokens are stored in the session token object for later use.

After successful completion, the user is redirected to the target_url stored in the session. This is set by the require_authentication function (see above). If a stored URL is not available, the user is redirected to /.

Note: It is useful to have an absolute expiry time for the returned access token. This is calculated from the current time and the expires_in property of the returned token object.

app.get('/auth/callback', async (req, res) => {
  try {
    let token = await authClient.getToken(req.url)
    token.expiry = new Date().getTime() + (token.expires_in * 1000);
    req.session.token = token;
    let target_url = req.session.target_url ? req.session.target_url : "/";
    res.redirect(target_url);
    delete req.session.target_url;
  } catch (error) {
    res.send("ERROR: " + error);
  };
});

Application logout route

This route triggers an application logout.
The revokeToken function is called twice - once to revoke the access token and once to revoke the refresh token. The req.session.destroy() removes all session data; this includes the token object.

If the logout is called with ?slo=true the browser is redirected to the Verify tenant logout page. Otherwise this is just a local logout and any session at the Verify tenant is left intact.

app.get('/logout', (req, res) => {
  if (req.session.token) {
    let token = req.session.token;
    if (token.access_token) authClient.revokeToken(token, "access_token");
    if (token.refresh_token) authClient.revokeToken(token, "refresh_token");
    req.session.destroy();
  }
  if (req.query.slo) {
    res.redirect(config.tenantUrl + '/idaas/mtfim/sps/idaas/logout');
  } else {
    res.send("Logged out");
  }
});

Utility function to process API responses

When calling functions that require a token, the Verify SDK returns an object that contains a response object and a token object. If the token object is populated, it is a refreshed token that replaces the one currently held. The response object contains the API response.

This utility function performs the required token check and returns the embedded response object. You'll see this function used in the homepage route to process the response from the userInfo call.

function process_response(response) {
  if (response.token && response.token.expires_in) {
    response.token.expiry = new Date().getTime() + (token.expires_in * 1000);
    req.session.token = response.token;
  }
  return response.response;
}

Application homepage route

This is the application home page. It invokes the authentication_required middleware function to enforce authentication. This means req.session.token will be populated.

Assuming the OIDC scope was included when the OAuthContext was initialized, req.session.token will include an id_token attribute which contains a signed JSON Web Token (JWT). This JWT could be validated and parsed to get user data.

However, rather than doing that here, the userInfo function of the OAuthContext is used to get identity information which is then displayed in the browser. The process_response utility function is used to process the response.

app.get('/', authentication_required, async (req, res) => {
  let userInfo = process_response(await authClient.userInfo(req.session.token));
  res.send(`<h1>Welcome ${userInfo.name}</h1>` +
    `<p>UserID: ${userInfo.preferred_username}</p>`);
});

Start the server and test

Start the server:

node server.js

Navigate to http://localhost:3000.

The authentication_required middleware function determines that no tokens are available. It uses the SDK to generate an authentication trigger URL and re-directs to it. Assuming the user is not already authenticated, IBM Security Verify displays a login page.

Login to IBM Security Verify. If login is successful you will be redirected back to the redirect URL of the example application. It will then obtain tokens from IBM Security Verify and redirect the (now authenticated) user back to the homepage.

The homepage route makes a call to the IBM Security Verify userInfo endpoint and uses the information returned to show the name and username of the authenticated user.

Navigate to http://localhost:3000/logout to logout. You will receive the response:

Logged out

At this point your local session has ended but you could re-establish it from your IBM Security Verify tenant if that session is still active. To logout of the IBM Security Verify session as well, navigate to http://localhost:3000/logout?slo=true

This time you are redirected to the logout page of your IBM Security Verify tenant which ends that session too.

Full source

The full source code below includes console.log statements so you can trace the calls being made in the application console.

Click here for the full source code.
// Imports
const express = require('express');
const session = require('express-session');
const OAuthContext = require('ibm-verify-sdk').OAuthContext;

// Load contents of .env into process.env
require('dotenv').config();

// Express setup
const app = express();

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: true
}));

let port = 3000;
app.listen(port, () => {
  console.log(`Server started.  Listening on port ${port}.`);
})

// Instantiate OAuthContext
let config = {
  tenantUrl: process.env.TENANT_URL,
  clientId: process.env.CLIENT_ID,
  clientSecret: process.env.CLIENT_SECRET,
  redirectUri: process.env.APP_URL + "/auth/callback",
  responseType: process.env.RESPONSE_TYPE,
  flowType: process.env.FLOW_TYPE,
  scope: process.env.SCOPE
};

let authClient = new OAuthContext(config);

// Middleware function to require authentication
// If token object found, pass to next function.
// If no token object found, generates OIDC
// authentication request and redirects user
async function authentication_required(req, res, next) {
  if (req.session.token) {
    next()
  } else {
    req.session.target_url = req.url;
    try {
      let url = await authClient.authenticate();
      console.log("** Calling: " + url);
      res.redirect(url);
    } catch (error) {
      res.send(error);
    }
    return;
  }
}

// OIDC redirect route
// user has authenticated through SV, now get the token
app.get('/auth/callback', async (req, res) => {
  try {
    console.log("** Response: " + req.url);
    console.log("** Calling token endpoint");
    let token = await authClient.getToken(req.url)
    console.log("** Response: " + JSON.stringify(token));
    token.expiry = new Date().getTime() + (token.expires_in * 1000);
    req.session.token = token;
    let target_url = req.session.target_url ? req.session.target_url : "/";
    res.redirect(target_url);
    delete req.session.target_url;
  } catch (error) {
    res.send("ERROR: " + error);
  };
});

// Logout route
app.get('/logout', (req, res) => {
  if (req.session.token) {
    let token = req.session.token;
    if (token.access_token) authClient.revokeToken(token, "access_token");
    if (token.refresh_token) authClient.revokeToken(token, "refresh_token");
    req.session.destroy();
  }
  if (req.query.slo) {
    res.redirect(config.tenantUrl + '/idaas/mtfim/sps/idaas/logout');
  } else {
    res.send("Logged out");
  }
});

// Utility function to parse API responses
// Checks and processes any refreshed token
// Returns enclosed API response.
function process_response(response) {
  if (response.token && response.token.expires_in) {
    console.log("** Refreshed token: " + JSON.stringify(response.token));
    response.token.expiry = new Date().getTime() + (token.expires_in * 1000);
    req.session.token = response.token;
  }
  return response.response;
}

// Home route - requires authentication
// Uses userInfo to get user information JSON from Verify
app.get('/', authentication_required, async (req, res) => {
  console.log("** Calling userInfo");
  let userInfo = process_response(await authClient.userInfo(req.session.token));
  console.log("** Response:" + JSON.stringify(userInfo));
  res.send(`<h1>Welcome ${userInfo.name}</h1>` +
    `<p>UserID: ${userInfo.preferred_username}</p>`);
});