Skip to content

Commit 6bc31eb

Browse files
committed
feat(sso): SAML/OIDC single sign-on
Vendor-neutral plugin contract plus the host wiring that consumes it. With no SSO plugin installed, everything degrades to a no-op fallback, so OSS deployments are unaffected. - Plugin contract (@trigger.dev/plugins) + lazy loader/fallback in internal-packages/sso: status, portal-link, enforce/JIT config, route-decision, begin/complete authorization, identity resolution, JIT evaluation, and periodic session validation. All methods return neverthrow Results; the fallback is fail-open. - Login: 'Sign in with SSO' entry + dedicated /login/sso flow and /auth/sso(.callback) routes, plus auto-discovery from magic-link/OAuth. - Org settings -> SSO page: plan-tier upsell, connection status, verified-domain list, enforcement + JIT provisioning + default-role configuration, and an admin-portal link dialog. - AuthUser carries an optional signed 'sso' marker; SSO-established sessions are periodically re-validated against the identity provider on a single-flight, throttled, fail-open basis and logged out only on an explicit invalid result. - SSO_ENABLED gate (default off) so the feature ships dark until its backing plugin is available; SSO_SESSION_REVALIDATION_INTERVAL_SECONDS controls the cadence.
1 parent 93b4715 commit 6bc31eb

48 files changed

Lines changed: 3127 additions & 106 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/sso-plugin-contract.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/plugins": patch
3+
---
4+
5+
Add the SSO plugin contract (`SsoController`, `SsoPlugin`, domain types, error unions). Vendor-neutral surface for self-service SSO setup, login routing, and JIT provisioning — the cloud implementation lives outside the package; OSS deployments get a no-op fallback that returns `no_sso` from `decideRouteForEmail`.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Add `POST /webhooks/v1/accounts`: a thin passthrough that verifies inbound
7+
webhooks via the SSO plugin and enqueues them on a dedicated worker. No-op
8+
(404) when no plugin is installed.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Wire the SSO plugin loader (`@trigger.dev/sso`) into the webapp: SSO auth
7+
method, `hasSso` flag, `SsoStrategy`, and contributor fallback env vars.
8+
No-op (`no_sso`) without the plugin.

apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
ChartBarIcon,
44
Cog8ToothIcon,
55
CreditCardIcon,
6+
LinkIcon,
67
LockClosedIcon,
78
ShieldCheckIcon,
89
UserGroupIcon,
@@ -18,6 +19,7 @@ import {
1819
organizationRolesPath,
1920
organizationSettingsPath,
2021
organizationSlackIntegrationPath,
22+
organizationSsoPath,
2123
organizationTeamPath,
2224
organizationVercelIntegrationPath,
2325
rootPath,
@@ -48,10 +50,12 @@ export function OrganizationSettingsSideMenu({
4850
organization,
4951
buildInfo,
5052
isUsingPlugin,
53+
isSsoUsingPlugin,
5154
}: {
5255
organization: MatchedOrganization;
5356
buildInfo: BuildInfo;
5457
isUsingPlugin: boolean;
58+
isSsoUsingPlugin: boolean;
5559
}) {
5660
const { isManagedCloud } = useFeatures();
5761
const featureFlags = useFeatureFlags();
@@ -117,7 +121,7 @@ export function OrganizationSettingsSideMenu({
117121
{featureFlags.hasPrivateConnections && (
118122
<SideMenuItem
119123
name="Private Connections"
120-
icon={LockClosedIcon}
124+
icon={LinkIcon}
121125
activeIconColor="text-purple-500"
122126
inactiveIconColor="text-purple-500"
123127
to={v3PrivateConnectionsPath(organization)}
@@ -142,6 +146,21 @@ export function OrganizationSettingsSideMenu({
142146
data-action="roles"
143147
/>
144148
)}
149+
{isManagedCloud && isSsoUsingPlugin && (
150+
<SideMenuItem
151+
name="SSO"
152+
icon={LockClosedIcon}
153+
activeIconColor="text-indigo-400"
154+
inactiveIconColor="text-indigo-400"
155+
to={organizationSsoPath(organization)}
156+
data-action="sso"
157+
badge={
158+
currentPlan?.v3Subscription?.plan?.code === "enterprise" ? undefined : (
159+
<Badge variant="extra-small">Enterprise</Badge>
160+
)
161+
}
162+
/>
163+
)}
145164
<SideMenuItem
146165
name="Settings"
147166
icon={Cog8ToothIcon}

apps/webapp/app/env.server.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1842,6 +1842,32 @@ const EnvironmentSchema = z
18421842

18431843
// Force RBAC to not use the plugin
18441844
RBAC_FORCE_FALLBACK: BoolEnv.default(false),
1845+
1846+
// Force SSO to not use the plugin (contributors without the cloud
1847+
// plugin installed can opt in to a clean OSS-only experience).
1848+
SSO_FORCE_FALLBACK: BoolEnv.default(false),
1849+
// Emit a console.log when the SSO fallback is selected because no
1850+
// plugin is installed. Default off so OSS deployments stay quiet.
1851+
SSO_LOG_FALLBACK: BoolEnv.default(false),
1852+
// Master deploy gate for the whole SSO feature. Default OFF so the
1853+
// image can ship dark and be flipped on only once the SSO plugin's
1854+
// backing services are available. When false, the SSO controller is
1855+
// forced to the OSS fallback — login link hidden, SSO login disabled,
1856+
// settings inert, and session re-validation skipped.
1857+
SSO_ENABLED: BoolEnv.default(false),
1858+
// How often (seconds) a live SSO session is re-validated against the
1859+
// identity provider. The check is single-flight per user, so this is
1860+
// the minimum interval between plugin round-trips, not a per-request
1861+
// cost. Defaults to 5 minutes: every active SSO user drives one
1862+
// billing→IdP round-trip per window, so a seconds-scale default
1863+
// exhausts vendor rate limits at trivial user counts (masked by
1864+
// fail-open, so it degrades silently).
1865+
SSO_SESSION_REVALIDATION_INTERVAL_SECONDS: z.coerce.number().int().default(300),
1866+
// Hard timeout (ms) on the re-validation round-trip. If the SSO plugin
1867+
// doesn't answer within this window the check fails OPEN (session kept)
1868+
// and emits a `sso.revalidation.timeout` warn log — alert on an
1869+
// elevated rate of those to catch a slow/unhealthy SSO dependency.
1870+
SSO_SESSION_REVALIDATION_TIMEOUT_MS: z.coerce.number().int().default(2000),
18451871
})
18461872
.and(GithubAppEnvSchema)
18471873
.and(S2EnvSchema)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { prisma } from "~/db.server";
2+
import { logger } from "~/services/logger.server";
3+
import { rbac } from "~/services/rbac.server";
4+
5+
export type EnsureOrgMemberParams = {
6+
userId: string;
7+
organizationId: string;
8+
// null = use the seeded MEMBER role from the existing enum. A non-null
9+
// value is an RBAC role id; when an RBAC plugin is installed it gets
10+
// attached after the OrgMember row is created.
11+
roleId: string | null;
12+
source: "sso_jit" | "invite" | "manual";
13+
};
14+
15+
export type EnsureOrgMemberResult = { created: boolean; orgMemberId: string };
16+
17+
// Idempotent OrgMember upsert. If the (userId, organizationId) row
18+
// already exists this is a no-op (returns `{ created: false }`); we do
19+
// NOT touch the existing role to avoid demoting a user that JIT happens
20+
// to fire for again.
21+
//
22+
// Seat-limit enforcement lives at the call sites — every existing
23+
// OrgMember insert in the codebase does its own seat check before
24+
// calling in. This helper deliberately does none (SSO JIT and
25+
// invite-accept are exempt by policy).
26+
export async function ensureOrgMember(
27+
params: EnsureOrgMemberParams
28+
): Promise<EnsureOrgMemberResult> {
29+
const { userId, organizationId, roleId, source } = params;
30+
31+
const existing = await prisma.orgMember.findFirst({
32+
where: { userId, organizationId },
33+
select: { id: true },
34+
});
35+
if (existing) {
36+
return { created: false, orgMemberId: existing.id };
37+
}
38+
39+
const member = await prisma.orgMember.create({
40+
data: {
41+
userId,
42+
organizationId,
43+
role: "MEMBER",
44+
},
45+
select: { id: true },
46+
});
47+
48+
if (roleId !== null) {
49+
const result = await rbac.setUserRole({ userId, organizationId, roleId });
50+
if (!result.ok) {
51+
logger.warn("ensureOrgMember.setUserRole failed", {
52+
source,
53+
userId,
54+
organizationId,
55+
roleId,
56+
error: result.error,
57+
});
58+
}
59+
}
60+
61+
return { created: true, orgMemberId: member.id };
62+
}

apps/webapp/app/models/user.server.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,18 @@ type FindOrCreateGoogle = {
3030
authenticationExtraParams: Record<string, unknown>;
3131
};
3232

33-
type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub | FindOrCreateGoogle;
33+
type FindOrCreateSso = {
34+
authenticationMethod: "SSO";
35+
email: User["email"];
36+
firstName: string | null;
37+
lastName: string | null;
38+
};
39+
40+
type FindOrCreateUser =
41+
| FindOrCreateMagicLink
42+
| FindOrCreateGithub
43+
| FindOrCreateGoogle
44+
| FindOrCreateSso;
3445

3546
type LoggedInUser = {
3647
user: User;
@@ -48,6 +59,9 @@ export async function findOrCreateUser(input: FindOrCreateUser): Promise<LoggedI
4859
case "GOOGLE": {
4960
return findOrCreateGoogleUser(input);
5061
}
62+
case "SSO": {
63+
return findOrCreateSsoUser(input);
64+
}
5165
}
5266
}
5367

@@ -303,6 +317,44 @@ export async function findOrCreateGoogleUser({
303317
};
304318
}
305319

320+
// Find an existing user by email (lowercased) or create a new one with the
321+
// SSO authentication method. Mirrors the magic-link upsert shape; the
322+
// callback route is responsible for normalising email before calling.
323+
// Plugin writes (linking the IdP identity row) happen via the SSO plugin
324+
// after this returns.
325+
export async function findOrCreateSsoUser({
326+
email,
327+
firstName,
328+
lastName,
329+
}: FindOrCreateSso): Promise<LoggedInUser> {
330+
assertEmailAllowed(email);
331+
332+
const normalised = email.toLowerCase().trim();
333+
const existingUser = await prisma.user.findFirst({ where: { email: normalised } });
334+
335+
const fullName = [firstName, lastName].filter(Boolean).join(" ").trim() || null;
336+
337+
const user = await prisma.user.upsert({
338+
where: { email: normalised },
339+
update: {
340+
// Existing magic-link / OAuth users keep their original
341+
// authenticationMethod; we only refresh name/displayName when the
342+
// user has nothing set yet so we don't clobber a customised display
343+
// name on every SSO login.
344+
...(existingUser?.name ? {} : { name: fullName }),
345+
...(existingUser?.displayName ? {} : { displayName: fullName }),
346+
},
347+
create: {
348+
email: normalised,
349+
name: fullName,
350+
displayName: fullName,
351+
authenticationMethod: "SSO",
352+
},
353+
});
354+
355+
return { user, isNewUser: !existingUser };
356+
}
357+
306358
export type UserWithDashboardPreferences = User & {
307359
dashboardPreferences: DashboardPreferences;
308360
};

0 commit comments

Comments
 (0)