diff --git a/patches/@node-saml+node-saml+4.0.5.patch b/patches/@node-saml+node-saml+4.0.5.patch
new file mode 100644
index 0000000000..81fd700b31
--- /dev/null
+++ b/patches/@node-saml+node-saml+4.0.5.patch
@@ -0,0 +1,23 @@
+diff --git a/node_modules/@node-saml/node-saml/lib/saml.js b/node_modules/@node-saml/node-saml/lib/saml.js
+index fba15b9..a5778cb 100644
+--- a/node_modules/@node-saml/node-saml/lib/saml.js
++++ b/node_modules/@node-saml/node-saml/lib/saml.js
+@@ -336,7 +336,8 @@ class SAML {
+ const requestOrResponse = request || response;
+ (0, utility_1.assertRequired)(requestOrResponse, "either request or response is required");
+ let buffer;
+- if (this.options.skipRequestCompression) {
++ // logout requestOrResponse must be compressed anyway
++ if (this.options.skipRequestCompression && operation !== "logout") {
+ buffer = Buffer.from(requestOrResponse, "utf8");
+ }
+ else {
+@@ -495,7 +496,7 @@ class SAML {
+ try {
+ xml = Buffer.from(container.SAMLResponse, "base64").toString("utf8");
+ doc = await (0, xml_1.parseDomFromString)(xml);
+- const inResponseToNodes = xml_1.xpath.selectAttributes(doc, "/*[local-name()='Response']/@InResponseTo");
++ const inResponseToNodes = xml_1.xpath.selectAttributes(doc, "/*[local-name()='Response' or local-name()='LogoutResponse']/@InResponseTo");
+ if (inResponseToNodes) {
+ inResponseTo = inResponseToNodes.length ? inResponseToNodes[0].nodeValue : null;
+ await this.validateInResponseTo(inResponseTo);
diff --git a/patches/ldapauth-fork+4.3.3.patch b/patches/ldapauth-fork+4.3.3.patch
new file mode 100644
index 0000000000..4d31210c9d
--- /dev/null
+++ b/patches/ldapauth-fork+4.3.3.patch
@@ -0,0 +1,64 @@
+diff --git a/node_modules/ldapauth-fork/lib/ldapauth.js b/node_modules/ldapauth-fork/lib/ldapauth.js
+index 85ecf36a8b..a7d07e0f78 100644
+--- a/node_modules/ldapauth-fork/lib/ldapauth.js
++++ b/node_modules/ldapauth-fork/lib/ldapauth.js
+@@ -69,6 +69,7 @@ function LdapAuth(opts) {
+ this.opts.bindProperty || (this.opts.bindProperty = 'dn');
+ this.opts.groupSearchScope || (this.opts.groupSearchScope = 'sub');
+ this.opts.groupDnProperty || (this.opts.groupDnProperty = 'dn');
++ this.opts.tlsStarted = false;
+
+ EventEmitter.call(this);
+
+@@ -108,21 +109,7 @@ function LdapAuth(opts) {
+ this._userClient.on('error', this._handleError.bind(this));
+
+ var self = this;
+- if (this.opts.starttls) {
+- // When starttls is enabled, this callback supplants the 'connect' callback
+- this._adminClient.starttls(this.opts.tlsOptions, this._adminClient.controls, function(err) {
+- if (err) {
+- self._handleError(err);
+- } else {
+- self._onConnectAdmin();
+- }
+- });
+- this._userClient.starttls(this.opts.tlsOptions, this._userClient.controls, function(err) {
+- if (err) {
+- self._handleError(err);
+- }
+- });
+- } else if (opts.reconnect) {
++ if (opts.reconnect && !this.opts.starttls) {
+ this.once('_installReconnectListener', function() {
+ self.log && self.log.trace('install reconnect listener');
+ self._adminClient.on('connect', function() {
+@@ -384,6 +371,28 @@ LdapAuth.prototype._findGroups = function(user, callback) {
+ */
+ LdapAuth.prototype.authenticate = function(username, password, callback) {
+ var self = this;
++ if (this.opts.starttls && !this.opts.tlsStarted) {
++ // When starttls is enabled, this callback supplants the 'connect' callback
++ this._adminClient.starttls(this.opts.tlsOptions, this._adminClient.controls, function (err) {
++ if (err) {
++ self._handleError(err);
++ } else {
++ self._onConnectAdmin(function(){self._handleAuthenticate(username, password, callback);});
++ }
++ });
++ this._userClient.starttls(this.opts.tlsOptions, this._userClient.controls, function (err) {
++ if (err) {
++ self._handleError(err);
++ }
++ });
++ } else {
++ self._handleAuthenticate(username, password, callback);
++ }
++};
++
++LdapAuth.prototype._handleAuthenticate = function (username, password, callback) {
++ this.opts.tlsStarted = true;
++ var self = this;
+
+ if (typeof password === 'undefined' || password === null || password === '') {
+ return callback(new Error('no password given'));
diff --git a/services/web/app/src/Features/Authentication/AuthenticationController.mjs b/services/web/app/src/Features/Authentication/AuthenticationController.mjs
index 586262d311..1d782a7425 100644
--- a/services/web/app/src/Features/Authentication/AuthenticationController.mjs
+++ b/services/web/app/src/Features/Authentication/AuthenticationController.mjs
@@ -117,9 +117,9 @@ const AuthenticationController = {
// so we can send back our custom `{message: {text: "", type: ""}}` responses on failure,
// and send a `{redir: ""}` response on success
passport.authenticate(
- 'local',
+ Settings.ldap?.enable ? ['custom-fail-ldapauth','local'] : ['local'],
{ keepSessionInfo: true },
- async function (err, user, info) {
+ async function (err, user, infoArray) {
if (err) {
return next(err)
}
@@ -141,6 +141,7 @@ const AuthenticationController = {
return next(err)
}
} else {
+ let info = infoArray[0]
if (info.redir != null) {
return res.json({ redir: info.redir })
} else {
diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs
index d4140ef90a..bd1b2d2f7b 100644
--- a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs
+++ b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs
@@ -157,6 +157,10 @@ async function requestReset(req, res, next) {
return res.status(404).json({
message: req.i18n.translate('secondary_email_password_reset'),
})
+ } else if (status === 'external') {
+ return res.status(403).json({
+ message: req.i18n.translate('password_managed_externally'),
+ })
} else {
return res.status(404).json({
message: req.i18n.translate('cant_find_email'),
diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs
index 995306a431..0882cedecf 100644
--- a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs
+++ b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs
@@ -18,6 +18,10 @@ async function generateAndEmailResetToken(email) {
return null
}
+ if (!user.hashedPassword) {
+ return 'external'
+ }
+
if (user.email !== email) {
return 'secondary'
}
diff --git a/services/web/app/src/Features/User/UserController.mjs b/services/web/app/src/Features/User/UserController.mjs
index 46f187e4ed..3e316fb285 100644
--- a/services/web/app/src/Features/User/UserController.mjs
+++ b/services/web/app/src/Features/User/UserController.mjs
@@ -413,7 +413,7 @@ async function updateUserSettings(req, res, next) {
if (
newEmail == null ||
newEmail === user.email ||
- req.externalAuthenticationSystemUsed()
+ (req.externalAuthenticationSystemUsed() && !user.hashedPassword)
) {
// end here, don't update email
SessionManager.setInSessionUser(req.session, {
@@ -490,6 +490,7 @@ async function doLogout(req) {
}
async function logout(req, res, next) {
+ if (req?.session.saml_extce) return res.redirect(308, '/saml/logout')
const requestedRedirect = req.body.redirect
? UrlHelper.getSafeRedirectPath(req.body.redirect)
: undefined
diff --git a/services/web/app/views/user/login.pug b/services/web/app/views/user/login.pug
index 621d5f7020..570128777e 100644
--- a/services/web/app/views/user/login.pug
+++ b/services/web/app/views/user/login.pug
@@ -25,8 +25,9 @@ block content
label(for='email') #{translate("email")}
input#email.form-control(
name='email'
- type='email'
+ type=(settings.ldap && settings.ldap.enable) ? 'text' : 'email'
required
+ placeholder=(settings.ldap && settings.ldap.enable) ? settings.ldap.placeholder : 'email@example.com'
autofocus='true'
autocomplete='username'
)
@@ -46,3 +47,12 @@ block content
if login_support_text
hr
p.text-center !{login_support_text}
+ if settings.saml && settings.saml.enable
+ form(data-ol-async-form, name="samlLoginForm")
+ .actions(style='margin-top: 30px;')
+ a.btn.btn-secondary.btn-block(
+ href='/saml/login',
+ data-ol-disabled-inflight
+ )
+ span(data-ol-inflight="idle") #{settings.saml.identityServiceName}
+ span(hidden data-ol-inflight="pending") #{translate("logging_in")}…
diff --git a/services/web/app/views/user/settings.pug b/services/web/app/views/user/settings.pug
index 45d21c7572..a07863682e 100644
--- a/services/web/app/views/user/settings.pug
+++ b/services/web/app/views/user/settings.pug
@@ -11,7 +11,7 @@ block append meta
meta(
name='ol-shouldAllowEditingDetails'
data-type='boolean'
- content=shouldAllowEditingDetails
+ content=shouldAllowEditingDetails || hasPassword
)
meta(name='ol-oauthProviders' data-type='json' content=oauthProviders)
meta(name='ol-institutionLinked' data-type='json' content=institutionLinked)
@@ -34,7 +34,7 @@ block append meta
meta(
name='ol-isExternalAuthenticationSystemUsed'
data-type='boolean'
- content=externalAuthenticationSystemUsed()
+ content=externalAuthenticationSystemUsed() && !hasPassword
)
meta(name='ol-user' data-type='json' content=user)
meta(name='ol-labsExperiments' data-type='json' content=labsExperiments)
diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js
index 2651b0c118..50057f64a8 100644
--- a/services/web/config/settings.defaults.js
+++ b/services/web/config/settings.defaults.js
@@ -1046,6 +1046,8 @@ module.exports = {
'launchpad',
'server-ce-scripts',
'user-activate',
+ 'ldap-authentication',
+ 'saml-authentication',
'symbol-palette',
'track-changes',
],
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 54425e969b..f30582d81f 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -166,6 +166,7 @@
"already_have_sl_account": "Already have an __appName__ account?",
"also": "Also",
"alternatively_create_new_institution_account": "Alternatively, you can create a new account with your institution email (__email__) by clicking __clickText__.",
+ "alternatively_create_local_admin_account": "Alternatively, you can create __appName__ local admin account.",
"an_email_has_already_been_sent_to": "An email has already been sent to <0>__email__0>. Please wait and try again later.",
"an_error_occured_while_restoring_project": "An error occured while restoring the project",
"an_error_occurred_when_verifying_the_coupon_code": "An error occurred when verifying the coupon code",
@@ -1289,6 +1290,7 @@
"loading_github_repositories": "Loading your GitHub repositories",
"loading_prices": "loading prices",
"loading_recent_github_commits": "Loading recent commits",
+ "local_account": "Local account",
"log_entry_description": "Log entry with level: __level__",
"log_entry_maximum_entries": "Maximum log entries limit hit",
"log_entry_maximum_entries_enable_stop_on_first_error": "Try to fix the first error and recompile. Often one error causes many later error messages. You can <0>Enable “Stop on first error”0> to focus on fixing errors. We recommend fixing errors as soon as possible; letting them accumulate may lead to hard-to-debug and fatal errors. <1>Learn more1>",
diff --git a/services/web/modules/launchpad/app/src/LaunchpadController.mjs b/services/web/modules/launchpad/app/src/LaunchpadController.mjs
index aa2b11be04..816a9dbc21 100644
--- a/services/web/modules/launchpad/app/src/LaunchpadController.mjs
+++ b/services/web/modules/launchpad/app/src/LaunchpadController.mjs
@@ -153,7 +153,8 @@ function registerExternalAuthAdmin(authMethod) {
await User.updateOne(
{ _id: user._id },
{
- $set: { isAdmin: true, emails: [{ email, reversedHostname }] },
+ $set: { isAdmin: true, emails: [{ email, reversedHostname, 'confirmedAt' : Date.now() }] },
+ $unset: { 'hashedPassword': "" }, // external-auth user must not have a hashedPassword
}
).exec()
} catch (err) {
diff --git a/services/web/modules/launchpad/app/views/launchpad.pug b/services/web/modules/launchpad/app/views/launchpad.pug
index 1af19bb4fe..f7b15d9062 100644
--- a/services/web/modules/launchpad/app/views/launchpad.pug
+++ b/services/web/modules/launchpad/app/views/launchpad.pug
@@ -129,6 +129,41 @@ block content
span(data-ol-inflight='idle') #{translate("register")}
span(hidden data-ol-inflight='pending') #{translate("registering")}…
+ h3 #{translate('local_account')}
+ p
+ | #{translate('alternatively_create_local_admin_account')}
+
+ form(
+ data-ol-async-form
+ data-ol-register-admin
+ action='/launchpad/register_admin'
+ method='POST'
+ )
+ input(name='_csrf' type='hidden' value=csrfToken)
+ +formMessages
+ .form-group
+ label.form-label(for='email') #{translate("email")}
+ input.form-control(
+ name='email'
+ type='email'
+ id='email-local'
+ autocomplete='username'
+ required
+ autofocus='true'
+ )
+ .form-group
+ label.form-label(for='passwordField') #{translate("password")}
+ input#passwordField.form-control(
+ name='password'
+ type='password'
+ autocomplete='new-password'
+ required
+ )
+ .actions
+ button.btn-primary.btn(type='submit' data-ol-disabled-inflight)
+ span(data-ol-inflight='idle') #{translate("register")}
+ span(hidden data-ol-inflight='pending') #{translate("registering")}…
+
// Saml Form
if authMethod === 'saml'
h3 #{translate('saml')}
@@ -158,6 +193,41 @@ block content
span(data-ol-inflight='idle') #{translate("register")}
span(hidden data-ol-inflight='pending') #{translate("registering")}…
+ h3 #{translate('local_account')}
+ p
+ | #{translate('alternatively_create_local_admin_account')}
+
+ form(
+ data-ol-async-form
+ data-ol-register-admin
+ action='/launchpad/register_admin'
+ method='POST'
+ )
+ input(name='_csrf' type='hidden' value=csrfToken)
+ +formMessages
+ .form-group
+ label.form-label(for='email-local') #{translate("email")}
+ input.form-control(
+ name='email'
+ type='email'
+ id='email-local'
+ autocomplete='username'
+ required
+ autofocus='true'
+ )
+ .form-group
+ label.form-label(for='passwordField') #{translate("password")}
+ input#passwordField.form-control(
+ name='password'
+ type='password'
+ autocomplete='new-password'
+ required
+ )
+ .actions
+ button.btn-primary.btn(type='submit' data-ol-disabled-inflight)
+ span(data-ol-inflight='idle') #{translate("register")}
+ span(hidden data-ol-inflight='pending') #{translate("registering")}…
+
br
diff --git a/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs b/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs
new file mode 100644
index 0000000000..64fa4f5a96
--- /dev/null
+++ b/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs
@@ -0,0 +1,64 @@
+import logger from '@overleaf/logger'
+import LoginRateLimiter from '../../../../app/src/Features/Security/LoginRateLimiter.js'
+import { handleAuthenticateErrors } from '../../../../app/src/Features/Authentication/AuthenticationErrors.js'
+import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js'
+import AuthenticationManagerLdap from './AuthenticationManagerLdap.mjs'
+
+const AuthenticationControllerLdap = {
+ async doPassportLdapLogin(req, ldapUser, done) {
+ let user, info
+ try {
+ ;({ user, info } = await AuthenticationControllerLdap._doPassportLdapLogin(
+ req,
+ ldapUser
+ ))
+ } catch (error) {
+ return done(error)
+ }
+ return done(undefined, user, info)
+ },
+ async _doPassportLdapLogin(req, ldapUser) {
+ const { fromKnownDevice } = AuthenticationController.getAuditInfo(req)
+ const auditLog = {
+ ipAddress: req.ip,
+ info: { method: 'LDAP password login', fromKnownDevice },
+ }
+
+ let user, isPasswordReused
+ try {
+ user = await AuthenticationManagerLdap.promises.findOrCreateLdapUser(ldapUser, auditLog)
+ } catch (error) {
+ return {
+ user: false,
+ info: handleAuthenticateErrors(error, req),
+ }
+ }
+ if (user && AuthenticationController.captchaRequiredForLogin(req, user)) {
+ return {
+ user: false,
+ info: {
+ text: req.i18n.translate('cannot_verify_user_not_robot'),
+ type: 'error',
+ errorReason: 'cannot_verify_user_not_robot',
+ status: 400,
+ },
+ }
+ } else if (user) {
+ // async actions
+ return { user, info: undefined }
+ } else { //something wrong
+ logger.debug({ email : ldapUser.mail }, 'failed LDAP log in')
+ return {
+ user: false,
+ info: {
+ type: 'error',
+ status: 500,
+ },
+ }
+ }
+ },
+}
+
+export const {
+ doPassportLdapLogin,
+} = AuthenticationControllerLdap
diff --git a/services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs b/services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs
new file mode 100644
index 0000000000..1371f76d52
--- /dev/null
+++ b/services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs
@@ -0,0 +1,80 @@
+import Settings from '@overleaf/settings'
+import { callbackify } from '@overleaf/promise-utils'
+import UserCreator from '../../../../app/src/Features/User/UserCreator.js'
+import { User } from '../../../../app/src/models/User.js'
+
+const AuthenticationManagerLdap = {
+ splitFullName(fullName) {
+ fullName = fullName.trim();
+ let lastSpaceIndex = fullName.lastIndexOf(' ');
+ let firstNames = fullName.substring(0, lastSpaceIndex).trim();
+ let lastName = fullName.substring(lastSpaceIndex + 1).trim();
+ return [firstNames, lastName];
+ },
+ async findOrCreateLdapUser(profile, auditLog) {
+ //user is already authenticated in Ldap
+ const {
+ attEmail,
+ attFirstName,
+ attLastName,
+ attName,
+ attAdmin,
+ valAdmin,
+ updateUserDetailsOnLogin,
+ } = Settings.ldap
+
+ const email = Array.isArray(profile[attEmail])
+ ? profile[attEmail][0].toLowerCase()
+ : profile[attEmail].toLowerCase()
+ let nameParts = ["",""]
+ if ((!attFirstName || !attLastName) && attName) {
+ nameParts = this.splitFullName(profile[attName] || "")
+ }
+ const firstName = attFirstName ? (profile[attFirstName] || "") : nameParts[0]
+ let lastName = attLastName ? (profile[attLastName] || "") : nameParts[1]
+ if (!firstName && !lastName) lastName = email
+ let isAdmin = false
+ if( attAdmin && valAdmin ) {
+ isAdmin = (profile._groups?.length > 0) ||
+ (Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) :
+ profile[attAdmin] === valAdmin)
+ }
+ let user = await User.findOne({ 'email': email }).exec()
+ if( !user ) {
+ user = await UserCreator.promises.createNewUser(
+ {
+ email: email,
+ first_name: firstName,
+ last_name: lastName,
+ isAdmin: isAdmin,
+ holdingAccount: false,
+ }
+ )
+ await User.updateOne(
+ { _id: user._id },
+ { $set : { 'emails.0.confirmedAt' : Date.now() } }
+ ).exec() //email of ldap user is confirmed
+ }
+ let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {}
+ if( attAdmin && valAdmin ) {
+ user.isAdmin = isAdmin
+ userDetails.isAdmin = isAdmin
+ }
+ const result = await User.updateOne(
+ { _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails },
+ {}
+ ).exec()
+ if (result.modifiedCount !== 1) {
+ throw new ParallelLoginError()
+ }
+ return user
+ },
+}
+
+export default {
+ findOrCreateLdapUser: callbackify(AuthenticationManagerLdap.findOrCreateLdapUser),
+ promises: AuthenticationManagerLdap,
+}
+export const {
+ splitFullName,
+} = AuthenticationManagerLdap
diff --git a/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs b/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs
new file mode 100644
index 0000000000..e7f312fc11
--- /dev/null
+++ b/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs
@@ -0,0 +1,17 @@
+import Settings from '@overleaf/settings'
+
+function initLdapSettings() {
+ Settings.ldap = {
+ enable: true,
+ placeholder: process.env.OVERLEAF_LDAP_PLACEHOLDER || 'Username',
+ attEmail: process.env.OVERLEAF_LDAP_EMAIL_ATT || 'mail',
+ attFirstName: process.env.OVERLEAF_LDAP_FIRST_NAME_ATT,
+ attLastName: process.env.OVERLEAF_LDAP_LAST_NAME_ATT,
+ attName: process.env.OVERLEAF_LDAP_NAME_ATT,
+ attAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT,
+ valAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE,
+ updateUserDetailsOnLogin: String(process.env.OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN ).toLowerCase() === 'true',
+ }
+}
+
+export default initLdapSettings
diff --git a/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs b/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs
new file mode 100644
index 0000000000..c4093b8684
--- /dev/null
+++ b/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs
@@ -0,0 +1,136 @@
+import Settings from '@overleaf/settings'
+import logger from '@overleaf/logger'
+import passport from 'passport'
+import ldapjs from 'ldapauth-fork/node_modules/ldapjs/lib/index.js'
+import UserGetter from '../../../../app/src/Features/User/UserGetter.js'
+import { splitFullName } from './AuthenticationManagerLdap.mjs'
+
+async function fetchLdapContacts(userId, contacts) {
+ if (!Settings.ldap?.enable || !process.env.OVERLEAF_LDAP_CONTACTS_FILTER) {
+ return []
+ }
+
+ const ldapOpts = passport._strategy('custom-fail-ldapauth').options.server
+ const { attEmail, attFirstName = "", attLastName = "", attName = "" } = Settings.ldap
+ const {
+ url,
+ timeout,
+ connectTimeout,
+ tlsOptions,
+ starttls,
+ bindDN,
+ bindCredentials,
+ } = ldapOpts
+ const searchBase = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_BASE || ldapOpts.searchBase
+ const searchScope = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_SCOPE || 'sub'
+ const ldapConfig = { url, timeout, connectTimeout, tlsOptions }
+
+ let ldapUsers
+ const client = ldapjs.createClient(ldapConfig)
+ try {
+ if (starttls) {
+ await _upgradeToTLS(client, tlsOptions)
+ }
+ await _bindLdap(client, bindDN, bindCredentials)
+
+ const filter = await _formContactsSearchFilter(client, ldapOpts, userId, process.env.OVERLEAF_LDAP_CONTACTS_FILTER)
+ const searchOptions = { scope: searchScope, attributes: [attEmail, attFirstName, attLastName, attName], filter }
+
+ ldapUsers = await _searchLdap(client, searchBase, searchOptions)
+ } catch (err) {
+ logger.warn({ err }, 'error in fetchLdapContacts')
+ return []
+ } finally {
+ client.unbind()
+ }
+
+ const newLdapContacts = ldapUsers.reduce((acc, ldapUser) => {
+ const email = Array.isArray(ldapUser[attEmail])
+ ? ldapUser[attEmail][0]?.toLowerCase()
+ : ldapUser[attEmail]?.toLowerCase()
+ if (!email) return acc
+ if (!contacts.some(contact => contact.email === email)) {
+ let nameParts = ["",""]
+ if ((!attFirstName || !attLastName) && attName) {
+ nameParts = splitFullName(ldapUser[attName] || "")
+ }
+ const firstName = attFirstName ? (ldapUser[attFirstName] || "") : nameParts[0]
+ const lastName = attLastName ? (ldapUser[attLastName] || "") : nameParts[1]
+ acc.push({
+ first_name: firstName,
+ last_name: lastName,
+ email: email,
+ type: 'user',
+ })
+ }
+ return acc
+ }, [])
+
+ return newLdapContacts.sort((a, b) =>
+ a.last_name.localeCompare(b.last_name) ||
+ a.first_name.localeCompare(a.first_name) ||
+ a.email.localeCompare(b.email)
+ )
+}
+
+function _upgradeToTLS(client, tlsOptions) {
+ return new Promise((resolve, reject) => {
+ client.on('error', error => reject(new Error(`LDAP client error: ${error}`)))
+ client.on('connect', () => {
+ client.starttls(tlsOptions, null, error => {
+ if (error) {
+ reject(new Error(`StartTLS error: ${error}`))
+ } else {
+ resolve()
+ }
+ })
+ })
+ })
+}
+
+function _bindLdap(client, bindDN, bindCredentials) {
+ return new Promise((resolve, reject) => {
+ client.bind(bindDN, bindCredentials, error => {
+ if (error) {
+ reject(error)
+ } else {
+ resolve()
+ }
+ })
+ })
+}
+
+function _searchLdap(client, baseDN, options) {
+ return new Promise((resolve, reject) => {
+ const searchEntries = []
+ client.search(baseDN, options, (error, res) => {
+ if (error) {
+ reject(error)
+ } else {
+ res.on('searchEntry', entry => searchEntries.push(entry.object))
+ res.on('error', reject)
+ res.on('end', () => resolve(searchEntries))
+ }
+ })
+ })
+}
+
+async function _formContactsSearchFilter(client, ldapOpts, userId, contactsFilter) {
+ const searchProperty = process.env.OVERLEAF_LDAP_CONTACTS_PROPERTY
+ if (!searchProperty) {
+ return contactsFilter
+ }
+ const email = await UserGetter.promises.getUserEmail(userId)
+ const searchOptions = {
+ scope: ldapOpts.searchScope,
+ attributes: [searchProperty],
+ filter: `(${Settings.ldap.attEmail}=${email})`,
+ }
+ const searchBase = ldapOpts.searchBase
+ const ldapUser = (await _searchLdap(client, searchBase, searchOptions))[0]
+ const searchPropertyValue = ldapUser ? ldapUser[searchProperty]
+ : process.env.OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE || 'IMATCHNOTHING'
+ return contactsFilter.replace(/{{userProperty}}/g, searchPropertyValue)
+}
+
+export default fetchLdapContacts
diff --git a/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs b/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs
new file mode 100644
index 0000000000..b07dc3f3bd
--- /dev/null
+++ b/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs
@@ -0,0 +1,78 @@
+import fs from 'fs'
+import passport from 'passport'
+import Settings from '@overleaf/settings'
+import { doPassportLdapLogin } from './AuthenticationControllerLdap.mjs'
+import { Strategy as LdapStrategy } from 'passport-ldapauth'
+
+function _readFilesContentFromEnv(envVar) {
+// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]'
+ if (!envVar) return undefined
+ try {
+ const parsedFileNames = JSON.parse(envVar)
+ return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8'))
+ } catch (error) {
+ if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name
+ return fs.readFileSync(envVar, 'utf8')
+ } else {
+ throw error
+ }
+ }
+}
+
+// custom responses on authentication failure
+class CustomFailLdapStrategy extends LdapStrategy {
+ constructor(options, validate) {
+ super(options, validate);
+ this.name = 'custom-fail-ldapauth'
+ }
+ authenticate(req, options) {
+ const defaultFail = this.fail.bind(this)
+ this.fail = function(info, status) {
+ info.type = 'error'
+ info.key = 'invalid-password-retry-or-reset'
+ info.status = 401
+ return defaultFail(info, status)
+ }.bind(this)
+ super.authenticate(req, options)
+ }
+}
+
+const ldapServerOpts = {
+ url: process.env.OVERLEAF_LDAP_URL,
+ bindDN: process.env.OVERLEAF_LDAP_BIND_DN || "",
+ bindCredentials: process.env.OVERLEAF_LDAP_BIND_CREDENTIALS || "",
+ bindProperty: process.env.OVERLEAF_LDAP_BIND_PROPERTY,
+ searchBase: process.env.OVERLEAF_LDAP_SEARCH_BASE,
+ searchFilter: process.env.OVERLEAF_LDAP_SEARCH_FILTER,
+ searchScope: process.env.OVERLEAF_LDAP_SEARCH_SCOPE || 'sub',
+ searchAttributes: JSON.parse(process.env.OVERLEAF_LDAP_SEARCH_ATTRIBUTES || '[]'),
+ groupSearchBase: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_BASE,
+ groupSearchFilter: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_FILTER,
+ groupSearchScope: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_SCOPE || 'sub',
+ groupSearchAttributes: ["dn"],
+ groupDnProperty: process.env.OVERLEAF_LDAP_ADMIN_DN_PROPERTY,
+ cache: String(process.env.OVERLEAF_LDAP_CACHE).toLowerCase() === 'true',
+ timeout: process.env.OVERLEAF_LDAP_TIMEOUT ? Number(process.env.OVERLEAF_LDAP_TIMEOUT) : undefined,
+ connectTimeout: process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT ? Number(process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT) : undefined,
+ starttls: String(process.env.OVERLEAF_LDAP_STARTTLS).toLowerCase() === 'true',
+ tlsOptions: {
+ ca: _readFilesContentFromEnv(process.env.OVERLEAF_LDAP_TLS_OPTS_CA_PATH),
+ rejectUnauthorized: String(process.env.OVERLEAF_LDAP_TLS_OPTS_REJECT_UNAUTH).toLowerCase() === 'true',
+ }
+}
+
+function addLdapStrategy(passport) {
+ passport.use(
+ new CustomFailLdapStrategy(
+ {
+ server: ldapServerOpts,
+ passReqToCallback: true,
+ usernameField: 'email',
+ passwordField: 'password',
+ },
+ doPassportLdapLogin
+ )
+ )
+}
+
+export default addLdapStrategy
diff --git a/services/web/modules/ldap-authentication/index.mjs b/services/web/modules/ldap-authentication/index.mjs
new file mode 100644
index 0000000000..f56d7ffee0
--- /dev/null
+++ b/services/web/modules/ldap-authentication/index.mjs
@@ -0,0 +1,30 @@
+import initLdapSettings from './app/src/InitLdapSettings.mjs'
+import addLdapStrategy from './app/src/LdapStrategy.mjs'
+import fetchLdapContacts from './app/src/LdapContacts.mjs'
+
+let ldapModule = {};
+if (process.env.EXTERNAL_AUTH === 'ldap') {
+ initLdapSettings()
+ ldapModule = {
+ name: 'ldap-authentication',
+ hooks: {
+ passportSetup: function (passport, callback) {
+ try {
+ addLdapStrategy(passport)
+ callback(null)
+ } catch (error) {
+ callback(error)
+ }
+ },
+ getContacts: async function (userId, contacts, callback) {
+ try {
+ const newLdapContacts = await fetchLdapContacts(userId, contacts)
+ callback(null, newLdapContacts)
+ } catch (error) {
+ callback(error)
+ }
+ },
+ }
+ }
+}
+export default ldapModule
diff --git a/services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs b/services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs
new file mode 100644
index 0000000000..f5db3f738d
--- /dev/null
+++ b/services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs
@@ -0,0 +1,160 @@
+import Settings from '@overleaf/settings'
+import logger from '@overleaf/logger'
+import passport from 'passport'
+import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js'
+import AuthenticationManagerSaml from './AuthenticationManagerSaml.mjs'
+import UserController from '../../../../app/src/Features/User/UserController.js'
+import UserSessionsManager from '../../../../app/src/Features/User/UserSessionsManager.js'
+import { handleAuthenticateErrors } from '../../../../app/src/Features/Authentication/AuthenticationErrors.js'
+import { xmlResponse } from '../../../../app/src/infrastructure/Response.js'
+
+const AuthenticationControllerSaml = {
+ passportSamlAuthWithIdP(req, res, next) {
+ if ( passport._strategy('saml')._saml.options.authnRequestBinding === 'HTTP-POST') {
+ const csp = res.getHeader('Content-Security-Policy')
+ if (csp) {
+ res.setHeader(
+ 'Content-Security-Policy',
+ csp.replace(/(?:^|\s)(default-src|form-action)[^;]*;?/g, '')
+ )
+ }
+ }
+ passport.authenticate('saml')(req, res, next)
+ },
+ passportSamlLogin(req, res, next) {
+ // This function is middleware which wraps the passport.authenticate middleware,
+ // so we can send back our custom `{message: {text: "", type: ""}}` responses on failure,
+ // and send a `{redir: ""}` response on success
+ passport.authenticate(
+ 'saml',
+ { keepSessionInfo: true },
+ async function (err, user, info) {
+ if (err) {
+ return next(err)
+ }
+ if (user) {
+ // `user` is either a user object or false
+ AuthenticationController.setAuditInfo(req, {
+ method: 'SAML login',
+ })
+ try {
+ await AuthenticationController.promises.finishLogin(user, req, res)
+ } catch (err) {
+ return next(err)
+ }
+ } else {
+ if (info.redir != null) {
+ return res.json({ redir: info.redir })
+ } else {
+ res.status(info.status || 200)
+ delete info.status
+ const body = { message: info }
+ const { errorReason } = info
+ if (errorReason) {
+ body.errorReason = errorReason
+ delete info.errorReason
+ }
+ return res.json(body)
+ }
+ }
+ }
+ )(req, res, next)
+ },
+ async doPassportSamlLogin(req, profile, done) {
+ let user, info
+ try {
+ ;({ user, info } = await AuthenticationControllerSaml._doPassportSamlLogin(
+ req,
+ profile
+ ))
+ } catch (error) {
+ return done(error)
+ }
+ return done(undefined, user, info)
+ },
+ async _doPassportSamlLogin(req, profile) {
+ const { fromKnownDevice } = AuthenticationController.getAuditInfo(req)
+ const auditLog = {
+ ipAddress: req.ip,
+ info: { method: 'SAML login', fromKnownDevice },
+ }
+
+ let user
+ try {
+ user = await AuthenticationManagerSaml.promises.findOrCreateSamlUser(profile, auditLog)
+ } catch (error) {
+ return {
+ user: false,
+ info: handleAuthenticateErrors(error, req),
+ }
+ }
+ if (user) {
+ req.session.saml_extce = {nameID : profile.nameID, sessionIndex : profile.sessionIndex}
+ return { user, info: undefined }
+ } else { //something wrong
+ logger.debug({ email : profile.mail }, 'failed SAML log in')
+ return {
+ user: false,
+ info: {
+ type: 'error',
+ text: 'Unknown error',
+ status: 500,
+ },
+ }
+ }
+ },
+ async passportSamlSPLogout(req, res, next) {
+ passport._strategy('saml').logout(req, async (err, url) => {
+ if (err) logger.error({ err }, 'can not generate logout url')
+ await UserController.promises.doLogout(req)
+ res.redirect(url)
+ })
+ },
+ passportSamlIdPLogout(req, res, next) {
+ passport.authenticate('saml')(req, res, (err) => {
+ if (err) return next(err)
+ res.redirect('/login');
+ })
+ },
+ async doPassportSamlLogout(req, profile, done) {
+ let user, info
+ try {
+ ;({ user, info } = await AuthenticationControllerSaml._doPassportSamlLogout(
+ req,
+ profile
+ ))
+ } catch (error) {
+ return done(error)
+ }
+ return done(undefined, user, info)
+ },
+ async _doPassportSamlLogout(req, profile) {
+ if (req?.session?.saml_extce?.nameID === profile.nameID &&
+ req?.session?.saml_extce?.sessionIndex === profile.sessionIndex) {
+ profile = req.user
+ }
+ await UserSessionsManager.promises.untrackSession(req.user, req.sessionID).catch(err => {
+ logger.warn({ err, userId: req.user._id }, 'failed to untrack session')
+ })
+ return { user: profile, info: undefined }
+ },
+ passportSamlMetadata(req, res) {
+ const samlStratery = passport._strategy('saml')
+ res.setHeader('Content-Disposition', `attachment; filename="${samlStratery._saml.options.issuer}-meta.xml"`)
+ xmlResponse(res,
+ samlStratery.generateServiceProviderMetadata(
+ samlStratery._saml.options.decryptionCert,
+ samlStratery._saml.options.signingCert
+ )
+ )
+ },
+}
+export const {
+ passportSamlAuthWithIdP,
+ passportSamlLogin,
+ passportSamlSPLogout,
+ passportSamlIdPLogout,
+ doPassportSamlLogin,
+ doPassportSamlLogout,
+ passportSamlMetadata,
+} = AuthenticationControllerSaml
diff --git a/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs b/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs
new file mode 100644
index 0000000000..47d97f3019
--- /dev/null
+++ b/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs
@@ -0,0 +1,60 @@
+import Settings from '@overleaf/settings'
+import UserCreator from '../../../../app/src/Features/User/UserCreator.js'
+import { User } from '../../../../app/src/models/User.js'
+
+const AuthenticationManagerSaml = {
+ async findOrCreateSamlUser(profile, auditLog) {
+ const {
+ attEmail,
+ attFirstName,
+ attLastName,
+ attAdmin,
+ valAdmin,
+ updateUserDetailsOnLogin,
+ } = Settings.saml
+ const email = Array.isArray(profile[attEmail])
+ ? profile[attEmail][0].toLowerCase()
+ : profile[attEmail].toLowerCase()
+ const firstName = attFirstName ? profile[attFirstName] : ""
+ const lastName = attLastName ? profile[attLastName] : email
+ let isAdmin = false
+ if( attAdmin && valAdmin ) {
+ isAdmin = (Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) :
+ profile[attAdmin] === valAdmin)
+ }
+ let user = await User.findOne({ 'email': email }).exec()
+ if( !user ) {
+ user = await UserCreator.promises.createNewUser(
+ {
+ email: email,
+ first_name: firstName,
+ last_name: lastName,
+ isAdmin: isAdmin,
+ holdingAccount: false,
+ }
+ )
+ await User.updateOne(
+ { _id: user._id },
+ { $set : { 'emails.0.confirmedAt' : Date.now() } }
+ ).exec() //email of saml user is confirmed
+ }
+ let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {}
+ if( attAdmin && valAdmin ) {
+ user.isAdmin = isAdmin
+ userDetails.isAdmin = isAdmin
+ }
+ const result = await User.updateOne(
+ { _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails },
+ {}
+ ).exec()
+
+ if (result.modifiedCount !== 1) {
+ throw new ParallelLoginError()
+ }
+ return user
+ },
+}
+
+export default {
+ promises: AuthenticationManagerSaml,
+}
diff --git a/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs b/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs
new file mode 100644
index 0000000000..441f9033af
--- /dev/null
+++ b/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs
@@ -0,0 +1,16 @@
+import Settings from '@overleaf/settings'
+
+function initSamlSettings() {
+ Settings.saml = {
+ enable: true,
+ identityServiceName: process.env.OVERLEAF_SAML_IDENTITY_SERVICE_NAME || 'Login with SAML IdP',
+ attEmail: process.env.OVERLEAF_SAML_EMAIL_FIELD || 'nameID',
+ attFirstName: process.env.OVERLEAF_SAML_FIRST_NAME_FIELD || 'givenName',
+ attLastName: process.env.OVERLEAF_SAML_LAST_NAME_FIELD || 'lastName',
+ attAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD,
+ valAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE,
+ updateUserDetailsOnLogin: String(process.env.OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN).toLowerCase() === 'true',
+ }
+}
+
+export default initSamlSettings
diff --git a/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs b/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs
new file mode 100644
index 0000000000..65b42c92ae
--- /dev/null
+++ b/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs
@@ -0,0 +1,12 @@
+import logger from '@overleaf/logger'
+import { passportSamlLogin, passportSamlIdPLogout } from './AuthenticationControllerSaml.mjs'
+
+export default {
+ apply(webRouter) {
+ logger.debug({}, 'Init SAML NonCsrfRouter')
+ webRouter.get('/saml/login/callback', passportSamlLogin)
+ webRouter.post('/saml/login/callback', passportSamlLogin)
+ webRouter.get('/saml/logout/callback', passportSamlIdPLogout)
+ webRouter.post('/saml/logout/callback', passportSamlIdPLogout)
+ },
+}
diff --git a/services/web/modules/saml-authentication/app/src/SamlRouter.mjs b/services/web/modules/saml-authentication/app/src/SamlRouter.mjs
new file mode 100644
index 0000000000..9ee3677901
--- /dev/null
+++ b/services/web/modules/saml-authentication/app/src/SamlRouter.mjs
@@ -0,0 +1,14 @@
+import logger from '@overleaf/logger'
+import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js'
+import { passportSamlAuthWithIdP, passportSamlSPLogout, passportSamlMetadata} from './AuthenticationControllerSaml.mjs'
+
+export default {
+ apply(webRouter) {
+ logger.debug({}, 'Init SAML router')
+ webRouter.get('/saml/login', passportSamlAuthWithIdP)
+ AuthenticationController.addEndpointToLoginWhitelist('/saml/login')
+ webRouter.post('/saml/logout', AuthenticationController.requireLogin(), passportSamlSPLogout)
+ webRouter.get('/saml/meta', passportSamlMetadata)
+ AuthenticationController.addEndpointToLoginWhitelist('/saml/meta')
+ },
+}
diff --git a/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs b/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs
new file mode 100644
index 0000000000..3a16459f98
--- /dev/null
+++ b/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs
@@ -0,0 +1,62 @@
+import fs from 'fs'
+import passport from 'passport'
+import Settings from '@overleaf/settings'
+import { doPassportSamlLogin, doPassportSamlLogout } from './AuthenticationControllerSaml.mjs'
+import { Strategy as SamlStrategy } from '@node-saml/passport-saml'
+
+function _readFilesContentFromEnv(envVar) {
+// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]'
+ if (!envVar) return undefined
+ try {
+ const parsedFileNames = JSON.parse(envVar)
+ return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8'))
+ } catch (error) {
+ if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name
+ return fs.readFileSync(envVar, 'utf8')
+ } else {
+ throw error
+ }
+ }
+}
+
+const samlOptions = {
+ entryPoint: process.env.OVERLEAF_SAML_ENTRYPOINT,
+ callbackUrl: process.env.OVERLEAF_SAML_CALLBACK_URL,
+ issuer: process.env.OVERLEAF_SAML_ISSUER,
+ audience: process.env.OVERLEAF_SAML_AUDIENCE,
+ cert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_IDP_CERT),
+ signingCert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_PUBLIC_CERT),
+ privateKey: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_PRIVATE_KEY),
+ decryptionCert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_CERT),
+ decryptionPvk: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_PVK),
+ signatureAlgorithm: process.env.OVERLEAF_SAML_SIGNATURE_ALGORITHM,
+ additionalParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_PARAMS || '{}'),
+ additionalAuthorizeParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_AUTHORIZE_PARAMS || '{}'),
+ identifierFormat: process.env.OVERLEAF_SAML_IDENTIFIER_FORMAT,
+ acceptedClockSkewMs: process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS ? Number(process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS) : undefined,
+ attributeConsumingServiceIndex: process.env.OVERLEAF_SAML_ATTRIBUTE_CONSUMING_SERVICE_INDEX,
+ authnContext: process.env.OVERLEAF_SAML_AUTHN_CONTEXT ? JSON.parse(process.env.OVERLEAF_SAML_AUTHN_CONTEXT) : undefined,
+ forceAuthn: String(process.env.OVERLEAF_SAML_FORCE_AUTHN).toLowerCase() === 'true',
+ disableRequestedAuthnContext: String(process.env.OVERLEAF_SAML_DISABLE_REQUESTED_AUTHN_CONTEXT).toLowerCase() === 'true',
+ skipRequestCompression: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING === 'HTTP-POST', // compression should be skipped iff authnRequestBinding is POST
+ authnRequestBinding: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING,
+ validateInResponseTo: process.env.OVERLEAF_SAML_VALIDATE_IN_RESPONSE_TO,
+ requestIdExpirationPeriodMs: process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS ? Number(process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS) : undefined,
+// cacheProvider: process.env.OVERLEAF_SAML_CACHE_PROVIDER,
+ logoutUrl: process.env.OVERLEAF_SAML_LOGOUT_URL,
+ logoutCallbackUrl: process.env.OVERLEAF_SAML_LOGOUT_CALLBACK_URL,
+ additionalLogoutParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS || '{}'),
+ passReqToCallback: true,
+}
+
+function addSamlStrategy(passport) {
+ passport.use(
+ new SamlStrategy(
+ samlOptions,
+ doPassportSamlLogin,
+ doPassportSamlLogout
+ )
+ )
+}
+
+export default addSamlStrategy
diff --git a/services/web/modules/saml-authentication/index.mjs b/services/web/modules/saml-authentication/index.mjs
new file mode 100644
index 0000000000..35ea70283f
--- /dev/null
+++ b/services/web/modules/saml-authentication/index.mjs
@@ -0,0 +1,26 @@
+import initSamlSettings from './app/src/InitSamlSettings.mjs'
+import addSamlStrategy from './app/src/SamlStrategy.mjs'
+import SamlRouter from './app/src/SamlRouter.mjs'
+import SamlNonCsrfRouter from './app/src/SamlNonCsrfRouter.mjs'
+
+let samlModule = {};
+
+if (process.env.EXTERNAL_AUTH === 'saml') {
+ initSamlSettings()
+ samlModule = {
+ name: 'saml-authentication',
+ hooks: {
+ passportSetup: function (passport, callback) {
+ try {
+ addSamlStrategy(passport)
+ callback(null)
+ } catch (error) {
+ callback(error)
+ }
+ },
+ },
+ router: SamlRouter,
+ nonCsrfRouter: SamlNonCsrfRouter,
+ }
+}
+export default samlModule