fix(security): persistent remote links keep working, short-lived ones still expire
The remote-login session fix capped EVERY session at shortLived.ttlMs, so opening
a persistent link gave a 15-minute session. That is wrong for the link type an
operator uses for their own devices — persistent means the link keeps working.
Session lifetime now follows the token type:
short-lived -> never outlives the token that authorised it (falls back to the
configured TTL when there is no usable expiry, and an already
expired token does not fall through to the long one)
persistent -> a long session (30 days), because the link itself never expires
It is still a SESSION rather than the daemon token — opaque, revocable, and gone
on restart — so the leak this all started from stays fixed: a recipient never
receives the dashboard's permanent credential.
Moved the rule out of server.ts into remote-session.ts so it is unit-tested
rather than living inline in a request handler. 15 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/fix-persistent-remote-session-ttl.md
Normal file
7
.changeset/fix-persistent-remote-session-ttl.md
Normal file
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user