From ce66a1908d3615ac69c3d541621312271a6cc445 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 29 Apr 2026 15:42:42 -0700 Subject: [PATCH 01/14] feat(sdk): add signInWithSaml, signInWithSso, getSamlConnectionForEmail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three methods on StackClientApp that mirror signInWithOAuth: - signInWithSaml({ connectionId, returnTo }) — explicit connection selection. Calls /auth/saml/login/[connectionId] in stack_response_mode =json so the SDK can intercept the redirect URL. - signInWithSso({ email, returnTo }) — email-domain discovery via /auth/saml/discover, then redirects through the matched connection. Throws when no connection matches so callers can fall back to other sign-in methods. - getSamlConnectionForEmail(email) — pure lookup with no redirect, so the customer's UI can render branding ("Sign in with Acme SSO") before the user clicks. Backed by getSamlUrl + authorizeSaml + discoverSamlConnection on StackClientInterface (mirrors getOAuthUrl + authorizeOAuth pattern, without provider_scope or bot challenge — SAML originates from a corporate IdP, not a public form). Generated via pnpm -w run generate-sdks; propagates from packages/template into packages/js, packages/react, packages/stack. --- .../src/interface/client-interface.ts | 106 ++++++++++++++++++ .../apps/implementations/client-app-impl.ts | 57 ++++++++++ 2 files changed, 163 insertions(+) diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts index 6942da8042..3366995f04 100644 --- a/packages/stack-shared/src/interface/client-interface.ts +++ b/packages/stack-shared/src/interface/client-interface.ts @@ -1422,6 +1422,112 @@ export class StackClientInterface { return Result.ok(location); } + /** + * Build the URL the SDK redirects to for SAML SSO. Mirrors getOAuthUrl + * but without provider_scope / bot challenge — SAML sign-in is initiated + * from a corporate IdP, not a public form. + */ + async getSamlUrl( + options: { + connectionId: string, + redirectUrl: string, + errorRedirectUrl: string, + afterCallbackRedirectUrl?: string, + codeChallenge: string, + state: string, + } + ): Promise { + const updatedRedirectUrl = new URL(options.redirectUrl); + for (const key of ["code", "state"]) { + if (updatedRedirectUrl.searchParams.has(key)) { + updatedRedirectUrl.searchParams.delete(key); + } + } + if ("projectOwnerSession" in this.options) { + throw new Error("Admin session token is currently not supported for SAML"); + } + const clientSecret = this.options.publishableClientKey ?? publishableClientKeyNotNecessarySentinel; + const url = new URL(this.getBestApiUrl() + "/auth/saml/login/" + encodeURIComponent(options.connectionId)); + url.searchParams.set("client_id", this.projectId); + url.searchParams.set("client_secret", clientSecret); + url.searchParams.set("redirect_uri", updatedRedirectUrl.toString()); + url.searchParams.set("scope", "legacy"); + url.searchParams.set("state", options.state); + url.searchParams.set("grant_type", "authorization_code"); + url.searchParams.set("code_challenge", options.codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("response_type", "code"); + url.searchParams.set("error_redirect_uri", options.errorRedirectUrl); + if (options.afterCallbackRedirectUrl) { + url.searchParams.set("after_callback_redirect_url", options.afterCallbackRedirectUrl); + } + return url.toString(); + } + + async authorizeSaml(options: { + connectionId: string, + redirectUrl: string, + errorRedirectUrl: string, + afterCallbackRedirectUrl?: string, + codeChallenge: string, + state: string, + }): Promise { + if (typeof window === "undefined") { + throw new StackAssertionError("authorizeSaml can currently only be called in a browser environment"); + } + await this.options.prepareRequest?.(); + const url = new URL(await this.getSamlUrl(options)); + url.searchParams.set("stack_response_mode", "json"); + + const rawRes = await fetch(url, { method: "GET" }); + const processedResponse = await this._processResponse(rawRes); + if (processedResponse.status === "error") { + throw processedResponse.error; + } + if (processedResponse.data.status !== 200) { + throw new StackAssertionError(`SAML authorize returned an unexpected status: ${processedResponse.data.status}`); + } + const body = await processedResponse.data.json(); + if (body == null || typeof body !== "object" || Array.isArray(body)) { + throw new StackAssertionError("SAML authorize response body must be an object", { body }); + } + const location = body.location; + if (typeof location !== "string") { + throw new StackAssertionError("SAML authorize response is missing a redirect location", { body }); + } + return location; + } + + /** + * Looks up the SAML connection matching an email's domain. Returns null + * when no connection matches, so callers can fall back to other sign-in + * methods. + */ + async discoverSamlConnection(email: string): Promise<{ connectionId: string, displayName: string } | null> { + const url = new URL(this.getBestApiUrl() + "/auth/saml/discover"); + url.searchParams.set("email", email); + url.searchParams.set("project_id", this.projectId); + const rawRes = await fetch(url, { method: "GET" }); + if (rawRes.status === 404) { + return null; + } + const processedResponse = await this._processResponse(rawRes); + if (processedResponse.status === "error") { + throw processedResponse.error; + } + if (processedResponse.data.status !== 200) { + throw new StackAssertionError(`SAML discover returned an unexpected status: ${processedResponse.data.status}`); + } + const body = await processedResponse.data.json(); + if (body == null || typeof body !== "object" || Array.isArray(body)) { + throw new StackAssertionError("SAML discover response body must be an object", { body }); + } + return { + connectionId: String(body.connection_id), + displayName: String(body.display_name), + }; + } + async callOAuthCallback(options: { oauthParams: URLSearchParams, redirectUri: string, diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 1097699f15..539b6d0c0d 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -2854,6 +2854,63 @@ export class _StackClientAppImplIncomplete { + return await this._interface.discoverSamlConnection(email); + } + /** * Handles MFA verification by redirecting to the OTP page */ From 958407d0c3a91fc912f07c0852bc3d231467ad63 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 29 Apr 2026 15:44:13 -0700 Subject: [PATCH 02/14] feat(examples/demo): add SAML SSO demo page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit examples/demo/src/app/saml-demo/page.tsx — manual end-to-end check for the SAML round-trip. Two flows: 1. Email-domain discovery: enter alice@acme.test, click "Sign in via SSO". The SDK calls getSamlConnectionForEmail then redirects via the matched connection. 2. Direct connection ID: per-tenant buttons that call signInWithSaml with explicit connectionId (the pattern most B2B login pages use when they brand each tenant separately). Page also shows the current signed-in state + an SDK snippet so a developer can see exactly what to copy. Pairs with seed-dummy-data's STACK_SEED_ENABLE_SAML=true block which pre-creates the matching acme + globex connections. --- examples/demo/src/app/saml-demo/page.tsx | 146 +++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 examples/demo/src/app/saml-demo/page.tsx diff --git a/examples/demo/src/app/saml-demo/page.tsx b/examples/demo/src/app/saml-demo/page.tsx new file mode 100644 index 0000000000..3fd638d28a --- /dev/null +++ b/examples/demo/src/app/saml-demo/page.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { useStackApp, useUser } from "@stackframe/stack"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { Button, Card, CardContent, CardHeader, Input, Typography } from "@stackframe/stack-ui"; +import { useState } from "react"; + +export default function SamlDemoPage() { + const app = useStackApp() as ReturnType & { + signInWithSaml: (options: { connectionId: string, returnTo?: string }) => Promise, + signInWithSso: (options: { email: string, returnTo?: string }) => Promise, + getSamlConnectionForEmail: (email: string) => Promise<{ connectionId: string, displayName: string } | null>, + }; + const user = useUser(); + const [email, setEmail] = useState(""); + const [discoveryResult, setDiscoveryResult] = useState<{ connectionId: string, displayName: string } | null | "error" | undefined>(undefined); + + return ( +
+ SAML SSO Demo + + Manual end-to-end check for the SAML round-trip against the local mock IdP. Seed the dummy + project with STACK_SEED_ENABLE_SAML=true first; that pre-creates two + connections (acme + globex) pointing at localhost:8115. + + +
+ + Current state + +
+
Signed in: {user ? "yes" : "no"}
+ {user && ( + <> +
User ID: {user.id}
+
Email: {user.primaryEmail ?? "(none)"}
+
Display name: {user.displayName ?? "(none)"}
+ + )} +
+ {user && ( + + )} +
+
+ + + 1. Sign in by email-domain discovery + + + Enter alice@acme.test or bob@globex.test. The SDK looks up + the matching SAML connection via /auth/saml/discover, then redirects. + +
+ setEmail(e.target.value)} + /> + + +
+ {discoveryResult === "error" && ( + No SAML connection matches this domain. + )} + {discoveryResult && discoveryResult !== "error" && ( + + Matched: {discoveryResult.connectionId} ({discoveryResult.displayName}) + + )} +
+
+ + + 2. Sign in by direct connection ID + + + For when the customer's UI has explicit per-tenant buttons rather than a unified + email field. + +
+ + +
+
+
+ + + SDK snippet + +
{`// Email-domain discovery (preferred for unified login forms)
+await app.signInWithSso({ email: "alice@acme.test", returnTo: "/" });
+
+// Direct connection ID (when you render per-tenant buttons)
+await app.signInWithSaml({ connectionId: "acme", returnTo: "/" });
+
+// Just preview the matching connection without redirecting:
+const conn = await app.getSamlConnectionForEmail("alice@acme.test");
+// → { connectionId: "acme", displayName: "Acme Corp SSO" }`}
+
+
+
+
+ ); +} From f8093a31c18dc9cf6278732038ac1f10496283bd Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 29 Apr 2026 15:47:13 -0700 Subject: [PATCH 03/14] test(e2e): add SAML discover, metadata, and login route tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three test files exercising the SAML routes that don't require a full IdP round-trip: - discover.test.ts: 5 cases for /auth/saml/discover — happy path, unknown domain (404), case-insensitivity, unknown project_id, cross-project isolation (project A's connection isn't visible from a query against project B's domain). - metadata.test.ts: 3 cases for /auth/saml/metadata — XML contains entityID + ACS URL embedded, 404 for unknown connection, 404 when connection exists but has no IdP cert (incomplete configuration). - login.test.ts: 5 cases for /auth/saml/login — JSON-mode returns the IdP redirect URL with SAMLRequest+RelayState, browser-redirect mode sets the stack-saml-inner- CSRF cookie, 404 unknown connection, 403 when allowSignIn=false, invalid client_id rejected. Test integrity: all tests drive the API only — no imports from apps/backend/src/saml/. SAML config is set via the standard config override endpoint (no test-only mutator), so the routes run through the same code path real customers would hit. Full SAML round-trip tests (login → mock IdP → ACS → session) deferred to a follow-up — they need a sequenced flow against the mock-saml-idp service that's separate from these endpoint-level tests. --- .../api/v1/auth/saml/discover.test.ts | 97 +++++++++++++++ .../endpoints/api/v1/auth/saml/login.test.ts | 115 ++++++++++++++++++ .../api/v1/auth/saml/metadata.test.ts | 72 +++++++++++ 3 files changed, 284 insertions(+) create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/auth/saml/discover.test.ts create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/auth/saml/login.test.ts create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/auth/saml/metadata.test.ts 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..a6dcf28977 --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/saml/discover.test.ts @@ -0,0 +1,97 @@ +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(); + // Push the SAML connection at the environment level — that's where the + // IdP-side fields live. The discovery endpoint reads from the rendered + // organization config which folds in env overrides. + await Project.updateConfig({ + [`auth.saml.connections.${slug}.displayName`]: `${slug} SSO`, + [`auth.saml.connections.${slug}.allowSignIn`]: true, + [`auth.saml.connections.${slug}.domain`]: domain, + [`auth.saml.connections.${slug}.idpEntityId`]: `https://idp.${domain}/saml/metadata`, + [`auth.saml.connections.${slug}.idpSsoUrl`]: `https://idp.${domain}/saml/sso`, + [`auth.saml.connections.${slug}.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 {