Public: anyone can read this; the crew can change it.
Linked sign-in methods read "Link an account you have" and never offer to create one #2904
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2883-linked-sign-in-methods-read-link-an-acco → main 5 commits fork 8 threads · 8 unresolved
README.md +3 −2
| @@ -346,14 +346,15 @@ The relying party is the address the browser used (or | ||
| 346 | 346 | Sign-up is a username, an email address and a passkey (`/signup`, `src/web/pages/signup-passkey.ts`); the running server refuses a password-only sign-up on the form, `POST /api/v1/users` and the terminal (`passkeyOnlySignup`, set in `main.ts`). Accounts that already have a password still sign in. The owner claim of a spawned forge (`/claim`, `web/pages/claim.ts`) and an admin's everyday account (`web/pages/everyday.ts`) make their account with a passkey too (`web/passkey-ceremony.ts`, challenges from `POST /claim/options` and `POST /settings/everyday/options`); neither reads a password, and without JavaScript the page says a passkey needs it. A claim post with no passkey is refused and does not spend the link. |
| 347 | 347 | |
| 348 | 348 | A forge with a parent (`FORGE_PARENT_URL`, or the `upstream` in a spawned |
| 349 | forge's `parent.json`) offers "Continue with <parent>" on `/signin`. The forge | |
| 349 | forge's `parent.json`) lists the parent under "Link an account you have" on `/signin`. The forge | |
| 350 | 350 | registers itself once at the parent's `/oauth/register` (the client id is kept |
| 351 | 351 | in `rp_client`), sends the person through the parent's authorize page with |
| 352 | 352 | PKCE, and on return checks state (also bound to the browser by an HttpOnly |
| 353 | 353 | cookie), nonce, `iss`, `aud`, `exp` and the ES256 signature against the |
| 354 | 354 | parent's JWKS (`kernel/jwt.ts`; keys are cached ten minutes and an unknown |
| 355 | 355 | `kid` refetches them once a minute at most). The parent's `sub` — never the |
| 356 | username — maps to a local account made on first sight with no password | |
| 356 | username — maps to a local account that is already linked to it; a `sub` nobody | |
| 357 | holds makes no user and ends on `/signup` (#2883) | |
| 357 | 358 | (`core/federation.ts`; `http/rp.ts` has the two routes). No dependency added. |
| 358 | 359 | The parent's answers are read by bytes and refused past 16 KiB (64 KiB for the |
| 359 | 360 | key set), and starting a sign-in is throttled like a password attempt. |
src/core/federation.ts +13 −73
| @@ -12,8 +12,7 @@ import { join } from "node:path"; | ||
| 12 | 12 | import { sha256 } from "../kernel/hash.ts"; |
| 13 | 13 | import { parentUrlProblem } from "./parent_releases.ts"; |
| 14 | 14 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 15 | import { createUser, getUserById, getUserByName, isActive, type User } from "./users.ts"; | |
| 16 | import type { SignupSlot } from "../actions/signup-window.ts"; | |
| 15 | import { getUserById, isActive, type User } from "./users.ts"; | |
| 17 | 16 | |
| 18 | 17 | /** Where the parent sends a person back; the address registered at the parent. */ |
| 19 | 18 | export const CALLBACK_PATH = "/signin/parent/callback"; |
| @@ -78,79 +77,20 @@ export function takeLogin(db: Database, state: string, now = Date.now()): Login | ||
| 78 | 77 | }; |
| 79 | 78 | } |
| 80 | 79 | |
| 81 | /** A local username for a parent's person: their name if free and fit, else made unique by their `sub`. */ | |
| 82 | async function makeAccount(db: Database, sub: string, preferred: string, slot: SignupSlot): Promise<User | undefined> { | |
| 83 | const base = preferred.toLowerCase().replace(/[^a-z0-9.-]/g, "").replace(/^[.-]+/, "").slice(0, 30); | |
| 84 | const tail = sub.toLowerCase().replace(/[^a-z0-9]/g, "").slice(-8); | |
| 85 | for (const username of [base, `${base}-${tail}`, `user-${tail}`]) { | |
| 86 | if (username === "" || username.startsWith("-")) continue; | |
| 87 | if (getUserByName(db, username) !== undefined) continue; | |
| 88 | const made = await createUser(db, { | |
| 89 | username, | |
| 90 | // Unique, never mailed: `.invalid` is reserved and resolves nowhere. | |
| 91 | email: `${sub.toLowerCase()}@sso.invalid`, | |
| 92 | displayName: preferred, | |
| 93 | origin: "parent", | |
| 94 | slot, | |
| 95 | }); | |
| 96 | if (made.ok) return made.value; | |
| 97 | if (made.error === "signup_closed") return undefined; | |
| 98 | } | |
| 99 | return undefined; | |
| 100 | } | |
| 101 | ||
| 102 | const making = new Map<string, Promise<User | undefined>>(); | |
| 103 | ||
| 104 | /** Whether this issuer's `sub` already has a local account: a return, not a first sight. */ | |
| 105 | export function knownIdentity(db: Database, issuer: string, sub: string): boolean { | |
| 106 | return ( | |
| 107 | db | |
| 108 | .query<{ user_id: string }, [string, string]>( | |
| 109 | "SELECT user_id FROM federated_identity WHERE issuer = ?1 AND sub = ?2", | |
| 110 | ) | |
| 111 | .get(issuer, sub) !== null | |
| 112 | ); | |
| 113 | } | |
| 114 | ||
| 115 | 80 | /** |
| 116 | * The local account for an issuer's `sub`, made on first sight with no | |
| 117 | * password — a stranger's sign-up, so only with a place in the sign-up | |
| 118 | * window and while signup is open. Two returns of the same person at once | |
| 119 | * make one account. | |
| 81 | * The local account an issuer's `sub` is linked to, if it is active. A | |
| 82 | * linked method signs in an account that already exists and never makes | |
| 83 | * one: an identity nobody here holds gets nothing (#2883). | |
| 120 | 84 | */ |
| 121 | export function accountFor( | |
| 122 | db: Database, | |
| 123 | issuer: string, | |
| 124 | sub: string, | |
| 125 | preferred: string, | |
| 126 | slot: SignupSlot | undefined, | |
| 127 | ): Promise<User | undefined> { | |
| 128 | const key = `${issuer}\0${sub}`; | |
| 129 | const pending = making.get(key); | |
| 130 | if (pending !== undefined) return pending; | |
| 131 | const attempt = (async (): Promise<User | undefined> => { | |
| 132 | const known = db | |
| 133 | .query<{ user_id: string }, [string, string]>( | |
| 134 | "SELECT user_id FROM federated_identity WHERE issuer = ?1 AND sub = ?2", | |
| 135 | ) | |
| 136 | .get(issuer, sub); | |
| 137 | if (known !== null) { | |
| 138 | const user = getUserById(db, known.user_id); | |
| 139 | return user !== undefined && isActive(user) ? user : undefined; | |
| 140 | } | |
| 141 | if (slot === undefined) return undefined; | |
| 142 | const made = await makeAccount(db, sub, preferred, slot); | |
| 143 | if (made === undefined) return undefined; | |
| 144 | db.query("INSERT INTO federated_identity (issuer, sub, user_id) VALUES (?1, ?2, ?3)").run( | |
| 145 | issuer, | |
| 146 | sub, | |
| 147 | made.id, | |
| 148 | ); | |
| 149 | return made; | |
| 150 | })(); | |
| 151 | making.set(key, attempt); | |
| 152 | void attempt.finally(() => making.delete(key)); | |
| 153 | return attempt; | |
| 85 | export function linkedAccount(db: Database, issuer: string, sub: string): User | undefined { | |
| 86 | const known = db | |
| 87 | .query<{ user_id: string }, [string, string]>( | |
| 88 | "SELECT user_id FROM federated_identity WHERE issuer = ?1 AND sub = ?2", | |
| 89 | ) | |
| 90 | .get(issuer, sub); | |
| 91 | if (known === null) return undefined; | |
| 92 | const user = getUserById(db, known.user_id); | |
| 93 | return user !== undefined && isActive(user) ? user : undefined; | |
| 154 | 94 | } |
| 155 | 95 | |
| 156 | 96 | /** The spawn record's upstream, read once per forge: it does not change while the process runs. */ |
src/core/users.ts +1 −5
| @@ -124,7 +124,7 @@ export type CreateUserError = | ||
| 124 | 124 | * code, and `seed`, which no request reaches. Every other origin makes a person who is not an admin, even on a |
| 125 | 125 | * forge with no admin at all: no sign-up becomes the admin by being first. |
| 126 | 126 | */ |
| 127 | export type StrangerOrigin = "signup" | "passkey" | "parent"; | |
| 127 | export type StrangerOrigin = "signup" | "passkey"; | |
| 128 | 128 | export type AdminOrigin = "claim" | "first-admin" | "seed"; |
| 129 | 129 | export type ExemptOrigin = AdminOrigin | "everyday" | "agent" | "spawn"; |
| 130 | 130 | export type AccountOrigin = |
| @@ -133,7 +133,6 @@ export type AccountOrigin = | ||
| 133 | 133 | * signup is `invite` it opens the door once, spent with the account. |
| 134 | 134 | */ |
| 135 | 135 | | { origin: "signup" | "passkey"; slot: SignupSlot; invite?: string | undefined } |
| 136 | | { origin: "parent"; slot: SignupSlot } | |
| 137 | 136 | | { origin: "edge" } |
| 138 | 137 | | { origin: Exclude<ExemptOrigin, AdminOrigin> } |
| 139 | 138 | | ({ origin: AdminOrigin } & AdminFields); |
| @@ -240,9 +239,6 @@ function admission(db: Database, input: AccountOrigin): Admission { | ||
| 240 | 239 | return newAccountsClosed(db, input.invite) || input.invite === undefined |
| 241 | 240 | ? REFUSED |
| 242 | 241 | : { admitted: true, spend: input.invite }; |
| 243 | case "parent": | |
| 244 | if (input.slot.spent) throw new Error("a sign-up slot makes one account"); | |
| 245 | return newAccountsClosed(db) ? REFUSED : ADMITTED; | |
| 246 | 242 | case "edge": |
| 247 | 243 | return newAccountsClosed(db) ? REFUSED : ADMITTED; |
| 248 | 244 | case "claim": |
src/http/rp.ts +12 −19
| @@ -1,20 +1,20 @@ | ||
| 1 | 1 | /** |
| 2 | * Continue with the parent forge (#748): this forge as a relying party of | |
| 2 | * Sign in through the parent forge (#748): this forge as a relying party of | |
| 3 | 3 | * the OpenID provider its parent already is. `/signin/parent` registers |
| 4 | 4 | * this forge at the parent once (the client id is kept), then sends the |
| 5 | 5 | * person there with a state, a nonce and a PKCE challenge; the state also |
| 6 | 6 | * rides in an HttpOnly cookie so only the browser that began can finish. |
| 7 | 7 | * `/signin/parent/callback` trades the code, checks the id_token (state, |
| 8 | 8 | * nonce, iss, aud, exp, signature against the parent's JWKS) and signs in |
| 9 | * the local account the parent's `sub` maps to, made on first sight. | |
| 9 | * the local account the parent's `sub` is linked to; a `sub` nobody here | |
| 10 | * holds makes no account and ends on `/signup`. | |
| 10 | 11 | * Both are GETs that change nothing a stranger could use: a callback with |
| 11 | 12 | * no matching cookie and state is refused before anything is fetched. |
| 12 | 13 | */ |
| 13 | 14 | import { createHash, randomBytes } from "node:crypto"; |
| 14 | 15 | import type { App } from "../app.ts"; |
| 15 | 16 | import { |
| 16 | accountFor, | |
| 17 | knownIdentity, | |
| 17 | linkedAccount, | |
| 18 | 18 | clientFor, |
| 19 | 19 | keepClient, |
| 20 | 20 | parentIssuerOf, |
| @@ -26,7 +26,7 @@ import { | ||
| 26 | 26 | } from "../core/federation.ts"; |
| 27 | 27 | import { readCapped } from "../origins/providers/http.ts"; |
| 28 | 28 | import { clientAddress } from "./peer.ts"; |
| 29 | import { signinAllowed, signupSlot } from "../web/pages/site.ts"; | |
| 29 | import { signinAllowed } from "../web/pages/site.ts"; | |
| 30 | 30 | import { createSession } from "../core/sessions.ts"; |
| 31 | 31 | import { verifyJwt } from "../kernel/jwt.ts"; |
| 32 | 32 | import { html } from "../web/html.ts"; |
| @@ -171,10 +171,6 @@ async function begin(ctx: Ctx, linkUser?: string): Promise<Response> { | ||
| 171 | 171 | return new Response(null, { status: 303, headers }); |
| 172 | 172 | } |
| 173 | 173 | |
| 174 | const clean = (s: unknown, max: number): string => | |
| 175 | // eslint-disable-next-line no-control-regex | |
| 176 | typeof s === "string" ? s.replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, max) : ""; | |
| 177 | ||
| 178 | 174 | export async function parentCallback(ctx: Ctx): Promise<Response> { |
| 179 | 175 | const q = ctx.url.searchParams; |
| 180 | 176 | const state = q.get("state") ?? ""; |
| @@ -236,17 +232,14 @@ export async function parentCallback(ctx: Ctx): Promise<Response> { | ||
| 236 | 232 | back.headers.set("cache-control", "no-store"); |
| 237 | 233 | return back; |
| 238 | 234 | } |
| 239 | const name = clean(verified.value["preferred_username"], 64); | |
| 240 | // A returning person is a sign-in; first sight of a sub makes an account, | |
| 241 | // and that is a sign-up, held to the sign-up limit before it is made. | |
| 242 | const slot = knownIdentity(ctx.app.db, login.issuer, sub) ? undefined : signupSlot(ctx); | |
| 243 | if (typeof slot === "number") { | |
| 244 | const slow = refuse(`Too many new accounts from here. Try again in ${slot}s.`, 429); | |
| 245 | slow.headers.set("retry-after", String(slot)); | |
| 246 | return slow; | |
| 235 | // A linked method signs in an account that exists; it never makes one. | |
| 236 | const user = linkedAccount(ctx.app.db, login.issuer, sub); | |
| 237 | if (user === undefined) { | |
| 238 | const done = redirect("/signup?linked=unknown"); | |
| 239 | done.headers.append("set-cookie", clear); | |
| 240 | done.headers.set("cache-control", "no-store"); | |
| 241 | return done; | |
| 247 | 242 | } |
| 248 | const user = await accountFor(ctx.app.db, login.issuer, sub, name, slot); | |
| 249 | if (user === undefined) return refuse("An account for you could not be made or is not allowed in.", 403); | |
| 250 | 243 | const session = createSession(ctx.app.db, user.id); |
| 251 | 244 | const done = redirect("/", sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url)); |
| 252 | 245 | done.headers.append("set-cookie", clear); |
src/web/pages/site.ts +30 −33
| @@ -24,6 +24,7 @@ import { hasAnyUser, verifyPasswordThrottled, type User } from "../../core/users | ||
| 24 | 24 | import { isThemeName } from "../../design/themes.ts"; |
| 25 | 25 | import { cookieValue, crossOrigin, type Ctx } from "../../http/context.ts"; |
| 26 | 26 | import { studioDoor, studioHome } from "../studio/home.ts"; |
| 27 | import { parentIssuerOf } from "../../core/federation.ts"; | |
| 27 | 28 | import { activeEdgeToken, edgeIdentity, edgeLoginUrl, edgeStrangerLogin } from "../../http/edge.ts"; |
| 28 | 29 | import { sshCommand } from "../../ssh/command.ts"; |
| 29 | 30 | import { hostOf, pageHead, signInFirst } from "../chrome.ts"; |
| @@ -46,7 +47,6 @@ import { | ||
| 46 | 47 | import { existsSync } from "node:fs"; |
| 47 | 48 | import { join } from "node:path"; |
| 48 | 49 | import { hasAdmin } from "../../spawn/claim.ts"; |
| 49 | import { parentIssuerOf } from "../../core/federation.ts"; | |
| 50 | 50 | import { getSignup, type SignupMode } from "../../settings/signup.ts"; |
| 51 | 51 | |
| 52 | 52 | export function sessionCookie( |
| @@ -232,6 +232,23 @@ const SIGNUP_SENTENCES: Record<SignupMode, string | undefined> = { | ||
| 232 | 232 | closed: "This forge is closed to new accounts.", |
| 233 | 233 | }; |
| 234 | 234 | |
| 235 | /** | |
| 236 | * The linked sign-in methods (the platform edge, the parent forge) are doors | |
| 237 | * for an account that already exists and never make one (#2883). | |
| 238 | */ | |
| 239 | function linkedMethods(edge: string | undefined, next: string | undefined, parent: string | undefined): Html | null { | |
| 240 | if (edge === undefined && parent === undefined) return null; | |
outdated
With this early return, the two |
||
| 241 | return html`<section class="stack mt" id="linked-methods"> | |
| 242 | <h2>Link an account you have</h2> | |
| 243 | <p class="muted small">These sign in an account that already exists and never make one.</p> | |
| 244 | ${when(edge, (name) => html`<p class="muted small">Accounts made through ${name} sign in there and have no password here. The form above is for an account that was given one.</p>`)} | |
| 245 | <div class="stack"> | |
| 246 | ${when(edge, (name) => btn(`Sign in through ${name}`, { href: edgeLoginUrl(next ?? "/"), variant: "outline" }))} | |
| 247 | ${when(parent, (url) => btn(`Sign in through ${new URL(url).host}`, { href: "/signin/parent", variant: "outline" }))} | |
| 248 | </div> | |
| 249 | </section>`; | |
| 250 | } | |
| 251 | ||
| 235 | 252 | function authForm( |
| 236 | 253 | ctx: Ctx, |
| 237 | 254 | kind: "signin" | "signup", |
| @@ -251,32 +268,20 @@ function authForm( | ||
| 251 | 268 | // of the whole user table — and the sign-in screen paints on every session. |
| 252 | 269 | const firstAccount = kind === "signup" && !hasAnyUser(ctx.app.db); |
| 253 | 270 | |
| 254 | // Behind a platform edge, that is the door: the button first, and the | |
| 255 | // password form kept for the accounts that have one — an account the edge | |
| 256 | // made has none, and the note says so before anyone types. | |
| 257 | // A newcomer the edge vouched for is already through it: no button to go | |
| 271 | // A newcomer the edge vouched for is already through it: no door to go | |
| 258 | 272 | // round again, and the name is theirs alone. |
| 259 | 273 | const vouched = edgeStrangerLogin(ctx.request, activeEdgeToken(ctx.app), ctx.auth.user); |
| 260 | 274 | const edge = |
| 261 | 275 | activeEdgeToken(ctx.app) === undefined || vouched !== undefined |
| 262 | 276 | ? undefined |
| 263 | 277 | : (ctx.app.config.edgeName ?? "the platform"); |
| 264 | const throughEdge = when( | |
| 265 | edge, | |
| 266 | (name) => | |
| 267 | html`<div class="stack mb"> | |
| 268 | ${btn(`Continue with ${name}`, { | |
| 269 | href: edgeLoginUrl(next ?? "/"), | |
| 270 | variant: "primary", | |
| 271 | size: "lg", | |
| 272 | })} | |
| 273 | <p class="muted small"> | |
| 274 | ${kind === "signin" | |
| 275 | ? `Accounts made through ${name} sign in there and have no password here. The form below is for an account that was given one.` | |
| 276 | : `Signing in through ${name} does not make an account here; the form below does.`} | |
| 277 | </p> | |
| 278 | </div>`, | |
| 279 | ); | |
| 278 | const unknownLink = | |
| 279 | kind === "signup" && ctx.url.searchParams.get("linked") === "unknown" | |
| 280 | ? html`<p class="muted small mb" id="linked-unknown" role="status"> | |
| 281 | No account here is linked to that sign-in, and a linked sign-in never makes one. | |
| 282 | To get an account, make one with the form below. | |
outdated
High: this sentence promises something that doesn't exist. Settings links only the open-platform.sh edge login ( Minor, not blocking: on a forge whose sign-up is |
||
| 283 | </p>` | |
| 284 | : null; | |
| 280 | 285 | |
| 281 | 286 | // No door makes a password-only account: sign-up is a name, an address and |
| 282 | 287 | // a passkey, made in the browser (`signup-passkey.ts`). |
| @@ -298,7 +303,7 @@ function authForm( | ||
| 298 | 303 | ${when(SIGNUP_SENTENCES[getSignup(ctx.app.db)], (said) => html`<p class="muted small mb" id="signup-mode">${said}</p>`)}`, |
| 299 | 304 | ); |
| 300 | 305 | |
| 301 | const body = html`${throughEdge} | |
| 306 | const body = html`${unknownLink} | |
| 302 | 307 | ${passkeyFirst} |
| 303 | 308 | <form method="post" action="${action}"> |
| 304 | 309 | ${formField({ |
| @@ -404,15 +409,7 @@ function authForm( | ||
| 404 | 409 | kind === "signin", |
| 405 | 410 | () => html`<p class="mt"><a class="small" href="/recover">Lost your device?</a></p>`, |
| 406 | 411 | )} |
outdated
High: this drops the parent door that every existing parent-made account (no password, no passkey) needs to sign in. Pass the parent issuer in as well and render |
||
| 407 | ${when( | |
| 408 | kind === "signin" ? parentIssuerOf(ctx.app.config) : undefined, | |
| 409 | (parent) => html`<p class="mt"> | |
| 410 | ${btn(`Continue with ${new URL(parent).host}`, { | |
| 411 | href: "/signin/parent", | |
| 412 | variant: "outline", | |
| 413 | })} | |
| 414 | </p>`, | |
| 415 | )} | |
| 412 | ${kind === "signin" ? linkedMethods(edge, next, parentIssuerOf(ctx.app.config)) : null} | |
| 416 | 413 | ${claimable |
| 417 | 414 | ? html`<p class="muted small mt" id="claim-door"> |
| 418 | 415 | Was this forge made for you? <a href="/claim">Claim it with your code</a>. |
| @@ -484,8 +481,8 @@ export function signinAllowed(ctx: Ctx, key: string): number | undefined { | ||
| 484 | 481 | |
| 485 | 482 | /** |
| 486 | 483 | * Sign-ups by the socket's peer (`actions/signup-window.ts`), asked by the |
| 487 | * signup form, the API signup, the terminal's create screen, the passkey door | |
| 488 | * and the parent sign-in's first sight of a person, which share one window. | |
| 484 | * signup form, the API signup, the terminal's create screen and | |
| 485 | * the passkey door, which share one window. | |
| 489 | 486 | */ |
| 490 | 487 | export function signupAllowed(ctx: Ctx): number | undefined { |
| 491 | 488 | return signupWindow(ctx.app, clientAddress(ctx.request)); |
test/edge-auth.test.ts +3 −2
| @@ -170,12 +170,13 @@ describe("a forge behind the platform edge", () => { | ||
| 170 | 170 | expect(response.headers.get("location")).toBe("/_plat/login?rd=%2Fnew"); |
| 171 | 171 | }); |
| 172 | 172 | |
| 173 | test("the forge's own sign-in page leads with the edge and says who has no password", async () => { | |
| 173 | test("the forge's own sign-in page offers the edge as a linked method and says who has no password", async () => { | |
| 174 | 174 | const page = await fetch(`${base}/signin?next=%2Fnew`).then((r) => |
| 175 | 175 | r.text(), |
| 176 | 176 | ); |
| 177 | 177 | expect(page).toContain('href="/_plat/login?rd=%2Fnew"'); |
| 178 | expect(page).toContain("Continue with the platform"); | |
| 178 | expect(page).toContain("Link an account you have"); | |
| 179 | expect(page).not.toContain("Continue with"); | |
| 179 | 180 | expect(page).toContain("have no password here"); |
| 180 | 181 | // The password form is still there for an account that was given one. |
| 181 | 182 | expect(page).toContain('name="password"'); |
test/forge-signin-signup.test.ts +22 −20
| @@ -1,8 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | * First sight of a person through the parent is a sign-up: it shares the | |
| 3 | * sign-up window with the passkey door, is refused with the wait when the | |
| 4 | * window is spent, and makes no account. A returning person is a sign-in and | |
| 5 | * is never held to that window. | |
| 2 | * The parent's sign-in makes no account, so it never touches the sign-up | |
| 3 | * window the passkey door spends: a linked person signs in, an unknown one | |
| 4 | * is sent to /signup. | |
| 6 | 5 | */ |
| 7 | 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 8 | 7 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -80,23 +79,26 @@ afterAll(async () => { | ||
| 80 | 79 | for (const dir of dirs) await rm(dir, { recursive: true, force: true }); |
| 81 | 80 | }); |
| 82 | 81 | |
| 83 | test("a new sub past the sign-up limit is refused with the wait and makes no account; a returning sub is not throttled; the passkey door shares the window", async () => { | |
| 84 | expect((await through(cookies.amy!)).status).toBe(303); | |
| 85 | expect(getUserByName(child.db, "amy")).toBeDefined(); | |
| 86 | expect((await passkeySignUp("pk1")).status).toBe(201); | |
| 82 | test("a linked sub signs in and is not throttled; an unknown sub makes no account and spends no sign-up window; the passkey door keeps the window", async () => { | |
| 83 | const amy = await createUser(child.db, { origin: "seed", username: "amy", email: "amy@child.local", password: "a-long-family-passphrase", trusted: true }); | |
| 84 | if (!amy.ok) throw new Error(amy.error); | |
| 85 | const amyParent = getUserByName(parent.db, "amy")!; | |
| 86 | child.db | |
| 87 | .query("INSERT INTO federated_identity (issuer, sub, user_id) VALUES (?1, ?2, ?3)") | |
| 88 | .run(new URL(ps.url).origin, amyParent.id, amy.value.id); | |
| 89 | for (let i = 0; i < 3; i++) { | |
| 90 | const done = await through(cookies.amy!); | |
| 91 | expect(done.status).toBe(303); | |
| 92 | expect(cookieOf(done, "forge_session")).toBeDefined(); | |
| 93 | } | |
| 87 | 94 | |
| 88 | const refused = await through(cookies.ben!); | |
| 89 | expect(refused.status).toBe(429); | |
| 90 | expect(Number(refused.headers.get("retry-after"))).toBeGreaterThan(0); | |
| 91 | expect(await refused.text()).toContain("Try again in"); | |
| 95 | const unknown = await through(cookies.ben!); | |
| 96 | expect(unknown.status).toBe(303); | |
| 97 | expect(unknown.headers.get("location")).toStartWith("/signup"); | |
| 92 | 98 | expect(getUserByName(child.db, "ben")).toBeUndefined(); |
| 93 | 99 | |
| 94 | // Amy is known here: signing in again is not a sign-up. | |
| 95 | const again = await through(cookies.amy!); | |
| 96 | expect(again.status).toBe(303); | |
| 97 | expect(cookieOf(again, "forge_session")).toBeDefined(); | |
| 98 | expect(child.db.query("SELECT COUNT(*) AS n FROM user WHERE username = 'amy'").get()).toEqual({ n: 1 }); | |
| 99 | ||
| 100 | // And the spent window holds the passkey door too. | |
| 101 | expect((await passkeySignUp("pk2")).status).toBe(429); | |
| 100 | // The window is untouched by all of that: two passkey sign-ups, then the wait. | |
| 101 | expect((await passkeySignUp("pk1")).status).toBe(201); | |
| 102 | expect((await passkeySignUp("pk2")).status).toBe(201); | |
| 103 | expect((await passkeySignUp("pk3")).status).toBe(429); | |
| 102 | 104 | }); |
test/forge-signin.test.ts +16 −6
| @@ -93,6 +93,12 @@ describe("sign in through the parent forge", () => { | ||
| 93 | 93 | }); |
| 94 | 94 | if (!made.ok) throw new Error(made.error); |
| 95 | 95 | alice = made.value.id; |
| 96 | // A linked method signs in an account that exists: alice has one here. | |
| 97 | const local = await createUser(child.db, { origin: "seed", username: "alice", email: "[email protected]" }); | |
| 98 | if (!local.ok) throw new Error(local.error); | |
| 99 | child.db | |
| 100 | .query("INSERT INTO federated_identity (issuer, sub, user_id) VALUES (?1, ?2, ?3)") | |
| 101 | .run(new URL(parentServers.url).origin, alice, local.value.id); | |
| 96 | 102 | aliceCookie = `forge_session=${createSession(parent.db, alice).token}`; |
| 97 | 103 | }); |
| 98 | 104 | afterAll(async () => { |
| @@ -103,7 +109,9 @@ describe("sign in through the parent forge", () => { | ||
| 103 | 109 | |
| 104 | 110 | test("only a forge with a parent offers the button", async () => { |
| 105 | 111 | const onChild = await (await fetch(`${childServers.url}/signin`)).text(); |
| 106 | expect(onChild).toContain(`Continue with ${new URL(parentServers.url).host}`); | |
| 112 | expect(onChild).toContain("Link an account you have"); | |
| 113 | expect(onChild).toContain(`Sign in through ${new URL(parentServers.url).host}`); | |
| 114 | expect(onChild).not.toContain("Continue with"); | |
| 107 | 115 | expect(onChild).toContain('href="/signin/parent"'); |
| 108 | 116 | expect(await (await fetch(`${parentServers.url}/signin`)).text()).not.toContain("Continue with"); |
| 109 | 117 | const none = await fetch(`${parentServers.url}/signin/parent`, { redirect: "manual" }); |
| @@ -198,18 +206,20 @@ describe("sign in through the parent forge", () => { | ||
| 198 | 206 | expect(tokens()).toEqual(before); |
| 199 | 207 | }); |
| 200 | 208 | |
| 201 | test("a parent's name that is taken here does not take over the local account", async () => { | |
| 209 | test("a parent's person nobody here holds gets no account, even when their name is taken here", async () => { | |
| 202 | 210 | const bob = await createUser(parent.db, { origin: "seed", username: "bob", email: "[email protected]", password: PASSWORD }); |
| 203 | 211 | if (!bob.ok) throw new Error(bob.error); |
| 204 | 212 | const local = await createUser(child.db, { origin: "seed", username: "bob", email: "[email protected]", password: PASSWORD }); |
| 205 | 213 | if (!local.ok) throw new Error(local.error); |
| 214 | const users = () => child.db.query("SELECT COUNT(*) AS n FROM user").get(); | |
| 215 | const before = users(); | |
| 206 | 216 | const { callback, state } = await begin(`forge_session=${createSession(parent.db, bob.value.id).token}`); |
| 207 | 217 | const done = await finish(callback, state); |
| 208 | 218 | expect(done.status).toBe(303); |
| 209 | const row = child.db | |
| 210 | .query<{ user_id: string }, [string]>("SELECT user_id FROM federated_identity WHERE sub = ?1") | |
| 211 | .get(bob.value.id)!; | |
| 212 | expect(row.user_id).not.toBe(local.value.id); | |
| 219 | expect(done.headers.get("location")).toStartWith("/signup"); | |
| 220 | expect(cookieOf(done, "forge_session")).toBeUndefined(); | |
| 221 | expect(child.db.query("SELECT COUNT(*) AS n FROM federated_identity WHERE sub = ?1").get(bob.value.id)).toEqual({ n: 0 }); | |
| 222 | expect(users()).toEqual(before); | |
| 213 | 223 | }); |
| 214 | 224 | |
| 215 | 225 | test("a callback is refused without its browser's cookie, with another state, or a second time", async () => { |
test/linked-signin.test.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** | |
| 2 | * Linked sign-in methods (#2883) are for an account you already have: the | |
| 3 | * pages say so and never offer to "Continue with" one, and a first sign-in | |
| 4 | * through one for an identity no account holds makes no user and ends on | |
| 5 | * /signup with a sentence on how to get an account. | |
| 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 { createSession } from "../src/core/sessions.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | let dirs: string[] = []; | |
| 20 | let parent: App; | |
| 21 | let child: App; | |
| 22 | let ps: Servers; | |
| 23 | let cs: Servers; | |
| 24 | let stranger = ""; | |
| 25 | ||
| 26 | beforeAll(async () => { | |
| 27 | const make = async () => { | |
| 28 | const dir = await mkdtemp(join(tmpdir(), "forge-linked-")); | |
| 29 | dirs.push(dir); | |
| 30 | return dir; | |
| 31 | }; | |
| 32 | parent = createApp({ dataDir: await make(), port: 0 }); | |
| 33 | ps = await serve(parent); | |
| 34 | child = createApp({ dataDir: await make(), port: 0, parentUrl: ps.url, parentLoopback: true, edgeToken: "edge-secret", edgeName: "the platform" }); | |
| 35 | cs = await serve(child); | |
| 36 | const made = await createUser(parent.db, { origin: "seed", username: "newcomer", email: "[email protected]", password: "a-long-family-passphrase", adminIfFirst: true }); | |
| 37 | if (!made.ok) throw new Error(made.error); | |
| 38 | stranger = `forge_session=${createSession(parent.db, made.value.id).token}`; | |
| 39 | }); | |
| 40 | ||
| 41 | afterAll(async () => { | |
| 42 | ps.stop(); | |
| 43 | cs.stop(); | |
| 44 | for (const dir of dirs) await rm(dir, { recursive: true, force: true }); | |
| 45 | }); | |
| 46 | ||
| 47 | test("no sign-in or sign-up page says Continue with; linked methods sit under their heading", async () => { | |
| 48 | for (const path of ["/signin", "/signup", "/signin?next=%2Fnew"]) { | |
| 49 | const text = await (await fetch(`${cs.url}${path}`, { redirect: "follow" })).text(); | |
| 50 | expect(text).not.toContain("Continue with"); | |
| 51 | } | |
| 52 | const text = await (await fetch(`${cs.url}/signin`)).text(); | |
| 53 | expect(text).toContain("<h2>Link an account you have</h2>"); | |
| 54 | expect(text).toContain("never make one"); | |
| 55 | expect(text).toContain('href="/signin/parent"'); | |
| 56 | expect(text).toContain(`Sign in through ${new URL(ps.url).host}`); | |
| 57 | expect(text).toContain("/_plat/login"); | |
| 58 | }); | |
| 59 | ||
| 60 | test("an unknown linked identity creates no user and ends on /signup saying how to get an account", async () => { | |
| 61 | const users = () => child.db.query("SELECT COUNT(*) AS n FROM user").get(); | |
| 62 | const before = users(); | |
| 63 | const start = await fetch(`${cs.url}/signin/parent`, { redirect: "manual" }); | |
| 64 | const to = new URL(start.headers.get("location")!); | |
| 65 | const consent = await fetch(`${ps.url}/oauth/authorize`, { | |
| 66 | method: "POST", | |
| 67 | redirect: "manual", | |
| 68 | headers: { cookie: stranger, origin: ps.url }, | |
| 69 | body: new URLSearchParams({ ...Object.fromEntries(to.searchParams), decision: "allow" }), | |
| 70 | }); | |
| 71 | const back = new URL(consent.headers.get("location")!); | |
| 72 | const rp = start.headers.getSetCookie().map((c) => c.split(";")[0]!).find((c) => c.startsWith("forge_rp="))!; | |
| 73 | const done = await fetch(`${cs.url}${back.pathname}${back.search}`, { redirect: "manual", headers: { cookie: rp } }); | |
| 74 | expect(done.status).toBe(303); | |
| 75 | expect(done.headers.getSetCookie().some((c) => c.startsWith("forge_session=") && !c.startsWith("forge_session=;"))).toBe(false); | |
| 76 | expect(users()).toEqual(before); | |
| 77 | const landing = done.headers.get("location")!; | |
| 78 | expect(landing.startsWith("/signup")).toBe(true); | |
| 79 | const text = await (await fetch(`${cs.url}${landing}`, { headers: { "x-plat-edge-token": "edge-secret", "x-plat-user": "newcomer" } })).text(); | |
| 80 | expect(text).toContain("make one with the form below"); | |
| 81 | expect(users()).toEqual(before); | |
| 82 | }); | |
| 83 | ||
| 84 | test("a parent identity with an existing link still signs in", async () => { | |
| 85 | const mine = await createUser(child.db, { origin: "seed", username: "linked-me", email: "[email protected]" }); | |
| 86 | if (!mine.ok) throw new Error(mine.error); | |
| 87 | const sub = (parent.db.query("SELECT id FROM user WHERE username = 'newcomer'").get() as { id: string }).id; | |
| 88 | child.db.query("INSERT INTO federated_identity (issuer, sub, user_id) VALUES (?1, ?2, ?3)").run(new URL(ps.url).origin, sub, mine.value.id); | |
| 89 | const start = await fetch(`${cs.url}/signin/parent`, { redirect: "manual" }); | |
| 90 | const to = new URL(start.headers.get("location")!); | |
| 91 | const consent = await fetch(`${ps.url}/oauth/authorize`, { | |
| 92 | method: "POST", | |
| 93 | redirect: "manual", | |
| 94 | headers: { cookie: stranger, origin: ps.url }, | |
| 95 | body: new URLSearchParams({ ...Object.fromEntries(to.searchParams), decision: "allow" }), | |
| 96 | }); | |
| 97 | const back = new URL(consent.headers.get("location")!); | |
| 98 | const rp = start.headers.getSetCookie().map((c) => c.split(";")[0]!).find((c) => c.startsWith("forge_rp="))!; | |
| 99 | const done = await fetch(`${cs.url}${back.pathname}${back.search}`, { redirect: "manual", headers: { cookie: rp } }); | |
| 100 | expect(done.headers.get("location")).toBe("/"); | |
| 101 | expect(done.headers.getSetCookie().some((c) => c.startsWith("forge_session=") && !c.startsWith("forge_session=;"))).toBe(true); | |
| 102 | }); |
test/no-signup-admin.test.ts +1 −1
| @@ -96,7 +96,7 @@ test("the platform edge makes no account, so no admin", async () => { | ||
| 96 | 96 | }); |
| 97 | 97 | |
| 98 | 98 | test("no other origin makes the admin of a forge with none, whatever its door", async () => { |
| 99 | for (const origin of ["signup", "passkey", "parent"] as const) { | |
| 99 | for (const origin of ["signup", "passkey"] as const) { | |
| 100 | 100 | const app = await fresh(); |
| 101 | 101 | const made = await createUser(app.db, { origin, slot: fixtureSlot(app), username: origin, email: `${origin}@f.test` }); |
| 102 | 102 | expect(made.ok && made.value.isAdmin).toBe(false); |
test/signup-gate-parent.test.ts +6 −12
| @@ -71,25 +71,20 @@ afterAll(async () => { | ||
| 71 | 71 | for (const dir of dirs) await rm(dir, { recursive: true, force: true }); |
| 72 | 72 | }); |
| 73 | 73 | |
| 74 | const shut: [SignupMode, string][] = [["closed", "alice"], ["invite", "bea"]]; | |
| 75 | for (const [mode, name] of shut) { | |
| 74 | const modes: [SignupMode, string][] = [["closed", "alice"], ["invite", "bea"], ["open", "cleo"]]; | |
| 75 | for (const [mode, name] of modes) { | |
| 76 | 76 | test(`signup ${mode}: a parent's person new to the child gets no account and no session`, async () => { |
| 77 | 77 | setSignup(child.db, mode, owner); |
| 78 | const before = child.db.query("SELECT COUNT(*) AS n FROM user").get(); | |
| 78 | 79 | const done = await through(name); |
| 79 | expect(done.status).toBe(403); | |
| 80 | expect(done.status).toBe(303); | |
| 81 | expect(done.headers.get("location")).toStartWith("/signup"); | |
| 80 | 82 | expect(cookieOf(done, "forge_session")).toBe(""); |
| 81 | 83 | expect(getUserByName(child.db, name)).toBeUndefined(); |
| 84 | expect(child.db.query("SELECT COUNT(*) AS n FROM user").get()).toEqual(before); | |
| 82 | 85 | }); |
| 83 | 86 | } |
| 84 | 87 | |
| 85 | test("signup open: the same door makes the account", async () => { | |
| 86 | setSignup(child.db, "open", owner); | |
| 87 | const done = await through("cleo"); | |
| 88 | expect(done.status).toBe(303); | |
| 89 | expect(cookieOf(done, "forge_session")).not.toBe(""); | |
| 90 | expect(getUserByName(child.db, "cleo")).toBeDefined(); | |
| 91 | }); | |
| 92 | ||
| 93 | 88 | /** Each place in `src` that makes an account, and the origin it says. */ |
| 94 | 89 | async function callers(): Promise<Record<string, string[]>> { |
| 95 | 90 | const found: Record<string, string[]> = {}; |
| @@ -120,7 +115,6 @@ test("every caller of createUser says where its account comes from", async () => | ||
| 120 | 115 | "src/actions/spawn.ts": ["spawn", "spawn"], |
| 121 | 116 | "src/actions/users.ts": ["signup", "passkey"], |
| 122 | 117 | "src/chaos/world.ts": ["seed"], |
| 123 | "src/core/federation.ts": ["parent"], | |
| 124 | 118 | "src/web/pages/claim.ts": ["claim"], |
| 125 | 119 | "src/web/pages/everyday.ts": ["everyday"], |
| 126 | 120 | }); |
The comment breaks mid-sentence. Reflow it into one sentence.