Compare commits

..

3 Commits

Author SHA1 Message Date
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
b66cc8209c
Merge pull request #24 from mrteye/jules/investigate-mobile-login-cookie-bug-12452066356128611281
chore: Complete Investigation on Mobile Login Cookie Redirect Failure
2026-08-24 21:23:18 -07:00
google-labs-jules[bot]
947b38d81a 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>
2026-08-25 00:31:19 +00:00
10 changed files with 420 additions and 73 deletions

67
deno.lock generated
View File

@ -436,6 +436,73 @@
] ]
} }
}, },
"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/query/types.ts": "a6dc8024867fe7ccb0ba4b4fa403ee5d474c7742174128c8e689c3b5e5eaa933",
"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": { "workspace": {
"dependencies": [ "dependencies": [
"jsr:@cliffy/command@1.0.0-rc.7", "jsr:@cliffy/command@1.0.0-rc.7",

View File

@ -0,0 +1,104 @@
# 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,23 @@
## 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,8 @@
import { Pool as _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.",
);

View File

@ -1,5 +1,5 @@
import type { Context } from "jsr:@hono/hono@4"; import type { Context } from "jsr:@hono/hono@4";
import { getCookie } from "jsr:@hono/hono@4/cookie"; import { deleteCookie } from "jsr:@hono/hono@4/cookie";
import { sqlWrapper } from "./db.ts"; import { sqlWrapper } from "./db.ts";
import { valkey } from "./valkey.ts"; import { valkey } from "./valkey.ts";
@ -41,22 +41,45 @@ export function getCookieDomain(customRpId?: string): string | undefined {
/** /**
* Helper to get authenticated user from session cookie. * Helper to get authenticated user from session cookie.
* Checks Valkey cache first, with automatic PostgreSQL sessions table fallback. * Checks Valkey cache first, with automatic PostgreSQL sessions table fallback.
* Iterates through all session_id cookies to prevent Android Chrome cookie shadowing.
*/ */
/**
* Extracts all session_id tokens from the Cookie header.
* Necessary because Chromium Android can send both a host-only and a wildcard cookie simultaneously.
*/
export function extractAllSessionIds(c: Context): string[] {
const cookieHeader = c.req.header("cookie") || "";
if (!cookieHeader) return [];
return [...cookieHeader.matchAll(/(?:^|;\s*)session_id=([^;]+)/g)]
.map((m) => decodeURIComponent(m[1].trim()))
.filter(Boolean);
}
export async function getAuthenticatedUser( export async function getAuthenticatedUser(
c: Context, c: Context,
): Promise<AuthenticatedUser | null> { ): Promise<AuthenticatedUser | null> {
const sessionId = getCookie(c, "session_id"); const sessionMatches = extractAllSessionIds(c);
if (!sessionId) return null; if (sessionMatches.length === 0) return null;
// Iterate over each candidate session ID
for (let i = 0; i < sessionMatches.length; i++) {
const candidateId = sessionMatches[i];
// 1. Try Valkey cache // 1. Try Valkey cache
try { try {
const sessionDataStr = await valkey.get(sessionId); const sessionDataStr = await valkey.get(candidateId);
if (sessionDataStr) { if (sessionDataStr) {
const sessionData = JSON.parse(sessionDataStr); const sessionData = JSON.parse(sessionDataStr);
if (sessionData && sessionData.uuid) { if (sessionData && sessionData.uuid) {
if (i > 0) {
// A stale ghost cookie was ahead of this valid one.
// Attempt to purge the host-only cookie to heal the browser jar.
deleteCookie(c, "session_id", { path: "/" });
}
return { return {
userId: sessionData.uuid, userId: sessionData.uuid,
sessionId, sessionId: candidateId,
username: sessionData.username || "", username: sessionData.username || "",
}; };
} }
@ -67,11 +90,12 @@ export async function getAuthenticatedUser(
// 2. Fallback to PostgreSQL sessions table // 2. Fallback to PostgreSQL sessions table
try { try {
const nowIso = new Date().toISOString();
const session = await sqlWrapper.sql` const session = await sqlWrapper.sql`
SELECT s.user_id, s.expires_at, u.username SELECT s.user_id, s.expires_at, u.username
FROM sessions s FROM sessions s
JOIN users u ON s.user_id = u.id JOIN users u ON s.user_id = u.id
WHERE s.id = ${sessionId} AND s.expires_at > NOW() WHERE s.id = ${candidateId} AND s.expires_at > ${nowIso}
`.then((res: any) => res[0]); `.then((res: any) => res[0]);
if (session) { if (session) {
@ -85,15 +109,20 @@ export async function getAuthenticatedUser(
), ),
); );
await valkey.setex( await valkey.setex(
sessionId, candidateId,
ttlSeconds, ttlSeconds,
JSON.stringify({ uuid: session.user_id, username }), JSON.stringify({ uuid: session.user_id, username }),
); );
} catch (_e) {} } catch (_e) {}
return { userId: session.user_id, sessionId, username };
if (i > 0) {
deleteCookie(c, "session_id", { path: "/" });
}
return { userId: session.user_id, sessionId: candidateId, username };
} }
} catch (_err) { } catch (_err) {
return null; // Continue to next candidate
}
} }
return null; return null;

View File

@ -659,3 +659,47 @@ Deno.test("App Launchpad Zero-Knowledge Query Logic", async (t) => {
} }
}); });
}); });
Deno.test("Cookie Shadowing & Multi-Cookie Iteration", async (t) => {
await t.step(
"GET /dashboard authenticates when fresh session is shadowed by stale host-only cookie",
async () => {
const mockGet = (key: string) => {
if (key === "fresh_wildcard_session") {
return Promise.resolve(
JSON.stringify({ uuid: "user-uuid", username: "tylerg" }),
);
}
return Promise.resolve(null);
};
valkey.get = mockGet as any;
const originalSql = sqlWrapper.sql;
sqlWrapper.sql = () => Promise.resolve([]);
try {
const res = await app.request("/dashboard", {
method: "GET",
headers: {
// Android Chrome sends stale host cookie first, then wildcard cookie
Cookie:
"session_id=stale_host_cookie; session_id=fresh_wildcard_session",
},
});
assertEquals(res.status, 200);
const text = await res.text();
assert(text.includes("Application Launchpad"));
// Verify Set-Cookie header attempts to delete the host-only cookie
const setCookieHeader = res.headers.get("set-cookie") || "";
assert(
setCookieHeader.includes("session_id=") &&
(setCookieHeader.includes("Max-Age=0") ||
setCookieHeader.includes("Expires=")),
);
} finally {
sqlWrapper.sql = originalSql;
}
},
);
});

View File

@ -12,6 +12,7 @@ import type {
RegistrationResponseJSON, RegistrationResponseJSON,
} from "jsr:@simplewebauthn/server@13"; } from "jsr:@simplewebauthn/server@13";
import { deleteCookie, getCookie, setCookie } from "jsr:@hono/hono@4/cookie"; import { deleteCookie, getCookie, setCookie } from "jsr:@hono/hono@4/cookie";
import { extractAllSessionIds } from "./auth-session.ts";
import { import {
decodeBase64Url, decodeBase64Url,
encodeBase64Url, encodeBase64Url,
@ -820,13 +821,13 @@ app.post("/api/login/verify", async (c) => {
return c.json({ error: "Internal server error" }, 500); return c.json({ error: "Internal server error" }, 500);
} }
const oldSessionId = getCookie(c, "session_id"); const oldSessionIds = extractAllSessionIds(c);
if (oldSessionId) { if (oldSessionIds.length > 0) {
for (const old of oldSessionIds) {
try { try {
await valkey.del(oldSessionId); await valkey.del(old);
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${oldSessionId}`; await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${old}`;
} catch (_e) { } catch (_e) {}
// Best effort cleanup
} }
} }
@ -2198,27 +2199,27 @@ app.post("/api/revoke", async (c) => {
const authHeader = c.req.header("Authorization"); const authHeader = c.req.header("Authorization");
if (authHeader && authHeader.startsWith("Bearer ")) { if (authHeader && authHeader.startsWith("Bearer ")) {
token = authHeader.split(" ")[1]; token = authHeader.split(" ")[1];
} else {
// Fallback to session cookie (Web UI)
token = getCookie(c, "session_id") || "";
} }
if (!token) { if (!token) {
// Fallback to session cookie (Web UI)
const tokens = extractAllSessionIds(c);
if (tokens.length === 0) {
return c.json({ error: "Missing or invalid token" }, 401); return c.json({ error: "Missing or invalid token" }, 401);
} }
// SIDE EFFECT: Deletes the key in Valkey cache instantly for (const t of tokens) {
try {
await valkey.del(t);
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${t}`;
} catch (_e) {}
}
} else {
// SDK Token Path
try { try {
await valkey.del(token); await valkey.del(token);
} catch (_err: unknown) {
return c.json({ error: "Failed to revoke session from cache" }, 500);
}
// Best effort delete from postgres if it's a UUID style session id
try {
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${token}`; await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${token}`;
} catch (_e) { } catch (_e) {}
// ignore
} }
const cookieDomain = getCookieDomain(rpID); const cookieDomain = getCookieDomain(rpID);

View File

@ -5,7 +5,7 @@ your current job is to create, plan, or format a task file, please refer to
--- ---
# Orchestrator Meta-Prompts # Orchestrator Meta-Prompts (`tasks/path.md`)
## 1. Task Planning Template (System Analyst) ## 1. Task Planning Template (System Analyst)
@ -95,3 +95,30 @@ Provide a structured critique report with:
- **Strengths:** Key architectural insights captured by the author. - **Strengths:** Key architectural insights captured by the author.
- **Identified Gaps & Refinements:** Concrete adjustments to incorporate into the task file before implementation starts. - **Identified Gaps & Refinements:** Concrete adjustments to incorporate into the task file before implementation starts.
``` ```
---
## 5. Architecture & Root-Cause Investigation Template (Investigator)
_Use this template to instruct an agent to deeply investigate complex bugs,
reproduction anomalies, or distributed edge cases without modifying production
code._
```text
**Role:** Act as a Principal Systems & Security Investigator.
**The Scope:** [Describe anomaly, bug symptoms, error logs, and affected components.]
**Directives:**
- **STRICT CONSTRAINT:** Experiment, research, and report ONLY. Do not modify existing production code or create PRs with production changes.
- Place all reproduction test scripts and experiments in `scratch/` or hermetic test harnesses.
**Your Task:**
1. Formulate clear, falsifiable hypotheses based on observed logs and symptoms.
2. Design and execute minimal reproduction scripts or benchmarks to test each hypothesis in isolation.
3. Trace data flows through database, caching layers, ingress proxies, and client runtime engines.
4. Document the definitive root cause and rank potential architectural solutions from most reliable to least, with explicit rationale and trade-offs.
**Deliverable:**
Author an investigative report in `scratch/INVESTIGATIVE_REPORT.md` detailing verified findings, discarded hypotheses, and ranked solutions.
```

View File

@ -4,6 +4,7 @@ import { deleteCookie, getCookie } from "jsr:@hono/hono@4/cookie";
import { sql } from "../server/db.ts"; import { sql } from "../server/db.ts";
import { valkey } from "../server/valkey.ts"; import { valkey } from "../server/valkey.ts";
import { import {
extractAllSessionIds,
getAuthenticatedUser, getAuthenticatedUser,
getCookieDomain, getCookieDomain,
isGlobalAdmin, isGlobalAdmin,
@ -35,13 +36,13 @@ uiApp.get("/", (c) => {
}); });
uiApp.get("/logout", async (c) => { uiApp.get("/logout", async (c) => {
const sessionId = getCookie(c, "session_id"); const sessionIds = extractAllSessionIds(c);
const rawRedirect = c.req.query("redirect"); const rawRedirect = c.req.query("redirect");
let safeRedirect = null; let safeRedirect = null;
const userIp = c.req.header("x-forwarded-for") || "127.0.0.1"; const userIp = c.req.header("x-forwarded-for") || "127.0.0.1";
let userId = null; let userId = null;
if (sessionId) { if (sessionIds.length > 0) {
try { try {
// Get user ID for auditing before we delete the session // Get user ID for auditing before we delete the session
const authUser = await getAuthenticatedUser(c); const authUser = await getAuthenticatedUser(c);
@ -49,8 +50,13 @@ uiApp.get("/logout", async (c) => {
userId = authUser.userId; userId = authUser.userId;
} }
await valkey.del(sessionId); // Purge all candidate cookies sent by the browser to ensure ghosts are eradicated
await sql`DELETE FROM sessions WHERE id = ${sessionId}`; for (const sId of sessionIds) {
try {
await valkey.del(sId);
await sql`DELETE FROM sessions WHERE id = ${sId}`;
} catch (_e) {}
}
} catch (_e) { } catch (_e) {
// Best effort cleanup // Best effort cleanup
} }