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

Identity comes from the forge: /admin opens for a forge admin and the password sign-in is gone #21

Merged opened this

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

Merged

mason/bazaar:mason/13-identity-comes-from-the-forge-admin-open → main 4 commits fork 3 threads · 2 unresolved

  • Merged. mason/13-identity-comes-from-the-forge-admin-open was merged into main as 4cf6b27.

Review

Sign in to review this change.
9 files changed +112 −16
README.md +9 −3
@@ -120,10 +120,16 @@ session cookie is still there, but it only carries the CSRF token now.
120120 | **admin** | Runs the shop: every path under `/admin`, and any order without its token. |
121121
122122 Anyone who is not an admin gets one page at `/admin`, status 403, whose only
123 action is **Sign in** at `/__forge/signin?to=<the path they asked for>`; the
123 action is **Sign in** at `/__forge/signin?to=<the page they asked for>`; the
124124 site header offers the same link, and **Sign out** at `/__forge/signout` once
125 somebody is signed in. The terminal takes the same three roles from the
126 environment and keeps stock moves and refunds for an admin.
125 somebody is signed in. That `to` is the page's path *and* query, so signing in
126 from `/search?q=beans` comes back to the results — with one exception: an
127 order's `token` is stripped, because it opens that order to whoever holds it
128 and the sign-in path is handed to the forge. Those links also carry
129 `rel="noreferrer"`: `/__forge/*` shares this origin, so the `Referer` would
130 otherwise hand over the very URL `to` was careful to clean. The terminal takes
131 the same three roles from the environment and keeps stock moves and refunds
132 for an admin.
127133
128134 ## Shape
129135
src/admin.ts +2 −2
@@ -21,7 +21,7 @@ import { forOrder, outbox } from "./mail.ts";
2121 import { parseCents } from "./money.ts";
2222 import { render, type Ctx } from "./context.ts";
2323 import { notFound } from "./shop.ts";
24 import { identityConfigured } from "./who.ts";
24 import { identityConfigured, returnTo } from "./who.ts";
2525
2626 const MESSAGES: Record<string, { text: string; bad?: boolean }> = {
2727 saved: { text: "Saved." },
@@ -55,7 +55,7 @@ const guard = (ctx: Ctx): Response | null =>
5555 : render(
5656 ctx,
5757 { title: "Admin", admin: true, status: 403 },
58 pages.forbidden(ctx.url.pathname + ctx.url.search, identityConfigured()),
58 pages.forbidden(returnTo(ctx.url), identityConfigured()),
5959 );
6060
6161 // --- the door ---------------------------------------------------------------
src/context.ts +2 −1
@@ -10,6 +10,7 @@ import { layout } from "./html.ts";
1010 import type { Applied } from "./codes.ts";
1111 import type { CartLine } from "./cart.ts";
1212 import type { Session } from "./session.ts";
13 import { returnTo } from "./who.ts";
1314 import type { Who } from "./who.ts";
1415
1516 export type Ctx = {
@@ -45,7 +46,7 @@ export function render(
4546 layout({
4647 title: options.title,
4748 who: ctx.who,
48 path: ctx.url.pathname,
49 path: returnTo(ctx.url),
4950 cartCount: cartCount(ctx.cart),
5051 categories: categories(),
5152 current: options.current,
src/html.ts +4 −4
@@ -8,7 +8,7 @@
88 */
99
1010 import { CSS_PATH } from "./style.ts";
11 import { SIGN_OUT_HREF, signInHref } from "./who.ts";
11 import { FORGE_LINK_ATTRS, SIGN_OUT_HREF, signInHref } from "./who.ts";
1212 import type { Session } from "./session.ts";
1313 import type { Who } from "./who.ts";
1414
@@ -43,7 +43,7 @@ export type Chrome = {
4343 title: string;
4444 /** Who the forge says is here: it decides the account corner and the Admin link. */
4545 who: Who;
46 /** The path being rendered, so "Sign in" comes back to it. */
46 /** The page being rendered, path and query, so "Sign in" comes back to it. */
4747 path: string;
4848 cartCount: number;
4949 categories: { slug: string; name: string }[];
@@ -56,10 +56,10 @@ export type Chrome = {
5656 /** The account corner: who the forge says you are, and the door either way. */
5757 export const account = (who: Who, path: string): string =>
5858 who.role === "anon"
59 ? `<span class="who"><a href="${escape(signInHref(path))}">Sign in</a></span>`
59 ? `<span class="who"><a ${FORGE_LINK_ATTRS} href="${escape(signInHref(path))}">Sign in</a></span>`
6060 : `<span class="who"><strong>${escape(who.name)}</strong>${
6161 who.role === "admin" ? ' <span class="muted">admin</span>' : ""
62 } <a href="${escape(SIGN_OUT_HREF)}">Sign out</a></span>`;
62 } <a ${FORGE_LINK_ATTRS} href="${escape(SIGN_OUT_HREF)}">Sign out</a></span>`;
6363
6464 export function layout(chrome: Chrome): string {
6565 const link = (href: string, text: string, key: string): string =>
src/pages/admin.ts +3 −3
@@ -5,7 +5,7 @@
55 */
66
77 import { csrfField, escape, variantRowStyle } from "../html.ts";
8 import { SIGN_OUT_HREF, signInHref } from "../who.ts";
8 import { FORGE_LINK_ATTRS, SIGN_OUT_HREF, signInHref } from "../who.ts";
99 import { formatCents } from "../money.ts";
1010 import { itemsTable, orderTotals, statusPill } from "./checkout.ts";
1111 import { describe } from "../discounts.ts";
@@ -31,7 +31,7 @@ export const nav = (current: string): string =>
3131 key === current ? `<strong>${escape(label)}</strong>` : `<a href="${escape(href)}">${escape(label)}</a>`,
3232 )
3333 .join("")}
34 <a href="${escape(SIGN_OUT_HREF)}" style="margin-left:auto">Sign out</a></nav>`;
34 <a ${FORGE_LINK_ATTRS} href="${escape(SIGN_OUT_HREF)}" style="margin-left:auto">Sign out</a></nav>`;
3535
3636 /**
3737 * What anyone who is not an admin sees under /admin: one sentence and one
@@ -41,7 +41,7 @@ export const nav = (current: string): string =>
4141 export const forbidden = (to: string, configured: boolean): string => `
4242 <h1>Admin</h1>
4343 <p class="lede">This is for the people who run the shop. The forge says who you are; it has not said you are an admin here.</p>
44 <p><a class="btn" href="${escape(signInHref(to))}">Sign in</a></p>
44 <p><a class="btn" ${FORGE_LINK_ATTRS} href="${escape(signInHref(to))}">Sign in</a></p>
4545 ${
4646 configured
4747 ? '<p class="muted">Signed in already? Then this account is not an admin of this app.</p>'
src/server.test.ts +18
@@ -611,6 +611,22 @@ test("/admin opens for an admin the forge signed, and refunds and restocks", asy
611611 expect(await admin.page("/admin/mail")).toContain(`Your bazaar order ${number}`);
612612 });
613613
614 test("no link to the forge carries a referrer, so the page's query stays here", async () => {
615 // Referrer-Policy is per origin and /__forge/* shares ours, so without this
616 // the Referer on the way to the sign-in would be the whole current URL —
617 // including an order's access token, which `to=` is careful to strip.
618 const anon = new Shopper();
619 expect(await anon.page("/")).toContain('<a rel="noreferrer" href="/__forge/signin?to=%2F">Sign in</a>');
620 const results = await anon.page("/search?q=beans");
621 expect(results).toContain('<a rel="noreferrer" href="/__forge/signin?to=%2Fsearch%3Fq%3Dbeans"');
622 const closed = await (await anon.get("/admin/orders")).text();
623 expect(closed).toContain('rel="noreferrer" href="/__forge/signin?to=%2Fadmin%2Forders"');
624
625 const admin = asAdmin();
626 expect(await admin.page("/")).toContain('<a rel="noreferrer" href="/__forge/signout">Sign out</a>');
627 expect(await admin.page("/admin")).toContain('<a rel="noreferrer" href="/__forge/signout"');
628 });
629
614630 test("/admin is 403 and a sign-in link for everybody the forge did not sign as an admin", async () => {
615631 const anon = new Shopper();
616632 const closed = await anon.get("/admin/orders");
@@ -621,6 +637,8 @@ test("/admin is 403 and a sign-in link for everybody the forge did not sign as a
621637 expect(page).not.toContain('type="password"');
622638 // the shop itself is still open, and the header offers the way in
623639 expect(await anon.page("/")).toContain("/__forge/signin?to=%2F");
640 // and it comes back to the page you were on, query and all
641 expect(await anon.page("/search?q=beans")).toContain("/__forge/signin?to=%2Fsearch%3Fq%3Dbeans");
624642
625643 const user = new Shopper({ id: "u_2", name: "bo", role: "user" });
626644 expect((await user.get("/admin")).status).toBe(403);
src/style.ts +6 −2
@@ -26,9 +26,13 @@ a { color: inherit; text-underline-offset: .2em; }
2626 header.top { border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--paper); z-index: 5; }
2727 header.top .wrap { display: flex; gap: 1rem; align-items: center; min-height: 3.75rem; flex-wrap: wrap; }
2828 .brand { font-weight: 700; letter-spacing: -.02em; font-size: 1.15rem; text-decoration: none; }
29 nav.cats { display: flex; gap: .9rem; flex: 1; font-size: .92rem; flex-wrap: wrap; }
29 nav.cats { display: flex; gap: .9rem; flex: 1 0 auto; font-size: .92rem; flex-wrap: wrap; }
3030 nav.cats a[aria-current="page"] { font-weight: 600; }
31 form.find { display: flex; gap: .4rem; }
31 /* The search box gives up its width first, so an extra link (Admin) or a long
32 name in the account corner does not push the categories onto a second row. */
33 form.find { display: flex; gap: .4rem; flex: 0 1 14rem; min-width: 9rem; }
34 form.find input { flex: 1 1 auto; width: auto; min-width: 0; }
35 form.find .btn { flex: none; }
3236 input, select, textarea, button { font: inherit; color: inherit; }
3337 input, select, textarea { background: transparent; border: 1px solid var(--line); border-radius: .5rem; padding: .5rem .65rem; width: 100%; }
3438 input:focus-visible, select:focus-visible, textarea:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
src/who.test.ts +32 −1
@@ -1,7 +1,7 @@
11 /** The identity check, on its own: what the forge signs, and what it does not. */
22
33 import { expect, test } from "bun:test";
4 import { atKeyboard, stamp, whoIs } from "./who.ts";
4 import { atKeyboard, returnTo, signInHref, stamp, whoIs } from "./who.ts";
55
66 const KEY = "a-key-only-the-forge-and-this-app-know";
77
@@ -78,3 +78,34 @@ test("the terminal's person comes from the environment the forge set", () => {
7878 role: "anon",
7979 });
8080 });
81
82 test("signing in comes back to the page you were on, query and all", () => {
83 const at = (href: string) => returnTo(new URL(href, "https://bazaar.example"));
84 expect(at("/search?q=beans")).toBe("/search?q=beans");
85 expect(at("/")).toBe("/");
86 expect(at("/admin/orders?m=saved&q=1001")).toBe("/admin/orders?m=saved&q=1001");
87 // never the host, and never a query long enough to be a nuisance
88 expect(at("/c/coffee")).toBe("/c/coffee");
89 expect(at(`/search?q=${"x".repeat(600)}`)).toBe("/search");
90 });
91
92 test("an order's access token is not carried into the forge's sign-in", () => {
93 const at = (href: string) => returnTo(new URL(href, "https://bazaar.example"));
94 expect(at("/orders/1001?token=secret")).toBe("/orders/1001");
95 expect(at("/pay/1001?token=secret&m=x")).toBe("/pay/1001?m=x");
96 expect(signInHref(at("/orders/1001?token=secret"))).toBe("/__forge/signin?to=%2Forders%2F1001");
97 });
98
99 test("a path a browser would read as a host comes back as the root", () => {
100 const at = (pathname: string) => {
101 const url = new URL("https://bazaar.example/");
102 url.pathname = pathname;
103 return returnTo(url);
104 };
105 expect(at("//evil.com/x")).toBe("/");
106 expect(at("/\\evil.com/x")).toBe("/");
107 expect(at("///evil.com")).toBe("/");
108 // a real path still comes back whole
109 expect(at("/")).toBe("/");
110 expect(at("/orders/1001")).toBe("/orders/1001");
111 });
src/who.ts +36
@@ -64,4 +64,40 @@ export function atKeyboard(env: NodeJS.ProcessEnv = process.env): Who {
6464
6565 /** Where a person goes to become someone: the forge's sign-in, and back here. */
6666 export const signInHref = (to: string): string => `/__forge/signin?to=${encodeURIComponent(to)}`;
67
68 /**
69 * The page to come back to after signing in: path and query, as a same-origin
70 * path. An order's access token is the one thing left behind — it opens that
71 * order to whoever holds it, and this string is handed to the forge, which is
72 * one more place for it to be written down. The rule is the reason, not the
73 * name: any query parameter that is a secret — a token, a code, a key — is
74 * dropped here too, so add it to the list when you add it to a URL. A query
75 * long enough to be a nuisance is dropped and the path alone comes back.
outdated

Approved. Two nits from #13, each in one commit, no behaviour beyond them and no dependency. I read every changed line, ran the suite at 64f2686 (42 pass / 0 fail) and reverted context.ts to the old ctx.url.pathname to confirm the new server test does fail without the fix — it does. I exercised the preview as a visitor: / in ~230 ms over the network, /search?q=beans renders to=%2Fsearch%3Fq%3Dbeans, / renders to=%2F, add-to-cart → /cart → /checkout still walks the whole path, and the security floor is intact on every response (CSP with script-src 'none' and base-uri 'none', nosniff, Referrer-Policy, HttpOnly/SameSite=Lax/Secure sid).

Security of returnTo specifically, since it is a string this app hands to the forge:

  • No open redirect. The value is always url.pathname (+ re-serialised query), so it always begins with a single /, and signInHref percent-encodes it. I tried the usual protocol-relative shapes against the preview: //evil.com/x normalises to to=%2Fevil.com%2Fx, /\/evil.com comes back as to=%2F%255C%2Fevil.com. Nothing escapes the origin.
  • No injection. ?q="><script> round-trips as %2522%253E%253Cscript%253E inside the href, and account() escapes on top of that.
  • token stripping is right, and delete removes every copy of the parameter, not just the first. The 512-character fallback to the bare path is measured before encoding, which is the conservative direction.

Two observations, neither blocking, neither worth a round:

  1. The token still reaches the forge by the other door. Referrer-Policy: strict-origin-when-cross-origin sends the full URL on a same-origin navigation, so clicking Sign in from /orders/1001?token=… puts ?token=… in the Referer on the request to /__forge/signin. to= was the more durable of the two channels (it survives into a redirect target), so this commit is still a net win — but if you want the rest of it closed, Referrer-Policy: same-origin in the floor in src/server.ts is the one-line version.
  2. The cost you named in the description is real and I agree with the trade: an anon on an order page who signs in lands on /orders/N without its token and gets nothing. Worth a follow-up only if someone reports it; the emailed link still works.

The CSS does what it claims. In the desktop picture the categories, Admin, search, Cart and the account corner sit on one row with the search box shrunk; in the phone picture the header wraps into clean rows with the categories together and no overlap. nav.cats { flex: 1 0 auto } means the categories no longer shrink, which is the point — the wrap below 9rem of search box is the escape hatch.

One cosmetic thing for whoever next touches README.md: the new sentence left The terminal takes the same three roles from the trailing on an over-long line, out of step with the wrapping around it.

outdated

Approved — this is the follow-up I asked for on #13, plus a third commit I had not asked for and that is right.

I read all three commits, ran the suite at 735cbd0 (43 pass / 0 fail) and exercised the preview as a visitor. The direction holds: identity still comes only from the forge's signed headers, /admin is still 403 with no password field for anyone the forge has not signed as an admin, and no dependency arrived.

returnTo is the right shape. One function owns the return path; src/context.ts and the /admin guard both call it, so the header and the door can no longer disagree. The value is always built from url.pathname plus a re-serialised query, so it cannot be steered by a form field, and signInHref percent-encodes it: ?q="><script> round-trips as %2522%253E%253Cscript%253E inside the href and account() escapes on top. query.delete("token") removes every copy of the parameter, not just the first.

The rel="noreferrer" commit is correct rather than cargo-culted. The floor sets referrer-policy: strict-origin-when-cross-origin, which is decided per origin, and /__forge/ is served on this one — so without it the Referer on the way to the sign-in would have carried the whole current URL, including the very token that to= strips. Closing both channels or neither is the only coherent choice. Verified on the preview: the header is intact on every response, and every /__forge/ anchor in the tree goes through the constant.

The preview behaves. / 200 in ~230 ms over the network, /search?q=beans renders to=%2Fsearch%3Fq%3Dbeans, /admin is 403 with the sign-in link and to=%2Fadmin. The desktop picture shows the header on one row with both the Admin link and the account corner, which is what the CSS commit was for; the phone wraps to a second row as before, with no shift.

Protocol-relative paths, since returnTo is now the chokepoint. A request to //evil.com/x renders to=%2F%2Fevil.com%2Fx on our 404 page. I followed it: the forge collapses the path and /__forge/signin sanitises it back to to=%2F, so there is no open redirect today. One line here — require /^\/[^/\\]/ or fall back to "/" — would stop that safety depending on the forge's behaviour rather than ours. Not blocking.

Two things left, neither a reason to hold this:

  1. The sign-in link is a dead end on the money path. An order opens only with its token, so a person part-way through checkout at /pay/1001?token=… who clicks Sign in returns to /pay/1001 and gets a 404. The pull discloses it, but a 404 mid-purchase is a lost sale, not a missing page, and it sits on the one path the whole shop exists for. The token must not go to the forge, so the fix is elsewhere: either do not offer the link on a token-opened order or pay page, or let an order open for the account that placed it — #12's ground now that ctx.who exists. Worth an issue before that path is walked for real.
  2. The deny-list is a list of one. Dropping token is complete today, because every other query parameter in this app is q, m or added, but it will not stay complete on its own: the next code or key that arrives in a query string rides along to the forge silently. A line in returnTo's comment naming that rule would keep the next person honest.

Nit, one line: SIGN_OUT_HREF now sits against returnTo's closing brace with no blank line between them, and the new doc block follows it — a blank line above line 82 puts the file back in the shape the rest of it has.

76 *
77 * `//evil.com/x` is a path to us and an off-origin URL to a browser, so a
78 * pathname that could be read as a host does not travel: the root comes back
79 * instead. The forge sanitises `to=` as well, but an open redirect should not
80 * depend on someone else's care.
81 */
82 const SECRET_PARAMS = ["token"];
83
84 export function returnTo(url: URL): string {
85 if (!/^\/($|[^/\\])/.test(url.pathname)) return "/";

Approved. Four small commits, all inside the direction (identity from the forge, nothing of our own), no dependency, and the security reasoning runs the right way round. Anchored here because this line is the only new surface in the change.

What I read and ran

  • returnTo is the whole new attack surface. The guard /^\/($|[^/\\])/ rejects a pathname a browser would read as a host — I ran it: //evil.com/x, /\\evil.com/x and ///evil.com all come back /. Everything else is a URL pathname, so ?, # and control characters are already percent-encoded, and the result is encodeURIComponentd in signInHref and escaped into the attribute. No injection path, and not leaning on the forge to sanitise to= is the right instinct.
  • Stripping token is complete, not just illustrative: token is the only secret query parameter the app reads (src/shop.ts:221); every other read is m, q, added. ?TOKEN=x survives the filter, but searchParams.get("token") is case-sensitive so a surviving variant is not a working token. Naming the rule rather than the parameter in the comment is what keeps that true later.
  • rel="noreferrer" closes the other half of the same leak, and closes all of it: Referrer-Policy: strict-origin-when-cross-origin does nothing for a same-origin hop to /__forge/, and every anchor to /__forge/ in the tree (src/html.ts:59,62, src/pages/admin.ts:34,44) now goes through FORGE_LINK_ATTRS. None left behind.
  • Tests: bun test is 44 pass / 0 fail at 5b9f4fd. I reverted context.ts to the old ctx.url.pathname in a scratch copy and both new server assertions fail, so the behaviour is genuinely pinned.
  • Preview: / and /search?q=beans serve to=%2F and to=%2Fsearch%3Fq%3Dbeans with rel="noreferrer"; /admin/orders is 403 with the same link and no password field anywhere; the floor is on every response (CSP with script-src 'none' and base-uri 'none', nosniff, Referrer-Policy, HttpOnly; SameSite=Lax; Secure sid). I bought a thing as a guest through the simulated gateway: 303 to /pay/BZ-…?token=…, the pay page renders, and its header Sign in is to=%2Fpay%2FBZ-… with the token gone — exactly as advertised.

One nit, for this pull or the next hand on the header. nav.cats { flex: 1 0 auto } forbids the categories from shrinking below their single-line content width (the item wraps internally, so its auto basis is max-content). Signed in as an admin the five links measure ~322px in the phone picture; at a 360px viewport, minus the wrap padding, they no longer fit and the header scrolls sideways instead of wrapping — the old flex: 1 squeezed and wrapped. flex: 1 1 auto; min-width: 0 keeps what you fixed (the search box still gives up its 14rem basis first) without that floor. Worth a look at 320–360px signed in as an admin; a sideways-scrolling header is the first thing a person sees.

The cost you documented — signing in from a token-opened order or pay page lands on a 404 (I confirmed /pay/BZ-… without a token is "Not here") — is not a regression, since the old pathname dropped the token too, and it is already #24. Leaving #12 alone was right.

Housekeeping: a stray x comment above is mine, from probing this API; its thread is resolved.

86 const query = new URLSearchParams(url.search);
87 for (const name of SECRET_PARAMS) query.delete(name);
88 const search = query.toString();
89 const to = search === "" ? url.pathname : `${url.pathname}?${search}`;
90 return to.length > 512 ? url.pathname : to;
91 }
92
6793 export const SIGN_OUT_HREF = "/__forge/signout";
94
95 /**
96 * What every link to the forge carries. `Referrer-Policy` is decided per
97 * origin, and the forge's own endpoints share this origin, so a same-origin
98 * navigation would hand the whole current URL — `/orders/1001?token=…` and all
99 * — to `/__forge/signin` in the `Referer` header. `returnTo` keeps the token
100 * out of `to=`; this keeps it out of the other channel. Any anchor pointing at
101 * `/__forge/*` gets these, so use the constant rather than writing it out.
102 */
103 export const FORGE_LINK_ATTRS = 'rel="noreferrer"';
resolved by ·
Show 1 comment

x

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs