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

A person sent to sign in by an app comes back to the app, whether they sign i… #12177

Merged opened this in answer to

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

Merged

mason/forge:mason/12174-a-person-sent-to-sign-in-by-an-app-comes → main 10 commits fork 8 threads · 8 unresolved

  • Merged. mason/12174-a-person-sent-to-sign-in-by-an-app-comes was merged into main as e1f0f37.

Review

Sign in to review this change.
5 files changed +179 −2
docs/features/signin-asking.md added +13
@@ -0,0 +1,13 @@
1 # Sent to sign in by an app, and back again
2
3 An app that uses this forge to sign its people in sends a stranger to `/oauth/authorize`. The forge sends them on to `/signin?next=<that request>`; `src/web/pages/asking.ts` reads the `next` the way the consent page does, so only a registered client with a registered return address is ever described.
4
5 - **Who is asking:** the sign-in and sign-up pages lead with the return host, the verified part ("**gretchy.store** wants you to sign in"). The name the app registered follows as small secondary text, escaped, never in the lead: dynamic client registration is open, so a name is whatever its registrant typed. The consent page itself still names only the host.
6 - **Sign up keeps the request:** every link between the two forms and from the header carries the page the person came from (`src/web/return_to.ts`); a new account ends on the authorize request, so the consent is the next page, not the home page.
7 - **A `next` that is not a registered authorize request** says nothing about any app.
8
9 ## Terminal
10
11 There is no terminal form of this: the redirect is a browser exchange between the app and the forge. The same account reaches the forge's terminal with an SSH key added under Settings → Keys (`ssh <forge>`, the exact command is in `FORGE_APP_SSH`); an account made through an app's sign-up is the same account.
12
13 Tests: `test/signin-asking.test.ts` (including a real-browser walk, skipped where no Chromium runs).
src/web/pages/asking.ts added +26
@@ -0,0 +1,26 @@
1 /**
2 * Who is asking a person to sign in: when the `next` of the sign-in or
3 * sign-up page is an OAuth authorize request, the app that made it.
4 * Read the way the consent page reads it, so only a registered client with a
5 * registered return address is ever named.
6 */
7 import type { Database } from "bun:sqlite";
8 import { parseAuthRequest } from "../../core/oauth.ts";
9
10 export interface Asking {
11 /** The name the app registered. */
12 readonly name: string;
13 /** Where the person will be sent back to: the app's address. */
14 readonly host: string;
15 }
16
17 export function askingOf(db: Database, next: string | undefined): Asking | undefined {
18 if (next === undefined) return undefined;
19 const url = URL.parse(next, "http://forge.invalid");
20 if (url === null || url.pathname !== "/oauth/authorize") return undefined;
21 const request = parseAuthRequest(db, (n) => url.searchParams.get(n) ?? "");
22 if (!request.ok) return undefined;
23 const back = URL.parse(request.value.redirectUri);
24 if (back === null) return undefined;
25 return { name: request.value.client.name, host: back.host };
26 }
src/web/pages/site.ts +7
@@ -65,6 +65,7 @@ import {
6565 import { existsSync } from "node:fs";
6666 import { join } from "node:path";
6767 import { secureFor } from "../../http/cookie_secure.ts";
68 import { askingOf } from "./asking.ts";
6869 import { SIGNUP_OWN_FORGE, SIGNUP_YOU_GET } from "../../core/contribute.ts";
6970 import { hasAdmin } from "../../spawn/claim.ts";
7071 import { getSignup, type SignupMode } from "../../settings/signup.ts";
@@ -273,6 +274,8 @@ function authForm(
273274 if (next) query.set("next", next);
274275 const invite = kind === "signup" ? inviteOf(ctx) : undefined;
275276 if (invite !== undefined) query.set("invite", invite);
277 // The way to the other form keeps where the person was going.
278 const asking = askingOf(ctx.app.db, next);
276279 const action = query.size === 0 ? `/${kind}` : `/${kind}?${query.toString()}`;
277280 // "Is this the first account?" is a `LIMIT 1` existence check, not a count
278281 // of the whole user table — and the sign-in screen paints on every session.
@@ -443,6 +446,10 @@ function authForm(
443446 existsSync(join(ctx.app.config.dataDir, "parent.json")) && unowned;
444447 return html`<div class="narrow">
445448 ${pageHead(kind === "signin" ? "Sign in" : getSignup(ctx.app.db) === "request" ? "Ask for an account" : "Create your account")}
449 ${when(
450 asking,
451 (who) => html`<p class="mb" id="asking"><strong>${who.host}</strong> <span class="small muted">(${who.name})</span> wants you to sign in.</p>`,
outdated

HIGH: this shows an attacker-registrable name, bolded, as the actor on the sign-in and sign-up pages. Show the verified host first and the name only as secondary text. It also removes the sign-up copy for this case; check that is wanted.

outdated

Principle 1: one sentence. Drop the second clause on both forms ('Make an account here and you go straight back to it.' / 'You go back to it once you have.'). The page already names the host and the form's button says what happens. On sign-up it also sits directly above signup-you-get, so the page explains itself twice.

452 )}
outdated

Principle 1: 'the forge has not checked that' is a note that changes no decision. Show the name as plain muted text or drop it.

446453 ${when(
447454 kind === "signup",
448455 () => html`<p class="mb" id="signup-you-get">${SIGNUP_YOU_GET} <a href="/forges/new">${SIGNUP_OWN_FORGE}</a></p>`,
test/helpers/chrome.ts +2 −2
@@ -374,7 +374,7 @@ export async function clickAndFollow(
374374 steps: readonly string[];
375375 until: (url: string) => boolean;
376376 /** Done for another reason the page cannot see (a server got the request). */
377 done?: () => boolean;
377 done?: (requested: readonly string[]) => boolean;
378378 timeoutMs?: number;
379379 },
380380 ): Promise<Followed> {
@@ -444,7 +444,7 @@ export async function clickAndFollow(
444444 });
445445 const deadline = Date.now() + (input.timeoutMs ?? 15_000);
446446 const reached = () =>
447 requested.some(input.until) || (input.done?.() ?? false);
447 requested.some(input.until) || (input.done?.(requested) ?? false);
448448 let loaded = cdp.once("Page.loadEventFired");
449449 await cdp.send("Page.navigate", { url: input.url });
450450 await Promise.race([loaded, Bun.sleep(Math.max(0, deadline - Date.now()))]);
test/signin-asking.test.ts added +131
@@ -0,0 +1,131 @@
1 /**
2 * A person sent to sign in by an app (#12174): the sign-in and sign-up pages
3 * say who is asking, the way to sign up keeps the authorize request, and a
4 * new account finishes on it; the consent leads with the verified return host,
5 * never the registered name.
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { findChromium } from "../src/looks/capture.ts";
13 import { chromiumRuns, clickAndFollow } from "./helpers/chrome.ts";
14 import { getUserByName } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16
17 const REDIRECT = "https://gretchy.store/auth/callback";
18 let dataDir: string;
19 let app: App;
20 let servers: Servers;
21 let base: string;
22 let next: string;
23
24 beforeAll(async () => {
25 dataDir = await mkdtemp(join(tmpdir(), "forge-signin-asking-"));
26 delete process.env.FORGE_EDGE_TOKEN;
27 app = createApp({ dataDir, port: 0 });
28 servers = await serve(app);
29 base = servers.url;
30 const res = await fetch(`${base}/oauth/register`, {
31 method: "POST",
32 headers: { "content-type": "application/json" },
33 body: JSON.stringify({ client_name: "Gretchy Store", redirect_uris: [REDIRECT] }),
34 });
35 const clientId = ((await res.json()) as { client_id: string }).client_id;
36 next = `/oauth/authorize?${new URLSearchParams({
37 client_id: clientId,
38 redirect_uri: REDIRECT,
39 response_type: "code",
40 code_challenge: "A".repeat(43),
41 code_challenge_method: "S256",
42 scope: "openid",
43 state: "s1",
44 })}`;
45 });
46 afterAll(async () => {
47 servers.stop();
48 await rm(dataDir, { recursive: true, force: true });
49 });
50
51 test("authorize sends a stranger to sign in, and both pages name the app and keep the request", async () => {
52 const sent = await fetch(`${base}${next}`, { redirect: "manual" });
53 expect(sent.status).toBe(303);
54 const door = sent.headers.get("location")!;
55 expect(door.startsWith("/signin?next=")).toBe(true);
56 const signin = await (await fetch(`${base}${door}`)).text();
57 expect(signin).toContain("Gretchy Store");

Redundant: the assertion on line 54 already contains this string. Remove it, and add an escaping test for a hostile registered name.

58 expect(signin).toContain("<strong>gretchy.store</strong>");
59 expect(signin).toContain("(Gretchy Store)</span> wants you to sign in.");
60 expect(signin).not.toContain("<strong>Gretchy Store</strong>");
61 const link = `/signup?next=${encodeURIComponent(next)}`;
62 expect(signin).toContain(`href="${link.replaceAll("&", "&amp;")}"`);
63 const signup = await (await fetch(`${base}${link}`)).text();
64 expect(signup).toContain("Gretchy Store");
65 });
66
67 test("signing up with next lands on the authorize request, which names the return host", async () => {
68 const made = await fetch(`${base}/signup?next=${encodeURIComponent(next)}`, {
69 method: "POST",
70 headers: { origin: base },
71 body: new URLSearchParams({ username: "newcomer", password: "a-long-enough-passphrase", email: "" }),
72 redirect: "manual",
73 });
74 expect(made.status).toBe(303);
75 expect(made.headers.get("location")).toBe(next);
76 const cookie = made.headers.get("set-cookie")!.split(";")[0]!;
77 const consent = await (await fetch(`${base}${next}`, { headers: { cookie } })).text();
78 expect(consent).toContain("Sign in to <strong>gretchy.store</strong>");
79 });
80
81 test("a next that is not a registered authorize request names nobody", async () => {
82 const page = await (await fetch(`${base}/signin?next=${encodeURIComponent("/oauth/authorize?client_id=nope")}`)).text();
83 expect(page).not.toContain("wants you to sign in");
84 });
85
86 test("a hostile registered name is shown escaped, never as markup", async () => {
87 const hostile = "<img src=x onerror=1>";
88 const res = await fetch(`${base}/oauth/register`, {
89 method: "POST",
90 headers: { "content-type": "application/json" },
91 body: JSON.stringify({ client_name: hostile, redirect_uris: [REDIRECT] }),
92 });
93 expect(res.status).toBe(201);
94 const clientId = ((await res.json()) as { client_id: string }).client_id;
95 const request = `/oauth/authorize?${new URLSearchParams({
96 client_id: clientId,
97 redirect_uri: REDIRECT,
98 response_type: "code",
99 code_challenge: "A".repeat(43),
100 code_challenge_method: "S256",
101 })}`;
102 const page = await (await fetch(`${base}/signin?next=${encodeURIComponent(request)}`)).text();
103 expect(page).toContain("<strong>gretchy.store</strong>");
104 expect(page).toContain("&lt;img src=x onerror=1&gt;");
105 expect(page).not.toContain(hostile);
106 });
107
108 const chromium = findChromium();
109 test.skipIf(chromium === undefined || !chromiumRuns(chromium))("in a browser: stranger, sign in page, Sign up, Create account, back on the consent for the same app", async () => {
110 const seen = await clickAndFollow(chromium!, {
111 url: `${base}${next}`,
112 cookie: { name: "forge_theme", value: "light" },
113 steps: [
114 `[...document.querySelectorAll("a")].find((a) => a.textContent.includes("Sign up")).click()`,
115 `(() => {
116 document.querySelector('input[name="username"]').value = "walker";
117 document.querySelector('input[name="password"]').value = "a-long-enough-passphrase";
118 document.querySelector('form[method="post"] button[type="submit"]').click();
119 })()`,
120 ],
121 until: () => false,
122 // The account exists a moment before the redirect to the consent is
123 // requested: wait for the redirect, not only the account.
124 done: (requested) =>
125 getUserByName(app.db, "walker") !== undefined &&
126 requested.filter((url) => url.includes("/oauth/authorize")).length > 1,
127 timeoutMs: 20_000,
128 });
129 expect(seen.requested.some((url) => url.includes("/signup?next="))).toBe(true);
130 expect(seen.requested.filter((url) => url.includes("/oauth/authorize")).length).toBeGreaterThan(1);
131 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs