Investigate mobile login cookie persistence bug

- Investigated Chromium Android wildcard domain cookie behavior.
- Researched Hono's `getCookie` first-match parsing behavior.
- Added experimental Deno test scripts in `scratch/` for Hono cookie header parsing and pg timezone concepts.
- Wrote full root-cause analysis and ranked architectural solutions in `scratch/INVESTIGATIVE_REPORT.md`.

Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot] 2026-08-25 00:31:19 +00:00
parent 4b78f70efd
commit 947b38d81a
5 changed files with 168 additions and 0 deletions

66
deno.lock generated
View File

@ -436,6 +436,72 @@
]
}
},
"remote": {
"https://deno.land/std@0.160.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74",
"https://deno.land/std@0.160.0/_util/os.ts": "8a33345f74990e627b9dfe2de9b040004b08ea5146c7c9e8fe9a29070d193934",
"https://deno.land/std@0.160.0/async/abortable.ts": "87aa7230be8360c24ad437212311c9e8d4328854baec27b4c7abb26e85515c06",
"https://deno.land/std@0.160.0/async/deadline.ts": "48ac998d7564969f3e6ec6b6f9bf0217ebd00239b1b2292feba61272d5dd58d0",
"https://deno.land/std@0.160.0/async/debounce.ts": "dc8b92d4a4fe7eac32c924f2b8d3e62112530db70cadce27042689d82970b350",
"https://deno.land/std@0.160.0/async/deferred.ts": "d8fb253ffde2a056e4889ef7e90f3928f28be9f9294b6505773d33f136aab4e6",
"https://deno.land/std@0.160.0/async/delay.ts": "0419dfc993752849692d1f9647edf13407c7facc3509b099381be99ffbc9d699",
"https://deno.land/std@0.160.0/async/mod.ts": "dd0a8ed4f3984ffabe2fcca7c9f466b7932d57b1864ffee148a5d5388316db6b",
"https://deno.land/std@0.160.0/async/mux_async_iterator.ts": "3447b28a2a582224a3d4d3596bccbba6e85040da3b97ed64012f7decce98d093",
"https://deno.land/std@0.160.0/async/pool.ts": "ef9eb97b388543acbf0ac32647121e4dbe629236899586c4d4311a8770fbb239",
"https://deno.land/std@0.160.0/async/tee.ts": "9af3a3e7612af75861308b52249e167f5ebc3dcfc8a1a4d45462d96606ee2b70",
"https://deno.land/std@0.160.0/bytes/bytes_list.ts": "aba5e2369e77d426b10af1de0dcc4531acecec27f9b9056f4f7bfbf8ac147ab4",
"https://deno.land/std@0.160.0/bytes/equals.ts": "3c3558c3ae85526f84510aa2b48ab2ad7bdd899e2e0f5b7a8ffc85acb3a6043a",
"https://deno.land/std@0.160.0/bytes/mod.ts": "b2e342fd3669176a27a4e15061e9d588b89c1aaf5008ab71766e23669565d179",
"https://deno.land/std@0.160.0/crypto/_fnv/fnv32.ts": "aa9bddead8c6345087d3abd4ef35fb9655622afc333fc41fff382b36e64280b5",
"https://deno.land/std@0.160.0/crypto/_fnv/fnv64.ts": "625d7e7505b6cb2e9801b5fd6ed0a89256bac12b2bbb3e4664b85a88b0ec5bef",
"https://deno.land/std@0.160.0/crypto/_fnv/index.ts": "a8f6a361b4c6d54e5e89c16098f99b6962a1dd6ad1307dbc97fa1ecac5d7060a",
"https://deno.land/std@0.160.0/crypto/_fnv/util.ts": "4848313bed7f00f55be3cb080aa0583fc007812ba965b03e4009665bde614ce3",
"https://deno.land/std@0.160.0/crypto/_wasm_crypto/lib/deno_std_wasm_crypto.generated.mjs": "258b484c2da27578bec61c01d4b62c21f72268d928d03c968c4eb590cb3bd830",
"https://deno.land/std@0.160.0/crypto/_wasm_crypto/mod.ts": "6c60d332716147ded0eece0861780678d51b560f533b27db2e15c64a4ef83665",
"https://deno.land/std@0.160.0/crypto/keystack.ts": "e481eed28007395e554a435e880fee83a5c73b9259ed8a135a75e4b1e4f381f7",
"https://deno.land/std@0.160.0/crypto/mod.ts": "fadedc013b4a86fda6305f1adc6d1c02225834d53cff5d95cc05f62b25127517",
"https://deno.land/std@0.160.0/crypto/timing_safe_equal.ts": "82a29b737bc8932d75d7a20c404136089d5d23629e94ba14efa98a8cc066c73e",
"https://deno.land/std@0.160.0/datetime/formatter.ts": "7c8e6d16a0950f400aef41b9f1eb9168249869776ec520265dfda785d746589e",
"https://deno.land/std@0.160.0/datetime/mod.ts": "ea927ca96dfb28c7b9a5eed5bdc7ac46bb9db38038c4922631895cea342fea87",
"https://deno.land/std@0.160.0/datetime/tokenizer.ts": "7381e28f6ab51cb504c7e132be31773d73ef2f3e1e50a812736962b9df1e8c47",
"https://deno.land/std@0.160.0/encoding/base64.ts": "c57868ca7fa2fbe919f57f88a623ad34e3d970d675bdc1ff3a9d02bba7409db2",
"https://deno.land/std@0.160.0/encoding/base64url.ts": "a5f82a9fa703bd85a5eb8e7c1296bc6529e601ebd9642cc2b5eaa6b38fa9e05a",
"https://deno.land/std@0.160.0/encoding/hex.ts": "4cc5324417cbb4ac9b828453d35aed45b9cc29506fad658f1f138d981ae33795",
"https://deno.land/std@0.160.0/fmt/colors.ts": "9e36a716611dcd2e4865adea9c4bec916b5c60caad4cdcdc630d4974e6bb8bd4",
"https://deno.land/std@0.160.0/io/buffer.ts": "fae02290f52301c4e0188670e730cd902f9307fb732d79c4aa14ebdc82497289",
"https://deno.land/std@0.160.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3",
"https://deno.land/std@0.160.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09",
"https://deno.land/std@0.160.0/path/_util.ts": "d16be2a16e1204b65f9d0dfc54a9bc472cafe5f4a190b3c8471ec2016ccd1677",
"https://deno.land/std@0.160.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633",
"https://deno.land/std@0.160.0/path/glob.ts": "cb5255638de1048973c3e69e420c77dc04f75755524cb3b2e160fe9277d939ee",
"https://deno.land/std@0.160.0/path/mod.ts": "56fec03ad0ebd61b6ab39ddb9b0ddb4c4a5c9f2f4f632e09dd37ec9ebfd722ac",
"https://deno.land/std@0.160.0/path/posix.ts": "6b63de7097e68c8663c84ccedc0fd977656eb134432d818ecd3a4e122638ac24",
"https://deno.land/std@0.160.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9",
"https://deno.land/std@0.160.0/path/win32.ts": "ee8826dce087d31c5c81cd414714e677eb68febc40308de87a2ce4b40e10fb8d",
"https://deno.land/std@0.160.0/testing/_diff.ts": "a23e7fc2b4d8daa3e158fa06856bedf5334ce2a2831e8bf9e509717f455adb2c",
"https://deno.land/std@0.160.0/testing/_format.ts": "cd11136e1797791045e639e9f0f4640d5b4166148796cad37e6ef75f7d7f3832",
"https://deno.land/std@0.160.0/testing/asserts.ts": "1e340c589853e82e0807629ba31a43c84ebdcdeca910c4a9705715dfdb0f5ce8",
"https://deno.land/x/postgres@v0.17.0/client.ts": "348779c9f6a1c75ef1336db662faf08dce7d2101ff72f0d1e341ba1505c8431d",
"https://deno.land/x/postgres@v0.17.0/client/error.ts": "0817583b666fd546664ed52c1d37beccc5a9eebcc6e3c2ead20ada99b681e5f7",
"https://deno.land/x/postgres@v0.17.0/connection/auth.ts": "1070125e2ac4ca4ade36d69a4222d37001903092826d313217987583edd61ce9",
"https://deno.land/x/postgres@v0.17.0/connection/connection.ts": "428ed3efa055870db505092b5d3545ef743497b7b4b72cf8f0593e7dd4788acd",
"https://deno.land/x/postgres@v0.17.0/connection/connection_params.ts": "52bfe90e8860f584b95b1b08c254dde97c3aa763c4b6bee0c80c5930e35459e0",
"https://deno.land/x/postgres@v0.17.0/connection/message.ts": "f9257948b7f87d58bfbfe3fc6e2e08f0de3ef885655904d56a5f73655cc22c5a",
"https://deno.land/x/postgres@v0.17.0/connection/message_code.ts": "466719008b298770c366c5c63f6cf8285b7f76514dadb4b11e7d9756a8a1ddbf",
"https://deno.land/x/postgres@v0.17.0/connection/packet.ts": "050aeff1fc13c9349e89451a155ffcd0b1343dc313a51f84439e3e45f64b56c8",
"https://deno.land/x/postgres@v0.17.0/connection/scram.ts": "0c7a2551fe7b1a1c62dd856b7714731a7e7534ccca10093336782d1bfc5b2bd2",
"https://deno.land/x/postgres@v0.17.0/deps.ts": "f47ccb41f7f97eaad455d94f407ef97146ae99443dbe782894422c869fbba69e",
"https://deno.land/x/postgres@v0.17.0/mod.ts": "a1e18fd9e6fedc8bc24e5aeec3ae6de45e2274be1411fb66e9081420c5e81d7d",
"https://deno.land/x/postgres@v0.17.0/pool.ts": "892db7b5e1787988babecc994a151ebbd7d017f080905cbe9c3d7b44a73032a9",
"https://deno.land/x/postgres@v0.17.0/query/array_parser.ts": "f8a229d82c3801de8266fa2cc4afe12e94fef8d0c479e73655c86ed3667ef33f",
"https://deno.land/x/postgres@v0.17.0/query/decode.ts": "44a4a6cbcf494ed91a4fecae38a57dce63a7b519166f02c702791d9717371419",
"https://deno.land/x/postgres@v0.17.0/query/decoders.ts": "16cb0e60227d86692931e315421b15768c78526e3aeb84e25fcc4111096de9fd",
"https://deno.land/x/postgres@v0.17.0/query/encode.ts": "5f1418a2932b7c2231556e4a5f5f56efef48728014070cfafe7656963f342933",
"https://deno.land/x/postgres@v0.17.0/query/oid.ts": "8c33e1325f34e4ca9f11a48b8066c8cfcace5f64bc1eb17ad7247af4936999e1",
"https://deno.land/x/postgres@v0.17.0/query/query.ts": "edb473cbcfeff2ee1c631272afb25d079d06b66b5853f42492725b03ffa742b6",
"https://deno.land/x/postgres@v0.17.0/query/transaction.ts": "8e75c3ce0aca97da7fe126e68f8e6c08d640e5c8d2016e62cee5c254bebe7fe8",
"https://deno.land/x/postgres@v0.17.0/utils/deferred.ts": "dd94f2a57355355c47812b061a51b55263f72d24e9cb3fdb474c7519f4d61083",
"https://deno.land/x/postgres@v0.17.0/utils/utils.ts": "19c3527ddd5c6c4c49ae36397120274c7f41f9d3cbf479cb36065d23329e9f90"
},
"workspace": {
"dependencies": [
"jsr:@cliffy/command@1.0.0-rc.7",

View File

@ -0,0 +1,53 @@
# 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.

View File

@ -0,0 +1,7 @@
## Chromium Android Wildcard Cookie Behavior
Upon research into Chromium bugs and specs regarding `Domain=.atyg.org`:
- RFC 6265 defines that a cookie with a `Domain` attribute is a "domain cookie" and is sent to subdomains.
- On Android Chrome, when `fetch()` or `window.location.replace` is executed immediately after the `Set-Cookie` header is received, there are known race conditions in the cookie jar sync.
- Further, if a domain has `SameSite=Lax` (which Hono sets by default unless specified), it is strictly blocked on cross-site requests, but `window.location.replace` from `login.atyg.org` to `login.atyg.org/dashboard` or another subdomain is still same-site.
- A critical issue on Android is that sometimes `Domain=.atyg.org` cookies are dropped during immediate redirects if the exact hostname doesn't perfectly align in the OS-level cookie sync, or if there is a conflict with an already existing host-only cookie for `auth.atyg.org` vs `atyg.org`.
- **Hono multiple cookie parsing issue**: As we saw in test 1, Hono's `getCookie` parses the FIRST matching cookie. If `auth.atyg.org` has a stale host-only cookie `session_id=old_stale` and the response sets `Domain=.atyg.org` with `session_id=new_valid`, the browser might send `Cookie: session_id=old_stale; session_id=new_valid`. Hono will read `old_stale`, fail DB lookup, and force a re-login.

View File

@ -0,0 +1,38 @@
import { Hono } from "jsr:@hono/hono@4";
import { getCookie } from "jsr:@hono/hono@4/cookie";
const app = new Hono();
app.get('/', (c) => {
const sessionId = getCookie(c, "session_id");
const allCookies = c.req.header("cookie");
return c.json({
parsedSessionId: sessionId,
rawCookieHeader: allCookies
});
});
const req1 = new Request("http://localhost/", {
headers: {
"Cookie": "session_id=first-uuid; session_id=second-uuid"
}
});
const req2 = new Request("http://localhost/", {
headers: {
"cookie": "session_id=first-uuid; other=123"
}
});
async function run() {
console.log("Test 1: Multiple session_id cookies");
const res1 = await app.fetch(req1);
console.log(await res1.json());
console.log("Test 2: Lowercase cookie header");
const res2 = await app.fetch(req2);
console.log(await res2.json());
}
run();

View File

@ -0,0 +1,4 @@
import { Pool } from "https://deno.land/x/postgres@v0.17.0/mod.ts";
// Instead of real pg connection, let's just create a test that shows how NOW() works vs Date.now()
console.log("Postgres uses the database server's local timezone for NOW() unless explicitly AT TIME ZONE 'UTC' is used or the server runs in UTC.");
console.log("In containerized environments, the Deno container and Postgres container often both default to UTC, but if one differs, NOW() in Postgres could be hours ahead/behind Deno's generated expires_at.");