Skip to content

Commit

Permalink
refactor(service): users/auth: migrate saml ingress protocol to trype…
Browse files Browse the repository at this point in the history
…script and new auth scheme
  • Loading branch information
restjohn committed Nov 15, 2024
1 parent 3acf16b commit 1f82c17
Showing 1 changed file with 106 additions and 234 deletions.
340 changes: 106 additions & 234 deletions service/src/ingress/ingress.protocol.saml.ts
Original file line number Diff line number Diff line change
@@ -1,251 +1,123 @@
const SamlStrategy = require('@node-saml/passport-saml').Strategy
, log = require('winston')
, User = require('../models/user')
, Role = require('../models/role')
, TokenAssertion = require('./verification').TokenAssertion
, api = require('../api')
, AuthenticationInitializer = require('./index')
import express from 'express'
import { Authenticator } from 'passport'
import { SamlConfig, Strategy as SamlStrategy, VerifyWithRequest } from '@node-saml/passport-saml'
import { IdentityProvider, IdentityProviderUser } from './ingress.entities'
import { IngressProtocolWebBinding, IngressResponseType } from './ingress.protocol.bindings'

function configure(strategy) {
log.info('Configuring ' + strategy.title + ' authentication');

const options = {
path: `/auth/${strategy.name}/callback`,
entryPoint: strategy.settings.entryPoint,
cert: strategy.settings.cert,
issuer: strategy.settings.issuer
}
if (strategy.settings.privateKey) {
options.privateKey = strategy.settings.privateKey;
}
if (strategy.settings.decryptionPvk) {
options.decryptionPvk = strategy.settings.decryptionPvk;
}
if (strategy.settings.signatureAlgorithm) {
options.signatureAlgorithm = strategy.settings.signatureAlgorithm;
}
if(strategy.settings.audience) {
options.audience = strategy.settings.audience;
}
if(strategy.settings.identifierFormat) {
options.identifierFormat = strategy.settings.identifierFormat;
}
if(strategy.settings.acceptedClockSkewMs) {
options.acceptedClockSkewMs = strategy.settings.acceptedClockSkewMs;
}
if(strategy.settings.attributeConsumingServiceIndex) {
options.attributeConsumingServiceIndex = strategy.settings.attributeConsumingServiceIndex;
}
if(strategy.settings.disableRequestedAuthnContext) {
options.disableRequestedAuthnContext = strategy.settings.disableRequestedAuthnContext;
}
if(strategy.settings.authnContext) {
options.authnContext = strategy.settings.authnContext;
}
if(strategy.settings.forceAuthn) {
options.forceAuthn = strategy.settings.forceAuthn;
}
if(strategy.settings.skipRequestCompression) {
options.skipRequestCompression = strategy.settings.skipRequestCompression;
}
if(strategy.settings.authnRequestBinding) {
options.authnRequestBinding = strategy.settings.authnRequestBinding;
}
if(strategy.settings.RACComparison) {
options.RACComparison = strategy.settings.RACComparison;
}
if(strategy.settings.providerName) {
options.providerName = strategy.settings.providerName;
}
if(strategy.settings.idpIssuer) {
options.idpIssuer = strategy.settings.idpIssuer;
type SamlProfileKeys = {
id?: string
email?: string
displayName?: string
}

type SamlProtocolSettings =
Pick<
SamlConfig,
| 'path'
| 'entryPoint'
| 'cert'
| 'issuer'
| 'privateKey'
| 'decryptionPvk'
| 'signatureAlgorithm'
| 'audience'
| 'identifierFormat'
| 'acceptedClockSkewMs'
| 'attributeConsumingServiceIndex'
| 'disableRequestedAuthnContext'
| 'authnContext'
| 'forceAuthn'
| 'skipRequestCompression'
| 'authnRequestBinding'
| 'racComparison'
| 'providerName'
| 'idpIssuer'
| 'validateInResponseTo'
| 'requestIdExpirationPeriodMs'
| 'logoutUrl'
>
& {
profile: SamlProfileKeys
}

function copyProtocolSettings(from: SamlProtocolSettings): SamlProtocolSettings {
const copy = { ...from }
copy.profile = { ...from.profile }
return copy
}

function applyDefaultProtocolSettings(idp: IdentityProvider): SamlProtocolSettings {
const settings = copyProtocolSettings(idp.protocolSettings as SamlProtocolSettings)
if (!settings.profile) {
settings.profile = {}
}
if(strategy.settings.validateInResponseTo) {
options.validateInResponseTo = strategy.settings.validateInResponseTo;
if (!settings.profile.displayName) {
settings.profile.displayName = 'email'
}
if(strategy.settings.requestIdExpirationPeriodMs) {
options.requestIdExpirationPeriodMs = strategy.settings.requestIdExpirationPeriodMs;
if (!settings.profile.email) {
settings.profile.email = 'email'
}
if(strategy.settings.logoutUrl) {
options.logoutUrl = strategy.settings.logoutUrl;
if (!settings.profile.id) {
settings.profile.id = 'uid'
}
return settings
}

AuthenticationInitializer.passport.use(new SamlStrategy(options, function (profile, done) {
const uid = profile[strategy.settings.profile.id];

if (!uid) {
log.warn('Failed to find property uid. SAML profile keys ' + Object.keys(profile));
return done('Failed to load user id from SAML profile');
}

// TODO: users-next
User.getUserByAuthenticationStrategy(strategy.type, uid, function (err, user) {
if (err) return done(err);

if (!user) {
// Create an account for the user
Role.getRole('USER_ROLE', function (err, role) {
if (err) return done(err);

const user = {
username: uid,
displayName: profile[strategy.settings.profile.displayName],
email: profile[strategy.settings.profile.email],
active: false,
roleId: role._id,
authentication: {
type: strategy.name,
id: uid,
authenticationConfiguration: {
name: strategy.name
}
}
};
// TODO: users-next
new api.User().create(user).then(newUser => {
if (!newUser.authentication.authenticationConfiguration.enabled) {
log.warn(newUser.authentication.authenticationConfiguration.title + " authentication is not enabled");
return done(null, false, { message: 'Authentication method is not enabled, please contact a MAGE administrator for assistance.' });
}
return done(null, newUser);
}).catch(err => done(err));
});
} else if (!user.active) {
return done(null, user, { message: "User is not approved, please contact your MAGE administrator to approve your account." });
} else if (!user.authentication.authenticationConfiguration.enabled) {
log.warn(user.authentication.authenticationConfiguration.title + " authentication is not enabled");
return done(null, user, { message: 'Authentication method is not enabled, please contact a MAGE administrator for assistance.' });
} else {
return done(null, user);
}
});
}));

function authenticate(req, res, next) {
AuthenticationInitializer.passport.authenticate(strategy.name, function (err, user, info = {}) {
if (err) {
console.error('saml: authentication error', err);
return next(err);
}

req.user = user;

// For inactive or disabled accounts don't generate an authorization token
if (!user.active || !user.enabled) {
log.warn('Failed user login attempt: User ' + user.username + ' account is inactive or disabled.');
return next();
export function createWebBinding(idp: IdentityProvider, passport: Authenticator, baseUrlPath: string): IngressProtocolWebBinding {
const { profile: profileKeys, ...settings } = applyDefaultProtocolSettings(idp)
// TODO: this will need the the saml callback override change
settings.path = `${baseUrlPath}/callback`
const samlStrategy = new SamlStrategy(settings,
(function samlSignIn(req, profile, done) {
if (!profile) {
return done(new Error('missing saml profile'))
}

if (!user.authentication.authenticationConfigurationId) {
log.warn('Failed user login attempt: ' + user.authentication.type + ' is not configured');
return next();
const uid = profile[profileKeys.id!]
if (!uid || typeof uid !== 'string') {
return done(new Error(`saml profile missing id for key ${profileKeys.id}`))
}

if (!user.authentication.authenticationConfiguration.enabled) {
log.warn('Failed user login attempt: Authentication ' + user.authentication.authenticationConfiguration.title + ' is disabled.');
return next();
const idpAccount: IdentityProviderUser = {
username: uid,
displayName: profile[profileKeys.displayName!] as string,
email: profile[profileKeys.email!] as string | undefined,
phones: [],
}

// DEPRECATED session authorization, remove req.login which creates session in next version
req.login(user, function (err) {
if (err) {
return next(err);
const webUser: Pick<Express.User, 'admittingFromIdentityProvider'> = {
admittingFromIdentityProvider: {
idpName: idp.name,
account: idpAccount,
}
AuthenticationInitializer.tokenService.generateToken(user._id.toString(), TokenAssertion.Authorized, 60 * 5)
.then(token => {
req.token = token;
req.user = user;
req.info = info
next();
}).catch(err => {
next(err);
});
});
})(req, res, next);
}

AuthenticationInitializer.app.post(
`/auth/${strategy.name}/callback`,
authenticate,
function (req, res) {
let state = {};
try {
state = JSON.parse(req.body.RelayState)
} catch (ignore) {
console.warn('saml: error parsing RelayState', ignore)
}

if (state.initiator === 'mage') {
if (state.client === 'mobile') {
let uri;
if (!req.user.active || !req.user.enabled) {
uri = `mage://app/invalid_account?active=${req.user.active}&enabled=${req.user.enabled}`;
} else {
uri = `mage://app/authentication?token=${req.token}`
}

res.redirect(uri);
} else {
res.render('authentication', { host: req.getRoot(), login: { token: req.token, user: req.user } });
try {
const relayState = JSON.parse(req.body.RelayState) || {}
if (!relayState) {
return done(new Error('missing saml relay state'))
}
} else {
if (req.user.active && req.user.enabled) {
res.redirect(`/#/signin?strategy=${strategy.name}&action=authorize-device&token=${req.token}`);
} else {
const action = !req.user.active ? 'inactive-account' : 'disabled-account';
res.redirect(`/#/signin?strategy=${strategy.name}&action=${action}`);
if (relayState.initiator !== 'mage') {
return done(new Error(`invalid saml relay state initiator: ${relayState.initiator}`))
}
webUser.admittingFromIdentityProvider!.flowState = relayState.flowState
}
}
);
}

function setDefaults(strategy) {
if (!strategy.settings.profile) {
strategy.settings.profile = {};
}
if (!strategy.settings.profile.displayName) {
strategy.settings.profile.displayName = 'email';
}
if (!strategy.settings.profile.email) {
strategy.settings.profile.email = 'email';
}
if (!strategy.settings.profile.id) {
strategy.settings.profile.id = 'uid';
catch (err) {
return done(err as Error)
}
done(null, webUser)
}) as VerifyWithRequest,
(function samlSignOut() {
console.warn('saml sign out unimplemented')
}) as VerifyWithRequest
)
const handleIngressFlowRequest = express.Router()
.post('/callback',
passport.authenticate(samlStrategy),
)
return {
ingressResponseType: IngressResponseType.Redirect,
beginIngressFlow(req, res, next, flowState): any {
const RelayState = JSON.stringify({ initiator: 'mage', flowState })
passport.authenticate(samlStrategy, { additionalParams: { RelayState } } as any)(req, res, next)
},
handleIngressFlowRequest
}
}

function initialize(strategy) {
const app = AuthenticationInitializer.app;
const passport = AuthenticationInitializer.passport;
// const provision = AuthenticationInitializer.provision;

setDefaults(strategy);
configure(strategy);

// function parseLoginMetadata(req, res, next) {
// req.loginOptions = {
// userAgent: req.headers['user-agent'],
// appVersion: req.param('appVersion')
// };

// next();
// }
app.get(
'/auth/' + strategy.name + '/signin',
function (req, res, next) {
const state = {
initiator: 'mage',
client: req.query.state
};

passport.authenticate(strategy.name, {
additionalParams: { RelayState: JSON.stringify(state) }
})(req, res, next);
}
);
}

module.exports = {
initialize
}

0 comments on commit 1f82c17

Please sign in to comment.