Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MSP Dashboard: Add Entra SSO Hook #24740

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ module.exports = {

fn: async function () {

return {};
return {
replaceBuiltInAuthWithEntra: (sails.config.custom.entraClientSecret !== undefined),
};

}

Expand Down
Original file line number Diff line number Diff line change
@@ -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('/');

}


};
118 changes: 118 additions & 0 deletions ee/bulk-operations-dashboard/api/hooks/entra-sso/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* 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) => {
// 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) => {
// 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)=>{
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);
},
},
},
};
};
8 changes: 8 additions & 0 deletions ee/bulk-operations-dashboard/api/policies/is-logged-in.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
15 changes: 15 additions & 0 deletions ee/bulk-operations-dashboard/config/custom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
// ||
//--------------------------------------------------------------------------


};
1 change: 1 addition & 0 deletions ee/bulk-operations-dashboard/config/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
};
2 changes: 2 additions & 0 deletions ee/bulk-operations-dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions ee/bulk-operations-dashboard/views/layouts/layout.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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/>
Expand All @@ -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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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/>
Expand All @@ -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="[email protected]" 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="[email protected]" 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>
Expand Down
Loading