diff --git a/.server-changes/accounts-webhook-passthrough.md b/.server-changes/accounts-webhook-passthrough.md new file mode 100644 index 00000000000..56f239469b6 --- /dev/null +++ b/.server-changes/accounts-webhook-passthrough.md @@ -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. diff --git a/.server-changes/sso-plugin-plumbing.md b/.server-changes/sso-plugin-plumbing.md new file mode 100644 index 00000000000..1a5c8d631dd --- /dev/null +++ b/.server-changes/sso-plugin-plumbing.md @@ -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. diff --git a/.server-changes/sso-session-expired-logout-ux.md b/.server-changes/sso-session-expired-logout-ux.md new file mode 100644 index 00000000000..9ef6d4df130 --- /dev/null +++ b/.server-changes/sso-session-expired-logout-ux.md @@ -0,0 +1,8 @@ +--- +area: webapp +type: improvement +--- + +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. + +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. diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index c25a433d7f9..4f1fcd606e3 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -1,10 +1,9 @@ -import { ArrowLeftIcon } from "@heroicons/react/24/solid"; +import { ArrowLeftIcon, LinkIcon } from "@heroicons/react/24/solid"; import { BellIcon } from "~/assets/icons/BellIcon"; import { CreditCardIcon } from "~/assets/icons/CreditCardIcon"; import { PadlockIcon } from "~/assets/icons/PadlockIcon"; import { UsageIcon } from "~/assets/icons/UsageIcon"; import { RolesIcon } from "~/assets/icons/RolesIcon"; -import { ShieldLockIcon } from "~/assets/icons/ShieldLockIcon"; import { SlackIcon } from "~/assets/icons/SlackIcon"; import { SlidersIcon } from "~/assets/icons/SlidersIcon"; import { UserGroupIcon } from "~/assets/icons/UserGroupIcon"; @@ -17,6 +16,7 @@ import { organizationRolesPath, organizationSettingsPath, organizationSlackIntegrationPath, + organizationSsoPath, organizationTeamPath, organizationVercelIntegrationPath, rootPath, @@ -48,10 +48,12 @@ export function OrganizationSettingsSideMenu({ organization, buildInfo, isUsingPlugin, + isSsoUsingPlugin, }: { organization: MatchedOrganization; buildInfo: BuildInfo; isUsingPlugin: boolean; + isSsoUsingPlugin: boolean; }) { const { isManagedCloud } = useFeatures(); const featureFlags = useFeatureFlags(); @@ -128,7 +130,7 @@ export function OrganizationSettingsSideMenu({ {featureFlags.hasPrivateConnections && ( )} + {isManagedCloud && isSsoUsingPlugin && ( + Enterprise + ) + } + /> + )} { eventSource.removeEventListener(event ?? "message", handler); + eventSource.removeEventListener("error", errorHandler); eventSource.close(); }; }, [url, event, init, disabled]); diff --git a/apps/webapp/app/models/orgMember.server.ts b/apps/webapp/app/models/orgMember.server.ts new file mode 100644 index 00000000000..4ec6930d2f8 --- /dev/null +++ b/apps/webapp/app/models/orgMember.server.ts @@ -0,0 +1,80 @@ +import { Prisma, 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 { + 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 }; + } + + // Two concurrent JIT/invite flows can both miss the findFirst above and + // race to create the same (userId, organizationId) row; the unique + // constraint makes one lose with P2002. Treat that as the idempotent + // "already a member" case rather than letting it break sign-in. + 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 }; +} diff --git a/apps/webapp/app/models/user.server.ts b/apps/webapp/app/models/user.server.ts index c48221c4b61..983668ce43e 100644 --- a/apps/webapp/app/models/user.server.ts +++ b/apps/webapp/app/models/user.server.ts @@ -30,7 +30,18 @@ type FindOrCreateGoogle = { authenticationExtraParams: Record; }; -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 { + // Validate the canonical value we actually look up and persist below — + // validating raw `email` would let case/whitespace variants slip past + // (or misapply) the allow-list policy. + const normalised = email.toLowerCase().trim(); + assertEmailAllowed(normalised); + + const existingUser = await prisma.user.findFirst({ where: { email: normalised } }); + + 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; }; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx new file mode 100644 index 00000000000..fa27c86c6fb --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx @@ -0,0 +1,798 @@ +import { + ArrowTopRightOnSquareIcon, + CheckCircleIcon, + ClockIcon, + ExclamationCircleIcon, + LockClosedIcon, +} from "@heroicons/react/20/solid"; +import { type MetaFunction } from "@remix-run/react"; +import { redirect, type ActionFunctionArgs } from "@remix-run/server-runtime"; +import { useEffect, useState } from "react"; +import { useFetcher } from "@remix-run/react"; +import { z } from "zod"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { + MainHorizontallyCenteredContainer, + PageBody, + PageContainer, +} from "~/components/layout/AppLayout"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, +} from "~/components/primitives/Dialog"; +import { Header2 } from "~/components/primitives/Headers"; +import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { Switch } from "~/components/primitives/Switch"; +import { $replica } from "~/db.server"; +import { featuresForRequest } from "~/features.server"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { rbac } from "~/services/rbac.server"; +import { ssoController } from "~/services/sso.server"; +import { getCurrentPlan } from "~/services/platform.v3.server"; +import type { Role } from "@trigger.dev/plugins"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; +import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; +import { v3BillingPath } from "~/utils/pathBuilder"; + +export const meta: MetaFunction = () => [{ title: "SSO settings | Trigger.dev" }]; + +const Params = z.object({ organizationSlug: z.string() }); + +async function resolveOrg(slug: string) { + return $replica.organization.findFirst({ + where: { slug }, + select: { id: true, title: true }, + }); +} + +function planAllowsSso(plan: unknown): boolean { + if (!plan || typeof plan !== "object") return false; + const subscription = (plan as { v3Subscription?: { plan?: { code?: string } } }) + .v3Subscription; + return subscription?.plan?.code === "enterprise"; +} + +// The render-level upsell (planAllowsSso on the client) is cosmetic — +// any org member could still POST the actions directly. Mutations that +// provision real IdP-side resources are gated here, server-side. +async function requireSsoEntitlement(orgId: string): Promise { + const plan = await getCurrentPlan(orgId); + if (!planAllowsSso(plan)) { + throw new Response("SSO requires an Enterprise plan", { status: 403 }); + } +} + +export const loader = dashboardLoader( + { + params: Params, + context: async (params) => { + const org = await resolveOrg(params.organizationSlug); + return org ? { organizationId: org.id, orgTitle: org.title } : {}; + }, + authorization: { action: "manage", resource: { type: "sso" } }, + }, + async ({ context, request }) => { + const { isManagedCloud } = featuresForRequest(request); + // Gate on managed cloud AND the SSO plugin actually being loaded + // (SSO_ENABLED off → OSS fallback → isUsingPlugin false). Without + // this the page renders for every managed-cloud org even when SSO + // is disabled for the deployment. + if (!isManagedCloud || !(await ssoController.isUsingPlugin())) { + throw new Response("Not Found", { status: 404 }); + } + + const orgId = context.organizationId; + if (!orgId) { + throw new Response("Not Found", { status: 404 }); + } + + // The page is reachable on every paid + free plan; when the org + // isn't on Enterprise we render the upsell state instead of the + // SSO UI. Plan-tier enforcement lives in the React render so the + // sidebar entry and the page itself stay aligned. + const [statusResult, allRoles, assignableIds] = await Promise.all([ + ssoController.getStatus(orgId), + rbac.allRoles(orgId), + rbac.getAssignableRoleIds(orgId), + ]); + const status = statusResult.isOk() + ? statusResult.value + : { + hasIdpOrg: false, + enforced: false, + jitProvisioningEnabled: false, + jitDefaultRoleId: null, + idpOrgId: null, + primaryConnectionId: null, + domains: [] as Array<{ + domain: string; + verified: boolean; + state: "pending" | "verified" | "failed"; + verificationFailedReason: string | null; + }>, + connections: [] as Array<{ + id: string; + name: string | null; + connectionType: string; + state: "active" | "inactive"; + }>, + }; + + // JIT can't promote new users to Owner — that role is reserved for + // the founding member and explicit transfers. Plan-gated roles are + // filtered out via the assignable set so the UI doesn't offer + // something the org can't actually use. + const assignable = new Set(assignableIds); + const jitRoles = allRoles.filter( + (r) => r.name !== "Owner" && assignable.has(r.id) + ); + + return typedjson({ status, orgTitle: context.orgTitle, jitRoles }); + } +); + +const NULL_ROLE_VALUE = "__none__"; +const DEFAULT_JIT_ROLE_NAME = "Developer"; + +// Don't use `z.coerce.boolean()` — it goes through JS `Boolean()`, +// which treats the string "false" as truthy (any non-empty string). +const boolish = z + .union([z.literal("true"), z.literal("false")]) + .transform((v) => v === "true"); + +const ActionSchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("save_config"), + enforced: boolish, + jitEnabled: boolish, + jitRoleId: z.string(), + }), + z.object({ + action: z.literal("portal_link"), + intent: z.enum(["sso", "domain_verification"]), + }), +]); + +export const action = dashboardAction( + { + params: Params, + context: async (params) => { + const org = await resolveOrg(params.organizationSlug); + return org ? { organizationId: org.id } : {}; + }, + authorization: { action: "manage", resource: { type: "sso" } }, + }, + async ({ request, context, user, params }) => { + const orgId = context.organizationId; + if (!orgId) { + throw new Response("Not Found", { status: 404 }); + } + + const { isManagedCloud } = featuresForRequest(request); + if (!isManagedCloud) { + throw new Response("Not Found", { status: 404 }); + } + await requireSsoEntitlement(orgId); + + const formData = await request.formData(); + const parsed = ActionSchema.safeParse({ + action: formData.get("action"), + enforced: formData.get("enforced") ?? undefined, + jitEnabled: formData.get("jitEnabled") ?? undefined, + jitRoleId: formData.get("jitRoleId") ?? undefined, + intent: formData.get("intent") ?? undefined, + }); + if (!parsed.success) { + return new Response("Bad Request", { status: 400 }); + } + + switch (parsed.data.action) { + case "save_config": { + const jitRoleId = + parsed.data.jitRoleId === NULL_ROLE_VALUE ? null : parsed.data.jitRoleId; + // Issue all three writes in parallel — they touch the same + // OrgSsoConfig row but only update disjoint columns, so there + // is no contention. A failure on any leaves the others applied; + // surface the first error string back to the form. + const [enforced, jit, jitRole] = await Promise.all([ + ssoController.setEnforced({ + organizationId: orgId, + enforced: parsed.data.enforced, + }), + ssoController.setJitProvisioningEnabled({ + organizationId: orgId, + enabled: parsed.data.jitEnabled, + }), + ssoController.setJitDefaultRole({ organizationId: orgId, roleId: jitRoleId }), + ]); + const failed = [enforced, jit, jitRole].find((r) => r.isErr()); + if (failed && failed.isErr()) { + return new Response(`Error: ${failed.error}`, { status: 400 }); + } + return redirect(`/orgs/${params.organizationSlug}/settings/sso`); + } + case "portal_link": { + const url = new URL(request.url); + const returnUrl = `${url.protocol}//${url.host}/orgs/${params.organizationSlug}/settings/sso`; + const result = await ssoController.generatePortalLink({ + organizationId: orgId, + userId: user.id, + intent: parsed.data.intent, + returnUrl, + }); + if (result.isErr()) { + return Response.json({ ok: false, error: result.error }, { status: 400 }); + } + return Response.json({ ok: true, url: result.value.url }); + } + } + } +); + +function defaultJitRoleId( + jitRoles: ReadonlyArray, + current: string | null +): string { + // Persisted value wins, even when it points at something the picker + // can no longer offer — keeps the user's prior choice visible. + if (current) return current; + const dev = jitRoles.find((r) => r.name === DEFAULT_JIT_ROLE_NAME); + return dev?.id ?? NULL_ROLE_VALUE; +} + +export default function Page() { + const { status, orgTitle, jitRoles } = useTypedLoaderData(); + const organization = useOrganization(); + const _plan = useCurrentPlan(); + + const isEntitled = planAllowsSso(_plan); + const activeConnections = status.connections.filter((c) => c.state === "active"); + const hasActive = activeConnections.length > 0; + + // Deferred-save: each field starts mirrored from `status`, edits stay + // local until Save commits all three to the action. The `key` trick + // below resets local state after a successful save (when `status` + // changes via revalidation following the redirect). + const initialJitRoleId = defaultJitRoleId(jitRoles, status.jitDefaultRoleId); + const [draftEnforced, setDraftEnforced] = useState(status.enforced); + const [draftJitEnabled, setDraftJitEnabled] = useState(status.jitProvisioningEnabled); + const [draftJitRoleId, setDraftJitRoleId] = useState(initialJitRoleId); + + // Re-sync drafts when the loader returns fresh `status` (post-save + // redirect → revalidation). useEffect rather than a memo so we don't + // stomp in-flight edits during the same render. + useEffect(() => { + setDraftEnforced(status.enforced); + setDraftJitEnabled(status.jitProvisioningEnabled); + setDraftJitRoleId(defaultJitRoleId(jitRoles, status.jitDefaultRoleId)); + // jitRoles only changes if the org changes; the role list itself is + // stable across saves on a given org. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [status.enforced, status.jitProvisioningEnabled, status.jitDefaultRoleId]); + + const isDirty = + draftEnforced !== status.enforced || + draftJitEnabled !== status.jitProvisioningEnabled || + draftJitRoleId !== initialJitRoleId; + + const [portalUrl, setPortalUrl] = useState(null); + const [enforceModalOpen, setEnforceModalOpen] = useState(false); + const portalFetcher = useFetcher<{ ok: boolean; url?: string; error?: string }>(); + const saveFetcher = useFetcher(); + const isSaving = saveFetcher.state !== "idle"; + + useEffect(() => { + if (portalFetcher.data?.ok && portalFetcher.data.url) { + setPortalUrl(portalFetcher.data.url); + } + }, [portalFetcher.data]); + + const openPortal = (intent: "sso" | "domain_verification") => { + setPortalUrl(null); + portalFetcher.submit( + { action: "portal_link", intent }, + { method: "POST" } + ); + }; + + const submitSave = () => { + saveFetcher.submit( + { + action: "save_config", + enforced: draftEnforced ? "true" : "false", + jitEnabled: draftJitEnabled ? "true" : "false", + jitRoleId: draftJitRoleId, + }, + { method: "POST" } + ); + }; + + return ( + + + + + + + {!isEntitled ? ( + + ) : !status.hasIdpOrg ? ( + openPortal("sso")} /> + ) : !hasActive ? ( + openPortal("sso")} + onOpenDomain={() => openPortal("domain_verification")} + /> + ) : ( + openPortal("sso")} + onToggleEnforced={(next) => { + // Going on→off is harmless; going off→on locks users out so + // we still require explicit confirmation. The modal updates + // the draft only; nothing is persisted until Save. + if (next && !status.enforced) { + setEnforceModalOpen(true); + } else { + setDraftEnforced(next); + } + }} + onToggleJit={(next) => setDraftJitEnabled(next)} + onChangeJitRole={(roleId) => setDraftJitRoleId(roleId ?? NULL_ROLE_VALUE)} + onSave={submitSave} + /> + )} + + + + setPortalUrl(null)} /> + + setEnforceModalOpen(false)} + onConfirm={() => { + setDraftEnforced(true); + setEnforceModalOpen(false); + }} + /> + + ); +} + +function EnterpriseUpsellState({ organizationSlug }: { organizationSlug: string }) { + return ( +
+
+ + SSO is available on the Enterprise plan +
+ + Single sign-on (SAML / OIDC) lets your IT admins manage who can access Trigger.dev + through your identity provider — Okta, Azure AD, Google Workspace, OneLogin, and more. + Upgrade your organization to Enterprise to configure it. + +
    +
  • Self-service domain verification and connection setup via the admin portal.
  • +
  • Just-in-time user provisioning for your verified domains.
  • +
  • Per-domain enforcement so contractors keep using existing sign-in methods.
  • +
+
+ + Talk to sales + + + Contact us + +
+
+ ); +} + +function NoIdpOrgState({ onOpenPortal }: { onOpenPortal: () => void }) { + return ( +
+ Configure SSO for your organization + + Single sign-on lets your IT admins manage who can access Trigger.dev through your + identity provider (Okta, Azure AD, Google Workspace, OneLogin, and more). The first + click opens the admin portal in a 5-minute single-use link. + + +
+ ); +} + +type DomainRow = { + domain: string; + verified: boolean; + state: "pending" | "verified" | "failed"; + verificationFailedReason: string | null; +}; + +function NoActiveConnectionState({ + domains, + onOpenSso, + onOpenDomain, +}: { + domains: ReadonlyArray; + onOpenSso: () => void; + onOpenDomain: () => void; +}) { + const verifiedDomains = domains.filter((d) => d.state === "verified"); + const failedDomains = domains.filter((d) => d.state === "failed"); + const pendingDomains = domains.filter((d) => d.state === "pending"); + const hasUnresolved = failedDomains.length > 0 || pendingDomains.length > 0; + + return ( +
+ {failedDomains.length > 0 && ( + + {failedDomains.length === 1 + ? `Domain verification failed for ${failedDomains[0].domain}. Re-check the DNS records in the admin portal and re-run verification.` + : `${failedDomains.length} domains failed verification. Re-check the DNS records in the admin portal and re-run verification.`} + + )} + {failedDomains.length === 0 && verifiedDomains.length > 0 && ( + + {verifiedDomains.length === 1 + ? `Domain verified: ${verifiedDomains[0].domain}. Continue in the admin portal to finish setting up your identity provider connection.` + : `${verifiedDomains.length} domains verified. Continue in the admin portal to finish setting up your identity provider connection.`} + + )} + {failedDomains.length === 0 && verifiedDomains.length === 0 && ( + + Not yet configured. Continue in the admin portal to verify a domain and set up your + identity provider connection. + + )} + + {domains.length > 0 && ( +
+ Domains + +
+ )} + +
+ + +
+
+ ); +} + +function DomainList({ domains }: { domains: ReadonlyArray }) { + return ( +
    + {domains.map((d) => { + const visual = domainVisual(d.state); + return ( +
  • +
    + {d.domain} + {d.state === "failed" && d.verificationFailedReason && ( + + Reason: {d.verificationFailedReason} + + )} +
    + + {visual.icon} + {d.state} + +
  • + ); + })} +
+ ); +} + +function domainVisual(state: DomainRow["state"]) { + switch (state) { + case "verified": + return { + row: "border-emerald-500/30 bg-emerald-500/5", + label: "text-emerald-400", + icon: , + }; + case "failed": + return { + row: "border-rose-500/30 bg-rose-500/5", + label: "text-rose-400", + icon: , + }; + case "pending": + default: + return { + row: "border-amber-500/20 bg-amber-500/5", + label: "text-amber-400", + icon: , + }; + } +} + +function ActiveConnectionState({ + orgTitle, + status, + activeConnections, + jitRoles, + draftEnforced, + draftJitEnabled, + draftJitRoleId, + isDirty, + isSaving, + onTogglePortal, + onToggleEnforced, + onToggleJit, + onChangeJitRole, + onSave, +}: { + orgTitle: string; + status: { + enforced: boolean; + jitProvisioningEnabled: boolean; + jitDefaultRoleId: string | null; + domains: ReadonlyArray; + }; + activeConnections: ReadonlyArray<{ id: string; name: string | null; connectionType: string }>; + jitRoles: ReadonlyArray; + draftEnforced: boolean; + draftJitEnabled: boolean; + draftJitRoleId: string; + isDirty: boolean; + isSaving: boolean; + onTogglePortal: () => void; + onToggleEnforced: (next: boolean) => void; + onToggleJit: (next: boolean) => void; + onChangeJitRole: (roleId: string | null) => void; + onSave: () => void; +}) { + return ( +
+
+ {orgTitle} – SSO connection + {activeConnections.map((conn) => ( +
+ + {conn.name ?? conn.connectionType} + + + Type: {conn.connectionType} + +
+ ))} +
+ +
+ Verified domains + {status.domains.length === 0 ? ( + + No domains verified yet. + + ) : ( + + )} +
+ +
+ Configuration +
+
+ + Require SSO for matching domains + + + When on, users whose email matches a verified domain must use SSO to sign in. + +
+ +
+
+
+ + JIT provisioning + + + Auto-create memberships for first-time SSO sign-ins from your verified domains. + +
+ +
+
+
+ + Default role for JIT provisioned users + + + Role assigned to new users created via JIT provisioning. Owner is reserved + and cannot be granted automatically. + +
+ + value={draftJitRoleId} + setValue={(v) => onChangeJitRole(v === NULL_ROLE_VALUE ? null : v)} + items={[ + { id: NULL_ROLE_VALUE, name: "None", description: "" }, + ...jitRoles, + ]} + variant="tertiary/small" + dropdownIcon + text={(v) => + v === NULL_ROLE_VALUE + ? "None" + : jitRoles.find((r) => r.id === v)?.name ?? "Select a role" + } + > + {(items) => + items.map((role) => ( + + + {role.name} + {role.description ? ( + {role.description} + ) : null} + + + )) + } + +
+
+ { + e.preventDefault(); + onTogglePortal(); + }} + > + Open admin portal + + +
+
+
+ ); +} + +function PortalLinkDialog({ + url, + onClose, +}: { + url: string | null; + onClose: () => void; +}) { + return ( + (open ? undefined : onClose())}> + + Admin portal link + + This link is active for 5 minutes — copy it and share it with your IT contact via + whatever channel you prefer. + +
+ {url ?? ""} +
+ + +
+ + +
+
+
+
+ ); +} + +function EnforceConfirmDialog({ + open, + orgTitle, + onCancel, + onConfirm, +}: { + open: boolean; + orgTitle: string; + onCancel: () => void; + onConfirm: () => void; +}) { + return ( + (next ? undefined : onCancel())}> + + Enable SSO enforcement for {orgTitle}? + + Once enabled, users whose email domain matches your verified domains will be + redirected to your identity provider to sign in. They will no longer be able to use + magic link, GitHub, or Google via that domain. +
+
+ Users with non-matching emails (e.g. contractors with personal emails) will continue + to use existing methods. +
+ + + + +
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx index a24a857d6f6..6a6487ec4fd 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx @@ -9,8 +9,13 @@ import { } from "~/components/navigation/OrganizationSettingsSideMenu"; import { useOrganization } from "~/hooks/useOrganizations"; import { rbac } from "~/services/rbac.server"; +import { ssoController } from "~/services/sso.server"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const [isUsingPlugin, isSsoUsingPlugin] = await Promise.all([ + rbac.isUsingPlugin(), + ssoController.isUsingPlugin(), + ]); return typedjson({ buildInfo: { appVersion: process.env.BUILD_APP_VERSION, @@ -19,12 +24,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { gitRefName: process.env.BUILD_GIT_REF_NAME, buildTimestampSeconds: process.env.BUILD_TIMESTAMP_SECONDS, } satisfies BuildInfo, - isUsingPlugin: await rbac.isUsingPlugin(), + isUsingPlugin, + isSsoUsingPlugin, }); }; export default function Page() { - const { buildInfo, isUsingPlugin } = useTypedLoaderData(); + const { buildInfo, isUsingPlugin, isSsoUsingPlugin } = useTypedLoaderData(); const organization = useOrganization(); return ( @@ -34,6 +40,7 @@ export default function Page() { organization={organization} buildInfo={buildInfo} isUsingPlugin={isUsingPlugin} + isSsoUsingPlugin={isSsoUsingPlugin} /> diff --git a/apps/webapp/app/routes/auth.github.callback.tsx b/apps/webapp/app/routes/auth.github.callback.tsx index 44277dbf941..24838437b87 100644 --- a/apps/webapp/app/routes/auth.github.callback.tsx +++ b/apps/webapp/app/routes/auth.github.callback.tsx @@ -7,6 +7,8 @@ import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server"; import { commitSession, getUserSession } from "~/services/sessionStorage.server"; import { commitAuthenticatedSession } from "~/services/sessionDuration.server"; import { trackAndClearReferralSource } from "~/services/referralSource.server"; +import { appendRedirectTo, ssoRedirectFromAuthError } from "~/services/ssoAutoDiscovery.server"; +import type { AuthUser } from "~/services/authUser"; import { redirectCookie } from "./auth.github"; import { sanitizeRedirectPath } from "~/utils"; @@ -15,9 +17,26 @@ export let loader: LoaderFunction = async ({ request }) => { const redirectValue = await redirectCookie.parse(cookie); const redirectTo = sanitizeRedirectPath(redirectValue); - const auth = await authenticator.authenticate("github", request, { - failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response - }); + // The SSO auto-discovery gate runs inside the strategy's verify + // callback (before any account write), so an SSO-enforced domain + // throws out here instead of linking the GitHub identity. remix-auth + // surfaces its own OAuth redirects by throwing Responses — pass those + // through; an SsoRequiredError becomes the SSO redirect. + let auth: AuthUser; + try { + // throwOnError so a verify-callback throw surfaces as an + // AuthorizationError (carrying the SsoRequiredError as `cause`) + // rather than being flattened into a bare 401 Response — otherwise + // the SSO-enforced redirect below is never reached. + auth = await authenticator.authenticate("github", request, { throwOnError: true }); + } catch (thrown) { + if (thrown instanceof Response) throw thrown; + const ssoRedirect = ssoRedirectFromAuthError(thrown); + if (ssoRedirect) { + return redirect(appendRedirectTo(ssoRedirect, redirectTo)); + } + return redirect("/login"); + } const session = await getUserSession(request); diff --git a/apps/webapp/app/routes/auth.google.callback.tsx b/apps/webapp/app/routes/auth.google.callback.tsx index e065a9de58e..f2e02173749 100644 --- a/apps/webapp/app/routes/auth.google.callback.tsx +++ b/apps/webapp/app/routes/auth.google.callback.tsx @@ -7,6 +7,8 @@ import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server"; import { commitSession, getUserSession } from "~/services/sessionStorage.server"; import { commitAuthenticatedSession } from "~/services/sessionDuration.server"; import { trackAndClearReferralSource } from "~/services/referralSource.server"; +import { appendRedirectTo, ssoRedirectFromAuthError } from "~/services/ssoAutoDiscovery.server"; +import type { AuthUser } from "~/services/authUser"; import { redirectCookie } from "./auth.google"; import { sanitizeRedirectPath } from "~/utils"; @@ -15,9 +17,26 @@ export let loader: LoaderFunction = async ({ request }) => { const redirectValue = await redirectCookie.parse(cookie); const redirectTo = sanitizeRedirectPath(redirectValue); - const auth = await authenticator.authenticate("google", request, { - failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response - }); + // The SSO auto-discovery gate runs inside the strategy's verify + // callback (before any account write), so an SSO-enforced domain + // throws out here instead of linking the Google identity. remix-auth + // surfaces its own OAuth redirects by throwing Responses — pass those + // through; an SsoRequiredError becomes the SSO redirect. + let auth: AuthUser; + try { + // throwOnError so a verify-callback throw surfaces as an + // AuthorizationError (carrying the SsoRequiredError as `cause`) + // rather than being flattened into a bare 401 Response — otherwise + // the SSO-enforced redirect below is never reached. + auth = await authenticator.authenticate("google", request, { throwOnError: true }); + } catch (thrown) { + if (thrown instanceof Response) throw thrown; + const ssoRedirect = ssoRedirectFromAuthError(thrown); + if (ssoRedirect) { + return redirect(appendRedirectTo(ssoRedirect, redirectTo)); + } + return redirect("/login"); + } const session = await getUserSession(request); diff --git a/apps/webapp/app/routes/auth.sso.callback.tsx b/apps/webapp/app/routes/auth.sso.callback.tsx new file mode 100644 index 00000000000..9258f7c2b0d --- /dev/null +++ b/apps/webapp/app/routes/auth.sso.callback.tsx @@ -0,0 +1,110 @@ +import { redirect, type LoaderFunctionArgs } from "@remix-run/node"; +import type { SsoFlow, SsoProfile } from "@trigger.dev/plugins"; +import type { AuthUser } from "~/services/authUser"; +import { prisma } from "~/db.server"; +import { redirectWithErrorMessage } from "~/models/message.server"; +import { authenticator } from "~/services/auth.server"; +import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server"; +import { logger } from "~/services/logger.server"; +import { ssoController } from "~/services/sso.server"; +import { commitAuthenticatedSession } from "~/services/sessionDuration.server"; +import { commitSession, getUserSession } from "~/services/sessionStorage.server"; + +// Resolve the SSO completion for either the SP-initiated (state present) +// or IdP-initiated (no state) flow. Throws a redirect to the error page +// on failure, letting the caller stay on the happy path. Returning a +// single shape here is what lets the loader use a plain destructure +// rather than three conditionally-assigned `let`s. +async function resolveSsoCompletion( + code: string, + state: string | null +): Promise<{ profile: SsoProfile; redirectTo: string; flow: SsoFlow }> { + if (state) { + const completion = await ssoController.completeAuthorization({ code, state }); + if (completion.isErr()) { + logger.warn("SSO callback failed", { reason: completion.error, idpInitiated: false }); + throw redirect(`/login/sso?error=sso_failed`); + } + return completion.value; + } + + const completion = await ssoController.completeIdpInitiatedAuthorization({ code }); + if (completion.isErr()) { + logger.warn("SSO callback failed", { reason: completion.error, idpInitiated: true }); + throw redirect(`/login/sso?error=sso_failed`); + } + return { ...completion.value, flow: "idp_initiated" }; +} + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + if (!code) { + return redirect(`/login/sso?error=missing_code`); + } + const state = url.searchParams.get("state"); + + const { profile, redirectTo, flow } = await resolveSsoCompletion(code, state); + + // `throwOnError` makes the SSO strategy's verify-callback failures + // (resolveSsoIdentity errors, DB failures in findOrCreateSsoUser, + // ensureOrgMember) surface as a thrown AuthorizationError rather than a + // redirect. Without this catch they'd 500; mirror the GitHub/Google + // callbacks and redirect back to the SSO error page instead. remix-auth + // signals its own redirects by throwing Responses — pass those through. + let auth: AuthUser; + try { + auth = await authenticator.authenticate("sso", request, { + throwOnError: true, + context: { profile, flow }, + }); + } catch (thrown) { + if (thrown instanceof Response) throw thrown; + logger.warn("SSO authentication failed", { error: thrown }); + return redirect(`/login/sso?error=sso_failed`); + } + + const session = await getUserSession(request); + + const userRecord = await prisma.user.findFirst({ + where: { id: auth.userId }, + select: { id: true, mfaEnabledAt: true }, + }); + if (!userRecord) { + return redirectWithErrorMessage( + "/login", + request, + "Could not find your account. Please contact support." + ); + } + + if (userRecord.mfaEnabledAt) { + session.set("pending-mfa-user-id", userRecord.id); + session.set("pending-mfa-redirect-to", redirectTo); + // Carry the SSO marker through the MFA hop so the final session is + // revalidated against the IdP exactly like a non-MFA SSO session. + session.set("pending-sso", { + idpOrgId: profile.idpOrgId, + connectionId: profile.idpConnectionId, + }); + + const headers = new Headers(); + headers.append("Set-Cookie", await commitSession(session)); + headers.append("Set-Cookie", await setLastAuthMethodHeader("sso")); + return redirect("/login/mfa", { headers }); + } + + // Mark the session as SSO-established so the periodic re-validation + // hook knows to check it against the IdP. The marker is signed into + // the cookie (tamper-proof). + session.set(authenticator.sessionKey, { + ...auth, + sso: { idpOrgId: profile.idpOrgId, connectionId: profile.idpConnectionId }, + }); + + const headers = new Headers(); + headers.append("Set-Cookie", await commitAuthenticatedSession(session, auth.userId)); + headers.append("Set-Cookie", await setLastAuthMethodHeader("sso")); + + return redirect(redirectTo, { headers }); +} diff --git a/apps/webapp/app/routes/auth.sso.ts b/apps/webapp/app/routes/auth.sso.ts new file mode 100644 index 00000000000..c1eb6113451 --- /dev/null +++ b/apps/webapp/app/routes/auth.sso.ts @@ -0,0 +1,78 @@ +import { redirect, type ActionFunctionArgs } from "@remix-run/node"; +import { tryCatch } from "@trigger.dev/core/v3"; +import { SSO_FLOWS, type SsoFlow } from "@trigger.dev/plugins"; +import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; +import { ssoController } from "~/services/sso.server"; +import { + checkSsoEmailRateLimit, + checkSsoIpRateLimit, + SsoRateLimitError, +} from "~/services/ssoRateLimiter.server"; +import { extractClientIp } from "~/utils/extractClientIp.server"; +import { sanitizeRedirectPath } from "~/utils"; + +const VALID_FLOWS: ReadonlySet = new Set(SSO_FLOWS); + +function isSsoFlow(value: string): value is SsoFlow { + return VALID_FLOWS.has(value as SsoFlow); +} + +export async function action({ request }: ActionFunctionArgs) { + if (request.method !== "POST") { + return new Response(null, { status: 405 }); + } + + const form = await request.formData(); + const rawEmail = form.get("email"); + if (typeof rawEmail !== "string" || rawEmail.trim().length === 0) { + return redirect("/login/sso?error=missing_email"); + } + const email = rawEmail.toLowerCase().trim(); + + const rawRedirectTo = form.get("redirectTo"); + const redirectTo = + sanitizeRedirectPath(typeof rawRedirectTo === "string" ? rawRedirectTo : null) ?? "/"; + const rawFlow = (form.get("flow") as string | null) ?? "user_initiated"; + const flow: SsoFlow = isSsoFlow(rawFlow) ? rawFlow : "user_initiated"; + + if (env.LOGIN_RATE_LIMITS_ENABLED) { + const xff = request.headers.get("x-forwarded-for"); + const clientIp = extractClientIp(xff); + const [rateError] = await tryCatch( + Promise.all([ + clientIp ? checkSsoIpRateLimit(clientIp) : Promise.resolve(), + checkSsoEmailRateLimit(email), + ]) + ); + if (rateError) { + if (rateError instanceof SsoRateLimitError) { + logger.warn("SSO login rate limit exceeded", { clientIp, email }); + } else { + logger.error("SSO login rate limiter failed", { clientIp, email, error: rateError }); + } + return redirect(`/login/sso?email=${encodeURIComponent(email)}&error=rate_limited`); + } + } + + // decideRouteForEmail is the auto-discovery gate — "should I redirect + // a magic-link / OAuth attempt to SSO?" That gate requires + // enforced=true. user_initiated means the user explicitly chose SSO, + // so enforcement is irrelevant; we just need a configured domain, + // which beginAuthorization itself validates (returns + // no_org_for_domain / no_active_connection). + if (flow !== "user_initiated") { + const decision = await ssoController.decideRouteForEmail(email); + if (decision.isErr() || decision.value.kind === "no_sso") { + return redirect(`/login/sso?email=${encodeURIComponent(email)}&error=no_sso_for_domain`); + } + } + + const begun = await ssoController.beginAuthorization({ email, redirectTo, flow }); + if (begun.isErr()) { + logger.warn("SSO beginAuthorization failed", { reason: begun.error, email, flow }); + return redirect(`/login/sso?email=${encodeURIComponent(email)}&error=${begun.error}`); + } + + return redirect(begun.value.url); +} diff --git a/apps/webapp/app/routes/login._index/route.tsx b/apps/webapp/app/routes/login._index/route.tsx index 72901fa5ddb..cba10d5f11c 100644 --- a/apps/webapp/app/routes/login._index/route.tsx +++ b/apps/webapp/app/routes/login._index/route.tsx @@ -1,4 +1,4 @@ -import { EnvelopeIcon } from "@heroicons/react/20/solid"; +import { EnvelopeIcon, LockClosedIcon } from "@heroicons/react/20/solid"; import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; import { Form } from "@remix-run/react"; import { GitHubLightIcon } from "@trigger.dev/companyicons"; @@ -7,23 +7,34 @@ import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; import { GoogleLogo } from "~/assets/logos/GoogleLogo"; import { LoginPageLayout } from "~/components/LoginPageLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormError } from "~/components/primitives/FormError"; import { Header1 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; import { TextLink } from "~/components/primitives/TextLink"; +import { featuresForRequest } from "~/features.server"; import { isGithubAuthSupported, isGoogleAuthSupported } from "~/services/auth.server"; import { getLastAuthMethod } from "~/services/lastAuthMethod.server"; import { commitSession, setRedirectTo } from "~/services/redirectTo.server"; import { getUserId } from "~/services/session.server"; import { getUserSession } from "~/services/sessionStorage.server"; +import { ssoController } from "~/services/sso.server"; +import { flags as getGlobalFlags } from "~/v3/featureFlags.server"; import { requestUrl } from "~/utils/requestUrl.server"; +import { SSO_SESSION_EXPIRED_REASON } from "~/utils/ssoSession"; +import { cn } from "~/utils/cn"; -function LastUsedBadge() { +function LastUsedBadge({ className }: { className?: string }) { const shouldReduceMotion = useReducedMotion(); return ( -
+
).hasSso === true; + } + if (redirectTo) { const session = await setRedirectTo(request, redirectTo); @@ -78,8 +106,10 @@ export async function loader({ request }: LoaderFunctionArgs) { redirectTo, showGithubAuth: isGithubAuthSupported, showGoogleAuth: isGoogleAuthSupported, + showSsoAuth, lastAuthMethod, authError: null, + notice, isVercelMarketplace: redirectTo.startsWith("/vercel/callback"), }, { @@ -105,8 +135,10 @@ export async function loader({ request }: LoaderFunctionArgs) { redirectTo: null, showGithubAuth: isGithubAuthSupported, showGoogleAuth: isGoogleAuthSupported, + showSsoAuth, lastAuthMethod, authError, + notice, isVercelMarketplace: false, }); } @@ -124,6 +156,11 @@ export default function LoginPage() { Create an account or login + {data.notice && ( + + {data.notice} + + )}
{data.showGithubAuth && ( @@ -181,6 +218,26 @@ export default function LoginPage() {
)} + {data.showSsoAuth && !data.isVercelMarketplace && ( +
+
+
+ {data.lastAuthMethod === "sso" && } + + + Sign in with SSO + +
+
+ )} {data.authError && {data.authError}}
diff --git a/apps/webapp/app/routes/login.magic/route.tsx b/apps/webapp/app/routes/login.magic/route.tsx index 06523b3d8c5..7e7d544da63 100644 --- a/apps/webapp/app/routes/login.magic/route.tsx +++ b/apps/webapp/app/routes/login.magic/route.tsx @@ -22,7 +22,11 @@ import { Spinner } from "~/components/primitives/Spinner"; import { TextLink } from "~/components/primitives/TextLink"; import { authenticator } from "~/services/auth.server"; import { commitSession, getUserSession } from "~/services/sessionStorage.server"; -import { setRedirectTo, commitSession as commitRedirectSession } from "~/services/redirectTo.server"; +import { + setRedirectTo, + getRedirectTo, + commitSession as commitRedirectSession, +} from "~/services/redirectTo.server"; import { sanitizeRedirectPath } from "~/utils"; import { checkMagicLinkEmailRateLimit, @@ -30,6 +34,7 @@ import { MagicLinkRateLimitError, checkMagicLinkIpRateLimit, } from "~/services/magicLinkRateLimiter.server"; +import { ssoRedirectForEmail } from "~/services/ssoAutoDiscovery.server"; import { logger, tryCatch } from "@trigger.dev/core/v3"; import { env } from "~/env.server"; import { extractClientIp } from "~/utils/extractClientIp.server"; @@ -130,55 +135,63 @@ export async function action({ request }: ActionFunctionArgs) { switch (data.action) { case "send": { - if (!env.LOGIN_RATE_LIMITS_ENABLED) { - return authenticator.authenticate("email-link", request, { - successRedirect: "/login/magic", - failureRedirect: "/login/magic", - }); - } - const { email } = data; - const xff = request.headers.get("x-forwarded-for"); - const clientIp = extractClientIp(xff); - const [error] = await tryCatch( - Promise.all([ - clientIp ? checkMagicLinkIpRateLimit(clientIp) : Promise.resolve(), - checkMagicLinkEmailRateLimit(email), - checkMagicLinkEmailDailyRateLimit(email), - ]) - ); + if (env.LOGIN_RATE_LIMITS_ENABLED) { + const xff = request.headers.get("x-forwarded-for"); + const clientIp = extractClientIp(xff); + + const [error] = await tryCatch( + Promise.all([ + clientIp ? checkMagicLinkIpRateLimit(clientIp) : Promise.resolve(), + checkMagicLinkEmailRateLimit(email), + checkMagicLinkEmailDailyRateLimit(email), + ]) + ); + + if (error) { + if (error instanceof MagicLinkRateLimitError) { + logger.warn("Login magic link rate limit exceeded", { + clientIp, + email, + error, + }); + } else { + logger.error("Failed sending login magic link", { + clientIp, + email, + error, + }); + } - if (error) { - if (error instanceof MagicLinkRateLimitError) { - logger.warn("Login magic link rate limit exceeded", { - clientIp, - email, - error, + const errorMessage = + error instanceof MagicLinkRateLimitError + ? "Too many magic link requests. Please try again shortly." + : "Failed sending magic link. Please try again shortly."; + + const session = await getUserSession(request); + session.set("auth:error", { + message: errorMessage, }); - } else { - logger.error("Failed sending login magic link", { - clientIp, - email, - error, + + return redirect("/login/magic", { + headers: { + "Set-Cookie": await commitSession(session), + }, }); } + } - const errorMessage = - error instanceof MagicLinkRateLimitError - ? "Too many magic link requests. Please try again shortly." - : "Failed sending magic link. Please try again shortly."; - - const session = await getUserSession(request); - session.set("auth:error", { - message: errorMessage, - }); - - return redirect("/login/magic", { - headers: { - "Set-Cookie": await commitSession(session), - }, - }); + // SSO auto-discovery AFTER rate limiting: this is a DB lookup on + // attacker-controlled input, and the redirect-vs-send response is + // a domain-enumeration oracle — both need the limiter in front. + // Carry the user's original destination (stored in the redirect + // cookie by the loader) through the SSO handoff so they land where + // they meant to after authenticating, not on `/`. + const redirectTo = await getRedirectTo(request); + const ssoRedirect = await ssoRedirectForEmail(email, "domain_policy", redirectTo); + if (ssoRedirect) { + return redirect(ssoRedirect); } return authenticator.authenticate("email-link", request, { diff --git a/apps/webapp/app/routes/login.mfa/route.tsx b/apps/webapp/app/routes/login.mfa/route.tsx index 67006c37482..c75bee54970 100644 --- a/apps/webapp/app/routes/login.mfa/route.tsx +++ b/apps/webapp/app/routes/login.mfa/route.tsx @@ -155,7 +155,11 @@ export async function action({ request }: ActionFunctionArgs) { async function completeLogin(request: Request, session: Session, userId: string) { // Set the auth key on the same session object to avoid conflicting Set-Cookie headers // (both authSession and session share the same __session cookie name) - session.set(authenticator.sessionKey, { userId }); + const pendingSso = session.get("pending-sso") as + | { idpOrgId: string; connectionId: string } + | undefined; + session.set(authenticator.sessionKey, pendingSso ? { userId, sso: pendingSso } : { userId }); + session.unset("pending-sso"); // Get the redirect URL and clean up pending MFA data const redirectTo = session.get("pending-mfa-redirect-to") ?? "/"; diff --git a/apps/webapp/app/routes/login.sso/route.tsx b/apps/webapp/app/routes/login.sso/route.tsx new file mode 100644 index 00000000000..0fc7bc3bb54 --- /dev/null +++ b/apps/webapp/app/routes/login.sso/route.tsx @@ -0,0 +1,166 @@ +import { ArrowLeftIcon, LockClosedIcon } from "@heroicons/react/20/solid"; +import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; +import { Form, useNavigation } from "@remix-run/react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { LoginPageLayout } from "~/components/LoginPageLayout"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormError } from "~/components/primitives/FormError"; +import { Header1 } from "~/components/primitives/Headers"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Spinner } from "~/components/primitives/Spinner"; +import { authenticator } from "~/services/auth.server"; + +type Reason = "default" | "domain_policy" | "oauth_blocked" | "expired"; + +const REASON_VALUES: ReadonlySet = new Set([ + "default", + "domain_policy", + "oauth_blocked", + "expired", +]); + +function parseReason(value: string | null): Reason { + if (!value) return "default"; + return REASON_VALUES.has(value as Reason) ? (value as Reason) : "default"; +} + +const CONTENT: Record = { + default: { + heading: "Sign in with SSO", + body: "Enter your work email.", + }, + domain_policy: { + heading: "SSO required", + body: + "Trigger.dev couldn't send a magic link because your organization requires single sign-on. Continue to your identity provider.", + }, + oauth_blocked: { + heading: "SSO required", + body: + "You can't use that provider to sign in — your organization requires SSO. Continue with your identity provider.", + }, + expired: { + heading: "Login attempt timed out", + body: "Your SSO login attempt expired. Click Try again to restart.", + }, +}; + +const ERROR_MESSAGES: Record = { + missing_email: "Please enter your work email.", + no_sso_for_domain: + "We couldn't find an SSO configuration for that email's domain. Try a different login method.", + no_org_for_domain: "We couldn't complete sign-in. Try again or contact your administrator.", + no_active_connection: "Your organization doesn't have an active SSO connection yet.", + feature_disabled: "SSO is not currently available.", + rate_limited: "Too many SSO sign-in attempts. Please try again shortly.", + sso_failed: "We couldn't complete sign-in. Try again.", + missing_code: "We couldn't complete sign-in. Try again.", +}; + +export const meta: MetaFunction = () => [ + { title: "Sign in with SSO – Trigger.dev" }, + { name: "viewport", content: "width=device-width,initial-scale=1" }, +]; + +export async function loader({ request }: LoaderFunctionArgs) { + // Already-authenticated users have no business on the SSO form — bounce + // them home, mirroring the /login/magic loader guard. Combined with + // /login/sso being non-navigable, a crafted `?redirectTo=/login/sso` + // can't strand a signed-in user here either. + await authenticator.isAuthenticated(request, { + successRedirect: "/", + }); + + const url = new URL(request.url); + const reason = parseReason(url.searchParams.get("reason")); + const email = url.searchParams.get("email") ?? ""; + const errorCode = url.searchParams.get("error"); + const redirectTo = url.searchParams.get("redirectTo") ?? "/"; + + return typedjson({ + reason, + email, + redirectTo, + errorMessage: errorCode ? (ERROR_MESSAGES[errorCode] ?? "We couldn't complete sign-in. Try again.") : null, + }); +} + +export default function LoginSsoPage() { + const { reason, email, redirectTo, errorMessage } = useTypedLoaderData(); + const navigation = useNavigation(); + const isLoading = + (navigation.state === "loading" || navigation.state === "submitting") && + navigation.formAction === "/auth/sso"; + + const content = CONTENT[reason]; + const emailReadOnly = reason === "oauth_blocked"; + + return ( + +
+ + +
+ + {content.heading} + + + {content.body} + +
+ + + + + + + {errorMessage && {errorMessage}} +
+ + + All login options + +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/logout.tsx b/apps/webapp/app/routes/logout.tsx index bd7cd1394b1..20b4705f352 100644 --- a/apps/webapp/app/routes/logout.tsx +++ b/apps/webapp/app/routes/logout.tsx @@ -1,10 +1,22 @@ import { type ActionFunction, type LoaderFunction } from "@remix-run/node"; import { authenticator } from "~/services/auth.server"; +import { sanitizeRedirectPath } from "~/utils"; +import { SSO_SESSION_EXPIRED_REASON } from "~/utils/ssoSession"; + +function logoutRedirectTo(request: Request): string { + const url = new URL(request.url); + // Trusted internal constant — bypasses sanitizeRedirectPath, which rejects + // /login as a navigable target. + if (url.searchParams.get("reason") === SSO_SESSION_EXPIRED_REASON) { + return `/login?reason=${SSO_SESSION_EXPIRED_REASON}`; + } + return sanitizeRedirectPath(url.searchParams.get("redirectTo"), "/"); +} export const action: ActionFunction = async ({ request }) => { - return await authenticator.logout(request, { redirectTo: "/" }); + return await authenticator.logout(request, { redirectTo: logoutRedirectTo(request) }); }; export const loader: LoaderFunction = async ({ request }) => { - return await authenticator.logout(request, { redirectTo: "/" }); + return await authenticator.logout(request, { redirectTo: logoutRedirectTo(request) }); }; diff --git a/apps/webapp/app/routes/magic.tsx b/apps/webapp/app/routes/magic.tsx index d4606e1b7de..d2b7f2ee361 100644 --- a/apps/webapp/app/routes/magic.tsx +++ b/apps/webapp/app/routes/magic.tsx @@ -8,6 +8,8 @@ import { getRedirectTo } from "~/services/redirectTo.server"; import { commitSession, getSession } from "~/services/sessionStorage.server"; import { commitAuthenticatedSession } from "~/services/sessionDuration.server"; import { trackAndClearReferralSource } from "~/services/referralSource.server"; +import { appendRedirectTo, ssoRedirectFromAuthError } from "~/services/ssoAutoDiscovery.server"; +import type { AuthUser } from "~/services/authUser"; import { sanitizeRedirectPath } from "~/utils"; export async function loader({ request }: LoaderFunctionArgs) { @@ -16,9 +18,34 @@ export async function loader({ request }: LoaderFunctionArgs) { const sanitized = sanitizeRedirectPath(await getRedirectTo(request)); const redirectTo = sanitized === "/" ? undefined : sanitized; - const auth = await authenticator.authenticate("email-link", request, { - failureRedirect: "/login/magic", // If auth fails, the failureRedirect will be thrown as a Response - }); + // The magic-link verify callback runs the SSO gate before any account + // write, so an SSO-enforced domain throws out here. remix-auth's own + // redirects are thrown Responses — pass those through. + let auth: AuthUser; + try { + auth = await authenticator.authenticate("email-link", request); + } catch (thrown) { + if (thrown instanceof Response) throw thrown; + const ssoRedirect = ssoRedirectFromAuthError(thrown); + if (ssoRedirect) { + return redirect(appendRedirectTo(ssoRedirect, redirectTo)); + } + // Without `failureRedirect`, remix-auth no longer flashes the verify + // error onto the session before throwing — so flash it here under the + // same `auth:error` key the /login/magic loader reads. Otherwise an + // expired/invalid link silently re-renders the email form with no + // indication of what went wrong. + const session = await getSession(request.headers.get("cookie")); + session.set("auth:error", { + message: + thrown instanceof Error + ? thrown.message + : "Your magic link is invalid or has expired.", + }); + return redirect("/login/magic", { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } // manually get the session const session = await getSession(request.headers.get("cookie")); diff --git a/apps/webapp/app/routes/resources.session-check.ts b/apps/webapp/app/routes/resources.session-check.ts new file mode 100644 index 00000000000..dce4c4e115b --- /dev/null +++ b/apps/webapp/app/routes/resources.session-check.ts @@ -0,0 +1,10 @@ +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import { requireUserId } from "~/services/session.server"; + +// Authenticated probe for transports that can't read response headers +// (EventSource): requireUserId runs the SSO revalidation hook, so a revoked +// session returns the 401 + marker header that the client guard acts on. +export async function loader({ request }: LoaderFunctionArgs) { + await requireUserId(request); + return json({ ok: true }); +} diff --git a/apps/webapp/app/routes/vercel.onboarding.tsx b/apps/webapp/app/routes/vercel.onboarding.tsx index 1cf351532e9..d7b8798fc5e 100644 --- a/apps/webapp/app/routes/vercel.onboarding.tsx +++ b/apps/webapp/app/routes/vercel.onboarding.tsx @@ -19,6 +19,7 @@ import { ButtonSpinner } from "~/components/primitives/Spinner"; import { prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; +import { ssoRedirectForEmail } from "~/services/ssoAutoDiscovery.server"; import { confirmBasicDetailsPath, newProjectPath } from "~/utils/pathBuilder"; import { redirectWithErrorMessage } from "~/models/message.server"; import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; @@ -192,6 +193,38 @@ export async function action({ request }: ActionFunctionArgs) { return json({ error: "Invalid submission" }, { status: 400 }); } + // SSO auto-discovery: if the signed-in user's domain requires SSO, the + // current session was established via a non-SSO method — block the + // onboarding action and route them through the SSO flow instead. + const sessionUser = await prisma.user.findFirst({ + where: { id: userId }, + select: { email: true }, + }); + if (sessionUser?.email) { + // Preserve the in-progress Vercel install across the SSO handoff: + // rebuild the onboarding URL (same shape the org-step redirect below + // uses) and pass it as `redirectTo` so the single-use `code`, + // `configurationId`, and `next` aren't lost when the user is bounced + // to their identity provider. + const resumeParams = new URLSearchParams(); + resumeParams.set("code", submission.data.code); + if (submission.data.configurationId) { + resumeParams.set("configurationId", submission.data.configurationId); + } + if (submission.data.next) { + resumeParams.set("next", submission.data.next); + } + const resumeUrl = `/vercel/onboarding?${resumeParams.toString()}`; + const ssoRedirect = await ssoRedirectForEmail( + sessionUser.email, + "oauth_blocked", + resumeUrl + ); + if (ssoRedirect) { + return redirect(ssoRedirect); + } + } + const { code, configurationId, next } = submission.data; // Handle org selection diff --git a/apps/webapp/app/routes/webhooks.v1.accounts.ts b/apps/webapp/app/routes/webhooks.v1.accounts.ts new file mode 100644 index 00000000000..e868873ded1 --- /dev/null +++ b/apps/webapp/app/routes/webhooks.v1.accounts.ts @@ -0,0 +1,44 @@ +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { logger } from "~/services/logger.server"; +import { ssoController } from "~/services/sso.server"; +import { accountsWebhookWorker } from "~/v3/accountsWebhookWorker.server"; + +// Thin, vendor-neutral passthrough for inbound account-management +// webhooks. This route does NOT verify or interpret the payload — it +// forwards the raw body + headers to the plugin, which owns the +// provider-specific signature scheme, then enqueues the verified event +// for the background worker. When no plugin is installed the controller +// returns `feature_disabled` and we 404 (don't advertise the endpoint). +export async function action({ request }: ActionFunctionArgs) { + if (request.method !== "POST") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + const rawBody = await request.text(); + const headers = Object.fromEntries(request.headers); + + const verified = await ssoController.verifyWebhook({ rawBody, headers }); + + if (verified.isErr()) { + switch (verified.error) { + case "invalid_signature": + return json({ error: "invalid signature" }, { status: 400 }); + case "feature_disabled": + return json({ error: "not found" }, { status: 404 }); + default: + // Transient/internal — let the provider retry. + logger.error("accounts webhook verify failed", { reason: verified.error }); + return json({ error: "internal error" }, { status: 500 }); + } + } + + // Idempotent enqueue keyed on the event id — providers redeliver, so + // dedupe at the door. Processing happens async in accountsWebhookWorker. + await accountsWebhookWorker.enqueueOnce({ + id: verified.value.event.id, + job: "account.webhook.event", + payload: verified.value.event, + }); + + return json({ received: true }, { status: 200 }); +} diff --git a/apps/webapp/app/services/auth.server.ts b/apps/webapp/app/services/auth.server.ts index c5650691012..0c0a276806d 100644 --- a/apps/webapp/app/services/auth.server.ts +++ b/apps/webapp/app/services/auth.server.ts @@ -4,6 +4,7 @@ import { addEmailLinkStrategy } from "./emailAuth.server"; import { addGitHubStrategy } from "./gitHubAuth.server"; import { addGoogleStrategy } from "./googleAuth.server"; import { sessionStorage } from "./sessionStorage.server"; +import { addSsoStrategy } from "./ssoAuth.server"; import { env } from "~/env.server"; // Create an instance of the authenticator, pass a generic with what @@ -27,5 +28,6 @@ if (env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET) { } addEmailLinkStrategy(authenticator); +addSsoStrategy(authenticator); export { authenticator, isGithubAuthSupported, isGoogleAuthSupported }; diff --git a/apps/webapp/app/services/authUser.ts b/apps/webapp/app/services/authUser.ts index 4c1ce6a209b..5109f2c4bc1 100644 --- a/apps/webapp/app/services/authUser.ts +++ b/apps/webapp/app/services/authUser.ts @@ -1,3 +1,11 @@ export type AuthUser = { userId: string; + // Present only when the session was established via SSO. Carries the + // minimum the periodic re-validation hook needs to ask the IdP whether + // the session is still valid. Signed into the session cookie, so it's + // tamper-proof. Absent ⇒ non-SSO session ⇒ never revalidated. + sso?: { + idpOrgId: string; + connectionId: string; + }; }; diff --git a/apps/webapp/app/services/emailAuth.server.tsx b/apps/webapp/app/services/emailAuth.server.tsx index 81d4ffcc18c..9e8fbdb44b4 100644 --- a/apps/webapp/app/services/emailAuth.server.tsx +++ b/apps/webapp/app/services/emailAuth.server.tsx @@ -7,6 +7,7 @@ import type { AuthUser } from "./authUser"; import { logger } from "./logger.server"; import { postAuthentication } from "./postAuth.server"; +import { SsoRequiredError, ssoRedirectForEmail } from "./ssoAutoDiscovery.server"; let secret = env.MAGIC_LINK_SECRET; if (!secret) throw new Error("Missing MAGIC_LINK_SECRET env variable."); @@ -29,6 +30,16 @@ const emailStrategy = new EmailLinkStrategy( }) => { logger.info("Magic link user authenticated", { email, magicLinkVerify }); + // Gate the link CLICK, not just the send: a magic link issued before + // SSO enforcement flipped on (or replayed within its validity + // window) must not mint a session for an enforced domain. + if (magicLinkVerify) { + const ssoRedirect = await ssoRedirectForEmail(email, "domain_policy"); + if (ssoRedirect) { + throw new SsoRequiredError(ssoRedirect); + } + } + try { const { user, isNewUser } = await findOrCreateUser({ email, diff --git a/apps/webapp/app/services/gitHubAuth.server.ts b/apps/webapp/app/services/gitHubAuth.server.ts index 981a22a2d0a..f757a57d83c 100644 --- a/apps/webapp/app/services/gitHubAuth.server.ts +++ b/apps/webapp/app/services/gitHubAuth.server.ts @@ -5,6 +5,7 @@ import { findOrCreateUser } from "~/models/user.server"; import type { AuthUser } from "./authUser"; import { logger } from "./logger.server"; import { postAuthentication } from "./postAuth.server"; +import { SsoRequiredError, ssoRedirectForEmail } from "./ssoAutoDiscovery.server"; export function addGitHubStrategy( authenticator: Authenticator, @@ -24,6 +25,16 @@ export function addGitHubStrategy( throw new Error("GitHub login requires an email address"); } + const email = emails[0].value; + + // SSO auto-discovery gate — BEFORE findOrCreateUser, so an + // SSO-enforced domain never gets this GitHub identity linked onto + // an existing account. + const ssoRedirect = await ssoRedirectForEmail(email, "oauth_blocked"); + if (ssoRedirect) { + throw new SsoRequiredError(ssoRedirect); + } + try { logger.debug("GitHub login", { emails, @@ -32,7 +43,7 @@ export function addGitHubStrategy( }); const { user, isNewUser } = await findOrCreateUser({ - email: emails[0].value, + email, authenticationMethod: "GITHUB", authenticationProfile: profile, authenticationExtraParams: extraParams, diff --git a/apps/webapp/app/services/googleAuth.server.ts b/apps/webapp/app/services/googleAuth.server.ts index bcd227f2e97..d79c4983418 100644 --- a/apps/webapp/app/services/googleAuth.server.ts +++ b/apps/webapp/app/services/googleAuth.server.ts @@ -5,6 +5,7 @@ import { findOrCreateUser } from "~/models/user.server"; import type { AuthUser } from "./authUser"; import { logger } from "./logger.server"; import { postAuthentication } from "./postAuth.server"; +import { SsoRequiredError, ssoRedirectForEmail } from "./ssoAutoDiscovery.server"; export function addGoogleStrategy( authenticator: Authenticator, @@ -24,6 +25,16 @@ export function addGoogleStrategy( throw new Error("Google login requires an email address"); } + const email = emails[0].value; + + // SSO auto-discovery gate — BEFORE findOrCreateUser, so an + // SSO-enforced domain never gets this Google identity linked onto + // an existing account. + const ssoRedirect = await ssoRedirectForEmail(email, "oauth_blocked"); + if (ssoRedirect) { + throw new SsoRequiredError(ssoRedirect); + } + try { logger.debug("Google login", { emails, @@ -32,7 +43,7 @@ export function addGoogleStrategy( }); const { user, isNewUser } = await findOrCreateUser({ - email: emails[0].value, + email, authenticationMethod: "GOOGLE", authenticationProfile: profile, authenticationExtraParams: extraParams, diff --git a/apps/webapp/app/services/lastAuthMethod.server.ts b/apps/webapp/app/services/lastAuthMethod.server.ts index e058ea73a02..6fdbc80917c 100644 --- a/apps/webapp/app/services/lastAuthMethod.server.ts +++ b/apps/webapp/app/services/lastAuthMethod.server.ts @@ -1,7 +1,7 @@ import { createCookie } from "@remix-run/node"; import { env } from "~/env.server"; -export type LastAuthMethod = "github" | "google" | "email"; +export type LastAuthMethod = "github" | "google" | "email" | "sso"; // Cookie that persists for 1 year to remember the user's last login method export const lastAuthMethodCookie = createCookie("last-auth-method", { @@ -14,7 +14,7 @@ export const lastAuthMethodCookie = createCookie("last-auth-method", { export async function getLastAuthMethod(request: Request): Promise { const cookie = request.headers.get("Cookie"); const value = await lastAuthMethodCookie.parse(cookie); - if (value === "github" || value === "google" || value === "email") { + if (value === "github" || value === "google" || value === "email" || value === "sso") { return value; } return null; diff --git a/apps/webapp/app/services/session.server.ts b/apps/webapp/app/services/session.server.ts index 3c55e8efa83..bdd565cf2f9 100644 --- a/apps/webapp/app/services/session.server.ts +++ b/apps/webapp/app/services/session.server.ts @@ -5,6 +5,7 @@ import { extractClientIp } from "~/utils/extractClientIp.server"; import { authenticator } from "./auth.server"; import { getImpersonationId } from "./impersonation.server"; import { logger } from "./logger.server"; +import { revalidateSsoSession } from "./ssoSessionRevalidation.server"; /** * Logs the user out when their session has lived past `User.nextSessionEnd`. @@ -82,6 +83,11 @@ export async function getUserId(request: Request): Promise { // for this path happens in `getUser`, where we already pay for the User // row fetch. `requireUserId` callers stay cookie-only. const authUser = await authenticator.isAuthenticated(request); + // SSO session re-validation runs here so it covers both navigation + // (getUser) and API fetches (requireUserId). It's single-flight and + // throttled, so most requests do nothing; only SSO-marked sessions + // touch Redis. Throws redirect("/logout") if the IdP says invalid. + await revalidateSsoSession(request, authUser); return authUser?.userId; } diff --git a/apps/webapp/app/services/sso.server.ts b/apps/webapp/app/services/sso.server.ts new file mode 100644 index 00000000000..400ff86190e --- /dev/null +++ b/apps/webapp/app/services/sso.server.ts @@ -0,0 +1,22 @@ +import { $replica, prisma } from "~/db.server"; +import type { PrismaClient } from "@trigger.dev/database"; +import sso from "@trigger.dev/sso"; +import { env } from "~/env.server"; + +// sso.create() is synchronous — returns a lazy controller that resolves +// any installed SSO plugin on first call. Top-level await is not used +// because the webapp's CJS build does not support it. +// +// Auth-path reads run on every login attempt — pass the replica +// explicitly so they don't pile up on the primary. Writes (config +// mutations) still go through the primary. +export const ssoController = sso.create( + // $replica is structurally a PrismaClient minus `$transaction`. The + // fallback only uses `findFirst` on it, so the cast is safe. + { primary: prisma, replica: $replica as PrismaClient }, + // SSO_ENABLED is the deploy gate: until it's on, force the OSS + // fallback so the entire SSO surface (login, settings, callback, + // re-validation) stays inert. SSO_FORCE_FALLBACK remains an + // independent contributor/debug override. + { forceFallback: !env.SSO_ENABLED || env.SSO_FORCE_FALLBACK } +); diff --git a/apps/webapp/app/services/ssoAuth.server.ts b/apps/webapp/app/services/ssoAuth.server.ts new file mode 100644 index 00000000000..e27ad41ab76 --- /dev/null +++ b/apps/webapp/app/services/ssoAuth.server.ts @@ -0,0 +1,142 @@ +import type { SessionStorage } from "@remix-run/server-runtime"; +import type { AuthenticateOptions, Authenticator } from "remix-auth"; +import { Strategy } from "remix-auth"; +import type { SsoFlow, SsoProfile } from "@trigger.dev/plugins"; +import { prisma } from "~/db.server"; +import { ensureOrgMember } from "~/models/orgMember.server"; +import { findOrCreateSsoUser } from "~/models/user.server"; +import type { AuthUser } from "./authUser"; +import { logger } from "./logger.server"; +import { postAuthentication } from "./postAuth.server"; +import { ssoController } from "./sso.server"; + +export type SsoVerifyParams = { + profile: SsoProfile; + flow: SsoFlow; +}; + +// Hybrid remix-auth strategy. The strategy is invoked by the callback +// route AFTER it has performed the SSO code exchange via the plugin — +// the route passes the verified profile + flow through +// `authenticator.authenticate("sso", request, { context })`. The +// strategy reads that context and runs the user-resolution side of the +// flow (plugin identity lookups + host-side User/OrgMember writes). +// +// In an OSS deployment with no SSO plugin installed, the plugin's +// `resolveSsoIdentity` returns `feature_disabled` from the fallback, +// which propagates here as a failure. That's the expected behaviour: +// without the plugin there is no callback route invoking the strategy +// in the first place. +class SsoStrategy extends Strategy { + name = "sso"; + + async authenticate( + request: Request, + sessionStorage: SessionStorage, + options: AuthenticateOptions + ): Promise { + const ctx = (options.context ?? undefined) as SsoVerifyParams | undefined; + if (!ctx?.profile || !ctx?.flow) { + return this.failure( + "SSO strategy invoked without profile context", + request, + sessionStorage, + options + ); + } + try { + const user = await this.verify(ctx); + return this.success(user, request, sessionStorage, options); + } catch (error) { + const cause = error instanceof Error ? error : new Error(String(error)); + return this.failure(cause.message, request, sessionStorage, options, cause); + } + } +} + +export function addSsoStrategy(authenticator: Authenticator) { + authenticator.use( + new SsoStrategy(async ({ profile, flow }) => { + const decision = await ssoController.resolveSsoIdentity({ profile }); + if (decision.isErr()) { + // Surfaces "feature_disabled" in OSS deployments. The callback + // route's error path translates this into a generic + // sign-in-failed user-facing message. + throw new Error(`SSO resolve failed: ${decision.error}`); + } + + const value = decision.value; + + let userId: string; + let isNewUser = false; + + if (value.kind === "create_new_user") { + const created = await findOrCreateSsoUser({ + authenticationMethod: "SSO", + email: profile.email, + firstName: profile.firstName, + lastName: profile.lastName, + }); + userId = created.user.id; + isNewUser = created.isNewUser; + } else { + userId = value.userId; + } + + // Best-effort: attaching the IdP identity row is an optimisation + // for the next login (it lets resolveSsoIdentity take the + // existing_user_by_idp fast path instead of falling back to + // linked_by_email). The user is already authenticated by this + // point, so we log and continue rather than failing the sign-in; + // a later successful login will write the row. + const attach = await ssoController.attachSsoIdentity({ userId, profile }); + if (attach.isErr()) { + logger.warn("SSO attachSsoIdentity failed", { + reason: attach.error, + userId, + flow, + }); + } + + const jit = await ssoController.evaluateJit({ + userId, + idpOrgId: profile.idpOrgId, + }); + if (jit.isOk() && jit.value.shouldProvision) { + const result = await ensureOrgMember({ + userId, + organizationId: jit.value.organizationId, + roleId: jit.value.roleId, + source: "sso_jit", + }); + if (!result.created) { + logger.info("SSO JIT skipped — membership already exists", { + userId, + organizationId: jit.value.organizationId, + }); + } + } else if (jit.isErr() && jit.error !== "feature_disabled") { + logger.warn("SSO evaluateJit failed", { reason: jit.error, userId, flow }); + } + + const user = await prisma.user.findFirst({ where: { id: userId } }); + if (user) { + await postAuthentication({ + user, + isNewUser, + loginMethod: "SSO", + }); + } + + // Carry the SSO marker on the returned AuthUser so the session is + // self-describing — `revalidateSsoSession()` keys off `AuthUser.sso`, + // and relying on the callback route to re-attach it would silently + // disable revalidation for any other caller of this strategy. + return { + userId, + sso: { idpOrgId: profile.idpOrgId, connectionId: profile.idpConnectionId }, + }; + }), + "sso" + ); +} diff --git a/apps/webapp/app/services/ssoAutoDiscovery.server.ts b/apps/webapp/app/services/ssoAutoDiscovery.server.ts new file mode 100644 index 00000000000..771a5a70ebd --- /dev/null +++ b/apps/webapp/app/services/ssoAutoDiscovery.server.ts @@ -0,0 +1,90 @@ +import { sanitizeRedirectPath } from "~/utils"; +import { logger } from "./logger.server"; +import { ssoController } from "./sso.server"; + +// Appends the user's original post-login destination to an SSO login URL +// so it survives the SSO round-trip: the `/login/sso` loader reads +// `redirectTo`, threads it through `beginAuthorization`, and the callback +// redirects there on success. A `/` (or empty) destination is the default +// and isn't worth carrying. The value is sanitized to avoid open-redirects +// — callers that already sanitized just pay a cheap idempotent no-op. +export function appendRedirectTo(ssoLoginUrl: string, redirectTo?: string | null): string { + if (!redirectTo) return ssoLoginUrl; + const safe = sanitizeRedirectPath(redirectTo); + if (safe === "/") return ssoLoginUrl; + const sep = ssoLoginUrl.includes("?") ? "&" : "?"; + return `${ssoLoginUrl}${sep}redirectTo=${encodeURIComponent(safe)}`; +} + +// Shared auto-discovery check used by every login path that resolves a +// user identity before establishing a session: the magic-link send path +// (`/login/magic` action), the GitHub + Google OAuth callbacks, and the +// Vercel onboarding action. Each caller invokes this before committing +// the session; on `sso_required` they must short-circuit and redirect +// the user to the SSO flow instead. +// +// Fail-open: a plugin / DB error returns `null` so the original flow +// proceeds. The plugin logs the underlying reason; we additionally log +// here so the call site is obvious in traces. +export async function ssoRedirectForEmail( + email: string, + reason: "domain_policy" | "oauth_blocked", + redirectTo?: string | null +): Promise { + const normalised = email.toLowerCase().trim(); + if (!normalised) return null; + + // Fail-open covers both shapes of failure: a returned `Err` (handled + // below) and a thrown/rejected promise (e.g. the plugin throwing before + // it can build its ResultAsync). Either way the original login flow + // proceeds rather than being blocked by an SSO dependency error. + let decision: Awaited>; + try { + decision = await ssoController.decideRouteForEmail(normalised); + } catch (error) { + logger.warn("SSO auto-discovery fail-open (threw)", { error, email: normalised }); + return null; + } + if (decision.isErr()) { + logger.warn("SSO auto-discovery fail-open", { reason: decision.error, email: normalised }); + return null; + } + if (decision.value.kind !== "sso_required") return null; + + return appendRedirectTo( + `/login/sso?email=${encodeURIComponent(normalised)}&reason=${reason}`, + redirectTo + ); +} + +// Thrown from inside a strategy verify callback when the email's domain +// requires SSO. Must abort BEFORE any account write — blocking only the +// session would still leave the OAuth identity linked onto a user row +// that SSO enforcement was supposed to protect. +export class SsoRequiredError extends Error { + constructor(public readonly redirectTo: string) { + super(`sso_required:${redirectTo}`); + this.name = "SsoRequiredError"; + } +} + +// remix-auth wraps verify-callback throws in AuthorizationError (with +// the original error as `cause`); older strategy versions only preserve +// the message. Handle both. +export function ssoRedirectFromAuthError(thrown: unknown): string | null { + if ( + typeof thrown === "object" && + thrown !== null && + "cause" in thrown && + thrown.cause instanceof SsoRequiredError + ) { + return thrown.cause.redirectTo; + } + if (thrown instanceof SsoRequiredError) { + return thrown.redirectTo; + } + if (thrown instanceof Error && thrown.message.startsWith("sso_required:")) { + return thrown.message.slice("sso_required:".length); + } + return null; +} diff --git a/apps/webapp/app/services/ssoRateLimiter.server.ts b/apps/webapp/app/services/ssoRateLimiter.server.ts new file mode 100644 index 00000000000..262148027e4 --- /dev/null +++ b/apps/webapp/app/services/ssoRateLimiter.server.ts @@ -0,0 +1,67 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { env } from "~/env.server"; +import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiter.server"; +import { singleton } from "~/utils/singleton"; + +export class SsoRateLimitError extends Error { + public readonly retryAfter: number; + + constructor(retryAfter: number) { + super("SSO sign-in rate limit exceeded."); + this.retryAfter = retryAfter; + } +} + +function getRedisClient() { + return createRedisRateLimitClient({ + port: env.RATE_LIMIT_REDIS_PORT, + host: env.RATE_LIMIT_REDIS_HOST, + username: env.RATE_LIMIT_REDIS_USERNAME, + password: env.RATE_LIMIT_REDIS_PASSWORD, + tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true", + clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1", + }); +} + +const ssoEmailRateLimiter = singleton("ssoEmailRateLimiter", initializeEmailLimiter); +const ssoIpRateLimiter = singleton("ssoIpRateLimiter", initializeIpLimiter); + +function initializeEmailLimiter() { + return new RateLimiter({ + redisClient: getRedisClient(), + keyPrefix: "auth:sso:email", + limiter: Ratelimit.slidingWindow(5, "1 m"), + logSuccess: false, + logFailure: true, + }); +} + +function initializeIpLimiter() { + return new RateLimiter({ + redisClient: getRedisClient(), + keyPrefix: "auth:sso:ip", + limiter: Ratelimit.slidingWindow(20, "1 m"), + logSuccess: false, + logFailure: true, + }); +} + +export async function checkSsoEmailRateLimit(identifier: string): Promise { + const result = await ssoEmailRateLimiter.limit(identifier); + if (!result.success) { + // Clamp: `reset` can already be in the past by the time we read it, + // which would otherwise yield a negative Retry-After. + const retryAfter = Math.max(0, new Date(result.reset).getTime() - Date.now()); + throw new SsoRateLimitError(retryAfter); + } +} + +export async function checkSsoIpRateLimit(ip: string): Promise { + const result = await ssoIpRateLimiter.limit(ip); + if (!result.success) { + // Clamp: `reset` can already be in the past by the time we read it, + // which would otherwise yield a negative Retry-After. + const retryAfter = Math.max(0, new Date(result.reset).getTime() - Date.now()); + throw new SsoRateLimitError(retryAfter); + } +} diff --git a/apps/webapp/app/services/ssoSessionRevalidation.server.ts b/apps/webapp/app/services/ssoSessionRevalidation.server.ts new file mode 100644 index 00000000000..230526ad99d --- /dev/null +++ b/apps/webapp/app/services/ssoSessionRevalidation.server.ts @@ -0,0 +1,162 @@ +import { json, redirect } from "@remix-run/node"; +import { env } from "~/env.server"; +import { createRedisClient } from "~/redis.server"; +import { singleton } from "~/utils/singleton"; +import { + SSO_SESSION_INVALIDATED_HEADER, + ssoSessionExpiredLogoutPath, +} from "~/utils/ssoSession"; +import type { AuthUser } from "./authUser"; +import { logger } from "./logger.server"; +import { ssoController } from "./sso.server"; + +// Dedicated Redis client for the single-flight throttle. Reuses the +// shared REDIS_* connection (same wiring the other simple shared-state +// services use). +const redis = singleton("ssoRevalidationRedis", () => + createRedisClient("trigger:ssoRevalidation", { + host: env.REDIS_HOST, + port: env.REDIS_PORT, + username: env.REDIS_USERNAME, + password: env.REDIS_PASSWORD, + tlsDisabled: env.REDIS_TLS_DISABLED === "true", + }) +); + +function revalidationKey(userId: string): string { + return `sso:reval:${userId}`; +} + +// Module-scoped so it's a unique symbol — lets the Promise.race result be +// narrowed cleanly between "timed out" and the plugin's Result. +const REVALIDATION_TIMEOUT = Symbol("sso-revalidation-timeout"); + +/** + * Periodically re-validate an SSO-established session against the IdP. + * + * Called from the session read path on every authenticated request, but: + * - returns immediately unless the SSO feature is enabled AND the + * session carries the `sso` marker (non-SSO sessions pay nothing — no + * Redis round-trip); + * - is single-flight via a Redis `SET key 1 NX EX `: only the + * first request per interval window actually calls the SSO plugin, + * concurrent requests see the key and skip; + * - fails OPEN — any error (Redis or the plugin) keeps the session + * alive. Only an explicit `{ valid: false }` triggers logout. + * + * Throws `redirect("/logout")` when the session is confirmed invalid, + * mirroring how `maybeAutoLogout` terminates a session from this path. + */ +export async function revalidateSsoSession( + request: Request, + authUser: AuthUser | null | undefined +): Promise { + // Deploy gate + SSO-session gate. + if (!env.SSO_ENABLED) return; + if (!authUser?.sso) return; + + // Never revalidate on /logout itself — the loader there must be allowed + // to destroy the cookie rather than redirect in a loop. + if (new URL(request.url).pathname === "/logout") return; + + const interval = env.SSO_SESSION_REVALIDATION_INTERVAL_SECONDS; + const key = revalidationKey(authUser.userId); + + // Single-flight: acquire the window. Only the request that sets the + // key (NX) proceeds to the actual check; everyone else this window + // treats the session as valid. + let acquired: "OK" | null; + try { + acquired = await redis.set(key, "1", "EX", interval, "NX"); + } catch (error) { + // Redis unavailable → fail-open, don't block the request. + logger.warn("SSO revalidation: redis SET NX failed; skipping", { error }); + return; + } + if (acquired !== "OK") return; + + // Hard 2s (env-configurable) timeout on the plugin round-trip so a slow + // or hung SSO dependency can never block the request. On timeout we fail + // OPEN (keep the session + the throttle key) and emit a stable + // `sso.revalidation.timeout` warn for alerting. + const timeoutMs = env.SSO_SESSION_REVALIDATION_TIMEOUT_MS; + let timer: ReturnType | undefined; + let result: Awaited> | typeof REVALIDATION_TIMEOUT; + try { + result = await Promise.race([ + // ResultAsync is a PromiseLike; Promise.resolve unwraps it to a Result. + Promise.resolve( + ssoController.validateSession({ + userId: authUser.userId, + idpOrgId: authUser.sso.idpOrgId, + connectionId: authUser.sso.connectionId, + }) + ), + new Promise((resolve) => { + timer = setTimeout(() => resolve(REVALIDATION_TIMEOUT), timeoutMs); + }), + ]); + } catch (error) { + // A ResultAsync resolves to an Err rather than rejecting, but guard + // against a synchronous throw / rejected promise from the plugin all + // the same — fail OPEN (keep the session + the throttle key) exactly + // like the Err branch below. + if (timer) clearTimeout(timer); + logger.warn("SSO revalidation threw; failing open (session kept alive)", { + userId: authUser.userId, + error, + }); + return; + } + if (timer) clearTimeout(timer); + + if (result === REVALIDATION_TIMEOUT) { + logger.warn("SSO revalidation timed out; failing open (session kept alive)", { + event: "sso.revalidation.timeout", + userId: authUser.userId, + timeoutMs, + }); + return; + } + + if (result.isErr()) { + // Fail-open: keep the session, and keep the throttle key so we don't + // hammer the plugin while the dependency is unhealthy. + logger.warn("SSO revalidation errored; failing open (session kept alive)", { + userId: authUser.userId, + reason: result.error, + }); + return; + } + + if (result.value.valid) return; // still valid — TTL governs the next check + + // Confirmed invalid. Clear the throttle so other tabs/requests for this + // user re-check (and log out) on their next request instead of waiting + // for the TTL, then terminate this session. + try { + await redis.del(key); + } catch { + // best-effort; the key expires on its own anyway + } + logger.info("SSO revalidation: session invalid, logging out", { + userId: authUser.userId, + }); + + // Navigations get the logout redirect; programmatic/API fetches can't + // follow a 302-to-HTML, so they get a 401 carrying the marker header that + // the client fetch guard turns into the same redirect. + const url = new URL(request.url); + const isRemixDataRequest = url.searchParams.has("_data"); + const dest = request.headers.get("sec-fetch-dest"); + const isDocumentRequest = dest + ? dest === "document" + : (request.headers.get("accept") ?? "").includes("text/html"); + if (isRemixDataRequest || isDocumentRequest) { + throw redirect(ssoSessionExpiredLogoutPath()); + } + throw json( + { error: "sso_session_invalidated" }, + { status: 401, headers: { [SSO_SESSION_INVALIDATED_HEADER]: "1" } } + ); +} diff --git a/apps/webapp/app/utils.ts b/apps/webapp/app/utils.ts index 7551bef1b6f..2f8cdf7e50c 100644 --- a/apps/webapp/app/utils.ts +++ b/apps/webapp/app/utils.ts @@ -9,7 +9,14 @@ const DEFAULT_REDIRECT = "/"; // `/admin/api/` covers admin JSON endpoints while leaving `/admin`, // `/admin/back-office/*`, `/admin/orgs`, etc. navigable. const NON_NAVIGABLE_PREFIXES = ["/resources/", "/auth/", "/admin/api/", "/api/", "/engine/"]; -const NON_NAVIGABLE_EXACT = new Set(["/magic", "/logout", "/login", "/login/magic", "/login/mfa"]); +const NON_NAVIGABLE_EXACT = new Set([ + "/magic", + "/logout", + "/login", + "/login/magic", + "/login/mfa", + "/login/sso", +]); function isNavigablePath(pathname: string): boolean { if (NON_NAVIGABLE_EXACT.has(pathname)) return false; diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 0411693e19c..48a74caade3 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -119,6 +119,10 @@ export function organizationRolesPath(organization: OrgForPath) { return `${organizationPath(organization)}/settings/roles`; } +export function organizationSsoPath(organization: OrgForPath) { + return `${organizationPath(organization)}/settings/sso`; +} + export function inviteTeamMemberPath(organization: OrgForPath) { return `${organizationPath(organization)}/invite`; } diff --git a/apps/webapp/app/utils/ssoSession.ts b/apps/webapp/app/utils/ssoSession.ts new file mode 100644 index 00000000000..671e8b2a968 --- /dev/null +++ b/apps/webapp/app/utils/ssoSession.ts @@ -0,0 +1,13 @@ +// Shared (server + client) constants for the SSO session-revalidation flow. + +export const SSO_SESSION_INVALIDATED_HEADER = "x-sso-session-invalidated"; + +export const SSO_SESSION_EXPIRED_REASON = "session_expired"; + +// The reason rides as its own `?reason=` param, not `?redirectTo=/login...`, +// because the redirect sanitizer rejects /login and would drop it. +export function ssoSessionExpiredLogoutPath(): string { + return `/logout?reason=${SSO_SESSION_EXPIRED_REASON}`; +} + +export const SSO_SESSION_CHECK_PATH = "/resources/session-check"; diff --git a/apps/webapp/app/utils/ssoSessionGuard.ts b/apps/webapp/app/utils/ssoSessionGuard.ts new file mode 100644 index 00000000000..74f5df4f1bf --- /dev/null +++ b/apps/webapp/app/utils/ssoSessionGuard.ts @@ -0,0 +1,51 @@ +import { + SSO_SESSION_CHECK_PATH, + SSO_SESSION_INVALIDATED_HEADER, + ssoSessionExpiredLogoutPath, +} from "./ssoSession"; + +// Client-side counterpart to the SSO revalidation hook: programmatic +// requests can't follow the server's 302-to-/logout, so the server marks +// their 401 with a header that we watch for here and turn into a redirect. + +let redirecting = false; + +function redirectToSsoLogout() { + if (redirecting) return; + const { pathname } = window.location; + if (pathname === "/logout" || pathname === "/login") return; + redirecting = true; + window.location.assign(ssoSessionExpiredLogoutPath()); +} + +export function installSsoSessionGuard() { + if (typeof window === "undefined") return; + const w = window as Window & { __ssoSessionGuardInstalled?: boolean }; + if (w.__ssoSessionGuardInstalled) return; + w.__ssoSessionGuardInstalled = true; + + const originalFetch = window.fetch.bind(window); + window.fetch = async (...args: Parameters): Promise => { + const response = await originalFetch(...args); + try { + if (response.headers.get(SSO_SESSION_INVALIDATED_HEADER) === "1") { + redirectToSsoLogout(); + } + } catch { + // Header access can throw on opaque responses; ours are same-origin. + } + return response; + }; +} + +// Throttled because EventSource fires `error` on every transient reconnect. +let lastProbeAt = 0; +const PROBE_THROTTLE_MS = 5_000; + +export function probeSsoSession() { + if (typeof window === "undefined" || redirecting) return; + const now = Date.now(); + if (now - lastProbeAt < PROBE_THROTTLE_MS) return; + lastProbeAt = now; + void fetch(SSO_SESSION_CHECK_PATH, { headers: { accept: "application/json" } }).catch(() => {}); +} diff --git a/apps/webapp/app/v3/accountsWebhookWorker.server.ts b/apps/webapp/app/v3/accountsWebhookWorker.server.ts new file mode 100644 index 00000000000..2846d836978 --- /dev/null +++ b/apps/webapp/app/v3/accountsWebhookWorker.server.ts @@ -0,0 +1,82 @@ +import { Worker as RedisWorker } from "@trigger.dev/redis-worker"; +import { z } from "zod"; +import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; +import { singleton } from "~/utils/singleton"; +import { ssoController } from "~/services/sso.server"; + +// Dedicated worker for inbound account-management webhooks. The webhook +// proxy route verifies the signature via the plugin and enqueues the +// parsed event here; this worker calls back into the plugin to apply the +// DB writes. The plugin owns the vendor-specific logic; the webapp owns +// the queue runtime (this file), mirroring `commonWorker.server.ts`. +// +// Vendor-neutral by design: the catalog/job names and payload shape carry +// no provider identity. +const PayloadSchema = z.object({ + id: z.string(), + event: z.string(), + data: z.unknown(), +}); + +function initializeWorker() { + const redisOptions = { + keyPrefix: "accounts-webhook:worker:", + host: env.COMMON_WORKER_REDIS_HOST, + port: env.COMMON_WORKER_REDIS_PORT, + username: env.COMMON_WORKER_REDIS_USERNAME, + password: env.COMMON_WORKER_REDIS_PASSWORD, + enableAutoPipelining: true, + ...(env.COMMON_WORKER_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }), + }; + + const worker = new RedisWorker({ + name: "accounts-webhook-worker", + redisOptions, + catalog: { + "account.webhook.event": { + schema: PayloadSchema, + visibilityTimeoutMs: 30_000, + retry: { maxAttempts: 5 }, + }, + }, + concurrency: { + workers: 2, + tasksPerWorker: 4, + limit: 8, + }, + pollIntervalMs: 1_000, + immediatePollIntervalMs: 50, + shutdownTimeoutMs: 30_000, + jobs: { + "account.webhook.event": async ({ payload }) => { + // The plugin returns a Result; throw on error so the worker + // retries (a resolved err would otherwise be silently dropped). + // `z.unknown()` infers `data?: unknown`, but the contract's + // SsoWebhookEvent requires `data: unknown` — restate the fields + // explicitly so the optional-vs-required shapes line up. + const result = await ssoController.processWebhookEvent({ + id: payload.id, + event: payload.event, + data: payload.data, + }); + if (result.isErr()) { + throw new Error(`account webhook processing failed: ${result.error}`); + } + }, + }, + }); + + // Only poll on worker-role instances (same gate as commonWorker) and + // only when the feature is enabled (no plugin loaded otherwise). + if (env.COMMON_WORKER_ENABLED === "true" && env.SSO_ENABLED) { + logger.debug( + `👨‍🏭 Starting accounts webhook worker at host ${env.COMMON_WORKER_REDIS_HOST}` + ); + worker.start(); + } + + return worker; +} + +export const accountsWebhookWorker = singleton("accountsWebhookWorker", initializeWorker); diff --git a/apps/webapp/app/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts index 3066f2dda01..f1995d1eb00 100644 --- a/apps/webapp/app/v3/featureFlags.ts +++ b/apps/webapp/app/v3/featureFlags.ts @@ -8,6 +8,7 @@ export const FEATURE_FLAG = { hasAiAccess: "hasAiAccess", hasComputeAccess: "hasComputeAccess", hasPrivateConnections: "hasPrivateConnections", + hasSso: "hasSso", mollifierEnabled: "mollifierEnabled", workerQueueScheduledSplitEnabled: "workerQueueScheduledSplitEnabled", realtimeBackend: "realtimeBackend", @@ -21,6 +22,7 @@ export const FeatureFlagCatalog = { [FEATURE_FLAG.hasAiAccess]: z.coerce.boolean(), [FEATURE_FLAG.hasComputeAccess]: z.coerce.boolean(), [FEATURE_FLAG.hasPrivateConnections]: z.coerce.boolean(), + [FEATURE_FLAG.hasSso]: z.coerce.boolean(), [FEATURE_FLAG.mollifierEnabled]: z.coerce.boolean(), [FEATURE_FLAG.workerQueueScheduledSplitEnabled]: z.coerce.boolean(), // Which backend serves the realtime run feed. Controllable diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 31d78667323..64aa62d3280 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -126,9 +126,11 @@ "@trigger.dev/companyicons": "^1.5.35", "@trigger.dev/core": "workspace:*", "@trigger.dev/database": "workspace:*", + "@trigger.dev/plugins": "workspace:*", + "@trigger.dev/rbac": "workspace:*", + "@trigger.dev/sso": "workspace:*", "@trigger.dev/otlp-importer": "workspace:*", "@trigger.dev/platform": "1.0.28", - "@trigger.dev/rbac": "workspace:*", "@trigger.dev/redis-worker": "workspace:*", "@trigger.dev/sdk": "workspace:*", "@types/pg": "8.6.6", diff --git a/internal-packages/database/prisma/migrations/20260527130000_add_sso_authentication_method/migration.sql b/internal-packages/database/prisma/migrations/20260527130000_add_sso_authentication_method/migration.sql new file mode 100644 index 00000000000..2d4fb9e77c2 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260527130000_add_sso_authentication_method/migration.sql @@ -0,0 +1,2 @@ +-- Idempotent enum addition. +ALTER TYPE "AuthenticationMethod" ADD VALUE IF NOT EXISTS 'SSO'; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 337a6059ebd..f8a6ad46b3d 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -112,6 +112,7 @@ enum AuthenticationMethod { GITHUB MAGIC_LINK GOOGLE + SSO } /// Used to generate PersonalAccessTokens, they're one-time use diff --git a/internal-packages/sso/package.json b/internal-packages/sso/package.json new file mode 100644 index 00000000000..66425feccb5 --- /dev/null +++ b/internal-packages/sso/package.json @@ -0,0 +1,25 @@ +{ + "name": "@trigger.dev/sso", + "private": true, + "version": "0.0.1", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "dependencies": { + "@trigger.dev/core": "workspace:*", + "@trigger.dev/plugins": "workspace:*", + "neverthrow": "^8.2.0" + }, + "devDependencies": { + "@trigger.dev/database": "workspace:*", + "@types/node": "^20.14.14", + "rimraf": "6.0.1" + }, + "scripts": { + "clean": "rimraf dist", + "typecheck": "tsc --noEmit", + "build": "pnpm run clean && tsc --noEmit false --outDir dist --declaration", + "dev": "tsc --noEmit false --outDir dist --declaration --watch", + "test": "vitest run", + "test:watch": "vitest" + } +} diff --git a/internal-packages/sso/src/fallback.ts b/internal-packages/sso/src/fallback.ts new file mode 100644 index 00000000000..564eb3391b5 --- /dev/null +++ b/internal-packages/sso/src/fallback.ts @@ -0,0 +1,168 @@ +import type { + OrgSsoStatus, + SsoBeginError, + SsoCompleteError, + SsoController, + SsoDecisionError, + SsoMutationError, + SsoPortalError, + SsoProfile, + SsoResolutionDecision, + SsoRouteDecision, + SsoValidateError, + SsoWebhookError, + SsoWebhookEvent, +} from "@trigger.dev/plugins"; +import { errAsync, okAsync, type ResultAsync } from "neverthrow"; + +// The default fallback used when no cloud SSO plugin is installed. +// `decideRouteForEmail` returns no_sso so OSS deployments behave +// identically to a deployment with no SSO feature at all. Mutation +// methods return feature_disabled so callers can surface a clear +// "not available" message in UI gated by `isUsingPlugin()`. +// +// The fallback never touches the database. It still accepts the loader's +// Prisma input for signature parity with the real cloud plugin factory +// (so the loader can swap implementations without changing its call), +// but ignores it entirely. +export class SsoFallback { + constructor(_prisma?: unknown) {} + + create(): SsoController { + return new SsoFallbackController(); + } +} + +class SsoFallbackController implements SsoController { + async isUsingPlugin(): Promise { + return false; + } + + getStatus(_organizationId: string): ResultAsync { + return okAsync({ + hasIdpOrg: false, + enforced: false, + jitProvisioningEnabled: false, + jitDefaultRoleId: null, + idpOrgId: null, + primaryConnectionId: null, + domains: [], + connections: [], + }); + } + + generatePortalLink(_params: { + organizationId: string; + userId: string; + intent: "sso" | "domain_verification"; + returnUrl: string; + }): ResultAsync<{ url: string }, SsoPortalError> { + return errAsync("idp_org_unavailable" as const); + } + + setEnforced(_params: { + organizationId: string; + enforced: boolean; + }): ResultAsync { + return errAsync("feature_disabled" as const); + } + + setJitProvisioningEnabled(_params: { + organizationId: string; + enabled: boolean; + }): ResultAsync { + return errAsync("feature_disabled" as const); + } + + setJitDefaultRole(_params: { + organizationId: string; + roleId: string | null; + }): ResultAsync { + return errAsync("feature_disabled" as const); + } + + updateConfig(_params: { + organizationId: string; + enforced: boolean; + jitProvisioningEnabled: boolean; + jitDefaultRoleId: string | null; + }): ResultAsync { + return errAsync("feature_disabled" as const); + } + + decideRouteForEmail(_email: string): ResultAsync { + return okAsync({ kind: "no_sso" as const }); + } + + beginAuthorization(_params: { + email: string; + redirectTo: string; + flow: import("@trigger.dev/plugins").SsoFlow; + }): ResultAsync<{ url: string }, SsoBeginError> { + return errAsync("feature_disabled" as const); + } + + completeAuthorization(_params: { + code: string; + state: string; + }): ResultAsync< + { + profile: SsoProfile; + redirectTo: string; + flow: import("@trigger.dev/plugins").SsoFlow; + }, + SsoCompleteError + > { + return errAsync("connection_unknown" as const); + } + + completeIdpInitiatedAuthorization(_params: { + code: string; + }): ResultAsync<{ profile: SsoProfile; redirectTo: string }, SsoCompleteError> { + return errAsync("connection_unknown" as const); + } + + // Fail-open: with no plugin there are no SSO sessions to invalidate, + // and the host treats `valid: true` as "leave the session alone". + validateSession(_params: { + userId: string; + idpOrgId: string; + connectionId: string; + }): ResultAsync<{ valid: boolean }, SsoValidateError> { + return okAsync({ valid: true }); + } + + resolveSsoIdentity(_params: { + profile: SsoProfile; + }): ResultAsync { + return errAsync("feature_disabled" as const); + } + + attachSsoIdentity(_params: { + userId: string; + profile: SsoProfile; + }): ResultAsync { + return errAsync("feature_disabled" as const); + } + + evaluateJit(_params: { + userId: string; + idpOrgId: string; + }): ResultAsync< + { shouldProvision: boolean; organizationId: string; roleId: string | null }, + SsoMutationError + > { + return errAsync("feature_disabled" as const); + } + + verifyWebhook(_params: { + rawBody: string; + headers: Record; + }): ResultAsync<{ event: SsoWebhookEvent }, SsoWebhookError> { + return errAsync("feature_disabled" as const); + } + + processWebhookEvent(_event: SsoWebhookEvent): ResultAsync { + return errAsync("feature_disabled" as const); + } +} diff --git a/internal-packages/sso/src/index.ts b/internal-packages/sso/src/index.ts new file mode 100644 index 00000000000..aec22b81ce4 --- /dev/null +++ b/internal-packages/sso/src/index.ts @@ -0,0 +1,233 @@ +import type { + OrgSsoStatus, + SsoBeginError, + SsoCompleteError, + SsoController, + SsoDecisionError, + SsoFlow, + SsoMutationError, + SsoPlugin, + SsoPortalError, + SsoProfile, + SsoResolutionDecision, + SsoRouteDecision, + SsoValidateError, + SsoWebhookError, + SsoWebhookEvent, +} from "@trigger.dev/plugins"; +import type { PrismaClient } from "@trigger.dev/database"; +import { ResultAsync } from "neverthrow"; +import { SsoFallback } from "./fallback.js"; +export type { SsoController } from "@trigger.dev/plugins"; + +export type SsoPrismaInput = + | PrismaClient + | { primary: PrismaClient; replica: PrismaClient }; + +export type SsoCreateOptions = { + // When true, skip loading the plugin. Useful for tests and for + // contributors who don't have the cloud plugin installed. + forceFallback?: boolean; + // Override the dynamic importer. Lets tests inject a fake plugin + // module or a synthetic ERR_MODULE_NOT_FOUND failure without touching + // the real plugin install on disk. + importer?: (moduleName: string) => Promise<{ default: SsoPlugin }>; +}; + +// Loads the cloud plugin lazily; falls back to the OSS no-op +// implementation if not installed. Synchronous create() avoids +// top-level await (not supported in the webapp's CJS build). +export class LazyController implements SsoController { + private readonly _init: Promise; + + constructor(prisma: SsoPrismaInput, options?: SsoCreateOptions) { + this._init = this.load(prisma, options); + } + + private async load( + prisma: SsoPrismaInput, + options?: SsoCreateOptions + ): Promise { + if (options?.forceFallback) { + return new SsoFallback(prisma).create(); + } + const moduleName = "@triggerdotdev/plugins/sso"; + const importer = + options?.importer ?? + ((m: string) => import(m) as Promise<{ default: SsoPlugin }>); + try { + const module = await importer(moduleName); + const plugin: SsoPlugin = module.default; + console.log("SSO: using plugin implementation"); + return plugin.create(); + } catch (err) { + // Distinguish the two failure modes the dynamic import can hit: + // + // 1. The plugin itself is absent (no install) — expected on OSS + // deployments. Quiet by default; logged when SSO_LOG_FALLBACK=1 + // so contributors can opt into a visible signal locally. + // 2. The plugin module loaded but its initialization failed + // (transitive dep missing, syntax error, …). Always logged + // loudly because this indicates a real bug. + // + // Node throws ERR_MODULE_NOT_FOUND for both cases, so we + // disambiguate by checking whether the missing specifier is the + // plugin's own module name. + const code = (err as NodeJS.ErrnoException | undefined)?.code; + const message = err instanceof Error ? err.message : String(err); + const isModuleNotFound = + code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND"; + const isPluginItselfMissing = + isModuleNotFound && message.includes(moduleName); + + if (!isPluginItselfMissing) { + console.error( + "SSO: plugin found but failed to load; falling back to default implementation", + err + ); + } else if (process.env.SSO_LOG_FALLBACK === "1" || process.env.SSO_LOG_FALLBACK === "true") { + console.log("SSO: no plugin installed (ERR_MODULE_NOT_FOUND); using fallback"); + } + return new SsoFallback(prisma).create(); + } + } + + private c(): Promise { + return this._init; + } + + // Bridges a Promise> back into a ResultAsync. + // The `load()` method above always resolves (it catches and falls + // back), so `this.c()` is safe to lift via fromSafePromise. Inner + // controller methods are expected to never throw — they return + // errors via the Result instead — so the .andThen flatten is total. + private call(factory: (c: SsoController) => ResultAsync): ResultAsync { + return ResultAsync.fromSafePromise(this.c().then(factory)).andThen((r) => r); + } + + async isUsingPlugin(): Promise { + return (await this.c()).isUsingPlugin(); + } + + getStatus(organizationId: string): ResultAsync { + return this.call((c) => c.getStatus(organizationId)); + } + + generatePortalLink(params: { + organizationId: string; + userId: string; + intent: "sso" | "domain_verification"; + returnUrl: string; + }): ResultAsync<{ url: string }, SsoPortalError> { + return this.call((c) => c.generatePortalLink(params)); + } + + setEnforced(params: { + organizationId: string; + enforced: boolean; + }): ResultAsync { + return this.call((c) => c.setEnforced(params)); + } + + setJitProvisioningEnabled(params: { + organizationId: string; + enabled: boolean; + }): ResultAsync { + return this.call((c) => c.setJitProvisioningEnabled(params)); + } + + setJitDefaultRole(params: { + organizationId: string; + roleId: string | null; + }): ResultAsync { + return this.call((c) => c.setJitDefaultRole(params)); + } + + updateConfig(params: { + organizationId: string; + enforced: boolean; + jitProvisioningEnabled: boolean; + jitDefaultRoleId: string | null; + }): ResultAsync { + return this.call((c) => c.updateConfig(params)); + } + + decideRouteForEmail(email: string): ResultAsync { + return this.call((c) => c.decideRouteForEmail(email)); + } + + beginAuthorization(params: { + email: string; + redirectTo: string; + flow: SsoFlow; + }): ResultAsync<{ url: string }, SsoBeginError> { + return this.call((c) => c.beginAuthorization(params)); + } + + completeAuthorization(params: { + code: string; + state: string; + }): ResultAsync<{ profile: SsoProfile; redirectTo: string; flow: SsoFlow }, SsoCompleteError> { + return this.call((c) => c.completeAuthorization(params)); + } + + completeIdpInitiatedAuthorization(params: { + code: string; + }): ResultAsync<{ profile: SsoProfile; redirectTo: string }, SsoCompleteError> { + return this.call((c) => c.completeIdpInitiatedAuthorization(params)); + } + + validateSession(params: { + userId: string; + idpOrgId: string; + connectionId: string; + }): ResultAsync<{ valid: boolean }, SsoValidateError> { + return this.call((c) => c.validateSession(params)); + } + + resolveSsoIdentity(params: { + profile: SsoProfile; + }): ResultAsync { + return this.call((c) => c.resolveSsoIdentity(params)); + } + + attachSsoIdentity(params: { + userId: string; + profile: SsoProfile; + }): ResultAsync { + return this.call((c) => c.attachSsoIdentity(params)); + } + + evaluateJit(params: { + userId: string; + idpOrgId: string; + }): ResultAsync< + { shouldProvision: boolean; organizationId: string; roleId: string | null }, + SsoMutationError + > { + return this.call((c) => c.evaluateJit(params)); + } + + verifyWebhook(params: { + rawBody: string; + headers: Record; + }): ResultAsync<{ event: SsoWebhookEvent }, SsoWebhookError> { + return this.call((c) => c.verifyWebhook(params)); + } + + processWebhookEvent(event: SsoWebhookEvent): ResultAsync { + return this.call((c) => c.processWebhookEvent(event)); + } +} + +class Sso { + // Synchronous — returns a lazy controller that resolves any installed + // plugin on first call. + create(prisma: SsoPrismaInput, options?: SsoCreateOptions): SsoController { + return new LazyController(prisma, options); + } +} + +const loader = new Sso(); + +export default loader; diff --git a/internal-packages/sso/src/loader.test.ts b/internal-packages/sso/src/loader.test.ts new file mode 100644 index 00000000000..fa05f2947eb --- /dev/null +++ b/internal-packages/sso/src/loader.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + OrgSsoStatus, + SsoController, + SsoFlow, + SsoPlugin, + SsoProfile, + SsoResolutionDecision, + SsoRouteDecision, +} from "@trigger.dev/plugins"; +import { errAsync, okAsync, type ResultAsync } from "neverthrow"; +import loader, { LazyController } from "./index.js"; + +// A minimal stub controller used in the "plugin found" test path. Only +// the methods the test cares about return useful values; the rest +// return permissive defaults. +function makeStubController(overrides: Partial = {}): SsoController { + const stub: SsoController = { + async isUsingPlugin() { + return true; + }, + getStatus(): ResultAsync { + return okAsync({ + hasIdpOrg: true, + enforced: false, + jitProvisioningEnabled: true, + jitDefaultRoleId: null, + idpOrgId: "idp_stub", + primaryConnectionId: null, + domains: [], + connections: [], + }); + }, + generatePortalLink() { + return okAsync({ url: "https://stub.example/portal" }); + }, + setEnforced() { + return okAsync(undefined as void); + }, + setJitProvisioningEnabled() { + return okAsync(undefined as void); + }, + setJitDefaultRole() { + return okAsync(undefined as void); + }, + updateConfig() { + return okAsync(undefined as void); + }, + decideRouteForEmail(): ResultAsync { + return okAsync({ + kind: "sso_required", + idpOrgId: "idp_stub", + }); + }, + beginAuthorization() { + return okAsync({ url: "https://stub.example/auth" }); + }, + completeAuthorization() { + return errAsync("connection_unknown" as const); + }, + completeIdpInitiatedAuthorization() { + return errAsync("connection_unknown" as const); + }, + resolveSsoIdentity(): ResultAsync { + return errAsync("feature_disabled" as const); + }, + attachSsoIdentity() { + return errAsync("feature_disabled" as const); + }, + evaluateJit() { + return errAsync("feature_disabled" as const); + }, + validateSession() { + return okAsync({ valid: true }); + }, + verifyWebhook() { + return errAsync("invalid_signature" as const); + }, + processWebhookEvent() { + return okAsync(undefined as void); + }, + ...overrides, + }; + return stub; +} + +// Minimal Prisma stub. The fallback's only constructor work is to +// record the input; nothing else here touches the database. +const fakePrisma = {} as unknown as Parameters[0]; + +describe("SSO LazyController", () => { + describe("plugin missing (ERR_MODULE_NOT_FOUND on the plugin's own moduleName)", () => { + it("falls back to the no-op implementation and reports isUsingPlugin=false", async () => { + const importer = vi.fn(async (moduleName: string) => { + const err = Object.assign(new Error(`Cannot find module '${moduleName}'`), { + code: "ERR_MODULE_NOT_FOUND", + }); + throw err; + }); + + const controller = new LazyController(fakePrisma, { importer }); + + expect(await controller.isUsingPlugin()).toBe(false); + const decision = await controller.decideRouteForEmail("anyone@example.com"); + expect(decision.isOk()).toBe(true); + expect(decision._unsafeUnwrap()).toEqual({ kind: "no_sso" }); + }); + + it("does not log to console.log unless SSO_LOG_FALLBACK=1", async () => { + const previous = process.env.SSO_LOG_FALLBACK; + delete process.env.SSO_LOG_FALLBACK; + const importer = vi.fn(async (moduleName: string) => { + const err = Object.assign(new Error(`Cannot find module '${moduleName}'`), { + code: "ERR_MODULE_NOT_FOUND", + }); + throw err; + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + try { + const controller = new LazyController(fakePrisma, { importer }); + await controller.isUsingPlugin(); + const fallbackLogs = logSpy.mock.calls.filter((args) => + args.some( + (a) => + typeof a === "string" && a.includes("no plugin installed") + ) + ); + expect(fallbackLogs.length).toBe(0); + expect(errorSpy).not.toHaveBeenCalled(); + } finally { + logSpy.mockRestore(); + errorSpy.mockRestore(); + if (previous === undefined) delete process.env.SSO_LOG_FALLBACK; + else process.env.SSO_LOG_FALLBACK = previous; + } + }); + + it("logs an info line when SSO_LOG_FALLBACK=1", async () => { + const previous = process.env.SSO_LOG_FALLBACK; + process.env.SSO_LOG_FALLBACK = "1"; + const importer = vi.fn(async (moduleName: string) => { + const err = Object.assign(new Error(`Cannot find module '${moduleName}'`), { + code: "ERR_MODULE_NOT_FOUND", + }); + throw err; + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + + try { + const controller = new LazyController(fakePrisma, { importer }); + await controller.isUsingPlugin(); + const fallbackLogs = logSpy.mock.calls.filter((args) => + args.some( + (a) => typeof a === "string" && a.includes("no plugin installed") + ) + ); + expect(fallbackLogs.length).toBe(1); + } finally { + logSpy.mockRestore(); + if (previous === undefined) delete process.env.SSO_LOG_FALLBACK; + else process.env.SSO_LOG_FALLBACK = previous; + } + }); + }); + + describe("plugin broken (transitive dep missing or init error)", () => { + it("logs a console.error and falls back", async () => { + const importer = vi.fn(async () => { + // Module-not-found from a *transitive* dep, not the plugin + // itself — its `message` won't contain the plugin's moduleName. + const err = Object.assign( + new Error(`Cannot find module 'some-transitive-dep'`), + { code: "ERR_MODULE_NOT_FOUND" } + ); + throw err; + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + try { + const controller = new LazyController(fakePrisma, { importer }); + expect(await controller.isUsingPlugin()).toBe(false); + expect(errorSpy).toHaveBeenCalled(); + const firstCallArgs = errorSpy.mock.calls[0]!; + expect( + firstCallArgs.some( + (a) => typeof a === "string" && a.includes("plugin found but failed to load") + ) + ).toBe(true); + } finally { + errorSpy.mockRestore(); + } + }); + + it("logs a console.error for non-module-not-found errors too", async () => { + const importer = vi.fn(async () => { + throw new SyntaxError("Unexpected token in plugin source"); + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + try { + const controller = new LazyController(fakePrisma, { importer }); + expect(await controller.isUsingPlugin()).toBe(false); + expect(errorSpy).toHaveBeenCalled(); + } finally { + errorSpy.mockRestore(); + } + }); + }); + + describe("plugin found", () => { + it("delegates isUsingPlugin to the plugin implementation", async () => { + const stub = makeStubController(); + const plugin: SsoPlugin = { create: () => stub }; + const importer = vi.fn(async () => ({ default: plugin })); + + const controller = new LazyController(fakePrisma, { importer }); + expect(await controller.isUsingPlugin()).toBe(true); + }); + + it("delegates decideRouteForEmail and propagates the result", async () => { + const stub = makeStubController(); + const plugin: SsoPlugin = { create: () => stub }; + const importer = vi.fn(async () => ({ default: plugin })); + + const controller = new LazyController(fakePrisma, { importer }); + const decision = await controller.decideRouteForEmail("admin@example.com"); + expect(decision.isOk()).toBe(true); + expect(decision._unsafeUnwrap()).toEqual({ + kind: "sso_required", + idpOrgId: "idp_stub", + }); + }); + + it("propagates plugin errors through ResultAsync", async () => { + const stub = makeStubController(); + const plugin: SsoPlugin = { create: () => stub }; + const importer = vi.fn(async () => ({ default: plugin })); + + const controller = new LazyController(fakePrisma, { importer }); + const profile: SsoProfile = { + email: "user@example.com", + firstName: null, + lastName: null, + idpSubjectId: "sub_x", + idpOrgId: "idp_stub", + idpConnectionId: "conn_x", + }; + const result = await controller.resolveSsoIdentity({ profile }); + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBe("feature_disabled"); + }); + + it("loads the plugin module only once across many calls", async () => { + const stub = makeStubController(); + const plugin: SsoPlugin = { create: () => stub }; + const importer = vi.fn(async () => ({ default: plugin })); + + const controller = new LazyController(fakePrisma, { importer }); + await controller.isUsingPlugin(); + await controller.decideRouteForEmail("a@example.com"); + await controller.decideRouteForEmail("b@example.com"); + expect(importer).toHaveBeenCalledTimes(1); + }); + }); + + describe("forceFallback option", () => { + it("skips the importer entirely", async () => { + const importer = vi.fn(); + const controller = new LazyController(fakePrisma, { + forceFallback: true, + importer: importer as never, + }); + expect(await controller.isUsingPlugin()).toBe(false); + expect(importer).not.toHaveBeenCalled(); + }); + }); + + describe("loader.create() factory", () => { + it("returns a working LazyController", async () => { + const controller = loader.create(fakePrisma, { forceFallback: true }); + expect(await controller.isUsingPlugin()).toBe(false); + const decision = await controller.decideRouteForEmail("x@example.com"); + expect(decision._unsafeUnwrap()).toEqual({ kind: "no_sso" }); + }); + }); + + describe("fallback parameter shapes", () => { + it("propagates SsoFlow through beginAuthorization (uses fallback for error path)", async () => { + // Smoke test that `SsoFlow` typing flows through. Plugin not present → + // beginAuthorization returns `feature_disabled` per the fallback. + const controller = loader.create(fakePrisma, { forceFallback: true }); + const flow: SsoFlow = "user_initiated"; + const result = await controller.beginAuthorization({ + email: "x@example.com", + redirectTo: "/", + flow, + }); + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBe("feature_disabled"); + }); + }); +}); diff --git a/internal-packages/sso/tsconfig.json b/internal-packages/sso/tsconfig.json new file mode 100644 index 00000000000..8da0857b403 --- /dev/null +++ b/internal-packages/sso/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2019", + "lib": ["ES2019", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "preserveWatchOutput": true, + "skipLibCheck": true, + "noEmit": true, + "strict": true, + "customConditions": ["@triggerdotdev/source"] + }, + "exclude": ["node_modules"] +} diff --git a/internal-packages/sso/vitest.config.ts b/internal-packages/sso/vitest.config.ts new file mode 100644 index 00000000000..e07f05e842b --- /dev/null +++ b/internal-packages/sso/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["**/*.test.ts"], + globals: true, + isolate: true, + testTimeout: 10_000, + }, +}); diff --git a/packages/plugins/src/sso.ts b/packages/plugins/src/sso.ts index 20880791dca..39a7da678ef 100644 --- a/packages/plugins/src/sso.ts +++ b/packages/plugins/src/sso.ts @@ -134,6 +134,20 @@ export interface SsoController { roleId: string | null; }): ResultAsync; + // Atomic counterpart to the three setters above: the settings form + // presents enforced + JIT-enabled + JIT-default-role as a single Save, + // so they must commit all-or-nothing. Implementations write all three + // OrgSsoConfig columns inside one transaction (with the JIT role RBAC + // check inside it too), so a `rbac_role_invalid`/`internal` failure + // leaves none of the fields changed. Prefer this over the individual + // setters for the admin Save path. + updateConfig(params: { + organizationId: string; + enforced: boolean; + jitProvisioningEnabled: boolean; + jitDefaultRoleId: string | null; + }): ResultAsync; + // --- Auth flow --- // Called by every login entry point BEFORE the strategy proceeds. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f78f48bfb8b..b0ee7478d10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -562,6 +562,9 @@ importers: '@trigger.dev/platform': specifier: 1.0.28 version: 1.0.28 + '@trigger.dev/plugins': + specifier: workspace:* + version: link:../../packages/plugins '@trigger.dev/rbac': specifier: workspace:* version: link:../../internal-packages/rbac @@ -571,6 +574,9 @@ importers: '@trigger.dev/sdk': specifier: workspace:* version: link:../../packages/trigger-sdk + '@trigger.dev/sso': + specifier: workspace:* + version: link:../../internal-packages/sso '@types/pg': specifier: 8.6.6 version: 8.6.6 @@ -1402,6 +1408,28 @@ importers: specifier: 4.1.7 version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@20.14.14)(@vitest/coverage-v8@4.1.7)(vite@6.4.2(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + internal-packages/sso: + dependencies: + '@trigger.dev/core': + specifier: workspace:* + version: link:../../packages/core + '@trigger.dev/plugins': + specifier: workspace:* + version: link:../../packages/plugins + neverthrow: + specifier: ^8.2.0 + version: 8.2.0 + devDependencies: + '@trigger.dev/database': + specifier: workspace:* + version: link:../database + '@types/node': + specifier: 20.14.14 + version: 20.14.14 + rimraf: + specifier: 6.0.1 + version: 6.0.1 + internal-packages/testcontainers: dependencies: '@clickhouse/client': @@ -7085,12 +7113,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.53.2': - resolution: {integrity: sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==} - cpu: [x64] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.60.1': resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] @@ -11601,16 +11623,9 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true - glob@11.0.0: - resolution: {integrity: sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==} - engines: {node: 20 || >=22} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.6: @@ -11619,7 +11634,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -12262,10 +12277,6 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jackspeak@4.0.1: - resolution: {integrity: sha512-cub8rahkh0Q/bw1+GxP7aeSe29hHHn2V4m29nnDlvCdlgU+3UGxkZp7Z53jLUdpX3jdTO0nJZUDl3xvbWc2Xog==} - engines: {node: 20 || >=22} - jackspeak@4.2.3: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} @@ -23910,9 +23921,6 @@ snapshots: '@rollup/rollup-linux-s390x-gnu@4.60.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.53.2': - optional: true - '@rollup/rollup-linux-x64-gnu@4.60.1': optional: true @@ -29557,22 +29565,13 @@ snapshots: glob@10.4.5: dependencies: - foreground-child: 3.1.1 + foreground-child: 3.3.1 jackspeak: 3.4.3 minimatch: 9.0.5 minipass: 7.1.2 package-json-from-dist: 1.0.0 path-scurry: 1.11.1 - glob@11.0.0: - dependencies: - foreground-child: 3.1.1 - jackspeak: 4.0.1 - minimatch: 10.0.1 - minipass: 7.1.2 - package-json-from-dist: 1.0.0 - path-scurry: 2.0.0 - glob@11.1.0: dependencies: foreground-child: 3.3.1 @@ -30285,12 +30284,6 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 - jackspeak@4.0.1: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - jackspeak@4.2.3: dependencies: '@isaacs/cliui': 9.0.0 @@ -31809,7 +31802,7 @@ snapshots: neverthrow@8.2.0: optionalDependencies: - '@rollup/rollup-linux-x64-gnu': 4.53.2 + '@rollup/rollup-linux-x64-gnu': 4.60.1 nice-try@1.0.5: {} @@ -33656,7 +33649,7 @@ snapshots: resolve-import@2.0.0: dependencies: - glob: 11.0.0 + glob: 11.1.0 walk-up-path: 4.0.0 resolve-pkg-maps@1.0.0: {} @@ -33704,7 +33697,7 @@ snapshots: rimraf@6.0.1: dependencies: - glob: 11.0.0 + glob: 11.1.0 package-json-from-dist: 1.0.0 robust-predicates@3.0.2: {} @@ -34540,7 +34533,7 @@ snapshots: sync-content@2.0.1: dependencies: - glob: 11.0.0 + glob: 11.1.0 mkdirp: 3.0.1 path-scurry: 2.0.0 rimraf: 6.0.1