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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
},
});
Expand All @@ -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);
Expand Down
35 changes: 35 additions & 0 deletions backend/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
206 changes: 206 additions & 0 deletions backend/src/routes/__tests__/enterpriseAuthRoutes.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading