From 42cb9d814318dd56b1511f893452f948562f5943 Mon Sep 17 00:00:00 2001 From: jzhao234 Date: Tue, 15 Sep 2026 14:43:10 +0000 Subject: [PATCH] Fleet sessions live one day, idle out after twelve hours (ADR-0064) The elcano_session cookie was a stateless HMAC valid for fourteen days from login with no notion of inactivity: a laptop left open on Friday was still signed in the next Friday. Elcano Auth v2 makes Fleet's session an application session that renews silently through the OIDC handoff while the user's 30-day central session is live, so its job is now to bound a stolen cookie and re-check the account with Auth, not to decide how often people log in. It now carries an idle deadline next to the absolute one, both enforced on every request, and the proxy re-mints the cookie on activity. Problem SessionPayload had a single exp set to mint + 14 days. Nothing recorded activity, so idle sessions never ended early, and fourteen days is out of line with the one-day / twelve-hour convention Explorer and Lens now follow (auth/docs/AUTH_V2_IMPLEMENTATION.md, "Application session conventions"). Being stateless, Fleet had no last_seen_at to touch, so an idle limit could only live inside the cookie. Fix - SessionPayload gains `idle`. exp is the absolute deadline (sessionAbsoluteSeconds, one day), copied on every re-mint and never extended; idle is min(now + sessionIdleSeconds (12h), exp). - verifySessionToken refuses a token whose idle or exp has passed and refuses a signed token with no idle claim. Pre-deploy cookies are not grandfathered, the same call ADR-0041 made for claimless cookies: a cookie signed for fourteen days flat is exactly what this removes. Central-Auth users are signed back in without a prompt; password users log in once more. - refreshSessionCookie re-signs the payload with a later idle when the last mint is over sessionTouchSeconds (60) old, preserving email, exp, epoch, source, issuer and subject, with Max-Age set to the remaining absolute life and Secure following the request. It does nothing for elcano_auth sessions (Fleet cannot re-mint Auth's cookie), under a minute, or once idle already sits on exp. One minute is the touch convention shared with Auth, Explorer and Lens. - The request proxy calls it on every authenticated pass-through, pages and /api alike, and never on redirects, 401s, public routes or bearer-only requests. - Both mint paths share signSessionPayload so neither can emit a cookie the verifier refuses; their cookie Max-Age is the absolute lifetime. - The mocked e2e cookie minter mirrors the new claim. - ADR-0064 records the decision and amends ADR-0041; DEPLOYMENT.md's "Ending a session" note no longer says fourteen days. Tests - auth.test.ts: mint deadlines; idle refusal ahead of exp; refusal of a signed token without idle; no re-mint under a minute; re-mint after a minute keeps every identity claim and sets httpOnly/lax/secure/path and Max-Age = remaining absolute; activity never moves exp; re-minting stops once idle is capped; elcano_auth sessions untouched; Secure follows a plain-HTTP dev request. Each fails on the previous code. - proxy.test.ts: the cookie is touched exactly once with (request, response, session) on an authenticated pass-through and never on redirects, 401s, public routes or bearer requests. - npm run typecheck, npm run lint (oxlint), npx vitest run (full web suite). npm run build and the mocked Playwright suite are left to CI. Co-Authored-By: Claude Fable 5.1 --- docs/DEPLOYMENT.md | 5 +- .../adr/0064-application-session-lifetimes.md | 87 +++++++++ docs/adr/README.md | 1 + web/e2e/mocked/_session.ts | 13 +- web/src/app/api/auth/login/route.ts | 4 +- web/src/app/api/auth/oidc/callback/route.ts | 4 +- web/src/app/lib/auth.test.ts | 166 +++++++++++++++++- web/src/app/lib/auth.ts | 110 ++++++++++-- web/src/proxy.test.ts | 25 +++ web/src/proxy.ts | 11 +- 10 files changed, 398 insertions(+), 28 deletions(-) create mode 100644 docs/adr/0064-application-session-lifetimes.md diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index e16940ac..7642c3c7 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -331,8 +331,9 @@ each piece yourself): > decides *who may use chat*. A stand-alone deploy needs none of this; users > just log in with email + password. > - > **Ending a session.** The cookie is a stateless HMAC valid for 14 days, so - > there is nothing to delete server-side — revocation works by invalidating + > **Ending a session.** The cookie is a stateless HMAC that lives at most one + > day and lapses after twelve idle hours ([ADR-0064](adr/0064-application-session-lifetimes.md)), + > so there is nothing to delete server-side — revocation works by invalidating > what the cookie *claims* (the design and its carve-outs: > [`SESSION-EPOCH.md`](SESSION-EPOCH.md)). Three levers, narrowest first: > - **One account** — reset its password (Settings → Admin → "Users & roles", diff --git a/docs/adr/0064-application-session-lifetimes.md b/docs/adr/0064-application-session-lifetimes.md new file mode 100644 index 00000000..087dba2d --- /dev/null +++ b/docs/adr/0064-application-session-lifetimes.md @@ -0,0 +1,87 @@ +# ADR-0064: Fleet sessions live one day, idle out after twelve hours, and are re-minted on activity + +- **Status:** Accepted +- **Date:** 2026-09-15 +- **Deciders:** fleet maintainers, Elcano Auth owner + +## Context + +The `elcano_session` cookie ([ADR-0014](0014-oidc-sso-in-nextjs.md), +[ADR-0041](0041-mandatory-session-epoch-claim.md)) was a stateless HMAC over +`{email, exp, epoch, …}` valid for fourteen days from mint, with no notion of +inactivity: a laptop left open on Friday was still signed in the following +Friday, and the only thing that ended a session early was a password reset or a +central logout event moving the epoch. + +Elcano Auth v2 changed what this cookie is for. Users now hold a **central** +session at the auth service (30 days absolute, 7 days idle) and each +application mints its own session from it through the OIDC code handoff. When +an application session lapses while the central one is live, the user is +redirected through the handoff and signed back in without a prompt. So the +application session no longer decides how often people log in. It decides two +other things: how long a stolen application cookie stays useful, and how often +the application re-checks with Auth that the account is still enabled and the +password unchanged. Both want a short number. Explorer and Lens settled on one +day absolute and twelve hours idle, and the Auth owner recorded that as the +convention for every Elcano application session +(`auth/docs/AUTH_V2_IMPLEMENTATION.md`, "Application session conventions"). + +Fleet was the odd one out, and it is stateless: there is no `last_seen_at` row +to touch, so an idle limit has to live in the cookie itself. + +## Decision + +**The `elcano_session` cookie carries an `idle` deadline alongside `exp`, both +are enforced on every request, and the proxy re-mints the cookie with a later +`idle` when the last mint is more than a minute old.** + +- `exp` is the absolute deadline, one day from mint (`sessionAbsoluteSeconds`). + It is copied on every re-mint, never extended. `idle` is + `min(now + 12h, exp)` (`sessionIdleSeconds`). +- `verifySessionToken` refuses a token whose `idle` or `exp` has passed, and + refuses a correctly signed token with **no** `idle` claim. Pre-deploy cookies + are not grandfathered, for the same reason ADR-0041 did not grandfather + claimless cookies: one signed for fourteen days flat is exactly what this + decision removes. +- `refreshSessionCookie` runs in the request proxy on every authenticated + pass-through (pages and `/api/*` alike). It re-signs the payload with the new + `idle`, keeps `email`, `exp`, `epoch`, `source`, `issuer`, `subject` + unchanged, and sets the cookie with `Max-Age` equal to the remaining absolute + life. It does nothing when the deadline would move by less than + `sessionTouchSeconds` (60), so a page's burst of requests costs one + `Set-Cookie`, and nothing once `idle` already sits on `exp`. One minute is + the Elcano touch convention shared with Auth, Explorer, and Lens. +- Both mint paths (password form, OIDC callback) go through one + `signSessionPayload`, so neither can produce a cookie the verifier refuses. +- The auth service's own `elcano_auth` cookie (magic-link path) is untouched: + Fleet holds only its public key and cannot re-mint it. Its lifetime is Auth's + decision. + +## Enforcement + +- `web/src/app/lib/auth.test.ts` — mint deadlines; idle refusal ahead of the + absolute deadline; refusal of a signed token without `idle`; no re-mint + under a minute; re-mint after a minute preserves every identity claim and + sets the expected cookie attributes; activity never moves `exp`; re-minting + stops once `idle` is capped at `exp`; `elcano_auth` sessions are left alone; + the `Secure` flag follows the request. +- `web/src/proxy.test.ts` — the proxy touches the cookie exactly once on an + authenticated pass-through and never on redirects, 401s, public routes, or + bearer-only requests. + +## Consequences + +- **Every logged-in user is signed out once at deploy.** Central-Auth users + are signed back in by the handoff without a prompt; password users log in + once more. +- A stolen Fleet cookie is worth at most one day, and at most twelve hours if + the victim stops using it. Before, fourteen days. +- Fleet re-runs the OIDC handoff at least daily per user, so a disabled Auth + account or rotated password is caught within a day even if a back-channel + logout delivery were lost. +- The proxy now writes a `Set-Cookie` on roughly one authenticated request per + user per minute. It is a signing operation on an already-imported HMAC key; + no storage is involved and the tier stays stateless. +- Deployments that want different numbers change the two constants; they are + deliberately not settings, because the values are a cross-service + convention rather than a per-box policy. diff --git a/docs/adr/README.md b/docs/adr/README.md index db7089be..e575e0ed 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -72,3 +72,4 @@ reviewable, and citable. Each record names the file or test that enforces it. | [0061](0061-retire-the-changelog.md) | Retire `CHANGELOG.md`; the PR is the record and the release notes are generated | Accepted; extends ADR-0059 | | [0062](0062-trunk-based-development.md) | Trunk-based development: `main` is the only branch, every PR squash-merges into it, one CI lane | Accepted; amends ADR-0059 and ADR-0061 | | [0063](0063-remove-the-rampart-pii-engine.md) | Remove the Rampart PII engine, its one-click installer and the `scripts/rampart-service` npm tree; PII redaction keeps the built-in pattern engine | Accepted; amends ADR-0028 and ADR-0036 | +| [0064](0064-application-session-lifetimes.md) | Fleet sessions live one day, idle out after twelve hours, and are re-minted on activity | Accepted; amends ADR-0041 | diff --git a/web/e2e/mocked/_session.ts b/web/e2e/mocked/_session.ts index 64b384ef..59bdb7b9 100644 --- a/web/e2e/mocked/_session.ts +++ b/web/e2e/mocked/_session.ts @@ -19,16 +19,19 @@ function b64url(buf: Buffer): string { } // ── elcano_session: HMAC cookie (password path) ──────────────────────────── -// Mirrors createSessionToken: base64url(JSON{email,exp,epoch}) + "." + +// Mirrors createSessionToken: base64url(JSON{email,exp,idle,epoch}) + "." + // base64url(HMAC-SHA256(secret, payload)). // -// The epoch claim is mandatory — verifySessionToken refuses a token without one -// — but its VALUE only matters to the Go tier, which this suite mocks away, so -// any stand-in works here. +// The epoch and idle claims are mandatory — verifySessionToken refuses a token +// missing either (ADR-0041, ADR-0064) — but the epoch's VALUE only matters to +// the Go tier, which this suite mocks away, so any stand-in works here. exp is +// the one-day absolute deadline and idle the twelve-hour idle deadline. export function mintSessionToken(email: string): string { + const now = Math.floor(Date.now() / 1000); const payload = JSON.stringify({ email: email.toLowerCase(), - exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24, + exp: now + 60 * 60 * 24, + idle: now + 60 * 60 * 12, epoch: "e2e-session-epoch", }); const encodedPayload = b64url(Buffer.from(payload, "utf8")); diff --git a/web/src/app/api/auth/login/route.ts b/web/src/app/api/auth/login/route.ts index 9c3f5744..5e09f553 100644 --- a/web/src/app/api/auth/login/route.ts +++ b/web/src/app/api/auth/login/route.ts @@ -4,7 +4,7 @@ import { createSessionToken, getRedirectUrl, getSessionCookieName, - sessionMaxAgeSeconds, + sessionAbsoluteSeconds, isSecureRequest, } from "@/app/lib/auth"; import { chatServerFetch, fetchSessionEpoch } from "@/app/lib/chatServer"; @@ -83,7 +83,7 @@ export async function POST(request: NextRequest) { httpOnly: true, sameSite: "lax", secure: isSecureRequest(request), - maxAge: sessionMaxAgeSeconds, + maxAge: sessionAbsoluteSeconds, path: "/", }); return NextResponse.redirect(getRedirectUrl(request, "/"), { status: 303 }); diff --git a/web/src/app/api/auth/oidc/callback/route.ts b/web/src/app/api/auth/oidc/callback/route.ts index 8c167a08..03caf1f3 100644 --- a/web/src/app/api/auth/oidc/callback/route.ts +++ b/web/src/app/api/auth/oidc/callback/route.ts @@ -4,7 +4,7 @@ import { getRedirectUrl, getSessionCookieName, isSecureRequest, - sessionMaxAgeSeconds, + sessionAbsoluteSeconds, } from "@/app/lib/auth"; import { fetchExternalSessionEpoch } from "@/app/lib/chatServer"; import { @@ -144,7 +144,7 @@ export async function GET(request: NextRequest): Promise { httpOnly: true, sameSite: "lax", secure, - maxAge: sessionMaxAgeSeconds, + maxAge: sessionAbsoluteSeconds, path: "/", }); clearTempCookies(res, secure); diff --git a/web/src/app/lib/auth.test.ts b/web/src/app/lib/auth.test.ts index f52a6687..c5cfd78b 100644 --- a/web/src/app/lib/auth.test.ts +++ b/web/src/app/lib/auth.test.ts @@ -1,4 +1,5 @@ -import { beforeAll, afterEach, describe, expect, it } from "vitest"; +import { beforeAll, afterEach, describe, expect, it, vi } from "vitest"; +import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; // Exercises the auth lib: the Ed25519 verifier (verifyElcanoToken, mirrors @@ -286,3 +287,166 @@ describe("getRedirectUrl / isSecureRequest — canonical origin vs forwarded hea expect(auth.isSecureRequest(request)).toBe(true); }); }); + +describe("HMAC session lifetimes (ADR-0064: one day absolute, twelve hours idle)", () => { + const HOUR = 60 * 60; + const T0 = 1_800_000_000; // fixed mint instant, unix seconds + + function decodePayload(token: string) { + const body = token.split(".")[0].replace(/-/g, "+").replace(/_/g, "/"); + return JSON.parse(atob(body.padEnd(Math.ceil(body.length / 4) * 4, "="))) as Record; + } + + // signWithSecret mints a token with an arbitrary payload, the way a pre-ADR + // build would have, so the tests can present claims the library never emits. + async function signWithSecret(payload: object) { + const body = toBase64Url(enc.encode(JSON.stringify(payload))); + const key = await crypto.subtle.importKey("raw", enc.encode(SECRET), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + const sig = new Uint8Array(await crypto.subtle.sign("HMAC", key, enc.encode(body))); + return `${body}.${toBase64Url(sig)}`; + } + + function at(seconds: number) { + vi.setSystemTime(seconds * 1000); + } + + function requestAndResponse(secure = true) { + const request = { + headers: new Headers(secure ? { "x-forwarded-proto": "https" } : {}), + nextUrl: new URL(secure ? "https://chat.example.com/chat" : "http://localhost:3000/chat"), + } as unknown as NextRequest; + return { request, response: NextResponse.next() }; + } + + afterEach(() => { + vi.useRealTimers(); + }); + + it("mints exp one day out and idle twelve hours out", async () => { + vi.useFakeTimers(); + at(T0); + const payload = decodePayload(await auth.createSessionToken("bob@x.com", "epoch-1")); + expect(payload.exp).toBe(T0 + 24 * HOUR); + expect(payload.idle).toBe(T0 + 12 * HOUR); + expect(auth.sessionAbsoluteSeconds).toBe(24 * HOUR); + expect(auth.sessionIdleSeconds).toBe(12 * HOUR); + }); + + it("refuses a session idle for twelve hours even though its absolute deadline is ahead", async () => { + vi.useFakeTimers(); + at(T0); + const token = await auth.createSessionToken("bob@x.com", "epoch-1"); + at(T0 + 12 * HOUR - 1); + expect(await auth.verifySessionToken(token)).not.toBeNull(); + at(T0 + 12 * HOUR + 1); + expect(await auth.verifySessionToken(token)).toBeNull(); + }); + + it("refuses a correctly signed token with no idle claim (pre-ADR cookies are not grandfathered)", async () => { + const token = await signWithSecret({ email: "bob@x.com", exp: future, epoch: "epoch-1" }); + expect(await auth.verifySessionToken(token)).toBeNull(); + const withIdle = await signWithSecret({ email: "bob@x.com", exp: future, idle: future, epoch: "epoch-1" }); + expect(await auth.verifySessionToken(withIdle)).not.toBeNull(); + }); + + it("does not re-mint when the last mint is under a minute old", async () => { + vi.useFakeTimers(); + at(T0); + const token = await auth.createSessionToken("bob@x.com", "epoch-1"); + at(T0 + 30); + const session = await auth.getSessionFromRequest(reqWith({ [auth.getSessionCookieName()]: token })); + const { request, response } = requestAndResponse(); + expect(await auth.refreshSessionCookie(request, response, session!)).toBeNull(); + expect(response.cookies.get(auth.getSessionCookieName())).toBeUndefined(); + }); + + it("re-mints after a minute: idle moves forward, exp and every identity claim are copied", async () => { + vi.useFakeTimers(); + at(T0); + const token = await auth.createOidcSessionToken("Bob@x.com", "epoch-9", "https://auth.example.com", "sub-1"); + at(T0 + 61); + const session = await auth.getSessionFromRequest(reqWith({ [auth.getSessionCookieName()]: token })); + const { request, response } = requestAndResponse(); + const refreshed = await auth.refreshSessionCookie(request, response, session!); + expect(refreshed).not.toBeNull(); + const payload = decodePayload(refreshed!); + expect(payload).toMatchObject({ + email: "bob@x.com", + exp: T0 + 24 * HOUR, + idle: T0 + 61 + 12 * HOUR, + epoch: "epoch-9", + source: "oidc", + issuer: "https://auth.example.com", + subject: "sub-1", + }); + const cookie = response.cookies.get(auth.getSessionCookieName()); + expect(cookie?.value).toBe(refreshed); + expect(cookie).toMatchObject({ httpOnly: true, sameSite: "lax", secure: true, path: "/" }); + expect(cookie?.maxAge).toBe(24 * HOUR - 61); + expect(await auth.verifySessionToken(refreshed)).toMatchObject({ source: "oidc", subject: "sub-1" }); + }); + + it("activity never extends the absolute deadline", async () => { + vi.useFakeTimers(); + at(T0); + let token = await auth.createSessionToken("bob@x.com", "epoch-1"); + // Touch at 11h (idle -> 23h) and 22h (idle -> capped at exp, 24h). + for (const [step, wantIdle] of [ + [11 * HOUR, T0 + 23 * HOUR], + [22 * HOUR, T0 + 24 * HOUR], + ] as const) { + at(T0 + step); + const session = await auth.getSessionFromRequest(reqWith({ [auth.getSessionCookieName()]: token })); + expect(session).not.toBeNull(); + const { request, response } = requestAndResponse(); + const refreshed = await auth.refreshSessionCookie(request, response, session!); + expect(refreshed).not.toBeNull(); + expect(decodePayload(refreshed!)).toMatchObject({ exp: T0 + 24 * HOUR, idle: wantIdle }); + token = refreshed!; + } + at(T0 + 24 * HOUR - 1); + expect(await auth.verifySessionToken(token)).not.toBeNull(); + at(T0 + 24 * HOUR + 1); + expect(await auth.verifySessionToken(token)).toBeNull(); + }); + + it("stops re-minting once idle already sits on the absolute deadline", async () => { + vi.useFakeTimers(); + at(T0); + const minted = await auth.createSessionToken("bob@x.com", "epoch-1"); + at(T0 + 11 * HOUR); + const first = requestAndResponse(); + const s1 = await auth.getSessionFromRequest(reqWith({ [auth.getSessionCookieName()]: minted })); + const t1 = await auth.refreshSessionCookie(first.request, first.response, s1!); + at(T0 + 22 * HOUR + 30 * 60); + const second = requestAndResponse(); + const s2 = await auth.getSessionFromRequest(reqWith({ [auth.getSessionCookieName()]: t1! })); + const t2 = await auth.refreshSessionCookie(second.request, second.response, s2!); + expect(decodePayload(t2!).idle).toBe(T0 + 24 * HOUR); + at(T0 + 22 * HOUR + 35 * 60); + const third = requestAndResponse(); + const s3 = await auth.getSessionFromRequest(reqWith({ [auth.getSessionCookieName()]: t2! })); + expect(s3).not.toBeNull(); + expect(await auth.refreshSessionCookie(third.request, third.response, s3!)).toBeNull(); + expect(third.response.cookies.get(auth.getSessionCookieName())).toBeUndefined(); + }); + + it("leaves elcano_auth sessions alone: Fleet cannot re-mint the auth service's cookie", async () => { + const token = await makeToken(priv, { email: "carol@elcanotek.com", exp: future }); + const session = await auth.getSessionFromRequest(reqWith({ [auth.getElcanoCookieName()]: token })); + const { request, response } = requestAndResponse(); + expect(await auth.refreshSessionCookie(request, response, session!)).toBeNull(); + expect(response.cookies.get(auth.getSessionCookieName())).toBeUndefined(); + }); + + it("mints an insecure cookie on a plain-HTTP dev request", async () => { + vi.useFakeTimers(); + at(T0); + const token = await auth.createSessionToken("bob@x.com", "epoch-1"); + at(T0 + 120); + const session = await auth.getSessionFromRequest(reqWith({ [auth.getSessionCookieName()]: token })); + const { request, response } = requestAndResponse(false); + await auth.refreshSessionCookie(request, response, session!); + expect(response.cookies.get(auth.getSessionCookieName())?.secure).toBe(false); + }); +}); diff --git a/web/src/app/lib/auth.ts b/web/src/app/lib/auth.ts index 5d4a45c9..3338ff3e 100644 --- a/web/src/app/lib/auth.ts +++ b/web/src/app/lib/auth.ts @@ -12,7 +12,21 @@ import type { NextRequest } from "next/server"; // Either valid cookie is a session; the membership check is enforced // downstream by chat-server (403 not_a_member) for elcano_auth users. const sessionCookieName = "elcano_session"; -export const sessionMaxAgeSeconds = 60 * 60 * 24 * 14; +// Session lifetimes (ADR-0064). The Fleet session is an APPLICATION session in +// the Elcano two-layer model: while the user's central Auth session (30 days) +// is live, an expired Fleet session costs them only a redirect through the +// OIDC handoff, so these limits bound a stolen cookie and force a daily +// re-check of the account rather than deciding how often people log in. +// One day absolute, twelve hours idle is the convention for every Elcano +// application session (Explorer and Lens use the same numbers). +export const sessionAbsoluteSeconds = 60 * 60 * 24; +export const sessionIdleSeconds = 60 * 60 * 12; +// A request re-mints the cookie (pushing the idle deadline out) only when the +// previous mint is more than this old, so a page's burst of requests costs one +// Set-Cookie instead of one per request. The idle limit therefore behaves as +// "twelve hours minus at most one minute", never longer. One minute is the +// Elcano convention; keep it a constant, not a setting. +export const sessionTouchSeconds = 60; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); @@ -35,11 +49,19 @@ export type Session = { epoch?: string; issuer?: string; subject?: string; + // idle is the HMAC cookie's idle deadline (unix seconds); refreshSessionCookie + // pushes it out on activity. Absent for elcano sessions, which Fleet cannot + // re-mint. + idle?: number; }; type SessionPayload = { email: string; + // exp is the absolute deadline: fixed at mint, never extended by activity. exp: number; + // idle is the idle deadline: min(now + sessionIdleSeconds, exp), moved + // forward by refreshSessionCookie while the user stays active. + idle: number; epoch: string; source?: "password" | "oidc"; issuer?: string; @@ -91,18 +113,28 @@ async function signPayload(payload: string) { return bytesToBase64Url(new Uint8Array(signature)); } +async function signSessionPayload(payload: SessionPayload) { + const encodedPayload = encodePayload(JSON.stringify(payload)); + const signature = await signPayload(encodedPayload); + return `${encodedPayload}.${signature}`; +} + +// freshDeadlines returns the exp/idle pair for a session minted now. The idle +// deadline never passes the absolute one. +function freshDeadlines(nowSeconds: number) { + const exp = nowSeconds + sessionAbsoluteSeconds; + return { exp, idle: Math.min(nowSeconds + sessionIdleSeconds, exp) }; +} + // createSessionToken mints the HMAC cookie. `epoch` is the account's current // chat-server session epoch (fetchSessionEpoch) and is mandatory: a cookie // without it is refused by verifySessionToken below, so no mint path may skip it. export async function createSessionToken(email: string, epoch: string) { - const payload = JSON.stringify({ + return signSessionPayload({ email: email.toLowerCase(), - exp: Math.floor(Date.now() / 1000) + sessionMaxAgeSeconds, + ...freshDeadlines(Math.floor(Date.now() / 1000)), epoch, - } satisfies SessionPayload); - const encodedPayload = encodePayload(payload); - const signature = await signPayload(encodedPayload); - return `${encodedPayload}.${signature}`; + }); } export async function createOidcSessionToken( @@ -111,17 +143,57 @@ export async function createOidcSessionToken( issuer: string, subject: string, ) { - const payload = JSON.stringify({ + return signSessionPayload({ email: email.toLowerCase(), - exp: Math.floor(Date.now() / 1000) + sessionMaxAgeSeconds, + ...freshDeadlines(Math.floor(Date.now() / 1000)), epoch, source: "oidc", issuer, subject, - } satisfies SessionPayload); - const encodedPayload = encodePayload(payload); - const signature = await signPayload(encodedPayload); - return `${encodedPayload}.${signature}`; + }); +} + +// refreshSessionCookie implements the idle limit for the HMAC cookie. A +// stateless cookie has no server-side last_seen_at to touch, so "activity" +// is recorded by re-minting the cookie with a later idle deadline. It is +// called by the request proxy on every authenticated pass-through and does +// nothing unless the session is an HMAC one whose idle deadline can move by +// at least sessionTouchSeconds; the absolute deadline is copied, never +// extended. Returns the new token when a cookie was set, for tests. +export async function refreshSessionCookie( + request: NextRequest, + response: NextResponse, + session: Session, +): Promise { + if (session.source === "elcano" || session.idle === undefined || !session.epoch) { + return null; + } + const now = Math.floor(Date.now() / 1000); + const idle = Math.min(now + sessionIdleSeconds, session.exp); + if (idle - session.idle < sessionTouchSeconds) { + return null; + } + const token = await signSessionPayload({ + email: session.email, + exp: session.exp, + idle, + epoch: session.epoch, + ...(session.source === "oidc" + ? { source: "oidc" as const, issuer: session.issuer, subject: session.subject } + : {}), + }); + response.cookies.set({ + name: sessionCookieName, + value: token, + httpOnly: true, + sameSite: "lax", + secure: isSecureRequest(request), + // The browser drops the cookie at the absolute deadline even if the idle + // deadline inside it is later; the server enforces both regardless. + maxAge: session.exp - now, + path: "/", + }); + return token; } export async function verifySessionToken(token: string | undefined | null) { @@ -148,7 +220,16 @@ export async function verifySessionToken(token: string | undefined | null) { } const payload = JSON.parse(decodePayload(encodedPayload)) as SessionPayload; - if (!payload.email || payload.exp * 1000 < Date.now()) { + const now = Date.now(); + if (!payload.email || typeof payload.exp !== "number" || payload.exp * 1000 < now) { + return null; + } + // No idle claim means the cookie predates the idle limit (ADR-0064) and was + // signed for fourteen days flat. It is not grandfathered: honouring it would + // keep every pre-deploy cookie alive for the rest of those fourteen days. + // Users with a live central Auth session are signed back in by the OIDC + // handoff without a prompt; password users log in once more. + if (typeof payload.idle !== "number" || payload.idle * 1000 < now) { return null; } // No epoch claim means the cookie predates per-user session revocation, and @@ -306,6 +387,7 @@ async function resolveSession( source: hmac.source === "oidc" ? "oidc" : "password", issuer: hmac.issuer, subject: hmac.subject, + idle: hmac.idle, }; } diff --git a/web/src/proxy.test.ts b/web/src/proxy.test.ts index 3bd9ff2b..f82428ac 100644 --- a/web/src/proxy.test.ts +++ b/web/src/proxy.test.ts @@ -9,12 +9,14 @@ import { NextRequest } from "next/server"; // moc's username/password Bearer token). const getSessionFromRequestMock = vi.fn(); +const refreshSessionCookieMock = vi.fn(); const getRedirectUrlMock = vi.fn( (_req: unknown, pathname: string) => new URL(`https://chat.elcanotek.com${pathname}`), ); vi.mock("@/app/lib/auth", () => ({ getSessionFromRequest: (...args: unknown[]) => getSessionFromRequestMock(...args), + refreshSessionCookie: (...args: unknown[]) => refreshSessionCookieMock(...args), getRedirectUrl: (...args: unknown[]) => getRedirectUrlMock(...(args as [unknown, string])), })); vi.mock("@/app/lib/buildId", () => ({ @@ -32,6 +34,7 @@ describe("proxy", () => { beforeEach(() => { getSessionFromRequestMock.mockReset(); getRedirectUrlMock.mockClear(); + refreshSessionCookieMock.mockReset(); }); it("redirects an unauthenticated page request to /login", async () => { @@ -174,6 +177,28 @@ describe("proxy", () => { }, ); + it("touches the session cookie on an authenticated pass-through (ADR-0064 idle limit)", async () => { + const session = { email: "a@x.com", exp: 0, idle: 0, epoch: "e", source: "password" }; + getSessionFromRequestMock.mockResolvedValue(session); + const request = req("/api/conversations"); + const res = await proxy(request); + expect(res.status).toBe(200); + expect(refreshSessionCookieMock).toHaveBeenCalledTimes(1); + expect(refreshSessionCookieMock).toHaveBeenCalledWith(request, res, session); + }); + + it("does not touch the cookie on redirects, 401s, public or bearer-only requests", async () => { + getSessionFromRequestMock.mockResolvedValue(null); + await proxy(req("/chat")); + await proxy(req("/api/conversations")); + await proxy(req("/login")); + await proxy(req("/api/orchestrator/tasks", { authorization: "Bearer moc-token" })); + getSessionFromRequestMock.mockResolvedValue({ email: "a@x.com", exp: 0, source: "password" }); + await proxy(req("/login")); + await proxy(req("/shared/abc")); + expect(refreshSessionCookieMock).not.toHaveBeenCalled(); + }); + it("stamps the CSP on redirect and 401 responses too", async () => { getSessionFromRequestMock.mockResolvedValue(null); const redirect = await proxy(req("/chat")); diff --git a/web/src/proxy.ts b/web/src/proxy.ts index eaf8554f..492073ea 100644 --- a/web/src/proxy.ts +++ b/web/src/proxy.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -import { getRedirectUrl, getSessionFromRequest } from "@/app/lib/auth"; +import { getRedirectUrl, getSessionFromRequest, refreshSessionCookie } from "@/app/lib/auth"; import { BUILD_ID_HEADER, currentBuildId } from "@/app/lib/buildId"; // ONE gate for the unified frontend. It protects BOTH views — /chat/* and @@ -163,7 +163,14 @@ export async function proxy(request: NextRequest) { return decorate(NextResponse.redirect(getRedirectUrl(request, "/login")), pathname); } - return decorate(NextResponse.next(), pathname); + const res = decorate(NextResponse.next(), pathname); + // Activity keeps an HMAC session alive: re-mint the cookie with a later idle + // deadline when the last mint is over a minute old (ADR-0064). Bearer-only + // and elcano_auth requests have nothing Fleet can refresh. + if (session) { + await refreshSessionCookie(request, res, session); + } + return res; } export const config = {