@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/README.md +33 -0
- package/dist/chunk-3CLSDLXB.js +204 -0
- package/dist/chunk-3CLSDLXB.js.map +1 -0
- package/dist/{chunk-U5SDV7YV.js → chunk-5CFOMWBN.js} +25 -1
- package/dist/{chunk-U5SDV7YV.js.map → chunk-5CFOMWBN.js.map} +1 -1
- 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 +136 -3
- package/dist/index.js +211 -188
- package/dist/index.js.map +1 -1
- package/dist/{identity-links-BI3DYE2I.d.ts → sessions-B9YKXs0i.d.ts} +97 -2
- 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 +15 -2
- package/dist/testkit.js +62 -8
- package/dist/testkit.js.map +1 -1
- package/package.json +17 -3
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 './
|
|
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,
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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/
|
|
35
|
-
var
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
function
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
477
|
+
sessionCookie,
|
|
478
|
+
sessionTokenOf,
|
|
479
|
+
sessionsSchema,
|
|
480
|
+
usersIdentityProvider,
|
|
481
|
+
verifyPassword
|
|
459
482
|
};
|
|
460
483
|
//# sourceMappingURL=index.js.map
|