feat(FN-2929): merge fusion/fn-2929

- fix(FN-2929): complete Step 6 — add changeset and docs
- test(FN-2929): update loginProvider API test for origin payload
- test(FN-2929): complete Step 4 — cover oauth redirect rewrite and callback proxy
- fix(FN-2929): complete Step 3 — send browser origin in auth login request
- fix(FN-2929): complete Step 1-2 — rewrite OAuth redirect and add callback proxy
- feat(FN-2934): merge fusion/fn-2934

Fusion-Task-Id: FN-2929
This commit is contained in:
Fusion
2026-04-29 01:30:27 -07:00
committed by gsxdsm
parent 2bd552b52a
commit 854dc7d1e8
5 changed files with 219 additions and 82 deletions

View File

@@ -948,7 +948,7 @@ describe("loginProvider", () => {
expect(globalThis.fetch).toHaveBeenCalledWith("/api/auth/login", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ provider: "anthropic" }),
body: JSON.stringify({ provider: "anthropic", origin: window.location.origin }),
});
});

View File

@@ -1505,7 +1505,7 @@ export function fetchAuthStatus(): Promise<{
export function loginProvider(provider: string): Promise<{ url: string; instructions?: string }> {
return api<{ url: string; instructions?: string }>("/auth/login", {
method: "POST",
body: JSON.stringify({ provider }),
body: JSON.stringify({ provider, origin: window.location.origin }),
});
}

View File

@@ -4810,49 +4810,55 @@ describe("POST /auth/login", () => {
expect(res.body.instructions).toBe("Open in browser");
});
it("rewrites localhost redirect_uri to request hostname", async () => {
it("rewrites redirect_uri to dashboard oauth proxy when origin is non-localhost", async () => {
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation((_provider: string, callbacks: any) => {
callbacks.onAuth({
url: "https://accounts.example.com/o/oauth2/v2/auth?redirect_uri=http%3A%2F%2Flocalhost%3A4040%2Fapi%2Fauth%2Fcallback",
url: "https://accounts.example.com/o/oauth2/v2/auth?state=test-state&redirect_uri=http%3A%2F%2Flocalhost%3A8085%2Foauth2callback",
instructions: "Open in browser",
});
return Promise.resolve();
});
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({ provider: "anthropic" }), {
"Content-Type": "application/json",
Host: "192.168.1.2:8080",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/auth/login",
JSON.stringify({ provider: "anthropic", origin: "https://my-host.example.com" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
const returnedUrl = new URL(res.body.url);
const redirectUri = new URL(returnedUrl.searchParams.get("redirect_uri") ?? "");
expect(redirectUri.toString()).toBe("http://192.168.1.2:8080/api/auth/callback");
expect(returnedUrl.searchParams.get("redirect_uri")).toBe("https://my-host.example.com/api/auth/oauth-callback");
});
it("rewrites redirect_uri protocol to https when request is secure", async () => {
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation((_provider: string, callbacks: any) => {
callbacks.onAuth({
url: "https://accounts.example.com/o/oauth2/v2/auth?redirect_uri=http%3A%2F%2Flocalhost%3A4040%2Fapi%2Fauth%2Fcallback",
it.each(["http://localhost:4040", "http://127.0.0.1:4040"])(
"does not rewrite redirect_uri when origin is local (%s)",
async (origin) => {
const unchangedUrl =
"https://accounts.example.com/o/oauth2/v2/auth?state=test-state&redirect_uri=http%3A%2F%2Flocalhost%3A8085%2Foauth2callback";
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation((_provider: string, callbacks: any) => {
callbacks.onAuth({ url: unchangedUrl });
return Promise.resolve();
});
return Promise.resolve();
});
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({ provider: "anthropic" }), {
"Content-Type": "application/json",
Host: "dashboard.example.com",
"X-Forwarded-Proto": "https",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/auth/login",
JSON.stringify({ provider: "anthropic", origin }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
const returnedUrl = new URL(res.body.url);
const redirectUri = new URL(returnedUrl.searchParams.get("redirect_uri") ?? "");
expect(redirectUri.toString()).toBe("https://dashboard.example.com/api/auth/callback");
});
expect(res.status).toBe(200);
expect(res.body.url).toBe(unchangedUrl);
},
);
it("leaves auth URL unchanged when redirect_uri is not localhost", async () => {
it("does not rewrite redirect_uri when origin is missing", async () => {
const unchangedUrl =
"https://accounts.example.com/o/oauth2/v2/auth?redirect_uri=https%3A%2F%2Fdashboard.example.com%2Fapi%2Fauth%2Fcallback";
"https://accounts.example.com/o/oauth2/v2/auth?state=test-state&redirect_uri=http%3A%2F%2Flocalhost%3A8085%2Foauth2callback";
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation((_provider: string, callbacks: any) => {
callbacks.onAuth({ url: unchangedUrl });
@@ -4861,31 +4867,11 @@ describe("POST /auth/login", () => {
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({ provider: "anthropic" }), {
"Content-Type": "application/json",
Host: "192.168.1.2:8080",
});
expect(res.status).toBe(200);
expect(res.body.url).toBe(unchangedUrl);
});
it("leaves auth URL unchanged when Host header is missing", async () => {
const unchangedUrl =
"https://accounts.example.com/o/oauth2/v2/auth?redirect_uri=http%3A%2F%2Flocalhost%3A4040%2Fapi%2Fauth%2Fcallback";
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation((_provider: string, callbacks: any) => {
callbacks.onAuth({ url: unchangedUrl });
return Promise.resolve();
});
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({ provider: "anthropic" }), {
"Content-Type": "application/json",
Host: "",
});
expect(res.status).toBe(200);
expect(res.body.url).toBe(unchangedUrl);
});
it("returns 400 when provider is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({}), {
"Content-Type": "application/json",
@@ -4948,6 +4934,76 @@ describe("POST /auth/login", () => {
});
});
describe("GET /auth/oauth-callback", () => {
let store: TaskStore;
let authStorage: AuthStorageLike;
beforeEach(() => {
store = createMockStore();
authStorage = createMockAuthStorage();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { authStorage }));
return app;
}
it("proxies callback request to original localhost callback server", async () => {
const callbackServer = express();
callbackServer.get("/oauth2callback", (req, res) => {
res.status(200).type("text/html").send(`proxied:${String(req.query.code)}:${String(req.query.state)}`);
});
const callbackListener = await new Promise<import("node:http").Server>((resolve) => {
const listener = callbackServer.listen(0, () => resolve(listener));
});
try {
const address = callbackListener.address();
const port = typeof address === "object" && address ? address.port : 0;
expect(port).toBeGreaterThan(0);
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation((_provider: string, callbacks: any) => {
callbacks.onAuth({
url: `https://accounts.example.com/o/oauth2/v2/auth?state=test-state&redirect_uri=${encodeURIComponent(`http://localhost:${port}/oauth2callback`)}`,
});
return Promise.resolve();
});
const app = buildApp();
const loginRes = await REQUEST(
app,
"POST",
"/api/auth/login",
JSON.stringify({ provider: "anthropic", origin: "https://remote.example.com" }),
{ "Content-Type": "application/json" },
);
expect(loginRes.status).toBe(200);
const res = await REQUEST(app, "GET", "/api/auth/oauth-callback?code=test-code&state=test-state");
expect(res.status).toBe(200);
expect(String(res.body)).toContain("proxied:test-code:test-state");
} finally {
await new Promise<void>((resolve, reject) => callbackListener.close((err) => (err ? reject(err) : resolve())));
}
});
it("returns 400 for unknown state", async () => {
const res = await REQUEST(buildApp(), "GET", "/api/auth/oauth-callback?code=test-code&state=unknown");
expect(res.status).toBe(400);
expect(String(res.body)).toContain("OAuth session expired or not found");
});
it("returns 400 with error page when oauth provider reports error", async () => {
const res = await REQUEST(buildApp(), "GET", "/api/auth/oauth-callback?error=access_denied&state=test-state");
expect(res.status).toBe(400);
expect(String(res.body)).toContain("OAuth failed");
expect(String(res.body)).toContain("access_denied");
});
});
describe("POST /auth/logout", () => {
let store: TaskStore;
let authStorage: AuthStorageLike;

View File

@@ -37,48 +37,73 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
*/
const loginInProgress = new Map<string, AbortController>();
function isSecureRequest(req: { secure?: boolean; headers?: Record<string, string | string[] | undefined> }): boolean {
const forwardedProto = req.headers?.["x-forwarded-proto"];
const normalizedProto = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
return Boolean(req.secure) || normalizedProto === "https";
const OAUTH_SESSION_TTL_MS = 5 * 60 * 1000;
const oauthSessions = new Map<string, { port: number; path: string; originalRedirectUri: string; expiresAt: number }>();
function isLocalhostOrigin(origin: string): boolean {
try {
const url = new URL(origin);
return url.hostname === "localhost" || url.hostname === "127.0.0.1";
} catch {
return false;
}
}
function rewriteRedirectUri(authUrl: string, reqHost: string, secure: boolean): string {
const trimmedHost = reqHost.trim();
if (!trimmedHost) {
return authUrl;
}
function simpleErrorHtml(title: string, detail?: string): string {
const safeTitle = String(title);
const safeDetail = detail ? String(detail) : "";
return `<!DOCTYPE html><html><head><meta charset="utf-8" /><title>${safeTitle}</title></head><body><h2>${safeTitle}</h2>${safeDetail ? `<p>${safeDetail}</p>` : ""}<p>You can close this tab.</p></body></html>`;
}
let authUrlObj: URL;
try {
authUrlObj = new URL(authUrl);
} catch {
return authUrl;
function cleanupExpiredOauthSessions(): void {
const now = Date.now();
for (const [state, session] of oauthSessions.entries()) {
if (session.expiresAt <= now) {
oauthSessions.delete(state);
}
}
}
function setOauthSession(state: string, details: { port: number; path: string; originalRedirectUri: string }): void {
cleanupExpiredOauthSessions();
oauthSessions.set(state, { ...details, expiresAt: Date.now() + OAUTH_SESSION_TTL_MS });
const timeout = setTimeout(() => {
const current = oauthSessions.get(state);
if (current && current.expiresAt <= Date.now()) {
oauthSessions.delete(state);
}
}, OAUTH_SESSION_TTL_MS + 1_000);
timeout.unref();
}
function rewriteAuthUrl(authUrl: string, origin: string): { url: string; state: string; originalRedirectUri: string; port: number; path: string } {
const authUrlObj = new URL(authUrl);
const state = authUrlObj.searchParams.get("state");
const redirectUri = authUrlObj.searchParams.get("redirect_uri");
if (!state) {
throw badRequest("OAuth provider did not return state in auth URL");
}
if (!redirectUri) {
return authUrl;
throw badRequest("OAuth provider did not return redirect_uri in auth URL");
}
let redirectUriUrl: URL;
try {
redirectUriUrl = new URL(redirectUri);
} catch {
return authUrl;
const redirectUriUrl = new URL(redirectUri);
const port = Number.parseInt(redirectUriUrl.port, 10);
if (!Number.isFinite(port) || port <= 0) {
throw badRequest("OAuth provider returned invalid callback redirect_uri");
}
if (redirectUriUrl.hostname !== "localhost" && redirectUriUrl.hostname !== "127.0.0.1") {
return authUrl;
}
const newRedirectUri = new URL("/api/auth/oauth-callback", origin).toString();
authUrlObj.searchParams.set("redirect_uri", newRedirectUri);
const hostUrl = new URL(`http://${trimmedHost}`);
redirectUriUrl.hostname = hostUrl.hostname;
redirectUriUrl.port = hostUrl.port;
redirectUriUrl.protocol = secure ? "https:" : "http:";
authUrlObj.searchParams.set("redirect_uri", redirectUriUrl.toString());
return authUrlObj.toString();
return {
url: authUrlObj.toString(),
state,
originalRedirectUri: redirectUriUrl.toString(),
port,
path: `${redirectUriUrl.pathname}${redirectUriUrl.search}`,
};
}
/**
@@ -306,10 +331,13 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
*/
router.post("/auth/login", async (req, res) => {
try {
const { provider } = req.body;
const { provider, origin } = req.body;
if (!provider || typeof provider !== "string") {
throw badRequest("provider is required");
}
if (origin !== undefined && typeof origin !== "string") {
throw badRequest("origin must be a string when provided");
}
// Prevent concurrent logins for the same provider
if (loginInProgress.has(provider)) {
@@ -370,10 +398,18 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
const authInfo = await authUrlPromise;
clearTimeout(timeout);
const reqHost = req.get("host");
const rewrittenUrl = reqHost ? rewriteRedirectUri(authInfo.url, reqHost, isSecureRequest(req)) : authInfo.url;
let responseUrl = authInfo.url;
if (origin && !isLocalhostOrigin(origin)) {
const rewritten = rewriteAuthUrl(authInfo.url, origin);
setOauthSession(rewritten.state, {
port: rewritten.port,
path: rewritten.path,
originalRedirectUri: rewritten.originalRedirectUri,
});
responseUrl = rewritten.url;
}
res.json({ url: rewrittenUrl, instructions: authInfo.instructions });
res.json({ url: responseUrl, instructions: authInfo.instructions });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
@@ -385,6 +421,46 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
}
});
router.get("/auth/oauth-callback", async (req, res) => {
try {
const error = typeof req.query.error === "string" ? req.query.error : undefined;
const code = typeof req.query.code === "string" ? req.query.code : undefined;
const state = typeof req.query.state === "string" ? req.query.state : undefined;
if (error) {
return res.status(400).type("text/html").send(simpleErrorHtml("OAuth failed", error));
}
if (!code || !state) {
return res.status(400).type("text/html").send(simpleErrorHtml("Missing OAuth parameters"));
}
cleanupExpiredOauthSessions();
const session = oauthSessions.get(state);
if (!session || session.expiresAt <= Date.now()) {
oauthSessions.delete(state);
return res.status(400).type("text/html").send(simpleErrorHtml("OAuth session expired or not found"));
}
const callbackUrl = new URL(`http://localhost:${session.port}${session.path}`);
callbackUrl.searchParams.set("code", code);
callbackUrl.searchParams.set("state", state);
const callbackResponse = await fetch(callbackUrl, { method: "GET" });
const responseBody = await callbackResponse.text();
const contentType = callbackResponse.headers.get("content-type") ?? "text/html";
oauthSessions.delete(state);
return res.status(callbackResponse.status).type(contentType).send(responseBody);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/auth/logout
* Removes credentials for a provider.