@krizaka/auth 0.8.0 → 0.9.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.
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Query, ModuleSchema } from '@krizaka/db';
2
2
  export { Query } from '@krizaka/db';
3
- import { E as ExternalIdentity, I as IdentityLinkStore, a as IdentityProvider } from './identity-links-BI3DYE2I.js';
4
- export { C as CompleteOptions, F as FEDERATED_PROVIDERS, b as FEDERATION_CODE, c as FederatedProvider, d as FederationCode, e as FederationError, f as IdentityLinkError, S as StartOptions, g as StartedSignIn, h as dbIdentityLinks, i as identityLinksMigration, j as isFederatedProvider, k as isLocalReturn, m as memoryIdentityLinks, l as memoryIdentityProvider, p as pkceChallenge } from './identity-links-BI3DYE2I.js';
3
+ import { E as ExternalIdentity, I as IdentityLinkStore, S as SessionStore, N as NewSession, a as IdentityProvider } from './sessions-B9YKXs0i.js';
4
+ export { C as CompleteOptions, F as FEDERATED_PROVIDERS, b as FEDERATION_CODE, c as FederatedProvider, d as FederationCode, e as FederationError, f as IdentityLinkError, g as Session, h as SessionCookie, i as Sessions, j as SessionsDeps, k as StartOptions, l as StartedSignIn, m as clearedSessionCookie, n as createSessions, o as dbIdentityLinks, p as identityLinksMigration, q as isFederatedProvider, r as isLocalReturn, s as memoryIdentityLinks, t as memoryIdentityProvider, u as pkceChallenge, v as sessionCookie, w as sessionTokenOf } from './sessions-B9YKXs0i.js';
5
5
 
6
6
  /**
7
7
  * The account PORT (ADR-0008 of Yomizaka, now shared). A product's account — name, language, theme, avatar, e-mail change with
@@ -148,6 +148,82 @@ declare function dbAccounts(deps: AccountDeps): AccountService;
148
148
  /** @deprecated The old name of `dbAccounts` (a name says what a thing does, not the technology). Removed in the next major. */
149
149
  declare const postgresAccounts: typeof dbAccounts;
150
150
 
151
+ /**
152
+ * The administration's locks, as one pure decision every product shares (it was copied in each product's `lib/access.ts`): a session,
153
+ * the ADMIN role, and an address on the allowed list. A pure function of its inputs: the product reads the session and the address
154
+ * and maps the verdict to its own answer.
155
+ */
156
+ /** Stable codes of a refusal. */
157
+ declare const ADMIN_REFUSAL: {
158
+ readonly unauthenticated: "UNAUTHENTICATED";
159
+ readonly forbidden: "FORBIDDEN";
160
+ readonly ipNotAllowed: "IP_NOT_ALLOWED";
161
+ readonly notFound: "NOT_FOUND";
162
+ };
163
+ type AdminRefusal = (typeof ADMIN_REFUSAL)[keyof typeof ADMIN_REFUSAL];
164
+ /** The verdict: let in, or the status and code to answer. */
165
+ type AdminDecision = {
166
+ ok: true;
167
+ } | {
168
+ ok: false;
169
+ status: 401 | 403 | 404;
170
+ code: AdminRefusal;
171
+ };
172
+ /**
173
+ * The list the administration's address lock reads, from the environment's value (`AUTH_ALLOWED_IPS`): an empty list is « nobody » in
174
+ * production and « anybody » in development, so a forgotten setting never opens the console.
175
+ * @param list the configured addresses
176
+ * @returns the list to hand to `requireAdmin`, or null (anybody)
177
+ */
178
+ declare function allowedIps(list: readonly string[], options: {
179
+ production: boolean;
180
+ }): readonly string[] | null;
181
+ /** Whether an address may use the administration: `null` allowed means anybody, an empty list nobody, and a missing address is never allowed by a list. */
182
+ declare function ipAllowed(ip: string | null, allowed: readonly string[] | null): boolean;
183
+ interface AdminGateInput {
184
+ /** The caller's session, or null when there is none. Only its account's role is read. */
185
+ session: {
186
+ account: {
187
+ role: string;
188
+ };
189
+ } | null;
190
+ /** The caller's address (see `clientIp`). */
191
+ ip: string | null;
192
+ /** The addresses allowed (see `allowedIps`); null = anybody. */
193
+ allowedIps: readonly string[] | null;
194
+ /** `api` (default): 401 without a session, 403 with the reason. `page`: the administration does not announce itself, so every failed lock is a 404. */
195
+ surface?: "api" | "page";
196
+ /** The role that administers. Default `ADMIN`. */
197
+ role?: string;
198
+ }
199
+ /**
200
+ * The administration's three locks. The first that fails decides.
201
+ * @returns `{ ok: true }`, or the status (401, 403 or 404 on a page) and the code to answer
202
+ */
203
+ declare function requireAdmin(input: AdminGateInput): AdminDecision;
204
+
205
+ /** How a deployment tells the client's address: the header its edge sets, and how many proxies append to `X-Forwarded-For`. */
206
+ interface ClientIpRules {
207
+ /** A header the edge sets and overwrites with the client's address (`do-connecting-ip` on DigitalOcean App Platform, `cf-connecting-ip` behind Cloudflare). Read first when present. */
208
+ header?: string | undefined;
209
+ /** How many trusted proxies append to `X-Forwarded-For`: 1 (the default) to 10. */
210
+ trustedProxyHops?: number | undefined;
211
+ }
212
+ /**
213
+ * The client's IP address, as far as this deployment can trust it. `X-Forwarded-For` is a list each proxy appends to: its LEFT end is
214
+ * whatever the client sent (anyone can write `X-Forwarded-For: 1.2.3.4`), so a limit keyed on it is a limit the attacker chooses. The
215
+ * address the last trusted proxy saw is read from the RIGHT:
216
+ *
217
+ * - the `header` the edge sets and overwrites, when present;
218
+ * - otherwise the entry `trustedProxyHops` (default 1: one proxy) from the right of `X-Forwarded-For`, then `X-Real-IP`;
219
+ * - otherwise null (local development without a proxy).
220
+ *
221
+ * The product reads its environment (`ABUSE_CLIENT_IP_HEADER`, `ABUSE_TRUSTED_PROXY_HOPS`) and passes the values: this function reads none.
222
+ * @param headers the request's headers
223
+ * @returns a valid IPv4 or IPv6 address, or null
224
+ */
225
+ declare function clientIp(headers: Headers, rules?: ClientIpRules): string | null;
226
+
151
227
  /** What federated sign-in needs from the product's accounts: no second user table, just a look-up and, when registration is open, a creation. */
152
228
  interface FederatedAccounts {
153
229
  /** The account that holds this address (lower case), and whether the product has verified it; null when none. */
@@ -213,6 +289,25 @@ declare function rolesMigration(roles: readonly string[], options?: {
213
289
  scopeTable?: string;
214
290
  }): string;
215
291
 
292
+ /** The shortest password the products accept. */
293
+ declare const PASSWORD_MIN_LENGTH = 12;
294
+ /** The longest: a longer one is refused before any work is done (a very long password is a way to make the server spend time). */
295
+ declare const PASSWORD_MAX_LENGTH = 256;
296
+ /** Why a new password is refused, or null when its length is acceptable. */
297
+ declare function passwordProblem(password: string): "TOO_SHORT" | "TOO_LONG" | null;
298
+ /**
299
+ * Hashes a password with a fresh salt.
300
+ * @returns `scrypt$N$r$p$salt$hash`
301
+ */
302
+ declare function hashPassword(password: string): Promise<string>;
303
+ /**
304
+ * Checks a password against a stored hash, in constant time. A hash of another scheme, a malformed one or an over-long password is `false`.
305
+ * The cost parameters are read from the stored value, so an older hash still verifies after the default changes.
306
+ */
307
+ declare function verifyPassword(password: string, stored: string): Promise<boolean>;
308
+ /** A hash to verify against when the address has no account: the same work, the same time, the same answer. */
309
+ declare const decoyHash: () => Promise<string>;
310
+
216
311
  /**
217
312
  * Roles per scope (a shop, a workspace), as DATA: a model says which roles exist, which permissions each holds and who may hand which
218
313
  * role to whom. The code asks `can(role, "invoices:write")`, never `if (role === "owner")`. One person may belong to several scopes, with
@@ -343,6 +438,44 @@ declare function rolesSchema(roles: readonly string[], options?: {
343
438
  }): ModuleSchema;
344
439
  /** Which external identity belongs to which account (federated sign-in): `(issuer, subject) → account`, never a second user table. Idempotent. */
345
440
  declare const identityLinksSchema: ModuleSchema;
441
+ /**
442
+ * The table of web sessions: the cookie holds a random token, the table its keyed hash (a leaked table opens nothing). The shape the
443
+ * products' own `sessions` table has, so a product that has it adopts version 1 (`adoptMigration`) and one that does not emits it.
444
+ * Idempotent.
445
+ */
446
+ declare const sessionsSchema: ModuleSchema;
447
+
448
+ /**
449
+ * An in-memory session store, for unit tests and demos. `accountOf` plays the accounts table: it answers the account of an id, or null
450
+ * when it does not exist or is not active.
451
+ */
452
+ declare function memorySessionStore<Account>(accountOf: (accountId: string) => Account | null): SessionStore<Account> & {
453
+ rows(): readonly NewSession[];
454
+ };
455
+
456
+ interface DbSessionStoreOptions<Account> {
457
+ /** The product's session table. Default `sessions` (`id`, `account_id`, `token_hash` unique, `created_at`, `expires_at`, `ip`, `user_agent`). */
458
+ sessions?: string;
459
+ /** The product's account table. Default `accounts`. */
460
+ accounts?: string;
461
+ /** The account columns the session carries (`id`, `email`, `role`, `locale`, `name`…): all checked, all read in one query with the session. */
462
+ columns: readonly string[];
463
+ /** Builds the product's account from a row holding exactly `columns`. */
464
+ toAccount: (row: Record<string, unknown>) => Account;
465
+ /** An account that is not active signs nobody in (a suspension applies at once): the column and the value it must have (`status` = `ACTIVE`). */
466
+ active?: {
467
+ column: string;
468
+ equals: string;
469
+ };
470
+ }
471
+ /**
472
+ * The session store over the product's own tables (`sessions` joined to `accounts`): the declared tables and columns are checked by
473
+ * `ident()` and nothing else reaches SQL. The expiry is compared with the clock's `now`, not the database's.
474
+ * @param query the product's query
475
+ * @param options the table names, the account columns and how the account is built
476
+ * @throws DbError `DB_UNSAFE_IDENTIFIER` for a table or column name that is not a safe identifier
477
+ */
478
+ declare function dbSessionStore<Account>(query: Query, options: DbSessionStoreOptions<Account>): SessionStore<Account>;
346
479
 
347
480
  /** The `fetch` the adapter calls krizaka-users with (injected: no global, no network in tests). */
348
481
  type Fetcher = (url: string, init: {
@@ -369,4 +502,4 @@ declare function parseExternalIdentity(body: unknown): ExternalIdentity;
369
502
  */
370
503
  declare function usersIdentityProvider({ baseUrl, appUrl, fetch }: UsersFederationOptions): IdentityProvider;
371
504
 
372
- export { type AccountDeps, AccountError, type AccountProfile, type AccountService, EMAIL_CHANGE_TTL_HOURS, ExternalIdentity, FEDERATED_REFUSAL, type FederatedAccounts, type FederatedRefusal, type FederatedSignIn, type FederatedSignInDeps, type FederatedSignInResult, type Fetcher, IdentityLinkStore, IdentityProvider, type Invitation, type Mail, type MailLink, type Member, PASSWORD_RESET_MAX_MINUTES, PASSWORD_RESET_MIN_MINUTES, PASSWORD_RESET_TTL_MINUTES, type ProfilePatch, ROLES_CODE, type RoleModel, type Roles, type RolesCode, type RolesDeps, RolesError, type RolesQuery, type SessionSummary, type Theme, type UsersFederationOptions, accountsMigration, accountsSchema, can, createFederatedSignIn, createRoles, dbAccounts, defineRoles, emailChangeMail, emailChangeToken, identityLinksSchema, isEmailChangeToken, isLinkToken, linkToken, parseExternalIdentity, passwordResetMail, postgresAccounts, rolesMigration, rolesSchema, usersIdentityProvider };
505
+ export { ADMIN_REFUSAL, type AccountDeps, AccountError, type AccountProfile, type AccountService, type AdminDecision, type AdminGateInput, type AdminRefusal, type ClientIpRules, type DbSessionStoreOptions, EMAIL_CHANGE_TTL_HOURS, ExternalIdentity, FEDERATED_REFUSAL, type FederatedAccounts, type FederatedRefusal, type FederatedSignIn, type FederatedSignInDeps, type FederatedSignInResult, type Fetcher, IdentityLinkStore, IdentityProvider, type Invitation, type Mail, type MailLink, type Member, NewSession, PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_RESET_MAX_MINUTES, PASSWORD_RESET_MIN_MINUTES, PASSWORD_RESET_TTL_MINUTES, type ProfilePatch, ROLES_CODE, type RoleModel, type Roles, type RolesCode, type RolesDeps, RolesError, type RolesQuery, SessionStore, type SessionSummary, type Theme, type UsersFederationOptions, accountsMigration, accountsSchema, allowedIps, can, clientIp, createFederatedSignIn, createRoles, dbAccounts, dbSessionStore, decoyHash, defineRoles, emailChangeMail, emailChangeToken, hashPassword, identityLinksSchema, ipAllowed, isEmailChangeToken, isLinkToken, linkToken, memorySessionStore, parseExternalIdentity, passwordProblem, passwordResetMail, postgresAccounts, requireAdmin, rolesMigration, rolesSchema, sessionsSchema, usersIdentityProvider, verifyPassword };
package/dist/index.js CHANGED
@@ -12,198 +12,69 @@ import {
12
12
  memoryIdentityLinks,
13
13
  memoryIdentityProvider,
14
14
  pkceChallenge,
15
- rolesSchema
16
- } from "./chunk-U5SDV7YV.js";
17
-
18
- // src/accounts.ts
19
- import crypto2 from "crypto";
20
- import { addHours, addMinutes } from "date-fns";
21
-
22
- // src/port.ts
23
- var AccountError = class extends Error {
24
- constructor(status, code, message) {
25
- super(message);
26
- this.status = status;
27
- this.code = code;
28
- this.name = "AccountError";
29
- }
30
- status;
31
- code;
32
- };
15
+ rolesSchema,
16
+ sessionsSchema
17
+ } from "./chunk-5CFOMWBN.js";
18
+ import {
19
+ AccountError,
20
+ EMAIL_CHANGE_TTL_HOURS,
21
+ PASSWORD_RESET_MAX_MINUTES,
22
+ PASSWORD_RESET_MIN_MINUTES,
23
+ PASSWORD_RESET_TTL_MINUTES,
24
+ dbAccounts,
25
+ emailChangeMail,
26
+ emailChangeToken,
27
+ isEmailChangeToken,
28
+ isLinkToken,
29
+ linkToken,
30
+ passwordResetMail,
31
+ postgresAccounts
32
+ } from "./chunk-3CLSDLXB.js";
33
33
 
34
- // src/accounts.ts
35
- var EMAIL_CHANGE_TTL_HOURS = 24;
36
- var PASSWORD_RESET_TTL_MINUTES = 30;
37
- var PASSWORD_RESET_MIN_MINUTES = 5;
38
- var PASSWORD_RESET_MAX_MINUTES = 1440;
39
- var checkResetTtl = (minutes) => {
40
- if (!Number.isInteger(minutes) || minutes < PASSWORD_RESET_MIN_MINUTES || minutes > PASSWORD_RESET_MAX_MINUTES) {
41
- throw new RangeError(`passwordResetTtlMinutes must be a whole number from ${PASSWORD_RESET_MIN_MINUTES} to ${PASSWORD_RESET_MAX_MINUTES}, got ${minutes}`);
42
- }
43
- return minutes;
34
+ // src/admin-gate.ts
35
+ var ADMIN_REFUSAL = {
36
+ unauthenticated: "UNAUTHENTICATED",
37
+ forbidden: "FORBIDDEN",
38
+ ipNotAllowed: "IP_NOT_ALLOWED",
39
+ notFound: "NOT_FOUND"
44
40
  };
45
- var NAME_MAX = 80;
46
- var PASSWORD_MIN = 12;
47
- var PASSWORD_MAX = 256;
48
- var THEMES = ["light", "dark", "system"];
49
- var linkToken = (prefix) => `${prefix}1.${crypto2.randomBytes(32).toString("base64url")}`;
50
- var isLinkToken = (prefix, value) => !!value && new RegExp(`^${prefix}1\\.[A-Za-z0-9_-]{43}$`).test(value);
51
- var emailChangeToken = linkToken;
52
- var isEmailChangeToken = isLinkToken;
53
- function passwordResetMail(product, locale, url, ttlMinutes = PASSWORD_RESET_TTL_MINUTES) {
54
- checkResetTtl(ttlMinutes);
55
- return locale === "fr" ? { subject: `R\xE9initialisez votre mot de passe \u2014 ${product}`, text: `Bonjour,
56
-
57
- Pour choisir un nouveau mot de passe pour votre compte ${product}, ouvrez ce lien (valide ${ttlMinutes} min, une seule fois) :
58
- ${url}
59
-
60
- Si vous n'avez rien demand\xE9, ignorez ce message : votre mot de passe ne change pas.
61
- ` } : { subject: `Reset your password \u2014 ${product}`, text: `Hello,
62
-
63
- To choose a new password for your ${product} account, open this link (valid ${ttlMinutes} min, once):
64
- ${url}
65
-
66
- If you did not ask for this, ignore this message: your password does not change.
67
- ` };
41
+ function allowedIps(list, options) {
42
+ if (list.length > 0) return list;
43
+ return options.production ? [] : null;
44
+ }
45
+ function ipAllowed(ip, allowed) {
46
+ if (allowed === null) return true;
47
+ return !!ip && allowed.includes(ip);
48
+ }
49
+ function requireAdmin(input) {
50
+ const page = input.surface === "page";
51
+ const refuse = (status, code) => page ? { ok: false, status: 404, code: ADMIN_REFUSAL.notFound } : { ok: false, status, code };
52
+ if (!input.session) return refuse(401, ADMIN_REFUSAL.unauthenticated);
53
+ if (input.session.account.role !== (input.role ?? "ADMIN")) return refuse(403, ADMIN_REFUSAL.forbidden);
54
+ if (!ipAllowed(input.ip, input.allowedIps)) return refuse(403, ADMIN_REFUSAL.ipNotAllowed);
55
+ return { ok: true };
68
56
  }
69
- function emailChangeMail(product, locale, url) {
70
- return locale === "fr" ? { subject: `Confirmez votre nouvelle adresse \u2014 ${product}`, text: `Bonjour,
71
-
72
- Pour utiliser cette adresse avec votre compte ${product}, confirmez-la en ouvrant ce lien (valide ${EMAIL_CHANGE_TTL_HOURS} h) :
73
- ${url}
74
-
75
- Si vous n'avez rien demand\xE9, ignorez ce message : rien ne change.
76
- ` } : { subject: `Confirm your new address \u2014 ${product}`, text: `Hello,
77
-
78
- To use this address with your ${product} account, confirm it by opening this link (valid ${EMAIL_CHANGE_TTL_HOURS} h):
79
- ${url}
80
57
 
81
- If you did not ask for this, ignore this message: nothing changes.
82
- ` };
58
+ // src/client-ip.ts
59
+ var IPV4 = /^(?:\d{1,3}\.){3}\d{1,3}$/;
60
+ var IPV6 = /^[0-9a-f:]+(?:%\w+)?$/i;
61
+ function isIp(value) {
62
+ return IPV4.test(value) && value.split(".").every((n) => Number(n) <= 255) || value.includes(":") && IPV6.test(value);
83
63
  }
84
- var looksLikeEmail = (address) => {
85
- if (address.length > 320 || /\s/.test(address)) return false;
86
- const at = address.indexOf("@");
87
- if (at < 1 || at !== address.lastIndexOf("@")) return false;
88
- const domain = address.slice(at + 1);
89
- const dot = domain.lastIndexOf(".");
90
- return dot > 0 && dot < domain.length - 1;
91
- };
92
- var cleanName = (raw) => {
93
- const name = raw.replace(/\s+/g, " ").trim();
94
- if (name.length > NAME_MAX) throw new AccountError(400, "BAD_REQUEST", `A name is at most ${NAME_MAX} characters`);
95
- return name;
96
- };
97
- function dbAccounts(deps) {
98
- const { query } = deps;
99
- const resetTtlMinutes = checkResetTtl(deps.passwordResetTtlMinutes ?? PASSWORD_RESET_TTL_MINUTES);
100
- const transaction = deps.transaction ?? ((work) => work(query));
101
- const wrong = () => new AccountError(403, "WRONG_PASSWORD", "The password is not right");
102
- async function profileOf(id) {
103
- const [row] = await query(
104
- `SELECT a.id, a.email, a.name, a.locale, a.theme, a.avatar_seed, a.email_verified_at,
105
- (SELECT c.new_email FROM email_changes c WHERE c.account_id = a.id AND c.used_at IS NULL AND c.expires_at > now() ORDER BY c.created_at DESC LIMIT 1) AS pending
106
- FROM accounts a WHERE a.id = $1`,
107
- [id]
108
- );
109
- if (!row) throw new AccountError(404, "NOT_FOUND", "No such account");
110
- return { id: row.id, email: row.email, name: row.name, locale: row.locale, theme: row.theme, avatarSeed: row.avatar_seed, emailVerified: !!row.email_verified_at, pendingEmail: row.pending };
64
+ function clientIp(headers, rules = {}) {
65
+ const custom = rules.header?.trim().toLowerCase();
66
+ if (custom) {
67
+ const value = headers.get(custom)?.split(",")[0]?.trim();
68
+ if (value && isIp(value)) return value;
111
69
  }
112
- async function confirmPassword(id, password) {
113
- const [row] = await query("SELECT password_hash FROM accounts WHERE id = $1", [id]);
114
- if (!row?.password_hash || password.length > PASSWORD_MAX || !await deps.passwords.verify(password, row.password_hash)) throw wrong();
115
- }
116
- return {
117
- profile: profileOf,
118
- async updateProfile(id, patch) {
119
- const name = patch.name === void 0 ? void 0 : patch.name === null ? null : cleanName(patch.name);
120
- if (patch.avatarSeed && !/^[a-z0-9-]{1,40}$/.test(patch.avatarSeed)) throw new AccountError(400, "BAD_REQUEST", "Not an avatar");
121
- if (patch.theme && !THEMES.includes(patch.theme)) throw new AccountError(400, "BAD_REQUEST", "Not a theme");
122
- await query(
123
- `UPDATE accounts SET name = CASE WHEN $2::boolean THEN $3 ELSE name END, locale = COALESCE($4, locale),
124
- avatar_seed = CASE WHEN $5::boolean THEN $6 ELSE avatar_seed END, theme = CASE WHEN $7::boolean THEN $8 ELSE theme END WHERE id = $1`,
125
- [id, name !== void 0, name === "" ? null : name ?? null, patch.locale ?? null, patch.avatarSeed !== void 0, patch.avatarSeed ?? null, patch.theme !== void 0, patch.theme ?? null]
126
- );
127
- return profileOf(id);
128
- },
129
- async requestEmailChange(id, input, now) {
130
- await confirmPassword(id, input.password);
131
- const address = input.newEmail.trim().toLowerCase();
132
- if (!looksLikeEmail(address)) throw new AccountError(400, "BAD_REQUEST", "Not an e-mail address");
133
- const [taken] = await query("SELECT 1 FROM accounts WHERE email = $1", [address]);
134
- if (taken) return;
135
- const profile = await profileOf(id);
136
- await query("UPDATE email_changes SET used_at = $2 WHERE account_id = $1 AND used_at IS NULL", [id, now]);
137
- const token = (deps.token ?? (() => emailChangeToken(deps.tokenPrefix)))();
138
- await query("INSERT INTO email_changes (account_id, new_email, token_hash, expires_at) VALUES ($1, $2, $3, $4)", [id, address, deps.tokenHash(token), addHours(now, EMAIL_CHANGE_TTL_HOURS)]);
139
- const url = `${deps.baseUrl.replace(/\/$/, "")}/${profile.locale}/confirm-email?token=${encodeURIComponent(token)}`;
140
- await deps.mail({ to: address, locale: profile.locale, ...emailChangeMail(deps.product, profile.locale, url), link: { kind: "emailChange", url, hours: EMAIL_CHANGE_TTL_HOURS } });
141
- },
142
- async confirmEmailChange(token, now) {
143
- if (!isEmailChangeToken(deps.tokenPrefix, token)) return "INVALID";
144
- const [row] = await query("UPDATE email_changes SET used_at = $2 WHERE token_hash = $1 AND used_at IS NULL AND expires_at > $2 RETURNING account_id, new_email", [deps.tokenHash(token), now]);
145
- if (!row) return "INVALID";
146
- try {
147
- await query("UPDATE accounts SET email = $2, email_verified_at = $3 WHERE id = $1", [row.account_id, row.new_email, now]);
148
- } catch {
149
- return "INVALID";
150
- }
151
- return "CHANGED";
152
- },
153
- async changePassword(id, input, keepSessionId) {
154
- await confirmPassword(id, input.current);
155
- if (input.next.length < PASSWORD_MIN) throw new AccountError(400, "PASSWORD_TOO_SHORT", `At least ${PASSWORD_MIN} characters`);
156
- if (input.next.length > PASSWORD_MAX) throw new AccountError(400, "PASSWORD_TOO_LONG", `At most ${PASSWORD_MAX} characters`);
157
- await query("UPDATE accounts SET password_hash = $2 WHERE id = $1", [id, await deps.passwords.hash(input.next)]);
158
- await query("DELETE FROM sessions WHERE account_id = $1 AND ($2::uuid IS NULL OR id <> $2::uuid)", [id, keepSessionId ?? null]);
159
- },
160
- confirmPassword,
161
- async requestPasswordReset(email, now) {
162
- const address = email.trim().toLowerCase();
163
- if (!looksLikeEmail(address)) return;
164
- const issued = await transaction(async (q) => {
165
- const [row] = await q("SELECT id, locale FROM accounts WHERE email = $1 AND password_hash IS NOT NULL FOR UPDATE", [address]);
166
- if (!row) return null;
167
- await q("UPDATE password_resets SET used_at = $2 WHERE account_id = $1 AND used_at IS NULL", [row.id, now]);
168
- const token = (deps.token ?? (() => linkToken(deps.tokenPrefix)))();
169
- await q("INSERT INTO password_resets (account_id, token_hash, expires_at) VALUES ($1, $2, $3)", [row.id, deps.tokenHash(token), addMinutes(now, resetTtlMinutes)]);
170
- return { token, locale: row.locale };
171
- });
172
- if (!issued) return;
173
- const url = `${deps.baseUrl.replace(/\/$/, "")}/${issued.locale}/reset-password?token=${encodeURIComponent(issued.token)}`;
174
- await deps.mail({ to: address, locale: issued.locale, ...passwordResetMail(deps.product, issued.locale, url, resetTtlMinutes), link: { kind: "passwordReset", url, minutes: resetTtlMinutes } });
175
- },
176
- async resetPassword(token, next, now) {
177
- if (next.length < PASSWORD_MIN) throw new AccountError(400, "PASSWORD_TOO_SHORT", `At least ${PASSWORD_MIN} characters`);
178
- if (next.length > PASSWORD_MAX) throw new AccountError(400, "PASSWORD_TOO_LONG", `At most ${PASSWORD_MAX} characters`);
179
- if (!isLinkToken(deps.tokenPrefix, token)) return "INVALID";
180
- return transaction(async (q) => {
181
- const [row] = await q("UPDATE password_resets SET used_at = $2 WHERE token_hash = $1 AND used_at IS NULL AND expires_at > $2 RETURNING account_id", [deps.tokenHash(token), now]);
182
- if (!row) return "INVALID";
183
- await q("UPDATE accounts SET password_hash = $2 WHERE id = $1", [row.account_id, await deps.passwords.hash(next)]);
184
- await q("DELETE FROM sessions WHERE account_id = $1", [row.account_id]);
185
- return "CHANGED";
186
- });
187
- },
188
- async sessions(id, now, currentSessionId) {
189
- const rows = await query("SELECT id, created_at, expires_at, ip, user_agent FROM sessions WHERE account_id = $1 AND expires_at > $2 ORDER BY created_at DESC", [id, now]);
190
- return rows.map((r) => ({ id: r.id, createdAt: r.created_at, expiresAt: r.expires_at, ip: r.ip, userAgent: r.user_agent, current: r.id === currentSessionId }));
191
- },
192
- async signOutOthers(id, keepSessionId) {
193
- return (await query("DELETE FROM sessions WHERE account_id = $1 AND id <> $2::uuid RETURNING id", [id, keepSessionId])).length;
194
- },
195
- async exportAccount(id, now, extra) {
196
- const profile = await profileOf(id);
197
- const live = await this.sessions(id, now);
198
- return { exportedAt: now.toISOString(), account: profile, sessions: live.map(({ current: _current, ...s }) => s), ...extra ? await extra() : {} };
199
- },
200
- async deleteAccount(id) {
201
- const rows = await query("DELETE FROM accounts WHERE id = $1 RETURNING id", [id]);
202
- if (!rows.length) throw new AccountError(404, "NOT_FOUND", "No such account");
203
- }
204
- };
70
+ const hops = Math.max(1, Math.min(10, Number(rules.trustedProxyHops) || 1));
71
+ const chain = (headers.get("x-forwarded-for") ?? "").split(",").map((part) => part.trim()).filter(Boolean);
72
+ const fromRight = chain[chain.length - hops];
73
+ if (fromRight && isIp(fromRight)) return fromRight;
74
+ const real = headers.get("x-real-ip")?.trim();
75
+ if (real && isIp(real)) return real;
76
+ return null;
205
77
  }
206
- var postgresAccounts = dbAccounts;
207
78
 
208
79
  // src/federated-sign-in.ts
209
80
  var FEDERATED_REFUSAL = {
@@ -249,9 +120,41 @@ function rolesMigration(roles, options = {}) {
249
120
  return rolesSchema(roles, { scoped: options.scopeTable !== void 0 }).migrations[0].sql({ schema: "public", tables });
250
121
  }
251
122
 
123
+ // src/password.ts
124
+ import crypto2 from "crypto";
125
+ import { promisify } from "util";
126
+ var scrypt = promisify(crypto2.scrypt);
127
+ var N = 32768;
128
+ var R = 8;
129
+ var P = 1;
130
+ var KEYLEN = 32;
131
+ var MAXMEM = 64 * 1024 * 1024;
132
+ var OPTIONS = { N, r: R, p: P, maxmem: MAXMEM };
133
+ var PASSWORD_MIN_LENGTH = 12;
134
+ var PASSWORD_MAX_LENGTH = 256;
135
+ function passwordProblem(password) {
136
+ if (password.length < PASSWORD_MIN_LENGTH) return "TOO_SHORT";
137
+ if (password.length > PASSWORD_MAX_LENGTH) return "TOO_LONG";
138
+ return null;
139
+ }
140
+ async function hashPassword(password) {
141
+ const salt = crypto2.randomBytes(16);
142
+ const hash = await scrypt(password, salt, KEYLEN, OPTIONS);
143
+ return `scrypt$${N}$${R}$${P}$${salt.toString("base64url")}$${hash.toString("base64url")}`;
144
+ }
145
+ async function verifyPassword(password, stored) {
146
+ const [scheme, n, r, p, salt, hash] = stored.split("$");
147
+ if (scheme !== "scrypt" || !salt || !hash || password.length > PASSWORD_MAX_LENGTH) return false;
148
+ const expected = Buffer.from(hash, "base64url");
149
+ const given = await scrypt(password, Buffer.from(salt, "base64url"), expected.length, { N: Number(n), r: Number(r), p: Number(p), maxmem: MAXMEM });
150
+ return given.length === expected.length && crypto2.timingSafeEqual(given, expected);
151
+ }
152
+ var decoy;
153
+ var decoyHash = () => decoy ??= hashPassword(crypto2.randomBytes(24).toString("base64url"));
154
+
252
155
  // src/roles.ts
253
156
  import crypto3 from "crypto";
254
- import { addHours as addHours2 } from "date-fns";
157
+ import { addHours } from "date-fns";
255
158
  var defineRoles = (model) => model;
256
159
  var can = (model, role, permission) => !!role && model.permissions[role].includes(permission);
257
160
  var ROLES_CODE = {
@@ -347,7 +250,7 @@ function createRoles(deps) {
347
250
  const token = newToken();
348
251
  const [row] = await query(
349
252
  "INSERT INTO invitations (id, scope_id, email, role, token_hash, invited_by, created_at, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, created_at, expires_at",
350
- [crypto3.randomUUID(), scopeId, email, role, deps.tokenHash(token), actor.id, now, addHours2(now, deps.inviteTtlHours)]
253
+ [crypto3.randomUUID(), scopeId, email, role, deps.tokenHash(token), actor.id, now, addHours(now, deps.inviteTtlHours)]
351
254
  );
352
255
  return { token, invitation: { id: row.id, email, role, createdAt: row.created_at, expiresAt: row.expires_at } };
353
256
  },
@@ -379,6 +282,108 @@ function createRoles(deps) {
379
282
  };
380
283
  }
381
284
 
285
+ // src/sessions.ts
286
+ import crypto4 from "crypto";
287
+ var BODY = /^[A-Za-z0-9_-]{43}$/;
288
+ var PREFIX = /^[a-z]{1,4}$/;
289
+ var USER_AGENT_MAX = 300;
290
+ function createSessions(deps) {
291
+ if (!PREFIX.test(deps.tokenPrefix)) throw new RangeError(`tokenPrefix must be 1 to 4 lower-case letters, got ${deps.tokenPrefix}`);
292
+ const head = `${deps.tokenPrefix}1.`;
293
+ const secret = () => typeof deps.secret === "function" ? deps.secret() : deps.secret;
294
+ const isToken = (value) => !!value && value.startsWith(head) && BODY.test(value.slice(head.length));
295
+ const tokenHash = (token) => crypto4.createHmac("sha256", `session:${secret()}`).update(token).digest("hex");
296
+ const newToken = () => `${head}${crypto4.randomBytes(32).toString("base64url")}`;
297
+ return {
298
+ newToken,
299
+ isToken,
300
+ tokenHash,
301
+ async create(accountId, meta) {
302
+ const days = typeof deps.sessionDays === "function" ? await deps.sessionDays() : deps.sessionDays;
303
+ if (!Number.isInteger(days) || days < 1) throw new RangeError(`sessionDays must be a whole number of days from 1, got ${days}`);
304
+ const token = newToken();
305
+ const createdAt = deps.clock.now();
306
+ const expires = new Date(createdAt);
307
+ expires.setUTCHours(expires.getUTCHours() + days * 24);
308
+ await deps.store.insert({ accountId, tokenHash: tokenHash(token), createdAt, expiresAt: expires, ip: meta.ip, userAgent: meta.userAgent?.slice(0, USER_AGENT_MAX) ?? null });
309
+ return { token, expires };
310
+ },
311
+ async resolve(token) {
312
+ if (!isToken(token)) return null;
313
+ return deps.store.find(tokenHash(token), deps.clock.now());
314
+ },
315
+ async revoke(token) {
316
+ if (!isToken(token)) return;
317
+ await deps.store.delete(tokenHash(token));
318
+ }
319
+ };
320
+ }
321
+ function sessionCookie(token, expires, options) {
322
+ return { name: options.name, value: token, httpOnly: true, secure: options.secure, sameSite: "lax", path: "/", expires };
323
+ }
324
+ function clearedSessionCookie(options, now) {
325
+ return sessionCookie("", now, options);
326
+ }
327
+ function sessionTokenOf(headers, options) {
328
+ if (options.bearer !== false) {
329
+ const bearer = /^bearer (\S+)$/i.exec(headers.get("authorization") ?? "")?.[1];
330
+ if (bearer) return bearer;
331
+ }
332
+ const prefix = `${options.cookie}=`;
333
+ return headers.get("cookie")?.split(";").map((part) => part.trim()).find((part) => part.startsWith(prefix))?.slice(prefix.length);
334
+ }
335
+
336
+ // src/sessions-memory.ts
337
+ function memorySessionStore(accountOf) {
338
+ const rows = /* @__PURE__ */ new Map();
339
+ let next = 0;
340
+ return {
341
+ async insert(session) {
342
+ if (rows.has(session.tokenHash)) throw new Error("duplicate session token hash");
343
+ next += 1;
344
+ rows.set(session.tokenHash, { ...session, id: `session-${next}` });
345
+ },
346
+ async find(tokenHash, now) {
347
+ const row = rows.get(tokenHash);
348
+ if (!row || row.expiresAt.getTime() <= now.getTime()) return null;
349
+ const account = accountOf(row.accountId);
350
+ return account === null ? null : { id: row.id, account };
351
+ },
352
+ async delete(tokenHash) {
353
+ rows.delete(tokenHash);
354
+ },
355
+ rows: () => [...rows.values()]
356
+ };
357
+ }
358
+
359
+ // src/sessions-persistence.ts
360
+ import { ident as ident2 } from "@krizaka/db";
361
+ function dbSessionStore(query, options) {
362
+ const sessions = ident2(options.sessions ?? "sessions");
363
+ const accounts = ident2(options.accounts ?? "accounts");
364
+ const columns = options.columns.map((column) => `a.${ident2(column)}`).join(", ");
365
+ const active = options.active ? ` AND a.${ident2(options.active.column)} = $3` : "";
366
+ return {
367
+ async insert(session) {
368
+ await query(`INSERT INTO ${sessions} (account_id, token_hash, created_at, expires_at, ip, user_agent) VALUES ($1, $2, $3, $4, $5, $6)`, [session.accountId, session.tokenHash, session.createdAt, session.expiresAt, session.ip, session.userAgent]);
369
+ },
370
+ async find(tokenHash, now) {
371
+ const params = [tokenHash, now];
372
+ if (options.active) params.push(options.active.equals);
373
+ const [row] = await query(
374
+ `SELECT s.id AS session_id, ${columns} FROM ${sessions} s JOIN ${accounts} a ON a.id = s.account_id WHERE s.token_hash = $1 AND s.expires_at > $2${active}`,
375
+ params
376
+ );
377
+ if (!row) return null;
378
+ const { session_id: id, ...account } = row;
379
+ return { id, account: options.toAccount(account) };
380
+ },
381
+ async delete(tokenHash) {
382
+ await query(`DELETE FROM ${sessions} WHERE token_hash = $1`, [tokenHash]);
383
+ }
384
+ };
385
+ }
386
+
382
387
  // src/users-federation.ts
383
388
  var START = (base, provider) => `${base}/api/v1/auth/web/${provider}/start`;
384
389
  var EXCHANGE = (base) => `${base}/api/v1/auth/web/exchange`;
@@ -418,6 +423,7 @@ function usersIdentityProvider({ baseUrl, appUrl, fetch }) {
418
423
  };
419
424
  }
420
425
  export {
426
+ ADMIN_REFUSAL,
421
427
  AccountError,
422
428
  EMAIL_CHANGE_TTL_HOURS,
423
429
  FEDERATED_PROVIDERS,
@@ -425,6 +431,8 @@ export {
425
431
  FEDERATION_CODE,
426
432
  FederationError,
427
433
  IdentityLinkError,
434
+ PASSWORD_MAX_LENGTH,
435
+ PASSWORD_MIN_LENGTH,
428
436
  PASSWORD_RESET_MAX_MINUTES,
429
437
  PASSWORD_RESET_MIN_MINUTES,
430
438
  PASSWORD_RESET_TTL_MINUTES,
@@ -432,16 +440,24 @@ export {
432
440
  RolesError,
433
441
  accountsMigration,
434
442
  accountsSchema,
443
+ allowedIps,
435
444
  can,
445
+ clearedSessionCookie,
446
+ clientIp,
436
447
  createFederatedSignIn,
437
448
  createRoles,
449
+ createSessions,
438
450
  dbAccounts,
439
451
  dbIdentityLinks,
452
+ dbSessionStore,
453
+ decoyHash,
440
454
  defineRoles,
441
455
  emailChangeMail,
442
456
  emailChangeToken,
457
+ hashPassword,
443
458
  identityLinksMigration,
444
459
  identityLinksSchema,
460
+ ipAllowed,
445
461
  isEmailChangeToken,
446
462
  isFederatedProvider,
447
463
  isLinkToken,
@@ -449,12 +465,19 @@ export {
449
465
  linkToken,
450
466
  memoryIdentityLinks,
451
467
  memoryIdentityProvider,
468
+ memorySessionStore,
452
469
  parseExternalIdentity,
470
+ passwordProblem,
453
471
  passwordResetMail,
454
472
  pkceChallenge,
455
473
  postgresAccounts,
474
+ requireAdmin,
456
475
  rolesMigration,
457
476
  rolesSchema,
458
- usersIdentityProvider
477
+ sessionCookie,
478
+ sessionTokenOf,
479
+ sessionsSchema,
480
+ usersIdentityProvider,
481
+ verifyPassword
459
482
  };
460
483
  //# sourceMappingURL=index.js.map