Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/sso-plugin-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/plugins": patch
---

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`.
8 changes: 8 additions & 0 deletions .server-changes/accounts-webhook-passthrough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
area: webapp
type: feature
---

Add `POST /webhooks/v1/accounts`: a thin passthrough that verifies inbound
webhooks via the SSO plugin and enqueues them on a dedicated worker. No-op
(404) when no plugin is installed.
8 changes: 8 additions & 0 deletions .server-changes/sso-plugin-plumbing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
area: webapp
type: feature
---

Wire the SSO plugin loader (`@trigger.dev/sso`) into the webapp: SSO auth
method, `hasSso` flag, `SsoStrategy`, and contributor fallback env vars.
No-op (`no_sso`) without the plugin.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
ChartBarIcon,
Cog8ToothIcon,
CreditCardIcon,
LinkIcon,
LockClosedIcon,
ShieldCheckIcon,
UserGroupIcon,
Expand All @@ -18,6 +19,7 @@ import {
organizationRolesPath,
organizationSettingsPath,
organizationSlackIntegrationPath,
organizationSsoPath,
organizationTeamPath,
organizationVercelIntegrationPath,
rootPath,
Expand Down Expand Up @@ -48,10 +50,12 @@ export function OrganizationSettingsSideMenu({
organization,
buildInfo,
isUsingPlugin,
isSsoUsingPlugin,
}: {
organization: MatchedOrganization;
buildInfo: BuildInfo;
isUsingPlugin: boolean;
isSsoUsingPlugin: boolean;
}) {
const { isManagedCloud } = useFeatures();
const featureFlags = useFeatureFlags();
Expand Down Expand Up @@ -117,7 +121,7 @@ export function OrganizationSettingsSideMenu({
{featureFlags.hasPrivateConnections && (
<SideMenuItem
name="Private Connections"
icon={LockClosedIcon}
icon={LinkIcon}
activeIconColor="text-purple-500"
inactiveIconColor="text-purple-500"
to={v3PrivateConnectionsPath(organization)}
Expand All @@ -142,6 +146,21 @@ export function OrganizationSettingsSideMenu({
data-action="roles"
/>
)}
{isManagedCloud && isSsoUsingPlugin && (
<SideMenuItem
name="SSO"
icon={LockClosedIcon}
activeIconColor="text-indigo-400"
inactiveIconColor="text-indigo-400"
to={organizationSsoPath(organization)}
data-action="sso"
badge={
currentPlan?.v3Subscription?.plan?.code === "enterprise" ? undefined : (
<Badge variant="extra-small">Enterprise</Badge>
)
}
/>
)}
<SideMenuItem
name="Settings"
icon={Cog8ToothIcon}
Expand Down
26 changes: 26 additions & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1842,6 +1842,32 @@ const EnvironmentSchema = z

// Force RBAC to not use the plugin
RBAC_FORCE_FALLBACK: BoolEnv.default(false),

// Force SSO to not use the plugin (contributors without the cloud
// plugin installed can opt in to a clean OSS-only experience).
SSO_FORCE_FALLBACK: BoolEnv.default(false),
// Emit a console.log when the SSO fallback is selected because no
// plugin is installed. Default off so OSS deployments stay quiet.
SSO_LOG_FALLBACK: BoolEnv.default(false),
// Master deploy gate for the whole SSO feature. Default OFF so the
// image can ship dark and be flipped on only once the SSO plugin's
// backing services are available. When false, the SSO controller is
// forced to the OSS fallback — login link hidden, SSO login disabled,
// settings inert, and session re-validation skipped.
SSO_ENABLED: BoolEnv.default(false),
// How often (seconds) a live SSO session is re-validated against the
// identity provider. The check is single-flight per user, so this is
// the minimum interval between plugin round-trips, not a per-request
// cost. Defaults to 5 minutes: every active SSO user drives one
// billing→IdP round-trip per window, so a seconds-scale default
// exhausts vendor rate limits at trivial user counts (masked by
// fail-open, so it degrades silently).
SSO_SESSION_REVALIDATION_INTERVAL_SECONDS: z.coerce.number().int().default(300),
// Hard timeout (ms) on the re-validation round-trip. If the SSO plugin
// doesn't answer within this window the check fails OPEN (session kept)
// and emits a `sso.revalidation.timeout` warn log — alert on an
// elevated rate of those to catch a slow/unhealthy SSO dependency.
SSO_SESSION_REVALIDATION_TIMEOUT_MS: z.coerce.number().int().default(2000),
})
.and(GithubAppEnvSchema)
.and(S2EnvSchema)
Expand Down
62 changes: 62 additions & 0 deletions apps/webapp/app/models/orgMember.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { rbac } from "~/services/rbac.server";

export type EnsureOrgMemberParams = {
userId: string;
organizationId: string;
// null = use the seeded MEMBER role from the existing enum. A non-null
// value is an RBAC role id; when an RBAC plugin is installed it gets
// attached after the OrgMember row is created.
roleId: string | null;
source: "sso_jit" | "invite" | "manual";
};

export type EnsureOrgMemberResult = { created: boolean; orgMemberId: string };

// Idempotent OrgMember upsert. If the (userId, organizationId) row
// already exists this is a no-op (returns `{ created: false }`); we do
// NOT touch the existing role to avoid demoting a user that JIT happens
// to fire for again.
//
// Seat-limit enforcement lives at the call sites — every existing
// OrgMember insert in the codebase does its own seat check before
// calling in. This helper deliberately does none (SSO JIT and
// invite-accept are exempt by policy).
export async function ensureOrgMember(
params: EnsureOrgMemberParams
): Promise<EnsureOrgMemberResult> {
const { userId, organizationId, roleId, source } = params;

const existing = await prisma.orgMember.findFirst({
where: { userId, organizationId },
select: { id: true },
});
if (existing) {
return { created: false, orgMemberId: existing.id };
}

const member = await prisma.orgMember.create({
data: {
userId,
organizationId,
role: "MEMBER",
},
select: { id: true },
});
Comment on lines +31 to +46

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make ensureOrgMember truly idempotent under concurrency.

The findFirstcreate sequence races: two concurrent requests can both miss on Line 31 and one will throw a unique-constraint error on Line 39, which breaks sign-in/JIT provisioning instead of returning { created: false }.

Suggested fix
+import { Prisma } from "`@prisma/client`";
 import { prisma } from "~/db.server";
 import { logger } from "~/services/logger.server";
 import { rbac } from "~/services/rbac.server";
@@
-  const member = await prisma.orgMember.create({
-    data: {
-      userId,
-      organizationId,
-      role: "MEMBER",
-    },
-    select: { id: true },
-  });
+  let member: { id: string };
+  try {
+    member = await prisma.orgMember.create({
+      data: {
+        userId,
+        organizationId,
+        role: "MEMBER",
+      },
+      select: { id: true },
+    });
+  } catch (error) {
+    if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
+      const existingAfterConflict = await prisma.orgMember.findFirst({
+        where: { userId, organizationId },
+        select: { id: true },
+      });
+      if (existingAfterConflict) {
+        return { created: false, orgMemberId: existingAfterConflict.id };
+      }
+    }
+    throw error;
+  }


if (roleId !== null) {
const result = await rbac.setUserRole({ userId, organizationId, roleId });
if (!result.ok) {
logger.warn("ensureOrgMember.setUserRole failed", {
source,
userId,
organizationId,
roleId,
error: result.error,
});
}
}

return { created: true, orgMemberId: member.id };
}
54 changes: 53 additions & 1 deletion apps/webapp/app/models/user.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,18 @@ type FindOrCreateGoogle = {
authenticationExtraParams: Record<string, unknown>;
};

type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub | FindOrCreateGoogle;
type FindOrCreateSso = {
authenticationMethod: "SSO";
email: User["email"];
firstName: string | null;
lastName: string | null;
};

type FindOrCreateUser =
| FindOrCreateMagicLink
| FindOrCreateGithub
| FindOrCreateGoogle
| FindOrCreateSso;

type LoggedInUser = {
user: User;
Expand All @@ -48,6 +59,9 @@ export async function findOrCreateUser(input: FindOrCreateUser): Promise<LoggedI
case "GOOGLE": {
return findOrCreateGoogleUser(input);
}
case "SSO": {
return findOrCreateSsoUser(input);
}
}
}

Expand Down Expand Up @@ -303,6 +317,44 @@ export async function findOrCreateGoogleUser({
};
}

// Find an existing user by email (lowercased) or create a new one with the
// SSO authentication method. Mirrors the magic-link upsert shape; the
// callback route is responsible for normalising email before calling.
// Plugin writes (linking the IdP identity row) happen via the SSO plugin
// after this returns.
export async function findOrCreateSsoUser({
email,
firstName,
lastName,
}: FindOrCreateSso): Promise<LoggedInUser> {
assertEmailAllowed(email);

const normalised = email.toLowerCase().trim();
const existingUser = await prisma.user.findFirst({ where: { email: normalised } });
Comment on lines +330 to +333

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate the canonicalized email value before policy checks.

Line 330 validates raw email, but Lines 332/348 use normalised for lookup/write. Validate the same canonical value you persist, otherwise case/whitespace variants can bypass or misapply assertEmailAllowed.

Suggested fix
 export async function findOrCreateSsoUser({
   email,
   firstName,
   lastName,
 }: FindOrCreateSso): Promise<LoggedInUser> {
-  assertEmailAllowed(email);
-
   const normalised = email.toLowerCase().trim();
+  assertEmailAllowed(normalised);
   const existingUser = await prisma.user.findFirst({ where: { email: normalised } });

Also applies to: 348-349


const fullName = [firstName, lastName].filter(Boolean).join(" ").trim() || null;

const user = await prisma.user.upsert({
where: { email: normalised },
update: {
// Existing magic-link / OAuth users keep their original
// authenticationMethod; we only refresh name/displayName when the
// user has nothing set yet so we don't clobber a customised display
// name on every SSO login.
...(existingUser?.name ? {} : { name: fullName }),
...(existingUser?.displayName ? {} : { displayName: fullName }),
},
create: {
email: normalised,
name: fullName,
displayName: fullName,
authenticationMethod: "SSO",
},
});

return { user, isNewUser: !existingUser };
}

export type UserWithDashboardPreferences = User & {
dashboardPreferences: DashboardPreferences;
};
Expand Down
Loading
Loading