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
9 changes: 8 additions & 1 deletion apps/cloud/src/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ const AuthCallbackSearch = Schema.Struct({
state: Schema.optional(Schema.String),
});

// Where to send the user after the callback completes (the /login page passes
// the path the SSR auth gate captured). Validated server-side to same-origin
// relative paths before use.
const AuthLoginSearch = Schema.Struct({
returnTo: Schema.optional(Schema.String),
});

const PendingInvitationInviter = Schema.Struct({
email: Schema.String,
name: Schema.NullOr(Schema.String),
Expand Down Expand Up @@ -143,7 +150,7 @@ const McpApprovalErrors = [

/** Public auth endpoints — no authentication required */
export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic")
.add(HttpApiEndpoint.get("login", "/auth/login"))
.add(HttpApiEndpoint.get("login", "/auth/login", { query: AuthLoginSearch }))
.add(
HttpApiEndpoint.get("callback", "/auth/callback", {
query: AuthCallbackSearch,
Expand Down
12 changes: 12 additions & 0 deletions apps/cloud/src/auth/cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// The one request-header cookie parser (workos.ts, edge/marketing.ts, and the
// SSR auth gate all need it). Pure string code — safe in any bundle.

export const parseCookie = (cookieHeader: string | null, name: string): string | null => {
if (!cookieHeader) return null;
const match = cookieHeader
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith(`${name}=`));
if (!match) return null;
return match.slice(name.length + 1) || null;
};
31 changes: 26 additions & 5 deletions apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import {
McpSessionForbiddenError,
} from "./api";
import { NoOrganization } from "@executor-js/api/server";
// Pure constants/codec module (no React) — safe in the backend graph.
import { AUTH_HINT_COOKIE } from "@executor-js/react/multiplayer/auth-hint";
import { SessionContext, SessionCookies } from "./middleware";
import { encodeLoginState, decodeLoginState } from "./login-state";
import { safeReturnTo } from "./return-to";
import { UserStoreService } from "./context";
import { env } from "cloudflare:workers";
import { WorkOSError } from "./errors";
Expand Down Expand Up @@ -63,7 +67,7 @@ const DELETE_COOKIE_OPTIONS = {
secure: true,
};

const randomState = (): string => {
const randomNonce = (): string => {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
Expand Down Expand Up @@ -143,14 +147,19 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group(
"cloudAuthPublic",
(handlers) =>
handlers
.handleRaw("login", () =>
.handleRaw("login", ({ query }) =>
Effect.gen(function* () {
const workos = yield* WorkOSClient;
// Use the explicit public site URL — in dev, the request's Host
// header points at the internal proxy target, not the public URL
// WorkOS needs to redirect back to.
const origin = env.VITE_PUBLIC_SITE_URL ?? "";
const state = randomState();
// OAuth round-trips `state` verbatim, so the validated returnTo
// rides inside it next to the CSRF nonce — no extra cookie.
const state = encodeLoginState({
nonce: randomNonce(),
returnTo: safeReturnTo(query.returnTo) ?? undefined,
});
const url = workos.getAuthorizationUrl(`${origin}${AUTH_PATHS.callback}`, state);
return setResponseCookie(
HttpServerResponse.redirect(url, { status: 302 }),
Expand Down Expand Up @@ -211,9 +220,15 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group(
return HttpServerResponse.text("Failed to create session", { status: 500 });
}

// Resume where the SSR gate interrupted them. The state passed the
// CSRF check above whenever it's present, but it's still a
// round-tripped value — so the returnTo inside it is re-validated
// like any other untrusted path.
const returnTo = safeReturnTo(decodeLoginState(query.state)?.returnTo) ?? "/";

return deleteResponseCookie(
setResponseCookie(
HttpServerResponse.redirect("/", { status: 302 }),
HttpServerResponse.redirect(returnTo, { status: 302 }),
"wos-session",
sealedSession,
RESPONSE_COOKIE_OPTIONS,
Expand Down Expand Up @@ -253,7 +268,13 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
)
.handleRaw("logout", () =>
Effect.succeed(
deleteResponseCookie(HttpServerResponse.redirect("/", { status: 302 }), "wos-session"),
// The auth-hint travels with the session: leaving it behind would
// make the next page load optimistically paint the app shell for a
// signed-out browser.
deleteResponseCookie(
deleteResponseCookie(HttpServerResponse.redirect("/", { status: 302 }), "wos-session"),
AUTH_HINT_COOKIE,
),
),
)
.handle("organizations", () =>
Expand Down
39 changes: 39 additions & 0 deletions apps/cloud/src/auth/login-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from "@effect/vitest";

import { decodeLoginState, encodeLoginState } from "./login-state";

// The OAuth state parameter carries { nonce, returnTo } through the WorkOS
// round trip. It crosses a trust boundary twice (authorize URL out, callback
// query in), so decoding must be total — junk reads as null, never a throw.
describe("login state codec", () => {
it("round-trips nonce + returnTo", () => {
const state = { nonce: "a".repeat(64), returnTo: "/integrations/sentry?addAccount=1" };
expect(decodeLoginState(encodeLoginState(state))).toEqual(state);
});

it("round-trips a state without returnTo", () => {
const state = { nonce: "b".repeat(64) };
expect(decodeLoginState(encodeLoginState(state))).toEqual(state);
});

it("is URL-safe verbatim (no characters that need query escaping)", () => {
const encoded = encodeLoginState({ nonce: "c".repeat(64), returnTo: "/tools?q=a b&x=+/" });
expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
});

// The callback can be reached by WorkOS-initiated redirects whose state we
// never minted, and by anyone typing a URL.
const junk = [
null,
undefined,
"", // absent
"not-base64url!!", // invalid alphabet
"aGVsbG8", // valid base64url, not JSON ("hello")
"eyJmb28iOiJiYXIifQ", // valid JSON, wrong shape ({"foo":"bar"})
];
for (const value of junk) {
it(`reads ${JSON.stringify(value)} as null`, () => {
expect(decodeLoginState(value)).toBeNull();
});
}
});
36 changes: 36 additions & 0 deletions apps/cloud/src/auth/login-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// ---------------------------------------------------------------------------
// Login state — the OAuth `state` parameter for the WorkOS login round trip.
//
// `state` exists for CSRF (the callback checks it against the wos-login-state
// cookie), and OAuth round-trips it verbatim — which makes it the natural
// carrier for `returnTo` too: base64url(JSON { nonce, returnTo }). One value,
// one cookie, and the destination is covered by the same timing-safe
// comparison that authenticates the nonce.
//
// Decoding is total: anything that isn't our encoding reads as null, because
// the callback can also be reached by WorkOS-initiated redirects whose state
// (if any) we never minted.
// ---------------------------------------------------------------------------

import { Encoding, Option, Result, Schema } from "effect";

const LoginStateSchema = Schema.Struct({
nonce: Schema.String,
returnTo: Schema.optional(Schema.String),
});

export type LoginState = typeof LoginStateSchema.Type;

const LoginStateFromJson = Schema.fromJsonString(LoginStateSchema);
const decodeLoginStateJson = Schema.decodeUnknownOption(LoginStateFromJson);
const encodeLoginStateJson = Schema.encodeSync(LoginStateFromJson);

export const encodeLoginState = (state: LoginState): string =>
Encoding.encodeBase64Url(encodeLoginStateJson(state));

/** Decode a state value back; null for anything we didn't mint. */
export const decodeLoginState = (value: string | null | undefined): LoginState | null => {
if (!value) return null;
const json = Result.getOrNull(Encoding.decodeBase64UrlString(value));
return json === null ? null : Option.getOrNull(decodeLoginStateJson(json));
};
59 changes: 59 additions & 0 deletions apps/cloud/src/auth/return-to.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from "@effect/vitest";

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

// Guards the login round-trip channel (SSR gate → /login → /api/auth/login →
// OAuth state param → callback redirect). Everything here crosses a trust
// boundary, so the validator is what stands between "resume where you were"
// and an open redirect.
describe("isSafeReturnTo", () => {
const safe = ["/", "/tools", "/integrations/sentry?addAccount=1", "/billing/plans"];
for (const path of safe) {
it(`allows ${path}`, () => {
expect(isSafeReturnTo(path)).toBe(true);
});
}

const unsafe = [
"https://evil.example", // absolute URL — off-origin redirect
"//evil.example", // protocol-relative — same thing in disguise
"/api/auth/logout", // API endpoints are never a landing page
"/api", // bare /api too
"javascript:alert(1)", // not a path at all
"tools", // relative paths resolve unpredictably
"", // empty
];
for (const path of unsafe) {
it(`rejects ${JSON.stringify(path)}`, () => {
expect(isSafeReturnTo(path)).toBe(false);
});
}

// /api-keys is a React page, not an API path — the prefix check must not
// swallow it.
it("allows /api-keys (page, not API)", () => {
expect(isSafeReturnTo("/api-keys")).toBe(true);
});
});

describe("safeReturnTo", () => {
it("passes a safe path through", () => {
expect(safeReturnTo("/tools")).toBe("/tools");
});
it("nulls unsafe and absent values", () => {
expect(safeReturnTo("https://evil.example")).toBeNull();
expect(safeReturnTo(null)).toBeNull();
expect(safeReturnTo(undefined)).toBeNull();
});
});

describe("loginPath", () => {
it("omits returnTo for the root (the default destination)", () => {
expect(loginPath("/")).toBe("/login");
});
it("carries deep links URI-encoded", () => {
expect(loginPath("/integrations/sentry?addAccount=1")).toBe(
"/login?returnTo=%2Fintegrations%2Fsentry%3FaddAccount%3D1",
);
});
});
23 changes: 23 additions & 0 deletions apps/cloud/src/auth/return-to.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// ---------------------------------------------------------------------------
// returnTo — the "send me back where I was" path carried through the login
// flow (SSR gate → /login → /api/auth/login → OAuth state param → callback).
//
// The value crosses trust boundaries (query params, the state round-tripped
// through the identity provider), so every consumer validates with
// `isSafeReturnTo` before using it: same-origin relative paths only — no
// absolute/protocol-relative URLs (open redirect) and nothing under /api
// (bouncing a fresh login into an API endpoint is never what the user meant).
//
// Pure string code — imported by server handlers and the login page alike.
// ---------------------------------------------------------------------------

export const isSafeReturnTo = (path: string): boolean =>
path.startsWith("/") && !path.startsWith("//") && !/^\/api(\/|$)/.test(path);

/** The validated returnTo, or null when absent/unsafe. */
export const safeReturnTo = (path: string | null | undefined): string | null =>
path && isSafeReturnTo(path) ? path : null;

/** The /login URL that comes back to `returnTo` ("/" needs no parameter). */
export const loginPath = (returnTo: string): string =>
returnTo === "/" ? "/login" : `/login?returnTo=${encodeURIComponent(returnTo)}`;
Loading
Loading