diff --git a/.changeset/sso-plugin-contract.md b/.changeset/sso-plugin-contract.md new file mode 100644 index 00000000000..1b6a275c2b3 --- /dev/null +++ b/.changeset/sso-plugin-contract.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/plugins": patch +--- + +Add the SSO plugin contract (`SsoController`, `SsoPlugin`, domain types, error unions). Vendor-neutral surface for self-service SSO setup, login routing, and JIT provisioning — the cloud implementation lives outside the package; OSS deployments get a no-op fallback that returns `no_sso` from `decideRouteForEmail`. diff --git a/.server-changes/accounts-webhook-passthrough.md b/.server-changes/accounts-webhook-passthrough.md new file mode 100644 index 00000000000..56f239469b6 --- /dev/null +++ b/.server-changes/accounts-webhook-passthrough.md @@ -0,0 +1,8 @@ +--- +area: webapp +type: feature +--- + +Add `POST /webhooks/v1/accounts`: a thin passthrough that verifies inbound +webhooks via the SSO plugin and enqueues them on a dedicated worker. No-op +(404) when no plugin is installed. diff --git a/.server-changes/sso-plugin-plumbing.md b/.server-changes/sso-plugin-plumbing.md new file mode 100644 index 00000000000..1a5c8d631dd --- /dev/null +++ b/.server-changes/sso-plugin-plumbing.md @@ -0,0 +1,8 @@ +--- +area: webapp +type: feature +--- + +Wire the SSO plugin loader (`@trigger.dev/sso`) into the webapp: SSO auth +method, `hasSso` flag, `SsoStrategy`, and contributor fallback env vars. +No-op (`no_sso`) without the plugin. diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index 3c17ff482ba..626866a41e7 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -3,6 +3,7 @@ import { ChartBarIcon, Cog8ToothIcon, CreditCardIcon, + LinkIcon, LockClosedIcon, ShieldCheckIcon, UserGroupIcon, @@ -18,6 +19,7 @@ import { organizationRolesPath, organizationSettingsPath, organizationSlackIntegrationPath, + organizationSsoPath, organizationTeamPath, organizationVercelIntegrationPath, rootPath, @@ -48,10 +50,12 @@ export function OrganizationSettingsSideMenu({ organization, buildInfo, isUsingPlugin, + isSsoUsingPlugin, }: { organization: MatchedOrganization; buildInfo: BuildInfo; isUsingPlugin: boolean; + isSsoUsingPlugin: boolean; }) { const { isManagedCloud } = useFeatures(); const featureFlags = useFeatureFlags(); @@ -117,7 +121,7 @@ export function OrganizationSettingsSideMenu({ {featureFlags.hasPrivateConnections && ( )} + {isManagedCloud && isSsoUsingPlugin && ( + Enterprise + ) + } + /> + )} { + const { userId, organizationId, roleId, source } = params; + + const existing = await prisma.orgMember.findFirst({ + where: { userId, organizationId }, + select: { id: true }, + }); + if (existing) { + return { created: false, orgMemberId: existing.id }; + } + + const member = await prisma.orgMember.create({ + data: { + userId, + organizationId, + role: "MEMBER", + }, + select: { id: true }, + }); + + if (roleId !== null) { + const result = await rbac.setUserRole({ userId, organizationId, roleId }); + if (!result.ok) { + logger.warn("ensureOrgMember.setUserRole failed", { + source, + userId, + organizationId, + roleId, + error: result.error, + }); + } + } + + return { created: true, orgMemberId: member.id }; +} diff --git a/apps/webapp/app/models/user.server.ts b/apps/webapp/app/models/user.server.ts index c48221c4b61..cbe1e8db788 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 { + assertEmailAllowed(email); + + const normalised = email.toLowerCase().trim(); + 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..3a49e6197d7 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx @@ -0,0 +1,798 @@ +import { + ArrowTopRightOnSquareIcon, + CheckCircleIcon, + ClockIcon, + ExclamationCircleIcon, + LockClosedIcon, +} from "@heroicons/react/20/solid"; +import { redirect, type MetaFunction } from "@remix-run/react"; +import type { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { useEffect, useState } from "react"; +import { useFetcher } from "@remix-run/react"; +import { z } from "zod"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { + MainHorizontallyCenteredContainer, + PageBody, + PageContainer, +} from "~/components/layout/AppLayout"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, +} from "~/components/primitives/Dialog"; +import { Header2 } from "~/components/primitives/Headers"; +import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { Switch } from "~/components/primitives/Switch"; +import { $replica } from "~/db.server"; +import { featuresForRequest } from "~/features.server"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { rbac } from "~/services/rbac.server"; +import { ssoController } from "~/services/sso.server"; +import { getCurrentPlan } from "~/services/platform.v3.server"; +import type { Role } from "@trigger.dev/plugins"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; +import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; +import { v3BillingPath } from "~/utils/pathBuilder"; + +export const meta: MetaFunction = () => [{ title: "SSO settings | Trigger.dev" }]; + +const Params = z.object({ organizationSlug: z.string() }); + +async function resolveOrg(slug: string) { + return $replica.organization.findFirst({ + where: { slug }, + select: { id: true, title: true }, + }); +} + +function planAllowsSso(plan: unknown): boolean { + if (!plan || typeof plan !== "object") return false; + const subscription = (plan as { v3Subscription?: { plan?: { code?: string } } }) + .v3Subscription; + return subscription?.plan?.code === "enterprise"; +} + +// The render-level upsell (planAllowsSso on the client) is cosmetic — +// any org member could still POST the actions directly. Mutations that +// provision real IdP-side resources are gated here, server-side. +async function requireSsoEntitlement(orgId: string): Promise { + const plan = await getCurrentPlan(orgId); + if (!planAllowsSso(plan)) { + throw new Response("SSO requires an Enterprise plan", { status: 403 }); + } +} + +export const loader = dashboardLoader( + { + params: Params, + context: async (params) => { + const org = await resolveOrg(params.organizationSlug); + return org ? { organizationId: org.id, orgTitle: org.title } : {}; + }, + authorization: { action: "manage", resource: { type: "sso" } }, + }, + async ({ context, request }) => { + const { isManagedCloud } = featuresForRequest(request); + // Gate on managed cloud AND the SSO plugin actually being loaded + // (SSO_ENABLED off → OSS fallback → isUsingPlugin false). Without + // this the page renders for every managed-cloud org even when SSO + // is disabled for the deployment. + if (!isManagedCloud || !(await ssoController.isUsingPlugin())) { + throw new Response("Not Found", { status: 404 }); + } + + const orgId = context.organizationId; + if (!orgId) { + throw new Response("Not Found", { status: 404 }); + } + + // The page is reachable on every paid + free plan; when the org + // isn't on Enterprise we render the upsell state instead of the + // SSO UI. Plan-tier enforcement lives in the React render so the + // sidebar entry and the page itself stay aligned. + const [statusResult, allRoles, assignableIds] = await Promise.all([ + ssoController.getStatus(orgId), + rbac.allRoles(orgId), + rbac.getAssignableRoleIds(orgId), + ]); + const status = statusResult.isOk() + ? statusResult.value + : { + hasIdpOrg: false, + enforced: false, + jitProvisioningEnabled: false, + jitDefaultRoleId: null, + idpOrgId: null, + primaryConnectionId: null, + domains: [] as Array<{ + domain: string; + verified: boolean; + state: "pending" | "verified" | "failed"; + verificationFailedReason: string | null; + }>, + connections: [] as Array<{ + id: string; + name: string | null; + connectionType: string; + state: "active" | "inactive"; + }>, + }; + + // JIT can't promote new users to Owner — that role is reserved for + // the founding member and explicit transfers. Plan-gated roles are + // filtered out via the assignable set so the UI doesn't offer + // something the org can't actually use. + const assignable = new Set(assignableIds); + const jitRoles = allRoles.filter( + (r) => r.name !== "Owner" && assignable.has(r.id) + ); + + return typedjson({ status, orgTitle: context.orgTitle, jitRoles }); + } +); + +const NULL_ROLE_VALUE = "__none__"; +const DEFAULT_JIT_ROLE_NAME = "Developer"; + +// Don't use `z.coerce.boolean()` — it goes through JS `Boolean()`, +// which treats the string "false" as truthy (any non-empty string). +const boolish = z + .union([z.literal("true"), z.literal("false")]) + .transform((v) => v === "true"); + +const ActionSchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("save_config"), + enforced: boolish, + jitEnabled: boolish, + jitRoleId: z.string(), + }), + z.object({ + action: z.literal("portal_link"), + intent: z.enum(["sso", "domain_verification"]), + }), +]); + +export const action = dashboardAction( + { + params: Params, + context: async (params) => { + const org = await resolveOrg(params.organizationSlug); + return org ? { organizationId: org.id } : {}; + }, + authorization: { action: "manage", resource: { type: "sso" } }, + }, + async ({ request, context, user, params }) => { + const orgId = context.organizationId; + if (!orgId) { + throw new Response("Not Found", { status: 404 }); + } + + const { isManagedCloud } = featuresForRequest(request); + if (!isManagedCloud) { + throw new Response("Not Found", { status: 404 }); + } + await requireSsoEntitlement(orgId); + + const formData = await request.formData(); + const parsed = ActionSchema.safeParse({ + action: formData.get("action"), + enforced: formData.get("enforced") ?? undefined, + jitEnabled: formData.get("jitEnabled") ?? undefined, + jitRoleId: formData.get("jitRoleId") ?? undefined, + intent: formData.get("intent") ?? undefined, + }); + if (!parsed.success) { + return new Response("Bad Request", { status: 400 }); + } + + switch (parsed.data.action) { + case "save_config": { + const jitRoleId = + parsed.data.jitRoleId === NULL_ROLE_VALUE ? null : parsed.data.jitRoleId; + // Issue all three writes in parallel — they touch the same + // OrgSsoConfig row but only update disjoint columns, so there + // is no contention. A failure on any leaves the others applied; + // surface the first error string back to the form. + const [enforced, jit, jitRole] = await Promise.all([ + ssoController.setEnforced({ + organizationId: orgId, + enforced: parsed.data.enforced, + }), + ssoController.setJitProvisioningEnabled({ + organizationId: orgId, + enabled: parsed.data.jitEnabled, + }), + ssoController.setJitDefaultRole({ organizationId: orgId, roleId: jitRoleId }), + ]); + const failed = [enforced, jit, jitRole].find((r) => r.isErr()); + if (failed && failed.isErr()) { + return new Response(`Error: ${failed.error}`, { status: 400 }); + } + return redirect(`/orgs/${params.organizationSlug}/settings/sso`); + } + case "portal_link": { + const url = new URL(request.url); + const returnUrl = `${url.protocol}//${url.host}/orgs/${params.organizationSlug}/settings/sso`; + const result = await ssoController.generatePortalLink({ + organizationId: orgId, + userId: user.id, + intent: parsed.data.intent, + returnUrl, + }); + if (result.isErr()) { + return Response.json({ ok: false, error: result.error }, { status: 400 }); + } + return Response.json({ ok: true, url: result.value.url }); + } + } + } +); + +function defaultJitRoleId( + jitRoles: ReadonlyArray, + current: string | null +): string { + // Persisted value wins, even when it points at something the picker + // can no longer offer — keeps the user's prior choice visible. + if (current) return current; + const dev = jitRoles.find((r) => r.name === DEFAULT_JIT_ROLE_NAME); + return dev?.id ?? NULL_ROLE_VALUE; +} + +export default function Page() { + const { status, orgTitle, jitRoles } = useTypedLoaderData(); + const organization = useOrganization(); + const _plan = useCurrentPlan(); + + const isEntitled = planAllowsSso(_plan); + const activeConnections = status.connections.filter((c) => c.state === "active"); + const hasActive = activeConnections.length > 0; + + // Deferred-save: each field starts mirrored from `status`, edits stay + // local until Save commits all three to the action. The `key` trick + // below resets local state after a successful save (when `status` + // changes via revalidation following the redirect). + const initialJitRoleId = defaultJitRoleId(jitRoles, status.jitDefaultRoleId); + const [draftEnforced, setDraftEnforced] = useState(status.enforced); + const [draftJitEnabled, setDraftJitEnabled] = useState(status.jitProvisioningEnabled); + const [draftJitRoleId, setDraftJitRoleId] = useState(initialJitRoleId); + + // Re-sync drafts when the loader returns fresh `status` (post-save + // redirect → revalidation). useEffect rather than a memo so we don't + // stomp in-flight edits during the same render. + useEffect(() => { + setDraftEnforced(status.enforced); + setDraftJitEnabled(status.jitProvisioningEnabled); + setDraftJitRoleId(defaultJitRoleId(jitRoles, status.jitDefaultRoleId)); + // jitRoles only changes if the org changes; the role list itself is + // stable across saves on a given org. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [status.enforced, status.jitProvisioningEnabled, status.jitDefaultRoleId]); + + const isDirty = + draftEnforced !== status.enforced || + draftJitEnabled !== status.jitProvisioningEnabled || + draftJitRoleId !== initialJitRoleId; + + const [portalUrl, setPortalUrl] = useState(null); + const [enforceModalOpen, setEnforceModalOpen] = useState(false); + const portalFetcher = useFetcher<{ ok: boolean; url?: string; error?: string }>(); + const saveFetcher = useFetcher(); + const isSaving = saveFetcher.state !== "idle"; + + useEffect(() => { + if (portalFetcher.data?.ok && portalFetcher.data.url) { + setPortalUrl(portalFetcher.data.url); + } + }, [portalFetcher.data]); + + const openPortal = (intent: "sso" | "domain_verification") => { + setPortalUrl(null); + portalFetcher.submit( + { action: "portal_link", intent }, + { method: "POST" } + ); + }; + + const submitSave = () => { + saveFetcher.submit( + { + action: "save_config", + enforced: draftEnforced ? "true" : "false", + jitEnabled: draftJitEnabled ? "true" : "false", + jitRoleId: draftJitRoleId, + }, + { method: "POST" } + ); + }; + + return ( + + + + + + + {!isEntitled ? ( + + ) : !status.hasIdpOrg ? ( + openPortal("sso")} /> + ) : !hasActive ? ( + openPortal("sso")} + onOpenDomain={() => openPortal("domain_verification")} + /> + ) : ( + openPortal("sso")} + onToggleEnforced={(next) => { + // Going on→off is harmless; going off→on locks users out so + // we still require explicit confirmation. The modal updates + // the draft only; nothing is persisted until Save. + if (next && !status.enforced) { + setEnforceModalOpen(true); + } else { + setDraftEnforced(next); + } + }} + onToggleJit={(next) => setDraftJitEnabled(next)} + onChangeJitRole={(roleId) => setDraftJitRoleId(roleId ?? NULL_ROLE_VALUE)} + onSave={submitSave} + /> + )} + + + + setPortalUrl(null)} /> + + setEnforceModalOpen(false)} + onConfirm={() => { + setDraftEnforced(true); + setEnforceModalOpen(false); + }} + /> + + ); +} + +function EnterpriseUpsellState({ organizationSlug }: { organizationSlug: string }) { + return ( +
+
+ + SSO is available on the Enterprise plan +
+ + Single sign-on (SAML / OIDC) lets your IT admins manage who can access Trigger.dev + through your identity provider — Okta, Azure AD, Google Workspace, OneLogin, and more. + Upgrade your organization to Enterprise to configure it. + +
    +
  • Self-service domain verification and connection setup via the admin portal.
  • +
  • Just-in-time user provisioning for your verified domains.
  • +
  • Per-domain enforcement so contractors keep using existing sign-in methods.
  • +
+
+ + Talk to sales + + + Contact us + +
+
+ ); +} + +function NoIdpOrgState({ onOpenPortal }: { onOpenPortal: () => void }) { + return ( +
+ Configure SSO for your organization + + Single sign-on lets your IT admins manage who can access Trigger.dev through your + identity provider (Okta, Azure AD, Google Workspace, OneLogin, and more). The first + click opens the admin portal in a 5-minute single-use link. + + +
+ ); +} + +type DomainRow = { + domain: string; + verified: boolean; + state: "pending" | "verified" | "failed"; + verificationFailedReason: string | null; +}; + +function NoActiveConnectionState({ + domains, + onOpenSso, + onOpenDomain, +}: { + domains: ReadonlyArray; + onOpenSso: () => void; + onOpenDomain: () => void; +}) { + const verifiedDomains = domains.filter((d) => d.state === "verified"); + const failedDomains = domains.filter((d) => d.state === "failed"); + const pendingDomains = domains.filter((d) => d.state === "pending"); + const hasUnresolved = failedDomains.length > 0 || pendingDomains.length > 0; + + return ( +
+ {failedDomains.length > 0 && ( + + {failedDomains.length === 1 + ? `Domain verification failed for ${failedDomains[0].domain}. Re-check the DNS records in the admin portal and re-run verification.` + : `${failedDomains.length} domains failed verification. Re-check the DNS records in the admin portal and re-run verification.`} + + )} + {failedDomains.length === 0 && verifiedDomains.length > 0 && ( + + {verifiedDomains.length === 1 + ? `Domain verified: ${verifiedDomains[0].domain}. Continue in the admin portal to finish setting up your identity provider connection.` + : `${verifiedDomains.length} domains verified. Continue in the admin portal to finish setting up your identity provider connection.`} + + )} + {failedDomains.length === 0 && verifiedDomains.length === 0 && ( + + Not yet configured. Continue in the admin portal to verify a domain and set up your + identity provider connection. + + )} + + {domains.length > 0 && ( +
+ Domains + +
+ )} + +
+ + +
+
+ ); +} + +function DomainList({ domains }: { domains: ReadonlyArray }) { + return ( +
    + {domains.map((d) => { + const visual = domainVisual(d.state); + return ( +
  • +
    + {d.domain} + {d.state === "failed" && d.verificationFailedReason && ( + + Reason: {d.verificationFailedReason} + + )} +
    + + {visual.icon} + {d.state} + +
  • + ); + })} +
+ ); +} + +function domainVisual(state: DomainRow["state"]) { + switch (state) { + case "verified": + return { + row: "border-emerald-500/30 bg-emerald-500/5", + label: "text-emerald-400", + icon: , + }; + case "failed": + return { + row: "border-rose-500/30 bg-rose-500/5", + label: "text-rose-400", + icon: , + }; + case "pending": + default: + return { + row: "border-amber-500/20 bg-amber-500/5", + label: "text-amber-400", + icon: , + }; + } +} + +function ActiveConnectionState({ + orgTitle, + status, + activeConnections, + jitRoles, + draftEnforced, + draftJitEnabled, + draftJitRoleId, + isDirty, + isSaving, + onTogglePortal, + onToggleEnforced, + onToggleJit, + onChangeJitRole, + onSave, +}: { + orgTitle: string; + status: { + enforced: boolean; + jitProvisioningEnabled: boolean; + jitDefaultRoleId: string | null; + domains: ReadonlyArray; + }; + activeConnections: ReadonlyArray<{ id: string; name: string | null; connectionType: string }>; + jitRoles: ReadonlyArray; + draftEnforced: boolean; + draftJitEnabled: boolean; + draftJitRoleId: string; + isDirty: boolean; + isSaving: boolean; + onTogglePortal: () => void; + onToggleEnforced: (next: boolean) => void; + onToggleJit: (next: boolean) => void; + onChangeJitRole: (roleId: string | null) => void; + onSave: () => void; +}) { + return ( +
+
+ {orgTitle} – SSO connection + {activeConnections.map((conn) => ( +
+ + {conn.name ?? conn.connectionType} + + + Type: {conn.connectionType} + +
+ ))} +
+ +
+ Verified domains + {status.domains.length === 0 ? ( + + No domains verified yet. + + ) : ( + + )} +
+ +
+ Configuration +
+
+ + Require SSO for matching domains + + + When on, users whose email matches a verified domain must use SSO to sign in. + +
+ +
+
+
+ + JIT provisioning + + + Auto-create memberships for first-time SSO sign-ins from your verified domains. + +
+ +
+
+
+ + Default role for JIT provisioned users + + + Role assigned to new users created via JIT provisioning. Owner is reserved + and cannot be granted automatically. + +
+ + value={draftJitRoleId} + setValue={(v) => onChangeJitRole(v === NULL_ROLE_VALUE ? null : v)} + items={[ + { id: NULL_ROLE_VALUE, name: "None", description: "" }, + ...jitRoles, + ]} + variant="tertiary/small" + dropdownIcon + text={(v) => + v === NULL_ROLE_VALUE + ? "None" + : jitRoles.find((r) => r.id === v)?.name ?? "Select a role" + } + > + {(items) => + items.map((role) => ( + + + {role.name} + {role.description ? ( + {role.description} + ) : null} + + + )) + } + +
+
+ { + e.preventDefault(); + onTogglePortal(); + }} + > + Open admin portal + + +
+
+
+ ); +} + +function PortalLinkDialog({ + url, + onClose, +}: { + url: string | null; + onClose: () => void; +}) { + return ( + (open ? undefined : onClose())}> + + Admin portal link + + This link is active for 5 minutes — copy it and share it with your IT contact via + whatever channel you prefer. + +
+ {url ?? ""} +
+ + +
+ + +
+
+
+
+ ); +} + +function EnforceConfirmDialog({ + open, + orgTitle, + onCancel, + onConfirm, +}: { + open: boolean; + orgTitle: string; + onCancel: () => void; + onConfirm: () => void; +}) { + return ( + (next ? undefined : onCancel())}> + + Enable SSO enforcement for {orgTitle}? + + Once enabled, users whose email domain matches your verified domains will be + redirected to your identity provider to sign in. They will no longer be able to use + magic link, GitHub, or Google via that domain. +
+
+ Users with non-matching emails (e.g. contractors with personal emails) will continue + to use existing methods. +
+ + + + +
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx index a24a857d6f6..6a6487ec4fd 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx @@ -9,8 +9,13 @@ import { } from "~/components/navigation/OrganizationSettingsSideMenu"; import { useOrganization } from "~/hooks/useOrganizations"; import { rbac } from "~/services/rbac.server"; +import { ssoController } from "~/services/sso.server"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const [isUsingPlugin, isSsoUsingPlugin] = await Promise.all([ + rbac.isUsingPlugin(), + ssoController.isUsingPlugin(), + ]); return typedjson({ buildInfo: { appVersion: process.env.BUILD_APP_VERSION, @@ -19,12 +24,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { gitRefName: process.env.BUILD_GIT_REF_NAME, buildTimestampSeconds: process.env.BUILD_TIMESTAMP_SECONDS, } satisfies BuildInfo, - isUsingPlugin: await rbac.isUsingPlugin(), + isUsingPlugin, + isSsoUsingPlugin, }); }; export default function Page() { - const { buildInfo, isUsingPlugin } = useTypedLoaderData(); + const { buildInfo, isUsingPlugin, isSsoUsingPlugin } = useTypedLoaderData(); const organization = useOrganization(); return ( @@ -34,6 +40,7 @@ export default function Page() { organization={organization} buildInfo={buildInfo} isUsingPlugin={isUsingPlugin} + isSsoUsingPlugin={isSsoUsingPlugin} /> diff --git a/apps/webapp/app/routes/auth.github.callback.tsx b/apps/webapp/app/routes/auth.github.callback.tsx index 44277dbf941..89f4f88a7a0 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 { ssoRedirectFromAuthError } from "~/services/ssoAutoDiscovery.server"; +import type { AuthUser } from "~/services/authUser"; import { redirectCookie } from "./auth.github"; import { sanitizeRedirectPath } from "~/utils"; @@ -15,9 +17,26 @@ export let loader: LoaderFunction = async ({ request }) => { const redirectValue = await redirectCookie.parse(cookie); const redirectTo = sanitizeRedirectPath(redirectValue); - const auth = await authenticator.authenticate("github", request, { - failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response - }); + // The SSO auto-discovery gate runs inside the strategy's verify + // callback (before any account write), so an SSO-enforced domain + // throws out here instead of linking the GitHub identity. remix-auth + // surfaces its own OAuth redirects by throwing Responses — pass those + // through; an SsoRequiredError becomes the SSO redirect. + let auth: AuthUser; + try { + // throwOnError so a verify-callback throw surfaces as an + // AuthorizationError (carrying the SsoRequiredError as `cause`) + // rather than being flattened into a bare 401 Response — otherwise + // the SSO-enforced redirect below is never reached. + auth = await authenticator.authenticate("github", request, { throwOnError: true }); + } catch (thrown) { + if (thrown instanceof Response) throw thrown; + const ssoRedirect = ssoRedirectFromAuthError(thrown); + if (ssoRedirect) { + return redirect(ssoRedirect); + } + return redirect("/login"); + } const session = await getUserSession(request); diff --git a/apps/webapp/app/routes/auth.google.callback.tsx b/apps/webapp/app/routes/auth.google.callback.tsx index e065a9de58e..23f78f3fc51 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 { ssoRedirectFromAuthError } from "~/services/ssoAutoDiscovery.server"; +import type { AuthUser } from "~/services/authUser"; import { redirectCookie } from "./auth.google"; import { sanitizeRedirectPath } from "~/utils"; @@ -15,9 +17,26 @@ export let loader: LoaderFunction = async ({ request }) => { const redirectValue = await redirectCookie.parse(cookie); const redirectTo = sanitizeRedirectPath(redirectValue); - const auth = await authenticator.authenticate("google", request, { - failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response - }); + // The SSO auto-discovery gate runs inside the strategy's verify + // callback (before any account write), so an SSO-enforced domain + // throws out here instead of linking the Google identity. remix-auth + // surfaces its own OAuth redirects by throwing Responses — pass those + // through; an SsoRequiredError becomes the SSO redirect. + let auth: AuthUser; + try { + // throwOnError so a verify-callback throw surfaces as an + // AuthorizationError (carrying the SsoRequiredError as `cause`) + // rather than being flattened into a bare 401 Response — otherwise + // the SSO-enforced redirect below is never reached. + auth = await authenticator.authenticate("google", request, { throwOnError: true }); + } catch (thrown) { + if (thrown instanceof Response) throw thrown; + const ssoRedirect = ssoRedirectFromAuthError(thrown); + if (ssoRedirect) { + return redirect(ssoRedirect); + } + return redirect("/login"); + } const session = await getUserSession(request); diff --git a/apps/webapp/app/routes/auth.sso.callback.tsx b/apps/webapp/app/routes/auth.sso.callback.tsx new file mode 100644 index 00000000000..5327a5616c6 --- /dev/null +++ b/apps/webapp/app/routes/auth.sso.callback.tsx @@ -0,0 +1,96 @@ +import { redirect, type LoaderFunctionArgs } from "@remix-run/node"; +import type { SsoFlow, SsoProfile } from "@trigger.dev/plugins"; +import { prisma } from "~/db.server"; +import { redirectWithErrorMessage } from "~/models/message.server"; +import { authenticator } from "~/services/auth.server"; +import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server"; +import { logger } from "~/services/logger.server"; +import { ssoController } from "~/services/sso.server"; +import { commitAuthenticatedSession } from "~/services/sessionDuration.server"; +import { commitSession, getUserSession } from "~/services/sessionStorage.server"; + +// Resolve the SSO completion for either the SP-initiated (state present) +// or IdP-initiated (no state) flow. Throws a redirect to the error page +// on failure, letting the caller stay on the happy path. Returning a +// single shape here is what lets the loader use a plain destructure +// rather than three conditionally-assigned `let`s. +async function resolveSsoCompletion( + code: string, + state: string | null +): Promise<{ profile: SsoProfile; redirectTo: string; flow: SsoFlow }> { + if (state) { + const completion = await ssoController.completeAuthorization({ code, state }); + if (completion.isErr()) { + logger.warn("SSO callback failed", { reason: completion.error, idpInitiated: false }); + throw redirect(`/login/sso?error=sso_failed`); + } + return completion.value; + } + + const completion = await ssoController.completeIdpInitiatedAuthorization({ code }); + if (completion.isErr()) { + logger.warn("SSO callback failed", { reason: completion.error, idpInitiated: true }); + throw redirect(`/login/sso?error=sso_failed`); + } + return { ...completion.value, flow: "idp_initiated" }; +} + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + if (!code) { + return redirect(`/login/sso?error=missing_code`); + } + const state = url.searchParams.get("state"); + + const { profile, redirectTo, flow } = await resolveSsoCompletion(code, state); + + const auth = await authenticator.authenticate("sso", request, { + throwOnError: true, + context: { profile, flow }, + }); + + const session = await getUserSession(request); + + const userRecord = await prisma.user.findFirst({ + where: { id: auth.userId }, + select: { id: true, mfaEnabledAt: true }, + }); + if (!userRecord) { + return redirectWithErrorMessage( + "/login", + request, + "Could not find your account. Please contact support." + ); + } + + if (userRecord.mfaEnabledAt) { + session.set("pending-mfa-user-id", userRecord.id); + session.set("pending-mfa-redirect-to", redirectTo); + // Carry the SSO marker through the MFA hop so the final session is + // revalidated against the IdP exactly like a non-MFA SSO session. + session.set("pending-sso", { + idpOrgId: profile.idpOrgId, + connectionId: profile.idpConnectionId, + }); + + const headers = new Headers(); + headers.append("Set-Cookie", await commitSession(session)); + headers.append("Set-Cookie", await setLastAuthMethodHeader("sso")); + return redirect("/login/mfa", { headers }); + } + + // Mark the session as SSO-established so the periodic re-validation + // hook knows to check it against the IdP. The marker is signed into + // the cookie (tamper-proof). + session.set(authenticator.sessionKey, { + ...auth, + sso: { idpOrgId: profile.idpOrgId, connectionId: profile.idpConnectionId }, + }); + + const headers = new Headers(); + headers.append("Set-Cookie", await commitAuthenticatedSession(session, auth.userId)); + headers.append("Set-Cookie", await setLastAuthMethodHeader("sso")); + + return redirect(redirectTo, { headers }); +} diff --git a/apps/webapp/app/routes/auth.sso.ts b/apps/webapp/app/routes/auth.sso.ts new file mode 100644 index 00000000000..c1eb6113451 --- /dev/null +++ b/apps/webapp/app/routes/auth.sso.ts @@ -0,0 +1,78 @@ +import { redirect, type ActionFunctionArgs } from "@remix-run/node"; +import { tryCatch } from "@trigger.dev/core/v3"; +import { SSO_FLOWS, type SsoFlow } from "@trigger.dev/plugins"; +import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; +import { ssoController } from "~/services/sso.server"; +import { + checkSsoEmailRateLimit, + checkSsoIpRateLimit, + SsoRateLimitError, +} from "~/services/ssoRateLimiter.server"; +import { extractClientIp } from "~/utils/extractClientIp.server"; +import { sanitizeRedirectPath } from "~/utils"; + +const VALID_FLOWS: ReadonlySet = new Set(SSO_FLOWS); + +function isSsoFlow(value: string): value is SsoFlow { + return VALID_FLOWS.has(value as SsoFlow); +} + +export async function action({ request }: ActionFunctionArgs) { + if (request.method !== "POST") { + return new Response(null, { status: 405 }); + } + + const form = await request.formData(); + const rawEmail = form.get("email"); + if (typeof rawEmail !== "string" || rawEmail.trim().length === 0) { + return redirect("/login/sso?error=missing_email"); + } + const email = rawEmail.toLowerCase().trim(); + + const rawRedirectTo = form.get("redirectTo"); + const redirectTo = + sanitizeRedirectPath(typeof rawRedirectTo === "string" ? rawRedirectTo : null) ?? "/"; + const rawFlow = (form.get("flow") as string | null) ?? "user_initiated"; + const flow: SsoFlow = isSsoFlow(rawFlow) ? rawFlow : "user_initiated"; + + if (env.LOGIN_RATE_LIMITS_ENABLED) { + const xff = request.headers.get("x-forwarded-for"); + const clientIp = extractClientIp(xff); + const [rateError] = await tryCatch( + Promise.all([ + clientIp ? checkSsoIpRateLimit(clientIp) : Promise.resolve(), + checkSsoEmailRateLimit(email), + ]) + ); + if (rateError) { + if (rateError instanceof SsoRateLimitError) { + logger.warn("SSO login rate limit exceeded", { clientIp, email }); + } else { + logger.error("SSO login rate limiter failed", { clientIp, email, error: rateError }); + } + return redirect(`/login/sso?email=${encodeURIComponent(email)}&error=rate_limited`); + } + } + + // decideRouteForEmail is the auto-discovery gate — "should I redirect + // a magic-link / OAuth attempt to SSO?" That gate requires + // enforced=true. user_initiated means the user explicitly chose SSO, + // so enforcement is irrelevant; we just need a configured domain, + // which beginAuthorization itself validates (returns + // no_org_for_domain / no_active_connection). + if (flow !== "user_initiated") { + const decision = await ssoController.decideRouteForEmail(email); + if (decision.isErr() || decision.value.kind === "no_sso") { + return redirect(`/login/sso?email=${encodeURIComponent(email)}&error=no_sso_for_domain`); + } + } + + const begun = await ssoController.beginAuthorization({ email, redirectTo, flow }); + if (begun.isErr()) { + logger.warn("SSO beginAuthorization failed", { reason: begun.error, email, flow }); + return redirect(`/login/sso?email=${encodeURIComponent(email)}&error=${begun.error}`); + } + + return redirect(begun.value.url); +} diff --git a/apps/webapp/app/routes/login._index/route.tsx b/apps/webapp/app/routes/login._index/route.tsx index 72901fa5ddb..5686dd53149 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"; @@ -12,18 +12,27 @@ 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 { 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,6 +99,7 @@ export async function loader({ request }: LoaderFunctionArgs) { redirectTo, showGithubAuth: isGithubAuthSupported, showGoogleAuth: isGoogleAuthSupported, + showSsoAuth, lastAuthMethod, authError: null, isVercelMarketplace: redirectTo.startsWith("/vercel/callback"), @@ -105,6 +127,7 @@ export async function loader({ request }: LoaderFunctionArgs) { redirectTo: null, showGithubAuth: isGithubAuthSupported, showGoogleAuth: isGoogleAuthSupported, + showSsoAuth, lastAuthMethod, authError, isVercelMarketplace: false, @@ -181,6 +204,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..cc25e418f5a 100644 --- a/apps/webapp/app/routes/login.magic/route.tsx +++ b/apps/webapp/app/routes/login.magic/route.tsx @@ -30,6 +30,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 +131,59 @@ 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, + }); + } + + const errorMessage = + error instanceof MagicLinkRateLimitError + ? "Too many magic link requests. Please try again shortly." + : "Failed sending magic link. Please try again shortly."; - if (error) { - if (error instanceof MagicLinkRateLimitError) { - logger.warn("Login magic link rate limit exceeded", { - clientIp, - email, - error, + 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. + const ssoRedirect = await ssoRedirectForEmail(email, "domain_policy"); + 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..ae6d15168ae --- /dev/null +++ b/apps/webapp/app/routes/login.sso/route.tsx @@ -0,0 +1,157 @@ +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"; + +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) { + 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/magic.tsx b/apps/webapp/app/routes/magic.tsx index d4606e1b7de..26717916474 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 { ssoRedirectFromAuthError } from "~/services/ssoAutoDiscovery.server"; +import type { AuthUser } from "~/services/authUser"; import { sanitizeRedirectPath } from "~/utils"; export async function loader({ request }: LoaderFunctionArgs) { @@ -16,9 +18,20 @@ 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(ssoRedirect); + } + return redirect("/login/magic"); + } // manually get the session const session = await getSession(request.headers.get("cookie")); diff --git a/apps/webapp/app/routes/vercel.onboarding.tsx b/apps/webapp/app/routes/vercel.onboarding.tsx index 1cf351532e9..4d6b5b26f6b 100644 --- a/apps/webapp/app/routes/vercel.onboarding.tsx +++ b/apps/webapp/app/routes/vercel.onboarding.tsx @@ -19,6 +19,7 @@ import { ButtonSpinner } from "~/components/primitives/Spinner"; import { prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; +import { ssoRedirectForEmail } from "~/services/ssoAutoDiscovery.server"; import { confirmBasicDetailsPath, newProjectPath } from "~/utils/pathBuilder"; import { redirectWithErrorMessage } from "~/models/message.server"; import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; @@ -192,6 +193,20 @@ 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) { + const ssoRedirect = await ssoRedirectForEmail(sessionUser.email, "oauth_blocked"); + if (ssoRedirect) { + return redirect(ssoRedirect); + } + } + const { code, configurationId, next } = submission.data; // Handle org selection diff --git a/apps/webapp/app/routes/webhooks.v1.accounts.ts b/apps/webapp/app/routes/webhooks.v1.accounts.ts new file mode 100644 index 00000000000..e868873ded1 --- /dev/null +++ b/apps/webapp/app/routes/webhooks.v1.accounts.ts @@ -0,0 +1,44 @@ +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { logger } from "~/services/logger.server"; +import { ssoController } from "~/services/sso.server"; +import { accountsWebhookWorker } from "~/v3/accountsWebhookWorker.server"; + +// Thin, vendor-neutral passthrough for inbound account-management +// webhooks. This route does NOT verify or interpret the payload — it +// forwards the raw body + headers to the plugin, which owns the +// provider-specific signature scheme, then enqueues the verified event +// for the background worker. When no plugin is installed the controller +// returns `feature_disabled` and we 404 (don't advertise the endpoint). +export async function action({ request }: ActionFunctionArgs) { + if (request.method !== "POST") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + const rawBody = await request.text(); + const headers = Object.fromEntries(request.headers); + + const verified = await ssoController.verifyWebhook({ rawBody, headers }); + + if (verified.isErr()) { + switch (verified.error) { + case "invalid_signature": + return json({ error: "invalid signature" }, { status: 400 }); + case "feature_disabled": + return json({ error: "not found" }, { status: 404 }); + default: + // Transient/internal — let the provider retry. + logger.error("accounts webhook verify failed", { reason: verified.error }); + return json({ error: "internal error" }, { status: 500 }); + } + } + + // Idempotent enqueue keyed on the event id — providers redeliver, so + // dedupe at the door. Processing happens async in accountsWebhookWorker. + await accountsWebhookWorker.enqueueOnce({ + id: verified.value.event.id, + job: "account.webhook.event", + payload: verified.value.event, + }); + + return json({ received: true }, { status: 200 }); +} diff --git a/apps/webapp/app/services/auth.server.ts b/apps/webapp/app/services/auth.server.ts index c5650691012..0c0a276806d 100644 --- a/apps/webapp/app/services/auth.server.ts +++ b/apps/webapp/app/services/auth.server.ts @@ -4,6 +4,7 @@ import { addEmailLinkStrategy } from "./emailAuth.server"; import { addGitHubStrategy } from "./gitHubAuth.server"; import { addGoogleStrategy } from "./googleAuth.server"; import { sessionStorage } from "./sessionStorage.server"; +import { addSsoStrategy } from "./ssoAuth.server"; import { env } from "~/env.server"; // Create an instance of the authenticator, pass a generic with what @@ -27,5 +28,6 @@ if (env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET) { } addEmailLinkStrategy(authenticator); +addSsoStrategy(authenticator); export { authenticator, isGithubAuthSupported, isGoogleAuthSupported }; diff --git a/apps/webapp/app/services/authUser.ts b/apps/webapp/app/services/authUser.ts index 4c1ce6a209b..5109f2c4bc1 100644 --- a/apps/webapp/app/services/authUser.ts +++ b/apps/webapp/app/services/authUser.ts @@ -1,3 +1,11 @@ export type AuthUser = { userId: string; + // Present only when the session was established via SSO. Carries the + // minimum the periodic re-validation hook needs to ask the IdP whether + // the session is still valid. Signed into the session cookie, so it's + // tamper-proof. Absent ⇒ non-SSO session ⇒ never revalidated. + sso?: { + idpOrgId: string; + connectionId: string; + }; }; diff --git a/apps/webapp/app/services/emailAuth.server.tsx b/apps/webapp/app/services/emailAuth.server.tsx index 81d4ffcc18c..9e8fbdb44b4 100644 --- a/apps/webapp/app/services/emailAuth.server.tsx +++ b/apps/webapp/app/services/emailAuth.server.tsx @@ -7,6 +7,7 @@ import type { AuthUser } from "./authUser"; import { logger } from "./logger.server"; import { postAuthentication } from "./postAuth.server"; +import { SsoRequiredError, ssoRedirectForEmail } from "./ssoAutoDiscovery.server"; let secret = env.MAGIC_LINK_SECRET; if (!secret) throw new Error("Missing MAGIC_LINK_SECRET env variable."); @@ -29,6 +30,16 @@ const emailStrategy = new EmailLinkStrategy( }) => { logger.info("Magic link user authenticated", { email, magicLinkVerify }); + // Gate the link CLICK, not just the send: a magic link issued before + // SSO enforcement flipped on (or replayed within its validity + // window) must not mint a session for an enforced domain. + if (magicLinkVerify) { + const ssoRedirect = await ssoRedirectForEmail(email, "domain_policy"); + if (ssoRedirect) { + throw new SsoRequiredError(ssoRedirect); + } + } + try { const { user, isNewUser } = await findOrCreateUser({ email, diff --git a/apps/webapp/app/services/gitHubAuth.server.ts b/apps/webapp/app/services/gitHubAuth.server.ts index 981a22a2d0a..f757a57d83c 100644 --- a/apps/webapp/app/services/gitHubAuth.server.ts +++ b/apps/webapp/app/services/gitHubAuth.server.ts @@ -5,6 +5,7 @@ import { findOrCreateUser } from "~/models/user.server"; import type { AuthUser } from "./authUser"; import { logger } from "./logger.server"; import { postAuthentication } from "./postAuth.server"; +import { SsoRequiredError, ssoRedirectForEmail } from "./ssoAutoDiscovery.server"; export function addGitHubStrategy( authenticator: Authenticator, @@ -24,6 +25,16 @@ export function addGitHubStrategy( throw new Error("GitHub login requires an email address"); } + const email = emails[0].value; + + // SSO auto-discovery gate — BEFORE findOrCreateUser, so an + // SSO-enforced domain never gets this GitHub identity linked onto + // an existing account. + const ssoRedirect = await ssoRedirectForEmail(email, "oauth_blocked"); + if (ssoRedirect) { + throw new SsoRequiredError(ssoRedirect); + } + try { logger.debug("GitHub login", { emails, @@ -32,7 +43,7 @@ export function addGitHubStrategy( }); const { user, isNewUser } = await findOrCreateUser({ - email: emails[0].value, + email, authenticationMethod: "GITHUB", authenticationProfile: profile, authenticationExtraParams: extraParams, diff --git a/apps/webapp/app/services/googleAuth.server.ts b/apps/webapp/app/services/googleAuth.server.ts index bcd227f2e97..d79c4983418 100644 --- a/apps/webapp/app/services/googleAuth.server.ts +++ b/apps/webapp/app/services/googleAuth.server.ts @@ -5,6 +5,7 @@ import { findOrCreateUser } from "~/models/user.server"; import type { AuthUser } from "./authUser"; import { logger } from "./logger.server"; import { postAuthentication } from "./postAuth.server"; +import { SsoRequiredError, ssoRedirectForEmail } from "./ssoAutoDiscovery.server"; export function addGoogleStrategy( authenticator: Authenticator, @@ -24,6 +25,16 @@ export function addGoogleStrategy( throw new Error("Google login requires an email address"); } + const email = emails[0].value; + + // SSO auto-discovery gate — BEFORE findOrCreateUser, so an + // SSO-enforced domain never gets this Google identity linked onto + // an existing account. + const ssoRedirect = await ssoRedirectForEmail(email, "oauth_blocked"); + if (ssoRedirect) { + throw new SsoRequiredError(ssoRedirect); + } + try { logger.debug("Google login", { emails, @@ -32,7 +43,7 @@ export function addGoogleStrategy( }); const { user, isNewUser } = await findOrCreateUser({ - email: emails[0].value, + email, authenticationMethod: "GOOGLE", authenticationProfile: profile, authenticationExtraParams: extraParams, diff --git a/apps/webapp/app/services/lastAuthMethod.server.ts b/apps/webapp/app/services/lastAuthMethod.server.ts index e058ea73a02..6fdbc80917c 100644 --- a/apps/webapp/app/services/lastAuthMethod.server.ts +++ b/apps/webapp/app/services/lastAuthMethod.server.ts @@ -1,7 +1,7 @@ import { createCookie } from "@remix-run/node"; import { env } from "~/env.server"; -export type LastAuthMethod = "github" | "google" | "email"; +export type LastAuthMethod = "github" | "google" | "email" | "sso"; // Cookie that persists for 1 year to remember the user's last login method export const lastAuthMethodCookie = createCookie("last-auth-method", { @@ -14,7 +14,7 @@ export const lastAuthMethodCookie = createCookie("last-auth-method", { export async function getLastAuthMethod(request: Request): Promise { const cookie = request.headers.get("Cookie"); const value = await lastAuthMethodCookie.parse(cookie); - if (value === "github" || value === "google" || value === "email") { + if (value === "github" || value === "google" || value === "email" || value === "sso") { return value; } return null; diff --git a/apps/webapp/app/services/session.server.ts b/apps/webapp/app/services/session.server.ts index 3c55e8efa83..bdd565cf2f9 100644 --- a/apps/webapp/app/services/session.server.ts +++ b/apps/webapp/app/services/session.server.ts @@ -5,6 +5,7 @@ import { extractClientIp } from "~/utils/extractClientIp.server"; import { authenticator } from "./auth.server"; import { getImpersonationId } from "./impersonation.server"; import { logger } from "./logger.server"; +import { revalidateSsoSession } from "./ssoSessionRevalidation.server"; /** * Logs the user out when their session has lived past `User.nextSessionEnd`. @@ -82,6 +83,11 @@ export async function getUserId(request: Request): Promise { // for this path happens in `getUser`, where we already pay for the User // row fetch. `requireUserId` callers stay cookie-only. const authUser = await authenticator.isAuthenticated(request); + // SSO session re-validation runs here so it covers both navigation + // (getUser) and API fetches (requireUserId). It's single-flight and + // throttled, so most requests do nothing; only SSO-marked sessions + // touch Redis. Throws redirect("/logout") if the IdP says invalid. + await revalidateSsoSession(request, authUser); return authUser?.userId; } diff --git a/apps/webapp/app/services/sso.server.ts b/apps/webapp/app/services/sso.server.ts new file mode 100644 index 00000000000..400ff86190e --- /dev/null +++ b/apps/webapp/app/services/sso.server.ts @@ -0,0 +1,22 @@ +import { $replica, prisma } from "~/db.server"; +import type { PrismaClient } from "@trigger.dev/database"; +import sso from "@trigger.dev/sso"; +import { env } from "~/env.server"; + +// sso.create() is synchronous — returns a lazy controller that resolves +// any installed SSO plugin on first call. Top-level await is not used +// because the webapp's CJS build does not support it. +// +// Auth-path reads run on every login attempt — pass the replica +// explicitly so they don't pile up on the primary. Writes (config +// mutations) still go through the primary. +export const ssoController = sso.create( + // $replica is structurally a PrismaClient minus `$transaction`. The + // fallback only uses `findFirst` on it, so the cast is safe. + { primary: prisma, replica: $replica as PrismaClient }, + // SSO_ENABLED is the deploy gate: until it's on, force the OSS + // fallback so the entire SSO surface (login, settings, callback, + // re-validation) stays inert. SSO_FORCE_FALLBACK remains an + // independent contributor/debug override. + { forceFallback: !env.SSO_ENABLED || env.SSO_FORCE_FALLBACK } +); diff --git a/apps/webapp/app/services/ssoAuth.server.ts b/apps/webapp/app/services/ssoAuth.server.ts new file mode 100644 index 00000000000..8d4621abcfe --- /dev/null +++ b/apps/webapp/app/services/ssoAuth.server.ts @@ -0,0 +1,135 @@ +import type { SessionStorage } from "@remix-run/server-runtime"; +import type { AuthenticateOptions, Authenticator } from "remix-auth"; +import { Strategy } from "remix-auth"; +import type { SsoFlow, SsoProfile } from "@trigger.dev/plugins"; +import { prisma } from "~/db.server"; +import { ensureOrgMember } from "~/models/orgMember.server"; +import { findOrCreateSsoUser } from "~/models/user.server"; +import type { AuthUser } from "./authUser"; +import { logger } from "./logger.server"; +import { postAuthentication } from "./postAuth.server"; +import { ssoController } from "./sso.server"; + +export type SsoVerifyParams = { + profile: SsoProfile; + flow: SsoFlow; +}; + +// Hybrid remix-auth strategy. The strategy is invoked by the callback +// route AFTER it has performed the SSO code exchange via the plugin — +// the route passes the verified profile + flow through +// `authenticator.authenticate("sso", request, { context })`. The +// strategy reads that context and runs the user-resolution side of the +// flow (plugin identity lookups + host-side User/OrgMember writes). +// +// In an OSS deployment with no SSO plugin installed, the plugin's +// `resolveSsoIdentity` returns `feature_disabled` from the fallback, +// which propagates here as a failure. That's the expected behaviour: +// without the plugin there is no callback route invoking the strategy +// in the first place. +class SsoStrategy extends Strategy { + name = "sso"; + + async authenticate( + request: Request, + sessionStorage: SessionStorage, + options: AuthenticateOptions + ): Promise { + const ctx = (options.context ?? undefined) as SsoVerifyParams | undefined; + if (!ctx?.profile || !ctx?.flow) { + return this.failure( + "SSO strategy invoked without profile context", + request, + sessionStorage, + options + ); + } + try { + const user = await this.verify(ctx); + return this.success(user, request, sessionStorage, options); + } catch (error) { + const cause = error instanceof Error ? error : new Error(String(error)); + return this.failure(cause.message, request, sessionStorage, options, cause); + } + } +} + +export function addSsoStrategy(authenticator: Authenticator) { + authenticator.use( + new SsoStrategy(async ({ profile, flow }) => { + const decision = await ssoController.resolveSsoIdentity({ profile }); + if (decision.isErr()) { + // Surfaces "feature_disabled" in OSS deployments. The callback + // route's error path translates this into a generic + // sign-in-failed user-facing message. + throw new Error(`SSO resolve failed: ${decision.error}`); + } + + const value = decision.value; + + let userId: string; + let isNewUser = false; + + if (value.kind === "create_new_user") { + const created = await findOrCreateSsoUser({ + authenticationMethod: "SSO", + email: profile.email, + firstName: profile.firstName, + lastName: profile.lastName, + }); + userId = created.user.id; + isNewUser = created.isNewUser; + } else { + userId = value.userId; + } + + // Best-effort: attaching the IdP identity row is an optimisation + // for the next login (it lets resolveSsoIdentity take the + // existing_user_by_idp fast path instead of falling back to + // linked_by_email). The user is already authenticated by this + // point, so we log and continue rather than failing the sign-in; + // a later successful login will write the row. + const attach = await ssoController.attachSsoIdentity({ userId, profile }); + if (attach.isErr()) { + logger.warn("SSO attachSsoIdentity failed", { + reason: attach.error, + userId, + flow, + }); + } + + const jit = await ssoController.evaluateJit({ + userId, + idpOrgId: profile.idpOrgId, + }); + if (jit.isOk() && jit.value.shouldProvision) { + const result = await ensureOrgMember({ + userId, + organizationId: jit.value.organizationId, + roleId: jit.value.roleId, + source: "sso_jit", + }); + if (!result.created) { + logger.info("SSO JIT skipped — membership already exists", { + userId, + organizationId: jit.value.organizationId, + }); + } + } else if (jit.isErr() && jit.error !== "feature_disabled") { + logger.warn("SSO evaluateJit failed", { reason: jit.error, userId, flow }); + } + + const user = await prisma.user.findFirst({ where: { id: userId } }); + if (user) { + await postAuthentication({ + user, + isNewUser, + loginMethod: "SSO", + }); + } + + return { userId }; + }), + "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..beb5068e678 --- /dev/null +++ b/apps/webapp/app/services/ssoAutoDiscovery.server.ts @@ -0,0 +1,61 @@ +import { logger } from "./logger.server"; +import { ssoController } from "./sso.server"; + +// 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" +): Promise { + const normalised = email.toLowerCase().trim(); + if (!normalised) return null; + + const decision = await ssoController.decideRouteForEmail(normalised); + 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 `/login/sso?email=${encodeURIComponent(normalised)}&reason=${reason}`; +} + +// 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..7fc0573f5ad --- /dev/null +++ b/apps/webapp/app/services/ssoRateLimiter.server.ts @@ -0,0 +1,63 @@ +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) { + const retryAfter = 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) { + const retryAfter = 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..06b4fdd2ceb --- /dev/null +++ b/apps/webapp/app/services/ssoSessionRevalidation.server.ts @@ -0,0 +1,141 @@ +import { json, redirect } from "@remix-run/node"; +import { env } from "~/env.server"; +import { createRedisClient } from "~/redis.server"; +import { singleton } from "~/utils/singleton"; +import type { AuthUser } from "./authUser"; +import { logger } from "./logger.server"; +import { ssoController } from "./sso.server"; + +// Dedicated Redis client for the single-flight throttle. Reuses the +// shared REDIS_* connection (same wiring the other simple shared-state +// services use). +const redis = singleton("ssoRevalidationRedis", () => + createRedisClient("trigger:ssoRevalidation", { + host: env.REDIS_HOST, + port: env.REDIS_PORT, + username: env.REDIS_USERNAME, + password: env.REDIS_PASSWORD, + tlsDisabled: env.REDIS_TLS_DISABLED === "true", + }) +); + +function revalidationKey(userId: string): string { + return `sso:reval:${userId}`; +} + +// Module-scoped so it's a unique symbol — lets the Promise.race result be +// narrowed cleanly between "timed out" and the plugin's Result. +const REVALIDATION_TIMEOUT = Symbol("sso-revalidation-timeout"); + +/** + * Periodically re-validate an SSO-established session against the IdP. + * + * Called from the session read path on every authenticated request, but: + * - returns immediately unless the SSO feature is enabled AND the + * session carries the `sso` marker (non-SSO sessions pay nothing — no + * Redis round-trip); + * - is single-flight via a Redis `SET key 1 NX EX `: only the + * first request per interval window actually calls the SSO plugin, + * concurrent requests see the key and skip; + * - fails OPEN — any error (Redis or the plugin) keeps the session + * alive. Only an explicit `{ valid: false }` triggers logout. + * + * Throws `redirect("/logout")` when the session is confirmed invalid, + * mirroring how `maybeAutoLogout` terminates a session from this path. + */ +export async function revalidateSsoSession( + request: Request, + authUser: AuthUser | null | undefined +): Promise { + // Deploy gate + SSO-session gate. + if (!env.SSO_ENABLED) return; + if (!authUser?.sso) return; + + // Never revalidate on /logout itself — the loader there must be allowed + // to destroy the cookie rather than redirect in a loop. + if (new URL(request.url).pathname === "/logout") return; + + const interval = env.SSO_SESSION_REVALIDATION_INTERVAL_SECONDS; + const key = revalidationKey(authUser.userId); + + // Single-flight: acquire the window. Only the request that sets the + // key (NX) proceeds to the actual check; everyone else this window + // treats the session as valid. + let acquired: "OK" | null; + try { + acquired = await redis.set(key, "1", "EX", interval, "NX"); + } catch (error) { + // Redis unavailable → fail-open, don't block the request. + logger.warn("SSO revalidation: redis SET NX failed; skipping", { error }); + return; + } + if (acquired !== "OK") return; + + // Hard 2s (env-configurable) timeout on the plugin round-trip so a slow + // or hung SSO dependency can never block the request. On timeout we fail + // OPEN (keep the session + the throttle key) and emit a stable + // `sso.revalidation.timeout` warn for alerting. + const timeoutMs = env.SSO_SESSION_REVALIDATION_TIMEOUT_MS; + let timer: ReturnType | undefined; + const 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); + }), + ]); + 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, + }); + + // Browser navigations (and Remix client-side data requests, which + // handle redirects via x-remix-redirect) get the logout redirect. + // Programmatic/API fetches get a clean 401 instead of a 302-to-HTML. + 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("/logout"); + } + throw json({ error: "sso_session_invalidated" }, { status: 401 }); +} diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 76254797669..887ef4150c6 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/v3/accountsWebhookWorker.server.ts b/apps/webapp/app/v3/accountsWebhookWorker.server.ts new file mode 100644 index 00000000000..2ae26e3d5d9 --- /dev/null +++ b/apps/webapp/app/v3/accountsWebhookWorker.server.ts @@ -0,0 +1,75 @@ +import { Worker as RedisWorker } from "@trigger.dev/redis-worker"; +import { z } from "zod"; +import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; +import { singleton } from "~/utils/singleton"; +import { ssoController } from "~/services/sso.server"; + +// Dedicated worker for inbound account-management webhooks. The webhook +// proxy route verifies the signature via the plugin and enqueues the +// parsed event here; this worker calls back into the plugin to apply the +// DB writes. The plugin owns the vendor-specific logic; the webapp owns +// the queue runtime (this file), mirroring `commonWorker.server.ts`. +// +// Vendor-neutral by design: the catalog/job names and payload shape carry +// no provider identity. +const PayloadSchema = z.object({ + id: z.string(), + event: z.string(), + data: z.unknown(), +}); + +function initializeWorker() { + const redisOptions = { + keyPrefix: "accounts-webhook:worker:", + host: env.COMMON_WORKER_REDIS_HOST, + port: env.COMMON_WORKER_REDIS_PORT, + username: env.COMMON_WORKER_REDIS_USERNAME, + password: env.COMMON_WORKER_REDIS_PASSWORD, + enableAutoPipelining: true, + ...(env.COMMON_WORKER_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }), + }; + + const worker = new RedisWorker({ + name: "accounts-webhook-worker", + redisOptions, + catalog: { + "account.webhook.event": { + schema: PayloadSchema, + visibilityTimeoutMs: 30_000, + retry: { maxAttempts: 5 }, + }, + }, + concurrency: { + workers: 2, + tasksPerWorker: 4, + limit: 8, + }, + pollIntervalMs: 1_000, + immediatePollIntervalMs: 50, + shutdownTimeoutMs: 30_000, + jobs: { + "account.webhook.event": async ({ payload }) => { + // The plugin returns a Result; throw on error so the worker + // retries (a resolved err would otherwise be silently dropped). + const result = await ssoController.processWebhookEvent(payload); + if (result.isErr()) { + throw new Error(`account webhook processing failed: ${result.error}`); + } + }, + }, + }); + + // Only poll on worker-role instances (same gate as commonWorker) and + // only when the feature is enabled (no plugin loaded otherwise). + if (env.COMMON_WORKER_ENABLED === "true" && env.SSO_ENABLED) { + logger.debug( + `👨‍🏭 Starting accounts webhook worker at host ${env.COMMON_WORKER_REDIS_HOST}` + ); + worker.start(); + } + + return worker; +} + +export const accountsWebhookWorker = singleton("accountsWebhookWorker", initializeWorker); diff --git a/apps/webapp/app/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts index 3066f2dda01..f1995d1eb00 100644 --- a/apps/webapp/app/v3/featureFlags.ts +++ b/apps/webapp/app/v3/featureFlags.ts @@ -8,6 +8,7 @@ export const FEATURE_FLAG = { hasAiAccess: "hasAiAccess", hasComputeAccess: "hasComputeAccess", hasPrivateConnections: "hasPrivateConnections", + hasSso: "hasSso", mollifierEnabled: "mollifierEnabled", workerQueueScheduledSplitEnabled: "workerQueueScheduledSplitEnabled", realtimeBackend: "realtimeBackend", @@ -21,6 +22,7 @@ export const FeatureFlagCatalog = { [FEATURE_FLAG.hasAiAccess]: z.coerce.boolean(), [FEATURE_FLAG.hasComputeAccess]: z.coerce.boolean(), [FEATURE_FLAG.hasPrivateConnections]: z.coerce.boolean(), + [FEATURE_FLAG.hasSso]: z.coerce.boolean(), [FEATURE_FLAG.mollifierEnabled]: z.coerce.boolean(), [FEATURE_FLAG.workerQueueScheduledSplitEnabled]: z.coerce.boolean(), // Which backend serves the realtime run feed. Controllable diff --git a/apps/webapp/package.json b/apps/webapp/package.json index efebaf48207..a017fe607c3 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.27", - "@trigger.dev/rbac": "workspace:*", "@trigger.dev/redis-worker": "workspace:*", "@trigger.dev/sdk": "workspace:*", "@types/pg": "8.6.6", diff --git a/internal-packages/database/prisma/migrations/20260527130000_add_sso_authentication_method/migration.sql b/internal-packages/database/prisma/migrations/20260527130000_add_sso_authentication_method/migration.sql new file mode 100644 index 00000000000..2d4fb9e77c2 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260527130000_add_sso_authentication_method/migration.sql @@ -0,0 +1,2 @@ +-- Idempotent enum addition. +ALTER TYPE "AuthenticationMethod" ADD VALUE IF NOT EXISTS 'SSO'; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 337a6059ebd..f8a6ad46b3d 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -112,6 +112,7 @@ enum AuthenticationMethod { GITHUB MAGIC_LINK GOOGLE + SSO } /// Used to generate PersonalAccessTokens, they're one-time use diff --git a/internal-packages/sso/package.json b/internal-packages/sso/package.json new file mode 100644 index 00000000000..66425feccb5 --- /dev/null +++ b/internal-packages/sso/package.json @@ -0,0 +1,25 @@ +{ + "name": "@trigger.dev/sso", + "private": true, + "version": "0.0.1", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "dependencies": { + "@trigger.dev/core": "workspace:*", + "@trigger.dev/plugins": "workspace:*", + "neverthrow": "^8.2.0" + }, + "devDependencies": { + "@trigger.dev/database": "workspace:*", + "@types/node": "^20.14.14", + "rimraf": "6.0.1" + }, + "scripts": { + "clean": "rimraf dist", + "typecheck": "tsc --noEmit", + "build": "pnpm run clean && tsc --noEmit false --outDir dist --declaration", + "dev": "tsc --noEmit false --outDir dist --declaration --watch", + "test": "vitest run", + "test:watch": "vitest" + } +} diff --git a/internal-packages/sso/src/fallback.ts b/internal-packages/sso/src/fallback.ts new file mode 100644 index 00000000000..ecf52fc0d9b --- /dev/null +++ b/internal-packages/sso/src/fallback.ts @@ -0,0 +1,159 @@ +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); + } + + 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..0df15a59e2f --- /dev/null +++ b/internal-packages/sso/src/index.ts @@ -0,0 +1,224 @@ +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)); + } + + 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..6a801f2ee46 --- /dev/null +++ b/internal-packages/sso/src/loader.test.ts @@ -0,0 +1,301 @@ +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); + }, + 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/package.json b/packages/plugins/package.json index 676b53f923d..8ad1cdc71c9 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -16,7 +16,8 @@ "dist" ], "dependencies": { - "@trigger.dev/core": "workspace:*" + "@trigger.dev/core": "workspace:*", + "neverthrow": "^8.2.0" }, "scripts": { "clean": "rimraf dist .turbo", diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index 9a03d93b66b..7c0464c3d68 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -17,6 +17,29 @@ export type { AuthenticatedEnvironment, } from "./rbac.js"; +export type { + SsoPlugin, + SsoController, + OrgSsoStatus, + SsoRouteDecision, + SsoFlow, + SsoProfile, + SsoConnectionState, + SsoDomainState, + SsoDomainStatus, + SsoResolutionDecision, + SsoDecisionError, + SsoBeginError, + SsoCompleteError, + SsoMutationError, + SsoPortalError, + SsoValidateError, + SsoWebhookError, + SsoWebhookEvent, +} from "./sso.js"; + +export { SSO_FLOWS } from "./sso.js"; + // Convenience re-exports — gives plugin authors (and the cloud workspace // link) one import surface without reaching into @trigger.dev/core // directly. Both helpers live in core; this is purely a forwarder. diff --git a/packages/plugins/src/sso.ts b/packages/plugins/src/sso.ts new file mode 100644 index 00000000000..20880791dca --- /dev/null +++ b/packages/plugins/src/sso.ts @@ -0,0 +1,230 @@ +import type { ResultAsync } from "neverthrow"; + +// === Domain types === + +export type SsoConnectionState = "active" | "inactive"; + +export type SsoDomainState = "pending" | "verified" | "failed"; + +export type SsoDomainStatus = { + domain: string; + verified: boolean; + state: SsoDomainState; + // Vendor-supplied reason code present when state === "failed". + // Plugin keeps it opaque; the host UI surfaces it to the admin so + // they know which knob to turn before retrying verification. + verificationFailedReason: string | null; +}; + +export type OrgSsoStatus = { + hasIdpOrg: boolean; + enforced: boolean; + jitProvisioningEnabled: boolean; + jitDefaultRoleId: string | null; + idpOrgId: string | null; + primaryConnectionId: string | null; + domains: ReadonlyArray; + connections: ReadonlyArray<{ + id: string; + name: string | null; + connectionType: string; + state: SsoConnectionState; + }>; +}; + +export type SsoRouteDecision = + | { kind: "no_sso" } + | { kind: "sso_required"; idpOrgId: string }; + +export const SSO_FLOWS = [ + "user_initiated", + "auto_discovery_magic", + "auto_discovery_oauth", + "auto_discovery_vercel", + "idp_initiated", +] as const; + +export type SsoFlow = (typeof SSO_FLOWS)[number]; + +export type SsoProfile = { + // Lowercase-normalized at the plugin / host boundary. + email: string; + firstName: string | null; + lastName: string | null; + idpSubjectId: string; + idpOrgId: string; + idpConnectionId: string; +}; + +export type SsoResolutionDecision = + | { kind: "existing_user_by_idp"; userId: string } + | { kind: "linked_by_email"; userId: string } + | { kind: "create_new_user"; profile: SsoProfile }; + +// === Errors === + +export type SsoDecisionError = "internal"; + +export type SsoBeginError = + | "no_org_for_domain" + | "no_active_connection" + | "feature_disabled"; + +export type SsoCompleteError = + | "state_replayed_or_expired" + | "state_invalid_signature" + | "code_exchange_failed" + | "org_mismatch" + | "email_mismatch" + | "connection_unknown"; + +export type SsoMutationError = "feature_disabled" | "rbac_role_invalid" | "internal"; + +// Vendor-neutral name for "the identity-provider organisation isn't available". +export type SsoPortalError = "idp_org_unavailable" | "internal"; + +// The only failure a session re-validation can report is "internal" — +// callers MUST treat it as fail-open (keep the session). An invalid +// session is NOT an error: it's a successful result of `{ valid: false }`. +export type SsoValidateError = "internal"; + +// Inbound webhook handling. `invalid_signature` → reject (4xx, no retry); +// `feature_disabled` → no plugin installed (host returns 404); `internal` +// → transient, the host returns 5xx so the provider retries. +export type SsoWebhookError = "invalid_signature" | "feature_disabled" | "internal"; + +// A verified, JSON-serializable inbound event. Vendor-neutral envelope — +// `event` is the provider's event-type string, `data` its opaque payload. +export type SsoWebhookEvent = { id: string; event: string; data: unknown }; + +// === Controller === + +export interface SsoController { + // True when a real SSO plugin is loaded. Hosts gate behaviour that's + // only meaningful when the plugin is present (e.g. rendering the + // settings tab, registering the SSO strategy actively). + isUsingPlugin(): Promise; + + // --- Provisioning + admin UI --- + + getStatus(organizationId: string): ResultAsync; + + // Returns an admin-portal link the customer's IT admin uses to + // configure their identity provider. First call also performs any lazy + // initialization the plugin needs (no separate enable() method). + generatePortalLink(params: { + organizationId: string; + userId: string; + intent: "sso" | "domain_verification"; + returnUrl: string; + }): ResultAsync<{ url: string }, SsoPortalError>; + + setEnforced(params: { + organizationId: string; + enforced: boolean; + }): ResultAsync; + + setJitProvisioningEnabled(params: { + organizationId: string; + enabled: boolean; + }): ResultAsync; + + setJitDefaultRole(params: { + organizationId: string; + roleId: string | null; + }): ResultAsync; + + // --- Auth flow --- + + // Called by every login entry point BEFORE the strategy proceeds. + // Composite gate (plan tier + feature flags + config + enforced) is + // implemented here. Fail-open: returns no_sso on internal error so a + // plugin outage doesn't lock users out. + decideRouteForEmail(email: string): ResultAsync; + + // Returns the URL the user should be redirected to in order to + // authenticate with their identity provider. Internally mints a + // single-use signed state token; the implementation is opaque to + // OSS callers. Email is lowercase-normalized before lookup. + beginAuthorization(params: { + email: string; + redirectTo: string; + flow: SsoFlow; + }): ResultAsync<{ url: string }, SsoBeginError>; + + // SP-initiated callback. Verifies and consumes the signed state token + // single-use, exchanges the code with the SSO provider, cross-checks + // the returned profile against the state claims. Returns profile + + // state-carried redirectTo + flow. + completeAuthorization(params: { + code: string; + state: string; + }): ResultAsync<{ profile: SsoProfile; redirectTo: string; flow: SsoFlow }, SsoCompleteError>; + + // IdP-initiated callback (no state). Validates the returned connection + // identifier is one of ours. Default redirectTo is "/". + completeIdpInitiatedAuthorization(params: { + code: string; + }): ResultAsync<{ profile: SsoProfile; redirectTo: string }, SsoCompleteError>; + + // Re-validate a live SSO session against the IdP. Called periodically + // (throttled by the host) for sessions that were established via SSO. + // The available signal is whether the user's identity-provider + // connection is still active, so `valid` reflects that. Returns an + // `internal` error on any infrastructure failure (e.g. the identity + // provider is unreachable) — the host MUST fail-open on the error and + // only invalidate the session on an explicit `{ valid: false }`. + validateSession(params: { + userId: string; + idpOrgId: string; + connectionId: string; + }): ResultAsync<{ valid: boolean }, SsoValidateError>; + + // Look up an existing identity by IdP subject, or by lowercased email. + // Returns a decision the OSS callback handler uses to drive + // User/OrgMember writes. The plugin DOES NOT write to OSS public.* + // tables — those writes are the host's responsibility. + resolveSsoIdentity(params: { + profile: SsoProfile; + }): ResultAsync; + + // After the host has created/found the User row, the plugin attaches + // the IdP identity row in its own storage. + attachSsoIdentity(params: { + userId: string; + profile: SsoProfile; + }): ResultAsync; + + // Returns whether JIT should provision a membership for the given + // (userId, idpOrgId), and the resolved roleId to assign (the org's + // JIT default role, or null when no RBAC plugin is installed). + // The host performs the actual OrgMember insert. + evaluateJit(params: { + userId: string; + idpOrgId: string; + }): ResultAsync< + { shouldProvision: boolean; organizationId: string; roleId: string | null }, + SsoMutationError + >; + + // --- Inbound webhooks --- + + // Verify the signature of a raw inbound webhook request and return the + // parsed, JSON-serializable event. The host forwards the raw body + + // headers from a thin proxy route; the plugin owns the vendor-specific + // signature scheme. The host enqueues the returned event for async + // processing (it never enqueues an unverified request). + verifyWebhook(params: { + rawBody: string; + headers: Record; + }): ResultAsync<{ event: SsoWebhookEvent }, SsoWebhookError>; + + // Process a previously-verified webhook event (the host's background + // worker calls this). Performs the plugin's own state writes; throws + // nothing — failures surface as `internal` so the worker retries. + processWebhookEvent(event: SsoWebhookEvent): ResultAsync; +} + +export interface SsoPlugin { + create(): SsoController | Promise; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc80eb9ba1c..8ad9ba863cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -558,6 +558,9 @@ importers: '@trigger.dev/platform': specifier: 1.0.27 version: 1.0.27 + '@trigger.dev/plugins': + specifier: workspace:* + version: link:../../packages/plugins '@trigger.dev/rbac': specifier: workspace:* version: link:../../internal-packages/rbac @@ -567,6 +570,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 @@ -1395,6 +1401,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': @@ -1949,6 +1977,9 @@ importers: '@trigger.dev/core': specifier: workspace:* version: link:../core + neverthrow: + specifier: ^8.2.0 + version: 8.2.0 devDependencies: '@types/node': specifier: 20.14.14 @@ -7075,12 +7106,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] @@ -11591,16 +11616,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: @@ -11609,7 +11627,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==} @@ -12252,10 +12270,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} @@ -23916,9 +23930,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 @@ -29563,22 +29574,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 @@ -30291,12 +30293,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 @@ -31817,7 +31813,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: {} @@ -33666,7 +33662,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: {} @@ -33714,7 +33710,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: {} @@ -34550,7 +34546,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