Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions apps/webapp/app/entry.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@ import { hydrateRoot } from "react-dom/client";
import { clientBeforeFirstRender } from "./clientBeforeFirstRender";
import { LocaleContextProvider } from "./components/primitives/LocaleProvider";
import { OperatingSystemContextProvider } from "./components/primitives/OperatingSystemProvider";
import { installSsoSessionGuard } from "./utils/ssoSessionGuard";

clientBeforeFirstRender();
installSsoSessionGuard();

hydrateRoot(
document,
Expand Down
9 changes: 0 additions & 9 deletions apps/webapp/app/hooks/useEventSource.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useEffect, useState } from "react";
import { probeSsoSession } from "~/utils/ssoSessionGuard";

type EventSourceOptions = {
init?: EventSourceInit;
Expand Down Expand Up @@ -29,21 +28,13 @@ export function useEventSource(

const eventSource = new EventSource(url, init);
eventSource.addEventListener(event ?? "message", handler);
eventSource.addEventListener("error", errorHandler);

function handler(event: MessageEvent) {
setData(event.data || "UNKNOWN_EVENT_DATA");
}

// EventSource can't surface response headers, so on a stream error probe
// an authenticated endpoint; a revoked session redirects via the guard.
function errorHandler() {
probeSsoSession();
}

return () => {
eventSource.removeEventListener(event ?? "message", handler);
eventSource.removeEventListener("error", errorHandler);
eventSource.close();
};
}, [url, event, init, disabled]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,14 @@ 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 { prisma } from "~/db.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 { throwPermissionDenied } from "~/utils/permissionDenied";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { v3BillingPath } from "~/utils/pathBuilder";

Expand All @@ -45,7 +46,10 @@ 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({
// Use primary: this slug→id lookup scopes the org-level RBAC/entitlement
// checks (loader and action), and replica lag could run them against a
// stale or missing org scope.
return prisma.organization.findFirst({
where: { slug },
select: { id: true, title: true },
});
Expand All @@ -68,16 +72,41 @@ async function requireSsoEntitlement(orgId: string): Promise<void> {
}
}

const EMPTY_SSO_STATUS = {
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";
}>,
};

export const loader = dashboardLoader(
{
params: Params,
context: async (params) => {
const org = await resolveOrg(params.organizationSlug);
return org ? { organizationId: org.id, orgTitle: org.title } : {};
Comment thread
0ski marked this conversation as resolved.
},
authorization: { action: "manage", resource: { type: "sso" } },
// No static `authorization` gate here: SSO is plan-gated *before* it's
// role-gated. A non-Enterprise org must render the upsell for everyone —
// gating on manage:sso at the wrapper would show a non-Owner "Permission
// denied" for a feature their org can't use yet. We resolve the plan in
// the body and only enforce manage:sso once the org is actually entitled.
},
async ({ context, request }) => {
async ({ context, ability }) => {
// True only when SSO_ENABLED is on and a real SSO plugin is loaded.
if (!(await ssoController.isUsingPlugin())) {
throw new Response("Not Found", { status: 404 });
Expand All @@ -88,37 +117,31 @@ export const loader = dashboardLoader(
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.
// Plan first. When the org isn't on Enterprise the page renders the
// upsell state for every role, so we skip the role check (and the
// SSO/role queries it would gate) and return empty data.
const plan = await getCurrentPlan(orgId);
if (!planAllowsSso(plan)) {
return typedjson({
status: EMPTY_SSO_STATUS,
orgTitle: context.orgTitle,
jitRoles: [] as Role[],
});
}

// Entitled: the page is now a real config surface, so enforce the role
// gate. A non-Owner without manage:sso gets the permission panel — the
// same 403 the dashboardLoader `authorization` block would have thrown.
if (!ability.can("manage", { type: "sso" })) {
throwPermissionDenied();
}

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";
}>,
};
const status = statusResult.isOk() ? statusResult.value : EMPTY_SSO_STATUS;

// JIT can't promote new users to Owner — that role is reserved for
// the founding member and explicit transfers. Plan-gated roles are
Expand Down
10 changes: 0 additions & 10 deletions apps/webapp/app/routes/resources.session-check.ts

This file was deleted.

17 changes: 6 additions & 11 deletions apps/webapp/app/services/ssoSessionRevalidation.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@ import { tryCatch } from "@trigger.dev/core/v3";
import { env } from "~/env.server";
import { createRedisClient } from "~/redis.server";
import { singleton } from "~/utils/singleton";
import {
SSO_SESSION_INVALIDATED_HEADER,
ssoSessionExpiredLogoutPath,
} from "~/utils/ssoSession";
import { ssoSessionExpiredLogoutPath } from "~/utils/ssoSession";
import type { AuthUser } from "./authUser";
import { logger } from "./logger.server";
import { ssoController } from "./sso.server";
Expand Down Expand Up @@ -142,9 +139,10 @@ export async function revalidateSsoSession(
userId: authUser.userId,
});

// Navigations get the logout redirect; programmatic/API fetches can't
// follow a 302-to-HTML, so they get a 401 carrying the marker header that
// the client fetch guard turns into the same redirect.
// Navigations (and Remix data requests, which the client follows) get the
// logout redirect. Programmatic/API fetches can't follow a 302-to-HTML, so
// they get a plain 401; the session is re-checked and the user is redirected
// on their next navigation/refresh.
const url = new URL(request.url);
const isRemixDataRequest = url.searchParams.has("_data");
const dest = request.headers.get("sec-fetch-dest");
Expand All @@ -154,8 +152,5 @@ export async function revalidateSsoSession(
if (isRemixDataRequest || isDocumentRequest) {
throw redirect(ssoSessionExpiredLogoutPath());
}
throw json(
{ error: "sso_session_invalidated" },
{ status: 401, headers: { [SSO_SESSION_INVALIDATED_HEADER]: "1" } }
);
throw json({ error: "sso_session_invalidated" }, { status: 401 });
}
4 changes: 0 additions & 4 deletions apps/webapp/app/utils/ssoSession.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
// Shared (server + client) constants for the SSO session-revalidation flow.

export const SSO_SESSION_INVALIDATED_HEADER = "x-sso-session-invalidated";

export const SSO_SESSION_EXPIRED_REASON = "session_expired";

// The reason rides as its own `?reason=` param, not `?redirectTo=/login...`,
// because the redirect sanitizer rejects /login and would drop it.
export function ssoSessionExpiredLogoutPath(): string {
return `/logout?reason=${SSO_SESSION_EXPIRED_REASON}`;
}

export const SSO_SESSION_CHECK_PATH = "/resources/session-check";
51 changes: 0 additions & 51 deletions apps/webapp/app/utils/ssoSessionGuard.ts

This file was deleted.

Loading