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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/5585-a-sign-in-flow-s-expiry-sentences-live-i → main 2 commits fork
6 files
changed +52
−5
scripts/shards.ts +1 −1
| @@ -454,7 +454,7 @@ export function parseShardSpec( | ||
| 454 | 454 | | { ok: true; none: true } |
| 455 | 455 | | { ok: false; error: string } { |
| 456 | 456 | 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()); | |
| 458 | 458 | if (!m) |
| 459 | 459 | return { |
| 460 | 460 | ok: false, |
src/apps/scaffold.ts +3 −2
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | import { readFileSync } from "node:fs"; |
| 16 | 16 | import type { FileWrite } from "../git/write.ts"; |
| 17 | 17 | import { beaconJs } from "../web/beacon.ts"; |
| 18 | import { SIGNIN_EXPIRED, SIGNIN_NOT_THIS_BROWSER } from "../kernel/signin_words.ts"; | |
| 18 | 19 | |
| 19 | 20 | /** |
| 20 | 21 | * The evaluator an app decides with is the forge's own, carried as text: |
| @@ -667,14 +668,14 @@ export async function finish( | ||
| 667 | 668 | const state = url.searchParams.get("state") ?? ""; |
| 668 | 669 | const code = url.searchParams.get("code") ?? ""; |
| 669 | 670 | 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)}); | |
| 671 | 672 | const row = db |
| 672 | 673 | .query<{ verifier: string; nonce: string; expires_at: number }, [string]>( |
| 673 | 674 | "DELETE FROM login WHERE state = ?1 RETURNING verifier, nonce, expires_at", |
| 674 | 675 | ) |
| 675 | 676 | .get(hash(state)); |
| 676 | 677 | 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)}); | |
| 678 | 679 | try { |
| 679 | 680 | const res = await fetcher(s)(\`\${issuer(s)}/oauth/token\`, { |
| 680 | 681 | method: "POST", |
src/http/rp.ts +3 −2
| @@ -28,6 +28,7 @@ import { readCapped } from "../origins/providers/http.ts"; | ||
| 28 | 28 | import { clientAddress } from "./peer.ts"; |
| 29 | 29 | import { signinAllowed } from "../web/pages/site.ts"; |
| 30 | 30 | import { createSession } from "../core/sessions.ts"; |
| 31 | import { SIGNIN_EXPIRED, SIGNIN_NOT_THIS_BROWSER } from "../kernel/signin_words.ts"; | |
| 31 | 32 | import { verifyJwt } from "../kernel/jwt.ts"; |
| 32 | 33 | import { html } from "../web/html.ts"; |
| 33 | 34 | import { flashTo, page, redirect } from "../web/layout.ts"; |
| @@ -177,7 +178,7 @@ export async function parentCallback(ctx: Ctx): Promise<Response> { | ||
| 177 | 178 | const code = q.get("code") ?? ""; |
| 178 | 179 | // The cookie is the browser that began; without it nothing is fetched or spent. |
| 179 | 180 | 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); | |
| 181 | 182 | const login = takeLogin(ctx.app.db, state); |
| 182 | 183 | const clear = cookieHeader("", 0, ctx.url, ctx.app.config.url); |
| 183 | 184 | const refuse = (message: string, status = 400): Response => { |
| @@ -186,7 +187,7 @@ export async function parentCallback(ctx: Ctx): Promise<Response> { | ||
| 186 | 187 | return response; |
| 187 | 188 | }; |
| 188 | 189 | 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); | |
| 190 | 191 | if (q.has("error") || code === "" || code.length > 512) |
| 191 | 192 | return refuse("The parent forge did not sign you in."); |
| 192 | 193 | 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", () => { | ||
| 513 | 513 | expect(parseShardSpec("1/3")).toEqual({ ok: true, index: 1, total: 3 }); |
| 514 | 514 | expect(parseShardSpec("3/3")).toEqual({ ok: true, index: 3, total: 3 }); |
| 515 | 515 | expect(parseShardSpec(" 2/4 ")).toEqual({ ok: true, index: 2, total: 4 }); |
| 516 | expect(parseShardSpec("1000/1000")).toEqual({ ok: true, index: 1000, total: 1000 }); | |
| 516 | 517 | for (const bad of ["0/3", "1/0", "4/3", "1", "1-3", "a/b", "1/3/5"]) |
| 517 | 518 | expect(parseShardSpec(bad).ok).toBe(false); |
| 518 | 519 | }); |
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 | }); |
new above
offline