Show OAuth callback failures in the UI instead of dropping them - #864
Show OAuth callback failures in the UI instead of dropping them#864henry-dowling wants to merge 1 commit into
Conversation
A failed OAuth callback redirected to /settings?integration_error=<provider>, but nothing in the frontend read that param — the browser bounced through the provider and back in under a second and the failure was invisible, reading as a dead Connect button. Frontend: SourceConnectorList reads the param on mount, shows a banner above the connector cards, scrolls it into view (the Sources section sits below the fold on /settings), and strips the params from the URL. Unknown provider slugs render a generic message — the URL-controlled value is never echoed. Backend: consent denials (?error=access_denied), callbacks missing code/state, and invalid/expired state now redirect with integration_error too, instead of stranding the browser on raw 422/400 JSON on the API host. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
2 clusters identified |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
ischindl
left a comment
There was a problem hiding this comment.
Review: PR #864 — Show OAuth callback failures in the UI instead of dropping them
Author: Henry Dowling
Files: 4 files (+250 / -15)
Reviewed against project standards (AGENTS.md + 12 rules)
Verdict: APPROVED
Standards compliance
-
No backwards compatibility / fallbacks — OAuth callback params (code, state) went from required to optional; the new error param is additive. The old required-param behavior would have 422'd — the PR intentionally converts that to a UI-friendly redirect instead. No compatibility shims, no dual code paths, no fallback defaults.
-
No excessive try/catch — Zero new try/except blocks added. The old
except HTTPException: raisebecomesexcept HTTPException: redirect, repurposing an existing handler — no new catch surface. -
Simple, readable code — The
_error_redirect()helper deduplicates redirect-URL construction. The frontend useEffect parses params, sets state, and strips from URL in a single pass. ThecallbackErrorsplit fromerroris justified by lifecycle differences (refresh() clears error but not callbackError). -
Tests verify intent — Backend: 3 tests assert correct 302 + location for each failure mode (consent denial, missing code, invalid state). Frontend: 5 tests cover banner visibility, access_denied wording, content-spoofing guard, param preservation, and no-param-no-banner.
-
Match codebase conventions — Uses existing
connectorForProvider(), existing error-banner styling, existing pytest/AsyncClient patterns.
Specific observations
- Content-spoofing guard is correct: unrecognized slugs never echo into DOM text.
- URL stripping uses replaceState with [] deps, so refresh doesn't resurrect.
- Old bottom banner correctly removed — consolidated above cards, fixing the below-the-fold invisibility.
- scrollIntoView dependency is justified: the banner renders in the 4th settings section.
Concerns
None. Minimal, correct, well-tested.
Structured outcome
files=[backend/integrations/router.py, backend/tests/test_integration_callback_security.py, frontend/src/components/integrations/SourceConnectorList.tsx, frontend/src/components/integrations/SourceConnectorList.test.tsx], violations={fallbacks:0, trycatch:0, readability:0, test_intent:0, conventions:0}, localized_only=yes
Problem
When an OAuth token exchange failed, the backend redirected to
/settings?integration_error=<provider>&reason=connection_failed— but no frontend code consumed that param. Because a re-authorization on an already-approved app auto-redirects instantly, the whole failed round-trip (settings → provider → callback → settings) took under a second and looked exactly like a dead Connect button. Worse, the two most common real failures never emitted the param at all: cancelling on the consent screen returned a raw FastAPI 422 JSON page on the API host (missing requiredcodeparam), and an expired state (consent tab left open past the 10-minute TTL) returned bare 400 JSON.What changed
Frontend (
SourceConnectorList):integration_error+reasonon mount and shows an error banner above the connector cards (the old error slot rendered below ~10 cards, under the fold)./settings, below the fold on typical viewports.reason=access_denied("authorization was cancelled") vs exchange failures.Backend (
integrations/router.py):code/stateare now optional; a consent denial (?error=access_denied, no code — RFC 6749 §4.1.2.1) or missing params redirects withintegration_errorinstead of 422 JSON.Screenshot
Banner on
/settingsafter a failed GitHub connect (captured live in dev): https://app.joinstash.ai/f/7a11943b-2ed0-4b0e-83fe-64fefad3e150Tests
test_integration_callback_security.py8/8 — new: consent denial → 302reason=access_denied, missing code → 302, invalid state → 302.test_github_index_success_logs_internal_source_id_only) reproduces on pristine main with this change stashed — pre-existing, unrelated.ruff check/formatclean; eslint 0 errors;tscclean in changed files.🤖 Generated with Claude Code