feat(FN-2930): merge fusion/fn-2930
- test(FN-2930): complete Step 2 — add auth redirect rewrite coverage - feat(FN-2930): complete Step 1 — rewrite OAuth redirect_uri host - feat(FN-2928): merge fusion/fn-2928 Fusion-Task-Id: FN-2930
This commit is contained in:
@@ -4810,6 +4810,82 @@ describe("POST /auth/login", () => {
|
|||||||
expect(res.body.instructions).toBe("Open in browser");
|
expect(res.body.instructions).toBe("Open in browser");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rewrites localhost redirect_uri to request hostname", 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",
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves auth URL unchanged when redirect_uri is not localhost", async () => {
|
||||||
|
const unchangedUrl =
|
||||||
|
"https://accounts.example.com/o/oauth2/v2/auth?redirect_uri=https%3A%2F%2Fdashboard.example.com%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: "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 () => {
|
it("returns 400 when provider is missing", async () => {
|
||||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({}), {
|
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({}), {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@@ -37,6 +37,50 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
*/
|
*/
|
||||||
const loginInProgress = new Map<string, AbortController>();
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteRedirectUri(authUrl: string, reqHost: string, secure: boolean): string {
|
||||||
|
const trimmedHost = reqHost.trim();
|
||||||
|
if (!trimmedHost) {
|
||||||
|
return authUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
let authUrlObj: URL;
|
||||||
|
try {
|
||||||
|
authUrlObj = new URL(authUrl);
|
||||||
|
} catch {
|
||||||
|
return authUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri = authUrlObj.searchParams.get("redirect_uri");
|
||||||
|
if (!redirectUri) {
|
||||||
|
return authUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
let redirectUriUrl: URL;
|
||||||
|
try {
|
||||||
|
redirectUriUrl = new URL(redirectUri);
|
||||||
|
} catch {
|
||||||
|
return authUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redirectUriUrl.hostname !== "localhost" && redirectUriUrl.hostname !== "127.0.0.1") {
|
||||||
|
return authUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/auth/status
|
* GET /api/auth/status
|
||||||
* Returns list of all providers with their authentication status and type.
|
* Returns list of all providers with their authentication status and type.
|
||||||
@@ -325,7 +369,11 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
|
|
||||||
const authInfo = await authUrlPromise;
|
const authInfo = await authUrlPromise;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
res.json({ url: authInfo.url, instructions: authInfo.instructions });
|
|
||||||
|
const reqHost = req.get("host");
|
||||||
|
const rewrittenUrl = reqHost ? rewriteRedirectUri(authInfo.url, reqHost, isSecureRequest(req)) : authInfo.url;
|
||||||
|
|
||||||
|
res.json({ url: rewrittenUrl, instructions: authInfo.instructions });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
Reference in New Issue
Block a user