Skip to content

Commit 7269b1f

Browse files
byt3questerAchintya-Chatterjeeprakashchoudhary07vikasosmium
authored
Feature: Implement Google Oauth login (#2278)
* added google auth and its tests * added test for fetch call to github api * added feature flag and refactored code * refactored google callback function and some error checks * added test for developer role and checks for email from oauth * added Error messages for emails and their tests * used devflag middleware and add handling redirect url to separate function * changed test names and made stub to function * made cookie options to a function --------- Co-authored-by: Achintya Chatterjee <[email protected]> Co-authored-by: Prakash Choudhary <[email protected]> Co-authored-by: Vikas Singh <[email protected]>
1 parent a9bdb4d commit 7269b1f

File tree

14 files changed

+591
-44
lines changed

14 files changed

+591
-44
lines changed

config/custom-environment-variables.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ module.exports = {
3737
clientSecret: "GITHUB_CLIENT_SECRET",
3838
},
3939

40+
googleOauth: {
41+
clientId: "GOOGLE_CLIENT_ID",
42+
clientSecret: "GOOGLE_CLIENT_SECRET",
43+
},
44+
4045
githubAccessToken: "GITHUB_PERSONAL_ACCESS_TOKEN",
4146

4247
firestore: "FIRESTORE_CONFIG",

config/default.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ module.exports = {
3232
clientSecret: "<clientSecret>",
3333
},
3434

35+
googleOauth: {
36+
clientId: "<clientId>",
37+
clientSecret: "<clientSecret>",
38+
},
39+
3540
emailServiceConfig: {
3641
email: "<RDS_EMAIL>",
3742
password: "<EMAIL PASSWORD GENERATED AFTER 2FA>",

controllers/auth.js

Lines changed: 134 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,120 @@ const {
99
USER_DOES_NOT_EXIST_ERROR,
1010
} = require("../constants/errorMessages");
1111

12+
const googleAuthLogin = (req, res, next) => {
13+
const { redirectURL } = req.query;
14+
return passport.authenticate("google", {
15+
scope: ["email"],
16+
state: redirectURL,
17+
})(req, res, next);
18+
};
19+
20+
function handleRedirectUrl(req) {
21+
const rdsUiUrl = new URL(config.get("services.rdsUi.baseUrl"));
22+
let authRedirectionUrl = rdsUiUrl;
23+
let isMobileApp = false;
24+
let isV2FlagPresent = false;
25+
let devMode = false;
26+
27+
if ("state" in req.query) {
28+
try {
29+
const redirectUrl = new URL(req.query.state);
30+
if (redirectUrl.searchParams.get("isMobileApp") === "true") {
31+
isMobileApp = true;
32+
redirectUrl.searchParams.delete("isMobileApp");
33+
}
34+
35+
if (`.${redirectUrl.hostname}`.endsWith(`.${rdsUiUrl.hostname}`)) {
36+
// Matching *.realdevsquad.com
37+
authRedirectionUrl = redirectUrl;
38+
devMode = Boolean(redirectUrl.searchParams.get("dev"));
39+
} else {
40+
logger.error(`Malicious redirect URL provided URL: ${redirectUrl}, Will redirect to RDS`);
41+
}
42+
if (redirectUrl.searchParams.get("v2") === "true") {
43+
isV2FlagPresent = true;
44+
}
45+
} catch (error) {
46+
logger.error("Invalid redirect URL provided", error);
47+
}
48+
}
49+
return {
50+
authRedirectionUrl,
51+
isMobileApp,
52+
isV2FlagPresent,
53+
devMode,
54+
};
55+
}
56+
57+
const getAuthCookieOptions = () => {
58+
const rdsUiUrl = new URL(config.get("services.rdsUi.baseUrl"));
59+
return {
60+
domain: rdsUiUrl.hostname,
61+
expires: new Date(Date.now() + config.get("userToken.ttl") * 1000),
62+
httpOnly: true,
63+
secure: true,
64+
sameSite: "lax",
65+
};
66+
};
67+
68+
async function handleGoogleLogin(req, res, user, authRedirectionUrl) {
69+
try {
70+
if (!user.emails || user.emails.length === 0) {
71+
logger.error("Google login failed: No emails found in user data");
72+
return res.boom.unauthorized("No email found in Google account");
73+
}
74+
const primaryEmail = user.emails.find((email) => email.verified === true);
75+
if (!primaryEmail) {
76+
logger.error("Google login failed: No verified email found");
77+
return res.boom.unauthorized("No verified email found in Google account");
78+
}
79+
80+
const userData = {
81+
email: primaryEmail.value,
82+
created_at: Date.now(),
83+
updated_at: null,
84+
};
85+
86+
const userDataFromDB = await users.fetchUser({ email: userData.email });
87+
88+
if (userDataFromDB.userExists) {
89+
if (userDataFromDB.user.roles?.developer) {
90+
const errorMessage = encodeURIComponent("Google login is restricted for developer role.");
91+
const separator = authRedirectionUrl.search ? "&" : "?";
92+
return res.redirect(`${authRedirectionUrl}${separator}error=${errorMessage}`);
93+
}
94+
}
95+
96+
const { userId, incompleteUserDetails } = await users.addOrUpdate(userData);
97+
98+
const token = authService.generateAuthToken({ userId });
99+
100+
const cookieOptions = getAuthCookieOptions();
101+
102+
res.cookie(config.get("userToken.cookieName"), token, cookieOptions);
103+
104+
if (incompleteUserDetails) {
105+
authRedirectionUrl = "https://my.realdevsquad.com/new-signup";
106+
}
107+
108+
return res.redirect(authRedirectionUrl);
109+
} catch (err) {
110+
logger.error("Unexpected error during Google login", err);
111+
return res.boom.unauthorized("User cannot be authenticated");
112+
}
113+
}
114+
115+
const googleAuthCallback = (req, res, next) => {
116+
const { authRedirectionUrl } = handleRedirectUrl(req);
117+
return passport.authenticate("google", { session: false }, async (err, accessToken, user) => {
118+
if (err) {
119+
logger.error(err);
120+
return res.boom.unauthorized("User cannot be authenticated");
121+
}
122+
return await handleGoogleLogin(req, res, user, authRedirectionUrl);
123+
})(req, res, next);
124+
};
125+
12126
/**
13127
* Makes authentication call to GitHub statergy
14128
*
@@ -41,33 +155,7 @@ const githubAuthLogin = (req, res, next) => {
41155
*/
42156
const githubAuthCallback = (req, res, next) => {
43157
let userData;
44-
let isMobileApp = false;
45-
const rdsUiUrl = new URL(config.get("services.rdsUi.baseUrl"));
46-
let authRedirectionUrl = rdsUiUrl;
47-
let devMode = false;
48-
let isV2FlagPresent = false;
49-
50-
if ("state" in req.query) {
51-
try {
52-
const redirectUrl = new URL(req.query.state);
53-
if (redirectUrl.searchParams.get("isMobileApp") === "true") {
54-
isMobileApp = true;
55-
redirectUrl.searchParams.delete("isMobileApp");
56-
}
57-
58-
if (redirectUrl.searchParams.get("v2") === "true") isV2FlagPresent = true;
59-
60-
if (`.${redirectUrl.hostname}`.endsWith(`.${rdsUiUrl.hostname}`)) {
61-
// Matching *.realdevsquad.com
62-
authRedirectionUrl = redirectUrl;
63-
devMode = Boolean(redirectUrl.searchParams.get("dev"));
64-
} else {
65-
logger.error(`Malicious redirect URL provided URL: ${redirectUrl}, Will redirect to RDS`);
66-
}
67-
} catch (error) {
68-
logger.error("Invalid redirect URL provided", error);
69-
}
70-
}
158+
let { authRedirectionUrl, isMobileApp, isV2FlagPresent, devMode } = handleRedirectUrl(req);
71159
try {
72160
return passport.authenticate("github", { session: false }, async (err, accessToken, user) => {
73161
if (err) {
@@ -77,23 +165,33 @@ const githubAuthCallback = (req, res, next) => {
77165
userData = {
78166
github_id: user.username,
79167
github_display_name: user.displayName,
168+
email: user._json.email,
80169
github_created_at: Number(new Date(user._json.created_at).getTime()),
81170
github_user_id: user.id,
82171
created_at: Date.now(),
83172
updated_at: null,
84173
};
85174

175+
if (!userData.email) {
176+
const githubBaseUrl = config.get("githubApi.baseUrl");
177+
const res = await fetch(`${githubBaseUrl}/user/emails`, {
178+
headers: {
179+
Authorization: `token ${accessToken}`,
180+
},
181+
});
182+
const emails = await res.json();
183+
const primaryEmails = emails.filter((item) => item.primary);
184+
185+
if (primaryEmails.length > 0) {
186+
userData.email = primaryEmails[0].email;
187+
}
188+
}
189+
86190
const { userId, incompleteUserDetails, role } = await users.addOrUpdate(userData);
87191

88192
const token = authService.generateAuthToken({ userId });
89193

90-
const cookieOptions = {
91-
domain: rdsUiUrl.hostname,
92-
expires: new Date(Date.now() + config.get("userToken.ttl") * 1000),
93-
httpOnly: true,
94-
secure: true,
95-
sameSite: "lax",
96-
};
194+
const cookieOptions = getAuthCookieOptions();
97195
// respond with a cookie
98196
res.cookie(config.get("userToken.cookieName"), token, cookieOptions);
99197

@@ -232,6 +330,8 @@ const fetchDeviceDetails = async (req, res) => {
232330
module.exports = {
233331
githubAuthLogin,
234332
githubAuthCallback,
333+
googleAuthLogin,
334+
googleAuthCallback,
235335
signout,
236336
storeUserDeviceInfo,
237337
updateAuthStatus,

middlewares/passport.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const passport = require("passport");
22
const GitHubStrategy = require("passport-github2").Strategy;
3+
const GoogleStrategy = require("passport-google-oauth20").Strategy;
34

45
try {
56
passport.use(
@@ -14,6 +15,18 @@ try {
1415
}
1516
)
1617
);
18+
passport.use(
19+
new GoogleStrategy(
20+
{
21+
clientID: config.get("googleOauth.clientId"),
22+
clientSecret: config.get("googleOauth.clientSecret"),
23+
callbackURL: `${config.get("services.rdsApi.baseUrl")}/auth/google/callback`,
24+
},
25+
(accessToken, refreshToken, profile, done) => {
26+
return done(null, accessToken, profile);
27+
}
28+
)
29+
);
1730
} catch (err) {
1831
logger.error("Error initialising passport:", err);
1932
}

models/users.js

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,15 +127,23 @@ const addOrUpdate = async (userData, userId = null, devFeatureFlag) => {
127127
}
128128

129129
// userId is null, Add or Update user
130-
let user;
130+
let user = null;
131+
131132
if (userData.github_user_id) {
132133
user = await userModel.where("github_user_id", "==", userData.github_user_id).limit(1).get();
133134
}
134-
if (!user || (user && user.empty)) {
135+
136+
if (userData.github_id && (!user || user.empty)) {
135137
user = await userModel.where("github_id", "==", userData.github_id).limit(1).get();
136138
}
139+
140+
if (userData.email && (!user || user.empty)) {
141+
user = await userModel.where("email", "==", userData.email).limit(1).get();
142+
}
143+
137144
if (user && !user.empty && user.docs !== null) {
138-
await userModel.doc(user.docs[0].id).set({ ...userData, updated_at: Date.now() }, { merge: true });
145+
const { created_at: createdAt, ...updatedUserData } = userData;
146+
await userModel.doc(user.docs[0].id).set({ ...updatedUserData, updated_at: Date.now() }, { merge: true });
139147

140148
const logData = {
141149
type: logType.USER_DETAILS_UPDATED,
@@ -153,7 +161,6 @@ const addOrUpdate = async (userData, userId = null, devFeatureFlag) => {
153161
role: Object.values(AUTHORITIES).find((role) => data.roles[role]) || AUTHORITIES.USER,
154162
};
155163
}
156-
157164
// Add new user
158165
/*
159166
Adding default archived role enables us to query for only
@@ -377,7 +384,7 @@ const fetchUsers = async (usernames = []) => {
377384
* @param { Object }: Object with username and userId, any of the two can be used
378385
* @return {Promise<{userExists: boolean, user: <userModel>}|{userExists: boolean, user: <userModel>}>}
379386
*/
380-
const fetchUser = async ({ userId = null, username = null, githubUsername = null, discordId = null }) => {
387+
const fetchUser = async ({ userId = null, username = null, githubUsername = null, discordId = null, email = null }) => {
381388
try {
382389
let userData, id;
383390
if (username) {
@@ -402,6 +409,12 @@ const fetchUser = async ({ userId = null, username = null, githubUsername = null
402409
id = doc.id;
403410
userData = doc.data();
404411
});
412+
} else if (email) {
413+
const user = await userModel.where("email", "==", email).limit(1).get();
414+
user.forEach((doc) => {
415+
id = doc.id;
416+
userData = doc.data();
417+
});
405418
}
406419

407420
if (userData && userData.disabled_roles !== undefined) {

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"nodemailer-mock": "^2.0.6",
4141
"passport": "0.7.0",
4242
"passport-github2": "0.1.12",
43+
"passport-google-oauth20": "^2.0.0",
4344
"rate-limiter-flexible": "5.0.3",
4445
"winston": "3.13.0"
4546
},

routes/auth.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,16 @@ import auth from "../controllers/auth";
44
import authenticate from "../middlewares/authenticate";
55
import userDeviceInfoValidator from "../middlewares/validators/qrCodeAuth";
66
import qrCodeAuthValidator from "../middlewares/validators/qrCodeAuth";
7+
import { devFlagMiddleware } from "../middlewares/devFlag";
78

89
router.get("/github/login", auth.githubAuthLogin);
910

1011
router.get("/github/callback", auth.githubAuthCallback);
1112

13+
router.get("/google/login", devFlagMiddleware, auth.googleAuthLogin);
14+
15+
router.get("/google/callback", auth.googleAuthCallback);
16+
1217
router.get("/signout", auth.signout);
1318

1419
router.get("/qr-code-auth", userDeviceInfoValidator.validateFetchingUserDocument, auth.fetchUserDeviceInfo);

test/config/test.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ module.exports = {
3030
identity_store_id: "test-identity-store-id",
3131
},
3232

33+
googleOauth: {
34+
clientId: "cliendId",
35+
clientSecret: "clientSecret",
36+
},
3337
firestore: `{
3438
"type": "service_account",
3539
"project_id": "test-project-id-for-emulator",

test/fixtures/auth/githubUserInfo.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ module.exports = () => {
4343
company: null,
4444
blog: "",
4545
location: null,
46-
email: null,
46+
4747
hireable: null,
4848
bio: null,
4949
twitter_username: null,

0 commit comments

Comments
 (0)