Invite links must prove the email address before an account is made (#49)
An invite link worked as an unverified claim to its email address: the inviter sees the link, so any active user could invite any address, open the link themselves and register that address, picking up every pending invite for it, and site administration if the address was in BOOTSTRAP_ADMINS. Logins in BOOTSTRAP_ADMINS could be claimed by anyone. - The emailed invite link carries an HMAC proof; without it the invite page offers "Email me the link" (rate limited) and passkey registration refuses. - Joining checks the account made is the one the invite was for. - BOOTSTRAP_ADMINS matches email addresses only. Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo
Canberk Demirci authored · GitHub committed signed1 parent d4d7881commit cb3e5500131863fc480ed3d60160c6a15183f89cBrowse files
9 files changed+128−21
Files (9)
- apps/diffinery/docs/accounts/invitations.md+1−1
- apps/diffinery/docs/admin/site-administration.md+2−2
- apps/diffinery/docs/self-hosting/configuration.md+2−2
- apps/diffinery/docs/self-hosting/deploy.md+1−1
- apps/diffinery/src/auth/invite-proof.ts+26−0
- apps/diffinery/src/lib/directory.ts+28−10
- apps/diffinery/src/web/auth.tsx+44−1
- apps/diffinery/test/e2e/passkeys.test.mjs+23−3
- apps/diffinery/wrangler.jsonc+1−1
modifiedapps/diffinery/docs/accounts/invitations.md+1−1View file
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | |
| 10 | 10 | 1. Someone with permission creates an invite for an email address. The invite can be for the whole instance, for an organization (with an organization role) or for a repository (with a repository role). |
| 11 | 11 | 2. Diffinery emails the invite link to that address, and shows the link to the person who created it so they can also send it another way. |
| 12 | -3. The invited person opens `https://diffinery.com/invite/<token>`, picks a username, creates a passkey and is signed in. | |
| 12 | +3. The invited person opens the link from the invitation email, picks a username, creates a passkey and is signed in. The emailed link carries a proof that they can read mail at that address. The link the inviter sees (`https://diffinery.com/invite/<token>`) doesn't: opening it shows **Confirm your email** with an **Email me the link** button, which sends the link to the invited address again (at most once every 2 minutes and 5 times a day). | |
| 13 | 13 | 4. Their account is active, and every open invite for their email address is accepted at once: they join each organization and repository they were invited to. |
| 14 | 14 | 5. They land on the organization or repository of the invite they opened (or the dashboard, for an instance invite). |
| 15 | 15 |
modifiedapps/diffinery/docs/admin/site-administration.md+2−2View file
| @@ -19,9 +19,9 @@ | ||
| 19 | 19 | |
| 20 | 20 | ### Who becomes a site administrator |
| 21 | 21 | |
| 22 | -The instance setting `BOOTSTRAP_ADMINS` (in `apps/diffinery/wrangler.jsonc` under `vars`) is a comma-separated list of email addresses and usernames. An account becomes a site administrator when: | |
| 22 | +The instance setting `BOOTSTRAP_ADMINS` (in `apps/diffinery/wrangler.jsonc` under `vars`) is a comma-separated list of email addresses. An account becomes a site administrator when: | |
| 23 | 23 | |
| 24 | -- it is created with an email address or username on the list (from an invite, or by verifying the email address through email recovery at `/auth/access`), or | |
| 24 | +- it is created with an email address on the list (from an invite, after opening the link emailed to that address, or by verifying the email address through email recovery at `/auth/access`), or | |
| 25 | 25 | - it was waiting for an invite (pending) and the person verifies an email address on the list at `/auth/access`. |
| 26 | 26 | |
| 27 | 27 | Bootstrap administrators don't need an invite. Adding an address to the list later doesn't promote an account that is already active, and there is no page for granting or removing site administration. See [Configuration](/docs/self-hosting/configuration) for the setting. |
modifiedapps/diffinery/docs/self-hosting/configuration.md+2−2View file
| @@ -23,7 +23,7 @@ | ||
| 23 | 23 | | --- | --- | --- | --- | |
| 24 | 24 | | `PUBLIC_URL` | `https://diffinery.com` | Yes | The canonical origin of the instance. See [PUBLIC_URL](#public_url). On deploy, `scripts/bootstrap.sh` replaces it with `https://$DIFFINERY_DOMAIN` (default `https://diffinery.com`), whatever `wrangler.jsonc` says. | |
| 25 | 25 | | `MAIL_FROM` | `notifications@diffinery.com` | No | Sender address of every email. The display name is "Diffinery", or `<actor> (Diffinery)` for emails about someone's action. On deploy, `scripts/bootstrap.sh` replaces it with `notifications@$MAIL_DOMAIN` (default `notifications@$DIFFINERY_DOMAIN`). When empty, Diffinery sends from `notifications@<hostname of PUBLIC_URL>`. The domain must be onboarded to Email Sending. | |
| 26 | -| `BOOTSTRAP_ADMINS` | A comma-separated list of emails and logins | For the first sign-in | Emails or logins (case-insensitive) that become site administrators. See [BOOTSTRAP_ADMINS](#bootstrap_admins). | | |
| 26 | +| `BOOTSTRAP_ADMINS` | A comma-separated list of emails | For the first sign-in | Emails (case-insensitive) that become site administrators. See [BOOTSTRAP_ADMINS](#bootstrap_admins). | | |
| 27 | 27 | | `ACCESS_TEAM_DOMAIN` | Empty (filled in on deploy) | For email sign-in | Your Cloudflare Zero Trust team domain, like `example.cloudflareaccess.com`. Used to fetch the Access signing keys and to check the token issuer. | |
| 28 | 28 | | `ACCESS_AUD` | Empty (filled in on deploy) | For email sign-in | The audience tag of the Access application that guards `/auth/access`. | |
| 29 | 29 | | `DEV_AUTH` | Not set in production; `dev@diffinery.local` in the `dev` environment | Never in production | Local development only. See [DEV_AUTH](#dev_auth). | |
| @@ -45,7 +45,7 @@ | ||
| 45 | 45 | |
| 46 | 46 | ### BOOTSTRAP_ADMINS |
| 47 | 47 | |
| 48 | -A comma-separated list; spaces around entries are ignored and matching is case-insensitive. An entry can be an email address or a login. | |
| 48 | +A comma-separated list of email addresses; spaces around entries are ignored and matching is case-insensitive. Logins in the list are ignored, because anyone could pick a free login, while an email address has to be proven (by email sign-in or by opening the link in an invitation email). | |
| 49 | 49 | |
| 50 | 50 | An account becomes a site administrator from this list only when it is created (through email sign-in at `/auth/access`, by joining from an invitation, or through `DEV_AUTH` locally), or when an account that is still waiting for an invite signs in through `/auth/access`. Accounts that already exist are not promoted or demoted when you change the list later, and there is no page for granting or removing site administration. See [Site administration](/docs/admin/site-administration). |
| 51 | 51 |
modifiedapps/diffinery/docs/self-hosting/deploy.md+1−1View file
| @@ -51,7 +51,7 @@ | ||
| 51 | 51 | } |
| 52 | 52 | ``` |
| 53 | 53 | |
| 54 | -- `BOOTSTRAP_ADMINS`: comma-separated emails (or logins) of the people who become site administrators when they first sign in. Replace the existing list with your own. | |
| 54 | +- `BOOTSTRAP_ADMINS`: comma-separated emails of the people who become site administrators when they first sign in. Replace the existing list with your own. | |
| 55 | 55 | - Leave `ACCESS_TEAM_DOMAIN` and `ACCESS_AUD` empty; bootstrap fills them in. |
| 56 | 56 | - `routes`, `PUBLIC_URL` and `MAIL_FROM` can stay as they are. The deployed config gets a route for `<DIFFINERY_DOMAIN>/*`, `PUBLIC_URL` set to `https://<DIFFINERY_DOMAIN>` and `MAIL_FROM` set to `notifications@<MAIL_DOMAIN or DIFFINERY_DOMAIN>`. You can still edit them to match, so that the file reads correctly, but the deploy doesn't use their values. |
| 57 | 57 |
addedapps/diffinery/src/auth/invite-proof.ts+26−0View file
| @@ -1,0 +1,26 @@ | ||
| 1 | +import type { Env } from "../env.js"; | |
| 2 | +import { b64url, safeEqual } from "../lib/util.js"; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Proof that someone can read the invited address's email. The invite link shown to the | |
| 6 | + * person who sent it creates nothing on its own: only the copy emailed to the invited | |
| 7 | + * address carries this proof, so nobody can join as an address they don't own (and so | |
| 8 | + * collect its other invitations, or a bootstrap administrator's rights). | |
| 9 | + * The key is derived from MASTER_KEY, so emailed links stay valid across deploys. | |
| 10 | + */ | |
| 11 | +async function key(env: Env): Promise<CryptoKey> { | |
| 12 | + // Local development has no MASTER_KEY unless one is passed; production always does. | |
| 13 | + const secret = env.MASTER_KEY ?? (env.DEV_AUTH ? "diffinery-dev-invite-proof" : null); | |
| 14 | + if (!secret) throw new Error("MASTER_KEY is not set, so invitation emails can't be signed"); | |
| 15 | + return crypto.subtle.importKey("raw", new TextEncoder().encode(`diffinery-invite-proof-v1:${secret}`), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); | |
| 16 | +} | |
| 17 | + | |
| 18 | +/** The proof for the invite whose token hashes to `tokenHash`. */ | |
| 19 | +export async function inviteProof(env: Env, tokenHash: string): Promise<string> { | |
| 20 | + return b64url(new Uint8Array(await crypto.subtle.sign("HMAC", await key(env), new TextEncoder().encode(tokenHash)))); | |
| 21 | +} | |
| 22 | + | |
| 23 | +export async function validInviteProof(env: Env, tokenHash: string, proof: string | undefined | null): Promise<boolean> { | |
| 24 | + if (!proof || proof.length > 100) return false; | |
| 25 | + return safeEqual(proof, await inviteProof(env, tokenHash)); | |
| 26 | +} |
modifiedapps/diffinery/src/lib/directory.ts+28−10View file
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | import { mailForAudit, tokenCreated } from "../mail/notify.js"; |
| 6 | 6 | import { enqueueMail } from "../mail/send.js"; |
| 7 | 7 | import { archivePrefix, deletePrefixes } from "./storage-keys.js"; |
| 8 | +import { inviteProof } from "../auth/invite-proof.js"; | |
| 8 | 9 | |
| 9 | 10 | export interface Account { |
| 10 | 11 | id: string; |
| @@ -83,9 +84,13 @@ | ||
| 83 | 84 | return this.db.prepare("SELECT * FROM accounts WHERE email = ? AND kind = 'user'").bind(email).first<Account>(); |
| 84 | 85 | } |
| 85 | 86 | |
| 86 | - private isBootstrapAdmin(email: string, login?: string): boolean { | |
| 87 | + /** | |
| 88 | + * Only a verified email address makes a bootstrap administrator. Logins on the list are | |
| 89 | + * ignored: whoever creates an account picks its login, so a login proves nothing. | |
| 90 | + */ | |
| 91 | + private isBootstrapAdmin(email: string): boolean { | |
| 87 | 92 | const list = (this.env.BOOTSTRAP_ADMINS ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean); |
| 88 | - return list.includes(email.toLowerCase()) || (!!login && list.includes(login.toLowerCase())); | |
| 93 | + return list.includes(email.toLowerCase()); | |
| 89 | 94 | } |
| 90 | 95 | |
| 91 | 96 | private async uniqueLogin(base: string): Promise<string> { |
| @@ -102,13 +107,13 @@ | ||
| 102 | 107 | async userForEmail(email: string, hints: { name?: string; login?: string; id?: string } = {}): Promise<Account> { |
| 103 | 108 | const existing = await this.accountByEmail(email); |
| 104 | 109 | if (existing) { |
| 105 | - if (existing.status === "pending" && this.isBootstrapAdmin(email, existing.login)) { | |
| 110 | + if (existing.status === "pending" && this.isBootstrapAdmin(email)) { | |
| 106 | 111 | await this.db.prepare("UPDATE accounts SET status = 'active', site_admin = 1 WHERE id = ?").bind(existing.id).run(); |
| 107 | 112 | return { ...existing, status: "active", site_admin: 1 }; |
| 108 | 113 | } |
| 109 | 114 | return existing; |
| 110 | 115 | } |
| 111 | - const admin = this.isBootstrapAdmin(email, hints.login); | |
| 116 | + const admin = this.isBootstrapAdmin(email); | |
| 112 | 117 | const login = await this.uniqueLogin(hints.login ?? email.split("@")[0]!); |
| 113 | 118 | const id = hints.id ?? newId(); |
| 114 | 119 | const hasInvite = await this.db.prepare("SELECT 1 FROM invites WHERE email = ? AND accepted_at IS NULL AND expires_at > ?").bind(email, now()).first(); |
| @@ -426,18 +431,31 @@ | ||
| 426 | 431 | // If the person already exists, activate them now so the invite also works from the dashboard. |
| 427 | 432 | if (existing?.status === "pending") await this.setStatus(existing.id, "active"); |
| 428 | 433 | const url = `${this.env.PUBLIC_URL}/invite/${token}`; |
| 429 | - const org = input.orgId ? await this.account(input.orgId) : null; | |
| 430 | - const repo = input.repoId ? await this.repoById(input.repoId) : null; | |
| 434 | + await this.mailInvite({ id, email: input.email, org_id: input.orgId ?? null, repo_id: input.repoId ?? null, role: input.role, invited_by: inviter.id, expires_at: new Date(Date.now() + 14 * 86400_000).toISOString() }, token); | |
| 435 | + return { id, url, token }; | |
| 436 | + } | |
| 437 | + | |
| 438 | + /** | |
| 439 | + * Email an invitation to its address. Only this emailed link carries the proof that lets | |
| 440 | + * someone create an account for the address (auth/invite-proof.ts); the plain link shown to | |
| 441 | + * the inviter leads to a page that sends this email. | |
| 442 | + */ | |
| 443 | + async mailInvite(inv: { id: string; email: string; org_id: string | null; repo_id: string | null; role: string; invited_by: string | null; expires_at: string }, token: string): Promise<void> { | |
| 444 | + const proof = await inviteProof(this.env, await sha256(token)); | |
| 445 | + const url = `${this.env.PUBLIC_URL}/invite/${token}?proof=${proof}`; | |
| 446 | + const inviter = inv.invited_by ? await this.account(inv.invited_by) : null; | |
| 447 | + const org = inv.org_id ? await this.account(inv.org_id) : null; | |
| 448 | + const repo = inv.repo_id ? await this.repoById(inv.repo_id) : null; | |
| 449 | + const existing = await this.accountByEmail(inv.email); | |
| 431 | 450 | await enqueueMail(this.env, [ |
| 432 | 451 | { |
| 433 | 452 | kind: "invite", |
| 434 | - data: { inviter: inviter.login, url, org: org?.login, repo: repo ? `${repo.owner_login}/${repo.name}` : undefined, role: input.role, expiresAt: new Date(Date.now() + 14 * 86400_000).toISOString() }, | |
| 435 | - to: existing ? { accountId: existing.id } : { email: input.email }, | |
| 453 | + data: { inviter: inviter?.login ?? "diffinery", url, org: org?.login, repo: repo ? `${repo.owner_login}/${repo.name}` : undefined, role: inv.role, expiresAt: inv.expires_at }, | |
| 454 | + to: existing ? { accountId: existing.id } : { email: inv.email }, | |
| 436 | 455 | reason: "invitation", |
| 437 | 456 | at: now(), |
| 438 | 457 | }, |
| 439 | 458 | ]); |
| 440 | - return { id, url, token }; | |
| 441 | 459 | } |
| 442 | 460 | |
| 443 | 461 | async invites(filter: { orgId?: string; repoId?: string; site?: boolean; pending?: boolean }): Promise<Array<{ id: string; email: string; role: string; createdAt: string; expiresAt: string; acceptedAt: string | null; invitedBy: string | null }>> { |
| @@ -453,7 +471,7 @@ | ||
| 453 | 471 | async acceptInvite(token: string, user: Account): Promise<{ orgId: string | null; repoId: string | null }> { |
| 454 | 472 | const inv = await this.db.prepare("SELECT * FROM invites WHERE token_hash = ?").bind(await sha256(token)).first<Record<string, string | null>>(); |
| 455 | 473 | if (!inv || inv.accepted_at || inv.expires_at! < now()) throw new HttpError(404, "this invite is invalid or has expired"); |
| 456 | - if (inv.email && user.email && inv.email.toLowerCase() !== user.email.toLowerCase()) throw new HttpError(403, `this invite was sent to ${inv.email}; sign in with that address`); | |
| 474 | + if (inv.email && inv.email.toLowerCase() !== (user.email ?? "").toLowerCase()) throw new HttpError(403, `this invite was sent to ${inv.email}; sign in with that address`); | |
| 457 | 475 | await this.applyInvite(inv, user); |
| 458 | 476 | return { orgId: inv.org_id ?? null, repoId: inv.repo_id ?? null }; |
| 459 | 477 | } |
modifiedapps/diffinery/src/web/auth.tsx+44−1View file
| @@ -17,7 +17,9 @@ | ||
| 17 | 17 | import { verifyAccessJwt } from "../lib/jwt.js"; |
| 18 | 18 | import { HttpError, b64url, fromB64url, isValidLogin, newId } from "../lib/util.js"; |
| 19 | 19 | import { ICONS } from "./assets.js"; |
| 20 | -import { flash, post, prm, render, statusOf, type WebApp } from "./ui.js"; | |
| 20 | +import { Form, act, flash, post, prm, render, statusOf, type WebApp } from "./ui.js"; | |
| 21 | +import { csrfToken } from "./csrf.js"; | |
| 22 | +import { validInviteProof } from "../auth/invite-proof.js"; | |
| 21 | 23 | |
| 22 | 24 | /** |
| 23 | 25 | * Passkey (WebAuthn) sign-in. Browsers sign in with a passkey and get a |
| @@ -146,6 +148,22 @@ | ||
| 146 | 148 | </AuthCard>, |
| 147 | 149 | ); |
| 148 | 150 | } |
| 151 | + // The link alone isn't enough to create the account: the copy emailed to the address carries a proof. | |
| 152 | + const proof = c.req.query("proof"); | |
| 153 | + if (!(await validInviteProof(c.env, inv.hash, proof))) { | |
| 154 | + return render( | |
| 155 | + c, | |
| 156 | + { title: "Join Diffinery" }, | |
| 157 | + <AuthCard title="Confirm your email"> | |
| 158 | + <p class="muted"> | |
| 159 | + This invitation is for <b>{inv.email}</b>. To make sure it's you, open the link in the invitation email sent to that address. If you can't find it, we can send it again. | |
| 160 | + </p> | |
| 161 | + <Form action={`/invite/${encodeURIComponent(token)}/email`} t={csrfToken(c)}> | |
| 162 | + <button class="btn primary block">Email me the link</button> | |
| 163 | + </Form> | |
| 164 | + </AuthCard>, | |
| 165 | + ); | |
| 166 | + } | |
| 149 | 167 | return render( |
| 150 | 168 | c, |
| 151 | 169 | { title: "Join Diffinery" }, |
| @@ -155,6 +173,7 @@ | ||
| 155 | 173 | </p> |
| 156 | 174 | <form data-passkey="register" data-next="/"> |
| 157 | 175 | <input type="hidden" name="invite" value={token} /> |
| 176 | + <input type="hidden" name="proof" value={proof} /> | |
| 158 | 177 | <div class="field"> |
| 159 | 178 | <label for="login">Username</label> |
| 160 | 179 | <input id="login" name="login" type="text" value={loginFromEmail(inv.email)} required pattern="[A-Za-z0-9][A-Za-z0-9_\-]{0,38}" autocomplete="username" /> |
| @@ -288,6 +307,7 @@ | ||
| 288 | 307 | const inv = await auth.invite(inviteToken); |
| 289 | 308 | if (!inv) throw new HttpError(404, "This invite is invalid, already used, or expired."); |
| 290 | 309 | if (!inv.email) throw new HttpError(422, "This invite has no email address. Sign in first, then open it again."); |
| 310 | + if (!(await validInviteProof(c.env, inv.hash, str(body.proof)))) throw new HttpError(403, `Open the link in the invitation email sent to ${inv.email} to create your account.`); | |
| 291 | 311 | if (await dir.accountByEmail(inv.email)) throw new HttpError(409, `${inv.email} already has an account. Sign in with its passkey, then open this invite again.`); |
| 292 | 312 | const login = str(body.login).trim() || loginFromEmail(inv.email); |
| 293 | 313 | if (!isValidLogin(login) || login.length > 39) throw new HttpError(422, "Usernames use letters, numbers and dashes (up to 39), and some names are reserved."); |
| @@ -366,6 +386,8 @@ | ||
| 366 | 386 | if (await dir.accountByLogin(s.login)) throw new HttpError(409, `The username ${s.login} was just taken. Pick another one.`); |
| 367 | 387 | // Creating the account accepts every open invite for this email, including this one. |
| 368 | 388 | account = await dir.userForEmail(s.email, { login: s.login, name: s.name || undefined, id: s.accountId }); |
| 389 | + // Two registrations racing for one invite: only the one that created the account gets a passkey on it. | |
| 390 | + if (account.id !== s.accountId) throw new HttpError(409, `${s.email} already has an account.`); | |
| 369 | 391 | await auth.addPasskey({ ...key, account_id: account.id }); |
| 370 | 392 | c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "invite.accept", detail: { method: "passkey", passkeyId: key.id, ...signinContext(c) } })); |
| 371 | 393 | flash(c, "notice", `Welcome to Diffinery, ${account.login}. You can sign in with this passkey from now on.`); |
| @@ -389,6 +411,27 @@ | ||
| 389 | 411 | return c.json({ redirect }); |
| 390 | 412 | }); |
| 391 | 413 | |
| 414 | + // ------------------------------------------------------------ invitations: resend the email that proves the address | |
| 415 | + | |
| 416 | + post(web, "/invite/:token/email", async (c) => { | |
| 417 | + const token = prm(c, "token"); | |
| 418 | + const back = `/invite/${encodeURIComponent(token)}`; | |
| 419 | + const inv = await new Auth(c.env).invite(token); | |
| 420 | + if (!inv?.email) throw new HttpError(404, "this invite is invalid, already used, or expired"); | |
| 421 | + return act(c, back, async () => { | |
| 422 | + // At most one email every 2 minutes and 5 a day for an address, counting the first one. | |
| 423 | + const since = (ms: number) => new Date(Date.now() - ms).toISOString(); | |
| 424 | + const sent = await c.env.DB.prepare("SELECT sum(created_at > ?) AS recent, count(*) AS day FROM email_log WHERE kind = 'invite' AND lower(to_addr) = lower(?) AND created_at > ?") | |
| 425 | + .bind(since(120_000), inv.email, since(86400_000)) | |
| 426 | + .first<{ recent: number | null; day: number }>(); | |
| 427 | + if ((sent?.recent ?? 0) > 0) throw new HttpError(429, `We emailed ${inv.email} a moment ago. Check that inbox (and its spam folder) for the link.`); | |
| 428 | + if ((sent?.day ?? 0) >= 5) throw new HttpError(429, `We have emailed ${inv.email} several times today. Ask the person who invited you to send a new invitation.`); | |
| 429 | + const row = await c.env.DB.prepare("SELECT id, email, org_id, repo_id, role, invited_by, expires_at FROM invites WHERE id = ?").bind(inv.id).first<{ id: string; email: string; org_id: string | null; repo_id: string | null; role: string; invited_by: string | null; expires_at: string }>(); | |
| 430 | + if (!row) throw new HttpError(404, "this invite is invalid, already used, or expired"); | |
| 431 | + await c.get("dir").mailInvite(row, token); | |
| 432 | + }, `We emailed the link to ${inv.email}. Open it from that inbox to create your account.`); | |
| 433 | + }); | |
| 434 | + | |
| 392 | 435 | // ------------------------------------------------------------ sign out |
| 393 | 436 | |
| 394 | 437 | post(web, "/auth/logout", async (c) => { |
modifiedapps/diffinery/test/e2e/passkeys.test.mjs+23−3View file
| @@ -4,11 +4,12 @@ | ||
| 4 | 4 | // pnpm --filter @diffinery/app e2e |
| 5 | 5 | // |
| 6 | 6 | // Covers: signed-out redirects, joining from an invite link with a new passkey |
| 7 | -// (which also applies the invite's org membership), adding and removing | |
| 7 | +// (only from the emailed copy, which proves the address; the invite also applies | |
| 8 | +// its org membership), adding and removing | |
| 8 | 9 | // passkeys, signing out and back in, admin-issued one-time sign-in links, |
| 9 | 10 | // single-use invites and links, and refusing cross-site and token-driven calls. |
| 10 | 11 | import { spawn, execFileSync } from "node:child_process"; |
| 11 | -import { createHash } from "node:crypto"; | |
| 12 | +import { createHash, createHmac, randomBytes } from "node:crypto"; | |
| 12 | 13 | import { mkdtempSync, rmSync } from "node:fs"; |
| 13 | 14 | import { tmpdir } from "node:os"; |
| 14 | 15 | import { join, dirname, resolve } from "node:path"; |
| @@ -28,6 +29,9 @@ | ||
| 28 | 29 | const INVITE = "invite-token-for-pal-0123456789abcdef"; |
| 29 | 30 | const ADMIN_LINK = "signin-link-for-boss-0123456789abcdef"; |
| 30 | 31 | const hash = (s) => createHash("sha256").update(s).digest("hex"); |
| 32 | +const MASTER_KEY = randomBytes(32).toString("base64"); | |
| 33 | +/** The proof only the invitation email carries (src/auth/invite-proof.ts). */ | |
| 34 | +const proofFor = (token) => createHmac("sha256", `diffinery-invite-proof-v1:${MASTER_KEY}`).update(hash(token)).digest("base64url"); | |
| 31 | 35 | |
| 32 | 36 | const freePort = () => |
| 33 | 37 | new Promise((res) => { |
| @@ -100,7 +104,7 @@ | ||
| 100 | 104 | ); |
| 101 | 105 | dev = spawn( |
| 102 | 106 | "pnpm", |
| 103 | - ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--show-interactive-dev-session=false"], | |
| 107 | + ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--var", `MASTER_KEY:${MASTER_KEY}`, "--var", "BOOTSTRAP_ADMINS:pal", "--show-interactive-dev-session=false"], | |
| 104 | 108 | { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true }, |
| 105 | 109 | ); |
| 106 | 110 | dev.stdout.on("data", (d) => (devLog += d)); |
| @@ -144,7 +148,20 @@ | ||
| 144 | 148 | assert.match(await page.textContent("[data-passkey-msg]"), /passkey|cancelled|closed/i); |
| 145 | 149 | |
| 146 | 150 | // ------------------------------------------------------------ join from an invite link |
| 151 | + // The bare link (what the inviter sees) creates nothing: it offers to email the address. | |
| 147 | 152 | await page.goto(`${base}/invite/${INVITE}`); |
| 153 | + assert.match(await text(page), /Confirm your email/); | |
| 154 | + assert.equal(await page.locator("#login").count(), 0); | |
| 155 | + const options = (body) => fetch(`${base}/auth/passkey/register/options`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); | |
| 156 | + for (const proof of [undefined, "", "forged", proofFor("another-invite")]) { | |
| 157 | + const r = await options({ invite: INVITE, login: "pal", proof }); | |
| 158 | + assert.equal(r.status, 403, `proof ${proof}`); | |
| 159 | + assert.match((await r.json()).message, /invitation email sent to pal@example\.com/); | |
| 160 | + } | |
| 161 | + await Promise.all([page.waitForLoadState("load"), page.click("text=Email me the link")]); | |
| 162 | + assert.match(await text(page), /We emailed the link to pal@example\.com/); | |
| 163 | + // The emailed link carries the proof. | |
| 164 | + await page.goto(`${base}/invite/${INVITE}?proof=${proofFor(INVITE)}`); | |
| 148 | 165 | assert.match(await text(page), /invited as pal@example\.com/); |
| 149 | 166 | await page.fill("#login", "pal"); |
| 150 | 167 | await page.fill("#name", "Pal Friend"); |
| @@ -160,6 +177,9 @@ | ||
| 160 | 177 | // The invite's org membership was applied. |
| 161 | 178 | await page.goto(`${base}/settings/orgs`); |
| 162 | 179 | assert.match(await text(page), /crew/); |
| 180 | + // Picking a login that is on BOOTSTRAP_ADMINS doesn't make anyone a site administrator: only a verified email does. | |
| 181 | + await page.goto(`${base}/api/v3/user`); | |
| 182 | + assert.equal(JSON.parse(await page.textContent("body")).site_admin, false); | |
| 163 | 183 | |
| 164 | 184 | // Invites work once. |
| 165 | 185 | const stranger = await person(); |
modifiedapps/diffinery/wrangler.jsonc+1−1View file
| @@ -18,7 +18,7 @@ | ||
| 18 | 18 | "vars": { |
| 19 | 19 | "PUBLIC_URL": "https://diffinery.com", |
| 20 | 20 | "MAIL_FROM": "notifications@diffinery.com", |
| 21 | - "BOOTSTRAP_ADMINS": "cnbrkdmrci,iletisim@canberkdemirci.com,canberkdemirci@icloud.com,iletisim@altugankarali.com", | |
| 21 | + "BOOTSTRAP_ADMINS": "iletisim@canberkdemirci.com,canberkdemirci@icloud.com,iletisim@altugankarali.com", | |
| 22 | 22 | "ACCESS_TEAM_DOMAIN": "", |
| 23 | 23 | "ACCESS_AUD": "" |
| 24 | 24 | }, |