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.js b/services/web/app/src/Features/Authentication/AuthenticationController.js
index 7a97d2ac9c..baba8aacee 100644
--- a/services/web/app/src/Features/Authentication/AuthenticationController.js
+++ b/services/web/app/src/Features/Authentication/AuthenticationController.js
@@ -82,6 +82,7 @@ const AuthenticationController = {
analyticsId: user.analyticsId || user._id,
alphaProgram: user.alphaProgram || undefined, // only store if set
betaProgram: user.betaProgram || undefined, // only store if set
+ externalAuth: user.externalAuth || false,
}
if (user.isAdmin) {
lightUser.isAdmin = true
diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs
index b7fc2da9c8..419a36ecf2 100644
--- a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs
+++ b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs
@@ -119,7 +119,7 @@ async function requestReset(req, res, next) {
OError.tag(err, 'failed to generate and email password reset token', {
email,
})
- if (err.message === 'user does not have permission for change-password') {
+ if (err.message === 'user does not have one or more permissions within change-password') {
return res.status(403).json({
message: {
key: 'no-password-allowed-due-to-sso',
diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs
index 094f18b95f..2c1aefe6a6 100644
--- a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs
+++ b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs
@@ -72,6 +72,7 @@ async function getUserForPasswordResetToken(token) {
'overleaf.id': 1,
email: 1,
must_reconfirm: 1,
+ hashedPassword: 1,
})
await assertUserPermissions(user, ['change-password'])
diff --git a/services/web/app/src/Features/User/UserController.js b/services/web/app/src/Features/User/UserController.js
index e4186d39a8..04be431801 100644
--- a/services/web/app/src/Features/User/UserController.js
+++ b/services/web/app/src/Features/User/UserController.js
@@ -515,4 +515,5 @@ module.exports = {
expireDeletedUsersAfterDuration: expressify(expireDeletedUsersAfterDuration),
ensureAffiliationMiddleware: expressify(ensureAffiliationMiddleware),
ensureAffiliation,
+ doLogout,
}
diff --git a/services/web/app/src/Features/User/UserPagesController.mjs b/services/web/app/src/Features/User/UserPagesController.mjs
index 29fc505a7c..596357da76 100644
--- a/services/web/app/src/Features/User/UserPagesController.mjs
+++ b/services/web/app/src/Features/User/UserPagesController.mjs
@@ -53,10 +53,8 @@ async function settingsPage(req, res) {
const reconfirmedViaSAML = _.get(req.session, ['saml', 'reconfirmed'])
delete req.session.saml
let shouldAllowEditingDetails = true
- if (Settings.ldap && Settings.ldap.updateUserDetailsOnLogin) {
- shouldAllowEditingDetails = false
- }
- if (Settings.saml && Settings.saml.updateUserDetailsOnLogin) {
+ const externalAuth = req.user.externalAuth
+ if (externalAuth && Settings[externalAuth].updateUserDetailsOnLogin) {
shouldAllowEditingDetails = false
}
const oauthProviders = Settings.oauthProviders || {}
diff --git a/services/web/app/src/infrastructure/ExpressLocals.js b/services/web/app/src/infrastructure/ExpressLocals.js
index eae1b48219..589e23dfd9 100644
--- a/services/web/app/src/infrastructure/ExpressLocals.js
+++ b/services/web/app/src/infrastructure/ExpressLocals.js
@@ -106,9 +106,9 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) {
webRouter.use(function (req, res, next) {
req.externalAuthenticationSystemUsed =
- Features.externalAuthenticationSystemUsed
+ () => !!req?.user?.externalAuth
res.locals.externalAuthenticationSystemUsed =
- Features.externalAuthenticationSystemUsed
+ () => !!req?.user?.externalAuth
req.hasFeature = res.locals.hasFeature = Features.hasFeature
next()
})
diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs
index a7e8d5e05f..b67762bc5d 100644
--- a/services/web/app/src/router.mjs
+++ b/services/web/app/src/router.mjs
@@ -217,6 +217,8 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
CaptchaMiddleware.canSkipCaptcha
)
+ await Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter)
+
webRouter.get('/login', UserPagesController.loginPage)
AuthenticationController.addEndpointToLoginWhitelist('/login')
@@ -285,8 +287,6 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
TokenAccessRouter.apply(webRouter)
HistoryRouter.apply(webRouter, privateApiRouter)
- await Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter)
-
if (Settings.enableSubscriptions) {
webRouter.get(
'/user/bonus',
diff --git a/services/web/app/views/user/login.pug b/services/web/app/views/user/login.pug
index 1ad77cb8b4..ffeb3eca89 100644
--- a/services/web/app/views/user/login.pug
+++ b/services/web/app/views/user/login.pug
@@ -23,10 +23,10 @@ block content
| !{translate('password_compromised_try_again_or_use_known_device_or_reset', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}, {name: 'a', attrs: {href: '/user/password/reset', target: '_blank'}}])}.
.form-group
input.form-control(
- type='email',
+ type=(settings.ldap && settings.ldap.enable) ? 'text' : 'email',
name='email',
required,
- placeholder='email@example.com',
+ placeholder=(settings.ldap && settings.ldap.enable) ? settings.ldap.placeholder : 'email@example.com',
autofocus="true"
)
.form-group
@@ -47,4 +47,21 @@ block content
if login_support_text
hr
p.text-center !{login_support_text}
-
+ if settings.saml && settings.saml.enable
+ .actions(style='margin-top: 30px;')
+ a.button.btn-secondary.btn(
+ href='/saml/login',
+ style="width: 100%;"
+ data-ol-disabled-inflight
+ )
+ span(data-ol-inflight="idle") #{settings.saml.identityServiceName}
+ span(hidden data-ol-inflight="pending") #{translate("logging_in")}…
+ if settings.oidc && settings.oidc.enable
+ .actions(style='margin-top: 30px;')
+ a.button.btn-secondary.btn(
+ href='/oidc/login',
+ style="width: 100%;"
+ data-ol-disabled-inflight
+ )
+ span(data-ol-inflight="idle") #{settings.oidc.identityServiceName}
+ span(hidden data-ol-inflight="pending") #{translate("logging_in")}…
diff --git a/services/web/app/views/user/passwordReset.pug b/services/web/app/views/user/passwordReset.pug
index 410e79fbb2..1d019b65fc 100644
--- a/services/web/app/views/user/passwordReset.pug
+++ b/services/web/app/views/user/passwordReset.pug
@@ -52,7 +52,7 @@ block content
.notification-content-and-cta
.notification-content
p
- | !{translate("you_cant_reset_password_due_to_sso", {}, [{name: 'a', attrs: {href: '/sso-login'}}])}
+ | !{translate("you_cant_reset_password_due_to_ldap_or_sso")}
input(type="hidden", name="_csrf", value=csrfToken)
.form-group.mb-3
diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js
index a7ff970ef0..8571b6bad4 100644
--- a/services/web/config/settings.defaults.js
+++ b/services/web/config/settings.defaults.js
@@ -1005,6 +1005,9 @@ module.exports = {
'launchpad',
'server-ce-scripts',
'user-activate',
+ 'authentication/ldap',
+ 'authentication/saml',
+ 'authentication/oidc',
],
viewIncludes: {},
@@ -1031,6 +1034,20 @@ module.exports = {
managedUsers: {
enabled: false,
},
+
+ oauthProviders: {
+ ...(process.env.EXTERNAL_AUTH && process.env.EXTERNAL_AUTH.includes('oidc') && {
+ [process.env.OVERLEAF_OIDC_PROVIDER_ID || 'oidc']: {
+ name: process.env.OVERLEAF_OIDC_PROVIDER_NAME || 'OIDC Provider',
+ descriptionKey: process.env.OVERLEAF_OIDC_PROVIDER_DESCRIPTION,
+ descriptionOptions: { link: process.env.OVERLEAF_OIDC_PROVIDER_INFO_LINK },
+ hideWhenNotLinked: process.env.OVERLEAF_OIDC_PROVIDER_HIDE_NOT_LINKED ?
+ process.env.OVERLEAF_OIDC_PROVIDER_HIDE_NOT_LINKED.toLowerCase() === 'true' : undefined,
+ linkPath: '/oidc/login',
+ },
+ }),
+ },
+
}
module.exports.mergeWith = function (overrides) {
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 9862e47817..f6ed377775 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -2104,6 +2104,7 @@
"you_can_select_or_invite_collaborator": "",
"you_can_select_or_invite_collaborator_plural": "",
"you_can_still_use_your_premium_features": "",
+ "you_cant_add_or_change_password_due_to_ldap_or_sso": "",
"you_cant_add_or_change_password_due_to_sso": "",
"you_cant_join_this_group_subscription": "",
"you_dont_have_any_add_ons_on_your_account": "",
diff --git a/services/web/frontend/js/features/settings/components/linking-section.tsx b/services/web/frontend/js/features/settings/components/linking-section.tsx
index 0b9001927e..204e801c76 100644
--- a/services/web/frontend/js/features/settings/components/linking-section.tsx
+++ b/services/web/frontend/js/features/settings/components/linking-section.tsx
@@ -204,7 +204,8 @@ function SSOLinkingWidgetContainer({
const { t } = useTranslation()
const { unlink } = useSSOContext()
- let description = ''
+ let description = subscription.provider.descriptionKey ||
+ `${t('login_with_service', { service: subscription.provider.name, })}.`
switch (subscription.providerId) {
case 'collabratec':
description = t('linked_collabratec_description')
diff --git a/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx b/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx
index 800a7540ae..bb767d984c 100644
--- a/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx
+++ b/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx
@@ -4,6 +4,7 @@ import { FetchError } from '../../../../infrastructure/fetch-json'
import IEEELogo from '../../../../shared/svgs/ieee-logo'
import GoogleLogo from '../../../../shared/svgs/google-logo'
import OrcidLogo from '../../../../shared/svgs/orcid-logo'
+import OpenIDLogo from '../../../../shared/svgs/openid-logo'
import LinkingStatus from './status'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLModal, {
@@ -17,6 +18,7 @@ const providerLogos: { readonly [p: string]: JSX.Element } = {
collabratec: