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
Check · Review · Land · Live done — Landed and live in production.
mason/bazaar:mason/13-identity-comes-from-the-forge-admin-open → main 4 commits fork 3 threads · 2 unresolved
README.md +9 −3
| @@ -120,10 +120,16 @@ session cookie is still there, but it only carries the CSRF token now. | ||
| 120 | 120 | | **admin** | Runs the shop: every path under `/admin`, and any order without its token. | |
| 121 | 121 | |
| 122 | 122 | 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 | |
| 124 | 124 | 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. | |
| 127 | 133 | |
| 128 | 134 | ## Shape |
| 129 | 135 |
src/admin.ts +2 −2
| @@ -21,7 +21,7 @@ import { forOrder, outbox } from "./mail.ts"; | ||
| 21 | 21 | import { parseCents } from "./money.ts"; |
| 22 | 22 | import { render, type Ctx } from "./context.ts"; |
| 23 | 23 | import { notFound } from "./shop.ts"; |
| 24 | import { identityConfigured } from "./who.ts"; | |
| 24 | import { identityConfigured, returnTo } from "./who.ts"; | |
| 25 | 25 | |
| 26 | 26 | const MESSAGES: Record<string, { text: string; bad?: boolean }> = { |
| 27 | 27 | saved: { text: "Saved." }, |
| @@ -55,7 +55,7 @@ const guard = (ctx: Ctx): Response | null => | ||
| 55 | 55 | : render( |
| 56 | 56 | ctx, |
| 57 | 57 | { title: "Admin", admin: true, status: 403 }, |
| 58 | pages.forbidden(ctx.url.pathname + ctx.url.search, identityConfigured()), | |
| 58 | pages.forbidden(returnTo(ctx.url), identityConfigured()), | |
| 59 | 59 | ); |
| 60 | 60 | |
| 61 | 61 | // --- the door --------------------------------------------------------------- |
src/context.ts +2 −1
| @@ -10,6 +10,7 @@ import { layout } from "./html.ts"; | ||
| 10 | 10 | import type { Applied } from "./codes.ts"; |
| 11 | 11 | import type { CartLine } from "./cart.ts"; |
| 12 | 12 | import type { Session } from "./session.ts"; |
| 13 | import { returnTo } from "./who.ts"; | |
| 13 | 14 | import type { Who } from "./who.ts"; |
| 14 | 15 | |
| 15 | 16 | export type Ctx = { |
| @@ -45,7 +46,7 @@ export function render( | ||
| 45 | 46 | layout({ |
| 46 | 47 | title: options.title, |
| 47 | 48 | who: ctx.who, |
| 48 | path: ctx.url.pathname, | |
| 49 | path: returnTo(ctx.url), | |
| 49 | 50 | cartCount: cartCount(ctx.cart), |
| 50 | 51 | categories: categories(), |
| 51 | 52 | current: options.current, |
src/html.ts +4 −4
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | 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"; | |
| 12 | 12 | import type { Session } from "./session.ts"; |
| 13 | 13 | import type { Who } from "./who.ts"; |
| 14 | 14 | |
| @@ -43,7 +43,7 @@ export type Chrome = { | ||
| 43 | 43 | title: string; |
| 44 | 44 | /** Who the forge says is here: it decides the account corner and the Admin link. */ |
| 45 | 45 | 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. */ | |
| 47 | 47 | path: string; |
| 48 | 48 | cartCount: number; |
| 49 | 49 | categories: { slug: string; name: string }[]; |
| @@ -56,10 +56,10 @@ export type Chrome = { | ||
| 56 | 56 | /** The account corner: who the forge says you are, and the door either way. */ |
| 57 | 57 | export const account = (who: Who, path: string): string => |
| 58 | 58 | 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>` | |
| 60 | 60 | : `<span class="who"><strong>${escape(who.name)}</strong>${ |
| 61 | 61 | 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>`; | |
| 63 | 63 | |
| 64 | 64 | export function layout(chrome: Chrome): string { |
| 65 | 65 | const link = (href: string, text: string, key: string): string => |
src/pages/admin.ts +3 −3
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | 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"; | |
| 9 | 9 | import { formatCents } from "../money.ts"; |
| 10 | 10 | import { itemsTable, orderTotals, statusPill } from "./checkout.ts"; |
| 11 | 11 | import { describe } from "../discounts.ts"; |
| @@ -31,7 +31,7 @@ export const nav = (current: string): string => | ||
| 31 | 31 | key === current ? `<strong>${escape(label)}</strong>` : `<a href="${escape(href)}">${escape(label)}</a>`, |
| 32 | 32 | ) |
| 33 | 33 | .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>`; | |
| 35 | 35 | |
| 36 | 36 | /** |
| 37 | 37 | * What anyone who is not an admin sees under /admin: one sentence and one |
| @@ -41,7 +41,7 @@ export const nav = (current: string): string => | ||
| 41 | 41 | export const forbidden = (to: string, configured: boolean): string => ` |
| 42 | 42 | <h1>Admin</h1> |
| 43 | 43 | <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> | |
| 45 | 45 | ${ |
| 46 | 46 | configured |
| 47 | 47 | ? '<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 | ||
| 611 | 611 | expect(await admin.page("/admin/mail")).toContain(`Your bazaar order ${number}`); |
| 612 | 612 | }); |
| 613 | 613 | |
| 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 | ||
| 614 | 630 | test("/admin is 403 and a sign-in link for everybody the forge did not sign as an admin", async () => { |
| 615 | 631 | const anon = new Shopper(); |
| 616 | 632 | 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 | ||
| 621 | 637 | expect(page).not.toContain('type="password"'); |
| 622 | 638 | // the shop itself is still open, and the header offers the way in |
| 623 | 639 | 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"); | |
| 624 | 642 | |
| 625 | 643 | const user = new Shopper({ id: "u_2", name: "bo", role: "user" }); |
| 626 | 644 | expect((await user.get("/admin")).status).toBe(403); |
src/style.ts +6 −2
| @@ -26,9 +26,13 @@ a { color: inherit; text-underline-offset: .2em; } | ||
| 26 | 26 | header.top { border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--paper); z-index: 5; } |
| 27 | 27 | header.top .wrap { display: flex; gap: 1rem; align-items: center; min-height: 3.75rem; flex-wrap: wrap; } |
| 28 | 28 | .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; } | |
| 30 | 30 | 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; } | |
| 32 | 36 | input, select, textarea, button { font: inherit; color: inherit; } |
| 33 | 37 | input, select, textarea { background: transparent; border: 1px solid var(--line); border-radius: .5rem; padding: .5rem .65rem; width: 100%; } |
| 34 | 38 | 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 @@ | ||
| 1 | 1 | /** The identity check, on its own: what the forge signs, and what it does not. */ |
| 2 | 2 | |
| 3 | 3 | import { expect, test } from "bun:test"; |
| 4 | import { atKeyboard, stamp, whoIs } from "./who.ts"; | |
| 4 | import { atKeyboard, returnTo, signInHref, stamp, whoIs } from "./who.ts"; | |
| 5 | 5 | |
| 6 | 6 | const KEY = "a-key-only-the-forge-and-this-app-know"; |
| 7 | 7 | |
| @@ -78,3 +78,34 @@ test("the terminal's person comes from the environment the forge set", () => { | ||
| 78 | 78 | role: "anon", |
| 79 | 79 | }); |
| 80 | 80 | }); |
| 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 { | ||
| 64 | 64 | |
| 65 | 65 | /** Where a person goes to become someone: the forge's sign-in, and back here. */ |
| 66 | 66 | 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 — 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
The The preview behaves. Protocol-relative paths, since Two things left, neither a reason to hold this:
Nit, one line: |
||
| 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
One nit, for this pull or the next hand on the header. The cost you documented — signing in from a token-opened order or pay page lands on a 404 (I confirmed Housekeeping: a stray |
||
| 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 | ||
| 67 | 93 | 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 commentx |
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.tsto the oldctx.url.pathnameto 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=beansrendersto=%2Fsearch%3Fq%3Dbeans,/rendersto=%2F, add-to-cart →/cart→/checkoutstill walks the whole path, and the security floor is intact on every response (CSP withscript-src 'none'andbase-uri 'none', nosniff, Referrer-Policy, HttpOnly/SameSite=Lax/Securesid).Security of
returnTospecifically, since it is a string this app hands to the forge:url.pathname(+ re-serialised query), so it always begins with a single/, andsignInHrefpercent-encodes it. I tried the usual protocol-relative shapes against the preview://evil.com/xnormalises toto=%2Fevil.com%2Fx,/\/evil.comcomes back asto=%2F%255C%2Fevil.com. Nothing escapes the origin.?q="><script>round-trips as%2522%253E%253Cscript%253Einside the href, andaccount()escapes on top of that.tokenstripping is right, anddeleteremoves 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:
Referrer-Policy: strict-origin-when-cross-originsends the full URL on a same-origin navigation, so clicking Sign in from/orders/1001?token=…puts?token=…in theRefereron 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-originin the floor insrc/server.tsis the one-line version./orders/Nwithout 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 below9remof 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 thetrailing on an over-long line, out of step with the wrapping around it.