@krizaka/auth 0.7.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/README.md +39 -4
- package/dist/chunk-3CLSDLXB.js +204 -0
- package/dist/chunk-3CLSDLXB.js.map +1 -0
- package/dist/{chunk-H7AKE2OH.js → chunk-5CFOMWBN.js} +135 -11
- package/dist/chunk-5CFOMWBN.js.map +1 -0
- package/dist/contract.d.ts +46 -0
- package/dist/contract.js +27 -0
- package/dist/contract.js.map +1 -0
- package/dist/index.d.ts +319 -13
- package/dist/index.js +222 -239
- package/dist/index.js.map +1 -1
- package/dist/sessions-B9YKXs0i.d.ts +192 -0
- package/dist/settings.d.ts +13 -0
- package/dist/settings.js +17 -0
- package/dist/settings.js.map +1 -0
- package/dist/testkit.d.ts +16 -2
- package/dist/testkit.js +62 -8
- package/dist/testkit.js.map +1 -1
- package/package.json +19 -4
- package/dist/chunk-H7AKE2OH.js.map +0 -1
- package/dist/identity-links-C5AG7Fbp.d.ts +0 -239
package/dist/index.js
CHANGED
|
@@ -3,204 +3,78 @@ import {
|
|
|
3
3
|
FEDERATION_CODE,
|
|
4
4
|
FederationError,
|
|
5
5
|
IdentityLinkError,
|
|
6
|
+
accountsSchema,
|
|
6
7
|
dbIdentityLinks,
|
|
7
8
|
identityLinksMigration,
|
|
9
|
+
identityLinksSchema,
|
|
8
10
|
isFederatedProvider,
|
|
9
11
|
isLocalReturn,
|
|
10
12
|
memoryIdentityLinks,
|
|
11
13
|
memoryIdentityProvider,
|
|
12
|
-
pkceChallenge
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
import
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
14
|
+
pkceChallenge,
|
|
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";
|
|
30
33
|
|
|
31
|
-
// src/
|
|
32
|
-
var
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (!Number.isInteger(minutes) || minutes < PASSWORD_RESET_MIN_MINUTES || minutes > PASSWORD_RESET_MAX_MINUTES) {
|
|
38
|
-
throw new RangeError(`passwordResetTtlMinutes must be a whole number from ${PASSWORD_RESET_MIN_MINUTES} to ${PASSWORD_RESET_MAX_MINUTES}, got ${minutes}`);
|
|
39
|
-
}
|
|
40
|
-
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"
|
|
41
40
|
};
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
function
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
Si vous n'avez rien demand\xE9, ignorez ce message : votre mot de passe ne change pas.
|
|
58
|
-
` } : { subject: `Reset your password \u2014 ${product}`, text: `Hello,
|
|
59
|
-
|
|
60
|
-
To choose a new password for your ${product} account, open this link (valid ${ttlMinutes} min, once):
|
|
61
|
-
${url}
|
|
62
|
-
|
|
63
|
-
If you did not ask for this, ignore this message: your password does not change.
|
|
64
|
-
` };
|
|
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 };
|
|
65
56
|
}
|
|
66
|
-
function emailChangeMail(product, locale, url) {
|
|
67
|
-
return locale === "fr" ? { subject: `Confirmez votre nouvelle adresse \u2014 ${product}`, text: `Bonjour,
|
|
68
|
-
|
|
69
|
-
Pour utiliser cette adresse avec votre compte ${product}, confirmez-la en ouvrant ce lien (valide ${EMAIL_CHANGE_TTL_HOURS} h) :
|
|
70
|
-
${url}
|
|
71
|
-
|
|
72
|
-
Si vous n'avez rien demand\xE9, ignorez ce message : rien ne change.
|
|
73
|
-
` } : { subject: `Confirm your new address \u2014 ${product}`, text: `Hello,
|
|
74
|
-
|
|
75
|
-
To use this address with your ${product} account, confirm it by opening this link (valid ${EMAIL_CHANGE_TTL_HOURS} h):
|
|
76
|
-
${url}
|
|
77
57
|
|
|
78
|
-
|
|
79
|
-
|
|
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);
|
|
80
63
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const dot = domain.lastIndexOf(".");
|
|
87
|
-
return dot > 0 && dot < domain.length - 1;
|
|
88
|
-
};
|
|
89
|
-
var cleanName = (raw) => {
|
|
90
|
-
const name = raw.replace(/\s+/g, " ").trim();
|
|
91
|
-
if (name.length > NAME_MAX) throw new AccountError(400, "BAD_REQUEST", `A name is at most ${NAME_MAX} characters`);
|
|
92
|
-
return name;
|
|
93
|
-
};
|
|
94
|
-
function dbAccounts(deps) {
|
|
95
|
-
const { query } = deps;
|
|
96
|
-
const resetTtlMinutes = checkResetTtl(deps.passwordResetTtlMinutes ?? PASSWORD_RESET_TTL_MINUTES);
|
|
97
|
-
const transaction = deps.transaction ?? ((work) => work(query));
|
|
98
|
-
const wrong = () => new AccountError(403, "WRONG_PASSWORD", "The password is not right");
|
|
99
|
-
async function profileOf(id) {
|
|
100
|
-
const [row] = await query(
|
|
101
|
-
`SELECT a.id, a.email, a.name, a.locale, a.theme, a.avatar_seed, a.email_verified_at,
|
|
102
|
-
(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
|
|
103
|
-
FROM accounts a WHERE a.id = $1`,
|
|
104
|
-
[id]
|
|
105
|
-
);
|
|
106
|
-
if (!row) throw new AccountError(404, "NOT_FOUND", "No such account");
|
|
107
|
-
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 };
|
|
108
|
-
}
|
|
109
|
-
async function confirmPassword(id, password) {
|
|
110
|
-
const [row] = await query("SELECT password_hash FROM accounts WHERE id = $1", [id]);
|
|
111
|
-
if (!row?.password_hash || password.length > PASSWORD_MAX || !await deps.passwords.verify(password, row.password_hash)) throw wrong();
|
|
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;
|
|
112
69
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
`UPDATE accounts SET name = CASE WHEN $2::boolean THEN $3 ELSE name END, locale = COALESCE($4, locale),
|
|
121
|
-
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`,
|
|
122
|
-
[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]
|
|
123
|
-
);
|
|
124
|
-
return profileOf(id);
|
|
125
|
-
},
|
|
126
|
-
async requestEmailChange(id, input, now) {
|
|
127
|
-
await confirmPassword(id, input.password);
|
|
128
|
-
const address = input.newEmail.trim().toLowerCase();
|
|
129
|
-
if (!looksLikeEmail(address)) throw new AccountError(400, "BAD_REQUEST", "Not an e-mail address");
|
|
130
|
-
const [taken] = await query("SELECT 1 FROM accounts WHERE email = $1", [address]);
|
|
131
|
-
if (taken) return;
|
|
132
|
-
const profile = await profileOf(id);
|
|
133
|
-
await query("UPDATE email_changes SET used_at = $2 WHERE account_id = $1 AND used_at IS NULL", [id, now]);
|
|
134
|
-
const token = (deps.token ?? (() => emailChangeToken(deps.tokenPrefix)))();
|
|
135
|
-
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)]);
|
|
136
|
-
const url = `${deps.baseUrl.replace(/\/$/, "")}/${profile.locale}/confirm-email?token=${encodeURIComponent(token)}`;
|
|
137
|
-
await deps.mail({ to: address, locale: profile.locale, ...emailChangeMail(deps.product, profile.locale, url), link: { kind: "emailChange", url, hours: EMAIL_CHANGE_TTL_HOURS } });
|
|
138
|
-
},
|
|
139
|
-
async confirmEmailChange(token, now) {
|
|
140
|
-
if (!isEmailChangeToken(deps.tokenPrefix, token)) return "INVALID";
|
|
141
|
-
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]);
|
|
142
|
-
if (!row) return "INVALID";
|
|
143
|
-
try {
|
|
144
|
-
await query("UPDATE accounts SET email = $2, email_verified_at = $3 WHERE id = $1", [row.account_id, row.new_email, now]);
|
|
145
|
-
} catch {
|
|
146
|
-
return "INVALID";
|
|
147
|
-
}
|
|
148
|
-
return "CHANGED";
|
|
149
|
-
},
|
|
150
|
-
async changePassword(id, input, keepSessionId) {
|
|
151
|
-
await confirmPassword(id, input.current);
|
|
152
|
-
if (input.next.length < PASSWORD_MIN) throw new AccountError(400, "PASSWORD_TOO_SHORT", `At least ${PASSWORD_MIN} characters`);
|
|
153
|
-
if (input.next.length > PASSWORD_MAX) throw new AccountError(400, "PASSWORD_TOO_LONG", `At most ${PASSWORD_MAX} characters`);
|
|
154
|
-
await query("UPDATE accounts SET password_hash = $2 WHERE id = $1", [id, await deps.passwords.hash(input.next)]);
|
|
155
|
-
await query("DELETE FROM sessions WHERE account_id = $1 AND ($2::uuid IS NULL OR id <> $2::uuid)", [id, keepSessionId ?? null]);
|
|
156
|
-
},
|
|
157
|
-
confirmPassword,
|
|
158
|
-
async requestPasswordReset(email, now) {
|
|
159
|
-
const address = email.trim().toLowerCase();
|
|
160
|
-
if (!looksLikeEmail(address)) return;
|
|
161
|
-
const issued = await transaction(async (q) => {
|
|
162
|
-
const [row] = await q("SELECT id, locale FROM accounts WHERE email = $1 AND password_hash IS NOT NULL FOR UPDATE", [address]);
|
|
163
|
-
if (!row) return null;
|
|
164
|
-
await q("UPDATE password_resets SET used_at = $2 WHERE account_id = $1 AND used_at IS NULL", [row.id, now]);
|
|
165
|
-
const token = (deps.token ?? (() => linkToken(deps.tokenPrefix)))();
|
|
166
|
-
await q("INSERT INTO password_resets (account_id, token_hash, expires_at) VALUES ($1, $2, $3)", [row.id, deps.tokenHash(token), addMinutes(now, resetTtlMinutes)]);
|
|
167
|
-
return { token, locale: row.locale };
|
|
168
|
-
});
|
|
169
|
-
if (!issued) return;
|
|
170
|
-
const url = `${deps.baseUrl.replace(/\/$/, "")}/${issued.locale}/reset-password?token=${encodeURIComponent(issued.token)}`;
|
|
171
|
-
await deps.mail({ to: address, locale: issued.locale, ...passwordResetMail(deps.product, issued.locale, url, resetTtlMinutes), link: { kind: "passwordReset", url, minutes: resetTtlMinutes } });
|
|
172
|
-
},
|
|
173
|
-
async resetPassword(token, next, now) {
|
|
174
|
-
if (next.length < PASSWORD_MIN) throw new AccountError(400, "PASSWORD_TOO_SHORT", `At least ${PASSWORD_MIN} characters`);
|
|
175
|
-
if (next.length > PASSWORD_MAX) throw new AccountError(400, "PASSWORD_TOO_LONG", `At most ${PASSWORD_MAX} characters`);
|
|
176
|
-
if (!isLinkToken(deps.tokenPrefix, token)) return "INVALID";
|
|
177
|
-
return transaction(async (q) => {
|
|
178
|
-
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]);
|
|
179
|
-
if (!row) return "INVALID";
|
|
180
|
-
await q("UPDATE accounts SET password_hash = $2 WHERE id = $1", [row.account_id, await deps.passwords.hash(next)]);
|
|
181
|
-
await q("DELETE FROM sessions WHERE account_id = $1", [row.account_id]);
|
|
182
|
-
return "CHANGED";
|
|
183
|
-
});
|
|
184
|
-
},
|
|
185
|
-
async sessions(id, now, currentSessionId) {
|
|
186
|
-
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]);
|
|
187
|
-
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 }));
|
|
188
|
-
},
|
|
189
|
-
async signOutOthers(id, keepSessionId) {
|
|
190
|
-
return (await query("DELETE FROM sessions WHERE account_id = $1 AND id <> $2::uuid RETURNING id", [id, keepSessionId])).length;
|
|
191
|
-
},
|
|
192
|
-
async exportAccount(id, now, extra) {
|
|
193
|
-
const profile = await profileOf(id);
|
|
194
|
-
const live = await this.sessions(id, now);
|
|
195
|
-
return { exportedAt: now.toISOString(), account: profile, sessions: live.map(({ current: _current, ...s }) => s), ...extra ? await extra() : {} };
|
|
196
|
-
},
|
|
197
|
-
async deleteAccount(id) {
|
|
198
|
-
const rows = await query("DELETE FROM accounts WHERE id = $1 RETURNING id", [id]);
|
|
199
|
-
if (!rows.length) throw new AccountError(404, "NOT_FOUND", "No such account");
|
|
200
|
-
}
|
|
201
|
-
};
|
|
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;
|
|
202
77
|
}
|
|
203
|
-
var postgresAccounts = dbAccounts;
|
|
204
78
|
|
|
205
79
|
// src/federated-sign-in.ts
|
|
206
80
|
var FEDERATED_REFUSAL = {
|
|
@@ -233,68 +107,54 @@ function createFederatedSignIn({ links, accounts, clock }) {
|
|
|
233
107
|
}
|
|
234
108
|
|
|
235
109
|
// src/migration.ts
|
|
236
|
-
function accountsMigration(options = {}) {
|
|
237
|
-
return `
|
|
238
|
-
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS name text CHECK (name IS NULL OR char_length(name) <= 80);
|
|
239
|
-
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS avatar_seed text CHECK (avatar_seed IS NULL OR avatar_seed ~ '^[a-z0-9-]{1,40}$');
|
|
240
|
-
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS theme text CHECK (theme IS NULL OR theme IN ('light', 'dark', 'system'));
|
|
241
|
-
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS email_verified_at timestamptz;
|
|
242
|
-
${options.backfillVerified ? "UPDATE accounts SET email_verified_at = created_at WHERE email_verified_at IS NULL;\n" : ""}CREATE TABLE IF NOT EXISTS email_changes (
|
|
243
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
244
|
-
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
245
|
-
new_email text NOT NULL CHECK (new_email = lower(new_email)),
|
|
246
|
-
token_hash text NOT NULL UNIQUE,
|
|
247
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
248
|
-
expires_at timestamptz NOT NULL,
|
|
249
|
-
used_at timestamptz
|
|
250
|
-
);
|
|
251
|
-
CREATE TABLE IF NOT EXISTS password_resets (
|
|
252
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
253
|
-
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
254
|
-
token_hash text NOT NULL UNIQUE,
|
|
255
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
256
|
-
expires_at timestamptz NOT NULL,
|
|
257
|
-
used_at timestamptz
|
|
258
|
-
);
|
|
259
|
-
`;
|
|
260
|
-
}
|
|
261
110
|
var IDENT = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
262
111
|
var ident = (name) => {
|
|
263
112
|
if (!IDENT.test(name)) throw new Error(`not a safe SQL identifier: ${name}`);
|
|
264
113
|
return name;
|
|
265
114
|
};
|
|
115
|
+
function accountsMigration(options = {}) {
|
|
116
|
+
return accountsSchema(options).migrations[0].sql({ schema: "public", tables: { accounts: "accounts" } });
|
|
117
|
+
}
|
|
266
118
|
function rolesMigration(roles, options = {}) {
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
const list = roles.map((r) => `'${ident(r)}'`).join(", ");
|
|
270
|
-
return `
|
|
271
|
-
CREATE TABLE IF NOT EXISTS memberships (
|
|
272
|
-
scope_id uuid NOT NULL${scope},
|
|
273
|
-
account_id uuid NOT NULL REFERENCES ${owner}(id) ON DELETE CASCADE,
|
|
274
|
-
role text NOT NULL CHECK (role IN (${list})),
|
|
275
|
-
invited_by uuid REFERENCES ${owner}(id) ON DELETE SET NULL,
|
|
276
|
-
created_at timestamptz NOT NULL,
|
|
277
|
-
PRIMARY KEY (scope_id, account_id)
|
|
278
|
-
);
|
|
279
|
-
CREATE INDEX IF NOT EXISTS memberships_account ON memberships(account_id);
|
|
280
|
-
CREATE TABLE IF NOT EXISTS invitations (
|
|
281
|
-
id uuid PRIMARY KEY,
|
|
282
|
-
scope_id uuid NOT NULL${scope},
|
|
283
|
-
email text NOT NULL CHECK (email = lower(email)),
|
|
284
|
-
role text NOT NULL CHECK (role IN (${list})),
|
|
285
|
-
token_hash text NOT NULL UNIQUE,
|
|
286
|
-
invited_by uuid REFERENCES ${owner}(id) ON DELETE SET NULL,
|
|
287
|
-
created_at timestamptz NOT NULL,
|
|
288
|
-
expires_at timestamptz NOT NULL,
|
|
289
|
-
used_at timestamptz
|
|
290
|
-
);
|
|
291
|
-
CREATE INDEX IF NOT EXISTS invitations_scope ON invitations(scope_id) WHERE used_at IS NULL;
|
|
292
|
-
`;
|
|
119
|
+
const tables = { owner: ident(options.ownerTable ?? "accounts"), ...options.scopeTable ? { scope: ident(options.scopeTable) } : {} };
|
|
120
|
+
return rolesSchema(roles, { scoped: options.scopeTable !== void 0 }).migrations[0].sql({ schema: "public", tables });
|
|
293
121
|
}
|
|
294
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
|
+
|
|
295
155
|
// src/roles.ts
|
|
296
156
|
import crypto3 from "crypto";
|
|
297
|
-
import { addHours
|
|
157
|
+
import { addHours } from "date-fns";
|
|
298
158
|
var defineRoles = (model) => model;
|
|
299
159
|
var can = (model, role, permission) => !!role && model.permissions[role].includes(permission);
|
|
300
160
|
var ROLES_CODE = {
|
|
@@ -390,7 +250,7 @@ function createRoles(deps) {
|
|
|
390
250
|
const token = newToken();
|
|
391
251
|
const [row] = await query(
|
|
392
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",
|
|
393
|
-
[crypto3.randomUUID(), scopeId, email, role, deps.tokenHash(token), actor.id, now,
|
|
253
|
+
[crypto3.randomUUID(), scopeId, email, role, deps.tokenHash(token), actor.id, now, addHours(now, deps.inviteTtlHours)]
|
|
394
254
|
);
|
|
395
255
|
return { token, invitation: { id: row.id, email, role, createdAt: row.created_at, expiresAt: row.expires_at } };
|
|
396
256
|
},
|
|
@@ -422,6 +282,108 @@ function createRoles(deps) {
|
|
|
422
282
|
};
|
|
423
283
|
}
|
|
424
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
|
+
|
|
425
387
|
// src/users-federation.ts
|
|
426
388
|
var START = (base, provider) => `${base}/api/v1/auth/web/${provider}/start`;
|
|
427
389
|
var EXCHANGE = (base) => `${base}/api/v1/auth/web/exchange`;
|
|
@@ -461,6 +423,7 @@ function usersIdentityProvider({ baseUrl, appUrl, fetch }) {
|
|
|
461
423
|
};
|
|
462
424
|
}
|
|
463
425
|
export {
|
|
426
|
+
ADMIN_REFUSAL,
|
|
464
427
|
AccountError,
|
|
465
428
|
EMAIL_CHANGE_TTL_HOURS,
|
|
466
429
|
FEDERATED_PROVIDERS,
|
|
@@ -468,21 +431,33 @@ export {
|
|
|
468
431
|
FEDERATION_CODE,
|
|
469
432
|
FederationError,
|
|
470
433
|
IdentityLinkError,
|
|
434
|
+
PASSWORD_MAX_LENGTH,
|
|
435
|
+
PASSWORD_MIN_LENGTH,
|
|
471
436
|
PASSWORD_RESET_MAX_MINUTES,
|
|
472
437
|
PASSWORD_RESET_MIN_MINUTES,
|
|
473
438
|
PASSWORD_RESET_TTL_MINUTES,
|
|
474
439
|
ROLES_CODE,
|
|
475
440
|
RolesError,
|
|
476
441
|
accountsMigration,
|
|
442
|
+
accountsSchema,
|
|
443
|
+
allowedIps,
|
|
477
444
|
can,
|
|
445
|
+
clearedSessionCookie,
|
|
446
|
+
clientIp,
|
|
478
447
|
createFederatedSignIn,
|
|
479
448
|
createRoles,
|
|
449
|
+
createSessions,
|
|
480
450
|
dbAccounts,
|
|
481
451
|
dbIdentityLinks,
|
|
452
|
+
dbSessionStore,
|
|
453
|
+
decoyHash,
|
|
482
454
|
defineRoles,
|
|
483
455
|
emailChangeMail,
|
|
484
456
|
emailChangeToken,
|
|
457
|
+
hashPassword,
|
|
485
458
|
identityLinksMigration,
|
|
459
|
+
identityLinksSchema,
|
|
460
|
+
ipAllowed,
|
|
486
461
|
isEmailChangeToken,
|
|
487
462
|
isFederatedProvider,
|
|
488
463
|
isLinkToken,
|
|
@@ -490,11 +465,19 @@ export {
|
|
|
490
465
|
linkToken,
|
|
491
466
|
memoryIdentityLinks,
|
|
492
467
|
memoryIdentityProvider,
|
|
468
|
+
memorySessionStore,
|
|
493
469
|
parseExternalIdentity,
|
|
470
|
+
passwordProblem,
|
|
494
471
|
passwordResetMail,
|
|
495
472
|
pkceChallenge,
|
|
496
473
|
postgresAccounts,
|
|
474
|
+
requireAdmin,
|
|
497
475
|
rolesMigration,
|
|
498
|
-
|
|
476
|
+
rolesSchema,
|
|
477
|
+
sessionCookie,
|
|
478
|
+
sessionTokenOf,
|
|
479
|
+
sessionsSchema,
|
|
480
|
+
usersIdentityProvider,
|
|
481
|
+
verifyPassword
|
|
499
482
|
};
|
|
500
483
|
//# sourceMappingURL=index.js.map
|