From 6ac70675b5e6dace5fe5ef07096bec9ae6a6ec17 Mon Sep 17 00:00:00 2001 From: Farve Date: Wed, 13 Nov 2019 09:53:43 -0500 Subject: [PATCH] Azure Active Directory Support Adds Azure Active Directory as a provider. The `passport-azure-ad` strategy supports OIDC and BearerStrategy. This commit only supports the OpenID Connect protocol. --- README.md | 8 +++ docs/README.md | 2 + docs/active-directory-oidc.md | 24 +++++++ package.json | 1 + services/login.js | 2 +- strategies/active-directory-oidc.js | 83 +++++++++++++++++++++++ strategies/active-directory-oidc.test.js | 84 ++++++++++++++++++++++++ strategies/index.js | 1 + views/active-directory.svg | 28 ++++++++ 9 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 docs/active-directory-oidc.md create mode 100644 strategies/active-directory-oidc.js create mode 100644 strategies/active-directory-oidc.test.js create mode 100755 views/active-directory.svg diff --git a/README.md b/README.md index d154b85..a586f53 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ This module provides local authentication in Clay with a username and password a - Slack - Cognito - LDAP +- Azure Active Directory OIDC To get started editing in Clay, create a user account. The easiest way to do this is to create a `user.yml` file that looks like this: @@ -81,6 +82,13 @@ export LDAP_BIND_DN= export LDAP_BIND_CREDENTIALS= export LDAP_SEARCH_BASE= export LDAP_SEARCH_FILTER= + +export AD_OIDC_IDENTITY_METADATA= +export AD_OIDC_CLIENT_ID= +export AD_OIDC_RESPONSE_MODE= +export AD_OIDC_REDIRECT_URL= +export AD_OIDC_ALLOW_HTTP= +export AD_OIDC_SCOPE= ``` ## License diff --git a/docs/README.md b/docs/README.md index 1e4eef6..5bb83ab 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,3 +3,5 @@ This module only requires setting environment variables in your Clay instance for whichever provider(s) you want to use. A list of providers can be found below. - [Google](google.md) +- [Cognito](cognito.md) +- [Azure AD](active-directory-oidc.md) diff --git a/docs/active-directory-oidc.md b/docs/active-directory-oidc.md new file mode 100644 index 0000000..56533c9 --- /dev/null +++ b/docs/active-directory-oidc.md @@ -0,0 +1,24 @@ +# AWS Cognito OAuth + +A wrapper around the [Passport Azure Active Directory](http://www.passportjs.org/packages/passport-azure-ad/) package. + +### Configuration + +- `AD_OIDC_IDENTITY_METADATA` _(required)_: the metadata endpoint provided by the Microsoft Identity Portal. +- `AD_OIDC_CLIENT_ID` _(required)_: the client ID of your application in AAD (Azure Active Directory). +- `AD_OIDC_RESPONSE_MODE` _(required)_: must be 'query' or 'form_post. +- `AD_OIDC_RESPONSE_TYPE` _(required)_: must be 'code', 'code id_token', 'id_token code' or 'id_token'. +- `AD_OIDC_REDIRECT_URL` _(required)_: Must be a https url string, unless you set AD_OIDC_ALLOW_HTTP to true. This is the reply URL registered in AAD for your app. +- `AD_OIDC_CLIENT_SECRET` _(conditional)_: When responseType is not id_token, we have to provide client credential to redeem the authorization code. +- `AD_OIDC_ALLOW_HTTP` _(conditional)_: required to set to true if you want to use http url. +- `AD_OIDC_VALIDATE_ISSUER` _(conditional)_: required to set to false if you don't want to validate issuer, default value is true. +- `AD_OIDC_ISB2C` _(conditional)_: required to set to true if you are using B2C tenant. +- `AD_OIDC_ISSUER` _(conditional)_: this can be a string or an array of string. +- `AD_OIDC_SCOPE` _(conditional)_: list of scope values (comma delimited) besides openid indicating the required scope of the access token for accessing the requested resource. +- `AD_OIDC_LOGGING_LEVEL` _(conditional)_: logging level. +- `AD_OIDC_LOGGING_NO_PII` _(conditional)_: if this is set to true, no personal information such as tokens and claims will be logged. +- `AD_OIDC_NONCE_LIFETIME` _(conditional)_: the lifetime of nonce in session in seconds. +- `AD_OIDC_NONCE_MAX_AMOUNT` _(conditional)_: the max amount of nonce you want to keep in session or cookies. +- `AD_OIDC_USE_COOKIE` _(conditional)_: passport-azure-ad saves state and nonce in session by default for validation purpose. +- `AD_OIDC_COOKIE_ENCRYPTION` _(conditional)_: if useCookieInsteadOfSession is set to true, you must provide cookieEncryptionKeys. +- `AD_OIDC_CLOCKSKEW` _(conditional)_: this value is the clock skew (in seconds) allowed in token validation. diff --git a/package.json b/package.json index 4618d88..aecec62 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "express-flash": "0.0.2", "express-session": "^1.15.6", "passport": "^0.3.2", + "passport-azure-ad": ">=2.0.1", "passport-cognito-oauth2": "^0.1.1", "passport-google-oauth": "^1.0.0", "passport-http-header-token": "^1.1.0", diff --git a/services/login.js b/services/login.js index 281447e..0c1cff0 100644 --- a/services/login.js +++ b/services/login.js @@ -10,7 +10,7 @@ const _each = require('lodash/each'), */ function compileLoginPage() { const tpl = utils.compileTemplate('login.handlebars'), - icons = ['clay-logo', 'twitter', 'google', 'slack', 'ldap', 'logout', 'cognito']; + icons = ['clay-logo', 'twitter', 'google', 'slack', 'ldap', 'logout', 'cognito', 'active-directory']; // add svgs to handlebars _each(icons, icon => { diff --git a/strategies/active-directory-oidc.js b/strategies/active-directory-oidc.js new file mode 100644 index 0000000..b1e2ac9 --- /dev/null +++ b/strategies/active-directory-oidc.js @@ -0,0 +1,83 @@ +'use strict'; + +const passport = require('passport'), + utils = require('../utils'), + OIDCStrategy = require('passport-azure-ad').OIDCStrategy, + { + verify, + getAuthUrl, + getPathOrBase, + getCallbackUrl, + generateStrategyName + } = require('../utils'); + +/** + * Active Directory authentication strategy + * + * @param {object} site + */ + +function createActiveDirectoryOIDCStrategy(site) { + passport.use( + `adoidc-${site.slug}`, + new OIDCStrategy({ + identityMetadata: process.env.AD_OIDC_IDENTITY_METADATA, + clientID: process.env.AD_OIDC_CLIENT_ID, + responseMode: process.env.AD_OIDC_RESPONSE_MODE, + responseType: process.env.AD_OIDC_RESPONSE_TYPE, + redirectUrl: process.env.AD_OIDC_REDIRECT_URL, + allowHttpForRedirectUrl: process.env.AD_OIDC_ALLOW_HTTP == 'true', + clientSecret: process.env.AD_OIDC_CLIENT_SECRET, + validateIssuer: process.env.AD_OIDC_VALIDATE_ISSUER, + isB2C: process.env.AD_OIDC_ISB2C, + issuer: process.env.AD_OIDC_ISSUER, + passReqToCallback: true, + scope: process.env.AD_OIDC_SCOPE ? process.env.AD_OIDC_SCOPE.split(',') : null, + loggingLevel: process.env.AD_OIDC_LOGGING_LEVEL, + loggingNoPII: process.env.AD_OIDC_LOGGING_NO_PII, + nonceLifetime: process.env.AD_OIDC_NONCE_LIFETIME, + nonceMaxAmount: process.env.AD_OIDC_NONCE_MAX_AMOUNT, + useCookieInsteadOfSession: process.env.AD_OIDC_USE_COOKIE, + cookieEncryptionKeys: process.env.AD_OIDC_COOKIE_ENCRYPTION, + clockSkew: process.env.AD_OIDC_CLOCKSKEW + }, verifyOIDC()) + ); +} + +/** + * Wraps verify function for active directory + * @returns {function} + */ +function verifyOIDC(){ + return function(req, iss, sub, profile, done) { + utils.verify({ + username: 'preferred_username', + name: 'name', + provider: 'adoidc' + })(req, null, null, profile._json, done); + }; +} + +/** + * add authorization routes to the router + * @param {express.Router} router + * @param {object} site + * @param {string} provider + */ +function addAuthRoutes(router, site, provider) { + const strategy = generateStrategyName(provider, site); + + router.get(`/_auth/${provider}`, passport.authenticate(strategy)); + + router.post(`/_auth/${provider}/callback`, passport.authenticate(strategy, { + failureRedirect: `${getAuthUrl(site)}/login`, + failureFlash: true, + successReturnToOrRedirect: getPathOrBase(site) + })); // redirect to previous page or site root +} + +module.exports = createActiveDirectoryOIDCStrategy; +module.exports.addAuthRoutes = addAuthRoutes; + +// For testing purposes +module.exports.verifyOIDC = verifyOIDC; diff --git a/strategies/active-directory-oidc.test.js b/strategies/active-directory-oidc.test.js new file mode 100644 index 0000000..7d10c22 --- /dev/null +++ b/strategies/active-directory-oidc.test.js @@ -0,0 +1,84 @@ +'use strict'; + +const _startCase = require('lodash/startCase'), + _includes = require('lodash/includes'), + passport = require('passport'), + filename = __filename.split('/').pop().split('.').shift(), + utils = require('../utils'), + db = require('../services/storage'), + lib = require(`./${filename}`); + +describe(_startCase(filename), function () { + describe('verifyOIDC', function () { + const fn = lib[this.description]; + + it('calls verify with a slightly different function signature', function (done) { + utils.verify = jest.fn(() => (req, token, tokenSecret, profile, cb) => cb()) // eslint-disable-line + db.get = jest.fn().mockResolvedValue({ username: 'foo' }); + const profile = { _json: { preferred_username: 'foo' }}; + fn()({}, 'foo', 'bar', profile, function () { + expect(utils.verify).toBeCalled(); + done(); + }); + }); + }); + + describe('createActiveDirectoryOIDCStrategy', function () { + const siteStub = { slug: 'foo' }; + + it('creates active directory OIDC strategy', function () { + passport.use = jest.fn(); + + process.env.AD_OIDC_IDENTITY_METADATA = 'https://foo.com'; + process.env.AD_OIDC_CLIENT_ID = 'abc123'; + process.env.AD_OIDC_CONSUMER_CLIENT = '456'; + process.env.AD_OIDC_RESPONSE_MODE = 'form_post'; + process.env.AD_OIDC_RESPONSE_TYPE = 'id_token'; + process.env.AD_OIDC_REDIRECT_URL = 'https://redirect.com'; + lib(siteStub); + + expect(passport.use).toBeCalled(); + }); + }); + + describe('createActiveDirectoryOIDCStrategy with scope', function () { + const siteStub = { slug: 'foo' }; + + it('creates active directory OIDC strategy', function () { + passport.use = jest.fn(); + + process.env.AD_OIDC_IDENTITY_METADATA = 'https://foo.com'; + process.env.AD_OIDC_CLIENT_ID = 'abc123'; + process.env.AD_OIDC_CONSUMER_CLIENT = '456'; + process.env.AD_OIDC_RESPONSE_MODE = 'form_post'; + process.env.AD_OIDC_RESPONSE_TYPE = 'id_token'; + process.env.AD_OIDC_REDIRECT_URL = 'https://redirect.com'; + process.env.AD_OIDC_SCOPE = 'test1,test2'; + lib(siteStub); + + expect(passport.use).toBeCalled(); + }); + }); + + describe('addAuthRoutes', function () { + const fn = lib[this.description], + paths = [], + router = { + get: function (path) { + // testing if the paths are added, + // we're checking the paths array after each test + paths.push(path); + }, + post: function(path) { + paths.push(path); + }, + use: jest.fn(), + }; + + it('adds active directory OIDC auth and callback routes', function () { + fn(router, {}, 'adoidc'); + expect(_includes(paths, '/_auth/adoidc')).toEqual(true); + expect(_includes(paths, '/_auth/adoidc/callback')).toEqual(true); + }); + }); +}); diff --git a/strategies/index.js b/strategies/index.js index 833ef34..df7f6ce 100644 --- a/strategies/index.js +++ b/strategies/index.js @@ -2,6 +2,7 @@ const STRATEGIES = { apikey: require('./key'), + adoidc: require('./active-directory-oidc'), cognito: require('./cognito'), google: require('./google'), ldap: require('./ldap'), diff --git a/views/active-directory.svg b/views/active-directory.svg new file mode 100755 index 0000000..33f5984 --- /dev/null +++ b/views/active-directory.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + +