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

@@ -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",
});
});
});