From ecab28b000a21d9af0ed38aefa16241dbcf93d01 Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 3 Jan 2025 10:14:13 -0600 Subject: [PATCH] MSP Dashboard: Add Entra SSO Hook (#24740) Related to: #24688 Changes: - Added two new dependencies: `jsonwebtoken` and `@azure/msal-node` - Added a new hook: `entra-sso`. A hook that replaces the default authentication mechanism with Microsoft Entra SSO. - Added a new action: signup-sso-user-or-redirect. This action finds or creates user records for authenticated SSO users and attaches the user record to the user's session. - updated the is-logged-in policy to check if an SSO user's token is still valid. - Added a link to the account page to the app's header navigation. --- .../account/view-account-overview.js | 1 + .../controllers/account/view-edit-profile.js | 4 +- .../entrance/signup-sso-user-or-redirect.js | 48 +++++++ .../api/hooks/entra-sso/index.js | 127 ++++++++++++++++++ .../api/policies/is-logged-in.js | 8 ++ ee/bulk-operations-dashboard/config/custom.js | 15 +++ ee/bulk-operations-dashboard/config/routes.js | 1 + ee/bulk-operations-dashboard/package.json | 2 + .../views/layouts/layout.ejs | 3 + .../views/pages/account/account-overview.ejs | 9 +- .../views/pages/account/edit-profile.ejs | 6 +- 11 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 ee/bulk-operations-dashboard/api/controllers/entrance/signup-sso-user-or-redirect.js create mode 100644 ee/bulk-operations-dashboard/api/hooks/entra-sso/index.js diff --git a/ee/bulk-operations-dashboard/api/controllers/account/view-account-overview.js b/ee/bulk-operations-dashboard/api/controllers/account/view-account-overview.js index f5841f9981..06fceb94c9 100644 --- a/ee/bulk-operations-dashboard/api/controllers/account/view-account-overview.js +++ b/ee/bulk-operations-dashboard/api/controllers/account/view-account-overview.js @@ -21,6 +21,7 @@ module.exports = { // If billing features are enabled, include our configured Stripe.js // public key in the view locals. Otherwise, leave it as undefined. return { + replaceBuiltInAuthWithEntra: (sails.config.custom.entraClientSecret !== undefined), stripePublishableKey: sails.config.custom.enableBillingFeatures? sails.config.custom.stripePublishableKey : undefined, }; diff --git a/ee/bulk-operations-dashboard/api/controllers/account/view-edit-profile.js b/ee/bulk-operations-dashboard/api/controllers/account/view-edit-profile.js index baea0f7c20..017ce5dba5 100644 --- a/ee/bulk-operations-dashboard/api/controllers/account/view-edit-profile.js +++ b/ee/bulk-operations-dashboard/api/controllers/account/view-edit-profile.js @@ -18,7 +18,9 @@ module.exports = { fn: async function () { - return {}; + return { + replaceBuiltInAuthWithEntra: (sails.config.custom.entraClientSecret !== undefined), + }; } diff --git a/ee/bulk-operations-dashboard/api/controllers/entrance/signup-sso-user-or-redirect.js b/ee/bulk-operations-dashboard/api/controllers/entrance/signup-sso-user-or-redirect.js new file mode 100644 index 0000000000..8c3f090b9c --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/entrance/signup-sso-user-or-redirect.js @@ -0,0 +1,48 @@ +module.exports = { + + + friendlyName: 'Signup SSO user or redirect', + + + description: 'Looks up or creates user records for Entra SSO users, and attaches the database id to the requesting user\'s session.', + + + exits: { + redirect: { + responseType: 'redirect', + }, + }, + + + fn: async function () { + if(!this.req.session) {// If the requesting user does not have a session, redirect them to the login page. + throw {redirect: '/login'}; + } + // If the requesting user has a session, but it does not contain a ssoUserInformation object, we'll redirect them to the login page. + if (!this.req.session.ssoUserInformation) { + throw {redirect: '/login'}; + } + let ssoUserInfo = this.req.session.ssoUserInformation; + // Look for a user record with this sso user's email address. + let possibleUserRecordForThisEntraUser = await User.findOne({emailAddress: ssoUserInfo.unique_name}); + + if(possibleUserRecordForThisEntraUser) { + // If we found an existing user record that uses this Entra user's email address, we'll set the requesting session.userId to be the id of the database record. + this.req.session.userId = possibleUserRecordForThisEntraUser.id; + } else { + // If we did not find a user in the database for this Entra user, we'll create a new one. + let newUserRecord = await User.create({ + fullName: ssoUserInfo.given_name +' '+ssoUserInfo.family_name, + emailAddress: ssoUserInfo.unique_name, + password: await sails.helpers.passwords.hashPassword(ssoUserInfo.sub),// Note: this password cannot be changed. + apiToken: await sails.helpers.strings.uuid(), + }).fetch(); + this.req.session.userId = newUserRecord.id; + } + // Redirect the logged-in user to the homepage. + return this.res.redirect('/'); + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/hooks/entra-sso/index.js b/ee/bulk-operations-dashboard/api/hooks/entra-sso/index.js new file mode 100644 index 0000000000..677d582ebe --- /dev/null +++ b/ee/bulk-operations-dashboard/api/hooks/entra-sso/index.js @@ -0,0 +1,127 @@ +/** + * Module dependencies + */ +let { ConfidentialClientApplication } = require('@azure/msal-node'); +let jwt = require('jsonwebtoken'); + +/** + * Entra SSO Hook + */ +module.exports = function (sails) { + + let entraSSOClient; + return { + defaults: { + entraSSO: { + userModelIdentity: 'user', + }, + }, + + initialize: function (cb) { + if (!sails.config.custom.entraClientId) { + return cb(); + } + + sails.log('Entra SSO enabled. The built-in authorization mechanism will be disabled.'); + + // Throw errors if required config variables are missing. + if(!sails.config.custom.entraTenantId){ + throw new Error(`Missing config! No sails.config.custom.entraTenantId was configured. To replace this app's built-in authorization mechanism with Entra SSO, an entraTenantId value is required.`); + } + if(!sails.config.custom.entraClientSecret){ + throw new Error(`Missing config! No sails.config.custom.entraClientSecret was configured. To replace this app's built-in authorization mechanism with Entra SSO, an entraClientSecret value is required.`); + } + + // [?]: https://learn.microsoft.com/en-us/entra/external-id/customers/tutorial-web-app-node-sign-in-sign-out#create-msal-configuration-object + // Configure the SSO client application. + entraSSOClient = new ConfidentialClientApplication({ + auth: { + clientId: sails.config.custom.entraClientId, + authority: `https://login.microsoftonline.com/${sails.config.custom.entraTenantId}`, + clientSecret: sails.config.custom.entraClientSecret, + }, + }); + + var err; + // Validate `userModelIdentity` config + if (typeof sails.config.entraSSO.userModelIdentity !== 'string') { + sails.config.entraSSO.userModelIdentity = 'user'; + } + sails.config.entraSSO.userModelIdentity = sails.config.entraSSO.userModelIdentity.toLowerCase(); + // We must wait for the `orm` hook before acquiring our user model from `sails.models` + // because it might not be ready yet. + if (!sails.hooks.orm) { + err = new Error(); + err.code = 'E_HOOK_INITIALIZE'; + err.name = 'Entra SSO Hook Error'; + err.message = 'The "Entra SSO" hook depends on the "orm" hook- cannot load the "Entra SSO" hook without it!'; + return cb(err); + } + sails.after('hook:orm:loaded', ()=>{ + + // Look up configured user model + var UserModel = sails.models[sails.config.entraSSO.userModelIdentity]; + + if (!UserModel) { + err = new Error(); + err.code = 'E_HOOK_INITIALIZE'; + err.name = 'Entra SSO Hook Error'; + err.message = 'Could not load the Entra SSO hook because `sails.config.passport.userModelIdentity` refers to an unknown model: "'+sails.config.entraSSO.userModelIdentity+'".'; + if (sails.config.entraSSO.userModelIdentity === 'user') { + err.message += '\nThis option defaults to `user` if unspecified or invalid- maybe you need to set or correct it?'; + } + return cb(err); + } + cb(); + }); + }, + + routes: { + before: { + '/login': async (req, res, next) => { + if (!sails.config.custom.entraClientId) { + return next(); + } + // Get the sso login url and redirect the user + // [?]: https://learn.microsoft.com/en-us/javascript/api/%40azure/msal-node/confidentialclientapplication?view=msal-js-latest#@azure-msal-node-confidentialclientapplication-getauthcodeurl + let entraAuthorizationUrl = await entraSSOClient.getAuthCodeUrl({ + redirectUri: `${sails.config.custom.baseUrl}/authorization-code/callback`, + scopes: ['openid', 'profile', 'email', 'User.Read'], + }); + // Redirect the user to the SSO login url. + res.redirect(entraAuthorizationUrl); + }, + '/authorization-code/callback': async (req, res, next) => { + if (!sails.config.custom.entraClientId) { + return next(); + } + // Make sure there is a code query string. + let codeToGetToken = req.query.code; + if(!codeToGetToken){ + res.unauthorized(); + } + // [?]: https://learn.microsoft.com/en-us/javascript/api/%40azure/msal-node/confidentialclientapplication?view=msal-js-latest#@azure-msal-node-confidentialclientapplication-acquiretokenbycode + let responseFromEntra = await entraSSOClient.acquireTokenByCode({ + code: codeToGetToken, + redirectUri: `${sails.config.custom.baseUrl}/authorization-code/callback`, + scopes: ['openid', 'profile', 'email', 'User.Read'], + }); + // Decode the accessToken in the response from Entra. + let decodedToken = jwt.decode(responseFromEntra.accessToken); + // Set the decoded token as the user's ssoUserInformation in their session. + req.session.ssoUserInformation = decodedToken; + // Redirect the user to the signup-sso-user-or-redirect endpoint. + res.redirect('/entrance/signup-sso-user-or-redirect'); // Note: This action handles signing in/up users who authenticate through Microsoft Entra. + }, + '/logout': async(req, res, next)=>{ + if (!sails.config.custom.entraClientId) { + return next(); + } + let logoutUri = `https://login.microsoftonline.com/${sails.config.custom.entraTenantId}/oauth2/v2.0/logout?post_logout_redirect_uri=${sails.config.custom.baseUrl}/`; + delete req.session.userId; + res.redirect(logoutUri); + }, + }, + }, + }; +}; diff --git a/ee/bulk-operations-dashboard/api/policies/is-logged-in.js b/ee/bulk-operations-dashboard/api/policies/is-logged-in.js index 0c03b1a689..7c38e54a4a 100644 --- a/ee/bulk-operations-dashboard/api/policies/is-logged-in.js +++ b/ee/bulk-operations-dashboard/api/policies/is-logged-in.js @@ -16,6 +16,14 @@ module.exports = async function (req, res, proceed) { // > For more about where `req.me` comes from, check out this app's // > custom hook (`api/hooks/custom/index.js`). if (req.me) { + if(sails.config.custom.msalClientSecret){ + if (req.session.ssoUserInformation) { + let tokenExpiresAt = req.session.ssoUserInformation.exp * 1000; + if(tokenExpiresAt < Date.now() || req.session.ssoUserInformation.tid !== sails.config.custom.entraTenantId) { + return res.unauthorized(); + } + } + } return proceed(); } diff --git a/ee/bulk-operations-dashboard/config/custom.js b/ee/bulk-operations-dashboard/config/custom.js index a33b5d98cc..e9b1e0967b 100644 --- a/ee/bulk-operations-dashboard/config/custom.js +++ b/ee/bulk-operations-dashboard/config/custom.js @@ -107,4 +107,19 @@ module.exports.custom = { // [?] Here's how you get one: https://fleetdm.com/docs/using-fleet/fleetctl-cli#get-the-api-token-of-an-api-only-user // fleetApiToken: 'asdfasdfasdfasdf', + + /************************************************************************** + * * + * Entra SSO configuration * + * * + **************************************************************************/ + // entraTenantId: '...', // « The tenant ID of this application in the Microsoft Entra dashboard. + // entraClientId: '...', // « The Application (client) ID of this application in the Microsoft Entra dashboard. + // entraClientSecret: '...', //« The client secret for the application that has been created for this dashboard. + //-------------------------------------------------------------------------- + // /\ Configure these to replace the built-in authentication with Microsoft Entra SSO. + // || + //-------------------------------------------------------------------------- + + }; diff --git a/ee/bulk-operations-dashboard/config/routes.js b/ee/bulk-operations-dashboard/config/routes.js index 74c428effc..4f0b4b52f4 100644 --- a/ee/bulk-operations-dashboard/config/routes.js +++ b/ee/bulk-operations-dashboard/config/routes.js @@ -68,4 +68,5 @@ module.exports.routes = { 'POST /api/v1/software/edit-software': { action: 'software/edit-software' }, 'POST /api/v1/software/upload-software': { action: 'software/upload-software' }, 'GET /api/v1/get-labels': { action: 'get-labels' }, + 'GET /entrance/signup-sso-user-or-redirect': { action: 'entrance/signup-sso-user-or-redirect' }, }; diff --git a/ee/bulk-operations-dashboard/package.json b/ee/bulk-operations-dashboard/package.json index ca805a15af..8c417fb867 100644 --- a/ee/bulk-operations-dashboard/package.json +++ b/ee/bulk-operations-dashboard/package.json @@ -5,11 +5,13 @@ "description": "a Sails application", "keywords": [], "dependencies": { + "@azure/msal-node": "2.16.2", "@sailshq/connect-redis": "^6.1.3", "@sailshq/lodash": "^3.10.6", "@sailshq/socket.io-redis": "^6.1.2", "axios": "1.7.7", "form-data": "4.0.1", + "jsonwebtoken": "9.0.2", "sails": "^1.5.11", "sails-hook-apianalytics": "^2.0.6", "sails-hook-organics": "^2.2.2", diff --git a/ee/bulk-operations-dashboard/views/layouts/layout.ejs b/ee/bulk-operations-dashboard/views/layouts/layout.ejs index 6a8dba9199..e3ff9c8a89 100644 --- a/ee/bulk-operations-dashboard/views/layouts/layout.ejs +++ b/ee/bulk-operations-dashboard/views/layouts/layout.ejs @@ -57,6 +57,8 @@ Scripts Software <% if(me) { %> + + Account Sign out <% } else { %> Log in @@ -68,6 +70,7 @@ Configuration profiles Scripts <% if(me) { %> + Account Sign out <% } else { %> Log in diff --git a/ee/bulk-operations-dashboard/views/pages/account/account-overview.ejs b/ee/bulk-operations-dashboard/views/pages/account/account-overview.ejs index 2f5f3dc3d1..6035bbfd53 100644 --- a/ee/bulk-operations-dashboard/views/pages/account/account-overview.ejs +++ b/ee/bulk-operations-dashboard/views/pages/account/account-overview.ejs @@ -1,5 +1,4 @@
-

My account


@@ -24,18 +23,18 @@ Unverified
-
-
+
+ -
+
Password:
••••••••••
diff --git a/ee/bulk-operations-dashboard/views/pages/account/edit-profile.ejs b/ee/bulk-operations-dashboard/views/pages/account/edit-profile.ejs index 75589f176d..0d5ca7a6fc 100644 --- a/ee/bulk-operations-dashboard/views/pages/account/edit-profile.ejs +++ b/ee/bulk-operations-dashboard/views/pages/account/edit-profile.ejs @@ -1,5 +1,4 @@
-

Update personal info


@@ -14,9 +13,10 @@
- - + +
Please enter a valid email address.
+ Changing email addresses is currently not supported when using SSO.