diff --git a/services/web/test/acceptance/src/helpers/groupSSO.js b/services/web/test/acceptance/src/helpers/groupSSO.js new file mode 100644 index 0000000000..a355d7ec7e --- /dev/null +++ b/services/web/test/acceptance/src/helpers/groupSSO.js @@ -0,0 +1,219 @@ +const fs = require('fs') +const Path = require('path') +const User = require('./User').promises +const Subscription = require('./Subscription').promises +const { SSOConfig } = require('../../../../app/src/models/SSOConfig') +const UserHelper = require('./UserHelper') +const SAMLHelper = require('./SAMLHelper') +const Settings = require('@overleaf/settings') +const { + getProviderId, +} = require('../../../../app/src/Features/Subscription/GroupUtils') +const UserGetter = require('../../../../app/src/Features/User/UserGetter') +const { + Subscription: SubscriptionModel, +} = require('../../../../app/src/models/Subscription') + +const SAML_TEST_CERT = fs + .readFileSync(Path.resolve(__dirname, '../../files/saml-cert.crt'), 'utf8') + .replace(/-----BEGIN CERTIFICATE-----/, '') + .replace(/-----END CERTIFICATE-----/, '') + .replace(/\n/g, '') + +function getEnrollmentUrl(groupId) { + return `/subscription/${groupId}/sso_enrollment` +} + +const userIdAttribute = 'nameID' + +const baseSsoConfig = { + entryPoint: 'http://example-sso.com/saml', + certificates: [SAML_TEST_CERT], + signatureAlgorithm: 'sha256', + userIdAttribute, +} // the database also sets enabled and validated, but we cannot set that in the POST request for /manage/groups/:ID/settings/sso + +async function createGroupSSO() { + const nonSSOMemberHelper = await UserHelper.createUser() + const nonSSOMember = nonSSOMemberHelper.user + + const groupAdminUser = new User() + const memberUser = new User() + + await groupAdminUser.ensureUserExists() + await memberUser.ensureUserExists() + + const ssoConfig = new SSOConfig({ + ...baseSsoConfig, + enabled: true, + validated: true, + }) + + await ssoConfig.save() + + const subscription = new Subscription({ + adminId: groupAdminUser._id, + memberIds: [memberUser._id, nonSSOMember._id, groupAdminUser._id], + groupPlan: true, + planCode: 'group_professional_10_enterprise', + features: { + groupSSO: true, + }, + ssoConfig: ssoConfig._id, + membersLimit: 10, + }) + await subscription.ensureExists() + const subscriptionId = subscription._id.toString() + const enrollmentUrl = getEnrollmentUrl(subscriptionId) + const internalProviderId = getProviderId(subscriptionId) + + await linkGroupMember( + memberUser.email, + memberUser.password, + subscriptionId, + 'mock@email.com' + ) + + const userHelper = new UserHelper() + + return { + ssoConfig, + internalProviderId, + userIdAttribute, + subscription, + subscriptionId, + groupAdminUser, + memberUser, + nonSSOMemberHelper, + nonSSOMember, + userHelper, + enrollmentUrl, + } +} + +async function linkGroupMember( + userEmail, + userPassword, + groupId, + externalUserId +) { + // eslint-disable-next-line no-restricted-syntax + const subscription = await SubscriptionModel.findById(groupId) + .populate('ssoConfig') + .exec() + const userIdAttribute = subscription?.ssoConfig?.userIdAttribute + + const internalProviderId = getProviderId(groupId) + const enrollmentUrl = getEnrollmentUrl(groupId) + const userHelper = await UserHelper.loginUser( + { + email: userEmail, + password: userPassword, + }, + `/subscription/${groupId}/sso_enrollment` + ) + + const { headers } = await userHelper.fetch(enrollmentUrl, { + method: 'POST', + }) + if ( + !headers.get('location') || + !headers.get('location').includes(Settings.saml.groupSSO.initPath) + ) { + throw new Error('invalid redirect when linking to group SSO') + } + + const redirectTo = new URL(headers.get('location')) + + const initSSOResponse = await userHelper.fetch(redirectTo) + + // redirect to IdP + const idpEntryPointUrl = new URL(initSSOResponse.headers.get('location')) + const requestId = await SAMLHelper.getRequestId(idpEntryPointUrl) + const response = await userHelper.fetch(Settings.saml.groupSSO.path, { + method: 'POST', + body: new URLSearchParams({ + SAMLResponse: SAMLHelper.createMockSamlResponse({ + requestId, + userIdAttribute, + uniqueId: externalUserId, + issuer: 'https://www.overleaf.test/saml/group-sso/meta', + }), + }), + }) + if (response.status !== 302) { + throw new Error('failed to link group SSO') + } + + // ensure user linked + const user = await UserGetter.promises.getUser( + { email: userEmail }, + { samlIdentifiers: 1, enrollment: 1 } + ) + + const { enrollment, samlIdentifiers } = user + const linkedToGroupSSO = samlIdentifiers.some( + identifier => identifier.providerId === internalProviderId + ) + const userIsEnrolledInSSO = enrollment.sso.some( + sso => sso.groupId.toString() === groupId.toString() + ) + if (!linkedToGroupSSO || !userIsEnrolledInSSO) { + throw new Error('error setting up test user with group SSO linked') + } + + return userHelper +} + +async function setConfigAndEnableSSO( + subscriptionHelper, + adminEmailPassword, + config +) { + config = config || { + entryPoint: 'http://idp.example.com/entry_point', + certificates: [SAML_TEST_CERT], + userIdAttribute: 'email', + userLastNameAttribute: 'lastName', + } + + const { email, password } = adminEmailPassword + const userHelper = await UserHelper.loginUser({ + email, + password, + }) + + const createResponse = await userHelper.fetch( + `/manage/groups/${subscriptionHelper._id}/settings/sso`, + { + method: 'POST', + body: JSON.stringify(config), + headers: { + 'Content-Type': 'application/json', + }, + } + ) + if (createResponse.status !== 201) { + throw new Error( + `failed to set SSO config. Status = ${createResponse.status}` + ) + } + + await subscriptionHelper.setValidatedSSO() + + const enableResponse = await userHelper.fetch( + `/manage/groups/${subscriptionHelper._id}/settings/enableSSO`, + { method: 'POST' } + ) + + if (enableResponse.status !== 200) { + throw new Error(`failed to enable SSO. Status = ${enableResponse.status}`) + } +} + +module.exports = { + createGroupSSO, + linkGroupMember, + baseSsoConfig, + setConfigAndEnableSSO, +} diff --git a/services/web/test/acceptance/src/mocks/MockGoogleOauthApi.js b/services/web/test/acceptance/src/mocks/MockGoogleOauthApi.js new file mode 100644 index 0000000000..744cb6b6ee --- /dev/null +++ b/services/web/test/acceptance/src/mocks/MockGoogleOauthApi.js @@ -0,0 +1,46 @@ +const AbstractMockApi = require('./AbstractMockApi') + +class MockGoogleOauthApi extends AbstractMockApi { + reset() { + this.profiles = {} + this.tokens = {} + } + + addProfile(profile, token, authorizationCode) { + this.profiles[token] = { + picture: 'https://example.com/picture.jpg', + email_verified: true, + locale: 'en-GB', + ...profile, + } + this.tokens[authorizationCode] = token + } + + applyRoutes() { + this.app.post('/oauth/token', (req, res, next) => { + if (!this.tokens[req.body.code]) { + return res.sendStatus(400) + } + res.json({ + access_token: this.tokens[req.body.code], + }) + }) + + this.app.get('/oauth2/v3/userinfo', (req, res, next) => { + if (!this.profiles[req.query.access_token]) { + return res.sendStatus(400) + } + res.json(this.profiles[req.query.access_token]) + }) + } +} + +module.exports = MockGoogleOauthApi + +// type hint for the inherited `instance` method +/** + * @function instance + * @memberOf MockGoogleOauthApi + * @static + * @returns {MockGoogleOauthApi} + */