diff --git a/.env.example b/.env.example index 7094f8a3f..9f04e4eb3 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/deploy-reusable.yml b/.github/workflows/deploy-reusable.yml index 5ebffce98..3927d6680 100644 --- a/.github/workflows/deploy-reusable.yml +++ b/.github/workflows/deploy-reusable.yml @@ -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 }} diff --git a/apps/api/.env.example b/apps/api/.env.example index 336666a4c..eb48d59ac 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -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= diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index 02f8de512..7232bdc08 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -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'); @@ -193,7 +193,6 @@ 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 }); @@ -201,6 +200,7 @@ export async function createAuth(env: Env) { 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 = {}; const trustedProviders: string[] = []; @@ -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', diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 0fe893ae6..894c9e9ba 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -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; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 6a1b1b096..1a256e99b 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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) diff --git a/apps/api/src/routes/admin-platform-config.ts b/apps/api/src/routes/admin-platform-config.ts index 1457426bc..ec5ed76c9 100644 --- a/apps/api/src/routes/admin-platform-config.ts +++ b/apps/api/src/routes/admin-platform-config.ts @@ -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), @@ -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), + }, }; } diff --git a/apps/api/src/routes/setup.ts b/apps/api/src/routes/setup.ts index 53bdb5fb2..be5ea7b0a 100644 --- a/apps/api/src/routes/setup.ts +++ b/apps/api/src/routes/setup.ts @@ -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), @@ -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), + }, }; } diff --git a/apps/api/src/services/platform-config-validation.ts b/apps/api/src/services/platform-config-validation.ts index 1b75dbc42..3b0863d5d 100644 --- a/apps/api/src/services/platform-config-validation.ts +++ b/apps/api/src/services/platform-config-validation.ts @@ -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; } @@ -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 { const response = await fetch(GITHUB_TOKEN_URL, { method: 'POST', @@ -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) }); @@ -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) }); @@ -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'], diff --git a/apps/api/src/services/platform-config.ts b/apps/api/src/services/platform-config.ts index 1767bb237..000ee9841 100644 --- a/apps/api/src/services/platform-config.ts +++ b/apps/api/src/services/platform-config.ts @@ -28,6 +28,11 @@ export interface ResolvedPlatformConfig { clientId: ResolvedPlatformValue; clientSecret: ResolvedPlatformValue; }; + gitlab: { + host: ResolvedPlatformValue; + clientId: ResolvedPlatformValue; + clientSecret: ResolvedPlatformValue; + }; } export interface PlatformConfigStatus { @@ -38,6 +43,7 @@ export interface PlatformConfigStatus { githubApp: IntegrationStatus; githubWebhook: IntegrationStatus; googleOAuth: IntegrationStatus; + gitlabOAuth: IntegrationStatus; }; } @@ -61,6 +67,11 @@ export interface PlatformIntegrationInput { clientId?: string; clientSecret?: string; }; + gitlab?: { + host?: string; + clientId?: string; + clientSecret?: string; + }; } export const SETUP_COMPLETED_SETTING_KEY = 'setup.completed'; @@ -71,6 +82,8 @@ const SETTING_KEYS = { githubAppId: 'integration.github.appId', githubAppSlug: 'integration.github.appSlug', googleClientId: 'integration.google.clientId', + gitlabHost: 'integration.gitlab.host', + gitlabClientId: 'integration.gitlab.clientId', } as const; const SECRET_KINDS = { @@ -78,6 +91,7 @@ const SECRET_KINDS = { githubAppPrivateKey: 'github.appPrivateKey', githubWebhookSecret: 'github.webhookSecret', googleClientSecret: 'google.clientSecret', + gitlabClientSecret: 'gitlab.clientSecret', } as const; const ENV_KEYS = { @@ -92,6 +106,9 @@ const ENV_KEYS = { // OAuth app, redirect URI (/api/auth/callback/google), and scopes. googleClientId: 'GOOGLE_LOGIN_CLIENT_ID', googleClientSecret: 'GOOGLE_LOGIN_CLIENT_SECRET', + gitlabHost: 'GITLAB_HOST', + gitlabClientId: 'GITLAB_CLIENT_ID', + gitlabClientSecret: 'GITLAB_CLIENT_SECRET', } as const; const DEFAULT_SETUP_RATE_LIMIT_WINDOW_SECONDS = 15 * 60; @@ -274,6 +291,7 @@ export async function savePlatformIntegrationConfig( const by = creatorId(env, updatedBy); const github = input.github ?? {}; const google = input.google ?? {}; + const gitlab = input.gitlab ?? {}; const githubClientId = trimOptional(github.clientId); if (githubClientId) await writeSetting(env, SETTING_KEYS.githubClientId, githubClientId, by); @@ -287,6 +305,12 @@ export async function savePlatformIntegrationConfig( const googleClientId = trimOptional(google.clientId); if (googleClientId) await writeSetting(env, SETTING_KEYS.googleClientId, googleClientId, by); + const gitlabHost = trimOptional(gitlab.host); + if (gitlabHost) await writeSetting(env, SETTING_KEYS.gitlabHost, gitlabHost, by); + + const gitlabClientId = trimOptional(gitlab.clientId); + if (gitlabClientId) await writeSetting(env, SETTING_KEYS.gitlabClientId, gitlabClientId, by); + const githubClientSecret = trimOptional(github.clientSecret); if (githubClientSecret) { await upsertSecret(env, 'github', SECRET_KINDS.githubClientSecret, 'GitHub OAuth client secret', githubClientSecret, by); @@ -307,6 +331,11 @@ export async function savePlatformIntegrationConfig( await upsertSecret(env, 'google', SECRET_KINDS.googleClientSecret, 'Google OAuth client secret', googleClientSecret, by); } + const gitlabClientSecret = trimOptional(gitlab.clientSecret); + if (gitlabClientSecret) { + await upsertSecret(env, 'gitlab', SECRET_KINDS.gitlabClientSecret, 'GitLab OAuth client secret', gitlabClientSecret, by); + } + return resolvePlatformConfig(env); } @@ -320,6 +349,9 @@ export async function resolvePlatformConfig(env: Env): Promise { + const config = await resolvePlatformConfig(env); + if (!config.gitlab.host.value || !config.gitlab.clientId.value || !config.gitlab.clientSecret.value) return null; + const host = normalizeBaseUrl(config.gitlab.host.value); + return { + host, + apiBaseUrl: `${host}/api/v4`, + clientId: config.gitlab.clientId.value, + clientSecret: config.gitlab.clientSecret.value, + }; +} + /** * Login Google OAuth client — the BetterAuth "Sign in with Google" social * provider. Resolved from the setup-wizard platform store first, then the @@ -426,7 +492,7 @@ export function isSetupTokenConfigured(env: Env): boolean { function sourceLabel(source: PlatformConfigSource): string { if (source === 'runtime') return 'set here'; - if (source === 'environment') return 'set via GitHub secret'; + if (source === 'environment') return 'set via environment fallback'; return 'not configured'; } @@ -480,6 +546,10 @@ export async function getPlatformConfigStatus(env: Env): Promise ({ env.GOOGLE_LOGIN_CLIENT_ID && env.GOOGLE_LOGIN_CLIENT_SECRET ? { clientId: env.GOOGLE_LOGIN_CLIENT_ID, clientSecret: env.GOOGLE_LOGIN_CLIENT_SECRET } : null, + getGitLabOAuthConfig: async (env: { + GITLAB_HOST?: string; + GITLAB_CLIENT_ID?: string; + GITLAB_CLIENT_SECRET?: string; + }) => + env.GITLAB_HOST && env.GITLAB_CLIENT_ID && env.GITLAB_CLIENT_SECRET + ? { + host: env.GITLAB_HOST, + apiBaseUrl: `${env.GITLAB_HOST}/api/v4`, + clientId: env.GITLAB_CLIENT_ID, + clientSecret: env.GITLAB_CLIENT_SECRET, + } + : null, })); function fakeEnv(requireApproval = 'true') { @@ -177,6 +190,25 @@ describe('BetterAuth configuration', () => { expect(capturedOptions?.socialProviders).toHaveProperty('google'); }); + it('adds GitLab social provider when GitLab OAuth is configured', async () => { + const { createAuth } = await import('../../src/auth'); + await createAuth({ + ...fakeEnv(), + GITLAB_HOST: 'https://gitlab.example.com', + GITLAB_CLIENT_ID: 'gitlab-client-id', + GITLAB_CLIENT_SECRET: 'gitlab-client-secret', + } as never); + + expect(capturedOptions?.socialProviders).toHaveProperty('github'); + expect(capturedOptions?.socialProviders).toMatchObject({ + gitlab: { + clientId: 'gitlab-client-id', + clientSecret: 'gitlab-client-secret', + issuer: 'https://gitlab.example.com', + }, + }); + }); + it('does NOT add Google login from the infra Google client', async () => { const { createAuth } = await import('../../src/auth'); await createAuth({ diff --git a/apps/api/tests/unit/routes/admin-platform-config.test.ts b/apps/api/tests/unit/routes/admin-platform-config.test.ts new file mode 100644 index 000000000..9293a3fd1 --- /dev/null +++ b/apps/api/tests/unit/routes/admin-platform-config.test.ts @@ -0,0 +1,188 @@ +import Database from 'better-sqlite3'; +import { Hono } from 'hono'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Env } from '../../../src/env'; +import { AppError } from '../../../src/middleware/error'; +import { generateEncryptionKey } from '../../../src/services/encryption'; +import { getGitLabOAuthConfig } from '../../../src/services/platform-config'; + +vi.mock('../../../src/middleware/auth', () => ({ + requireAuth: () => async (_c: any, next: any) => next(), + requireApproved: () => async (_c: any, next: any) => next(), + requireSuperadmin: () => async (c: any, next: any) => { + if (c.req.header('X-Test-Role') !== 'superadmin') { + return c.json({ error: 'FORBIDDEN', message: 'Superadmin required' }, 403); + } + await next(); + }, + getUserId: () => 'superadmin-1', +})); + +const { adminPlatformConfigRoutes } = await import('../../../src/routes/admin-platform-config'); + +interface SqliteD1Result { + meta: { changes: number }; + results?: unknown[]; +} + +function createD1(sqlite: Database.Database): D1Database { + return { + prepare(sql: string) { + const statement = sqlite.prepare(sql); + let bindings: unknown[] = []; + return { + bind(...values: unknown[]) { + bindings = values; + return this; + }, + async first() { + return statement.get(...bindings) as T | null; + }, + async all() { + return { results: statement.all(...bindings) as T[] }; + }, + async run(): Promise { + const result = statement.run(...bindings); + return { meta: { changes: result.changes } }; + }, + }; + }, + } as unknown as D1Database; +} + +function createEnv(overrides: Partial = {}): Env { + const sqlite = new Database(':memory:'); + sqlite.exec(` + CREATE TABLE users (id TEXT PRIMARY KEY); + INSERT INTO users (id) VALUES ('system_anonymous_trials'); + CREATE TABLE platform_settings ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by TEXT + ); + CREATE TABLE platform_credentials ( + id TEXT PRIMARY KEY, + credential_type TEXT NOT NULL, + provider TEXT, + agent_type TEXT, + credential_kind TEXT NOT NULL DEFAULT 'api-key', + label TEXT NOT NULL, + encrypted_token TEXT NOT NULL, + iv TEXT NOT NULL, + is_enabled INTEGER NOT NULL DEFAULT 1, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `); + return { + DATABASE: createD1(sqlite), + BASE_DOMAIN: 'example.com', + ENCRYPTION_KEY: generateEncryptionKey(), + ...overrides, + } as Env; +} + +function createApp() { + const app = new Hono<{ Bindings: Env }>(); + app.onError((err, c) => { + if (err instanceof AppError) { + return c.json(err.toJSON(), err.statusCode); + } + return c.json({ error: 'INTERNAL_ERROR', message: err.message }, 500); + }); + app.route('/api/admin/platform-config', adminPlatformConfigRoutes); + return app; +} + +describe('admin platform config routes', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('persists GitLab OAuth config through the superadmin runtime config endpoint', async () => { + const env = createEnv(); + const res = await createApp().request( + '/api/admin/platform-config', + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-Test-Role': 'superadmin', + }, + body: JSON.stringify({ + config: { + gitlab: { + host: 'https://gitlab.admin.example.com/', + clientId: 'gitlab-admin-client', + clientSecret: 'gitlab-admin-secret', + }, + }, + }), + }, + env + ); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ + status: { + integrations: { + gitlabOAuth: { configured: true, label: 'set here' }, + }, + }, + }); + + const settings = await env.DATABASE.prepare( + `SELECT key, value FROM platform_settings WHERE key LIKE 'integration.gitlab.%' ORDER BY key` + ).all<{ key: string; value: string }>(); + expect(settings.results).toEqual([ + { key: 'integration.gitlab.clientId', value: 'gitlab-admin-client' }, + { key: 'integration.gitlab.host', value: 'https://gitlab.admin.example.com/' }, + ]); + + const secretRow = await env.DATABASE.prepare( + `SELECT provider, credential_kind AS credentialKind, encrypted_token AS encryptedToken, is_enabled AS isEnabled + FROM platform_credentials + WHERE credential_type = 'platform-integration' AND provider = 'gitlab'` + ).first<{ + provider: string; + credentialKind: string; + encryptedToken: string; + isEnabled: number; + }>(); + expect(secretRow).toMatchObject({ + provider: 'gitlab', + credentialKind: 'gitlab.clientSecret', + isEnabled: 1, + }); + expect(secretRow?.encryptedToken).not.toBe('gitlab-admin-secret'); + expect(secretRow?.encryptedToken).not.toContain('gitlab-admin-secret'); + + await expect(getGitLabOAuthConfig(env)).resolves.toEqual({ + host: 'https://gitlab.admin.example.com', + apiBaseUrl: 'https://gitlab.admin.example.com/api/v4', + clientId: 'gitlab-admin-client', + clientSecret: 'gitlab-admin-secret', + }); + }); + + it('keeps the endpoint behind the superadmin guard', async () => { + const res = await createApp().request( + '/api/admin/platform-config', + { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-Test-Role': 'admin' }, + body: JSON.stringify({ config: { gitlab: { host: 'https://gitlab.example.com' } } }), + }, + createEnv() + ); + + expect(res.status).toBe(403); + await expect(res.json()).resolves.toEqual({ + error: 'FORBIDDEN', + message: 'Superadmin required', + }); + }); +}); diff --git a/apps/api/tests/unit/routes/login-providers-config.test.ts b/apps/api/tests/unit/routes/login-providers-config.test.ts index ec3e85f64..174057eb4 100644 --- a/apps/api/tests/unit/routes/login-providers-config.test.ts +++ b/apps/api/tests/unit/routes/login-providers-config.test.ts @@ -11,17 +11,18 @@ import { Hono } from 'hono'; import { describe, expect, it } from 'vitest'; import type { Env } from '../../../src/env'; -import { getGitHubOAuthConfig, getGoogleLoginOAuthConfig } from '../../../src/services/platform-config'; +import { getGitHubOAuthConfig, getGitLabOAuthConfig, getGoogleLoginOAuthConfig } from '../../../src/services/platform-config'; function createApp() { const app = new Hono<{ Bindings: Env }>(); // Replicate the exact handler from src/index.ts app.get('/api/config/login-providers', async (c) => { - const [github, google] = await Promise.all([ + 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 }); }); return app; } @@ -29,24 +30,34 @@ function createApp() { async function get(env: Partial) { const res = await createApp().request('/api/config/login-providers', {}, env as Env); expect(res.status).toBe(200); - return (await res.json()) as { github: boolean; google: boolean }; + return (await res.json()) as { github: boolean; google: boolean; gitlab: boolean }; } describe('GET /api/config/login-providers', () => { it('reports both false when nothing is configured', async () => { - expect(await get({})).toEqual({ github: false, google: false }); + expect(await get({})).toEqual({ github: false, google: false, gitlab: false }); }); it('reports github true when GitHub OAuth is configured', async () => { expect( await get({ GITHUB_CLIENT_ID: 'gh-id', GITHUB_CLIENT_SECRET: 'gh-secret' }) - ).toEqual({ github: true, google: false }); + ).toEqual({ github: true, google: false, gitlab: false }); }); it('reports google true from the LOGIN client env vars', async () => { expect( await get({ GOOGLE_LOGIN_CLIENT_ID: 'login-id', GOOGLE_LOGIN_CLIENT_SECRET: 'login-secret' }) - ).toEqual({ github: false, google: true }); + ).toEqual({ github: false, google: true, gitlab: false }); + }); + + it('reports gitlab true when GitLab OAuth is configured', async () => { + expect( + await get({ + GITLAB_HOST: 'https://gitlab.example.com', + GITLAB_CLIENT_ID: 'gitlab-client-id', + GITLAB_CLIENT_SECRET: 'gitlab-client-secret', + }) + ).toEqual({ github: false, google: false, gitlab: true }); }); it('reports google FALSE when only the infra/GCP Google client is set', async () => { @@ -54,6 +65,6 @@ describe('GET /api/config/login-providers', () => { // Google login button. expect( await get({ GOOGLE_CLIENT_ID: 'infra-id', GOOGLE_CLIENT_SECRET: 'infra-secret' }) - ).toEqual({ github: false, google: false }); + ).toEqual({ github: false, google: false, gitlab: false }); }); }); diff --git a/apps/api/tests/unit/routes/setup.test.ts b/apps/api/tests/unit/routes/setup.test.ts index bde3f19f9..c949479ca 100644 --- a/apps/api/tests/unit/routes/setup.test.ts +++ b/apps/api/tests/unit/routes/setup.test.ts @@ -201,4 +201,68 @@ describe('setup routes', () => { ).first<{ value: string }>(); expect(row?.value).toBe('true'); }); + + it('completes setup with valid GitLab login config', async () => { + const env = createEnv(); + const res = await createApp().request( + '/api/setup/complete', + { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'CF-Connecting-IP': '198.51.100.12' }, + body: JSON.stringify({ + token: 'setup-token', + config: { + gitlab: { + host: 'https://gitlab.example.com', + clientId: 'gitlab-client-id', + clientSecret: 'gitlab-client-secret', + }, + }, + }), + }, + env, + ); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ + completed: true, + status: { + integrations: { + gitlabOAuth: { configured: true, label: 'set here' }, + }, + }, + }); + }); + + it('rejects invalid GitLab host values', async () => { + const res = await createApp().request( + '/api/setup/config', + { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'CF-Connecting-IP': '198.51.100.13' }, + body: JSON.stringify({ + token: 'setup-token', + config: { + gitlab: { + host: 'http://gitlab.example.com/group', + clientId: 'gitlab-client-id', + clientSecret: 'gitlab-client-secret', + }, + }, + }), + }, + createEnv(), + ); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toMatchObject({ + error: 'BAD_REQUEST', + details: { + errors: [ + 'GitLab host must use HTTPS unless it points to localhost', + 'GitLab host must not include credentials, a path, query string, or fragment', + ], + }, + }); + }); }); diff --git a/apps/api/tests/unit/services/platform-config.test.ts b/apps/api/tests/unit/services/platform-config.test.ts index 359b19a35..c0a2288a4 100644 --- a/apps/api/tests/unit/services/platform-config.test.ts +++ b/apps/api/tests/unit/services/platform-config.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Env } from '../../../src/env'; import { generateEncryptionKey } from '../../../src/services/encryption'; import { + getGitLabOAuthConfig, getGoogleInfraOAuthConfig, getGoogleLoginOAuthConfig, getPlatformConfigStatus, @@ -79,6 +80,9 @@ function createEnv(overrides: Partial = {}): Env { GITHUB_APP_PRIVATE_KEY: 'env-private-key', GITHUB_APP_SLUG: 'env-app-slug', GITHUB_WEBHOOK_SECRET: 'env-webhook-secret', + GITLAB_HOST: 'https://gitlab.example.com/', + GITLAB_CLIENT_ID: 'env-gitlab-client', + GITLAB_CLIENT_SECRET: 'env-gitlab-secret', // Infra/GCP Google client (kept separate from login). GOOGLE_CLIENT_ID: 'env-google-infra-client', GOOGLE_CLIENT_SECRET: 'env-google-infra-secret', @@ -100,6 +104,8 @@ describe('platform config resolver', () => { expect(config.github.clientId).toMatchObject({ value: 'env-gh-client', source: 'environment' }); expect(config.github.clientSecret).toMatchObject({ value: 'env-gh-secret', source: 'environment' }); expect(config.google.clientId).toMatchObject({ value: 'env-google-login-client', source: 'environment' }); + expect(config.gitlab.host).toMatchObject({ value: 'https://gitlab.example.com/', source: 'environment' }); + expect(config.gitlab.clientId).toMatchObject({ value: 'env-gitlab-client', source: 'environment' }); }); it('resolves login Google and infra Google from independent env vars', async () => { @@ -152,6 +158,11 @@ describe('platform config resolver', () => { clientId: 'runtime-google-client', clientSecret: 'runtime-google-secret', }, + gitlab: { + host: 'https://gitlab.runtime.example.com/', + clientId: 'runtime-gitlab-client', + clientSecret: 'runtime-gitlab-secret', + }, }, 'admin-1'); const config = await resolvePlatformConfig(env); @@ -159,6 +170,33 @@ describe('platform config resolver', () => { expect(config.github.clientSecret).toMatchObject({ value: 'runtime-gh-secret', source: 'runtime' }); expect(config.github.appId).toMatchObject({ value: '98765', source: 'runtime' }); expect(config.google.clientSecret).toMatchObject({ value: 'runtime-google-secret', source: 'runtime' }); + expect(config.gitlab.host).toMatchObject({ value: 'https://gitlab.runtime.example.com/', source: 'runtime' }); + expect(config.gitlab.clientSecret).toMatchObject({ value: 'runtime-gitlab-secret', source: 'runtime' }); + + const secretRow = await env.DATABASE.prepare( + `SELECT provider, credential_kind AS credentialKind, encrypted_token AS encryptedToken, is_enabled AS isEnabled + FROM platform_credentials + WHERE credential_type = 'platform-integration' AND provider = 'gitlab'` + ).first<{ + provider: string; + credentialKind: string; + encryptedToken: string; + isEnabled: number; + }>(); + expect(secretRow).toMatchObject({ + provider: 'gitlab', + credentialKind: 'gitlab.clientSecret', + isEnabled: 1, + }); + expect(secretRow?.encryptedToken).not.toBe('runtime-gitlab-secret'); + expect(secretRow?.encryptedToken).not.toContain('runtime-gitlab-secret'); + + await expect(getGitLabOAuthConfig(env)).resolves.toEqual({ + host: 'https://gitlab.runtime.example.com', + apiBaseUrl: 'https://gitlab.runtime.example.com/api/v4', + clientId: 'runtime-gitlab-client', + clientSecret: 'runtime-gitlab-secret', + }); }); it('skips an undecryptable runtime secret and falls back to env instead of throwing', async () => { @@ -182,8 +220,9 @@ describe('platform config resolver', () => { const status = await getPlatformConfigStatus(env); expect(status.integrations.githubOAuth).toMatchObject({ configured: false, label: 'not configured' }); - expect(status.integrations.githubApp).toMatchObject({ configured: true, label: 'set via GitHub secret' }); + expect(status.integrations.githubApp).toMatchObject({ configured: true, label: 'set via environment fallback' }); expect(status.integrations.googleOAuth).toMatchObject({ configured: true, label: 'set here' }); + expect(status.integrations.gitlabOAuth).toMatchObject({ configured: true, label: 'set via environment fallback' }); }); it('rate-limits setup token attempts atomically via D1 rows', async () => { diff --git a/apps/api/tests/unit/services/signup-approval.test.ts b/apps/api/tests/unit/services/signup-approval.test.ts index 5d8dfb146..87742a909 100644 --- a/apps/api/tests/unit/services/signup-approval.test.ts +++ b/apps/api/tests/unit/services/signup-approval.test.ts @@ -37,6 +37,7 @@ vi.mock('../../../src/services/platform-config', () => ({ ? { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET } : null, getGoogleLoginOAuthConfig: async () => null, + getGitLabOAuthConfig: async () => null, })); import { createAuth } from '../../../src/auth'; diff --git a/apps/api/wrangler.toml b/apps/api/wrangler.toml index d374b9512..41ca3df6d 100644 --- a/apps/api/wrangler.toml +++ b/apps/api/wrangler.toml @@ -302,6 +302,9 @@ crons = ["*/5 * * * *", "30 * * * *", "0 3 * * *", "0 4 * * *", "0 5 1 * *"] # - GOOGLE_CLIENT_SECRET (optional — Google Cloud Console OAuth client secret for infra/GCP) # - GOOGLE_LOGIN_CLIENT_ID (optional — Google login OAuth client ID for "Sign in with Google"; separate client, or set via /setup) # - GOOGLE_LOGIN_CLIENT_SECRET (optional — Google login OAuth client secret; separate client, or set via /setup) +# - GITLAB_HOST (optional — GitLab OAuth host, e.g. https://gitlab.com; or set via /setup) +# - GITLAB_CLIENT_ID (optional — GitLab OAuth application ID; or set via /setup) +# - GITLAB_CLIENT_SECRET (optional — GitLab OAuth secret; or set via /setup) # - SEGMENT_WRITE_KEY (optional — Segment.io write key, enables Segment event forwarding) # - GA4_API_SECRET (optional — Google Analytics 4 API secret, enables GA4 forwarding; NOTE: GA4 Measurement Protocol requires api_secret as a query parameter — ensure outbound request URLs are not logged) # - R2_ACCESS_KEY_ID (optional — R2 S3-compatible API token key ID, enables task attachment presigned uploads) diff --git a/apps/web/src/components/PlatformIntegrationConfigForm.tsx b/apps/web/src/components/PlatformIntegrationConfigForm.tsx index 15d392946..3ef8aa465 100644 --- a/apps/web/src/components/PlatformIntegrationConfigForm.tsx +++ b/apps/web/src/components/PlatformIntegrationConfigForm.tsx @@ -23,6 +23,7 @@ interface PlatformIntegrationConfigFormProps { } const FIELD_LABELS = { + host: 'Host URL', clientId: 'Client ID', clientSecret: 'Client secret', appId: 'App ID', @@ -60,7 +61,7 @@ export function PlatformIntegrationConfigForm({ return (
-
+
+ + + + + +
; config.google = { ...config.google, [field]: trimmed }; } + + if (key.startsWith('gitlab.')) { + const field = key.slice('gitlab.'.length) as keyof NonNullable; + config.gitlab = { ...config.gitlab, [field]: trimmed }; + } } return config; } diff --git a/apps/web/src/components/trial/LoginSheet.tsx b/apps/web/src/components/trial/LoginSheet.tsx index f8f654ad1..5f8016af3 100644 --- a/apps/web/src/components/trial/LoginSheet.tsx +++ b/apps/web/src/components/trial/LoginSheet.tsx @@ -50,12 +50,20 @@ async function defaultGoogleSignIn(returnTo: string): Promise { }); } +async function defaultGitLabSignIn(returnTo: string): Promise { + await authClient.signIn.social({ + provider: 'gitlab', + callbackURL: returnTo, + }); +} + export function LoginSheet({ isOpen, onClose, trialId, onSignIn }: LoginSheetProps) { const isMobile = useIsMobile(); const providers = useLoginProviders(); const panelRef = useRef(null); - const primaryCtaRef = useRef(null); + const githubCtaRef = useRef(null); const googleCtaRef = useRef(null); + const gitlabCtaRef = useRef(null); const closeButtonRef = useRef(null); // Esc to close + focus management. @@ -70,7 +78,12 @@ export function LoginSheet({ isOpen, onClose, trialId, onSignIn }: LoginSheetPro } // Focus trap: cycle between primary CTA and close button on Tab. if (e.key === 'Tab') { - const focusables = [primaryCtaRef.current, googleCtaRef.current, closeButtonRef.current].filter(Boolean); + const focusables = [ + githubCtaRef.current, + googleCtaRef.current, + gitlabCtaRef.current, + closeButtonRef.current, + ].filter(Boolean); const currentIndex = focusables.findIndex((element) => element === document.activeElement); if (currentIndex === -1) return; if (e.shiftKey && currentIndex === 0) { @@ -86,10 +99,10 @@ export function LoginSheet({ isOpen, onClose, trialId, onSignIn }: LoginSheetPro document.addEventListener('keydown', handleKeyDown); // Initial focus on the primary CTA so keyboard + screen-reader users land // on the main action, not the close button. - primaryCtaRef.current?.focus(); + (githubCtaRef.current ?? googleCtaRef.current ?? gitlabCtaRef.current ?? closeButtonRef.current)?.focus(); return () => document.removeEventListener('keydown', handleKeyDown); - }, [isOpen, onClose]); + }, [isOpen, onClose, providers.github, providers.gitlab, providers.google]); // Lock body scroll while open (matches Dialog primitive behavior). useEffect(() => { @@ -129,6 +142,15 @@ export function LoginSheet({ isOpen, onClose, trialId, onSignIn }: LoginSheetPro } }; + const handleGitLabSignIn = async () => { + try { + await defaultGitLabSignIn(returnTo); + } catch (err) { + // eslint-disable-next-line no-console -- user-visible failure path + console.error('Trial LoginSheet: GitLab sign-in failed', err); + } + }; + const panelBase = 'fixed glass-modal glass-panel-container glass-composited shadow-overlay flex flex-col'; const panelLayout = isMobile @@ -194,26 +216,28 @@ export function LoginSheet({ isOpen, onClose, trialId, onSignIn }: LoginSheetPro We’ll save this trial to your account so you can keep exploring.

- + {providers.github && ( + + )} {providers.google && ( + )}
, document.body, diff --git a/apps/web/src/hooks/useLoginProviders.ts b/apps/web/src/hooks/useLoginProviders.ts index 17150c713..73caef1ce 100644 --- a/apps/web/src/hooks/useLoginProviders.ts +++ b/apps/web/src/hooks/useLoginProviders.ts @@ -5,6 +5,7 @@ import { fetchLoginProviders } from '../lib/api/setup'; export interface LoginProviders { github: boolean; google: boolean; + gitlab: boolean; } /** @@ -12,19 +13,19 @@ export interface LoginProviders { * * Defaults show GitHub (the primary path — never hidden by a transient fetch * failure) and hide Google until we confirm a login client is configured, so we - * never render a "Sign in with Google" button that would error on click. + * never render provider buttons that would error on click. */ export function useLoginProviders(): LoginProviders { - const [providers, setProviders] = useState({ github: true, google: false }); + const [providers, setProviders] = useState({ github: true, google: false, gitlab: false }); useEffect(() => { let active = true; fetchLoginProviders() .then((p) => { - if (active) setProviders({ github: p.github, google: p.google }); + if (active) setProviders({ github: p.github, google: p.google, gitlab: p.gitlab }); }) .catch(() => { - /* keep defaults on failure — GitHub visible, Google hidden */ + /* keep defaults on failure — GitHub visible, other providers hidden */ }); return () => { active = false; diff --git a/apps/web/src/lib/api/admin.ts b/apps/web/src/lib/api/admin.ts index 21f275e25..c5e63aa2e 100644 --- a/apps/web/src/lib/api/admin.ts +++ b/apps/web/src/lib/api/admin.ts @@ -486,6 +486,7 @@ export interface PlatformConfigStatus { githubApp: PlatformIntegrationStatus; githubWebhook: PlatformIntegrationStatus; googleOAuth: PlatformIntegrationStatus; + gitlabOAuth: PlatformIntegrationStatus; }; } @@ -502,6 +503,11 @@ export interface PlatformIntegrationConfigInput { clientId?: string; clientSecret?: string; }; + gitlab?: { + host?: string; + clientId?: string; + clientSecret?: string; + }; } export interface PlatformConfigStatusResponse { diff --git a/apps/web/src/lib/api/setup.ts b/apps/web/src/lib/api/setup.ts index 8d7c1b521..663e12af0 100644 --- a/apps/web/src/lib/api/setup.ts +++ b/apps/web/src/lib/api/setup.ts @@ -25,6 +25,7 @@ export interface SetupCompleteResponse { export interface LoginProvidersResponse { github: boolean; google: boolean; + gitlab: boolean; } /** Public: which login providers are configured (Google = the login client). */ diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 987256d01..c95bbfb1b 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -36,6 +36,17 @@ export async function signInWithGoogle() { }); } +/** + * Sign in with GitLab OAuth. + * Redirects to the configured GitLab host for authentication. + */ +export async function signInWithGitLab() { + await authClient.signIn.social({ + provider: 'gitlab', + callbackURL: window.location.origin + '/dashboard', + }); +} + /** * Sign out the current user. * Clears session and redirects to home. diff --git a/apps/web/src/pages/AdminPlatformConfig.tsx b/apps/web/src/pages/AdminPlatformConfig.tsx index 599b2e012..a444d4f0b 100644 --- a/apps/web/src/pages/AdminPlatformConfig.tsx +++ b/apps/web/src/pages/AdminPlatformConfig.tsx @@ -55,7 +55,7 @@ export function AdminPlatformConfig() {

- Configure runtime platform integrations. Values set here are stored in D1 and encrypted platform credentials; existing GitHub secret fallbacks remain active until overridden. + Configure runtime platform integrations. Values set here are stored in D1 and encrypted platform credentials; existing environment fallbacks remain active until overridden.

{error && setError(null)}>{error}} diff --git a/apps/web/src/pages/DeviceAuth.tsx b/apps/web/src/pages/DeviceAuth.tsx index a298d5ad8..cb1c0b37c 100644 --- a/apps/web/src/pages/DeviceAuth.tsx +++ b/apps/web/src/pages/DeviceAuth.tsx @@ -28,7 +28,7 @@ export function DeviceAuth() { setCode(initialCode); }, [initialCode]); - const handleLogin = async (provider: 'github' | 'google') => { + const handleLogin = async (provider: 'github' | 'google' | 'gitlab') => { const returnPath = `/device${code ? `?code=${encodeURIComponent(normalizeCode(code))}` : ''}`; await authClient.signIn.social({ provider, @@ -43,7 +43,15 @@ export function DeviceAuth() { return; } if (!isAuthenticated) { - await handleLogin('github'); + if (providers.github) { + await handleLogin('github'); + } else if (providers.google) { + await handleLogin('google'); + } else if (providers.gitlab) { + await handleLogin('gitlab'); + } else { + setError('No login provider is configured.'); + } return; } @@ -102,14 +110,16 @@ export function DeviceAuth() { ) : (
- + {providers.github && ( + + )} {providers.google && ( + )}
)}
diff --git a/apps/web/src/pages/Landing.tsx b/apps/web/src/pages/Landing.tsx index 2e6eed545..f75484034 100644 --- a/apps/web/src/pages/Landing.tsx +++ b/apps/web/src/pages/Landing.tsx @@ -4,7 +4,7 @@ import { useLocation,useNavigate } from 'react-router'; import { useAuth } from '../components/AuthProvider'; import { useLoginProviders } from '../hooks/useLoginProviders'; -import { signInWithGitHub, signInWithGoogle } from '../lib/auth'; +import { signInWithGitHub, signInWithGitLab, signInWithGoogle } from '../lib/auth'; const PUBLIC_WEBSITE_URL = import.meta.env.VITE_PUBLIC_WEBSITE_URL || 'https://simple-agent-manager.org'; @@ -53,6 +53,14 @@ export function Landing() { } }; + const handleGitLabSignIn = async () => { + try { + await signInWithGitLab(); + } catch (error) { + console.error('Failed to sign in with GitLab:', error); + } + }; + return (
@@ -75,16 +83,24 @@ export function Landing() { ))}
- + {providers.github && ( + + )} {providers.google && ( )} + {providers.gitlab && ( + + )}

Bring your own cloud — your infrastructure, your costs. @@ -121,3 +137,14 @@ function GoogleMark() { ); } + +function GitLabMark() { + return ( + + ); +} diff --git a/apps/web/src/pages/Setup.tsx b/apps/web/src/pages/Setup.tsx index 38911e0a1..239e8d906 100644 --- a/apps/web/src/pages/Setup.tsx +++ b/apps/web/src/pages/Setup.tsx @@ -137,7 +137,7 @@ export function Setup() {

First-run setup

- Enter the setup token from Cloudflare Worker variables, then configure platform sign-in and GitHub automation. + Enter the setup token from Cloudflare Worker variables, then configure platform sign-in and repository automation.

{setupStatus?.forced && ( diff --git a/apps/web/tests/playwright/platform-config-audit.spec.ts b/apps/web/tests/playwright/platform-config-audit.spec.ts index deb2d5e1f..8d94073af 100644 --- a/apps/web/tests/playwright/platform-config-audit.spec.ts +++ b/apps/web/tests/playwright/platform-config-audit.spec.ts @@ -17,7 +17,7 @@ const PLATFORM_STATUS = { githubOAuth: { configured: true, source: 'environment', - label: 'set via GitHub secret', + label: 'set via environment fallback', fields: { clientId: { configured: true, source: 'environment', updatedAt: null, updatedBy: null }, clientSecret: { configured: true, source: 'environment', updatedAt: null, updatedBy: null }, @@ -26,7 +26,7 @@ const PLATFORM_STATUS = { githubApp: { configured: true, source: 'environment', - label: 'set via GitHub secret', + label: 'set via environment fallback', fields: { appId: { configured: true, source: 'environment', updatedAt: null, updatedBy: null }, appPrivateKey: { configured: true, source: 'environment', updatedAt: null, updatedBy: null }, @@ -50,6 +50,16 @@ const PLATFORM_STATUS = { clientSecret: { configured: true, source: 'runtime', updatedAt: '2026-07-07T00:00:00Z', updatedBy: 'admin-platform-config' }, }, }, + gitlabOAuth: { + configured: false, + source: 'unset', + label: 'not configured', + fields: { + host: { configured: false, source: 'unset', updatedAt: null, updatedBy: null }, + clientId: { configured: false, source: 'unset', updatedAt: null, updatedBy: null }, + clientSecret: { configured: false, source: 'unset', updatedAt: null, updatedBy: null }, + }, + }, }, }; @@ -61,14 +71,14 @@ async function respondJson(route: Route, status: number, body: unknown) { }); } -async function setupMocks(page: Page, authenticated = true, googleLogin = true) { +async function setupMocks(page: Page, authenticated = true, googleLogin = true, gitlabLogin = true) { await page.route('**/api/**', async (route) => { const request = route.request(); const path = new URL(request.url()).pathname; if (path === '/api/auth/get-session') return respondJson(route, 200, authenticated ? ADMIN_USER : null); if (path === '/api/config/login-providers') { - return respondJson(route, 200, { github: true, google: googleLogin }); + return respondJson(route, 200, { github: true, google: googleLogin, gitlab: gitlabLogin }); } if (path === '/api/dashboard/active-tasks') return respondJson(route, 200, { tasks: [] }); if (path === '/api/trial-status') return respondJson(route, 200, { isTrial: false }); @@ -110,7 +120,7 @@ test.describe('Platform config first-run and admin UI', () => { await page.getByLabel('Setup token').fill('test-setup-token'); await page.getByRole('button', { name: 'Verify token' }).click(); await expect(page.getByTestId('platform-config-form')).toBeVisible(); - await expect(page.getByText('set via GitHub secret').first()).toBeVisible(); + await expect(page.getByText('set via environment fallback').first()).toBeVisible(); await expect(page.getByText('set here').first()).toBeVisible(); await expect(page.getByText('not configured').first()).toBeVisible(); await assertNoOverflow(page); @@ -133,18 +143,21 @@ test.describe('Platform config first-run and admin UI', () => { await page.goto('/'); await expect(page.getByRole('button', { name: 'Sign in with GitHub' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Sign in with Google' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Sign in with GitLab' })).toBeVisible(); await assertNoOverflow(page); await screenshot(page, 'platform-config-landing-login'); await page.goto('/device?code=ABCD-1234'); await expect(page.getByRole('button', { name: 'Log in with GitHub' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Log in with Google' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Log in with GitLab' })).toBeVisible(); await assertNoOverflow(page); await screenshot(page, 'platform-config-device-login'); await page.goto('/__test/trial-chat-gate?ideas=3&loginOpen=1'); await expect(page.getByTestId('trial-login-github')).toBeVisible(); await expect(page.getByTestId('trial-login-google')).toBeVisible(); + await expect(page.getByTestId('trial-login-gitlab')).toBeVisible(); await assertNoOverflow(page); await screenshot(page, 'platform-config-trial-login-sheet'); }); @@ -155,13 +168,16 @@ test.describe('Platform config first-run and admin UI', () => { await page.goto('/'); await expect(page.getByRole('button', { name: 'Sign in with GitHub' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Sign in with Google' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Sign in with GitLab' })).toBeVisible(); await page.goto('/device?code=ABCD-1234'); await expect(page.getByRole('button', { name: 'Log in with GitHub' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Log in with Google' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Log in with GitLab' })).toBeVisible(); await page.goto('/__test/trial-chat-gate?ideas=3&loginOpen=1'); await expect(page.getByTestId('trial-login-github')).toBeVisible(); await expect(page.getByTestId('trial-login-google')).toHaveCount(0); + await expect(page.getByTestId('trial-login-gitlab')).toBeVisible(); }); }); diff --git a/apps/web/tests/unit/pages/landing.test.tsx b/apps/web/tests/unit/pages/landing.test.tsx index 4dedf8b57..3f9fc82dc 100644 --- a/apps/web/tests/unit/pages/landing.test.tsx +++ b/apps/web/tests/unit/pages/landing.test.tsx @@ -12,6 +12,8 @@ vi.mock('../../../src/components/AuthProvider', () => ({ vi.mock('../../../src/lib/auth', () => ({ signInWithGitHub: vi.fn(), + signInWithGitLab: vi.fn(), + signInWithGoogle: vi.fn(), })); vi.mock('@simple-agent-manager/ui', () => ({ diff --git a/apps/www/src/content/docs/docs/architecture/security.md b/apps/www/src/content/docs/docs/architecture/security.md index a4330d313..7f2d8da91 100644 --- a/apps/www/src/content/docs/docs/architecture/security.md +++ b/apps/www/src/content/docs/docs/architecture/security.md @@ -18,7 +18,7 @@ These are Cloudflare Worker secrets set during deployment: | `JWT_PUBLIC_KEY` | RSA-2048 key for token verification (exposed via JWKS) | | `CF_API_TOKEN` | Cloudflare deploy, DNS, Origin CA certificate issuance, observability, and AI Gateway operations (requires Account → SSL and Certificates → Edit) | -Security keys are automatically generated and persisted by Pulumi on first deployment. Cloudflare secrets remain Worker secrets because they are deployment trust roots. GitHub App/OAuth, GitHub webhook, and Google OAuth credentials can be supplied either as optional environment fallbacks or through the first-run/superadmin platform config UI; runtime values are stored encrypted in D1 and override environment fallbacks. They never appear in source control. +Security keys are automatically generated and persisted by Pulumi on first deployment. Cloudflare secrets remain Worker secrets because they are deployment trust roots. GitHub App/OAuth, GitHub webhook, Google OAuth, and GitLab OAuth credentials can be supplied either as optional environment fallbacks or through the first-run/superadmin platform config UI; runtime values are stored encrypted in D1 and override environment fallbacks. They never appear in source control. ### Platform Integration Credentials @@ -30,6 +30,7 @@ Admin-managed integration secrets stored encrypted in D1: | GitHub App private key | Installation tokens for repository access | Runtime D1 → Worker env → unset | | GitHub webhook secret | GitHub App webhook HMAC verification | Runtime D1 → Worker env → unset | | Google login OAuth client secret | Google sign-in (BetterAuth social login) | Runtime D1 → Worker env (`GOOGLE_LOGIN_CLIENT_SECRET`) → unset | +| GitLab OAuth client secret | GitLab sign-in and future repository access | Runtime D1 → Worker env (`GITLAB_CLIENT_SECRET`) → unset | | Google infra OAuth client secret | GCP deployment authorization flows (separate client from login) | Worker env (`GOOGLE_CLIENT_SECRET`) → unset | ### User Credentials @@ -46,13 +47,13 @@ User credentials are **never** stored as environment variables or Worker secrets ## Authentication Flow -SAM uses **BetterAuth** with GitHub OAuth for user authentication: +SAM uses **BetterAuth** with configured OAuth login providers for user authentication: -1. User clicks "Sign in with GitHub" -2. API redirects to GitHub OAuth -3. GitHub returns authorization code +1. User clicks a configured sign-in provider such as GitHub, Google, or GitLab +2. API redirects to that provider's OAuth flow +3. The provider returns an authorization code 4. API exchanges code for access token -5. API fetches user profile and primary email +5. API fetches user profile and email 6. BetterAuth creates/updates user record and session 7. Session cookie set in browser diff --git a/apps/www/src/content/docs/docs/guides/self-hosting.mdx b/apps/www/src/content/docs/docs/guides/self-hosting.mdx index 190ddf27e..00abf4c3b 100644 --- a/apps/www/src/content/docs/docs/guides/self-hosting.mdx +++ b/apps/www/src/content/docs/docs/guides/self-hosting.mdx @@ -124,6 +124,9 @@ instead of choosing a generic prefix. | `GH_WEBHOOK_SECRET` | Optional GitHub App webhook secret; can be configured in `/setup` instead | | `GOOGLE_LOGIN_CLIENT_ID` | Optional Google **login** OAuth client ID (Sign in with Google); can be configured in `/setup` instead. Register redirect URI `https://api.yourdomain.com/api/auth/callback/google` | | `GOOGLE_LOGIN_CLIENT_SECRET` | Optional Google **login** OAuth client secret; can be configured in `/setup` instead | +| `GITLAB_HOST` | Optional GitLab OAuth host, such as `https://gitlab.com`; can be configured in `/setup` instead. Register redirect URI `https://api.yourdomain.com/api/auth/callback/gitlab` | +| `GITLAB_CLIENT_ID` | Optional GitLab OAuth application ID; can be configured in `/setup` instead | +| `GITLAB_CLIENT_SECRET` | Optional GitLab OAuth secret; can be configured in `/setup` instead | | `GOOGLE_CLIENT_ID` | Optional Google **infra/GCP** OAuth client ID for GCP deployment authorization (separate client from login; NOT set via `/setup`) | | `GOOGLE_CLIENT_SECRET` | Optional Google **infra/GCP** OAuth client secret (separate client from login; NOT set via `/setup`) | | `CF_AIG_TOKEN` | Optional narrower Cloudflare AI Gateway Unified Billing token | @@ -131,7 +134,7 @@ instead of choosing a generic prefix. | `DEVCONTAINER_CACHE_CLOUDFLARE_ACCOUNT_ID` | Optional Cloudflare account override for managed devcontainer registry credentials | :::note -GitHub App secrets use `GH_*` prefix because GitHub Actions secret names cannot start with `GITHUB_*`. When present, the deployment workflow maps those `GH_*` secrets to `GITHUB_*` Worker secrets and SAM uses them as environment fallbacks. Values saved in `/setup` or the superadmin platform config UI take precedence. +GitHub App secrets use `GH_*` prefix because GitHub Actions secret names cannot start with `GITHUB_*`. When present, the deployment workflow maps those `GH_*` secrets to `GITHUB_*` Worker secrets and SAM uses them as environment fallbacks. Google login and GitLab OAuth fallback secrets are passed through with their normal names. Values saved in `/setup` or the superadmin platform config UI take precedence. ::: :::note diff --git a/apps/www/src/content/docs/docs/reference/configuration.md b/apps/www/src/content/docs/docs/reference/configuration.md index f56f44321..fe18a5eef 100644 --- a/apps/www/src/content/docs/docs/reference/configuration.md +++ b/apps/www/src/content/docs/docs/reference/configuration.md @@ -32,6 +32,9 @@ These are Cloudflare Worker secrets, set during deployment. Pulumi auto-generate | `GITHUB_APP_PRIVATE_KEY` | Optional fallback GitHub App private key (PEM or base64); runtime admin config takes precedence | | `GITHUB_APP_SLUG` | Optional fallback GitHub App URL slug; runtime admin config takes precedence | | `GITHUB_WEBHOOK_SECRET` | Optional fallback GitHub App webhook HMAC secret; runtime admin config takes precedence | +| `GITLAB_HOST` | Optional fallback GitLab OAuth host, such as `https://gitlab.com`; runtime admin config takes precedence | +| `GITLAB_CLIENT_ID` | Optional fallback GitLab OAuth application ID; runtime admin config takes precedence | +| `GITLAB_CLIENT_SECRET` | Optional fallback GitLab OAuth secret; runtime admin config takes precedence | | `TRIAL_CLAIM_TOKEN_SECRET` | Trial onboarding HMAC secret (auto-generated) | ## Worker Variables @@ -61,7 +64,7 @@ Set in GitHub Settings → Environments → production: characters of the domain's SHA-256 hash. The self-host onboarding flow fills it in for you. -Required GitHub Actions secrets include `CF_API_TOKEN`, `CF_ACCOUNT_ID`, `CF_ZONE_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, and `PULUMI_CONFIG_PASSPHRASE`. GitHub App/OAuth secrets (`GH_CLIENT_ID`, `GH_CLIENT_SECRET`, `GH_APP_ID`, `GH_APP_PRIVATE_KEY`, `GH_APP_SLUG`, `GH_WEBHOOK_SECRET`) and the Google **login** OAuth secrets (`GOOGLE_LOGIN_CLIENT_ID`, `GOOGLE_LOGIN_CLIENT_SECRET`) are optional environment fallbacks; fresh deployments can set them through `/setup` instead. The Google **infra/GCP** OAuth secrets (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`) are a separate client used only for GCP deployment authorization and are not part of the `/setup` wizard. Deploy signing keys are generated and persisted by Pulumi during deployment; GitHub Environment values are only needed for explicit key overrides. +Required GitHub Actions secrets include `CF_API_TOKEN`, `CF_ACCOUNT_ID`, `CF_ZONE_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, and `PULUMI_CONFIG_PASSPHRASE`. GitHub App/OAuth secrets (`GH_CLIENT_ID`, `GH_CLIENT_SECRET`, `GH_APP_ID`, `GH_APP_PRIVATE_KEY`, `GH_APP_SLUG`, `GH_WEBHOOK_SECRET`), the Google **login** OAuth secrets (`GOOGLE_LOGIN_CLIENT_ID`, `GOOGLE_LOGIN_CLIENT_SECRET`), and GitLab OAuth secrets (`GITLAB_HOST`, `GITLAB_CLIENT_ID`, `GITLAB_CLIENT_SECRET`) are optional environment fallbacks; fresh deployments can set them through `/setup` instead. The Google **infra/GCP** OAuth secrets (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`) are a separate client used only for GCP deployment authorization and are not part of the `/setup` wizard. Deploy signing keys are generated and persisted by Pulumi during deployment; GitHub Environment values are only needed for explicit key overrides. :::note[Naming convention] GitHub App secrets use `GH_*` prefix (e.g., `GH_CLIENT_ID`, `GH_WEBHOOK_SECRET`) because GitHub Actions secret names cannot start with `GITHUB_*`. When present, the deploy workflow maps those `GH_*` secrets to `GITHUB_*` Worker secrets. Runtime admin config in D1 is resolved first, then these environment fallbacks, then unset. diff --git a/scripts/deploy/configure-secrets.sh b/scripts/deploy/configure-secrets.sh index d3d460fef..1dd31fa59 100644 --- a/scripts/deploy/configure-secrets.sh +++ b/scripts/deploy/configure-secrets.sh @@ -218,6 +218,19 @@ else echo -e "${YELLOW}ℹ Skipping Google login OAuth secrets (GOOGLE_LOGIN_CLIENT_ID/SECRET not set — configure Google sign-in via /setup)${NC}" fi +# Configure GitLab OAuth secrets (optional env fallback — the setup wizard is the +# primary path; use https://gitlab.com for the public service) +GITLAB_HOST="${GITLAB_HOST:-}" +GITLAB_CLIENT_ID="${GITLAB_CLIENT_ID:-}" +GITLAB_CLIENT_SECRET="${GITLAB_CLIENT_SECRET:-}" +if [ -n "$GITLAB_HOST" ] && [ -n "$GITLAB_CLIENT_ID" ] && [ -n "$GITLAB_CLIENT_SECRET" ]; then + set_worker_secret "GITLAB_HOST" "$GITLAB_HOST" "$ENVIRONMENT" "true" || FAILED=true + set_worker_secret "GITLAB_CLIENT_ID" "$GITLAB_CLIENT_ID" "$ENVIRONMENT" "true" || FAILED=true + set_worker_secret "GITLAB_CLIENT_SECRET" "$GITLAB_CLIENT_SECRET" "$ENVIRONMENT" "true" || FAILED=true +else + echo -e "${YELLOW}ℹ Skipping GitLab OAuth secrets (GITLAB_HOST/CLIENT_ID/CLIENT_SECRET not set — configure GitLab via /setup)${NC}" +fi + # Configure R2 S3-compatible API credentials (optional — only needed for task attachment uploads) R2_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID:-}" R2_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY:-}" diff --git a/scripts/deploy/types.ts b/scripts/deploy/types.ts index e13d912a1..ab97aed6f 100644 --- a/scripts/deploy/types.ts +++ b/scripts/deploy/types.ts @@ -326,6 +326,11 @@ export const OPTIONAL_SECRETS = [ 'GITHUB_WEBHOOK_SECRET', 'GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', + 'GOOGLE_LOGIN_CLIENT_ID', + 'GOOGLE_LOGIN_CLIENT_SECRET', + 'GITLAB_HOST', + 'GITLAB_CLIENT_ID', + 'GITLAB_CLIENT_SECRET', 'SEGMENT_WRITE_KEY', 'GA4_API_SECRET', 'GA4_MEASUREMENT_ID', diff --git a/tasks/active/2026-07-08-gitlab-platform-config-wip.md b/tasks/active/2026-07-08-gitlab-platform-config-wip.md new file mode 100644 index 000000000..08fa8f531 --- /dev/null +++ b/tasks/active/2026-07-08-gitlab-platform-config-wip.md @@ -0,0 +1,74 @@ +# GitLab Platform Config Foundation (WIP) + +## Problem + +SAM has a detailed GitLab integration plan in SAM idea `01KV7ZFD6HZS5N7J45VA798KN1`, but the implementation should start with the new DB-backed platform-level integration config model. GitLab OAuth app credentials should be configurable through first-run setup and superadmin platform config, with runtime D1 values overriding optional environment fallbacks. + +This task is intentionally WIP. It must not deploy to staging and must not merge to production. + +## Scope + +This PR covers the first implementation slice only: + +- Add GitLab OAuth app config to platform config resolution. +- Add optional env fallbacks for lockout/manual deployments. +- Add setup/admin API parsing, validation, and status reporting. +- Add the minimum GitLab login-provider wiring needed so GitLab-only setup does not complete into a lockout state. +- Add setup/admin UI fields for GitLab OAuth. +- Update public self-hosting/configuration/security docs. +- Add focused tests. + +Out of scope for this WIP PR: + +- GitLab project creation. +- GitLab repository provider model (`RepoProvider = gitlab`). +- GitLab task runner/VM clone/push support. +- GitLab merge request creation. +- GitLab webhooks/triggers. + +## Research Findings + +- Platform config lives in `apps/api/src/services/platform-config.ts`. +- Non-secret runtime values use `platform_settings`; secrets use encrypted `platform_credentials` with `credential_type='platform-integration'`. +- `/setup` and `/api/admin/platform-config` parse the same `PlatformIntegrationInput`. +- UI form is `apps/web/src/components/PlatformIntegrationConfigForm.tsx`; web API types are in `apps/web/src/lib/api/admin.ts`. +- Docs already describe GitHub/Google runtime config in: + - `apps/www/src/content/docs/docs/reference/configuration.md` + - `apps/www/src/content/docs/docs/guides/self-hosting.mdx` + - `apps/www/src/content/docs/docs/architecture/security.md` +- Existing tests to extend: + - `apps/api/tests/unit/services/platform-config.test.ts` + - `apps/api/tests/unit/routes/setup.test.ts` + - `apps/web/tests/playwright/platform-config-audit.spec.ts` + +## Implementation Checklist + +- [x] Extend API env typing and `.env.example` for optional `GITLAB_HOST`, `GITLAB_CLIENT_ID`, `GITLAB_CLIENT_SECRET`. +- [x] Extend `ResolvedPlatformConfig`, `PlatformConfigStatus`, and `PlatformIntegrationInput` with `gitlab`. +- [x] Store GitLab host/client ID in `platform_settings`; store client secret in encrypted `platform_credentials`. +- [x] Add `getGitLabOAuthConfig(env)` with runtime-first/env-fallback resolution. +- [x] Extend platform config validation for GitLab host/client fields. +- [x] Extend setup/admin route parsing for GitLab input. +- [x] Extend login provider status response with `gitlab`. +- [x] Wire GitLab into BetterAuth and login surfaces so configured GitLab OAuth is usable for sign-in. +- [x] Extend admin/setup web API types and platform config form UI. +- [x] Update public docs for optional GitLab config fallbacks and runtime config. +- [x] Add/extend tests. +- [x] Run UI visual audit for changed platform config form. + +## Acceptance Criteria + +- Superadmins can see GitLab OAuth config status alongside GitHub/Google. +- `/setup` and `/api/admin/platform-config` accept GitLab host/client ID/client secret. +- Runtime GitLab config overrides env fallback. +- Secret values are encrypted through the existing `platform_credentials` path. +- Setup completion can treat GitLab OAuth as a sign-in provider once configured. +- Existing GitHub and Google platform config behavior remains unchanged. +- The PR is opened as draft/WIP only, with no staging deployment and no merge. + +## References + +- SAM idea: `01KV7ZFD6HZS5N7J45VA798KN1` +- GitLab OAuth docs: https://docs.gitlab.com/integration/oauth_provider/ +- GitLab OAuth token API: https://docs.gitlab.com/api/oauth2/ +- `/do` constraint: WIP PR, no staging, no production merge.