- 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
105 lines
4.9 KiB
Markdown
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.
|