Skip to content

fix(auth): deliver the session cookie to the Owletto extension iframe (CHIPS)#1092

Merged
buremba merged 2 commits into
mainfrom
feat/ext-iframe-chips-auth
May 27, 2026
Merged

fix(auth): deliver the session cookie to the Owletto extension iframe (CHIPS)#1092
buremba merged 2 commits into
mainfrom
feat/ext-iframe-chips-auth

Conversation

@buremba
Copy link
Copy Markdown
Member

@buremba buremba commented May 26, 2026

Problem

The Owletto Chrome side panel embeds owletto-web in an <iframe> whose top-level is chrome-extension://… — a cross-site context. The deep-link session cookie was minted SameSite=Lax, which the browser withholds on cross-site iframe loads, so the embedded app rendered signed-out (the sign-in page; the Google 403 when the user clicked the in-iframe login).

Root cause (verified with browser experiments)

Cross-site-iframe cookie probes from the extension top-level on a real Chrome:

  • SameSite=Laxwithheld (the live bug)
  • SameSite=None; Secure (unpartitioned) → delivered, but widens CSRF + fragile to 3PC deprecation
  • SameSite=None; Secure; Partitioned set inside the iframe's partition → delivered same-partition, absent from other partitions

Fix (CHIPS)

  • POST /api/exchange-token (new): token in the request body; mints the cookie SameSite=None; Secure; Partitioned. The extension bootstrap POSTs here from inside the iframe, so the cookie is keyed to the chrome-extension:// partition — isolated (no CSRF widening) and 3PC-proof.
  • GET /api/exchange-token and /api/local-init keep the first-party SameSite=Lax cookie unchanged (CLI / menu-bar deep-link in a top-level tab).
  • GET /api/extension-bootstrap (new): the in-iframe page that reads the PAT from the URL fragment, strips it from history, and POSTs it — the PAT never lands in a request URL or history entry.

Backward compatible: the new routes are additive and the existing first-party cookie is unchanged, so this is safe to merge/deploy before the extension change.

Tests

New exchange-token-cookie.test.ts (passing locally against the integration DB): POSTNone; Secure; Partitioned; GETLax (never Partitioned/None); 400/401 on missing/invalid token; /extension-bootstrap serves the form-posting page. Adds a postForm test helper.

End-to-end (real stack)

Booted a real local gateway (embedded Postgres) with this change + the extension: POST /api/exchange-token fires from the iframe → 302 → zero /sign-in bouncesget-session 200 → the iframe renders the full signed-in app. (Fresh DB, so the session was minted by this flow, not a leftover cookie.)

Related / follow-up

  • Extension change: lobu-ai/owletto#233 (depends on this).
  • The packages/owletto submodule pointer bump lands as a separate PR after feat(owletto): consolidate CLI profiles into lobu.toml #233 merges (per AGENTS.md).
  • Deferred hardening: replace the PAT-in-fragment with a short-lived single-use exchange code.

Summary by CodeRabbit

  • New Features

    • Expanded authentication: unified token exchange flow for GET/POST entry points, plus an extension-bootstrap flow to support partitioned (CHIPS-style) session cookies where appropriate.
    • Context-aware session cookie handling with secure, SameSite, and partitioning choices based on request context.
  • Tests

    • Added tests validating token exchange behavior, redirects, and cookie attributes across client contexts.
    • Added a test helper to perform application/x-www-form-urlencoded POST requests for end-to-end auth tests.

Review Change Stack

… (CHIPS)

owletto-web is embedded in the extension side panel as a CROSS-SITE iframe
(top-level chrome-extension://). The deep-link session cookie was SameSite=Lax,
which the browser withholds on cross-site iframe loads, so the embedded app
rendered signed-out (sign-in page / Google 403 on the in-iframe login).

- POST /api/exchange-token: new handler (token in the body) that mints the
  session cookie as SameSite=None; Secure; Partitioned (CHIPS). The extension's
  iframe bootstrap POSTs here from inside its OWN partition, so the cookie is
  keyed to the chrome-extension:// top-level — delivered on same-partition
  iframe loads, isolated from every other site's partition (no CSRF widening),
  and it survives third-party-cookie deprecation.
- GET /api/exchange-token and /api/local-init keep the first-party SameSite=Lax
  cookie unchanged (CLI / menu-bar deep-link runs in a top-level tab).
- GET /api/extension-bootstrap: serves the in-iframe bootstrap page that reads
  the PAT from the URL fragment, strips it from history, and POSTs it — so the
  PAT never lands in a request URL or history entry.
- Test: POST -> None;Secure;Partitioned, GET -> Lax (never Partitioned/None),
  400/401 rejects, and the bootstrap HTML. Adds a postForm test helper.

Backward compatible: the new routes are additive and the existing GET/local-init
cookie is unchanged. Pairs with the extension change in lobu-ai/owletto#233; the
submodule pointer is bumped in a follow-up after that merges.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 26, 2026

📝 Walkthrough

Walkthrough

Implements CHIPS-style partitioned session cookies and POST-based token exchange for extension iframes, adds a shared exchange handler, an extension bootstrap HTML flow, a form-post test helper, and tests validating cookie posture and error cases.

Changes

Partitioned Session Cookie Exchange

Layer / File(s) Summary
Test helper for form-based requests
packages/server/src/__tests__/setup/test-helpers.ts
Exported postForm helper POSTs application/x-www-form-urlencoded via URLSearchParams, merges optional env, supports optional cookie header, ensures workspace initialization, and returns TestResponse with status, headers, and json()/text() accessors.
Cookie partitioning logic
packages/server/src/auth/routes.ts
mintSessionCookieValue accepts opts.partitioned, derives __Secure- naming and secure-context eligibility, and applies CHIPS flags (SameSite=None, Secure, Partitioned) when partitioning is enabled in a secure context; otherwise emits first-party posture (SameSite=Lax).
Deep-link token docs
packages/server/src/auth/routes.ts
Updates resolveDeepLinkToken documentation to show it can resolve PATs, OAuth access tokens, and Better Auth session tokens.
Token exchange handler refactoring
packages/server/src/auth/routes.ts
Adds handleExchangeToken helper to validate deep-link tokens, mint cookies with optional partitioned behavior, return standardized JSON errors, and sanitize next before redirecting.
Exchange endpoints & extension bootstrap
packages/server/src/auth/routes.ts
Refactors /api/exchange-token into GET and POST handlers delegating to handleExchangeToken; expands /extension-bootstrap to serve HTML that extracts token from URL fragment, strips the fragment via history.replaceState, and POSTs to /api/exchange-token to install a partitioned cookie before redirecting.
Cookie posture test suite
packages/server/src/auth/__tests__/exchange-token-cookie.test.ts
Adds tests asserting POST flows set SameSite=None, Secure, Partitioned; GET flows set SameSite=Lax without partitioning; includes cloud pairing OAuth exchange test, negative tests (400/401), and /api/extension-bootstrap HTML content checks.

Sequence Diagram

sequenceDiagram
  participant Client
  participant ExtBootstrap
  participant ExchangeToken
  participant SessionStore

  Note over Client,SessionStore: Extension iframe flow (POST)
  Client->>ExtBootstrap: GET with deep-link token in fragment
  ExtBootstrap->>Client: Serve HTML with fragment extraction and form script
  Client->>Client: Extract token from fragment and strip it from URL
  Client->>ExchangeToken: POST token in form body
  ExchangeToken->>SessionStore: Validate token and mint partitioned cookie
  ExchangeToken->>Client: 302 redirect with Set-Cookie SameSite=None Secure Partitioned

  Note over Client,SessionStore: First-party flow (GET)
  Client->>ExchangeToken: GET with token in query params
  ExchangeToken->>SessionStore: Validate token and mint first-party cookie
  ExchangeToken->>Client: 302 redirect with Set-Cookie SameSite=Lax
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • lobu-ai/lobu#827: Introduces the /api/exchange-token PAT handoff route and initial cookie/redirect behavior that this PR extends with partitioned cookie support and dual POST/GET flows.
  • lobu-ai/lobu#830: Modifies packages/server/src/auth/routes.ts token/cookie exchange flow at the same authentication routing level as this PR's refactoring.
  • lobu-ai/lobu#896: Adds Better Auth session_token that the extension exchanges via /api/exchange-token, directly related to this PR's cookie-partitioning and token-exchange flows.

Poem

🐰 A token hops through iframes with care,
With cookies partitioned in the air,
Form-posts and GET flows now both take flight,
Bootstrap HTML keeps fragments out of sight,
Secure and same-site, the exchange is right! 🍪✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: delivering the session cookie to the Owletto extension iframe using CHIPS (Cookies Having Independent Partitioned State).
Description check ✅ Passed The pull request description is comprehensive and well-structured, covering Problem, Root cause, Fix, Tests, End-to-end verification, and Related follow-ups. However, the Test plan section from the template is not explicitly included with checkboxes.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ext-iframe-chips-auth

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


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

@codecov-commenter
Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@buremba
Copy link
Copy Markdown
Member Author

buremba commented May 26, 2026

bug_free 86, simplicity 82, slop 0, bugs 0, 0 blockers

Typecheck/unit/integration logs all exit 0. Ran git diff --check and scanned the diff for dynamic imports/banned browser APIs; no findings. No live browser/server probe; CHIPS behavior is covered by header-level tests, not an actual browser run.

Full verdict JSON
{
  "bug_free_confidence": 86,
  "bugs": 0,
  "slop": 0,
  "simplicity": 82,
  "blockers": [],
  "change_type": "fix",
  "behavior_change_risk": "medium",
  "tests_adequate": true,
  "suggested_fixes": [],
  "notes": "Typecheck/unit/integration logs all exit 0. Ran git diff --check and scanned the diff for dynamic imports/banned browser APIs; no findings. No live browser/server probe; CHIPS behavior is covered by header-level tests, not an actual browser run.",
  "categories": {
    "src": 158,
    "tests": 116,
    "docs": 0,
    "config": 0,
    "deps": 0,
    "migrations": 0,
    "ci": 0,
    "generated": 0
  }
}

Local review gate — branch protection can require the pi-review commit status. See docs/REVIEW_SCHEMA.md.

resolveDeepLinkToken only handled owl_pat_ PATs and Better Auth session tokens.
The Owletto extension's CLOUD pairing (OAuth device-code) stores an oauth_tokens
access token, which is neither — so POST /api/exchange-token 401'd and the
cloud-paired side-panel iframe rendered signed-out (the original bug, for the
OAuth path specifically; local-init/native use owl_pat_ and already worked).

Resolve via OAuthProvider.verifyAccessToken, which already accepts both PATs and
oauth_tokens access tokens, then fall back to the Better Auth session lookup.

Test: a genuine OAuth access token (createTestAccessToken, asserted not owl_pat_)
now exchanges to a 302 + partitioned cookie.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/server/src/auth/routes.ts`:
- Around line 366-369: The current flow returns authInfo.userId for any valid
OAuth token which lets arbitrary bearer tokens mint full 7-day web sessions;
modify the logic around OAuthProvider.verifyAccessToken to only allow session
creation for an approved exchange token by checking a unique identifier or scope
on authInfo (e.g. authInfo.clientId === ALLOWED_PAIRING_CLIENT_ID OR
authInfo.scopes includes 'session:exchange' or authInfo.singleUse === true)
before returning the userId; otherwise reject/throw and do not create a session.
Ensure the checks are applied where verifyAccessToken(...) is called and keep
the existing calls to createDbClientFromEnv and resolveBaseUrl untouched.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 76d31348-e196-4fa9-8ca0-4c9ca7d55ab0

📥 Commits

Reviewing files that changed from the base of the PR and between 11a2644 and d391358.

📒 Files selected for processing (2)
  • packages/server/src/auth/__tests__/exchange-token-cookie.test.ts
  • packages/server/src/auth/routes.ts

Comment on lines +366 to +369
const sql = createDbClientFromEnv(c.env);
const baseUrl = resolveBaseUrl({ request: c.req.raw });
const authInfo = await new OAuthProvider(sql, baseUrl).verifyAccessToken(token);
if (authInfo?.userId) return authInfo.userId;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don't let arbitrary OAuth bearers mint full web sessions.

This path keeps only userId from verifyAccessToken() and then creates a 7-day Better Auth session, so a scoped OAuth access token can be upgraded into an unrestricted browser login. The new test coverage even proves a generic client token with only profile:read is accepted. Please restrict this exchange to the Owletto pairing client, a dedicated scope, or a single-use exchange credential instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/auth/routes.ts` around lines 366 - 369, The current flow
returns authInfo.userId for any valid OAuth token which lets arbitrary bearer
tokens mint full 7-day web sessions; modify the logic around
OAuthProvider.verifyAccessToken to only allow session creation for an approved
exchange token by checking a unique identifier or scope on authInfo (e.g.
authInfo.clientId === ALLOWED_PAIRING_CLIENT_ID OR authInfo.scopes includes
'session:exchange' or authInfo.singleUse === true) before returning the userId;
otherwise reject/throw and do not create a session. Ensure the checks are
applied where verifyAccessToken(...) is called and keep the existing calls to
createDbClientFromEnv and resolveBaseUrl untouched.

@buremba buremba merged commit f779068 into main May 27, 2026
25 checks passed
@buremba buremba deleted the feat/ext-iframe-chips-auth branch May 27, 2026 01:19
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.

2 participants