Skip to content

Commit

Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm

syntaqxcommitted Parentsfe7d232ac69112Browse files
16 files+419−460/16 viewed
+42−10
1212 hyphens, up to 39 characters.
1313
1414 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)).
1618
1719 Accounts can only be created in a browser. There is no API for it, by
1820 design: it keeps passwords out of scripts and agents, and lets g1t protect
130132
131133 1. **No account yet**: sign up on the page. When the invite was sent to
132134 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
134139 [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.
140143 2. **The address already has an account**: select **Sign in to accept**.
141144 After you sign in, the invite is accepted for you.
142145 3. **Signed in as someone else**: an invite sent to one address works only
161164 An expired, revoked or used invite says which, and who sent it, so you
162165 can ask them for a new one; or ask for access from the same page.
163166
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+
164188 ### Invite links for a group
165189
166190 g1t sometimes hands one link to a group: an event's judges, readers of a
209233 An owner can invite an email address straight into a workspace from its
210234 People page; see [members and roles](/guides/workspaces/#members-and-roles).
211235 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
214238 already on g1t costs nothing.
215239
216240 ### Need more invites?
245269 ## Confirming your email address
246270
247271 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`
249274 with two ways to confirm it, either one enough:
250275
251276 - a **six-digit code**, shown large in the email (and in its subject, so a
294319 is one GitHub has verified, so GitHub has already proved the inbox is
295320 yours, and no code is sent.
296321
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+
297329 ### Accounts that never confirmed
298330
299331 Accounts are confirmed once and stay confirmed. An account made before this
+3−2
333333
334334 The email names you and the workspace and links to the invite's page.
335335 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
338339 a one-time welcome. Until a new account confirms its address, its invite
339340 shows as **confirming their email** under the members, and you can still
340341 revoke it. See
+41−0
66 HAVE_AN_INVITE,
77 INVITES_CONTACT,
88 cleanCode,
9+ cleanProof,
10+ invitePath,
11+ inviteSignUpCopy,
912 inviteFor,
1013 inviteLink,
1114 inviteState,
4548 assert.equal(inviteLink(CODE, "http://localhost:8787/"), `http://localhost:8787/invite/${CODE}`);
4649 });
4750
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+
4889 test("a pasted link or code is tidied to the code", () => {
4990 assert.equal(cleanCode(CODE), CODE);
5091 assert.equal(cleanCode(` ${CODE} `), CODE);
+61−0
6565 return text.replace(/\s+/g, "").slice(0, 80);
6666 }
6767
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+
68129 type Listed = {
69130 status: "pending" | "awaiting_confirmation" | "redeemed" | "expired" | "revoked";
70131 redeemedBy: string | null;
+33−15
1−import { CircleAlert, Lock, Ticket } from "lucide-react";
1+import { CircleAlert, Lock, MailCheck, Ticket } from "lucide-react";
22 import { Form, Link, data, redirect } from "react-router";
33
44 import type { InvitePreview, User } from "@g1t/contracts";
1111 import { Avatar, ButtonLink, ErrorText, Field, Input, SubmitButton } from "../components/ui";
1212 import { githubSignInEnabled } from "../lib/github.server";
1313 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";
1515 import { clientKey } from "../lib/registration.server";
1616 import { assertSameOrigin, getViewer, requireUser, roleIn, startSession } from "../lib/session.server";
1717 import { rememberWorkspace } from "../lib/workspace-choice";
2929 * an address that has an account), and the workspace or repository it
3030 * gives. Signing in or up elsewhere (GitHub, /login) comes back here with
3131 * `?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.
3237 */
3338 export async function loader({ request, context, params }: Route.LoaderArgs) {
3439 const code = cleanCode(params.code);
3540 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 });
3845 const invite = checked.ok ? checked.value : null;
3946 // A shared link for a group signs up on /register, which names the group.
4047 if (invite?.sharedLabel) throw redirect(`/register?invite=${encodeURIComponent(code)}`);
6168 alreadyIn: viewer && invite ? alreadyIn(viewer, invite) : false,
6269 github: false,
6370 suggestion: suggestUsername(invite?.address),
71+ // Only a proof identity accepted goes back into the form.
72+ proof: invite?.emailProven ? emailProof : null,
6473 started: Date.now(),
6574 acceptError: null as string | null,
6675 };
95104 const code = cleanCode(params.code);
96105 const form = await request.formData();
97106 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 });
99109 if (!checked.ok) return data({ error: checked.error.message }, { status: 422 });
100110 const invite = checked.value;
101111
110120 String(form.get("password") ?? ""),
111121 code,
112122 client,
123+ // Identity checks it again, against this invite and this address.
124+ emailProof,
113125 );
114126 if (!result.ok) return data({ error: result.error.message }, { status: 422 });
115127 throw landIn(request, invite, [startSession(result.value.sessionToken)], true);
202214 const here = `/invite/${loaded.code}`;
203215 const back = `${here}?accept=1`;
204216 const github = `/auth/github?${new URLSearchParams({ invite: loaded.code, next: back })}`;
217+ const copy = inviteSignUpCopy(invite);
205218 return (
206219 <section aria-labelledby="sign-up" className="rounded-xl border border-line bg-surface/60 p-5 sm:p-6">
207220 <h2 id="sign-up" className="text-base font-semibold">
208221 Create your account
209222 </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+ )}
217230 {loaded.github && (
218231 <div className="mt-5">
219232 <ContinueWithGithub href={github} />
223236 <Form method="post" className={`relative space-y-4 ${loaded.github ? "" : "mt-5"}`}>
224237 <input type="hidden" name="intent" value="register" />
225238 <Honeypot started={loaded.started} />
239+ {loaded.proof && <input type="hidden" name="proof" value={loaded.proof} />}
226240 {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}>
228242 <span className="relative block">
229243 <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+ )}
231249 </span>
232250 </Field>
233251 ) : (
234− <Field label="Email" hint="We email it a code to confirm it before you start.">
252+ <Field label="Email" hint={copy.hint}>
235253 <Input name="email" type="email" autoComplete="email" required maxLength={254} />
236254 </Field>
237255 )}
+6−2
1111 import { githubSignInEnabled } from "../lib/github.server";
1212 import { Avatar, Button, ErrorText, Field, Input, SubmitButton } from "../components/ui";
1313 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";
1515 import { clientKey, registrationMode } from "../lib/registration.server";
1616 import {
1717 assertSameOrigin,
3636 // where it leads; it signs up, joins and lands in one go. A shared link
3737 // for a group signs up here: it joins nothing, and the form says which
3838 // 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+ }
4044 // Signing up with GitHub carries the invite code and `next` through it.
4145 const params = new URLSearchParams();
4246 if (code) params.set("invite", code);
+15−0
245245 /// registration is open.
246246 #[serde(default)]
247247 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>,
248253 /// Who is asking, such as the visitor's IP address, for rate limits.
249254 #[serde(default)]
250255 pub client: Option<String>,
12781283 pub viewer: Option<User>,
12791284 #[serde(default)]
12801285 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>,
12811290 }
12821291
12831292 /// Someone shown on an invite.
13331342 /// domains, such as `["cloudflare.com"]`. Empty for any address.
13341343 #[serde(default)]
13351344 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,
13361351 }
13371352
13381353 /// `accept_invite`: a signed-in person uses a workspace invite made for
+10−2
4141 export function identityClient(service: ServiceBinding): IdentityApi {
4242 const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
4343 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+ }),
4653 signIn: (username, password, client) => call("sign_in", { username, password, client: client ?? null }),
4754 twoFactorSignIn: (challenge, code, client) => call("two_factor_sign_in", { challenge, code, client: client ?? null }),
4855 signOut: (sessionToken) => call("sign_out", { sessionToken }),
153160 client: client ?? null,
154161 viewer: options.viewer ?? null,
155162 any_status: options.anyStatus ?? false,
163+ email_proof: options.emailProof ?? null,
156164 }),
157165 acceptInvite: (user, code) => call("accept_invite", { user, code }),
158166 inviteMember: (actor, slug, email) => call("invite_member", { actor, slug, email }),
+16−2
281281 sharedLabel: string | null;
282282 /** The email domains a shared invite link is limited to; empty for any address. */
283283 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;
284291 };
285292
286293 export type WaitlistStatus = "waiting" | "invited" | "dismissed";
702709 inviteCode?: string | null,
703710 /** Who is asking, such as the visitor's IP address, for rate limits. */
704711 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,
705718 ): Promise<Result<{ user: User; sessionToken: string }>>;
706719 /** Verifies a username and password for website sign-in. */
707720 /**
890903 /**
891904 * What a code is for. Unknown, used, revoked and expired codes all get the
892905 * 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`.
894908 */
895909 checkInvite(
896910 code: string,
897911 client?: string | null,
898− options?: { viewer?: User | null; anyStatus?: boolean },
912+ options?: { viewer?: User | null; anyStatus?: boolean; emailProof?: string | null },
899913 ): Promise<Result<InvitePreview>>;
900914 /**
901915 * A signed-in person uses a workspace invite sent to their address, or one
+4−0
759759 &full_name(&repo),
760760 a.role.label(),
761761 None,
762+ None,
762763 INVITATION_DAYS,
763764 )
764765 .await
804805 let id = self
805806 .insert_invitation(repo, workspace_id, None, Some(email), Some(&invite.id), role, &actor.id, days)
806807 .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);
807810 if let Some(code) = &invite.code
808811 && let Err(error) = crate::email::send_repo_invite(
809812 &self.env,
812815 &full_name(repo),
813816 role.label(),
814817 Some(code),
818+ proof.as_deref(),
815819 days,
816820 )
817821 .await
+15−0
4646 hex::encode(mac.finalize().into_bytes())
4747 }
4848
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+
4964 /// Whether two strings are equal, in time that depends on their length only.
5065 pub fn same(a: &str, b: &str) -> bool {
5166 a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0
+33−4
288288 pub workspace: Option<&'a str>,
289289 pub joins_existing_account: bool,
290290 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>,
291296 pub days: u64,
292297 /// A line from whoever sent it, such as staff approving a request.
293298 pub note: Option<&'a str>,
294299 }
295300
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+
296311 /// The subject and letter of an invite email.
297312 pub fn invite_letter(invite: &InviteEmail, site: &str) -> (String, Letter) {
298313 let (subject, intro) = invite_wording(invite.from, invite.workspace, invite.joins_existing_account);
314329 paragraphs: vec![intro],
315330 quotes,
316331 code: None,
317− action: Some((action, format!("{site}/invite/{}", invite.code))),
332+ action: Some((action, invite_link(site, invite.code, invite.proof))),
318333 footer: format!(
319334 "This invite works for {} days, only for this address. If you were not expecting it, you can ignore this message.",
320335 invite.days
413428 }
414429
415430 /// 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.
418434 pub async fn send_repo_invite(
419435 env: &Env,
420436 to: &str,
422438 repo: &str,
423439 role: &str,
424440 code: Option<&str>,
441+ proof: Option<&str>,
425442 days: u64,
426443 ) -> Result<()> {
427444 let (subject, intro) = repo_invite_wording(from, repo, role, code.is_some());
428445 let link = match code {
429− Some(code) => format!("{}/invite/{code}", site(env)),
446+ Some(code) => invite_link(&site(env), code, proof),
430447 None => format!("{}/{repo}/invitations", site(env)),
431448 };
432449 send_link(
574591 workspace: Some("Flagon, Inc."),
575592 joins_existing_account: false,
576593 code: "g1t-abcd",
594+ proof: None,
577595 days: 30,
578596 note,
579597 }
596614 }
597615
598616 #[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]
599626 fn links_point_at_the_site_they_are_given() {
600627 let (_, letter) = invite_letter(&invite(None, None), "http://localhost:8787");
601628 assert_eq!(letter.action.as_ref().unwrap().1, "http://localhost:8787/invite/g1t-abcd");
688715 workspace: Some("Flagon, Inc."),
689716 joins_existing_account: false,
690717 code: "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk",
718+ proof: None,
691719 days: 30,
692720 note: None,
693721 }, SITE);
698726 workspace: None,
699727 joins_existing_account: false,
700728 code: "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk",
729+ proof: None,
701730 days: 30,
702731 note: Some("Thanks for waiting. We would love to see the compiler."),
703732 }, SITE);
+2−0
584584 password_hash: "",
585585 verified: true,
586586 invite_code,
587+ // GitHub has confirmed the address already.
588+ email_proof: None,
587589 client: None,
588590 })
589591 .await?
+136−9
233233 }
234234 }
235235
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+
236268 /// What an invite used to sign up does once its account confirms its
237269 /// address.
238270 #[derive(Clone, Debug, PartialEq, Eq)]
439471 /// Whether the address is confirmed already (GitHub's verified email).
440472 pub verified: bool,
441473 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>,
442478 /// Who is asking, for rate limits.
443479 pub client: Option<&'a str>,
444480 }
498534 Sealer::new(&self.env.secret("IDENTITY_KEY").ok()?.to_string())
499535 }
500536
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+
501556 // --- Rate limits ---
502557
503558 /// Counts one more hit on `key` this hour; false once past `limit`.
693748 /// invite-only, `invite_code` must admit `email`; the code is spent in
694749 /// the same transaction as the account is made. What the invite gives
695750 /// (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
697753 /// in the transaction that confirms it (emails.rs, `confirm_address`).
698754 /// In open mode a code is used if it is good and otherwise ignored.
699755 pub async fn create_account(&self, new: NewAccount<'_>) -> Result<Outcome<User>> {
739795 }
740796 }
741797
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);
746804 let user = User {
747805 id: new_id("usr", now_ms()),
748806 username: new.username.to_owned(),
823881 self.count_failure(new.client).await?;
824882 return Ok(Outcome::fail(FailureCode::Forbidden, INVALID));
825883 }
826− // Confirmed already (GitHub): what the invite gives, now. Otherwise
884+ // Confirmed already (GitHub, or the invite email): what the invite gives, now. Otherwise
827885 // it waits, spent, for the address to be confirmed.
828886 if let Some(row) = invite
829887 && user.verified
11691227 };
11701228 if let (Some(email), Some(code)) = (&email, &invite.code) {
11711229 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;
11731231 }
11741232 let logs: Vec<String> = a.user.workspaces.iter().map(|membership| membership.slug.clone()).collect();
11751233 self.audit_invites(&a.user, "invite.created", logs, a.surface.unwrap_or(Surface::Web), format!("Created invite {}", invite.hint))
11841242 workspace: Option<&str>,
11851243 existing: bool,
11861244 code: &str,
1245+ invite_id: &str,
11871246 note: Option<&str>,
11881247 ) {
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) };
11891252 let invite = crate::email::InviteEmail {
11901253 to,
11911254 from,
11921255 workspace,
11931256 joins_existing_account: existing,
11941257 code,
1258+ proof: proof.as_deref(),
11951259 days: self.invite_ttl_days(),
11961260 note,
11971261 };
13491413 _ => false,
13501414 };
13511415 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()));
13521421 #[derive(Deserialize)]
13531422 struct From {
13541423 username: String,
13911460 expires_at: row.expires_at,
13921461 shared_label: None,
13931462 shared_domains: Vec::new(),
1463+ email_proven,
13941464 }))
13951465 }
13961466
15501620 if let Some(code) = &invite.code {
15511621 let from = self.display_name(&a.actor).await;
15521622 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;
15541624 }
15551625 self.audit_invites(
15561626 &a.actor,
19532023 return Ok(Outcome::fail(FailureCode::Conflict, "The invite could not be made. Try again."));
19542024 };
19552025 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;
19572027 }
19582028 invite.staff = Some(staff.to_owned());
19592029 Ok(Outcome::Ok(invite))
23962466 assert_eq!(admits(Some(&account), "ada@example.com", false), Ok(()));
23972467 }
23982468
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+
23992526 #[test]
24002527 fn addresses_are_checked_and_masked() {
24012528 assert_eq!(normalize_email(" Ada@Example.COM ").as_deref(), Some("ada@example.com"));
+1−0
427427 password_hash: &password_hash,
428428 verified: false,
429429 invite_code,
430+ email_proof: a.email_proof.as_deref(),
430431 client: a.client.as_deref(),
431432 })
432433 .await?
+1−0
379379 expires_at: link.expires_at.clone(),
380380 shared_label: Some(link.label.clone()),
381381 shared_domains: link.domains(),
382+ email_proven: false,
382383 }
383384 }
384385