| 1 | import type { Env } from "../env.js"; |
| 2 | import type { Account } from "../lib/directory.js"; |
| 3 | import { newId, now, randomToken, sha256 } from "../lib/util.js"; |
| 4 | import { enqueueMail } from "../mail/send.js"; |
| 5 | |
| 6 | /** Browser session cookie. `__Host-` pins it to this origin: Secure, Path=/, no Domain. */ |
| 7 | export const SESSION_COOKIE = "__Host-dfy_session"; |
| 8 | /** Binds an in-flight WebAuthn ceremony to the browser that started it. */ |
| 9 | export const CHALLENGE_COOKIE = "__Host-dfy_webauthn"; |
| 10 | export const SESSION_DAYS = 30; |
| 11 | /** However often it is used, a session ends this long after its sign-in. */ |
| 12 | export const SESSION_MAX_DAYS = 90; |
| 13 | const CHALLENGE_MINUTES = 5; |
| 14 | const LINK_HOURS = 24; |
| 15 | |
| 16 | export type PasskeyRow = { |
| 17 | id: string; |
| 18 | account_id: string; |
| 19 | public_key: string; |
| 20 | counter: number; |
| 21 | transports: string | null; |
| 22 | device_type: string; |
| 23 | backed_up: number; |
| 24 | name: string; |
| 25 | created_at: string; |
| 26 | last_used_at: string | null; |
| 27 | }; |
| 28 | |
| 29 | export type SessionRow = { |
| 30 | id: string; |
| 31 | account_id: string; |
| 32 | method: string; |
| 33 | passkey_id: string | null; |
| 34 | user_agent: string | null; |
| 35 | created_at: string; |
| 36 | last_seen_at: string; |
| 37 | expires_at: string; |
| 38 | }; |
| 39 | |
| 40 | export type InviteRow = { id: string; email: string | null; org_id: string | null; repo_id: string | null; role: string }; |
| 41 | |
| 42 | /** Who a passkey being registered will belong to. */ |
| 43 | export type Subject = |
| 44 | | { kind: "account"; accountId: string } |
| 45 | | { kind: "invite"; inviteHash: string; email: string; login: string; name: string; accountId: string } |
| 46 | | { kind: "link"; linkHash: string; accountId: string } |
| 47 | | { kind: "signup"; ticketHash: string; email: string; login: string; name: string; accountId: string } |
| 48 | | { kind: "login" }; |
| 49 | |
| 50 | const days = (n: number): string => new Date(Date.now() + n * 86400_000).toISOString(); |
| 51 | |
| 52 | /** When a session signed in at `createdAt` ends at the latest. */ |
| 53 | const lastDay = (createdAt: string): string => new Date(Date.parse(createdAt) + SESSION_MAX_DAYS * 86400_000).toISOString(); |
| 54 | |
| 55 | /** Sessions, passkeys, WebAuthn challenges and sign-in links in D1. */ |
| 56 | export class Auth { |
| 57 | constructor(private env: Env) {} |
| 58 | |
| 59 | private get db() { |
| 60 | return this.env.DB; |
| 61 | } |
| 62 | |
| 63 | /** Relying party: passkeys are bound to the hostname of PUBLIC_URL. */ |
| 64 | rp(): { id: string; origin: string; name: string } { |
| 65 | const u = new URL(this.env.PUBLIC_URL); |
| 66 | return { id: u.hostname, origin: u.origin, name: "Diffinery" }; |
| 67 | } |
| 68 | |
| 69 | // ---------------------------------------------------------------- sessions |
| 70 | |
| 71 | async createSession(accountId: string, method: "passkey" | "access" | "link", passkeyId: string | null, userAgent: string | null): Promise<string> { |
| 72 | const token = randomToken(32); |
| 73 | const at = now(); |
| 74 | await this.db |
| 75 | .prepare("INSERT INTO sessions (id, account_id, method, passkey_id, user_agent, created_at, last_seen_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") |
| 76 | .bind(await sha256(token), accountId, method, passkeyId, userAgent?.slice(0, 300) ?? null, at, at, days(SESSION_DAYS)) |
| 77 | .run(); |
| 78 | return token; |
| 79 | } |
| 80 | |
| 81 | /** Resolve a session cookie. Sessions slide: each day of use extends them by another 30 days, up to 90 days after the sign-in. */ |
| 82 | async session(token: string): Promise<{ session: SessionRow; account: Account } | null> { |
| 83 | if (!/^[A-Za-z0-9_-]{20,64}$/.test(token)) return null; |
| 84 | const id = await sha256(token); |
| 85 | // Both at once, the account through the session's own row: every signed-in page waits for this. |
| 86 | const [row, account] = await Promise.all([ |
| 87 | this.db.prepare("SELECT * FROM sessions WHERE id = ?").bind(id).first<SessionRow>(), |
| 88 | this.db.prepare("SELECT * FROM accounts WHERE id = (SELECT account_id FROM sessions WHERE id = ?)").bind(id).first<Account>(), |
| 89 | ]); |
| 90 | if (!row || row.expires_at < now() || lastDay(row.created_at) < now()) return null; |
| 91 | if (!account || account.id !== row.account_id || account.kind !== "user" || account.status === "disabled") return null; |
| 92 | if (Date.now() - Date.parse(row.last_seen_at) > 86400_000) { |
| 93 | const until = days(SESSION_DAYS); |
| 94 | const last = lastDay(row.created_at); |
| 95 | await this.db.prepare("UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?").bind(now(), until < last ? until : last, row.id).run(); |
| 96 | } |
| 97 | return { session: row, account }; |
| 98 | } |
| 99 | |
| 100 | async endSession(token: string): Promise<void> { |
| 101 | await this.db.prepare("DELETE FROM sessions WHERE id = ?").bind(await sha256(token)).run(); |
| 102 | } |
| 103 | |
| 104 | async sessions(accountId: string): Promise<SessionRow[]> { |
| 105 | const r = await this.db.prepare("SELECT * FROM sessions WHERE account_id = ? AND expires_at > ? ORDER BY last_seen_at DESC LIMIT 100").bind(accountId, now()).all<SessionRow>(); |
| 106 | return r.results; |
| 107 | } |
| 108 | |
| 109 | /** End one of the account's own sessions; false when it has none by that id. */ |
| 110 | async endSessionById(accountId: string, id: string): Promise<boolean> { |
| 111 | const r = await this.db.prepare("DELETE FROM sessions WHERE id = ? AND account_id = ?").bind(id, accountId).run(); |
| 112 | return (r.meta.changes ?? 0) > 0; |
| 113 | } |
| 114 | |
| 115 | /** Sign out every other browser of the account; returns how many were signed out. */ |
| 116 | async endOtherSessions(accountId: string, keepId: string | null): Promise<number> { |
| 117 | const r = await this.db.prepare("DELETE FROM sessions WHERE account_id = ? AND id != ?").bind(accountId, keepId ?? "").run(); |
| 118 | return r.meta.changes ?? 0; |
| 119 | } |
| 120 | |
| 121 | async endAllSessions(accountId: string): Promise<void> { |
| 122 | await this.db.prepare("DELETE FROM sessions WHERE account_id = ?").bind(accountId).run(); |
| 123 | } |
| 124 | |
| 125 | // ---------------------------------------------------------------- passkeys |
| 126 | |
| 127 | async passkeys(accountId: string): Promise<PasskeyRow[]> { |
| 128 | const r = await this.db.prepare("SELECT * FROM passkeys WHERE account_id = ? ORDER BY created_at").bind(accountId).all<PasskeyRow>(); |
| 129 | return r.results; |
| 130 | } |
| 131 | |
| 132 | async passkey(id: string): Promise<PasskeyRow | null> { |
| 133 | return this.db.prepare("SELECT * FROM passkeys WHERE id = ?").bind(id).first<PasskeyRow>(); |
| 134 | } |
| 135 | |
| 136 | async addPasskey(p: Omit<PasskeyRow, "created_at" | "last_used_at">): Promise<void> { |
| 137 | await this.db |
| 138 | .prepare("INSERT INTO passkeys (id, account_id, public_key, counter, transports, device_type, backed_up, name, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") |
| 139 | .bind(p.id, p.account_id, p.public_key, p.counter, p.transports, p.device_type, p.backed_up, p.name.slice(0, 100), now()) |
| 140 | .run(); |
| 141 | } |
| 142 | |
| 143 | async passkeyUsed(id: string, counter: number, backedUp: boolean): Promise<void> { |
| 144 | await this.db.prepare("UPDATE passkeys SET counter = ?, backed_up = ?, last_used_at = ? WHERE id = ?").bind(counter, backedUp ? 1 : 0, now(), id).run(); |
| 145 | } |
| 146 | |
| 147 | async renamePasskey(accountId: string, id: string, name: string): Promise<void> { |
| 148 | await this.db.prepare("UPDATE passkeys SET name = ? WHERE id = ? AND account_id = ?").bind(name.slice(0, 100), id, accountId).run(); |
| 149 | } |
| 150 | |
| 151 | /** Remove a passkey and every other session it signed in (a lost device's browsers). */ |
| 152 | async deletePasskey(accountId: string, id: string, keepSessionId: string | null): Promise<void> { |
| 153 | await this.db.batch([ |
| 154 | this.db.prepare("DELETE FROM sessions WHERE account_id = ? AND passkey_id = ? AND id != ?").bind(accountId, id, keepSessionId ?? ""), |
| 155 | this.db.prepare("DELETE FROM passkeys WHERE id = ? AND account_id = ?").bind(id, accountId), |
| 156 | ]); |
| 157 | } |
| 158 | |
| 159 | // ---------------------------------------------------------------- WebAuthn challenges |
| 160 | |
| 161 | /** Store a challenge; returns the value for the browser's challenge cookie. */ |
| 162 | async saveChallenge(purpose: "login" | "register", challenge: string, subject: Subject): Promise<string> { |
| 163 | const token = randomToken(24); |
| 164 | const expires = new Date(Date.now() + CHALLENGE_MINUTES * 60_000).toISOString(); |
| 165 | await this.db.batch([ |
| 166 | this.db.prepare("DELETE FROM webauthn_challenges WHERE expires_at < ?").bind(now()), |
| 167 | this.db.prepare("INSERT INTO webauthn_challenges (id, purpose, challenge, subject, expires_at) VALUES (?, ?, ?, ?, ?)").bind(await sha256(token), purpose, challenge, JSON.stringify(subject), expires), |
| 168 | ]); |
| 169 | return token; |
| 170 | } |
| 171 | |
| 172 | /** Consume a challenge exactly once. */ |
| 173 | async takeChallenge(token: string | undefined, purpose: "login" | "register"): Promise<{ challenge: string; subject: Subject } | null> { |
| 174 | if (!token) return null; |
| 175 | const row = await this.db |
| 176 | .prepare("DELETE FROM webauthn_challenges WHERE id = ? RETURNING purpose, challenge, subject, expires_at") |
| 177 | .bind(await sha256(token)) |
| 178 | .first<{ purpose: string; challenge: string; subject: string; expires_at: string }>(); |
| 179 | if (!row || row.purpose !== purpose || row.expires_at < now()) return null; |
| 180 | return { challenge: row.challenge, subject: JSON.parse(row.subject) as Subject }; |
| 181 | } |
| 182 | |
| 183 | // ---------------------------------------------------------------- invites and sign-in links |
| 184 | |
| 185 | /** An open invite by its secret token (null when used, expired or unknown). */ |
| 186 | async invite(token: string): Promise<(InviteRow & { hash: string }) | null> { |
| 187 | const hash = await sha256(token); |
| 188 | const row = await this.db.prepare("SELECT id, email, org_id, repo_id, role FROM invites WHERE token_hash = ? AND accepted_at IS NULL AND expires_at > ?").bind(hash, now()).first<InviteRow>(); |
| 189 | return row ? { ...row, hash } : null; |
| 190 | } |
| 191 | |
| 192 | async inviteByHash(hash: string): Promise<InviteRow | null> { |
| 193 | return this.db.prepare("SELECT id, email, org_id, repo_id, role FROM invites WHERE token_hash = ? AND accepted_at IS NULL AND expires_at > ?").bind(hash, now()).first<InviteRow>(); |
| 194 | } |
| 195 | |
| 196 | /** A single-use link that lets the holder add a passkey to `account` (account recovery, new devices). */ |
| 197 | async createSigninLink(by: Account, account: Account): Promise<{ url: string; expiresAt: string }> { |
| 198 | const token = randomToken(32); |
| 199 | const expiresAt = new Date(Date.now() + LINK_HOURS * 3600_000).toISOString(); |
| 200 | await this.db |
| 201 | .prepare("INSERT INTO signin_links (id, token_hash, account_id, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)") |
| 202 | .bind(newId(), await sha256(token), account.id, by.id, now(), expiresAt) |
| 203 | .run(); |
| 204 | // The token rides in the fragment, which browsers never send to the server, so request logs never hold |
| 205 | // it (web/auth.tsx /signin). Links sent before this used /signin/<token>, still accepted until they expire. |
| 206 | const url = `${this.env.PUBLIC_URL}/signin#${token}`; |
| 207 | await enqueueMail(this.env, [{ kind: "signin_link", data: { url, admin: by.login, expiresAt }, to: { accountId: account.id }, reason: "account", at: now() }]); |
| 208 | return { url, expiresAt }; |
| 209 | } |
| 210 | |
| 211 | async signinLink(token: string): Promise<{ hash: string; account: Account } | null> { |
| 212 | const hash = await sha256(token); |
| 213 | const row = await this.db.prepare("SELECT account_id FROM signin_links WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?").bind(hash, now()).first<{ account_id: string }>(); |
| 214 | if (!row) return null; |
| 215 | const account = await this.db.prepare("SELECT * FROM accounts WHERE id = ?").bind(row.account_id).first<Account>(); |
| 216 | return account && account.kind === "user" ? { hash, account } : null; |
| 217 | } |
| 218 | |
| 219 | /** Mark a link used; false when someone else used it first. */ |
| 220 | async useSigninLink(hash: string): Promise<boolean> { |
| 221 | const r = await this.db.prepare("UPDATE signin_links SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?").bind(now(), hash, now()).run(); |
| 222 | return (r.meta.changes ?? 0) > 0; |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | /** Read one cookie from a raw request. */ |
| 227 | export function cookieFrom(req: Request, name: string): string | undefined { |
| 228 | const cookie = req.headers.get("cookie") ?? ""; |
| 229 | for (const part of cookie.split(";")) { |
| 230 | const i = part.indexOf("="); |
| 231 | if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim(); |
| 232 | } |
| 233 | return undefined; |
| 234 | } |