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 #13

Merged opened this

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

Merged

mason/bazaar:mason/9-identity-comes-from-the-forge-admin-open → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/9-identity-comes-from-the-forge-admin-open was merged into main as 1d0fbed.

Review

Sign in to review this change.
19 files changed +415 −180
AGENTS.md +4 −1
@@ -31,7 +31,10 @@ preview at its own address, deployed from your branch as you push.
3131 escaped; every response carries the security floor in `src/server.ts`
3232 (keep it, extend it); a POST that is not same-origin is refused; secrets
3333 come from the environment, never the repository; no `eval`, no shell
34 built from user text; a private thing needs a session in an HttpOnly cookie.
34 built from user text. Who is asking comes from the forge's signed headers,
35 verified in `src/who.ts` and read only as `ctx.who` — never a password of
36 our own, never a query parameter; the session cookie is HttpOnly and
37 carries the CSRF token.
3538 - Speed: the first paint is server-rendered HTML; JavaScript is small, in
3639 files under `static/` named by content and cached for a year; no layout
3740 shift; the common request answers in milliseconds; work that can wait is
README.md +30 −6
@@ -5,8 +5,8 @@ variants and stock, a cart in a signed cookie, discount codes and gift cards,
55 tax by where the parcel goes, checkout with an address, a payment step,
66 receipts, and an `/admin` for products, inventory, codes, cards, tax and
77 orders with refunds. Every price is an integer number of cents, every form
8 carries a CSRF token, and checkout, payment, typing a code and admin sign-in
9 are rate limited.
8 carries a CSRF token, and checkout, payment and typing a code are rate
9 limited.
1010
1111 This is an app: the forge runs it. Production follows the default branch;
1212 every open pull gets a preview at its own address with its own copy of the
@@ -94,14 +94,36 @@ already been refunded.
9494
9595 | Variable | Meaning |
9696 | --- | --- |
97 | `ADMIN_PASSWORD` | The `/admin` password. When it is unset the app generates one and keeps it, and tells only the two places that are already privileged: the run log at boot, and the app's terminal. It is never printed on a page. |
97 | `FORGE_APP_IDENTITY_KEY` | The key the forge signs `x-forge-identity` with, verified in `src/who.ts`. Unset means no request can be verified: everybody is anonymous and `/admin` is closed to everybody. There is no password and no way around this. |
9898 | `APP_SECRET` | Signs cart cookies, sessions, CSRF tokens and order links. Generated into `FORGE_APP_DATA/secret` when unset. |
9999 | `STRIPE_SECRET_KEY` | A Stripe **test** key turns on real Stripe Checkout. |
100100 | `STRIPE_WEBHOOK_SECRET` | Verifies `POST /webhooks/stripe`. |
101101
102102 The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or
103103 `preview`), `FORGE_APP_URL`, `FORGE_APP_SSH` and `FORGE_APP_COMMIT` when it
104 starts the app. `forge.json` says how.
104 starts the app, and `FORGE_APP_USER`, `FORGE_APP_USER_ID` and
105 `FORGE_APP_USER_ROLE` when it opens the terminal. `forge.json` says how.
106
107 ## Who can do what
108
109 Identity is the forge's to assert and ours to verify. On every request the
110 forge sends `x-forge-identity` — `<userId>|<username>|<role>|<expiresMs>` —
111 and `x-forge-signature`, base64url of HMAC-SHA256 over the identity with
112 `FORGE_APP_IDENTITY_KEY`. `src/who.ts` recomputes that signature and compares
113 it in constant time; anything it cannot verify is `{ role: "anon" }`. The
114 session cookie is still there, but it only carries the CSRF token now.
115
116 | Who | What |
117 | --- | --- |
118 | **anon** | Reads the shop: catalog, search, product pages, the cart, and an order with the order's own token. |
119 | **user** | Buys: everything anon can do, and the header names them. |
120 | **admin** | Runs the shop: every path under `/admin`, and any order without its token. |
121
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
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.
105127
106128 ## Shape
107129
@@ -113,7 +135,8 @@ starts the app. `forge.json` says how.
113135 | `src/catalog.ts` `src/inventory.ts` `src/orders.ts` | Products, the stock ledger, orders and refunds |
114136 | `src/cart.ts` `src/codes.ts` `src/money.ts` `src/sign.ts` | The signed cart, the codes cookie, integer cents, HMACs |
115137 | `src/discounts.ts` `src/giftcards.ts` `src/tax.ts` | What a code is worth, what a card holds, what the rate is |
116 | `src/session.ts` `src/ratelimit.ts` `src/gateway.ts` `src/mail.ts` | Sessions and CSRF, token buckets, Stripe or the mock, the outbox |
138 | `src/who.ts` `src/session.ts` | Who the forge says is asking; the session that carries the CSRF token |
139 | `src/ratelimit.ts` `src/gateway.ts` `src/mail.ts` | Token buckets, Stripe or the mock, the outbox |
117140 | `src/shop.ts` `src/admin.ts` | Handlers for the shop and for `/admin` |
118141 | `src/pages/*.ts` `src/html.ts` `src/style.ts` | Rendering; the stylesheet is served under a name made from its own contents |
119142
@@ -123,4 +146,5 @@ The same shop over the same data. The exact command is in `FORGE_APP_SSH`
123146 when the app runs; the footer of every page prints it verbatim, and says the
124147 terminal is off when it is unset. `1/2/3` switch between stock, orders and the
125148 outbox, `j`/`k` move, `s` moves stock with a reason, `r` refunds the rest of an
126 order and puts it back on the shelf, `q` leaves.
149 order and puts it back on the shelf, `q` leaves. The first line says who the
150 forge named at the keyboard; `s` and `r` are an admin's.
src/admin.ts +18 −25
@@ -1,9 +1,10 @@
11 /**
22 * /admin: products, inventory, orders, refunds.
33 *
4 * Everything here is behind a session marked admin, and every write is a
5 * POST with the session's CSRF token. An admin can see any order and give
6 * any part of it back; both leave a row saying who asked and why.
4 * Everything here is for an admin — the role the forge signs into
5 * `ctx.who` — and every write is a POST with the session's CSRF token. An
6 * admin can see any order and give any part of it back; both leave a row
7 * saying who asked and why.
78 */
89
910 import * as catalog from "./catalog.ts";
@@ -12,16 +13,15 @@ import * as giftcards from "./giftcards.ts";
1213 import * as tax from "./tax.ts";
1314 import * as gateway from "./gateway.ts";
1415 import * as inventory from "./inventory.ts";
15 import * as limit from "./ratelimit.ts";
1616 import * as orders from "./orders.ts";
1717 import * as pages from "./pages/admin.ts";
1818 import { db, type Variant } from "./db.ts";
1919 import { field, intField, redirect } from "./http.ts";
2020 import { forOrder, outbox } from "./mail.ts";
2121 import { parseCents } from "./money.ts";
22 import { signIn, signOut, usingGeneratedPassword, verifyAdminPassword } from "./session.ts";
2322 import { render, type Ctx } from "./context.ts";
24 import { notFound, tooMany } from "./shop.ts";
23 import { notFound } from "./shop.ts";
24 import { identityConfigured } from "./who.ts";
2525
2626 const MESSAGES: Record<string, { text: string; bad?: boolean }> = {
2727 saved: { text: "Saved." },
@@ -44,8 +44,19 @@ const MESSAGES: Record<string, { text: string; bad?: boolean }> = {
4444
4545 const messageFrom = (ctx: Ctx) => MESSAGES[(ctx.url.searchParams.get("m") ?? "").slice(0, 30)];
4646
47 /**
48 * Every path under /admin passes through here. Only an admin the forge signed
49 * gets in; anyone else gets one page whose only action is to sign in. With no
50 * FORGE_APP_IDENTITY_KEY nothing can be verified, so nobody is an admin.
51 */
4752 const guard = (ctx: Ctx): Response | null =>
48 ctx.session.isAdmin ? null : render(ctx, { title: "Admin", admin: true, status: 401 }, pages.signIn(ctx.session, usingGeneratedPassword()));
53 ctx.who.role === "admin"
54 ? null
55 : render(
56 ctx,
57 { title: "Admin", admin: true, status: 403 },
58 pages.forbidden(ctx.url.pathname + ctx.url.search, identityConfigured()),
59 );
4960
5061 // --- the door ---------------------------------------------------------------
5162
@@ -73,24 +84,6 @@ export function dashboard(ctx: Ctx): Response {
7384 return render(ctx, { title: "Admin", admin: true }, pages.overview({ ...numbers, ...counts }, orders.recent(10)));
7485 }
7586
76 export function signInPost(ctx: Ctx): Response {
77 if (!limit.take(limit.SIGN_IN, ctx.ip)) return tooMany(ctx, limit.retryAfter(limit.SIGN_IN, ctx.ip), "sign-in");
78 const password = String(ctx.form?.get("password") ?? "");
79 if (!verifyAdminPassword(password))
80 return render(
81 ctx,
82 { title: "Admin", admin: true, status: 401 },
83 pages.signIn(ctx.session, usingGeneratedPassword(), "That password is not the one."),
84 );
85 signIn(ctx.session.sid);
86 return redirect("/admin");
87 }
88
89 export function signOutPost(ctx: Ctx): Response {
90 signOut(ctx.session.sid);
91 return redirect("/");
92 }
93
9487 // --- products ---------------------------------------------------------------
9588
9689 export function productList(ctx: Ctx): Response {
src/context.ts +6 −1
@@ -10,12 +10,16 @@ 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 type { Who } from "./who.ts";
1314
1415 export type Ctx = {
1516 request: Request;
1617 url: URL;
1718 params: Record<string, string>;
19 /** The CSRF token's session. It says nothing about who is asking. */
1820 session: Session;
21 /** Who the forge says is asking, verified in src/who.ts. */
22 who: Who;
1923 /** Parsed for POST routes; null for GET and for routes that read the body themselves. */
2024 form: FormData | null;
2125 cart: CartLine[];
@@ -40,7 +44,8 @@ export function render(
4044 return html(
4145 layout({
4246 title: options.title,
43 session: ctx.session,
47 who: ctx.who,
48 path: ctx.url.pathname,
4449 cartCount: cartCount(ctx.cart),
4550 categories: categories(),
4651 current: options.current,
src/db.ts +10
@@ -350,6 +350,16 @@ export type Payment = {
350350 created_at: string;
351351 };
352352
353 /**
354 * The settings the admin password left behind. Identity comes from the forge
355 * now, so the generated password and its salt open nothing — and a secret
356 * with no purpose should not sit in the database. The schema itself is left
357 * alone: this file grows tables, it never rebuilds them.
358 */
359 export const forgetPasswordSettings = (): void => {
360 db.query("DELETE FROM setting WHERE key IN ('admin_demo_password', 'admin_salt')").run();
361 };
362
353363 export const getSetting = (key: string): string | undefined =>
354364 db.query<{ value: string }, [string]>("SELECT value FROM setting WHERE key = ?1").get(key)?.value;
355365
src/html.ts +16 −2
@@ -8,7 +8,9 @@
88 */
99
1010 import { CSS_PATH } from "./style.ts";
11 import { SIGN_OUT_HREF, signInHref } from "./who.ts";
1112 import type { Session } from "./session.ts";
13 import type { Who } from "./who.ts";
1214
1315 export const escape = (value: unknown): string =>
1416 String(value ?? "").replace(
@@ -39,7 +41,10 @@ export function art(seed: string, label: string, className = "art"): string {
3941
4042 export type Chrome = {
4143 title: string;
42 session: Session;
44 /** Who the forge says is here: it decides the account corner and the Admin link. */
45 who: Who;
46 /** The path being rendered, so "Sign in" comes back to it. */
47 path: string;
4348 cartCount: number;
4449 categories: { slug: string; name: string }[];
4550 current?: string;
@@ -48,6 +53,14 @@ export type Chrome = {
4853 body: string;
4954 };
5055
56 /** The account corner: who the forge says you are, and the door either way. */
57 export const account = (who: Who, path: string): string =>
58 who.role === "anon"
59 ? `<span class="who"><a href="${escape(signInHref(path))}">Sign in</a></span>`
60 : `<span class="who"><strong>${escape(who.name)}</strong>${
61 who.role === "admin" ? ' <span class="muted">admin</span>' : ""
62 } <a href="${escape(SIGN_OUT_HREF)}">Sign out</a></span>`;
63
5164 export function layout(chrome: Chrome): string {
5265 const link = (href: string, text: string, key: string): string =>
5366 `<a href="${escape(href)}"${chrome.current === key ? ' aria-current="page"' : ""}>${escape(text)}</a>`;
@@ -64,13 +77,14 @@ export function layout(chrome: Chrome): string {
6477 <a class="brand" href="/">bazaar</a>
6578 <nav class="cats">
6679 ${chrome.categories.map((category) => link(`/c/${category.slug}`, category.name, `c:${category.slug}`)).join("\n ")}
67 ${chrome.session.isAdmin ? link("/admin", "Admin", "admin") : ""}
80 ${chrome.who.role === "admin" ? link("/admin", "Admin", "admin") : ""}
6881 </nav>
6982 <form class="find" method="get" action="/search" role="search">
7083 <input type="search" name="q" placeholder="Search" value="${escape(chrome.query ?? "")}" aria-label="Search products" maxlength="80">
7184 <button class="btn ghost">Find</button>
7285 </form>
7386 <a class="cart-link" href="/cart">Cart${chrome.cartCount > 0 ? ` · ${chrome.cartCount}` : ""}</a>
87 ${account(chrome.who, chrome.path)}
7488 </div></header>
7589 <main><div class="wrap">${chrome.body}</div></main>
7690 <footer class="bottom"><div class="wrap">
src/pages/admin.ts +14 −17
@@ -5,6 +5,7 @@
55 */
66
77 import { csrfField, escape, variantRowStyle } from "../html.ts";
8 import { SIGN_OUT_HREF, signInHref } from "../who.ts";
89 import { formatCents } from "../money.ts";
910 import { itemsTable, orderTotals, statusPill } from "./checkout.ts";
1011 import { describe } from "../discounts.ts";
@@ -30,26 +31,22 @@ export const nav = (current: string): string =>
3031 key === current ? `<strong>${escape(label)}</strong>` : `<a href="${escape(href)}">${escape(label)}</a>`,
3132 )
3233 .join("")}
33 <form method="post" action="/admin/signout" class="inline" style="margin-left:auto">
34 <button class="btn ghost" formnovalidate>Sign out</button>
35 </form></nav>`;
34 <a href="${escape(SIGN_OUT_HREF)}" style="margin-left:auto">Sign out</a></nav>`;
3635
37 export const signIn = (session: Session, generated: boolean, error?: string): string => `
36 /**
37 * What anyone who is not an admin sees under /admin: one sentence and one
38 * action. There is no password here to get wrong — the forge says who you
39 * are, so the only way in is through the forge's sign-in and back.
40 */
41 export const forbidden = (to: string, configured: boolean): string => `
3842 <h1>Admin</h1>
39 ${error ? `<p class="notice bad">${escape(error)}</p>` : ""}
40 <form method="post" action="/admin/signin" class="stack">
41 ${csrfField(session)}
42 <label>Password
43 <input type="password" name="password" autocomplete="current-password" required maxlength="200" autofocus>
44 </label>
45 <div><button class="btn">Sign in</button></div>
46 </form>
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>
4745 ${
48 generated
49 ? `<p class="notice">No <code>ADMIN_PASSWORD</code> is set, so this environment generated one. It is printed in the
50 app's run log when the app starts, and shown in the app's terminal — never on this page. Set
51 <code>ADMIN_PASSWORD</code> to choose it yourself.</p>`
52 : '<p class="muted">The password is <code>ADMIN_PASSWORD</code> from the environment.</p>'
46 configured
47 ? '<p class="muted">Signed in already? Then this account is not an admin of this app.</p>'
48 : `<p class="notice">This deployment has no <code>FORGE_APP_IDENTITY_KEY</code>, so no identity can be
49 verified and /admin is closed to everybody. That is the only setting there is: there is no password.</p>`
5350 }
5451 `;
5552
src/pages/checkout.ts +3 −2
@@ -13,6 +13,7 @@ import type { Order, OrderItem, Payment } from "../db.ts";
1313 import type { GatewayInfo } from "../gateway.ts";
1414 import type { Email } from "../mail.ts";
1515 import type { Session } from "../session.ts";
16 import type { Who } from "../who.ts";
1617
1718 const field = (
1819 name: string,
@@ -199,7 +200,7 @@ export function confirmation(
199200 items: OrderItem[],
200201 refunded: number,
201202 email: Email | null,
202 session: Session,
203 who: Who,
203204 ): string {
204205 const paid = order.status === "paid" || order.status === "refunded" || order.status === "partially_refunded";
205206 return `
@@ -223,7 +224,7 @@ ${orderTotals(order, refunded)}
223224 <h2>Shipping to</h2>
224225 <p>${addressBlock(order)}<br><span class="muted">${escape(order.email)}</span></p>
225226 <p class="muted">Keep this address: it is the only way back to this order.
226 ${session.isAdmin ? `<a href="/admin/orders/${escape(order.number)}">Open in admin</a>.` : ""}</p>
227 ${who.role === "admin" ? `<a href="/admin/orders/${escape(order.number)}">Open in admin</a>.` : ""}</p>
227228 `;
228229 }
229230
src/ratelimit.ts −1
@@ -12,7 +12,6 @@ export type Limit = { name: string; capacity: number; perSeconds: number };
1212
1313 export const CHECKOUT: Limit = { name: "checkout", capacity: 8, perSeconds: 60 };
1414 export const PAYMENT: Limit = { name: "payment", capacity: 20, perSeconds: 60 };
15 export const SIGN_IN: Limit = { name: "signin", capacity: 5, perSeconds: 300 };
1615 // Codes are guessable in principle, so trying them is the slowest thing here.
1716 export const CODES: Limit = { name: "codes", capacity: 10, perSeconds: 120 };
1817
src/routes.ts +2 −2
@@ -40,9 +40,9 @@ export const routes: Route[] = [
4040 { method: "GET", pattern: "/orders/:number", handler: shop.orderPage },
4141 { method: "GET", pattern: "/orders/:number/receipt", handler: shop.receiptPage },
4242
43 // Everything under /admin is for an admin the forge signed; the guard in
44 // src/admin.ts is on each handler, not on this table.
4345 { method: "GET", pattern: "/admin", handler: admin.dashboard },
44 { method: "POST", pattern: "/admin/signin", handler: admin.signInPost },
45 { method: "POST", pattern: "/admin/signout", handler: admin.signOutPost },
4646 { method: "GET", pattern: "/admin/products", handler: admin.productList },
4747 { method: "POST", pattern: "/admin/products", handler: admin.productCreate },
4848 { method: "GET", pattern: "/admin/products/new", handler: admin.productNew },
src/server.test.ts +98 −39
@@ -4,20 +4,37 @@ import { tmpdir } from "node:os";
44 import { join } from "node:path";
55
66 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "bazaar-"));
7 process.env.ADMIN_PASSWORD = "open-sesame-for-the-test";
7 process.env.FORGE_APP_IDENTITY_KEY = "the-key-the-forge-signs-with";
88 process.env.PORT = "0";
99 delete process.env.STRIPE_SECRET_KEY;
1010
11 const { stamp } = await import("./who.ts");
1112 const { server } = await import("./server.ts");
1213 const { db } = await import("./db.ts");
1314 const limit = await import("./ratelimit.ts");
1415 const giftcards = await import("./giftcards.ts");
1516 const base = `http://${server.hostname}:${server.port}`;
1617
18 type Person = { id: string; name: string; role: "user" | "admin"; expiresMs?: number };
19
20 /** Sign an identity the way the forge does, so a test can be somebody. */
21 function identityHeaders(person: Person | undefined, key = process.env.FORGE_APP_IDENTITY_KEY!): Record<string, string> {
22 if (!person) return {};
23 const identity = `${person.id}|${person.name}|${person.role}|${person.expiresMs ?? Date.now() + 600_000}`;
24 return { "x-forge-identity": identity, "x-forge-signature": stamp(key, identity) };
25 }
26
1727 /** A browser: it keeps its cookies and posts like a same-origin form does. */
1828 class Shopper {
1929 jar = new Map<string, string>();
2030
31 /** Who the forge says this browser is; undefined is a signed-out visitor. */
32 constructor(private person?: Person) {}
33
34 private forge(): Record<string, string> {
35 return identityHeaders(this.person);
36 }
37
2138 private remember(response: Response): void {
2239 for (const line of response.headers.getSetCookie()) {
2340 const [pair] = line.split(";");
@@ -32,7 +49,7 @@ class Shopper {
3249
3350 async get(path: string): Promise<Response> {
3451 const response = await fetch(base + path, {
35 headers: { cookie: this.cookieHeader(), "sec-fetch-site": "same-origin" },
52 headers: { cookie: this.cookieHeader(), "sec-fetch-site": "same-origin", ...this.forge() },
3653 redirect: "manual",
3754 });
3855 this.remember(response);
@@ -53,6 +70,7 @@ class Shopper {
5370 cookie: this.cookieHeader(),
5471 "sec-fetch-site": "same-origin",
5572 "content-type": "application/x-www-form-urlencoded",
73 ...this.forge(),
5674 },
5775 redirect: "manual",
5876 });
@@ -66,6 +84,9 @@ class Shopper {
6684 }
6785 }
6886
87 /** An admin, the way the forge signs one in. */
88 const asAdmin = (): Shopper => new Shopper({ id: "u_1", name: "ada", role: "admin" });
89
6990 const variantId = (sku: string): number =>
7091 db.query<{ id: number }, [string]>("SELECT id FROM variant WHERE sku = ?1").get(sku)!.id;
7192 const stockOf = (sku: string): number =>
@@ -548,24 +569,19 @@ test("an unsigned webhook is refused", async () => {
548569 expect(response.status).toBe(400);
549570 });
550571
551 test("admin is closed until it is signed into, then refunds and restocks", async () => {
572 test("/admin opens for an admin the forge signed, and refunds and restocks", async () => {
552573 limit.forget();
553574 const shopper = new Shopper();
554575 const { number } = await buy(shopper, "COF-GUJI-1000", 1);
555576
556 const admin = new Shopper();
557 const closed = await admin.get("/admin/orders");
558 expect(closed.status).toBe(401);
559 expect(await closed.text()).toContain("Password");
560
561 const door = await admin.page("/admin");
562 const wrong = await admin.post("/admin/signin", { password: "not-it" }, Shopper.tokenIn(door));
563 expect(wrong.status).toBe(401);
564
565 const signedIn = await admin.post("/admin/signin", { password: process.env.ADMIN_PASSWORD! }, Shopper.tokenIn(door));
566 expect(signedIn.status).toBe(303);
577 const admin = asAdmin();
567578 expect(await admin.page("/admin")).toContain("Overview");
568579 expect(await admin.page("/admin/orders")).toContain(number);
580 // the header names them, and only an admin is offered the Admin link
581 const front = await admin.page("/");
582 expect(front).toContain("ada");
583 expect(front).toContain("/__forge/signout");
584 expect(front).toContain('href="/admin"');
569585
570586 const stockBefore = stockOf("COF-GUJI-1000");
571587 const orderPage = await admin.page(`/admin/orders/${number}`);
@@ -595,34 +611,79 @@ test("admin is closed until it is signed into, then refunds and restocks", async
595611 expect(await admin.page("/admin/mail")).toContain(`Your bazaar order ${number}`);
596612 });
597613
598 test("a generated admin password is never printed on the sign-in page", async () => {
599 limit.forget();
600 const configured = process.env.ADMIN_PASSWORD;
601 delete process.env.ADMIN_PASSWORD;
602 const { ensureDemoPassword } = await import("./session.ts");
603 const generated = ensureDemoPassword()!;
604 expect(generated.length).toBeGreaterThan(8);
614 test("/admin is 403 and a sign-in link for everybody the forge did not sign as an admin", async () => {
615 const anon = new Shopper();
616 const closed = await anon.get("/admin/orders");
617 expect(closed.status).toBe(403);
618 const page = await closed.text();
619 expect(page).toContain("/__forge/signin?to=%2Fadmin%2Forders");
620 expect(page).toContain("Sign in");
621 expect(page).not.toContain('type="password"');
622 // the shop itself is still open, and the header offers the way in
623 expect(await anon.page("/")).toContain("/__forge/signin?to=%2F");
605624
606 const visitor = new Shopper();
607 const door = await visitor.page("/admin");
608 expect(door).not.toContain(generated);
609 expect(door).toContain("run log");
610 const wrong = await visitor.post("/admin/signin", { password: "guess" }, Shopper.tokenIn(door));
611 expect(wrong.status).toBe(401);
612 expect(await wrong.text()).not.toContain(generated);
625 const user = new Shopper({ id: "u_2", name: "bo", role: "user" });
626 expect((await user.get("/admin")).status).toBe(403);
627 expect((await user.get("/admin/products")).status).toBe(403);
628 expect(await user.page("/")).not.toContain('href="/admin"');
629 // a write is refused by the guard, not merely by the CSRF check: this post
630 // carries a good token from a page the user is allowed to see
631 const before = stockOf("BRW-FILT-100");
632 const csrf = Shopper.tokenIn(await user.page(`/p/${slugOf("BRW-FILT-100")}`));
633 expect(csrf).not.toBe("");
634 const wrote = await user.post(
635 "/admin/inventory",
636 { variant: String(variantId("BRW-FILT-100")), delta: "+5", reason: "mine now" },
637 csrf,
638 );
639 expect(wrote.status).toBe(403);
640 expect(stockOf("BRW-FILT-100")).toBe(before);
613641
614 // it is still the password, for whoever read it from the log or the terminal
615 const signedIn = await visitor.post("/admin/signin", { password: generated }, Shopper.tokenIn(door));
616 expect(signedIn.status).toBe(303);
642 // an admin's own stamp, with a character moved
643 const stale = new Shopper({ id: "u_1", name: "ada", role: "admin", expiresMs: Date.now() - 1000 });
644 expect((await stale.get("/admin")).status).toBe(403);
617645
618 process.env.ADMIN_PASSWORD = configured;
619 limit.forget();
646 const identity = `u_1|ada|admin|${Date.now() + 600_000}`;
647 const tampered = await fetch(base + "/admin", {
648 headers: {
649 "sec-fetch-site": "same-origin",
650 "x-forge-identity": identity,
651 "x-forge-signature": stamp("a-key-that-is-not-the-forge", identity),
652 },
653 redirect: "manual",
654 });
655 expect(tampered.status).toBe(403);
656
657 // and a claim with no signature at all
658 const bare = await fetch(base + "/admin", {
659 headers: { "sec-fetch-site": "same-origin", "x-forge-identity": identity },
660 redirect: "manual",
661 });
662 expect(bare.status).toBe(403);
663 });
664
665 test("with no FORGE_APP_IDENTITY_KEY /admin is closed to everybody, and says so", async () => {
666 const key = process.env.FORGE_APP_IDENTITY_KEY!;
667 const identity = `u_1|ada|admin|${Date.now() + 600_000}`;
668 delete process.env.FORGE_APP_IDENTITY_KEY;
669 try {
670 const shut = await fetch(base + "/admin", {
671 headers: {
672 "sec-fetch-site": "same-origin",
673 "x-forge-identity": identity,
674 "x-forge-signature": stamp(key, identity),
675 },
676 redirect: "manual",
677 });
678 expect(shut.status).toBe(403);
679 expect(await shut.text()).toContain("FORGE_APP_IDENTITY_KEY");
680 } finally {
681 process.env.FORGE_APP_IDENTITY_KEY = key;
682 }
620683 });
621684
622685 test("an admin moves stock and the ledger says why", async () => {
623 const admin = new Shopper();
624 const door = await admin.page("/admin");
625 await admin.post("/admin/signin", { password: process.env.ADMIN_PASSWORD! }, Shopper.tokenIn(door));
686 const admin = asAdmin();
626687 const page = await admin.page("/admin/inventory");
627688 const before = stockOf("BRW-FILT-100");
628689 const moved = await admin.post(
@@ -636,9 +697,7 @@ test("an admin moves stock and the ledger says why", async () => {
636697 });
637698
638699 test("an admin adds a product and a variant, and the shop sells it", async () => {
639 const admin = new Shopper();
640 const door = await admin.page("/admin");
641 await admin.post("/admin/signin", { password: process.env.ADMIN_PASSWORD! }, Shopper.tokenIn(door));
700 const admin = asAdmin();
642701 const form = await admin.page("/admin/products/new");
643702 const categoryId = db.query<{ id: number }, []>("SELECT id FROM category ORDER BY id LIMIT 1").get()!.id;
644703 const created = await admin.post(
src/server.ts +20 −17
@@ -6,11 +6,11 @@
66 *
77 * This file is the front door and nothing else: it puts the security floor on
88 * every response, refuses a POST that is not same-origin and does not carry
9 * the session's CSRF token, builds the request's context, and hands over to
10 * the table in src/routes.ts.
9 * the session's CSRF token, works out who the forge says is asking, builds
10 * the request's context, and hands over to the table in src/routes.ts.
1111 */
1212
13 import { dataDir, db } from "./db.ts";
13 import { dataDir, db, forgetPasswordSettings } from "./db.ts";
1414 import { decode as decodeCart, COOKIE as CART_COOKIE } from "./cart.ts";
1515 import { decode as decodeCodes, COOKIE as CODES_COOKIE } from "./codes.ts";
1616 import { CSS, CSS_PATH } from "./style.ts";
@@ -18,7 +18,6 @@ import {
1818 checkCsrf,
1919 cookie,
2020 COOKIE as SESSION_COOKIE,
21 ensureDemoPassword,
2221 load as loadSession,
2322 readCookies,
2423 useSecret,
@@ -30,6 +29,8 @@ import { seed } from "./catalog.ts";
3029 import { seed as seedDiscounts } from "./discounts.ts";
3130 import { seed as seedTax } from "./tax.ts";
3231 import { text } from "./http.ts";
32 import { whoIs } from "./who.ts";
33 import type { Who } from "./who.ts";
3334 import type { Ctx } from "./context.ts";
3435
3536 const secret = loadSecret(dataDir);
@@ -103,11 +104,14 @@ export const server = Bun.serve({
103104
104105 const found = match(method, url.pathname);
105106 const session = loadSession(cookies[SESSION_COOKIE]);
107 // Identity is the forge's to assert and ours to verify; the cookie above
108 // only carries the CSRF token.
109 const who = whoIs(request.headers);
106110 if (cookies[SESSION_COOKIE] !== session.sid)
107111 outgoing.push(cookie(SESSION_COOKIE, session.sid, { maxAge: 60 * 60 * 24 * 30, secure: publicOrigin.startsWith("https:") }));
108112
109113 if (!found) {
110 const ctx = context(request, url, {}, session, null, cookies, outgoing, self);
114 const ctx = context(request, url, {}, session, who, null, cookies, outgoing, self);
111115 return finish(notFound(ctx), outgoing);
112116 }
113117
@@ -125,7 +129,7 @@ export const server = Bun.serve({
125129 return finish(text("that form was not from here — go back and try again", 403), outgoing);
126130 }
127131
128 const ctx = context(request, url, found.params, session, form, cookies, outgoing, self);
132 const ctx = context(request, url, found.params, session, who, form, cookies, outgoing, self);
129133 try {
130134 const response = await found.route.handler(ctx);
131135 return finish(request.method === "HEAD" ? new Response(null, response) : response, outgoing);
@@ -141,6 +145,7 @@ function context(
141145 url: URL,
142146 params: Record<string, string>,
143147 session: ReturnType<typeof loadSession>,
148 who: Who,
144149 form: FormData | null,
145150 cookies: Record<string, string>,
146151 outgoing: string[],
@@ -152,6 +157,7 @@ function context(
152157 url,
153158 params,
154159 session,
160 who,
155161 form,
156162 cart: decodeCart(cookies[CART_COOKIE], secret),
157163 codes: decodeCodes(cookies[CODES_COOKIE], secret),
@@ -163,18 +169,15 @@ function context(
163169 };
164170 }
165171
166 // Old carts and abandoned sessions are not worth keeping; this is cheap and
167 // happens once at boot, after the socket is already listening.
172 // Old carts and abandoned sessions are not worth keeping, and the password
173 // this app used to have left two settings that now open nothing. Both are
174 // cheap and happen once at boot, after the socket is already listening.
168175 db.query("DELETE FROM session WHERE seen_at < datetime('now', '-30 days')").run();
176 forgetPasswordSettings();
169177
170178 console.log(`bazaar listening on http://${server.hostname}:${server.port} (${env})`);
171179
172 // When nobody set ADMIN_PASSWORD the app makes one. It is told here, to
173 // whoever can read the run log, and in the app's terminal — never to a
174 // browser, because a sign-in page that prints the password is not a door.
175 const generated = ensureDemoPassword();
176 if (generated)
177 console.log(
178 `admin password (generated, kept in ${dataDir}/app.db): ${generated}\n` +
179 " set ADMIN_PASSWORD to choose your own; it is never shown on a page.",
180 );
180 // There is no password to announce: /admin opens for whoever the forge signs
181 // in as an admin. Without the key nothing can be verified, so say so once.
182 if (!process.env.FORGE_APP_IDENTITY_KEY)
183 console.log("FORGE_APP_IDENTITY_KEY is unset: every request is anonymous and /admin is closed to everybody.");
src/session.ts +11 −55
@@ -1,20 +1,20 @@
11 /**
22 * Sessions and CSRF.
33 *
4 * A session is a random id in an HttpOnly cookie and a row in the database;
5 * the cookie carries nothing else, so signing out is a delete the browser
6 * cannot argue with. The CSRF token for a session is an HMAC of its id: it
7 * needs no storage, it cannot be guessed without the secret, and a form
8 * posted from another site cannot carry one because it cannot read the page.
4 * A session is a random id in an HttpOnly cookie and a row in the database.
5 * It says nothing about who anybody is — that comes from the forge, in
6 * src/who.ts — it exists so a form can carry a CSRF token. The token is an
7 * HMAC of the session id: it needs no storage, it cannot be guessed without
8 * the secret, and a form posted from another site cannot carry one because it
9 * cannot read the page.
910 */
1011
11 import { randomBytes, scryptSync } from "node:crypto";
12 import { db, getSetting, setSetting } from "./db.ts";
12 import { db } from "./db.ts";
1313 import { equals, hmac, token } from "./sign.ts";
1414
1515 export const COOKIE = "sid";
1616
17 export type Session = { id: number; sid: string; isAdmin: boolean; csrf: string };
17 export type Session = { id: number; sid: string; csrf: string };
1818
1919 const secretRef = { value: "" };
2020 export const useSecret = (secret: string): void => {
@@ -44,60 +44,16 @@ export function cookie(name: string, value: string, options: { maxAge?: number;
4444 /** The session the cookie names, or a fresh one. Never trusts the cookie's claims. */
4545 export function load(sid: string | undefined): Session {
4646 if (sid && /^[A-Za-z0-9_-]{16,64}$/.test(sid)) {
47 const row = db.query<{ id: number; is_admin: number }, [string]>("SELECT id, is_admin FROM session WHERE id = ?1").get(sid);
47 const row = db.query<{ id: number }, [string]>("SELECT id FROM session WHERE id = ?1").get(sid);
4848 if (row) {
4949 db.query("UPDATE session SET seen_at = datetime('now') WHERE id = ?1").run(sid);
50 return { id: row.id, sid, isAdmin: row.is_admin === 1, csrf: csrfFor(sid) };
50 return { id: row.id, sid, csrf: csrfFor(sid) };
5151 }
5252 }
5353 const fresh = token(24);
5454 const row = db.query<{ id: number }, [string]>("INSERT INTO session (id) VALUES (?1) RETURNING id").get(fresh)!;
55 return { id: row.id, sid: fresh, isAdmin: false, csrf: csrfFor(fresh) };
55 return { id: row.id, sid: fresh, csrf: csrfFor(fresh) };
5656 }
5757
58 export const signIn = (sid: string): void => {
59 db.query("UPDATE session SET is_admin = 1 WHERE id = ?1").run(sid);
60 };
61
62 export const signOut = (sid: string): void => {
63 db.query("DELETE FROM session WHERE id = ?1").run(sid);
64 };
65
6658 export const checkCsrf = (session: Session, given: unknown): boolean =>
6759 typeof given === "string" && given.length > 0 && equals(given, session.csrf);
68
69 // --- the admin password -----------------------------------------------------
70
71 const derive = (password: string, salt: string): string =>
72 scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }).toString("base64url");
73
74 /**
75 * ADMIN_PASSWORD from the environment when it is set. When it is not — a
76 * preview nobody handed a secret to — one random password is generated and
77 * kept. It is told to whoever runs the app (the run log at boot, and the
78 * app's terminal, both of which are already privileged) and never to a
79 * browser: a sign-in page that prints the password is not a sign-in page.
80 */
81 export function ensureDemoPassword(): string | undefined {
82 if (process.env.ADMIN_PASSWORD) return undefined;
83 let existing = getSetting("admin_demo_password");
84 if (!existing) {
85 existing = randomBytes(9).toString("base64url");
86 setSetting("admin_demo_password", existing);
87 }
88 return existing;
89 }
90
91 /** True when the password in use is the generated one, without saying what it is. */
92 export const usingGeneratedPassword = (): boolean => !process.env.ADMIN_PASSWORD;
93
94 export function verifyAdminPassword(candidate: string): boolean {
95 const expected = process.env.ADMIN_PASSWORD ?? ensureDemoPassword() ?? "";
96 if (expected.length === 0 || candidate.length === 0 || candidate.length > 200) return false;
97 let salt = getSetting("admin_salt");
98 if (!salt) {
99 salt = randomBytes(16).toString("base64url");
100 setSetting("admin_salt", salt);
101 }
102 return equals(derive(candidate, salt), derive(expected, salt));
103 }
src/shop.ts +2 −2
@@ -221,7 +221,7 @@ function openOrder(ctx: Ctx, tokenFrom: "query" | "form"): Order | null {
221221 tokenFrom === "query" ? (ctx.url.searchParams.get("token") ?? "").slice(0, 64) : field(ctx.form, "token", 64);
222222 const order = orders.byNumber(number);
223223 if (!order) return null;
224 if (ctx.session.isAdmin) return order;
224 if (ctx.who.role === "admin") return order;
225225 return token.length > 0 && equals(token, order.access_token) ? order : null;
226226 }
227227
@@ -295,7 +295,7 @@ export async function orderPage(ctx: Ctx): Promise<Response> {
295295 return render(
296296 ctx,
297297 { title: `Order ${order.number}` },
298 pages.confirmation(order, orders.itemsOf(order.id), refunded, forOrder(order.id), ctx.session),
298 pages.confirmation(order, orders.itemsOf(order.id), refunded, forOrder(order.id), ctx.who),
299299 );
300300 }
301301
src/style.ts +1
@@ -36,6 +36,7 @@ input:focus-visible, select:focus-visible, textarea:focus-visible, button:focus-
3636 .btn.ghost { background: transparent; border-color: var(--line); color: inherit; }
3737 .btn.danger { background: var(--warn); color: #fff; }
3838 .btn[disabled] { opacity: .45; cursor: not-allowed; }
39 .who { white-space: nowrap; font-size: .9rem; display: flex; gap: .5rem; align-items: center; }
3940 .cart-link { white-space: nowrap; text-decoration: none; border: 1px solid var(--line); border-radius: 999px; padding: .35rem .8rem; font-size: .9rem; }
4041 main { padding: 2rem 0 4rem; }
4142 h1 { font-size: 1.8rem; letter-spacing: -.02em; margin: 0 0 .35rem; }
src/tty.ts +22 −9
@@ -3,6 +3,10 @@
33 * the app, with FORGE_APP_DATA pointing at the same database the web pages
44 * use — the same catalog, the same orders, the same ledger.
55 *
6 * Who is at the keyboard comes from the forge too: FORGE_APP_USER,
7 * FORGE_APP_USER_ID and FORGE_APP_USER_ROLE, named on the first screen. Moving
8 * stock and refunding are an admin's to do, here as on the web.
9 *
610 * 1/2/3 switch between stock, orders and the outbox. j/k move, s moves stock
711 * with a reason, r refunds what is left of an order and puts it back on the
812 * shelf, q leaves. Plain ANSI, no dependencies.
@@ -14,12 +18,17 @@ import { formatCents } from "./money.ts";
1418 import { history, addStock, setStock } from "./inventory.ts";
1519 import { itemsOf, paymentsOf, recent, refund, refundableCents, refundedCents } from "./orders.ts";
1620 import { outbox } from "./mail.ts";
17 import { ensureDemoPassword } from "./session.ts";
21 import { atKeyboard } from "./who.ts";
1822 import type { Order } from "./db.ts";
1923
20 // Whoever reached this terminal already had to get through ssh, so this is
21 // where a generated admin password is told. It is never put on a web page.
22 const adminPassword = ensureDemoPassword();
24 const person = atKeyboard();
25 const isAdmin = person.role === "admin";
26 const whoLine =
27 person.role === "anon"
28 ? "the forge did not name you (anonymous) — stock moves and refunds are closed"
29 : `signed in as ${person.name} (${person.id}) · ${person.role}${
30 isAdmin ? "" : " — stock moves and refunds are for an admin"
31 }`;
2332
2433 const where =
2534 process.env.FORGE_APP_ENV === "preview"
@@ -69,7 +78,7 @@ function draw(): void {
6978 cursor = Math.min(cursor, Math.max(0, rows.length - 1));
7079 const lines: string[] = [];
7180 lines.push(`${bold("bazaar")} ${dim(where)} ${dim(chosen().label)}`);
72 if (adminPassword) lines.push(dim(`/admin password (generated, set ADMIN_PASSWORD to replace it): ${adminPassword}`));
81 lines.push(dim(whoLine));
7382 lines.push(
7483 ["stock", "orders", "outbox"]
7584 .map((name, i) => (name === tab ? bold(`${i + 1} ${name}`) : dim(`${i + 1} ${name}`)))
@@ -107,9 +116,9 @@ function draw(): void {
107116 prompt === undefined
108117 ? dim(
109118 tab === "stock"
110 ? "1/2/3 tabs · j/k move · s move stock · h ledger · q quit"
119 ? `1/2/3 tabs · j/k move${isAdmin ? " · s move stock" : ""} · h ledger · q quit`
111120 : tab === "orders"
112 ? "1/2/3 tabs · j/k move · r refund the rest and restock · q quit"
121 ? `1/2/3 tabs · j/k move${isAdmin ? " · r refund the rest and restock" : ""} · q quit`
113122 : "1/2/3 tabs · j/k move · q quit",
114123 )
115124 : `${prompt.label} ${prompt.text}${esc}7m ${esc}0m`,
@@ -175,8 +184,12 @@ process.stdin.on("data", (key: string) => {
175184 if (key === "1") ((tab = "stock"), (cursor = 0));
176185 if (key === "2") ((tab = "orders"), (cursor = 0));
177186 if (key === "3") ((tab = "outbox"), (cursor = 0));
178 if (key === "s" && tab === "stock") prompt = { label: "Move (e.g. 12 a delivery arrived):", text: "", run: moveStock };
179 if (key === "r" && tab === "orders") prompt = { label: "Refund the rest — reason:", text: "", run: refundRest };
187 if (key === "s" && tab === "stock")
188 if (isAdmin) prompt = { label: "Move (e.g. 12 a delivery arrived):", text: "", run: moveStock };
189 else message = "Moving stock is for an admin.";
190 if (key === "r" && tab === "orders")
191 if (isAdmin) prompt = { label: "Refund the rest — reason:", text: "", run: refundRest };
192 else message = "Refunding is for an admin.";
180193 if (key === "h" && tab === "stock")
181194 message = history(8)
182195 .map((entry) => dim(`${entry.at} ${entry.sku} ${entry.delta > 0 ? "+" : ""}${entry.delta} ${entry.reason}`))
src/units.test.ts +11 −1
@@ -16,7 +16,7 @@ const catalog = await import("./catalog.ts");
1616 const discounts = await import("./discounts.ts");
1717 const giftcards = await import("./giftcards.ts");
1818 const tax = await import("./tax.ts");
19 const { db } = await import("./db.ts");
19 const { db, forgetPasswordSettings, getSetting, setSetting } = await import("./db.ts");
2020
2121 catalog.seed();
2222 discounts.seed();
@@ -240,3 +240,13 @@ test("a bucket empties and refills", () => {
240240 test("output is escaped", () => {
241241 expect(escape(`<script>"&'`)).toBe("&lt;script&gt;&quot;&amp;&#39;");
242242 });
243
244 test("the settings the admin password left behind are deleted, and nothing else is", () => {
245 setSetting("admin_demo_password", "whatever-it-was");
246 setSetting("admin_salt", "and-its-salt");
247 setSetting("tax_default_bp", "800");
248 forgetPasswordSettings();
249 expect(getSetting("admin_demo_password")).toBeUndefined();
250 expect(getSetting("admin_salt")).toBeUndefined();
251 expect(getSetting("tax_default_bp")).toBe("800");
252 });
src/who.test.ts added +80
@@ -0,0 +1,80 @@
1 /** The identity check, on its own: what the forge signs, and what it does not. */
2
3 import { expect, test } from "bun:test";
4 import { atKeyboard, stamp, whoIs } from "./who.ts";
5
6 const KEY = "a-key-only-the-forge-and-this-app-know";
7
8 /** Headers the way the forge sends them; the server tests use this shape too. */
9 const forge = (identity: string, key = KEY): Headers =>
10 new Headers({ "x-forge-identity": identity, "x-forge-signature": stamp(key, identity) });
11
12 const soon = (): number => Date.now() + 60_000;
13
14 const withKey = <T>(key: string | undefined, run: () => T): T => {
15 const had = process.env.FORGE_APP_IDENTITY_KEY;
16 if (key === undefined) delete process.env.FORGE_APP_IDENTITY_KEY;
17 else process.env.FORGE_APP_IDENTITY_KEY = key;
18 try {
19 return run();
20 } finally {
21 if (had === undefined) delete process.env.FORGE_APP_IDENTITY_KEY;
22 else process.env.FORGE_APP_IDENTITY_KEY = had;
23 }
24 };
25
26 test("a signed identity is the person the forge says it is", () => {
27 withKey(KEY, () => {
28 expect(whoIs(forge(`u_7|ada|admin|${soon()}`))).toEqual({ role: "admin", id: "u_7", name: "ada" });
29 expect(whoIs(forge(`u_8|bo|user|${soon()}`))).toEqual({ role: "user", id: "u_8", name: "bo" });
30 });
31 });
32
33 test("anything that cannot be verified is anonymous", () => {
34 withKey(KEY, () => {
35 const anon = { role: "anon" };
36 // no headers at all
37 expect(whoIs(new Headers())).toEqual(anon);
38 // the identity without its signature, and the signature without its identity
39 expect(whoIs(new Headers({ "x-forge-identity": `u_7|ada|admin|${soon()}` }))).toEqual(anon);
40 expect(whoIs(new Headers({ "x-forge-signature": stamp(KEY, "x") }))).toEqual(anon);
41 // signed with the wrong key, or the payload changed after signing
42 expect(whoIs(forge(`u_7|ada|admin|${soon()}`, "not-the-forge"))).toEqual(anon);
43 const tampered = forge(`u_7|ada|user|${soon()}`);
44 tampered.set("x-forge-identity", `u_7|ada|admin|${soon()}`);
45 expect(whoIs(tampered)).toEqual(anon);
46 // shapes that do not parse
47 expect(whoIs(forge(`u_7|ada|admin`))).toEqual(anon);
48 expect(whoIs(forge(`u_7|ada|admin|${soon()}|extra`))).toEqual(anon);
49 expect(whoIs(forge(`|ada|admin|${soon()}`))).toEqual(anon);
50 expect(whoIs(forge(`u_7||admin|${soon()}`))).toEqual(anon);
51 expect(whoIs(forge(`u_7|ada|owner|${soon()}`))).toEqual(anon);
52 expect(whoIs(forge(`u_7|ada|ADMIN|${soon()}`))).toEqual(anon);
53 expect(whoIs(forge(`u_7|ada|admin|soon`))).toEqual(anon);
54 expect(whoIs(forge(`u_7|ada|admin|${soon()}.5`))).toEqual(anon);
55 // an expiry that has passed
56 expect(whoIs(forge(`u_7|ada|admin|${Date.now() - 1}`))).toEqual(anon);
57 });
58 });
59
60 test("with no key nothing can be verified, so nobody is anybody", () => {
61 const identity = `u_7|ada|admin|${soon()}`;
62 const headers = withKey(KEY, () => forge(identity));
63 expect(withKey(undefined, () => whoIs(headers))).toEqual({ role: "anon" });
64 expect(withKey("", () => whoIs(headers))).toEqual({ role: "anon" });
65 });
66
67 test("the terminal's person comes from the environment the forge set", () => {
68 expect(atKeyboard({ FORGE_APP_USER: "ada", FORGE_APP_USER_ID: "u_7", FORGE_APP_USER_ROLE: "admin" })).toEqual({
69 role: "admin",
70 id: "u_7",
71 name: "ada",
72 });
73 expect(atKeyboard({})).toEqual({ role: "anon" });
74 expect(atKeyboard({ FORGE_APP_USER: "ada", FORGE_APP_USER_ID: "u_7", FORGE_APP_USER_ROLE: "root" })).toEqual({
75 role: "anon",
76 });
77 expect(atKeyboard({ FORGE_APP_USER: "", FORGE_APP_USER_ID: "u_7", FORGE_APP_USER_ROLE: "user" })).toEqual({
78 role: "anon",
79 });
80 });
src/who.ts added +67
@@ -0,0 +1,67 @@
1 /**
2 * Who is asking. Identity is the forge's to assert and ours to verify.
3 *
4 * The forge puts two headers on every request it proxies: `x-forge-identity`,
5 * which is `<userId>|<username>|<role>|<expiresMs>`, and `x-forge-signature`,
6 * which is base64url(HMAC-SHA256(FORGE_APP_IDENTITY_KEY, identity)). We
7 * recompute the signature and compare it in constant time; anything we cannot
8 * verify — no headers, no key, a shape that does not parse, an expiry that has
9 * passed — is anonymous. There is no other way in: no password, no query
10 * parameter, no environment flag, and nothing here reads a cookie.
11 *
12 * The app's own session cookie still exists, but it carries the CSRF token and
13 * nothing else. Identity and CSRF are different jobs.
14 */
15
16 import { equals, hmac } from "./sign.ts";
17
18 export type Role = "anon" | "user" | "admin";
19
20 export type Who = { role: "anon" } | { role: "user" | "admin"; id: string; name: string };
21
22 export const IDENTITY_HEADER = "x-forge-identity";
23 export const SIGNATURE_HEADER = "x-forge-signature";
24
25 export const ANON: Who = { role: "anon" };
26
27 /** The signature the forge puts on an identity string; the tests sign with it too. */
28 export const stamp = (key: string, identity: string): string => hmac(key, identity);
29
30 /** The forge's headers, verified. Anything we cannot prove is `{ role: "anon" }`. */

probe

probe: checking the review API shape, ignore

31 export function whoIs(headers: Headers, now = Date.now()): Who {
32 const key = process.env.FORGE_APP_IDENTITY_KEY;
33 if (!key) return ANON;
34
35 const identity = headers.get(IDENTITY_HEADER);
36 const signature = headers.get(SIGNATURE_HEADER);
37 if (!identity || !signature || identity.length > 512) return ANON;
38 if (!equals(signature, stamp(key, identity))) return ANON;
39
40 const fields = identity.split("|");
41 if (fields.length !== 4) return ANON;
42 const [id, name, role, expiresMs] = fields as [string, string, string, string];
43 if (id === "" || name === "") return ANON;
44 if (role !== "user" && role !== "admin") return ANON;
45 if (!/^\d{1,15}$/.test(expiresMs) || Number(expiresMs) <= now) return ANON;
46 return { role, id, name };
47 }
48
49 /** False when no FORGE_APP_IDENTITY_KEY is set: then nobody can be an admin here. */
50 export const identityConfigured = (): boolean => Boolean(process.env.FORGE_APP_IDENTITY_KEY);
51
52 /**
53 * Who is at the keyboard in the app's terminal. There are no headers on an
54 * ssh session: the forge names the person in the process environment before
55 * it starts us, which is the same trust as the socket itself.
56 */
57 export function atKeyboard(env: NodeJS.ProcessEnv = process.env): Who {
58 const role = env.FORGE_APP_USER_ROLE;
59 const id = env.FORGE_APP_USER_ID ?? "";
60 const name = env.FORGE_APP_USER ?? "";
61 if ((role !== "user" && role !== "admin") || id === "" || name === "") return ANON;
62 return { role, id, name };
63 }
64
65 /** Where a person goes to become someone: the forge's sign-in, and back here. */
66 export const signInHref = (to: string): string => `/__forge/signin?to=${encodeURIComponent(to)}`;
67 export const SIGN_OUT_HREF = "/__forge/signout";

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs