fix(login): handle AUTH-004 ?signed_in=1 cookie-exchange flow (P0)#150
Merged
Conversation
**Symptom:** OAuth + magic-link sign-in broken in prod since 2026-05-29. Click "Continue with GitHub" → GitHub auth → redirect lands at `https://instanode.dev/login/callback/?signed_in=1` showing "Sign-in failed. No session token in callback URL." **Root cause (classic rule-22 violation):** api PR #176 (`f176c40`, 2026-05-29) shipped AUTH-004 — a real P0 security fix that moved the session JWT out of the redirect URL (where it leaks via Referer + access logs) into an HttpOnly cookie scoped to /auth/exchange. The server-side change documented the required SPA companion in the api code comment block (auth.go:186-194) but the companion `instanode-web` PR was never written. Server changed contract; client kept reading `session_token=` and bailed when absent. Existing localStorage-backed sessions kept working (Bearer auth unchanged); ALL new OAuth + magic- link logins broke silently. **Fix:** LoginCallbackPage now branches on: - ?session_token=<jwt> (legacy URL token) — kept for backwards-compat with any older flows that still URL-deliver - ?signed_in=1 (AUTH-004) — POST {apiBase}/auth/exchange with credentials:include, read {token} from response, setToken, fetchMe, navigate as before The POST sends the HttpOnly cookie cross-origin (api.instanode.dev → instanode.dev), api returns the JWT in the response body, and the SPA proceeds with the same Bearer-auth shape it always used. Cookie is Max-Age=30s and single-use (api deletes on success), so the bridge window can't be replayed. If signed_in=1 AND session_token are both present, legacy wins (no needless /auth/exchange POST). **Tests:** 11 (was 6). 5 new cases cover the AUTH-004 path: - happy path (POST /auth/exchange, credentials:include, token in body) - exchange returns non-2xx JSON → surface api message - exchange returns 2xx empty body → surface "no token" error - exchange returns non-2xx non-JSON → surface status line - both query params present → legacy wins `npm run gate` clean locally (tsc + build + vitest). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous commit landed the tests but the Write to LoginCallbackPage.tsx silently failed in the dev harness. This commit adds the actual code: exchangeCookieForToken() helper + signed_in=1 branch + getAPIBaseURL import. Tests in the previous commit now pass against this code. `npm run gate` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vitest's vi.fn() with no signature returns Mock<[], unknown>; reading mock.calls[0][0] / [0][1] under TS strict (noUncheckedIndexedAccess + strict tuple types) raises TS2493. Give the spy a typed signature matching `fetch(input, init?)` so the destructuring works without casts. `npm run gate` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
size-limit report 📦
|
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.
Summary
P0: prod login broken since 2026-05-29. OAuth + magic-link sign-in lands on
/login/callback/?signed_in=1showing "Sign-in failed. No session token in callback URL."Root cause (rule-22 violation)
api PR
instanodedev/api#176(commitf176c40, 2026-05-29) shipped AUTH-004 — a real P0 security fix that moved the session JWT out of the redirect URL (where it leaks viaRefererheader + server-access logs) into an HttpOnly cookie scoped to/auth/exchange. The server-side change explicitly documented the required SPA companion in api/internal/handlers/auth.go:186-194:The dashboard companion was never written. The api PR merged solo. Old localStorage-backed sessions kept working (Bearer auth unchanged). ALL new OAuth + magic-link logins broke silently because the URL just had
?signed_in=1and the page only knew how to read?session_token=<jwt>.This is exactly the failure mode CLAUDE.md rule 22 was built to prevent: contract changes must touch all surfaces in one PR.
Fix
LoginCallbackPage now handles BOTH markers:
?session_token=<jwt>(legacy URL token) — kept for backwards-compat with alternate flows?signed_in=1(AUTH-004) — POST${apiBase}/auth/exchangewithcredentials: 'include', read{token}from response,setToken(),fetchMe(), navigate as beforeBehavior:
signed_in=1: cookie-exchange pathsession_token: legacy path/auth/exchangePOST)Test coverage block
Symptom:
/login/callback/?signed_in=1→ "No session token in callback URL"Enumeration:
rg -nF 'session_token' src/pages/(3 hits — all in LoginCallbackPage + tests)Sites found: 1 (LoginCallbackPage.tsx)
Sites touched: 1
Coverage test: 11 unit tests on LoginCallbackPage (was 6); 5 new cases specifically exercise the AUTH-004 path:
credentials: 'include', token in body)Live verified: after merge — will navigate to /login, click GitHub, confirm callback page navigates to /app (chrome devtools MCP).
Follow-up not in this PR
The rule-22 gap is structural: api shipped a URL-query contract change but the dashboard test surface had no mechanism to detect the mismatch. Future work: add a registry-iterating contract test (api side: assert each callback's redirect Location query matches one of
{signed_in=1, session_token=<jwt>}; web side: assert LoginCallbackPage handles every marker the registry knows about). Tracked separately so this PR can ship the user-visible fix now.🤖 Generated with Claude Code