@binary1702/auth 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/AGENTS.md +139 -0
  2. package/CHANGELOG.md +31 -0
  3. package/INSTALL.md +30 -17
  4. package/PROMPT.md +325 -37
  5. package/README.md +293 -56
  6. package/dist/cli.js +255 -22
  7. package/dist/core/admin-html.d.ts +26 -0
  8. package/dist/core/admin-html.js +252 -0
  9. package/dist/core/admin.d.ts +26 -0
  10. package/dist/core/admin.js +48 -0
  11. package/dist/core/config.d.ts +8 -1
  12. package/dist/core/config.js +20 -6
  13. package/dist/core/db.d.ts +138 -7
  14. package/dist/core/db.js +406 -47
  15. package/dist/core/email.d.ts +49 -0
  16. package/dist/core/email.js +213 -0
  17. package/dist/core/guard.d.ts +49 -0
  18. package/dist/core/guard.js +103 -0
  19. package/dist/core/html.d.ts +36 -0
  20. package/dist/core/html.js +106 -25
  21. package/dist/core/options.d.ts +76 -0
  22. package/dist/core/options.js +310 -0
  23. package/dist/core/org.d.ts +34 -0
  24. package/dist/core/org.js +55 -0
  25. package/dist/core/password.d.ts +16 -0
  26. package/dist/core/password.js +41 -12
  27. package/dist/core/rate.d.ts +26 -0
  28. package/dist/core/rate.js +70 -0
  29. package/dist/core/reset.d.ts +9 -0
  30. package/dist/core/reset.js +31 -0
  31. package/dist/core/session.d.ts +23 -2
  32. package/dist/core/session.js +45 -7
  33. package/dist/core/signup.d.ts +9 -6
  34. package/dist/core/signup.js +26 -15
  35. package/dist/core/tokens.d.ts +29 -0
  36. package/dist/core/tokens.js +64 -0
  37. package/dist/core/verify.d.ts +8 -0
  38. package/dist/core/verify.js +33 -0
  39. package/dist/core/yaml.d.ts +10 -0
  40. package/dist/core/yaml.js +85 -0
  41. package/dist/index.d.ts +17 -6
  42. package/dist/index.js +17 -6
  43. package/dist/next/index.d.ts +11 -4
  44. package/dist/next/index.js +455 -51
  45. package/dist/next/proxy.d.ts +2 -1
  46. package/dist/next/proxy.js +16 -7
  47. package/dist/react/index.d.ts +18 -0
  48. package/dist/react/index.js +55 -0
  49. package/package.json +16 -3
@@ -0,0 +1,76 @@
1
+ import { type YamlTree } from "./yaml.ts";
2
+ export declare const CONFIG_FILE = "b17_auth.yaml";
3
+ export type SignupMode = "open" | "closed" | "invite";
4
+ export type EmailOptions = {
5
+ from?: string;
6
+ replyTo?: string;
7
+ gmail?: {
8
+ /** the mailbox to send as */
9
+ user: string;
10
+ /** service account (Workspace, domain-wide delegation): its email and private key */
11
+ clientEmail?: string;
12
+ privateKey?: string;
13
+ /** or an OAuth client with a refresh token (a plain Gmail account) */
14
+ clientId?: string;
15
+ clientSecret?: string;
16
+ refreshToken?: string;
17
+ };
18
+ resend?: {
19
+ apiKey: string;
20
+ };
21
+ };
22
+ export type Options = {
23
+ /** App name: page titles and email subjects. Falls back to the request's host. */
24
+ name?: string;
25
+ signup: SignupMode;
26
+ inviteCode?: string;
27
+ emailDomain?: string;
28
+ requireVerified: boolean;
29
+ sessionDays: number;
30
+ /** Paths that need a session, exact or `prefix/*`; everything else is open. Use this OR `public`. */
31
+ protected: string[];
32
+ /** The reverse: everything needs a session except these (and static files). */
33
+ public: string[];
34
+ /** Emails that may open /auth/admin. Lowercased. */
35
+ admins: string[];
36
+ database?: string;
37
+ secret?: string;
38
+ email: EmailOptions;
39
+ /** role → permissions. "all" (or "*") is everything, "blue.*" a prefix */
40
+ permissions: Record<string, string[]>;
41
+ /** organization name → role → permissions, overriding `permissions` there */
42
+ organizations: Record<string, Record<string, string[]>>;
43
+ production: boolean;
44
+ };
45
+ export declare const DEFAULTS: Options;
46
+ /** Keys whose value must be a ${…} reference in the file, never a literal */
47
+ export declare const SECRET_KEYS: string[];
48
+ export type Env = Record<string, string | undefined>;
49
+ /** The env vars as they were before the file existed. Pure. */
50
+ export declare function fromEnv(env: Env): Partial<Options>;
51
+ /** "all" is the file's word for "*"; duplicates dropped. */
52
+ export declare function normalizePermissions(list: string[]): string[];
53
+ export declare class OptionsError extends Error {
54
+ constructor(message: string);
55
+ }
56
+ /** Replace every ${NAME} in a value from env. A missing variable is an error naming both. */
57
+ export declare function interpolate(value: string, env: Env, key: string): string;
58
+ /**
59
+ * Literal secrets in a committed file are how keys leak. Returns the keys
60
+ * that hold a literal where a ${…} reference belongs. Pure.
61
+ */
62
+ export declare function literalSecrets(tree: YamlTree): string[];
63
+ /** The file's contents as options, ${…} resolved. Pure. */
64
+ export declare function fromYaml(tree: YamlTree, env: Env): Partial<Options>;
65
+ /** defaults ← file ← env. Maps (email, permissions, organizations) merge key by key. Pure. */
66
+ export declare function resolveOptions(file: YamlTree | null, env: Env): Options;
67
+ /**
68
+ * Read b17_auth.yaml from the project root (the directory the server was
69
+ * started in), or from AUTH_CONFIG when that is set, and merge with env.
70
+ * Memoized; re-read in development when the file changes.
71
+ */
72
+ export declare function loadOptions(cwd?: string, env?: Env): Options;
73
+ /** The current options. What every handler reads. */
74
+ export declare function options(): Options;
75
+ /** Drop the memo (tests, CLI after writing the file). */
76
+ export declare function resetOptions(): void;
@@ -0,0 +1,310 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // One typed object for everything configurable, built from three
3
+ // layers, each overriding the one before:
4
+ //
5
+ // DEFAULTS what the package does with no configuration
6
+ // b17_auth.yaml the app's choices, with ${NAME} pulled from env
7
+ // env vars the deploy-time override, and the only home of secrets
8
+ //
9
+ // No file → the env vars work exactly as they always did. The pure
10
+ // part is resolveOptions(); loadOptions() adds the file read and a
11
+ // per-process memo (re-read in development when the file changes).
12
+ // ─────────────────────────────────────────────────────────────
13
+ import { existsSync, readFileSync, statSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { parseYaml, words } from "./yaml.js";
16
+ export const CONFIG_FILE = "b17_auth.yaml";
17
+ export const DEFAULTS = {
18
+ signup: "closed",
19
+ requireVerified: false,
20
+ sessionDays: 7,
21
+ protected: [],
22
+ public: [],
23
+ admins: [],
24
+ email: {},
25
+ // An owner can do everything unless a file says otherwise. A sole
26
+ // developer never has to write a permissions section at all.
27
+ permissions: { owner: ["*"] },
28
+ organizations: {},
29
+ production: false,
30
+ };
31
+ /** Keys whose value must be a ${…} reference in the file, never a literal */
32
+ export const SECRET_KEYS = ["database", "secret", "inviteCode", "email.gmail.privateKey", "email.gmail.clientSecret", "email.gmail.refreshToken", "email.resend.apiKey"];
33
+ // ── env layer ─────────────────────────────────────────────────
34
+ const truthy = (v) => /^(true|1|yes)$/i.test(v ?? "");
35
+ /** The env vars as they were before the file existed. Pure. */
36
+ export function fromEnv(env) {
37
+ const o = {};
38
+ if (env.AUTH_SIGNUP === "open")
39
+ o.signup = env.AUTH_INVITE_CODE ? "invite" : "open";
40
+ if (env.AUTH_INVITE_CODE)
41
+ o.inviteCode = env.AUTH_INVITE_CODE;
42
+ if (env.AUTH_EMAIL_DOMAIN)
43
+ o.emailDomain = env.AUTH_EMAIL_DOMAIN;
44
+ if (env.AUTH_REQUIRE_VERIFIED !== undefined)
45
+ o.requireVerified = truthy(env.AUTH_REQUIRE_VERIFIED);
46
+ if (env.AUTH_PROTECTED)
47
+ o.protected = words(env.AUTH_PROTECTED);
48
+ if (env.AUTH_PUBLIC)
49
+ o.public = words(env.AUTH_PUBLIC);
50
+ if (env.AUTH_ADMINS)
51
+ o.admins = words(env.AUTH_ADMINS).map((e) => e.toLowerCase());
52
+ if (env.DATABASE_URL)
53
+ o.database = env.DATABASE_URL;
54
+ if (env.AUTH_SECRET)
55
+ o.secret = env.AUTH_SECRET;
56
+ const email = {};
57
+ if (env.AUTH_EMAIL_FROM)
58
+ email.from = env.AUTH_EMAIL_FROM;
59
+ if (env.AUTH_EMAIL_REPLY_TO)
60
+ email.replyTo = env.AUTH_EMAIL_REPLY_TO;
61
+ if (env.AUTH_GMAIL_USER) {
62
+ email.gmail = {
63
+ user: env.AUTH_GMAIL_USER,
64
+ clientEmail: env.AUTH_GMAIL_CLIENT_EMAIL,
65
+ privateKey: env.AUTH_GMAIL_PRIVATE_KEY,
66
+ clientId: env.AUTH_GMAIL_CLIENT_ID,
67
+ clientSecret: env.AUTH_GMAIL_CLIENT_SECRET,
68
+ refreshToken: env.AUTH_GMAIL_REFRESH_TOKEN,
69
+ };
70
+ }
71
+ if (env.AUTH_RESEND_API_KEY)
72
+ email.resend = { apiKey: env.AUTH_RESEND_API_KEY };
73
+ if (Object.keys(email).length)
74
+ o.email = email;
75
+ if (env.AUTH_PERMISSIONS)
76
+ o.permissions = permissionsFromJson(env.AUTH_PERMISSIONS);
77
+ o.production = env.NODE_ENV === "production";
78
+ return o;
79
+ }
80
+ function permissionsFromJson(raw) {
81
+ let parsed;
82
+ try {
83
+ parsed = JSON.parse(raw);
84
+ }
85
+ catch {
86
+ throw new Error('AUTH_PERMISSIONS is not valid JSON. Expected: {"admin":["invoices.view"],"member":[]}');
87
+ }
88
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
89
+ throw new Error("AUTH_PERMISSIONS must be a JSON object mapping role → list of permissions");
90
+ }
91
+ const map = {};
92
+ for (const [role, perms] of Object.entries(parsed)) {
93
+ if (!Array.isArray(perms) || !perms.every((p) => typeof p === "string")) {
94
+ throw new Error(`AUTH_PERMISSIONS: role "${role}" must map to a list of strings`);
95
+ }
96
+ map[role] = normalizePermissions(perms);
97
+ }
98
+ return map;
99
+ }
100
+ /** "all" is the file's word for "*"; duplicates dropped. */
101
+ export function normalizePermissions(list) {
102
+ return [...new Set(list.map((p) => (p === "all" ? "*" : p)))];
103
+ }
104
+ // ── file layer ────────────────────────────────────────────────
105
+ export class OptionsError extends Error {
106
+ constructor(message) {
107
+ super(`${CONFIG_FILE}: ${message}`);
108
+ this.name = "OptionsError";
109
+ }
110
+ }
111
+ /** Replace every ${NAME} in a value from env. A missing variable is an error naming both. */
112
+ export function interpolate(value, env, key) {
113
+ return value.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => {
114
+ const v = env[name];
115
+ if (v === undefined || v === "")
116
+ throw new OptionsError(`${key} refers to \${${name}}, which is not set in the environment`);
117
+ return v;
118
+ });
119
+ }
120
+ const isRef = (v) => typeof v === "string" && /^\$\{[A-Z0-9_]+\}$/i.test(v.trim());
121
+ /**
122
+ * Literal secrets in a committed file are how keys leak. Returns the keys
123
+ * that hold a literal where a ${…} reference belongs. Pure.
124
+ */
125
+ export function literalSecrets(tree) {
126
+ const get = (path) => path.split(".").reduce((n, k) => (n && typeof n === "object" ? n[k] : undefined), tree);
127
+ return SECRET_KEYS.filter((k) => {
128
+ const v = get(k);
129
+ return typeof v === "string" && v.trim() !== "" && !isRef(v);
130
+ });
131
+ }
132
+ /** `key:` with nothing after it and nothing under it: the same as leaving the key out */
133
+ const isEmpty = (v) => typeof v === "object" && v !== null && Object.keys(v).length === 0;
134
+ function str(tree, key, env) {
135
+ const v = tree[key];
136
+ if (v === undefined || isEmpty(v))
137
+ return undefined;
138
+ if (typeof v !== "string")
139
+ throw new OptionsError(`${key} must be a value, not a section`);
140
+ return interpolate(v, env, key);
141
+ }
142
+ function section(tree, key) {
143
+ const v = tree[key];
144
+ if (v === undefined)
145
+ return undefined;
146
+ if (typeof v !== "object")
147
+ throw new OptionsError(`${key} must be a section with indented keys under it`);
148
+ return v;
149
+ }
150
+ /** The file's contents as options, ${…} resolved. Pure. */
151
+ export function fromYaml(tree, env) {
152
+ const o = {};
153
+ const known = new Set(["name", "signup", "inviteCode", "emailDomain", "requireVerified", "sessionDays", "protected", "public", "admins", "database", "secret", "email", "permissions", "organizations"]);
154
+ for (const k of Object.keys(tree))
155
+ if (!known.has(k))
156
+ throw new OptionsError(`unknown key "${k}"`);
157
+ const name = str(tree, "name", env);
158
+ if (name)
159
+ o.name = name;
160
+ const signup = str(tree, "signup", env);
161
+ if (signup !== undefined) {
162
+ if (!["open", "closed", "invite"].includes(signup))
163
+ throw new OptionsError(`signup must be open, closed or invite, not "${signup}"`);
164
+ o.signup = signup;
165
+ }
166
+ const invite = str(tree, "inviteCode", env);
167
+ if (invite)
168
+ o.inviteCode = invite;
169
+ const domain = str(tree, "emailDomain", env);
170
+ if (domain)
171
+ o.emailDomain = domain;
172
+ const rv = str(tree, "requireVerified", env);
173
+ if (rv !== undefined) {
174
+ if (!/^(true|false)$/i.test(rv))
175
+ throw new OptionsError(`requireVerified must be true or false`);
176
+ o.requireVerified = truthy(rv);
177
+ }
178
+ const days = str(tree, "sessionDays", env);
179
+ if (days !== undefined) {
180
+ if (!/^\d+$/.test(days) || Number(days) < 1)
181
+ throw new OptionsError(`sessionDays must be a whole number of days`);
182
+ o.sessionDays = Number(days);
183
+ }
184
+ for (const key of ["protected", "public"]) {
185
+ const v = str(tree, key, env);
186
+ if (v === undefined)
187
+ continue;
188
+ o[key] = words(v);
189
+ for (const p of o[key])
190
+ if (!p.startsWith("/"))
191
+ throw new OptionsError(`${key} paths start with /, not "${p}"`);
192
+ }
193
+ if (o.protected?.length && o.public?.length) {
194
+ throw new OptionsError("use either protected: (only these need sign-in) or public: (everything but these), not both");
195
+ }
196
+ const admins = str(tree, "admins", env);
197
+ if (admins !== undefined) {
198
+ o.admins = words(admins).map((e) => e.toLowerCase());
199
+ for (const a of o.admins)
200
+ if (!a.includes("@"))
201
+ throw new OptionsError(`admins are email addresses, not "${a}"`);
202
+ }
203
+ const database = str(tree, "database", env);
204
+ if (database)
205
+ o.database = database;
206
+ const secret = str(tree, "secret", env);
207
+ if (secret)
208
+ o.secret = secret;
209
+ const email = section(tree, "email");
210
+ if (email) {
211
+ const e = {};
212
+ const from = str(email, "from", env);
213
+ if (from)
214
+ e.from = from;
215
+ const replyTo = str(email, "replyTo", env);
216
+ if (replyTo)
217
+ e.replyTo = replyTo;
218
+ const gmail = section(email, "gmail");
219
+ if (gmail) {
220
+ const user = str(gmail, "user", env);
221
+ if (!user)
222
+ throw new OptionsError("email.gmail needs `user: <mailbox to send as>`");
223
+ e.gmail = {
224
+ user,
225
+ clientEmail: str(gmail, "clientEmail", env),
226
+ privateKey: str(gmail, "privateKey", env),
227
+ clientId: str(gmail, "clientId", env),
228
+ clientSecret: str(gmail, "clientSecret", env),
229
+ refreshToken: str(gmail, "refreshToken", env),
230
+ };
231
+ }
232
+ const resend = section(email, "resend");
233
+ if (resend) {
234
+ const apiKey = str(resend, "apiKey", env);
235
+ if (!apiKey)
236
+ throw new OptionsError("email.resend needs `apiKey: ${AUTH_RESEND_API_KEY}`");
237
+ e.resend = { apiKey };
238
+ }
239
+ o.email = e;
240
+ }
241
+ const perms = section(tree, "permissions");
242
+ if (perms)
243
+ o.permissions = roleMap(perms, env, "permissions");
244
+ const orgs = section(tree, "organizations");
245
+ if (orgs) {
246
+ o.organizations = {};
247
+ for (const [org, roles] of Object.entries(orgs)) {
248
+ if (typeof roles !== "object")
249
+ throw new OptionsError(`organizations.${org} must be a section of role: permissions`);
250
+ o.organizations[org] = roleMap(roles, env, `organizations.${org}`);
251
+ }
252
+ }
253
+ return o;
254
+ }
255
+ function roleMap(tree, env, where) {
256
+ const map = {};
257
+ for (const [role, v] of Object.entries(tree)) {
258
+ if (isEmpty(v)) {
259
+ map[role] = []; // `member:` with nothing: a role that exists and may do nothing yet
260
+ continue;
261
+ }
262
+ if (typeof v !== "string")
263
+ throw new OptionsError(`${where}.${role} must be a list of permissions separated by spaces`);
264
+ map[role] = normalizePermissions(words(interpolate(v, env, `${where}.${role}`)));
265
+ }
266
+ return map;
267
+ }
268
+ // ── merge ─────────────────────────────────────────────────────
269
+ /** defaults ← file ← env. Maps (email, permissions, organizations) merge key by key. Pure. */
270
+ export function resolveOptions(file, env) {
271
+ const layers = [DEFAULTS, file ? fromYaml(file, env) : {}, fromEnv(env)];
272
+ const out = { ...DEFAULTS, email: {}, permissions: {}, organizations: {} };
273
+ for (const layer of layers) {
274
+ for (const [k, v] of Object.entries(layer)) {
275
+ if (v === undefined)
276
+ continue;
277
+ if (k === "email" || k === "permissions" || k === "organizations")
278
+ Object.assign(out[k], v);
279
+ else
280
+ out[k] = v;
281
+ }
282
+ }
283
+ return out;
284
+ }
285
+ // ── loading ───────────────────────────────────────────────────
286
+ let cache = null;
287
+ /**
288
+ * Read b17_auth.yaml from the project root (the directory the server was
289
+ * started in), or from AUTH_CONFIG when that is set, and merge with env.
290
+ * Memoized; re-read in development when the file changes.
291
+ */
292
+ export function loadOptions(cwd = process.cwd(), env = process.env) {
293
+ const path = env.AUTH_CONFIG ? (env.AUTH_CONFIG.startsWith("/") ? env.AUTH_CONFIG : join(cwd, env.AUTH_CONFIG)) : join(cwd, CONFIG_FILE);
294
+ const exists = existsSync(path);
295
+ const mtime = exists ? statSync(path).mtimeMs : 0;
296
+ if (cache && cache.path === path && (cache.options.production || cache.mtime === mtime))
297
+ return cache.options;
298
+ const file = exists ? parseYaml(readFileSync(path, "utf8")) : null;
299
+ const options = resolveOptions(file, env);
300
+ cache = { options, path, mtime };
301
+ return options;
302
+ }
303
+ /** The current options. What every handler reads. */
304
+ export function options() {
305
+ return loadOptions();
306
+ }
307
+ /** Drop the memo (tests, CLI after writing the file). */
308
+ export function resetOptions() {
309
+ cache = null;
310
+ }
@@ -0,0 +1,34 @@
1
+ import type { User } from "./session.ts";
2
+ import type { SessionRow } from "./db.ts";
3
+ import type { Options } from "./options.ts";
4
+ export type Organization = {
5
+ id: string;
6
+ name: string;
7
+ };
8
+ export type Authorization = {
9
+ role: string | null;
10
+ permissions: string[];
11
+ };
12
+ export type Session = {
13
+ user: User;
14
+ organization: Organization | null;
15
+ authorization: Authorization;
16
+ };
17
+ export type PermissionSource = Pick<Options, "permissions" | "organizations">;
18
+ /** The effective permissions of a role, in an organization. The organization's own map wins. Pure. */
19
+ export declare function permissionsFor(role: string | null, org: string | null, opts: PermissionSource): string[];
20
+ /** Build the contract from the store's row. Pure. */
21
+ export declare function buildSession(row: SessionRow, opts: PermissionSource): Session;
22
+ /**
23
+ * The check. Pure. A granted entry matches when it is
24
+ * "*" everything
25
+ * "invoices.*" any permission under that prefix ("invoices.view", "invoices.a.b")
26
+ * exact the permission itself
27
+ */
28
+ export declare function hasPermission(session: Pick<Session, "authorization">, permission: string): boolean;
29
+ /** What a signed-out client sees from the React side. The HTTP endpoint answers 401 instead. */
30
+ export declare const NO_SESSION: {
31
+ user: null;
32
+ organization: null;
33
+ authorization: Authorization;
34
+ };
@@ -0,0 +1,55 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // The session contract: one JSON shape for "who is this, where are
3
+ // they, what may they do". Served by GET /auth/session and returned
4
+ // by getSession() on the server. Three keys, nothing else.
5
+ //
6
+ // user { id, email }
7
+ // organization { id, name } or null when the user is in none
8
+ // authorization { role, permissions }
9
+ //
10
+ // Role comes from the membership row (members.role, free text;
11
+ // the CLI defaults to "member"). Permissions are the app's business,
12
+ // so they come from b17_auth.yaml (or AUTH_PERMISSIONS):
13
+ //
14
+ // permissions: role → what it may do
15
+ // owner: all "all" (or "*") is everything
16
+ // member: blue.* green.read
17
+ // organizations: overrides, by organization name
18
+ // Acme:
19
+ // member: blue.read
20
+ //
21
+ // Unknown role, no role, or no map: no permissions. All pure; the
22
+ // store feeds it one row.
23
+ // ─────────────────────────────────────────────────────────────
24
+ /** The effective permissions of a role, in an organization. The organization's own map wins. Pure. */
25
+ export function permissionsFor(role, org, opts) {
26
+ if (!role)
27
+ return [];
28
+ const own = org ? opts.organizations[org]?.[role] : undefined;
29
+ return own ?? opts.permissions[role] ?? [];
30
+ }
31
+ /** Build the contract from the store's row. Pure. */
32
+ export function buildSession(row, opts) {
33
+ const organization = row.org_id && row.org_name !== null ? { id: row.org_id, name: row.org_name } : null;
34
+ const role = organization ? row.role : null;
35
+ return {
36
+ user: { id: row.id, email: row.email },
37
+ organization,
38
+ authorization: { role, permissions: permissionsFor(role, organization?.name ?? null, opts) },
39
+ };
40
+ }
41
+ /**
42
+ * The check. Pure. A granted entry matches when it is
43
+ * "*" everything
44
+ * "invoices.*" any permission under that prefix ("invoices.view", "invoices.a.b")
45
+ * exact the permission itself
46
+ */
47
+ export function hasPermission(session, permission) {
48
+ return session.authorization.permissions.some((granted) => granted === "*" || granted === permission || (granted.endsWith(".*") && permission.startsWith(granted.slice(0, -1))));
49
+ }
50
+ /** What a signed-out client sees from the React side. The HTTP endpoint answers 401 instead. */
51
+ export const NO_SESSION = {
52
+ user: null,
53
+ organization: null,
54
+ authorization: { role: null, permissions: [] },
55
+ };
@@ -1,2 +1,18 @@
1
+ export declare const SCRYPT_N: number;
2
+ export declare const SCRYPT_R = 8;
3
+ export declare const SCRYPT_P = 1;
1
4
  export declare function hashPassword(password: string): Promise<string>;
5
+ type Parsed = {
6
+ n: number;
7
+ r: number;
8
+ p: number;
9
+ salt: Buffer;
10
+ hash: Buffer;
11
+ };
12
+ /** The parts of a stored hash, or null when it is not one of ours. Pure. */
13
+ export declare function parseHash(stored: string): Parsed | null;
14
+ /** False for a wrong password and for anything that is not a well-formed hash. Never throws. */
2
15
  export declare function verifyPassword(password: string, stored: string): Promise<boolean>;
16
+ /** True when the stored hash was made with weaker parameters than today's, or is not ours. Pure. */
17
+ export declare function needsRehash(stored: string): boolean;
18
+ export {};
@@ -3,30 +3,59 @@
3
3
  //
4
4
  // Stored format: scrypt$N$r$p$<salt b64url>$<hash b64url>
5
5
  // Parameters live in the string, so they can be raised later
6
- // without invalidating existing rows.
6
+ // without invalidating existing rows: needsRehash() spots a row
7
+ // hashed under weaker parameters, and sign-in re-hashes it.
8
+ //
9
+ // N = 2^17 since 0.3.0 (about 0.3 s and 128 MB per hash): slow on
10
+ // purpose, it is what makes a stolen table expensive to crack.
7
11
  // ─────────────────────────────────────────────────────────────
8
12
  import { randomBytes, scrypt as scryptCb, timingSafeEqual } from "node:crypto";
9
13
  import { promisify } from "node:util";
10
14
  const scrypt = promisify(scryptCb);
11
- const N = 16384;
12
- const R = 8;
13
- const P = 1;
15
+ export const SCRYPT_N = 2 ** 17;
16
+ export const SCRYPT_R = 8;
17
+ export const SCRYPT_P = 1;
14
18
  const KEYLEN = 64;
15
19
  function run(password, salt, n, r, p) {
16
20
  return scrypt(password, salt, KEYLEN, { N: n, r, p, maxmem: 256 * n * r });
17
21
  }
18
22
  export async function hashPassword(password) {
19
23
  const salt = randomBytes(16);
20
- const hash = await run(password, salt, N, R, P);
21
- return ["scrypt", N, R, P, salt.toString("base64url"), hash.toString("base64url")].join("$");
24
+ const hash = await run(password, salt, SCRYPT_N, SCRYPT_R, SCRYPT_P);
25
+ return ["scrypt", SCRYPT_N, SCRYPT_R, SCRYPT_P, salt.toString("base64url"), hash.toString("base64url")].join("$");
22
26
  }
23
- export async function verifyPassword(password, stored) {
27
+ /** The parts of a stored hash, or null when it is not one of ours. Pure. */
28
+ export function parseHash(stored) {
24
29
  const parts = stored.split("$");
25
30
  if (parts.length !== 6 || parts[0] !== "scrypt")
26
- return false;
31
+ return null;
27
32
  const [, n, r, p, saltB64, hashB64] = parts;
28
- const salt = Buffer.from(saltB64, "base64url");
29
- const expected = Buffer.from(hashB64, "base64url");
30
- const actual = await run(password, salt, Number(n), Number(r), Number(p));
31
- return actual.length === expected.length && timingSafeEqual(actual, expected);
33
+ const num = (s) => (/^\d+$/.test(s) ? Number(s) : NaN);
34
+ const parsed = { n: num(n), r: num(r), p: num(p), salt: Buffer.from(saltB64, "base64url"), hash: Buffer.from(hashB64, "base64url") };
35
+ // N must be a power of two above 1; r and p positive; salt and hash present
36
+ if (!(parsed.n > 1 && Number.isInteger(parsed.n) && (parsed.n & (parsed.n - 1)) === 0))
37
+ return null;
38
+ if (!(parsed.r >= 1 && parsed.p >= 1))
39
+ return null;
40
+ if (parsed.salt.length === 0 || parsed.hash.length === 0)
41
+ return null;
42
+ return parsed;
43
+ }
44
+ /** False for a wrong password and for anything that is not a well-formed hash. Never throws. */
45
+ export async function verifyPassword(password, stored) {
46
+ const h = parseHash(stored);
47
+ if (!h)
48
+ return false;
49
+ try {
50
+ const actual = await run(password, h.salt, h.n, h.r, h.p);
51
+ return actual.length === h.hash.length && timingSafeEqual(actual, h.hash);
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
57
+ /** True when the stored hash was made with weaker parameters than today's, or is not ours. Pure. */
58
+ export function needsRehash(stored) {
59
+ const h = parseHash(stored);
60
+ return !h || h.n < SCRYPT_N || h.r < SCRYPT_R || h.p < SCRYPT_P;
32
61
  }
@@ -0,0 +1,26 @@
1
+ import { type Db } from "./db.ts";
2
+ export type Bucket = "signin:ip" | "signin:email" | "signup:ip" | "forgot:email" | "verify:email";
3
+ /** [limit, window in seconds] */
4
+ export declare const LIMITS: Record<Bucket, readonly [number, number]>;
5
+ export type Hit = {
6
+ count: number;
7
+ reset_at: string | Date;
8
+ };
9
+ export type Decision = {
10
+ allowed: boolean;
11
+ retryAfter: number;
12
+ count: number;
13
+ limit: number;
14
+ };
15
+ /** The rule. `hit` is the bucket's row after this request was counted. Pure. */
16
+ export declare function decide(hit: Hit, limit: number, now?: Date): Decision;
17
+ /** The client's address, as the first proxy saw it; "unknown" when no header says. Pure. */
18
+ export declare function clientIp(headers: {
19
+ get(name: string): string | null;
20
+ }): string;
21
+ /** The row's key. Emails lowercased, so "Ben@" and "ben@" share a bucket. Pure. */
22
+ export declare function bucketKey(bucket: Bucket, value: string): string;
23
+ /** The message a refused request sees. Pure. */
24
+ export declare function retryMessage(retryAfter: number): string;
25
+ /** Count this request against the bucket and decide. One statement, plus housekeeping when a window starts. */
26
+ export declare function rateLimit(bucket: Bucket, value: string, store?: Db, now?: Date): Promise<Decision>;
@@ -0,0 +1,70 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // Rate limiting: a fixed window per bucket, stored in one row.
3
+ //
4
+ // rateLimit("signin:email", "ben@x.y") one INSERT … ON CONFLICT that
5
+ // starts or bumps the window and
6
+ // returns the count; decide() then
7
+ // says allowed or not, and for how
8
+ // long to wait.
9
+ //
10
+ // Buckets and limits (per window):
11
+ // signin:ip 20 / 15 min stops guessing many accounts from one place
12
+ // signin:email 10 / 15 min stops guessing one account from many places
13
+ // signup:ip 10 / 1 h stops account spam
14
+ // forgot:email 3 / 1 h stops reset-email spam
15
+ // verify:email 3 / 1 h stops verification-email spam
16
+ //
17
+ // Refused requests get 429 with Retry-After. The decision is pure; the
18
+ // storage is one function.
19
+ // ─────────────────────────────────────────────────────────────
20
+ import { db, ensureSchema } from "./db.js";
21
+ /** [limit, window in seconds] */
22
+ export const LIMITS = {
23
+ "signin:ip": [20, 15 * 60],
24
+ "signin:email": [10, 15 * 60],
25
+ "signup:ip": [10, 60 * 60],
26
+ "forgot:email": [3, 60 * 60],
27
+ "verify:email": [3, 60 * 60],
28
+ };
29
+ /** The rule. `hit` is the bucket's row after this request was counted. Pure. */
30
+ export function decide(hit, limit, now = new Date()) {
31
+ const count = Number(hit.count);
32
+ const resetAt = new Date(hit.reset_at).getTime();
33
+ const retryAfter = Math.max(1, Math.ceil((resetAt - now.getTime()) / 1000));
34
+ return count <= limit ? { allowed: true, retryAfter: 0, count, limit } : { allowed: false, retryAfter, count, limit };
35
+ }
36
+ /** The client's address, as the first proxy saw it; "unknown" when no header says. Pure. */
37
+ export function clientIp(headers) {
38
+ const fwd = headers.get("x-forwarded-for");
39
+ if (fwd) {
40
+ const first = fwd.split(",")[0].trim();
41
+ if (first)
42
+ return first;
43
+ }
44
+ return headers.get("x-real-ip")?.trim() || "unknown";
45
+ }
46
+ /** The row's key. Emails lowercased, so "Ben@" and "ben@" share a bucket. Pure. */
47
+ export function bucketKey(bucket, value) {
48
+ return `${bucket}:${value.trim().toLowerCase()}`;
49
+ }
50
+ /** The message a refused request sees. Pure. */
51
+ export function retryMessage(retryAfter) {
52
+ const m = Math.ceil(retryAfter / 60);
53
+ return retryAfter < 90 ? `Too many attempts. Try again in ${retryAfter} seconds.` : `Too many attempts. Try again in ${m} minutes.`;
54
+ }
55
+ /** Count this request against the bucket and decide. One statement, plus housekeeping when a window starts. */
56
+ export async function rateLimit(bucket, value, store = db(), now = new Date()) {
57
+ const [limit, windowSeconds] = LIMITS[bucket];
58
+ await ensureSchema(store);
59
+ const [hit] = await store.query(`INSERT INTO rate_limits (bucket, count, reset_at)
60
+ VALUES ($1, 1, now() + ($2::int * interval '1 second'))
61
+ ON CONFLICT (bucket) DO UPDATE SET
62
+ count = CASE WHEN rate_limits.reset_at <= now() THEN 1 ELSE rate_limits.count + 1 END,
63
+ reset_at = CASE WHEN rate_limits.reset_at <= now() THEN now() + ($2::int * interval '1 second') ELSE rate_limits.reset_at END
64
+ RETURNING count, reset_at`, [bucketKey(bucket, value), windowSeconds]);
65
+ if (Number(hit.count) === 1) {
66
+ // A window just started: a cheap moment to drop windows nobody will look at again
67
+ await store.query(`DELETE FROM rate_limits WHERE reset_at < now() - interval '1 day'`);
68
+ }
69
+ return decide(hit, limit, now);
70
+ }