Skip to content

Auto-redirect dashboard to login on 401 instead of a dead-end error#75

Merged
mk7luke merged 1 commit into
mainfrom
fix/dashboard-unauthorized-redirect
Jul 7, 2026
Merged

Auto-redirect dashboard to login on 401 instead of a dead-end error#75
mk7luke merged 1 commit into
mainfrom
fix/dashboard-unauthorized-redirect

Conversation

@mk7luke

@mk7luke mk7luke commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Problem

When a dashboard session expired, the web SPA's /api/v1/* calls came back 401 and the UI just painted an "Authentication required" error card with no way to re-auth. The server only redirected to login for the legacy /dashboard SSR pages — never for the SPA at / — so the only escape was manually visiting /dashboard, signing out, then signing back in. Terrible UX.

Fix

1. web/src/api/client.ts — auto-redirect on 401. Centralized in the apiGet/apiSend fetch wrapper so every API call is covered: a 401 now bounces the whole page to /dashboard/auth/login?next=<current page>, which 302s to GitHub OAuth. Guards:

  • Skips demo mode and public /public/* share reads (no-auth, served before the auth gate — a public viewer is never hijacked into a GitHub login).
  • One-shot flag so a screen firing many parallel queries redirects once, not N times.
  • A 401 on a cookie-session request unambiguously means "session expired" (the server only 401s when OAuth is enabled), so this never fires spuriously in open mode.

2. src/dashboard/auth.ts — return you to where you were. The OAuth next param was hard-restricted to startsWith("/dashboard"), so post-login you'd land on the legacy dashboard instead of the SPA page you left. Replaced with an isSafeReturnPath() guard that accepts any same-origin absolute path (SPA routes and legacy) while rejecting open-redirects (//evil.com, /\evil.com, absolute URLs). Applied at both the login entry and the OAuth callback.

Verification

  • Server + web typecheck: clean.
  • Full unit suite: 290/290 passed.
  • smoke:dashboard (real auth flow): passed, including the unauthenticated→login redirect.
  • Focused test against the real auth routes (decoding the signed OAuth state cookie): confirmed /overview and /repos/acme/checkout-api/pr/142 now round-trip, /dashboard/settings still works, and //evil.com / /\evil.com / https://evil.com all fall back to /dashboard.

Note: the client-side redirect was verified via typecheck + logic review; it needs a real 401 from an OAuth-enabled instance to observe live in a browser.

🤖 Generated with Claude Code


Summary

Fixed the dashboard auth flow so SPA API requests now redirected users to login on 401 instead of leaving them on a dead-end error state. Expanded post-login return path handling to safely restore both SPA and legacy dashboard routes.

Changes

File Changes
src/dashboard/auth.ts Added shared validation for safe return paths and used it in both login initiation and OAuth callback redirects so users can return to SPA or legacy dashboard routes without enabling open redirects.
web/src/api/client.ts Added centralized 401 handling in the API client to redirect the browser to the dashboard login flow, with guards for demo mode, SSR/tests, public share routes, and duplicate redirects.

When a dashboard session expired, the SPA's /api/v1 calls came back 401
and the UI painted an "Authentication required" card with no way to
re-auth — the only escape was manually visiting /dashboard, signing out,
then signing back in.

- web/src/api/client.ts: on a 401 from apiGet/apiSend, bounce the whole
  page to /dashboard/auth/login?next=<current page> (which 302s to GitHub
  OAuth). Centralized in the fetch wrapper so every API call is covered.
  Skips demo mode and public /public/* share reads (no-auth, served before
  the auth gate); a one-shot flag redirects once when a screen fires many
  parallel queries.
- src/dashboard/auth.ts: broaden the OAuth `next` guard from
  startsWith("/dashboard") to isSafeReturnPath() so SPA routes (served at
  /) round-trip back to where the user was, while still rejecting
  open-redirects (//evil.com, /\evil.com, absolute URLs).

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

diffsentry Bot commented Jul 7, 2026

Copy link
Copy Markdown

💬 DiffSentry has completed the review — Review complete with comments

@diffsentry

diffsentry Bot commented Jul 7, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Fixed the dashboard auth flow so SPA API requests now redirected users to login on 401 instead of leaving them on a dead-end error state. Expanded post-login return path handling to safely restore both SPA and legacy dashboard routes.

Changes

Cohort / File(s) Summary
OAuth Return Safety
src/dashboard/auth.ts
Introduced a safe same-origin path validator for the login next parameter and applied it consistently when starting and completing OAuth login. This preserved return-to behavior for SPA routes while continuing to block open redirect payloads.
SPA Reauth Redirect
web/src/api/client.ts
Centralized 401 handling in the web API wrapper so expired cookie sessions now send users through the login flow automatically. The redirect logic avoided SSR, demo mode, public share pages, and repeated navigations from concurrent requests.

Sequence Diagram(s)

sequenceDiagram
    participant SPA
    participant API as /api/v1
    participant Auth as /dashboard/auth/login
    participant OAuth as GitHub OAuth
    SPA->>API: fetch protected resource
    API-->>SPA: 401 unauthorized
    SPA->>Auth: navigate with next=current route
    Auth-->>OAuth: redirect to provider
    OAuth-->>Auth: callback with auth code
    Auth-->>SPA: redirect back to saved route
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~20 minutes

Suggested Labels

bug, enhancement, security

Risk Assessment

Score: 17/100 — 🟡 Moderate

Factor Weight Detail
High-risk paths touched +7 src/dashboard/auth.ts
No new tests +10 Production code added/changed without accompanying tests

Test Coverage Signal

🔴 47 line(s) of production code added with no test changes.

Source files changed Test files changed Source lines + Test lines +
2 0 47 0

🧭 Description Drift

  • ℹ️ Description mentions “legacy /dashboard SSR pages” redirect behavior, but no server-side SSR redirect logic changed in this PR
    • The only server-side auth changes are in src/dashboard/auth.ts via isSafeReturnPath() and using it in /auth/login and the OAuth callback. There is no diff touching any existing unauthorized redirect behavior for legacy /dashboard pages, so that background context is not reflected in changed code.
  • ℹ️ Verification claims about tests/smoke runs are not represented in the diff because no tests were added or modified
    • The PR description cites focused auth-route testing, signed OAuth state-cookie decoding, and smoke:dashboard passing, but there are no test file changes in the diff. This is not necessarily wrong, just unsupported by code changes shown here.

Compares PR description claims to the actual diff. Update the description (or the code) so they tell the same story.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
PR Description ⚠️ Warning The description satisfies the requirement to explain both WHAT changed and WHY: it describes adding SPA-side 401 auto-redirects and broadening safe next handling, and explains this fixes the dead-end expired-session UX. However, it does not include a related issue or PR link, which is required if applicable. Address before merging or downgrade to non-blocking.
✅ Passed checks (4 passed)
Check name Status Explanation
PR Title ✅ Passed Meets all requirements: starts with imperative verb 'Auto-redirect', is 59 characters long, has no trailing period, and does not use a Conventional Commits prefix.
Schema bump ✅ Passed src/storage/db.ts was not changed in this PR. Only src/dashboard/auth.ts and web/src/api/client.ts were modified, so the schema-version check is not applicable.
Provider parity ✅ Passed src/ai/anthropic.ts was not modified in this PR. The changes are limited to src/dashboard/auth.ts and web/src/api/client.ts, so there is no request/response contract change requiring corresponding updates to openai.ts or openai-compatible.ts.
Pattern test coverage ✅ Passed No changes in src/safety-scanner.ts or src/pattern-checks.ts in this PR, so the scenario-coverage check does not apply.

✏️ Tip: You can configure your own custom pre-merge checks in your .diffsentry.yaml.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
📝 Generate docstrings (beta)
  • Push docstring commit to this branch
🧹 Simplify (beta)
  • Push simplification commit to this branch
🪄 Autofix unresolved comments (beta)
  • Push autofix commit to this branch

Comment @diffsentry help to get the list of available commands and usage tips.

@diffsentry diffsentry Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 0

This change cleanly fixes the expired-session dead end by centralizing 401 handling in the SPA client and broadening post-login return paths without opening obvious external redirects. The server-side isSafeReturnPath() guard is a good improvement, but the client-side redirect currently builds the login URL via string concatenation against an unvalidated window.location value, which can be tripped up by unusual path forms and is safer to construct with URL/URLSearchParams.

🧹 Nitpick comments (1)
web/src/api/client.ts (1)

Line 56: Build the login redirect URL with URL APIs instead of manual string concatenation.

🛠️ Refactor suggestion | 🟡 Minor

Build the login redirect URL with URL APIs instead of manual string concatenation.

redirectToLogin() currently interpolates returnTo directly into a path string. While encodeURIComponent() protects the query value, constructing navigation targets by concatenation is brittle around edge-case path forms and makes the intended same-origin redirect semantics less explicit.

Using new URL() plus searchParams.set() keeps the redirect target normalized and avoids accidental malformed URLs if the login route or next handling changes later. It also makes the code consistent with the rest of this file, which already relies on URL primitives for request construction.

🔧 Proposed fix
function redirectToLogin(path: string): void {
  // Nothing to redirect in SSR/tests or demo mode. Public reads (/public/*) are
  // served BEFORE the auth gate, so a 401 there isn't a session problem — never
  // hijack the no-auth share viewer into a GitHub login.
  if (typeof window === "undefined" || DEMO || path.startsWith("/public/")) return;
  // A screen fires many queries at once; the first 401 wins the redirect and the
  // rest are no-ops, so we never stack navigations.
  if (redirectingToLogin) return;
  redirectingToLogin = true;
  const returnTo = window.location.pathname + window.location.search + window.location.hash;
  const loginUrl = new URL("/dashboard/auth/login", window.location.origin);
  loginUrl.searchParams.set("next", returnTo);
  window.location.assign(loginUrl.toString());
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In web/src/api/client.ts around line 56, change `redirectToLogin()` to construct the `/dashboard/auth/login` navigation target with `new URL()` and `searchParams.set("next", returnTo)` instead of manual string concatenation; keep the existing `redirectingToLogin` guard and `/public/*` bypass logic intact. Preserve the exact `returnTo` composition from `window.location.pathname + window.location.search + window.location.hash`.
🧠 Prior discussions on this file
  • #65 (web/src/api/client.ts:38): 🛠️ Refactor suggestion | 🟡 Minor
  • #65 (web/src/api/client.ts:38): Looks good to me.
♻️ Suggested fix
function redirectToLogin(path: string): void {
  // Nothing to redirect in SSR/tests or demo mode. Public reads (/public/*) are
  // served BEFORE the auth gate, so a 401 there isn't a session problem — never
  // hijack the no-auth share viewer into a GitHub login.
  if (typeof window === "undefined" || DEMO || path.startsWith("/public/")) return;
  // A screen fires many queries at once; the first 401 wins the redirect and the
  // rest are no-ops, so we never stack navigations.
  if (redirectingToLogin) return;
  redirectingToLogin = true;
  const returnTo = window.location.pathname + window.location.search + window.location.hash;
  const loginUrl = new URL("/dashboard/auth/login", window.location.origin);
  loginUrl.searchParams.set("next", returnTo);
  window.location.assign(loginUrl.toString());
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In web/src/api/client.ts around line 56, change `redirectToLogin()` to construct the `/dashboard/auth/login` navigation target with `new URL()` and `searchParams.set("next", returnTo)` instead of manual string concatenation; keep the existing `redirectingToLogin` guard and `/public/*` bypass logic intact. Preserve the exact `returnTo` composition from `window.location.pathname + window.location.search + window.location.hash`.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

In `web/src/api/client.ts`:
- Line 56: In web/src/api/client.ts around line 56, change `redirectToLogin()` to construct the `/dashboard/auth/login` navigation target with `new URL()` and `searchParams.set("next", returnTo)` instead of manual string concatenation; keep the existing `redirectingToLogin` guard and `/public/*` bypass logic intact. Preserve the exact `returnTo` composition from `window.location.pathname + window.location.search + window.location.hash`.
🪄 Autofix (Beta)

Fix all unresolved DiffSentry comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: .diffsentry.yaml

Review profile: ASSERTIVE

Run ID: 41980526-699e-4502-a905-0cc58a9dd040

📥 Commits

Reviewing files at 537a01a (base SHA unavailable).

📒 Files selected for processing (2)
  • src/dashboard/auth.ts
  • web/src/api/client.ts

Comment thread web/src/api/client.ts
@diffsentry

diffsentry Bot commented Jul 7, 2026

Copy link
Copy Markdown

📌 Status — last updated 537a01a

🟡 Comments only

Risk score 17/100 (Moderate) ▁
Unresolved threads 1
Failing checks 0
Pending checks 1
Files reviewed 2
Updated 2026-07-07 18:18Z

Live-updated by DiffSentry on every push. Use @diffsentry ship for a verdict, @diffsentry timeline for full history.

@mk7luke mk7luke merged commit 471a337 into main Jul 7, 2026
4 checks passed
@mk7luke mk7luke deleted the fix/dashboard-unauthorized-redirect branch July 7, 2026 18:31
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