diff --git a/.env.example b/.env.example index 75604e4447c..b5d5319eb12 100644 --- a/.env.example +++ b/.env.example @@ -388,6 +388,8 @@ GITHUB_CALLBACK_URL=/oauth/github/callback GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_CALLBACK_URL=/oauth/google/callback +# Optional: restrict login to members of a specific Google Workspace group +# GOOGLE_WORKSPACE_GROUP=mygroup@mydomain.com # OpenID OPENID_CLIENT_ID= diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index f84724841eb..550e79629ee 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -28,25 +28,41 @@ const oauthHandler = async (req, res) => { } }; + + +/** + * Returns the required OAuth scopes for Google authentication + * @returns {string[]} Array of OAuth scopes + */ +const getGoogleScopes = () => { + const scopes = ['openid', 'profile', 'email']; + if (process.env.GOOGLE_WORKSPACE_GROUP) { + scopes.push('https://www.googleapis.com/auth/cloud-identity.groups.readonly'); + } + return scopes; +}; + /** * Google Routes */ router.get( '/google', passport.authenticate('google', { - scope: ['openid', 'profile', 'email'], + scope: getGoogleScopes(), session: false, }), ); router.get( '/google/callback', - passport.authenticate('google', { - failureRedirect: `${domains.client}/login`, - failureMessage: true, - session: false, - scope: ['openid', 'profile', 'email'], - }), + (req, res, next) => { + passport.authenticate('google', { + failureRedirect: `${domains.client}/login?error=Unauthorized`, + failureMessage: true, + session: false, + scope: getGoogleScopes(), + })(req, res, next); + }, oauthHandler, ); @@ -106,6 +122,7 @@ router.get( }), oauthHandler, ); + router.get( '/discord', passport.authenticate('discord', { diff --git a/api/strategies/googleStrategy.js b/api/strategies/googleStrategy.js index bf8562e1836..dc92c28e6e1 100644 --- a/api/strategies/googleStrategy.js +++ b/api/strategies/googleStrategy.js @@ -1,6 +1,13 @@ const { Strategy: GoogleStrategy } = require('passport-google-oauth20'); +const axios = require('axios'); const socialLogin = require('./socialLogin'); +const { logger } = require('~/config'); +/** + * Extracts and formats user profile details from Google OAuth profile + * @param {Object} profile - The profile object from Google OAuth + * @returns {Object} Formatted user profile details + */ const getProfileDetails = (profile) => ({ email: profile.emails[0].value, id: profile.id, @@ -10,7 +17,104 @@ const getProfileDetails = (profile) => ({ emailVerified: profile.emails[0].verified, }); -const googleLogin = socialLogin('google', getProfileDetails); +/** + * Retrieves the resource name for a Google Workspace group + * @param {string} accessToken - OAuth access token + * @param {string} groupEmail - Email address of the group + * @returns {Promise} Group resource name or null if not found + */ +async function getGroupResourceName(accessToken, groupEmail) { + try { + const response = await axios.get( + 'https://cloudidentity.googleapis.com/v1/groups:lookup', + { + params: { + 'groupKey.id': groupEmail, + }, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + return response.data.name; + } catch (error) { + logger.error('[getGroupResourceName] Error looking up group:', error.response?.data || error); + return null; + } +} + +/** + * Verifies if a user is a member of the specified Google Workspace group + * @param {string} accessToken - OAuth access token + * @param {string} userEmail - Email address of the user + * @returns {Promise} True if user is a member or if no group is specified + */ +async function checkGroupMembership(accessToken, userEmail) { + try { + const allowedGroup = process.env.GOOGLE_WORKSPACE_GROUP; + if (!allowedGroup) { + return true; + } + + const groupName = await getGroupResourceName(accessToken, allowedGroup); + if (!groupName) { + logger.error('[checkGroupMembership] Could not find group resource name'); + return false; + } + + const response = await axios.get( + `https://cloudidentity.googleapis.com/v1/${groupName}/memberships:checkTransitiveMembership`, + { + params: { query: `member_key_id == '${userEmail}'` }, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + return response.data.hasMembership || false; + } catch (error) { + logger.error( + '[checkGroupMembership] Error checking group membership:', + { userEmail, error: error.response?.data || error } + ); + return false; + } +} + +/** + * Handles Google OAuth login process with group membership verification + * @param {string} accessToken - OAuth access token + * @param {string} refreshToken - OAuth refresh token + * @param {Object} profile - User profile from Google + * @param {Function} done - Passport callback function + */ +async function googleLogin(accessToken, refreshToken, profile, done) { + try { + const userEmail = profile.emails[0].value; + const isMember = await checkGroupMembership(accessToken, userEmail); + + if (!isMember) { + return done(null, false); + } + + const socialLoginCallback = (err, user) => { + if (err) { + return done(err); + } + done(null, user); + }; + + return socialLogin('google', getProfileDetails)( + accessToken, + refreshToken, + profile, + socialLoginCallback + ); + } catch (error) { + return done(error); + } +} module.exports = () => new GoogleStrategy(