feat(FN-2521): add remote auth handoff endpoints and login URL flow
- Add remote auth token primitives plus login-url generation for short-lived phone auth handoff - Expose public handoff API endpoint and wire remote auth handling into dashboard server routes - Tighten remote access settings update typing to satisfy typecheck and preserve API isolation behavior - Add comprehensive dashboard tests for remote-auth helpers, route behavior, and server integration - Document the remote login-url and phone auth handoff contract in architecture docs
This commit is contained in:
@@ -388,6 +388,10 @@ Implemented in `agent-heartbeat.ts`:
|
||||
Key server capabilities:
|
||||
- REST APIs for tasks, git, GitHub, agents, missions, planning, automations/routines, settings
|
||||
- Remote access APIs (`/api/remote/*`) for provider config, activation, tunnel lifecycle, status, token issuance, authenticated URL generation, and QR payload generation
|
||||
- Remote auth handoff endpoints:
|
||||
- `POST /api/remote-access/auth/login-url` (daemon-auth protected) issues a tokenized phone-login URL for either `persistent` or `short-lived` mode.
|
||||
- `GET /remote-login?rt=<token>` (public) validates remote token strategy and redirects to dashboard auth handoff (`/?token=<daemonToken>` when daemon auth is enabled, otherwise `/`).
|
||||
- Invalid/missing/expired remote tokens return `401` JSON with deterministic codes: `remote_token_invalid`, `remote_token_missing`, `remote_token_expired`.
|
||||
- Chat APIs (`/api/chat/*`) with streaming response support (`routes.ts`, `chat.ts`)
|
||||
- Dev-server lifecycle + persistence APIs (`/api/dev-server/*`) backed by:
|
||||
- `dev-server-routes.ts` (router factory + per-project runtime registry)
|
||||
@@ -407,6 +411,8 @@ Key server capabilities:
|
||||
- `createServer()` accepts `ServerOptions.runtimeLogger`; when omitted it defaults to a console-backed logger, preserving readable output in non-TTY/headless modes.
|
||||
- CLI TTY dashboard sessions inject a logger backed by `DashboardLogSink`, so runtime diagnostics from server/routes are captured in the TUI log buffer.
|
||||
- Sensitive remote-auth material is never logged raw; route/UI responses mask persistent token values unless explicitly requested by token-generation actions.
|
||||
- Short-lived remote auth tokens are runtime-ephemeral (in-memory only, cleared on process restart) and TTL-enforced server-side against persisted `remoteAccess.tokenStrategy.shortLived.ttlMs` plus issued expiry metadata.
|
||||
- Remote login links carry auth material in query params (`rt` then `token` on redirect). Treat links/QR screenshots as secrets: they can leak through history, screenshots, and chat logs; prefer short-lived mode for sharing.
|
||||
- Intentional startup/banner text in `fn dashboard` and `fn serve` remains direct plain output for readability and backward-compatible scripting behavior.
|
||||
|
||||
### Real-time channels
|
||||
|
||||
146
packages/dashboard/src/__tests__/remote-auth.test.ts
Normal file
146
packages/dashboard/src/__tests__/remote-auth.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { RemoteAccessProjectSettings } from "@fusion/core";
|
||||
import {
|
||||
__resetRemoteAuthStateForTests,
|
||||
constantTimeEqual,
|
||||
issueRemoteAuthToken,
|
||||
maskRemoteToken,
|
||||
validateRemoteAuthToken,
|
||||
} from "../remote-auth.js";
|
||||
|
||||
function createRemoteSettings(overrides: Partial<RemoteAccessProjectSettings> = {}): RemoteAccessProjectSettings {
|
||||
return {
|
||||
enabled: true,
|
||||
activeProvider: "tailscale",
|
||||
providers: {
|
||||
tailscale: {
|
||||
enabled: true,
|
||||
hostname: "tail.example.ts.net",
|
||||
targetPort: 4040,
|
||||
acceptRoutes: false,
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: false,
|
||||
tunnelName: "",
|
||||
tunnelToken: null,
|
||||
ingressUrl: "",
|
||||
},
|
||||
},
|
||||
tokenStrategy: {
|
||||
persistent: {
|
||||
enabled: true,
|
||||
token: "frt_persistent_token",
|
||||
},
|
||||
shortLived: {
|
||||
enabled: true,
|
||||
ttlMs: 120_000,
|
||||
maxTtlMs: 86_400_000,
|
||||
},
|
||||
},
|
||||
lifecycle: {
|
||||
rememberLastRunning: false,
|
||||
wasRunningOnShutdown: false,
|
||||
lastRunningProvider: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("remote-auth", () => {
|
||||
beforeEach(() => {
|
||||
__resetRemoteAuthStateForTests();
|
||||
});
|
||||
|
||||
it("compares tokens with constant-time helper", () => {
|
||||
expect(constantTimeEqual("abc123", "abc123")).toBe(true);
|
||||
expect(constantTimeEqual("abc123", "abc124")).toBe(false);
|
||||
expect(constantTimeEqual("short", "much-longer")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns missing when token is absent", () => {
|
||||
const result = validateRemoteAuthToken(undefined, createRemoteSettings());
|
||||
expect(result).toEqual({ status: "missing" });
|
||||
});
|
||||
|
||||
it("returns disabled when remote access or token strategy is disabled", () => {
|
||||
const disabledRemote = validateRemoteAuthToken("anything", createRemoteSettings({ enabled: false }));
|
||||
expect(disabledRemote).toEqual({ status: "disabled" });
|
||||
|
||||
const disabledStrategies = validateRemoteAuthToken(
|
||||
"anything",
|
||||
createRemoteSettings({
|
||||
tokenStrategy: {
|
||||
persistent: { enabled: false, token: null },
|
||||
shortLived: { enabled: false, ttlMs: 120_000, maxTtlMs: 86_400_000 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(disabledStrategies).toEqual({ status: "disabled" });
|
||||
});
|
||||
|
||||
it("validates persistent token when configured", () => {
|
||||
const result = validateRemoteAuthToken("frt_persistent_token", createRemoteSettings());
|
||||
expect(result).toEqual({ status: "valid", tokenType: "persistent" });
|
||||
});
|
||||
|
||||
it("issues and validates short-lived token before expiry", () => {
|
||||
const now = Date.parse("2026-04-26T12:00:00.000Z");
|
||||
const settings = createRemoteSettings();
|
||||
|
||||
const issued = issueRemoteAuthToken("short-lived", settings, now);
|
||||
const result = validateRemoteAuthToken(issued.token, settings, now + 30_000);
|
||||
|
||||
expect(issued.tokenType).toBe("short-lived");
|
||||
expect(issued.expiresAt).toBeDefined();
|
||||
expect(result.status).toBe("valid");
|
||||
expect(result.tokenType).toBe("short-lived");
|
||||
});
|
||||
|
||||
it("marks short-lived token expired by expiresAt", () => {
|
||||
const now = Date.parse("2026-04-26T12:00:00.000Z");
|
||||
const settings = createRemoteSettings({
|
||||
tokenStrategy: {
|
||||
persistent: { enabled: true, token: "frt_persistent_token" },
|
||||
shortLived: { enabled: true, ttlMs: 60_000, maxTtlMs: 86_400_000 },
|
||||
},
|
||||
});
|
||||
|
||||
const issued = issueRemoteAuthToken("short-lived", settings, now);
|
||||
const result = validateRemoteAuthToken(issued.token, settings, now + 60_001);
|
||||
|
||||
expect(result.status).toBe("expired");
|
||||
expect(result.tokenType).toBe("short-lived");
|
||||
});
|
||||
|
||||
it("enforces configured ttl when validating existing short-lived tokens", () => {
|
||||
const now = Date.parse("2026-04-26T12:00:00.000Z");
|
||||
const longTtlSettings = createRemoteSettings({
|
||||
tokenStrategy: {
|
||||
persistent: { enabled: true, token: "frt_persistent_token" },
|
||||
shortLived: { enabled: true, ttlMs: 180_000, maxTtlMs: 86_400_000 },
|
||||
},
|
||||
});
|
||||
|
||||
const issued = issueRemoteAuthToken("short-lived", longTtlSettings, now);
|
||||
|
||||
const shorterTtlSettings = createRemoteSettings({
|
||||
tokenStrategy: {
|
||||
persistent: { enabled: true, token: "frt_persistent_token" },
|
||||
shortLived: { enabled: true, ttlMs: 60_000, maxTtlMs: 86_400_000 },
|
||||
},
|
||||
});
|
||||
|
||||
const result = validateRemoteAuthToken(issued.token, shorterTtlSettings, now + 61_000);
|
||||
expect(result.status).toBe("expired");
|
||||
});
|
||||
|
||||
it("returns invalid for unknown tokens", () => {
|
||||
const result = validateRemoteAuthToken("frt_unknown", createRemoteSettings());
|
||||
expect(result).toEqual({ status: "invalid" });
|
||||
});
|
||||
|
||||
it("masks remote token values in diagnostics", () => {
|
||||
expect(maskRemoteToken("12345678")).toBe("********");
|
||||
expect(maskRemoteToken("frt_abcdefghijklmnop")).toBe("frt_…mnop");
|
||||
});
|
||||
});
|
||||
@@ -17808,3 +17808,139 @@ describe("GET /api/chat/sessions lookup=resume", () => {
|
||||
expect(res.body.sessions[0].id).toBe("chat-listed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote access auth login-url endpoints", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
const remoteAccessSettings = {
|
||||
enabled: true,
|
||||
activeProvider: "cloudflare",
|
||||
providers: {
|
||||
tailscale: {
|
||||
enabled: false,
|
||||
hostname: "tail.example.ts.net",
|
||||
targetPort: 4040,
|
||||
acceptRoutes: false,
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: true,
|
||||
tunnelName: "tunnel",
|
||||
tunnelToken: "cf-secret",
|
||||
ingressUrl: "https://remote.example.com",
|
||||
},
|
||||
},
|
||||
tokenStrategy: {
|
||||
persistent: {
|
||||
enabled: true,
|
||||
token: null,
|
||||
},
|
||||
shortLived: {
|
||||
enabled: true,
|
||||
ttlMs: 120000,
|
||||
maxTtlMs: 86400000,
|
||||
},
|
||||
},
|
||||
lifecycle: {
|
||||
rememberLastRunning: false,
|
||||
wasRunningOnShutdown: false,
|
||||
lastRunningProvider: null,
|
||||
},
|
||||
};
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteAccess: remoteAccessSettings,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates persistent login URL payload and persists generated fallback token", async () => {
|
||||
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteAccess: {
|
||||
...remoteAccessSettings,
|
||||
tokenStrategy: {
|
||||
...remoteAccessSettings.tokenStrategy,
|
||||
persistent: {
|
||||
...remoteAccessSettings.tokenStrategy.persistent,
|
||||
token: "frt_generated_persistent",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/remote-access/auth/login-url",
|
||||
JSON.stringify({ mode: "persistent" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tokenType).toBe("persistent");
|
||||
expect(res.body.loginUrl).toContain("https://remote.example.com/remote-login?rt=");
|
||||
expect(store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({
|
||||
remoteAccess: expect.objectContaining({
|
||||
tokenStrategy: expect.objectContaining({
|
||||
persistent: expect.objectContaining({
|
||||
token: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("creates short-lived login URL payload with expiresAt", async () => {
|
||||
const withPersistentToken = {
|
||||
...remoteAccessSettings,
|
||||
tokenStrategy: {
|
||||
...remoteAccessSettings.tokenStrategy,
|
||||
persistent: {
|
||||
...remoteAccessSettings.tokenStrategy.persistent,
|
||||
token: "frt_persistent",
|
||||
},
|
||||
},
|
||||
};
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteAccess: withPersistentToken,
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/remote-access/auth/login-url",
|
||||
JSON.stringify({ mode: "short-lived" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tokenType).toBe("short-lived");
|
||||
expect(res.body.expiresAt).toEqual(expect.any(String));
|
||||
expect(res.body.loginUrl).toContain("/remote-login?rt=");
|
||||
expect(JSON.stringify(res.body)).not.toContain("cf-secret");
|
||||
expect(JSON.stringify(res.body)).not.toContain("frt_persistent");
|
||||
});
|
||||
|
||||
it("rejects invalid login-url mode", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/remote-access/auth/login-url",
|
||||
JSON.stringify({ mode: "legacy" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("mode must be");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2128,3 +2128,159 @@ describe("createServer scoped scheduling resolver regressions", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /remote-login", () => {
|
||||
const originalDaemonToken = process.env.FUSION_DAEMON_TOKEN;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.FUSION_DAEMON_TOKEN;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalDaemonToken === undefined) {
|
||||
delete process.env.FUSION_DAEMON_TOKEN;
|
||||
} else {
|
||||
process.env.FUSION_DAEMON_TOKEN = originalDaemonToken;
|
||||
}
|
||||
});
|
||||
|
||||
function buildRemoteAccessSettings() {
|
||||
return {
|
||||
enabled: true,
|
||||
activeProvider: "cloudflare",
|
||||
providers: {
|
||||
tailscale: {
|
||||
enabled: false,
|
||||
hostname: "tail.example.ts.net",
|
||||
targetPort: 4040,
|
||||
acceptRoutes: false,
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: true,
|
||||
tunnelName: "tunnel",
|
||||
tunnelToken: "secret",
|
||||
ingressUrl: "https://remote.example.com",
|
||||
},
|
||||
},
|
||||
tokenStrategy: {
|
||||
persistent: {
|
||||
enabled: true,
|
||||
token: "frt_persistent_token",
|
||||
},
|
||||
shortLived: {
|
||||
enabled: true,
|
||||
ttlMs: 120000,
|
||||
maxTtlMs: 86400000,
|
||||
},
|
||||
},
|
||||
lifecycle: {
|
||||
rememberLastRunning: false,
|
||||
wasRunningOnShutdown: false,
|
||||
lastRunningProvider: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("redirects valid token to dashboard with daemon token handoff when daemon auth is enabled", async () => {
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({ remoteAccess: buildRemoteAccessSettings() }),
|
||||
});
|
||||
const app = createServer(store, { daemon: { token: "fn_daemon_token" } });
|
||||
|
||||
const res = await GET(app, "/remote-login?rt=frt_persistent_token");
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe("/?token=fn_daemon_token");
|
||||
});
|
||||
|
||||
it("redirects valid token to root when daemon auth is disabled", async () => {
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({ remoteAccess: buildRemoteAccessSettings() }),
|
||||
});
|
||||
const app = createServer(store, { noAuth: true });
|
||||
|
||||
const res = await GET(app, "/remote-login?rt=frt_persistent_token");
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe("/");
|
||||
});
|
||||
|
||||
it("returns 401 for invalid and missing remote token", async () => {
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({ remoteAccess: buildRemoteAccessSettings() }),
|
||||
});
|
||||
const app = createServer(store, { daemon: { token: "fn_daemon_token" } });
|
||||
|
||||
const invalid = await GET(app, "/remote-login?rt=frt_wrong");
|
||||
expect(invalid.status).toBe(401);
|
||||
expect(invalid.body).toEqual({ error: "Unauthorized", code: "remote_token_invalid" });
|
||||
|
||||
const missing = await GET(app, "/remote-login");
|
||||
expect(missing.status).toBe(401);
|
||||
expect(missing.body).toEqual({ error: "Unauthorized", code: "remote_token_missing" });
|
||||
});
|
||||
|
||||
it("issues short-lived login URL and expires remote-login handoff after TTL", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-04-26T12:00:00.000Z"));
|
||||
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
remoteAccess: {
|
||||
...buildRemoteAccessSettings(),
|
||||
tokenStrategy: {
|
||||
persistent: { enabled: true, token: "frt_persistent_token" },
|
||||
shortLived: { enabled: true, ttlMs: 120000, maxTtlMs: 86400000 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const app = createServer(store, { daemon: { token: "fn_daemon_token" } });
|
||||
|
||||
const issue = await REQUEST(
|
||||
app,
|
||||
"POST",
|
||||
"/api/remote-access/auth/login-url",
|
||||
JSON.stringify({ mode: "short-lived" }),
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer fn_daemon_token",
|
||||
},
|
||||
);
|
||||
|
||||
expect(issue.status).toBe(200);
|
||||
expect(typeof issue.body === "object" ? (issue.body as Record<string, unknown>).loginUrl : "").toEqual(expect.any(String));
|
||||
const issuedLoginUrl = new URL(String((issue.body as Record<string, unknown>).loginUrl));
|
||||
const shortLivedToken = issuedLoginUrl.searchParams.get("rt");
|
||||
expect(shortLivedToken).toBeTruthy();
|
||||
|
||||
const beforeExpiry = await GET(app, `/remote-login?rt=${shortLivedToken}`);
|
||||
expect(beforeExpiry.status).toBe(302);
|
||||
expect(beforeExpiry.headers.location).toBe("/?token=fn_daemon_token");
|
||||
|
||||
vi.advanceTimersByTime(121000);
|
||||
|
||||
const afterExpiry = await GET(app, `/remote-login?rt=${shortLivedToken}`);
|
||||
expect(afterExpiry.status).toBe(401);
|
||||
expect(afterExpiry.body).toEqual({ error: "Unauthorized", code: "remote_token_expired" });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not accept remote rt query tokens as API auth", async () => {
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({ remoteAccess: buildRemoteAccessSettings() }),
|
||||
});
|
||||
const app = createServer(store, { daemon: { token: "fn_daemon_token" } });
|
||||
|
||||
const res = await GET(app, "/api/tasks?rt=frt_persistent_token");
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({
|
||||
error: "Unauthorized",
|
||||
message: "Valid bearer token required",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
166
packages/dashboard/src/remote-auth.ts
Normal file
166
packages/dashboard/src/remote-auth.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import type { ProjectSettings } from "@fusion/core";
|
||||
|
||||
export type RemoteTokenValidationStatus = "valid" | "missing" | "invalid" | "expired" | "disabled";
|
||||
export type RemoteTokenType = "persistent" | "short-lived";
|
||||
|
||||
type RemoteAccessSettings = NonNullable<ProjectSettings["remoteAccess"]>;
|
||||
|
||||
interface ShortLivedTokenEntry {
|
||||
expiresAtMs: number;
|
||||
issuedAtMs: number;
|
||||
}
|
||||
|
||||
export interface RemoteTokenValidationResult {
|
||||
status: RemoteTokenValidationStatus;
|
||||
tokenType?: RemoteTokenType;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface IssueRemoteTokenResult {
|
||||
token: string;
|
||||
tokenType: RemoteTokenType;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SHORT_LIVED_TTL_MS = 900_000;
|
||||
const MIN_SHORT_LIVED_TTL_MS = 60_000;
|
||||
const MAX_SHORT_LIVED_TTL_MS = 86_400_000;
|
||||
|
||||
const shortLivedTokens = new Map<string, ShortLivedTokenEntry>();
|
||||
|
||||
export function constantTimeEqual(provided: string, expected: string): boolean {
|
||||
const expectedBuffer = Buffer.from(expected, "utf8");
|
||||
if (provided.length !== expectedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const providedBuffer = Buffer.from(provided, "utf8");
|
||||
if (providedBuffer.length !== expectedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(providedBuffer, expectedBuffer);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function generateRemoteToken(): string {
|
||||
return `frt_${randomBytes(20).toString("base64url")}`;
|
||||
}
|
||||
|
||||
export function maskRemoteToken(token: string): string {
|
||||
if (token.length <= 8) {
|
||||
return "********";
|
||||
}
|
||||
return `${token.slice(0, 4)}…${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
function resolveEffectiveShortLivedTtlMs(settings: RemoteAccessSettings): number {
|
||||
const configured = Number(settings.tokenStrategy.shortLived.ttlMs ?? DEFAULT_SHORT_LIVED_TTL_MS);
|
||||
if (!Number.isFinite(configured)) {
|
||||
return DEFAULT_SHORT_LIVED_TTL_MS;
|
||||
}
|
||||
|
||||
const maxConfigured = Number(settings.tokenStrategy.shortLived.maxTtlMs ?? MAX_SHORT_LIVED_TTL_MS);
|
||||
const boundedMax = Number.isFinite(maxConfigured)
|
||||
? Math.min(Math.max(maxConfigured, MIN_SHORT_LIVED_TTL_MS), MAX_SHORT_LIVED_TTL_MS)
|
||||
: MAX_SHORT_LIVED_TTL_MS;
|
||||
|
||||
return Math.min(Math.max(configured, MIN_SHORT_LIVED_TTL_MS), boundedMax);
|
||||
}
|
||||
|
||||
export function issueRemoteAuthToken(
|
||||
mode: RemoteTokenType,
|
||||
settings: RemoteAccessSettings,
|
||||
nowMs: number = Date.now(),
|
||||
): IssueRemoteTokenResult {
|
||||
purgeExpiredRemoteShortLivedTokens(nowMs);
|
||||
if (mode === "persistent") {
|
||||
const persistentToken = settings.tokenStrategy.persistent.token;
|
||||
if (!settings.tokenStrategy.persistent.enabled || !persistentToken) {
|
||||
throw new Error("Persistent remote token is not configured");
|
||||
}
|
||||
|
||||
return {
|
||||
token: persistentToken,
|
||||
tokenType: "persistent",
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.tokenStrategy.shortLived.enabled) {
|
||||
throw new Error("Short-lived token mode is disabled");
|
||||
}
|
||||
|
||||
const ttlMs = resolveEffectiveShortLivedTtlMs(settings);
|
||||
const token = generateRemoteToken();
|
||||
const expiresAtMs = nowMs + ttlMs;
|
||||
|
||||
shortLivedTokens.set(token, { expiresAtMs, issuedAtMs: nowMs });
|
||||
|
||||
return {
|
||||
token,
|
||||
tokenType: "short-lived",
|
||||
expiresAt: new Date(expiresAtMs).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function isRemoteAccessTokenStrategyEnabled(settings: RemoteAccessSettings): boolean {
|
||||
return settings.tokenStrategy.persistent.enabled || settings.tokenStrategy.shortLived.enabled;
|
||||
}
|
||||
|
||||
export function validateRemoteAuthToken(
|
||||
rt: string | null | undefined,
|
||||
settings: RemoteAccessSettings,
|
||||
nowMs: number = Date.now(),
|
||||
): RemoteTokenValidationResult {
|
||||
if (!settings.enabled || !isRemoteAccessTokenStrategyEnabled(settings)) {
|
||||
return { status: "disabled" };
|
||||
}
|
||||
|
||||
if (!rt) {
|
||||
return { status: "missing" };
|
||||
}
|
||||
|
||||
const persistent = settings.tokenStrategy.persistent;
|
||||
if (persistent.enabled && persistent.token && constantTimeEqual(rt, persistent.token)) {
|
||||
return { status: "valid", tokenType: "persistent" };
|
||||
}
|
||||
|
||||
const shortLived = settings.tokenStrategy.shortLived;
|
||||
const issued = shortLivedTokens.get(rt);
|
||||
if (shortLived.enabled && issued) {
|
||||
const ttlMs = resolveEffectiveShortLivedTtlMs(settings);
|
||||
const configuredExpiryMs = issued.issuedAtMs + ttlMs;
|
||||
const effectiveExpiryMs = Math.min(issued.expiresAtMs, configuredExpiryMs);
|
||||
if (nowMs > effectiveExpiryMs) {
|
||||
shortLivedTokens.delete(rt);
|
||||
return {
|
||||
status: "expired",
|
||||
tokenType: "short-lived",
|
||||
expiresAt: new Date(effectiveExpiryMs).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "valid",
|
||||
tokenType: "short-lived",
|
||||
expiresAt: new Date(effectiveExpiryMs).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
export function purgeExpiredRemoteShortLivedTokens(nowMs: number = Date.now()): void {
|
||||
for (const [token, entry] of shortLivedTokens.entries()) {
|
||||
if (entry.expiresAtMs <= nowMs) {
|
||||
shortLivedTokens.delete(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetRemoteAuthStateForTests(): void {
|
||||
shortLivedTokens.clear();
|
||||
}
|
||||
@@ -35,9 +35,9 @@ import {
|
||||
updatePiExtensionDisabledIds,
|
||||
} from "@fusion/core";
|
||||
import { readFile as fsReadFile } from "node:fs/promises";
|
||||
import type { Request } from "express";
|
||||
import { ApiError, badRequest } from "../api-error.js";
|
||||
import { getAuthFileCandidates, getFusionAuthPath, type StoredAuthProvider } from "../auth-paths.js";
|
||||
import { generateRemoteToken, issueRemoteAuthToken, maskRemoteToken } from "../remote-auth.js";
|
||||
import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js";
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
|
||||
@@ -64,59 +64,115 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
const { router, options, store, runtimeLogger, getProjectContext, rethrowAsApiError, emitAuthSyncAuditLog } = ctx;
|
||||
const { githubToken, validateModelPresets, sanitizeOverlapIgnorePaths, discoverDashboardPiExtensions } = deps;
|
||||
|
||||
const REMOTE_MIN_TTL_MS = 60_000;
|
||||
const REMOTE_MAX_TTL_MS = 86_400_000;
|
||||
const remoteShortLivedTokens = new Map<string, { expiresAt: number }>();
|
||||
function resolveRemoteBaseUrl(remoteAccess: NonNullable<Awaited<ReturnType<typeof store.getSettings>>["remoteAccess"]>): URL {
|
||||
if (!remoteAccess.activeProvider) {
|
||||
throw new ApiError(409, "No active remote provider configured", { code: "REMOTE_PROVIDER_NOT_CONFIGURED" });
|
||||
}
|
||||
|
||||
function generateRemoteToken(): string {
|
||||
return `rtok_${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
||||
if (remoteAccess.activeProvider === "cloudflare") {
|
||||
const ingressUrl = remoteAccess.providers.cloudflare.ingressUrl?.trim();
|
||||
if (!ingressUrl) {
|
||||
throw new ApiError(409, "Cloudflare ingress URL is not configured", { code: "REMOTE_URL_NOT_CONFIGURED" });
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(ingressUrl);
|
||||
} catch {
|
||||
throw new ApiError(409, "Cloudflare ingress URL is invalid", { code: "REMOTE_URL_INVALID" });
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new ApiError(409, "Cloudflare ingress URL must use http or https", { code: "REMOTE_URL_INVALID" });
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const hostname = remoteAccess.providers.tailscale.hostname?.trim();
|
||||
if (!hostname) {
|
||||
throw new ApiError(409, "Tailscale hostname is not configured", { code: "REMOTE_URL_NOT_CONFIGURED" });
|
||||
}
|
||||
|
||||
const baseUrl = new URL(`http://${hostname}`);
|
||||
const targetPort = Number(remoteAccess.providers.tailscale.targetPort);
|
||||
if (Number.isFinite(targetPort) && targetPort > 0 && targetPort !== 80) {
|
||||
baseUrl.port = String(targetPort);
|
||||
}
|
||||
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
function maskRemoteToken(token: string): string {
|
||||
if (token.length <= 8) return "********";
|
||||
return `${token.slice(0, 4)}…${token.slice(-4)}`;
|
||||
}
|
||||
async function ensurePersistentRemoteToken(
|
||||
scopedStore: typeof store,
|
||||
remoteAccess: NonNullable<Awaited<ReturnType<typeof store.getSettings>>["remoteAccess"]>,
|
||||
): Promise<string> {
|
||||
const existing = remoteAccess.tokenStrategy.persistent.token?.trim();
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
async function ensurePersistentRemoteToken(scopedStore: typeof store): Promise<string> {
|
||||
const settings = await scopedStore.getSettings();
|
||||
const existing = typeof settings.remotePersistentToken === "string" ? settings.remotePersistentToken : "";
|
||||
if (existing) return existing;
|
||||
const token = generateRemoteToken();
|
||||
await scopedStore.updateSettings({ remotePersistentToken: token });
|
||||
await scopedStore.updateSettings({
|
||||
remoteAccess: {
|
||||
...remoteAccess,
|
||||
tokenStrategy: {
|
||||
...remoteAccess.tokenStrategy,
|
||||
persistent: {
|
||||
...remoteAccess.tokenStrategy.persistent,
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
function resolveRemoteOrigin(req: Request): string {
|
||||
const protocol = req.protocol || "http";
|
||||
const hostHeader = req.get("host") ?? "127.0.0.1:4040";
|
||||
return `${protocol}://${hostHeader}`;
|
||||
}
|
||||
|
||||
async function buildRemoteUrlForTokenType(
|
||||
async function buildRemoteLoginUrlForTokenType(
|
||||
scopedStore: typeof store,
|
||||
req: Request,
|
||||
tokenType: "persistent" | "short-lived",
|
||||
ttlMs?: number,
|
||||
): Promise<{ url: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }> {
|
||||
const baseUrl = new URL(resolveRemoteOrigin(req));
|
||||
let token: string;
|
||||
let expiresAt: string | null = null;
|
||||
mode: "persistent" | "short-lived",
|
||||
): Promise<{ loginUrl: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }> {
|
||||
const settings = await scopedStore.getSettings();
|
||||
const remoteAccess = settings.remoteAccess;
|
||||
|
||||
if (tokenType === "short-lived") {
|
||||
const ttl = Math.floor(Number(ttlMs ?? 900_000));
|
||||
if (!Number.isFinite(ttl) || ttl < REMOTE_MIN_TTL_MS || ttl > REMOTE_MAX_TTL_MS) {
|
||||
throw new ApiError(400, "Short-lived token ttlMs out of range", { code: "INVALID_TTL" });
|
||||
}
|
||||
token = generateRemoteToken();
|
||||
const expiryMs = Date.now() + ttl;
|
||||
remoteShortLivedTokens.set(token, { expiresAt: expiryMs });
|
||||
expiresAt = new Date(expiryMs).toISOString();
|
||||
} else {
|
||||
token = await ensurePersistentRemoteToken(scopedStore);
|
||||
if (!remoteAccess?.enabled) {
|
||||
throw new ApiError(409, "Remote access is disabled", { code: "REMOTE_ACCESS_DISABLED" });
|
||||
}
|
||||
|
||||
baseUrl.searchParams.set("token", token);
|
||||
return { url: baseUrl.toString(), tokenType, expiresAt };
|
||||
const baseUrl = resolveRemoteBaseUrl(remoteAccess);
|
||||
|
||||
if (mode === "persistent") {
|
||||
if (!remoteAccess.tokenStrategy.persistent.enabled) {
|
||||
throw new ApiError(409, "Persistent remote token strategy is disabled", { code: "REMOTE_TOKEN_DISABLED" });
|
||||
}
|
||||
|
||||
const token = await ensurePersistentRemoteToken(scopedStore, remoteAccess);
|
||||
const loginUrl = new URL("/remote-login", baseUrl);
|
||||
loginUrl.searchParams.set("rt", token);
|
||||
return {
|
||||
loginUrl: loginUrl.toString(),
|
||||
tokenType: "persistent",
|
||||
expiresAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
let issued;
|
||||
try {
|
||||
issued = issueRemoteAuthToken("short-lived", remoteAccess);
|
||||
} catch (err) {
|
||||
throw new ApiError(409, err instanceof Error ? err.message : "Short-lived token generation failed", {
|
||||
code: "REMOTE_TOKEN_DISABLED",
|
||||
});
|
||||
}
|
||||
|
||||
const loginUrl = new URL("/remote-login", baseUrl);
|
||||
loginUrl.searchParams.set("rt", issued.token);
|
||||
return {
|
||||
loginUrl: loginUrl.toString(),
|
||||
tokenType: "short-lived",
|
||||
expiresAt: issued.expiresAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// Settings CRUD
|
||||
@@ -225,15 +281,17 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
const persistentToken = typeof settings.remotePersistentToken === "string" ? settings.remotePersistentToken : "";
|
||||
const remoteAccess = settings.remoteAccess;
|
||||
const persistentToken = remoteAccess?.tokenStrategy.persistent.token?.trim() ?? "";
|
||||
|
||||
res.json({
|
||||
settings: {
|
||||
remoteEnabled: Boolean(settings.remoteEnabled),
|
||||
remoteActiveProvider: (settings.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null,
|
||||
remoteTailscaleEnabled: Boolean(settings.remoteTailscaleEnabled),
|
||||
remoteCloudflareEnabled: Boolean(settings.remoteCloudflareEnabled),
|
||||
remoteShortLivedEnabled: Boolean(settings.remoteShortLivedEnabled),
|
||||
remoteShortLivedTtlMs: Number(settings.remoteShortLivedTtlMs ?? 900_000),
|
||||
remoteEnabled: Boolean(remoteAccess?.enabled),
|
||||
remoteActiveProvider: remoteAccess?.activeProvider ?? null,
|
||||
remoteTailscaleEnabled: Boolean(remoteAccess?.providers.tailscale.enabled),
|
||||
remoteCloudflareEnabled: Boolean(remoteAccess?.providers.cloudflare.enabled),
|
||||
remoteShortLivedEnabled: Boolean(remoteAccess?.tokenStrategy.shortLived.enabled),
|
||||
remoteShortLivedTtlMs: Number(remoteAccess?.tokenStrategy.shortLived.ttlMs ?? 900_000),
|
||||
remotePersistentToken: persistentToken ? maskRemoteToken(persistentToken) : null,
|
||||
},
|
||||
});
|
||||
@@ -248,7 +306,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
res.json({
|
||||
provider: (settings.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null,
|
||||
provider: settings.remoteAccess?.activeProvider ?? null,
|
||||
state: "stopped",
|
||||
url: null,
|
||||
lastError: null,
|
||||
@@ -266,7 +324,18 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
throw new ApiError(400, "Invalid remote provider", { code: "INVALID_PROVIDER" });
|
||||
}
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
await scopedStore.updateSettings({ remoteActiveProvider: provider });
|
||||
const settings = await scopedStore.getSettings();
|
||||
const remoteAccess = settings.remoteAccess;
|
||||
if (!remoteAccess) {
|
||||
throw new ApiError(409, "Remote access is not configured", { code: "REMOTE_ACCESS_DISABLED" });
|
||||
}
|
||||
|
||||
await scopedStore.updateSettings({
|
||||
remoteAccess: {
|
||||
...remoteAccess,
|
||||
activeProvider: provider,
|
||||
},
|
||||
});
|
||||
res.json({ activeProvider: provider });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
@@ -278,7 +347,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
const provider = (settings.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null;
|
||||
const provider = settings.remoteAccess?.activeProvider ?? null;
|
||||
if (!provider) {
|
||||
throw new ApiError(409, "No active provider configured", { code: "NO_ACTIVE_PROVIDER" });
|
||||
}
|
||||
@@ -293,7 +362,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
const provider = (settings.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null;
|
||||
const provider = settings.remoteAccess?.activeProvider ?? null;
|
||||
res.json({ state: "stopped", provider });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
@@ -304,8 +373,25 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
router.post("/remote/token/persistent/regenerate", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
const remoteAccess = settings.remoteAccess;
|
||||
if (!remoteAccess) {
|
||||
throw new ApiError(409, "Remote access is not configured", { code: "REMOTE_ACCESS_DISABLED" });
|
||||
}
|
||||
|
||||
const token = generateRemoteToken();
|
||||
await scopedStore.updateSettings({ remotePersistentToken: token });
|
||||
await scopedStore.updateSettings({
|
||||
remoteAccess: {
|
||||
...remoteAccess,
|
||||
tokenStrategy: {
|
||||
...remoteAccess.tokenStrategy,
|
||||
persistent: {
|
||||
...remoteAccess.tokenStrategy.persistent,
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
res.json({ token, maskedToken: maskRemoteToken(token) });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
@@ -315,27 +401,61 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
|
||||
router.post("/remote/token/short-lived/generate", async (req, res) => {
|
||||
try {
|
||||
const ttlMs = Number(req.body?.ttlMs ?? 900_000);
|
||||
if (!Number.isFinite(ttlMs) || ttlMs < REMOTE_MIN_TTL_MS || ttlMs > REMOTE_MAX_TTL_MS) {
|
||||
throw new ApiError(400, "Short-lived token ttlMs out of range", { code: "INVALID_TTL" });
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
const remoteAccess = settings.remoteAccess;
|
||||
if (!remoteAccess) {
|
||||
throw new ApiError(409, "Remote access is not configured", { code: "REMOTE_ACCESS_DISABLED" });
|
||||
}
|
||||
const token = generateRemoteToken();
|
||||
const expiresAt = new Date(Date.now() + ttlMs).toISOString();
|
||||
remoteShortLivedTokens.set(token, { expiresAt: Date.parse(expiresAt) });
|
||||
res.json({ token, expiresAt, ttlMs });
|
||||
|
||||
const ttlInput = req.body?.ttlMs;
|
||||
const modeSettings = (typeof ttlInput === "number" && Number.isFinite(ttlInput))
|
||||
? {
|
||||
...remoteAccess,
|
||||
tokenStrategy: {
|
||||
...remoteAccess.tokenStrategy,
|
||||
shortLived: {
|
||||
...remoteAccess.tokenStrategy.shortLived,
|
||||
ttlMs: Math.floor(ttlInput),
|
||||
},
|
||||
},
|
||||
}
|
||||
: remoteAccess;
|
||||
|
||||
const issued = issueRemoteAuthToken("short-lived", modeSettings);
|
||||
res.json({ token: issued.token, expiresAt: issued.expiresAt ?? null, ttlMs: modeSettings.tokenStrategy.shortLived.ttlMs });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to generate short-lived token");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/remote-access/auth/login-url", async (req, res) => {
|
||||
try {
|
||||
const mode = req.body?.mode;
|
||||
if (mode !== "persistent" && mode !== "short-lived") {
|
||||
throw new ApiError(400, "mode must be 'persistent' or 'short-lived'", { code: "INVALID_REMOTE_AUTH_MODE" });
|
||||
}
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const payload = await buildRemoteLoginUrlForTokenType(scopedStore, mode);
|
||||
res.json({
|
||||
loginUrl: payload.loginUrl,
|
||||
tokenType: payload.tokenType,
|
||||
...(payload.expiresAt ? { expiresAt: payload.expiresAt } : {}),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to generate remote login URL");
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/remote/url", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const tokenType = req.query.tokenType === "short-lived" ? "short-lived" : "persistent";
|
||||
const ttlMs = typeof req.query.ttlMs === "string" ? Number(req.query.ttlMs) : undefined;
|
||||
const payload = await buildRemoteUrlForTokenType(scopedStore, req, tokenType, ttlMs);
|
||||
res.json(payload);
|
||||
const payload = await buildRemoteLoginUrlForTokenType(scopedStore, tokenType);
|
||||
res.json({ url: payload.loginUrl, tokenType: payload.tokenType, expiresAt: payload.expiresAt });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to generate remote URL");
|
||||
@@ -346,15 +466,14 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const tokenType = req.query.tokenType === "short-lived" ? "short-lived" : "persistent";
|
||||
const ttlMs = typeof req.query.ttlMs === "string" ? Number(req.query.ttlMs) : undefined;
|
||||
const format = req.query.format === "image/svg" ? "image/svg" : "text";
|
||||
const payload = await buildRemoteUrlForTokenType(scopedStore, req, tokenType, ttlMs);
|
||||
const payload = await buildRemoteLoginUrlForTokenType(scopedStore, tokenType);
|
||||
if (format === "image/svg") {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="80"><rect width="100%" height="100%" fill="white"/><text x="10" y="42" font-size="12" fill="black">${payload.url.replace(/&/g, "&").replace(/</g, "<")}</text></svg>`;
|
||||
res.json({ ...payload, format, data: svg });
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="80"><rect width="100%" height="100%" fill="white"/><text x="10" y="42" font-size="12" fill="black">${payload.loginUrl.replace(/&/g, "&").replace(/</g, "<")}</text></svg>`;
|
||||
res.json({ url: payload.loginUrl, tokenType: payload.tokenType, expiresAt: payload.expiresAt, format, data: svg });
|
||||
return;
|
||||
}
|
||||
res.json({ ...payload, format, data: payload.url });
|
||||
res.json({ url: payload.loginUrl, tokenType: payload.tokenType, expiresAt: payload.expiresAt, format, data: payload.loginUrl });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to generate remote QR payload");
|
||||
|
||||
@@ -47,6 +47,7 @@ import { ChatManager } from "./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 { validateRemoteAuthToken } from "./remote-auth.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -895,6 +896,50 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/remote-login", async (req, res) => {
|
||||
const remoteToken = typeof req.query.rt === "string" ? req.query.rt : undefined;
|
||||
|
||||
let settings: Awaited<ReturnType<typeof store.getSettings>>;
|
||||
try {
|
||||
settings = await store.getSettings();
|
||||
} catch {
|
||||
res.status(401).json({ error: "Unauthorized", code: "remote_token_invalid" });
|
||||
return;
|
||||
}
|
||||
|
||||
const remoteAccess = settings.remoteAccess;
|
||||
if (!remoteAccess) {
|
||||
res.status(401).json({ error: "Unauthorized", code: "remote_token_invalid" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = validateRemoteAuthToken(remoteToken, remoteAccess);
|
||||
if (result.status !== "valid") {
|
||||
const codeByStatus: Record<string, string> = {
|
||||
missing: "remote_token_missing",
|
||||
expired: "remote_token_expired",
|
||||
invalid: "remote_token_invalid",
|
||||
disabled: "remote_token_invalid",
|
||||
};
|
||||
|
||||
res.status(401).json({
|
||||
error: "Unauthorized",
|
||||
code: codeByStatus[result.status] ?? "remote_token_invalid",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const daemonTokenForRedirect = getDaemonToken(options);
|
||||
if (daemonTokenForRedirect) {
|
||||
const redirectUrl = new URL("/", `${req.protocol}://${req.get("host")}`);
|
||||
redirectUrl.searchParams.set("token", daemonTokenForRedirect);
|
||||
res.redirect(302, redirectUrl.pathname + redirectUrl.search);
|
||||
return;
|
||||
}
|
||||
|
||||
res.redirect(302, "/");
|
||||
});
|
||||
|
||||
// REST API
|
||||
app.use("/api", createApiRoutes(store, {
|
||||
...options,
|
||||
|
||||
Reference in New Issue
Block a user