diff --git a/.server-changes/sso.md b/.server-changes/sso.md new file mode 100644 index 00000000000..67880748cca --- /dev/null +++ b/.server-changes/sso.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +SAML/OIDC single sign-on: SSO login with optional per-domain enforcement, JIT provisioning, and periodic re-validation against the IdP. diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index c25a433d7f9..e0ab1bf3b72 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..c023e847c95 --- /dev/null +++ b/apps/webapp/app/models/orgMember.server.ts @@ -0,0 +1,134 @@ +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 }; + +// Completes a JIT role assignment for an ALREADY-existing membership whose +// RBAC role never got applied. This is a no-op when a role is already +// assigned, so it can never demote a deliberately-set role — it only fills +// in the gap left by an interrupted provision (see `ensureOrgMember`). Always +// best-effort: a valid membership already exists, so a failure here is logged +// and swallowed rather than thrown. +async function healMissingRoleAssignment(params: { + userId: string; + organizationId: string; + roleId: string; + source: EnsureOrgMemberParams["source"]; +}): Promise { + const { userId, organizationId, roleId, source } = params; + + const currentRole = await rbac.getUserRole({ userId, organizationId }); + if (currentRole !== null) return; + + const result = await rbac.setUserRole({ userId, organizationId, roleId }); + if (!result.ok) { + logger.warn("ensureOrgMember.setUserRole failed while healing unassigned membership", { + source, + userId, + organizationId, + roleId, + error: result.error, + }); + } +} + +// 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) { + // Existing membership is normally a pure no-op: we don't re-touch the + // role, since a user JIT fires for again may have been deliberately + // promoted and must not be demoted back to the JIT default. + // + // The one exception is self-healing a half-provisioned row. The create + + // setUserRole + compensating delete below are not transactional (the RBAC + // plugin writes on its own connection, so a single DB transaction isn't + // possible). If setUserRole failed AND that compensating delete also + // failed, the placeholder MEMBER row is orphaned — and this findFirst + // would short-circuit every future login, stranding the user on the + // placeholder role forever. So when a JIT role is requested but the RBAC + // layer shows no role assigned, complete the assignment now. It's gated on + // "no role assigned", so it can never demote a real one. + if (roleId !== null) { + await healMissingRoleAssignment({ userId, organizationId, roleId, source }); + } + 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) { + // The membership was just created with the legacy `MEMBER` enum role as + // a placeholder; the intended RBAC role failed to apply. Leaving the row + // in place would grant the user `MEMBER` access — potentially broader + // than the configured (e.g. restrictive) JIT default role they were + // supposed to get. Roll back so we never half-provision into an + // unintended privilege level, then throw so the caller can decide + // whether to skip provisioning or fail the flow. + logger.warn("ensureOrgMember.setUserRole failed; rolling back membership", { + source, + userId, + organizationId, + roleId, + error: result.error, + }); + await prisma.orgMember.delete({ where: { id: member.id } }); + throw new Error(`ensureOrgMember: failed to apply role ${roleId}: ${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..d7efa86beb7 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx @@ -0,0 +1,792 @@ +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; + // The form is a single Save, so the three fields must commit + // all-or-nothing: `updateConfig` writes them in one transaction + // (with the JIT-role RBAC check inside it), so a failure leaves + // none of the fields changed rather than a partial config. + const result = await ssoController.updateConfig({ + organizationId: orgId, + enforced: parsed.data.enforced, + jitProvisioningEnabled: parsed.data.jitEnabled, + jitDefaultRoleId: jitRoleId, + }); + if (result.isErr()) { + return new Response(`Error: ${result.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 37e44330496..77f471713c7 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx @@ -11,10 +11,15 @@ import { } from "~/components/navigation/OrganizationSettingsSideMenu"; import { useOrganization } from "~/hooks/useOrganizations"; import { rbac } from "~/services/rbac.server"; +import { ssoController } from "~/services/sso.server"; const SETTINGS_ROUTE_ID = "routes/_app.orgs.$organizationSlug.settings"; 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, @@ -23,17 +28,20 @@ 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, }); }; function SettingsChrome({ buildInfo, isUsingPlugin, + isSsoUsingPlugin, children, }: { buildInfo: BuildInfo; isUsingPlugin: boolean; + isSsoUsingPlugin: boolean; children: ReactNode; }) { const organization = useOrganization(); @@ -45,6 +53,7 @@ function SettingsChrome({ organization={organization} buildInfo={buildInfo} isUsingPlugin={isUsingPlugin} + isSsoUsingPlugin={isSsoUsingPlugin} /> {children} @@ -53,10 +62,14 @@ function SettingsChrome({ } export default function Page() { - const { buildInfo, isUsingPlugin } = useTypedLoaderData(); + const { buildInfo, isUsingPlugin, isSsoUsingPlugin } = useTypedLoaderData(); return ( - + ); @@ -68,7 +81,7 @@ export default function Page() { // available via useRouteLoaderData. export function ErrorBoundary() { const data = useRouteLoaderData(SETTINGS_ROUTE_ID) as - | { buildInfo: BuildInfo; isUsingPlugin: boolean } + | { buildInfo: BuildInfo; isUsingPlugin: boolean; isSsoUsingPlugin: boolean } | undefined; if (!data) { @@ -76,7 +89,11 @@ export function ErrorBoundary() { } return ( - + ); diff --git a/apps/webapp/app/routes/auth.github.callback.tsx b/apps/webapp/app/routes/auth.github.callback.tsx index 44277dbf941..32c23ab665b 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,37 @@ 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)); + } + // 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 loader reads. Otherwise an allow-list + // rejection or provider error silently re-renders the login form with no + // indication of what went wrong. + const session = await getUserSession(request); + session.flash("auth:error", { + message: thrown instanceof Error ? thrown.message : "Failed to sign in with GitHub.", + }); + return redirect("/login", { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } const session = await getUserSession(request); @@ -42,6 +72,11 @@ export let loader: LoaderFunction = async ({ request }) => { if (userRecord.mfaEnabledAt) { session.set("pending-mfa-user-id", userRecord.id); session.set("pending-mfa-redirect-to", redirectTo); + // Clear any `pending-sso` left over from an aborted SSO login in the same + // browser session — otherwise `completeLogin` would stamp an SSO marker + // onto this GitHub session and `revalidateSsoSession` would later validate + // it against an IdP the user never authenticated through. + session.unset("pending-sso"); const headers = new Headers(); headers.append("Set-Cookie", await commitSession(session)); diff --git a/apps/webapp/app/routes/auth.google.callback.tsx b/apps/webapp/app/routes/auth.google.callback.tsx index e065a9de58e..932186b511b 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,37 @@ 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)); + } + // 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 loader reads. Otherwise an allow-list + // rejection or provider error silently re-renders the login form with no + // indication of what went wrong. + const session = await getUserSession(request); + session.flash("auth:error", { + message: thrown instanceof Error ? thrown.message : "Failed to sign in with Google.", + }); + return redirect("/login", { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } const session = await getUserSession(request); @@ -42,6 +72,11 @@ export let loader: LoaderFunction = async ({ request }) => { if (userRecord.mfaEnabledAt) { session.set("pending-mfa-user-id", userRecord.id); session.set("pending-mfa-redirect-to", redirectTo); + // Clear any `pending-sso` left over from an aborted SSO login in the same + // browser session — otherwise `completeLogin` would stamp an SSO marker + // onto this Google session and `revalidateSsoSession` would later validate + // it against an IdP the user never authenticated through. + session.unset("pending-sso"); const headers = new Headers(); headers.append("Set-Cookie", await commitSession(session)); 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..594a4089b65 --- /dev/null +++ b/apps/webapp/app/routes/auth.sso.callback.tsx @@ -0,0 +1,124 @@ +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"; +import { trackAndClearReferralSource } from "~/services/referralSource.server"; +import { sanitizeRedirectPath } from "~/utils"; + +// 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: rawRedirectTo, flow } = await resolveSsoCompletion(code, state); + // Sanitize at the exit point regardless of flow. SP-initiated values were + // sanitized on the way in (auth.sso.ts) and signed into the state token, but + // IdP-initiated `redirectTo` originates from the IdP's relay-state and never + // passed through the host — without this an IdP admin could craft an open + // redirect. Mirrors every other auth callback. A rejected value falls back + // to "/". The Vercel resume URL (`/vercel/onboarding?...`) is navigable and + // survives. + const redirectTo = sanitizeRedirectPath(rawRedirectTo); + + // `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")); + + // Attribute the referral source on the final session creation, like the + // other non-MFA auth callbacks. The MFA path defers this to `completeLogin`. + await trackAndClearReferralSource(request, auth.userId, headers); + + 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..c309f27dbc6 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.flash("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")); @@ -44,6 +71,11 @@ export async function loader({ request }: LoaderFunctionArgs) { if (userRecord.mfaEnabledAt) { session.set("pending-mfa-user-id", userRecord.id); session.set("pending-mfa-redirect-to", redirectTo ?? "/"); + // Clear any `pending-sso` left over from an aborted SSO login in the same + // browser session — otherwise `completeLogin` would stamp an SSO marker + // onto this magic-link session and `revalidateSsoSession` would later + // validate it against an IdP the user never authenticated through. + session.unset("pending-sso"); const headers = new Headers(); headers.append("Set-Cookie", await commitSession(session)); 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..8187ff9282c 100644 --- a/apps/webapp/app/routes/vercel.onboarding.tsx +++ b/apps/webapp/app/routes/vercel.onboarding.tsx @@ -17,8 +17,10 @@ import { Label } from "~/components/primitives/Label"; import { Select, SelectItem } from "~/components/primitives/Select"; import { ButtonSpinner } from "~/components/primitives/Spinner"; import { prisma } from "~/db.server"; +import { authenticator } from "~/services/auth.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 +194,48 @@ 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) { + // The user is already authenticated via a non-SSO method, so a plain + // redirect to `/login/sso` would be bounced straight home by that + // route's already-authenticated guard — silently dropping the install. + // Destroy the current session first (mirroring the OAuth callbacks, + // which never commit a session when SSO is required) so `/login/sso` + // accepts them. `authenticator.logout` redirects to `ssoRedirect` + // verbatim — unlike the `/logout` route it doesn't run the redirect + // sanitizer, which would otherwise reject the non-navigable + // `/login/sso` target. The resume URL rides along as `redirectTo` and + // survives the SSO round-trip. + return authenticator.logout(request, { redirectTo: 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..581a98bdfe2 --- /dev/null +++ b/apps/webapp/app/services/ssoAuth.server.ts @@ -0,0 +1,177 @@ +import type { SessionStorage } from "@remix-run/server-runtime"; +import type { AuthenticateOptions, Authenticator } from "remix-auth"; +import { Strategy } from "remix-auth"; +import { tryCatch } from "@trigger.dev/core/v3"; +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 + ); + } + const [error, user] = await tryCatch(this.verify(ctx)); + if (error) { + const cause = error instanceof Error ? error : new Error(String(error)); + return this.failure(cause.message, request, sessionStorage, options, cause); + } + return this.success(user, request, sessionStorage, options); + } +} + +// Resolve the host User for a verified SSO profile, creating one on the +// `create_new_user` decision. Throws on an errored decision — this surfaces +// "feature_disabled" in OSS deployments, which the callback route's error +// path translates into a generic sign-in-failed user-facing message. +async function resolveSsoUserId( + profile: SsoProfile +): Promise<{ userId: string; isNewUser: boolean }> { + const decision = await ssoController.resolveSsoIdentity({ profile }); + if (decision.isErr()) { + throw new Error(`SSO resolve failed: ${decision.error}`); + } + + if (decision.value.kind === "create_new_user") { + const created = await findOrCreateSsoUser({ + authenticationMethod: "SSO", + email: profile.email, + firstName: profile.firstName, + lastName: profile.lastName, + }); + return { userId: created.user.id, isNewUser: created.isNewUser }; + } + + return { userId: decision.value.userId, isNewUser: false }; +} + +// 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. +async function attachSsoIdentityBestEffort( + userId: string, + profile: SsoProfile, + flow: SsoFlow +): Promise { + const attach = await ssoController.attachSsoIdentity({ userId, profile }); + if (attach.isErr()) { + logger.warn("SSO attachSsoIdentity failed", { reason: attach.error, userId, flow }); + } +} + +// Best-effort JIT org provisioning. Like attachSsoIdentity above, a failure +// must not block an otherwise-valid sign-in: the user simply isn't +// provisioned this time and a later login retries. "feature_disabled" is the +// expected OSS-fallback result, so it's swallowed silently. +async function provisionJitMembershipBestEffort( + userId: string, + profile: SsoProfile, + flow: SsoFlow +): Promise { + const jit = await ssoController.evaluateJit({ userId, idpOrgId: profile.idpOrgId }); + if (jit.isErr()) { + if (jit.error !== "feature_disabled") { + logger.warn("SSO evaluateJit failed", { reason: jit.error, userId, flow }); + } + return; + } + + if (!jit.value.shouldProvision) return; + + const [provisionError, result] = await tryCatch( + ensureOrgMember({ + userId, + organizationId: jit.value.organizationId, + roleId: jit.value.roleId, + source: "sso_jit", + }) + ); + if (provisionError) { + // e.g. the RBAC role couldn't be applied, so ensureOrgMember rolled back + // the membership. + logger.warn("SSO JIT provisioning failed", { + reason: provisionError instanceof Error ? provisionError.message : String(provisionError), + userId, + organizationId: jit.value.organizationId, + flow, + }); + return; + } + + if (!result.created) { + logger.info("SSO JIT skipped — membership already exists", { + userId, + organizationId: jit.value.organizationId, + }); + } +} + +async function runPostAuthentication(userId: string, isNewUser: boolean): Promise { + const user = await prisma.user.findFirst({ where: { id: userId } }); + if (!user) { + // The user was just resolved or created above, so a null here means it + // was hard-deleted mid-flow (or a DB inconsistency). Fail closed — throw + // rather than skipping postAuthentication and still returning a valid + // AuthUser, which would mint a session for a user we can't confirm. + throw new Error(`SSO user not found after resolution: ${userId}`); + } + await postAuthentication({ user, isNewUser, loginMethod: "SSO" }); +} + +export function addSsoStrategy(authenticator: Authenticator) { + authenticator.use( + new SsoStrategy(async ({ profile, flow }) => { + const { userId, isNewUser } = await resolveSsoUserId(profile); + + await attachSsoIdentityBestEffort(userId, profile, flow); + await provisionJitMembershipBestEffort(userId, profile, flow); + await runPostAuthentication(userId, isNewUser); + + // 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..8f9c6d2c046 --- /dev/null +++ b/apps/webapp/app/services/ssoAutoDiscovery.server.ts @@ -0,0 +1,93 @@ +import { tryCatch } from "@trigger.dev/core/v3"; +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. + // `Promise.resolve` lifts the ResultAsync (a PromiseLike) into a real + // Promise so it satisfies tryCatch's signature. + const [error, decision] = await tryCatch( + Promise.resolve(ssoController.decideRouteForEmail(normalised)) + ); + if (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..ec1d5168b49 --- /dev/null +++ b/apps/webapp/app/services/ssoSessionRevalidation.server.ts @@ -0,0 +1,161 @@ +import { json, redirect } from "@remix-run/node"; +import { tryCatch } from "@trigger.dev/core/v3"; +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. + const [setError, acquired] = await tryCatch(redis.set(key, "1", "EX", interval, "NX")); + if (setError) { + // Redis unavailable → fail-open, don't block the request. + logger.warn("SSO revalidation: redis SET NX failed; skipping", { error: setError }); + 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..0caf5c591f3 --- /dev/null +++ b/apps/webapp/app/v3/accountsWebhookWorker.server.ts @@ -0,0 +1,91 @@ +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(), + }) + // Zod v3 treats a `z.unknown()` field as optional, so a payload missing + // `data` entirely would otherwise validate. Require the key's presence + // at runtime via refine — `.nonoptional()` is a v4-only API and isn't + // available on the classic `z` import this repo pins. + .refine((payload) => "data" in payload, { + message: "data is required", + path: ["data"], + }); + +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 0d7ecd0adce..6b75b9ef903 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", @@ -25,6 +26,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 2aeb27e3038..bb80da3a7ec 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..7939b7b70d0 100644 --- a/packages/plugins/src/sso.ts +++ b/packages/plugins/src/sso.ts @@ -134,6 +134,19 @@ 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 in one atomic write, so an `internal` failure + // leaves none of the fields changed rather than a partially-applied + // config. 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 8327a809fef..2c49a347024 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