From 7269b1f63c11643d1342d522ac237e73bcaa3b68 Mon Sep 17 00:00:00 2001 From: listiclehub1 <114046524+listiclehub1@users.noreply.github.com> Date: Thu, 26 Dec 2024 12:28:08 +0530 Subject: [PATCH] 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 <55826451+Achintya-Chatterjee@users.noreply.github.com> Co-authored-by: Prakash Choudhary <34452139+prakashchoudhary07@users.noreply.github.com> Co-authored-by: Vikas Singh <59792866+vikasosmium@users.noreply.github.com> --- config/custom-environment-variables.js | 5 + config/default.js | 5 + controllers/auth.js | 168 ++++++++++++--- middlewares/passport.js | 13 ++ models/users.js | 23 +- package.json | 1 + routes/auth.ts | 5 + test/config/test.js | 4 + test/fixtures/auth/githubUserInfo.js | 2 +- test/fixtures/auth/googleUserInfo.js | 77 +++++++ test/fixtures/user/user.js | 8 +- test/integration/auth.test.js | 286 +++++++++++++++++++++++++ test/utils/googleauth.js | 31 +++ yarn.lock | 7 + 14 files changed, 591 insertions(+), 44 deletions(-) create mode 100644 test/fixtures/auth/googleUserInfo.js create mode 100644 test/utils/googleauth.js diff --git a/config/custom-environment-variables.js b/config/custom-environment-variables.js index 5bdc42062..a17419a73 100644 --- a/config/custom-environment-variables.js +++ b/config/custom-environment-variables.js @@ -37,6 +37,11 @@ module.exports = { clientSecret: "GITHUB_CLIENT_SECRET", }, + googleOauth: { + clientId: "GOOGLE_CLIENT_ID", + clientSecret: "GOOGLE_CLIENT_SECRET", + }, + githubAccessToken: "GITHUB_PERSONAL_ACCESS_TOKEN", firestore: "FIRESTORE_CONFIG", diff --git a/config/default.js b/config/default.js index 8ad960566..4b7dc2b83 100644 --- a/config/default.js +++ b/config/default.js @@ -32,6 +32,11 @@ module.exports = { clientSecret: "", }, + googleOauth: { + clientId: "", + clientSecret: "", + }, + emailServiceConfig: { email: "", password: "", diff --git a/controllers/auth.js b/controllers/auth.js index bf0835a29..6b1bf4cd6 100644 --- a/controllers/auth.js +++ b/controllers/auth.js @@ -9,6 +9,120 @@ const { USER_DOES_NOT_EXIST_ERROR, } = require("../constants/errorMessages"); +const googleAuthLogin = (req, res, next) => { + const { redirectURL } = req.query; + return passport.authenticate("google", { + scope: ["email"], + state: redirectURL, + })(req, res, next); +}; + +function handleRedirectUrl(req) { + const rdsUiUrl = new URL(config.get("services.rdsUi.baseUrl")); + let authRedirectionUrl = rdsUiUrl; + let isMobileApp = false; + let isV2FlagPresent = false; + let devMode = false; + + if ("state" in req.query) { + try { + const redirectUrl = new URL(req.query.state); + if (redirectUrl.searchParams.get("isMobileApp") === "true") { + isMobileApp = true; + redirectUrl.searchParams.delete("isMobileApp"); + } + + if (`.${redirectUrl.hostname}`.endsWith(`.${rdsUiUrl.hostname}`)) { + // Matching *.realdevsquad.com + authRedirectionUrl = redirectUrl; + devMode = Boolean(redirectUrl.searchParams.get("dev")); + } else { + logger.error(`Malicious redirect URL provided URL: ${redirectUrl}, Will redirect to RDS`); + } + if (redirectUrl.searchParams.get("v2") === "true") { + isV2FlagPresent = true; + } + } catch (error) { + logger.error("Invalid redirect URL provided", error); + } + } + return { + authRedirectionUrl, + isMobileApp, + isV2FlagPresent, + devMode, + }; +} + +const getAuthCookieOptions = () => { + const rdsUiUrl = new URL(config.get("services.rdsUi.baseUrl")); + return { + domain: rdsUiUrl.hostname, + expires: new Date(Date.now() + config.get("userToken.ttl") * 1000), + httpOnly: true, + secure: true, + sameSite: "lax", + }; +}; + +async function handleGoogleLogin(req, res, user, authRedirectionUrl) { + try { + if (!user.emails || user.emails.length === 0) { + logger.error("Google login failed: No emails found in user data"); + return res.boom.unauthorized("No email found in Google account"); + } + const primaryEmail = user.emails.find((email) => email.verified === true); + if (!primaryEmail) { + logger.error("Google login failed: No verified email found"); + return res.boom.unauthorized("No verified email found in Google account"); + } + + const userData = { + email: primaryEmail.value, + created_at: Date.now(), + updated_at: null, + }; + + const userDataFromDB = await users.fetchUser({ email: userData.email }); + + if (userDataFromDB.userExists) { + if (userDataFromDB.user.roles?.developer) { + const errorMessage = encodeURIComponent("Google login is restricted for developer role."); + const separator = authRedirectionUrl.search ? "&" : "?"; + return res.redirect(`${authRedirectionUrl}${separator}error=${errorMessage}`); + } + } + + const { userId, incompleteUserDetails } = await users.addOrUpdate(userData); + + const token = authService.generateAuthToken({ userId }); + + const cookieOptions = getAuthCookieOptions(); + + res.cookie(config.get("userToken.cookieName"), token, cookieOptions); + + if (incompleteUserDetails) { + authRedirectionUrl = "https://my.realdevsquad.com/new-signup"; + } + + return res.redirect(authRedirectionUrl); + } catch (err) { + logger.error("Unexpected error during Google login", err); + return res.boom.unauthorized("User cannot be authenticated"); + } +} + +const googleAuthCallback = (req, res, next) => { + const { authRedirectionUrl } = handleRedirectUrl(req); + return passport.authenticate("google", { session: false }, async (err, accessToken, user) => { + if (err) { + logger.error(err); + return res.boom.unauthorized("User cannot be authenticated"); + } + return await handleGoogleLogin(req, res, user, authRedirectionUrl); + })(req, res, next); +}; + /** * Makes authentication call to GitHub statergy * @@ -41,33 +155,7 @@ const githubAuthLogin = (req, res, next) => { */ const githubAuthCallback = (req, res, next) => { let userData; - let isMobileApp = false; - const rdsUiUrl = new URL(config.get("services.rdsUi.baseUrl")); - let authRedirectionUrl = rdsUiUrl; - let devMode = false; - let isV2FlagPresent = false; - - if ("state" in req.query) { - try { - const redirectUrl = new URL(req.query.state); - if (redirectUrl.searchParams.get("isMobileApp") === "true") { - isMobileApp = true; - redirectUrl.searchParams.delete("isMobileApp"); - } - - if (redirectUrl.searchParams.get("v2") === "true") isV2FlagPresent = true; - - if (`.${redirectUrl.hostname}`.endsWith(`.${rdsUiUrl.hostname}`)) { - // Matching *.realdevsquad.com - authRedirectionUrl = redirectUrl; - devMode = Boolean(redirectUrl.searchParams.get("dev")); - } else { - logger.error(`Malicious redirect URL provided URL: ${redirectUrl}, Will redirect to RDS`); - } - } catch (error) { - logger.error("Invalid redirect URL provided", error); - } - } + let { authRedirectionUrl, isMobileApp, isV2FlagPresent, devMode } = handleRedirectUrl(req); try { return passport.authenticate("github", { session: false }, async (err, accessToken, user) => { if (err) { @@ -77,23 +165,33 @@ const githubAuthCallback = (req, res, next) => { userData = { github_id: user.username, github_display_name: user.displayName, + email: user._json.email, github_created_at: Number(new Date(user._json.created_at).getTime()), github_user_id: user.id, created_at: Date.now(), updated_at: null, }; + if (!userData.email) { + const githubBaseUrl = config.get("githubApi.baseUrl"); + const res = await fetch(`${githubBaseUrl}/user/emails`, { + headers: { + Authorization: `token ${accessToken}`, + }, + }); + const emails = await res.json(); + const primaryEmails = emails.filter((item) => item.primary); + + if (primaryEmails.length > 0) { + userData.email = primaryEmails[0].email; + } + } + const { userId, incompleteUserDetails, role } = await users.addOrUpdate(userData); const token = authService.generateAuthToken({ userId }); - const cookieOptions = { - domain: rdsUiUrl.hostname, - expires: new Date(Date.now() + config.get("userToken.ttl") * 1000), - httpOnly: true, - secure: true, - sameSite: "lax", - }; + const cookieOptions = getAuthCookieOptions(); // respond with a cookie res.cookie(config.get("userToken.cookieName"), token, cookieOptions); @@ -232,6 +330,8 @@ const fetchDeviceDetails = async (req, res) => { module.exports = { githubAuthLogin, githubAuthCallback, + googleAuthLogin, + googleAuthCallback, signout, storeUserDeviceInfo, updateAuthStatus, diff --git a/middlewares/passport.js b/middlewares/passport.js index 93ee4274a..62c9d3d71 100644 --- a/middlewares/passport.js +++ b/middlewares/passport.js @@ -1,5 +1,6 @@ const passport = require("passport"); const GitHubStrategy = require("passport-github2").Strategy; +const GoogleStrategy = require("passport-google-oauth20").Strategy; try { passport.use( @@ -14,6 +15,18 @@ try { } ) ); + passport.use( + new GoogleStrategy( + { + clientID: config.get("googleOauth.clientId"), + clientSecret: config.get("googleOauth.clientSecret"), + callbackURL: `${config.get("services.rdsApi.baseUrl")}/auth/google/callback`, + }, + (accessToken, refreshToken, profile, done) => { + return done(null, accessToken, profile); + } + ) + ); } catch (err) { logger.error("Error initialising passport:", err); } diff --git a/models/users.js b/models/users.js index 7e2514b38..7d64fd194 100644 --- a/models/users.js +++ b/models/users.js @@ -127,15 +127,23 @@ const addOrUpdate = async (userData, userId = null, devFeatureFlag) => { } // userId is null, Add or Update user - let user; + let user = null; + if (userData.github_user_id) { user = await userModel.where("github_user_id", "==", userData.github_user_id).limit(1).get(); } - if (!user || (user && user.empty)) { + + if (userData.github_id && (!user || user.empty)) { user = await userModel.where("github_id", "==", userData.github_id).limit(1).get(); } + + if (userData.email && (!user || user.empty)) { + user = await userModel.where("email", "==", userData.email).limit(1).get(); + } + if (user && !user.empty && user.docs !== null) { - await userModel.doc(user.docs[0].id).set({ ...userData, updated_at: Date.now() }, { merge: true }); + const { created_at: createdAt, ...updatedUserData } = userData; + await userModel.doc(user.docs[0].id).set({ ...updatedUserData, updated_at: Date.now() }, { merge: true }); const logData = { type: logType.USER_DETAILS_UPDATED, @@ -153,7 +161,6 @@ const addOrUpdate = async (userData, userId = null, devFeatureFlag) => { role: Object.values(AUTHORITIES).find((role) => data.roles[role]) || AUTHORITIES.USER, }; } - // Add new user /* Adding default archived role enables us to query for only @@ -377,7 +384,7 @@ const fetchUsers = async (usernames = []) => { * @param { Object }: Object with username and userId, any of the two can be used * @return {Promise<{userExists: boolean, user: }|{userExists: boolean, user: }>} */ -const fetchUser = async ({ userId = null, username = null, githubUsername = null, discordId = null }) => { +const fetchUser = async ({ userId = null, username = null, githubUsername = null, discordId = null, email = null }) => { try { let userData, id; if (username) { @@ -402,6 +409,12 @@ const fetchUser = async ({ userId = null, username = null, githubUsername = null id = doc.id; userData = doc.data(); }); + } else if (email) { + const user = await userModel.where("email", "==", email).limit(1).get(); + user.forEach((doc) => { + id = doc.id; + userData = doc.data(); + }); } if (userData && userData.disabled_roles !== undefined) { diff --git a/package.json b/package.json index 6f2ce9468..aeb67d916 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "nodemailer-mock": "^2.0.6", "passport": "0.7.0", "passport-github2": "0.1.12", + "passport-google-oauth20": "^2.0.0", "rate-limiter-flexible": "5.0.3", "winston": "3.13.0" }, diff --git a/routes/auth.ts b/routes/auth.ts index 2b4577207..0c53dbda9 100644 --- a/routes/auth.ts +++ b/routes/auth.ts @@ -4,11 +4,16 @@ import auth from "../controllers/auth"; import authenticate from "../middlewares/authenticate"; import userDeviceInfoValidator from "../middlewares/validators/qrCodeAuth"; import qrCodeAuthValidator from "../middlewares/validators/qrCodeAuth"; +import { devFlagMiddleware } from "../middlewares/devFlag"; router.get("/github/login", auth.githubAuthLogin); router.get("/github/callback", auth.githubAuthCallback); +router.get("/google/login", devFlagMiddleware, auth.googleAuthLogin); + +router.get("/google/callback", auth.googleAuthCallback); + router.get("/signout", auth.signout); router.get("/qr-code-auth", userDeviceInfoValidator.validateFetchingUserDocument, auth.fetchUserDeviceInfo); diff --git a/test/config/test.js b/test/config/test.js index f0e0396c9..824800679 100644 --- a/test/config/test.js +++ b/test/config/test.js @@ -30,6 +30,10 @@ module.exports = { identity_store_id: "test-identity-store-id", }, + googleOauth: { + clientId: "cliendId", + clientSecret: "clientSecret", + }, firestore: `{ "type": "service_account", "project_id": "test-project-id-for-emulator", diff --git a/test/fixtures/auth/githubUserInfo.js b/test/fixtures/auth/githubUserInfo.js index 153264e0a..80631c7ea 100644 --- a/test/fixtures/auth/githubUserInfo.js +++ b/test/fixtures/auth/githubUserInfo.js @@ -43,7 +43,7 @@ module.exports = () => { company: null, blog: "", location: null, - email: null, + email: "abc@gmail.com", hireable: null, bio: null, twitter_username: null, diff --git a/test/fixtures/auth/googleUserInfo.js b/test/fixtures/auth/googleUserInfo.js new file mode 100644 index 000000000..54428561d --- /dev/null +++ b/test/fixtures/auth/googleUserInfo.js @@ -0,0 +1,77 @@ +/** + * User info for Google auth response + * Multiple responses can be added to the array if required + * + * @return {Object} + */ +module.exports = () => { + return [ + { + id: "1234567890", + displayName: "Google User", + emails: [{ value: "test12@gmail.com", verified: true }], + photos: [ + { + value: "https://lh3.googleusercontent.com/a-/test", + }, + ], + provider: "google", + _raw: `{ + '"sub": "1234567890",\n' + + '"picture": "https://lh3.googleusercontent.com/a-/test",\n' + + '"email": "test12@gmail.com",\n' + + '"email_verified": true\n' + + }`, + _json: { + sub: "1234567890", + picture: "https://lh3.googleusercontent.com/a-/test", + email: "test12@gmail.com", + email_verified: true, + }, + }, + { + email: "test12@gmail.com", + roles: { + in_discord: true, + archived: false, + }, + incompleteUserDetails: false, + updated_at: Date.now(), + created_at: Date.now(), + }, + { + id: "1234567890", + displayName: "Google User", + emails: [{ value: "test123@gmail.com", verified: true }], + photos: [ + { + value: "https://lh3.googleusercontent.com/a-/test", + }, + ], + provider: "google", + _raw: `{ + '"sub": "1234567890",\n' + + '"picture": "https://lh3.googleusercontent.com/a-/test",\n' + + '"email": "test123@gmail.com",\n' + + '"email_verified": true\n' + + }`, + _json: { + sub: "1234567890", + picture: "https://lh3.googleusercontent.com/a-/test", + email: "test123@gmail.com", + email_verified: true, + }, + }, + { + email: "test123@gmail.com", + roles: { + developer: true, + in_discord: true, + archived: false, + }, + incompleteUserDetails: false, + updated_at: Date.now(), + created_at: Date.now(), + }, + ]; +}; diff --git a/test/fixtures/user/user.js b/test/fixtures/user/user.js index 65de62f97..4adfc24a2 100644 --- a/test/fixtures/user/user.js +++ b/test/fixtures/user/user.js @@ -94,7 +94,7 @@ module.exports = () => { github_id: "sagarbajpai", github_display_name: "Sagar Bajpai", phone: "1234567890", - email: "abc@gmail.com", + email: "abc1@gmail.com", status: "active", tokens: { githubAccessToken: "githubAccessToken", @@ -146,7 +146,7 @@ module.exports = () => { github_display_name: "Ankita Bannore", isMember: true, phone: "1234567890", - email: "abc@gmail.com", + email: "abc12@gmail.com", tokens: { githubAccessToken: "githubAccessToken", }, @@ -195,7 +195,7 @@ module.exports = () => { github_id: "ankur1234", github_display_name: "ankur-xyz", phone: "1234567890", - email: "abc@gmail.com", + email: "abc123@gmail.com", }, { username: "ritvik", @@ -426,7 +426,7 @@ module.exports = () => { github_display_name: "vinayak-trivedi", discordJoinedAt: "2023-04-06T01:47:34.488000+00:00", phone: "1234567890", - email: "abc@gmail.com", + email: "abcd123@gmail.com", status: "active", tokens: { githubAccessToken: "githubAccessToken", diff --git a/test/integration/auth.test.js b/test/integration/auth.test.js index cf9c2d872..8180abd0b 100644 --- a/test/integration/auth.test.js +++ b/test/integration/auth.test.js @@ -6,6 +6,7 @@ const passport = require("passport"); const app = require("../../server"); const cleanDb = require("../utils/cleanDb"); const { generateGithubAuthRedirectUrl } = require("..//utils/github"); +const { generateGoogleAuthRedirectUrl, stubPassportAuthenticate } = require("..//utils/googleauth"); const { addUserToDBForTest } = require("../../utils/users"); const userData = require("../fixtures/user/user")(); @@ -13,6 +14,7 @@ chai.use(chaiHttp); // Import fixtures const githubUserInfo = require("../fixtures/auth/githubUserInfo")(); +const googleUserInfo = require("../fixtures/auth/googleUserInfo")(); describe("auth", function () { afterEach(async function () { @@ -254,4 +256,288 @@ describe("auth", function () { expect(res.headers["set-cookie"][1]).to.include(`Domain=${rdsUiUrl.hostname}`); expect(res.headers["set-cookie"][1]).to.include("SameSite=Lax"); }); + + it("should redirect to the correct URL and update user email when GitHub API returns primary email", async function () { + const rdsUrl = new URL(config.get("services.rdsUi.baseUrl")).href; + const fakeEmails = [ + { primary: true, email: "primary@example.com" }, + { primary: false, email: "secondary@example.com" }, + ]; + const fetchStub = sinon.stub(global, "fetch").resolves(new Response(JSON.stringify(fakeEmails))); + + sinon.stub(passport, "authenticate").callsFake((strategy, options, callback) => { + callback(null, "accessToken", { + username: "github-user", + displayName: "GitHub User", + _json: { email: null, created_at: "2022-01-01" }, + id: 12345, + }); + return (req, res, next) => {}; + }); + + const res = await chai + .request(app) + .get(`/auth/github/callback`) + .query({ code: "codeReturnedByGithub", state: rdsUrl }) + .redirects(0); + expect(res).to.have.status(302); + + const fetchArgs = fetchStub.getCall(0).args; + expect(fetchArgs[0]).to.equal("https://api.github.com/user/emails"); + expect(fetchArgs[1].headers.Authorization).to.equal("token accessToken"); + }); + + it("should return google call back URL", async function () { + const googleOauthURL = generateGoogleAuthRedirectUrl({}); + const res = await chai.request(app).get("/auth/google/login?dev=true").redirects(0); + expect(res).to.have.status(302); + expect(res.headers.location).to.equal(googleOauthURL); + }); + + it("should return google call back URL with redirectUrl", async function () { + const RDS_MEMBERS_SITE_URL = "https://members.realdevsquad.com"; + const googleOauthURL = generateGoogleAuthRedirectUrl({ state: RDS_MEMBERS_SITE_URL }); + const res = await chai + .request(app) + .get("/auth/google/login?dev=true") + .query({ redirectURL: RDS_MEMBERS_SITE_URL }) + .redirects(0); + expect(res).to.have.status(302); + expect(res.headers.location).to.equal(googleOauthURL); + }); + + it("should redirect the google user to new sign up flow if they are have incomplete user details true", async function () { + const redirectURL = "https://my.realdevsquad.com/new-signup"; + stubPassportAuthenticate(googleUserInfo[0]); + + const res = await chai + .request(app) + .get("/auth/google/callback") + .query({ code: "codeReturnedByGoogle" }) + .redirects(0); + expect(res).to.have.status(302); + expect(res.headers.location).to.equal(redirectURL); + }); + + it("should redirect the google user to the goto page on successful login, if user has incomplete user details false", async function () { + await addUserToDBForTest(googleUserInfo[1]); + const rdsUiUrl = new URL(config.get("services.rdsUi.baseUrl")).href; + stubPassportAuthenticate(googleUserInfo[0]); + + const res = await chai + .request(app) + .get("/auth/google/callback") + .query({ code: "codeReturnedByGoogle", state: rdsUiUrl }) + .redirects(0); + expect(res).to.have.status(302); + expect(res.headers.location).to.equal(rdsUiUrl); + }); + + it("should redirect the google user to the redirect URL provided on successful login, if user has incomplete user details false", async function () { + await addUserToDBForTest(googleUserInfo[1]); + const rdsUrl = new URL("https://dashboard.realdevsquad.com").href; + stubPassportAuthenticate(googleUserInfo[0]); + const res = await chai + .request(app) + .get(`/auth/google/callback`) + .query({ code: "codeReturnedByGoogle", state: rdsUrl }) + .redirects(0); + expect(res).to.have.status(302); + expect(res.headers.location).to.equal(rdsUrl); + }); + + it("should redirect the google user to realdevsquad.com if non RDS URL provided, any url that is other than *.realdevsquad.com is invalid", async function () { + await addUserToDBForTest(googleUserInfo[1]); + const invalidRedirectUrl = new URL("https://google.com").href; + const rdsUiUrl = new URL(config.get("services.rdsUi.baseUrl")).href; + stubPassportAuthenticate(googleUserInfo[0]); + + const res = await chai + .request(app) + .get(`/auth/google/callback`) + .query({ code: "codeReturnedByGoogle", state: invalidRedirectUrl }) + .redirects(0); + expect(res).to.have.status(302); + expect(res.headers.location).to.equal(rdsUiUrl); + }); + + it("should redirect the google user to realdevsquad.com if invalid redirect URL provided", async function () { + await addUserToDBForTest(googleUserInfo[1]); + const invalidRedirectUrl = "invalidURL"; + const rdsUiUrl = new URL(config.get("services.rdsUi.baseUrl")).href; + stubPassportAuthenticate(googleUserInfo[0]); + const res = await chai + .request(app) + .get(`/auth/google/callback`) + .query({ code: "codeReturnedByGoogle", state: invalidRedirectUrl }) + .redirects(0); + expect(res).to.have.status(302); + expect(res.headers.location).to.equal(rdsUiUrl); + }); + + it("should issue JWT cookie on using google login", function (done) { + const rdsUiUrl = new URL(config.get("services.rdsUi.baseUrl")); + + stubPassportAuthenticate(googleUserInfo[0]); + + chai + .request(app) + .get("/auth/google/callback") + .query({ code: "codeReturnedByGoogle" }) + .redirects(0) + .end((err, res) => { + if (err) { + return done(err); + } + + expect(res).to.have.status(302); + expect(res.headers["set-cookie"]).to.have.length(1); + expect(res.headers["set-cookie"][0]) + .to.be.a("string") + .and.satisfy((msg) => msg.startsWith(config.get("userToken.cookieName"))); + expect(res.headers["set-cookie"][0]).to.include("HttpOnly"); + expect(res.headers["set-cookie"][0]).to.include("Secure"); + expect(res.headers["set-cookie"][0]).to.include(`Domain=${rdsUiUrl.hostname}`); + expect(res.headers["set-cookie"][0]).to.include("SameSite=Lax"); + + return done(); + }); + }); + + it("should redirect the google user to login page if the user is a developer", async function () { + await addUserToDBForTest(googleUserInfo[3]); + const rdsUiUrl = new URL(config.get("services.rdsUi.baseUrl")).href; + stubPassportAuthenticate(googleUserInfo[2]); + + const res = await chai + .request(app) + .get("/auth/google/callback") + .query({ code: "codeReturnedByGoogle", state: rdsUiUrl }) + .redirects(0); + expect(res).to.have.status(302); + const errorMessage = "Google login is restricted for developer role."; + const expectedUrl = `https://realdevsquad.com/?error=${encodeURIComponent(errorMessage)}`; + expect(res.headers.location).to.equal(expectedUrl); + }); + + it("should log in existing google user with same email via github OAuth", async function () { + await addUserToDBForTest(googleUserInfo[1]); + const rdsUiUrl = new URL(config.get("services.rdsUi.baseUrl")).href; + const userInfoFromGitHub = { + ...githubUserInfo[0], + _json: { + ...githubUserInfo[0]._json, + email: "test12@gmail.com", + }, + }; + stubPassportAuthenticate(userInfoFromGitHub); + + const res = await chai + .request(app) + .get("/auth/github/callback") + .query({ code: "codeReturnedByGithub", state: rdsUiUrl }) + .redirects(0); + expect(res).to.have.status(302); + expect(res.headers.location).to.equal(rdsUiUrl); + }); + + it("should log in existing github user with same email via google OAuth", async function () { + await addUserToDBForTest(userData[0]); + const rdsUiUrl = new URL(config.get("services.rdsUi.baseUrl")).href; + const userInfoFromGoogle = { + ...googleUserInfo[0], + emails: [{ value: "abc@gmail.com", verified: true }], + }; + stubPassportAuthenticate(userInfoFromGoogle); + + const res = await chai + .request(app) + .get("/auth/google/callback") + .query({ code: "codeReturnedByGoogle", state: rdsUiUrl }) + .redirects(0); + expect(res).to.have.status(302); + expect(res.headers.location).to.equal(rdsUiUrl); + }); + + it("should get the verified email and redirect the google user to the goto page on successful login", async function () { + await addUserToDBForTest(googleUserInfo[1]); + const rdsUiUrl = new URL(config.get("services.rdsUi.baseUrl")).href; + const googleUser = { + ...googleUserInfo[0], + emails: [ + { value: "test123@example.com", verified: false }, + { value: "test12@gmail.com", verified: true }, + ], + }; + stubPassportAuthenticate(googleUser); + + const res = await chai + .request(app) + .get("/auth/google/callback") + .query({ code: "codeReturnedByGoogle", state: rdsUiUrl }) + .redirects(0); + expect(res).to.have.status(302); + expect(res.headers.location).to.equal(rdsUiUrl); + }); + + it("should return 404 if dev feature flag is not enabled", async function () { + const res = await chai.request(app).get("/auth/google/login"); + + expect(res).to.have.status(404); + expect(res.body.message).to.equal("Route not found"); + }); + + it("should return 401 if google email does not exist", async function () { + const rdsUiUrl = new URL(config.get("services.rdsUi.baseUrl")).href; + const userInfoWithoutEmail = { + ...googleUserInfo[0], + emails: [], + }; + + stubPassportAuthenticate(userInfoWithoutEmail); + + const res = await chai + .request(app) + .get("/auth/google/callback") + .query({ code: "codeReturnedByGoogle", state: rdsUiUrl }) + .redirects(0); + + expect(res).to.have.status(401); + expect(res.body.message).to.equal("No email found in Google account"); + }); + + it("should return 401 if no verified email exists", async function () { + const rdsUiUrl = new URL(config.get("services.rdsUi.baseUrl")).href; + const userInfoWithUnverifiedEmail = { + ...googleUserInfo[0], + emails: [{ value: "test@example.com", verified: false }], + }; + stubPassportAuthenticate(userInfoWithUnverifiedEmail); + const res = await chai + .request(app) + .get("/auth/google/callback") + .query({ code: "codeReturnedByGoogle", state: rdsUiUrl }) + .redirects(0); + + expect(res).to.have.status(401); + expect(res.body.message).to.equal("No verified email found in Google account"); + }); + + it("should return 401 if google auth call fails", function (done) { + chai + .request(app) + .get("/auth/google/callback") + .query({ code: "codeReturnedByGoogle" }) + .end((err, res) => { + if (err) { + return done(err); + } + + expect(res).to.have.status(401); + expect(res.body).to.be.an("object"); + expect(res.body.message).to.equal("User cannot be authenticated"); + + return done(); + }); + }); }); diff --git a/test/utils/googleauth.js b/test/utils/googleauth.js new file mode 100644 index 000000000..4c4c5d847 --- /dev/null +++ b/test/utils/googleauth.js @@ -0,0 +1,31 @@ +const defaultClientId = config.get("googleOauth.clientId"); +const baseURL = config.get("services.rdsApi.baseUrl"); +const sinon = require("sinon"); +const passport = require("passport"); + +const generateGoogleAuthRedirectUrl = function ({ + baseUrl = "https://accounts.google.com/o/oauth2/v2/auth", + responseType = "code", + redirectUri = `${baseURL}/auth/google/callback`, + scope = "email", + state = "", + clientId = defaultClientId, +}) { + const encodedBaseUrl = encodeURI(baseUrl); + const encodedRedirectUri = encodeURIComponent(redirectUri); + const encodedScope = encodeURIComponent(scope); + let encodedUrl = `${encodedBaseUrl}?response_type=${responseType}&redirect_uri=${encodedRedirectUri}&scope=${encodedScope}`; + if (state) { + encodedUrl += `&state=${encodeURIComponent(state)}`; + } + return `${encodedUrl}&client_id=${clientId}`; +}; + +const stubPassportAuthenticate = function (userData, token = "accessToken") { + return sinon.stub(passport, "authenticate").callsFake((strategy, options, callback) => { + callback(null, token, userData); + return (req, res, next) => {}; + }); +}; + +module.exports = { generateGoogleAuthRedirectUrl, stubPassportAuthenticate }; diff --git a/yarn.lock b/yarn.lock index 11e87d719..027e73056 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7100,6 +7100,13 @@ passport-github2@0.1.12: dependencies: passport-oauth2 "1.x.x" +passport-google-oauth20@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz#0d241b2d21ebd3dc7f2b60669ec4d587e3a674ef" + integrity sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ== + dependencies: + passport-oauth2 "1.x.x" + passport-oauth2@1.x.x: version "1.8.0" resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.8.0.tgz#55725771d160f09bbb191828d5e3d559eee079c8"