Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ce66a19
feat(sdk): add signInWithSaml, signInWithSso, getSamlConnectionForEmail
BilalG1 Apr 29, 2026
958407d
feat(examples/demo): add SAML SSO demo page
BilalG1 Apr 29, 2026
f8093a3
test(e2e): add SAML discover, metadata, and login route tests
BilalG1 Apr 29, 2026
36d00f2
test(e2e): add full SAML SP-initiated round-trip tests via mock IdP
BilalG1 Apr 29, 2026
edb13f3
feat(dashboard): add SAML SSO management page
BilalG1 Apr 29, 2026
5fa9629
fix(saml): use whole-entry config writes; correct test request shapes
BilalG1 Apr 29, 2026
fe8197c
fix(dashboard): drop yup.url() validator and use env-only pushable=false
BilalG1 Apr 29, 2026
82424d1
feat(saml): gate SAML SSO behind alpha-stage saml-sso app
BilalG1 Apr 30, 2026
9cf7e8f
fix(saml): use backend origin for SP base URL in login + ACS routes
BilalG1 Apr 30, 2026
8facb27
fix(saml): admin POST /saml-connections must write whole entry
BilalG1 Apr 30, 2026
b00fc53
Merge branch 'pr/saml-backend' into pr/saml-client
BilalG1 Apr 30, 2026
a007318
Merge branch 'pr/saml-backend' into pr/saml-client
BilalG1 Apr 30, 2026
e3e09f5
fix(saml): expose SAML SDK methods on StackClientApp interface
BilalG1 Apr 30, 2026
314fa17
Merge branch 'pr/saml-backend' into pr/saml-client
BilalG1 Apr 30, 2026
54c4736
Merge branch 'pr/saml-backend' into pr/saml-client
BilalG1 Apr 30, 2026
457cba6
ci(saml): start mock-saml-idp in local-emulator and custom-port e2e w…
BilalG1 Apr 30, 2026
e37e5f3
Merge branch 'pr/saml-backend' into pr/saml-client
BilalG1 Apr 30, 2026
be4560b
fix(saml): address PR review — absolute SP URLs, domain normalization…
BilalG1 Apr 30, 2026
fdfc400
fix(saml): reject signInWithSaml when redirectMethod is "none"
BilalG1 May 1, 2026
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
10 changes: 10 additions & 0 deletions .github/workflows/e2e-api-tests-local-emulator.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/e2e-custom-base-port-api-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/src/app/api/latest/auth/saml/discover/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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}`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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`);
}
Expand Down
10 changes: 10 additions & 0 deletions apps/backend/src/app/api/latest/saml-connections/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown> = {};
Expand Down Expand Up @@ -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`);
}
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/src/lib/seed-dummy-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1988,6 +1988,9 @@ async function seedSamlConnections(projectId: string): Promise<void> {
// 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<typeof overrideEnvironmentConfigOverride>[0]["environmentConfigOverrideOverride"] = {};
for (const f of fetched) {
overlay[`auth.saml.connections.${f.slug}`] = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<AppEnabledGuard appId="saml-sso">
<PageContent />
</AppEnabledGuard>
);
}

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<string | null>(null);

const connectionEntries = Object.entries(connections);
const apiBase = getBrowserApiBase();

return (
<PageLayout
title="SAML SSO"
description="Manage SAML 2.0 connections so corporate IdP users can sign in to your project."
actions={<Button onClick={() => setCreateOpen(true)}>Add SAML connection</Button>}
>
{connectionEntries.length === 0 && (
<Alert>
No SAML connections configured yet. Click <strong>Add SAML connection</strong> to wire
up your first IdP.
</Alert>
)}

<div className="grid gap-4">
{connectionEntries.map(([id, conn]) => (
<Card key={id}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<Typography type="h4">{conn.displayName}</Typography>
<Typography type="p" variant="secondary" className="text-sm">
Connection ID: <code>{id}</code>
{conn.domain && (
<>
{" "}· Email domain: <code>{conn.domain}</code>
</>
)}
</Typography>
</div>
<div className="flex gap-2">
<Button variant="destructive" size="sm" onClick={() => setDeleteId(id)}>
Delete
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-2 text-sm">
<DetailRow label="IdP Entity ID" value={conn.idpEntityId} />
<DetailRow label="IdP SSO URL" value={conn.idpSsoUrl} />
<DetailRow
label="IdP signing cert"
value={conn.idpCertificate ? `<${conn.idpCertificate.length}-char certificate>` : null}
/>
<DetailRow label="Sign-in enabled" value={conn.allowSignIn ? "yes" : "no"} />
</div>
<div className="mt-3 grid gap-1 text-sm border-t pt-3">
<Typography type="p" variant="secondary" className="text-xs">
Paste these into your IdP&apos;s admin console:
</Typography>
<DetailRow
label="SP metadata URL"
value={`${apiBase}/api/v1/auth/saml/metadata/${id}?project_id=${project.id}`}
mono
/>
<DetailRow
label="ACS URL"
value={`${apiBase}/api/v1/auth/saml/acs/${id}`}
mono
/>
Comment thread
BilalG1 marked this conversation as resolved.
</div>
</CardContent>
</Card>
))}
</div>

<CreateDialog open={createOpen} onOpenChange={setCreateOpen} />
<DeleteDialog
connectionId={deleteId}
onClose={() => 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}
/>
</PageLayout>
);
}

function DetailRow({ label, value, mono }: { label: string, value: string | null | undefined, mono?: boolean }) {
return (
<div className="flex gap-2">
<span className="font-medium min-w-[140px]">{label}:</span>
<span className={mono ? "font-mono break-all" : "break-all"}>
{value ? value : <em className="text-gray-500">not set</em>}
</span>
</div>
);
}

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 (
<SmartFormDialog
open={open}
onOpenChange={onOpenChange}
title="Add a SAML connection"
formSchema={formSchema}
okButton={{ label: "Create" }}
cancelButton
onSubmit={async (values) => {
// 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<typeof updateConfig>[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 (
<ActionDialog
open={connectionId != null}
onOpenChange={(open) => { 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<typeof updateConfig>[0]["configUpdate"],
// SAML connection fields (cert, IdP URLs) are environment-level
// (not pushable) — same as OAuth client secrets.
pushable: false,
});
onClose();
},
}}
cancelButton
>
<Typography>
Delete <strong>{displayName ?? "this connection"}</strong>? Existing user accounts linked
via this connection will remain in the database but will no longer be able to sign in.
</Typography>
</ActionDialog>
);
}
Loading
Loading