Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ GITHUB_APP_ID=your-github-app-id
GITHUB_APP_PRIVATE_KEY=your-github-app-private-key-base64
GITHUB_APP_SLUG=your-github-app-slug

# GitLab OAuth (optional fallback; runtime setup/admin config takes precedence)
GITLAB_HOST=https://gitlab.com
GITLAB_CLIENT_ID=your-gitlab-oauth-application-id
GITLAB_CLIENT_SECRET=your-gitlab-oauth-secret

# Security keys (production deploys auto-generate these with Pulumi)
# For local development, generate with: tsx scripts/deploy/generate-keys.ts
JWT_PRIVATE_KEY=your-jwt-private-key-base64
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/deploy-reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,11 @@ jobs:
SECRET_JWT_PUBLIC_KEY: ${{ secrets.JWT_PUBLIC_KEY }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
GOOGLE_LOGIN_CLIENT_ID: ${{ secrets.GOOGLE_LOGIN_CLIENT_ID }}
GOOGLE_LOGIN_CLIENT_SECRET: ${{ secrets.GOOGLE_LOGIN_CLIENT_SECRET }}
GITLAB_HOST: ${{ secrets.GITLAB_HOST }}
GITLAB_CLIENT_ID: ${{ secrets.GITLAB_CLIENT_ID }}
GITLAB_CLIENT_SECRET: ${{ secrets.GITLAB_CLIENT_SECRET }}
BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
CREDENTIAL_ENCRYPTION_KEY: ${{ secrets.CREDENTIAL_ENCRYPTION_KEY }}
GH_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }}
Expand Down
5 changes: 5 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ BASE_DOMAIN=workspaces.example.com
# GITHUB_APP_ID=
# GITHUB_APP_PRIVATE_KEY=

# Optional GitLab OAuth fallback (runtime setup/admin config takes precedence)
# GITLAB_HOST=https://gitlab.com
# GITLAB_CLIENT_ID=
# GITLAB_CLIENT_SECRET=

# Cloudflare credentials (for DNS, Origin CA certificate issuance, and optional managed registry cache)
# Requires Account → SSL and Certificates → Edit for per-node Origin CA certificates.
# CF_API_TOKEN=
Expand Down
15 changes: 13 additions & 2 deletions apps/api/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { Env } from './env';
import { createModuleLogger } from './lib/logger';
import { readResponseJson } from './lib/runtime-validation';
import { getBetterAuthSecret } from './lib/secrets';
import { getGitHubOAuthConfig, getGoogleLoginOAuthConfig } from './services/platform-config';
import { getGitHubOAuthConfig, getGitLabOAuthConfig, getGoogleLoginOAuthConfig } from './services/platform-config';
import { isSignupApprovalRequired } from './services/signup-approval';

const log = createModuleLogger('auth');
Expand Down Expand Up @@ -193,14 +193,14 @@ export function selectPrimaryGitHubEmail(

/**
* Create BetterAuth instance with Cloudflare D1 + KV configuration.
* Uses GitHub OAuth as the social provider.
*/
export async function createAuth(env: Env) {
const db = drizzle(env.DATABASE, { schema });
// Sentinel id is env-overridable; fall back to the shared constant.
const sentinelId = env.TRIAL_ANONYMOUS_USER_ID ?? TRIAL_ANONYMOUS_USER_ID;
const githubOAuth = await getGitHubOAuthConfig(env);
const googleOAuth = await getGoogleLoginOAuthConfig(env);
const gitlabOAuth = await getGitLabOAuthConfig(env);
const socialProviders: Record<string, unknown> = {};
const trustedProviders: string[] = [];

Expand Down Expand Up @@ -293,6 +293,17 @@ export async function createAuth(env: Env) {
};
}

if (gitlabOAuth) {
trustedProviders.push('gitlab');
socialProviders.gitlab = {
clientId: gitlabOAuth.clientId,
clientSecret: gitlabOAuth.clientSecret,
issuer: gitlabOAuth.host,
scope: ['read_user'],
overrideUserInfoOnSignIn: true,
};
}

return betterAuth({
database: drizzleAdapter(db, {
provider: 'sqlite',
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ export interface Env {
GITHUB_APP_ID?: string;
GITHUB_APP_PRIVATE_KEY?: string;
GITHUB_APP_SLUG?: string; // GitHub App slug for install URL
GITLAB_HOST?: string; // Optional GitLab OAuth host fallback, e.g. https://gitlab.com
GITLAB_CLIENT_ID?: string;
GITLAB_CLIENT_SECRET?: string;
CF_API_TOKEN: string;
CF_ZONE_ID: string;
CF_ACCOUNT_ID: string;
Expand Down
7 changes: 4 additions & 3 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,12 +569,13 @@ app.get('/api/config/artifacts-enabled', (c) => {
// only render a provider button when that provider is actually usable. Google
// here means the LOGIN client (getGoogleLoginOAuthConfig), never the infra/GCP one.
app.get('/api/config/login-providers', async (c) => {
const { getGitHubOAuthConfig, getGoogleLoginOAuthConfig } = await import('./services/platform-config');
const [github, google] = await Promise.all([
const { getGitHubOAuthConfig, getGitLabOAuthConfig, getGoogleLoginOAuthConfig } = await import('./services/platform-config');
const [github, google, gitlab] = await Promise.all([
getGitHubOAuthConfig(c.env),
getGoogleLoginOAuthConfig(c.env),
getGitLabOAuthConfig(c.env),
]);
return c.json({ github: github !== null, google: google !== null });
return c.json({ github: github !== null, google: google !== null, gitlab: gitlab !== null });
});

// JWKS endpoint (must be at root level)
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/routes/admin-platform-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function parseConfig(value: unknown): PlatformIntegrationInput {
}
const github = isRecord(value.github) ? value.github : {};
const google = isRecord(value.google) ? value.google : {};
const gitlab = isRecord(value.gitlab) ? value.gitlab : {};
return {
github: {
clientId: optionalString(github.clientId),
Expand All @@ -41,6 +42,11 @@ function parseConfig(value: unknown): PlatformIntegrationInput {
clientId: optionalString(google.clientId),
clientSecret: optionalString(google.clientSecret),
},
gitlab: {
host: optionalString(gitlab.host),
clientId: optionalString(gitlab.clientId),
clientSecret: optionalString(gitlab.clientSecret),
},
};
}

Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/routes/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ function parseIntegrationConfig(value: unknown): PlatformIntegrationInput {

const github = isRecord(value.github) ? value.github : {};
const google = isRecord(value.google) ? value.google : {};
const gitlab = isRecord(value.gitlab) ? value.gitlab : {};
return {
github: {
clientId: optionalString(github.clientId),
Expand All @@ -65,6 +66,11 @@ function parseIntegrationConfig(value: unknown): PlatformIntegrationInput {
clientId: optionalString(google.clientId),
clientSecret: optionalString(google.clientSecret),
},
gitlab: {
host: optionalString(gitlab.host),
clientId: optionalString(gitlab.clientId),
clientSecret: optionalString(gitlab.clientSecret),
},
};
}

Expand Down
55 changes: 40 additions & 15 deletions apps/api/src/services/platform-config-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface PlatformConfigValidationResult {
errors: string[];
}

function present(value: string | null | undefined): boolean {
function present(value: string | null | undefined): value is string {
return typeof value === 'string' && value.trim().length > 0;
}

Expand All @@ -35,18 +35,37 @@ function validatePem(value: string, errors: string[]): void {
}
}

function validateOAuthClientId(value: string, provider: 'GitHub' | 'Google', errors: string[]): void {
function validateOAuthClientId(value: string, provider: 'GitHub' | 'Google' | 'GitLab', errors: string[]): void {
if (value.trim().length < 6) {
errors.push(`${provider} OAuth client id is too short`);
}
}

function validateSecret(value: string, provider: 'GitHub' | 'Google', errors: string[]): void {
function validateSecret(value: string, provider: 'GitHub' | 'Google' | 'GitLab', errors: string[]): void {
if (value.trim().length < 8) {
errors.push(`${provider} OAuth client secret is too short`);
}
}

function validateGitLabHost(value: string, errors: string[]): void {
let url: URL;
try {
url = new URL(value.trim());
} catch {
errors.push('GitLab host must be a valid URL');
return;
}

const localhostHosts = ['localhost', '127.0.0.1', '::1', '[::1]'];
const isLocalHttp = url.protocol === 'http:' && localhostHosts.includes(url.hostname);
if (url.protocol !== 'https:' && !isLocalHttp) {
errors.push('GitLab host must use HTTPS unless it points to localhost');
}
if (url.username || url.password || url.search || url.hash || (url.pathname !== '/' && url.pathname !== '')) {
errors.push('GitLab host must not include credentials, a path, query string, or fragment');
}
}

async function pingGitHubOAuth(clientId: string, clientSecret: string): Promise<string | null> {
const response = await fetch(GITHUB_TOKEN_URL, {
method: 'POST',
Expand Down Expand Up @@ -94,22 +113,27 @@ export async function validatePlatformIntegrationInput(
const errors: string[] = [];
const github = input.github ?? {};
const google = input.google ?? {};

if (present(github.clientId)) validateOAuthClientId(github.clientId!, 'GitHub', errors);
if (present(github.clientSecret)) validateSecret(github.clientSecret!, 'GitHub', errors);
if (present(github.appId)) validateIntegerString(github.appId!, 'GitHub App id', errors);
if (present(github.appSlug)) validateSlug(github.appSlug!, errors);
if (present(github.appPrivateKey)) validatePem(github.appPrivateKey!, errors);
if (present(github.webhookSecret) && github.webhookSecret!.trim().length < 16) {
const gitlab = input.gitlab ?? {};

if (present(github.clientId)) validateOAuthClientId(github.clientId, 'GitHub', errors);
if (present(github.clientSecret)) validateSecret(github.clientSecret, 'GitHub', errors);
if (present(github.appId)) validateIntegerString(github.appId, 'GitHub App id', errors);
if (present(github.appSlug)) validateSlug(github.appSlug, errors);
if (present(github.appPrivateKey)) validatePem(github.appPrivateKey, errors);
if (present(github.webhookSecret) && github.webhookSecret.trim().length < 16) {
errors.push('GitHub webhook secret must be at least 16 characters');
}

if (present(google.clientId)) validateOAuthClientId(google.clientId!, 'Google', errors);
if (present(google.clientSecret)) validateSecret(google.clientSecret!, 'Google', errors);
if (present(google.clientId)) validateOAuthClientId(google.clientId, 'Google', errors);
if (present(google.clientSecret)) validateSecret(google.clientSecret, 'Google', errors);

if (present(gitlab.host)) validateGitLabHost(gitlab.host, errors);
if (present(gitlab.clientId)) validateOAuthClientId(gitlab.clientId, 'GitLab', errors);
if (present(gitlab.clientSecret)) validateSecret(gitlab.clientSecret, 'GitLab', errors);

if (present(github.clientId) && present(github.clientSecret)) {
try {
const error = await pingGitHubOAuth(github.clientId!, github.clientSecret!);
const error = await pingGitHubOAuth(github.clientId, github.clientSecret);
if (error) errors.push(error);
} catch (err) {
log.warn('github_oauth_validation_ping_failed', { error: err instanceof Error ? err.message : String(err) });
Expand All @@ -118,7 +142,7 @@ export async function validatePlatformIntegrationInput(

if (present(google.clientId) && present(google.clientSecret)) {
try {
const error = await pingGoogleOAuth(google.clientId!, google.clientSecret!, env.BASE_DOMAIN);
const error = await pingGoogleOAuth(google.clientId, google.clientSecret, env.BASE_DOMAIN);
if (error) errors.push(error);
} catch (err) {
log.warn('google_oauth_validation_ping_failed', { error: err instanceof Error ? err.message : String(err) });
Expand All @@ -131,7 +155,8 @@ export async function validatePlatformIntegrationInput(
export function validateSetupCanComplete(config: ResolvedPlatformConfig): PlatformConfigValidationResult {
const hasGitHub = Boolean(config.github.clientId.value && config.github.clientSecret.value);
const hasGoogle = Boolean(config.google.clientId.value && config.google.clientSecret.value);
if (!hasGitHub && !hasGoogle) {
const hasGitLab = Boolean(config.gitlab.host.value && config.gitlab.clientId.value && config.gitlab.clientSecret.value);
if (!hasGitHub && !hasGoogle && !hasGitLab) {
return {
ok: false,
errors: ['Configure at least one login provider before completing setup'],
Expand Down
Loading
Loading