diff --git a/.env.example b/.env.example index f1f1631a..a475c7ba 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,12 @@ FRONTEND_URL=http://localhost:10000 # client-supplied SSO headers. # SMARTPERFETTO_ENTERPRISE=false # SMARTPERFETTO_SSO_TRUSTED_HEADERS=false +# SMARTPERFETTO_SSO_COOKIE_SECRET=replace_with_a_32_byte_random_secret +# SMARTPERFETTO_OIDC_ISSUER_URL=https://idp.example.com +# SMARTPERFETTO_OIDC_CLIENT_ID=smartperfetto +# SMARTPERFETTO_OIDC_CLIENT_SECRET=replace_with_oidc_client_secret +# SMARTPERFETTO_OIDC_REDIRECT_URI=http://localhost:3000/api/auth/oidc/callback +# SMARTPERFETTO_OIDC_EMAIL_DOMAIN_MAP=example.com=default-dev-tenant # --------------------------------------------------- # AI runtime diff --git a/backend/.env.example b/backend/.env.example index aa4e5839..d02e4653 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -530,6 +530,17 @@ FRONTEND_URL=http://localhost:10000 # only safe behind an OIDC-authenticated reverse proxy that strips inbound # client-supplied SSO headers before injecting trusted identity headers. # SMARTPERFETTO_SSO_TRUSTED_HEADERS=false +# +# Provider-agnostic OIDC SSO. Configure these for /api/auth/oidc/login. +# SMARTPERFETTO_SSO_COOKIE_SECRET=replace_with_a_32_byte_random_secret +# SMARTPERFETTO_SSO_SESSION_TTL_MS=28800000 +# SMARTPERFETTO_OIDC_ISSUER_URL=https://idp.example.com +# SMARTPERFETTO_OIDC_CLIENT_ID=smartperfetto +# SMARTPERFETTO_OIDC_CLIENT_SECRET=replace_with_oidc_client_secret +# SMARTPERFETTO_OIDC_REDIRECT_URI=http://localhost:3000/api/auth/oidc/callback +# SMARTPERFETTO_OIDC_SCOPES=openid email profile +# SMARTPERFETTO_OIDC_EMAIL_DOMAIN_MAP=example.com=default-dev-tenant +# SMARTPERFETTO_OIDC_DEFAULT_TENANT_ID=default-dev-tenant # --------------------------------------------------- # Request Throttling (optional, in-memory, per API key/IP) diff --git a/backend/src/index.ts b/backend/src/index.ts index cf1483ad..e888a7ac 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -35,6 +35,7 @@ import ciGateRoutes from './routes/ciGateRoutes'; import memoryRoutes from './routes/memoryRoutes'; import caseRoutes from './routes/caseRoutes'; import ragAdminRoutes from './routes/ragAdminRoutes'; +import enterpriseAuthRoutes from './routes/enterpriseAuthRoutes'; import {authenticate} from './middleware/auth'; import { assertTraceAnalysisConfiguredForStartup, @@ -123,7 +124,9 @@ app.get('/health', (req, res) => { type: activeProvider.type, }, } : {}), - authRequired: !!process.env.SMARTPERFETTO_API_KEY, + authRequired: !!process.env.SMARTPERFETTO_API_KEY + || process.env.SMARTPERFETTO_ENTERPRISE === 'true' + || !!process.env.SMARTPERFETTO_OIDC_ISSUER_URL, diagnostics: selectedDiagnostics, }, }); @@ -143,6 +146,7 @@ app.get('/debug', (req, res) => { // API routes app.use('/api/sql', sqlRoutes); +app.use('/api/auth', enterpriseAuthRoutes); app.use('/api/traces', simpleTraceRoutes); app.use(AGENT_API_V1_LLM_BASE, aiChatRoutes); app.use('/api/perfetto', perfettoLocalRoutes); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 1ab7221e..0693966b 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -6,6 +6,7 @@ import { Request, Response, NextFunction } from 'express'; import crypto from 'crypto'; import { ErrorResponse } from '../types'; import { resolveFeatureConfig } from '../config'; +import { EnterpriseSsoService } from '../services/enterpriseSsoService'; type RequestContextAuthType = 'sso' | 'api_key' | 'dev'; @@ -31,6 +32,8 @@ interface AuthenticatedRequest extends Request { const API_KEY_ENV = 'SMARTPERFETTO_API_KEY'; const SSO_TRUSTED_HEADERS_ENV = 'SMARTPERFETTO_SSO_TRUSTED_HEADERS'; +const SSO_SESSION_TOKEN_PREFIX = 'sp_sso_'; +const SSO_SESSION_COOKIE_NAME = 'sp_sso_session'; export const DEFAULT_TENANT_ID = 'default-dev-tenant'; export const DEFAULT_WORKSPACE_ID = 'default-workspace'; export const DEFAULT_DEV_USER_ID = 'dev-user-123'; @@ -63,6 +66,19 @@ const getProvidedApiKey = (req: Request): string | undefined => { return undefined; }; +const requestHasSsoSessionCredential = (req: Request): boolean => { + const authHeader = req.headers.authorization; + if (typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) { + return authHeader.slice('Bearer '.length).trim().startsWith(SSO_SESSION_TOKEN_PREFIX); + } + return typeof req.headers.cookie === 'string' + && req.headers.cookie.split(';').some((cookie) => { + const [name, value = ''] = cookie.trim().split('='); + return name === SSO_SESSION_COOKIE_NAME + && decodeURIComponent(value).startsWith(SSO_SESSION_TOKEN_PREFIX); + }); +}; + const safeEquals = (a: string, b: string): boolean => { const aBuf = Buffer.from(a); const bBuf = Buffer.from(b); @@ -243,6 +259,25 @@ export const authenticate = async ( return; } + if (requestHasSsoSessionCredential(req)) { + try { + const sessionIdentity = EnterpriseSsoService.getInstance().resolveRequestIdentityFromRequest(req); + if (sessionIdentity) { + attachIdentity(req, sessionIdentity); + next(); + return; + } + } catch (error) { + if (resolveFeatureConfig(process.env).enterprise) { + sendUnauthorized( + res, + error instanceof Error ? error.message : 'Invalid SSO session', + ); + return; + } + } + } + const configuredKey = process.env[API_KEY_ENV]; if (!configuredKey) { if (resolveFeatureConfig(process.env).enterprise) { diff --git a/backend/src/routes/__tests__/enterpriseAuthRoutes.test.ts b/backend/src/routes/__tests__/enterpriseAuthRoutes.test.ts new file mode 100644 index 00000000..1a272c63 --- /dev/null +++ b/backend/src/routes/__tests__/enterpriseAuthRoutes.test.ts @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2024-2026 Gracker (Chris) +// This file is part of SmartPerfetto. See LICENSE for details. + +import crypto from 'crypto'; +import express from 'express'; +import request from 'supertest'; +import Database from 'better-sqlite3'; +import { authenticate, type AuthenticatedRequest } from '../../middleware/auth'; +import { createEnterpriseAuthRouter } from '../enterpriseAuthRoutes'; +import { applyEnterpriseMinimalSchema } from '../../services/enterpriseSchema'; +import { EnterpriseSsoService } from '../../services/enterpriseSsoService'; +import type { EnterpriseOidcUserInfo } from '../../services/enterpriseOidcClient'; + +const originalEnterprise = process.env.SMARTPERFETTO_ENTERPRISE; +const originalCookieSecret = process.env.SMARTPERFETTO_SSO_COOKIE_SECRET; +const originalApiKey = process.env.SMARTPERFETTO_API_KEY; + +function ssoUserId(issuer: string, subject: string): string { + return `sso-${crypto.createHash('sha256').update(`${issuer}|${subject}`).digest('hex').slice(0, 20)}`; +} + +function makeApp(service: EnterpriseSsoService, userInfo: EnterpriseOidcUserInfo): { + app: express.Express; + captured: { state?: string; nonce?: string }; +} { + const app = express(); + app.use(express.json()); + const captured: { state?: string; nonce?: string } = {}; + app.use('/api/auth', createEnterpriseAuthRouter({ + ssoService: service, + oidcClient: { + async buildAuthorizationUrl(params) { + captured.state = params.state; + captured.nonce = params.nonce; + return `https://idp.example.test/auth?state=${params.state}&nonce=${params.nonce}`; + }, + async exchangeCodeForUserInfo(code) { + if (code !== 'code-123') throw new Error('unexpected code'); + return userInfo; + }, + }, + })); + app.get('/protected', authenticate, (req, res) => { + res.json({ requestContext: (req as AuthenticatedRequest).requestContext }); + }); + return { app, captured }; +} + +function seedMemberships(db: Database.Database, userId: string): void { + const now = Date.now(); + db.prepare(` + INSERT INTO organizations (id, name, status, plan, created_at, updated_at) + VALUES ('tenant-a', 'Tenant A', 'active', 'enterprise', ?, ?) + `).run(now, now); + db.prepare(` + INSERT INTO workspaces (id, tenant_id, name, created_at, updated_at) + VALUES + ('workspace-a', 'tenant-a', 'Workspace A', ?, ?), + ('workspace-b', 'tenant-a', 'Workspace B', ?, ?) + `).run(now, now, now, now); + db.prepare(` + INSERT INTO users (id, tenant_id, email, display_name, idp_subject, created_at, updated_at) + VALUES (?, 'tenant-a', 'alice@example.test', 'Alice', 'https://idp.example.test|alice-sub', ?, ?) + `).run(userId, now, now); + db.prepare(` + INSERT INTO memberships (tenant_id, workspace_id, user_id, role, created_at) + VALUES + ('tenant-a', 'workspace-a', ?, 'analyst', ?), + ('tenant-a', 'workspace-b', ?, 'workspace_admin', ?) + `).run(userId, now, userId, now); +} + +describe('enterprise auth routes', () => { + let db: Database.Database; + + beforeEach(() => { + process.env.SMARTPERFETTO_ENTERPRISE = 'true'; + process.env.SMARTPERFETTO_SSO_COOKIE_SECRET = 'test-sso-cookie-secret-32-bytes'; + delete process.env.SMARTPERFETTO_API_KEY; + EnterpriseSsoService.resetForTests(); + db = new Database(':memory:'); + applyEnterpriseMinimalSchema(db); + }); + + afterEach(() => { + db.close(); + EnterpriseSsoService.resetForTests(); + if (originalEnterprise === undefined) { + delete process.env.SMARTPERFETTO_ENTERPRISE; + } else { + process.env.SMARTPERFETTO_ENTERPRISE = originalEnterprise; + } + if (originalCookieSecret === undefined) { + delete process.env.SMARTPERFETTO_SSO_COOKIE_SECRET; + } else { + process.env.SMARTPERFETTO_SSO_COOKIE_SECRET = originalCookieSecret; + } + if (originalApiKey === undefined) { + delete process.env.SMARTPERFETTO_API_KEY; + } else { + process.env.SMARTPERFETTO_API_KEY = originalApiKey; + } + }); + + test('runs OIDC callback into workspace-selection onboarding and audit, then authenticates selected workspace', async () => { + const issuer = 'https://idp.example.test'; + const subject = 'alice-sub'; + const userInfo: EnterpriseOidcUserInfo = { + issuer, + subject, + email: 'alice@example.test', + displayName: 'Alice', + claims: { + sub: subject, + email: 'alice@example.test', + name: 'Alice', + tenant_id: 'tenant-a', + }, + }; + const userId = ssoUserId(issuer, subject); + seedMemberships(db, userId); + const service = new EnterpriseSsoService(db); + EnterpriseSsoService.setInstanceForTests(service); + const { app, captured } = makeApp(service, userInfo); + + const login = await request(app) + .get('/api/auth/oidc/login?returnTo=/assistant-shell') + .expect(302); + expect(login.headers.location).toContain('https://idp.example.test/auth'); + expect(captured.state).toBeDefined(); + const stateCookie = login.headers['set-cookie'][0].split(';')[0]; + + const callback = await request(app) + .get(`/api/auth/oidc/callback?code=code-123&state=${captured.state}`) + .set('Cookie', stateCookie) + .expect(200); + + expect(callback.body).toMatchObject({ + success: true, + status: 'needs_workspace_selection', + tenantId: 'tenant-a', + userId, + returnTo: '/assistant-shell', + }); + expect(callback.body.workspaces.map((workspace: any) => workspace.workspaceId)).toEqual([ + 'workspace-a', + 'workspace-b', + ]); + expect(callback.body.accessToken).toMatch(/^sp_sso_/); + + const selected = await request(app) + .post('/api/auth/onboarding/workspace') + .set('Authorization', `Bearer ${callback.body.accessToken}`) + .send({ workspaceId: 'workspace-b' }) + .expect(200); + expect(selected.body).toMatchObject({ + success: true, + status: 'ready', + workspaceId: 'workspace-b', + }); + + const protectedRes = await request(app) + .get('/protected') + .set('Authorization', `Bearer ${callback.body.accessToken}`) + .expect(200); + expect(protectedRes.body.requestContext).toMatchObject({ + authType: 'sso', + tenantId: 'tenant-a', + workspaceId: 'workspace-b', + userId, + roles: ['workspace_admin'], + scopes: ['*'], + }); + + expect(service.listAuditEvents().map(event => event.action)).toEqual([ + 'sso_login', + 'workspace_selected', + 'provider_default_resolved', + ]); + }); + + test('returns needs_tenant_join when no tenant claim, domain mapping, or default tenant matches', async () => { + const service = new EnterpriseSsoService(db); + const { app, captured } = makeApp(service, { + issuer: 'https://idp.example.test', + subject: 'bob-sub', + email: 'bob@unknown.test', + claims: { sub: 'bob-sub', email: 'bob@unknown.test' }, + }); + + const login = await request(app).get('/api/auth/oidc/login').expect(302); + const stateCookie = login.headers['set-cookie'][0].split(';')[0]; + const callback = await request(app) + .get(`/api/auth/oidc/callback?code=code-123&state=${captured.state}`) + .set('Cookie', stateCookie) + .expect(200); + + expect(callback.body).toMatchObject({ + success: true, + status: 'needs_tenant_join', + }); + expect(callback.body.accessToken).toBeUndefined(); + expect(service.listAuditEvents()).toEqual([]); + }); +}); diff --git a/backend/src/routes/enterpriseAuthRoutes.ts b/backend/src/routes/enterpriseAuthRoutes.ts new file mode 100644 index 00000000..8ea4d284 --- /dev/null +++ b/backend/src/routes/enterpriseAuthRoutes.ts @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2024-2026 Gracker (Chris) +// This file is part of SmartPerfetto. See LICENSE for details. + +import express from 'express'; +import { + EnterpriseOidcClient, + type EnterpriseOidcUserInfo, +} from '../services/enterpriseOidcClient'; +import { + EnterpriseSsoService, + enterpriseSsoCookies, + type OnboardingResult, +} from '../services/enterpriseSsoService'; + +interface OidcClientLike { + buildAuthorizationUrl(params: { state: string; nonce: string }): Promise; + exchangeCodeForUserInfo(code: string): Promise; +} + +interface EnterpriseAuthRouteDeps { + oidcClient?: OidcClientLike | null; + ssoService?: EnterpriseSsoService; +} + +function cookieHeader(name: string, value: string, options: { + maxAgeSeconds?: number; + path?: string; + httpOnly?: boolean; +} = {}): string { + const parts = [ + `${name}=${encodeURIComponent(value)}`, + `Path=${options.path || '/'}`, + 'SameSite=Lax', + ]; + if (options.httpOnly !== false) parts.push('HttpOnly'); + if (options.maxAgeSeconds !== undefined) parts.push(`Max-Age=${options.maxAgeSeconds}`); + return parts.join('; '); +} + +function clearCookieHeader(name: string, path = '/'): string { + return cookieHeader(name, '', { maxAgeSeconds: 0, path }); +} + +function tokenFromRequest(req: express.Request, service: EnterpriseSsoService): string | null { + const authHeader = req.headers.authorization; + if (typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) { + return authHeader.slice('Bearer '.length).trim(); + } + const cookies = req.headers.cookie?.split(';') || []; + for (const cookie of cookies) { + const [name, ...rest] = cookie.trim().split('='); + if (name === service.sessionCookieName) return decodeURIComponent(rest.join('=')); + } + return null; +} + +function sendOnboardingResult(res: express.Response, service: EnterpriseSsoService, result: OnboardingResult): void { + if (result.accessToken) { + res.setHeader('Set-Cookie', cookieHeader( + service.sessionCookieName, + service.createSessionCookieValue(result.accessToken), + { maxAgeSeconds: 8 * 60 * 60 }, + )); + } + res.json({ success: true, ...result }); +} + +export function createEnterpriseAuthRouter(deps: EnterpriseAuthRouteDeps = {}): express.Router { + const router = express.Router(); + const getService = () => deps.ssoService || EnterpriseSsoService.getInstance(); + const oidcClient = deps.oidcClient === undefined + ? EnterpriseOidcClient.fromEnv() + : deps.oidcClient; + + router.get('/oidc/login', async (req, res) => { + if (!oidcClient) { + return res.status(404).json({ + success: false, + error: 'OIDC is not configured', + }); + } + + try { + const service = getService(); + const statePayload = service.createStatePayload( + typeof req.query.returnTo === 'string' ? req.query.returnTo : undefined, + ); + const signedState = service.signStatePayload(statePayload); + const authorizationUrl = await oidcClient.buildAuthorizationUrl({ + state: statePayload.state, + nonce: statePayload.nonce, + }); + res.setHeader('Set-Cookie', cookieHeader( + service.stateCookieName, + signedState, + { maxAgeSeconds: 10 * 60, path: '/api/auth/oidc/callback' }, + )); + return res.redirect(302, authorizationUrl); + } catch (error) { + return res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to start OIDC login', + }); + } + }); + + router.get('/oidc/callback', async (req, res) => { + if (!oidcClient) { + return res.status(404).json({ + success: false, + error: 'OIDC is not configured', + }); + } + const code = typeof req.query.code === 'string' ? req.query.code : ''; + const state = typeof req.query.state === 'string' ? req.query.state : ''; + const service = getService(); + const stateCookie = req.headers.cookie + ?.split(';') + .map(cookie => cookie.trim()) + .find(cookie => cookie.startsWith(`${enterpriseSsoCookies.state}=`)) + ?.slice(enterpriseSsoCookies.state.length + 1); + const statePayload = service.verifyStatePayload(stateCookie ? decodeURIComponent(stateCookie) : undefined); + if (!code || !statePayload || statePayload.state !== state) { + return res.status(400).json({ + success: false, + error: 'Invalid OIDC callback state', + }); + } + + try { + const userInfo = await oidcClient.exchangeCodeForUserInfo(code); + const result = service.completeOidcLogin(userInfo); + const cookies = [clearCookieHeader(service.stateCookieName, '/api/auth/oidc/callback')]; + if (result.accessToken) { + cookies.push(cookieHeader( + service.sessionCookieName, + service.createSessionCookieValue(result.accessToken), + { maxAgeSeconds: 8 * 60 * 60 }, + )); + } + res.setHeader('Set-Cookie', cookies); + return res.json({ + success: true, + ...result, + returnTo: statePayload.returnTo, + }); + } catch (error) { + return res.status(502).json({ + success: false, + error: error instanceof Error ? error.message : 'OIDC callback failed', + }); + } + }); + + router.get('/session', (req, res) => { + const service = getService(); + const session = service.getOnboardingSessionFromRequest(req); + if (!session) { + return res.json({ success: true, authenticated: false }); + } + return res.json({ + success: true, + authenticated: true, + tenantId: session.tenantId, + userId: session.userId, + workspaceId: session.selectedWorkspaceId, + status: session.selectedWorkspaceId ? 'ready' : 'needs_workspace_selection', + expiresAt: session.expiresAt, + }); + }); + + router.post('/onboarding/workspace', (req, res) => { + const service = getService(); + const accessToken = tokenFromRequest(req, service); + const workspaceId = typeof req.body?.workspaceId === 'string' ? req.body.workspaceId : ''; + if (!accessToken || !workspaceId) { + return res.status(400).json({ + success: false, + error: 'access token and workspaceId are required', + }); + } + return sendOnboardingResult(res, service, service.selectWorkspace(accessToken, workspaceId)); + }); + + router.post('/logout', (req, res) => { + const service = getService(); + const accessToken = tokenFromRequest(req, service); + if (accessToken) service.revokeSession(accessToken); + res.setHeader('Set-Cookie', clearCookieHeader(service.sessionCookieName)); + return res.json({ success: true }); + }); + + return router; +} + +export default createEnterpriseAuthRouter(); diff --git a/backend/src/services/__tests__/enterpriseOidcClient.test.ts b/backend/src/services/__tests__/enterpriseOidcClient.test.ts new file mode 100644 index 00000000..e49b6e02 --- /dev/null +++ b/backend/src/services/__tests__/enterpriseOidcClient.test.ts @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2024-2026 Gracker (Chris) +// This file is part of SmartPerfetto. See LICENSE for details. + +import { EnterpriseOidcClient } from '../enterpriseOidcClient'; + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as Response; +} + +describe('EnterpriseOidcClient', () => { + test('uses OIDC discovery, authorization URL, token exchange, and userinfo', async () => { + const calls: Array<{ url: string; init?: RequestInit }> = []; + const fetchImpl = (async (input: string | URL, init?: RequestInit) => { + const url = input.toString(); + calls.push({ url, init }); + if (url.endsWith('/.well-known/openid-configuration')) { + return jsonResponse({ + issuer: 'https://idp.example.test', + authorization_endpoint: 'https://idp.example.test/auth', + token_endpoint: 'https://idp.example.test/token', + userinfo_endpoint: 'https://idp.example.test/userinfo', + }); + } + if (url === 'https://idp.example.test/token') { + expect(init?.method).toBe('POST'); + expect((init?.body as URLSearchParams).get('code')).toBe('code-123'); + return jsonResponse({ access_token: 'access-123', token_type: 'Bearer' }); + } + if (url === 'https://idp.example.test/userinfo') { + expect((init?.headers as Record).authorization).toBe('Bearer access-123'); + return jsonResponse({ + sub: 'alice-sub', + email: 'alice@example.test', + name: 'Alice', + }); + } + return jsonResponse({}, 404); + }) as typeof fetch; + + const client = new EnterpriseOidcClient({ + issuerUrl: 'https://idp.example.test', + clientId: 'client-a', + clientSecret: 'secret-a', + redirectUri: 'https://smartperfetto.example.test/api/auth/oidc/callback', + scopes: ['openid', 'email'], + }, fetchImpl); + + const authorizationUrl = await client.buildAuthorizationUrl({ + state: 'state-123', + nonce: 'nonce-123', + }); + expect(authorizationUrl).toContain('https://idp.example.test/auth'); + expect(authorizationUrl).toContain('client_id=client-a'); + expect(authorizationUrl).toContain('state=state-123'); + + const userInfo = await client.exchangeCodeForUserInfo('code-123'); + expect(userInfo).toMatchObject({ + issuer: 'https://idp.example.test', + subject: 'alice-sub', + email: 'alice@example.test', + displayName: 'Alice', + }); + expect(calls.map(call => call.url)).toEqual([ + 'https://idp.example.test/.well-known/openid-configuration', + 'https://idp.example.test/token', + 'https://idp.example.test/userinfo', + ]); + }); +}); diff --git a/backend/src/services/__tests__/enterpriseSchema.test.ts b/backend/src/services/__tests__/enterpriseSchema.test.ts index 10d30619..a0076ea3 100644 --- a/backend/src/services/__tests__/enterpriseSchema.test.ts +++ b/backend/src/services/__tests__/enterpriseSchema.test.ts @@ -101,6 +101,28 @@ describe('enterprise minimal schema', () => { 'secret_version', 'created_at', ])); + expect([...columnNames(db!, 'audit_events')]).toEqual(expect.arrayContaining([ + 'id', + 'tenant_id', + 'workspace_id', + 'actor_user_id', + 'action', + 'resource_type', + 'resource_id', + 'metadata_json', + 'created_at', + ])); + expect([...columnNames(db!, 'sso_sessions')]).toEqual(expect.arrayContaining([ + 'id', + 'tenant_id', + 'workspace_id', + 'user_id', + 'selected_workspace_id', + 'auth_context_json', + 'created_at', + 'expires_at', + 'revoked_at', + ])); }); test('creates owner-guard and replay indexes for high-risk lookup paths', () => { @@ -113,6 +135,10 @@ describe('enterprise minimal schema', () => { expect(indexes.has('idx_agent_events_replay')).toBe(true); expect(indexes.has('idx_agent_events_owner_guard')).toBe(true); expect(indexes.has('idx_provider_snapshots_provider')).toBe(true); + expect(indexes.has('idx_audit_events_tenant_time')).toBe(true); + expect(indexes.has('idx_audit_events_actor')).toBe(true); + expect(indexes.has('idx_sso_sessions_user')).toBe(true); + expect(indexes.has('idx_sso_sessions_expiry')).toBe(true); }); test('is idempotent and records the applied schema version once', () => { @@ -122,7 +148,7 @@ describe('enterprise minimal schema', () => { const rows = db!.prepare( 'SELECT version FROM enterprise_schema_migrations ORDER BY version', ).all(); - expect(rows).toEqual([{ version: 1 }]); + expect(rows).toEqual([{ version: 1 }, { version: 2 }]); }); test('enforces the tenant workspace session run event chain', () => { diff --git a/backend/src/services/enterpriseDb.ts b/backend/src/services/enterpriseDb.ts new file mode 100644 index 00000000..eb02767e --- /dev/null +++ b/backend/src/services/enterpriseDb.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2024-2026 Gracker (Chris) +// This file is part of SmartPerfetto. See LICENSE for details. + +import fs from 'fs'; +import path from 'path'; +import Database from 'better-sqlite3'; +import { applyEnterpriseMinimalSchema } from './enterpriseSchema'; + +export const ENTERPRISE_DB_PATH_ENV = 'SMARTPERFETTO_ENTERPRISE_DB_PATH'; + +export function resolveEnterpriseDbPath(env: NodeJS.ProcessEnv = process.env): string { + const configured = env[ENTERPRISE_DB_PATH_ENV]; + if (configured && configured.trim().length > 0) { + return path.resolve(configured); + } + return path.join(process.cwd(), 'data', 'sessions', 'sessions.db'); +} + +export function openEnterpriseDb(dbPath = resolveEnterpriseDbPath()): Database.Database { + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + db.pragma('busy_timeout = 5000'); + db.pragma('foreign_keys = ON'); + applyEnterpriseMinimalSchema(db); + return db; +} diff --git a/backend/src/services/enterpriseOidcClient.ts b/backend/src/services/enterpriseOidcClient.ts new file mode 100644 index 00000000..765c5cc9 --- /dev/null +++ b/backend/src/services/enterpriseOidcClient.ts @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2024-2026 Gracker (Chris) +// This file is part of SmartPerfetto. See LICENSE for details. + +export interface OidcRuntimeConfig { + issuerUrl: string; + clientId: string; + clientSecret?: string; + redirectUri: string; + scopes: string[]; +} + +interface OidcDiscoveryDocument { + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint?: string; + issuer?: string; +} + +interface OidcTokenResponse { + access_token?: string; + token_type?: string; + expires_in?: number; + id_token?: string; + [key: string]: unknown; +} + +export interface EnterpriseOidcUserInfo { + issuer: string; + subject: string; + email?: string; + displayName?: string; + claims: Record; +} + +export const OIDC_ENV = { + issuerUrl: 'SMARTPERFETTO_OIDC_ISSUER_URL', + clientId: 'SMARTPERFETTO_OIDC_CLIENT_ID', + clientSecret: 'SMARTPERFETTO_OIDC_CLIENT_SECRET', + redirectUri: 'SMARTPERFETTO_OIDC_REDIRECT_URI', + scopes: 'SMARTPERFETTO_OIDC_SCOPES', +} as const; + +function normalizeIssuerUrl(value: string): string { + return value.replace(/\/+$/, ''); +} + +function discoveryUrlForIssuer(issuerUrl: string): string { + const normalized = normalizeIssuerUrl(issuerUrl); + if (normalized.endsWith('/.well-known/openid-configuration')) return normalized; + return `${normalized}/.well-known/openid-configuration`; +} + +export function resolveOidcRuntimeConfig(env: NodeJS.ProcessEnv = process.env): OidcRuntimeConfig | null { + const issuerUrl = env[OIDC_ENV.issuerUrl]?.trim(); + const clientId = env[OIDC_ENV.clientId]?.trim(); + const redirectUri = env[OIDC_ENV.redirectUri]?.trim(); + if (!issuerUrl || !clientId || !redirectUri) return null; + return { + issuerUrl: normalizeIssuerUrl(issuerUrl), + clientId, + clientSecret: env[OIDC_ENV.clientSecret]?.trim() || undefined, + redirectUri, + scopes: (env[OIDC_ENV.scopes] || 'openid email profile') + .split(/[,\s]+/) + .map(scope => scope.trim()) + .filter(Boolean), + }; +} + +export class EnterpriseOidcClient { + private discovery: OidcDiscoveryDocument | null = null; + + constructor( + private readonly config: OidcRuntimeConfig, + private readonly fetchImpl: typeof fetch = fetch, + ) {} + + static fromEnv(env: NodeJS.ProcessEnv = process.env): EnterpriseOidcClient | null { + const config = resolveOidcRuntimeConfig(env); + return config ? new EnterpriseOidcClient(config) : null; + } + + async buildAuthorizationUrl(params: { + state: string; + nonce: string; + }): Promise { + const discovery = await this.getDiscovery(); + const url = new URL(discovery.authorization_endpoint); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', this.config.clientId); + url.searchParams.set('redirect_uri', this.config.redirectUri); + url.searchParams.set('scope', this.config.scopes.join(' ')); + url.searchParams.set('state', params.state); + url.searchParams.set('nonce', params.nonce); + return url.toString(); + } + + async exchangeCodeForUserInfo(code: string): Promise { + const discovery = await this.getDiscovery(); + if (!discovery.userinfo_endpoint) { + throw new Error('OIDC discovery document is missing userinfo_endpoint'); + } + + const body = new URLSearchParams(); + body.set('grant_type', 'authorization_code'); + body.set('code', code); + body.set('redirect_uri', this.config.redirectUri); + body.set('client_id', this.config.clientId); + if (this.config.clientSecret) { + body.set('client_secret', this.config.clientSecret); + } + + const tokenResponse = await this.fetchImpl(discovery.token_endpoint, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }); + if (!tokenResponse.ok) { + throw new Error(`OIDC token exchange failed with status ${tokenResponse.status}`); + } + const token = await tokenResponse.json() as OidcTokenResponse; + if (!token.access_token || typeof token.access_token !== 'string') { + throw new Error('OIDC token response did not include access_token'); + } + + const userInfoResponse = await this.fetchImpl(discovery.userinfo_endpoint, { + headers: { + authorization: `Bearer ${token.access_token}`, + }, + }); + if (!userInfoResponse.ok) { + throw new Error(`OIDC userinfo request failed with status ${userInfoResponse.status}`); + } + const claims = await userInfoResponse.json() as Record; + const subject = typeof claims.sub === 'string' ? claims.sub.trim() : ''; + if (!subject) { + throw new Error('OIDC userinfo response did not include sub'); + } + + return { + issuer: discovery.issuer || this.config.issuerUrl, + subject, + email: typeof claims.email === 'string' ? claims.email : undefined, + displayName: typeof claims.name === 'string' ? claims.name : undefined, + claims, + }; + } + + private async getDiscovery(): Promise { + if (this.discovery) return this.discovery; + const response = await this.fetchImpl(discoveryUrlForIssuer(this.config.issuerUrl)); + if (!response.ok) { + throw new Error(`OIDC discovery failed with status ${response.status}`); + } + const parsed = await response.json() as OidcDiscoveryDocument; + if (!parsed.authorization_endpoint || !parsed.token_endpoint) { + throw new Error('OIDC discovery document is missing required endpoints'); + } + this.discovery = parsed; + return parsed; + } +} diff --git a/backend/src/services/enterpriseSchema.ts b/backend/src/services/enterpriseSchema.ts index a7e6aeb2..1e1ef36d 100644 --- a/backend/src/services/enterpriseSchema.ts +++ b/backend/src/services/enterpriseSchema.ts @@ -19,6 +19,8 @@ export const ENTERPRISE_MINIMAL_SCHEMA_TABLES = [ 'analysis_runs', 'agent_events', 'provider_snapshots', + 'audit_events', + 'sso_sessions', ] as const; export type EnterpriseMinimalSchemaTable = typeof ENTERPRISE_MINIMAL_SCHEMA_TABLES[number]; @@ -187,6 +189,53 @@ const MIGRATIONS: MigrationStep[] = [ `); }, }, + { + version: 2, + up: (db) => { + db.exec(` + CREATE TABLE IF NOT EXISTS audit_events ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + workspace_id TEXT, + actor_user_id TEXT, + action TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT, + metadata_json TEXT, + created_at INTEGER NOT NULL, + FOREIGN KEY (tenant_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE SET NULL, + FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_audit_events_tenant_time + ON audit_events(tenant_id, created_at); + CREATE INDEX IF NOT EXISTS idx_audit_events_actor + ON audit_events(tenant_id, actor_user_id, created_at); + CREATE INDEX IF NOT EXISTS idx_audit_events_resource + ON audit_events(tenant_id, resource_type, resource_id, created_at); + + CREATE TABLE IF NOT EXISTS sso_sessions ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + workspace_id TEXT, + user_id TEXT NOT NULL, + selected_workspace_id TEXT, + auth_context_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + revoked_at INTEGER, + FOREIGN KEY (tenant_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE SET NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (selected_workspace_id) REFERENCES workspaces(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_sso_sessions_user + ON sso_sessions(tenant_id, user_id, expires_at); + CREATE INDEX IF NOT EXISTS idx_sso_sessions_expiry + ON sso_sessions(expires_at, revoked_at); + `); + }, + }, ]; export function applyEnterpriseMinimalSchema(db: Database.Database): void { diff --git a/backend/src/services/enterpriseSsoService.ts b/backend/src/services/enterpriseSsoService.ts new file mode 100644 index 00000000..0cf82ab3 --- /dev/null +++ b/backend/src/services/enterpriseSsoService.ts @@ -0,0 +1,708 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2024-2026 Gracker (Chris) +// This file is part of SmartPerfetto. See LICENSE for details. + +import crypto from 'crypto'; +import type { Request } from 'express'; +import type Database from 'better-sqlite3'; +import { resolveFeatureConfig } from '../config'; +import type { RequestContextAuthType } from '../middleware/auth'; +import { openEnterpriseDb } from './enterpriseDb'; +import type { EnterpriseOidcUserInfo } from './enterpriseOidcClient'; + +const SESSION_COOKIE_NAME = 'sp_sso_session'; +const STATE_COOKIE_NAME = 'sp_oidc_state'; +const SESSION_TOKEN_PREFIX = 'sp_sso_'; +const DEFAULT_SESSION_TTL_MS = 8 * 60 * 60 * 1000; + +interface WorkspaceMembership { + workspaceId: string; + name: string; + role: string; +} + +interface StoredSsoSession { + id: string; + tenantId: string; + workspaceId?: string; + userId: string; + selectedWorkspaceId?: string; + authContext: { + authType: RequestContextAuthType; + roles: string[]; + scopes: string[]; + email?: string; + displayName?: string; + }; + createdAt: number; + expiresAt: number; + revokedAt?: number; +} + +export interface OidcStatePayload { + state: string; + nonce: string; + returnTo?: string; + createdAt: number; +} + +export type OnboardingStatus = + | 'ready' + | 'needs_workspace_selection' + | 'needs_tenant_join' + | 'no_workspace_membership'; + +export interface OnboardingResult { + status: OnboardingStatus; + accessToken?: string; + sessionId?: string; + tenantId?: string; + userId?: string; + workspaceId?: string; + expiresAt?: number; + workspaces?: WorkspaceMembership[]; + reason?: string; +} + +export interface RequestSsoIdentity { + userId: string; + email: string; + subscription: string; + authType: RequestContextAuthType; + tenantId: string; + workspaceId: string; + roles: string[]; + scopes: string[]; +} + +interface AuditInput { + tenantId: string; + workspaceId?: string; + actorUserId?: string; + action: string; + resourceType: string; + resourceId?: string; + metadata?: Record; +} + +interface AuditRow { + action: string; + resource_type: string; + resource_id: string | null; + tenant_id: string; + workspace_id: string | null; + actor_user_id: string | null; +} + +function nowMs(): number { + return Date.now(); +} + +function sanitizeId(value: unknown): string { + if (typeof value !== 'string') return ''; + return value.trim().replace(/[^a-zA-Z0-9._:-]/g, '').slice(0, 128); +} + +function safeString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed.replace(/[\r\n]/g, '').slice(0, 320) : undefined; +} + +function hmac(value: string, secret: string): string { + return crypto.createHmac('sha256', secret).update(value).digest('base64url'); +} + +function parseCookieHeader(header: string | undefined): Map { + const cookies = new Map(); + if (!header) return cookies; + for (const part of header.split(';')) { + const index = part.indexOf('='); + if (index <= 0) continue; + const name = part.slice(0, index).trim(); + const value = part.slice(index + 1).trim(); + if (name) cookies.set(name, decodeURIComponent(value)); + } + return cookies; +} + +function bearerTokenFromRequest(req: Request): string | undefined { + const authHeader = req.headers.authorization; + if (typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) { + return authHeader.slice('Bearer '.length).trim(); + } + return undefined; +} + +function claimString(userInfo: EnterpriseOidcUserInfo, keys: string[]): string | undefined { + for (const key of keys) { + const value = userInfo.claims[key]; + const sanitized = safeString(value); + if (sanitized) return sanitized; + } + return undefined; +} + +function claimValue(userInfo: EnterpriseOidcUserInfo, keys: string[]): unknown { + for (const key of keys) { + const value = userInfo.claims[key]; + if (value !== undefined && value !== null) return value; + } + return undefined; +} + +function parseDomainTenantMap(value: string | undefined): Map { + const map = new Map(); + if (!value) return map; + const trimmed = value.trim(); + if (!trimmed) return map; + + try { + const parsed = JSON.parse(trimmed) as Record; + for (const [domain, tenantId] of Object.entries(parsed)) { + const sanitizedTenant = sanitizeId(tenantId); + if (domain.trim() && sanitizedTenant) { + map.set(domain.trim().toLowerCase(), sanitizedTenant); + } + } + return map; + } catch { + // Fall through to comma-separated "example.com=tenant-a" parsing. + } + + for (const entry of trimmed.split(',')) { + const [domain, tenantId] = entry.split('=').map(part => part?.trim()); + const sanitizedTenant = sanitizeId(tenantId); + if (domain && sanitizedTenant) { + map.set(domain.toLowerCase(), sanitizedTenant); + } + } + return map; +} + +function scopesForRole(role: string): string[] { + if (role === 'org_admin' || role === 'workspace_admin') return ['*']; + if (role === 'viewer') return ['trace:read', 'report:read']; + return ['trace:read', 'trace:write', 'agent:run', 'report:read']; +} + +function normalizeRoles(input: unknown, fallbackRole = 'analyst'): string[] { + const raw = Array.isArray(input) + ? input + : typeof input === 'string' + ? input.split(',') + : []; + const roles = raw + .map(role => sanitizeId(role)) + .filter(Boolean); + return roles.length > 0 ? [...new Set(roles)] : [fallbackRole]; +} + +function normalizeReturnTo(value: unknown): string | undefined { + const candidate = safeString(value); + if (!candidate || !candidate.startsWith('/')) return undefined; + if (candidate.startsWith('//')) return undefined; + return candidate; +} + +export class EnterpriseSsoService { + private static instance: EnterpriseSsoService | undefined; + + constructor(private readonly db: Database.Database = openEnterpriseDb()) {} + + static getInstance(): EnterpriseSsoService { + if (!EnterpriseSsoService.instance) { + EnterpriseSsoService.instance = new EnterpriseSsoService(); + } + return EnterpriseSsoService.instance; + } + + static resetForTests(): void { + EnterpriseSsoService.instance = undefined; + } + + static setInstanceForTests(service: EnterpriseSsoService): void { + EnterpriseSsoService.instance = service; + } + + get sessionCookieName(): string { + return SESSION_COOKIE_NAME; + } + + get stateCookieName(): string { + return STATE_COOKIE_NAME; + } + + createStatePayload(returnTo?: string): OidcStatePayload { + return { + state: crypto.randomBytes(24).toString('base64url'), + nonce: crypto.randomBytes(24).toString('base64url'), + returnTo: normalizeReturnTo(returnTo), + createdAt: nowMs(), + }; + } + + signStatePayload(payload: OidcStatePayload): string { + return this.signJson(payload); + } + + verifyStatePayload(signedValue: string | undefined): OidcStatePayload | null { + if (!signedValue) return null; + const parsed = this.verifyJson(signedValue); + if (!parsed || !parsed.state || !parsed.nonce) return null; + if (nowMs() - parsed.createdAt > 10 * 60 * 1000) return null; + return parsed; + } + + createSessionCookieValue(accessToken: string): string { + return accessToken; + } + + resolveRequestIdentityFromRequest(req: Request): RequestSsoIdentity | null { + const token = this.extractSessionToken(req); + if (!token) return null; + const session = this.getSessionFromToken(token); + if (!session || !session.selectedWorkspaceId) return null; + return { + userId: session.userId, + email: session.authContext.email || '', + subscription: 'enterprise', + authType: 'sso', + tenantId: session.tenantId, + workspaceId: session.selectedWorkspaceId, + roles: session.authContext.roles, + scopes: session.authContext.scopes, + }; + } + + hasSessionCredential(req: Request): boolean { + return Boolean(this.extractSessionToken(req)); + } + + getOnboardingSessionFromRequest(req: Request): StoredSsoSession | null { + const token = this.extractSessionToken(req); + return token ? this.getSessionFromToken(token) : null; + } + + completeOidcLogin(userInfo: EnterpriseOidcUserInfo): OnboardingResult { + const tenantId = this.resolveTenantId(userInfo); + if (!tenantId) { + return { + status: 'needs_tenant_join', + reason: 'No tenant claim, domain mapping, or default tenant matched this SSO identity', + }; + } + + const createdUser = this.upsertTenantAndUser(tenantId, userInfo); + const userId = this.userIdFor(userInfo); + if (createdUser) { + this.recordAudit({ + tenantId, + actorUserId: userId, + action: 'user_created', + resourceType: 'user', + resourceId: userId, + metadata: { source: 'oidc', issuer: userInfo.issuer }, + }); + } + + const memberships = this.listMemberships(tenantId, userId); + const selectedWorkspace = this.resolveSelectedWorkspace(userInfo, memberships); + const roleClaim = claimValue(userInfo, ['roles', 'groups']); + const roles = selectedWorkspace + ? normalizeRoles(roleClaim, selectedWorkspace.role) + : normalizeRoles(roleClaim); + const scopes = [...new Set(roles.flatMap(scopesForRole))]; + const session = this.createSsoSession({ + tenantId, + userId, + selectedWorkspaceId: selectedWorkspace?.workspaceId, + roles, + scopes, + email: userInfo.email, + displayName: userInfo.displayName, + }); + + this.recordAudit({ + tenantId, + workspaceId: selectedWorkspace?.workspaceId, + actorUserId: userId, + action: 'sso_login', + resourceType: 'sso_session', + resourceId: session.sessionId, + metadata: { issuer: userInfo.issuer, subjectHash: this.subjectHash(userInfo) }, + }); + + if (!selectedWorkspace && memberships.length === 0) { + return { + status: 'no_workspace_membership', + accessToken: session.accessToken, + sessionId: session.sessionId, + tenantId, + userId, + expiresAt: session.expiresAt, + workspaces: [], + }; + } + if (!selectedWorkspace) { + return { + status: 'needs_workspace_selection', + accessToken: session.accessToken, + sessionId: session.sessionId, + tenantId, + userId, + expiresAt: session.expiresAt, + workspaces: memberships, + }; + } + + this.auditWorkspaceReady(tenantId, userId, selectedWorkspace.workspaceId, session.sessionId, true); + return { + status: 'ready', + accessToken: session.accessToken, + sessionId: session.sessionId, + tenantId, + userId, + workspaceId: selectedWorkspace.workspaceId, + expiresAt: session.expiresAt, + workspaces: memberships, + }; + } + + selectWorkspace(accessToken: string, workspaceIdInput: string): OnboardingResult { + const workspaceId = sanitizeId(workspaceIdInput); + const session = this.getSessionFromToken(accessToken); + if (!session) { + return { status: 'needs_tenant_join', reason: 'SSO session is missing or expired' }; + } + const membership = this.listMemberships(session.tenantId, session.userId) + .find(item => item.workspaceId === workspaceId); + if (!membership) { + return { + status: 'needs_workspace_selection', + accessToken, + sessionId: session.id, + tenantId: session.tenantId, + userId: session.userId, + expiresAt: session.expiresAt, + workspaces: this.listMemberships(session.tenantId, session.userId), + reason: 'Selected workspace is not available to this user', + }; + } + + const roles = [membership.role]; + const scopes = scopesForRole(membership.role); + this.db.prepare(` + UPDATE sso_sessions + SET selected_workspace_id = ?, workspace_id = ?, auth_context_json = ? + WHERE id = ? + `).run( + workspaceId, + workspaceId, + JSON.stringify({ ...session.authContext, roles, scopes }), + session.id, + ); + this.auditWorkspaceReady(session.tenantId, session.userId, workspaceId, session.id, false); + return { + status: 'ready', + accessToken, + sessionId: session.id, + tenantId: session.tenantId, + userId: session.userId, + workspaceId, + expiresAt: session.expiresAt, + workspaces: this.listMemberships(session.tenantId, session.userId), + }; + } + + revokeSession(accessToken: string): boolean { + const sessionId = this.sessionIdFromToken(accessToken); + if (!sessionId) return false; + const result = this.db.prepare(` + UPDATE sso_sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL + `).run(nowMs(), sessionId); + return result.changes > 0; + } + + listAuditEvents(): AuditRow[] { + return this.db.prepare(` + SELECT action, resource_type, resource_id, tenant_id, workspace_id, actor_user_id + FROM audit_events + ORDER BY created_at ASC + `).all(); + } + + private extractSessionToken(req: Request): string | undefined { + const bearer = bearerTokenFromRequest(req); + if (bearer?.startsWith(SESSION_TOKEN_PREFIX)) return bearer; + const cookieToken = parseCookieHeader(req.headers.cookie).get(SESSION_COOKIE_NAME); + return cookieToken?.startsWith(SESSION_TOKEN_PREFIX) ? cookieToken : undefined; + } + + private createSsoSession(input: { + tenantId: string; + userId: string; + selectedWorkspaceId?: string; + roles: string[]; + scopes: string[]; + email?: string; + displayName?: string; + }): { sessionId: string; accessToken: string; expiresAt: number } { + const sessionId = crypto.randomUUID(); + const createdAt = nowMs(); + const expiresAt = createdAt + this.sessionTtlMs(); + this.db.prepare(` + INSERT INTO sso_sessions + (id, tenant_id, workspace_id, user_id, selected_workspace_id, auth_context_json, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run( + sessionId, + input.tenantId, + input.selectedWorkspaceId ?? null, + input.userId, + input.selectedWorkspaceId ?? null, + JSON.stringify({ + authType: 'sso', + roles: input.roles, + scopes: input.scopes, + email: input.email, + displayName: input.displayName, + }), + createdAt, + expiresAt, + ); + return { + sessionId, + accessToken: this.signSessionId(sessionId), + expiresAt, + }; + } + + private getSessionFromToken(accessToken: string): StoredSsoSession | null { + const sessionId = this.sessionIdFromToken(accessToken); + if (!sessionId) return null; + const row = this.db.prepare(` + SELECT * FROM sso_sessions WHERE id = ? + `).get(sessionId); + if (!row || row.revoked_at || row.expires_at <= nowMs()) return null; + return { + id: row.id, + tenantId: row.tenant_id, + workspaceId: row.workspace_id ?? undefined, + userId: row.user_id, + selectedWorkspaceId: row.selected_workspace_id ?? undefined, + authContext: JSON.parse(row.auth_context_json), + createdAt: row.created_at, + expiresAt: row.expires_at, + revokedAt: row.revoked_at ?? undefined, + }; + } + + private sessionIdFromToken(accessToken: string): string | null { + if (!accessToken.startsWith(SESSION_TOKEN_PREFIX)) return null; + const signed = accessToken.slice(SESSION_TOKEN_PREFIX.length); + const separator = signed.lastIndexOf('.'); + if (separator <= 0) return null; + const sessionId = signed.slice(0, separator); + const signature = signed.slice(separator + 1); + const expected = hmac(sessionId, this.cookieSecret()); + if (signature.length !== expected.length) return null; + if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) return null; + return sessionId; + } + + private signSessionId(sessionId: string): string { + return `${SESSION_TOKEN_PREFIX}${sessionId}.${hmac(sessionId, this.cookieSecret())}`; + } + + private signJson(payload: object): string { + const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url'); + return `${encoded}.${hmac(encoded, this.cookieSecret())}`; + } + + private verifyJson(signedValue: string): T | null { + const separator = signedValue.lastIndexOf('.'); + if (separator <= 0) return null; + const encoded = signedValue.slice(0, separator); + const signature = signedValue.slice(separator + 1); + const expected = hmac(encoded, this.cookieSecret()); + if (signature.length !== expected.length) return null; + if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) return null; + try { + return JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as T; + } catch { + return null; + } + } + + private cookieSecret(): string { + const configured = process.env.SMARTPERFETTO_SSO_COOKIE_SECRET + || process.env.SMARTPERFETTO_API_KEY; + if (configured && configured.length >= 16) return configured; + if (resolveFeatureConfig(process.env).enterprise) { + throw new Error('SMARTPERFETTO_SSO_COOKIE_SECRET must be set for enterprise SSO'); + } + return 'dev-only-smartperfetto-sso-cookie-secret'; + } + + private sessionTtlMs(): number { + const parsed = Number.parseInt(process.env.SMARTPERFETTO_SSO_SESSION_TTL_MS || '', 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_SESSION_TTL_MS; + } + + private resolveTenantId(userInfo: EnterpriseOidcUserInfo): string | null { + const claimTenant = sanitizeId(claimString(userInfo, [ + 'smartperfetto_tenant_id', + 'tenant_id', + 'https://smartperfetto.dev/tenant_id', + ])); + if (claimTenant) return claimTenant; + + const email = userInfo.email || claimString(userInfo, ['email']); + const domain = email?.split('@')[1]?.toLowerCase(); + if (domain) { + const mappedTenant = parseDomainTenantMap(process.env.SMARTPERFETTO_OIDC_EMAIL_DOMAIN_MAP).get(domain); + if (mappedTenant) return mappedTenant; + } + + const defaultTenant = sanitizeId(process.env.SMARTPERFETTO_OIDC_DEFAULT_TENANT_ID); + return defaultTenant || null; + } + + private userIdFor(userInfo: EnterpriseOidcUserInfo): string { + const hash = crypto + .createHash('sha256') + .update(`${userInfo.issuer}|${userInfo.subject}`) + .digest('hex') + .slice(0, 20); + return `sso-${hash}`; + } + + private subjectHash(userInfo: EnterpriseOidcUserInfo): string { + return crypto + .createHash('sha256') + .update(`${userInfo.issuer}|${userInfo.subject}`) + .digest('hex') + .slice(0, 12); + } + + private upsertTenantAndUser(tenantId: string, userInfo: EnterpriseOidcUserInfo): boolean { + const existing = this.db.prepare('SELECT id FROM users WHERE id = ?').get(this.userIdFor(userInfo)); + const now = nowMs(); + this.db.prepare(` + INSERT OR IGNORE INTO organizations (id, name, status, plan, created_at, updated_at) + VALUES (?, ?, 'active', 'enterprise', ?, ?) + `).run(tenantId, tenantId, now, now); + this.db.prepare(` + INSERT INTO users (id, tenant_id, email, display_name, idp_subject, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + email = excluded.email, + display_name = excluded.display_name, + idp_subject = excluded.idp_subject, + updated_at = excluded.updated_at + `).run( + this.userIdFor(userInfo), + tenantId, + userInfo.email || `${this.userIdFor(userInfo)}@sso.local`, + userInfo.displayName || userInfo.email || this.userIdFor(userInfo), + `${userInfo.issuer}|${userInfo.subject}`, + now, + now, + ); + return !existing; + } + + private listMemberships(tenantId: string, userId: string): WorkspaceMembership[] { + return this.db.prepare(` + SELECT m.workspace_id, w.name, m.role + FROM memberships m + JOIN workspaces w ON w.id = m.workspace_id AND w.tenant_id = m.tenant_id + WHERE m.tenant_id = ? AND m.user_id = ? + ORDER BY w.name ASC + `).all(tenantId, userId).map(row => ({ + workspaceId: row.workspace_id, + name: row.name, + role: row.role, + })); + } + + private resolveSelectedWorkspace( + userInfo: EnterpriseOidcUserInfo, + memberships: WorkspaceMembership[], + ): WorkspaceMembership | null { + const claimWorkspace = sanitizeId(claimString(userInfo, [ + 'smartperfetto_workspace_id', + 'workspace_id', + 'https://smartperfetto.dev/workspace_id', + ])); + if (claimWorkspace) { + return memberships.find(item => item.workspaceId === claimWorkspace) || null; + } + return memberships.length === 1 ? memberships[0] : null; + } + + private auditWorkspaceReady( + tenantId: string, + userId: string, + workspaceId: string, + sessionId: string, + automatic: boolean, + ): void { + this.recordAudit({ + tenantId, + workspaceId, + actorUserId: userId, + action: 'workspace_selected', + resourceType: 'workspace', + resourceId: workspaceId, + metadata: { automatic }, + }); + this.recordAudit({ + tenantId, + workspaceId, + actorUserId: userId, + action: 'provider_default_resolved', + resourceType: 'provider', + resourceId: 'default', + metadata: { sessionId }, + }); + } + + private recordAudit(input: AuditInput): void { + this.db.prepare(` + INSERT INTO audit_events + (id, tenant_id, workspace_id, actor_user_id, action, resource_type, resource_id, metadata_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + crypto.randomUUID(), + input.tenantId, + input.workspaceId ?? null, + input.actorUserId ?? null, + input.action, + input.resourceType, + input.resourceId ?? null, + input.metadata ? JSON.stringify(input.metadata) : null, + nowMs(), + ); + } +} + +export const enterpriseSsoCookies = { + session: SESSION_COOKIE_NAME, + state: STATE_COOKIE_NAME, +}; diff --git a/docs/features/enterprise-multi-tenant/README.md b/docs/features/enterprise-multi-tenant/README.md index d10c3ebd..d3c34abc 100644 --- a/docs/features/enterprise-multi-tenant/README.md +++ b/docs/features/enterprise-multi-tenant/README.md @@ -30,7 +30,7 @@ ### 0.2 主线 A:身份与权限(§18) - [x] 2.1 `RequestContext` 接口与解析 middleware(SSO / API key / dev 三模式) -- [ ] 2.2 OIDC SSO 集成 + Onboarding flow(§15 全流程,含 audit) +- [x] 2.2 OIDC SSO 集成 + Onboarding flow(§15 全流程,含 audit) - [ ] 2.3 API key 管理(创建 / 撤销 / scope / 过期 / 审计) - [ ] 2.4 Membership / Role / RBAC 权限矩阵(§8.2)+ owner guard 全 route 覆盖 - [ ] 2.5 旧 API 兼容 wrapper:返回 `Deprecation: true` + `Sunset` header;统一走 RequestContext