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.

717 lines · 37.0 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 } 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
72async function startSession(c: AppContext, auth: Auth, accountId: string, method: "passkey" | "access" | "link", passkeyId: string | null): Promise<void> {
73 const old = getCookie(c, SESSION_COOKIE);
74 if (old) await auth.endSession(old);
75 const token = await auth.createSession(accountId, method, passkeyId, c.req.header("user-agent") ?? null);
76 setCookie(c, SESSION_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "Lax", maxAge: SESSION_DAYS * 86400 });
77}
78
79function setChallenge(c: AppContext, token: string): void {
80 setCookie(c, CHALLENGE_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "Strict", maxAge: 300 });
81}
82
83function takeChallengeCookie(c: AppContext): string | undefined {
84 const v = getCookie(c, CHALLENGE_COOKIE);
85 deleteCookie(c, CHALLENGE_COOKIE, { path: "/", secure: true });
86 return v;
87}
88
89/** JSON endpoint for the browser's passkey script: JSON in, JSON out, JSON errors. */
90function jsonPost(web: WebApp, path: string, handler: (c: AppContext, body: Record<string, unknown>) => Promise<Response>): void {
91 web.post(path, async (c) => {
92 try {
93 // A JSON content type forces a CORS preflight, so other sites cannot drive these endpoints.
94 if (!/^application\/json\b/i.test(c.req.header("content-type") ?? "")) throw new HttpError(415, "expected JSON");
95 const body = await c.req.json<Record<string, unknown>>().catch(() => {
96 throw new HttpError(400, "invalid JSON");
97 });
98 if (!body || typeof body !== "object") throw new HttpError(400, "invalid JSON");
99 return await handler(c, body);
100 } catch (e) {
101 const s = statusOf(e);
102 if (s.status >= 500) console.error(e);
103 const confirm = confirmUrl(e);
104 return c.json({ message: s.status >= 500 ? "Something went wrong. Please try again." : s.message, ...(confirm ? { confirm } : {}) }, s.status as 400);
105 }
106 });
107}
108
109const transportsOf = (k: PasskeyRow): AuthenticatorTransport[] | undefined => (k.transports ? (JSON.parse(k.transports) as AuthenticatorTransport[]) : undefined);
110
111// ---------------------------------------------------------------- pages
112
113const KeyMark: FC = () => (
114 <div class="authmark" aria-hidden="true">
115 {raw(ICONS.key)}
116 </div>
117);
118
119/** Error slot that the passkey script fills in. */
120const PasskeyMessage: FC = () => <div class="flash error" data-passkey-msg role="alert" hidden></div>;
121
122const AuthCard: FC<PropsWithChildren<{ title: string }>> = ({ title, children }) => (
123 <div class="authcard box">
124 <div class="body">
125 <KeyMark />
126 <h1>{title}</h1>
127 {children}
128 </div>
129 </div>
130);
131
132function accessConfigured(c: AppContext): boolean {
133 return !!(c.env.ACCESS_TEAM_DOMAIN && c.env.ACCESS_AUD);
134}
135
136/** Username and display name for a new account (invites and open sign-up). */
137const NewAccountFields: FC<{ email: string }> = ({ email }) => (
138 <>
139 <div class="field">
140 <label for="login">Username</label>
141 <input id="login" name="login" type="text" value={loginFromEmail(email)} required pattern="[A-Za-z0-9][A-Za-z0-9_\-]{0,38}" autocomplete="username" />
142 <div class="hint">Your repositories live at /{"{username}"}/…</div>
143 </div>
144 <div class="field">
145 <label for="name">Display name</label>
146 <input id="name" name="name" type="text" maxlength={100} autocomplete="name" />
147 </div>
148 </>
149);
150
151/** The username and display name a new account asked for, once they check out. */
152async function newAccountNames(c: AppContext, body: Record<string, unknown>, email: string): Promise<{ login: string; name: string }> {
153 const login = str(body.login).trim() || loginFromEmail(email);
154 if (!isValidLogin(login) || login.length > 39) throw new HttpError(422, "Usernames use letters, numbers and dashes (up to 39), and some names are reserved.");
155 if (await c.get("dir").accountByLogin(login)) throw new HttpError(409, `The username ${login} is taken. Pick another one.`);
156 return { login, name: str(body.name).trim().slice(0, 100) };
157}
158
159/** Invite landing for someone without an account: create a passkey and join in one step. */
160export async function invitePasskeyPage(c: AppContext, token: string): Promise<Response> {
161 const auth = new Auth(c.env);
162 const inv = await auth.invite(token);
163 if (!inv) throw new HttpError(404, "this invite is invalid, already used, or expired");
164 const next = `/invite/${encodeURIComponent(token)}`;
165 // The link alone isn't enough to create the account: the copy emailed to the address carries a proof.
166 // Ask for it before saying whether the address has an account, since the inviter has the link too.
167 const proof = c.req.query("proof");
168 if (inv.email && !(await validInviteProof(c.env, inv.hash, proof))) {
169 return render(
170 c,
171 { title: "Join Diffinery" },
172 <AuthCard title="Confirm your email">
173 <p class="muted">
174 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.
175 </p>
176 <Form action={`/invite/${encodeURIComponent(token)}/email`} t={csrfToken(c)}>
177 <button class="btn primary block">Email me the link</button>
178 </Form>
179 <p class="muted small">
180 Already have an account? <a href={`/login?next=${encodeURIComponent(next)}`}>Sign in</a> to accept.
181 </p>
182 </AuthCard>,
183 );
184 }
185 const exists = inv.email ? await c.get("dir").accountByEmail(inv.email) : null;
186 if (!inv.email || exists) {
187 return render(
188 c,
189 { title: "Accept invite" },
190 <AuthCard title="You have been invited">
191 <p class="muted">
192 {inv.email ? (
193 <>
194 <b>{inv.email}</b> already has a Diffinery account. Sign in with its passkey, and this page will let you accept the invite.
195 </>
196 ) : (
197 "Sign in to accept this invite."
198 )}
199 </p>
200 <a class="btn primary block" href={`/login?next=${encodeURIComponent(next)}`}>
201 Sign in
202 </a>
203 </AuthCard>,
204 );
205 }
206 return render(
207 c,
208 { title: "Join Diffinery" },
209 <AuthCard title="Join Diffinery">
210 <p class="muted">
211 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.
212 </p>
213 <form data-passkey="register" data-next="/">
214 <input type="hidden" name="invite" value={token} />
215 <input type="hidden" name="proof" value={proof} />
216 <NewAccountFields email={inv.email} />
217 <PasskeyMessage />
218 <button class="btn primary block">Create a passkey and join</button>
219 </form>
220 </AuthCard>,
221 );
222}
223
224const PasskeyUnsupported: FC = () => (
225 <noscript>
226 <p class="flash error">Passkeys need JavaScript.</p>
227 </noscript>
228);
229
230// ---------------------------------------------------------------- open sign-up (SIGNUP=open, auth/signup.ts)
231
232const TURNSTILE_MISSING = "Sign-up needs Turnstile configured: this instance has no TURNSTILE_SITE_KEY and TURNSTILE_SECRET. Ask its administrators for an invite.";
233
234const StepError: FC<{ error?: string }> = ({ error }) => (error ? <div class="flash error" role="alert">{error}</div> : null);
235
236/** Step 1: the email address (and the Turnstile widget). */
237function signupPage(c: AppContext, o: { email?: string; error?: string; status?: number } = {}): Response | Promise<Response> {
238 const mode = turnstileMode(c.env);
239 const siteKey = mode === "verify" ? c.env.TURNSTILE_SITE_KEY : undefined;
240 return render(
241 c,
242 { title: "Create an account", status: mode === "missing" ? 503 : o.status, turnstile: !!siteKey },
243 <AuthCard title="Create your Diffinery account">
244 {mode === "missing" ? (
245 <StepError error={TURNSTILE_MISSING} />
246 ) : (
247 <>
248 <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>
249 <StepError error={o.error} />
250 <Form action="/signup" t={csrfToken(c)}>
251 <div class="field">
252 <label for="email">Email address</label>
253 <input id="email" name="email" type="email" value={o.email} required maxlength={254} autocomplete="email" />
254 </div>
255 {siteKey ? <div class="cf-turnstile" data-sitekey={siteKey} data-theme="auto" style="margin-bottom:12px"></div> : null}
256 <button class="btn primary block">Email me a code</button>
257 </Form>
258 </>
259 )}
260 <div class="authfoot muted small">
261 <p>
262 Already have an account? <a href="/login">Sign in</a>.
263 </p>
264 </div>
265 </AuthCard>,
266 );
267}
268
269/** Step 2: the code from the email. */
270function signupCodePage(c: AppContext, o: { email: string; error?: string; status?: number }): Response | Promise<Response> {
271 return render(
272 c,
273 { title: "Enter your code", status: o.status },
274 <AuthCard title="Check your email">
275 <p class="muted">
276 We sent a code to <b>{o.email}</b>. Enter it here within {CODE_MINUTES} minutes.
277 </p>
278 <StepError error={o.error} />
279 <Form action="/signup/verify" t={csrfToken(c)}>
280 <input type="hidden" name="email" value={o.email} />
281 <div class="field">
282 <label for="code">Code</label>
283 <input id="code" name="code" type="text" inputmode="numeric" pattern="[0-9]{6}" maxlength={6} required autocomplete="one-time-code" />
284 </div>
285 <button class="btn primary block">Continue</button>
286 </Form>
287 <div class="authfoot muted small">
288 <p>
289 No email? Check your spam folder, or <a href="/signup">send a new code</a>.
290 </p>
291 </div>
292 </AuthCard>,
293 );
294}
295
296/** Step 3: a username and a passkey, bound to the single-use ticket for the confirmed address. */
297function signupPasskeyPage(c: AppContext, email: string, ticket: string): Response | Promise<Response> {
298 return render(
299 c,
300 { title: "Join Diffinery" },
301 <AuthCard title="Join Diffinery">
302 <p class="muted">
303 <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.
304 </p>
305 <PasskeyUnsupported />
306 <form data-passkey="register" data-next="/">
307 <input type="hidden" name="signup" value={ticket} />
308 <NewAccountFields email={email} />
309 <PasskeyMessage />
310 <button class="btn primary block">Create a passkey and join</button>
311 </form>
312 </AuthCard>,
313 );
314}
315
316/** Run a sign-up step. A refusal shows on that step's page with its status, keeping what was typed. */
317async function signupStep(fn: () => Promise<Response>, page: (error: string, status: number) => Response | Promise<Response>): Promise<Response> {
318 try {
319 return await fn();
320 } catch (e) {
321 const s = statusOf(e);
322 if (s.status >= 500 && s.status !== 503) throw e;
323 return page(s.message, s.status);
324 }
325}
326
327export function registerAuth(web: WebApp): void {
328 web.get("/login", async (c) => {
329 const next = safeNext(c.req.query("next"));
330 const p = c.get("principal");
331 // A sensitive action asked a signed-in person to confirm it's them (requireRecentSignIn).
332 if (c.req.query("confirm") === "1" && p.type === "account" && p.via === "session" && !(p.sessionAt && Date.now() - Date.parse(p.sessionAt) < RECENT_SIGNIN_MS)) {
333 return render(
334 c,
335 { title: "Confirm it's you" },
336 <AuthCard title="Confirm it's you">
337 <p class="muted">
338 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.
339 </p>
340 <PasskeyUnsupported />
341 <form data-passkey="login" data-next={next}>
342 <PasskeyMessage />
343 <button class="btn primary block">Confirm with a passkey</button>
344 </form>
345 <div class="authfoot muted small">
346 {accessConfigured(c) ? (
347 <p>
348 No passkey on this device? <a href={`/auth/access?next=${encodeURIComponent(next)}`}>Confirm by email</a>.
349 </p>
350 ) : null}
351 <p>
352 <a href={next}>Cancel</a>
353 </p>
354 </div>
355 </AuthCard>,
356 );
357 }
358 if (p.type === "account" && p.via !== "dev") return c.redirect(next, 302);
359 return render(
360 c,
361 { title: "Sign in" },
362 <AuthCard title="Sign in to Diffinery">
363 <p class="muted">Use the passkey saved on this device, your phone or your password manager.</p>
364 <PasskeyUnsupported />
365 <form data-passkey="login" data-next={next}>
366 <PasskeyMessage />
367 <button class="btn primary block">Sign in with a passkey</button>
368 </form>
369 <div class="authfoot muted small">
370 {signupOpen(c.env) ? (
371 <p>
372 New here? <a href="/signup">Create an account</a>.
373 </p>
374 ) : (
375 <p>New here? Diffinery is invite-only. Open the invite link someone sent you to create your passkey.</p>
376 )}
377 {accessConfigured(c) ? (
378 <p>
379 Lost your passkey? <a href={`/auth/access?next=${encodeURIComponent(next)}`}>Recover your account by email</a>.
380 </p>
381 ) : (
382 <p>Lost your passkey? Ask an administrator for a sign-in link.</p>
383 )}
384 </div>
385 </AuthCard>,
386 );
387 });
388
389 // Admin-issued sign-in link: add a passkey to an existing account.
390 web.get("/signin/:token", async (c) => {
391 const token = prm(c, "token");
392 const link = await new Auth(c.env).signinLink(token);
393 if (!link) throw new HttpError(404, "this sign-in link is invalid, already used, or expired");
394 return render(
395 c,
396 { title: "Add a passkey" },
397 <AuthCard title="Add a passkey">
398 <p class="muted">
399 This link lets you add a passkey to <b>{link.account.login}</b>
400 {link.account.email ? ` (${link.account.email})` : ""}. It works once. After that, sign in with the passkey.
401 </p>
402 <form data-passkey="register" data-next="/settings/security">
403 <input type="hidden" name="link" value={token} />
404 <div class="field">
405 <label for="label">Name for this passkey</label>
406 <input id="label" name="label" type="text" maxlength={100} placeholder={deviceLabel(c.req.header("user-agent"))} />
407 </div>
408 <PasskeyMessage />
409 <button class="btn primary block">Create a passkey</button>
410 </form>
411 </AuthCard>,
412 );
413 });
414
415 // ------------------------------------------------------------ sign in
416
417 jsonPost(web, "/auth/passkey/login/options", async (c) => {
418 const auth = new Auth(c.env);
419 const rp = auth.rp();
420 // No allowCredentials: the browser offers any passkey it holds for this site (discoverable credentials).
421 const opts = await generateAuthenticationOptions({ rpID: rp.id, userVerification: "required", timeout: CEREMONY_TIMEOUT });
422 setChallenge(c, await auth.saveChallenge("login", opts.challenge, { kind: "login" }));
423 return c.json(opts);
424 });
425
426 jsonPost(web, "/auth/passkey/login/verify", async (c, body) => {
427 const auth = new Auth(c.env);
428 const dir = c.get("dir");
429 const rp = auth.rp();
430 const ch = await auth.takeChallenge(takeChallengeCookie(c), "login");
431 if (!ch) throw new HttpError(400, "This sign-in attempt expired. Please try again.");
432 const response = body.response as AuthenticationResponseJSON | undefined;
433 if (!response || typeof response.id !== "string") throw new HttpError(400, "missing passkey response");
434 const key = await auth.passkey(response.id);
435 if (!key) throw new HttpError(401, "This passkey is not registered with Diffinery. It may have been removed.");
436 const result = await verifyAuthenticationResponse({
437 response,
438 expectedChallenge: ch.challenge,
439 expectedOrigin: rp.origin,
440 expectedRPID: rp.id,
441 requireUserVerification: true,
442 credential: { id: key.id, publicKey: new Uint8Array(fromB64url(key.public_key)), counter: key.counter, transports: transportsOf(key) },
443 }).catch((e: Error) => {
444 throw new HttpError(401, `Passkey check failed: ${e.message}`);
445 });
446 if (!result.verified) throw new HttpError(401, "Passkey check failed.");
447 const account = await dir.account(key.account_id);
448 if (!account) throw new HttpError(401, "This passkey's account no longer exists.");
449 if (account.status === "disabled") throw new HttpError(403, "Your account is disabled.");
450 await auth.passkeyUsed(key.id, result.authenticationInfo.newCounter, result.authenticationInfo.credentialBackedUp);
451 await startSession(c, auth, account.id, "passkey", key.id);
452 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) } }));
453 return c.json({ redirect: safeNext(body.next) });
454 });
455
456 // ------------------------------------------------------------ register a passkey
457
458 jsonPost(web, "/auth/passkey/register/options", async (c, body) => {
459 const auth = new Auth(c.env);
460 const dir = c.get("dir");
461 const rp = auth.rp();
462 let subject: Subject;
463 let user: { id: string; name: string; displayName: string };
464 let existing: PasskeyRow[] = [];
465 const inviteToken = str(body.invite);
466 const linkToken = str(body.link);
467 const signupToken = str(body.signup);
468 if (inviteToken) {
469 const inv = await auth.invite(inviteToken);
470 if (!inv) throw new HttpError(404, "This invite is invalid, already used, or expired.");
471 if (!inv.email) throw new HttpError(422, "This invite has no email address. Sign in first, then open it again.");
472 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.`);
473 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.`);
474 const { login, name } = await newAccountNames(c, body, inv.email);
475 const id = newId();
476 subject = { kind: "invite", inviteHash: inv.hash, email: inv.email, login, name, accountId: id };
477 user = { id, name: login, displayName: name || login };
478 } else if (signupToken) {
479 if (!signupOpen(c.env)) throw new HttpError(404, "Sign-up is closed on this instance.");
480 const signups = new Signups(c.env);
481 const ticket = await signups.ticket(signupToken);
482 if (!ticket) throw new HttpError(404, "This sign-up expired or was already used. Start again at /signup.");
483 if (await signups.accountFor(ticket.email)) throw new HttpError(409, `${ticket.email} already has an account. Sign in with its passkey.`);
484 const { login, name } = await newAccountNames(c, body, ticket.email);
485 const id = newId();
486 subject = { kind: "signup", ticketHash: ticket.hash, email: ticket.email, login, name, accountId: id };
487 user = { id, name: login, displayName: name || login };
488 } else if (linkToken) {
489 const link = await auth.signinLink(linkToken);
490 if (!link) throw new HttpError(404, "This sign-in link is invalid, already used, or expired.");
491 if (link.account.status === "disabled") throw new HttpError(403, "This account is disabled.");
492 subject = { kind: "link", linkHash: link.hash, accountId: link.account.id };
493 user = { id: link.account.id, name: link.account.login, displayName: link.account.name || link.account.login };
494 existing = await auth.passkeys(link.account.id);
495 } else {
496 const me = browserAccount(c);
497 requireRecentSignIn(c, "/settings/security");
498 subject = { kind: "account", accountId: me.id };
499 user = { id: me.id, name: me.login, displayName: me.name || me.login };
500 existing = await auth.passkeys(me.id);
501 }
502 const opts = await generateRegistrationOptions({
503 rpName: rp.name,
504 rpID: rp.id,
505 userName: user.name,
506 userDisplayName: user.displayName,
507 userID: new Uint8Array(new TextEncoder().encode(user.id)),
508 attestationType: "none",
509 excludeCredentials: existing.map((k) => ({ id: k.id, transports: transportsOf(k) })),
510 authenticatorSelection: { residentKey: "required", userVerification: "required" },
511 timeout: CEREMONY_TIMEOUT,
512 });
513 setChallenge(c, await auth.saveChallenge("register", opts.challenge, subject));
514 return c.json(opts);
515 });
516
517 jsonPost(web, "/auth/passkey/register/verify", async (c, body) => {
518 const auth = new Auth(c.env);
519 const dir = c.get("dir");
520 const rp = auth.rp();
521 const ch = await auth.takeChallenge(takeChallengeCookie(c), "register");
522 if (!ch) throw new HttpError(400, "This attempt expired. Please try again.");
523 const response = body.response as RegistrationResponseJSON | undefined;
524 if (!response || typeof response.id !== "string") throw new HttpError(400, "missing passkey response");
525 const result = await verifyRegistrationResponse({ response, expectedChallenge: ch.challenge, expectedOrigin: rp.origin, expectedRPID: rp.id, requireUserVerification: true }).catch((e: Error) => {
526 throw new HttpError(400, `The passkey could not be verified: ${e.message}`);
527 });
528 if (!result.verified) throw new HttpError(400, "The passkey could not be verified.");
529 const info = result.registrationInfo;
530 if (await auth.passkey(info.credential.id)) throw new HttpError(409, "This passkey is already registered.");
531 const label = str(body.label).trim() || deviceLabel(c.req.header("user-agent"));
532 const key = {
533 id: info.credential.id,
534 public_key: b64url(info.credential.publicKey),
535 counter: info.credential.counter,
536 transports: info.credential.transports?.length ? JSON.stringify(info.credential.transports) : null,
537 device_type: info.credentialDeviceType,
538 backed_up: info.credentialBackedUp ? 1 : 0,
539 name: label,
540 };
541 const s = ch.subject;
542 let account: Account;
543 let redirect: string;
544 if (s.kind === "account") {
545 const me = browserAccount(c);
546 if (me.id !== s.accountId) throw new HttpError(403, "You signed in as someone else while adding this passkey.");
547 requireRecentSignIn(c, "/settings/security");
548 await auth.addPasskey({ ...key, account_id: me.id });
549 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "passkey.add", detail: { name: label, passkeyId: key.id, ...signinContext(c) } }));
550 flash(c, "notice", `Passkey "${label}" added.`);
551 return c.json({ redirect: "/settings/security" });
552 } else if (s.kind === "invite") {
553 // Re-check: the invite or the username may have been taken while the browser was busy.
554 const inv = await auth.inviteByHash(s.inviteHash);
555 if (!inv) throw new HttpError(404, "This invite was used or expired in the meantime.");
556 if (await dir.accountByEmail(s.email)) throw new HttpError(409, `${s.email} already has an account.`);
557 if (await dir.accountByLogin(s.login)) throw new HttpError(409, `The username ${s.login} was just taken. Pick another one.`);
558 // Creating the account accepts every open invite for this email, including this one.
559 account = await dir.userForEmail(s.email, { login: s.login, name: s.name || undefined, id: s.accountId });
560 // Two registrations racing for one invite: only the one that created the account gets a passkey on it.
561 if (account.id !== s.accountId) throw new HttpError(409, `${s.email} already has an account.`);
562 await auth.addPasskey({ ...key, account_id: account.id });
563 c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "invite.accept", detail: { method: "passkey", passkeyId: key.id, ...signinContext(c) } }));
564 flash(c, "notice", `Welcome to Diffinery, ${account.login}. You can sign in with this passkey from now on.`);
565 // Land where they were invited to: the organization or the repository.
566 const repo = inv.repo_id ? await dir.repoById(inv.repo_id) : null;
567 const org = !repo && inv.org_id ? await dir.account(inv.org_id) : null;
568 redirect = repo ? `/${repo.owner_login}/${repo.name}` : org ? `/${org.login}` : "/";
569 } else if (s.kind === "signup") {
570 if (!signupOpen(c.env)) throw new HttpError(404, "Sign-up is closed on this instance.");
571 const signups = new Signups(c.env);
572 if (await signups.accountFor(s.email)) throw new HttpError(409, `${s.email} already has an account.`);
573 if (await dir.accountByLogin(s.login)) throw new HttpError(409, `The username ${s.login} was just taken. Pick another one.`);
574 if (!(await signups.useTicket(s.ticketHash))) throw new HttpError(404, "This sign-up was used or expired in the meantime. Start again at /signup.");
575 // Open sign-up makes an active account (and accepts any open invites for the address).
576 account = await dir.userForEmail(s.email, { login: s.login, name: s.name || undefined, id: s.accountId, active: true });
577 if (account.id !== s.accountId) throw new HttpError(409, `${s.email} already has an account.`);
578 await auth.addPasskey({ ...key, account_id: account.id });
579 c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "signup", detail: { method: "passkey", email: s.email, passkeyId: key.id, ...signinContext(c) } }));
580 flash(c, "notice", `Welcome to Diffinery, ${account.login}. You can sign in with this passkey from now on.`);
581 redirect = "/";
582 } else if (s.kind === "link") {
583 if (!(await auth.useSigninLink(s.linkHash))) throw new HttpError(404, "This sign-in link was used or expired in the meantime.");
584 const found = await dir.account(s.accountId);
585 if (!found || found.status === "disabled") throw new HttpError(403, "This account is disabled.");
586 account = found;
587 await auth.addPasskey({ ...key, account_id: account.id });
588 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) } }));
589 flash(c, "notice", `Passkey "${label}" added. Use it to sign in from now on.`);
590 redirect = "/settings/security";
591 } else {
592 throw new HttpError(400, "unexpected sign-in attempt");
593 }
594 await startSession(c, auth, account.id, s.kind === "link" ? "link" : "passkey", key.id);
595 return c.json({ redirect });
596 });
597
598 // ------------------------------------------------------------ invitations: resend the email that proves the address
599
600 post(web, "/invite/:token/email", async (c) => {
601 const token = prm(c, "token");
602 const back = `/invite/${encodeURIComponent(token)}`;
603 const inv = await new Auth(c.env).invite(token);
604 if (!inv?.email) throw new HttpError(404, "this invite is invalid, already used, or expired");
605 return act(c, back, async () => {
606 // At most one email every 2 minutes and 5 a day for an address, counting the first one.
607 const since = (ms: number) => new Date(Date.now() - ms).toISOString();
608 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 > ?")
609 .bind(since(120_000), inv.email, since(86400_000))
610 .first<{ recent: number | null; day: number }>();
611 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.`);
612 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.`);
613 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 }>();
614 if (!row) throw new HttpError(404, "this invite is invalid, already used, or expired");
615 await c.get("dir").mailInvite(row, token);
616 }, `We emailed the link to ${inv.email}. Open it from that inbox to create your account.`);
617 });
618
619 // ------------------------------------------------------------ open sign-up
620
621 // Off, sign-up doesn't exist: its pages answer 404 like any unknown path, before any CSRF check.
622 for (const path of ["/signup", "/signup/*"]) {
623 web.use(path, async (c, next) => {
624 if (!signupOpen(c.env)) throw new HttpError(404, "not found");
625 await next();
626 });
627 }
628
629 web.get("/signup", async (c) => {
630 const p = c.get("principal");
631 if (p.type === "account" && p.via !== "dev") return c.redirect("/", 302);
632 return signupPage(c);
633 });
634
635 post(web, "/signup", async (c, form) => {
636 const typed = form.s("email").trim().slice(0, 254);
637 return signupStep(
638 async () => {
639 const mode = turnstileMode(c.env);
640 if (mode === "missing") throw new HttpError(503, TURNSTILE_MISSING);
641 const email = normalEmail(typed);
642 if (!email) throw new HttpError(422, "Enter a valid email address.");
643 const ip = c.req.header("cf-connecting-ip") ?? null;
644 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.");
645 const signups = new Signups(c.env);
646 const existing = await signups.accountFor(email);
647 const issued = await signups.issue(email, ip ?? "unknown");
648 // The next page reads the same either way, so it never tells whether an address has an account.
649 // Its owner learns from the email: a code, or (no code) a note that they already have one.
650 await enqueueMail(c.env, [
651 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() },
652 ]);
653 return signupCodePage(c, { email });
654 },
655 (error, status) => signupPage(c, { email: typed, error, status }),
656 );
657 });
658
659 post(web, "/signup/verify", async (c, form) => {
660 const email = normalEmail(form.s("email"));
661 if (!email) return signupPage(c, { error: "Enter a valid email address.", status: 422 });
662 return signupStep(
663 async () => {
664 const signups = new Signups(c.env);
665 const r = await signups.check(email, form.s("code").trim());
666 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"}.`);
667 if (!r.ok) throw new HttpError(410, "That code has expired or was entered wrong too many times. Send a new code.");
668 return signupPasskeyPage(c, email, await signups.createTicket(email));
669 },
670 (error, status) => signupCodePage(c, { email, error, status }),
671 );
672 });
673
674 // ------------------------------------------------------------ sign out
675
676 post(web, "/auth/logout", async (c) => {
677 const token = getCookie(c, SESSION_COOKIE);
678 if (token) await new Auth(c.env).endSession(token);
679 deleteCookie(c, SESSION_COOKIE, { path: "/", secure: true });
680 return c.redirect("/login", 303);
681 });
682
683 // ------------------------------------------------------------ recovery through Cloudflare Access
684
685 // Cloudflare Access protects only this path. It verifies the person's email,
686 // and this handler turns that into a session so they can add a new passkey.
687 web.get("/auth/access", async (c) => {
688 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.");
689 const jwt = accessToken(c.req.raw);
690 if (!jwt) throw new HttpError(401, "Cloudflare Access did not sign this request. Open this page again to start the email check.");
691 const claims = await verifyAccessJwt(jwt, c.env.ACCESS_TEAM_DOMAIN, c.env.ACCESS_AUD).catch((e: Error) => {
692 throw new HttpError(401, `Cloudflare Access sign-in was rejected: ${e.message}`);
693 });
694 const email = typeof claims.email === "string" ? claims.email : null;
695 if (!email) throw new HttpError(401, "Cloudflare Access did not provide an email address.");
696 const dir = c.get("dir");
697 const account = await dir.userForEmail(email, { name: typeof claims.name === "string" ? claims.name : undefined });
698 if (account.status === "disabled") throw new HttpError(403, "Your account is disabled.");
699 const auth = new Auth(c.env);
700 await startSession(c, auth, account.id, "access", null);
701 c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "session.create", detail: { method: "access", ...signinContext(c) } }));
702 if (account.status === "pending") return c.redirect("/", 302);
703 const keys = await auth.passkeys(account.id);
704 if (keys.length) return c.redirect(safeNext(c.req.query("next")), 302);
705 flash(c, "notice", "You're signed in. Add a passkey now so you can sign in directly next time.");
706 return c.redirect("/settings/security", 302);
707 });
708}
709
710/** The account behind a browser session (or dev sign-in). API tokens cannot manage passkeys. */
711export function browserAccount(c: AppContext): Account {
712 const p = c.get("principal");
713 if (p.type !== "account" || p.via === "token") throw new HttpError(401, "Sign in with your passkey first.");
714 if (p.account.kind !== "user") throw new HttpError(403, "only people can have passkeys");
715 if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled");
716 return p.account;
717}