fix(auth): recover from a failed OAuth callback instead of wedging - #468
Open
interacsean wants to merge 4 commits into
Open
fix(auth): recover from a failed OAuth callback instead of wedging#468interacsean wants to merge 4 commits into
interacsean wants to merge 4 commits into
Conversation
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>
Contributor
Author
|
/review |
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. |
Contributor
Code Metrics Report
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%)
Reported by octocov |
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
marked this pull request as ready for review
August 28, 2026 05:54
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
attemptAutoLoginread 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:autoLoginredirects to the IdP?error=access_denied(or the exchange throws — e.g. a clobbered PKCE verifier)guardComponentforever; reloading replays the same failing callbackThe 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:
"denied"status — bouncing back would ask the same question and get the same answer. The error stays onuseAuth().error; the docs now show a guard that renders it with a retry.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.errorbranch and a state mismatch both return normally after recording the error, so settlement alone misclassifies them — and a denial that arrives as a throw (theerrorbranch touches IndexedDB first, which can reject) must still be terminal.What this deliberately does not do
history.statepreservation) and has been backed out.createAuthClientappears nowhere underexamples/). 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):
main's behaviour)"denied"clauseidle &&narrowingThe original four also fail wholesale against
origin/main'sauth-context.tsx.build/type-check/lint/fmt:checkpass; 1585 tests pass.References
🤖 Generated with Claude Code