Skip to content

Commit cbf9365

Browse files
authored
Gate signed-out document requests at the edge instead of flashing the app-shell skeleton (#975)
* Gate signed-out document requests at the edge instead of flashing the app-shell skeleton Signed-out visitors to the cloud app were SSR'd the authenticated app-shell skeleton (sidebar + card grid) until a client-side /account/me round trip 401'd and swapped in the login page — a loading preview of an app they were never going to reach. The sealed session cookie can be verified in the worker itself (unseal + JWT against cached JWKS; no per-request round trip), so a new request middleware does exactly that for document navigations: - signed-out -> 302 to /login?returnTo=<path> before any app HTML is served; an invalid cookie is also cleared (its presence is what routes the production root past the marketing site) - signed-in -> pass through, persisting any rotated sealed session (refresh tokens are single-use) - /login is a real route now; returnTo travels with the CSRF state cookie through the login flow and the callback resumes the original path (validated to same-origin relative, never /api) - /cloud (the marketing CTA target, previously a 404) redirects into the app root Signed-in loads get faster too: AuthProvider seeds optimistic state from a new non-HttpOnly display-only auth-hint cookie it maintains on every confirmed /account/me, so the shell paints without waiting on the probe; the resolved answer remains the authority. Logout and the gate clear the hint alongside the session. Unknown paths get a real 404 page (notFoundComponent) instead of mounting the auth gate's skeleton. The unauthenticated-skeleton e2e (the red repro for this report) is now green and grew scenarios for the deep-link round trip, open-redirect rejection, signed-in passthrough, and the 404 page. * Derive auth state idiomatically in AuthProvider Replace the seven hand-tracked primitive fields with a memoized resolved state: useMemo on the atom value gives the identify and hint-cookie effects a dependency that tracks real query transitions, and the context value is memoized so consumers don't re-render on unrelated renders. * Carry returnTo inside the OAuth state parameter instead of a second cookie The state parameter already round-trips through the identity provider and is authenticated by the wos-login-state CSRF cookie, so the destination rides inside it — base64url(JSON { nonce, returnTo }) — and the wos-login-return-to cookie goes away. The callback re-validates the decoded returnTo like any other untrusted path. * Decode the login-state envelope in the auth-session CSRF scenario The state parameter is now base64url(JSON { nonce, returnTo }), so the scenario decodes it and asserts the nonce inside instead of expecting the raw hex value. * Cover the auth-hint lifecycle and session-gate edges end to end Five new scenarios over the real WorkOS emulator: - auth-hint: a confirmed /account/me writes the hint and the NEXT load paints the real shell while the probe is held open; logout clears the hint with the session. - session-gate: an invalid wos-session is cleared on the way to /login (not just redirected); /cloud routes into the app both signed out and signed in; and a session whose access token no longer verifies is refreshed in-flight — the rotated sealed session reaches the browser, the spent one is revoked server-side (real single-use refresh tokens), and the rotated one keeps working. The stale-token path unseals the real session cookie with the same iron-webcrypto sealing the WorkOS SDK uses, corrupts the JWT signature, and reseals — exercising the gate's refresh branch without waiting out a real expiry.
1 parent 018e496 commit cbf9365

23 files changed

Lines changed: 1030 additions & 65 deletions

apps/cloud/src/auth/api.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ const AuthCallbackSearch = Schema.Struct({
5252
state: Schema.optional(Schema.String),
5353
});
5454

55+
// Where to send the user after the callback completes (the /login page passes
56+
// the path the SSR auth gate captured). Validated server-side to same-origin
57+
// relative paths before use.
58+
const AuthLoginSearch = Schema.Struct({
59+
returnTo: Schema.optional(Schema.String),
60+
});
61+
5562
const PendingInvitationInviter = Schema.Struct({
5663
email: Schema.String,
5764
name: Schema.NullOr(Schema.String),
@@ -143,7 +150,7 @@ const McpApprovalErrors = [
143150

144151
/** Public auth endpoints — no authentication required */
145152
export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic")
146-
.add(HttpApiEndpoint.get("login", "/auth/login"))
153+
.add(HttpApiEndpoint.get("login", "/auth/login", { query: AuthLoginSearch }))
147154
.add(
148155
HttpApiEndpoint.get("callback", "/auth/callback", {
149156
query: AuthCallbackSearch,

apps/cloud/src/auth/cookies.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// The one request-header cookie parser (workos.ts, edge/marketing.ts, and the
2+
// SSR auth gate all need it). Pure string code — safe in any bundle.
3+
4+
export const parseCookie = (cookieHeader: string | null, name: string): string | null => {
5+
if (!cookieHeader) return null;
6+
const match = cookieHeader
7+
.split(";")
8+
.map((c) => c.trim())
9+
.find((c) => c.startsWith(`${name}=`));
10+
if (!match) return null;
11+
return match.slice(name.length + 1) || null;
12+
};

apps/cloud/src/auth/handlers.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import {
1010
McpSessionForbiddenError,
1111
} from "./api";
1212
import { NoOrganization } from "@executor-js/api/server";
13+
// Pure constants/codec module (no React) — safe in the backend graph.
14+
import { AUTH_HINT_COOKIE } from "@executor-js/react/multiplayer/auth-hint";
1315
import { SessionContext, SessionCookies } from "./middleware";
16+
import { encodeLoginState, decodeLoginState } from "./login-state";
17+
import { safeReturnTo } from "./return-to";
1418
import { UserStoreService } from "./context";
1519
import { env } from "cloudflare:workers";
1620
import { WorkOSError } from "./errors";
@@ -63,7 +67,7 @@ const DELETE_COOKIE_OPTIONS = {
6367
secure: true,
6468
};
6569

66-
const randomState = (): string => {
70+
const randomNonce = (): string => {
6771
const bytes = new Uint8Array(32);
6872
crypto.getRandomValues(bytes);
6973
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
@@ -143,14 +147,19 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group(
143147
"cloudAuthPublic",
144148
(handlers) =>
145149
handlers
146-
.handleRaw("login", () =>
150+
.handleRaw("login", ({ query }) =>
147151
Effect.gen(function* () {
148152
const workos = yield* WorkOSClient;
149153
// Use the explicit public site URL — in dev, the request's Host
150154
// header points at the internal proxy target, not the public URL
151155
// WorkOS needs to redirect back to.
152156
const origin = env.VITE_PUBLIC_SITE_URL ?? "";
153-
const state = randomState();
157+
// OAuth round-trips `state` verbatim, so the validated returnTo
158+
// rides inside it next to the CSRF nonce — no extra cookie.
159+
const state = encodeLoginState({
160+
nonce: randomNonce(),
161+
returnTo: safeReturnTo(query.returnTo) ?? undefined,
162+
});
154163
const url = workos.getAuthorizationUrl(`${origin}${AUTH_PATHS.callback}`, state);
155164
return setResponseCookie(
156165
HttpServerResponse.redirect(url, { status: 302 }),
@@ -211,9 +220,15 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group(
211220
return HttpServerResponse.text("Failed to create session", { status: 500 });
212221
}
213222

223+
// Resume where the SSR gate interrupted them. The state passed the
224+
// CSRF check above whenever it's present, but it's still a
225+
// round-tripped value — so the returnTo inside it is re-validated
226+
// like any other untrusted path.
227+
const returnTo = safeReturnTo(decodeLoginState(query.state)?.returnTo) ?? "/";
228+
214229
return deleteResponseCookie(
215230
setResponseCookie(
216-
HttpServerResponse.redirect("/", { status: 302 }),
231+
HttpServerResponse.redirect(returnTo, { status: 302 }),
217232
"wos-session",
218233
sealedSession,
219234
RESPONSE_COOKIE_OPTIONS,
@@ -253,7 +268,13 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
253268
)
254269
.handleRaw("logout", () =>
255270
Effect.succeed(
256-
deleteResponseCookie(HttpServerResponse.redirect("/", { status: 302 }), "wos-session"),
271+
// The auth-hint travels with the session: leaving it behind would
272+
// make the next page load optimistically paint the app shell for a
273+
// signed-out browser.
274+
deleteResponseCookie(
275+
deleteResponseCookie(HttpServerResponse.redirect("/", { status: 302 }), "wos-session"),
276+
AUTH_HINT_COOKIE,
277+
),
257278
),
258279
)
259280
.handle("organizations", () =>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
3+
import { decodeLoginState, encodeLoginState } from "./login-state";
4+
5+
// The OAuth state parameter carries { nonce, returnTo } through the WorkOS
6+
// round trip. It crosses a trust boundary twice (authorize URL out, callback
7+
// query in), so decoding must be total — junk reads as null, never a throw.
8+
describe("login state codec", () => {
9+
it("round-trips nonce + returnTo", () => {
10+
const state = { nonce: "a".repeat(64), returnTo: "/integrations/sentry?addAccount=1" };
11+
expect(decodeLoginState(encodeLoginState(state))).toEqual(state);
12+
});
13+
14+
it("round-trips a state without returnTo", () => {
15+
const state = { nonce: "b".repeat(64) };
16+
expect(decodeLoginState(encodeLoginState(state))).toEqual(state);
17+
});
18+
19+
it("is URL-safe verbatim (no characters that need query escaping)", () => {
20+
const encoded = encodeLoginState({ nonce: "c".repeat(64), returnTo: "/tools?q=a b&x=+/" });
21+
expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
22+
});
23+
24+
// The callback can be reached by WorkOS-initiated redirects whose state we
25+
// never minted, and by anyone typing a URL.
26+
const junk = [
27+
null,
28+
undefined,
29+
"", // absent
30+
"not-base64url!!", // invalid alphabet
31+
"aGVsbG8", // valid base64url, not JSON ("hello")
32+
"eyJmb28iOiJiYXIifQ", // valid JSON, wrong shape ({"foo":"bar"})
33+
];
34+
for (const value of junk) {
35+
it(`reads ${JSON.stringify(value)} as null`, () => {
36+
expect(decodeLoginState(value)).toBeNull();
37+
});
38+
}
39+
});

apps/cloud/src/auth/login-state.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// ---------------------------------------------------------------------------
2+
// Login state — the OAuth `state` parameter for the WorkOS login round trip.
3+
//
4+
// `state` exists for CSRF (the callback checks it against the wos-login-state
5+
// cookie), and OAuth round-trips it verbatim — which makes it the natural
6+
// carrier for `returnTo` too: base64url(JSON { nonce, returnTo }). One value,
7+
// one cookie, and the destination is covered by the same timing-safe
8+
// comparison that authenticates the nonce.
9+
//
10+
// Decoding is total: anything that isn't our encoding reads as null, because
11+
// the callback can also be reached by WorkOS-initiated redirects whose state
12+
// (if any) we never minted.
13+
// ---------------------------------------------------------------------------
14+
15+
import { Encoding, Option, Result, Schema } from "effect";
16+
17+
const LoginStateSchema = Schema.Struct({
18+
nonce: Schema.String,
19+
returnTo: Schema.optional(Schema.String),
20+
});
21+
22+
export type LoginState = typeof LoginStateSchema.Type;
23+
24+
const LoginStateFromJson = Schema.fromJsonString(LoginStateSchema);
25+
const decodeLoginStateJson = Schema.decodeUnknownOption(LoginStateFromJson);
26+
const encodeLoginStateJson = Schema.encodeSync(LoginStateFromJson);
27+
28+
export const encodeLoginState = (state: LoginState): string =>
29+
Encoding.encodeBase64Url(encodeLoginStateJson(state));
30+
31+
/** Decode a state value back; null for anything we didn't mint. */
32+
export const decodeLoginState = (value: string | null | undefined): LoginState | null => {
33+
if (!value) return null;
34+
const json = Result.getOrNull(Encoding.decodeBase64UrlString(value));
35+
return json === null ? null : Option.getOrNull(decodeLoginStateJson(json));
36+
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
3+
import { isSafeReturnTo, loginPath, safeReturnTo } from "./return-to";
4+
5+
// Guards the login round-trip channel (SSR gate → /login → /api/auth/login →
6+
// OAuth state param → callback redirect). Everything here crosses a trust
7+
// boundary, so the validator is what stands between "resume where you were"
8+
// and an open redirect.
9+
describe("isSafeReturnTo", () => {
10+
const safe = ["/", "/tools", "/integrations/sentry?addAccount=1", "/billing/plans"];
11+
for (const path of safe) {
12+
it(`allows ${path}`, () => {
13+
expect(isSafeReturnTo(path)).toBe(true);
14+
});
15+
}
16+
17+
const unsafe = [
18+
"https://evil.example", // absolute URL — off-origin redirect
19+
"//evil.example", // protocol-relative — same thing in disguise
20+
"/api/auth/logout", // API endpoints are never a landing page
21+
"/api", // bare /api too
22+
"javascript:alert(1)", // not a path at all
23+
"tools", // relative paths resolve unpredictably
24+
"", // empty
25+
];
26+
for (const path of unsafe) {
27+
it(`rejects ${JSON.stringify(path)}`, () => {
28+
expect(isSafeReturnTo(path)).toBe(false);
29+
});
30+
}
31+
32+
// /api-keys is a React page, not an API path — the prefix check must not
33+
// swallow it.
34+
it("allows /api-keys (page, not API)", () => {
35+
expect(isSafeReturnTo("/api-keys")).toBe(true);
36+
});
37+
});
38+
39+
describe("safeReturnTo", () => {
40+
it("passes a safe path through", () => {
41+
expect(safeReturnTo("/tools")).toBe("/tools");
42+
});
43+
it("nulls unsafe and absent values", () => {
44+
expect(safeReturnTo("https://evil.example")).toBeNull();
45+
expect(safeReturnTo(null)).toBeNull();
46+
expect(safeReturnTo(undefined)).toBeNull();
47+
});
48+
});
49+
50+
describe("loginPath", () => {
51+
it("omits returnTo for the root (the default destination)", () => {
52+
expect(loginPath("/")).toBe("/login");
53+
});
54+
it("carries deep links URI-encoded", () => {
55+
expect(loginPath("/integrations/sentry?addAccount=1")).toBe(
56+
"/login?returnTo=%2Fintegrations%2Fsentry%3FaddAccount%3D1",
57+
);
58+
});
59+
});

apps/cloud/src/auth/return-to.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// ---------------------------------------------------------------------------
2+
// returnTo — the "send me back where I was" path carried through the login
3+
// flow (SSR gate → /login → /api/auth/login → OAuth state param → callback).
4+
//
5+
// The value crosses trust boundaries (query params, the state round-tripped
6+
// through the identity provider), so every consumer validates with
7+
// `isSafeReturnTo` before using it: same-origin relative paths only — no
8+
// absolute/protocol-relative URLs (open redirect) and nothing under /api
9+
// (bouncing a fresh login into an API endpoint is never what the user meant).
10+
//
11+
// Pure string code — imported by server handlers and the login page alike.
12+
// ---------------------------------------------------------------------------
13+
14+
export const isSafeReturnTo = (path: string): boolean =>
15+
path.startsWith("/") && !path.startsWith("//") && !/^\/api(\/|$)/.test(path);
16+
17+
/** The validated returnTo, or null when absent/unsafe. */
18+
export const safeReturnTo = (path: string | null | undefined): string | null =>
19+
path && isSafeReturnTo(path) ? path : null;
20+
21+
/** The /login URL that comes back to `returnTo` ("/" needs no parameter). */
22+
export const loginPath = (returnTo: string): string =>
23+
returnTo === "/" ? "/login" : `/login?returnTo=${encodeURIComponent(returnTo)}`;

0 commit comments

Comments
 (0)