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 = {