diff --git a/.github/workflows/e2e-api-tests-local-emulator.yaml b/.github/workflows/e2e-api-tests-local-emulator.yaml index 3ebed27862..57dd773f7c 100644 --- a/.github/workflows/e2e-api-tests-local-emulator.yaml +++ b/.github/workflows/e2e-api-tests-local-emulator.yaml @@ -140,6 +140,16 @@ jobs: wait-for: 30s log-output-if: true + - name: Start mock-saml-idp in background + uses: JarvusInnovations/background-action@v1.0.7 + with: + run: pnpm run start:mock-saml-idp --log-order=stream & + wait-on: | + http://localhost:8142/idp + tail: true + wait-for: 30s + log-output-if: true + - name: Start run-email-queue in background uses: JarvusInnovations/background-action@v1.0.7 with: diff --git a/.github/workflows/e2e-custom-base-port-api-tests.yaml b/.github/workflows/e2e-custom-base-port-api-tests.yaml index 95b7f680d3..58810848fc 100644 --- a/.github/workflows/e2e-custom-base-port-api-tests.yaml +++ b/.github/workflows/e2e-custom-base-port-api-tests.yaml @@ -136,6 +136,15 @@ jobs: tail: true wait-for: 30s log-output-if: true + - name: Start mock-saml-idp in background + uses: JarvusInnovations/background-action@v1.0.7 + with: + run: pnpm run start:mock-saml-idp --log-order=stream & + wait-on: | + http://localhost:6742/idp + tail: true + wait-for: 30s + log-output-if: true - name: Start run-email-queue in background uses: JarvusInnovations/background-action@v1.0.7 with: diff --git a/apps/backend/src/app/api/latest/auth/saml/acs/[connection_id]/route.tsx b/apps/backend/src/app/api/latest/auth/saml/acs/[connection_id]/route.tsx index 76b930efb1..825ec62ffa 100644 --- a/apps/backend/src/app/api/latest/auth/saml/acs/[connection_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/saml/acs/[connection_id]/route.tsx @@ -143,6 +143,10 @@ export const POST = createSmartRouteHandler({ } const prisma = await getPrismaClientForTenancy(tenancy); + if (!tenancy.config.apps.installed["saml-sso"]?.enabled) { + throw new KnownErrors.SamlSsoNotEnabled(); + } + if (!has(tenancy.config.auth.saml.connections, params.connection_id)) { throw new StatusError(StatusError.NotFound, `SAML connection ${params.connection_id} not found`); } diff --git a/apps/backend/src/app/api/latest/auth/saml/discover/route.tsx b/apps/backend/src/app/api/latest/auth/saml/discover/route.tsx index d154e0e20a..81b3326ac9 100644 --- a/apps/backend/src/app/api/latest/auth/saml/discover/route.tsx +++ b/apps/backend/src/app/api/latest/auth/saml/discover/route.tsx @@ -3,6 +3,7 @@ import type { SamlConnectionConfig } from "@/saml/saml"; import { getSoleTenancyFromProjectBranch, DEFAULT_BRANCH_ID } from "@/lib/tenancies"; import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; import { emailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; @@ -46,6 +47,9 @@ export const GET = createSmartRouteHandler({ if (!tenancy) { throw new StatusError(StatusError.NotFound, `Project ${query.project_id} not found`); } + if (!tenancy.config.apps.installed["saml-sso"]?.enabled) { + throw new KnownErrors.SamlSsoNotEnabled(); + } // Inject `id` into each connection so it satisfies SamlConnectionConfig — // the config schema stores id as the record key, not a value field. // Skip connections with sign-in disabled — discover is the entry point diff --git a/apps/backend/src/app/api/latest/auth/saml/login/[connection_id]/route.tsx b/apps/backend/src/app/api/latest/auth/saml/login/[connection_id]/route.tsx index b43afa728a..5dd5586701 100644 --- a/apps/backend/src/app/api/latest/auth/saml/login/[connection_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/saml/login/[connection_id]/route.tsx @@ -101,6 +101,10 @@ export const GET = createSmartRouteHandler({ throwCheckApiKeySetError(keyCheck.error, tenancy.project.id, new KnownErrors.InvalidPublishableClientKey(tenancy.project.id)); } + if (!tenancy.config.apps.installed["saml-sso"]?.enabled) { + throw new KnownErrors.SamlSsoNotEnabled(); + } + if (!has(tenancy.config.auth.saml.connections, params.connection_id)) { throw new StatusError(StatusError.NotFound, `SAML connection ${params.connection_id} not found`); } diff --git a/apps/backend/src/app/api/latest/auth/saml/metadata/[connection_id]/route.tsx b/apps/backend/src/app/api/latest/auth/saml/metadata/[connection_id]/route.tsx index 5ae07084ff..6f7c6cfa51 100644 --- a/apps/backend/src/app/api/latest/auth/saml/metadata/[connection_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/saml/metadata/[connection_id]/route.tsx @@ -1,6 +1,7 @@ import { getSoleTenancyFromProjectBranch, DEFAULT_BRANCH_ID } from "@/lib/tenancies"; import { getSpMetadataXml } from "@/saml/saml"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; @@ -45,6 +46,9 @@ export const GET = createSmartRouteHandler({ if (!tenancy) { throw new StatusError(StatusError.NotFound, `Project ${query.project_id} not found`); } + if (!tenancy.config.apps.installed["saml-sso"]?.enabled) { + throw new KnownErrors.SamlSsoNotEnabled(); + } if (!has(tenancy.config.auth.saml.connections, params.connection_id)) { throw new StatusError(StatusError.NotFound, `SAML connection ${params.connection_id} not found in project ${query.project_id}`); } diff --git a/apps/backend/src/app/api/latest/saml-connections/[connection_id]/route.tsx b/apps/backend/src/app/api/latest/saml-connections/[connection_id]/route.tsx index 6516e40c05..8f48cc5441 100644 --- a/apps/backend/src/app/api/latest/saml-connections/[connection_id]/route.tsx +++ b/apps/backend/src/app/api/latest/saml-connections/[connection_id]/route.tsx @@ -5,6 +5,7 @@ */ import { adaptSchema, adminAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { has } from "@stackframe/stack-shared/dist/utils/objects"; @@ -41,6 +42,9 @@ export const GET = createSmartRouteHandler({ }).defined(), }), async handler({ auth, params }) { + if (!auth.tenancy.config.apps.installed["saml-sso"]?.enabled) { + throw new KnownErrors.SamlSsoNotEnabled(); + } if (!has(auth.tenancy.config.auth.saml.connections, params.connection_id)) { throw new StatusError(StatusError.NotFound, `SAML connection ${params.connection_id} not found`); } diff --git a/apps/backend/src/app/api/latest/saml-connections/route.tsx b/apps/backend/src/app/api/latest/saml-connections/route.tsx index 471a8e33eb..22c07c9f7b 100644 --- a/apps/backend/src/app/api/latest/saml-connections/route.tsx +++ b/apps/backend/src/app/api/latest/saml-connections/route.tsx @@ -12,6 +12,7 @@ */ import { overrideEnvironmentConfigOverride, resetEnvironmentConfigOverrideKeys } from "@/lib/config"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; import { adaptSchema, adminAuthTypeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { has } from "@stackframe/stack-shared/dist/utils/objects"; @@ -48,6 +49,9 @@ export const GET = createSmartRouteHandler({ }).defined(), }), async handler({ auth }) { + if (!auth.tenancy.config.apps.installed["saml-sso"]?.enabled) { + throw new KnownErrors.SamlSsoNotEnabled(); + } const connections = auth.tenancy.config.auth.saml.connections; type Conn = (typeof auth.tenancy.config.auth.saml.connections)[string]; return { @@ -99,6 +103,9 @@ export const POST = createSmartRouteHandler({ body: samlConnectionResponseShape, }), async handler({ auth, body }) { + if (!auth.tenancy.config.apps.installed["saml-sso"]?.enabled) { + throw new KnownErrors.SamlSsoNotEnabled(); + } const exists = has(auth.tenancy.config.auth.saml.connections, body.id); const prefix = `auth.saml.connections.${body.id}`; const overlay: Record = {}; @@ -184,6 +191,9 @@ export const DELETE = createSmartRouteHandler({ }).defined(), }), async handler({ auth, body }) { + if (!auth.tenancy.config.apps.installed["saml-sso"]?.enabled) { + throw new KnownErrors.SamlSsoNotEnabled(); + } if (!has(auth.tenancy.config.auth.saml.connections, body.id)) { throw new StatusError(StatusError.NotFound, `SAML connection ${body.id} not found`); } diff --git a/apps/backend/src/lib/seed-dummy-data.ts b/apps/backend/src/lib/seed-dummy-data.ts index 388fe4d631..6557e8cf2d 100644 --- a/apps/backend/src/lib/seed-dummy-data.ts +++ b/apps/backend/src/lib/seed-dummy-data.ts @@ -1988,6 +1988,9 @@ async function seedSamlConnections(projectId: string): Promise { // dot-keys — config normalization with onDotIntoNonObject="ignore" // drops dot-keys that try to navigate into a record entry that // doesn't yet exist (same convention as auth.oauth.providers). + // No need to set `apps.installed.saml-sso.enabled` here — the dummy + // project's branch config (above) installs every entry in ALL_APPS, + // including alpha-stage apps, when excludeAlphaApps isn't set. const overlay: Parameters[0]["environmentConfigOverrideOverride"] = {}; for (const f of fetched) { overlay[`auth.saml.connections.${f.slug}`] = { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sso/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sso/page-client.tsx new file mode 100644 index 0000000000..7a71a79fb2 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sso/page-client.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { SmartFormDialog } from "@/components/form-dialog"; +import { ActionDialog, Alert, Button, Card, CardContent, CardHeader, Typography } from "@/components/ui"; +import { useUpdateConfig } from "@/lib/config-update"; +import { getPublicEnvVar } from "@/lib/env"; +import React, { useState } from "react"; +import * as yup from "yup"; +import { AppEnabledGuard } from "../app-enabled-guard"; +import { PageLayout } from "../page-layout"; +import { useAdminApp } from "../use-admin-app"; + +function getBrowserApiBase(): string { + const url = getPublicEnvVar("NEXT_PUBLIC_BROWSER_STACK_API_URL") ?? getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? ""; + return url.replace(/\/+$/, ""); +} + +/** + * Dashboard for managing SAML SSO connections on the current project. + * + * Connection config is stored at tenancy.config.auth.saml.connections — + * the same JSON-config the seed script and admin /saml-connections + * endpoints write. This page reads via project.useConfig() and writes + * via useUpdateConfig() with key paths. + * + * V1 scope: single-page list with create + delete dialogs. The + * paste-IdP-metadata helper that auto-fills idpEntityId/idpSsoUrl/ + * idpCertificate from a single XML blob is a planned follow-up — for + * now connection fields are entered manually. + */ +export default function PageClient() { + return ( + + + + ); +} + +function PageContent() { + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + const config = project.useConfig(); + const connections = config.auth.saml.connections; + + const [createOpen, setCreateOpen] = useState(false); + const [deleteId, setDeleteId] = useState(null); + + const connectionEntries = Object.entries(connections); + const apiBase = getBrowserApiBase(); + + return ( + setCreateOpen(true)}>Add SAML connection} + > + {connectionEntries.length === 0 && ( + + No SAML connections configured yet. Click Add SAML connection to wire + up your first IdP. + + )} + +
+ {connectionEntries.map(([id, conn]) => ( + + +
+
+ {conn.displayName} + + Connection ID: {id} + {conn.domain && ( + <> + {" "}· Email domain: {conn.domain} + + )} + +
+
+ +
+
+
+ +
+ + + ` : null} + /> + +
+
+ + Paste these into your IdP's admin console: + + + +
+
+
+ ))} +
+ + + setDeleteId(null)} + // Guard against stale deleteId after a config refresh removed the + // entry — index types claim non-undefined but the key may be gone. + displayName={deleteId && Object.hasOwn(connections, deleteId) ? connections[deleteId].displayName : null} + /> +
+ ); +} + +function DetailRow({ label, value, mono }: { label: string, value: string | null | undefined, mono?: boolean }) { + return ( +
+ {label}: + + {value ? value : not set} + +
+ ); +} + +function CreateDialog({ open, onOpenChange }: { open: boolean, onOpenChange: (open: boolean) => void }) { + const stackAdminApp = useAdminApp(); + const updateConfig = useUpdateConfig(); + + const formSchema = yup.object({ + id: yup.string() + .matches(/^[a-z0-9_-]+$/, "ID can only contain lowercase letters, digits, underscores, and dashes") + .nonEmpty().label("Connection ID"), + displayName: yup.string().nonEmpty().label("Display name"), + domain: yup.string().optional().label("Email domain (for discovery)"), + idpEntityId: yup.string().nonEmpty().label("IdP Entity ID"), + // Skip yup's url() — it rejects http://localhost which breaks dev/test setups. + // The backend SAML wrapper validates the URL on use. + idpSsoUrl: yup.string().nonEmpty().label("IdP SSO URL"), + idpCertificate: yup.string().nonEmpty().label("IdP signing certificate (X.509, base64)"), + allowSignIn: yup.boolean().default(true).label("Enable sign-in"), + }); + + return ( + { + // Set the whole connection entry as a single value. Deep dot-keys + // (e.g. `auth.saml.connections.X.displayName`) get dropped during + // config normalization when the parent record entry doesn't yet + // exist — same convention as auth.oauth.providers in the + // auth-methods page. + // Normalize domain on write — discovery does case-insensitive + // matching but does not trim, so trailing whitespace from a + // pasted value would silently break lookups. + const normalizedDomain = values.domain?.trim().toLowerCase() || undefined; + await updateConfig({ + adminApp: stackAdminApp, + configUpdate: { + [`auth.saml.connections.${values.id}`]: { + displayName: values.displayName, + allowSignIn: values.allowSignIn, + domain: normalizedDomain, + idpEntityId: values.idpEntityId, + idpSsoUrl: values.idpSsoUrl, + idpCertificate: (values.idpCertificate ?? "").replace(/-----BEGIN CERTIFICATE-----|-----END CERTIFICATE-----|\s+/g, ""), + }, + } as Parameters[0]["configUpdate"], + // SAML connection fields (cert, IdP URLs) are environment-level + // (not pushable) — same as OAuth client secrets. + pushable: false, + }); + }} + /> + ); +} + +function DeleteDialog({ connectionId, displayName, onClose }: { + connectionId: string | null, + displayName: string | null, + onClose: () => void, +}) { + const stackAdminApp = useAdminApp(); + const updateConfig = useUpdateConfig(); + + return ( + { if (!open) onClose(); }} + title="Delete SAML connection?" + danger + okButton={{ + label: "Delete connection", + onClick: async () => { + if (!connectionId) return; + await updateConfig({ + adminApp: stackAdminApp, + configUpdate: { [`auth.saml.connections.${connectionId}`]: null } as Parameters[0]["configUpdate"], + // SAML connection fields (cert, IdP URLs) are environment-level + // (not pushable) — same as OAuth client secrets. + pushable: false, + }); + onClose(); + }, + }} + cancelButton + > + + Delete {displayName ?? "this connection"}? Existing user accounts linked + via this connection will remain in the database but will no longer be able to sign in. + + + ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sso/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sso/page.tsx new file mode 100644 index 0000000000..f086081c8b --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sso/page.tsx @@ -0,0 +1,9 @@ +import PageClient from "./page-client"; + +export const metadata = { + title: "SAML SSO", +}; + +export default function Page() { + return ; +} diff --git a/apps/dashboard/src/lib/apps-frontend.tsx b/apps/dashboard/src/lib/apps-frontend.tsx index b4bb954985..709471413a 100644 --- a/apps/dashboard/src/lib/apps-frontend.tsx +++ b/apps/dashboard/src/lib/apps-frontend.tsx @@ -1,5 +1,5 @@ import { Link } from "@/components/link"; -import { ChartLineIcon, ClipboardTextIcon, CreditCardIcon, EnvelopeSimpleIcon, FingerprintSimpleIcon, KeyIcon, MailboxIcon, RocketIcon, ShieldCheckIcon, SparkleIcon, TelevisionSimpleIcon, TriangleIcon, UserGearIcon, UsersIcon, VaultIcon, WebhooksLogoIcon } from "@phosphor-icons/react"; +import { BuildingsIcon, ChartLineIcon, ClipboardTextIcon, CreditCardIcon, EnvelopeSimpleIcon, FingerprintSimpleIcon, KeyIcon, MailboxIcon, RocketIcon, ShieldCheckIcon, SparkleIcon, TelevisionSimpleIcon, TriangleIcon, UserGearIcon, UsersIcon, VaultIcon, WebhooksLogoIcon } from "@phosphor-icons/react"; import { StackAdminApp } from "@stackframe/stack"; import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config"; import { getRelativePart, isChildUrl } from "@stackframe/stack-shared/dist/utils/urls"; @@ -162,6 +162,21 @@ export const ALL_APPS_FRONTEND = { ), }, + "saml-sso": { + icon: BuildingsIcon, + href: "sso", + navigationItems: [ + { displayName: "SAML SSO", href: "." }, + ], + screenshots: [], + storeDescription: ( + <> +

SAML SSO lets enterprise customers sign in to your project through their corporate identity provider.

+

Add per-tenant connections with Okta, Azure AD, Google Workspace, or any SAML 2.0 IdP — your customers paste in the SP metadata and ACS URLs from the connection card.

+

The Stack SDK exposes signInWithSso for email-domain discovery and signInWithSaml for explicit connection selection.

+ + ), + }, "api-keys": { icon: KeyIcon, href: "api-keys-app", diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/saml/discover.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/saml/discover.test.ts new file mode 100644 index 0000000000..a84d0cf95f --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/saml/discover.test.ts @@ -0,0 +1,153 @@ +import { it } from "../../../../../../helpers"; +import { Project, niceBackendFetch } from "../../../../../backend-helpers"; + +/** + * Tests for GET /auth/saml/discover. + * + * Test integrity: drives the API only — no imports from + * apps/backend/src/saml/. Project config is set via the standard config + * override endpoint (no special test-only mutator), so the discovery + * lookup runs through the same code path real customers would hit. + * + * Connection isolation note: each `it` block creates its own project via + * Project.createAndSwitch, so connections don't leak across tests. + */ + +async function createProjectWithSamlConnection(slug: string, domain: string) { + const { projectId } = await Project.createAndSwitch(); + // Set the entire connection entry as a single value. The override + // system handles `auth.saml.connections.{id}: {full object}` cleanly, + // but per-field deep dot-keys (e.g. .displayName) on a record entry + // that doesn't yet exist get dropped during config normalization with + // onDotIntoNonObject="ignore" — same convention as auth.oauth.providers + // (see auth-methods/page-client.tsx). + await Project.updateConfig({ + "apps.installed.saml-sso": { enabled: true }, + [`auth.saml.connections.${slug}`]: { + displayName: `${slug} SSO`, + allowSignIn: true, + domain, + idpEntityId: `https://idp.${domain}/saml/metadata`, + idpSsoUrl: `https://idp.${domain}/saml/sso`, + idpCertificate: "MIICertificatePlaceholderForDiscoveryTest=", + }, + }); + return { projectId }; +} + +it("returns the matching connection for a known email domain", async ({ expect }) => { + const { projectId } = await createProjectWithSamlConnection("acme", "acme.test"); + + const response = await niceBackendFetch( + `/api/v1/auth/saml/discover?email=alice@acme.test&project_id=${projectId}`, + { method: "GET" }, + ); + + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { + "connection_id": "acme", + "display_name": "acme SSO", + }, + "headers": Headers {