Skip to content

fix(auth): make SAML SP-initiated login work end-to-end#405

Merged
therealbrad merged 1 commit into
mainfrom
fix/saml-sp-login-flow
Jun 5, 2026
Merged

fix(auth): make SAML SP-initiated login work end-to-end#405
therealbrad merged 1 commit into
mainfrom
fix/saml-sp-login-flow

Conversation

@therealbrad
Copy link
Copy Markdown
Contributor

Description

SAML single sign-on (SP-initiated) never completed in any configuration. This fixes four independent defects that surface together when the app runs behind a reverse proxy (reported case: Okta IdP, app on k3s):

  1. Redirects used the internal pod hostname. With output: "standalone" the server binds to the pod's HOSTNAME, and Next builds request.url from that bound host rather than the Host header — so SAML redirects emitted unreachable URLs like https://<pod>:3000/.... Absolute auth URLs are now built from NEXTAUTH_URL via a shared getAppBaseUrl() helper (also applied to the magic-link and logout/SLO redirects).
  2. Login looked up the wrong key. The sign-in UI passes the SsoProvider id, but the handler queried SamlConfiguration.id → 404 "SAML provider not found or disabled". Now looks up by the unique providerId foreign key.
  3. Assertion posted to a path with no handler. The IdP posts to /api/auth/callback/saml, but the validator lived at /api/auth/saml/callback and no NextAuth saml provider exists, so the POST hit the [...nextauth] catch-all and failed with "This action with HTTP POST is not supported by NextAuth.js". The validator now lives at the path the IdP targets and hands off to /api/auth/saml/complete to mint the session.
  4. State stored in SameSite=Lax cookies. Browsers withhold those on the IdP's cross-site POST, losing the provider/destination. They're now carried in the SAML RelayState, backed by Valkey (single-use, short-lived, works across pods) with a signed-token fallback when Valkey is absent.

Also seeds access/isApi/passwordChangedAt into the completion session token so middleware sees the user's access level on the first request.

Related Issue

Customer-reported SAML/Okta setup failure (no public issue).

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

Testing

  • 61 new/updated unit tests across the SAML endpoints and relay-state helpers — including one that mints a session via /api/auth/saml/complete and decodes it with NextAuth's own decode() to prove the post-login handoff yields a valid session (sub === userId).
  • pnpm precommit (lint + format) clean; pnpm build succeeds with the new route registered; tsc --noEmit clean.
  • Requires NEXTAUTH_URL set to the public origin (already required for the ACS URL).

Checklist

  • Code follows the project's style guidelines
  • Added tests that prove the fix is effective
  • New and existing unit tests pass locally
  • Documentation updated where applicable (N/A)

🤖 Generated with Claude Code

SAML single sign-on never completed in any configuration. Four
independent defects, surfaced together behind a reverse proxy
(Okta + k3s):

- Redirects were built from the request host. With the standalone
  server bound to the pod HOSTNAME, this emitted internal URLs like
  https://<pod>:3000/... that don't resolve. Build absolute auth URLs
  from NEXTAUTH_URL via a shared getAppBaseUrl() helper.

- The login lookup keyed on SamlConfiguration.id, but the sign-in UI
  passes the SsoProvider id. Look up by the unique providerId foreign
  key instead, which returns a 404 today.

- The IdP posts the assertion to /api/auth/callback/saml, but the
  handler lived at /api/auth/saml/callback and no NextAuth saml
  provider exists, so the POST hit the catch-all and failed with
  "This action with HTTP POST is not supported by NextAuth.js". Move
  the validator to the path the IdP actually targets and hand off to
  /api/auth/saml/complete to mint the session.

- Provider and destination were stored in SameSite=Lax cookies, which
  browsers withhold on the IdP's cross-site POST. Carry them in the
  SAML RelayState instead, backed by Valkey (single-use, short-lived,
  works across pods) with a signed-token fallback.

Also seed access/isApi/passwordChangedAt into the completion token so
middleware sees the user's access on the first request, and add unit
tests covering each fix plus the post-login session handoff.
@therealbrad therealbrad force-pushed the fix/saml-sp-login-flow branch from 8fdf88f to d0a3fc3 Compare June 5, 2026 18:13
@therealbrad therealbrad merged commit e65517c into main Jun 5, 2026
5 of 7 checks passed
@therealbrad therealbrad deleted the fix/saml-sp-login-flow branch June 5, 2026 18:28
@therealbrad
Copy link
Copy Markdown
Contributor Author

🎉 This PR is included in version 0.35.1 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

therealbrad added a commit that referenced this pull request Jun 5, 2026
…letion flow

Follow-up to the SP-initiated SAML fix (#405), three changes:

- IdP-initiated SSO: an Okta dashboard-tile launch posts an assertion with no
  RelayState. Rather than rejecting it, the ACS now picks the enabled SAML
  config whose certificate validates the assertion's signature (the stored
  issuer is the SP entity id, not a reliable IdP identifier) and defaults the
  post-login destination to /.

- Enforce 2FA for SAML logins: the completion route now reads force2FAAllLogins
  and stamps the same twoFactorRequired / twoFactorSetupRequired claims the
  NextAuth jwt callback sets at sign-in, so SAML sessions hit the existing
  middleware 2FA gate instead of bypassing it.

- Single-use completion token: the token handed to /api/auth/saml/complete
  rides in a redirect URL, so it now registers a single-use jti in Valkey
  (deleted on consume) and its lifetime is cut to two minutes — a replayed link
  is rejected.
therealbrad added a commit that referenced this pull request Jun 5, 2026
…letion flow (#406)

Follow-up to the SP-initiated SAML fix (#405), three changes:

- IdP-initiated SSO: an Okta dashboard-tile launch posts an assertion with no
  RelayState. Rather than rejecting it, the ACS now picks the enabled SAML
  config whose certificate validates the assertion's signature (the stored
  issuer is the SP entity id, not a reliable IdP identifier) and defaults the
  post-login destination to /.

- Enforce 2FA for SAML logins: the completion route now reads force2FAAllLogins
  and stamps the same twoFactorRequired / twoFactorSetupRequired claims the
  NextAuth jwt callback sets at sign-in, so SAML sessions hit the existing
  middleware 2FA gate instead of bypassing it.

- Single-use completion token: the token handed to /api/auth/saml/complete
  rides in a redirect URL, so it now registers a single-use jti in Valkey
  (deleted on consume) and its lifetime is cut to two minutes — a replayed link
  is rejected.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant