Compare commits
No commits in common. "b9f4ccd71df44a39d1b204a18cb8c0f7be9cd0e1" and "4b78f70efde39291d5576ae0114ae7f82364d2e0" have entirely different histories.
b9f4ccd71d
...
4b78f70efd
67
deno.lock
generated
67
deno.lock
generated
@ -436,73 +436,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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",
|
||||||
|
|||||||
@ -1,104 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
## 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.
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
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();
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
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.",
|
|
||||||
);
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import type { Context } from "jsr:@hono/hono@4";
|
import type { Context } from "jsr:@hono/hono@4";
|
||||||
import { deleteCookie } from "jsr:@hono/hono@4/cookie";
|
import { getCookie } 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,88 +41,59 @@ 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 sessionMatches = extractAllSessionIds(c);
|
const sessionId = getCookie(c, "session_id");
|
||||||
if (sessionMatches.length === 0) return null;
|
if (!sessionId) return null;
|
||||||
|
|
||||||
// Iterate over each candidate session ID
|
// 1. Try Valkey cache
|
||||||
for (let i = 0; i < sessionMatches.length; i++) {
|
try {
|
||||||
const candidateId = sessionMatches[i];
|
const sessionDataStr = await valkey.get(sessionId);
|
||||||
|
if (sessionDataStr) {
|
||||||
// 1. Try Valkey cache
|
const sessionData = JSON.parse(sessionDataStr);
|
||||||
try {
|
if (sessionData && sessionData.uuid) {
|
||||||
const sessionDataStr = await valkey.get(candidateId);
|
return {
|
||||||
if (sessionDataStr) {
|
userId: sessionData.uuid,
|
||||||
const sessionData = JSON.parse(sessionDataStr);
|
sessionId,
|
||||||
if (sessionData && sessionData.uuid) {
|
username: sessionData.username || "",
|
||||||
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 {
|
|
||||||
userId: sessionData.uuid,
|
|
||||||
sessionId: candidateId,
|
|
||||||
username: sessionData.username || "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (_err) {
|
|
||||||
// Valkey cache miss or connection hiccup - fallback to DB
|
|
||||||
}
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
// Valkey cache miss or connection hiccup - fallback to DB
|
||||||
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
const username = session.username || "";
|
const username = session.username || "";
|
||||||
// Repopulate Valkey in background
|
// Repopulate Valkey in background
|
||||||
try {
|
try {
|
||||||
const ttlSeconds = Math.max(
|
const ttlSeconds = Math.max(
|
||||||
1,
|
1,
|
||||||
Math.floor(
|
Math.floor(
|
||||||
(new Date(session.expires_at).getTime() - Date.now()) / 1000,
|
(new Date(session.expires_at).getTime() - Date.now()) / 1000,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await valkey.setex(
|
await valkey.setex(
|
||||||
candidateId,
|
sessionId,
|
||||||
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) {
|
|
||||||
// Continue to next candidate
|
|
||||||
}
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -659,47 +659,3 @@ 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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@ -12,7 +12,6 @@ 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,
|
||||||
@ -821,13 +820,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 oldSessionIds = extractAllSessionIds(c);
|
const oldSessionId = getCookie(c, "session_id");
|
||||||
if (oldSessionIds.length > 0) {
|
if (oldSessionId) {
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2199,27 +2198,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)
|
return c.json({ error: "Missing or invalid token" }, 401);
|
||||||
const tokens = extractAllSessionIds(c);
|
}
|
||||||
if (tokens.length === 0) {
|
|
||||||
return c.json({ error: "Missing or invalid token" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const t of tokens) {
|
// SIDE EFFECT: Deletes the key in Valkey cache instantly
|
||||||
try {
|
try {
|
||||||
await valkey.del(t);
|
await valkey.del(token);
|
||||||
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${t}`;
|
} catch (_err: unknown) {
|
||||||
} catch (_e) {}
|
return c.json({ error: "Failed to revoke session from cache" }, 500);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// SDK Token Path
|
// Best effort delete from postgres if it's a UUID style session id
|
||||||
try {
|
try {
|
||||||
await valkey.del(token);
|
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);
|
||||||
|
|||||||
@ -5,7 +5,7 @@ your current job is to create, plan, or format a task file, please refer to
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Orchestrator Meta-Prompts (`tasks/path.md`)
|
# Orchestrator Meta-Prompts
|
||||||
|
|
||||||
## 1. Task Planning Template (System Analyst)
|
## 1. Task Planning Template (System Analyst)
|
||||||
|
|
||||||
@ -95,30 +95,3 @@ 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.
|
|
||||||
```
|
|
||||||
14
ui/mod.ts
14
ui/mod.ts
@ -4,7 +4,6 @@ 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,
|
||||||
@ -36,13 +35,13 @@ uiApp.get("/", (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
uiApp.get("/logout", async (c) => {
|
uiApp.get("/logout", async (c) => {
|
||||||
const sessionIds = extractAllSessionIds(c);
|
const sessionId = getCookie(c, "session_id");
|
||||||
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 (sessionIds.length > 0) {
|
if (sessionId) {
|
||||||
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);
|
||||||
@ -50,13 +49,8 @@ uiApp.get("/logout", async (c) => {
|
|||||||
userId = authUser.userId;
|
userId = authUser.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Purge all candidate cookies sent by the browser to ensure ghosts are eradicated
|
await valkey.del(sessionId);
|
||||||
for (const sId of sessionIds) {
|
await sql`DELETE FROM sessions WHERE id = ${sessionId}`;
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user