-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
feat(sso): SAML/OIDC single sign-on #3911
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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`. |
| 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. |
| 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 |
|---|---|---|
| @@ -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 }, | ||
| }); | ||
|
|
||
| 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 }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -48,6 +59,9 @@ export async function findOrCreateUser(input: FindOrCreateUser): Promise<LoggedI | |
| case "GOOGLE": { | ||
| return findOrCreateGoogleUser(input); | ||
| } | ||
| case "SSO": { | ||
| return findOrCreateSsoUser(input); | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate the canonicalized email value before policy checks. Line 330 validates raw 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; | ||
| }; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make
ensureOrgMembertruly idempotent under concurrency.The
findFirst→createsequence 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