Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

796 lines · 43.4 KBRaw History
1/** @jsxImportSource hono/jsx */
2import {
3 generateAuthenticationOptions,
4 generateRegistrationOptions,
5 verifyAuthenticationResponse,
6 verifyRegistrationResponse,
7 type AuthenticationResponseJSON,
8 type AuthenticatorTransport,
9 type RegistrationResponseJSON,
10} from "@simplewebauthn/server";
11import { deleteCookie, getCookie, setCookie } from "hono/cookie";
12import { raw } from "hono/html";
13import type { FC, PropsWithChildren } from "hono/jsx";
14import { RECENT_SIGNIN_MS, accessToken, requireRecentSignIn, type AppContext } from "../auth/principal.js";
15import { Auth, CHALLENGE_COOKIE, SESSION_COOKIE, SESSION_DAYS, type PasskeyRow, type Subject } from "../auth/sessions.js";
16import type { Account } from "../lib/directory.js";
17import { verifyAccessJwt } from "../lib/jwt.js";
18import { HttpError, b64url, fromB64url, isValidLogin, newId, now, sha256 } from "../lib/util.js";
19import { ICONS } from "./assets.js";
20import { Form, act, confirmUrl, flash, post, prm, render, statusOf, type WebApp } from "./ui.js";
21import { csrfToken } from "./csrf.js";
22import { validInviteProof } from "../auth/invite-proof.js";
23import { CODE_MINUTES, Signups, normalEmail, signupOpen, turnstileMode, verifyTurnstile } from "../auth/signup.js";
24import { enqueueMail } from "../mail/send.js";
25
26/**
27 * Passkey (WebAuthn) sign-in. Browsers sign in with a passkey and get a
28 * session cookie. New people create their passkey from an invite link (or,
29 * with SIGNUP=open, after confirming their email at /signup), and admins can
30 * issue one-time sign-in links to add a passkey to an existing account. Cloudflare Access, when configured, protects only /auth/access and
31 * serves as an email-based recovery path.
32 */
33
34const CEREMONY_TIMEOUT = 120_000;
35
36/**
37 * `path` when it is a path on this site, otherwise null. Browsers drop tabs and newlines
38 * from URLs and read a backslash as `/`, so `/<tab>/evil.com` and `/\evil.com` lead off-site.
39 */
40export function localPath(path: unknown): string | null {
41 if (typeof path !== "string" || /[\x00-\x1f\x7f]/.test(path)) return null;
42 return /^\/(?![/\\])/.test(path) ? path : null;
43}
44
45/** Only same-site relative paths are allowed as post-sign-in destinations. */
46export function safeNext(next: unknown): string {
47 return localPath(next) ?? "/";
48}
49
50const str = (v: unknown): string => (typeof v === "string" ? v : "");
51
52/** A login suggestion from an email address. */
53export function loginFromEmail(email: string): string {
54 return (email.split("@")[0] ?? "").replace(/[^A-Za-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 39) || "user";
55}
56
57/** A friendly default name for a new passkey, from the browser's user agent. */
58export function deviceLabel(ua: string | undefined): string {
59 const u = ua ?? "";
60 const os = /iPhone/.test(u) ? "iPhone" : /iPad/.test(u) ? "iPad" : /Android/.test(u) ? "Android" : /Mac OS X|Macintosh/.test(u) ? "Mac" : /Windows/.test(u) ? "Windows" : /CrOS/.test(u) ? "ChromeOS" : /Linux/.test(u) ? "Linux" : "";
61 const browser = /Edg\//.test(u) ? "Edge" : /Firefox\//.test(u) ? "Firefox" : /Chrome\//.test(u) ? "Chrome" : /Safari\//.test(u) ? "Safari" : "";
62 return [os, browser].filter(Boolean).join(" · ") || "Passkey";
63}
64
65/** Where a sign-in came from, for the audit log and the "new sign-in" email. */
66export function signinContext(c: AppContext): { device: string; ip?: string; location?: string } {
67 const cf = (c.req.raw as { cf?: { city?: string; region?: string; country?: string } }).cf;
68 const location = [cf?.city, cf?.country].filter(Boolean).join(", ") || undefined;
69 return { device: deviceLabel(c.req.header("user-agent")), ip: c.req.header("cf-connecting-ip") ?? undefined, location };
70}
71
72/** Sign this browser in; returns the new session's id. */
73async function startSession(c: AppContext, auth: Auth, accountId: string, method: "passkey" | "access" | "link", passkeyId: string | null): Promise<string> {
74 const old = getCookie(c, SESSION_COOKIE);
75 if (old) await auth.endSession(old);
76 const token = await auth.createSession(accountId, method, passkeyId, c.req.header("user-agent") ?? null);
77 setCookie(c, SESSION_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "Lax", maxAge: SESSION_DAYS * 86400 });
78 return sha256(token);
79}
80
81/** What recovering an account (by email, or with a sign-in link) does to the account's other browsers. */
82const RECOVERY_NOTE = "Every other browser signed in to your account was signed out; your access tokens keep working.";
83
84function setChallenge(c: AppContext, token: string): void {
85 setCookie(c, CHALLENGE_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "Strict", maxAge: 300 });
86}
87
88function takeChallengeCookie(c: AppContext): string | undefined {
89 const v = getCookie(c, CHALLENGE_COOKIE);
90 deleteCookie(c, CHALLENGE_COOKIE, { path: "/", secure: true });
91 return v;
92}
93
94/** JSON endpoint for the browser's passkey script: JSON in, JSON out, JSON errors. */
95function jsonPost(web: WebApp, path: string, handler: (c: AppContext, body: Record<string, unknown>) => Promise<Response>): void {
96 web.post(path, async (c) => {
97 try {
98 // A JSON content type forces a CORS preflight, so other sites cannot drive these endpoints.
99 if (!/^application\/json\b/i.test(c.req.header("content-type") ?? "")) throw new HttpError(415, "expected JSON");
100 const body = await c.req.json<Record<string, unknown>>().catch(() => {
101 throw new HttpError(400, "invalid JSON");
102 });
103 if (!body || typeof body !== "object") throw new HttpError(400, "invalid JSON");
104 return await handler(c, body);
105 } catch (e) {
106 const s = statusOf(e);
107 if (s.status >= 500) console.error(e);
108 const confirm = confirmUrl(e);
109 return c.json({ message: s.status >= 500 ? "Something went wrong. Please try again." : s.message, ...(confirm ? { confirm } : {}) }, s.status as 400);
110 }
111 });
112}
113
114const transportsOf = (k: PasskeyRow): AuthenticatorTransport[] | undefined => (k.transports ? (JSON.parse(k.transports) as AuthenticatorTransport[]) : undefined);
115
116// ---------------------------------------------------------------- pages
117
118const KeyMark: FC = () => (
119 <div class="authmark" aria-hidden="true">
120 {raw(ICONS.key)}
121 </div>
122);
123
124/** Error slot that the passkey script fills in. */
125const PasskeyMessage: FC = () => <div class="flash error" data-passkey-msg role="alert" hidden></div>;
126
127const AuthCard: FC<PropsWithChildren<{ title: string }>> = ({ title, children }) => (
128 <div class="authcard box">
129 <div class="body">
130 <KeyMark />
131 <h1>{title}</h1>
132 {children}
133 </div>
134 </div>
135);
136
137function accessConfigured(c: AppContext): boolean {
138 return !!(c.env.ACCESS_TEAM_DOMAIN && c.env.ACCESS_AUD);
139}
140
141/** Username and display name for a new account (invites and open sign-up). */
142const NewAccountFields: FC<{ email: string }> = ({ email }) => (
143 <>
144 <div class="field">
145 <label for="login">Username</label>
146 <input id="login" name="login" type="text" value={loginFromEmail(email)} required pattern="[A-Za-z0-9][A-Za-z0-9_\-]{0,38}" autocomplete="username" />
147 <div class="hint">Your repositories live at /{"{username}"}/…</div>
148 </div>
149 <div class="field">
150 <label for="name">Display name</label>
151 <input id="name" name="name" type="text" maxlength={100} autocomplete="name" />
152 </div>
153 </>
154);
155
156/** The username and display name a new account asked for, once they check out. */
157async function newAccountNames(c: AppContext, body: Record<string, unknown>, email: string): Promise<{ login: string; name: string }> {
158 const login = str(body.login).trim() || loginFromEmail(email);
159 if (!isValidLogin(login) || login.length > 39) throw new HttpError(422, "Usernames use letters, numbers and dashes (up to 39), and some names are reserved.");
160 if (await c.get("dir").loginTaken(login)) throw new HttpError(409, `The username ${login} is taken. Pick another one.`);
161 return { login, name: str(body.name).trim().slice(0, 100) };
162}
163
164/**
165 * Runs on the invite page: an invitation email carries its proof in the URL's fragment
166 * (`#proof=...`), which browsers never send, so it stays out of request logs. The page posts it
167 * to the server in a form body instead, with the fragment taken out of the address bar first.
168 */
169const INVITE_PROOF_SCRIPT = `(function(){function go(){var m=/^#proof=([A-Za-z0-9_-]{16,128})$/.exec(location.hash);var f=document.getElementById("invite-proof");if(!m||!f)return;history.replaceState(null,"",location.pathname);f.elements.proof.value=m[1];f.submit();}go();addEventListener("hashchange",go);})();`;
170
171/** Runs on /signin: takes a sign-in link's token from the fragment and shows which account it adds a passkey to. */
172const SIGNIN_LINK_SCRIPT = `(function(){function go(){var s=document.getElementById("signin-link-status"),f=document.getElementById("signin-link-form");var t=location.hash.slice(1);if(!t&&f.elements.link.value)return;if(!/^[A-Za-z0-9_-]{20,100}$/.test(t)){s.textContent="This link is incomplete. Open it again from the email or message it came in, in full.";f.style.display="none";return;}history.replaceState(null,"",location.pathname);fetch("/auth/passkey/link-info",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({link:t}),credentials:"same-origin"}).then(function(r){return r.json().then(function(j){if(!r.ok)throw new Error(j.message||"This sign-in link is invalid, already used, or expired.");return j;});}).then(function(j){s.textContent="This link lets you add a passkey to "+j.login+(j.email?" ("+j.email+")":"")+". It works once. After that, sign in with the passkey.";f.elements.link.value=t;f.style.display="";}).catch(function(e){s.textContent=e.message;f.style.display="none";});}go();addEventListener("hashchange",go);})();`;
173
174/** Invite landing for someone without an account: create a passkey and join in one step. */
175export async function invitePasskeyPage(c: AppContext, token: string, posted?: string): Promise<Response> {
176 const auth = new Auth(c.env);
177 const inv = await auth.invite(token);
178 if (!inv) throw new HttpError(404, "this invite is invalid, already used, or expired");
179 const next = `/invite/${encodeURIComponent(token)}`;
180 // The link alone isn't enough to create the account: the copy emailed to the address carries a proof.
181 // Ask for it before saying whether the address has an account, since the inviter has the link too.
182 // Emails sent now carry it in the fragment (posted here by INVITE_PROOF_SCRIPT); older ones in the query.
183 const proof = posted ?? c.req.query("proof");
184 if (inv.email && !(await validInviteProof(c.env, inv.hash, proof))) {
185 return render(
186 c,
187 { title: "Join Diffinery", script: posted === undefined ? INVITE_PROOF_SCRIPT : undefined },
188 <AuthCard title="Confirm your email">
189 <p class="muted">
190 This invitation is for <b>{inv.email}</b>. To make sure it's you, open the link in the invitation email sent to that address. If you can't find it, we can send it again.
191 </p>
192 <Form action={`/invite/${encodeURIComponent(token)}/proof`} t={csrfToken(c)} id="invite-proof" style="display:none">
193 <input type="hidden" name="proof" value="" />
194 </Form>
195 <Form action={`/invite/${encodeURIComponent(token)}/email`} t={csrfToken(c)}>
196 <button class="btn primary block">Email me the link</button>
197 </Form>
198 <p class="muted small">
199 Already have an account? <a href={`/login?next=${encodeURIComponent(next)}`}>Sign in</a> to accept.
200 </p>
201 </AuthCard>,
202 );
203 }
204 const exists = inv.email ? await c.get("dir").accountByEmail(inv.email) : null;
205 if (!inv.email || exists) {
206 return render(
207 c,
208 { title: "Accept invite" },
209 <AuthCard title="You have been invited">
210 <p class="muted">
211 {inv.email ? (
212 <>
213 <b>{inv.email}</b> already has a Diffinery account. Sign in with its passkey, and this page will let you accept the invite.
214 </>
215 ) : (
216 "Sign in to accept this invite."
217 )}
218 </p>
219 <a class="btn primary block" href={`/login?next=${encodeURIComponent(next)}`}>
220 Sign in
221 </a>
222 </AuthCard>,
223 );
224 }
225 return render(
226 c,
227 { title: "Join Diffinery" },
228 <AuthCard title="Join Diffinery">
229 <p class="muted">
230 You were invited as <b>{inv.email}</b>. Pick a username, then create a passkey. Your device keeps it and unlocks it with your fingerprint, face or screen lock, so there is no password to remember.
231 </p>
232 <form data-passkey="register" data-next="/">
233 <input type="hidden" name="invite" value={token} />
234 <input type="hidden" name="proof" value={proof} />
235 <NewAccountFields email={inv.email} />
236 <PasskeyMessage />
237 <button class="btn primary block">Create a passkey and join</button>
238 </form>
239 </AuthCard>,
240 );
241}
242
243const PasskeyUnsupported: FC = () => (
244 <noscript>
245 <p class="flash error">Passkeys need JavaScript.</p>
246 </noscript>
247);
248
249// ---------------------------------------------------------------- open sign-up (SIGNUP=open, auth/signup.ts)
250
251const TURNSTILE_MISSING = "Sign-up needs Turnstile configured: this instance has no TURNSTILE_SITE_KEY and TURNSTILE_SECRET. Ask its administrators for an invite.";
252
253const StepError: FC<{ error?: string }> = ({ error }) => (error ? <div class="flash error" role="alert">{error}</div> : null);
254
255/** Step 1: the email address (and the Turnstile widget). */
256function signupPage(c: AppContext, o: { email?: string; error?: string; status?: number } = {}): Response | Promise<Response> {
257 const mode = turnstileMode(c.env);
258 const siteKey = mode === "verify" ? c.env.TURNSTILE_SITE_KEY : undefined;
259 return render(
260 c,
261 { title: "Create an account", status: mode === "missing" ? 503 : o.status, turnstile: !!siteKey },
262 <AuthCard title="Create your Diffinery account">
263 {mode === "missing" ? (
264 <StepError error={TURNSTILE_MISSING} />
265 ) : (
266 <>
267 <p class="muted">Enter your email address. We'll send you a code to confirm it, then you pick a username and create a passkey. There is no password.</p>
268 <StepError error={o.error} />
269 <Form action="/signup" t={csrfToken(c)}>
270 <div class="field">
271 <label for="email">Email address</label>
272 <input id="email" name="email" type="email" value={o.email} required maxlength={254} autocomplete="email" />
273 </div>
274 {siteKey ? <div class="cf-turnstile" data-sitekey={siteKey} data-theme="auto" style="margin-bottom:12px"></div> : null}
275 <button class="btn primary block">Email me a code</button>
276 </Form>
277 </>
278 )}
279 <div class="authfoot muted small">
280 <p>
281 Already have an account? <a href="/login">Sign in</a>.
282 </p>
283 </div>
284 </AuthCard>,
285 );
286}
287
288/** Step 2: the code from the email. */
289function signupCodePage(c: AppContext, o: { email: string; error?: string; status?: number }): Response | Promise<Response> {
290 return render(
291 c,
292 { title: "Enter your code", status: o.status },
293 <AuthCard title="Check your email">
294 <p class="muted">
295 We sent a code to <b>{o.email}</b>. Enter it here within {CODE_MINUTES} minutes.
296 </p>
297 <StepError error={o.error} />
298 <Form action="/signup/verify" t={csrfToken(c)}>
299 <input type="hidden" name="email" value={o.email} />
300 <div class="field">
301 <label for="code">Code</label>
302 <input id="code" name="code" type="text" inputmode="numeric" pattern="[0-9]{6}" maxlength={6} required autocomplete="one-time-code" />
303 </div>
304 <button class="btn primary block">Continue</button>
305 </Form>
306 <div class="authfoot muted small">
307 <p>
308 No email? Check your spam folder, or <a href="/signup">send a new code</a>.
309 </p>
310 </div>
311 </AuthCard>,
312 );
313}
314
315/** Step 3: a username and a passkey, bound to the single-use ticket for the confirmed address. */
316function signupPasskeyPage(c: AppContext, email: string, ticket: string): Response | Promise<Response> {
317 return render(
318 c,
319 { title: "Join Diffinery" },
320 <AuthCard title="Join Diffinery">
321 <p class="muted">
322 <b>{email}</b> is confirmed. Pick a username, then create a passkey. Your device keeps it and unlocks it with your fingerprint, face or screen lock, so there is no password to remember.
323 </p>
324 <PasskeyUnsupported />
325 <form data-passkey="register" data-next="/">
326 <input type="hidden" name="signup" value={ticket} />
327 <NewAccountFields email={email} />
328 <PasskeyMessage />
329 <button class="btn primary block">Create a passkey and join</button>
330 </form>
331 </AuthCard>,
332 );
333}
334
335/** Run a sign-up step. A refusal shows on that step's page with its status, keeping what was typed. */
336async function signupStep(fn: () => Promise<Response>, page: (error: string, status: number) => Response | Promise<Response>): Promise<Response> {
337 try {
338 return await fn();
339 } catch (e) {
340 const s = statusOf(e);
341 if (s.status >= 500 && s.status !== 503) throw e;
342 return page(s.message, s.status);
343 }
344}
345
346export function registerAuth(web: WebApp): void {
347 web.get("/login", async (c) => {
348 const next = safeNext(c.req.query("next"));
349 const p = c.get("principal");
350 // A sensitive action asked a signed-in person to confirm it's them (requireRecentSignIn).
351 if (c.req.query("confirm") === "1" && p.type === "account" && p.via === "session" && !(p.sessionAt && Date.now() - Date.parse(p.sessionAt) < RECENT_SIGNIN_MS)) {
352 return render(
353 c,
354 { title: "Confirm it's you" },
355 <AuthCard title="Confirm it's you">
356 <p class="muted">
357 You're signed in as <b>{p.account.login}</b>. Adding passkeys, creating tokens and agents, and deleting or transferring repositories need a sign-in from the last 2 hours. Use your passkey to continue.
358 </p>
359 <PasskeyUnsupported />
360 <form data-passkey="login" data-next={next}>
361 <PasskeyMessage />
362 <button class="btn primary block">Confirm with a passkey</button>
363 </form>
364 <div class="authfoot muted small">
365 {accessConfigured(c) ? (
366 <p>
367 No passkey on this device? <a href={`/auth/access?next=${encodeURIComponent(next)}`}>Confirm by email</a>.
368 </p>
369 ) : null}
370 <p>
371 <a href={next}>Cancel</a>
372 </p>
373 </div>
374 </AuthCard>,
375 );
376 }
377 if (p.type === "account" && p.via !== "dev") return c.redirect(next, 302);
378 return render(
379 c,
380 // The pages people land on after signing in mount Kumo islands: fetch the bundle while they sign in.
381 { title: "Sign in", prefetchKumo: true },
382 <AuthCard title="Sign in to Diffinery">
383 <p class="muted">Use the passkey saved on this device, your phone or your password manager.</p>
384 <PasskeyUnsupported />
385 <form data-passkey="login" data-next={next}>
386 <PasskeyMessage />
387 <button class="btn primary block">Sign in with a passkey</button>
388 </form>
389 <div class="authfoot muted small">
390 {signupOpen(c.env) ? (
391 <p>
392 New here? <a href="/signup">Create an account</a>.
393 </p>
394 ) : (
395 <p>
396 New here? Diffinery is invite-only. Open the invite link someone sent you to create your passkey{turnstileMode(c.env) !== "missing" ? <>, or <a href="/early-access">request early access</a></> : null}.
397 </p>
398 )}
399 {accessConfigured(c) ? (
400 <p>
401 Lost your passkey? <a href={`/auth/access?next=${encodeURIComponent(next)}`}>Recover your account by email</a>. Recovering signs out every other browser; access tokens keep working.
402 </p>
403 ) : (
404 <p>Lost your passkey? Ask an administrator for a sign-in link.</p>
405 )}
406 </div>
407 </AuthCard>,
408 );
409 });
410
411 // Admin-issued sign-in link: add a passkey to an existing account. Links carry the token in the
412 // fragment (/signin#<token>), which browsers never send, so request logs never hold a usable copy:
413 // SIGNIN_LINK_SCRIPT reads it, takes it out of the address bar, and asks which account it is for.
414 web.get("/signin", (c) =>
415 render(
416 c,
417 { title: "Add a passkey", script: SIGNIN_LINK_SCRIPT },
418 <AuthCard title="Add a passkey">
419 <p class="muted" id="signin-link-status">
420 Checking this sign-in link…
421 </p>
422 <p class="muted small">Using it signs out every other browser signed in to this account, in case a lost device is still signed in. Access tokens keep working.</p>
423 <form data-passkey="register" data-next="/settings/security" id="signin-link-form" style="display:none">
424 <input type="hidden" name="link" value="" />
425 <div class="field">
426 <label for="label">Name for this passkey</label>
427 <input id="label" name="label" type="text" maxlength={100} placeholder={deviceLabel(c.req.header("user-agent"))} />
428 </div>
429 <PasskeyMessage />
430 <button class="btn primary block">Create a passkey</button>
431 </form>
432 </AuthCard>,
433 ),
434 );
435
436 // Which account a sign-in link is for, for the page above; the token comes in the body, never the URL.
437 jsonPost(web, "/auth/passkey/link-info", async (c, body) => {
438 const link = await new Auth(c.env).signinLink(str(body.link));
439 if (!link) throw new HttpError(404, "This sign-in link is invalid, already used, or expired.");
440 return c.json({ login: link.account.login, email: link.account.email ?? null });
441 });
442
443 // Links sent before the token moved to the fragment.
444 web.get("/signin/:token", async (c) => {
445 const token = prm(c, "token");
446 const link = await new Auth(c.env).signinLink(token);
447 if (!link) throw new HttpError(404, "this sign-in link is invalid, already used, or expired");
448 return render(
449 c,
450 { title: "Add a passkey" },
451 <AuthCard title="Add a passkey">
452 <p class="muted">
453 This link lets you add a passkey to <b>{link.account.login}</b>
454 {link.account.email ? ` (${link.account.email})` : ""}. It works once. After that, sign in with the passkey.
455 </p>
456 <p class="muted small">Using it signs out every other browser signed in to this account, in case a lost device is still signed in. Access tokens keep working.</p>
457 <form data-passkey="register" data-next="/settings/security">
458 <input type="hidden" name="link" value={token} />
459 <div class="field">
460 <label for="label">Name for this passkey</label>
461 <input id="label" name="label" type="text" maxlength={100} placeholder={deviceLabel(c.req.header("user-agent"))} />
462 </div>
463 <PasskeyMessage />
464 <button class="btn primary block">Create a passkey</button>
465 </form>
466 </AuthCard>,
467 );
468 });
469
470 // ------------------------------------------------------------ sign in
471
472 jsonPost(web, "/auth/passkey/login/options", async (c) => {
473 const auth = new Auth(c.env);
474 const rp = auth.rp();
475 // No allowCredentials: the browser offers any passkey it holds for this site (discoverable credentials).
476 const opts = await generateAuthenticationOptions({ rpID: rp.id, userVerification: "required", timeout: CEREMONY_TIMEOUT });
477 setChallenge(c, await auth.saveChallenge("login", opts.challenge, { kind: "login" }));
478 return c.json(opts);
479 });
480
481 jsonPost(web, "/auth/passkey/login/verify", async (c, body) => {
482 const auth = new Auth(c.env);
483 const dir = c.get("dir");
484 const rp = auth.rp();
485 const ch = await auth.takeChallenge(takeChallengeCookie(c), "login");
486 if (!ch) throw new HttpError(400, "This sign-in attempt expired. Please try again.");
487 const response = body.response as AuthenticationResponseJSON | undefined;
488 if (!response || typeof response.id !== "string") throw new HttpError(400, "missing passkey response");
489 const key = await auth.passkey(response.id);
490 if (!key) throw new HttpError(401, "This passkey is not registered with Diffinery. It may have been removed.");
491 const result = await verifyAuthenticationResponse({
492 response,
493 expectedChallenge: ch.challenge,
494 expectedOrigin: rp.origin,
495 expectedRPID: rp.id,
496 requireUserVerification: true,
497 credential: { id: key.id, publicKey: new Uint8Array(fromB64url(key.public_key)), counter: key.counter, transports: transportsOf(key) },
498 }).catch((e: Error) => {
499 throw new HttpError(401, `Passkey check failed: ${e.message}`);
500 });
501 if (!result.verified) throw new HttpError(401, "Passkey check failed.");
502 const account = await dir.account(key.account_id);
503 if (!account) throw new HttpError(401, "This passkey's account no longer exists.");
504 if (account.status === "disabled") throw new HttpError(403, "Your account is disabled.");
505 await auth.passkeyUsed(key.id, result.authenticationInfo.newCounter, result.authenticationInfo.credentialBackedUp);
506 await startSession(c, auth, account.id, "passkey", key.id);
507 c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "session.create", detail: { method: "passkey", passkey: key.name, passkeyId: key.id, ...signinContext(c) } }));
508 return c.json({ redirect: safeNext(body.next) });
509 });
510
511 // ------------------------------------------------------------ register a passkey
512
513 jsonPost(web, "/auth/passkey/register/options", async (c, body) => {
514 const auth = new Auth(c.env);
515 const dir = c.get("dir");
516 const rp = auth.rp();
517 let subject: Subject;
518 let user: { id: string; name: string; displayName: string };
519 let existing: PasskeyRow[] = [];
520 const inviteToken = str(body.invite);
521 const linkToken = str(body.link);
522 const signupToken = str(body.signup);
523 if (inviteToken) {
524 const inv = await auth.invite(inviteToken);
525 if (!inv) throw new HttpError(404, "This invite is invalid, already used, or expired.");
526 if (!inv.email) throw new HttpError(422, "This invite has no email address. Sign in first, then open it again.");
527 if (!(await validInviteProof(c.env, inv.hash, str(body.proof)))) throw new HttpError(403, `Open the link in the invitation email sent to ${inv.email} to create your account.`);
528 if (await dir.accountByEmail(inv.email)) throw new HttpError(409, `${inv.email} already has an account. Sign in with its passkey, then open this invite again.`);
529 const { login, name } = await newAccountNames(c, body, inv.email);
530 const id = newId();
531 subject = { kind: "invite", inviteHash: inv.hash, email: inv.email, login, name, accountId: id };
532 user = { id, name: login, displayName: name || login };
533 } else if (signupToken) {
534 if (!signupOpen(c.env)) throw new HttpError(404, "Sign-up is closed on this instance.");
535 const signups = new Signups(c.env);
536 const ticket = await signups.ticket(signupToken);
537 if (!ticket) throw new HttpError(404, "This sign-up expired or was already used. Start again at /signup.");
538 if (await signups.accountFor(ticket.email)) throw new HttpError(409, `${ticket.email} already has an account. Sign in with its passkey.`);
539 const { login, name } = await newAccountNames(c, body, ticket.email);
540 const id = newId();
541 subject = { kind: "signup", ticketHash: ticket.hash, email: ticket.email, login, name, accountId: id };
542 user = { id, name: login, displayName: name || login };
543 } else if (linkToken) {
544 const link = await auth.signinLink(linkToken);
545 if (!link) throw new HttpError(404, "This sign-in link is invalid, already used, or expired.");
546 if (link.account.status === "disabled") throw new HttpError(403, "This account is disabled.");
547 subject = { kind: "link", linkHash: link.hash, accountId: link.account.id };
548 user = { id: link.account.id, name: link.account.login, displayName: link.account.name || link.account.login };
549 existing = await auth.passkeys(link.account.id);
550 } else {
551 const me = browserAccount(c);
552 requireRecentSignIn(c, "/settings/security");
553 subject = { kind: "account", accountId: me.id };
554 user = { id: me.id, name: me.login, displayName: me.name || me.login };
555 existing = await auth.passkeys(me.id);
556 }
557 const opts = await generateRegistrationOptions({
558 rpName: rp.name,
559 rpID: rp.id,
560 userName: user.name,
561 userDisplayName: user.displayName,
562 userID: new Uint8Array(new TextEncoder().encode(user.id)),
563 attestationType: "none",
564 excludeCredentials: existing.map((k) => ({ id: k.id, transports: transportsOf(k) })),
565 authenticatorSelection: { residentKey: "required", userVerification: "required" },
566 timeout: CEREMONY_TIMEOUT,
567 });
568 setChallenge(c, await auth.saveChallenge("register", opts.challenge, subject));
569 return c.json(opts);
570 });
571
572 jsonPost(web, "/auth/passkey/register/verify", async (c, body) => {
573 const auth = new Auth(c.env);
574 const dir = c.get("dir");
575 const rp = auth.rp();
576 const ch = await auth.takeChallenge(takeChallengeCookie(c), "register");
577 if (!ch) throw new HttpError(400, "This attempt expired. Please try again.");
578 const response = body.response as RegistrationResponseJSON | undefined;
579 if (!response || typeof response.id !== "string") throw new HttpError(400, "missing passkey response");
580 const result = await verifyRegistrationResponse({ response, expectedChallenge: ch.challenge, expectedOrigin: rp.origin, expectedRPID: rp.id, requireUserVerification: true }).catch((e: Error) => {
581 throw new HttpError(400, `The passkey could not be verified: ${e.message}`);
582 });
583 if (!result.verified) throw new HttpError(400, "The passkey could not be verified.");
584 const info = result.registrationInfo;
585 if (await auth.passkey(info.credential.id)) throw new HttpError(409, "This passkey is already registered.");
586 const label = str(body.label).trim() || deviceLabel(c.req.header("user-agent"));
587 const key = {
588 id: info.credential.id,
589 public_key: b64url(info.credential.publicKey),
590 counter: info.credential.counter,
591 transports: info.credential.transports?.length ? JSON.stringify(info.credential.transports) : null,
592 device_type: info.credentialDeviceType,
593 backed_up: info.credentialBackedUp ? 1 : 0,
594 name: label,
595 };
596 const s = ch.subject;
597 let account: Account;
598 let redirect: string;
599 if (s.kind === "account") {
600 const me = browserAccount(c);
601 if (me.id !== s.accountId) throw new HttpError(403, "You signed in as someone else while adding this passkey.");
602 requireRecentSignIn(c, "/settings/security");
603 await auth.addPasskey({ ...key, account_id: me.id });
604 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "passkey.add", detail: { name: label, passkeyId: key.id, ...signinContext(c) } }));
605 flash(c, "notice", `Passkey "${label}" added.`);
606 return c.json({ redirect: "/settings/security" });
607 } else if (s.kind === "invite") {
608 // Re-check: the invite or the username may have been taken while the browser was busy.
609 const inv = await auth.inviteByHash(s.inviteHash);
610 if (!inv) throw new HttpError(404, "This invite was used or expired in the meantime.");
611 if (await dir.accountByEmail(s.email)) throw new HttpError(409, `${s.email} already has an account.`);
612 if (await dir.loginTaken(s.login)) throw new HttpError(409, `The username ${s.login} was just taken. Pick another one.`);
613 // Creating the account accepts every open invite for this email, including this one.
614 account = await dir.userForEmail(s.email, { login: s.login, name: s.name || undefined, id: s.accountId });
615 // Two registrations racing for one invite: only the one that created the account gets a passkey on it.
616 if (account.id !== s.accountId) throw new HttpError(409, `${s.email} already has an account.`);
617 await auth.addPasskey({ ...key, account_id: account.id });
618 c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "invite.accept", detail: { method: "passkey", passkeyId: key.id, ...signinContext(c) } }));
619 flash(c, "notice", `Welcome to Diffinery, ${account.login}. You can sign in with this passkey from now on.`);
620 // Land where they were invited to: the organization or the repository.
621 const repo = inv.repo_id ? await dir.repoById(inv.repo_id) : null;
622 const org = !repo && inv.org_id ? await dir.account(inv.org_id) : null;
623 redirect = repo ? `/${repo.owner_login}/${repo.name}` : org ? `/${org.login}` : "/";
624 } else if (s.kind === "signup") {
625 if (!signupOpen(c.env)) throw new HttpError(404, "Sign-up is closed on this instance.");
626 const signups = new Signups(c.env);
627 if (await signups.accountFor(s.email)) throw new HttpError(409, `${s.email} already has an account.`);
628 if (await dir.loginTaken(s.login)) throw new HttpError(409, `The username ${s.login} was just taken. Pick another one.`);
629 if (!(await signups.useTicket(s.ticketHash))) throw new HttpError(404, "This sign-up was used or expired in the meantime. Start again at /signup.");
630 // Open sign-up makes an active account (and accepts any open invites for the address).
631 account = await dir.userForEmail(s.email, { login: s.login, name: s.name || undefined, id: s.accountId, active: true });
632 if (account.id !== s.accountId) throw new HttpError(409, `${s.email} already has an account.`);
633 await auth.addPasskey({ ...key, account_id: account.id });
634 c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "signup", detail: { method: "passkey", email: s.email, passkeyId: key.id, ...signinContext(c) } }));
635 flash(c, "notice", `Welcome to Diffinery, ${account.login}. You can sign in with this passkey from now on.`);
636 redirect = "/";
637 } else if (s.kind === "link") {
638 if (!(await auth.useSigninLink(s.linkHash))) throw new HttpError(404, "This sign-in link was used or expired in the meantime.");
639 const found = await dir.account(s.accountId);
640 if (!found || found.status === "disabled") throw new HttpError(403, "This account is disabled.");
641 account = found;
642 await auth.addPasskey({ ...key, account_id: account.id });
643 c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "passkey.add", detail: { name: label, via: "signin-link", passkeyId: key.id, ...signinContext(c) } }));
644 flash(c, "notice", `Passkey "${label}" added. Use it to sign in from now on. ${RECOVERY_NOTE}`);
645 redirect = "/settings/security";
646 } else {
647 throw new HttpError(400, "unexpected sign-in attempt");
648 }
649 const sid = await startSession(c, auth, account.id, s.kind === "link" ? "link" : "passkey", key.id);
650 // A sign-in link is account recovery: a lost or stolen device may still be signed in, so only this browser stays.
651 if (s.kind === "link") {
652 const signedOut = await auth.endOtherSessions(account.id, sid);
653 c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "session.revoke_others", detail: { via: "signin-link", sessions: signedOut } }));
654 }
655 return c.json({ redirect });
656 });
657
658 // ------------------------------------------------------------ invitations: resend the email that proves the address
659
660 // The proof from an invitation email's fragment, posted by the invite page (INVITE_PROOF_SCRIPT).
661 post(web, "/invite/:token/proof", async (c, form) => {
662 const token = prm(c, "token");
663 // Signed in: the invite is accepted as that account, with no proof needed.
664 if (c.get("principal").type === "account") return c.redirect(`/invite/${encodeURIComponent(token)}`, 303);
665 return invitePasskeyPage(c, token, form.s("proof"));
666 });
667
668 post(web, "/invite/:token/email", async (c) => {
669 const token = prm(c, "token");
670 const back = `/invite/${encodeURIComponent(token)}`;
671 const inv = await new Auth(c.env).invite(token);
672 if (!inv?.email) throw new HttpError(404, "this invite is invalid, already used, or expired");
673 return act(c, back, async () => {
674 // At most one email every 2 minutes and 5 a day for an address, counting the first one.
675 const since = (ms: number) => new Date(Date.now() - ms).toISOString();
676 const sent = await c.env.DB.prepare("SELECT sum(created_at > ?) AS recent, count(*) AS day FROM email_log WHERE kind = 'invite' AND lower(to_addr) = lower(?) AND created_at > ?")
677 .bind(since(120_000), inv.email, since(86400_000))
678 .first<{ recent: number | null; day: number }>();
679 if ((sent?.recent ?? 0) > 0) throw new HttpError(429, `We emailed ${inv.email} a moment ago. Check that inbox (and its spam folder) for the link.`);
680 if ((sent?.day ?? 0) >= 5) throw new HttpError(429, `We have emailed ${inv.email} several times today. Ask the person who invited you to send a new invitation.`);
681 const row = await c.env.DB.prepare("SELECT id, email, org_id, repo_id, role, invited_by, expires_at FROM invites WHERE id = ?").bind(inv.id).first<{ id: string; email: string; org_id: string | null; repo_id: string | null; role: string; invited_by: string | null; expires_at: string }>();
682 if (!row) throw new HttpError(404, "this invite is invalid, already used, or expired");
683 await c.get("dir").mailInvite(row, token);
684 }, `We emailed the link to ${inv.email}. Open it from that inbox to create your account.`);
685 });
686
687 // ------------------------------------------------------------ open sign-up
688
689 // Off, sign-up doesn't exist: its pages answer 404 like any unknown path, before any CSRF check.
690 // Only the sign-up page itself leads to the early access request instead (web/early-access.tsx).
691 for (const path of ["/signup", "/signup/*"]) {
692 web.use(path, async (c, next) => {
693 if (!signupOpen(c.env) && c.req.method === "GET" && c.req.path === "/signup" && turnstileMode(c.env) !== "missing") return c.redirect("/early-access", 302);
694 if (!signupOpen(c.env)) throw new HttpError(404, "not found");
695 await next();
696 });
697 }
698
699 web.get("/signup", async (c) => {
700 const p = c.get("principal");
701 if (p.type === "account" && p.via !== "dev") return c.redirect("/", 302);
702 return signupPage(c);
703 });
704
705 post(web, "/signup", async (c, form) => {
706 const typed = form.s("email").trim().slice(0, 254);
707 return signupStep(
708 async () => {
709 const mode = turnstileMode(c.env);
710 if (mode === "missing") throw new HttpError(503, TURNSTILE_MISSING);
711 const email = normalEmail(typed);
712 if (!email) throw new HttpError(422, "Enter a valid email address.");
713 const ip = c.req.header("cf-connecting-ip") ?? null;
714 if (mode === "verify" && !(await verifyTurnstile(c.env.TURNSTILE_SECRET!, form.s("cf-turnstile-response"), ip))) throw new HttpError(403, "We couldn't confirm that you're a person. Please try again.");
715 const signups = new Signups(c.env);
716 const existing = await signups.accountFor(email);
717 const issued = await signups.issue(email, ip ?? "unknown");
718 // The next page reads the same either way, so it never tells whether an address has an account.
719 // Its owner learns from the email: a code, or (no code) a note that they already have one.
720 await enqueueMail(c.env, [
721 existing ? { kind: "signup_attempt", data: { email }, to: { accountId: existing.id }, reason: "account", at: now() } : { kind: "signup_code", data: issued, to: { email }, reason: "signup", at: now() },
722 ]);
723 return signupCodePage(c, { email });
724 },
725 (error, status) => signupPage(c, { email: typed, error, status }),
726 );
727 });
728
729 post(web, "/signup/verify", async (c, form) => {
730 const email = normalEmail(form.s("email"));
731 if (!email) return signupPage(c, { error: "Enter a valid email address.", status: 422 });
732 return signupStep(
733 async () => {
734 const signups = new Signups(c.env);
735 const r = await signups.check(email, form.s("code").trim());
736 if (!r.ok && r.left > 0) throw new HttpError(400, `That code is wrong. You can try ${r.left} more time${r.left === 1 ? "" : "s"}.`);
737 if (!r.ok) throw new HttpError(410, "That code has expired or was entered wrong too many times. Send a new code.");
738 return signupPasskeyPage(c, email, await signups.createTicket(email));
739 },
740 (error, status) => signupCodePage(c, { email, error, status }),
741 );
742 });
743
744 // ------------------------------------------------------------ sign out
745
746 post(web, "/auth/logout", async (c) => {
747 const token = getCookie(c, SESSION_COOKIE);
748 if (token) await new Auth(c.env).endSession(token);
749 deleteCookie(c, SESSION_COOKIE, { path: "/", secure: true });
750 return c.redirect("/login", 303);
751 });
752
753 // ------------------------------------------------------------ recovery through Cloudflare Access
754
755 // Cloudflare Access protects only this path. It verifies the person's email,
756 // and this handler turns that into a session so they can add a new passkey.
757 web.get("/auth/access", async (c) => {
758 if (!accessConfigured(c)) throw new HttpError(404, "Account recovery by email is not set up on this instance. Ask an administrator for a sign-in link.");
759 const jwt = accessToken(c.req.raw);
760 if (!jwt) throw new HttpError(401, "Cloudflare Access did not sign this request. Open this page again to start the email check.");
761 const claims = await verifyAccessJwt(jwt, c.env.ACCESS_TEAM_DOMAIN, c.env.ACCESS_AUD).catch((e: Error) => {
762 throw new HttpError(401, `Cloudflare Access sign-in was rejected: ${e.message}`);
763 });
764 const email = typeof claims.email === "string" ? claims.email : null;
765 if (!email) throw new HttpError(401, "Cloudflare Access did not provide an email address.");
766 const dir = c.get("dir");
767 const account = await dir.userForEmail(email, { name: typeof claims.name === "string" ? claims.name : undefined });
768 if (account.status === "disabled") throw new HttpError(403, "Your account is disabled.");
769 const auth = new Auth(c.env);
770 // Someone already signed in to this account here is confirming it's them (requireRecentSignIn); anyone else is
771 // recovering the account, and a lost or stolen device may still be signed in: every other browser is signed out.
772 const p = c.get("principal");
773 const confirming = p.type === "account" && p.via === "session" && p.account.id === account.id;
774 const sid = await startSession(c, auth, account.id, "access", null);
775 const signedOut = confirming ? 0 : await auth.endOtherSessions(account.id, sid);
776 c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "session.create", detail: { method: "access", ...signinContext(c) } }));
777 if (signedOut) c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "session.revoke_others", detail: { via: "access", sessions: signedOut } }));
778 if (account.status === "pending") return c.redirect("/", 302);
779 const keys = await auth.passkeys(account.id);
780 if (keys.length) {
781 if (signedOut) flash(c, "notice", `You're signed in. ${RECOVERY_NOTE}`);
782 return c.redirect(safeNext(c.req.query("next")), 302);
783 }
784 flash(c, "notice", `You're signed in.${signedOut ? ` ${RECOVERY_NOTE}` : ""} Add a passkey now so you can sign in directly next time.`);
785 return c.redirect("/settings/security", 302);
786 });
787}
788
789/** The account behind a browser session (or dev sign-in). API tokens cannot manage passkeys. */
790export function browserAccount(c: AppContext): Account {
791 const p = c.get("principal");
792 if (p.type !== "account" || p.via === "token") throw new HttpError(401, "Sign in with your passkey first.");
793 if (p.account.kind !== "user") throw new HttpError(403, "only people can have passkeys");
794 if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled");
795 return p.account;
796}