| 1 | /** @jsxImportSource hono/jsx */ |
| 2 | import { |
| 3 | generateAuthenticationOptions, |
| 4 | generateRegistrationOptions, |
| 5 | verifyAuthenticationResponse, |
| 6 | verifyRegistrationResponse, |
| 7 | type AuthenticationResponseJSON, |
| 8 | type AuthenticatorTransport, |
| 9 | type RegistrationResponseJSON, |
| 10 | } from "@simplewebauthn/server"; |
| 11 | import { deleteCookie, getCookie, setCookie } from "hono/cookie"; |
| 12 | import { raw } from "hono/html"; |
| 13 | import type { FC, PropsWithChildren } from "hono/jsx"; |
| 14 | import { RECENT_SIGNIN_MS, accessToken, requireRecentSignIn, type AppContext } from "../auth/principal.js"; |
| 15 | import { Auth, CHALLENGE_COOKIE, SESSION_COOKIE, SESSION_DAYS, type PasskeyRow, type Subject } from "../auth/sessions.js"; |
| 16 | import type { Account } from "../lib/directory.js"; |
| 17 | import { verifyAccessJwt } from "../lib/jwt.js"; |
| 18 | import { HttpError, b64url, fromB64url, isValidLogin, newId, now } from "../lib/util.js"; |
| 19 | import { ICONS } from "./assets.js"; |
| 20 | import { Form, act, confirmUrl, flash, post, prm, render, statusOf, type WebApp } from "./ui.js"; |
| 21 | import { csrfToken } from "./csrf.js"; |
| 22 | import { validInviteProof } from "../auth/invite-proof.js"; |
| 23 | import { CODE_MINUTES, Signups, normalEmail, signupOpen, turnstileMode, verifyTurnstile } from "../auth/signup.js"; |
| 24 | import { 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 | |
| 34 | const 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 | */ |
| 40 | export 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. */ |
| 46 | export function safeNext(next: unknown): string { |
| 47 | return localPath(next) ?? "/"; |
| 48 | } |
| 49 | |
| 50 | const str = (v: unknown): string => (typeof v === "string" ? v : ""); |
| 51 | |
| 52 | /** A login suggestion from an email address. */ |
| 53 | export 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. */ |
| 58 | export 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. */ |
| 66 | export 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 | async 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 | |
| 79 | function setChallenge(c: AppContext, token: string): void { |
| 80 | setCookie(c, CHALLENGE_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "Strict", maxAge: 300 }); |
| 81 | } |
| 82 | |
| 83 | function 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. */ |
| 90 | function 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 | |
| 109 | const transportsOf = (k: PasskeyRow): AuthenticatorTransport[] | undefined => (k.transports ? (JSON.parse(k.transports) as AuthenticatorTransport[]) : undefined); |
| 110 | |
| 111 | // ---------------------------------------------------------------- pages |
| 112 | |
| 113 | const 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. */ |
| 120 | const PasskeyMessage: FC = () => <div class="flash error" data-passkey-msg role="alert" hidden></div>; |
| 121 | |
| 122 | const 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 | |
| 132 | function 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). */ |
| 137 | const 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. */ |
| 152 | async 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. */ |
| 160 | export 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 | |
| 224 | const 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 | |
| 232 | const TURNSTILE_MISSING = "Sign-up needs Turnstile configured: this instance has no TURNSTILE_SITE_KEY and TURNSTILE_SECRET. Ask its administrators for an invite."; |
| 233 | |
| 234 | const 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). */ |
| 237 | function 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. */ |
| 270 | function 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. */ |
| 297 | function 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. */ |
| 317 | async 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 | |
| 327 | export 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. */ |
| 711 | export 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 | } |