Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
87 changes: 87 additions & 0 deletions docs/adr/0064-application-session-lifetimes.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
13 changes: 8 additions & 5 deletions web/e2e/mocked/_session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
4 changes: 2 additions & 2 deletions web/src/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
createSessionToken,
getRedirectUrl,
getSessionCookieName,
sessionMaxAgeSeconds,
sessionAbsoluteSeconds,
isSecureRequest,
} from "@/app/lib/auth";
import { chatServerFetch, fetchSessionEpoch } from "@/app/lib/chatServer";
Expand Down Expand Up @@ -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 });
Expand Down
4 changes: 2 additions & 2 deletions web/src/app/api/auth/oidc/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
getRedirectUrl,
getSessionCookieName,
isSecureRequest,
sessionMaxAgeSeconds,
sessionAbsoluteSeconds,
} from "@/app/lib/auth";
import { fetchExternalSessionEpoch } from "@/app/lib/chatServer";
import {
Expand Down Expand Up @@ -144,7 +144,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
httpOnly: true,
sameSite: "lax",
secure,
maxAge: sessionMaxAgeSeconds,
maxAge: sessionAbsoluteSeconds,
path: "/",
});
clearTempCookies(res, secure);
Expand Down
166 changes: 165 additions & 1 deletion web/src/app/lib/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<string, unknown>;
}

// 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);
});
});
Loading