Signing up from the invite email confirms the address: its link carries a proof only that email has, so there is no code to enter
Every invite identity emails to an address (a person's invite, an owner's workspace or repository invite, staff's and an approved waitlist request's) now links to /invite/<code>?proof=<hmac>, an HMAC-SHA256 under IDENTITY_KEY of the invite's id and its bound address. The inviter sees the code, never the proof. The invite page passes the proof to check_invite (email_proven) and register; create_account starts the account confirmed only when the proof is this invite's and the account's address is the one it was sent to. A code typed in, a link passed on, an invite for anyone, a different address, or an email sent before this: confirmed after sign-up as before. No new secret and no migration. /register?invite= keeps the proof when it sends you to the invite's page. Docs: invites from your inbox.
| 12 | 12 | hyphens, up to 39 characters. | |
| 13 | 13 | ||
| 14 | 14 | Before you can do anything else, you [confirm your email | |
| 15 | − | address](#confirming-your-email-address) with the code g1t emails you. | |
| 15 | + | address](#confirming-your-email-address) with the code g1t emails you, | |
| 16 | + | unless you signed up from the link in an invite g1t emailed to that address | |
| 17 | + | (see [invites from your inbox](#invites-from-your-inbox)). | |
| 16 | 18 | ||
| 17 | 19 | Accounts can only be created in a browser. There is no API for it, by | |
| 18 | 20 | design: it keeps passwords out of scripts and agents, and lets g1t protect | |
| ⋯ | |||
| 130 | 132 | ||
| 131 | 133 | 1. **No account yet**: sign up on the page. When the invite was sent to | |
| 132 | 134 | your address, the email field is filled in and locked. Choose a | |
| 133 | − | username (one is suggested from your address) and a password, then | |
| 135 | + | username (one is suggested from your address) and a password. If you | |
| 136 | + | opened the page from the invite email itself, the address is already | |
| 137 | + | confirmed and you go straight in (see | |
| 138 | + | [invites from your inbox](#invites-from-your-inbox)); otherwise | |
| 134 | 139 | [confirm the address](#confirming-your-email-address) with the code g1t | |
| 135 | − | emails it, even though the invite came there: an invite link can be | |
| 136 | − | forwarded, so it does not prove the inbox is yours. Or select | |
| 137 | − | **Continue with GitHub**: the invite rides along, and the account uses | |
| 138 | − | the invited address when GitHub has verified it too, in which case no | |
| 139 | − | confirmation is needed. | |
| 140 | + | emails it. Or select **Continue with GitHub**: the invite rides along, | |
| 141 | + | and the account uses the invited address when GitHub has verified it | |
| 142 | + | too, in which case no confirmation is needed. | |
| 140 | 143 | 2. **The address already has an account**: select **Sign in to accept**. | |
| 141 | 144 | After you sign in, the invite is accepted for you. | |
| 142 | 145 | 3. **Signed in as someone else**: an invite sent to one address works only | |
| ⋯ | |||
| 161 | 164 | An expired, revoked or used invite says which, and who sent it, so you | |
| 162 | 165 | can ask them for a new one; or ask for access from the same page. | |
| 163 | 166 | ||
| 167 | + | ### Invites from your inbox | |
| 168 | + | ||
| 169 | + | When g1t emails an invite to an address (an invite you make for someone, | |
| 170 | + | an owner's invite into a workspace or a repository, or an approved | |
| 171 | + | [request for access](#asking-for-access)), the link in that email carries a | |
| 172 | + | `proof` that only the email has: `g1t.sh/invite/<code>?proof=…`. Opening | |
| 173 | + | the link shows that you can read that inbox, so: | |
| 174 | + | ||
| 175 | + | - the invite page says the address is confirmed because you came from the | |
| 176 | + | invite email, and the email field stays locked to it; | |
| 177 | + | - your new account starts with the address confirmed: no code is sent, and | |
| 178 | + | you land in the workspace or repository the invite was for straight | |
| 179 | + | away. | |
| 180 | + | ||
| 181 | + | Anything else confirms the address the usual way, after you sign up: the | |
| 182 | + | code typed at [g1t.sh/register](https://g1t.sh/register), an invite link | |
| 183 | + | copied from **Settings → Invites** (whoever made the invite sees the code, | |
| 184 | + | never the proof), an invite made for anyone with the link, or an invite | |
| 185 | + | email sent before this existed. The proof is tied to one invite and its | |
| 186 | + | address, and stops working when the invite is used, revoked or expires. | |
| 187 | + | ||
| 164 | 188 | ### Invite links for a group | |
| 165 | 189 | ||
| 166 | 190 | g1t sometimes hands one link to a group: an event's judges, readers of a | |
| ⋯ | |||
| 209 | 233 | An owner can invite an email address straight into a workspace from its | |
| 210 | 234 | People page; see [members and roles](/guides/workspaces/#members-and-roles). | |
| 211 | 235 | When the address has no g1t account, the invite makes the account, which | |
| 212 | − | joins the workspace once it confirms its email address, and it uses one | |
| 213 | − | invite. Inviting someone who is | |
| 236 | + | joins the workspace once it confirms its email address (at once when it | |
| 237 | + | was made from the invite email's link), and it uses one invite. Inviting someone who is | |
| 214 | 238 | already on g1t costs nothing. | |
| 215 | 239 | ||
| 216 | 240 | ### Need more invites? | |
| ⋯ | |||
| 245 | 269 | ## Confirming your email address | |
| 246 | 270 | ||
| 247 | 271 | A new account confirms its email address before it can do anything else on | |
| 248 | − | g1t. Right after you sign up, g1t emails the address from `noreply@g1t.sh` | |
| 272 | + | g1t, unless it already has (it was made with GitHub, or from the link in | |
| 273 | + | its invite email). Right after you sign up, g1t emails the address from `noreply@g1t.sh` | |
| 249 | 274 | with two ways to confirm it, either one enough: | |
| 250 | 275 | ||
| 251 | 276 | - a **six-digit code**, shown large in the email (and in its subject, so a | |
| ⋯ | |||
| 294 | 319 | is one GitHub has verified, so GitHub has already proved the inbox is | |
| 295 | 320 | yours, and no code is sent. | |
| 296 | 321 | ||
| 322 | + | ### Addresses an invite email has confirmed | |
| 323 | + | ||
| 324 | + | An account made from the link in the invite g1t emailed to its address | |
| 325 | + | starts confirmed the same way: following that link proved the inbox is | |
| 326 | + | yours. It works only for the address the invite was sent to, and only from | |
| 327 | + | the email's own link; see [invites from your inbox](#invites-from-your-inbox). | |
| 328 | + | ||
| 297 | 329 | ### Accounts that never confirmed | |
| 298 | 330 | ||
| 299 | 331 | Accounts are confirmed once and stay confirmed. An account made before this | |
| 333 | 333 | ||
| 334 | 334 | The email names you and the workspace and links to the invite's page. | |
| 335 | 335 | Someone new signs up right there, with the invited address filled in, and | |
| 336 | − | joins once they confirm it with the code g1t emails them; someone with an | |
| 337 | − | account signs in. Either way they land in the workspace as a member, with | |
| 336 | + | joins at once when they opened the page from that email (it proves the | |
| 337 | + | address is theirs), or otherwise once they confirm it with the code g1t | |
| 338 | + | emails them; someone with an account signs in. Either way they land in the workspace as a member, with | |
| 338 | 339 | a one-time welcome. Until a new account confirms its address, its invite | |
| 339 | 340 | shows as **confirming their email** under the members, and you can still | |
| 340 | 341 | revoke it. See |
| 6 | 6 | HAVE_AN_INVITE, | |
| 7 | 7 | INVITES_CONTACT, | |
| 8 | 8 | cleanCode, | |
| 9 | + | cleanProof, | |
| 10 | + | invitePath, | |
| 11 | + | inviteSignUpCopy, | |
| 9 | 12 | inviteFor, | |
| 10 | 13 | inviteLink, | |
| 11 | 14 | inviteState, | |
| ⋯ | |||
| 45 | 48 | assert.equal(inviteLink(CODE, "http://localhost:8787/"), `http://localhost:8787/invite/${CODE}`); | |
| 46 | 49 | }); | |
| 47 | 50 | ||
| 51 | + | const PROOF = "4f9c2a7e0b13d5c84f9c2a7e0b13d5c84f9c2a7e0b13d5c84f9c2a7e0b13d5c8"; | |
| 52 | + | ||
| 53 | + | test("an invite email's proof is kept only when it looks like one, and goes along to the invite's page", () => { | |
| 54 | + | assert.equal(cleanProof(PROOF), PROOF); | |
| 55 | + | assert.equal(cleanProof(` ${PROOF.toUpperCase()} `), PROOF); | |
| 56 | + | assert.equal(cleanProof("not-a-proof"), null); | |
| 57 | + | assert.equal(cleanProof("abc"), null); | |
| 58 | + | assert.equal(cleanProof("a".repeat(500)), null); | |
| 59 | + | assert.equal(cleanProof(null), null); | |
| 60 | + | assert.equal(invitePath(CODE, PROOF), `/invite/${CODE}?proof=${PROOF}`); | |
| 61 | + | assert.equal(invitePath(CODE, null), `/invite/${CODE}`); | |
| 62 | + | assert.equal(invitePath(CODE), `/invite/${CODE}`); | |
| 63 | + | }); | |
| 64 | + | ||
| 65 | + | test("signing up from the invite email says the address is confirmed already; otherwise the code step applies", () => { | |
| 66 | + | const base = { address: "ada@example.com", emailProven: false, workspace: { name: "Flagon, Inc." }, repository: null }; | |
| 67 | + | const proven = inviteSignUpCopy({ ...base, emailProven: true }); | |
| 68 | + | assert.equal(proven.intro, "You join Flagon, Inc. as soon as you create it."); | |
| 69 | + | assert.match(proven.confirmed ?? "", /^ada@example\.com is confirmed: you came here from the invite we emailed to it/); | |
| 70 | + | assert.match(proven.hint, /confirmed already/); | |
| 71 | + | assert.doesNotMatch(proven.hint, /code/); | |
| 72 | + | ||
| 73 | + | // No proof (a code typed in, or a link passed on): nothing new is said. | |
| 74 | + | const plain = inviteSignUpCopy(base); | |
| 75 | + | assert.equal(plain.intro, "You join Flagon, Inc. as soon as you confirm your email."); | |
| 76 | + | assert.equal(plain.confirmed, null); | |
| 77 | + | assert.equal(plain.hint, "Your invite was sent here. We email it a code to confirm it before you start."); | |
| 78 | + | ||
| 79 | + | // An invite for anyone with the code has no address to prove. | |
| 80 | + | const open = inviteSignUpCopy({ ...base, address: null, emailProven: true, workspace: null }); | |
| 81 | + | assert.equal(open.confirmed, null); | |
| 82 | + | assert.equal(open.intro, "It takes a minute."); | |
| 83 | + | assert.equal(open.hint, "We email it a code to confirm it before you start."); | |
| 84 | + | ||
| 85 | + | const repo = inviteSignUpCopy({ ...base, workspace: null, repository: { name: "flagon-io/g1t" }, emailProven: true }); | |
| 86 | + | assert.equal(repo.intro, "You get flagon-io/g1t as soon as you create it."); | |
| 87 | + | }); | |
| 88 | + | ||
| 48 | 89 | test("a pasted link or code is tidied to the code", () => { | |
| 49 | 90 | assert.equal(cleanCode(CODE), CODE); | |
| 50 | 91 | assert.equal(cleanCode(` ${CODE} `), CODE); | |
| 65 | 65 | return text.replace(/\s+/g, "").slice(0, 80); | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | + | /** | |
| 69 | + | * The `proof` an invite email's link carries, tidied: hex, or null for | |
| 70 | + | * anything else. Identity decides whether it is the invite's own; this only | |
| 71 | + | * keeps junk out of what is passed on and put back into a form. | |
| 72 | + | */ | |
| 73 | + | export function cleanProof(raw: string | null | undefined): string | null { | |
| 74 | + | const text = (raw ?? "").trim().toLowerCase(); | |
| 75 | + | return /^[0-9a-f]{16,128}$/.test(text) ? text : null; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /** An invite's page, keeping the email's proof when there is one. */ | |
| 79 | + | export function invitePath(code: string, proof?: string | null): string { | |
| 80 | + | const path = `/invite/${encodeURIComponent(code)}`; | |
| 81 | + | return proof ? `${path}?proof=${encodeURIComponent(proof)}` : path; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | type Proven = { | |
| 85 | + | /** The bound address in full, or null for an invite to anyone with the code. */ | |
| 86 | + | address: string | null; | |
| 87 | + | emailProven: boolean; | |
| 88 | + | workspace: { name: string } | null; | |
| 89 | + | repository: { name: string } | null; | |
| 90 | + | }; | |
| 91 | + | ||
| 92 | + | /** | |
| 93 | + | * What signing up on an invite's page says about the email address. Opened | |
| 94 | + | * from the invite's own email (`emailProven`), the address is confirmed | |
| 95 | + | * already, so there is no code to enter; otherwise the address is confirmed | |
| 96 | + | * after sign-up, as it always is. | |
| 97 | + | */ | |
| 98 | + | export function inviteSignUpCopy(invite: Proven): { | |
| 99 | + | /** Under "Create your account". */ | |
| 100 | + | intro: string; | |
| 101 | + | /** Under the email field. */ | |
| 102 | + | hint: string; | |
| 103 | + | /** Said plainly above the form when the address is confirmed already; null otherwise. */ | |
| 104 | + | confirmed: string | null; | |
| 105 | + | } { | |
| 106 | + | const proven = invite.emailProven && invite.address !== null; | |
| 107 | + | const when = proven ? "as soon as you create it" : "as soon as you confirm your email"; | |
| 108 | + | const intro = invite.workspace | |
| 109 | + | ? `You join ${invite.workspace.name} ${when}.` | |
| 110 | + | : invite.repository | |
| 111 | + | ? `You get ${invite.repository.name} ${when}.` | |
| 112 | + | : "It takes a minute."; | |
| 113 | + | if (proven) { | |
| 114 | + | return { | |
| 115 | + | intro, | |
| 116 | + | hint: "Your invite was sent here, and you opened it from that email, so this address is confirmed already.", | |
| 117 | + | confirmed: `${invite.address} is confirmed: you came here from the invite we emailed to it, so there is no code to enter after you sign up.`, | |
| 118 | + | }; | |
| 119 | + | } | |
| 120 | + | return { | |
| 121 | + | intro, | |
| 122 | + | hint: invite.address | |
| 123 | + | ? "Your invite was sent here. We email it a code to confirm it before you start." | |
| 124 | + | : "We email it a code to confirm it before you start.", | |
| 125 | + | confirmed: null, | |
| 126 | + | }; | |
| 127 | + | } | |
| 128 | + | ||
| 68 | 129 | type Listed = { | |
| 69 | 130 | status: "pending" | "awaiting_confirmation" | "redeemed" | "expired" | "revoked"; | |
| 70 | 131 | redeemedBy: string | null; |
| 1 | − | import { CircleAlert, Lock, Ticket } from "lucide-react"; | |
| 1 | + | import { CircleAlert, Lock, MailCheck, Ticket } from "lucide-react"; | |
| 2 | 2 | import { Form, Link, data, redirect } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { InvitePreview, User } from "@g1t/contracts"; | |
| ⋯ | |||
| 11 | 11 | import { Avatar, ButtonLink, ErrorText, Field, Input, SubmitButton } from "../components/ui"; | |
| 12 | 12 | import { githubSignInEnabled } from "../lib/github.server"; | |
| 13 | 13 | import { identity } from "../lib/services.server"; | |
| 14 | − | import { cleanCode, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites"; | |
| 14 | + | import { cleanCode, cleanProof, inviteSignUpCopy, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites"; | |
| 15 | 15 | import { clientKey } from "../lib/registration.server"; | |
| 16 | 16 | import { assertSameOrigin, getViewer, requireUser, roleIn, startSession } from "../lib/session.server"; | |
| 17 | 17 | import { rememberWorkspace } from "../lib/workspace-choice"; | |
| ⋯ | |||
| 29 | 29 | * an address that has an account), and the workspace or repository it | |
| 30 | 30 | * gives. Signing in or up elsewhere (GitHub, /login) comes back here with | |
| 31 | 31 | * `?accept=1`, which finishes the job. | |
| 32 | + | * | |
| 33 | + | * The link in the invite's own email also carries `?proof=`, which only | |
| 34 | + | * that email has: signing up from it makes the account with the address | |
| 35 | + | * confirmed already. The code alone (typed in, or a link passed on) does | |
| 36 | + | * not, and the address is confirmed after sign-up as usual. | |
| 32 | 37 | */ | |
| 33 | 38 | export async function loader({ request, context, params }: Route.LoaderArgs) { | |
| 34 | 39 | const code = cleanCode(params.code); | |
| 35 | 40 | const viewer = getViewer(context); | |
| 36 | − | const accepting = new URL(request.url).searchParams.get("accept") === "1"; | |
| 37 | − | const checked = await identity.checkInvite(code, clientKey(request), { viewer, anyStatus: true }); | |
| 41 | + | const search = new URL(request.url).searchParams; | |
| 42 | + | const accepting = search.get("accept") === "1"; | |
| 43 | + | const emailProof = cleanProof(search.get("proof")); | |
| 44 | + | const checked = await identity.checkInvite(code, clientKey(request), { viewer, anyStatus: true, emailProof }); | |
| 38 | 45 | const invite = checked.ok ? checked.value : null; | |
| 39 | 46 | // A shared link for a group signs up on /register, which names the group. | |
| 40 | 47 | if (invite?.sharedLabel) throw redirect(`/register?invite=${encodeURIComponent(code)}`); | |
| ⋯ | |||
| 61 | 68 | alreadyIn: viewer && invite ? alreadyIn(viewer, invite) : false, | |
| 62 | 69 | github: false, | |
| 63 | 70 | suggestion: suggestUsername(invite?.address), | |
| 71 | + | // Only a proof identity accepted goes back into the form. | |
| 72 | + | proof: invite?.emailProven ? emailProof : null, | |
| 64 | 73 | started: Date.now(), | |
| 65 | 74 | acceptError: null as string | null, | |
| 66 | 75 | }; | |
| ⋯ | |||
| 95 | 104 | const code = cleanCode(params.code); | |
| 96 | 105 | const form = await request.formData(); | |
| 97 | 106 | const client = clientKey(request); | |
| 98 | − | const checked = await identity.checkInvite(code, client, { viewer: getViewer(context) }); | |
| 107 | + | const emailProof = cleanProof(String(form.get("proof") ?? "")); | |
| 108 | + | const checked = await identity.checkInvite(code, client, { viewer: getViewer(context), emailProof }); | |
| 99 | 109 | if (!checked.ok) return data({ error: checked.error.message }, { status: 422 }); | |
| 100 | 110 | const invite = checked.value; | |
| 101 | 111 | ||
| ⋯ | |||
| 110 | 120 | String(form.get("password") ?? ""), | |
| 111 | 121 | code, | |
| 112 | 122 | client, | |
| 123 | + | // Identity checks it again, against this invite and this address. | |
| 124 | + | emailProof, | |
| 113 | 125 | ); | |
| 114 | 126 | if (!result.ok) return data({ error: result.error.message }, { status: 422 }); | |
| 115 | 127 | throw landIn(request, invite, [startSession(result.value.sessionToken)], true); | |
| ⋯ | |||
| 202 | 214 | const here = `/invite/${loaded.code}`; | |
| 203 | 215 | const back = `${here}?accept=1`; | |
| 204 | 216 | const github = `/auth/github?${new URLSearchParams({ invite: loaded.code, next: back })}`; | |
| 217 | + | const copy = inviteSignUpCopy(invite); | |
| 205 | 218 | return ( | |
| 206 | 219 | <section aria-labelledby="sign-up" className="rounded-xl border border-line bg-surface/60 p-5 sm:p-6"> | |
| 207 | 220 | <h2 id="sign-up" className="text-base font-semibold"> | |
| 208 | 221 | Create your account | |
| 209 | 222 | </h2> | |
| 210 | − | <p className="mt-1 text-sm text-muted"> | |
| 211 | − | {invite.workspace | |
| 212 | − | ? `You join ${invite.workspace.name} as soon as you confirm your email.` | |
| 213 | − | : invite.repository | |
| 214 | − | ? `You get ${invite.repository.name} as soon as you confirm your email.` | |
| 215 | − | : "It takes a minute."} | |
| 216 | − | </p> | |
| 223 | + | <p className="mt-1 text-sm text-muted">{copy.intro}</p> | |
| 224 | + | {copy.confirmed && ( | |
| 225 | + | <p className="mt-4 flex items-start gap-2 rounded-md border border-success/40 bg-success/5 p-3 text-sm" role="status"> | |
| 226 | + | <MailCheck size={16} aria-hidden="true" className="mt-0.5 shrink-0 text-success" /> | |
| 227 | + | <span>{copy.confirmed}</span> | |
| 228 | + | </p> | |
| 229 | + | )} | |
| 217 | 230 | {loaded.github && ( | |
| 218 | 231 | <div className="mt-5"> | |
| 219 | 232 | <ContinueWithGithub href={github} /> | |
| ⋯ | |||
| 223 | 236 | <Form method="post" className={`relative space-y-4 ${loaded.github ? "" : "mt-5"}`}> | |
| 224 | 237 | <input type="hidden" name="intent" value="register" /> | |
| 225 | 238 | <Honeypot started={loaded.started} /> | |
| 239 | + | {loaded.proof && <input type="hidden" name="proof" value={loaded.proof} />} | |
| 226 | 240 | {invite.address ? ( | |
| 227 | − | <Field label="Email" hint="Your invite was sent here. We email it a code to confirm it before you start."> | |
| 241 | + | <Field label="Email" hint={copy.hint}> | |
| 228 | 242 | <span className="relative block"> | |
| 229 | 243 | <Input name="email" type="email" value={invite.address} readOnly aria-readonly="true" autoComplete="email" /> | |
| 230 | − | <Lock size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-faint" /> | |
| 244 | + | {copy.confirmed ? ( | |
| 245 | + | <MailCheck size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-success" /> | |
| 246 | + | ) : ( | |
| 247 | + | <Lock size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-faint" /> | |
| 248 | + | )} | |
| 231 | 249 | </span> | |
| 232 | 250 | </Field> | |
| 233 | 251 | ) : ( | |
| 234 | − | <Field label="Email" hint="We email it a code to confirm it before you start."> | |
| 252 | + | <Field label="Email" hint={copy.hint}> | |
| 235 | 253 | <Input name="email" type="email" autoComplete="email" required maxLength={254} /> | |
| 236 | 254 | </Field> | |
| 237 | 255 | )} | |
| 11 | 11 | import { githubSignInEnabled } from "../lib/github.server"; | |
| 12 | 12 | import { Avatar, Button, ErrorText, Field, Input, SubmitButton } from "../components/ui"; | |
| 13 | 13 | import { identity } from "../lib/services.server"; | |
| 14 | − | import { cleanCode, looksAutomated, sharedDomainsHint, sharedInviteLine } from "../lib/invites"; | |
| 14 | + | import { cleanCode, cleanProof, invitePath, looksAutomated, sharedDomainsHint, sharedInviteLine } from "../lib/invites"; | |
| 15 | 15 | import { clientKey, registrationMode } from "../lib/registration.server"; | |
| 16 | 16 | import { | |
| 17 | 17 | assertSameOrigin, | |
| ⋯ | |||
| 36 | 36 | // where it leads; it signs up, joins and lands in one go. A shared link | |
| 37 | 37 | // for a group signs up here: it joins nothing, and the form says which | |
| 38 | 38 | // group it is for. | |
| 39 | − | if (invite && code && !invite.sharedLabel) throw redirect(`/invite/${encodeURIComponent(code)}`); | |
| 39 | + | // The invite email's proof goes with it, so the address it proves stays | |
| 40 | + | // confirmed there. | |
| 41 | + | if (invite && code && !invite.sharedLabel) { | |
| 42 | + | throw redirect(invitePath(code, cleanProof(new URL(request.url).searchParams.get("proof")))); | |
| 43 | + | } | |
| 40 | 44 | // Signing up with GitHub carries the invite code and `next` through it. | |
| 41 | 45 | const params = new URLSearchParams(); | |
| 42 | 46 | if (code) params.set("invite", code); | |
| 245 | 245 | /// registration is open. | |
| 246 | 246 | #[serde(default)] | |
| 247 | 247 | pub invite_code: Option<String>, | |
| 248 | + | /// The `proof` from the invite email's link. When it is the invite's | |
| 249 | + | /// own and `email` is the address the invite was sent to, the account | |
| 250 | + | /// starts with that address confirmed; otherwise it is ignored. | |
| 251 | + | #[serde(default)] | |
| 252 | + | pub email_proof: Option<String>, | |
| 248 | 253 | /// Who is asking, such as the visitor's IP address, for rate limits. | |
| 249 | 254 | #[serde(default)] | |
| 250 | 255 | pub client: Option<String>, | |
| ⋯ | |||
| 1278 | 1283 | pub viewer: Option<User>, | |
| 1279 | 1284 | #[serde(default)] | |
| 1280 | 1285 | pub any_status: bool, | |
| 1286 | + | /// The `proof` from the invite email's link, if the page was opened | |
| 1287 | + | /// from it: sets `InvitePreview::email_proven`. | |
| 1288 | + | #[serde(default)] | |
| 1289 | + | pub email_proof: Option<String>, | |
| 1281 | 1290 | } | |
| 1282 | 1291 | ||
| 1283 | 1292 | /// Someone shown on an invite. | |
| ⋯ | |||
| 1333 | 1342 | /// domains, such as `["cloudflare.com"]`. Empty for any address. | |
| 1334 | 1343 | #[serde(default)] | |
| 1335 | 1344 | pub shared_domains: Vec<String>, | |
| 1345 | + | /// Whether `email_proof` was this pending invite's own, from the email | |
| 1346 | + | /// it was sent in: the account made with it starts with `address` | |
| 1347 | + | /// confirmed. False without a proof, with a wrong one, and for an | |
| 1348 | + | /// invite bound to no address. | |
| 1349 | + | #[serde(default)] | |
| 1350 | + | pub email_proven: bool, | |
| 1336 | 1351 | } | |
| 1337 | 1352 | ||
| 1338 | 1353 | /// `accept_invite`: a signed-in person uses a workspace invite made for | |
| 41 | 41 | export function identityClient(service: ServiceBinding): IdentityApi { | |
| 42 | 42 | const call = <T>(method: string, args: object) => rpc<T>(service, method, args); | |
| 43 | 43 | return { | |
| 44 | − | register: (username, email, password, inviteCode, client) => | |
| 45 | − | call("register", { username, email, password, invite_code: inviteCode ?? null, client: client ?? null }), | |
| 44 | + | register: (username, email, password, inviteCode, client, emailProof) => | |
| 45 | + | call("register", { | |
| 46 | + | username, | |
| 47 | + | email, | |
| 48 | + | password, | |
| 49 | + | invite_code: inviteCode ?? null, | |
| 50 | + | email_proof: emailProof ?? null, | |
| 51 | + | client: client ?? null, | |
| 52 | + | }), | |
| 46 | 53 | signIn: (username, password, client) => call("sign_in", { username, password, client: client ?? null }), | |
| 47 | 54 | twoFactorSignIn: (challenge, code, client) => call("two_factor_sign_in", { challenge, code, client: client ?? null }), | |
| 48 | 55 | signOut: (sessionToken) => call("sign_out", { sessionToken }), | |
| ⋯ | |||
| 153 | 160 | client: client ?? null, | |
| 154 | 161 | viewer: options.viewer ?? null, | |
| 155 | 162 | any_status: options.anyStatus ?? false, | |
| 163 | + | email_proof: options.emailProof ?? null, | |
| 156 | 164 | }), | |
| 157 | 165 | acceptInvite: (user, code) => call("accept_invite", { user, code }), | |
| 158 | 166 | inviteMember: (actor, slug, email) => call("invite_member", { actor, slug, email }), | |
| 281 | 281 | sharedLabel: string | null; | |
| 282 | 282 | /** The email domains a shared invite link is limited to; empty for any address. */ | |
| 283 | 283 | sharedDomains: string[]; | |
| 284 | + | /** | |
| 285 | + | * Whether the page was opened from this pending invite's own email (its | |
| 286 | + | * `proof` checked out): the account made with it starts with `address` | |
| 287 | + | * confirmed. False without a proof, with a wrong one, or for an invite | |
| 288 | + | * bound to no address. | |
| 289 | + | */ | |
| 290 | + | emailProven: boolean; | |
| 284 | 291 | }; | |
| 285 | 292 | ||
| 286 | 293 | export type WaitlistStatus = "waiting" | "invited" | "dismissed"; | |
| ⋯ | |||
| 702 | 709 | inviteCode?: string | null, | |
| 703 | 710 | /** Who is asking, such as the visitor's IP address, for rate limits. */ | |
| 704 | 711 | client?: string | null, | |
| 712 | + | /** | |
| 713 | + | * The `proof` from the invite email's link. When it is the invite's own | |
| 714 | + | * and `email` is the address it was sent to, the account starts with that | |
| 715 | + | * address confirmed; otherwise it is ignored. | |
| 716 | + | */ | |
| 717 | + | emailProof?: string | null, | |
| 705 | 718 | ): Promise<Result<{ user: User; sessionToken: string }>>; | |
| 706 | 719 | /** Verifies a username and password for website sign-in. */ | |
| 707 | 720 | /** | |
| ⋯ | |||
| 890 | 903 | /** | |
| 891 | 904 | * What a code is for. Unknown, used, revoked and expired codes all get the | |
| 892 | 905 | * same answer, unless `anyStatus`: then a real code that is spent is | |
| 893 | − | * described, with its `status`. `viewer` sets `forViewer`. | |
| 906 | + | * described, with its `status`. `viewer` sets `forViewer`; `emailProof`, | |
| 907 | + | * the `proof` from the invite email's link, sets `emailProven`. | |
| 894 | 908 | */ | |
| 895 | 909 | checkInvite( | |
| 896 | 910 | code: string, | |
| 897 | 911 | client?: string | null, | |
| 898 | − | options?: { viewer?: User | null; anyStatus?: boolean }, | |
| 912 | + | options?: { viewer?: User | null; anyStatus?: boolean; emailProof?: string | null }, | |
| 899 | 913 | ): Promise<Result<InvitePreview>>; | |
| 900 | 914 | /** | |
| 901 | 915 | * A signed-in person uses a workspace invite sent to their address, or one | |
| 759 | 759 | &full_name(&repo), | |
| 760 | 760 | a.role.label(), | |
| 761 | 761 | None, | |
| 762 | + | None, | |
| 762 | 763 | INVITATION_DAYS, | |
| 763 | 764 | ) | |
| 764 | 765 | .await | |
| ⋯ | |||
| 804 | 805 | let id = self | |
| 805 | 806 | .insert_invitation(repo, workspace_id, None, Some(email), Some(&invite.id), role, &actor.id, days) | |
| 806 | 807 | .await?; | |
| 808 | + | // The email's link proves the address, as any invite email's does. | |
| 809 | + | let proof = self.email_proof_for(&invite.id, email); | |
| 807 | 810 | if let Some(code) = &invite.code | |
| 808 | 811 | && let Err(error) = crate::email::send_repo_invite( | |
| 809 | 812 | &self.env, | |
| ⋯ | |||
| 812 | 815 | &full_name(repo), | |
| 813 | 816 | role.label(), | |
| 814 | 817 | Some(code), | |
| 818 | + | proof.as_deref(), | |
| 815 | 819 | days, | |
| 816 | 820 | ) | |
| 817 | 821 | .await | |
| 46 | 46 | hex::encode(mac.finalize().into_bytes()) | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | + | /// The proof an invite email's link carries that whoever follows it reads | |
| 50 | + | /// that inbox: an HMAC-SHA256 under `key` (IDENTITY_KEY) of the invite's id | |
| 51 | + | /// and the address it is bound to, trimmed and lowercased. Only the email | |
| 52 | + | /// has it: the inviter sees the code, never this, and nobody can make one | |
| 53 | + | /// without the key. | |
| 54 | + | pub fn invite_proof(key: &[u8], invite_id: &str, email: &str) -> String { | |
| 55 | + | use hmac::{Hmac, Mac}; | |
| 56 | + | let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("HMAC takes any key length"); | |
| 57 | + | mac.update(b"g1t invite email proof\0"); | |
| 58 | + | mac.update(invite_id.as_bytes()); | |
| 59 | + | mac.update(b"\0"); | |
| 60 | + | mac.update(email.trim().to_lowercase().as_bytes()); | |
| 61 | + | hex::encode(mac.finalize().into_bytes()) | |
| 62 | + | } | |
| 63 | + | ||
| 49 | 64 | /// Whether two strings are equal, in time that depends on their length only. | |
| 50 | 65 | pub fn same(a: &str, b: &str) -> bool { | |
| 51 | 66 | a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0 |
| 288 | 288 | pub workspace: Option<&'a str>, | |
| 289 | 289 | pub joins_existing_account: bool, | |
| 290 | 290 | pub code: &'a str, | |
| 291 | + | /// The proof that whoever follows the link reads this inbox | |
| 292 | + | /// (invites.rs, `email_proof`): the account made from it starts with | |
| 293 | + | /// the address confirmed. None for an invite to an existing account, | |
| 294 | + | /// or without IDENTITY_KEY. | |
| 295 | + | pub proof: Option<&'a str>, | |
| 291 | 296 | pub days: u64, | |
| 292 | 297 | /// A line from whoever sent it, such as staff approving a request. | |
| 293 | 298 | pub note: Option<&'a str>, | |
| 294 | 299 | } | |
| 295 | 300 | ||
| 301 | + | /// An invite's page, as its email links to it: with the email's proof | |
| 302 | + | /// when it has one, so following it confirms the address (invites.rs). | |
| 303 | + | /// The code alone is what the inviter can see and share. | |
| 304 | + | pub fn invite_link(site: &str, code: &str, proof: Option<&str>) -> String { | |
| 305 | + | match proof { | |
| 306 | + | Some(proof) => format!("{site}/invite/{code}?proof={proof}"), | |
| 307 | + | None => format!("{site}/invite/{code}"), | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | ||
| 296 | 311 | /// The subject and letter of an invite email. | |
| 297 | 312 | pub fn invite_letter(invite: &InviteEmail, site: &str) -> (String, Letter) { | |
| 298 | 313 | let (subject, intro) = invite_wording(invite.from, invite.workspace, invite.joins_existing_account); | |
| ⋯ | |||
| 314 | 329 | paragraphs: vec![intro], | |
| 315 | 330 | quotes, | |
| 316 | 331 | code: None, | |
| 317 | − | action: Some((action, format!("{site}/invite/{}", invite.code))), | |
| 332 | + | action: Some((action, invite_link(site, invite.code, invite.proof))), | |
| 318 | 333 | footer: format!( | |
| 319 | 334 | "This invite works for {} days, only for this address. If you were not expecting it, you can ignore this message.", | |
| 320 | 335 | invite.days | |
| ⋯ | |||
| 413 | 428 | } | |
| 414 | 429 | ||
| 415 | 430 | /// An invitation to collaborate on one repository. `code` is set when the | |
| 416 | − | /// address has no account yet: the link then makes one and accepts; without | |
| 417 | − | /// it, the link opens the invitation to accept or decline. | |
| 431 | + | /// address has no account yet: the link then makes one and accepts, with | |
| 432 | + | /// `proof` (see [`invite_link`]); without it, the link opens the | |
| 433 | + | /// invitation to accept or decline. | |
| 418 | 434 | pub async fn send_repo_invite( | |
| 419 | 435 | env: &Env, | |
| 420 | 436 | to: &str, | |
| ⋯ | |||
| 422 | 438 | repo: &str, | |
| 423 | 439 | role: &str, | |
| 424 | 440 | code: Option<&str>, | |
| 441 | + | proof: Option<&str>, | |
| 425 | 442 | days: u64, | |
| 426 | 443 | ) -> Result<()> { | |
| 427 | 444 | let (subject, intro) = repo_invite_wording(from, repo, role, code.is_some()); | |
| 428 | 445 | let link = match code { | |
| 429 | − | Some(code) => format!("{}/invite/{code}", site(env)), | |
| 446 | + | Some(code) => invite_link(&site(env), code, proof), | |
| 430 | 447 | None => format!("{}/{repo}/invitations", site(env)), | |
| 431 | 448 | }; | |
| 432 | 449 | send_link( | |
| ⋯ | |||
| 574 | 591 | workspace: Some("Flagon, Inc."), | |
| 575 | 592 | joins_existing_account: false, | |
| 576 | 593 | code: "g1t-abcd", | |
| 594 | + | proof: None, | |
| 577 | 595 | days: 30, | |
| 578 | 596 | note, | |
| 579 | 597 | } | |
| ⋯ | |||
| 596 | 614 | } | |
| 597 | 615 | ||
| 598 | 616 | #[test] | |
| 617 | + | fn an_invite_link_carries_the_emails_proof_when_it_has_one() { | |
| 618 | + | let proven = InviteEmail { proof: Some("4f9c2a"), ..invite(None, None) }; | |
| 619 | + | let (_, letter) = invite_letter(&proven, SITE); | |
| 620 | + | assert_eq!(letter.action.as_ref().unwrap().1, "https://g1t.sh/invite/g1t-abcd?proof=4f9c2a"); | |
| 621 | + | // An invite sent before proofs, or for an existing account: the code alone. | |
| 622 | + | assert_eq!(invite_link(SITE, "g1t-abcd", None), "https://g1t.sh/invite/g1t-abcd"); | |
| 623 | + | } | |
| 624 | + | ||
| 625 | + | #[test] | |
| 599 | 626 | fn links_point_at_the_site_they_are_given() { | |
| 600 | 627 | let (_, letter) = invite_letter(&invite(None, None), "http://localhost:8787"); | |
| 601 | 628 | assert_eq!(letter.action.as_ref().unwrap().1, "http://localhost:8787/invite/g1t-abcd"); | |
| ⋯ | |||
| 688 | 715 | workspace: Some("Flagon, Inc."), | |
| 689 | 716 | joins_existing_account: false, | |
| 690 | 717 | code: "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk", | |
| 718 | + | proof: None, | |
| 691 | 719 | days: 30, | |
| 692 | 720 | note: None, | |
| 693 | 721 | }, SITE); | |
| ⋯ | |||
| 698 | 726 | workspace: None, | |
| 699 | 727 | joins_existing_account: false, | |
| 700 | 728 | code: "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk", | |
| 729 | + | proof: None, | |
| 701 | 730 | days: 30, | |
| 702 | 731 | note: Some("Thanks for waiting. We would love to see the compiler."), | |
| 703 | 732 | }, SITE); | |
| 584 | 584 | password_hash: "", | |
| 585 | 585 | verified: true, | |
| 586 | 586 | invite_code, | |
| 587 | + | // GitHub has confirmed the address already. | |
| 588 | + | email_proof: None, | |
| 587 | 589 | client: None, | |
| 588 | 590 | }) | |
| 589 | 591 | .await? |
| 233 | 233 | } | |
| 234 | 234 | } | |
| 235 | 235 | ||
| 236 | + | /// The proof for an invite's email link, or None when there is none to | |
| 237 | + | /// make: no key (a development setup), or no address the invite is bound | |
| 238 | + | /// to. See [`crypto::invite_proof`]. | |
| 239 | + | pub fn email_proof(key: &[u8], invite_id: &str, bound: Option<&str>) -> Option<String> { | |
| 240 | + | let bound = bound.map(str::trim).filter(|bound| !bound.is_empty())?; | |
| 241 | + | (!key.is_empty()).then(|| crypto::invite_proof(key, invite_id, bound)) | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | /// Whether `proof` shows that whoever brings it followed the invite's own | |
| 245 | + | /// email: it is the proof for this invite and the address it is bound to, | |
| 246 | + | /// and `email`, the address the account is made with, is that address. | |
| 247 | + | /// Anything else (no proof, a wrong or altered one, another invite's, an | |
| 248 | + | /// invite bound to no address, a different address) proves nothing, and | |
| 249 | + | /// the address is confirmed as any other is. | |
| 250 | + | pub fn proves_email(key: &[u8], invite_id: &str, bound: Option<&str>, email: &str, proof: Option<&str>) -> bool { | |
| 251 | + | let (Some(expected), Some(proof)) = (email_proof(key, invite_id, bound), proof.map(str::trim)) else { | |
| 252 | + | return false; | |
| 253 | + | }; | |
| 254 | + | let same_address = bound.is_some_and(|bound| bound.trim().to_lowercase() == email.trim().to_lowercase()); | |
| 255 | + | same_address && crypto::same(&expected, &proof.to_ascii_lowercase()) | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | /// Whether a new account starts with its address confirmed: GitHub | |
| 259 | + | /// confirmed it (`verified`), or `invite`, the one-person invite that | |
| 260 | + | /// admitted it, was followed from its own email with `proof` and `email` is | |
| 261 | + | /// the address it was sent to. A shared link, a code typed in or passed on, | |
| 262 | + | /// or an invite bound to no address: confirmed as any other is. | |
| 263 | + | pub fn starts_confirmed(key: &[u8], verified: bool, invite: Option<&InviteRow>, email: &str, proof: Option<&str>) -> bool { | |
| 264 | + | verified | |
| 265 | + | || invite.is_some_and(|row| row.kind == "account" && proves_email(key, &row.id, row.email.as_deref(), email, proof)) | |
| 266 | + | } | |
| 267 | + | ||
| 236 | 268 | /// What an invite used to sign up does once its account confirms its | |
| 237 | 269 | /// address. | |
| 238 | 270 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| ⋯ | |||
| 439 | 471 | /// Whether the address is confirmed already (GitHub's verified email). | |
| 440 | 472 | pub verified: bool, | |
| 441 | 473 | pub invite_code: Option<&'a str>, | |
| 474 | + | /// The proof from the invite email's link ([`proves_email`]): when it | |
| 475 | + | /// is the invite's and `email` is the address the invite was sent to, | |
| 476 | + | /// the account starts with that address confirmed. | |
| 477 | + | pub email_proof: Option<&'a str>, | |
| 442 | 478 | /// Who is asking, for rate limits. | |
| 443 | 479 | pub client: Option<&'a str>, | |
| 444 | 480 | } | |
| ⋯ | |||
| 498 | 534 | Sealer::new(&self.env.secret("IDENTITY_KEY").ok()?.to_string()) | |
| 499 | 535 | } | |
| 500 | 536 | ||
| 537 | + | /// The key invite email proofs are made under: IDENTITY_KEY, or none | |
| 538 | + | /// in a development setup without one (then no proof is made, and none | |
| 539 | + | /// is accepted). | |
| 540 | + | fn proof_key(&self) -> Vec<u8> { | |
| 541 | + | self.env.secret("IDENTITY_KEY").map(|key| key.to_string().into_bytes()).unwrap_or_default() | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | /// The proof for the link of an invite emailed to `to`, the address it | |
| 545 | + | /// is bound to; never shown anywhere but in that email. | |
| 546 | + | pub(crate) fn email_proof_for(&self, invite_id: &str, to: &str) -> Option<String> { | |
| 547 | + | email_proof(&self.proof_key(), invite_id, Some(to)) | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | /// Whether `proof` shows the invite in `row` was followed from its own | |
| 551 | + | /// email, by someone making an account with `email`. | |
| 552 | + | fn proven(&self, row: &InviteRow, email: &str, proof: Option<&str>) -> bool { | |
| 553 | + | starts_confirmed(&self.proof_key(), false, Some(row), email, proof) | |
| 554 | + | } | |
| 555 | + | ||
| 501 | 556 | // --- Rate limits --- | |
| 502 | 557 | ||
| 503 | 558 | /// Counts one more hit on `key` this hour; false once past `limit`. | |
| ⋯ | |||
| 693 | 748 | /// invite-only, `invite_code` must admit `email`; the code is spent in | |
| 694 | 749 | /// the same transaction as the account is made. What the invite gives | |
| 695 | 750 | /// (a workspace, repository invitations) is applied once the address | |
| 696 | − | /// is confirmed: at once for an address GitHub has confirmed, otherwise | |
| 751 | + | /// is confirmed: at once for an address GitHub has confirmed or one | |
| 752 | + | /// proven by the invite email's link ([`proves_email`]), otherwise | |
| 697 | 753 | /// in the transaction that confirms it (emails.rs, `confirm_address`). | |
| 698 | 754 | /// In open mode a code is used if it is good and otherwise ignored. | |
| 699 | 755 | pub async fn create_account(&self, new: NewAccount<'_>) -> Result<Outcome<User>> { | |
| ⋯ | |||
| 739 | 795 | } | |
| 740 | 796 | } | |
| 741 | 797 | ||
| 742 | − | // Only an address GitHub has confirmed starts confirmed. An invite | |
| 743 | − | // bound to the address proves nothing: its link can be forwarded, | |
| 744 | − | // so the new account confirms the address like any other. | |
| 745 | − | let verified = new.verified; | |
| 798 | + | // An address GitHub has confirmed starts confirmed, and so does the | |
| 799 | + | // address an invite was emailed to, when the link followed was the | |
| 800 | + | // email's own: its proof is in no code the inviter sees or shares. | |
| 801 | + | // The code alone proves nothing (it can be passed on), so without | |
| 802 | + | // the proof the new account confirms the address like any other. | |
| 803 | + | let verified = starts_confirmed(&self.proof_key(), new.verified, invite.as_ref(), new.email, new.email_proof); | |
| 746 | 804 | let user = User { | |
| 747 | 805 | id: new_id("usr", now_ms()), | |
| 748 | 806 | username: new.username.to_owned(), | |
| ⋯ | |||
| 823 | 881 | self.count_failure(new.client).await?; | |
| 824 | 882 | return Ok(Outcome::fail(FailureCode::Forbidden, INVALID)); | |
| 825 | 883 | } | |
| 826 | − | // Confirmed already (GitHub): what the invite gives, now. Otherwise | |
| 884 | + | // Confirmed already (GitHub, or the invite email): what the invite gives, now. Otherwise | |
| 827 | 885 | // it waits, spent, for the address to be confirmed. | |
| 828 | 886 | if let Some(row) = invite | |
| 829 | 887 | && user.verified | |
| ⋯ | |||
| 1169 | 1227 | }; | |
| 1170 | 1228 | if let (Some(email), Some(code)) = (&email, &invite.code) { | |
| 1171 | 1229 | let from = self.display_name(&a.user).await; | |
| 1172 | − | self.send_invite_email(email, Some(&from), None, false, code, None).await; | |
| 1230 | + | self.send_invite_email(email, Some(&from), None, false, code, &invite.id, None).await; | |
| 1173 | 1231 | } | |
| 1174 | 1232 | let logs: Vec<String> = a.user.workspaces.iter().map(|membership| membership.slug.clone()).collect(); | |
| 1175 | 1233 | self.audit_invites(&a.user, "invite.created", logs, a.surface.unwrap_or(Surface::Web), format!("Created invite {}", invite.hint)) | |
| ⋯ | |||
| 1184 | 1242 | workspace: Option<&str>, | |
| 1185 | 1243 | existing: bool, | |
| 1186 | 1244 | code: &str, | |
| 1245 | + | invite_id: &str, | |
| 1187 | 1246 | note: Option<&str>, | |
| 1188 | 1247 | ) { | |
| 1248 | + | // An invite that makes an account carries the proof that the link | |
| 1249 | + | // came from this email; one for an existing account has nothing | |
| 1250 | + | // to prove. | |
| 1251 | + | let proof = if existing { None } else { self.email_proof_for(invite_id, to) }; | |
| 1189 | 1252 | let invite = crate::email::InviteEmail { | |
| 1190 | 1253 | to, | |
| 1191 | 1254 | from, | |
| 1192 | 1255 | workspace, | |
| 1193 | 1256 | joins_existing_account: existing, | |
| 1194 | 1257 | code, | |
| 1258 | + | proof: proof.as_deref(), | |
| 1195 | 1259 | days: self.invite_ttl_days(), | |
| 1196 | 1260 | note, | |
| 1197 | 1261 | }; | |
| ⋯ | |||
| 1349 | 1413 | _ => false, | |
| 1350 | 1414 | }; | |
| 1351 | 1415 | let repository = self.repository_of_code(&row.id).await?; | |
| 1416 | + | // Opened from the invite's own email: the account it makes starts | |
| 1417 | + | // with the address confirmed. Said only while it can make one. | |
| 1418 | + | let email_proven = pending | |
| 1419 | + | && !has_account | |
| 1420 | + | && row.email.as_deref().is_some_and(|bound| self.proven(&row, bound, a.email_proof.as_deref())); | |
| 1352 | 1421 | #[derive(Deserialize)] | |
| 1353 | 1422 | struct From { | |
| 1354 | 1423 | username: String, | |
| ⋯ | |||
| 1391 | 1460 | expires_at: row.expires_at, | |
| 1392 | 1461 | shared_label: None, | |
| 1393 | 1462 | shared_domains: Vec::new(), | |
| 1463 | + | email_proven, | |
| 1394 | 1464 | })) | |
| 1395 | 1465 | } | |
| 1396 | 1466 | ||
| ⋯ | |||
| 1550 | 1620 | if let Some(code) = &invite.code { | |
| 1551 | 1621 | let from = self.display_name(&a.actor).await; | |
| 1552 | 1622 | let workspace = self.workspace_name(&workspace_id, &slug).await; | |
| 1553 | − | self.send_invite_email(&email, Some(&from), Some(&workspace), has_account, code, None).await; | |
| 1623 | + | self.send_invite_email(&email, Some(&from), Some(&workspace), has_account, code, &invite.id, None).await; | |
| 1554 | 1624 | } | |
| 1555 | 1625 | self.audit_invites( | |
| 1556 | 1626 | &a.actor, | |
| ⋯ | |||
| 1953 | 2023 | return Ok(Outcome::fail(FailureCode::Conflict, "The invite could not be made. Try again.")); | |
| 1954 | 2024 | }; | |
| 1955 | 2025 | if let (Some(email), Some(code)) = (&email, &invite.code) { | |
| 1956 | − | self.send_invite_email(email, None, None, false, code, note).await; | |
| 2026 | + | self.send_invite_email(email, None, None, false, code, &invite.id, note).await; | |
| 1957 | 2027 | } | |
| 1958 | 2028 | invite.staff = Some(staff.to_owned()); | |
| 1959 | 2029 | Ok(Outcome::Ok(invite)) | |
| ⋯ | |||
| 2396 | 2466 | assert_eq!(admits(Some(&account), "ada@example.com", false), Ok(())); | |
| 2397 | 2467 | } | |
| 2398 | 2468 | ||
| 2469 | + | const KEY: &[u8] = b"identity key"; | |
| 2470 | + | ||
| 2471 | + | #[test] | |
| 2472 | + | fn an_invite_emails_proof_is_for_its_invite_and_address_only() { | |
| 2473 | + | let proof = email_proof(KEY, "inv_1", Some("ada@example.com")).unwrap(); | |
| 2474 | + | assert_eq!(proof.len(), 64); | |
| 2475 | + | let proves = |id: &str, bound: Option<&str>, email: &str, proof: Option<&str>| proves_email(KEY, id, bound, email, proof); | |
| 2476 | + | // The right invite and address, however the address is written. | |
| 2477 | + | assert!(proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&proof))); | |
| 2478 | + | assert!(proves("inv_1", Some("Ada@Example.com"), " ADA@example.com ", Some(&proof))); | |
| 2479 | + | assert!(proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&proof.to_uppercase()))); | |
| 2480 | + | // Another address: the account confirms that one itself. | |
| 2481 | + | assert!(!proves("inv_1", Some("ada@example.com"), "eve@example.com", Some(&proof))); | |
| 2482 | + | // Another invite's proof, even for the same address. | |
| 2483 | + | assert!(!proves("inv_2", Some("ada@example.com"), "ada@example.com", Some(&proof))); | |
| 2484 | + | // Tampered, cut short, empty or missing. | |
| 2485 | + | let mut tampered = proof.clone().into_bytes(); | |
| 2486 | + | tampered[10] = if tampered[10] == b'0' { b'1' } else { b'0' }; | |
| 2487 | + | let tampered = String::from_utf8(tampered).unwrap(); | |
| 2488 | + | assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&tampered))); | |
| 2489 | + | assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&proof[..32]))); | |
| 2490 | + | assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(""))); | |
| 2491 | + | assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", None)); | |
| 2492 | + | // An invite bound to no address has no proof to give. | |
| 2493 | + | assert_eq!(email_proof(KEY, "inv_1", None), None); | |
| 2494 | + | assert!(!proves("inv_1", None, "ada@example.com", Some(&proof))); | |
| 2495 | + | // Made under another key: not ours. | |
| 2496 | + | let foreign = email_proof(b"another key", "inv_1", Some("ada@example.com")).unwrap(); | |
| 2497 | + | assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&foreign))); | |
| 2498 | + | // Without a key (development) none is made, and none is taken. | |
| 2499 | + | assert_eq!(email_proof(b"", "inv_1", Some("ada@example.com")), None); | |
| 2500 | + | let unkeyed = crypto::invite_proof(b"", "inv_1", "ada@example.com"); | |
| 2501 | + | assert!(!proves_email(b"", "inv_1", Some("ada@example.com"), "ada@example.com", Some(&unkeyed))); | |
| 2502 | + | } | |
| 2503 | + | ||
| 2504 | + | #[test] | |
| 2505 | + | fn an_account_starts_confirmed_only_from_the_invite_email_to_its_address() { | |
| 2506 | + | let invite = row(None, false, LATER); | |
| 2507 | + | let proof = email_proof(KEY, &invite.id, invite.email.as_deref()).unwrap(); | |
| 2508 | + | // From the invite email, with the address it was sent to. | |
| 2509 | + | assert!(starts_confirmed(KEY, false, Some(&invite), "ada@example.com", Some(&proof))); | |
| 2510 | + | // The code alone (typed in, or a link passed on), or a bad proof. | |
| 2511 | + | assert!(!starts_confirmed(KEY, false, Some(&invite), "ada@example.com", None)); | |
| 2512 | + | assert!(!starts_confirmed(KEY, false, Some(&invite), "ada@example.com", Some("0123"))); | |
| 2513 | + | // A different address than the invite's. | |
| 2514 | + | assert!(!starts_confirmed(KEY, false, Some(&invite), "eve@example.com", Some(&proof))); | |
| 2515 | + | // No invite (open registration, or a shared link), or one bound to no address. | |
| 2516 | + | assert!(!starts_confirmed(KEY, false, None, "ada@example.com", Some(&proof))); | |
| 2517 | + | let unbound = InviteRow { email: None, ..row(None, false, LATER) }; | |
| 2518 | + | assert!(!starts_confirmed(KEY, false, Some(&unbound), "ada@example.com", Some(&proof))); | |
| 2519 | + | // A workspace invite makes no account. | |
| 2520 | + | let join = InviteRow { kind: "workspace".into(), ..row(None, false, LATER) }; | |
| 2521 | + | assert!(!starts_confirmed(KEY, false, Some(&join), "ada@example.com", Some(&proof))); | |
| 2522 | + | // GitHub's confirmed address, whatever else. | |
| 2523 | + | assert!(starts_confirmed(KEY, true, None, "ada@example.com", None)); | |
| 2524 | + | } | |
| 2525 | + | ||
| 2399 | 2526 | #[test] | |
| 2400 | 2527 | fn addresses_are_checked_and_masked() { | |
| 2401 | 2528 | assert_eq!(normalize_email(" Ada@Example.COM ").as_deref(), Some("ada@example.com")); | |
| 427 | 427 | password_hash: &password_hash, | |
| 428 | 428 | verified: false, | |
| 429 | 429 | invite_code, | |
| 430 | + | email_proof: a.email_proof.as_deref(), | |
| 430 | 431 | client: a.client.as_deref(), | |
| 431 | 432 | }) | |
| 432 | 433 | .await? |
| 379 | 379 | expires_at: link.expires_at.clone(), | |
| 380 | 380 | shared_label: Some(link.label.clone()), | |
| 381 | 381 | shared_domains: link.domains(), | |
| 382 | + | email_proven: false, | |
| 382 | 383 | } | |
| 383 | 384 | } | |
| 384 | 385 |