Skip to content

Commit e5012c1

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 38f2804 commit e5012c1

53 files changed

Lines changed: 3181 additions & 111 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
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.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
When an SSO session is revalidated and the IdP reports it invalid, the user is now sent to the login page with a "Your SSO session expired. Please sign in again." notice instead of seeing a raw `sso_session_invalidated` 401.
7+
8+
Navigations redirect through `/logout` (clearing the cookie) to `/login?reason=session_expired`. Programmatic fetches (Remix fetchers, Electric, etc.) get a 401 carrying an `x-sso-session-invalidated` marker header that a client-side fetch guard turns into the same logout redirect. EventSource streams, which can't read response headers, probe a new lightweight `/resources/session-check` endpoint on stream error to trigger the redirect.

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

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
1+
import { ArrowLeftIcon, LinkIcon } from "@heroicons/react/24/solid";
22
import { BellIcon } from "~/assets/icons/BellIcon";
33
import { CreditCardIcon } from "~/assets/icons/CreditCardIcon";
44
import { PadlockIcon } from "~/assets/icons/PadlockIcon";
55
import { UsageIcon } from "~/assets/icons/UsageIcon";
66
import { RolesIcon } from "~/assets/icons/RolesIcon";
7-
import { ShieldLockIcon } from "~/assets/icons/ShieldLockIcon";
87
import { SlackIcon } from "~/assets/icons/SlackIcon";
98
import { SlidersIcon } from "~/assets/icons/SlidersIcon";
109
import { UserGroupIcon } from "~/assets/icons/UserGroupIcon";
@@ -17,6 +16,7 @@ import {
1716
organizationRolesPath,
1817
organizationSettingsPath,
1918
organizationSlackIntegrationPath,
19+
organizationSsoPath,
2020
organizationTeamPath,
2121
organizationVercelIntegrationPath,
2222
rootPath,
@@ -48,10 +48,12 @@ export function OrganizationSettingsSideMenu({
4848
organization,
4949
buildInfo,
5050
isUsingPlugin,
51+
isSsoUsingPlugin,
5152
}: {
5253
organization: MatchedOrganization;
5354
buildInfo: BuildInfo;
5455
isUsingPlugin: boolean;
56+
isSsoUsingPlugin: boolean;
5557
}) {
5658
const { isManagedCloud } = useFeatures();
5759
const featureFlags = useFeatureFlags();
@@ -128,7 +130,7 @@ export function OrganizationSettingsSideMenu({
128130
{featureFlags.hasPrivateConnections && (
129131
<SideMenuItem
130132
name="Private Connections"
131-
icon={PadlockIcon}
133+
icon={LinkIcon}
132134
activeIconColor="text-text-bright"
133135
inactiveIconColor="text-text-dimmed"
134136
to={v3PrivateConnectionsPath(organization)}
@@ -145,6 +147,21 @@ export function OrganizationSettingsSideMenu({
145147
data-action="roles"
146148
/>
147149
)}
150+
{isManagedCloud && isSsoUsingPlugin && (
151+
<SideMenuItem
152+
name="SSO"
153+
icon={PadlockIcon}
154+
activeIconColor="text-indigo-400"
155+
inactiveIconColor="text-indigo-400"
156+
to={organizationSsoPath(organization)}
157+
data-action="sso"
158+
badge={
159+
currentPlan?.v3Subscription?.plan?.code === "enterprise" ? undefined : (
160+
<Badge variant="extra-small">Enterprise</Badge>
161+
)
162+
}
163+
/>
164+
)}
148165
<SideMenuItem
149166
name="Settings"
150167
icon={SlidersIcon}

apps/webapp/app/entry.client.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import { hydrateRoot } from "react-dom/client";
33
import { clientBeforeFirstRender } from "./clientBeforeFirstRender";
44
import { LocaleContextProvider } from "./components/primitives/LocaleProvider";
55
import { OperatingSystemContextProvider } from "./components/primitives/OperatingSystemProvider";
6+
import { installSsoSessionGuard } from "./utils/ssoSessionGuard";
67

78
clientBeforeFirstRender();
9+
installSsoSessionGuard();
810

911
hydrateRoot(
1012
document,

apps/webapp/app/env.server.ts

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

18721872
// Force RBAC to not use the plugin
18731873
RBAC_FORCE_FALLBACK: BoolEnv.default(false),
1874+
1875+
// Force SSO to not use the plugin (contributors without the cloud
1876+
// plugin installed can opt in to a clean OSS-only experience).
1877+
SSO_FORCE_FALLBACK: BoolEnv.default(false),
1878+
// Emit a console.log when the SSO fallback is selected because no
1879+
// plugin is installed. Default off so OSS deployments stay quiet.
1880+
SSO_LOG_FALLBACK: BoolEnv.default(false),
1881+
// Master deploy gate for the whole SSO feature. Default OFF so the
1882+
// image can ship dark and be flipped on only once the SSO plugin's
1883+
// backing services are available. When false, the SSO controller is
1884+
// forced to the OSS fallback — login link hidden, SSO login disabled,
1885+
// settings inert, and session re-validation skipped.
1886+
SSO_ENABLED: BoolEnv.default(false),
1887+
// How often (seconds) a live SSO session is re-validated against the
1888+
// identity provider. The check is single-flight per user, so this is
1889+
// the minimum interval between plugin round-trips, not a per-request
1890+
// cost. Defaults to 5 minutes: every active SSO user drives one
1891+
// billing→IdP round-trip per window, so a seconds-scale default
1892+
// exhausts vendor rate limits at trivial user counts (masked by
1893+
// fail-open, so it degrades silently).
1894+
SSO_SESSION_REVALIDATION_INTERVAL_SECONDS: z.coerce.number().int().positive().default(300),
1895+
// Hard timeout (ms) on the re-validation round-trip. If the SSO plugin
1896+
// doesn't answer within this window the check fails OPEN (session kept)
1897+
// and emits a `sso.revalidation.timeout` warn log — alert on an
1898+
// elevated rate of those to catch a slow/unhealthy SSO dependency.
1899+
SSO_SESSION_REVALIDATION_TIMEOUT_MS: z.coerce.number().int().positive().default(2000),
18741900
})
18751901
.and(GithubAppEnvSchema)
18761902
.and(S2EnvSchema)

apps/webapp/app/hooks/useEventSource.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useEffect, useState } from "react";
2+
import { probeSsoSession } from "~/utils/ssoSessionGuard";
23

34
type EventSourceOptions = {
45
init?: EventSourceInit;
@@ -28,13 +29,21 @@ export function useEventSource(
2829

2930
const eventSource = new EventSource(url, init);
3031
eventSource.addEventListener(event ?? "message", handler);
32+
eventSource.addEventListener("error", errorHandler);
3133

3234
function handler(event: MessageEvent) {
3335
setData(event.data || "UNKNOWN_EVENT_DATA");
3436
}
3537

38+
// EventSource can't surface response headers, so on a stream error probe
39+
// an authenticated endpoint; a revoked session redirects via the guard.
40+
function errorHandler() {
41+
probeSsoSession();
42+
}
43+
3644
return () => {
3745
eventSource.removeEventListener(event ?? "message", handler);
46+
eventSource.removeEventListener("error", errorHandler);
3847
eventSource.close();
3948
};
4049
}, [url, event, init, disabled]);
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Prisma, 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+
// Two concurrent JIT/invite flows can both miss the findFirst above and
40+
// race to create the same (userId, organizationId) row; the unique
41+
// constraint makes one lose with P2002. Treat that as the idempotent
42+
// "already a member" case rather than letting it break sign-in.
43+
let member: { id: string };
44+
try {
45+
member = await prisma.orgMember.create({
46+
data: {
47+
userId,
48+
organizationId,
49+
role: "MEMBER",
50+
},
51+
select: { id: true },
52+
});
53+
} catch (error) {
54+
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
55+
const existingAfterConflict = await prisma.orgMember.findFirst({
56+
where: { userId, organizationId },
57+
select: { id: true },
58+
});
59+
if (existingAfterConflict) {
60+
return { created: false, orgMemberId: existingAfterConflict.id };
61+
}
62+
}
63+
throw error;
64+
}
65+
66+
if (roleId !== null) {
67+
const result = await rbac.setUserRole({ userId, organizationId, roleId });
68+
if (!result.ok) {
69+
logger.warn("ensureOrgMember.setUserRole failed", {
70+
source,
71+
userId,
72+
organizationId,
73+
roleId,
74+
error: result.error,
75+
});
76+
}
77+
}
78+
79+
return { created: true, orgMemberId: member.id };
80+
}

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

Lines changed: 56 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,47 @@ 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+
// Validate the canonical value we actually look up and persist below —
331+
// validating raw `email` would let case/whitespace variants slip past
332+
// (or misapply) the allow-list policy.
333+
const normalised = email.toLowerCase().trim();
334+
assertEmailAllowed(normalised);
335+
336+
const existingUser = await prisma.user.findFirst({ where: { email: normalised } });
337+
338+
const fullName = [firstName, lastName].filter(Boolean).join(" ").trim() || null;
339+
340+
const user = await prisma.user.upsert({
341+
where: { email: normalised },
342+
update: {
343+
// Existing magic-link / OAuth users keep their original
344+
// authenticationMethod; we only refresh name/displayName when the
345+
// user has nothing set yet so we don't clobber a customised display
346+
// name on every SSO login.
347+
...(existingUser?.name ? {} : { name: fullName }),
348+
...(existingUser?.displayName ? {} : { displayName: fullName }),
349+
},
350+
create: {
351+
email: normalised,
352+
name: fullName,
353+
displayName: fullName,
354+
authenticationMethod: "SSO",
355+
},
356+
});
357+
358+
return { user, isNewUser: !existingUser };
359+
}
360+
306361
export type UserWithDashboardPreferences = User & {
307362
dashboardPreferences: DashboardPreferences;
308363
};

0 commit comments

Comments
 (0)