Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 `<APP_BASE_URL>/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="<your client id>"
GITLAB_CLIENT_SECRET="<your 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`.
Expand Down
12 changes: 12 additions & 0 deletions webapp/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <APP_BASE_URL>/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=

Expand Down
3 changes: 3 additions & 0 deletions webapp/create-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions webapp/src/backend/router/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
15 changes: 11 additions & 4 deletions webapp/src/backend/service/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -128,7 +129,7 @@ export function generatePublicAuthToken(tokenId: string, payload: PublicTokenPay
});
}

interface UserData {
export interface UserData {
email: string;
emails?: string[];
fullName?: string;
Expand Down Expand Up @@ -180,7 +181,7 @@ async function acceptUserInvites(user: User) {
}
}

async function authenticateUser(userData: UserData, { isCliSession }: { isCliSession: boolean; }): Promise<AuthResult> {
export async function authenticateUser(userData: UserData, { isCliSession }: { isCliSession: boolean; }): Promise<AuthResult> {
let user = await findUserByLoginProvider(userData.loginProvider, userData.externalId);
let isNewUser = false;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -396,6 +399,10 @@ export const authProviders: Record<LoginProviderType, Provider> = {
auth: authGithubUser,
getAuthUrl: getGithubAuthUrl,
},
[LoginProviderType.Gitlab]: {
auth: authGitlabUser,
getAuthUrl: getGitlabAuthUrl,
},
[LoginProviderType.Email]: {
auth: authEmailUser,
getAuthUrl: getEmailAuthUrl,
Expand Down
104 changes: 104 additions & 0 deletions webapp/src/backend/service/auth/authGitlab.ts
Original file line number Diff line number Diff line change
@@ -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<AuthResult> {
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();
}
1 change: 1 addition & 0 deletions webapp/src/backend/service/auth/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const AUTH_REQUEST_COLLECTION_NAME = "authRequests";
export enum LoginProviderType {
Google = "google",
Github = "github",
Gitlab = "gitlab",
Email = "email",
}

Expand Down
2 changes: 1 addition & 1 deletion webapp/src/backend/service/user/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ export async function getMultipleUsers(userIds: string[]): Promise<User[]> {
}

export async function resetUserLoginProviders(userId: string, newEmail: string): Promise<void> {
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 } } } }
Expand Down
8 changes: 8 additions & 0 deletions webapp/src/config/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions webapp/src/frontend/library/logos/LogoGitlab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export function LogoGitlab() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M10 18.86 13.678 7.541H6.322L10 18.86Z" fill="#E24329"/>
<path d="M10 18.86 6.322 7.541H1.167L10 18.86Z" fill="#FC6D26"/>
<path d="M1.167 7.541.05 10.977a.764.764 0 0 0 .278.854L10 18.86 1.167 7.541Z" fill="#FCA326"/>
<path d="M1.167 7.541h5.155L4.106.726a.383.383 0 0 0-.728 0L1.167 7.541Z" fill="#E24329"/>
<path d="M10 18.86 13.678 7.541h5.155L10 18.86Z" fill="#FC6D26"/>
<path d="m18.833 7.541 1.117 3.436a.764.764 0 0 1-.278.854L10 18.86l8.833-11.319Z" fill="#FCA326"/>
<path d="M18.833 7.541h-5.155L15.894.726a.383.383 0 0 1 .728 0l2.211 6.815Z" fill="#E24329"/>
</svg>
);
}
2 changes: 2 additions & 0 deletions webapp/src/frontend/models/AuthProviders.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export interface AuthProviders {
google: boolean;
github: boolean;
gitlab: boolean;
email: boolean;
testUser: boolean;
}
Expand All @@ -9,6 +10,7 @@ export function defaultAuthProviders(): AuthProviders {
return {
google: false,
github: false,
gitlab: false,
email: false,
testUser: false,
};
Expand Down
13 changes: 13 additions & 0 deletions webapp/src/frontend/pages/auth/Auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down Expand Up @@ -135,6 +143,11 @@ export function Auth() {
{isCli && <input type="hidden" name="cli" value={isCli.toString()}/>}
{redirect && <input type="hidden" name="redirect" value={redirect}/>}
</form>
<form method="get" action="/auth/gitlab">
<AuthButton provider={AuthProvider.Gitlab} disabled={!authProviders.gitlab}/>
{isCli && <input type="hidden" name="cli" value={isCli.toString()}/>}
{redirect && <input type="hidden" name="redirect" value={redirect}/>}
</form>
<form method="get" action="/auth/google">
<AuthButton provider={AuthProvider.Google} disabled={!authProviders.google}/>
{isCli && <input type="hidden" name="cli" value={isCli.toString()}/>}
Expand Down
13 changes: 13 additions & 0 deletions webapp/src/frontend/pages/auth/authButton/AuthButton.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading