Public: anyone can read this; the crew can change it.
Passkeys, part 3: the claim link and the everyday account take a passkey, not a password #2024
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/1890-passkeys-part-3-the-claim-link-and-the-e → main 7 commits fork 5 threads · 5 unresolved
README.md +1 −1
| @@ -129,7 +129,7 @@ to `/recover`: give the email on your account and, if it is verified, a link | ||
| 129 | 129 | that lasts 30 minutes and works once registers a new passkey and signs you in. |
| 130 | 130 | The relying party is the address the browser used (or |
| 131 | 131 | `FORGE_URL`), so a passkey does not follow a forge to another hostname. |
| 132 | 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. Two paths still make an account without a passkey and are the next step: the owner claim of a spawned forge (`web/pages/claim.ts`) and an admin's everyday account (`web/pages/everyday.ts`). | |
| 132 | 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. | |
| 133 | 133 | |
| 134 | 134 | Your SSH key is your identity. A key the forge knows signs you straight in; any |
| 135 | 135 | other key browses public repos as a guest and can press `s` to sign in with a |
src/http/server.ts +4 −2
| @@ -212,8 +212,8 @@ import { | ||
| 212 | 212 | composerSubmit, |
| 213 | 213 | tellSubmit, |
| 214 | 214 | } from "../web/studio/actions.ts"; |
| 215 | import { everydayCreate, everydayKeep, everydaySwitch } from "../web/pages/everyday.ts"; | |
| 216 | import { claimFragmentPage, claimSubmit } from "../web/pages/claim.ts"; | |
| 215 | import { everydayCreate, everydayKeep, everydayOptions, everydaySwitch } from "../web/pages/everyday.ts"; | |
| 216 | import { claimFragmentPage, claimOptions, claimSubmit } from "../web/pages/claim.ts"; | |
| 217 | 217 | import { familyPage } from "../web/pages/family.ts"; |
| 218 | 218 | import { forgeLinkSubmit, forgesPage } from "../web/pages/forges.ts"; |
| 219 | 219 | import { newForgePage, newForgeSubmit } from "../web/pages/new-forge.ts"; |
| @@ -593,6 +593,7 @@ export function buildRouter(): Router<Handler> { | ||
| 593 | 593 | // A spawned child's claim link: GET shows, POST takes it (spawn/claim.ts). |
| 594 | 594 | router.add("GET", "/claim", claimFragmentPage); |
| 595 | 595 | router.add("POST", "/claim", claimSubmit); |
| 596 | router.add("POST", "/claim/options", claimOptions); | |
| 596 | 597 | // "+ New forge": the spawn door as a page, any signed-in person (spawn/door.ts). |
| 597 | 598 | router.add("GET", "/forges", forgesPage); |
| 598 | 599 | router.add("GET", "/family", familyPage); |
| @@ -691,6 +692,7 @@ export function buildRouter(): Router<Handler> { | ||
| 691 | 692 | router.add("POST", "/settings/password", passwordUpdate); |
| 692 | 693 | // The everyday account: an admin's daily account, apart from admin (#596). |
| 693 | 694 | router.add("POST", "/settings/everyday", everydayCreate); |
| 695 | router.add("POST", "/settings/everyday/options", everydayOptions); | |
| 694 | 696 | router.add("POST", "/settings/everyday/switch", everydaySwitch); |
| 695 | 697 | router.add("POST", "/settings/everyday/keep", everydayKeep); |
| 696 | 698 | router.add("GET", "/settings/tokens", tokensPage); |
src/web/pages/account.ts +6 −1
| @@ -53,6 +53,7 @@ import { | ||
| 53 | 53 | field as uiField, |
| 54 | 54 | } from "../ui.ts"; |
| 55 | 55 | import { everydayLine } from "./everyday.ts"; |
| 56 | import { passkeyJs } from "../passkey-client.ts"; | |
| 56 | 57 | |
| 57 | 58 | type Section = "profile" | "password" | "tokens" | "keys" | "passkeys"; |
| 58 | 59 | |
| @@ -222,11 +223,15 @@ function profileView(ctx: Ctx, values: ProfileForm): Html { | ||
| 222 | 223 | // GET /settings |
| 223 | 224 | export function accountPage(ctx: Ctx): Response { |
| 224 | 225 | if (!ctx.auth.user) return signInFirst(ctx); |
| 226 | const pair = everydayLine(ctx, ctx.auth.user); | |
| 225 | 227 | return accountShell( |
| 226 | 228 | ctx, |
| 227 | 229 | "profile", |
| 228 | 230 | "Settings", |
| 229 | html`${everydayLine(ctx, ctx.auth.user)}${profileView(ctx, currentProfile(ctx.auth.user))}${notifyView(ctx)}`, | |
| 231 | html`${pair}${profileView(ctx, currentProfile(ctx.auth.user))}${notifyView(ctx)}`, | |
| 232 | 200, | |
| 233 | // The everyday form makes its account with a passkey (#1890). | |
| 234 | pair ? passkeyJs : undefined, | |
| 230 | 235 | ); |
| 231 | 236 | } |
| 232 | 237 |
src/web/pages/claim.ts +66 −29
| @@ -2,8 +2,9 @@ | ||
| 2 | 2 | * `/claim`: the page a spawned child's link opens (`spawn/claim.ts`). |
| 3 | 3 | * The link is `/claim#<token>`, so the secret never leaves the browser in a |
| 4 | 4 | * request line. The path: the person a forge was made for opens the link they were sent, |
| 5 | * reads who will be able to see it, makes their account, and lands in | |
| 6 | * their forge as its admin. The GET only shows the form — a link unfurled | |
| 5 | * reads who will be able to see it, makes their account with a passkey | |
| 6 | * (`passkey-ceremony.ts`; there is no password), and lands in their forge | |
| 7 | * as its admin. The GET only shows the form — a link unfurled | |
| 7 | 8 | * in a chat app must not spend itself — and the POST, same-origin, makes |
| 8 | 9 | * the account and burns the link in one step. A link that is used, expired |
| 9 | 10 | * or made up says which, plainly; a forge that was not spawned has no |
| @@ -15,7 +16,11 @@ | ||
| 15 | 16 | * so before anyone submits, and where to remove it. |
| 16 | 17 | */ |
| 17 | 18 | |
| 18 | import type { Ctx } from "../../http/context.ts"; | |
| 19 | import { crossOrigin, type Ctx } from "../../http/context.ts"; | |
| 20 | import { STRANGER_MAX } from "../../http/limits.ts"; | |
| 21 | import { clientAddress } from "../../http/peer.ts"; | |
| 22 | import { boundedBody, json, str } from "../../http/respond.ts"; | |
| 23 | import { addPasskey } from "../../core/passkeys.ts"; | |
| 19 | 24 | import { |
| 20 | 25 | addTeamMember, |
| 21 | 26 | listTeamMembers, |
| @@ -25,7 +30,7 @@ import { | ||
| 25 | 30 | type Team, |
| 26 | 31 | } from "../../core/orgs.ts"; |
| 27 | 32 | import { createSession } from "../../core/sessions.ts"; |
| 28 | import { createUser, getUserByName, type User } from "../../core/users.ts"; | |
| 33 | import { createUser, discardFreshUser, getUserByName, type User } from "../../core/users.ts"; | |
| 29 | 34 | import { |
| 30 | 35 | burnClaim, |
| 31 | 36 | readClaim, |
| @@ -38,10 +43,11 @@ import { pageHead } from "../chrome.ts"; | ||
| 38 | 43 | import { field, readForm } from "../form.ts"; |
| 39 | 44 | import { html, type Html } from "../html.ts"; |
| 40 | 45 | import { page, redirect } from "../layout.ts"; |
| 46 | import { makeOptions, PASSKEY_WORDS, passkeyFields, readPasskey } from "../passkey-ceremony.ts"; | |
| 47 | import { passkeyJs } from "../passkey-client.ts"; | |
| 41 | 48 | import { btn, card, field as formField } from "../ui.ts"; |
| 42 | import { sessionCookie } from "./site.ts"; | |
| 49 | import { sessionCookie, signinAllowed } from "./site.ts"; | |
| 43 | 50 | |
| 44 | const MIN_PASSWORD = 10; | |
| 45 | 51 | const USERNAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$/; |
| 46 | 52 | |
| 47 | 53 | interface Values { |
| @@ -50,7 +56,7 @@ interface Values { | ||
| 50 | 56 | /** The claim code, from the form's body. */ |
| 51 | 57 | readonly token?: string; |
| 52 | 58 | } |
| 53 | type Errors = Partial<Record<"username" | "email" | "password", string>>; | |
| 59 | type Errors = Partial<Record<"username" | "email" | "passkey", string>>; | |
| 54 | 60 | |
| 55 | 61 | function dead(ctx: Ctx, state: Exclude<ClaimState["state"], "ok">): Response { |
| 56 | 62 | const says = { |
| @@ -164,18 +170,15 @@ function claimForm( | ||
| 164 | 170 | required: true, |
| 165 | 171 | ...(errors.email ? { error: errors.email } : {}), |
| 166 | 172 | })} |
| 167 | ${formField({ | |
| 168 | label: "Password", | |
| 169 | name: "password", | |
| 170 | type: "password", | |
| 171 | autocomplete: "new-password", | |
| 172 | required: true, | |
| 173 | minlength: MIN_PASSWORD, | |
| 174 | hint: "At least 10 characters.", | |
| 175 | ...(errors.password ? { error: errors.password } : {}), | |
| 176 | })} | |
| 173 | ${passkeyFields()} | |
| 174 | <p class="muted small mb"> | |
| 175 | You sign in with a passkey: a fingerprint, face or device PIN. | |
| 176 | There is no password to remember. | |
| 177 | </p> | |
| 178 | <p class="passkey-note mb" id="passkey-make-note" role="alert">${errors.passkey ?? ""}</p> | |
| 179 | <noscript><p class="muted small">Making the passkey needs JavaScript.</p></noscript> | |
| 177 | 180 | <div class="form-actions"> |
| 178 | ${btn("Claim this forge", { variant: "primary" })} | |
| 181 | <button type="submit" id="passkey-make" class="btn" data-variant="primary" data-options="/claim/options">Claim this forge with a passkey</button> | |
| 179 | 182 | </div> |
| 180 | 183 | </form>`, |
| 181 | 184 | )} |
| @@ -210,10 +213,44 @@ export async function claimFragmentPage(ctx: Ctx): Promise<Response> { | ||
| 210 | 213 | ctx, |
| 211 | 214 | title: "This forge is yours", |
| 212 | 215 | body: claimForm(ctx, spawned, owners(ctx, spawned).spawner, {}, {}), |
| 213 | scriptJs: fragmentJs, | |
| 216 | scriptJs: fragmentJs + passkeyJs, | |
| 214 | 217 | }); |
| 215 | 218 | } |
| 216 | 219 | |
| 220 | /** Whether a name and address are fit for the first admin; else the words. */ | |
| 221 | function problems(ctx: Ctx, username: string, email: string): Errors { | |
| 222 | const errors: Errors = {}; | |
| 223 | if (!USERNAME.test(username)) | |
| 224 | errors.username = "Usernames are letters, digits, dots, dashes."; | |
| 225 | else if (getUserByName(ctx.app.db, username)) | |
| 226 | errors.username = "That username is taken."; | |
| 227 | if (!email.includes("@")) errors.email = "An email address, please."; | |
| 228 | return errors; | |
| 229 | } | |
| 230 | ||
| 231 | // POST /claim/options — a passkey challenge, for a live link and a fit name. | |
| 232 | // Nothing is spent: the link burns only when the key comes back. | |
| 233 | export async function claimOptions(ctx: Ctx): Promise<Response> { | |
| 234 | const refuse = (status: number, message: string) => | |
| 235 | json({ error: "refused", message }, status); | |
| 236 | if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden"); | |
| 237 | const wait = signinAllowed(ctx, `claim:${clientAddress(ctx.request)}`); | |
| 238 | if (wait !== undefined) | |
| 239 | return json({ error: "rate_limited", message: `Too many attempts. Try again in ${wait}s.` }, 429, { "retry-after": String(wait) }); | |
| 240 | const data = await boundedBody(ctx.request, STRANGER_MAX); | |
| 241 | if (data === "too_large") return refuse(413, "payload too large"); | |
| 242 | const spawned = await spawnedOrg(ctx.app); | |
| 243 | if (spawned === undefined) return refuse(404, "This is not a claim link."); | |
| 244 | const state = readClaim(ctx.app.db, spawned.org.id, str(data.token)).state; | |
| 245 | if (state !== "ok") | |
| 246 | return refuse(state === "unknown" ? 404 : 410, state === "unknown" ? "This is not a claim link." : `This link is ${state === "owned" ? "already claimed" : state}.`); | |
| 247 | const username = str(data.username).trim(); | |
| 248 | const bad = problems(ctx, username, str(data.email).trim()); | |
| 249 | const first = bad.username ?? bad.email; | |
| 250 | if (first !== undefined) return refuse(422, first); | |
| 251 | return json(makeOptions(ctx, "claim", username)); | |
| 252 | } | |
| 253 | ||
| 217 | 254 | // POST /claim — make the account, burn the link, sign the person in. |
| 218 | 255 | export async function claimSubmit(ctx: Ctx): Promise<Response> { |
| 219 | 256 | const form = await readForm(ctx); |
| @@ -233,25 +270,21 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> { | ||
| 233 | 270 | email: field(form, "email"), |
| 234 | 271 | token, |
| 235 | 272 | }; |
| 236 | const password = field(form, "password"); | |
| 237 | const errors: Errors = {}; | |
| 238 | if (!USERNAME.test(values.username)) | |
| 239 | errors.username = "Usernames are letters, digits, dots, dashes."; | |
| 240 | else if (getUserByName(ctx.app.db, values.username)) | |
| 241 | errors.username = "That username is taken."; | |
| 242 | if (!values.email.includes("@")) errors.email = "An email address, please."; | |
| 243 | if (password.length < MIN_PASSWORD) | |
| 244 | errors.password = "Passwords need at least 10 characters."; | |
| 245 | 273 | const again = (e: Errors) => |
| 246 | 274 | page( |
| 247 | 275 | { |
| 248 | 276 | ctx, |
| 249 | 277 | title: "This forge is yours", |
| 250 | 278 | body: claimForm(ctx, spawned, spawner, values, e), |
| 279 | scriptJs: passkeyJs, | |
| 251 | 280 | }, |
| 252 | 281 | 422, |
| 253 | 282 | ); |
| 283 | const errors = problems(ctx, values.username, values.email); | |
| 254 | 284 | if (Object.keys(errors).length > 0) return again(errors); |
| 285 | // The key, or nothing: a password in the form is not read at all. | |
| 286 | const key = await readPasskey(ctx, "claim", form); | |
| 287 | if (!key.ok) return again({ passkey: PASSKEY_WORDS[key.error] }); | |
| 255 | 288 | |
| 256 | 289 | // Spent first, in one statement: of two people on one link, one wins. |
| 257 | 290 | const spent = burnClaim(ctx.app.db, orgId, token); |
| @@ -259,7 +292,6 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> { | ||
| 259 | 292 | const made = await createUser(ctx.app.db, { |
| 260 | 293 | username: values.username, |
| 261 | 294 | email: values.email, |
| 262 | password, | |
| 263 | 295 | isAdmin: true, |
| 264 | 296 | // The one-time claim link the owner handed out is the proof. |
| 265 | 297 | trusted: true, |
| @@ -274,6 +306,11 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> { | ||
| 274 | 306 | : { username: "That username is taken." }, |
| 275 | 307 | ); |
| 276 | 308 | } |
| 309 | if (!addPasskey(ctx.app.db, made.value.id, "Passkey", key.value).ok) { | |
This rollback (discard the user, unburn the link) has no test. It is the part that keeps a failed claim from spending the owner's only link, so cover it, together with the Low: this rollback ( |
||
| 310 | discardFreshUser(ctx.app.db, made.value.id); | |
| 311 | unburnClaim(ctx.app.db, orgId, token); | |
| 312 | return again({ passkey: PASSKEY_WORDS.taken }); | |
| 313 | } | |
| 277 | 314 | if (team) { |
| 278 | 315 | addTeamMember(ctx.app.db, team, made.value.id); |
| 279 | 316 | // As the page said: unless the spawner kept access, they leave now. |
src/web/pages/everyday.ts +50 −19
| @@ -19,17 +19,20 @@ import { | ||
| 19 | 19 | import { spawnedOrg } from "../../spawn/claim.ts"; |
| 20 | 20 | import { requireVerified } from "../../actions/verify.ts"; |
| 21 | 21 | import { createSession } from "../../core/sessions.ts"; |
| 22 | import { createUser, getUserByName, type User } from "../../core/users.ts"; | |
| 23 | import { cookieValue, type Ctx } from "../../http/context.ts"; | |
| 22 | import { createUser, discardFreshUser, getUserByName, type User } from "../../core/users.ts"; | |
| 23 | import { addPasskey } from "../../core/passkeys.ts"; | |
| 24 | import { cookieValue, crossOrigin, type Ctx } from "../../http/context.ts"; | |
| 25 | import { STRANGER_MAX } from "../../http/limits.ts"; | |
| 26 | import { boundedBody, json, str } from "../../http/respond.ts"; | |
| 24 | 27 | import { signInFirst } from "../chrome.ts"; |
| 25 | 28 | import { field, readForm } from "../form.ts"; |
| 26 | 29 | import { html, type Html } from "../html.ts"; |
| 27 | 30 | import { flashTo, redirect } from "../layout.ts"; |
| 31 | import { makeOptions, PASSKEY_WORDS, passkeyFields, readPasskey } from "../passkey-ceremony.ts"; | |
| 28 | 32 | import { btn, card, field as formField } from "../ui.ts"; |
| 29 | 33 | import { signOutHere } from "../../actions/signins.ts"; |
| 30 | 34 | import { sessionCookie } from "./site.ts"; |
| 31 | 35 | |
| 32 | const MIN_PASSWORD = 10; | |
| 33 | 36 | const USERNAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$/; |
| 34 | 37 | |
| 35 | 38 | /** The everyday name offered first: the admin's, with `-me`, if it is free. */ |
| @@ -100,17 +103,15 @@ function everydayFields(ctx: Ctx, user: User, offerKeep: boolean): Html { | ||
| 100 | 103 | autocomplete: "email", |
| 101 | 104 | required: true, |
| 102 | 105 | })} |
| 103 | ${formField({ | |
| 104 | label: "Password", | |
| 105 | name: "password", | |
| 106 | type: "password", | |
| 107 | autocomplete: "new-password", | |
| 108 | required: true, | |
| 109 | minlength: MIN_PASSWORD, | |
| 110 | hint: "At least 10 characters.", | |
| 111 | })} | |
| 106 | ${passkeyFields()} | |
| 107 | <p class="muted small"> | |
| 108 | It signs in with a passkey of its own: a fingerprint, face or | |
| 109 | device PIN. There is no password. | |
| 110 | </p> | |
| 111 | <p class="passkey-note" id="passkey-make-note" role="alert"></p> | |
| 112 | <noscript><p class="muted small">Making the passkey needs JavaScript.</p></noscript> | |
| 112 | 113 | <div class="form-actions"> |
| 113 | ${btn("Make it", { variant: "primary" })} | |
| 114 | <button type="submit" id="passkey-make" class="btn" data-variant="primary" data-options="/settings/everyday/options">Make it with a passkey</button> | |
| 114 | 115 | <span class="spacer"></span> |
| 115 | 116 | ${offerKeep ? btn("Keep using admin", { |
| 116 | 117 | form: "everyday-keep", |
| @@ -127,6 +128,33 @@ function everydayFields(ctx: Ctx, user: User, offerKeep: boolean): Html { | ||
| 127 | 128 | ></form>`; |
| 128 | 129 | } |
| 129 | 130 | |
| 131 | /** Why `username` cannot be the everyday account's name, or undefined. */ | |
Nit: this check and |
||
| 132 | function nameProblem(ctx: Ctx, username: string): string | undefined { | |
| 133 | if (!USERNAME.test(username)) return "Usernames are letters, digits, dots, dashes."; | |
| 134 | if (getUserByName(ctx.app.db, username)) return "That username is taken."; | |
| 135 | return undefined; | |
| 136 | } | |
| 137 | ||
| 138 | // POST /settings/everyday/options — a passkey challenge for the everyday | |
| 139 | // account, for a signed-in admin without one. Nothing is written. | |
| 140 | export async function everydayOptions(ctx: Ctx): Promise<Response> { | |
| 141 | const refuse = (status: number, message: string) => | |
| 142 | json({ error: "refused", message }, status); | |
| 143 | if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden"); | |
| 144 | const admin = ctx.auth.user; | |
| 145 | if (!admin) return refuse(401, "Sign in first."); | |
| 146 | if (ctx.auth.via !== "session") return refuse(403, "Passkeys are made from a signed-in browser."); | |
| 147 | if (!admin.isAdmin) return refuse(403, "forbidden"); | |
| 148 | if (everydayOf(ctx.app.db, admin) !== undefined) | |
| 149 | return refuse(409, "You already have an everyday account."); | |
| 150 | const data = await boundedBody(ctx.request, STRANGER_MAX); | |
| 151 | if (data === "too_large") return refuse(413, "payload too large"); | |
| 152 | const username = str(data.username).trim(); | |
| 153 | const bad = nameProblem(ctx, username) ?? (str(data.email).includes("@") ? undefined : "An email address, please."); | |
| 154 | if (bad !== undefined) return refuse(422, bad); | |
| 155 | return json(makeOptions(ctx, "everyday", username)); | |
| 156 | } | |
| 157 | ||
| 130 | 158 | // POST /settings/everyday — make the everyday account, link it, sign in as it. |
| 131 | 159 | export async function everydayCreate(ctx: Ctx): Promise<Response> { |
| 132 | 160 | const admin = ctx.auth.user; |
| @@ -141,13 +169,13 @@ export async function everydayCreate(ctx: Ctx): Promise<Response> { | ||
| 141 | 169 | }); |
| 142 | 170 | const username = field(form, "username"); |
| 143 | 171 | const email = field(form, "email"); |
| 144 | const password = field(form, "password"); | |
| 145 | 172 | const say = (message: string) => |
| 146 | 173 | flashTo(ctx, "/", { tone: "danger", message }); |
| 147 | if (!USERNAME.test(username)) | |
| 148 | return say("Usernames are letters, digits, dots, dashes."); | |
| 149 | if (password.length < MIN_PASSWORD) | |
| 150 | return say("Passwords need at least 10 characters."); | |
| 174 | const bad = nameProblem(ctx, username); | |
| 175 | if (bad !== undefined) return say(bad); | |
| 176 | // The key, or nothing: a password in the form is not read at all. | |
| 177 | const key = await readPasskey(ctx, "everyday", form); | |
| 178 | if (!key.ok) return say(PASSKEY_WORDS[key.error]); | |
| 151 | 179 | // The same names a sign-up may not take (a crew member's, a page's, |
| 152 | 180 | // another admin's), held where accounts are made; the admin's own handle |
| 153 | 181 | // may appear in their everyday name ("ada-me"). A proven admin vouches for their own second account, as a claim |
| @@ -155,7 +183,6 @@ export async function everydayCreate(ctx: Ctx): Promise<Response> { | ||
| 155 | 183 | const made = await createUser(ctx.app.db, { |
| 156 | 184 | username, |
| 157 | 185 | email, |
| 158 | password, | |
| 159 | 186 | reservedSelf: admin.id, |
| 160 | 187 | trusted: requireVerified(ctx.app, admin).ok, |
| 161 | 188 | }); |
| @@ -169,6 +196,10 @@ export async function everydayCreate(ctx: Ctx): Promise<Response> { | ||
| 169 | 196 | ? "That name is taken by the forge itself; pick another." |
| 170 | 197 | : "That account could not be made.", |
| 171 | 198 | ); |
| 199 | if (!addPasskey(ctx.app.db, made.value.id, "Passkey", key.value).ok) { | |
| 200 | discardFreshUser(ctx.app.db, made.value.id); | |
| 201 | return say(PASSKEY_WORDS.taken); | |
| 202 | } | |
| 172 | 203 | linkEveryday(ctx.app.db, admin, made.value); |
| 173 | 204 | // On a spawned forge the apps are the client's org's: the everyday |
| 174 | 205 | // account writes to it, or it could not open or tell the crew anything. |
src/web/pages/passkeys.ts +2 −2
| @@ -36,10 +36,10 @@ import { accountShell } from "./account.ts"; | ||
| 36 | 36 | import { safeNext, sessionCookie, signinAllowed } from "./site.ts"; |
| 37 | 37 | |
| 38 | 38 | /** One-time challenges, one store per ceremony per forge. */ |
| 39 | const stores = new WeakMap<App, { add: ReturnType<typeof challenges>; signin: ReturnType<typeof challenges>; recover: ReturnType<typeof challenges>; signup: ReturnType<typeof challenges> }>(); | |
| 39 | const stores = new WeakMap<App, { add: ReturnType<typeof challenges>; signin: ReturnType<typeof challenges>; recover: ReturnType<typeof challenges>; signup: ReturnType<typeof challenges>; claim: ReturnType<typeof challenges>; everyday: ReturnType<typeof challenges> }>(); | |
| 40 | 40 | export function store(app: App) { |
| 41 | 41 | let s = stores.get(app); |
| 42 | if (!s) stores.set(app, (s = { add: challenges(), signin: challenges(), recover: challenges(), signup: challenges() })); | |
| 42 | if (!s) stores.set(app, (s = { add: challenges(), signin: challenges(), recover: challenges(), signup: challenges(), claim: challenges(), everyday: challenges() })); | |
| 43 | 43 | return s; |
| 44 | 44 | } |
| 45 | 45 |
src/web/passkey-ceremony.ts added +75
| @@ -0,0 +1,75 @@ | ||
| 1 | /** | |
| 2 | * The server half of a form that makes an account with a passkey — the | |
| 3 | * claim link (`pages/claim.ts`) and the everyday account | |
| 4 | * (`pages/everyday.ts`). The page's button asks its options route for a | |
| 5 | * challenge (`makeOptions`), the browser makes the key, `passkey-client.ts` | |
| 6 | * writes the answer into the form's hidden fields (`passkeyFields`) and | |
| 7 | * submits it; the page's POST reads it back (`readPasskey`). A form with no | |
| 8 | * key in it — a password, or a browser without script — makes nothing. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { Credential } from "../auth/webauthn.ts"; | |
| 12 | import { newChallenge, verifyRegistration } from "../auth/webauthn.ts"; | |
| 13 | import { findPasskey } from "../core/passkeys.ts"; | |
| 14 | import type { Ctx } from "../http/context.ts"; | |
| 15 | import { field, type Form } from "./form.ts"; | |
| 16 | import { html, type Html } from "./html.ts"; | |
| 17 | import { relying, store } from "./pages/passkeys.ts"; | |
| 18 | ||
| 19 | /** | |
| 20 | * Which form is making the account. Each has its own challenge store, so a | |
| 21 | * challenge one options route issued is spent only at that form's POST. | |
| 22 | */ | |
outdated
Low: |
||
| 23 | export type Ceremony = "claim" | "everyday"; | |
| 24 | ||
| 25 | /** What the browser needs for `navigator.credentials.create`, for `username`. */ | |
| 26 | export function makeOptions(ctx: Ctx, ceremony: Ceremony, username: string) { | |
| 27 | return { | |
| 28 | challenge: store(ctx.app)[ceremony].issue(), | |
| 29 | rpId: relying(ctx).rpId, | |
| 30 | rpName: "forge", | |
| 31 | user: { id: newChallenge(), name: username, displayName: username }, | |
| 32 | exclude: [], | |
| 33 | }; | |
| 34 | } | |
| 35 | ||
| 36 | /** The hidden fields the client script fills before the form posts. */ | |
| 37 | export const passkeyFields = (): Html => | |
| 38 | html`<input type="hidden" name="challenge" value="" /> | |
| 39 | <input type="hidden" name="clientDataJSON" value="" /> | |
| 40 | <input type="hidden" name="attestationObject" value="" />`; | |
| 41 | ||
| 42 | export type PasskeyRefusal = "missing" | "expired" | "rejected" | "taken"; | |
| 43 | ||
| 44 | /** The words for each way a posted key is refused, written for anyone. */ | |
| 45 | export const PASSKEY_WORDS: Record<PasskeyRefusal, string> = { | |
| 46 | missing: "This needs a passkey, and a browser with JavaScript on to make one. Passwords are not taken here.", | |
| 47 | expired: "That request expired. Try again.", | |
| 48 | rejected: "That passkey was not accepted.", | |
| 49 | taken: "That passkey is already registered.", | |
| 50 | }; | |
| 51 | ||
| 52 | /** | |
| 53 | * The verified key a form carries, or why not. The challenge answers once, | |
| 54 | * whatever else is wrong; a key another account holds is refused here, so | |
| 55 | * the caller never makes an account it then cannot give a key. | |
| 56 | */ | |
| 57 | export async function readPasskey( | |
| 58 | ctx: Ctx, | |
| 59 | ceremony: Ceremony, | |
| 60 | form: Form, | |
| 61 | ): Promise<{ ok: true; value: Credential } | { ok: false; error: PasskeyRefusal }> { | |
| 62 | const clientDataJSON = field(form, "clientDataJSON"); | |
| 63 | const attestationObject = field(form, "attestationObject"); | |
| 64 | const challenge = field(form, "challenge"); | |
| 65 | if (clientDataJSON === "" || attestationObject === "" || challenge === "") | |
| 66 | return { ok: false, error: "missing" }; | |
outdated
High (untested): refusing a credential another account holds matters for security, and no test reaches it. Add a test that claims with a device whose key is already registered. Assert a 422 with this message, no user made, and the link unspent. |
||
| 67 | if (!store(ctx.app)[ceremony].consume(challenge)) return { ok: false, error: "expired" }; | |
| 68 | const made = await verifyRegistration( | |
| 69 | { clientDataJSON, attestationObject }, | |
| 70 | { challenge, ...relying(ctx) }, | |
| 71 | ); | |
| 72 | if (!made.ok) return { ok: false, error: "rejected" }; | |
| 73 | if (findPasskey(ctx.app.db, made.id)) return { ok: false, error: "taken" }; | |
| 74 | return { ok: true, value: made }; | |
| 75 | } |
src/web/passkey-client.ts +26 −2
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | * The browser half of passkeys: two small ceremonies, inlined (nonce'd) on | |
| 3 | * the two pages that need them. A page without script still works; it just | |
| 2 | * The browser half of passkeys: small ceremonies, inlined (nonce'd) on the | |
| 3 | * pages that need them. A page without script still works; it just | |
| 4 | 4 | * has no passkey button. Binary travels as base64url, as the server expects. |
| 5 | 5 | */ |
| 6 | 6 | export const passkeyJs = /* js */ ` |
| @@ -120,6 +120,30 @@ export const passkeyJs = /* js */ ` | ||
| 120 | 120 | }); |
| 121 | 121 | }).then(function (r) { location.href = r.next; }); |
| 122 | 122 | }); |
| 123 | // A form that makes an account (the claim link, the everyday account): | |
| 124 | // the key goes into its hidden fields and the form posts as it always did. | |
| 125 | wire('passkey-make', function () { | |
| 126 | var el = doc.getElementById('passkey-make'), form = el.form; | |
| 127 | function val(n) { var f = form.elements[n]; return f ? f.value : ''; } | |
| 128 | if (form.reportValidity && !form.reportValidity()) { el.disabled = false; return Promise.resolve(); } | |
| 129 | var who = { token: val('token'), username: val('username'), email: val('email') }; | |
| 130 | return post(el.getAttribute('data-options'), who).then(function (o) { | |
| 131 | return navigator.credentials.create({ publicKey: { | |
| 132 | challenge: bytes(o.challenge), | |
| 133 | rp: { id: o.rpId, name: o.rpName }, | |
| 134 | user: { id: bytes(o.user.id), name: o.user.name, displayName: o.user.displayName }, | |
| 135 | pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }], | |
| 136 | authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' }, | |
| 137 | attestation: 'none', | |
| 138 | timeout: 120000 | |
| 139 | } }).then(function (cred) { | |
| 140 | form.elements.challenge.value = o.challenge; | |
| 141 | form.elements.clientDataJSON.value = text(cred.response.clientDataJSON); | |
| 142 | form.elements.attestationObject.value = text(cred.response.attestationObject); | |
| 143 | form.submit(); | |
| 144 | }); | |
| 145 | }); | |
| 146 | }); | |
| 123 | 147 | wire('passkey-signin', function () { |
| 124 | 148 | return post('/signin/passkey/options').then(function (o) { |
| 125 | 149 | return navigator.credentials.get({ publicKey: { |
src/web/studio/home.ts +6 −2
| @@ -54,6 +54,7 @@ import { everydayCard } from "../pages/everyday.ts"; | ||
| 54 | 54 | import { nextStepsCard, nextStepsOf } from "../pages/next-steps.ts"; |
| 55 | 55 | import { spawnedOrg } from "../../spawn/claim.ts"; |
| 56 | 56 | import { clientApps } from "./client_home.ts"; |
| 57 | import { passkeyJs } from "../passkey-client.ts"; | |
| 57 | 58 | |
| 58 | 59 | /** The events that can change what is on you. */ |
| 59 | 60 | const ON_YOU_LIVE = |
| @@ -212,10 +213,13 @@ export async function studioHome( | ||
| 212 | 213 | ctx, |
| 213 | 214 | title: "Home", |
| 214 | 215 | body: lean, |
| 216 | // The everyday step makes its account with a passkey (#1890). | |
| 217 | ...(firstSteps ? { scriptJs: passkeyJs } : {}), | |
| 215 | 218 | }); |
| 216 | 219 | } |
| 220 | const ask = firstSteps ?? everydayCard(ctx, user); | |
| 217 | 221 | const body = html`<h1 class="sr-only">Home</h1> |
| 218 | ${firstSteps ?? everydayCard(ctx, user)} ${address} ${liveLine(ctx)} | |
| 222 | ${ask} ${address} ${liveLine(ctx)} | |
| 219 | 223 | ${when( |
| 220 | 224 | steps, |
| 221 | 225 | () => html`<p class="small muted" id="lean-home"> |
| @@ -250,7 +254,7 @@ export async function studioHome( | ||
| 250 | 254 | ctx, |
| 251 | 255 | title: "Home", |
| 252 | 256 | body, |
| 253 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${feedJs}\n${needsYou.scriptJs}`, | |
| 257 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${feedJs}\n${needsYou.scriptJs}${ask ? `\n${passkeyJs}` : ""}`, | |
| 254 | 258 | }); |
| 255 | 259 | } |
| 256 | 260 |
src/web/style/components.ts +2 −1
| @@ -130,7 +130,8 @@ fieldset.field > .check + .check { margin-top: 8px; } | ||
| 130 | 130 | .field-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end; } |
| 131 | 131 | .field-row .field { flex: 1; min-width: 160px; margin-bottom: 0; } |
| 132 | 132 | .form-actions { margin-top: 16px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } |
| 133 | .error-note { | |
| 133 | /* A passkey ceremony's note slot looks like an error only once it says one. */ | |
| 134 | .error-note, .passkey-note:not(:empty) { | |
| 134 | 135 | display: block; color: var(--danger); font-size: 13px; font-weight: 500; |
| 135 | 136 | padding: 8px 12px; margin: 0 0 12px; |
| 136 | 137 | border: 1px solid color-mix(in srgb, var(--danger) 32%, transparent); |
test/claim-link.test.ts +93 −11
| @@ -5,7 +5,8 @@ | ||
| 5 | 5 | * the browser and posts it in the body. Any site admin of the parent can |
| 6 | 6 | * re-issue a link for a child nobody has claimed; the old one dies at once. |
| 7 | 7 | * Through MCP the link stays out of the agent's conversation unless asked |
| 8 | * for there on purpose (#839). | |
| 8 | * for there on purpose (#839). The account a link makes has a passkey and | |
| 9 | * no password (#1890). | |
| 9 | 10 | */ |
| 10 | 11 | |
| 11 | 12 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| @@ -15,6 +16,8 @@ import { join } from "node:path"; | ||
| 15 | 16 | import { createRepo } from "../src/actions/repos.ts"; |
| 16 | 17 | import { childDirOf } from "../src/actions/spawn.ts"; |
| 17 | 18 | import { createApp, type App } from "../src/app.ts"; |
| 19 | import { verifyRegistration } from "../src/auth/webauthn.ts"; | |
| 20 | import { addPasskey, listPasskeys } from "../src/core/passkeys.ts"; | |
| 18 | 21 | import { createToken } from "../src/core/tokens.ts"; |
| 19 | 22 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 20 | 23 | import { commitFiles } from "../src/git/write.ts"; |
| @@ -22,6 +25,7 @@ import { repoDir } from "../src/git/repos.ts"; | ||
| 22 | 25 | import { serve, type Servers } from "../src/http/server.ts"; |
| 23 | 26 | import { firstBoot } from "../src/spawn/first-boot.ts"; |
| 24 | 27 | import { quietLogs } from "./helpers/globals.ts"; |
| 28 | import { device, passkeyFields } from "./helpers/passkey.ts"; | |
| 25 | 29 | |
| 26 | 30 | quietLogs(); |
| 27 | 31 | |
| @@ -54,6 +58,9 @@ describe("the claim link in the fragment, and its re-issue", () => { | ||
| 54 | 58 | body: new URLSearchParams(form), |
| 55 | 59 | redirect: "manual", |
| 56 | 60 | }); |
| 61 | /** What the page does: a passkey from `/claim/options`, then the form. */ | |
| 62 | const claimWithKey = async (form: Record<string, string>) => | |
| 63 | claim({ ...form, ...(await passkeyFields(childServers.url, "/claim/options", form)) }); | |
| 57 | 64 | const tokenOf = (url: string) => url.split("#")[1] ?? ""; |
| 58 | 65 | |
| 59 | 66 | beforeAll(async () => { |
| @@ -146,7 +153,6 @@ describe("the claim link in the fragment, and its re-issue", () => { | ||
| 146 | 153 | body: new URLSearchParams({ |
| 147 | 154 | username: "q", |
| 148 | 155 | email: "[email protected]", |
| 149 | password: PASSWORD, | |
| 150 | 156 | }), |
| 151 | 157 | redirect: "manual", |
| 152 | 158 | }, |
| @@ -174,29 +180,105 @@ describe("the claim link in the fragment, and its re-issue", () => { | ||
| 174 | 180 | token: tokenOf(first), |
| 175 | 181 | username: "old", |
| 176 | 182 | email: "[email protected]", |
| 177 | password: PASSWORD, | |
| 178 | 183 | }); |
| 179 | 184 | expect(old.status).toBe(404); |
| 180 | 185 | expect(getUserByName(child.db, "old")).toBeUndefined(); |
| 181 | 186 | // A reserved name is refused for what it is (#720), and the link is |
| 182 | 187 | // not spent by the refusal. |
| 183 | const reserved = await claim({ | |
| 184 | token: tokenOf(second), | |
| 185 | username: "admin", | |
| 186 | email: "[email protected]", | |
| 187 | password: PASSWORD, | |
| 188 | }); | |
| 188 | const reservedForm = { token: tokenOf(second), username: "admin", email: "[email protected]" }; | |
| 189 | const reservedKey = await passkeyFields(childServers.url, "/claim/options", reservedForm); | |
| 190 | const reserved = await claim({ ...reservedForm, ...reservedKey }); | |
| 189 | 191 | expect(reserved.status).toBe(422); |
| 190 | 192 | expect(await reserved.text()).toContain("That name is reserved."); |
| 191 | 193 | expect(getUserByName(child.db, "admin")).toBeUndefined(); |
| 192 | const ok = await claim({ | |
| 194 | // The same filled form again: its challenge answered once already. | |
| 195 | const replayed = await claim({ ...reservedForm, ...reservedKey }); | |
| 196 | expect(replayed.status).toBe(422); | |
| 197 | expect(await replayed.text()).toContain("That request expired."); | |
| 198 | expect(getUserByName(child.db, "admin")).toBeUndefined(); | |
| 199 | // A device whose key another account on this forge already holds. | |
| 200 | const holder = await createUser(child.db, { trusted: true, username: "holder", email: "[email protected]" }); | |
| 201 | if (!holder.ok) throw new Error(holder.error); | |
| 202 | const shared = await device(childServers.url); | |
| 203 | const held = await passkeyFields(childServers.url, "/claim/options", { token: tokenOf(second), username: "holder-two", email: "[email protected]" }, { device: shared }); | |
| 204 | const cred = await verifyRegistration(held, { challenge: held.challenge, origin: childServers.url, rpId: new URL(childServers.url).hostname }); | |
| 205 | if (!cred.ok) throw new Error("registration"); | |
| 206 | expect(addPasskey(child.db, holder.value.id, "Passkey", cred).ok).toBe(true); | |
| 207 | const takenForm = { token: tokenOf(second), username: "taker", email: "[email protected]" }; | |
| 208 | const taken = await claim({ | |
| 209 | ...takenForm, | |
| 210 | ...(await passkeyFields(childServers.url, "/claim/options", takenForm, { device: shared })), | |
| 211 | }); | |
| 212 | expect(taken.status).toBe(422); | |
| 213 | expect(await taken.text()).toContain("That passkey is already registered."); | |
| 214 | expect(getUserByName(child.db, "taker")).toBeUndefined(); | |
| 215 | expect(listPasskeys(child.db, holder.value.id)).toHaveLength(1); | |
| 216 | // Still unspent: the link gives a challenge yet. | |
| 217 | const live = await fetch(`${childServers.url}/claim/options`, { | |
| 218 | method: "POST", | |
| 219 | headers: { origin: childServers.url, "content-type": "application/json" }, | |
| 220 | body: JSON.stringify(takenForm), | |
| 221 | }); | |
| 222 | expect(live.status).toBe(200); | |
| 223 | // A claim challenge, kept to try at the everyday form once signed in. | |
| 224 | const claimOnly = await passkeyFields(childServers.url, "/claim/options", takenForm); | |
| 225 | // A password and no passkey is refused, and spends nothing. | |
| 226 | const password = await claim({ | |
| 193 | 227 | token: tokenOf(second), |
| 194 | 228 | username: "maria-owner", |
| 195 | 229 | email: "[email protected]", |
| 196 | 230 | password: PASSWORD, |
| 197 | 231 | }); |
| 232 | expect(password.status).toBe(422); | |
| 233 | expect(await password.text()).toContain("Passwords are not taken here."); | |
| 234 | expect(getUserByName(child.db, "maria-owner")).toBeUndefined(); | |
| 235 | const ok = await claimWithKey({ | |
| 236 | token: tokenOf(second), | |
| 237 | username: "maria-owner", | |
| 238 | email: "[email protected]", | |
| 239 | }); | |
| 198 | 240 | expect(ok.status).toBe(303); |
| 199 | expect(getUserByName(child.db, "maria-owner")?.isAdmin).toBe(true); | |
| 241 | const owner = getUserByName(child.db, "maria-owner")!; | |
| 242 | expect(owner.isAdmin).toBe(true); | |
| 243 | expect(listPasskeys(child.db, owner.id)).toHaveLength(1); | |
| 244 | expect( | |
| 245 | (child.db.query("SELECT password_hash AS h FROM user WHERE id = ?1").get(owner.id) as { h: string }).h, | |
| 246 | ).toBe(""); | |
| 247 | // Single-use: the spent link gives no second challenge and no second account. | |
| 248 | const twice = await fetch(`${childServers.url}/claim/options`, { | |
| 249 | method: "POST", | |
| 250 | headers: { origin: childServers.url, "content-type": "application/json" }, | |
| 251 | body: JSON.stringify({ token: tokenOf(second), username: "maria-two", email: "[email protected]" }), | |
| 252 | }); | |
| 253 | expect(twice.status).toBe(410); | |
| 254 | expect(getUserByName(child.db, "maria-two")).toBeUndefined(); | |
| 255 | // A challenge from /claim/options is not spent at the everyday form. | |
| 256 | const cookie = (ok.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 257 | const crossed = await fetch(`${childServers.url}/settings/everyday`, { | |
| 258 | method: "POST", | |
| 259 | headers: { cookie, origin: childServers.url }, | |
| 260 | body: new URLSearchParams({ username: "owner-me", email: "[email protected]", ...claimOnly }), | |
| 261 | redirect: "manual", | |
| 262 | }); | |
| 263 | expect(crossed.status).toBe(303); | |
| 264 | const flash = (crossed.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 265 | const home = await (await fetch(`${childServers.url}/`, { headers: { cookie: `${cookie}; ${flash}` } })).text(); | |
| 266 | expect(home).toContain("That request expired."); | |
| 267 | expect(getUserByName(child.db, "owner-me")).toBeUndefined(); | |
| 268 | }); | |
| 269 | ||
| 270 | test("/claim/options refuses another site, and slows a stream of asks", async () => { | |
| 271 | const ask = (origin: string) => | |
| 272 | fetch(`${childServers.url}/claim/options`, { | |
| 273 | method: "POST", | |
| 274 | headers: { origin, "content-type": "application/json" }, | |
| 275 | body: JSON.stringify({ token: "x", username: "someone", email: "[email protected]" }), | |
| 276 | }); | |
| 277 | expect((await ask("https://evil.test")).status).toBe(403); | |
| 278 | let res = await ask(childServers.url); | |
| 279 | for (let i = 0; i < 100 && res.status !== 429; i++) res = await ask(childServers.url); | |
| 280 | expect(res.status).toBe(429); | |
| 281 | expect(Number(res.headers.get("retry-after"))).toBeGreaterThan(0); | |
| 200 | 282 | }); |
| 201 | 283 | |
| 202 | 284 | test("once claimed, the sign-in page no longer offers a claim", async () => { |
test/client-home.test.ts +3 −4
| @@ -25,6 +25,7 @@ import { createForgeApp } from "../src/tui/forge.ts"; | ||
| 25 | 25 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 26 | 26 | import { quietLogs } from "./helpers/globals.ts"; |
| 27 | 27 | import { settleApp } from "./helpers/tui.ts"; |
| 28 | import { passkeyFields } from "./helpers/passkey.ts"; | |
| 28 | 29 | |
| 29 | 30 | quietLogs(); |
| 30 | 31 | |
| @@ -136,10 +137,8 @@ describe("a client's first screen", () => { | ||
| 136 | 137 | method: "POST", |
| 137 | 138 | headers: { origin: servers.url }, |
| 138 | 139 | body: new URLSearchParams({ |
| 139 | token, | |
| 140 | username: "maria", | |
| 141 | email: "[email protected]", | |
| 142 | password: PASSWORD, | |
| 140 | ...{ token, username: "maria", email: "[email protected]" }, | |
| 141 | ...(await passkeyFields(servers.url, "/claim/options", { token, username: "maria", email: "[email protected]" })), | |
| 143 | 142 | }), |
| 144 | 143 | redirect: "manual", |
| 145 | 144 | }); |
test/client-list.test.ts +4 −4
| @@ -22,6 +22,7 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 22 | 22 | import { useChildFetch } from "../src/spawn/clients.ts"; |
| 23 | 23 | import { firstBoot } from "../src/spawn/first-boot.ts"; |
| 24 | 24 | import { quietLogs } from "./helpers/globals.ts"; |
| 25 | import { passkeyFields } from "./helpers/passkey.ts"; | |
| 25 | 26 | |
| 26 | 27 | quietLogs(); |
| 27 | 28 | |
| @@ -225,14 +226,13 @@ describe("the client list", () => { | ||
| 225 | 226 | }); |
| 226 | 227 | |
| 227 | 228 | test("once claimed, the row says so, and the MCP tool answers the same rows", async () => { |
| 229 | const who = { token: claimUrl.split("#")[1]!, username: "mara", email: "[email protected]" }; | |
| 228 | 230 | const claimed = await fetch(`${childServers.url}/claim`, { |
| 229 | 231 | method: "POST", |
| 230 | 232 | headers: { origin: childServers.url }, |
| 231 | 233 | body: new URLSearchParams({ |
| 232 | token: claimUrl.split("#")[1]!, | |
| 233 | username: "mara", | |
| 234 | email: "[email protected]", | |
| 235 | password: PASSWORD, | |
| 234 | ...who, | |
| 235 | ...(await passkeyFields(childServers.url, "/claim/options", who)), | |
| 236 | 236 | }), |
| 237 | 237 | redirect: "manual", |
| 238 | 238 | }); |
test/everyday.test.ts +42 −17
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The everyday account (#596): a forge's admin is asked once, kindly, to |
| 3 | 3 | * make an account apart from admin for daily work; making it signs them in |
| 4 | * as it, keeping admin is recorded; both account pages name the pair. A | |
| 4 | * as it, keeping admin is recorded; both account pages name the pair. The | |
| 5 | * everyday account is made with a passkey and never a password (#1890). A | |
| 5 | 6 | * token for an admin is minted only on purpose, and an MCP client holding |
| 6 | 7 | * one is told it acts as admin. |
| 7 | 8 | */ |
| @@ -26,9 +27,11 @@ import { | ||
| 26 | 27 | orgAccessByOrg, |
| 27 | 28 | } from "../src/core/orgs.ts"; |
| 28 | 29 | import { createToken } from "../src/core/tokens.ts"; |
| 30 | import { listPasskeys } from "../src/core/passkeys.ts"; | |
| 29 | 31 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 30 | 32 | import { serve, type Servers } from "../src/http/server.ts"; |
| 31 | 33 | import { quietLogs } from "./helpers/globals.ts"; |
| 34 | import { passkeyFields } from "./helpers/passkey.ts"; | |
| 32 | 35 | |
| 33 | 36 | quietLogs(); |
| 34 | 37 | |
| @@ -66,6 +69,14 @@ describe("the everyday account", () => { | ||
| 66 | 69 | body: new URLSearchParams(form), |
| 67 | 70 | redirect: "manual", |
| 68 | 71 | }); |
| 72 | /** What the page does: a passkey from the options route, then the form. */ | |
| 73 | const make = async (cookie: string, form: Record<string, string>) => | |
| 74 | post(cookie, "/settings/everyday", { | |
| 75 | ...form, | |
| 76 | ...(await passkeyFields(servers.url, "/settings/everyday/options", form, { headers: { cookie } })), | |
| 77 | }); | |
| 78 | const hasPassword = (username: string) => | |
| 79 | (app.db.query("SELECT password_hash AS h FROM user WHERE username = ?1").get(username) as { h: string }).h !== ""; | |
| 69 | 80 | |
| 70 | 81 | beforeAll(async () => { |
| 71 | 82 | dataDir = await mkdtemp(join(tmpdir(), "forge-everyday-")); |
| @@ -109,14 +120,19 @@ describe("the everyday account", () => { | ||
| 109 | 120 | const refused = await post(kim, "/settings/everyday", { |
| 110 | 121 | username: "kim-me", |
| 111 | 122 | email: "[email protected]", |
| 112 | password: PASSWORD, | |
| 113 | 123 | }); |
| 114 | 124 | expect(refused.status).toBe(403); |
| 125 | const asked = await fetch(url("/settings/everyday/options"), { | |
| 126 | method: "POST", | |
| 127 | headers: { cookie: kim, origin: servers.url, "content-type": "application/json" }, | |
| 128 | body: JSON.stringify({ username: "kim-me", email: "[email protected]" }), | |
| 129 | }); | |
| 130 | expect(asked.status).toBe(403); | |
| 115 | 131 | const root = await signIn("ada"); |
| 116 | 132 | const crossed = await post( |
| 117 | 133 | root, |
| 118 | 134 | "/settings/everyday", |
| 119 | { username: "x-me", email: "[email protected]", password: PASSWORD }, | |
| 135 | { username: "x-me", email: "[email protected]" }, | |
| 120 | 136 | "https://evil.test", |
| 121 | 137 | ); |
| 122 | 138 | expect(crossed.status).toBe(403); |
| @@ -124,18 +140,24 @@ describe("the everyday account", () => { | ||
| 124 | 140 | expect(getUserByName(app.db, "x-me")).toBeUndefined(); |
| 125 | 141 | }); |
| 126 | 142 | |
| 127 | test("a weak password or a taken name makes nothing", async () => { | |
| 143 | test("a password and no passkey, or a taken name, makes nothing", async () => { | |
| 128 | 144 | const root = await signIn("ada"); |
| 129 | await post(root, "/settings/everyday", { | |
| 145 | const refused = await post(root, "/settings/everyday", { | |
| 130 | 146 | username: "ada-me", |
| 131 | 147 | email: "[email protected]", |
| 132 | password: "short", | |
| 133 | }); | |
| 134 | await post(root, "/settings/everyday", { | |
| 135 | username: "kim", | |
| 136 | email: "[email protected]", | |
| 137 | 148 | password: PASSWORD, |
| 138 | 149 | }); |
| 150 | expect(refused.status).toBe(303); | |
| 151 | const flash = (refused.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 152 | expect(await page(`${root}; ${flash}`, "/")).toContain("Passwords are not taken here."); | |
| 153 | // A taken name is refused before any key is made. | |
| 154 | const asked = await fetch(url("/settings/everyday/options"), { | |
| 155 | method: "POST", | |
| 156 | headers: { cookie: root, origin: servers.url, "content-type": "application/json" }, | |
| 157 | body: JSON.stringify({ username: "kim", email: "[email protected]" }), | |
| 158 | }); | |
| 159 | expect(asked.status).toBe(422); | |
| 160 | expect(await asked.text()).toContain("That username is taken."); | |
| 139 | 161 | expect(getUserByName(app.db, "ada-me")).toBeUndefined(); |
| 140 | 162 | expect(everydayOf(app.db, getUserByName(app.db, "ada")!)).toBeUndefined(); |
| 141 | 163 | }); |
| @@ -143,10 +165,9 @@ describe("the everyday account", () => { | ||
| 143 | 165 | test("a reserved name makes nothing (#720): a page's, or another admin's; the admin's own stays theirs", async () => { |
| 144 | 166 | const root = await signIn("ada"); |
| 145 | 167 | for (const username of ["settings", "bea-me"]) { |
| 146 | const res = await post(root, "/settings/everyday", { | |
| 168 | const res = await make(root, { | |
| 147 | 169 | username, |
| 148 | 170 | email: `${username}@x.test`, |
| 149 | password: PASSWORD, | |
| 150 | 171 | }); |
| 151 | 172 | expect(res.status).toBe(303); |
| 152 | 173 | expect(getUserByName(app.db, username)).toBeUndefined(); |
| @@ -156,10 +177,12 @@ describe("the everyday account", () => { | ||
| 156 | 177 | |
| 157 | 178 | test("Make it: an ordinary account, linked both ways, and the person is signed in as it", async () => { |
| 158 | 179 | const root = await signIn("ada"); |
| 159 | const res = await post(root, "/settings/everyday", { | |
| 180 | const home = await page(root, "/"); | |
| 181 | expect(home).toContain('id="passkey-make"'); | |
| 182 | expect(home).not.toContain('name="password"'); | |
| 183 | const res = await make(root, { | |
| 160 | 184 | username: "ada-me", |
| 161 | 185 | email: "[email protected]", |
| 162 | password: PASSWORD, | |
| 163 | 186 | }); |
| 164 | 187 | expect(res.status).toBe(303); |
| 165 | 188 | expect(res.headers.get("location")).toBe("/"); |
| @@ -167,6 +190,8 @@ describe("the everyday account", () => { | ||
| 167 | 190 | expect(cookie).toContain("forge_session="); |
| 168 | 191 | const daily = getUserByName(app.db, "ada-me")!; |
| 169 | 192 | expect(daily.isAdmin).toBe(false); |
| 193 | expect(listPasskeys(app.db, daily.id)).toHaveLength(1); | |
| 194 | expect(hasPassword("ada-me")).toBe(false); | |
| 170 | 195 | const admin = getUserByName(app.db, "ada")!; |
| 171 | 196 | expect(everydayOf(app.db, admin)?.username).toBe("ada-me"); |
| 172 | 197 | expect(adminOf(app.db, daily)?.username).toBe("ada"); |
| @@ -287,7 +312,7 @@ describe("the everyday account", () => { | ||
| 287 | 312 | |
| 288 | 313 | test("an admin who already has one is shown the way to it: asked again, and on their settings, a sign-in as it", async () => { |
| 289 | 314 | const ada = await signIn("ada"); |
| 290 | const again = await post(ada, "/settings/everyday", { username: "ada-two", email: "[email protected]", password: PASSWORD }); | |
| 315 | const again = await post(ada, "/settings/everyday", { username: "ada-two", email: "[email protected]" }); | |
| 291 | 316 | expect(again.status).toBe(303); |
| 292 | 317 | const flash = (again.headers.get("set-cookie") ?? "").split(";")[0]!; |
| 293 | 318 | const settings = await page(`${ada}; ${flash}`, again.headers.get("location")!); |
| @@ -307,7 +332,7 @@ describe("the everyday account", () => { | ||
| 307 | 332 | |
| 308 | 333 | test("an everyday account may not take a name the forge reserves", async () => { |
| 309 | 334 | const boss = await signIn("bea"); |
| 310 | await post(boss, "/settings/everyday", { username: "settings", email: "[email protected]", password: PASSWORD }); | |
| 335 | await make(boss, { username: "settings", email: "[email protected]" }); | |
| 311 | 336 | expect(getUserByName(app.db, "settings")).toBeUndefined(); |
| 312 | 337 | }); |
| 313 | 338 | |
| @@ -351,7 +376,7 @@ describe("the everyday account", () => { | ||
| 351 | 376 | if (!cyd.ok) throw new Error("cyd"); |
| 352 | 377 | await Bun.write(join(dataDir, "parent.json"), JSON.stringify({ org: "stalls" })); |
| 353 | 378 | const admin = await signIn("cyd"); |
| 354 | const res = await post(admin, "/settings/everyday", { username: "cyd-me", email: "[email protected]", password: PASSWORD }); | |
| 379 | const res = await make(admin, { username: "cyd-me", email: "[email protected]" }); | |
| 355 | 380 | expect(res.status).toBe(303); |
| 356 | 381 | expect(res.headers.get("location")).toContain("/stalls#teams"); |
| 357 | 382 | // No session for the new account: the admin is still the one signed in. |
test/first-login.test.ts +7 −7
| @@ -26,6 +26,7 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 26 | 26 | import { firstBoot } from "../src/spawn/first-boot.ts"; |
| 27 | 27 | import { hostedPlacement } from "../src/spawn/placement.ts"; |
| 28 | 28 | import { quietLogs } from "./helpers/globals.ts"; |
| 29 | import { passkeyFields } from "./helpers/passkey.ts"; | |
| 29 | 30 | |
| 30 | 31 | quietLogs(); |
| 31 | 32 | |
| @@ -143,14 +144,13 @@ describe("the first login to a spawned forge", () => { | ||
| 143 | 144 | servers = await serve(child); |
| 144 | 145 | const link = spawned.value.claimUrl!; |
| 145 | 146 | const token = link.split("/claim#")[1]!; |
| 147 | const who = { token, username: "maria", email: "[email protected]" }; | |
| 146 | 148 | const claimed = await fetch(url("/claim"), { |
| 147 | 149 | method: "POST", |
| 148 | 150 | headers: { origin: servers.url }, |
| 149 | 151 | body: new URLSearchParams({ |
| 150 | token, | |
| 151 | username: "maria", | |
| 152 | email: "[email protected]", | |
| 153 | password: PASSWORD, | |
| 152 | ...who, | |
| 153 | ...(await passkeyFields(servers.url, "/claim/options", who)), | |
| 154 | 154 | }), |
| 155 | 155 | redirect: "manual", |
| 156 | 156 | }); |
| @@ -215,10 +215,10 @@ describe("the first login to a spawned forge", () => { | ||
| 215 | 215 | }); |
| 216 | 216 | |
| 217 | 217 | test("the everyday account, made first, joins the client's org and sees the card part-way, every step one it can do", async () => { |
| 218 | const who = { username: "maria-me", email: "[email protected]" }; | |
| 218 | 219 | const res = await post(owner, "/settings/everyday", { |
| 219 | username: "maria-me", | |
| 220 | email: "me@bakery.test", | |
| 221 | password: PASSWORD, | |
| 220 | ...who, | |
| 221 | ...(await passkeyFields(servers.url, "/settings/everyday/options", who, { headers: { cookie: owner } })), | |
| 222 | 222 | }); |
| 223 | 223 | expect(res.status).toBe(303); |
| 224 | 224 | daily = (res.headers.get("set-cookie") ?? "").split(";")[0]!; |
test/helpers/passkey.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** | |
| 2 | * A software authenticator for tests: a P-256 key that answers the creation | |
| 3 | * and sign-in ceremonies the way a browser's would (attestation "none"), and | |
| 4 | * `passkeyFields`, which runs a page's options route and returns the hidden | |
| 5 | * fields `passkey-client.ts` fills before a form that makes an account posts. | |
| 6 | */ | |
| 7 | import { b64u } from "../../src/auth/webauthn.ts"; | |
| 8 | ||
| 9 | const te = new TextEncoder(); | |
| 10 | const sha = async (d: Uint8Array<ArrayBuffer>) => new Uint8Array(await crypto.subtle.digest("SHA-256", d)); | |
| 11 | const cat = (...p: Uint8Array[]): Uint8Array<ArrayBuffer> => { const o: Uint8Array<ArrayBuffer> = new Uint8Array(p.reduce((n, a) => n + a.length, 0)); let i = 0; for (const a of p) { o.set(a, i); i += a.length; } return o; }; | |
| 12 | function head(major: number, n: number): Uint8Array { | |
| 13 | if (n < 24) return Uint8Array.of((major << 5) | n); | |
| 14 | if (n < 256) return Uint8Array.of((major << 5) | 24, n); | |
| 15 | return Uint8Array.of((major << 5) | 25, n >> 8, n & 255); | |
| 16 | } | |
| 17 | type V = number | string | Uint8Array | [V, V][]; | |
| 18 | function enc(v: V): Uint8Array { | |
| 19 | if (typeof v === "number") return v >= 0 ? head(0, v) : head(1, -1 - v); | |
| 20 | if (typeof v === "string") { const b = te.encode(v); return cat(head(3, b.length), b); } | |
| 21 | if (v instanceof Uint8Array) return cat(head(2, v.length), v); | |
| 22 | return cat(head(5, v.length), ...v.flatMap(([k, x]) => [enc(k), enc(x)])); | |
| 23 | } | |
| 24 | function rawToDer(raw: Uint8Array): Uint8Array<ArrayBuffer> { | |
| 25 | const int = (b: Uint8Array) => { let i = 0; while (i < b.length - 1 && b[i] === 0) i++; b = b.subarray(i); if (b[0]! & 0x80) b = cat(Uint8Array.of(0), b); return cat(Uint8Array.of(2, b.length), b); }; | |
| 26 | const body = cat(int(raw.subarray(0, 32)), int(raw.subarray(32))); | |
| 27 | return cat(Uint8Array.of(0x30, body.length), body); | |
| 28 | } | |
| 29 | ||
| 30 | export type Device = Awaited<ReturnType<typeof device>>; | |
| 31 | ||
| 32 | /** An authenticator bound to one origin, e.g. `http://127.0.0.1:1234`. */ | |
| 33 | export async function device(origin: string, rp = new URL(origin).hostname) { | |
| 34 | const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign"])) as CryptoKeyPair; | |
| 35 | const jwk = await crypto.subtle.exportKey("jwk", pair.publicKey); | |
| 36 | const unb = (s: string) => new Uint8Array(Buffer.from(s, "base64url")); | |
| 37 | const cose: [number, V][] = [[1, 2], [3, -7], [-1, 1], [-2, unb(jwk.x!)], [-3, unb(jwk.y!)]]; | |
| 38 | const id = crypto.getRandomValues(new Uint8Array(32)); | |
| 39 | let count = 0; | |
| 40 | const cd = (type: string, challenge: string) => te.encode(JSON.stringify({ type, challenge, origin })); | |
| 41 | const counter = (n: number) => Uint8Array.of(n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255); | |
| 42 | return { | |
| 43 | credId: b64u(id), | |
| 44 | async create(challenge: string) { | |
| 45 | const authData = cat(await sha(te.encode(rp)), Uint8Array.of(0x45), counter(0), new Uint8Array(16), Uint8Array.of(0, id.length), id, enc(cose as V)); | |
| 46 | return { clientDataJSON: b64u(cd("webauthn.create", challenge)), attestationObject: b64u(enc([["fmt", "none"], ["attStmt", []], ["authData", authData]])) }; | |
| 47 | }, | |
| 48 | async get(challenge: string) { | |
| 49 | const clientData = cd("webauthn.get", challenge); | |
| 50 | const authData = cat(await sha(te.encode(rp)), Uint8Array.of(0x05), counter(++count)); | |
| 51 | const sig = rawToDer(new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, pair.privateKey, cat(authData, await sha(clientData))))); | |
| 52 | return { id: b64u(id), clientDataJSON: b64u(clientData), authenticatorData: b64u(authData), signature: b64u(sig) }; | |
| 53 | }, | |
| 54 | }; | |
| 55 | } | |
| 56 | ||
| 57 | /** | |
| 58 | * What the page's button does: ask `base + optionsPath` for a challenge with | |
| 59 | * `who` (the form's token, username, email), make the key, and return the | |
| 60 | * hidden fields to post with the form. `headers` carries a session cookie | |
| 61 | * where the page needs one. Throws when the options route refuses. | |
| 62 | */ | |
| 63 | export async function passkeyFields( | |
| 64 | base: string, | |
| 65 | optionsPath: string, | |
| 66 | who: Record<string, string>, | |
| 67 | opts: { headers?: Record<string, string>; device?: Device } = {}, | |
| 68 | ): Promise<{ challenge: string; clientDataJSON: string; attestationObject: string }> { | |
| 69 | const origin = new URL(base).origin; | |
| 70 | const res = await fetch(`${base}${optionsPath}`, { | |
| 71 | method: "POST", | |
| 72 | headers: { "content-type": "application/json", origin, ...opts.headers }, | |
| 73 | body: JSON.stringify(who), | |
| 74 | }); | |
| 75 | if (res.status !== 200) throw new Error(`passkey options ${res.status} ${await res.text()}`); | |
| 76 | const { challenge } = (await res.json()) as { challenge: string }; | |
| 77 | const d = opts.device ?? (await device(origin)); | |
| 78 | return { challenge, ...(await d.create(challenge)) }; | |
| 79 | } |
test/signup-passkey.test.ts +1 −46
| @@ -9,56 +9,11 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | import { b64u } from "../src/auth/webauthn.ts"; | |
| 13 | 12 | import { signUp } from "../src/actions/users.ts"; |
| 14 | 13 | import { createUser } from "../src/core/users.ts"; |
| 15 | 14 | import { errorText } from "../src/tui/screens/common.ts"; |
| 16 | 15 | import { serve, type Servers } from "../src/http/server.ts"; |
| 17 | ||
| 18 | const te = new TextEncoder(); | |
| 19 | const sha = async (d: Uint8Array<ArrayBuffer>) => new Uint8Array(await crypto.subtle.digest("SHA-256", d)); | |
| 20 | const cat = (...p: Uint8Array[]): Uint8Array<ArrayBuffer> => { const o: Uint8Array<ArrayBuffer> = new Uint8Array(p.reduce((n, a) => n + a.length, 0)); let i = 0; for (const a of p) { o.set(a, i); i += a.length; } return o; }; | |
| 21 | function head(major: number, n: number): Uint8Array { | |
| 22 | if (n < 24) return Uint8Array.of((major << 5) | n); | |
| 23 | if (n < 256) return Uint8Array.of((major << 5) | 24, n); | |
| 24 | return Uint8Array.of((major << 5) | 25, n >> 8, n & 255); | |
| 25 | } | |
| 26 | type V = number | string | Uint8Array | [V, V][]; | |
| 27 | function enc(v: V): Uint8Array { | |
| 28 | if (typeof v === "number") return v >= 0 ? head(0, v) : head(1, -1 - v); | |
| 29 | if (typeof v === "string") { const b = te.encode(v); return cat(head(3, b.length), b); } | |
| 30 | if (v instanceof Uint8Array) return cat(head(2, v.length), v); | |
| 31 | return cat(head(5, v.length), ...v.flatMap(([k, x]) => [enc(k), enc(x)])); | |
| 32 | } | |
| 33 | function rawToDer(raw: Uint8Array): Uint8Array<ArrayBuffer> { | |
| 34 | const int = (b: Uint8Array) => { let i = 0; while (i < b.length - 1 && b[i] === 0) i++; b = b.subarray(i); if (b[0]! & 0x80) b = cat(Uint8Array.of(0), b); return cat(Uint8Array.of(2, b.length), b); }; | |
| 35 | const body = cat(int(raw.subarray(0, 32)), int(raw.subarray(32))); | |
| 36 | return cat(Uint8Array.of(0x30, body.length), body); | |
| 37 | } | |
| 38 | ||
| 39 | async function device(origin: string, rp: string) { | |
| 40 | const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign"])) as CryptoKeyPair; | |
| 41 | const jwk = await crypto.subtle.exportKey("jwk", pair.publicKey); | |
| 42 | const unb = (s: string) => new Uint8Array(Buffer.from(s, "base64url")); | |
| 43 | const cose: [number, V][] = [[1, 2], [3, -7], [-1, 1], [-2, unb(jwk.x!)], [-3, unb(jwk.y!)]]; | |
| 44 | const id = crypto.getRandomValues(new Uint8Array(32)); | |
| 45 | let count = 0; | |
| 46 | const cd = (type: string, challenge: string) => te.encode(JSON.stringify({ type, challenge, origin })); | |
| 47 | const counter = (n: number) => Uint8Array.of(n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255); | |
| 48 | return { | |
| 49 | credId: b64u(id), | |
| 50 | async create(challenge: string) { | |
| 51 | const authData = cat(await sha(te.encode(rp)), Uint8Array.of(0x45), counter(0), new Uint8Array(16), Uint8Array.of(0, id.length), id, enc(cose as V)); | |
| 52 | return { clientDataJSON: b64u(cd("webauthn.create", challenge)), attestationObject: b64u(enc([["fmt", "none"], ["attStmt", []], ["authData", authData]])) }; | |
| 53 | }, | |
| 54 | async get(challenge: string) { | |
| 55 | const clientData = cd("webauthn.get", challenge); | |
| 56 | const authData = cat(await sha(te.encode(rp)), Uint8Array.of(0x05), counter(++count)); | |
| 57 | const sig = rawToDer(new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, pair.privateKey, cat(authData, await sha(clientData))))); | |
| 58 | return { id: b64u(id), clientDataJSON: b64u(clientData), authenticatorData: b64u(authData), signature: b64u(sig) }; | |
| 59 | }, | |
| 60 | }; | |
| 61 | } | |
| 16 | import { device } from "./helpers/passkey.ts"; | |
| 62 | 17 | |
| 63 | 18 | describe("passkey sign-up", () => { |
| 64 | 19 | let dataDir: string; |
test/spawn-child-apps.test.ts +4 −4
| @@ -28,6 +28,7 @@ import { firstBoot } from "../src/spawn/first-boot.ts"; | ||
| 28 | 28 | import { hostedPlacement } from "../src/spawn/placement.ts"; |
| 29 | 29 | import { quietLogs } from "./helpers/globals.ts"; |
| 30 | 30 | import { emailVerified } from "../src/core/verify.ts"; |
| 31 | import { passkeyFields } from "./helpers/passkey.ts"; | |
| 31 | 32 | |
| 32 | 33 | quietLogs(); |
| 33 | 34 | |
| @@ -131,14 +132,13 @@ describe("a spawned child's apps", () => { | ||
| 131 | 132 | await firstBoot(child); |
| 132 | 133 | servers = await serve(child); |
| 133 | 134 | const token = spawned.value.claimUrl!.split("/claim#")[1]!; |
| 135 | const who = { token, username: "maria", email: "[email protected]" }; | |
| 134 | 136 | const claimed = await fetch(`${servers.url}/claim`, { |
| 135 | 137 | method: "POST", |
| 136 | 138 | headers: { origin: servers.url }, |
| 137 | 139 | body: new URLSearchParams({ |
| 138 | token, | |
| 139 | username: "maria", | |
| 140 | email: "[email protected]", | |
| 141 | password: "a-long-bakery-passphrase", | |
| 140 | ...who, | |
| 141 | ...(await passkeyFields(servers.url, "/claim/options", who)), | |
| 142 | 142 | }), |
| 143 | 143 | redirect: "manual", |
| 144 | 144 | }); |
test/spawn-first-boot.test.ts +12 −7
| @@ -36,6 +36,7 @@ import { firstBoot } from "../src/spawn/first-boot.ts"; | ||
| 36 | 36 | import { hostedPlacement } from "../src/spawn/placement.ts"; |
| 37 | 37 | import type { SpawnSource } from "../src/spawn/plan.ts"; |
| 38 | 38 | import { quietLogs } from "./helpers/globals.ts"; |
| 39 | import { passkeyFields } from "./helpers/passkey.ts"; | |
| 39 | 40 | |
| 40 | 41 | quietLogs(); |
| 41 | 42 | |
| @@ -65,13 +66,15 @@ describe("a spawned child's first boot", () => { | ||
| 65 | 66 | body: new URLSearchParams({ token: t, ...form }), |
| 66 | 67 | redirect: "manual", |
| 67 | 68 | }); |
| 69 | /** What the page does: a passkey from `/claim/options`, then the form. */ | |
| 70 | const claimWithKey = async (t: string, form: Record<string, string>) => | |
| 71 | claim(t, { ...form, ...(await passkeyFields(servers.url, "/claim/options", { token: t, ...form })) }); | |
| 68 | 72 | /** The link is still good: nothing claimed it yet. */ |
| 69 | 73 | const unspent = (t: string) => |
| 70 | 74 | readClaim(child.db, getOrg(child.db, "bakery")!.id, t).state === "ok"; |
| 71 | 75 | const maria = { |
| 72 | 76 | username: "maria", |
| 73 | 77 | email: "[email protected]", |
| 74 | password: "a-long-bakery-passphrase", | |
| 75 | 78 | }; |
| 76 | 79 | |
| 77 | 80 | beforeAll(async () => { |
| @@ -293,15 +296,16 @@ describe("a spawned child's first boot", () => { | ||
| 293 | 296 | expect(unspent(token)).toBe(true); |
| 294 | 297 | }); |
| 295 | 298 | |
| 296 | test("a weak password is refused, and the link is not spent on it", async () => { | |
| 297 | const res = await claim(token, { ...maria, password: "short" }); | |
| 299 | test("a password and no passkey is refused, and the link is not spent on it", async () => { | |
| 300 | const res = await claim(token, { ...maria, password: "a-long-bakery-passphrase" }); | |
| 298 | 301 | expect(res.status).toBe(422); |
| 302 | expect(await res.text()).toContain("Passwords are not taken here."); | |
| 299 | 303 | expect(getUserByName(child.db, "maria")).toBeUndefined(); |
| 300 | 304 | expect(unspent(token)).toBe(true); |
| 301 | 305 | }); |
| 302 | 306 | |
| 303 | 307 | test("claiming makes the person the child's admin and an owner of its organisation, signed in", async () => { |
| 304 | const res = await claim(token, maria); | |
| 308 | const res = await claimWithKey(token, maria); | |
| 305 | 309 | expect(res.status).toBe(303); |
| 306 | 310 | expect(res.headers.get("location")).toBe("/"); |
| 307 | 311 | const cookie = res.headers.get("set-cookie") ?? ""; |
| @@ -411,7 +415,7 @@ describe("claims are bound to a spawned forge, and the link is shown once", () = | ||
| 411 | 415 | const res = await fetch(`${servers.url}/claim`, { |
| 412 | 416 | method: "POST", |
| 413 | 417 | headers: { origin: "https://forge.test" }, |
| 414 | body: new URLSearchParams({ token: crafted, username: "mallory", email: "[email protected]", password: "a-long-mallory-passphrase" }), | |
| 418 | body: new URLSearchParams({ token: crafted, username: "mallory", email: "[email protected]" }), | |
| 415 | 419 | redirect: "manual", |
| 416 | 420 | }); |
| 417 | 421 | expect(res.status).toBe(404); |
| @@ -449,10 +453,11 @@ describe("claims are bound to a spawned forge, and the link is shown once", () = | ||
| 449 | 453 | try { |
| 450 | 454 | const page = (await (await fetch(`${kidServers.url}/claim`)).text()).replace(/\s+/g, " "); |
| 451 | 455 | expect(page).toContain("alice keeps owner access to this forge; you can remove it in your organisation"); |
| 456 | const who = { token, username: "kai-owner", email: "[email protected]" }; | |
| 452 | 457 | const res = await fetch(`${kidServers.url}/claim`, { |
| 453 | 458 | method: "POST", |
| 454 | 459 | headers: { origin: kidServers.url }, |
| 455 | body: new URLSearchParams({ token, username: "kai-owner", email: "[email protected]", password: "a-long-kai-passphrase" }), | |
| 460 | body: new URLSearchParams({ ...who, ...(await passkeyFields(kidServers.url, "/claim/options", who)) }), | |
| 456 | 461 | redirect: "manual", |
| 457 | 462 | }); |
| 458 | 463 | expect(res.status).toBe(303); |
| @@ -465,7 +470,7 @@ describe("claims are bound to a spawned forge, and the link is shown once", () = | ||
| 465 | 470 | const crafted = await fetch(`${kidServers.url}/claim`, { |
| 466 | 471 | method: "POST", |
| 467 | 472 | headers: { origin: kidServers.url }, |
| 468 | body: new URLSearchParams({ token: other, username: "mallory", email: "[email protected]", password: "a-long-mallory-passphrase" }), | |
| 473 | body: new URLSearchParams({ token: other, username: "mallory", email: "[email protected]" }), | |
| 469 | 474 | redirect: "manual", |
| 470 | 475 | }); |
| 471 | 476 | expect(crafted.status).toBe(404); |
test/spawn-keys.test.ts +3 −4
| @@ -36,6 +36,7 @@ import { loadKey, open } from "../src/kernel/secret.ts"; | ||
| 36 | 36 | import { firstBoot } from "../src/spawn/first-boot.ts"; |
| 37 | 37 | import { loadOrCreateHostKey } from "../src/ssh/keys.ts"; |
| 38 | 38 | import { quietLogs } from "./helpers/globals.ts"; |
| 39 | import { passkeyFields } from "./helpers/passkey.ts"; | |
| 39 | 40 | |
| 40 | 41 | quietLogs(); |
| 41 | 42 | |
| @@ -391,10 +392,8 @@ describe("a spawned child's own keys and domain", () => { | ||
| 391 | 392 | method: "POST", |
| 392 | 393 | headers: { origin: childServers.url }, |
| 393 | 394 | body: new URLSearchParams({ |
| 394 | token, | |
| 395 | username: "maria-owner", | |
| 396 | email: "[email protected]", | |
| 397 | password: "a-long-maria-passphrase", | |
| 395 | ...{ token, username: "maria-owner", email: "[email protected]" }, | |
| 396 | ...(await passkeyFields(childServers.url, "/claim/options", { token, username: "maria-owner", email: "[email protected]" })), | |
| 398 | 397 | }), |
| 399 | 398 | redirect: "manual", |
| 400 | 399 | }); |
The rate limit and the cross-origin refusal on this route have no tests. Add one for a 403 from a foreign Origin and one for a 429 with
retry-afteronce the limit is reached.