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

A sign-in flow's expiry sentences live in one module, read by the parent sign-in and an app's scaffold #5602

Merged opened this

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

Merged

mason/forge:mason/5585-a-sign-in-flow-s-expiry-sentences-live-i → main 2 commits fork

  • Merged. mason/5585-a-sign-in-flow-s-expiry-sentences-live-i was merged into main as f9a3ed0.

Review

Sign in to review this change.
6 files changed +52 −5
scripts/shards.ts +1 −1
@@ -454,7 +454,7 @@ export function parseShardSpec(
454454 | { ok: true; none: true }
455455 | { ok: false; error: string } {
456456 if (spec === undefined || spec.trim() === "") return { ok: true, none: true };
457 const m = /^([1-9][0-9]{0,2})\/([1-9][0-9]{0,2})$/.exec(spec.trim());
457 const m = /^([1-9][0-9]{0,3})\/([1-9][0-9]{0,3})$/.exec(spec.trim());
458458 if (!m)
459459 return {
460460 ok: false,
src/apps/scaffold.ts +3 −2
@@ -15,6 +15,7 @@
1515 import { readFileSync } from "node:fs";
1616 import type { FileWrite } from "../git/write.ts";
1717 import { beaconJs } from "../web/beacon.ts";
18 import { SIGNIN_EXPIRED, SIGNIN_NOT_THIS_BROWSER } from "../kernel/signin_words.ts";
1819
1920 /**
2021 * The evaluator an app decides with is the forge's own, carried as text:
@@ -667,14 +668,14 @@ export async function finish(
667668 const state = url.searchParams.get("state") ?? "";
668669 const code = url.searchParams.get("code") ?? "";
669670 if (state === "" || code === "" || state !== cookieOf(request, LOGIN_COOKIE))
670 return refuse("This sign-in did not start in this browser.");
671 return refuse(${JSON.stringify(SIGNIN_NOT_THIS_BROWSER)});
671672 const row = db
672673 .query<{ verifier: string; nonce: string; expires_at: number }, [string]>(
673674 "DELETE FROM login WHERE state = ?1 RETURNING verifier, nonce, expires_at",
674675 )
675676 .get(hash(state));
676677 if (row === null || row.expires_at <= now)
677 return refuse("This sign-in took too long or was already used.");
678 return refuse(${JSON.stringify(SIGNIN_EXPIRED)});
678679 try {
679680 const res = await fetcher(s)(\`\${issuer(s)}/oauth/token\`, {
680681 method: "POST",
src/http/rp.ts +3 −2
@@ -28,6 +28,7 @@ import { readCapped } from "../origins/providers/http.ts";
2828 import { clientAddress } from "./peer.ts";
2929 import { signinAllowed } from "../web/pages/site.ts";
3030 import { createSession } from "../core/sessions.ts";
31 import { SIGNIN_EXPIRED, SIGNIN_NOT_THIS_BROWSER } from "../kernel/signin_words.ts";
3132 import { verifyJwt } from "../kernel/jwt.ts";
3233 import { html } from "../web/html.ts";
3334 import { flashTo, page, redirect } from "../web/layout.ts";
@@ -177,7 +178,7 @@ export async function parentCallback(ctx: Ctx): Promise<Response> {
177178 const code = q.get("code") ?? "";
178179 // The cookie is the browser that began; without it nothing is fetched or spent.
179180 if (state === "" || state !== cookieValue(ctx.request, STATE_COOKIE))
180 return failure(ctx, "This sign-in did not start in this browser.", 400);
181 return failure(ctx, SIGNIN_NOT_THIS_BROWSER, 400);
181182 const login = takeLogin(ctx.app.db, state);
182183 const clear = cookieHeader("", 0, ctx.url, ctx.app.config.url);
183184 const refuse = (message: string, status = 400): Response => {
@@ -186,7 +187,7 @@ export async function parentCallback(ctx: Ctx): Promise<Response> {
186187 return response;
187188 };
188189 if (login === undefined || login.issuer !== parentIssuer(ctx.app) || login.redirectUri !== redirectUriOf(ctx))
189 return refuse("This sign-in took too long or was already used.");
190 return refuse(SIGNIN_EXPIRED);
190191 if (q.has("error") || code === "" || code.length > 512)
191192 return refuse("The parent forge did not sign you in.");
192193 try {
src/kernel/signin_words.ts added +9
@@ -0,0 +1,9 @@
1 /**
2 * What a sign-in flow says when its return cannot be finished: the state
3 * did not come back to the browser that began, or the login it names is
4 * gone (spent, or past its time). The parent sign-in (`http/rp.ts`) and the
5 * sign-in an app's scaffold writes (`apps/scaffold.ts`) say the same thing,
6 * so it is said here once.
7 */
8 export const SIGNIN_NOT_THIS_BROWSER = "This sign-in did not start in this browser.";
9 export const SIGNIN_EXPIRED = "This sign-in took too long or was already used.";
test/shards.test.ts +1
@@ -513,6 +513,7 @@ describe("parseShardSpec", () => {
513513 expect(parseShardSpec("1/3")).toEqual({ ok: true, index: 1, total: 3 });
514514 expect(parseShardSpec("3/3")).toEqual({ ok: true, index: 3, total: 3 });
515515 expect(parseShardSpec(" 2/4 ")).toEqual({ ok: true, index: 2, total: 4 });
516 expect(parseShardSpec("1000/1000")).toEqual({ ok: true, index: 1000, total: 1000 });
516517 for (const bad of ["0/3", "1/0", "4/3", "1", "1-3", "a/b", "1/3/5"])
517518 expect(parseShardSpec(bad).ok).toBe(false);
518519 });
test/signin-words.test.ts added +35
@@ -0,0 +1,35 @@
1 /**
2 * A sign-in flow's expiry sentences are said from one place
3 * (`src/kernel/signin_words.ts`). Each is a real sentence, and neither the
4 * parent sign-in nor an app's scaffold types its own copy back in.
5 */
6 import { describe, expect, test } from "bun:test";
7 import { readFileSync } from "node:fs";
8 import { join } from "node:path";
9 import * as words from "../src/kernel/signin_words.ts";
10
11 const READERS = ["src/http/rp.ts", "src/apps/scaffold.ts"];
12 const source = (path: string): string =>
13 readFileSync(join(import.meta.dir, "..", path), "utf8");
14
15 describe("a sign-in flow's expiry sentences", () => {
16 const sentences = Object.entries(words);
17
18 test("each exported sentence says something", () => {
19 expect(sentences.length).toBe(2);
20 for (const [, sentence] of sentences) {
21 expect(typeof sentence).toBe("string");
22 expect(sentence.trim()).not.toBe("");
23 }
24 });
25
26 for (const path of READERS)
27 test(`${path} imports the sentences and types none of them`, () => {
28 const text = source(path);
29 expect(text).toContain("kernel/signin_words.ts");
30 for (const [name, sentence] of sentences) {
31 expect(text).toContain(name);
32 expect(text).not.toContain(sentence);
33 }
34 });
35 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs