-
Notifications
You must be signed in to change notification settings - Fork 165
Expand file tree
/
Copy pathssr-gate.ts
More file actions
131 lines (113 loc) · 6.06 KB
/
Copy pathssr-gate.ts
File metadata and controls
131 lines (113 loc) · 6.06 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
// ---------------------------------------------------------------------------
// SSR auth gate — the server-side session check for DOCUMENT requests.
//
// Before this gate, every signed-out visitor was served the SPA, whose root
// AuthGate SSRs the AUTHENTICATED app-shell skeleton until a client-side
// `/account/me` round trip 401s — the "bad skeleton on unauthed state" flash.
// The sealed `wos-session` cookie can be verified right here in the worker
// (unseal + JWT check against cached JWKS — no per-request WorkOS round trip
// except token refresh), so signed-out visitors are 302'd to /login before any
// app HTML exists, and signed-in visitors proceed knowing the session is real.
//
// Scope: GET/HEAD requests that are document navigations (sec-fetch-dest /
// accept), excluding app-owned paths (/api, /mcp — they answer for themselves
// earlier in the middleware chain). Everything else passes through untouched.
// ---------------------------------------------------------------------------
import { createMiddleware } from "@tanstack/react-start";
import { Effect, Exit, ManagedRuntime } from "effect";
import { AUTH_HINT_COOKIE } from "@executor-js/react/multiplayer/auth-hint";
import { isAppOwnedPath } from "../app-paths";
import { parseCookie } from "./cookies";
import { loginPath, safeReturnTo } from "./return-to";
import { WorkOSClient } from "./workos";
const SESSION_COOKIE = "wos-session";
/** Mirrors the handlers' COOKIE_OPTIONS (path /, HttpOnly, Lax, 7d, Secure). */
const SESSION_COOKIE_ATTRIBUTES = "Path=/; HttpOnly; Secure; SameSite=Lax";
const SESSION_MAX_AGE = 60 * 60 * 24 * 7;
const isDocumentRequest = (request: Request): boolean => {
if (request.method !== "GET" && request.method !== "HEAD") return false;
// Browsers label navigations explicitly; non-browser clients fall back to
// content negotiation. Anything that isn't asking for a page (vite module
// requests, JSON fetches, health probes) passes through ungated.
const dest = request.headers.get("sec-fetch-dest");
if (dest !== null) return dest === "document";
return request.headers.get("accept")?.includes("text/html") ?? false;
};
// Lazy for the same reason start.ts instantiates the app handler lazily: this
// module reaches workers-only imports (cloudflare:workers via ./workos), which
// must stay behind the stripped `.server()` callback so the client bundle
// never pulls them in. One runtime per isolate — the WorkOS client holds no
// sockets, just config and a JWKS cache, so sharing it across requests is
// exactly what the unified app handler already does.
let runtime: ManagedRuntime.ManagedRuntime<WorkOSClient, unknown> | undefined;
const getRuntime = () => (runtime ??= ManagedRuntime.make(WorkOSClient.Default));
type VerifiedSession = { readonly refreshedSession?: string | undefined };
// EVERY failure collapses to "signed out" — WorkOS errors inside the effect
// and layer-construction errors like a bad cookie password (runPromiseExit
// carries those in its Exit too) — so the login flow surfaces the real
// problem instead of 500ing every page.
const verifySession = async (sealed: string): Promise<VerifiedSession | null> => {
const exit = await getRuntime().runPromiseExit(
Effect.flatMap(WorkOSClient.asEffect(), (workos) => workos.authenticateSealedSession(sealed)),
);
return Exit.isSuccess(exit) ? exit.value : null;
};
const sessionSetCookie = (sealed: string) =>
`${SESSION_COOKIE}=${sealed}; ${SESSION_COOKIE_ATTRIBUTES}; Max-Age=${SESSION_MAX_AGE}`;
const redirect = (
location: string,
options?: {
/** Drop the (invalid) session + auth-hint cookies along the way. */
readonly clearSession?: boolean;
/** Persist a WorkOS-rotated sealed session (refresh tokens are single-use). */
readonly refreshedSession?: string | undefined;
},
): Response => {
const headers = new Headers({ location });
if (options?.clearSession) {
headers.append("set-cookie", `${SESSION_COOKIE}=; ${SESSION_COOKIE_ATTRIBUTES}; Max-Age=0`);
headers.append("set-cookie", `${AUTH_HINT_COOKIE}=; Path=/; SameSite=Lax; Max-Age=0`);
}
if (options?.refreshedSession) {
headers.append("set-cookie", sessionSetCookie(options.refreshedSession));
}
return new Response(null, { status: 302, headers });
};
export const authGateMiddleware = createMiddleware({ type: "request" }).server(
async ({ pathname, request, next }) => {
if (isAppOwnedPath(pathname) || !isDocumentRequest(request)) return next();
const sealed = parseCookie(request.headers.get("cookie"), SESSION_COOKIE);
const url = new URL(request.url);
// /login is the one page signed-out visitors are FOR; a signed-in visitor
// landing here is bounced straight back to where they were headed.
if (pathname === "/login") {
const session = sealed ? await verifySession(sealed) : null;
if (!session) return next();
return redirect(safeReturnTo(url.searchParams.get("returnTo")) ?? "/", {
refreshedSession: session.refreshedSession,
});
}
// Marketing CTAs link to /cloud, which is not a route — it's "open the
// app". Send it to the root (the gate below decides app vs login).
const returnTo = pathname === "/cloud" ? "/" : `${pathname}${url.search}`;
if (!sealed) return redirect(loginPath(returnTo));
const session = await verifySession(sealed);
if (!session) {
// A cookie that doesn't verify is worse than none: on executor.sh its
// mere presence keeps routing / into the app instead of marketing.
return redirect(loginPath(returnTo), { clearSession: true });
}
if (pathname === "/cloud") {
return redirect("/", { refreshedSession: session.refreshedSession });
}
const result = await next();
if (session.refreshedSession) {
// WorkOS refresh tokens are single-use: the rotated sealed session MUST
// reach the browser or the next expiry logs the user out.
const response = new Response(result.response.body, result.response);
response.headers.append("set-cookie", sessionSetCookie(session.refreshedSession));
return response;
}
return result;
},
);