Skip to content

fix(auth): recover from a failed OAuth callback instead of wedging - #468

Open
interacsean wants to merge 4 commits into
mainfrom
fix/auth/callback-error-dead-end
Open

fix(auth): recover from a failed OAuth callback instead of wedging#468
interacsean wants to merge 4 commits into
mainfrom
fix/auth/callback-error-dead-end

Conversation

@interacsean

@interacsean interacsean commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Scope

This PR fixes app-shell's own logic error around failed OAuth callbacks. The upstream defects it interacts with — the auth client cleaning the callback URL only on success, and its replaceState({}, …) clobbering router history state — are deliberately not worked around here; they are filed as auth-public-client#139 (present since 0.3.0, verified against published tarballs).

The bug (ours)

attemptAutoLogin read the URL to decide whether a callback was in progress. Because upstream leaves ?code= / ?error= in place on every failure path, any failed callback made that check permanently true:

  1. Session dies → autoLogin redirects to the IdP
  2. IdP answers ?error=access_denied (or the exchange throws — e.g. a clobbered PKCE verifier)
  3. The URL still looks like a callback → auto-login never fires again
  4. App sits on guardComponent forever; reloading replays the same failing callback

The URL was the wrong signal. The check means "is an exchange in flight", and the callback-status machinery (createCallbackStatusManager) already tracks exactly that.

The fix

Gate auto-login on the callback's settled status, not the URL. The URL stays authoritative only while the status is "idle" — this client claims the exchange itself whenever it is constructed on a callback URL, so "idle" means nobody is handling those parameters and redirecting away from them would be unsafe.

And make sure it cannot loop instead. Un-wedging auto-login naively turns every failed callback into an infinite full-page redirect cycle (each attempt is a fresh page load, so nothing in memory can break it). Three guards:

  • An explicit server refusal becomes a terminal "denied" status — bouncing back would ask the same question and get the same answer. The error stays on useAuth().error; the docs now show a guard that renders it with a retry.
  • A recoverable failure gets one retry per tab, counted in sessionStorage (page loads reset memory). Unreadable storage counts as exhausted — looping is the worse failure, and such a browser can't complete the flow anyway.
  • Outcomes are classified from the resulting auth state, not resolve-vs-throw: the server-error branch and a state mismatch both return normally after recording the error, so settlement alone misclassifies them — and a denial that arrives as a throw (the error branch touches IndexedDB first, which can reject) must still be terminal.

What this deliberately does not do

  • No URL cleanup. The stale parameters remain in the address bar after a failure until upstream#139 lands. app-shell no longer misbehaves because of them, but reimplementing the library's cleanup in a consumer was the wrong layer — an earlier revision of this PR did exactly that (including history.state preservation) and has been backed out.
  • No browser proof. The example apps don't wire auth at all (createAuthClient appears nowhere under examples/). The behavioural evidence is the mutation-tested unit suite below; end-to-end confirmation needs a consuming app driving a real IdP denial.

Testing

Six new tests. Every clause of the change is load-bearing under mutation — each row was verified by injecting that regression and watching the named tests fail (all green when restored):

Mutation Killed by
revert gate to the URL check (main's behaviour) resumes auto-login; settled-callback-drives-auto-login
drop the "denied" clause 3 tests (denial, throwing denial, budget)
remove the retry ceiling budget-spent test
classify by resolve/throw instead of auth state throwing-denial; budget-spent
drop the idle && narrowing resumes auto-login; settled-callback

The original four also fail wholesale against origin/main's auth-context.tsx. build / type-check / lint / fmt:check pass; 1585 tests pass.

References

🤖 Generated with Claude Code

auth-public-client cleans the callback parameters out of the URL only when
the code exchange succeeds. Every failure path — the authorization server
returning an error, a state mismatch, a missing PKCE verifier, a failed
exchange — returned or threw with ?code= or ?error= still in the query
string. attemptAutoLogin read that URL to decide whether a callback was in
progress, so it treated the page as a live callback forever: auto-login
never fired again, the app sat on guardComponent, and reloading only
replayed the same failing callback. Editing the URL by hand was the only
way out.

Clear the parameters on every outcome, preserving unrelated query
parameters and the hash, via replaceState so the back button cannot replay
the failed callback either. Decide auto-login from the callback's status
rather than the URL, which is what the check meant all along — "is an
exchange in flight", not "has this page ever been a callback". The URL is
still consulted while the status is idle, where it means nobody has
claimed these parameters and redirecting would be unsafe.

A callback the server explicitly refuses becomes its own status, "denied",
which deliberately does not re-initiate login: bouncing back would ask the
same question, get the same answer, and loop — worse than the dead end it
replaced. The error stays on useAuth().error, the URL is still cleaned,
and a deliberate reload retries. A state mismatch or failed exchange does
resume auto-login, being the recoverable case.

Each of the four new tests was verified to fail against the unfixed code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@interacsean

Copy link
Copy Markdown
Contributor Author

/review

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Code Review completed successfully!

Code review complete for PR #468 (fix/auth/callback-error-dead-end). No inline review comments were needed — the implementation is sound with no High or Medium issues. The only finding is a Low-severity note that the "denied" callback status is sticky within a client lifetime, which is acceptable per the PR's stated design. Verdict: Approve.

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Code Metrics Report

main (41ae0e3) #468 (594eaa9) +/-
Coverage 90.1% 90.4% +0.2%
Test Execution Time 2m5s 1m59s -6s
Details
  |                     | main (41ae0e3) | #468 (594eaa9) |  +/-  |
  |---------------------|----------------|----------------|-------|
+ | Coverage            |          90.1% |          90.4% | +0.2% |
  |   Files             |            152 |            152 |     0 |
  |   Lines             |           5201 |           5228 |   +27 |
+ |   Covered           |           4690 |           4727 |   +37 |
+ | Test Execution Time |           2m5s |          1m59s |   -6s |

Code coverage of files in pull request scope (85.9% → 95.7%)

Files Coverage +/- Status
packages/core/src/contexts/auth-context.tsx 95.7% +9.7% modified

Reported by octocov

interacsean and others added 3 commits August 27, 2026 13:48
Adversarial review of this PR found the first cut traded the wedge for a
redirect loop on exactly the paths it called recoverable.

Bound the retries. A failed callback resumes auto-login, but every attempt
is a full-page redirect to the authorization server, so a deterministic
failure — evicted or blocked browser storage, a misconfigured client, two
tabs overwriting each other's single-slot PKCE verifier — looped forever.
Recoverable failures now get one retry per tab, counted in sessionStorage
because each attempt is a fresh page load, after which the callback is
terminal. Unreadable storage counts as exhausted: without somewhere to
count, the ceiling cannot be enforced, and looping is the worse failure.

Classify the outcome from the resulting auth state rather than from
whether the callback threw. Two failure paths return normally after
recording the error — the server's `error` branch and a state mismatch —
so resolve-vs-throw let both skip the ceiling. It also fixes a denial that
arrives as a throw (the error branch clears OAuth temp data first, which
opens IndexedDB and can reject) being misread as retryable, which put it
straight into the loop the denied state exists to prevent.

Preserve history.state when stripping the parameters. Passing a fresh {}
discards what routers keep there — react-router's {usr, key, idx} and
Next.js's __NA — desyncing both for the rest of the session.

Document the terminal case: the guard is where a failed sign-in becomes
visible, and the guard in our own docs only rendered a spinner.

Tests for the retry ceiling, the throwing denial, history preservation,
and the auto-login gate's idle narrowing, which had no coverage. Each was
verified to fail against the code it guards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scope decision: app-shell should not reimplement upstream's callback URL
cleanup. auth-public-client owns it and performs it only on success — a
long-standing defect present since 0.3.0, now filed as
tailor-platform/auth-public-client#139 together with the replaceState
history.state clobbering.

What remains here is app-shell's own logic error and its consequences:
the auto-login gate read the URL to mean "callback in progress", which a
failed callback made permanently true. The gate now reads the callback's
settled status (the URL stays authoritative only while no callback has
been claimed), an explicit server refusal is terminal, and recoverable
failures get one retry per tab.

Removes clearOAuthCallbackParams and the history.state preservation —
both belong upstream — along with their three tests. The stale parameters
now remain in the URL after a failure until #139 is fixed; app-shell no
longer misbehaves because of them.

With the strip gone the gate is load-bearing, and the mutation results
show it: reverting the gate to the URL check now fails two tests, where
previously the strip masked it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lychee in doc-check runs unauthenticated and 404s on the private
auth-public-client repo, failing link-check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@interacsean
interacsean marked this pull request as ready for review August 28, 2026 05:54
@interacsean
interacsean requested a review from a team as a code owner August 28, 2026 05:54
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.

1 participant