Previously the call would no-op the redirect and then await neverResolve(),
hanging the caller forever. Throw an explicit error instead so the misuse
surfaces immediately.
- Dashboard SSO page now renders absolute SP metadata + ACS URLs from
NEXT_PUBLIC_BROWSER_STACK_API_URL so values are paste-ready into Okta
/ Azure AD / Google Workspace consoles.
- Trim + lowercase email domain on write so trailing whitespace can't
silently break discovery (matching is case-insensitive but not trim).
- Guard DeleteDialog displayName against stale deleteId after a config
refresh removed the entry.
- signInWithSso gains the same browser-only guard as signInWithSaml so
SSR callers get a coherent error from the right method.
- SAML e2e tests now derive redirect_uri from localhostUrl("01") and
the mock IdP base from suffix 42 (matches mock-saml-idp default port);
align both wait-on URLs in the workflows. Round-trip happy path now
exchanges the OAuth code, asserts is_new_user + the JIT-created
user's primary_email and display_name, and asserts the callback origin
matches the redirect_uri so a custom-port job can no longer pass while
wired to a non-running dashboard.
Pull in PR review fixes from pr/saml-backend (5c9dab2d1, 1f7957978):
SP origin pinned to NEXT_PUBLIC_STACK_API_URL, branch_id query param on
discover + metadata, retryTransaction-wrapped SAML account linking,
NameID-as-email format restriction, POST-only metadata rejection, ACS
allowSignIn re-check, atomic SamlOuterInfo consume, and Object.hasOwn
guards on all `connections[id]` lookups.
Conflict resolutions across the five SAML route handlers:
- Kept HEAD's saml-sso app gate alongside the backend PR's `has()` checks.
- Took the backend PR's NEXT_PUBLIC_STACK_API_URL approach for SP origin
(deploy-time-stable, matches the OAuth provider convention) and dropped
the now-unused `fullReq` arg from login + ACS handler signatures.
- Took the backend PR's create/update branching in saml-connections POST
so optional fields (domain, attributeMapping) are preserved on partial
updates; removed a leftover overlay declaration from HEAD.
- ACS now deletes the SamlOuterInfo row up-front (right after extracting
InResponseTo) and runs the rest of the flow against the consumed record.
This closes the replay race where two concurrent SAMLResponse POSTs could
both pass a findUnique check before either reached the post-success delete
and each issue an OAuth code. If downstream verification fails, the client
must restart the flow.
- Replace `id in tenancy.config.auth.saml.connections` with `has(...)` from
utils/objects across all six SAML route call sites. The `in` operator walks
the prototype chain, so `__proto__` (which the body schema's regex actually
allows) would pass the guard and surface internal config paths in errors.
- Pin SP origin to NEXT_PUBLIC_STACK_API_URL across metadata, login, and ACS
so SP entityId / audience match the value the IdP signs assertions against.
Login no longer derives baseUrl from `redirect_uri`; metadata no longer
derives it from `req.url`.
- Discover and metadata accept an optional `branch_id` query param so
non-default branches resolve the same tenancy login uses (login parses
branch from `client_id` = `projectId#branchId`).
- linkSamlAccountToUser wraps both writes in retryTransaction so a partial
failure can't leave a sign-in-enabled SamlAccount with no AuthMethod.
- saml-connections POST writes the whole nested record entry on first create
(dotting into a missing parent is silently dropped by the config layer);
updates keep dot-paths so optional fields aren't wiped.
- Restrict NameID-as-email fallback to `nameid-format:emailAddress` so
opaque persistent NameIDs don't become verified user emails.
- Reject POST-only IdP metadata with a clear V1 error (we only emit
HTTP-Redirect AuthnRequests).
- ACS re-checks `allowSignIn === false` to close the 10-minute mid-flow
disable window.
Adds back seedSamlConnections + STACK_SEED_ENABLE_SAML callsite. This
PR is the first one in the stack where auth.saml.connections.* exists
in the config schema, so the writes actually take effect (on the mock
IdP PR they were silently dropped during normalization).
Default mock URL is now derived from NEXT_PUBLIC_STACK_PORT_PREFIX +
the mock's new port suffix 42, instead of being hardcoded to
http://localhost:8115. STACK_MOCK_SAML_URL still wins as an explicit
override. Metadata fetches now use a 10s AbortSignal.timeout and
encodeURIComponent the slug into the URL.
Removes seedSamlConnections (and its STACK_SEED_ENABLE_SAML callsite)
plus the @node-saml/node-saml dependency from this PR. Both depend on
config.auth.saml schema entries that don't exist on this branch yet —
the seed wrote overrides that were silently dropped during config
normalization, and node-saml had no consumer here.
They land together in the stacked backend PR alongside the schema and
the SAML protocol wrapper that actually imports node-saml.
Two related changes in apps/mock-saml-idp/src/index.ts:
1. Replay misbehavior now re-emits the original RelayState alongside the
cached SAMLResponse. Previously buildAssertion returned the cached
SAMLResponse but the login handler still rendered the form with the
*current* request's RelayState, which tests "old response + fresh
state" rather than a true replay. AssertionResult now carries
relayState so the handler uses whatever the assertion path returned.
2. Split the 100-line buildAssertion into focused helpers:
consumeNextMisbehavior, resolveSigningTenant, buildAssertionFields,
renderLoginResponseXml, cacheReplayableResponse. Same behavior; the
replay short-circuit and the cache update are now obvious at a glance.
Also updates the file header to clarify that the @node-saml/node-saml
dependency it pairs with lands in the stacked backend PR — this PR
ships the mock alone.
The mock SAML IdP previously used port suffix 15, which is also bound by
examples/supabase. Under \`pnpm dev:basic\` / \`dev:full\` whichever started
second failed to bind. Suffixes 01–41 are otherwise spoken for; 42 is the
first free slot before the LocalStack reservation at 50–99.
- Default port → \`\${prefix}42\`
- \`pnpm kms\` cleanup list grows to include 42
- e2e CI health-check URL updated to \`http://localhost:8142/idp\`
- Add a dev launchpad tile so the SAML mock is discoverable next to the
OAuth mock
- Move @types/express and @types/node-forge to devDependencies — they're
compile-time only.
- Drop the next.js ESLint extend (this is a plain Express service, not Next).
Rename .eslintrc.js to .eslintrc.cjs to match the convention used by every
other workspace package.
- Add --ext .tsx,.ts to the lint script (required when only the defaults
config is used, since the bare typescript-eslint parser doesn't pick up
.ts/.tsx by default; matches apps/e2e and packages/stack-shared).
Without the mock IdP running, the SAML round-trip e2e test fails with
ECONNREFUSED on port 8115 (or 6715 with the custom port prefix). The
standard e2e workflow already starts the service; mirror that step here.
Branch-level config schema now includes auth.saml.connections (default
{}), so getConfig surfaces it. Update the three inline snapshots that
serialize the full auth object to match.
`${keyedCookieNamePrefixes}` interpolated the entire array, which was
harmless when the array had one entry but produced
"stack-oauth-inner-,stack-saml-inner-" once a second prefix was added,
breaking every existing OAuth set-cookie snapshot.
The implementation in client-app-impl.ts already defines
signInWithSaml, signInWithSso, and getSamlConnectionForEmail, but the
public StackClientApp interface in template/.../client-app.ts didn't
list them. TypeScript users got no autocomplete and no type-checking
for the methods, and the regenerated js/react/stack packages
inherited the gap.
Add the three signatures to the interface near signInWithOAuth.
Run \`pnpm -w run generate-sdks\` to propagate to the downstream
packages (js/react/stack). Also add a saml-sso → Building2 mapping to
the docs APP_ICONS map (broke when the type added "saml-sso" to AppId).
Tests:
- New regression test in discover.test.ts confirming the new
allowSignIn=false filter (#1396 fix) returns 404, so a disabled
connection no longer leaks into the signInWithSso flow.
Discover is the entry point for the SDK's signInWithSso({ email }) flow.
Previously it returned every connection whose domain matched, including
ones the project admin had disabled (`allowSignIn: false`). The SDK
would then send the user through /auth/saml/login, which intentionally
403s for disabled connections — so disabling a connection was a sharp
UX cliff: domain match → branded "Sign in with Acme SSO" CTA → 403.
Treat disabled connections as if they didn't exist for discovery
purposes. Direct sign-in via signInWithSaml({ connectionId }) is still
gated separately in the login route, which is the right place for
"intentional, explicit access by ID."
Two cleanups in seed-dummy-data, both flagged on PR #1395:
- The parallel `Promise.all` block ran `seedSamlConnections(projectId)`
alongside another `overrideEnvironmentConfigOverride` write on the same
project (`payments.testMode`). Both are read-modify-write, so concurrent
reads of the env config can each see the stale state and the second
write clobbers the first — the existing TODO at `config.ts:491` already
documents the underlying race. Sequence the SAML seed after the
parallel block to avoid the race until the override is wrapped in a
serializable txn.
- Type the SAML overlay with the target parameter type directly instead
of using an `as Parameters<...>` cast, per project style.
The pattern \`/_[a-zA-Z][a-zA-Z0-9_.-]{8,}/g\` matched any SCREAMING_SNAKE_CASE
identifier with an underscore followed by 9+ chars — e.g.
\`STRIPE_ACCOUNT_INFO_NOT_FOUND\` became \`STRIPE<stripped SAML id>\`,
breaking unrelated Stripe / payments / OAuth e2e snapshots.
The regex isn't load-bearing today: no current SAML test snapshots a
random AuthnRequest / Response / Assertion ID. Cookie-name SAML IDs
are already covered by \`keyedCookieNamePrefixes\` ("stack-saml-inner-"),
URL path segments only carry the deterministic connection_id, and no
test snapshots raw SAML XML. If a future test ever does, follow the
precedent of the timestamp strip on the next line and anchor the
replacement to specific XML attributes (\`ID="..."\`, \`InResponseTo="..."\`)
rather than matching loose \`_<chars>\` strings everywhere.
Previously wrote per-field deep dot-keys
(`auth.saml.connections.X.displayName`, ...). When the parent record
entry didn't yet exist, normalization with `onDotIntoNonObject="ignore"`
silently dropped them — POST returned 200 but persisted nothing.
Mirror the dashboard's create dialog (commit 5fa9629de) by writing the
whole connection object as a single overlay entry. Add a regression test
that creates a connection from empty and verifies it round-trips through
LIST + GET; covers the gate too.
The login route built the SP `callbackUrl` from `query.redirect_uri.origin`,
which is the customer's app — not the backend. The IdP would then POST
the assertion to e.g. `http://localhost:8103/api/v1/auth/saml/acs/acme`
(the demo app), which 404s because the ACS handler only exists on the
backend.
Fix both login and ACS to derive `baseUrl` from the incoming request's
own origin, matching what the metadata route already does. The e2e
round-trip test didn't catch this because in tests the customer and
backend run on the same host.
Register a new alpha-stage `saml-sso` app rather than exposing SAML to
every project. Users opt in from the App Store; the dashboard SSO page,
admin CRUD, and SDK routes all 400 with `SAML_SSO_NOT_ENABLED` until
the app is installed. Alpha apps stay hidden in production via the
existing NODE_ENV filter in `getAllAvailableAppIds`.
- Add `saml-sso` to `ALL_APPS` + `ALL_APPS_FRONTEND` (icon, store copy,
nav item pointing at the existing /sso route)
- Wrap SSO page with `AppEnabledGuard` for the redirect-on-disabled UX
- Backend: each SAML route checks `apps.installed["saml-sso"]?.enabled`
and throws the new `KnownErrors.SamlSsoNotEnabled`
- E2E: setup helpers enable the app on test projects; new discover
test verifies the gate fires for unconfigured projects
- Seed dummy data: enable saml-sso when `STACK_SEED_ENABLE_SAML=true`
so the seeded acme/globex connections work without an extra click
- Fix a pre-existing indent slip in the delete-dialog updateConfig call
Surfaced while taking screenshots for the PR description:
- yup.string().url() rejects http://localhost which breaks any local
or test-env IdP setup. The backend SAML wrapper validates the URL
on use anyway. Drop the client-side .url() check.
- The form was set to pushable=true which routes through the
GitHub-pushable config dialog. SAML connection fields (cert,
IdP URLs) live at the environment level (not the branch level
that's pushed to GitHub) — same as OAuth client secrets in the
auth-methods page. Set pushable=false to write directly via env
config override.
E2E tests + dashboard SSO page were writing per-field deep dot-keys
like `auth.saml.connections.X.displayName`, which the config
normalizer drops because the parent record entry doesn't yet exist
when the connection is being created. Match the existing
auth.oauth.providers convention: write the whole connection entry
as a single value on create.
Also fixed two test-harness issues uncovered while running the suite:
- Round-trip ACS POST was using niceBackendFetch which always
JSON.stringifies the body. Switched to plain niceFetch so
URLSearchParams gets sent as application/x-www-form-urlencoded.
- Mock IdP /metadata returns application/xml, which makes niceFetch
return ArrayBuffer; added a TextDecoder pass before regex matching.
Single page at /projects/[projectId]/sso for managing SAML connections.
Lists existing connections (read from project.useConfig() — same source
as the e2e tests and seed script use), with add + delete dialogs.
Add dialog: ID, display name, optional email domain (for discovery),
IdP entity ID, IdP SSO URL, and IdP signing certificate. PEM headers
are stripped from the cert automatically before saving.
Delete dialog warns that user accounts linked via the connection
remain in the database — they just become unable to sign in until a
connection with the same id is recreated.
Each connection card surfaces the SP metadata URL + ACS URL so the
customer's IT admin can copy them into the IdP console without
manually composing the URLs.
V1 limitations (planned follow-ups):
- Edit happens by deleting + recreating; no in-place edit dialog yet.
- No "paste IdP metadata XML" auto-fill (the backend metadata-parser
exists; just needs to be wired up to a paste box).
- No separate detail page; everything is on the list.
apps/e2e/tests/backend/endpoints/api/v1/auth/saml/round-trip.test.ts
exercises the entire SP-initiated flow against the running mock IdP on
port 8115:
GET /auth/saml/login → IdP URL with SAMLRequest
POST mock /idp/[tenant]/login → auto-POST HTML with signed SAMLResponse
POST /auth/saml/acs → backend verifies + issues OAuth code
Five test cases:
1. Happy path: new user JIT-created, ACS responds with 303/307 + OAuth
code in the redirect.
2. Wrong audience: mock IdP misbehaves via /test-controls
{ kind: 'wrong-audience' }, backend rejects.
3. Bad signature (cross-tenant forgery): mock signs with another
tenant's key via { kind: 'bad-signature' }, backend rejects.
4. Expired assertion: NotOnOrAfter in the past via { kind: 'expired' },
backend rejects.
5. Replay: same SAMLResponse POSTed twice — second attempt rejected
because SamlOuterInfo was consumed by the first ACS call.
Fetches the mock IdP's cert at test setup time so the SAML
verification chain is real (the mock regenerates keys per startup, so
hardcoded certs would never match).
Test integrity reaffirmed: the test file imports only from helpers,
backend-helpers, and ports — NO imports from apps/backend/src/saml/.
Negative cases come from the mock deliberately misbehaving, never from
injecting bad data into the backend's own validator. Mock IdP uses
samlify; backend uses @node-saml/node-saml — different libraries on
each side mean a bug in either surfaces as a test failure rather than
canceling out.
Tests written and lint/typecheck clean; runtime verification needs the
backend + mock-saml-idp services up (CI workflow already wired).
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.
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.
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.
Two bugs surfaced when running the SAML e2e suite against the live
backend (in a separate PR):
1. Routes accessed `tenancy.config.auth.saml.connections[id].field`
without first checking that the entry exists. With strict null
checks off, TS types this as always-defined and the route 500'd
with a TypeError on missing connections instead of returning 404.
Add an explicit `id in connections` guard at the top of each
route (login, acs, metadata).
2. SAML responses signed at the Response element (samlify default,
also what Okta + Azure AD emit) failed verification because the
backend was configured with wantAssertionsSigned=true,
wantAuthnResponseSigned=false — i.e. demanded an Assertion-level
signature. Per SAML 2.0 §4.1.4.2 either is valid. Flip to
wantAuthnResponseSigned=true so we accept what real-world IdPs
actually send.
Three endpoints under /api/v1/saml-connections — admin-only thin REST
wrappers around the JSON-config storage so the dashboard SSO pages
don't compose key paths manually:
- GET /saml-connections list all (omits cert)
- POST /saml-connections upsert by id
- DELETE /saml-connections delete by id
- GET /saml-connections/[id] full detail (includes cert)
User accounts linked via a deleted connection remain in the DB; they
just become unable to sign in until a connection with the same id is
recreated. (Dashboard delete UX should warn on this.)
Underlying storage is the same overrideEnvironmentConfigOverride /
resetEnvironmentConfigOverrideKeys flow used by the seed script and
the e2e tests, so behavior is identical across all surfaces.
Two routes that complete the SAML SP-initiated round trip:
- GET /api/v1/auth/saml/login/[connection_id]
Receives the same Stack Auth OAuth client params as
/auth/oauth/authorize (client_id, redirect_uri, scope, state, etc.),
builds an AuthnRequest, persists the OAuth context + AuthnRequest ID
in SamlOuterInfo, sets a CSRF cookie keyed to the request ID, and
redirects to the IdP. Honors stack_response_mode=json so the SDK
can intercept programmatically. V1 scope: SP-initiated only, no
signed AuthnRequests, no link/upgrade flow.
- POST /api/v1/auth/saml/acs/[connection_id]
Receives the IdP's POST. Parses InResponseTo from the response
WITHOUT verifying the signature, looks up SamlOuterInfo to recover
tenancy/connection (this is necessary because the connection ID
alone doesn't index a tenancy in the JSON-config storage model).
Validates CSRF cookie, then runs node-saml's full
validatePostResponseAsync (signature + audience + clock skew +
InResponseTo). Defense-in-depth re-checks InResponseTo and
cross-connection mismatch (the latter handles 'assertion sent to
the wrong ACS endpoint' forgery, e2e test #10).
On success, runs find-existing / link / create via the
saml-account.tsx helpers, then hands off to oauthServer.authorize
so Stack Auth issues a customer-facing OAuth code (mirrors the
oauth/callback pattern). Deletes SamlOuterInfo at the end for
replay protection.
Adds extractInResponseTo helper to saml/saml.tsx for the pre-validation
parse described above.
Routes typecheck and lint clean. Runtime untested — needs the e2e test
matrix (task #15) to exercise the round-trip end-to-end against the
mock IdP.
Adds tenancy.config.auth.saml — mirrors the auth.oauth shape:
- branchAuthSchema gains saml.{accountMergeStrategy, connections}
with non-sensitive per-connection fields (displayName, allowSignIn,
domain). domain feeds /auth/saml/discover.
- environmentConfigSchema extends saml.connections with IdP-side
fields (idpEntityId, idpSsoUrl, idpCertificate, attributeMapping).
These belong at the environment level — different per IdP deployment
even though the cert is technically a public key — same way
oauth.providers splits clientId/clientSecret out of branch config.
- Defaults block adds an empty saml block; per-connection defaults set
allowSignIn=true and a placeholder displayName so partial configs
validate cleanly.
Also drops the temporary unknown-cast workaround in saml-account.tsx
(handleSamlEmailMergeStrategy) and updates the metadata + discover
routes to construct SamlConnectionConfig from the typed config record
(injecting the connection ID since it's stored as the record key).
Adds matching coverage in schema-fuzzer.test.ts so the fuzzed config
shape includes a sample SAML connection.
Two of the four planned SAML routes — the public-fetchable / read-only
ones with no OAuth2-server integration:
- GET /api/v1/auth/saml/metadata/[connection_id]?project_id=...
SP metadata XML for the IdP admin to paste into their IdP console.
Includes the project_id query param because connection IDs alone
don't identify a tenancy (config lives in JSON, not a Prisma table).
- GET /api/v1/auth/saml/discover?email=...&project_id=...
Email-domain → connection lookup for the SDK's signInWithSso flow.
Returns 404 (not 200 with null) when no connection matches so the
SDK can fall back to other sign-in methods on status alone.
login + acs routes are the next chunk. They need to mirror the OAuth
callback's oauthServer.authorize integration so the customer's app
receives a Stack Auth OAuth code on success — that's a meaningful
copy-from-pattern job and is left for the next commit so it can be
reviewed and tested in isolation.
Three modules under apps/backend/src/saml/:
- saml.tsx — buildSamlClient (per-connection SAML instance), build
AuthnRequestUrl (returns URL + extracted requestId for replay
protection), parseAndVerifyAssertion (signature + audience + clock-skew
+ InResponseTo are all enforced by node-saml), getSpMetadataXml.
Defines SamlConnectionConfig locally so the wrapper doesn't depend on
the project-config schema work.
- metadata-parser.tsx — pulls entityId, ssoUrl, and the signing X509
certificate out of pasted IdP metadata XML. Uses xmldom + xpath rather
than regex so it handles attribute-order variations across IdPs.
- discovery.tsx — email-domain to connection lookup for the
signInWithSso({ email }) flow. Iterates the project's connections and
returns the first whose `domain` matches.
The clock-skew tolerance is set to 60s, matching the e2e test matrix
item #16. The 'wantAssertionsSigned: true' default means an unsigned
assertion is rejected even if the response itself is signed — which is
the safer default per OWASP SAML guidance.
Splits the email-merge strategy out of oauth.tsx into a small shared
external-auth.tsx so the upcoming SAML ACS handler can reuse the same
contact-channel lookup + link_method/raise_error/allow_duplicates switch
without duplicating it.
Also adds saml-account.tsx with the SAML-side parallel of OAuth's
findExisting / link / create user-linking helpers, operating on
ProjectUserSamlAccount and SamlAuthMethod. Each helper is keyed by
(tenancyId, samlConnectionId, nameId), so a NameID arriving from a
different connection is treated as a separate identity — connection
isolation is enforced at the DB level.
Schema strategy fallback: handleSamlEmailMergeStrategy reads
tenancy.config.auth.saml.accountMergeStrategy if set, otherwise falls
back to the OAuth strategy. The SAML config field will be added with
the project config schema work.
Adds @xmldom/xmldom and xpath as direct backend deps for the upcoming
SAML protocol wrapper (currently transitive through @node-saml/node-saml).
Adds three tables to back per-user SAML accounts and the in-flight
AuthnRequest temp store:
- ProjectUserSamlAccount (mirrors ProjectUserOAuthAccount): one row per
(tenancy, samlConnectionId, NameID). The unique constraint on
(tenancyId, samlConnectionId, nameId) is what enforces multi-tenant
connection isolation at the DB level — the same NameID from a
different connection is treated as a distinct identity.
- SamlAuthMethod (mirrors OAuthAuthMethod): connects an AuthMethod to a
ProjectUserSamlAccount via composite FK.
- SamlOuterInfo (mirrors OAuthOuterInfo): keyed by AuthnRequest ID so
the ACS handler can look up the original context when the IdP POSTs
the assertion back via the browser. ID is TEXT (not UUID) because
SAML AuthnRequest IDs are XML xs:ID strings.
Per-connection config (entity ID, IdP cert, ACS URL, attribute mapping,
domain) is intentionally NOT a Prisma model — it lives in
tenancy.config.auth.saml.connections JSON, matching how OAuth provider
config (clientId/clientSecret) is stored.
mock-saml-idp originally depended on body-parser for the parser
middleware, but switched to using express.urlencoded()/express.json()
directly. The package.json dep was removed but the lockfile entry
remained, breaking 'pnpm install --frozen-lockfile' in CI.
Deep dot-keys like `auth.saml.connections.X.field` get dropped by
config normalization with onDotIntoNonObject=ignore when the parent
record entry doesn't yet exist. Match the existing convention from
auth.oauth.providers and write the whole connection entry as a
single value.
(Bug surfaced when running the SAML e2e tests against a live
backend in a separate PR. Applied here so the seed function works
on its own without requiring downstream PRs.)
Three smaller pieces that unlock e2e testing:
- .github/workflows/e2e-api-tests.yaml: starts mock-saml-idp on port
8115 alongside mock-oauth-server, with /idp as the readiness probe.
Root package.json adds start:mock-saml-idp script and includes the
mock in dev:basic.
- apps/e2e/tests/snapshot-serializer.ts: strips SAMLRequest /
SAMLResponse / RelayState query+form params, adds stack-saml-inner-
to keyed cookie name prefixes (so the per-AuthnRequest CSRF cookie
doesn't reroll snapshots), and adds regex replacements for SAML xs:ID
identifiers and IssueInstant/NotBefore/NotOnOrAfter timestamps.
- apps/backend/src/lib/seed-dummy-data.ts: STACK_SEED_ENABLE_SAML=true
pre-creates acme + globex SAML connections on the dummy project,
fetching the IdP metadata from the running mock at seed time so the
seeded cert matches what the mock generated at startup. The mock
regenerates keys per restart, so re-seed if you restart it. Mock URL
configurable via STACK_MOCK_SAML_URL (default localhost:8115).
Adds apps/mock-saml-idp, a multi-tenant SAML 2.0 Identity Provider mock
mirroring apps/mock-oauth-server. Each tenant has its own RSA keypair
and self-signed cert generated at startup, so one mock service can back
many SamlConnection rows in tests and exercise per-connection isolation.
Uses samlify deliberately because the upcoming backend SAML wrapper will
use @node-saml/node-saml. Different libraries on each side means a bug
in either library's signature canonicalization surfaces as a test
failure instead of being masked by both sides agreeing.
Endpoints:
- GET /idp/:tenant/metadata IdP metadata XML
- GET /idp/:tenant/sso AuthnRequest receiver, renders login form
- POST /idp/:tenant/login builds and auto-POSTs signed assertion
- POST /idp/:tenant/test-controls queues misbehaviors (bad-signature,
expired, wrong-audience, replay, etc.)
- GET /idp introspection
Also adds @node-saml/node-saml to apps/backend deps for the upcoming
backend SAML protocol wrapper.
## Summary
- Move the `/api/internal/[transport]` MCP route from the docs app to
the backend, so the public `ask_stack_auth` MCP tool is served from the
same origin as the AI query API it proxies to.
- Replace the bespoke docs-tools HTTP client in
`apps/backend/src/lib/ai/tools/docs.ts` with an `@ai-sdk/mcp` client
that talks to Mintlify's generated MCP server. The backend AI agent now
consumes Mintlify's lower-level search/fetch tools directly instead of
going through the docs app.
- Swap `STACK_DOCS_INTERNAL_BASE_URL` for `STACK_MINTLIFY_MCP_URL`
(defaults to the Mintlify-hosted MCP URL).
- Move the `@vercel/mcp-adapter` dependency from `docs` to
`apps/backend`.
## Test plan
- [ ] `pnpm typecheck`
- [ ] `pnpm lint`
- [ ] e2e: new
`apps/e2e/tests/backend/endpoints/api/v1/internal/mcp.test.ts` covers
`tools/list` and validation on `tools/call`
- [ ] Manual: hit `POST /api/internal/mcp` on the backend and confirm
`ask_stack_auth` is listed and callable
- [ ] Manual: confirm backend AI agent docs tools resolve via the
Mintlify MCP URL
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Backend docs tooling now uses a Mintlify MCP server for documentation
tools and discovery.
* **Chores**
* Development environment variables updated to point to the Mintlify MCP
endpoint.
* Backend dependency added to support MCP integration; docs package
dependency removed.
* **Tests**
* Added end-to-end tests for the internal MCP endpoint and tool
validation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary
Rolling PR for dashboard UI bug fixes. Each fix is appended to the **Fix
log** below with before/after screenshots. This PR stays open until we
batch-merge or split.
---
## Fix log
### 1. Hide Alpha/Beta stage badges in onboarding "Select apps" tooltip
**Bug:** On the new-project onboarding, hovering an app card showed an
"Alpha" or "Beta" stage badge next to the app name in the tooltip. These
shouldn't be surfaced on the onboarding step.
**Fix:** Removed the stage badge from the onboarding app-card tooltip
only. The "Required" badge is preserved, and stage badges on other
surfaces (app management, app store, command palette) are unchanged.
#### Before / After — Beta (Payments)
| Before | After |
| --- | --- |
|

|

|
#### Before / After — Alpha (Onboarding)
| Before | After |
| --- | --- |
|

|

|
---
### 2. Eliminate full-page flash when advancing onboarding steps
**Bug:** Moving between onboarding steps (e.g. Configure authentication
→ Select email theme) briefly blanked out the entire page — only the
navbar remained visible for roughly two seconds — before the next step
rendered. It felt like a complete browser reload.
**Fix:** Contained the suspension inside the wizard. A local Suspense
boundary around the onboarding page means that when any data cache
refresh fires during the step advance, the suspension no longer bubbles
up to the site-wide loading indicator. The step-advance state update is
also marked as a React transition, so the current step stays rendered
until the next step is ready to commit. Net effect: the previous step is
visible throughout the save, then the next step swaps in without a blank
frame.
#### Before — full blank flash mid-transition
| Auth step (start) | Mid-transition (blank) | Email theme step (end) |
| --- | --- | --- |
|

|

|

|
#### After — previous step stays visible, no blank frame
| Auth step (start) | Mid-transition (auth stays visible) | Email theme
step (end) |
| --- | --- | --- |
|

|

|

|
---
### 3. Add a subtle back arrow to the onboarding timeline
**Bug:** The only way to return to a previous step in the new-project
onboarding was to click one of the tiny completed-step dots at the
bottom of the page — not discoverable, and easy to miss.
**Fix:** Added a small muted left-arrow next to the timeline dots.
Clicking it advances back one step. It's absolute-positioned so the dots
stay perfectly centered, and it hides itself on the first step (where
there's nothing to go back to).
#### Before / After — Select apps step
| Before — dots only | After — back arrow next to the dots |
| --- | --- |
|

|

|
### 4. Unify onboarding step styling — cards everywhere, no
glassmorphism
**Bug:** Step-to-step styling in the onboarding was inconsistent. The
Config and Email-theme steps used a glassmorphic surround
(`backdrop-blur`, translucent whites) while the other steps used solid
cards. Advancing from auth to email made it look like the visual
language had changed mid-flow.
**Fix:** Dropped the glassmorphic variants from the onboarding wizard.
The config-choice option cards, the email-theme container, and the
`ModeNotImplementedCard` surround all now use the same solid card
treatment (`bg-white/90` light, `bg-white/[0.06]` dark, with subtle
ring). One consistent surface across every step.
#### Before / After — Config choice step
| Before — glassmorphic | After — solid card |
| --- | --- |
|

|

|
#### Before / After — Email theme step
| Before — glassmorphic | After — solid card |
| --- | --- |
|

|

|
### 5. Add "Copy prompt" button on the project setup page
**Bug:** The post-project-creation setup page surfaces a terminal
command for every framework (Next.js, React, JS, Python), but there was
no one-click handoff for users who drive their setup through an AI
agent. Users had to manually copy the command, figure out whether the
Stack Auth MCP server got registered, and add it themselves if not.
**Fix:** Added a compact **✦ Copy prompt** button at the top-right above
the steps list. Clicking it copies a framework-aware prompt to the
clipboard — the prompt tells the user's AI agent to run the install
command for the currently-selected framework, then verify the Stack Auth
MCP server (`stack-auth`, transport `http`,
`https://mcp.stack-auth.com/`) is registered in its client config and
add it manually if the install didn't.
#### Before / After — Project setup page
| Before — no AI handoff | After — "Copy prompt" at the top-right |
| --- | --- |
|

|

|
### 6. Disable email theme cards while the onboarding step is saving
**Bug:** On the "Select an email theme" step, the theme cards stayed
clickable after clicking Continue. Because we keep the previous step
visible during the step-advance transition (fix#2), users could click
through to a different theme mid-save — the server would then commit
whatever selection was active at click time, not the one on screen when
Continue was pressed.
**Fix:** Added `disabled={saving}` to the email theme buttons, matching
the same pattern the config-choice, apps-selection, and auth-setup steps
already follow. Added `disabled:cursor-not-allowed disabled:opacity-60`
so users get a clear visual signal that the cards are locked while the
save is in flight.
---
<!-- Append new fixes above this line. Template:
### N. <title>
**Bug:** …
**Fix:** …
#### Before / After
| Before | After |
| --- | --- |
|  |  |
-->
## Test plan
- [ ] Load the new-project onboarding "Select apps" step and hover every
app card — no Alpha/Beta badge appears.
- [ ] Hover a required app — "Required" badge still appears.
- [ ] Confirm app management tooltips, app store detail page, and
command palette still show stage badges (out of scope for this PR).
- [ ] Drive the onboarding from Configure authentication to Select email
theme — the auth panel stays rendered throughout the save phase and the
email panel swaps in without the site-wide loading indicator or a blank
content area.
- [ ] Repeat for other step transitions (Config → Apps, Apps → Auth,
Email → Domain, Domain → Payments) — same seamless behavior.
- [ ] From any step after Config, the back arrow appears to the left of
the dots. Clicking it goes back one step. On the first step, the arrow
is not rendered.
- [ ] Walk through every onboarding step. Container surface is visually
consistent across steps — no glassmorphic/card mismatch between Config,
Apps, Auth, Email Theme, Payments.
- [ ] On the project setup page, the "Copy prompt" button appears above
the steps (top-right). Clicking it copies the prompt for the
currently-selected framework (Next.js / React / JS / Python) and shows a
success toast.
- [ ] On the "Select an email theme" step, click Continue — the three
theme cards become visibly dimmed (`opacity-60`, `cursor-not-allowed`)
for the duration of the save and don't respond to clicks. Once the next
step renders they stop being visible anyway.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added back navigation to onboarding wizard steps.
* Added "Copy prompt" button for framework-aware terminal commands with
MCP verification.
* Added loading indicator during asynchronous operations.
* **UI/UX Improvements**
* Updated card styling for unselected options.
* Disabled email theme selection during save operations.
* Removed stage badges (Alpha/Beta) from app cards.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->