Skip to content

fix(session-reuse): recover from stale cookies + fix broken chooser affordances#103

Merged
aspiers merged 32 commits intomainfrom
session-reuse-bugs
Apr 27, 2026
Merged

fix(session-reuse): recover from stale cookies + fix broken chooser affordances#103
aspiers merged 32 commits intomainfrom
session-reuse-bugs

Conversation

@aspiers
Copy link
Copy Markdown
Contributor

@aspiers aspiers commented Apr 21, 2026

Summary

Follow-up to the cross-client session-reuse work shipped in #96, addressing two classes of post-ship bug uncovered after that PR merged to main:

  • Stale/partial device-session cookies land the user on upstream @atproto/oauth-provider's stock welcome page instead of the email/OTP form. Layered fix: auth-service now requires both dev-id + ses-id before trusting the device-session signal and falls through to the email form otherwise, clearing orphan cookies in both host-only and shared-parent-domain scopes. pds-core adds a pre-route guard in front of /oauth/authorize and /account that intercepts requests resolving to a device with zero bound accounts (migration-005 TTL purge, fixation race, purged bindings) and 303-redirects to auth-service with prompt=login plus cookie clears — so the stock three-button welcome page is never rendered from an ePDS entry point.

  • Two chooser affordances miscategorised as "already correct" / "unreachable" in the Layer 3 design doc:

    • "Another account" is rendered by upstream as <div role="button" aria-label="Login to account that is not listed">. React's delegated root-level click listener intercepts first and swaps the chooser for upstream's stock sign-in form — so any injected anchor was cosmetic. The chooser-enrichment snippet now rebinds the upstream div with a capture-phase handler that preventDefault() + stopImmediatePropagation() and hard-navigates to auth.<host>/oauth/authorize?prompt=login. Device bindings are preserved — "Another account" adds a new binding rather than replacing.
    • "Sign up" is rendered by upstream's chooser SPA itself and crashes in its compiled React bundle when clicked under ePDS (account creation goes through auth-service's OTP flow). The enrichment snippet hides any button/anchor whose trimmed text is exactly "Sign up". Follow-up for the root-cause fix: Upstream compiled bundle crashes when Sign up is clicked on chooser #104.
  • Random-handle mode: when the flow resolves to epds_handle_mode=random, handles are hidden from the chooser row (surfaced only as a title tooltip) so server-generated opaque handles don't become the primary identifier. Uses the shared resolveHandleMode precedence so pds-core and auth-service can't disagree.

Test plan

  • pnpm format:check && pnpm lint && pnpm typecheck && pnpm test — all green (650/650 unit tests).
  • pnpm docker:build && docker compose up -d — full stack up.
  • E2E: @session-reuse scenarios targeting the chooser fixes (Another account, Sign up, different account) all pass.
  • E2E: full @session-reuse suite — 4 scenarios intermittently fail in the existing OTP setup step (#step-otp.active timing out on returning-user sign-ins); pre-existing flakiness, not introduced by this PR.
  • Manual repro in a real browser against the docker stack:
    • Sign in via demo, land on chooser on next OAuth.
    • Click "Another account" — URL changes to auth.<host>/oauth/authorize?prompt=login and the ePDS email form renders (not upstream's vanilla sign-in).
    • Complete OTP as a different email, start a fresh OAuth — both accounts appear in the chooser.
    • Confirm upstream "Sign up" button is not visible.

Changesets

  • .changeset/session-reuse-robustness.md covers end-user, client-developer, and operator scope.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • More robust sign-in recovery when device/session cookies are missing, mismatched, or stale: cookies are cleared and users are routed to the email/OTP login so upstream welcome UI is not shown.
  • New Features

    • Welcome-page guard redirects affected OAuth/account requests to the login flow.
    • Chooser updates: “Another account” hard-navigates to login, upstream “Sign up” hidden, random handles shown via tooltip with email primary.
    • Preview chooser route and chooser-enrichment metadata support; handle-mode resolution respects query, client metadata, and env default.
  • Documentation

    • Added/updated design docs on session-reuse, chooser enrichment, and preview behavior.
  • Tests

    • Expanded unit and e2e coverage for session-reuse, chooser, guard, and preview scenarios; improved per-scenario console/HTML capture.

Copilot AI review requested due to automatic review settings April 21, 2026 17:02
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
epds-demo Ready Ready Preview, Comment Apr 27, 2026 6:42pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 21, 2026

🦋 Changeset detected

Latest commit: aa6bb06

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 21, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Auth-service now requires both dev-id and ses-id for session reuse and detects/clears orphan half-pairs. pds-core adds a welcome-page guard that validates device cookies, queries bound accounts, and issues 303 redirects to auth-service with cookie-clears when no usable session exists. Chooser enrichment injects runtime metas, hides upstream “Sign up”, rebinds “Another account” to force prompt=login, and supports random-handle display via meta-driven client script.

Changes

Cohort / File(s) Summary
Design & Docs
docs/design/session-reuse-bugs.md, docs/design/pds-white-boxing.md, docs/design/cross-client-session-reuse.md, .changeset/session-reuse-robustness.md, .changeset/chooser-preview-route.md
Design and changelog documenting orphan-cookie handling, welcome-page guard, chooser enrichment behavior, meta-driven handle-mode, and preview-route docs.
Auth-service: session reuse
packages/auth-service/src/lib/session-reuse.ts, packages/auth-service/src/routes/login-page.ts, packages/auth-service/src/__tests__/session-reuse.test.ts
Require both dev-id+ses-id; add orphan detection, shared cookie-domain derivation, append clear-Set-Cookie helpers; login route uses orphan detection and clears/publishes logs; tests expanded.
PDS-Core: welcome-page guard
packages/pds-core/src/welcome-page-guard.ts, packages/pds-core/src/__tests__/welcome-page-guard.test.ts, packages/pds-core/src/index.ts
New middleware for GET /oauth/authorize and /account*: safe cookie parsing, list device accounts, append cookie-clear headers and 303-bounce to auth-service with prompt=login on orphan/zero-binding cases; mounted into express stack and reused cookie-domain.
Chooser enrichment & client script
packages/pds-core/src/chooser-enrichment.ts, packages/pds-core/src/__tests__/chooser-enrichment.test.ts
Enrichment script reads <meta> tags (epds-handle-mode, epds-auth-origin), rebinds upstream “Another account” with capture-phase hard-navigate adding prompt=login, hides upstream “Sign up” idempotently, implements random-handle tooltip/display behavior, and middleware resolves handle mode per-request.
Preview & preview routes
packages/pds-core/src/lib/preview-chooser.ts, packages/pds-core/src/__tests__/preview-chooser.test.ts, packages/auth-service/src/routes/preview.ts, packages/pds-core/src/__tests__/preview-consent.test.ts, packages/shared/src/preview-ui.ts, packages/shared/src/__tests__/preview-ui.test.ts
Add /preview/chooser handler, consolidate preview controls for epds_handle_mode/numAccounts, reuse enrichment metas/script, and update preview tests and auth-service preview behavior.
Shared handle resolver
packages/shared/src/handle.ts, packages/shared/src/index.ts
Add resolveHandleMode util with precedence: query → client metadata → env default → picker-with-random; export it.
E2E features & steps
features/session-reuse-bugs.feature, features/passwordless-authentication.feature, e2e/step-definitions/session-reuse-bugs.steps.ts, e2e/step-definitions/session-reuse.steps.ts, e2e/cucumber.mjs
Add session-reuse resilience feature and step defs exercising half-pairs/stale cookies/bounce+clear behavior, chooser escape-hatch, and random-handle UI; update chooser click to target upstream “Another account”; cucumber profile excludes @pending.
E2E infra & helpers
e2e/support/hooks.ts, e2e/support/utils.ts, e2e/support/world.ts
Per-scenario console capture stream and per-failure HTML snapshot; attachConsoleCapture helper; EpdsWorld.consoleCapture property; conditional console forwarding in reset.
Tests: chooser & guard
packages/pds-core/src/__tests__/chooser-enrichment.test.ts, packages/pds-core/src/__tests__/welcome-page-guard.test.ts
Extensive unit/integration tests for meta injection, handle-mode resolution, DOM patch idempotency, welcome-guard parsing/redirect/clear headers, and provider-account-list control flows.

Sequence Diagram(s)

sequenceDiagram
    participant Browser as Browser (Client)
    participant PDS as pds-core (Welcome Guard)
    participant Auth as auth-service
    participant Provider as upstream OAuth Provider

    Browser->>PDS: GET /oauth/authorize?client_id=... (Cookie header)
    PDS->>PDS: parseDeviceCookies(req.Cookie) -> {devId?, sesId?}
    alt orphan/half-pair OR zero bound accounts
        PDS->>Auth: 303 Location -> /oauth/authorize?...&prompt=login
        PDS->>Browser: Set-Cookie: dev-id/ses-id (Max-Age=0 host & Domain), Cache-Control: no-store
        Browser-->>Auth: follows redirect -> auth renders email+OTP form
    else both cookies present and bindings > 0
        PDS->>Provider: next() (pass-through)
        Provider->>Browser: serve chooser HTML (enriched)
        Browser->>Browser: enrichment script reads metas, hides signup, rebinds "Another account"
        Browser->>Auth: "Another account" click -> hard-navigate to /oauth/authorize?prompt=login
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 I nibbled on dev-id and ses-id crumbs so neat,

Cleared orphaned cookies and made stale flows retreat.
I hid the loose "Sign up" and rewired "Another account" to roam,
Sent broken paths back to login so each user finds home.
Hop along, tidy sessions — every token has a seat!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.77% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: fixing session-reuse bugs with stale cookies and broken chooser interaction affordances.
Linked Issues check ✅ Passed The PR successfully implements fixes for issue #104 by hiding the upstream Sign-up button via CSS and client-side script interception to prevent bundle crashes.
Out of Scope Changes check ✅ Passed All code changes directly support the stated objectives: session-reuse robustness, cookie clearing, welcome-page guard middleware, chooser affordances, and handle-mode resolution.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch session-reuse-bugs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@railway-app
Copy link
Copy Markdown

railway-app Bot commented Apr 21, 2026

🚅 Deployed to the ePDS-pr-103 environment in ePDS

Service Status Web Updated (UTC)
@certified-app/demo untrusted ✅ Success (View Logs) Web Apr 27, 2026 at 6:47 pm
@certified-app/demo ✅ Success (View Logs) Web Apr 27, 2026 at 6:42 pm
@certified-app/pds-core ✅ Success (View Logs) Web Apr 27, 2026 at 3:54 pm
@certified-app/auth-service ✅ Success (View Logs) Web Apr 27, 2026 at 3:54 pm

@coveralls-official
Copy link
Copy Markdown

coveralls-official Bot commented Apr 21, 2026

Coverage Report for CI Build 25013031214

Coverage increased (+3.2%) to 46.754%

Details

  • Coverage increased (+3.2%) from the base build.
  • Patch coverage: 39 uncovered changes across 7 files (226 of 265 lines covered, 85.28%).
  • 4 coverage regressions across 3 files.

Uncovered Changes

File Changed Covered %
packages/pds-core/src/index.ts 22 0 0.0%
packages/auth-service/src/routes/login-page.ts 8 1 12.5%
packages/shared/src/handle.ts 4 0 0.0%
packages/auth-service/src/routes/preview.ts 3 0 0.0%
packages/demo/src/app/api/oauth/login/route.ts 6 5 83.33%
packages/pds-core/src/chooser-enrichment.ts 20 19 95.0%
packages/shared/src/preview-ui.ts 18 17 94.44%

Coverage Regressions

4 previously-covered lines in 3 files lost coverage.

File Lines Losing Coverage Coverage
packages/auth-service/src/routes/preview.ts 2 0.0%
packages/auth-service/src/routes/login-page.ts 1 6.34%
packages/pds-core/src/index.ts 1 0.0%

Coverage Stats

Coverage Status
Relevant Lines: 2540
Covered Lines: 1208
Line Coverage: 47.56%
Relevant Branches: 1496
Covered Branches: 679
Branch Coverage: 45.39%
Branches in Coverage %: Yes
Coverage Strength: 4.78 hits per line

💛 - Coveralls

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Hardens cross-client OAuth session reuse against stale/partial dev-id/ses-id cookies, prevents upstream’s stock welcome/sign-in UIs from surfacing in ePDS flows, and updates chooser enrichment behavior (including handle-mode driven UI tweaks).

Changes:

  • Tighten auth-service session reuse detection to require a full dev-id+ses-id pair and clear orphan half-pairs on the email/OTP form response.
  • Add a pds-core pre-route “welcome-page guard” that bounces empty-device requests to auth-service with cookie clears + prompt=login.
  • Update chooser enrichment to (a) rebind upstream “Another account”, (b) hide upstream “Sign up”, and (c) propagate per-request handle-mode via <meta> tags; plus improved E2E debugging output capture.

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
packages/shared/src/index.ts Re-export shared resolveHandleMode.
packages/shared/src/handle.ts Add shared handle-mode resolution helper used by both services.
packages/pds-core/src/welcome-page-guard.ts New pre-route guard to prevent upstream stock welcome page rendering and clear stale cookies.
packages/pds-core/src/index.ts Wire welcome-page guard and update chooser-enrichment middleware construction.
packages/pds-core/src/chooser-enrichment.ts Enrichment script updates (Another account rebind, Sign up hide, random-handle UI) + meta injection and per-request handle-mode resolution.
packages/pds-core/src/tests/welcome-page-guard.test.ts Unit tests for new guard utilities and middleware behavior.
packages/pds-core/src/tests/chooser-enrichment.test.ts Update/enhance tests for new enrichment behavior and meta injection.
packages/auth-service/src/routes/login-page.ts Clear orphan cookie half-pairs and use shared handle-mode resolver wrapper.
packages/auth-service/src/lib/session-reuse.ts Require full cookie pair for reuse; add orphan detection + cookie-domain derivation + cookie clearing helpers.
packages/auth-service/src/tests/session-reuse.test.ts Expanded unit tests for new session-reuse/orphan-cookie behavior.
features/session-reuse-bugs.feature New E2E feature coverage for stale-cookie scenarios + chooser affordances (some pending).
features/passwordless-authentication.feature Update scenario wording to “Another account” and add assertion against upstream stock sign-in form.
e2e/support/world.ts Add consoleCapture handle to world state.
e2e/support/utils.ts Reattach console/pageerror capture after browser context resets.
e2e/support/hooks.ts Create per-scenario console log file; write page HTML on failure.
e2e/step-definitions/session-reuse.steps.ts Update step to click upstream “Another account” button.
e2e/step-definitions/session-reuse-bugs.steps.ts New step definitions for stale-cookie resilience scenarios.
e2e/cucumber.mjs Exclude @pending scenarios from the session-reuse profile.
docs/design/session-reuse-bugs.md New design doc capturing regression taxonomy and layered fixes.
docs/design/pds-white-boxing.md Update design docs to reflect new chooser enrichment + welcome-page guard.
docs/design/cross-client-session-reuse.md Update docs to reflect “Another account” rebind approach.
.changeset/session-reuse-robustness.md Patch changeset documenting user/operator-facing behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/pds-core/src/welcome-page-guard.ts Outdated
Comment thread packages/pds-core/src/chooser-enrichment.ts
Comment thread packages/pds-core/src/chooser-enrichment.ts Outdated
Comment thread e2e/support/hooks.ts
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: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/design/pds-white-boxing.md (1)

294-320: ⚠️ Potential issue | 🟡 Minor

Update this section for the new cookie-pair contract.

This still documents auth-service session reuse as dev-id-only and shows the old implementation. With this PR, the risk is now tied to the dev-id + ses-id pair and orphan cleanup, so the white-boxing doc will mislead future upstream bump audits.

📝 Suggested doc update
-### 17. `dev-id` cookie detection on the auth-service side
+### 17. Device-session cookie-pair detection on the auth-service side
 
 **File:** `packages/auth-service/src/lib/session-reuse.ts`, called from
 `packages/auth-service/src/routes/login-page.ts`
 
 The auth-service's `GET /oauth/authorize` route reads the upstream
-`dev-id` cookie directly to decide whether to bypass its own email/OTP form
-and redirect to pds-core's stock `/oauth/authorize`:
+`dev-id` and `ses-id` cookies directly to decide whether to bypass its own
+email/OTP form and redirect to pds-core's stock `/oauth/authorize`:
 
 ```ts
 export function hasDeviceSessionCookie(req: SessionReuseRequest): boolean {
-  if (req.cookies && typeof req.cookies['dev-id'] === 'string') return true
-  const raw = req.headers.cookie ?? ''
-  return /(?:^|;\s*)dev-id=/.test(raw)
+  const hasDevId = hasCookie(req, 'dev-id')
+  const hasSesId = hasCookie(req, 'ses-id')
+  return hasDevId && hasSesId
 }

-The auth-service does not parse or verify the cookie; it just treats
-presence as "the browser has an upstream device session, defer to pds-core".
+The auth-service does not verify the cookie values; it just requires the full
+pair before treating the browser as having an upstream device session. A
+half-pair is treated as an orphan state and cleared before falling back to the
+email/OTP form.

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @docs/design/pds-white-boxing.md around lines 294 - 320, Update the doc to
reflect the new cookie-pair contract: change the example implementation of
hasDeviceSessionCookie (in packages/auth-service/src/lib/session-reuse.ts) to
check for both 'dev-id' and 'ses-id' (use hasCookie(req, 'dev-id') and
hasCookie(req, 'ses-id') and return their logical AND) instead of checking only
'dev-id', and update the explanatory text to state that the auth-service
requires the pair but does not verify values (half-pairs are treated as orphans
and cleared before falling back to email/OTP).


</details>

</blockquote></details>

</blockquote></details>
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@e2e/support/hooks.ts`:
- Around line 69-75: The empty catch in the HTML artifact capture (surrounding
writeFile, this.page.content, and safeName) swallows useful diagnostics; replace
the silent catch with a call to logger.debug(...) that includes the caught error
and contextual info (safeName and target path like
`reports/screenshots/${safeName}.html`) so failures are recorded at debug level;
ensure you use the existing logger instance (not logger.info) and keep the
operation best-effort (i.e., don't rethrow).
- Around line 51-55: The per-scenario console WriteStream created by
createWriteStream and stored on this.consoleCapture (and attached via
attachConsoleCapture) is never closed, leaking file descriptors and leaving logs
unflushed; in the After hook, check for this.consoleCapture and gracefully
end/close it (e.g., call end() and/or close()) and remove any listeners if
attachConsoleCapture added them so the stream is fully flushed and released
before the scenario completes.

In `@packages/auth-service/src/lib/session-reuse.ts`:
- Around line 142-149: The cookie cleanup currently expires only the base
cookies ('dev-id' and 'ses-id') in the loop in session-reuse.ts; also expire the
signed sidecars by extending the loop in the code that uses res and cookieDomain
to clear both `${name}` and `${name}:hash` (i.e., append Set-Cookie for
`${name}=; Max-Age=0; Path=/` and for `${name}:hash=; Max-Age=0; Path=/`, and
likewise with Domain=${cookieDomain} when cookieDomain is set) so the signed
`:hash` cookies are removed as well.

In `@packages/pds-core/src/__tests__/welcome-page-guard.test.ts`:
- Around line 153-172: appendCookieClearHeaders currently only expires dev-id
and ses-id; update it to also expire the sidecar cookies named with the :hash
suffix (as defined in DEVICE_COOKIE_NAMES in cookie-domain.ts) so that both
dev-id:hash and ses-id:hash are cleared host-only and, when a cookie domain is
provided, cleared with Domain=<domain> as well; update the test in
welcome-page-guard.test.ts to expect Set-Cookie entries for dev-id:hash and
ses-id:hash in the same order/positions as the corresponding dev-id and ses-id
entries for both host-only and domain-scoped variants.

In `@packages/pds-core/src/index.ts`:
- Around line 657-668: Ensure the welcome-page guard installation fails loudly
instead of silently degrading: when installing welcomePageGuardMiddleware on
pds.app, first verify pds.app._router and pds.app._router.stack exist, throw an
error if missing; when obtaining stack and popping the guardLayer confirm
guardLayer is defined and not null, throw an error if it is missing; when
searching for the expressInit insertion index (guardIdx) ensure the splice
actually inserts the guardLayer before upstream routes (and throw if expressInit
not found and insertion would append to tail) and only call
logger.info('Welcome-page guard installed') after successful validation and
insertion.

In `@packages/pds-core/src/welcome-page-guard.ts`:
- Around line 55-73: The parseDeviceCookies function currently calls
decodeURIComponent unconditionally which throws on malformed percent-escapes;
update it to avoid crashing by only decoding values for the cookies we care
about ("dev-id" and "ses-id") or by wrapping decodeURIComponent in a try/catch
and skipping/ignoring values that fail to decode. Concretely, inside
parseDeviceCookies (the loop that builds jar) check the cookie name first and if
name === 'dev-id' || name === 'ses-id' then attempt decodeURIComponent(value)
inside a try/catch (skip that cookie on error); continue to populate jar only
for those names so DEVICE_ID_RE and SESSION_ID_RE are validated against
safely-decoded values and the function returns null rather than throwing on
malformed cookies.

---

Outside diff comments:
In `@docs/design/pds-white-boxing.md`:
- Around line 294-320: Update the doc to reflect the new cookie-pair contract:
change the example implementation of hasDeviceSessionCookie (in
packages/auth-service/src/lib/session-reuse.ts) to check for both 'dev-id' and
'ses-id' (use hasCookie(req, 'dev-id') and hasCookie(req, 'ses-id') and return
their logical AND) instead of checking only 'dev-id', and update the explanatory
text to state that the auth-service requires the pair but does not verify values
(half-pairs are treated as orphans and cleared before falling back to
email/OTP).
🪄 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

Run ID: 025d9891-ff34-4cb2-b41f-7f022e01493f

📥 Commits

Reviewing files that changed from the base of the PR and between 70fb816 and 91815f6.

📒 Files selected for processing (22)
  • .changeset/session-reuse-robustness.md
  • docs/design/cross-client-session-reuse.md
  • docs/design/pds-white-boxing.md
  • docs/design/session-reuse-bugs.md
  • e2e/cucumber.mjs
  • e2e/step-definitions/session-reuse-bugs.steps.ts
  • e2e/step-definitions/session-reuse.steps.ts
  • e2e/support/hooks.ts
  • e2e/support/utils.ts
  • e2e/support/world.ts
  • features/passwordless-authentication.feature
  • features/session-reuse-bugs.feature
  • packages/auth-service/src/__tests__/session-reuse.test.ts
  • packages/auth-service/src/lib/session-reuse.ts
  • packages/auth-service/src/routes/login-page.ts
  • packages/pds-core/src/__tests__/chooser-enrichment.test.ts
  • packages/pds-core/src/__tests__/welcome-page-guard.test.ts
  • packages/pds-core/src/chooser-enrichment.ts
  • packages/pds-core/src/index.ts
  • packages/pds-core/src/welcome-page-guard.ts
  • packages/shared/src/handle.ts
  • packages/shared/src/index.ts

Comment thread e2e/support/hooks.ts
Comment thread e2e/support/hooks.ts
Comment thread packages/auth-service/src/lib/session-reuse.ts Outdated
Comment thread packages/pds-core/src/__tests__/welcome-page-guard.test.ts
Comment thread packages/pds-core/src/index.ts Outdated
Comment thread packages/pds-core/src/welcome-page-guard.ts
Comment thread packages/pds-core/src/welcome-page-guard.ts
Comment thread packages/pds-core/src/welcome-page-guard.ts
Comment thread packages/pds-core/src/chooser-enrichment.ts
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.

🧹 Nitpick comments (2)
e2e/step-definitions/session-reuse-bugs.steps.ts (2)

216-240: Stale comment and redundant check — the #email / Sign in discriminator described in the comment is not actually implemented.

The comment at lines 227–231 promises that the assertion covers both "landed on email form" and "landed on chooser" cases by leveraging the presence of #email to disambiguate Sign in from the stock welcome button — but the code that follows (lines 232–238) just repeats the "Create a new account" check via a raw html.includes(). That substring scan is strictly weaker than the accessible-role check at lines 224–226 (it matches script data, JSON hydration, aria-live regions, etc.) and adds no new signal.

Either drop the duplicate or implement what the comment describes.

♻️ Suggested simplification
 Then(
   'the stock upstream welcome page is not shown',
   async function (this: EpdsWorld) {
     const page = getPage(this)
-    // The stock welcome page renders three buttons: "Authenticate",
-    // "Create a new account", and "Sign in". If any of them appear we've
-    // leaked the page. We check by accessible-button label so a future
-    // upstream tweak to the DOM tree does not silently bypass the assertion.
+    // The stock welcome page renders a "Create a new account" button; its
+    // absence (by accessible role) is our signal the guard intercepted
+    // before upstream's signin handler rendered. "Sign in" is overloaded
+    // with the auth-service form's submit button so we don't assert on it.
     await expect(
       page.getByRole('button', { name: 'Create a new account' }),
     ).toHaveCount(0)
-    // "Sign in" is overloaded with the auth-service form's submit button —
-    // but the auth-service form has an `#email` input that the stock welcome
-    // page does not. If we find neither, the welcome page is not shown.
-    // This lets the same assertion cover both the "landed on email form"
-    // and "landed on chooser" cases.
-    const html = await page.content()
-    // The stock page uses the exact phrase "Create a new account" in the
-    // button label; if that's gone, the welcome page isn't rendering.
-    expect(
-      html.includes('Create a new account'),
-      `Expected no stock welcome page, but found its "Create a new account" button on ${page.url()}`,
-    ).toBe(false)
   },
 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/step-definitions/session-reuse-bugs.steps.ts` around lines 216 - 240, The
test contains a stale comment about disambiguating "Sign in" via an `#email` field
but instead runs a redundant html.includes('Create a new account') string scan;
either remove the duplicate check or implement the promised discriminator:
replace the html.includes check with an explicit DOM presence/assertion for the
email input (e.g. using getPage(this) then page.locator('#email') or
page.getByRole('textbox', { name: /email/i }) to assert presence/absence) so the
test actually distinguishes the auth-service email form from the stock welcome
page; update or delete the comment accordingly.

403-411: Click step doesn't wait for the hard-navigation to complete.

Per the PR summary, "Another account" rebinds the upstream button with a capture-phase handler that then does a window.location hard-navigate to auth.<host>/oauth/authorize?prompt=login. A bare .click() returns as soon as the event dispatches, so the follow-on Then step (presumably "the browser lands on the auth-service email-and-OTP form") races the navigation. On a slow CI node this will manifest as flake rather than a real failure.

♻️ Suggested fix — await the navigation alongside the click
 When(
   'the user clicks "Another account" on the enriched account picker',
   async function (this: EpdsWorld) {
     const page = getPage(this)
-    await page
-      .getByRole('button', { name: 'Login to account that is not listed' })
-      .click()
+    await Promise.all([
+      page.waitForURL(/\/oauth\/authorize\b.*\bprompt=login\b/, {
+        timeout: 30_000,
+      }),
+      page
+        .getByRole('button', { name: 'Login to account that is not listed' })
+        .click(),
+    ])
   },
 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/step-definitions/session-reuse-bugs.steps.ts` around lines 403 - 411, The
click on the "Login to account that is not listed" button needs to be awaited
together with the resulting hard navigation to avoid races; replace the bare
await page.getByRole(...).click() in the When handler with a coordinated wait
(e.g., use Promise.all to await page.waitForNavigation(...) or page.waitForURL
matching the auth.<host>/oauth/authorize?prompt=login pattern alongside calling
page.getByRole(...).click()) so the step does not return until the browser has
completed the navigation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@e2e/step-definitions/session-reuse-bugs.steps.ts`:
- Around line 216-240: The test contains a stale comment about disambiguating
"Sign in" via an `#email` field but instead runs a redundant html.includes('Create
a new account') string scan; either remove the duplicate check or implement the
promised discriminator: replace the html.includes check with an explicit DOM
presence/assertion for the email input (e.g. using getPage(this) then
page.locator('#email') or page.getByRole('textbox', { name: /email/i }) to
assert presence/absence) so the test actually distinguishes the auth-service
email form from the stock welcome page; update or delete the comment
accordingly.
- Around line 403-411: The click on the "Login to account that is not listed"
button needs to be awaited together with the resulting hard navigation to avoid
races; replace the bare await page.getByRole(...).click() in the When handler
with a coordinated wait (e.g., use Promise.all to await
page.waitForNavigation(...) or page.waitForURL matching the
auth.<host>/oauth/authorize?prompt=login pattern alongside calling
page.getByRole(...).click()) so the step does not return until the browser has
completed the navigation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9fad951c-a211-43ed-901f-23438061bed0

📥 Commits

Reviewing files that changed from the base of the PR and between 91815f6 and dfb9c95.

📒 Files selected for processing (2)
  • e2e/step-definitions/session-reuse-bugs.steps.ts
  • features/session-reuse-bugs.feature
🚧 Files skipped from review as they are similar to previous changes (1)
  • features/session-reuse-bugs.feature

aspiers added a commit that referenced this pull request Apr 21, 2026
- welcome-page-guard: wrap decodeURIComponent in try/catch so a malformed
  percent-escape in an unrelated cookie (RFC 6265 permits literal %) no
  longer crashes the middleware; only the two device cookies are decoded.
- welcome-page-guard: skip the bounce when /account* is hit without a
  request_uri (direct nav / bookmark) — auth-service would 400 on a
  missing request_uri, worse UX than the stock welcome page we're
  suppressing. OAuth flows still bounce as before.
- welcome-page-guard + auth-service: extend the cleared cookie set to
  include the :hash sidecars upstream may emit when cookie.keys is
  configured (ePDS doesn't wire that today, but clearing them now is
  defensive future-proofing and matches the broadening name list).
- chooser-enrichment: HTML-escape authOrigin when embedding it into the
  <meta name="epds-auth-origin"> attribute. Operator-configured, not
  user-controlled, but cheap defense-in-depth against attribute-escape
  injection from a misconfigured value.
- chooser-enrichment: correct the random-mode comment — display:none on
  the handle span is intentional, not a bug. Removing opaque random
  handles from the accessibility tree is the right UX; the tooltip on
  the parent preserves the value for inspection.
- pds-core/index.ts: fail-closed if the Express _router.stack splice
  can't install the welcome-page guard, instead of silently booting with
  an inert guard.
- e2e/support/hooks.ts: close the per-scenario consoleCapture stream in
  After to avoid file-descriptor leaks and lost buffered output across a
  long run; log HTML-capture failures instead of swallowing them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 21, 2026 21:58
@railway-app railway-app Bot temporarily deployed to ePDS / ePDS-pr-103 April 21, 2026 21:58 Destroyed
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR is a follow-up to the cross-client session-reuse work, tightening session detection and adding guards/enrichment so stale/partial device-session cookies can’t land users on upstream @atproto/oauth-provider’s stock welcome/sign-in surfaces. It also fixes chooser affordances (“Another account”, “Sign up”) and adds random-handle-mode presentation consistency across auth-service and pds-core.

Changes:

  • Centralize handle-mode precedence via shared resolveHandleMode() and apply it consistently (auth-service + pds-core chooser enrichment), including hiding handles in random mode.
  • Add a pds-core pre-route “welcome-page guard” that redirects empty-device flows to auth-service with cookie clears.
  • Update chooser enrichment to hide upstream “Sign up” and rebind upstream “Another account” to hard-navigate to auth-service, plus add/extend unit + e2e coverage and design docs.

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/shared/src/index.ts Re-exports resolveHandleMode from shared.
packages/shared/src/handle.ts Adds shared resolveHandleMode() implementing precedence + fallback.
packages/pds-core/src/welcome-page-guard.ts New pre-route middleware to bounce empty-device flows and clear cookies.
packages/pds-core/src/index.ts Wires welcome-page guard and updates chooser enrichment wiring (deps + authOrigin).
packages/pds-core/src/chooser-enrichment.ts Enrichment script updates: handle-mode meta, hide “Sign up”, rebind “Another account”, authOrigin meta escaping.
packages/pds-core/src/tests/welcome-page-guard.test.ts Unit coverage for guarded paths, cookie parsing, bounce behavior, and cookie clears.
packages/pds-core/src/tests/chooser-enrichment.test.ts Updates/expands unit coverage for deterministic script + meta injection and new behaviors.
packages/auth-service/src/routes/login-page.ts Uses shared handle-mode resolver; clears orphan half-pair cookies on email-form responses.
packages/auth-service/src/lib/session-reuse.ts Tightens session detection to require both cookies; adds orphan detection + cookie-domain derivation + clear helpers.
packages/auth-service/src/tests/session-reuse.test.ts Expands unit tests for new cookie-pair/orphan logic and clearing behavior.
features/session-reuse-bugs.feature New e2e feature spec for stale-cookie resilience + chooser affordances + random-handle behavior.
features/passwordless-authentication.feature Updates scenario wording/steps for “Another account” behavior and asserts upstream sign-in isn’t shown.
e2e/support/world.ts Adds per-scenario consoleCapture handle on world.
e2e/support/utils.ts Reattaches console capture after browser context resets.
e2e/support/hooks.ts Implements per-scenario console capture and HTML snapshot on failures; closes streams in After hook.
e2e/step-definitions/session-reuse.steps.ts Updates chooser click step to target upstream “Another account” button.
e2e/step-definitions/session-reuse-bugs.steps.ts Adds step definitions for new stale-cookie resilience scenarios and chooser assertions.
e2e/cucumber.mjs Excludes @pending scenarios from the @session-reuse profile.
docs/design/session-reuse-bugs.md New design doc detailing failure modes and layered fixes.
docs/design/pds-white-boxing.md Updates design notes to reflect new enrichment behaviors + welcome-page guard.
docs/design/cross-client-session-reuse.md Updates design doc references from injected link to rebound “Another account”.
.changeset/session-reuse-robustness.md Patch changeset describing end-user/operator/dev-facing behavior changes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/pds-core/src/welcome-page-guard.ts Outdated
Comment thread packages/pds-core/src/chooser-enrichment.ts
Comment thread e2e/support/hooks.ts Outdated
aspiers added a commit that referenced this pull request Apr 21, 2026
- welcome-page-guard.buildBounceUrl: assign the raw serialized query
  string (target.search = parsed.search) instead of copying via
  searchParams.forEach + set(). The forEach+set() pattern collapsed
  repeated keys like scope=atproto&scope=transition:generic to the last
  value, contradicting the "preserves verbatim" contract. Added a unit
  test that asserts both scope values survive the bounce.
- chooser-enrichment.buildAnotherAccountUrl: return '' when the current
  URL has no request_uri, and skip the rebind in rebindAnotherAccount
  when the target URL would be empty. Previously a standalone /account
  click on "Another account" would navigate to
  /oauth/authorize?prompt=login, which auth-service rejects with a 400;
  letting upstream's default handler run is strictly better UX.
- e2e/support/hooks.ts: fix the now-inaccurate comment in After —
  resetBrowserContext re-attaches the same stream to the new Page,
  never creates a replacement, so there is only ever one stream per
  scenario to close.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@railway-app railway-app Bot temporarily deployed to ePDS / ePDS-pr-103 April 21, 2026 22:26 Destroyed
aspiers added a commit that referenced this pull request Apr 22, 2026
- welcome-page-guard: wrap decodeURIComponent in try/catch so a malformed
  percent-escape in an unrelated cookie (RFC 6265 permits literal %) no
  longer crashes the middleware; only the two device cookies are decoded.
- welcome-page-guard: skip the bounce when /account* is hit without a
  request_uri (direct nav / bookmark) — auth-service would 400 on a
  missing request_uri, worse UX than the stock welcome page we're
  suppressing. OAuth flows still bounce as before.
- welcome-page-guard + auth-service: extend the cleared cookie set to
  include the :hash sidecars upstream may emit when cookie.keys is
  configured (ePDS doesn't wire that today, but clearing them now is
  defensive future-proofing and matches the broadening name list).
- chooser-enrichment: HTML-escape authOrigin when embedding it into the
  <meta name="epds-auth-origin"> attribute. Operator-configured, not
  user-controlled, but cheap defense-in-depth against attribute-escape
  injection from a misconfigured value.
- chooser-enrichment: correct the random-mode comment — display:none on
  the handle span is intentional, not a bug. Removing opaque random
  handles from the accessibility tree is the right UX; the tooltip on
  the parent preserves the value for inspection.
- pds-core/index.ts: fail-closed if the Express _router.stack splice
  can't install the welcome-page guard, instead of silently booting with
  an inert guard.
- e2e/support/hooks.ts: close the per-scenario consoleCapture stream in
  After to avoid file-descriptor leaks and lost buffered output across a
  long run; log HTML-capture failures instead of swallowing them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
aspiers added a commit that referenced this pull request Apr 22, 2026
- welcome-page-guard.buildBounceUrl: assign the raw serialized query
  string (target.search = parsed.search) instead of copying via
  searchParams.forEach + set(). The forEach+set() pattern collapsed
  repeated keys like scope=atproto&scope=transition:generic to the last
  value, contradicting the "preserves verbatim" contract. Added a unit
  test that asserts both scope values survive the bounce.
- chooser-enrichment.buildAnotherAccountUrl: return '' when the current
  URL has no request_uri, and skip the rebind in rebindAnotherAccount
  when the target URL would be empty. Previously a standalone /account
  click on "Another account" would navigate to
  /oauth/authorize?prompt=login, which auth-service rejects with a 400;
  letting upstream's default handler run is strictly better UX.
- e2e/support/hooks.ts: fix the now-inaccurate comment in After —
  resetBrowserContext re-attaches the same stream to the new Page,
  never creates a replacement, so there is only ever one stream per
  scenario to close.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 22, 2026 11:27
@aspiers aspiers force-pushed the session-reuse-bugs branch from 98f7386 to 82db640 Compare April 22, 2026 11:27
@railway-app railway-app Bot temporarily deployed to ePDS / ePDS-pr-103 April 22, 2026 11:27 Destroyed
…ucture rules

The cross-client-session-reuse changeset had drifted into PR-description
territory, with per-audience sections referencing internal middleware
names (`pds-core mounts a pre-route guard…`), upstream source-code paths
(`upstream's request-manager.js force-overrides…`), and step-by-step
request-flow narratives. Release-notes readers consume ePDS as a single
product and don't need any of that to adapt their config or code.

Two changes in concert:

- Rewrite the changeset's per-audience sections as short bullet lists of
  "what do I need to do differently" points. End-user prose stays
  end-user-flavoured; client-app and operator sections name only the
  fields, env vars, and conditions that readers actually adapt against.

- Add two new subsections to the writing-changesets skill:
  - "Depth check: adaptation detail, not architecture detail" — the
    bar to clear before including any technical detail in a
    per-audience section is whether the reader's adaptation depends
    on knowing it. Lists what to avoid (internal package/module
    names, upstream source-code references, request-flow narratives,
    "why" explanations of internal mechanics) and what to keep
    (env vars, fields, error messages, observable old-vs-new).
  - "Structure dense sections as bullets" — when a per-audience
    section has 3+ distinct points, prefer a bullet list over a
    wall-of-text paragraph. Two-or-fewer points stay inline. Bullet
    lists survive the changesets-github 2-space indent.

The example block in the skill grows a second example showing the
bullet form for a denser change.
aspiers added 2 commits April 27, 2026 17:07
…ate-limit bullet

The "auto-pre-populated chooser on login_hint match" framing was wrong
twice over: the chooser is a clickable list (nothing to pre-populate),
and current shipped behaviour renders the chooser identically with or
without login_hint (auto-skip on hint match is the @pending P-series).

Also drop the EPDS_DISABLE_RATE_LIMIT bullet — that flag is its own
operator-facing change, not a deliverable of cross-client session reuse.
Moved to .changeset/rate-limit-disable-flag.md in a follow-up commit.

Mirror the same corrections in the writing-changesets skill example
so the next changeset doesn't re-learn the same mistakes.
Split out from the cross-client session-reuse changeset. The flag is a
standalone operator-facing knob for single-source-IP test environments;
shipping it under "session reuse" conflates an internal testing
affordance with a user-visible feature.
"Pass prompt=login on /oauth/authorize" assumes the reader hand-builds
authorization URLs, which most clients don't — they call into an OAuth
library or hit the PAR endpoint. Reframe as the OIDC parameter name
and where it goes (library option or PAR body), so a client developer
knows what to do without us prescribing one OAuth stack.
aspiers added 3 commits April 27, 2026 18:41
Renders a "Force re-authentication (prompt=login)" checkbox on every demo
sign-in form (homepage email/handle, plus flow2/3/4 pages). When checked,
the demo passes prompt=login both in the PAR body and on the authorize
redirect URL.

The URL placement is the load-bearing one: auth-service's
shouldReuseSession reads only the /oauth/authorize query string at the
AS-metadata redirect, never the PAR body. PAR-body prompt=login alone
left users on the chooser and made the changeset's claim look broken.

Factored the checkbox into ForceLoginCheckbox so both LoginForm and
FlowLoginPage share one uncontrolled implementation.
ePDS's session-reuse short-circuit reads the /oauth/authorize query
string, not the PAR body. Putting prompt=login in PAR alone is silently
ignored — the user still lands on the chooser. Codify this so the next
implementer doesn't waste time discovering it from logs:

- new "Forcing a Fresh Sign-In" section with where-to-put-it table and
  hand-rolled / NodeOAuthClient examples (the latter shows post-hoc
  searchParams.set on the URL the library returns)
- pitfall row mapping the symptom ("prompt=login ignored, chooser still
  shown") to the fix
Earlier wording said "set the OIDC prompt parameter on the authorization
request your OAuth library sends (typically a prompt: 'login' option, or
include prompt=login in the PAR body)". The PAR-body suggestion was
actively misleading: ePDS's short-circuit only reads the URL query
string, so a client following that advice would still hit the chooser.

Point readers at the epds-login skill for the URL-vs-body details
instead of trying to compress them into a release-note bullet.
@railway-app railway-app Bot temporarily deployed to ePDS / ePDS-pr-103 April 27, 2026 18:41 Destroyed
@sonarqubecloud
Copy link
Copy Markdown

**Important — where to put it:** ePDS's auth service decides whether to
engage session reuse by inspecting the **query string** of the
`/oauth/authorize` redirect, not the PAR body. PAR-body `prompt=login`
alone is **not** enough.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It's not just not enough, it's pointless - right?

Comment on lines +199 to +203
| Sent in | Engages ePDS short-circuit? |
| ------------------ | --------------------------- |
| Authorize URL only | Yes |
| PAR body only | No |
| Both | Yes |
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This table seems superfluous.

@aspiers aspiers enabled auto-merge April 27, 2026 21:07
@aspiers aspiers disabled auto-merge April 27, 2026 21:08
@aspiers aspiers merged commit bcfb1ff into main Apr 27, 2026
16 of 17 checks passed
@aspiers aspiers deleted the session-reuse-bugs branch April 27, 2026 21:08
aspiers added a commit to aspiers/ePDS that referenced this pull request Apr 27, 2026
Address review feedback on PR hypercerts-org#103 (threads on
.agents/skills/epds-login/SKILL.md:197 and :203):

- PAR-body `prompt=login` isn't merely "not enough" — it's ignored, so
  say so directly.
- Drop the 3-row table; it added no information beyond the prose plus
  the "URL only / both" rows could mislead readers into thinking dual
  placement is meaningful.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.

Upstream compiled bundle crashes when Sign up is clicked on chooser

2 participants