Skip to content

fix(auth): give every OAuth callback failure a way back - #70

Merged
guarzo merged 2 commits into
mainfrom
worktree-login-harden
Aug 4, 2026
Merged

fix(auth): give every OAuth callback failure a way back#70
guarzo merged 2 commits into
mainfrom
worktree-login-harden

Conversation

@guarzo

@guarzo guarzo commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Follow-up to #64. The critique credited src/app/error.tsx with covering the callback failure paths; it doesn't. error.tsx is a React error boundary for page and layout rendering and never wraps a route handler, so every throw in either OAuth callback reached the member as a bare Next 500 with no navigation back.

Alongside it, six paths returned text/plain and a status code:

missing params                              400
invalid or expired state                    400
link transaction not valid for this session 403

Three per callback. Leave a login tab open for eleven minutes and you got invalid or expired state on a white page.

What changed

Both callbacks redirect on every failure now.

Was EVE callback Discord callback
missing params 400 /login?error=oauth_failed /account?error=discord_failed
invalid or expired state 400 /login?error=oauth_expired /account?error=discord_expired
session mismatch 403 /account?error=link_expired, or /login?error=session_expired with no session /account?error=discord_expired, or /login?error=session_expired
(unhandled throw → 500) /account?error=link_failed or /login?error=oauth_failed /account?error=discord_failed

/account also distinguishes a dead session cookie from no cookie, so a first-time visitor is never told a session they never had has ended. The four admin pages keep their bare redirects on purpose: getAdminContext() returns null for a signed-in non-admin too (src/lib/admin-guard.ts:10-37), so they can't tell expiry from insufficient privilege and shouldn't guess.

Where to look

Replay safety. The transaction is consumed before the session-mismatch branch, so redirecting there can't hand anyone a replayable state. tests/auth-routes.test.ts now asserts the replay lands on the same destination as any unusable state and issues no second session cookie.

The catch logs .message, not err. Half this repo logs the error object (worker/dispatcher.ts:129), half logs .message (services/health.ts:11). .message is right here: EveSsoError is clean, but a Postgres error from the surrounding transaction can carry the failing query and its parameters on sibling properties, and those rows hold refresh tokens. Losing the stack is the deliberate cost.

Monitoring. Turning 4xx into 302 would blind a status-code dashboard — there isn't one, and these two console.error lines are the first observability these paths have ever had. docs/ops.md now records which codes log and which are normal background.

Verification

  • npm run typecheck — clean
  • npm test — 52 files, 431 tests passed (430 before; the new ESI-failure test is the addition)
  • npm run test:e2e60 passed, including 4 new /login specs
  • npm run format:check — clean
  • npm run lint — 0 errors, 5 pre-existing no-img-element warnings

Not done

No stylelint — CSS is still unlinted, which is how the deprecated word-break slipped through in #64. Worth a separate PR. The dated plan doc under docs/superpowers/plans/ still shows the old 400s and was left as the historical record it is.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 6 seconds

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8c2d0c65-62a6-4156-95ea-71d038039858

📥 Commits

Reviewing files that changed from the base of the PR and between 00769ae and 725f990.

📒 Files selected for processing (11)
  • docs/ops.md
  • e2e/account.spec.ts
  • e2e/admin.spec.ts
  • src/app/account/page.tsx
  • src/app/auth/discord/callback/route.ts
  • src/app/auth/eve/callback/route.ts
  • src/app/login/page.tsx
  • src/lib/admin-guard.ts
  • tests/admin-guard.test.ts
  • tests/auth-routes.test.ts
  • tests/discord-link.test.ts

Comment @coderabbitai help to get the list of available commands.

@guarzo
guarzo force-pushed the worktree-login-harden branch from eeb03e9 to 61b992a Compare August 4, 2026 11:59
@guarzo

guarzo commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto main after #63/#66/#67 landed. The conflict in src/app/auth/eve/callback/route.ts was mechanical (adopted sessionCookieAttrs from #67), but #67 landed on the same ground as this PR's session-expiry work, so two things changed substantively.

A comment in this PR became false. It said the admin pages couldn't tell a dead session from a non-admin, so they kept bare redirects. #67 made exactly that distinction. Comment corrected to point at resolveAdmin instead.

A second commit fixes a defect #67 introduced (fix(admin): don't tell a first-time visitor their session ended). resolveAdmin returned "no-session" for two different things — no cookie at all, and a cookie that no longer resolves — and denyAdmin sent both to /login?error=session_expired. So anyone who typed /admin without ever signing in was told a session they never had had ended. That's the same defect this PR exists to fix, on five new paths, which is why I fixed it here rather than filing it.

The union now has a third member, session-expired, and an absent cookie gets the plain login page. Worth noting that #67's denyAdmin doc comment predicted this: it says adding a member should fail to compile rather than silently fall through to the login redirect. It did, which is the first time that guard has actually fired. The e2e test at admin.spec.ts:46 asserted the buggy behaviour and is now two tests, one per case.

Re-verified against the new base, all output read:

  • npm run typecheck — clean
  • npm test — 52 files, 433 passed (up from 431: the no-cookie / dead-cookie split)
  • npm run test:e2e68 passed (up from 60: feat(shell): tell the admin denials apart, and give members a way out #67 added shell.spec.ts, plus the admin split above)
  • npm run format:check — clean
  • npm run lint — 0 errors, 5 pre-existing no-img-element warnings

guarzo added 2 commits August 4, 2026 08:00
Six failure paths across the two callbacks returned text/plain with a
status code and nothing else, and both callbacks had unwrapped throws.
app/error.tsx does not cover route handlers, so an ESI or Discord blip
during sign-in reached the member as a bare 500 with no navigation.

Every failure now redirects to a page that names what happened:
expired-or-forged state, provider unreachable, and session-gone are
distinguished, and the catch logs err.message (not the error object,
which for a Postgres failure can carry query parameters from rows that
hold refresh tokens).

/account also distinguishes a dead session cookie from no cookie, so a
first-time visitor is never told a session they never had has ended.
The admin pages keep bare redirects: getAdminContext() returns null for
a signed-in non-admin too, so they cannot tell the two apart.
covering two things: a visitor with no cookie at all, and one whose
cookie no longer resolves. Both redirected to
/login?error=session_expired, so anyone who typed /admin without ever
signing in was told a session they never had had ended.

resolveAdmin now returns session-expired only for a cookie that fails
to resolve; an absent cookie gets the plain login page. This is the
same line account/page.tsx draws, and the first time denyAdmin's
exhaustive switch has actually caught an added union member.
@guarzo
guarzo force-pushed the worktree-login-harden branch from 61b992a to 725f990 Compare August 4, 2026 12:04
@guarzo
guarzo merged commit b35d6f1 into main Aug 4, 2026
6 checks passed
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