Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: google workspace limit access to a single group #4613

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
# [email protected]

# OpenID
OPENID_CLIENT_ID=
Expand Down
31 changes: 24 additions & 7 deletions api/server/routes/oauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);

Expand Down Expand Up @@ -106,6 +122,7 @@ router.get(
}),
oauthHandler,
);

router.get(
'/discord',
passport.authenticate('discord', {
Expand Down
106 changes: 105 additions & 1 deletion api/strategies/googleStrategy.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<string|null>} 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<boolean>} 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(
Expand Down