Skip to content

Commit 86e6c8d

Browse files
feat(sso): implement organization-level OIDC configuration (#2736)
* feat(sso): implement organization-level OIDC configuration and enforcement - Add database migration and storage layer for org SSO config and sessions - Implement OIDC authorization code flow with PKCE in org-sso routes - Add API endpoints for OIDC flow, config management, and status checks - Create frontend SSO settings page for org admins with config form - Add React Query hooks for SSO state management - Integrate SSO enforcement in shell-layout to block access without valid session - Add SsoRequiredScreen component to guide users through SSO login - Update storage types and context factory to include new storage classes - Update test mocks with new storage fields Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fix(sso): fix hooks ordering violation and org membership authorization Move useOrgSsoStatus above conditional early returns in ShellLayoutContent to comply with React Rules of Hooks. Replace query-string orgId with ctx.organization?.id in /status and /authorize endpoints to prevent unauthorized users from probing SSO config of arbitrary organizations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(sso): mount routes after context middleware and fix redirect path - Move org-sso route mount after meshContext injection middleware to ensure context is available when handlers execute - Fix post-SSO redirect to use org root instead of removed org-admin path - Remove debug logging from SSO enforcement middleware Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(sso): add SSRF protection for OIDC discovery and token endpoints Validate all outbound URLs in the OIDC flow against SSRF attacks: - Enforce HTTPS in production - Block private/link-local IP ranges (169.254.x.x, 10.x.x.x, etc.) - Validate discovery document endpoints (token, jwks, authorization) - Allow HTTP + loopback in development for local OIDC providers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(sso): block localhost in SSRF validation for production Add localhost hostname pattern to privatePatterns so it is blocked in production. The dev-mode loopback allowance already handles it for local testing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 286387b commit 86e6c8d

21 files changed

Lines changed: 1560 additions & 1 deletion
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { type Kysely, sql } from "kysely";
2+
3+
export async function up(db: Kysely<unknown>): Promise<void> {
4+
// SSO provider configuration per organization
5+
await db.schema
6+
.createTable("org_sso_config")
7+
.addColumn("id", "text", (col) => col.primaryKey())
8+
.addColumn("organization_id", "text", (col) =>
9+
col.notNull().references("organization.id").onDelete("cascade"),
10+
)
11+
.addColumn("issuer", "text", (col) => col.notNull())
12+
.addColumn("client_id", "text", (col) => col.notNull())
13+
.addColumn("client_secret", "text", (col) => col.notNull()) // encrypted
14+
.addColumn("discovery_endpoint", "text")
15+
.addColumn("scopes", "text", (col) =>
16+
col.notNull().defaultTo('["openid","email","profile"]'),
17+
)
18+
.addColumn("domain", "text", (col) => col.notNull())
19+
.addColumn("enforced", "integer", (col) => col.notNull().defaultTo(0))
20+
.addColumn("created_at", "text", (col) =>
21+
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
22+
)
23+
.addColumn("updated_at", "text", (col) =>
24+
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
25+
)
26+
.execute();
27+
28+
// One SSO config per organization
29+
await db.schema
30+
.createIndex("idx_org_sso_config_org_id")
31+
.unique()
32+
.on("org_sso_config")
33+
.column("organization_id")
34+
.execute();
35+
36+
// Lookup by email domain
37+
await db.schema
38+
.createIndex("idx_org_sso_config_domain")
39+
.on("org_sso_config")
40+
.column("domain")
41+
.execute();
42+
43+
// Tracks per-user SSO authentication per organization
44+
await db.schema
45+
.createTable("org_sso_sessions")
46+
.addColumn("id", "text", (col) => col.primaryKey())
47+
.addColumn("user_id", "text", (col) =>
48+
col.notNull().references("user.id").onDelete("cascade"),
49+
)
50+
.addColumn("organization_id", "text", (col) =>
51+
col.notNull().references("organization.id").onDelete("cascade"),
52+
)
53+
.addColumn("authenticated_at", "text", (col) => col.notNull())
54+
.addColumn("expires_at", "text", (col) => col.notNull())
55+
.addColumn("created_at", "text", (col) =>
56+
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
57+
)
58+
.execute();
59+
60+
// One active session per user per org
61+
await db.schema
62+
.createIndex("idx_org_sso_sessions_user_org")
63+
.unique()
64+
.on("org_sso_sessions")
65+
.columns(["user_id", "organization_id"])
66+
.execute();
67+
}
68+
69+
export async function down(db: Kysely<unknown>): Promise<void> {
70+
await db.schema.dropTable("org_sso_sessions").execute();
71+
await db.schema.dropTable("org_sso_config").execute();
72+
}

apps/mesh/migrations/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import * as migration047addnextrunat from "./047-add-next-run-at.ts";
4949
import * as migration048mergeprojectsagents from "./048-merge-projects-agents.ts";
5050
import * as migration049removeorgadminprojects from "./049-remove-org-admin-projects.ts";
5151
import * as migration050durableagentruns from "./050-durable-agent-runs.ts";
52+
import * as migration051orgsso from "./051-org-sso.ts";
5253

5354
/**
5455
* Core migrations for the Mesh application.
@@ -112,6 +113,7 @@ const migrations: Record<string, Migration> = {
112113
"048-merge-projects-agents": migration048mergeprojectsagents,
113114
"049-remove-org-admin-projects": migration049removeorgadminprojects,
114115
"050-durable-agent-runs": migration050durableagentruns,
116+
"051-org-sso": migration051orgsso,
115117
};
116118

117119
export default migrations;

apps/mesh/src/api/app.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
tracingMiddleware,
3232
} from "../observability";
3333
import authRoutes from "./routes/auth";
34+
import orgSsoRoutes from "./routes/org-sso";
3435
import { createDecopilotRoutes } from "./routes/decopilot";
3536
import downstreamTokenRoutes from "./routes/downstream-token";
3637
import decoSitesRoutes from "./routes/deco-sites";
@@ -988,6 +989,53 @@ export async function createApp(options: CreateAppOptions = {}) {
988989
return next();
989990
});
990991

992+
// ============================================================================
993+
// Server-side SSO Enforcement Middleware
994+
// ============================================================================
995+
// When an org has SSO enforcement enabled, block API requests from users
996+
// who haven't completed the SSO flow. Exempt paths: SSO routes themselves,
997+
// auth routes, and non-org-scoped endpoints.
998+
app.use("*", async (c, next) => {
999+
const path = c.req.path;
1000+
// Skip SSO enforcement for SSO routes, auth routes, and public endpoints
1001+
if (
1002+
path.startsWith("/api/org-sso/") ||
1003+
path.startsWith("/api/auth/") ||
1004+
path.startsWith("/api/tools/management") ||
1005+
path.startsWith("/oauth-proxy/")
1006+
) {
1007+
return next();
1008+
}
1009+
1010+
const ctx = c.get("meshContext") as MeshContext | undefined;
1011+
if (!ctx?.organization?.id || !ctx?.auth?.user?.id) {
1012+
return next();
1013+
}
1014+
1015+
const ssoConfig = await ctx.storage.orgSsoConfig.getByOrgId(
1016+
ctx.organization.id,
1017+
);
1018+
if (!ssoConfig?.enforced) {
1019+
return next();
1020+
}
1021+
1022+
const hasValidSession = await ctx.storage.orgSsoSessions.isValid(
1023+
ctx.auth.user.id,
1024+
ctx.organization.id,
1025+
);
1026+
if (!hasValidSession) {
1027+
return c.json(
1028+
{ error: "SSO authentication required for this organization" },
1029+
403,
1030+
);
1031+
}
1032+
1033+
return next();
1034+
});
1035+
1036+
// Organization-level SSO routes (must be after context middleware)
1037+
app.route("/api/org-sso", orgSsoRoutes);
1038+
9911039
// Get all management tools (for OAuth consent UI)
9921040
app.get("/api/tools/management", (c) => {
9931041
return c.json({

0 commit comments

Comments
 (0)