diff --git a/README.md b/README.md index ac0f286..f0d1395 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ See [Your first scan](./docs/cli/your-first-scan.md) for a full CLI walkthrough. ## Going beyond the quick start -For a long-running instance shared with teammates, you'll want to swap the test user for real authentication. The web app supports Google OAuth and GitHub OAuth. +For a long-running instance shared with teammates, you'll want to swap the test user for real authentication. The web app supports Google OAuth, GitHub OAuth, and GitLab OAuth. ### Disable the test user @@ -94,6 +94,21 @@ The redirect path is fixed at `/auth/google/login` — only the host part (`APP_ As with Google, the callback path is fixed at `/auth/github/login`. +### GitLab OAuth + +1. In your GitLab instance, go to User Settings > Applications (or Admin Area > Applications for an instance-wide app) and create a new application. +2. Set the redirect URI to `/auth/gitlab/login` — e.g. `http://localhost:3001/auth/gitlab/login` for local development, or `https://omlet.example.com/auth/gitlab/login` in production. +3. Grant the `read_user` scope. +4. Set in `webapp/.env`: + + ```sh + GITLAB_BASE_URL="https://gitlab.com" + GITLAB_CLIENT_ID="" + GITLAB_CLIENT_SECRET="" + ``` + +If you're using a self-hosted GitLab instance, set `GITLAB_BASE_URL` to that instance URL instead. The callback path is fixed at `/auth/gitlab/login`. + ### Email login The web app has scaffolding for email flows (magic-link login, workspace invites, email-change confirmations), but the email sender is a stub. To enable these flows, wire up `EmailClient.sendEmail` in [`webapp/src/backend/service/emailing.ts`](./webapp/src/backend/service/emailing.ts) to your email provider, then set `EMAILS_ENABLED=true` and `VITE_EMAILS_ENABLED=true` in `webapp/.env`. diff --git a/webapp/.env.example b/webapp/.env.example index 7ae6618..ce113b3 100644 --- a/webapp/.env.example +++ b/webapp/.env.example @@ -37,6 +37,18 @@ GITHUB_CLIENT_ID= # Client ID of the Github OAuth app GITHUB_CLIENT_SECRET= +# Base URL of the GitLab instance (used for GitLab Sign-in), for example: +# https://gitlab.com or https://gitlab.example.com +GITLAB_BASE_URL= + +# Client ID of the GitLab OAuth app (used for GitLab Sign-in). +# In GitLab, set the redirect URL to /auth/gitlab/login, +# for example http://localhost:3001/auth/gitlab/login for local development. +GITLAB_CLIENT_ID= + +# Client secret of the GitLab OAuth app +GITLAB_CLIENT_SECRET= + # "production", "staging", or "local" (default: "local") APP_ENV= diff --git a/webapp/create-env.ts b/webapp/create-env.ts index 877d089..55f6354 100644 --- a/webapp/create-env.ts +++ b/webapp/create-env.ts @@ -49,6 +49,9 @@ function createEnvFile() { GOOGLE_CLIENT_SECRET: "", GITHUB_CLIENT_ID: "", GITHUB_CLIENT_SECRET: "", + GITLAB_BASE_URL: "https://gitlab.com", + GITLAB_CLIENT_ID: "", + GITLAB_CLIENT_SECRET: "", APP_ENV: "local", RESPONSE_COMPRESSION_ENABLED: "true", USE_STATIC_MIDDLEWARE: "false", diff --git a/webapp/src/backend/router/api.ts b/webapp/src/backend/router/api.ts index 4c959e9..c42f4e0 100644 --- a/webapp/src/backend/router/api.ts +++ b/webapp/src/backend/router/api.ts @@ -255,12 +255,14 @@ apiRouter.get("/auth-providers", async (req: Request, res: Response) => { const google = Boolean(config.GOOGLE_CLIENT_ID) && Boolean(config.GOOGLE_CLIENT_SECRET); const github = Boolean(config.GITHUB_CLIENT_ID) && Boolean(config.GITHUB_CLIENT_SECRET); + const gitlab = Boolean(config.GITLAB_CLIENT_ID) && Boolean(config.GITLAB_CLIENT_SECRET); const email = config.EMAILS_ENABLED; const testUser = config.ENABLE_TEST_USER; res.status(httpStatus.OK).json({ google, github, + gitlab, email, testUser, }); diff --git a/webapp/src/backend/service/auth/auth.ts b/webapp/src/backend/service/auth/auth.ts index e8be40b..1aa43d6 100644 --- a/webapp/src/backend/service/auth/auth.ts +++ b/webapp/src/backend/service/auth/auth.ts @@ -23,6 +23,7 @@ import { removeMember, } from "../workspace/workspace"; +import { authGitlabUser, getGitlabAuthUrl } from "./authGitlab"; import { type AuthRequestDoc, AuthRequestModel, LoginProviderType } from "./models"; export class OAuthFailure extends ServiceError { @@ -128,7 +129,7 @@ export function generatePublicAuthToken(tokenId: string, payload: PublicTokenPay }); } -interface UserData { +export interface UserData { email: string; emails?: string[]; fullName?: string; @@ -180,7 +181,7 @@ async function acceptUserInvites(user: User) { } } -async function authenticateUser(userData: UserData, { isCliSession }: { isCliSession: boolean; }): Promise { +export async function authenticateUser(userData: UserData, { isCliSession }: { isCliSession: boolean; }): Promise { let user = await findUserByLoginProvider(userData.loginProvider, userData.externalId); let isNewUser = false; @@ -257,11 +258,11 @@ function decodeGoogleIdToken(idToken: string): GoogleIdPayload { return jwt.decode(idToken) as GoogleIdPayload; } -interface LoginOptions { +export interface LoginOptions { isCliSession: boolean; } -interface AuthResult { +export interface AuthResult { user: User; token: AuthToken; isNewUser: boolean; @@ -358,6 +359,8 @@ async function authGithubUser(authCode: string, { isCliSession = false }: LoginO }); } } + + // This function is not called. // Email auth uses POST /api/auth-request instead function getEmailAuthUrl(): string { @@ -396,6 +399,10 @@ export const authProviders: Record = { auth: authGithubUser, getAuthUrl: getGithubAuthUrl, }, + [LoginProviderType.Gitlab]: { + auth: authGitlabUser, + getAuthUrl: getGitlabAuthUrl, + }, [LoginProviderType.Email]: { auth: authEmailUser, getAuthUrl: getEmailAuthUrl, diff --git a/webapp/src/backend/service/auth/authGitlab.ts b/webapp/src/backend/service/auth/authGitlab.ts new file mode 100644 index 0000000..c324a61 --- /dev/null +++ b/webapp/src/backend/service/auth/authGitlab.ts @@ -0,0 +1,104 @@ +import fetch from "node-fetch"; + +import { config } from "../../../config/backend"; + +import { + type AuthResult, + authenticateUser, + type LoginOptions, + OAuthFailure, + type UserData, +} from "./auth"; +import { LoginProviderType } from "./models"; + +const GITLAB_REDIRECT_URI = `${config.APP_BASE_URL}${config.GITLAB_LOGIN_PATH}`; + +interface GitlabTokenResponse { + access_token?: string; + error?: string; + error_description?: string; +} + +interface GitlabUserProfile { + id: number; + email?: string; + name?: string; + avatar_url?: string; +} + +export async function authGitlabUser(authCode: string, { isCliSession = false }: LoginOptions): Promise { + try { + const tokenResponse = await fetch(new URL("/oauth/token", config.GITLAB_BASE_URL).toString(), { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: config.GITLAB_CLIENT_ID, + client_secret: config.GITLAB_CLIENT_SECRET, + code: authCode, + grant_type: "authorization_code", + redirect_uri: GITLAB_REDIRECT_URI, + }).toString(), + }); + + const tokenData = await tokenResponse.json() as GitlabTokenResponse; + + if (!tokenResponse.ok || !tokenData.access_token) { + throw new Error(tokenData.error_description ?? tokenData.error ?? "Missing GitLab access token"); + } + + const userResponse = await fetch(new URL("/api/v4/user", config.GITLAB_BASE_URL).toString(), { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + }, + }); + + const userProfile = await userResponse.json() as GitlabUserProfile; + + if (!userResponse.ok || !userProfile.email) { + throw new Error("Missing GitLab user email"); + } + + const userData: UserData = { + loginProvider: LoginProviderType.Gitlab, + email: userProfile.email, + externalId: userProfile.id.toString(), + }; + + if (userProfile.name) { + userData.fullName = userProfile.name; + } + + if (userProfile.avatar_url) { + userData.avatarUrl = userProfile.avatar_url; + } + + return authenticateUser(userData, { isCliSession }); + } catch (err) { + throw new OAuthFailure(LoginProviderType.Gitlab, { + reason: err as Error, + }); + } +} + +export function getGitlabAuthUrl(state: string, { error }: { error?: string; }): string { + if (error) { + const errorUrl = new URL(`${config.APP_BASE_URL}/login`); + errorUrl.searchParams.append("error", `gitlab_${error}`); + return errorUrl.toString(); + } + + const url = new URL("/oauth/authorize", config.GITLAB_BASE_URL); + + url.searchParams.append("client_id", config.GITLAB_CLIENT_ID); + url.searchParams.append("response_type", "code"); + url.searchParams.append("scope", "read_user"); + url.searchParams.append("redirect_uri", GITLAB_REDIRECT_URI); + + if (state) { + url.searchParams.append("state", state); + } + + return url.toString(); +} diff --git a/webapp/src/backend/service/auth/models.ts b/webapp/src/backend/service/auth/models.ts index 103a95f..ea37ec2 100644 --- a/webapp/src/backend/service/auth/models.ts +++ b/webapp/src/backend/service/auth/models.ts @@ -5,6 +5,7 @@ const AUTH_REQUEST_COLLECTION_NAME = "authRequests"; export enum LoginProviderType { Google = "google", Github = "github", + Gitlab = "gitlab", Email = "email", } diff --git a/webapp/src/backend/service/user/user.ts b/webapp/src/backend/service/user/user.ts index 208dce5..f8ae83c 100644 --- a/webapp/src/backend/service/user/user.ts +++ b/webapp/src/backend/service/user/user.ts @@ -175,7 +175,7 @@ export async function getMultipleUsers(userIds: string[]): Promise { } export async function resetUserLoginProviders(userId: string, newEmail: string): Promise { - const typesToClear: LoginProviderType[] = [LoginProviderType.Github, LoginProviderType.Google]; + const typesToClear: LoginProviderType[] = [LoginProviderType.Github, LoginProviderType.Gitlab, LoginProviderType.Google]; await UserModel.updateOne( { _id: userId }, { $pull: { loginProviders: { type: { $in: typesToClear } } } } diff --git a/webapp/src/config/backend.ts b/webapp/src/config/backend.ts index 99a12f5..f454b19 100644 --- a/webapp/src/config/backend.ts +++ b/webapp/src/config/backend.ts @@ -36,6 +36,10 @@ interface BackendConfig { GITHUB_CLIENT_ID: string; GITHUB_CLIENT_SECRET: string; GITHUB_LOGIN_PATH: string; + GITLAB_BASE_URL: string; + GITLAB_CLIENT_ID: string; + GITLAB_CLIENT_SECRET: string; + GITLAB_LOGIN_PATH: string; INVITE_LIFETIME_MSEC: number; APP_ENV: AppEnvType; API_ROOT_PATH: string; @@ -76,6 +80,10 @@ const config: BackendConfig = { GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID ?? "", GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET ?? "", GITHUB_LOGIN_PATH: "/auth/github/login", + GITLAB_BASE_URL: process.env.GITLAB_BASE_URL ?? "https://gitlab.com", + GITLAB_CLIENT_ID: process.env.GITLAB_CLIENT_ID ?? "", + GITLAB_CLIENT_SECRET: process.env.GITLAB_CLIENT_SECRET ?? "", + GITLAB_LOGIN_PATH: "/auth/gitlab/login", INVITE_LIFETIME_MSEC: 365 * 24 * 60 * 60 * 1000, APP_ENV, API_ROOT_PATH: "/api", diff --git a/webapp/src/frontend/library/logos/LogoGitlab.tsx b/webapp/src/frontend/library/logos/LogoGitlab.tsx new file mode 100644 index 0000000..8151edb --- /dev/null +++ b/webapp/src/frontend/library/logos/LogoGitlab.tsx @@ -0,0 +1,13 @@ +export function LogoGitlab() { + return ( + + + + + + + + + + ); +} diff --git a/webapp/src/frontend/models/AuthProviders.ts b/webapp/src/frontend/models/AuthProviders.ts index 4536e73..5e8b8a6 100644 --- a/webapp/src/frontend/models/AuthProviders.ts +++ b/webapp/src/frontend/models/AuthProviders.ts @@ -1,6 +1,7 @@ export interface AuthProviders { google: boolean; github: boolean; + gitlab: boolean; email: boolean; testUser: boolean; } @@ -9,6 +10,7 @@ export function defaultAuthProviders(): AuthProviders { return { google: false, github: false, + gitlab: false, email: false, testUser: false, }; diff --git a/webapp/src/frontend/pages/auth/Auth.tsx b/webapp/src/frontend/pages/auth/Auth.tsx index 9407953..84d6896 100644 --- a/webapp/src/frontend/pages/auth/Auth.tsx +++ b/webapp/src/frontend/pages/auth/Auth.tsx @@ -72,6 +72,14 @@ export function Auth() { return "Authenticating with GitHub failed.\n" + "Please try again."; } + if (errorCode === "gitlab_access_denied") { + return "Authenticating with GitLab failed.\n" + + "Please give permission to authenticate."; + } + if (errorCode.startsWith("gitlab")) { + return "Authenticating with GitLab failed.\n" + + "Please try again."; + } if (errorCode.startsWith("google")) { return "Authenticating with Google failed.\n" + "Please try again."; @@ -135,6 +143,11 @@ export function Auth() { {isCli && } {redirect && } +
+ + {isCli && } + {redirect && } +
{isCli && } diff --git a/webapp/src/frontend/pages/auth/authButton/AuthButton.module.css b/webapp/src/frontend/pages/auth/authButton/AuthButton.module.css index 87f9dad..03399ce 100644 --- a/webapp/src/frontend/pages/auth/authButton/AuthButton.module.css +++ b/webapp/src/frontend/pages/auth/authButton/AuthButton.module.css @@ -27,6 +27,19 @@ box-shadow: inset 0 0 0 2px rgba(var(--background-primary-color-rgb), 0.3); } +.authButton.gitlab { + background-color: #fc6d26; + color: #fff; +} + +.authButton.gitlab:not(:disabled):hover { + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.2); +} + +.authButton.gitlab:not(:disabled):active { + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.35); +} + .authButton.google { background-color: var(--background-secondary-color); color: var(--label-primary-color); diff --git a/webapp/src/frontend/pages/auth/authButton/AuthButton.tsx b/webapp/src/frontend/pages/auth/authButton/AuthButton.tsx index 27ee039..067785a 100644 --- a/webapp/src/frontend/pages/auth/authButton/AuthButton.tsx +++ b/webapp/src/frontend/pages/auth/authButton/AuthButton.tsx @@ -1,6 +1,9 @@ +import { type ComponentType } from "react"; + import classNames from "classnames"; import { LogoGithub } from "../../../library/logos/LogoGithub"; +import { LogoGitlab } from "../../../library/logos/LogoGitlab"; import { LogoGoogle } from "../../../library/logos/LogoGoogle"; import classes from "./AuthButton.module.css"; @@ -8,6 +11,7 @@ import classes from "./AuthButton.module.css"; export enum AuthProvider { Github = "github", Google = "google", + Gitlab = "gitlab", } interface Props { @@ -15,33 +19,35 @@ interface Props { disabled?: boolean; } -const providerName = { +const providerName: Record = { [AuthProvider.Github]: "GitHub", [AuthProvider.Google]: "Google", + [AuthProvider.Gitlab]: "GitLab", }; -const providerIcon = { +const providerIcon: Record> = { [AuthProvider.Github]: LogoGithub, [AuthProvider.Google]: LogoGoogle, + [AuthProvider.Gitlab]: LogoGitlab, }; -const providerClasses = { +const providerClasses: Record = { [AuthProvider.Github]: classes.github, [AuthProvider.Google]: classes.google, + [AuthProvider.Gitlab]: classes.gitlab, }; -export function AuthButton({ - provider, - disabled = false, -}: Props) { +export function AuthButton({ provider, disabled = false }: Props) { const Icon = providerIcon[provider]; return ( ); }