Skip to content

Fix host-only cookie shadowing OTP loop (#116) - #117

Merged
aspiers merged 6 commits into
mainfrom
fix/host-only-cookie-shadow
Apr 28, 2026
Merged

Fix host-only cookie shadowing OTP loop (#116)#117
aspiers merged 6 commits into
mainfrom
fix/host-only-cookie-shadow

Conversation

@aspiers

@aspiers aspiers commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the infinite OTP loop diagnosed in #116. A user whose browser jar carries a stale host-only dev-id/ses-id pair from before PR #103 (cookie-domain broadening) gets trapped on the OTP form: every OAuth attempt completes the OTP step, redirects through /oauth/epds-callback, then gets bounced from /oauth/authorize back to OTP because the welcome-page-guard validates against the stale host-only values instead of the freshly-set Domain-scoped pair.

Two narrow changes resolve it; either alone is insufficient.

Bug 2 — cookie-domain middleware destroys host-only cookie clears

rewriteSetCookie rewrote every device-cookie Set-Cookie to add Domain=<parent>, including Max-Age=0 clears. The welcome-page-guard's bounce response intentionally emits TWO clears per cookie name (one host-only, no Domain=; one Domain-scoped) so browsers evict cookies in both scopes. The middleware silently rewrote the host-only line to be Domain-scoped, identical to the second line, so the host-only stale entry survived every bounce.

Fix: rewriteSetCookie now skips clearing cookies (detected by Max-Age=0 or past Expires). Normal Set-Cookie values continue to get Domain= injected. Unit tests added for each device cookie name, whitespace tolerance, past Expires, and the no-double-inject guard.

Bug 1 — Stale host-only entry parsed first, shadowing the freshly-set Domain-scoped pair

When a request carries both pairs in the Cookie header, the cookie package's parse() (used by both upstream's parseHttpCookies and welcome-page-guard's parseDeviceCookies) keeps the first occurrence per name. Per RFC 6265 §5.4 ordering, the host-only stale entry comes first when its creation time predates the Domain-scoped pair (Ken's case — host-only existed pre-PR-#103). The guard validates the wrong values, fails, bounces.

Fix: in /oauth/epds-callback, the response that emits the fresh Domain-scoped cookies also emits explicit host-only Max-Age=0 clears for dev-id, ses-id, and their :hash sidecars. The browser evicts any host-only twin before the very next request, so the welcome-page-guard at /oauth/authorize sees only the fresh pair. Idempotent — clearing cookies that don't exist is a no-op. Relies on Bug 2's fix to pass the host-only clears through unchanged.

Tests

  • New unit tests in cookie-domain.test.ts for the rewriter's clearing-cookie behaviour (Max-Age=0 host-only clears pass through unchanged for each device cookie name; positive Max-Age and future Expires still get Domain= injected; whitespace tolerated).
  • New e2e scenario in features/session-reuse-host-only-shadowing.feature: an affected user (only host-only stale pair on the jar, no Domain-scoped, no live device session) starts an OAuth flow and submits a valid OTP. They must reach the demo client's /welcome page rather than loop.
  • Full session-reuse e2e profile: 16/16 passing.

Changeset

No new changeset. This is a fix for an unreleased feature (PR #103, .changeset/cross-client-session-reuse.md is unreleased). The existing changeset's user-facing claim ("If your browser's leftover sign-in cookies no longer match the server, you land on the familiar email code form rather than a generic sign-in screen") still holds post-fix — the bug was that the recovery path looped infinitely instead of recovering, not that the recovery existed at all.

Architectural follow-up (NOT in this PR)

The welcome-page-guard runs on every GET to /oauth/authorize and /account*, so any future cookie-handling regression or new entry point can become an OTP loop the same way this bug did. Issue #116 flags a less invasive alternative (intercept upstream's welcome-page response and replace it, rather than pre-empting every request) for separate evaluation.

Test plan

  • pnpm vitest run packages/pds-core/src/__tests__/cookie-domain.test.ts — 33/33 passing
  • pnpm test:e2e:headless -p session-reuse — 16/16 passing against local docker stack
  • Manual repro per OTP login loops back to OTP screen when browser holds stale host-only device cookies #116 instructions: plant host-only dev-id/ses-id on epds-poc1.test.certified.app, log in via demo, reach /welcome without looping
  • CI green
  • Manual repro on epds1.test.certified.app with Ken's account once deployed

Closes #116

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Fixed a critical issue where returning users with stale session cookies would experience repeated sign-in prompts. Users can now successfully complete authentication and access the application.
  • Tests

    • Added comprehensive end-to-end scenario tests validating sign-in recovery behavior.
    • Added detailed unit test coverage for session cookie handling and eviction logic.

aspiers and others added 3 commits April 28, 2026 21:59
Two failing e2e scenarios that exercise the OTP-loop trap diagnosed in
GitHub issue #116. An affected user has a stale host-only dev-id/ses-id
pair on the pds-core host (left over from a session predating the
cookie-domain broadening in PR #103). The host-only pair shadows
everything because the cookie parser keeps the first occurrence per name
and per RFC 6265 host-only comes first in the Cookie header.

Scenarios:

- "Bounce response actually clears the host-only cookies" — drives the
  OAuth entry point and asserts the response that serves the email/OTP
  form has evicted the host-only pair from the jar. Currently fails:
  the welcome-page-guard's host-only clear is silently rewritten by the
  cookie-domain middleware to be Domain-scoped, so the host-only entry
  survives every bounce.

- "User can complete OTP sign-in despite stale host-only cookies" —
  end-to-end loop reproduction. An affected user submits a valid OTP
  and must reach the consent screen / app rather than be bounced back
  to the OTP form. Currently fails: the OTP form is re-rendered after
  every submit because the stale host-only pair keeps shadowing the
  fresh Domain-scoped pair epds-callback sets.

Both scenarios reuse the existing welcome-page-guard step harness and
the Background's "returning user has a PDS account" step (which clears
the browser context to a clean jar). A new Given plants the host-only
stale pair directly via Playwright's addCookies API with
domain: <host> (no leading dot).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rewriter

Bug 2 from GitHub issue #116. The cookie-domain middleware rewrites
device-cookie Set-Cookie headers to add Domain=<parent> so auth-service
on the sibling subdomain can read upstream's session cookies. Until now
it did this unconditionally, including for clearing cookies (Max-Age=0).

That broke welcome-page-guard's bounce path: the guard intentionally
emits two Set-Cookie lines per stale device cookie — one host-only
(no Domain=) and one Domain-scoped — to evict cookies in both scopes.
The middleware silently rewrote the host-only line to be Domain-scoped,
identical to the second line, so the host-only stale entry survived
every bounce. A user whose jar held a host-only dev-id/ses-id pair
from before PR #103 (cookie-domain broadening) thus got trapped in an
OTP loop with no way out.

Fix: rewriteSetCookie now passes through clearing cookies untouched.
A clear is identified by Max-Age=0 (canonical form upstream uses) or
a past Expires date. Normal Set-Cookie values continue to get
Domain= injected.

Drop the standalone e2e scenario that asserted the host-only clear
because it can't actually exercise the bounce path with only host-only
cookies in the jar (auth-service can't see them on the subdomain, so
the flow lands on the email form without ever reaching pds-core's
guard). Unit tests cover the rewriter at the function level. The
remaining e2e scenario in the feature file proves the user-facing
outcome via the post-OTP path, where bug 1's fix (still pending) is
the primary lever.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug 1 from GitHub issue #116. Browsers store host-only and Domain-scoped
cookies of the same name as two distinct entries in the jar. A user
whose jar held a host-only dev-id/ses-id pair from before PR #103
(cookie-domain broadening) accumulates two complete pairs after the
post-OTP /oauth/epds-callback redirect — host-only stale alongside the
fresh Domain-scoped one we just minted. The cookie parser used by both
upstream and welcome-page-guard keeps the first occurrence per name,
which per RFC 6265 ordering is the host-only stale value. The guard
validates the wrong values, fails its check, and bounces to
auth-service with prompt=login. The user loops on the OTP form
forever.

Fix: in the same response that emits the fresh Domain-scoped cookies,
also emit explicit host-only Max-Age=0 clears for dev-id, ses-id, and
their :hash sidecars. The browser evicts any host-only twin before the
very next request, so the welcome-page-guard at /oauth/authorize sees
only the fresh pair. Idempotent — emitting clears for cookies that
don't exist is a no-op.

Relies on the cookie-domain middleware's recent fix to leave
Max-Age=0 lines untouched (commit before this one). Without that, the
clears would be silently rewritten to Domain-scoped and never reach
the host-only entry.

Also strengthen the e2e scenario assertion: replace the weak "browser
does not land back on the OTP form" check with the existing
"browser is redirected back to the demo client" step, which proves
the user reaches /welcome with a valid token rather than just leaving
the OTP page.

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

@claude claude 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.

⚠️ Code review skipped — your organization's overage spend limit has been reached.

Code review is billed via overage credits. To resume reviews, an organization admin can raise the monthly limit at claude.ai/admin-settings/claude-code.

Once credits are available, reopen this pull request to trigger a review.

@vercel

vercel Bot commented Apr 28, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
epds-demo Ready Ready Preview, Comment Apr 28, 2026 10:59pm

Request Review

@changeset-bot

changeset-bot Bot commented Apr 28, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 9a1dcda

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

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

@coderabbitai

coderabbitai Bot commented Apr 28, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@aspiers has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 26 minutes and 45 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 737e3379-68c8-4601-9af6-f0933362ad1c

📥 Commits

Reviewing files that changed from the base of the PR and between b674db9 and 9a1dcda.

📒 Files selected for processing (3)
  • e2e/step-definitions/session-reuse-bugs.steps.ts
  • features/session-reuse-bugs.feature
  • packages/pds-core/src/index.ts
📝 Walkthrough

Walkthrough

This change fixes an issue where ePDS fails to recover from host-only device/session cookies by adding cookie clearing logic, E2E test infrastructure, and a feature test. The implementation detects cookie eviction instructions and prevents domain-rewriting of clearing cookies, while the callback route explicitly emits host-scoped clears to evict stale cookie twins before continuing OAuth flow.

Changes

Cohort / File(s) Summary
E2E Test Infrastructure
e2e/step-definitions/session-reuse-bugs.steps.ts, features/session-reuse-host-only-shadowing.feature
New Cucumber steps for setting deterministic host-only cookie state and automating OTP sign-in flow; new feature test verifying OAuth recovery from stale host-only cookies.
Cookie Domain Handling
packages/pds-core/src/cookie-domain.ts, packages/pds-core/src/__tests__/cookie-domain.test.ts
Added isClearingCookie helper to detect Max-Age=0 or past-dated Expires values; guards Domain= injection to prevent rewriting of clearing instructions; comprehensive test coverage for clearing behavior and Domain-scoped non-clears.
ePDS Callback Flow
packages/pds-core/src/index.ts
Emits host-scoped Set-Cookie clears (Max-Age=0, Path=/) for dev-id, dev-id:hash, ses-id, ses-id:hash immediately after device manager load to evict potential host-only shadow cookies before subsequent authorization checks.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

  • Issue #116 — This PR directly addresses the core issue by implementing the isClearingCookie guard in cookie-domain.ts, adding host-scoped eviction clears in the callback route, and introducing E2E tests/steps to verify host-only cookie recovery.

Possibly related PRs

  • PR #114 — Modifies the same e2e step-definitions file and overlaps on dev-id/ses-id cookie handling and pds-core callback behavior.
  • PR #21 — Alters the epds-callback flow and device/session cookie handling logic in pds-core/src/index.ts.
  • PR #103 — Addresses related stale/host-only device/session cookie handling and E2E test coverage for OAuth flow recovery.

Poem

🐰 A shadow cookie lurked in the host-only night,
But now our ePDS clears it with all of its might!
With Max-Age=0 and guards standing tall,
The OAuth flow dances—no OTP loop thrall! 🍪✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% 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 'Fix host-only cookie shadowing OTP loop (#116)' is concise, clear, and directly summarizes the main change—fixing a cookie shadowing bug that caused an infinite OTP loop. The title accurately reflects the primary objective of the pull request.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/host-only-cookie-shadow

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

railway-app Bot commented Apr 28, 2026

Copy link
Copy Markdown

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

Service Status Web Updated (UTC)
@certified-app/pds-core ✅ Success (View Logs) Web Apr 28, 2026 at 10:46 pm
@certified-app/demo untrusted ✅ Success (View Logs) Web Apr 28, 2026 at 10:27 pm
@certified-app/demo ✅ Success (View Logs) Web Apr 28, 2026 at 10:26 pm
@certified-app/auth-service ✅ Success (View Logs) Web Apr 28, 2026 at 10:26 pm

@coveralls-official

coveralls-official Bot commented Apr 28, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 25082001533

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage increased (+0.2%) to 47.832%

Details

  • Coverage increased (+0.2%) from the base build.
  • Patch coverage: 4 uncovered changes across 1 file (7 of 11 lines covered, 63.64%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
packages/pds-core/src/index.ts 4 0 0.0%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 2578
Covered Lines: 1240
Line Coverage: 48.1%
Relevant Branches: 1551
Covered Branches: 735
Branch Coverage: 47.39%
Branches in Coverage %: Yes
Coverage Strength: 4.89 hits per line

💛 - Coveralls

Bug fix to the bug fix in the previous commit. The unconditional host-only
Max-Age=0 clears in /oauth/epds-callback evicted the freshly-minted device
cookies on deployments where cookie-domain broadening is a no-op (Railway
preview envs, where auth-service and pds-core live under unrelated
*.up.railway.app hostnames). Without broadening, upstream's Set-Cookie
emits the device cookies host-only — exactly the scope the clears target,
so the browser sees the fresh cookies set and immediately deleted in
the same response.

Surfaced by CI's e2e suite against the PR-117 Railway preview: nine
post-OTP scenarios timed out waiting for /welcome with the browser
stuck on the bounce target /oauth/authorize?prompt=login. Local docker
stack (where auth-service IS a subdomain of pds-core) was unaffected
because broadening is active there.

Fix: only emit the host-only clears when deriveCookieDomain returned
non-null, i.e. when the cookie-domain middleware is actively rewriting
device cookies to be Domain-scoped. On the no-broadening path the
device cookies are already host-only and there is no twin to evict.

Drive-by: hoist deriveCookieDomain to the top of main() so the same
value is used for the welcome-page-guard, cookie-domain middleware,
and the host-only clear gate, eliminating a duplicate computation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread e2e/step-definitions/session-reuse-bugs.steps.ts Outdated
Comment thread features/session-reuse-host-only-shadowing.feature Outdated
…ario

Address review feedback on PR #117: the bespoke "the user submits a
valid OTP for the existing account" step duplicated the email-fill,
OTP-wait, mailpit-fetch, and code-fill mechanics already covered by
existing steps in auth.steps.ts and email.steps.ts. Decompose the
scenario into the existing four-step sequence:

  And the user enters the test email on the login page
  Then the login page shows an OTP verification form
  And an OTP email arrives in the mail trap
  When the user enters the OTP code

Drop the now-unused "submits a valid OTP for the existing account"
step definition. The reused steps are slightly more verbose at the
feature level but match the phrasing every other OTP scenario uses,
and they thread world.otpCode through the canonical mailpit-fetch
helper instead of inlining it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…reuse-bugs

Address review feedback on PR #117: the standalone
session-reuse-host-only-shadowing.feature file is unnecessary; the
scenario fits the existing session-reuse-bugs.feature thematically and
sequencing-wise. Merge it back in alongside the other welcome-page-guard
scenarios.

The previous separation rationale was that the affected-user starting
state (only host-only stale cookies, no Domain-scoped pair) couldn't
coexist with the Feature-level Background's "user has completed one
OAuth sign-in" step (which deposits a valid Domain-scoped pair).
Resolved by having the new "browser jar holds only a stale host-only
dev-id and ses-id pair" Given clear the cookie jar before planting,
so it works regardless of whether the Background left it empty or
populated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@railway-app
railway-app Bot temporarily deployed to ePDS / ePDS-pr-117 April 28, 2026 22:58 Destroyed
@sonarqubecloud

Copy link
Copy Markdown

@aspiers
aspiers merged commit 3f5d5c1 into main Apr 28, 2026
15 checks passed
@aspiers
aspiers deleted the fix/host-only-cookie-shadow branch April 28, 2026 23:13
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.

OTP login loops back to OTP screen when browser holds stale host-only device cookies

1 participant