diff --git a/.claude/test-cases/OAuthGithubLogin.md b/.claude/test-cases/OAuthGithubLogin.md new file mode 100644 index 00000000..e319f80a --- /dev/null +++ b/.claude/test-cases/OAuthGithubLogin.md @@ -0,0 +1,66 @@ +# OAuth — GitHub Login — Test Cases + +**Location:** Login page & Invitation signup → "Or Continue With" → GitHub · **Last updated:** 2026-06-11 + +> **Status legend:** ⏳ Pending · ✅ Pass · ❌ Fail · ⚠️ Blocked · 🔄 Flaky + +--- + +## Provider App Setup (do this once before testing) + +**Console:** https://github.com/settings/developers → **OAuth Apps → New OAuth App** + +| Setting | Value (local dev) | +|---|---| +| Homepage URL | `http://localhost:8080` | +| Authorization callback URL | `http://localhost:8080` (the SPA reads `?code=` on return) | +| Flow | Full-page redirect to `https://github.com/login/oauth/authorize` | + +Copy the **Client ID** and generate a **Client secret**, then set: + +| File | Variable | Value | +|---|---|---| +| root `.env` | `GITHUB_CLIENT_ID` | the client id | +| root `.env` | `GITHUB_CLIENT_SECRET` | the client secret | +| root `.env` | `GITHUB_BASE_OAUTH_URL` | `https://github.com/login/oauth` | +| root `.env` | `GITHUB_OAUTH_REQUIRED` | `true` (requires server-side verify via GitHub `/user`) | +| `frontend/.env` | `VUE_APP_GITHUB_CLIENT_ID` | the **same** client id | +| `frontend/.env` | `VUE_APP_GITHUB_BASE_OAUTH_URL` | `https://github.com/login/oauth` | +| `frontend/.env` | `VUE_APP_IS_GITHUB_LOGIN` | `true` | + +**Endpoints exercised:** `POST /api/v1/github/access-token` (code→token), `POST /api/v2/auth/login` (verify + session), `POST /api/v2/github-signup` (new user via invitation). GitHub identity is read from `https://api.github.com/user` + `/user/emails`. +**Restart** both servers after editing env (frontend env is build-time). + +--- + +## Configuration & Visibility + +| ID | Title | Precondition | Steps | Expected Result | Actual Result | Status | +|--------|------------------------------------------------|-----------------------------------------------|------------------------------------------------------------------|------------------------------------------------------------------------|---------------|--------| +| GHL_001 | Button hidden when disabled | `VUE_APP_IS_GITHUB_LOGIN='false'` | 1. Open the Login page | No GitHub button shown | | ⏳ | +| GHL_002 | Button shows when enabled | Flag `'true'` + client id set; servers restarted | 1. Open the Login page | GitHub button appears under the "Or Continue With" divider | | ⏳ | + +## Login & Signup + +| ID | Title | Precondition | Steps | Expected Result | Actual Result | Status | +|--------|------------------------------------------------|-----------------------------------------------|------------------------------------------------------------------|------------------------------------------------------------------------|---------------|--------| +| GHL_003 | Existing user logs in with GitHub | A verified user whose email matches the GitHub primary email | 1. Click GitHub 2. Authorize on GitHub 3. Get redirected back | Returns to the app logged in; the `?code=` is cleared from the URL | | ⏳ | +| GHL_004 | New user signs up via invitation | An open company invitation for the GitHub email | 1. Open invitation link 2. Click GitHub 3. Authorize | Account created (email pre-verified) then auto-logged-in | | ⏳ | +| GHL_005 | Primary verified email is used | GitHub account with multiple emails | 1. Log in with GitHub | The primary, verified GitHub email is the one matched/created | | ⏳ | + +## Security / Verification + +| ID | Title | Precondition | Steps | Expected Result | Actual Result | Status | +|--------|------------------------------------------------|-----------------------------------------------|------------------------------------------------------------------|------------------------------------------------------------------------|---------------|--------| +| GHL_006 | accessToken verification enforced | `GITHUB_OAUTH_REQUIRED='true'` | 1. Attempt login without a valid GitHub access token | Rejected ("accessToken is required" / verification failed); no session | | ⏳ | +| GHL_007 | Blocked email cannot log in | Auth record `isBlocked = true` | 1. Click GitHub and authorize that account | Rejected with the "email has been blocked" message | | ⏳ | + +## Coexistence + +| ID | Title | Precondition | Steps | Expected Result | Actual Result | Status | +|--------|------------------------------------------------|-----------------------------------------------|------------------------------------------------------------------|------------------------------------------------------------------------|---------------|--------| +| GHL_008 | GitHub & GitLab don't collide on redirect | Both GitHub and GitLab enabled | 1. Start a GitHub login and complete it | Only the GitHub handler processes the `?code=` (GitLab ignores it); login succeeds | | ⏳ | + +--- + +**Total:** 8 test cases · **All status:** ⏳ Pending diff --git a/.claude/test-cases/OAuthGitlabLogin.md b/.claude/test-cases/OAuthGitlabLogin.md new file mode 100644 index 00000000..93332602 --- /dev/null +++ b/.claude/test-cases/OAuthGitlabLogin.md @@ -0,0 +1,70 @@ +# OAuth — GitLab Login — Test Cases + +**Location:** Login page & Invitation signup → "Or Continue With" → GitLab · **Last updated:** 2026-06-11 + +> **Status legend:** ⏳ Pending · ✅ Pass · ❌ Fail · ⚠️ Blocked · 🔄 Flaky + +--- + +## Provider App Setup (do this once before testing) + +**Console:** https://gitlab.com/-/profile/applications → **Add new application** + +| Setting | Value (local dev) | +|---|---| +| Redirect URI | `http://localhost:8080` (exact — no trailing slash) | +| Scopes | ✅ `read_user` | +| Confidential | ✅ Yes (the secret is used server-side) | +| Flow | Full-page redirect to `https://gitlab.com/oauth/authorize` (`response_type=code`) | + +Copy the **Application ID** and **Secret**, then set: + +| File | Variable | Value | +|---|---|---| +| root `.env` | `GITLAB_CLIENT_ID` | the application id | +| root `.env` | `GITLAB_CLIENT_SECRET` | the secret | +| root `.env` | `GITLAB_BASE_OAUTH_URL` | `https://gitlab.com/oauth` | +| root `.env` | `GITLAB_BASE_API_URL` | `https://gitlab.com/api/v4` (identity verify `/user`) | +| root `.env` | `GITLAB_OAUTH_REQUIRED` | `true` (requires server-side verify via GitLab `/user`) | +| `frontend/.env` | `VUE_APP_GITLAB_CLIENT_ID` | the **same** application id | +| `frontend/.env` | `VUE_APP_GITLAB_BASE_OAUTH_URL` | `https://gitlab.com/oauth` | +| `frontend/.env` | `VUE_APP_IS_GITLAB_LOGIN` | `true` | + +**Endpoints exercised:** `POST /api/v1/gitlab/access-token` (code→token, sends `redirectUri`), `POST /api/v2/auth/login` (verify + session), `POST /api/v2/gitlab-signup` (new user via invitation). GitLab identity is read from `https://gitlab.com/api/v4/user`. +**Self-managed GitLab:** change the host in `GITLAB_BASE_OAUTH_URL` / `GITLAB_BASE_API_URL` / `VUE_APP_GITLAB_BASE_OAUTH_URL`. +**Restart** both servers after editing env (frontend env is build-time). + +--- + +## Configuration & Visibility + +| ID | Title | Precondition | Steps | Expected Result | Actual Result | Status | +|--------|------------------------------------------------|-----------------------------------------------|------------------------------------------------------------------|------------------------------------------------------------------------|---------------|--------| +| GLL_001 | Button hidden when disabled | `VUE_APP_IS_GITLAB_LOGIN='false'` | 1. Open the Login page | No GitLab button shown | | ⏳ | +| GLL_002 | Button shows when enabled | Flag `'true'` + client id set; servers restarted | 1. Open the Login page | GitLab (tanuki) button appears under the "Or Continue With" divider | | ⏳ | + +## Login & Signup + +| ID | Title | Precondition | Steps | Expected Result | Actual Result | Status | +|--------|------------------------------------------------|-----------------------------------------------|------------------------------------------------------------------|------------------------------------------------------------------------|---------------|--------| +| GLL_003 | Existing user logs in with GitLab | A verified user whose email matches the GitLab account | 1. Click GitLab 2. Authorize on GitLab 3. Get redirected back | Returns to the app logged in; the `?code=` is cleared from the URL | | ⏳ | +| GLL_004 | New user signs up via invitation | An open company invitation for the GitLab email | 1. Open invitation link 2. Click GitLab 3. Authorize | Account created (email pre-verified) then auto-logged-in | | ⏳ | +| GLL_005 | gitlabId linked on first login | First GitLab login for an existing email user | 1. Complete GLL_003 once | The user's auth record now carries the gitlabId | | ⏳ | + +## Security / Verification + +| ID | Title | Precondition | Steps | Expected Result | Actual Result | Status | +|--------|------------------------------------------------|-----------------------------------------------|------------------------------------------------------------------|------------------------------------------------------------------------|---------------|--------| +| GLL_006 | accessToken verification enforced | `GITLAB_OAUTH_REQUIRED='true'` | 1. Attempt login without a valid GitLab access token | Rejected ("accessToken is required" / verification failed); no session | | ⏳ | +| GLL_007 | Blocked email cannot log in | Auth record `isBlocked = true` | 1. Click GitLab and authorize that account | Rejected with the "email has been blocked" message | | ⏳ | + +## Coexistence + +| ID | Title | Precondition | Steps | Expected Result | Actual Result | Status | +|--------|------------------------------------------------|-----------------------------------------------|------------------------------------------------------------------|------------------------------------------------------------------------|---------------|--------| +| GLL_008 | GitLab & GitHub don't collide on redirect | Both GitLab and GitHub enabled | 1. Start a GitLab login and complete it | Only the GitLab handler processes the `?code=` (GitHub ignores it); login succeeds | | ⏳ | +| GLL_009 | All three providers visible together | Google + GitHub + GitLab all enabled | 1. Open the Login page | All three buttons render in one row under the divider | | ⏳ | + +--- + +**Total:** 9 test cases · **All status:** ⏳ Pending diff --git a/.claude/test-cases/OAuthGoogleLogin.md b/.claude/test-cases/OAuthGoogleLogin.md new file mode 100644 index 00000000..763c29ee --- /dev/null +++ b/.claude/test-cases/OAuthGoogleLogin.md @@ -0,0 +1,58 @@ +# OAuth — Google Login — Test Cases + +**Location:** Login page & Invitation signup → "Or Continue With" → Google · **Last updated:** 2026-06-11 + +> **Status legend:** ⏳ Pending · ✅ Pass · ❌ Fail · ⚠️ Blocked · 🔄 Flaky + +--- + +## Provider App Setup (do this once before testing) + +**Console:** https://console.cloud.google.com/apis/credentials → **Create Credentials → OAuth client ID → Web application** + +| Setting | Value (local dev) | +|---|---| +| Authorized JavaScript origins | `http://localhost:8080` (your `WEBURL` in prod) | +| Flow | GSI popup (`initCodeClient`, `ux_mode: popup`) — no redirect URI needed | + +Copy the **Client ID** and **Client secret**, then set: + +| File | Variable | Value | +|---|---|---| +| root `.env` | `GOOGLE_CLIENT_ID` | the client id | +| root `.env` | `GOOGLE_CLIENT_SECRET` | the client secret | +| root `.env` | `GOOGLE_OAUTH_URL` | `https://oauth2.googleapis.com/token` | +| root `.env` | `GOOGLE_OAUTH_REQUIRED` | `true` (rejects logins that fail id-token verification) | +| `frontend/.env` | `VUE_APP_GOOGLE_CLIENT_ID` | the **same** client id | +| `frontend/.env` | `VUE_APP_IS_GOOGLE_LOGIN` | `true` | + +**Endpoints exercised:** `POST /api/v1/google/access-token` (code→token), `POST /api/v2/auth/login` (verify + session), `POST /api/v2/google-signup` (new user via invitation). +**Restart** both servers after editing env (frontend env is build-time). + +--- + +## Configuration & Visibility + +| ID | Title | Precondition | Steps | Expected Result | Actual Result | Status | +|--------|------------------------------------------------|-----------------------------------------------|------------------------------------------------------------------|------------------------------------------------------------------------|---------------|--------| +| GGL_001 | Button hidden when disabled | `VUE_APP_IS_GOOGLE_LOGIN='false'` | 1. Open the Login page | No Google button; "Or Continue With" divider hidden if no other provider | | ⏳ | +| GGL_002 | Button shows when enabled | Flag `'true'` + client id set; servers restarted | 1. Open the Login page | Google button appears under the "Or Continue With" divider | | ⏳ | + +## Login & Signup + +| ID | Title | Precondition | Steps | Expected Result | Actual Result | Status | +|--------|------------------------------------------------|-----------------------------------------------|------------------------------------------------------------------|------------------------------------------------------------------------|---------------|--------| +| GGL_003 | Existing user logs in with Google | A verified user whose email matches a Google account | 1. Click Google 2. Pick the account in the popup | Popup closes; user is logged in and lands in the app (company selected) | | ⏳ | +| GGL_004 | New user signs up via invitation | An open company invitation for the Google email | 1. Open the invitation link 2. Click Google 3. Pick account | Account created (email pre-verified), then auto-logged-in | | ⏳ | +| GGL_005 | googleId linked on first login | First Google login for an existing email user | 1. Complete GGL_003 once | The user's auth record now carries the googleId (subsequent logins skip linking) | | ⏳ | + +## Security / Verification + +| ID | Title | Precondition | Steps | Expected Result | Actual Result | Status | +|--------|------------------------------------------------|-----------------------------------------------|------------------------------------------------------------------|------------------------------------------------------------------------|---------------|--------| +| GGL_006 | Unverified id-token is rejected | `GOOGLE_OAUTH_REQUIRED='true'` | 1. Attempt a login where the id-token can't be verified (wrong/expired token) | Login rejected with a clear error; no session issued | | ⏳ | +| GGL_007 | Blocked email cannot log in | A user whose auth record `isBlocked = true` | 1. Click Google and pick that account | Rejected with the "email has been blocked" message | | ⏳ | + +--- + +**Total:** 7 test cases · **All status:** ⏳ Pending diff --git a/.env.example b/.env.example index e886fe77..dc65df81 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,45 @@ AUTH_RATE_LIMIT_WINDOW_MS="900000" AUTH_RATE_LIMIT_BLOCK_MS="1800000" +# ───────────────────────────────────────── +# OAUTH / SOCIAL LOGIN (Google + GitHub) +# Optional — leave blank to keep social login disabled. Buttons only appear +# when the matching VUE_APP_IS_*_LOGIN flag (frontend/.env) is "true". +# +# The browser performs the OAuth popup/redirect, then the backend (a) exchanges +# the code for tokens and (b) re-verifies the identity with the provider before +# logging anyone in — so these client ids/secrets are REQUIRED for login to work. +# ───────────────────────────────────────── + +# Google — create an OAuth 2.0 Client ID (type: Web application) at +# https://console.cloud.google.com/apis/credentials +# Authorized JavaScript origins: your WEBURL (e.g. http://localhost:8080 in dev). +# The same client id must also be set as VUE_APP_GOOGLE_CLIENT_ID in frontend/.env. +GOOGLE_CLIENT_ID="" # used for token exchange AND id-token verification +GOOGLE_CLIENT_SECRET="" +GOOGLE_OAUTH_URL="https://oauth2.googleapis.com/token" # Google token endpoint +GOOGLE_OAUTH_REQUIRED="true" # "true" rejects logins that fail id-token verification (recommended) + +# GitHub — create an OAuth App at https://github.com/settings/developers +# Authorization callback URL: your WEBURL (the SPA reads the ?code= on return). +# The same client id must also be set as VUE_APP_GITHUB_CLIENT_ID in frontend/.env, +# and VUE_APP_GITHUB_BASE_OAUTH_URL=https://github.com/login/oauth. +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" +GITHUB_BASE_OAUTH_URL="https://github.com/login/oauth" # authorize + access_token base +GITHUB_OAUTH_REQUIRED="true" # "true" requires server-side verification via GitHub /user (recommended) + +# GitLab — create an Application at https://gitlab.com/-/profile/applications +# Redirect URI: your WEBURL (the SPA reads ?code= on return). Scope: read_user. +# The same client id must also be set as VUE_APP_GITLAB_CLIENT_ID in frontend/.env, +# and VUE_APP_GITLAB_BASE_OAUTH_URL=https://gitlab.com/oauth. +# For self-managed GitLab, point the *_BASE_OAUTH_URL / *_BASE_API_URL at your host. +GITLAB_CLIENT_ID="" +GITLAB_CLIENT_SECRET="" +GITLAB_BASE_OAUTH_URL="https://gitlab.com/oauth" # authorize + token base +GITLAB_BASE_API_URL="https://gitlab.com/api/v4" # used to verify the identity (/user) +GITLAB_OAUTH_REQUIRED="true" # "true" requires server-side verification via GitLab /user (recommended) + # ───────────────────────────────────────── # APP URLs # ───────────────────────────────────────── diff --git a/Modules/Auth/controller/authHelpers.js b/Modules/Auth/controller/authHelpers.js index 14040cf2..cc8bb491 100644 --- a/Modules/Auth/controller/authHelpers.js +++ b/Modules/Auth/controller/authHelpers.js @@ -310,6 +310,91 @@ const verifyGithubAuth = async (reqData, cb) => { } }; +/** + * Server-side verification for GitLab social login — same threat model as + * GitHub/Google: never trust the client-supplied {email, gitlabId}. When an + * accessToken is supplied we round-trip it through GitLab's /user API and + * reject the login unless the id/email returned by GitLab match the claim. + * GITLAB_BASE_API_URL lets self-managed instances point at their own host. + */ +const verifyGitlabAccessToken = async (accessToken) => { + const axios = require('axios'); + const apiBase = process.env.GITLAB_BASE_API_URL || 'https://gitlab.com/api/v4'; + const response = await axios.get(`${apiBase}/user`, { + headers: { Authorization: `Bearer ${accessToken}` }, + timeout: 8000, + }); + return response.data; +}; + +const verifyGitlabAuth = async (reqData, cb) => { + if (!reqData.gitlabId) { + cb({ status: false, message: "gitlabId is required" }); + return; + } + + if (reqData.accessToken) { + try { + const glUser = await verifyGitlabAccessToken(reqData.accessToken); + if (String(glUser.id) !== String(reqData.gitlabId)) { + cb({ status: false, message: "GitLab identity verification failed (id mismatch)" }); + return; + } + if (glUser.email && reqData.email && glUser.email.toLowerCase() !== reqData.email.toLowerCase()) { + cb({ status: false, message: "GitLab identity verification failed (email mismatch)" }); + return; + } + } catch (error) { + cb({ status: false, message: `GitLab identity verification failed: ${error.message}` }); + return; + } + } else if (process.env.GITLAB_OAUTH_REQUIRED !== 'false') { + cb({ status: false, message: "accessToken is required for GitLab login" }); + return; + } + + try { + const obj = { + type: dbCollections.USER_AUTH, + data: [{ email: reqData.email }], + }; + + const resData = await mongoC.MongoDbCrudOpration(dbCollections.GLOBAL, obj, "findOne"); + if (!resData?._id) { + cb({ status: false, message: "User not found" }); + return; + } + + if (resData.isBlocked) { + cb({ + status: false, + message: "Your email has been blocked. Please contact the administrator.", + }); + return; + } + + // Link gitlabId if not set + if (!resData.gitlabId) { + const updateObject = { + type: dbCollections.USER_AUTH, + data: [ + { email: reqData.email }, + { gitlabId: reqData.gitlabId }, + ], + }; + await mongoC.MongoDbCrudOpration(dbCollections.GLOBAL, updateObject, "findOneAndUpdate"); + } + + cb({ + status: true, + data: resData, + message: "Gitlab login successful", + }); + } catch (error) { + cb({ status: false, message: serviceCtr.mongoErrorMessage(error) }); + } +}; + /** * BUG-039 / #93 — server-side verification for Google social login. * @@ -326,11 +411,18 @@ const verifyGithubAuth = async (reqData, cb) => { * deployments don't hard-break on upgrade, but we log a warning so * operators see they're running unhardened. */ +// The Google client id used to verify the id-token's audience. Historically +// this read GOOGLE_OAUTH_CLIENT_ID, but the token exchange and the settings UI +// use GOOGLE_CLIENT_ID — so an operator who configured the latter left +// verification silently disabled. Accept either name (prefer the explicit +// OAuth one) so one configured value hardens the whole flow. +const getGoogleAudience = () => process.env.GOOGLE_OAUTH_CLIENT_ID || process.env.GOOGLE_CLIENT_ID || ''; + const verifyGoogleIdToken = async (idToken) => { const { OAuth2Client } = require('google-auth-library'); - const audience = process.env.GOOGLE_OAUTH_CLIENT_ID; + const audience = getGoogleAudience(); if (!audience) { - throw new Error('GOOGLE_OAUTH_CLIENT_ID not configured'); + throw new Error('Google client id not configured (set GOOGLE_CLIENT_ID)'); } const client = new OAuth2Client(audience); const ticket = await client.verifyIdToken({ idToken, audience }); @@ -345,7 +437,8 @@ const verifyGoogleAuth = async (reqData, cb) => { // BUG-039 / #93 fix: prefer the verified id-token payload over // anything the client tells us. - if (reqData.idToken && process.env.GOOGLE_OAUTH_CLIENT_ID) { + const googleAudience = getGoogleAudience(); + if (reqData.idToken && googleAudience) { try { const payload = await verifyGoogleIdToken(reqData.idToken); if (!payload || !payload.sub) { @@ -364,10 +457,10 @@ const verifyGoogleAuth = async (reqData, cb) => { cb({ status: false, message: `Google identity verification failed: ${error.message}` }); return; } - } else if (process.env.GOOGLE_OAUTH_CLIENT_ID && process.env.GOOGLE_OAUTH_REQUIRED !== 'false') { + } else if (googleAudience && process.env.GOOGLE_OAUTH_REQUIRED !== 'false') { cb({ status: false, message: "idToken is required for Google login" }); return; - } else if (!process.env.GOOGLE_OAUTH_CLIENT_ID) { logger.warn("Google login: GOOGLE_OAUTH_CLIENT_ID not configured — running unhardened (BUG-039)."); + } else if (!googleAudience) { logger.warn("Google login: client id not configured (GOOGLE_CLIENT_ID) — running unhardened (BUG-039)."); } try { @@ -513,6 +606,8 @@ exports.verifyAuth = (reqData, cb) => { verifyGoogleAuth(reqData, cb); } else if (reqData.authProvider === "github") { verifyGithubAuth(reqData, cb); + } else if (reqData.authProvider === "gitlab") { + verifyGitlabAuth(reqData, cb); } else { verifyLocalAuth(reqData, cb); } diff --git a/Modules/Auth/controller/createUser.js b/Modules/Auth/controller/createUser.js index ec9ce043..a494db5a 100644 --- a/Modules/Auth/controller/createUser.js +++ b/Modules/Auth/controller/createUser.js @@ -400,4 +400,109 @@ exports.githubSignup = async (req, res) => { message: error.message || "Internal Server Error", }); } +}; + +/** + * Sign up with gitlab + * @param {*} req + * @param {*} res + * @returns + */ +exports.gitlabSignup = async (req, res) => { + try { + const { firstName, lastName, email, gitlabId, assignCompany, companyUserDocID } = req.body; + + // Validate Required Fields + if (!firstName || !lastName || !email || !gitlabId) { + return res.status(400).json({ + status: false, + message: "First name, last name, email, and GitLab ID are required", + }); + } + + // Check if user already exists + const findObj = { + type: dbCollections.USER_AUTH, + data: [{ email }], + }; + + const existingUser = await mongoRef.MongoDbCrudOpration(dbCollections.GLOBAL, findObj, "findOne"); + + if (existingUser) { + // If already signed up via GitLab or Local, just return existing user + return res.status(409).json({ + status: false, + message: "Email already exists" + }); + } + + // Create Auth Document (GitLab Only) + const authDoc = { + email, + gitlabId, + isBlocked: false, + }; + + const authObj = { type: dbCollections.USER_AUTH, data: authDoc }; + const authRes = await mongoRef.MongoDbCrudOpration(dbCollections.GLOBAL, authObj, "save"); + + // Update user status in company users + if (assignCompany) { + const query = { + type: SCHEMA_TYPE.COMPANY_USERS, + data: [ + { + _id: new mongoose.Types.ObjectId(companyUserDocID) + }, + { + $set: { + status: 2, + userId: authRes._id + } + } + ] + } + await mongoRef.MongoDbCrudOpration(assignCompany, query, 'findOneAndUpdate'); + + // Import notification settings + await importUserNotifications(assignCompany, authRes._id).catch((error) => { + logger.error(`Import notification setting error in gitlabSignup hook: ${error}`); + }); + + // Add notification count object + await addAndRemoveUserInMongodbNotificationCount(assignCompany, authRes._id, "add").catch((error) => { + logger.error(`Add user in mongodb notification count error in gitlabSignup hook: ${error}`); + }); + } + + // Create User Document + const userDoc = { + _id: authRes._id, + AssignCompany: assignCompany ? [assignCompany] : [], + Employee_FName: firstName, + Employee_LName: lastName, + Employee_Email: email, + Employee_Name: `${firstName} ${lastName}`, + Time_Format: "12", + isDeleted: false, + isActive: true, + isOnline: false, + isEmailVerified: true, // GitLab email is already verified + }; + + const userObj = { type: dbCollections.USERS, data: userDoc }; + const userRes = await mongoRef.MongoDbCrudOpration(dbCollections.GLOBAL, userObj, "save"); + + return res.status(200).json({ + status: true, + message: "Gitlab signup successful", + data: userRes, + }); + } catch (error) { + logger.error(`Gitlab Signup API Error: ${error.message}`); + return res.status(500).json({ + status: false, + message: error.message || "Internal Server Error", + }); + } }; \ No newline at end of file diff --git a/Modules/Auth/routes2.js b/Modules/Auth/routes2.js index 9fc6043e..616418ed 100644 --- a/Modules/Auth/routes2.js +++ b/Modules/Auth/routes2.js @@ -352,4 +352,5 @@ exports.init = (app) => { app.post("/api/v2/google-signup", createUserCtrl.googleSignup); app.post("/api/v2/github-signup", createUserCtrl.githubSignup); + app.post("/api/v2/gitlab-signup", createUserCtrl.gitlabSignup); } \ No newline at end of file diff --git a/Modules/OAuth/controller.js b/Modules/OAuth/controller.js index c09a5a74..6bc45f9e 100644 --- a/Modules/OAuth/controller.js +++ b/Modules/OAuth/controller.js @@ -20,6 +20,9 @@ exports.getOAuthCred = async (req, res) => { githubClientId: backendEnvVars.GITHUB_CLIENT_ID || '', githubClientSecret: backendEnvVars.GITHUB_CLIENT_SECRET || '', isGithubLogin: envVars.VUE_APP_IS_GITHUB_LOGIN === "true", + gitlabClientId: backendEnvVars.GITLAB_CLIENT_ID || '', + gitlabClientSecret: backendEnvVars.GITLAB_CLIENT_SECRET || '', + isGitlabLogin: envVars.VUE_APP_IS_GITLAB_LOGIN === "true", }, }); } catch (error) { diff --git a/Modules/gitlabOAuth/controller.js b/Modules/gitlabOAuth/controller.js new file mode 100644 index 00000000..d0ab5900 --- /dev/null +++ b/Modules/gitlabOAuth/controller.js @@ -0,0 +1,44 @@ +const axios = require('axios'); +const logger = require("../../Config/loggerConfig"); + +// GitLab OAuth credentials. GITLAB_BASE_OAUTH_URL points at the OAuth base +// (e.g. https://gitlab.com/oauth) so the token endpoint is `${base}/token`. +// Self-managed GitLab is supported by changing the host in these vars. +const CLIENT_ID = process.env.GITLAB_CLIENT_ID; +const CLIENT_SECRET = process.env.GITLAB_CLIENT_SECRET; +const GITLAB_BASE_OAUTH_URL = process.env.GITLAB_BASE_OAUTH_URL || 'https://gitlab.com/oauth'; + +/** + * Exchange the authorization code for an access token. + * + * Unlike GitHub, GitLab requires `grant_type` and the exact `redirect_uri` + * that started the flow, so the client forwards the redirect URI it used. + */ +exports.getAccessToken = async (req, res) => { + try { + const { code, redirectUri } = req.body; + + if (!code) { + return res.send({ status: false, statusText: "Missing authorization code" }); + } + + const tokenResponse = await axios.post(`${GITLAB_BASE_OAUTH_URL}/token`, { + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }, { headers: { Accept: "application/json" } }); + + const accessToken = tokenResponse.data.access_token; + + res.send({ status: true, accessToken }); + } catch (error) { + logger.error("Error in gitlab getAccessToken hook:", error?.response?.data || error.message); + return res.send({ + status: false, + statusText: "Error in gitlab getAccessToken hook:", + error: error.message, + }); + } +}; diff --git a/Modules/gitlabOAuth/init.js b/Modules/gitlabOAuth/init.js new file mode 100644 index 00000000..be6fc26a --- /dev/null +++ b/Modules/gitlabOAuth/init.js @@ -0,0 +1,5 @@ +const routes = require('./routes'); + +exports.init = (app) => { + routes.init(app); +} diff --git a/Modules/gitlabOAuth/routes.js b/Modules/gitlabOAuth/routes.js new file mode 100644 index 00000000..54b7d794 --- /dev/null +++ b/Modules/gitlabOAuth/routes.js @@ -0,0 +1,5 @@ +const ctrl = require('./controller'); + +exports.init = (app) => { + app.post('/api/v1/gitlab/access-token', ctrl.getAccessToken); +} diff --git a/frontend/src/assets/images/svg/gitlab_icon.svg b/frontend/src/assets/images/svg/gitlab_icon.svg new file mode 100644 index 00000000..cd9533d6 --- /dev/null +++ b/frontend/src/assets/images/svg/gitlab_icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/composable/commonFunction.js b/frontend/src/composable/commonFunction.js index a0138c0c..38bff4db 100644 --- a/frontend/src/composable/commonFunction.js +++ b/frontend/src/composable/commonFunction.js @@ -833,5 +833,6 @@ export const escapeCSVValue = (value) => { export const isAuthDeviderShow = () => { const isAuthWithGoogle = process.env.VUE_APP_IS_GOOGLE_LOGIN || null; const isAuthWithGithub = process.env.VUE_APP_IS_GITHUB_LOGIN || null; - return (isAuthWithGoogle && isAuthWithGoogle === 'true') || (isAuthWithGithub && isAuthWithGithub === 'true') + const isAuthWithGitlab = process.env.VUE_APP_IS_GITLAB_LOGIN || null; + return (isAuthWithGoogle && isAuthWithGoogle === 'true') || (isAuthWithGithub && isAuthWithGithub === 'true') || (isAuthWithGitlab && isAuthWithGitlab === 'true') } \ No newline at end of file diff --git a/frontend/src/config/env.js b/frontend/src/config/env.js index fe394fd0..9a99ccd1 100644 --- a/frontend/src/config/env.js +++ b/frontend/src/config/env.js @@ -113,6 +113,7 @@ module.exports.DELETE_SESSION = '/api/v2/session/delete'; module.exports.UPDATE_SESSION = '/api/v2/session/update'; module.exports.API_SIGNUP_WITH_GOOGLE = '/api/v2/google-signup'; module.exports.API_SIGNUP_WITH_GITHUB = '/api/v2/github-signup'; +module.exports.API_SIGNUP_WITH_GITLAB = '/api/v2/gitlab-signup'; //Storage Routes //BUCKETS ROUTES @@ -200,4 +201,5 @@ module.exports.API_TIME_FORCASTING_REPORT ='/api/v1/report/time-forcasting'; module.exports.CUSTOME_MODALS = '/api/v1/customeModals' module.exports.API_OAUTH_GITHUB = '/api/v1/github'; module.exports.API_OAUTH_GOOGLE = '/api/v1/google'; +module.exports.API_OAUTH_GITLAB = '/api/v1/gitlab'; module.exports.GET_CHANGELOG = '/api/v2/changelog'; \ No newline at end of file diff --git a/frontend/src/plugins/oauth/github/GithubAuth.vue b/frontend/src/plugins/oauth/github/GithubAuth.vue index 5c705e46..98062955 100644 --- a/frontend/src/plugins/oauth/github/GithubAuth.vue +++ b/frontend/src/plugins/oauth/github/GithubAuth.vue @@ -45,6 +45,10 @@ const userData = ref(); const isLoading = ref(false); onMounted(async () => { + // Only handle the redirect if THIS provider started it. GitHub and GitLab + // both come back with ?code=, so without this guard both components would + // grab the same code and one would strip it from the URL before the other. + if (!localStorage.getItem("githubAuthMode")) return; const params = new URLSearchParams(window.location.search); const code = params.get("code"); if (!code) return; @@ -103,6 +107,11 @@ const login = async (userInfo) => { const object = { email: userInfo.email, githubId: userInfo.githubId, + // Forward the GitHub access token so the backend can re-verify the + // identity against GitHub's /user API instead of trusting the + // client-supplied id/email. Without this, login is rejected in the + // default (strict) server mode. + accessToken: userInfo.accessToken, isLoginType: "frontend", authProvider: "github" } @@ -278,6 +287,7 @@ async function fetchGitHubUser(token) { githubId: userData.id, firstName, lastName, + accessToken: token, } // Call API's diff --git a/frontend/src/plugins/oauth/gitlab/GitlabAuth.vue b/frontend/src/plugins/oauth/gitlab/GitlabAuth.vue new file mode 100644 index 00000000..27bb4f44 --- /dev/null +++ b/frontend/src/plugins/oauth/gitlab/GitlabAuth.vue @@ -0,0 +1,292 @@ + + + diff --git a/frontend/src/plugins/oauth/google/GoogleAuth.vue b/frontend/src/plugins/oauth/google/GoogleAuth.vue index 3796dc6b..b8a07792 100644 --- a/frontend/src/plugins/oauth/google/GoogleAuth.vue +++ b/frontend/src/plugins/oauth/google/GoogleAuth.vue @@ -104,6 +104,11 @@ const login = async (userInfo) => { const object = { email: userInfo.email, googleId: userInfo.sub, + // Forward the signed Google ID token so the backend can verify the + // identity server-side (google-auth-library) instead of trusting the + // client-supplied email/sub. Without this, login is rejected when + // the server runs in hardened mode. + idToken: userInfo.idToken, isLoginType: "frontend", authProvider: "google" } @@ -239,8 +244,10 @@ async function handleGoogleCallback(response) { const tokenRes = await apiRequestWithoutSecure("post", `${env.API_OAUTH_GOOGLE}/access-token`, { code: response.code }); const tokens = tokenRes.data; - // Decode ID token manually to get profile + // Decode ID token manually to get profile, and keep the raw token so + // the backend can verify it (the decoded copy is for display only). const userInfo = parseJwt(tokens.id_token); + userInfo.idToken = tokens.id_token; // Call API's if (props.mode === 'login') { diff --git a/frontend/src/plugins/oauth/oAuthPlugin.js b/frontend/src/plugins/oauth/oAuthPlugin.js index 1031b97a..91fe36e9 100644 --- a/frontend/src/plugins/oauth/oAuthPlugin.js +++ b/frontend/src/plugins/oauth/oAuthPlugin.js @@ -1,5 +1,6 @@ import oAuthWithGoogle from "./google/GoogleAuth.vue" import oAuthWithGithub from "./github/GithubAuth.vue" +import oAuthWithGitlab from "./gitlab/GitlabAuth.vue" import oAuthProviders from "./oAuthProviders.vue"; export default { @@ -7,5 +8,6 @@ export default { app.component('oAuthProviders', oAuthProviders); app.component('oAuthWithGoogle', oAuthWithGoogle); app.component('oAuthWithGithub', oAuthWithGithub); + app.component('oAuthWithGitlab', oAuthWithGitlab); } }; diff --git a/frontend/src/plugins/oauth/oAuthProviders.vue b/frontend/src/plugins/oauth/oAuthProviders.vue index de81fcfd..6e5a8393 100644 --- a/frontend/src/plugins/oauth/oAuthProviders.vue +++ b/frontend/src/plugins/oauth/oAuthProviders.vue @@ -6,6 +6,9 @@
+
+ +
@@ -29,6 +32,7 @@ defineProps({ const isAuthWithGoogle = ref(process.env.VUE_APP_IS_GOOGLE_LOGIN || null); const isAuthWithGithub = ref(process.env.VUE_APP_IS_GITHUB_LOGIN || null); +const isAuthWithGitlab = ref(process.env.VUE_APP_IS_GITLAB_LOGIN || null);