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:
@@ -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 }),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user