Skip to content

fix(auth): keep SSO working when the app and API are on different ori… - #1202

Open
njbrake wants to merge 5 commits into
thunderbird:mainfrom
njbrake:pr3-split-origin-auth
Open

fix(auth): keep SSO working when the app and API are on different ori…#1202
njbrake wants to merge 5 commits into
thunderbird:mainfrom
njbrake:pr3-split-origin-auth

Conversation

@njbrake

@njbrake njbrake commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

…gins

Note: this PR description was drafted by Claude via back-and-forth with @njbrake. The reasoning and decisions are his; the prose is Claude's.

Fixes two SSO failures that only appear when the app and the API are served from different origins. Both are invisible in the Compose and ALB setups, where one origin proxies both.

Sign-in returns a bare NOT_FOUND. Better Auth resolves its error redirect as onAPIError.errorURL || ${baseURL}/error, and baseURL is the API origin, which serves no HTML. Any auth failure lands on a blank 404 instead of the app. Now sets onAPIError.errorURL to APP_URL.

The OAuth state cookie is rejected. __Secure-better-auth.state is set by the API origin and read back during a redirect initiated from the app origin, so a cross-site SameSite=Lax cookie is dropped and sign-in fails. account.skipStateCookieCheck is now enabled only when APP_URL and the backend origin actually differ, so single-origin deployments keep the check.

PKCE still protects the exchange, and the state parameter is still round-tripped through the provider. The cookie is a second layer that a split-origin deployment cannot rely on.

…gins

_Note: this PR description was drafted by Claude via back-and-forth with @njbrake. The reasoning and decisions are his; the prose is Claude's._

Fixes two SSO failures that only appear when the app and the API are served from different origins. Both are invisible in the Compose and ALB setups, where one origin proxies both.

**Sign-in returns a bare `NOT_FOUND`.** Better Auth resolves its error redirect as `onAPIError.errorURL || ${baseURL}/error`, and `baseURL` is the API origin, which serves no HTML. Any auth failure lands on a blank 404 instead of the app. Now sets `onAPIError.errorURL` to `APP_URL`.

**The OAuth state cookie is rejected.** `__Secure-better-auth.state` is set by the API origin and read back during a redirect initiated from the app origin, so a cross-site `SameSite=Lax` cookie is dropped and sign-in fails. `account.skipStateCookieCheck` is now enabled only when `APP_URL` and the backend origin actually differ, so single-origin deployments keep the check.

PKCE still protects the exchange, and the state parameter is still round-tripped through the provider. The cookie is a second layer that a split-origin deployment cannot rely on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@ital0 ital0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for digging into this one. The split-origin failure mode is subtle, and the write-up in the description made it easy to follow. The errorURL change looks good to me as is.

A few things I'd like before merging:

skipStateCookieCheck

This is my main concern. The state cookie is what ties the OAuth callback to the browser that started the flow. With the check skipped, someone with an account on the same IdP could start a login, capture their own callback URL (code + state) without consuming it, and hand it to a victim. The victim's browser completes the flow and ends up logged into the attacker's account, and anything the victim types lands in the attacker's data. PKCE doesn't cover this, since the verifier lives server-side in the verification row: it binds the code exchange to the server, not to the initiating browser.

I know the alternative today is SSO being fully broken on split-origin deploys, so I don't want to block on perfection. Two ideas:

  1. If our split-origin deploys share a registrable domain (app.example.com + api.example.com), they're actually same-site and SameSite=Lax cookies still work. advanced.crossSubDomainCookies might let us keep the check there and reserve the skip for genuinely cross-site setups.
  2. The current condition compares origins, which is broader than SameSite. localhost:1420 vs localhost:8000 is cross-origin but same-site, so we also drop the check in dev and in the whole test suite. Worth tightening, or at least calling out.

One wording suggestion: the comment says the cookie was "only an additional binding check", which understates the login CSRF window a bit. Naming the trade-off explicitly will help future readers.

Possible redirect loop on persistent errors

The frontend never reads ?error=. In SSO mode the flow becomes: callback error → appUrl/?error=... → auth gate → /sso-redirect → auto-starts SSO on mount → IdP → error again. Recoverable errors (stale state) fix themselves on retry, which is great, but persistent ones (unlinked account, IdP misconfiguration) loop forever with no message. Before this change the bare 404 at least stopped the cycle. Could we point errorURL at a dedicated error route, or have SsoRedirect check for ?error= and show the retry UI it already has?

Tests

One side effect worth knowing: createTestSettings uses localhost:1420 and localhost:8000, so every existing OIDC/SAML integration test now runs with the skip active, and the cookie-check path loses coverage. A small unit test on the shape of the createAuth options (skip present only when origins differ, errorURL set) would document the contract.

- keep the OAuth state cookie check in every deployment: the signed cookie
  is the only thing binding a callback to the browser that started the
  flow, so skipping it to accommodate a cross-site split reopened login
  CSRF. Split-origin deployments must keep APP_URL and BETTER_AUTH_URL
  same-site instead, which is now documented.
- point onAPIError.errorURL at /auth-error rather than the app root: in
  SSO mode the root sits behind the auth gate and restarts an IdP
  round-trip on mount, so a persistent failure (unlinked account, IdP
  misconfiguration) looped forever. The new route is unguarded and
  terminal, and surfaces the provider error code for support.
- cover state binding end to end: missing cookie, an attacker state
  replayed in a victim browser, single-use consumption, and the redirect
  target of every failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@njbrake
njbrake deployed to fork-preview-approval August 12, 2026 18:23 — with GitHub Actions Active
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

Preview environment deployed 🚀

Service URL
Marketing / blog / docs https://thunderbolt-pr-1202.preview.thunderbolt.io
App https://app-pr-1202.preview.thunderbolt.io
API https://api-pr-1202.preview.thunderbolt.io
Keycloak https://auth-pr-1202.preview.thunderbolt.io
PowerSync https://powersync-pr-1202.preview.thunderbolt.io

Stack: preview-pr-1202 · Commit: d1f3e5e76b1991a8c721e8dbc0c0c1b15fc0629e

Auto-destroys on PR close/merge. Login via the bundled Keycloak realm — demo@thunderbolt.io / demo by default.

@njbrake

njbrake commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

You were right about the state cookie, so I dropped skipStateCookieCheck entirely rather than tightening the predicate.

Login CSRF.

better-auth 1.6.9's state.mjs confirms your read: under storeStateStrategy: "database" the verification row makes state single-use, but the signed cookie is the only browser binding. New sso-callback-state.test.ts drives the real callback: a fresh state with no cookie, and an attacker's state replayed against a victim's cookie, both stop before the token exchange. I checked that the test discriminates by re-adding the skip, at which point both reach the code exchange and the tests fail.

crossSubDomainCookies.

It doesn't help the deployment I need, which is Railway. up.railway.app is on the Public Suffix List, so web-x.up.railway.app and api-x.up.railway.app are different registrable domains, not siblings. That also means the session cookie (SameSite=Lax, 7 day) would never reach the app even with state fixed, so the original diff would not have finished the job. I'm moving those services onto subdomains of one custom apex, which makes everything same-site and needs no code.

Redirect loop.

errorURL now targets a dedicated /auth-error route instead of the app root. Verified in a browser against a production bundle built with VITE_AUTH_MODE=sso: /?error=... redirects into /sso-redirect and drops the error param, while /auth-error holds. The page reads both ?error= and ?state=, since one better-auth path reports state_not_found on the latter, and renders error_description as text.

Tests.

Those 6 plus 4 on the resolved option shape. Test settings stay cross-origin, which no longer costs coverage now that the skip is gone.

Two things from the source dive.

SAML never checked this cookie, since parseRelayState passes skipStateCookieCheck: true internally, so this only ever affected OIDC. And the state cookie's Max-Age is 300 while the verification row lives 10 minutes, so an IdP login slower than 5 minutes fails the same way. Pre-existing, so I left it.

The same-site requirement is now written up in docs/self-hosting/configuration.md.

Note: this comment was drafted by Claude Opus 5 via back-and-forth with @njbrake. The reasoning and decisions are his; the prose is Claude's.

@ital0 ital0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work on the follow-up.

A few small things that I believe worth to take a look.

globalThis.fetch = savedFetch
process.env.TRUSTED_ORIGINS = savedOrigins
if (cleanup) {
await cleanup().catch(() => {})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The repo pattern is one transaction per test with cleanup() in afterEach (backend/docs/testing.md is pretty explicit about it). The shared beforeAll transaction works here since each startFlow() writes fresh rows, but worth either matching the pattern or a short comment on why not. Also, .catch(() => {}) hides rollback failures silently.

Comment thread backend/src/auth/auth-options.test.ts Outdated
// that started the flow — the verification row makes state single-use but not
// browser-bound. Skipping the check reopens login CSRF, so split-origin
// deployments must keep the app and API same-site instead.
const sameSite = optionsFor({ appUrl: 'https://app.example.com', betterAuthUrl: 'https://api.example.com' })

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runs in the default consumer mode, so options.account is undefined and not.toHaveProperty passes without exercising anything. Pass authMode: 'oidc' so the test would actually catch a SSO-conditional skip coming back. (The callback suite still catches it, so this is belt-and-suspenders, but right now this test is green for free.)

Comment thread src/components/auth-error.test.tsx Outdated
const replace = mock(() => {})
const originalLocation = window.location

Object.defineProperty(window, 'location', {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

window.location is replaced globally and never restored; the afterEach only clears the mock. The fake object leaks into other test files running in the same worker. Restoring the original in an afterAll would fix it.

Comment thread src/components/auth-error.tsx Outdated
// Better Auth reports the code as `?error=` on most paths, but a missing state
// parameter arrives as `?state=state_not_found`.
const code = searchParams.get('error') || searchParams.get('state') || 'unknown_error'
const description = searchParams.get('error_description')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this pageview goes to PostHog with the full query string as $current_url (usePageTracking appends location.search, and sanitizeUrl only rewrites the /chats/:id pathname). With telemetry enabled, an IdP-supplied error_description lands in analytics. Stripping the query for this route in sanitizeUrl, or clearing the params here after reading them, should cover it.

Comment thread src/components/auth-error.tsx Outdated
<div className="flex flex-col items-center gap-2">
<h1 className="text-4xl font-semibold tracking-tight">Sign-in failed</h1>
<p className="text-muted-foreground">{describeAuthError(code)}</p>
<p className="text-[length:var(--font-size-xs)] text-muted-foreground">{description || code}</p>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When error_description is present, the raw code disappears from the page, though the JSDoc above says the code is always shown so support tickets keep the real signal. Showing both (labeled) would match the comment. Confirmed on the preview deploy: ?error=idp_exploded&error_description=Client+not+registered renders only the description.

Comment thread docs/self-hosting/configuration.md Outdated

The simplest deployments put the frontend and the backend behind one origin (Compose, the ALB, a k8s ingress). If you split them, `APP_URL` and `BETTER_AUTH_URL` must stay **same-site**: two hostnames under one registrable domain, such as `app.example.com` and `api.example.com`. Different ports or subdomains of a shared parent are fine.

Hostnames on different registrable domains break sign-in. The auth cookies are `SameSite=Lax`, so a cross-site deployment loses both the OAuth `state` cookie (the callback fails with `state_security_mismatch` after a successful IdP login) and the session cookie (`/get-session` returns 401 from the app origin). Watch out for PaaS hostnames that look like subdomains but are not: `up.railway.app`, `vercel.app`, and similar are on the [Public Suffix List](https://publicsuffix.org/list/), which makes `web.up.railway.app` and `api.up.railway.app` cross-site. Attach custom domains under one apex instead.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The redirect actually delivers ?error=state_mismatch (the new callback tests assert exactly that), so an operator searching logs for state_security_mismatch won't find it. Worth using the delivered code here.

- isolate sso-callback-state tests in a per-test transaction so consumed
  verification rows cannot leak between tests
- run the state-cookie assertions in SSO mode, where `account` exists, so
  they cannot pass vacuously
- restore the original window.location after the auth-error suite instead of
  leaving a global stub for later test files
- always render the error code next to the provider description, so support
  tickets keep the real signal
- strip the query string from /auth-error before analytics see it, since
  error_description is third-party text
- correct the documented error code for a cross-site callback failure
@njbrake
njbrake deployed to fork-preview-approval August 13, 2026 12:12 — with GitHub Actions Active
@njbrake

njbrake commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @ital0 ! I think Claude and I took care of those remaining issues. And please let me know if you would like the PRs presented in a different way, I know I sent a whole batch of them at once, if you want them submitted more gradually of if you want more or less AI involved in these PRs, let me know :) I saw and used a handful of the thunder skills you have in the repo so I have the feeling you all are AI friendly but just let me know if you want any different communication methods 🙏

njbrake added a commit to njbrake/thunderbolt that referenced this pull request Aug 13, 2026
This deployment was running `skipStateCookieCheck: true`, applied whenever the
app and API origins differ, which they do here. Upstream review of thunderbird#1202
established that this reopens login CSRF, and the reasoning holds for us:

Under `storeStateStrategy: "database"` the verification row makes `state`
single-use, but the signed cookie is the only thing binding a callback to the
browser that began the flow. PKCE does not close the gap, because the verifier
lives server-side in that same row: it binds the code exchange to the server,
not to the initiating browser. So an attacker with an account on the same
Keycloak realm could start a login, capture their own unconsumed callback URL,
and hand it to a victim, whose browser completes the flow and lands in the
attacker's account.

Dropping the skip costs nothing here. It was only ever needed for Railway's
generated hostnames, which sit on the Public Suffix List and so make sibling
subdomains cross-site. This deployment now serves the app and API as subdomains
of one apex, which is same-site, so `SameSite=Lax` delivers the state cookie
normally.

Also brings over the reviewed error-handling half, which never made it back to
this branch: `onAPIError.errorURL` targets an unguarded, terminal `/auth-error`
page rather than the app root. In SSO mode the root sits behind the auth gate,
which redirects to the sign-in route, which starts a fresh IdP round-trip on
mount, so a persistent failure looped through the identity provider forever.
`sanitizeUrl` strips the query string for that route so an IdP-supplied
`error_description` does not reach analytics.

The page's retry button targets `/sign-in`, this branch's route, rather than
upstream's `/sso-redirect`, which does not exist here.

Verified the new tests discriminate: reintroducing the skip fails all three
guards, including the attacker-replay case. Frontend 4349 pass, backend 1064
pass, 0 lint errors.

@ital0 ital0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the careful follow-up. The final direction resolves the state-cookie and automatic redirect-loop concerns from my earlier reviews, and the callback tests add useful coverage.

I did one more line-by-line pass and left focused comments on two sibling flows that still fall outside the new landing page, SAML and desktop loopback, plus the remaining trust and privacy boundary around error query data. The other notes are smaller copy, test, and self-hosting documentation corrections.

One general housekeeping item: main now contains broader URL sanitization from #1210, so please preserve that implementation while resolving the current conflict.

The overall direction makes sense to me. I think addressing the sibling flows and query-data handling will make the fix hold across the deployment modes this project supports.

Comment thread backend/src/auth/auth.ts
// /sso-redirect, which starts a new IdP round-trip on mount. A persistent
// failure (unlinked account, IdP misconfiguration) would loop through the
// IdP forever. /auth-error is unguarded and terminal.
onAPIError: { errorURL: `${settings.appUrl}/auth-error` },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onAPIError.errorURL does not cover the SAML result path in @better-auth/sso 1.6.9. processSAMLResponse() ignores relayState.errorURL and sends validation or linking failures to relayState.callbackURL. Thunderbolt sets that to /, so AuthGate starts SSO again and the redirect loop remains for SAML.

<p className="text-[length:var(--font-size-xs)] text-muted-foreground">Error code: {code}</p>
</div>

<Button onClick={() => window.location.replace(isSsoMode() ? '/sso-redirect' : '/')}>Try again</Button>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Desktop OIDC failures land on this web page while startSsoFlowLoopback() continues waiting for its localhost callback for up to five minutes. This button starts a normal web flow in the system browser and never settles the original desktop promise, leaving the app pending until timeout.

Comment thread src/lib/posthog.tsx Outdated
}
})()

if (queryStrippedRoutes.some((route) => route === pathname)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only sanitizes selected event properties. PostHog also records $session_entry_url, and same-origin analytics requests carry the full page URL in Referer, which the backend proxy forwards. error_description can still reach PostHog despite the test asserting that it never does.

Comment thread src/components/auth-error.tsx Outdated
<div className="flex flex-col items-center gap-2">
<h1 className="text-4xl font-semibold tracking-tight">Sign-in failed</h1>
<p className="text-muted-foreground">{describeAuthError(code)}</p>
{description && <p className="text-[length:var(--font-size-xs)] text-muted-foreground">{description}</p>}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/auth-error is public and renders error_description verbatim under Thunderbolt branding. A crafted URL can display arbitrary instructions. React escapes markup, but it does not prevent misleading text. When an IdP omits the description, the SSO plugin can also forward the literal value undefined.

const staleFlowCodes = new Set([
'invalid_state',
'please_restart_the_process',
'state_mismatch',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

state_mismatch does not mean the flow went stale. The callback tests use it for a missing or different-browser cookie, and the docs use it for persistent cross-site configuration. The page therefore reports a timeout and recommends retry for failures that happened immediately and cannot be fixed by retry.

Comment thread src/components/auth-error.tsx Outdated
return 'Your sign-in took too long to complete. Starting over usually fixes this.'
}
if (code === 'account_not_linked') {
return 'This email is already registered with a different sign-in method. Ask your administrator to link the accounts.'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thunderbolt has no administrator account-linking flow, while trusted SSO providers already link matching emails automatically. This message directs the user to an unavailable action and hides the more likely original-sign-in or identity-mapping problem.

Comment thread docs/self-hosting/configuration.md Outdated

### Serving the app and API on separate hostnames

The simplest deployments put the frontend and the backend behind one origin (Compose, the ALB, a k8s ingress). If you split them, `APP_URL` and `BETTER_AUTH_URL` must stay **same-site**: two hostnames under one registrable domain, such as `app.example.com` and `api.example.com`. Different ports or subdomains of a shared parent are fine.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This split-origin recipe is incomplete. SameSite is schemeful, so the URLs need the same scheme as well as the same registrable domain. Split origins also depend on CORS_ORIGINS, TRUSTED_ORIGINS, credentialed CORS, and an absolute VITE_THUNDERBOLT_CLOUD_URL pointing at the API. A deployment can follow the current paragraph and still fail sign-in.

Comment thread docs/self-hosting/configuration.md Outdated

The simplest deployments put the frontend and the backend behind one origin (Compose, the ALB, a k8s ingress). If you split them, `APP_URL` and `BETTER_AUTH_URL` must stay **same-site**: two hostnames under one registrable domain, such as `app.example.com` and `api.example.com`. Different ports or subdomains of a shared parent are fine.

Hostnames on different registrable domains break sign-in. The auth cookies are `SameSite=Lax`, so a cross-site deployment loses both the OAuth `state` cookie (after a successful IdP login the callback redirects to `APP_URL/auth-error?error=state_mismatch`, and the backend logs `state_security_mismatch`) and the session cookie (`/get-session` returns 401 from the app origin). Watch out for PaaS hostnames that look like subdomains but are not: `up.railway.app`, `vercel.app`, and similar are on the [Public Suffix List](https://publicsuffix.org/list/), which makes `web.up.railway.app` and `api.up.railway.app` cross-site. Attach custom domains under one apex instead.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better Auth 1.6.9 returns 200 null from /get-session when the session cookie is absent, not 401. The documented status points operators at the wrong troubleshooting symptom.

const savedOrigins = process.env.TRUSTED_ORIGINS

beforeAll(() => {
globalThis.fetch = Object.assign(stubbedFetch, { preconnect: () => {} }) as unknown as typeof fetch

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as unknown as typeof fetch bypasses the fetch contract. The test can continue compiling after the stub stops matching Bun's real fetch signature, which weakens this process-wide boundary.

njbrake and others added 2 commits August 20, 2026 15:31
Resolves the src/lib/posthog.tsx conflict in favour of main's thunderbird#1210
sanitization, which already strips every query string in sanitizeUrl —
the route-specific list this branch added is redundant beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QUZcKJcXPqNx8kSJCPrbby
- divert `?error=` at the AuthGate to /auth-error, so a SAML linking
  failure lands on a terminal page instead of looping back through the IdP
- add a `/desktop-error/:port` backend route and pass `errorCallbackURL`
  from desktop-initiate, so a desktop SSO failure reaches the waiting
  loopback server instead of timing out after five minutes
- attribute and truncate the provider's `error_description` on
  /auth-error, and drop the literal `undefined` Better Auth sends
- correct the `state_mismatch` and `account_not_linked` copy
- sanitize the `$session_entry_*` and `$set_once` URL properties and stop
  forwarding `Referer` through the PostHog proxy
- correct and expand the split-origin self-hosting docs
@njbrake
njbrake deployed to fork-preview-approval August 20, 2026 15:40 — with GitHub Actions Active

@ital0 ital0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the careful follow-up. The latest changes address most of my earlier comments.

I left one remaining inline note on the desktop error callback. In the versions pinned by the backend, OIDC state failures and SAML failures still bypass the loopback error path, leaving the desktop pending until the five-minute timeout.

method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ providerId: 'sso', callbackURL }),
body: JSON.stringify({ providerId: 'sso', callbackURL, errorCallbackURL }),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding the desktop error callback. I traced this through Better Auth and @better-auth/sso 1.6.9, and two branches still bypass it. An OIDC state failure happens before Better Auth can recover errorCallbackURL, so it falls back to the web /auth-error. SAML stores this value in RelayState, but processSAMLResponse() redirects with callbackURL instead. Neither branch reaches the loopback server, leaving the desktop flow pending until its five-minute timeout.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants