Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion apps/host-selfhost/src/auth/return-to.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "@effect/vitest";

import { isSafeReturnTo, loginPath, safeReturnTo } from "./return-to";
import { isSafeReturnTo, loginPath, mcpAuthorizeResumeTarget, safeReturnTo } from "./return-to";

describe("isSafeReturnTo", () => {
const safe = [
Expand Down Expand Up @@ -45,6 +45,34 @@ describe("safeReturnTo", () => {
});
});

describe("mcpAuthorizeResumeTarget", () => {
// Better Auth bounces an unauthenticated MCP authorize to /login carrying the
// OAuth params; after sign-in we resume by handing them back to the authorize
// endpoint so it issues a code (then the consent shim takes over).
it("rebuilds the authorize URL from a real MCP authorize redirect", () => {
const search =
"?response_type=code&client_id=abc&code_challenge=xyz&code_challenge_method=S256" +
"&redirect_uri=http%3A%2F%2Flocalhost%3A3118%2Fcallback&state=s1&scope=openid+profile&prompt=consent";
const target = mcpAuthorizeResumeTarget(search);
expect(target).not.toBeNull();
expect(target!.startsWith("/api/auth/mcp/authorize?")).toBe(true);
const params = new URLSearchParams(target!.split("?")[1]);
expect(params.get("client_id")).toBe("abc");
expect(params.get("redirect_uri")).toBe("http://localhost:3118/callback");
expect(params.get("response_type")).toBe("code");
});

it("ignores searches that are not an authorize request", () => {
expect(mcpAuthorizeResumeTarget("")).toBeNull();
expect(mcpAuthorizeResumeTarget("?returnTo=%2Ftools")).toBeNull();
// response_type alone is not enough: client_id and redirect_uri are required.
expect(mcpAuthorizeResumeTarget("?response_type=code&client_id=abc")).toBeNull();
expect(
mcpAuthorizeResumeTarget("?response_type=token&client_id=abc&redirect_uri=x"),
).toBeNull();
Comment on lines +68 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The test comment says "client_id and redirect_uri are required" but only exercises the direction where redirect_uri is absent — the symmetric case of redirect_uri present but client_id absent is not covered. Adding it makes the required-fields contract explicit in both directions.

Suggested change
// response_type alone is not enough: client_id and redirect_uri are required.
expect(mcpAuthorizeResumeTarget("?response_type=code&client_id=abc")).toBeNull();
expect(
mcpAuthorizeResumeTarget("?response_type=token&client_id=abc&redirect_uri=x"),
).toBeNull();
// response_type alone is not enough: client_id and redirect_uri are required.
expect(mcpAuthorizeResumeTarget("?response_type=code&client_id=abc")).toBeNull();
expect(mcpAuthorizeResumeTarget("?response_type=code&redirect_uri=x")).toBeNull();
expect(
mcpAuthorizeResumeTarget("?response_type=token&client_id=abc&redirect_uri=x"),
).toBeNull();

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

});
});

describe("loginPath", () => {
it("omits returnTo for the root", () => {
expect(loginPath("/")).toBe("/login");
Expand Down
20 changes: 20 additions & 0 deletions apps/host-selfhost/src/auth/return-to.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,23 @@ export const safeReturnTo = (path: string | null | undefined): string | null =>

export const loginPath = (returnTo: string): string =>
returnTo === "/" ? "/login" : `/login?returnTo=${encodeURIComponent(returnTo)}`;

// Better Auth's MCP authorize endpoint redirects an unauthenticated client to
// `loginPage` (/login) carrying the original OAuth request as query params
// (`response_type=code`, `client_id`, `redirect_uri`, `code_challenge`, ...).
// Unlike the integration OAuth callback (which arrives as `returnTo`), there is
// no returnTo here: the params ARE the request. After sign-in the login page
// must hand control back to the authorize endpoint so the now-authenticated
// request issues a code (and, via the consent shim, lands on /mcp-consent).
// Given a location search string, return that resume URL when it carries an MCP
// authorize request, else null. The target is our own same-origin authorize
// endpoint, which validates client_id/redirect_uri, so this is not an open
// redirect.
const MCP_AUTHORIZE_PATH = "/api/auth/mcp/authorize";

export const mcpAuthorizeResumeTarget = (search: string): string | null => {
const params = new URLSearchParams(search);
if (params.get("response_type") !== "code") return null;
if (!params.get("client_id") || !params.get("redirect_uri")) return null;
return `${MCP_AUTHORIZE_PATH}?${params.toString()}`;
};
14 changes: 11 additions & 3 deletions apps/host-selfhost/web/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Input } from "@executor-js/react/components/input";
import { Label } from "@executor-js/react/components/label";

import { authClient } from "./auth-client";
import { safeReturnTo } from "../src/auth/return-to";
import { mcpAuthorizeResumeTarget, safeReturnTo } from "../src/auth/return-to";

// Self-host login: email + password sign-in via Better Auth. On success we
// reload so the shared AuthProvider re-reads /account/me and the AuthGate swaps
Expand All @@ -16,7 +16,15 @@ import { safeReturnTo } from "../src/auth/return-to";
// redeeming an invite — either the full /join/<code> link, or by entering the
// code here ("Have an invite code?"), which forwards to the same join page.
export const LoginPage = () => {
const returnTo = safeReturnTo(new URLSearchParams(window.location.search).get("returnTo")) ?? "/";
const search = window.location.search;
// Where to go after sign-in: resume an interrupted MCP OAuth authorize if we
// arrived from one (Better Auth redirects it here with the OAuth params),
// otherwise honor a safe returnTo (e.g. an integration OAuth callback), else
// land on the dashboard.
const postLogin =
mcpAuthorizeResumeTarget(search) ??
safeReturnTo(new URLSearchParams(search).get("returnTo")) ??
"/";
const [mode, setMode] = useState<"signin" | "code">("signin");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
Expand All @@ -34,7 +42,7 @@ export const LoginPage = () => {
setError(result.error.message ?? "Sign in failed");
return;
}
window.location.href = returnTo;
window.location.href = postLogin;
};

const redeem = (event: FormEvent) => {
Expand Down
Loading