Skip to content

Commit 22a0783

Browse files
committed
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 48cf8ee commit 22a0783

5 files changed

Lines changed: 263 additions & 8 deletions

File tree

bun.lock

Lines changed: 4 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

e2e/cloud/auth-hint.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Cloud-specific: the auth-hint cookie lifecycle. The sealed session is
2+
// HttpOnly, so on a fresh page load the SPA can't know it's signed in until
3+
// /account/me resolves — the hint (executor-auth-hint, non-HttpOnly, written
4+
// by AuthProvider once /account/me confirms) is what lets the NEXT load paint
5+
// the real app shell immediately instead of a skeleton. These scenarios pin
6+
// the full loop: confirmed identity writes it, the next load seeds from it
7+
// while the probe is still in flight, and logout takes it away.
8+
import { expect } from "@effect/vitest";
9+
import { Effect } from "effect";
10+
11+
import { scenario } from "../src/scenario";
12+
import { Browser, Target } from "../src/services";
13+
14+
const HINT_COOKIE = "executor-auth-hint";
15+
16+
scenario(
17+
"Auth hint · a confirmed session writes the hint, and the next load paints the real shell from it",
18+
{},
19+
Effect.gen(function* () {
20+
const browser = yield* Browser;
21+
const target = yield* Target;
22+
const identity = yield* target.newIdentity();
23+
24+
yield* browser.session(identity, async ({ page, step }) => {
25+
const hintCookie = async () =>
26+
(await page.context().cookies()).find((cookie) => cookie.name === HINT_COOKIE);
27+
28+
await step("First signed-in load → /account/me confirms → the hint is written", async () => {
29+
await page.goto("/", { waitUntil: "commit" });
30+
await page.getByRole("link", { name: "Policies" }).waitFor();
31+
// The write happens in an effect after /account/me resolves.
32+
await expect.poll(hintCookie, { timeout: 10_000 }).toBeTruthy();
33+
});
34+
35+
const hint = (await hintCookie())!;
36+
expect(hint.httpOnly, "the hint is readable by the SPA — that's its job").toBe(false);
37+
expect(
38+
decodeURIComponent(hint.value),
39+
"it carries the confirmed identity (display data only)",
40+
).toContain(identity.label);
41+
42+
// Hold the auth probe open on the SECOND load. Without the hint this
43+
// window is a full-page skeleton; with it, the real shell.
44+
let probeResolved = false;
45+
await page.route("**/api/account/me", async (route) => {
46+
await new Promise((resolve) => setTimeout(resolve, 2_000));
47+
probeResolved = true;
48+
await route.continue();
49+
});
50+
51+
await step("Reload: the real app shell paints while /account/me is in flight", async () => {
52+
await page.goto("/", { waitUntil: "commit" });
53+
// Real nav text — the loading skeleton has no text at all.
54+
await page.getByRole("link", { name: "Policies" }).waitFor();
55+
});
56+
57+
expect(probeResolved, "the shell did NOT wait for /account/me — the hint seeded it").toBe(
58+
false,
59+
);
60+
expect(
61+
await page.getByRole("link", { name: "Billing" }).isVisible(),
62+
"it is the full signed-in nav, not a placeholder",
63+
).toBe(true);
64+
});
65+
}),
66+
);
67+
68+
scenario(
69+
"Auth hint · logout clears the hint with the session",
70+
{},
71+
Effect.gen(function* () {
72+
const browser = yield* Browser;
73+
const target = yield* Target;
74+
const identity = yield* target.newIdentity();
75+
76+
yield* browser.session(identity, async ({ page, step }) => {
77+
await step("Load the app signed in (the hint gets written)", async () => {
78+
await page.goto("/", { waitUntil: "commit" });
79+
await page.getByRole("link", { name: "Policies" }).waitFor();
80+
await expect
81+
.poll(async () => (await page.context().cookies()).some((c) => c.name === HINT_COOKIE), {
82+
timeout: 10_000,
83+
})
84+
.toBe(true);
85+
});
86+
87+
await step("Sign out through the product flow", async () => {
88+
// The shell's sign-out POSTs the logout endpoint from the page, so
89+
// the response's Set-Cookie clears apply to this browser context.
90+
await page.evaluate(() => fetch("/api/auth/logout", { method: "POST" }));
91+
});
92+
93+
const names = (await page.context().cookies()).map((cookie) => cookie.name);
94+
expect(names, "the hint never outlives the session").not.toContain(HINT_COOKIE);
95+
expect(names, "the session itself is gone too").not.toContain("wos-session");
96+
});
97+
}),
98+
);

e2e/cloud/auth-session.test.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ const setCookieFor = (response: Response, name: string): string => {
1818
return "";
1919
};
2020

21+
// state = base64url(JSON { nonce, returnTo? }) — the app's login-state
22+
// envelope (apps/cloud/src/auth/login-state.ts).
23+
const decodeLoginState = Schema.decodeUnknownOption(
24+
Schema.fromJsonString(
25+
Schema.Struct({ nonce: Schema.String, returnTo: Schema.optional(Schema.String) }),
26+
),
27+
);
28+
2129
scenario(
2230
"Auth · login redirects to hosted AuthKit carrying a short-lived CSRF state cookie",
2331
{},
@@ -33,13 +41,11 @@ scenario(
3341

3442
const authorizeUrl = new URL(response.headers.get("location") ?? "");
3543
const state = authorizeUrl.searchParams.get("state") ?? "";
36-
// state = base64url(JSON { nonce, returnTo? }) — the nonce is the CSRF
37-
// secret; returnTo (absent here, no query was passed) rides beside it.
38-
const decoded = Schema.decodeUnknownOption(
39-
Schema.fromJsonString(
40-
Schema.Struct({ nonce: Schema.String, returnTo: Schema.optional(Schema.String) }),
41-
),
42-
)(Result.getOrElse(Encoding.decodeBase64UrlString(state), () => ""));
44+
// The nonce is the CSRF secret; returnTo (absent here, no query was
45+
// passed) rides beside it.
46+
const decoded = decodeLoginState(
47+
Result.getOrElse(Encoding.decodeBase64UrlString(state), () => ""),
48+
);
4349
expect(decoded._tag, "the state decodes as our login-state envelope").toBe("Some");
4450
expect(
4551
decoded._tag === "Some" ? decoded.value.nonce : "",

e2e/cloud/session-gate.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Cloud-specific: the SSR auth gate's session handling BEYOND the basic
2+
// signed-out redirect (unauthenticated-skeleton.test.ts owns that): an
3+
// invalid cookie is actively cleared, /cloud (the marketing CTA path) routes
4+
// into the app, and — the nastiest path — a session whose access token no
5+
// longer verifies is refreshed in-flight, with the rotated sealed session
6+
// reaching the browser. WorkOS refresh tokens are single-use, so losing that
7+
// Set-Cookie silently logs the user out on next expiry; these scenarios pin
8+
// it against the real WorkOS emulator (real rotation, real revocation).
9+
import { expect } from "@effect/vitest";
10+
import { Effect } from "effect";
11+
import * as Iron from "iron-webcrypto";
12+
13+
import { scenario } from "../src/scenario";
14+
import { Api, Target } from "../src/services";
15+
import { E2E_COOKIE_PASSWORD } from "../targets/cloud";
16+
17+
/** A signed-out-style document request (what the gate keys on). */
18+
const documentRequest = (url: URL, cookie?: string) =>
19+
Effect.promise(() =>
20+
fetch(url, {
21+
redirect: "manual",
22+
headers: { accept: "text/html", ...(cookie ? { cookie } : {}) },
23+
}),
24+
);
25+
26+
const setCookieFor = (response: Response, name: string): string => {
27+
for (const header of response.headers.getSetCookie()) {
28+
if (header.startsWith(`${name}=`)) return header;
29+
}
30+
return "";
31+
};
32+
33+
scenario(
34+
"Session gate · an invalid session cookie is cleared on the way to /login",
35+
{},
36+
Effect.gen(function* () {
37+
// Gate: the REST API plane is mounted on this target.
38+
yield* Api;
39+
const target = yield* Target;
40+
41+
// A cookie that was never a sealed session. On executor.sh its mere
42+
// presence routes / past the marketing page, so the gate must not just
43+
// redirect — it must take the cookie (and the auth hint beside it) away.
44+
const response = yield* documentRequest(
45+
new URL("/", target.baseUrl),
46+
"wos-session=not-a-real-session; executor-auth-hint=stale",
47+
);
48+
expect(response.status, "an unverifiable session is signed out").toBe(302);
49+
expect(response.headers.get("location"), "…to the login page").toBe("/login");
50+
51+
const clearedSession = setCookieFor(response, "wos-session");
52+
expect(clearedSession, "the dead session cookie is dropped").toContain("Max-Age=0");
53+
const clearedHint = setCookieFor(response, "executor-auth-hint");
54+
expect(clearedHint, "the auth hint goes with it").toContain("Max-Age=0");
55+
}),
56+
);
57+
58+
scenario(
59+
"Session gate · /cloud (the marketing CTA path) routes into the app",
60+
{},
61+
Effect.gen(function* () {
62+
// Gate: the REST API plane is mounted on this target.
63+
yield* Api;
64+
const target = yield* Target;
65+
66+
// Marketing CTAs link to /cloud, which is not a route — it means "open
67+
// the app". Signed out, that lands on login (no returnTo: the deep link
68+
// IS the root)…
69+
const signedOut = yield* documentRequest(new URL("/cloud", target.baseUrl));
70+
expect(signedOut.status, "signed-out /cloud is gated like any page").toBe(302);
71+
expect(signedOut.headers.get("location"), "…straight to login").toBe("/login");
72+
73+
// …and signed in, it opens the app at the root instead of 404ing.
74+
const identity = yield* target.newIdentity();
75+
const signedIn = yield* documentRequest(
76+
new URL("/cloud", target.baseUrl),
77+
identity.headers!.cookie!,
78+
);
79+
expect(signedIn.status, "signed-in /cloud is a redirect, not a 404").toBe(302);
80+
expect(signedIn.headers.get("location"), "…into the app").toBe("/");
81+
}),
82+
);
83+
84+
// The sealed wos-session is iron-sealed JSON { accessToken, refreshToken, … }.
85+
// Corrupting the access token's signature makes the gate's verify fail the
86+
// same way an EXPIRED token does (invalid JWT → refresh path) — without
87+
// waiting out a real expiry. Same sealing library + password map the WorkOS
88+
// SDK uses, so the gate can't tell this seal from one the SDK minted.
89+
const withTamperedAccessToken = async (sessionCookie: string): Promise<string> => {
90+
const sealed = sessionCookie.slice("wos-session=".length).replace(/~\d$/, "");
91+
const session = (await Iron.unseal(sealed, { "1": E2E_COOKIE_PASSWORD }, Iron.defaults)) as {
92+
accessToken: string;
93+
};
94+
const [header, payload, signature] = session.accessToken.split(".");
95+
const tampered = {
96+
...session,
97+
accessToken: `${header}.${payload}.${[...(signature ?? "")].reverse().join("")}`,
98+
};
99+
const resealed = await Iron.seal(
100+
tampered,
101+
{ id: "1", secret: E2E_COOKIE_PASSWORD },
102+
Iron.defaults,
103+
);
104+
return `wos-session=${resealed}~2`;
105+
};
106+
107+
scenario(
108+
"Session gate · a stale access token is refreshed in-flight and the rotated session reaches the browser",
109+
{},
110+
Effect.gen(function* () {
111+
// Gate: the REST API plane is mounted on this target.
112+
yield* Api;
113+
const target = yield* Target;
114+
115+
const identity = yield* target.newIdentity();
116+
const staleCookie = yield* Effect.promise(() =>
117+
withTamperedAccessToken(identity.headers!.cookie!),
118+
);
119+
120+
// 1. The page is served (no login detour) — the gate refreshed the
121+
// session against WorkOS mid-request — and the ROTATED sealed session
122+
// is set on the response. Refresh tokens are single-use: dropping
123+
// this Set-Cookie would log the user out at the next expiry.
124+
const refreshed = yield* documentRequest(new URL("/", target.baseUrl), staleCookie);
125+
expect(refreshed.status, "a refreshable session still gets the page").toBe(200);
126+
const rotated = setCookieFor(refreshed, "wos-session");
127+
expect(rotated, "the rotated sealed session is persisted").toContain("Max-Age=");
128+
expect(rotated, "…as a fresh value, not the stale one").not.toContain(
129+
staleCookie.slice("wos-session=".length, 80),
130+
);
131+
132+
// 2. The rotation was real: the OLD session's refresh token is revoked
133+
// server-side, so replaying the stale cookie now signs out.
134+
const replayed = yield* documentRequest(new URL("/", target.baseUrl), staleCookie);
135+
expect(replayed.status, "the spent session is refused").toBe(302);
136+
expect(replayed.headers.get("location"), "…and signed out to login").toBe("/login");
137+
expect(
138+
setCookieFor(replayed, "wos-session"),
139+
"the spent cookie is cleared, not left to retry forever",
140+
).toContain("Max-Age=0");
141+
142+
// 3. And the rotated session the browser was handed actually works.
143+
const rotatedCookie = rotated.split(";")[0]!;
144+
const next = yield* documentRequest(new URL("/tools", target.baseUrl), rotatedCookie);
145+
expect(next.status, "the rotated session opens the app").toBe(200);
146+
}),
147+
);

e2e/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@types/react": "catalog:",
3737
"@types/react-dom": "catalog:",
3838
"@vitejs/plugin-react": "catalog:",
39+
"iron-webcrypto": "^2.0.0",
3940
"typescript": "catalog:",
4041
"vite": "catalog:",
4142
"vitest": "catalog:"

0 commit comments

Comments
 (0)