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.
This commit is contained in:
Eric 2025-01-03 10:14:13 -06:00 committed by GitHub
parent b193f2dc1c
commit ecab28b000
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 215 additions and 9 deletions

View file

@ -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,
};

View file

@ -18,7 +18,9 @@ module.exports = {
fn: async function () {
return {};
return {
replaceBuiltInAuthWithEntra: (sails.config.custom.entraClientSecret !== undefined),
};
}

View file

@ -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('/');
}
};

View file

@ -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);
},
},
},
};
};

View file

@ -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();
}

View file

@ -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.
// ||
//--------------------------------------------------------------------------
};

View file

@ -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' },
};

View file

@ -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",

View file

@ -57,6 +57,8 @@
<a class="nav-item nav-link ml-3 mr-3 d-flex align-items-center" href="/scripts">Scripts</a>
<a class="nav-item nav-link ml-3 mr-3 d-flex align-items-center" href="/software">Software</a>
<% if(me) { %>
<a class="nav-item nav-link ml-3 d-flex align-items-center" href="/account">Account</a>
<a class="nav-item nav-link ml-3 d-flex align-items-center" href="/logout">Sign out</a>
<% } else { %>
<a class="nav-item nav-link ml-3 d-flex align-items-center mr-2" href="/login">Log in</a>
@ -68,6 +70,7 @@
<a class="dropdown-item nav-link" href="/dashboard">Configuration profiles</a>
<a class="dropdown-item nav-link" href="/patch-progress">Scripts</a>
<% if(me) { %>
<a class="dropdown-item nav-link" href="/account">Account</a>
<a class="dropdown-item nav-link" href="/logout">Sign out</a>
<% } else { %>
<a class="dropdown-item nav-link" href="/login">Log in</a>

View file

@ -1,5 +1,4 @@
<div id="account-overview" v-cloak>
<account-notification-banner></account-notification-banner>
<div class="container pt-5 pb-5">
<h1>My account</h1>
<hr/>
@ -24,18 +23,18 @@
<span v-if="me.emailStatus === 'unconfirmed' || me.emailStatus === 'change-requested'" class="badge badge-pill badge-warning">Unverified</span>
</div>
</div>
<hr/>
<div class="row mb-3">
<hr/ v-if="!replaceBuiltInAuthWithEntra">
<div class="row mb-3" v-if="!replaceBuiltInAuthWithEntra">
<div class="col-sm-6">
<h4>Password</h4>
</div>
<div class="col-sm-6">
<span class="float-sm-right">
<a style="width: 150px" class="btn btn-sm btn-outline-info" href="/account/password">Change password</a>
<a style="width: 150px" class="btn btn-sm btn-outline-info" :disabled="replaceBuiltInAuthWithEntra" href="/account/password">Change password</a>
</span>
</div>
</div>
<div class="row">
<div class="row" v-if="!replaceBuiltInAuthWithEntra">
<div class="col-3">Password:</div>
<div class="col"><strong>••••••••••</strong></div>
</div>

View file

@ -1,5 +1,4 @@
<div id="edit-profile" v-cloak>
<account-notification-banner></account-notification-banner>
<div class="container pt-5 pb-5">
<h1>Update personal info</h1>
<hr/>
@ -14,9 +13,10 @@
</div>
<div class="col-sm-6">
<div class="form-group">
<label for="email-address">Email address</label>
<input class="form-control" id="email-address" name="email-address" type="email" :class="[formErrors.emailAddress ? 'is-invalid' : '']" v-model.trim="formData.emailAddress" placeholder="sturgeon@example.com" autocomplete="email">
<label for="email-address">Email address </label>
<input class="form-control" id="email-address" name="email-address" type="email" :disabled="replaceBuiltInAuthWithEntra" :class="[formErrors.emailAddress ? 'is-invalid' : '']" v-model.trim="formData.emailAddress" placeholder="sturgeon@example.com" autocomplete="email">
<div class="invalid-feedback" v-if="formErrors.emailAddress">Please enter a valid email address.</div>
<small class="text-danger " v-if="replaceBuiltInAuthWithEntra">Changing email addresses is currently not supported when using SSO.</small>
</div>
</div>
</div>