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

/login and /sign-in answer 308 to /signin, keeping a same-origin next #7728

Merged opened this

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

Merged

torque/forge:torque/7721-login-and-sign-in-answer-308-to-signin-k → main 3 commits fork

  • Merged. torque/7721-login-and-sign-in-answer-308-to-signin-k was merged into main as 819bdb6.

Review

Sign in to review this change.
5 files changed +75 −1
docs/design/audit.md +3 −1
@@ -33,7 +33,7 @@ their own vocabulary instead of composing the named one.
3333
3434 ## Finding 2 — the pages, and what each is for
3535
36 There are 64 top-level pages, one entry each: every static `GET` route in `src/http/server.ts`
36 There are 66 top-level pages, one entry each: every static `GET` route in `src/http/server.ts`
3737 that returns a page (`test/design-audit.test.ts` holds this list to the router,
3838 both ways). Each entry reads `route` — module · what it is for · what
3939 duplicates it · what breaks `docs/DESIGN.md`. "Breaks" counts colour literals
@@ -108,6 +108,8 @@ Machine routes are not pages and have no entry: `/audit.csv`,
108108 - `/signin` — `pages/site.ts` · sign in · `/signin/parent` · colours 3 (file).
109109 - `/signin/parent` — `http/rp.ts` · sign in with the forge that sent this one · `/signin` · no page (redirect).
110110 - `/signin/parent/callback` — `http/rp.ts` · the return leg of that sign-in · none · no page (redirect).
111 - `/login` — `http/server.ts` (`signinAlias`) · the address people type to sign in · it is `/signin`, keeping a same-origin `next` · none (308, no page of its own).
112 - `/sign-in` — `http/server.ts` (`signinAlias`) · the same, hyphenated · it is `/signin` · none (308, no page of its own).
111113 - `/signup` — `pages/site.ts` · make an account · `/signup` passkey flow (`signup-passkey.ts`) · colours 3 (file).
112114 - `/verify` — `pages/site.ts` · confirm an email address · none · colours 3 (file).
113115 - `/recover` — `pages/passkeys.ts` · get back in without a password · `/settings/passkeys` · none seen.
src/http/server.ts +10
@@ -120,6 +120,7 @@ import {
120120 logoutSubmit,
121121 newRepoPage,
122122 newRepoSubmit,
123 safeNext,
123124 signinPage,
124125 signinSubmit,
125126 signupPage,
@@ -745,6 +746,15 @@ export function buildRouter(): Router<Handler> {
745746 router.add("POST", "/home/archived/:op/undo", homeArchiveUndo);
746747 router.add("GET", "/signin", signinPage);
747748 router.add("POST", "/signin", signinSubmit);
749 // The addresses people type to sign in (#7721): a permanent move to
750 // `/signin`, carrying `next` only when `safeNext` calls it a path here.
751 const signinAlias = (ctx: Ctx): Response => {
752 const next = safeNext(ctx.url.searchParams.get("next"));
753 const location = next === undefined ? "/signin" : `/signin?${new URLSearchParams({ next })}`;
754 return new Response(null, { status: 308, headers: { location } });
755 };
756 router.add("GET", "/login", signinAlias);
757 router.add("GET", "/sign-in", signinAlias);
748758 router.add("GET", "/signin/parent", parentStart);
749759 router.add("GET", "/signin/parent/callback", parentCallback);
750760 router.add("GET", "/signup", signupPage);
test/forge-looks.test.ts +2
@@ -40,6 +40,8 @@ const NOT_A_PAGE: Record<string, string> = {
4040 "/asks": "redirects to /#needs-you",
4141 "/big": "redirects to /board?wall",
4242 "/flow": "redirects to /boards",
43 "/login": "redirects to /signin",
44 "/sign-in": "redirects to /signin",
4345 "/mcp": "a protocol endpoint, answers 405 to a GET",
4446 "/_measure": "the page-load beacon, a POST endpoint",
4547 "/events": "a live event stream that never ends",
test/server-timing-sweep.test.ts +2
@@ -44,8 +44,10 @@ const REDIRECTS = new Set([
4444 "/alice/forge/tells/new",
4545 "/big",
4646 "/flow",
47 "/login",
4748 "/settings/origins/callback",
4849 "/settings/resilience",
50 "/sign-in",
4951 ]);
5052
5153 /** Every GET pattern the router source registers, one-line or wrapped. */
test/signin-alias.test.ts added +58
@@ -0,0 +1,58 @@
1 /**
2 * `/login` and `/sign-in` are the addresses people type: each answers 308 to
3 * `/signin`, keeping `next` only when it is a path on this forge (#7721).
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11
12 let dataDir: string;
13 let app: App;
14 let servers: Servers;
15
16 beforeAll(async () => {
17 dataDir = await mkdtemp(join(tmpdir(), "forge-signin-alias-"));
18 app = createApp({ dataDir, port: 0, url: "https://forge.example" });
19 servers = await serve(app);
20 });
21 afterAll(async () => {
22 await servers.stop();
23 app.close();
24 await rm(dataDir, { recursive: true, force: true });
25 });
26
27 const go = (path: string) => fetch(`${servers.url}${path}`, { redirect: "manual" });
28
29 for (const alias of ["/login", "/sign-in"]) {
30 describe(alias, () => {
31 test("answers 308 to /signin", async () => {
32 const res = await go(alias);
33 expect(res.status).toBe(308);
34 expect(res.headers.get("location")).toBe("/signin");
35 });
36
37 test("keeps a same-origin next", async () => {
38 const res = await go(`${alias}?next=/settings`);
39 expect(res.status).toBe(308);
40 expect(res.headers.get("location")).toBe("/signin?next=%2Fsettings");
41 const back = new URL(res.headers.get("location")!, servers.url);
42 expect(back.searchParams.get("next")).toBe("/settings");
43 });
44
45 test("drops a next that leaves the forge", async () => {
46 for (const next of ["//evil.test", "https://evil.test", "/\\evil.test"]) {
47 const res = await go(`${alias}?next=${encodeURIComponent(next)}`);
48 expect(res.status).toBe(308);
49 expect(res.headers.get("location")).toBe("/signin");
50 }
51 });
52 });
53 }
54
55 test("/signup is not an alias", async () => {
56 const res = await go("/signup");
57 expect(res.status).not.toBe(308);
58 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs