auth-yes/scratch/INVESTIGATIVE_REPORT.md
Tyler Gillispie b9f4ccd71d feat(auth): resolve mobile cookie shadowing and synchronize tasks/path.md
- Implement iterative session cookie candidate resolution in getAuthenticatedUser
- Eliminate Hono first-match limitation causing mobile login redirect loops
- Use absolute UTC ISO strings for PostgreSQL session expiry queries
- Opportunistically clear host-level cookies upon shadow detection
- Ensure exhaustive server-side session revocation across all cookie candidates on logout
- Add automated regression test for cookie shadowing in server/main.test.ts
- Rename and standardize tasks/path.md with 5-template orchestrator standard
2026-08-24 21:48:33 -07:00

105 lines
4.9 KiB
Markdown

# Investigative Report: WebAuthn Mobile Login Redirect & Cookie Persistence Failure
## 1. Cookie Scoping & Chromium Cookie Jar Mechanics
### Findings:
Chromium on Android handles `Domain=.atyg.org` (wildcard cookies) correctly
according to RFC 6265, but there is a well-documented race condition /
overriding issue when both a host-only cookie (no Domain attribute) and a
wildcard domain cookie exist for the same name. If a user previously had a
`session_id` set exclusively for `auth.atyg.org` (perhaps during a test, or a
misconfigured previous version), and the new code sets `session_id` for
`Domain=.atyg.org`, the browser will send **both** cookies in the `Cookie`
header during the `GET /dashboard` request. Example:
`Cookie: session_id=stale-host-only-uuid; session_id=fresh-wildcard-uuid`.
Additionally, the timing of `window.location.replace("/dashboard")` executing
immediately after a `fetch()` resolves can sometimes trigger an Android
WebView/Chrome bug where the OS cookie jar hasn't fully committed the new
wildcard cookie before the navigation request fires, causing the browser to send
only the old cookies.
## 2. Server & Middleware Cookie Extraction (Hono)
### Findings:
In Hono v4, `getCookie(c, "session_id")` parses the `Cookie` string and returns
the **first** matching value it encounters. If the browser sends
`Cookie: session_id=stale-host-only-uuid; session_id=fresh-wildcard-uuid`, Hono
will extract `stale-host-only-uuid`. When `getAuthenticatedUser(c)` runs in
`server/auth-session.ts`, it queries Valkey and PostgreSQL for
`stale-host-only-uuid`. This query fails because the old session is expired or
deleted. Because it returns `null`, the middleware redirects the user back to
`/login` via a `302 Found`, creating the "infinite login loop" where the
authentication succeeds but the resulting session is instantly dropped.
## 3. Timezone & Session Expiry Skew (PostgreSQL)
### Findings:
In `server/auth-session.ts`, the fallback DB check uses:
`WHERE s.id = ${sessionId} AND s.expires_at > NOW()`
If the PostgreSQL container is running in a different timezone than the Deno
container (e.g., PG is local time, Deno is UTC, or vice versa), `NOW()` in PG
might evaluate to hours ahead of the `expires_at` value generated by Deno
(`new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)`). If `NOW()` is ahead, the
session instantly evaluates as expired. However, since Deno uses standard ISO
UTC dates for inserts, if PG is also default UTC (standard docker behavior),
this is less likely to be the root cause compared to the cookie shadowing issue,
but remains a critical architectural risk.
## 4. Traefik Ingress & Header Handling
### Findings:
Traefik generally forwards `Cookie` and `Set-Cookie` headers untouched unless
specific stripping middlewares are configured. However, some reverse proxies
lowercase header names. Hono's `getCookie` handles lowercase `cookie` correctly
(as verified in our script).
## Root Cause Summary
The failure is primarily caused by **Cookie Shadowing** combined with **Hono's
first-match cookie parsing**. The browser retains an old host-only cookie for
the specific subdomain, and when the new wildcard domain cookie is set, the
browser sends both. Hono reads the first one (the stale one), fails to find it
in the DB/Valkey, and forces a re-login.
## Recommended Architectural Solutions (Ranked)
**1. Most Reliable: Clear Host-Only Cookies & Use Strict Single Domain Setting**
- **Action:** Update the login verification endpoint to explicitly clear any
existing host-only cookies by sending an additional `Set-Cookie` header with
an empty value, `Max-Age=0`, and **no** `Domain` attribute, alongside the
valid wildcard `Domain=.atyg.org` cookie.
- **Rationale:** This forcibly purges the shadowing host-only cookie from the
browser's jar, ensuring only the wildcard cookie is sent.
**2. Highly Reliable: Parse All Cookies and Validate Iteratively**
- **Action:** Modify `getAuthenticatedUser` to manually parse
`c.req.header("cookie")` and extract an array of all `session_id` values.
Iterate through them, checking Valkey/DB until a valid session is found.
- **Rationale:** This bypasses Hono's first-match limitation and guarantees that
if _any_ valid session cookie is sent by the browser, the user is
authenticated.
**3. Address PG Timezone Skew**
- **Action:** Change `s.expires_at > NOW()` to pass the current time from Deno,
e.g., `s.expires_at > ${new Date().toISOString()}`, or use PostgreSQL's
`CURRENT_TIMESTAMP AT TIME ZONE 'UTC'`.
- **Rationale:** Eliminates any possibility of timezone mismatch between the
application runtime and the database engine.
**4. Delay Navigation on Mobile (Least Ideal)**
- **Action:** Add a 100-300ms `setTimeout` before executing
`window.location.replace("/dashboard")` on successful login.
- **Rationale:** Provides Android's cookie jar sync sufficient time to commit
the wildcard cookie before the top-level navigation fires, but feels hacky and
degrades UX.