diff --git a/.changeset/fix-persistent-remote-session-ttl.md b/.changeset/fix-persistent-remote-session-ttl.md new file mode 100644 index 0000000000..7c93c4b733 --- /dev/null +++ b/.changeset/fix-persistent-remote-session-ttl.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: A persistent remote link no longer expires after 15 minutes. +category: fix +dev: The remote-login session fix capped every session at `shortLived.ttlMs`, so opening a PERSISTENT link yielded a 15-minute session — wrong for the link type operators use for their own devices. `resolveRemoteSessionTtlMs` now follows the token type: short-lived sessions still cannot outlive the token that authorised them (falling back to the configured TTL when there is no usable expiry), while persistent tokens mint a long session (30 days, and in-memory so a restart ends it regardless). Moved out of server.ts into remote-session.ts so the rule is unit-tested. diff --git a/packages/dashboard/src/__tests__/remote-session.test.ts b/packages/dashboard/src/__tests__/remote-session.test.ts index b904d277d9..ec02cf88b6 100644 --- a/packages/dashboard/src/__tests__/remote-session.test.ts +++ b/packages/dashboard/src/__tests__/remote-session.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest"; import { buildRemoteSessionCookie, createRemoteSessionStore, + PERSISTENT_SESSION_TTL_MS, readCookie, REMOTE_SESSION_COOKIE, + resolveRemoteSessionTtlMs, } from "../remote-session.js"; import { createAuthMiddleware } from "../auth-middleware.js"; import { readFileSync } from "node:fs"; @@ -132,3 +134,39 @@ describe("remote-login redirect", () => { expect(handler).toContain("buildRemoteSessionCookie"); }); }); + +/* +FNXC:RemoteAuth 2026-08-19-02:10: +Session lifetime must follow the TOKEN TYPE. The first cut capped everything at `shortLived.ttlMs`, +so a PERSISTENT link expired in 15 minutes — wrong: persistent means the link keeps working, and it +is what the operator uses for their own devices. A short-lived link keeps the opposite guarantee: the +session it mints cannot outlive the link. +*/ +describe("remote session lifetime", () => { + const now = 1_000_000_000; + const settings = { tokenStrategy: { shortLived: { ttlMs: 900_000 } } }; + + it("gives a persistent token a long session, not the short-lived TTL", () => { + expect(resolveRemoteSessionTtlMs(settings, { tokenType: "persistent" }, now)).toBe(PERSISTENT_SESSION_TTL_MS); + // No token type at all (older validators) must also not be treated as short-lived. + expect(resolveRemoteSessionTtlMs(settings, {}, now)).toBe(PERSISTENT_SESSION_TTL_MS); + }); + + it("never lets a session outlive the short-lived token that authorised it", () => { + const expiresAt = new Date(now + 120_000).toISOString(); + expect(resolveRemoteSessionTtlMs(settings, { tokenType: "short-lived", expiresAt }, now)).toBe(120_000); + }); + + it("caps by the token expiry even when it is shorter than the configured TTL", () => { + const expiresAt = new Date(now + 5_000).toISOString(); + expect(resolveRemoteSessionTtlMs(settings, { tokenType: "short-lived", expiresAt }, now)).toBe(5_000); + }); + + it("falls back to the configured TTL for a short-lived token with no usable expiry", () => { + expect(resolveRemoteSessionTtlMs(settings, { tokenType: "short-lived" }, now)).toBe(900_000); + expect(resolveRemoteSessionTtlMs(settings, { tokenType: "short-lived", expiresAt: "nonsense" }, now)).toBe(900_000); + // An already-expired token must not yield a long session by falling through. + const past = new Date(now - 1_000).toISOString(); + expect(resolveRemoteSessionTtlMs(settings, { tokenType: "short-lived", expiresAt: past }, now)).toBe(900_000); + }); +}); diff --git a/packages/dashboard/src/remote-session.ts b/packages/dashboard/src/remote-session.ts index 384303136a..46aa606421 100644 --- a/packages/dashboard/src/remote-session.ts +++ b/packages/dashboard/src/remote-session.ts @@ -124,3 +124,44 @@ export function buildRemoteSessionCookie(session: RemoteSession, options: { secu if (options.secure) parts.push("Secure"); return parts.join("; "); } + +/* +FNXC:RemoteAuth 2026-08-19-02:10: +SESSION LIFETIME BY TOKEN TYPE. The first cut capped every session at `shortLived.ttlMs`, which made +a PERSISTENT link expire in 15 minutes — wrong, because persistent means "this link keeps working" +and the operator uses it for their own devices. + + short-lived token -> session expires no later than the token does. A 15-minute link must not buy a + longer stay through the back door. + persistent token -> a long session, because the link itself never expires. It is still a session + rather than the daemon token: opaque, revocable (revokeAll on rotation), and + gone on restart, so it is not the permanent credential the old redirect leaked. +*/ + +/** Persistent links get a long session; the store is in-memory, so a restart ends it regardless. */ +export const PERSISTENT_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +const DEFAULT_SHORT_LIVED_TTL_MS = 900_000; + +export function resolveRemoteSessionTtlMs( + remoteAccess: { tokenStrategy?: { shortLived?: { ttlMs?: number } } } | undefined, + validated: { tokenType?: string; expiresAt?: string | number | null } | undefined, + now: number = Date.now(), +): number { + const expiresAt = validated?.expiresAt; + if (expiresAt !== undefined && expiresAt !== null) { + const remaining = new Date(expiresAt).getTime() - now; + if (Number.isFinite(remaining) && remaining > 0) { + // Never outlive the token that authorised it. + return remaining; + } + } + + if (validated?.tokenType === "short-lived") { + // A short-lived token with no usable expiry falls back to the configured TTL, never the long one. + const configured = Number(remoteAccess?.tokenStrategy?.shortLived?.ttlMs ?? DEFAULT_SHORT_LIVED_TTL_MS); + return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_SHORT_LIVED_TTL_MS; + } + + return PERSISTENT_SESSION_TTL_MS; +} diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 03495ec8a4..95f3ccddfc 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -70,7 +70,7 @@ import { CliChatSessionRunner } from "./cli-chat.js"; import { stopAllDevServers } from "./dev-server-routes.js"; import type { SkillsAdapter } from "./skills-adapter.js"; import { createAuthMiddleware, authenticateUpgradeRequest, getDaemonToken } from "./auth-middleware.js"; -import { buildRemoteSessionCookie, createRemoteSessionStore } from "./remote-session.js"; +import { buildRemoteSessionCookie, createRemoteSessionStore, resolveRemoteSessionTtlMs } from "./remote-session.js"; import { setupCliSessionWebSocket } from "./cli-session-ws.js"; import { createCliSessionsRouter } from "./routes/cli-sessions.js"; import { getProjectIdFromRequest, resolveStoreForProjectId } from "./routes/context.js"; @@ -2161,28 +2161,6 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT } }); - /* - FNXC:RemoteAuth 2026-08-19-00:40: - Session lifetime for a remote login. A SHORT-LIVED remote token must not be able to mint a session - that outlives it — otherwise a 15-minute share link buys a longer stay through the back door — so - the session is capped by whatever remains of the token. A persistent token has no expiry to - inherit, so it uses the configured short-lived TTL as a bounded default rather than granting an - unbounded session. - */ - const resolveRemoteSessionTtlMs = ( - remoteAccess: { tokenStrategy?: { shortLived?: { ttlMs?: number } } } | undefined, - validated: { tokenType?: string; expiresAt?: string | number | null } | undefined, - ): number => { - const configured = Number(remoteAccess?.tokenStrategy?.shortLived?.ttlMs ?? 900_000); - const fallback = Number.isFinite(configured) && configured > 0 ? configured : 900_000; - const expiresAt = validated?.expiresAt; - if (expiresAt !== undefined && expiresAt !== null) { - const remaining = new Date(expiresAt).getTime() - Date.now(); - if (Number.isFinite(remaining) && remaining > 0) return Math.min(remaining, fallback); - } - return fallback; - }; - app.get("/remote-login", async (req, res) => { const remoteToken = typeof req.query.rt === "string" ? req.query.rt : undefined;