Commit Graph

3077 Commits

Author SHA1 Message Date
Bilal Godil
fdfc400027 fix(saml): reject signInWithSaml when redirectMethod is "none"
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.
2026-05-01 09:56:50 -07:00
Bilal Godil
be4560b416 fix(saml): address PR review — absolute SP URLs, domain normalization, port-prefix-aware tests
- 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.
2026-04-30 15:57:01 -07:00
Bilal Godil
e37e5f37b5 Merge branch 'pr/saml-backend' into pr/saml-client
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.
2026-04-30 15:29:17 -07:00
Bilal Godil
1f7957978f fix(saml): atomic OuterInfo consume + Object.hasOwn guards on connection lookups
- 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.
2026-04-30 15:19:15 -07:00
Bilal Godil
5c9dab2d1e fix(saml): address PR review — SP origin, branch scoping, atomicity, hardening
- 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.
2026-04-30 15:10:10 -07:00
Bilal Godil
841d0591d0 feat(seed): seed mock SAML connections on the dummy project
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.
2026-04-30 14:48:49 -07:00
Bilal Godil
7bcc84fb28 Merge branch 'pr/saml-mock-idp' into pr/saml-backend
# Conflicts:
#	apps/backend/package.json
2026-04-30 14:47:19 -07:00
Bilal Godil
6b8cd7e564 chore(backend): defer SAML seed + node-saml dep to stacked backend PR
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.
2026-04-30 14:45:48 -07:00
Bilal Godil
77e4fae463 fix(mock-saml-idp): split buildAssertion + replay full POST body
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.
2026-04-30 14:45:39 -07:00
Bilal Godil
18567fb2d0 chore(mock-saml-idp): switch to port suffix 42 to avoid collision
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
2026-04-30 14:45:04 -07:00
Bilal Godil
c25cc8ad56 chore(mock-saml-idp): tidy ESLint config and dev-only deps
- 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).
2026-04-30 14:44:12 -07:00
Bilal Godil
457cba6ee4 ci(saml): start mock-saml-idp in local-emulator and custom-port e2e workflows
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.
2026-04-30 10:31:14 -07:00
Bilal Godil
54c4736e36 Merge branch 'pr/saml-backend' into pr/saml-client 2026-04-30 09:57:26 -07:00
Bilal Godil
259a9852f6 fix(test): add saml key to backend config.test.ts inline snapshot
Companion to 90a9852c9, which updated apps/e2e/tests/js/config.test.ts
but missed the equivalent snapshot in the backend endpoint test.
2026-04-30 09:56:52 -07:00
BilalG1
314fa1780e
Merge branch 'pr/saml-backend' into pr/saml-client 2026-04-30 09:33:52 -07:00
Bilal Godil
90a9852c99 fix(test): add saml key to config.test.ts inline snapshots
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.
2026-04-30 09:33:35 -07:00
BilalG1
f7a487f001
Merge branch 'pr/saml-mock-idp' into pr/saml-backend 2026-04-30 09:30:27 -07:00
Bilal Godil
7dacbc4c08 fix(test): use matched cookie prefix in snapshot serializer
`${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.
2026-04-30 09:29:38 -07:00
Bilal Godil
e3e09f5464 fix(saml): expose SAML SDK methods on StackClientApp interface
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.
2026-04-29 18:50:26 -07:00
Bilal Godil
a007318b49 Merge branch 'pr/saml-backend' into pr/saml-client
# Conflicts:
#	apps/backend/src/lib/seed-dummy-data.ts
2026-04-29 18:50:09 -07:00
Bilal Godil
8f6ad97e40 fix(saml): skip allowSignIn=false connections in /auth/saml/discover
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."
2026-04-29 18:45:02 -07:00
Bilal Godil
75f8c69dd8 Merge branch 'pr/saml-mock-idp' into pr/saml-backend 2026-04-29 18:43:11 -07:00
Bilal Godil
1e912c7548 fix(seed): serialize SAML seed; drop overlay cast
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.
2026-04-29 18:42:59 -07:00
BilalG1
b00fc53748
Merge branch 'pr/saml-backend' into pr/saml-client 2026-04-29 18:31:35 -07:00
BilalG1
8c45c0607b
Merge branch 'pr/saml-mock-idp' into pr/saml-backend 2026-04-29 18:31:28 -07:00
Bilal Godil
aaeb8318a8 fix(test): drop overbroad SAML ID strip regex from snapshot serializer
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.
2026-04-29 18:28:43 -07:00
Bilal Godil
8facb272a6 fix(saml): admin POST /saml-connections must write whole entry
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.
2026-04-29 18:14:32 -07:00
Bilal Godil
9cf7e8f943 fix(saml): use backend origin for SP base URL in login + ACS routes
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.
2026-04-29 18:08:41 -07:00
Bilal Godil
82424d1be9 feat(saml): gate SAML SSO behind alpha-stage saml-sso app
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
2026-04-29 17:23:10 -07:00
Bilal Godil
fe8197ca8c fix(dashboard): drop yup.url() validator and use env-only pushable=false
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.
2026-04-29 16:59:52 -07:00
Bilal Godil
5fa9629ded fix(saml): use whole-entry config writes; correct test request shapes
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.
2026-04-29 16:47:17 -07:00
Bilal Godil
edb13f3107 feat(dashboard): add SAML SSO management page
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.
2026-04-29 16:47:17 -07:00
Bilal Godil
36d00f2c4d test(e2e): add full SAML SP-initiated round-trip tests via mock IdP
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).
2026-04-29 16:47:17 -07:00
Bilal Godil
f8093a31c1 test(e2e): add SAML discover, metadata, and login route tests
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.
2026-04-29 16:47:16 -07:00
Bilal Godil
958407d0c3 feat(examples/demo): add SAML SSO demo page
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.
2026-04-29 16:47:16 -07:00
Bilal Godil
ce66a1908d feat(sdk): add signInWithSaml, signInWithSso, getSamlConnectionForEmail
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.
2026-04-29 16:47:16 -07:00
Bilal Godil
2d1db7a56e fix(saml): harden route guards and accept Response-level signature
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.
2026-04-29 16:46:22 -07:00
Bilal Godil
b4bc68750e feat(backend): add admin CRUD for SAML connections
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.
2026-04-29 16:46:22 -07:00
Bilal Godil
191ad700bd feat(backend): add SAML login + ACS routes with OAuth2 integration
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.
2026-04-29 16:46:22 -07:00
Bilal Godil
189a543a31 feat(stack-shared): add SAML connection config to project schema
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.
2026-04-29 16:46:22 -07:00
Bilal Godil
11239b4687 feat(backend): add SAML metadata + discovery HTTP routes
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.
2026-04-29 16:46:22 -07:00
Bilal Godil
0e542f72f5 feat(backend): add SAML protocol wrapper around @node-saml/node-saml
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.
2026-04-29 16:46:22 -07:00
Bilal Godil
c1b7bed261 feat(backend): extract email-merge helper and add SAML account helpers
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).
2026-04-29 16:46:22 -07:00
Bilal Godil
cbd2e3fca3 feat(backend): add SAML SSO Prisma models + migration
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.
2026-04-29 16:46:22 -07:00
Bilal Godil
0e59570bc9 fix(lockfile): regenerate after removing body-parser dep from mock-saml-idp
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.
2026-04-29 16:46:13 -07:00
Bilal Godil
4949a9cfc2 fix(seed): use whole-entry config writes for SAML connections
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.)
2026-04-29 16:38:35 -07:00
Bilal Godil
6c7b14b3bc feat: wire mock-saml-idp into CI, snapshots, and seed dummy data
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).
2026-04-29 16:38:03 -07:00
Bilal Godil
d4d25f6255 feat(mock-saml-idp): scaffold mock SAML 2.0 IdP for SAML SSO testing
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.
2026-04-29 16:38:03 -07:00
Mantra
e831972c4c
Move internal MCP server to backend, use Mintlify MCP for docs tools (#1389)
## 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 -->
2026-04-29 09:45:52 -07:00
aadesh18
ed8961069c
fix(dashboard): UI bug fixes (#1377)
## 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-payments](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/before-hover-beta-payments.png)
|
![after-payments](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/after-hover-beta-payments.png)
|

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

| Before | After |
| --- | --- |
|
![before-onboarding](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/before-hover-alpha-onboarding.png)
|
![after-onboarding](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/after-hover-alpha-onboarding.png)
|

---

### 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) |
| --- | --- | --- |
|
![before-auth](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/before-01-auth-step.png)
|
![before-flash](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/before-02-suspense-flash.png)
|
![before-email](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/before-03-email-theme-step.png)
|

#### After — previous step stays visible, no blank frame

| Auth step (start) | Mid-transition (auth stays visible) | Email theme
step (end) |
| --- | --- | --- |
|
![after-auth](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/after-suspense-01-auth-step.png)
|
![after-mid](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/after-suspense-02-mid-transition.png)
|
![after-email](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/after-suspense-03-email-step.png)
|

---

### 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 |
| --- | --- |
|
![before-back-arrow](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/before-back-arrow-apps.png)
|
![after-back-arrow](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/after-back-arrow-apps.png)
|

### 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-glass-config](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/before-glass-config-choice.png)
|
![after-glass-config](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/after-glass-config-choice.png?v=2)
|

#### Before / After — Email theme step

| Before — glassmorphic | After — solid card |
| --- | --- |
|
![before-glass-email](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/before-glass-email-theme.png)
|
![after-glass-email](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/after-glass-email-theme.png)
|

### 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 |
| --- | --- |
|
![before-copy-prompt](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/before-copy-prompt-setup.png)
|
![after-copy-prompt](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/after-copy-prompt-setup.png)
|

### 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 |
| --- | --- |
| ![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 -->
2026-04-28 18:49:28 -07:00