Public: anyone can read this; the crew can change it.

Private pages work with the platform edge turned off: the forge alone issues and checks every session #1521

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/1460-private-pages-work-with-the-platform-edg → main 1 commit fork

  • Merged. scout/1460-private-pages-work-with-the-platform-edg was merged into main as 4537355.

Review

Sign in to review this change.
6 files changed +119 −10
scripts/gate.ts +1
@@ -163,6 +163,7 @@ export const GATE: readonly string[] = [
163163 "test/diff-edge-cases.test.ts",
164164 "test/diff.test.ts",
165165 "test/edge-auth.test.ts",
166 "test/own-session.test.ts",
166167 "test/edit-words.test.ts",
167168 "test/edits-security.test.ts",
168169 "test/edits.test.ts",
src/http/api.ts +2 −2
@@ -1145,7 +1145,7 @@ export function apiRoutes(router: Router<Handler>): void {
11451145 if (!verified.ok) return unauthorized();
11461146 const session = createSession(ctx.app.db, verified.value.id);
11471147 return json(publicUser(verified.value), 201, {
1148 "set-cookie": sessionCookie(session.token, session.expiresAt, ctx.url),
1148 "set-cookie": sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url),
11491149 });
11501150 });
11511151
@@ -1155,7 +1155,7 @@ export function apiRoutes(router: Router<Handler>): void {
11551155 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
11561156 signOutHere(ctx.app, cookieValue(ctx.request, "forge_session"));
11571157 return json({}, 200, {
1158 "set-cookie": sessionCookie("", new Date(0), ctx.url),
1158 "set-cookie": sessionCookie("", new Date(0), ctx.url, ctx.app.config.url),
11591159 });
11601160 });
11611161
src/web/pages/claim.ts +1 −1
@@ -289,6 +289,6 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> {
289289 // Home: where the crew's work shows (its first-login screen is #600).
290290 return redirect(
291291 "/",
292 sessionCookie(session.token, session.expiresAt, ctx.url),
292 sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url),
293293 );
294294 }
src/web/pages/everyday.ts +2 −2
@@ -182,7 +182,7 @@ export async function everydayCreate(ctx: Ctx): Promise<Response> {
182182 });
183183 const session = createSession(ctx.app.db, made.value.id);
184184 return redirect("/", {
185 cookie: sessionCookie(session.token, session.expiresAt, ctx.url),
185 cookie: sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url),
186186 url: ctx.url,
187187 flash: {
188188 message: `You are signed in as ${made.value.username}, your everyday account. Sign in as ${admin.username} when you mean to change the forge.`,
@@ -215,7 +215,7 @@ export async function everydaySwitch(ctx: Ctx): Promise<Response> {
215215 if (!daily) return new Response("not found", { status: 404 });
216216 signOutHere(ctx.app, cookieValue(ctx.request, "forge_session"));
217217 return redirect("/signin", {
218 cookie: sessionCookie("", new Date(0), ctx.url),
218 cookie: sessionCookie("", new Date(0), ctx.url, ctx.app.config.url),
219219 url: ctx.url,
220220 flash: { message: `Sign in as ${daily.username}, your everyday account.` },
221221 });
src/web/pages/site.ts +15 −5
@@ -54,8 +54,18 @@ import { existsSync } from "node:fs";
5454 import { join } from "node:path";
5555 import { hasAdmin } from "../../spawn/claim.ts";
5656
57 export function sessionCookie(value: string, expires: Date, url: URL): string {
58 const secure = url.protocol === "https:" ? "; Secure" : "";
57 export function sessionCookie(
58 value: string,
59 expires: Date,
60 url: URL,
61 forgeUrl?: string,
62 ): string {
63 // Secure when the request or the forge's own address is https: behind a
64 // proxy that ends TLS the request itself arrives as http.
65 const secure =
66 url.protocol === "https:" || forgeUrl?.startsWith("https://")
67 ? "; Secure"
68 : "";
5969 return `forge_session=${value}; Path=/; HttpOnly; SameSite=Lax; Expires=${expires.toUTCString()}${secure}`;
6070 }
6171
@@ -521,7 +531,7 @@ export async function signinSubmit(ctx: Ctx): Promise<Response> {
521531 const session = createSession(ctx.app.db, verified.value.id);
522532 return redirect(
523533 safeNext(ctx.url.searchParams.get("next")) ?? "/",
524 sessionCookie(session.token, session.expiresAt, ctx.url),
534 sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url),
525535 );
526536 }
527537
@@ -577,7 +587,7 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> {
577587 const session = createSession(ctx.app.db, created.value.id);
578588 return redirect(
579589 safeNext(ctx.url.searchParams.get("next")) ?? "/",
580 sessionCookie(session.token, session.expiresAt, ctx.url),
590 sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url),
581591 );
582592 }
583593
@@ -610,7 +620,7 @@ export function logoutSubmit(ctx: Ctx): Response {
610620 if (crossOrigin(ctx.request, ctx.url, ctx.app.log))
611621 return new Response("forbidden", { status: 403 });
612622 signOutHere(ctx.app, cookieValue(ctx.request, "forge_session"));
613 return redirect("/", sessionCookie("", new Date(0), ctx.url));
623 return redirect("/", sessionCookie("", new Date(0), ctx.url, ctx.app.config.url));
614624 }
615625
616626 // ---- new repository ----
test/own-session.test.ts added +98
@@ -0,0 +1,98 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { createRepo } from "../src/core/repos.ts";
7 import { listSessions } from "../src/core/sessions.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10
11 // #1460: with no platform edge configured the forge alone issues and checks
12 // every session, and an edge header never grants access.
13 describe("a forge with no platform edge", () => {
14 let dataDir: string;
15 let app: App;
16 let servers: Servers;
17 let base: string;
18
19 const signIn = async () => {
20 const r = await fetch(`${base}/signin`, {
21 method: "POST",
22 headers: { origin: "https://forge.example" },
23 body: new URLSearchParams({ username: "olive", password: "a-good-passphrase" }),
24 redirect: "manual",
25 });
26 return r;
27 };
28 const get = (path: string, headers: Record<string, string> = {}) =>
29 fetch(`${base}${path}`, { headers, redirect: "manual" });
30
31 beforeAll(async () => {
32 dataDir = await mkdtemp(join(tmpdir(), "forge-own-session-"));
33 app = createApp({ dataDir, port: 0, url: "https://forge.example" });
34 servers = await serve(app);
35 base = servers.url;
36 const made = await createUser(app.db, {
37 trusted: true,
38 username: "olive",
39 email: "[email protected]",
40 password: "a-good-passphrase",
41 adminIfFirst: true,
42 });
43 if (!made.ok) throw new Error(made.error);
44 const repo = await createRepo(app.db, app.reposRoot, {
45 ownerId: made.value.id,
46 name: "vault",
47 visibility: "private",
48 });
49 if (!repo.ok) throw new Error(repo.error);
50 });
51 afterAll(async () => {
52 servers.stop();
53 app.close();
54 await rm(dataDir, { recursive: true, force: true });
55 });
56
57 test("the session cookie is HttpOnly, SameSite, and Secure for an https forge", async () => {
58 const r = await signIn();
59 expect(r.status).toBe(303);
60 const cookie = r.headers.get("set-cookie")!;
61 expect(cookie).toContain("HttpOnly");
62 expect(cookie).toContain("SameSite=Lax");
63 expect(cookie).toContain("Secure");
64 });
65
66 test("a private page needs the forge's own session", async () => {
67 const cookie = (await signIn()).headers.get("set-cookie")!.split(";")[0]!;
68 for (const path of ["/olive/vault", "/settings/accounts"]) {
69 const anon = await get(path);
70 expect(anon.status).not.toBe(200);
71 expect(await anon.text()).not.toContain("vault</");
72 const authed = await get(path, { cookie });
73 expect(authed.status).toBe(200);
74 }
75 expect(await (await get("/olive/vault", { cookie })).text()).toContain("vault");
76 });
77
78 test("edge headers grant nothing when no edge is configured", async () => {
79 const forged = { "x-plat-user": "olive", "x-plat-edge-token": "anything" };
80 expect((await get("/olive/vault", forged)).status).not.toBe(200);
81 expect((await get("/settings/accounts", forged)).status).not.toBe(200);
82 });
83
84 test("a session revoked by its owner is refused on the next request", async () => {
85 const cookie = (await signIn()).headers.get("set-cookie")!.split(";")[0]!;
86 expect((await get("/olive/vault", { cookie })).status).toBe(200);
87 const user = app.db.query<{ id: string }, []>("SELECT id FROM user").get()!;
88 const id = listSessions(app.db, user.id, cookie.split("=")[1]).find((s) => s.current)!.id;
89 const ended = await fetch(`${base}/settings/accounts/olive/sessions/${id}/delete`, {
90 method: "POST",
91 headers: { cookie, origin: "https://forge.example" },
92 body: new URLSearchParams({}),
93 redirect: "manual",
94 });
95 expect(ended.status).toBe(303);
96 expect((await get("/olive/vault", { cookie })).status).not.toBe(200);
97 });
98 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs