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.

234 lines · 11.5 KBRaw History
1import type { Env } from "../env.js";
2import type { Account } from "../lib/directory.js";
3import { newId, now, randomToken, sha256 } from "../lib/util.js";
4import { enqueueMail } from "../mail/send.js";
5
6/** Browser session cookie. `__Host-` pins it to this origin: Secure, Path=/, no Domain. */
7export const SESSION_COOKIE = "__Host-dfy_session";
8/** Binds an in-flight WebAuthn ceremony to the browser that started it. */
9export const CHALLENGE_COOKIE = "__Host-dfy_webauthn";
10export const SESSION_DAYS = 30;
11/** However often it is used, a session ends this long after its sign-in. */
12export const SESSION_MAX_DAYS = 90;
13const CHALLENGE_MINUTES = 5;
14const LINK_HOURS = 24;
15
16export 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
29export 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
40export 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. */
43export 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
50const 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. */
53const 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. */
56export 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. */
227export 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}