From 2ec88e5994706c9791f5d253e40e80a5c7170c71 Mon Sep 17 00:00:00 2001 From: rigwig Date: Fri, 4 Sep 2026 23:49:16 -0400 Subject: [PATCH] fix(auth): don't expire sessions with the Google ID token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session cookie stores the Google ID token, and `authenticated` re-verified it on every request and rejected the session once the token's `exp` passed. Google issues ID tokens for about an hour, and nothing refreshed them, so every client — app.jetkvm.com included — was signed out roughly an hour after login regardless of activity, even though the cookie's maxAge is 24h. The token was already verified (signature, issuer, audience) by openid-client in the OIDC callback, and the cookie is signed with COOKIE_SECRET, so the per-request JWKS verification only served to enforce a lifetime that was never meant to be a session lifetime. Replace it with a real session policy: - idle timeout: the cookie's existing 24h maxAge, now actually rolling — `authenticated` touches `lastActiveAt` (at most every 5 minutes) so cookie-session re-issues the cookie while the session is in use; - absolute lifetime: 30 days from `authenticatedAt`, set at sign-in. Sessions issued before this change fall back to the token's `iat`. The client WebSocket upgrade path now applies the same check via getActiveSession() instead of accepting any cookie that carries a token. Co-Authored-By: Claude Fable 5.1 --- src/auth.ts | 86 +++++++++++++++++++--------- src/oidc.ts | 2 + src/webrtc-signaling.ts | 10 ++-- test/auth.test.ts | 123 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+), 30 deletions(-) create mode 100644 test/auth.test.ts diff --git a/src/auth.ts b/src/auth.ts index 8985196..be9a109 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -3,8 +3,8 @@ import * as jose from "jose"; import { UnauthorizedError } from "./errors"; const ALLOWED_IDENTITIES = process.env.ALLOWED_IDENTITIES?.split(",") - .map((identity) => identity.trim().toLowerCase()) - .filter(Boolean); + .map(identity => identity.trim().toLowerCase()) + .filter(Boolean); const getAllowedIdentities = () => { if (!ALLOWED_IDENTITIES) return null; @@ -19,40 +19,74 @@ export const isIdentityAllowed = (identity?: string | null) => { return allowedIdentities.has(identityNormalized); }; -export const verifyToken = async (idToken: string) => { - const JWKS = jose.createRemoteJWKSet( - new URL("https://www.googleapis.com/oauth2/v3/certs"), - ); +/** + * Hard cap on how long a session may live after sign-in, regardless of + * activity. The idle timeout is the cookie's `maxAge` (see index.ts), which + * rolls while the session is in use. + */ +export const SESSION_ABSOLUTE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; +/** + * cookie-session only re-sends the cookie when the session object changes, so + * to roll the idle timeout we touch `lastActiveAt` — but at most this often, to + * avoid a Set-Cookie on every request. + */ +export const SESSION_TOUCH_INTERVAL_MS = 5 * 60 * 1000; + +export interface ActiveSession { + /** Claims of the ID token verified by openid-client at sign-in. */ + claims: jose.JWTPayload & { email?: string }; +} + +/** + * Returns the signed-in identity for a session, or null when the session is + * missing, malformed, or past its absolute lifetime. + * + * The ID token was verified (signature, issuer, audience) by openid-client in + * the OIDC callback, and the session cookie itself is signed with + * COOKIE_SECRET, so the token is not re-verified here. In particular its `exp` + * is NOT treated as the session expiry: Google issues ID tokens for about an + * hour, and using that as the session lifetime signed everyone out hourly. + */ +export function getActiveSession( + session: CookieSessionInterfaces.CookieSessionObject | null | undefined, +): ActiveSession | null { + const idToken = session?.id_token; + if (typeof idToken !== "string" || !idToken) return null; + + let claims: jose.JWTPayload; try { - const { payload } = await jose.jwtVerify(idToken, JWKS, { - issuer: "https://accounts.google.com", - audience: process.env.GOOGLE_CLIENT_ID, - }); - - return payload; - } catch (e) { - console.error(e); + claims = jose.decodeJwt(idToken); + } catch { return null; } -}; -export const authenticated = async (req: Request, res: Response, next: NextFunction) => { - const idToken = req.session?.id_token; - if (!idToken) throw new UnauthorizedError(); + // Sessions created before `authenticatedAt` existed fall back to the + // token's issue time, which is when the user actually signed in. + const authenticatedAt: unknown = + session?.authenticatedAt ?? (claims.iat && claims.iat * 1000); + if (typeof authenticatedAt !== "number") return null; + if (Date.now() - authenticatedAt > SESSION_ABSOLUTE_MAX_AGE_MS) return null; - const payload = await verifyToken(idToken); - if (!payload) throw new UnauthorizedError(); - if (!payload.exp) throw new UnauthorizedError(); + return { claims }; +} - if (new Date(payload.exp * 1000) < new Date()) { - throw new UnauthorizedError(); - } +export const authenticated = async (req: Request, res: Response, next: NextFunction) => { + const active = getActiveSession(req.session); + if (!active) throw new UnauthorizedError(); - const email = (payload as { email?: string }).email; - if (!isIdentityAllowed(email)) { + if (!isIdentityAllowed(active.claims.email)) { throw new UnauthorizedError("Account is not in the allowlist", "account_not_allowed"); } + // Roll the idle timeout while the session is in use. + const lastActiveAt = req.session!.lastActiveAt; + if ( + typeof lastActiveAt !== "number" || + Date.now() - lastActiveAt > SESSION_TOUCH_INTERVAL_MS + ) { + req.session!.lastActiveAt = Date.now(); + } + next(); }; diff --git a/src/oidc.ts b/src/oidc.ts index 7908e76..cabf10d 100644 --- a/src/oidc.ts +++ b/src/oidc.ts @@ -99,6 +99,8 @@ export const Callback = async (req: express.Request, res: express.Response) => { } req.session!.id_token = tokenSet.id_token; + req.session!.authenticatedAt = Date.now(); + req.session!.lastActiveAt = Date.now(); await prisma.user.upsert({ where: { googleId: tokenClaims.sub }, diff --git a/src/webrtc-signaling.ts b/src/webrtc-signaling.ts index 310b513..f1d130c 100644 --- a/src/webrtc-signaling.ts +++ b/src/webrtc-signaling.ts @@ -8,6 +8,7 @@ import { Socket } from "node:net"; import { Device } from "@prisma/client"; import { Server, ServerResponse } from "node:http"; import { cookieSessionMiddleware } from "."; +import { getActiveSession } from "./auth"; // Maintain the shared state export const activeConnections: Map = @@ -243,15 +244,16 @@ async function handleClientSocketRequest( // Authenticate the client connection async function authenticateClientRequest(req: Request & { session: any }) { const session = req.session; - const token = session?.id_token; + const active = getActiveSession(session); - if (!token) { - console.log("[Client] No authentication token."); + if (!active) { + console.log("[Client] No active session."); return { deviceId: null }; } + const token: string = session.id_token; try { - const { sub } = jose.decodeJwt(token); + const { sub } = active.claims; const url = new URL(req.url || "", "http://localhost"); const deviceId = url.searchParams.get("id"); diff --git a/test/auth.test.ts b/test/auth.test.ts new file mode 100644 index 0000000..758c806 --- /dev/null +++ b/test/auth.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi } from "vitest"; +import { Request, Response } from "express"; +import * as jose from "jose"; +import { + authenticated, + getActiveSession, + SESSION_ABSOLUTE_MAX_AGE_MS, + SESSION_TOUCH_INTERVAL_MS, +} from "../src/auth"; +import { UnauthorizedError } from "../src/errors"; + +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; + +// Builds an ID token like the one openid-client stores in the session after +// sign-in. The signature is irrelevant here: sessions are trusted because the +// cookie is signed with COOKIE_SECRET, not because the token is re-verified. +async function idToken(opts: { issuedAgoMs: number; ttlMs?: number; email?: string }) { + const now = Date.now(); + const iat = Math.floor((now - opts.issuedAgoMs) / 1000); + const exp = Math.floor((now - opts.issuedAgoMs + (opts.ttlMs ?? HOUR)) / 1000); + return new jose.SignJWT({ email: opts.email ?? "user@example.com", sub: "123" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuer("https://accounts.google.com") + .setIssuedAt(iat) + .setExpirationTime(exp) + .sign(new TextEncoder().encode("test-key")); +} + +function runMiddleware(session: Record | null) { + const req = { session } as unknown as Request; + const res = {} as Response; + const next = vi.fn(); + return { req, run: () => authenticated(req, res, next), next }; +} + +describe("getActiveSession", () => { + it("returns null without a session or token", () => { + expect(getActiveSession(null)).toBeNull(); + expect(getActiveSession(undefined)).toBeNull(); + expect(getActiveSession({} as any)).toBeNull(); + expect(getActiveSession({ id_token: "not-a-jwt" } as any)).toBeNull(); + }); + + it("stays active after the ID token's own expiry has passed", async () => { + // Google ID tokens live ~1h; that is not the session lifetime. + const token = await idToken({ issuedAgoMs: 5 * HOUR }); + const active = getActiveSession({ + id_token: token, + authenticatedAt: Date.now() - 5 * HOUR, + } as any); + expect(active?.claims.email).toBe("user@example.com"); + }); + + it("expires once the absolute lifetime is exceeded", async () => { + const token = await idToken({ issuedAgoMs: 1 * HOUR }); + const authenticatedAt = Date.now() - SESSION_ABSOLUTE_MAX_AGE_MS - DAY; + expect(getActiveSession({ id_token: token, authenticatedAt } as any)).toBeNull(); + }); + + it("falls back to the token's iat for sessions created before authenticatedAt", async () => { + const recent = await idToken({ issuedAgoMs: 2 * DAY }); + expect(getActiveSession({ id_token: recent } as any)).not.toBeNull(); + + const ancient = await idToken({ issuedAgoMs: 40 * DAY }); + expect(getActiveSession({ id_token: ancient } as any)).toBeNull(); + }); +}); + +describe("authenticated middleware", () => { + it("rejects requests without a session", async () => { + const { run, next } = runMiddleware(null); + await expect(run()).rejects.toBeInstanceOf(UnauthorizedError); + expect(next).not.toHaveBeenCalled(); + }); + + it("accepts a session whose ID token expired hours ago", async () => { + const token = await idToken({ issuedAgoMs: 6 * HOUR }); + const { run, next } = runMiddleware({ + id_token: token, + authenticatedAt: Date.now() - 6 * HOUR, + }); + await run(); + expect(next).toHaveBeenCalledOnce(); + }); + + it("rejects a session past the absolute lifetime", async () => { + const token = await idToken({ issuedAgoMs: 0 }); + const { run, next } = runMiddleware({ + id_token: token, + authenticatedAt: Date.now() - SESSION_ABSOLUTE_MAX_AGE_MS - 1, + }); + await expect(run()).rejects.toBeInstanceOf(UnauthorizedError); + expect(next).not.toHaveBeenCalled(); + }); + + it("touches lastActiveAt so the cookie's idle timeout rolls, but not on every request", async () => { + const token = await idToken({ issuedAgoMs: 0 }); + const session: Record = { + id_token: token, + authenticatedAt: Date.now(), + }; + + const first = runMiddleware(session); + await first.run(); + const touched = session.lastActiveAt as number; + expect(typeof touched).toBe("number"); + + // Within the touch interval the session is left untouched, so + // cookie-session doesn't re-send the cookie on every request. + const second = runMiddleware(session); + await second.run(); + expect(session.lastActiveAt).toBe(touched); + + // Once the interval has elapsed, the next request touches it again. + const stale = Date.now() - SESSION_TOUCH_INTERVAL_MS - 1; + session.lastActiveAt = stale; + const before = Date.now(); + const third = runMiddleware(session); + await third.run(); + expect(session.lastActiveAt as number).toBeGreaterThanOrEqual(before); + }); +});