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:
Fusion
2026-04-26 04:38:33 -07:00
committed by gsxdsm
parent 8a261ae052
commit a4c219f89b
7 changed files with 843 additions and 69 deletions

View File

@@ -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, "&amp;").replace(/</g, "&lt;")}</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, "&amp;").replace(/</g, "&lt;")}</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");