Skip to content

Add browser-based session auth bootstrap#1458

Draft
t3dotgg wants to merge 4 commits intomainfrom
t3code/browser-auth-approval
Draft

Add browser-based session auth bootstrap#1458
t3dotgg wants to merge 4 commits intomainfrom
t3code/browser-auth-approval

Conversation

@t3dotgg
Copy link
Copy Markdown
Member

@t3dotgg t3dotgg commented Mar 27, 2026

Summary

  • Add browser-first authentication for web sessions using a one-time bootstrap token and a signed auth cookie.
  • Require an allowed browser origin for bootstrap, session checks, and WebSocket upgrades, while preserving the legacy token path for non-browser clients.
  • Update server startup to generate and open a pairing URL, and default web mode to a fixed loopback host for predictable local auth behavior.
  • Add client-side bootstrap/session flow so the web app can pair, clear the bootstrap hash, and reuse the authenticated browser session.
  • Expand server and web test coverage for origin checks, token consumption, cookie issuance, and session detection.

Testing

  • Not run (PR summary only).
  • Existing test coverage was added/updated for browser bootstrap, session auth, and WebSocket origin enforcement.
  • Repository-required checks to run before merge: bun fmt, bun lint, bun typecheck.

Note

High Risk
Introduces new browser authentication and origin enforcement for HTTP endpoints and WebSocket upgrades; mistakes here could lock users out or weaken access controls. Also changes default host/browse-open behavior, which affects startup and connectivity assumptions.

Overview
Adds a browser-first auth flow: the server now generates a one-time bootstrap token, exchanges it via a new POST /api/auth/bootstrap endpoint for a signed HttpOnly auth cookie, and exposes GET /api/auth/session for session checks (with CORS restricted to an allowed-origin set).

Updates WebSocket upgrades to require an allowed Origin plus a valid browser auth cookie, while keeping the legacy ?token= path for non-browser clients. Server startup now logs and auto-opens a pairing URL (token in hash) and defaults web mode host to 127.0.0.1 for predictable loopback pairing.

On the web client, adds ensureBrowserPairing() to consume/clear the bootstrap hash, perform the bootstrap exchange, and gate app rendering behind pairing (showing a “pairing required” screen when unauthenticated); attachment URL origin resolution is centralized via resolveServerHttpOrigin(). Tests are expanded across server and web to cover token consumption, origin checks, cookie issuance, session detection, and WebSocket rejection cases.

Written by Cursor Bugbot for commit e980a2f. This will update automatically on new commits. Configure here.

Note

Add browser-based session auth bootstrap to the server and web app

  • Adds a BrowserAuth Effect service (browserAuth.ts) that manages single-use bootstrap tokens, signs/verifies auth cookies with HMAC-SHA256, and tracks allowed origins derived from server config.
  • Exposes two new HTTP endpoints in wsServer.ts: POST /api/auth/bootstrap (exchanges a bootstrap token for a signed cookie) and GET /api/auth/session (checks auth status), both with CORS enforcement.
  • WebSocket upgrade now requires a valid allowed Origin header plus a valid auth cookie, with backward-compatible fallback for legacy authToken query-param clients that omit Origin.
  • The server startup logs a pairingUrl (bootstrap token embedded in the URL hash) and opens the browser to it; if the host is non-loopback it also logs a warning.
  • The web app (main.tsx) gates routing behind a BootstrappedApp component that exchanges the hash token and confirms auth before rendering, showing a pairing-required screen when unauthenticated.
  • Behavioral Change: the server now defaults to 127.0.0.1 in both desktop and web modes (previously web mode defaulted to an unbound host).
📊 Macroscope summarized e980a2f. 9 files reviewed, 4 issues evaluated, 0 issues filtered, 2 comments posted

🗂️ Filtered Issues

- Add bootstrap-token pairing flow and signed auth cookies
- Require browser origin/cookie checks for webSocket and session routes
- Update web client to exchange pairing tokens before connecting
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 27, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 1c692443-6ae9-468e-b977-350344064dd4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/browser-auth-approval

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

@github-actions github-actions bot added size:XL 500-999 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Mar 27, 2026
@t3dotgg t3dotgg marked this pull request as draft March 27, 2026 06:57
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 5 potential issues.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

return;
}

const cookieHeader = yield* browserAuth.createAuthCookie(url.protocol === "https:");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cookie Secure flag can never be set

Low Severity

The isSecure argument to createAuthCookie is computed as url.protocol === "https:", but url is always constructed with http://localhost:${port} as the base URL. This means url.protocol is always "http:", so the Secure cookie flag can never be set regardless of the actual transport. The conditional logic in formatAuthCookie for adding the Secure attribute is dead code.

Additional Locations (1)
Fix in Cursor Fix in Web


cachedApi = createWsNativeApi();
return cachedApi;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

initializeNativeApi duplicates existing readNativeApi logic

Low Severity

initializeNativeApi has an identical body to readNativeApi in browser contexts — both check the cache, fall back to window.nativeApi, then create via createWsNativeApi(). The only difference is the non-browser branch (throw vs. return undefined). The shared logic could be consolidated to avoid maintaining two copies.

Additional Locations (1)
Fix in Cursor Fix in Web

message: Schema.String,
cause: Schema.optional(Schema.Defect),
},
) {}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

BrowserAuthError exported but never used anywhere

Low Severity

BrowserAuthError is defined and exported but never referenced anywhere in the codebase — not in wsServer.ts, tests, or any other module. This is dead code that adds surface area without being used.

Fix in Cursor Fix in Web

return () => {
active = false;
};
}, []);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

StrictMode double-effect causes bootstrap token race condition

Medium Severity

Under React.StrictMode (enabled at line 96), the effect runs twice. The first invocation of ensureBrowserPairing synchronously clears the bootstrap token from the URL hash and fires the exchange fetch. Cleanup then sets active = false. The second invocation finds no token in the URL and immediately calls fetchSession, which races against the still-in-flight exchange POST. Since the auth cookie hasn't been set yet, the session check returns false, and the component transitions to the "unpaired" screen even though the exchange succeeded. The one-time server-side token is consumed, so refreshing won't help.

Additional Locations (1)
Fix in Cursor Fix in Web

state.expiresAt > now &&
state.token === token;
return [matches, matches ? { ...state, consumedAt: now } : state] as const;
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bootstrap token compared without timing-safe equality

Low Severity

consumeBootstrapToken compares the user-supplied token to the stored secret using state.token === token (simple string equality), while verifyCookie correctly uses crypto.timingSafeEqual for HMAC comparison. The bootstrap token is a security-sensitive secret and the same timing-safe approach applies, though the practical risk is mitigated by the token's one-time use, 5-minute TTL, and loopback-only scope.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

if (tryHandleProjectFaviconRequest(url, res)) {
return;
}
const requestOrigin = req.headers.origin;
const authEndpointHeaders =
typeof requestOrigin === "string" && (yield* browserAuth.isAllowedOrigin(requestOrigin))

At line 544, url.protocol === "https:" always evaluates to false because the url object is constructed at line 458 with a hardcoded http://localhost:${port} base. Since req.url is only a path, the protocol is always "http:", so browserAuth.createAuthCookie always receives false and the auth cookie never has the Secure flag — even when the server is accessed over HTTPS (e.g., behind a reverse proxy). This allows the cookie to be transmitted over insecure connections.

        const url = new URL(req.url ?? "/", `http://localhost:${port}`);
         if (tryHandleProjectFaviconRequest(url, res)) {
           return;
         }
 
-        const requestOrigin = req.headers.origin;
+        const requestOrigin = req.headers.origin;
+        const isHttps = req.headers["x-forwarded-proto"] === "https" || url.protocol === "https:";
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/wsServer.ts around lines 459-465:

At line 544, `url.protocol === "https:"` always evaluates to `false` because the `url` object is constructed at line 458 with a hardcoded `http://localhost:${port}` base. Since `req.url` is only a path, the protocol is always `"http:"`, so `browserAuth.createAuthCookie` always receives `false` and the auth cookie never has the `Secure` flag — even when the server is accessed over HTTPS (e.g., behind a reverse proxy). This allows the cookie to be transmitted over insecure connections.

Evidence trail:
apps/server/src/wsServer.ts lines 458: `const url = new URL(req.url ?? "/", \`http://localhost:${port}\`);` - hardcoded http base URL. Line 544: `yield* browserAuth.createAuthCookie(url.protocol === "https:");` - check always evaluates to false since the URL is constructed with http:// base. Verified at commit REVIEWED_COMMIT.

@@ -986,25 +1138,45 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
httpServer.on("upgrade", (request, socket, head) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Low src/wsServer.ts:1138

When browserAuth.isAllowedOrigin or browserAuth.isAuthenticatedRequest throws a defect, Effect.ignoreCause({ log: true }) silently catches the failure without calling rejectUpgrade or wss.handleUpgrade, leaving the WebSocket connection hanging until the client times out. Consider ensuring the socket is always closed on failure by moving the error handling outside the effect or using a catch block that explicitly rejects the upgrade before suppressing the error.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/wsServer.ts around line 1138:

When `browserAuth.isAllowedOrigin` or `browserAuth.isAuthenticatedRequest` throws a defect, `Effect.ignoreCause({ log: true })` silently catches the failure without calling `rejectUpgrade` or `wss.handleUpgrade`, leaving the WebSocket connection hanging until the client times out. Consider ensuring the socket is always closed on failure by moving the error handling outside the effect or using a catch block that explicitly rejects the upgrade before suppressing the error.

Evidence trail:
apps/server/src/wsServer.ts lines 1137-1178 (REVIEWED_COMMIT): The upgrade handler using Effect.gen with Effect.ignoreCause; apps/server/src/wsServer.ts lines 115-124: rejectUpgrade function that calls socket.end(); apps/server/src/wsServer.ts lines 1166-1170: The browserAuth.isAllowedOrigin and isAuthenticatedRequest yield points where defects could occur

t3dotgg added 3 commits March 27, 2026 00:24
- Poll auth session after bootstrap exchange before declaring pairing complete
- Keep the bootstrap hash on failed exchange and retry unpaired startup in the UI
- Treat `window.desktopBridge` like `nativeApi` in browser auth
- Add a regression test to ensure pairing flow is skipped and no fetch occurs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL 500-999 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant