Skip to main content

NPM Library

Jackson is available as an npm package that can be integrated into any web application framework (like Express.js for example). Please file an issue or submit a PR if you encounter any issues with your choice of framework.

npm i @boxyhq/saml-jackson

Integrating SAML Jackson with a Node.js app involves the following steps.

See the GitHub repo to see the source code for the Express integration

Express.js example

Requirements

  • Node.js version 14 or newer
  • A supported database
  • An Express.js based app to add SAML Jackson

Install SAML Jackson library

npm i --save @boxyhq/saml-jackson

Add Express Routes

// express
const express = require('express');
const router = express.Router();
const cors = require('cors'); // needed if you are calling the token userinfo endpoints from the frontend

// Set the required options. Refer to `Environment Variables` for the full list
const opts = {
externalUrl: 'https://my-cool-app.com',
samlAudience: 'https://my-cool-app.com',
samlPath: '/sso/oauth/saml',
db: {
engine: 'mongo',
url: 'mongodb://localhost:27017/my-cool-app',
},
};

let apiController;
let oauthController;
let logoutController;
// Please note that the initialization of @boxyhq/saml-jackson is async, you cannot run it at the top level
// Run this in a function where you initialize the express server.
async function init() {
const ret = await require('@boxyhq/saml-jackson').controllers(opts);
apiController = ret.apiController;
oauthController = ret.oauthController;
logoutController = ret.logoutController;
}
  • Add your app base URL as externalUrl
  • samlPath becomes part of the ACS URL. The ACS URL is an endpoint on the SP where the IdP will redirect to with its authentication response. For example: If externalUrl is http://localhost, and samlPath is /sso/acs, the ASC URL will be http://localhost/sso/acs

Add SAML Config API route

API Reference

// express.js middlewares are needed to parse json and x-www-form-urlencoded
router.use(express.json());
router.use(express.urlencoded({ extended: true }));

// SAML config API. You should pass this route through your authentication checks, do not expose this on the public interface without proper authentication in place.
router.post('/api/v1/saml/config', async (req, res) => {
try {
// apply your authentication flow (or ensure this route has passed through your auth middleware)
...

// only when properly authenticated, call the config function
res.json(await apiController.config(req.body));
} catch (err) {
res.status(500).json({
error: err.message,
});
}
});
// update config
router.patch('/api/v1/saml/config', async (req,res) => {
try {
// apply your authentication flow (or ensure this route has passed through your auth middleware)
...

// only when properly authenticated, call the config function
res.json(await apiController.updateConfig(req.body));
} catch (err) {
res.status(500).json({
error: err.message,
});
}
})
// fetch config
router.get('/api/v1/saml/config', async (req, res) => {
try {
// apply your authentication flow (or ensure this route has passed through your auth middleware)
...

// only when properly authenticated, call the config function
res.json(await apiController.getConfig(req.query));
} catch (err) {
res.status(500).json({
error: err.message,
});
}
});
// delete config
router.delete('/api/v1/saml/config', async (req, res) => {
try {
// apply your authentication flow (or ensure this route has passed through your auth middleware)
...

// only when properly authenticated, call the config function
await apiController.deleteConfig(req.body);
res.status(200).end();
} catch (err) {
res.status(500).json({
error: err.message,
});
}
});

OAuth: Authorize URL

The OAuth flow begins with redirecting your user to the authorize URL. The response contains the redirect_url to which you should redirect the user.

API Reference

// OAuth 2.0 flow
router.get('/oauth/authorize', async (req, res) => {
try {
const { redirect_url } = await oauthController.authorize(req.query);

res.redirect(redirect_url);
} catch (err) {
const { message, statusCode = 500 } = err;

res.status(statusCode).send(message);
}
});

Handle SAML Response

Add a method to handle the SAML Response from IdP.

IdP-initiated flow

To enable IdP-initiated SAML flow set https://boxyhq.com/docs/jackson/deploy/env-variables#idp_enabled. If idpDiscoveryPath is not set then always the first config will be chosen in case of multiple matches.

If oauthController.samlResponse returns app_select_form with no redirect_url, then we have hit the case where the IdP-initiated flow has multiple matches for the same IdP. Users can select an app and the flow is resumed with the idp_hint containing the user selection. For reference on how to add an IdP selection page, see: https://github.com/boxyhq/jackson/blob/main/pages/idp/select.tsx

info

SAML Response - IdP issues an HTTP POST request to SP's Assertion Consumer Service (ACS URL) with 2 fields SAMLResponse and RelayState.

router.post('/oauth/saml', async (req, res) => {
try {
const { redirect_url, app_select_form } =
await oauthController.samlResponse(req.body);

if (redirect_url) {
res.redirect(302, redirect_url);
} else {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(app_select_form);
}
} catch (err) {
const { message, statusCode = 500 } = err;

res.status(statusCode).send(message);
}
});

OAuth: Code Exchange

The code can then be exchanged for a token by making the following request. You should validate that the state matches the one you sent in the authorize request.

API Reference

router.post('/oauth/token', cors(), async (req, res) => {
try {
const result = await oauthController.token(req.body);

res.json(result);
} catch (err) {
const { message, statusCode = 500 } = err;

res.status(statusCode).send(message);
}
});

OAuth: Get User Profile

The short-lived access token can now be used to request the user's profile.

API Reference

router.get('/oauth/userinfo', async (req, res) => {
try {
let token = extractAuthToken(req);

// check for query param
if (!token) {
token = req.query.access_token;
}

if (!token) {
res.status(401).json({ message: 'Unauthorized' });
}

const profile = await oauthController.userInfo(token);

res.json(profile);
} catch (err) {
const { message, statusCode = 500 } = err;

res.status(statusCode).json({ message });
}
});

// set the router
app.use('/sso', router);

SLO: Create Logout Request

Create the logout request by calling the method createRequest().

router.get('/logout', async (req, res, next) => {
const { logoutUrl, logoutForm } = await logoutController.createRequest({
nameId: 'google-oauth2|108149256146623609101',
tenant: 'boxyhq.com',
product: 'demo',
redirectUrl: 'http://localhost:3000',
});

// HTTP-Redirect binding
if (logoutUrl) {
return res.redirect(logoutUrl);
}

// HTTP-POST binding
if (logoutForm) {
return res.send(logoutForm);
}
});

SLO: Handle the Response

IdP will send a response back to a specific URL. You need to register this URL on the IdP to handle the response properly.

router.post('/logout/callback', async (req, res, next) => {
const { SAMLResponse, RelayState } = req.body;

const { redirectUrl } = await logoutController.handleResponse({
SAMLResponse,
RelayState,
});

return res.redirect(redirectUrl);
});