pinomad 0.12.0 → 0.14.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/chunk-0dh2k3er.js +43 -0
- package/dist/{chunk-47ff1d1t.js → chunk-1mjbt2z2.js} +191 -11
- package/dist/{chunk-7wj0jh3t.js → chunk-1rdbnvy6.js} +1 -1
- package/dist/{chunk-ksfxqd8s.js → chunk-35szq73k.js} +2682 -459
- package/dist/{chunk-mnmqyq7d.js → chunk-5j7ybhjj.js} +1984 -333
- package/dist/{chunk-agq4tn9r.js → chunk-5pv0g1nj.js} +12 -10
- package/dist/{chunk-7mn38n4f.js → chunk-63ymn5c9.js} +2 -2
- package/dist/{chunk-nppgxh6c.js → chunk-6j6hkfbj.js} +8 -4
- package/dist/{chunk-pqe7q1s7.js → chunk-6vbp1vva.js} +2 -1
- package/dist/{chunk-g3ahp7b9.js → chunk-9trbbz4r.js} +1 -1
- package/dist/{chunk-gg2dcqbg.js → chunk-b0nwq1dt.js} +29 -12
- package/dist/chunk-dsr19q5h.js +23 -0
- package/dist/{chunk-41q0saz0.js → chunk-e53c0s8z.js} +9 -7
- package/dist/{chunk-np4jd070.js → chunk-e59k3bmz.js} +307 -56
- package/dist/chunk-eerpgvy9.js +315 -0
- package/dist/chunk-eewxy4cp.js +240 -0
- package/dist/{chunk-vqkb70d7.js → chunk-evbv1fkn.js} +3 -3
- package/dist/{chunk-vx7dy07y.js → chunk-ex5yr09h.js} +11 -6
- package/dist/{chunk-t91zt5f0.js → chunk-hg9gs396.js} +1 -1
- package/dist/{chunk-aqj9wfmb.js → chunk-mtkvft88.js} +1 -1
- package/dist/{chunk-t5abqsva.js → chunk-mxwcnnkg.js} +15 -305
- package/dist/{chunk-5qf1grkk.js → chunk-w927q9m1.js} +52 -16
- package/dist/pinomad.js +12 -12
- package/package.json +16 -11
- package/web/assets/{core-DnpzcQzs.js → core-BWHGsFpV.js} +1 -1
- package/web/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- package/web/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- package/web/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- package/web/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- package/web/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- package/web/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- package/web/assets/{highlighted-diff-Dn5ifsvK.js → highlighted-diff-DJ7KZCpS.js} +2 -2
- package/web/assets/{highlights-DnHv6NBM.js → highlights-CyEmo6OI.js} +1 -1
- package/web/assets/{highlights-themes-CUdrlZk3.js → highlights-themes-CSNkBojy.js} +1 -1
- package/web/assets/index-C_qkbyL7.css +1 -0
- package/web/assets/index-DefQtCI2.js +396 -0
- package/web/assets/{shiki-BuPP5HCg.js → shiki-BzCmRG-v.js} +2 -2
- package/web/assets/{shiki-langs-FJk0v0Ga.js → shiki-langs-DOkjyV4d.js} +1 -1
- package/web/assets/{stream-tokenizer-Bl6bisIa.js → stream-tokenizer-CBPqFe4Z.js} +1 -1
- package/web/assets/{theming-themes-BL_CC-Mm.js → theming-themes-wKVca0aB.js} +1 -1
- package/web/assets/{xterm-view-ZsuzJb2C.js → xterm-view-CKa1-QCM.js} +12 -12
- package/web/index.html +3 -2
- package/web/theme-boot.js +11 -0
- package/dist/chunk-42nqtx5r.js +0 -22
- package/dist/chunk-jeqsdcj1.js +0 -96
- package/web/assets/index-BV0ecolo.css +0 -1
- package/web/assets/index-DNsTTgPE.js +0 -396
|
@@ -9,14 +9,14 @@ import {
|
|
|
9
9
|
relayWsBase,
|
|
10
10
|
verifyRelayChallenge,
|
|
11
11
|
verifyRelayChallengeV2
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-9trbbz4r.js";
|
|
13
13
|
import {
|
|
14
14
|
normalizeHostName
|
|
15
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-6vbp1vva.js";
|
|
16
16
|
import {
|
|
17
17
|
commandHint,
|
|
18
18
|
webRoot
|
|
19
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-1rdbnvy6.js";
|
|
20
20
|
import"./chunk-df32avw6.js";
|
|
21
21
|
|
|
22
22
|
// apps/server/src/main.ts
|
|
@@ -58,7 +58,7 @@ async function lockDataDir(dir, options) {
|
|
|
58
58
|
|
|
59
59
|
// apps/server/src/accounts.ts
|
|
60
60
|
var DB_FILE = "server.sqlite";
|
|
61
|
-
var SCHEMA_VERSION =
|
|
61
|
+
var SCHEMA_VERSION = 10;
|
|
62
62
|
var SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
63
63
|
var JOIN_TOKEN_TTL_MS = 10 * 60 * 1000;
|
|
64
64
|
var USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
|
|
@@ -79,8 +79,20 @@ function normalizeUserCode(input) {
|
|
|
79
79
|
return code;
|
|
80
80
|
}
|
|
81
81
|
var formatUserCode = (code) => `${code.slice(0, 4)}-${code.slice(4)}`;
|
|
82
|
-
var
|
|
83
|
-
var
|
|
82
|
+
var EMAIL_CODE_TTL_MS = 10 * 60000;
|
|
83
|
+
var EMAIL_CODE_MAX_ATTEMPTS = 5;
|
|
84
|
+
var OAUTH_FLOW_TTL_MS = 10 * 60000;
|
|
85
|
+
var OAUTH_PENDING_TTL_MS = 10 * 60000;
|
|
86
|
+
var OAUTH_CODE_TTL_MS = 2 * 60000;
|
|
87
|
+
var REAUTH_TTL_MS = 5 * 60000;
|
|
88
|
+
var PROVIDER_NAMES = { google: "Google", github: "GitHub", apple: "Apple" };
|
|
89
|
+
var MAX_EMAIL_LENGTH = 254;
|
|
90
|
+
var MAX_LOCAL_PART_LENGTH = 64;
|
|
91
|
+
var EMAIL_PATTERN = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
|
92
|
+
function normalizeEmail(raw) {
|
|
93
|
+
const email = raw.trim().toLowerCase();
|
|
94
|
+
return email.length <= MAX_EMAIL_LENGTH && EMAIL_PATTERN.test(email) && email.indexOf("@") <= MAX_LOCAL_PART_LENGTH ? email : undefined;
|
|
95
|
+
}
|
|
84
96
|
var PUSH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
|
85
97
|
var MAX_PUSH_TOKENS_PER_USER = 32;
|
|
86
98
|
var APNS_TOKEN_PATTERN = /^(?:[0-9a-f]{2})+$/;
|
|
@@ -115,6 +127,17 @@ var MAX_USERNAME_CODEPOINTS = 254;
|
|
|
115
127
|
var scryptAsync = promisify(scrypt);
|
|
116
128
|
var DUMMY_HASH = `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${"A".repeat(22)}$${"A".repeat(43)}`;
|
|
117
129
|
var sha256Hex = (input) => createHash("sha256").update(input, "utf8").digest("hex");
|
|
130
|
+
var DAY_MS = 24 * 60 * 60 * 1000;
|
|
131
|
+
var codeHash = (purpose, email, code) => sha256Hex(`${purpose}:${email}:${code}`);
|
|
132
|
+
var USER_SELECT = "SELECT id, username, email, password_hash AS passwordHash FROM users";
|
|
133
|
+
var accountUser = (row) => row.email === null ? { username: row.username ?? "" } : { username: row.email, email: row.email };
|
|
134
|
+
var accountLockKey = (userId) => `user:${userId}`;
|
|
135
|
+
function requireEmail(raw) {
|
|
136
|
+
const email = normalizeEmail(raw);
|
|
137
|
+
if (email === undefined)
|
|
138
|
+
throw new Error(`not a valid email: ${raw}`);
|
|
139
|
+
return email;
|
|
140
|
+
}
|
|
118
141
|
var safeEqual = (a, b) => {
|
|
119
142
|
const left = Buffer.from(a);
|
|
120
143
|
const right = Buffer.from(b);
|
|
@@ -271,6 +294,135 @@ function migrate(db) {
|
|
|
271
294
|
COMMIT;
|
|
272
295
|
`);
|
|
273
296
|
}
|
|
297
|
+
if (version < 9)
|
|
298
|
+
migrateTo9(db);
|
|
299
|
+
if (version < 10) {
|
|
300
|
+
db.exec(`
|
|
301
|
+
BEGIN;
|
|
302
|
+
-- Apple sends a name only the first time; its refresh token is revoked
|
|
303
|
+
-- when the account goes or Apple is unlinked, with the client_id that
|
|
304
|
+
-- received it (the web Services ID or the app's bundle id).
|
|
305
|
+
ALTER TABLE user_identities ADD COLUMN name TEXT;
|
|
306
|
+
ALTER TABLE user_identities ADD COLUMN refresh_token TEXT;
|
|
307
|
+
ALTER TABLE user_identities ADD COLUMN refresh_client_id TEXT;
|
|
308
|
+
CREATE TABLE oauth_flows (
|
|
309
|
+
state_hash TEXT PRIMARY KEY,
|
|
310
|
+
provider TEXT NOT NULL,
|
|
311
|
+
kind TEXT NOT NULL CHECK (kind IN ('web', 'native', 'link')),
|
|
312
|
+
verifier TEXT NOT NULL,
|
|
313
|
+
nonce TEXT NOT NULL,
|
|
314
|
+
-- web and link: the hash of the browser's binding cookie.
|
|
315
|
+
binding_hash TEXT,
|
|
316
|
+
-- native: where the code goes, its PKCE challenge and the client's state.
|
|
317
|
+
redirect_uri TEXT,
|
|
318
|
+
code_challenge TEXT,
|
|
319
|
+
client_state TEXT,
|
|
320
|
+
link_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
321
|
+
-- link: the session that started it, which must still be live when
|
|
322
|
+
-- the identity is written.
|
|
323
|
+
link_session_hash TEXT,
|
|
324
|
+
created_at INTEGER NOT NULL,
|
|
325
|
+
expires_at INTEGER NOT NULL
|
|
326
|
+
);
|
|
327
|
+
CREATE INDEX oauth_flows_expiry ON oauth_flows (expires_at);
|
|
328
|
+
-- A provider sign-in that found no account, waiting for the person to
|
|
329
|
+
-- create one or link it to an account they sign in to.
|
|
330
|
+
CREATE TABLE oauth_pending (
|
|
331
|
+
id_hash TEXT PRIMARY KEY,
|
|
332
|
+
provider TEXT NOT NULL,
|
|
333
|
+
subject TEXT NOT NULL,
|
|
334
|
+
email TEXT,
|
|
335
|
+
email_verified INTEGER NOT NULL DEFAULT 0,
|
|
336
|
+
name TEXT,
|
|
337
|
+
refresh_token TEXT,
|
|
338
|
+
refresh_client_id TEXT,
|
|
339
|
+
kind TEXT NOT NULL CHECK (kind IN ('web', 'native', 'direct')),
|
|
340
|
+
binding_hash TEXT,
|
|
341
|
+
redirect_uri TEXT,
|
|
342
|
+
code_challenge TEXT,
|
|
343
|
+
client_state TEXT,
|
|
344
|
+
created_at INTEGER NOT NULL,
|
|
345
|
+
expires_at INTEGER NOT NULL
|
|
346
|
+
);
|
|
347
|
+
CREATE INDEX oauth_pending_expiry ON oauth_pending (expires_at);
|
|
348
|
+
CREATE TABLE oauth_codes (
|
|
349
|
+
code_hash TEXT PRIMARY KEY,
|
|
350
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
351
|
+
code_challenge TEXT NOT NULL,
|
|
352
|
+
expires_at INTEGER NOT NULL
|
|
353
|
+
);
|
|
354
|
+
-- A grant dies with the session it was issued to.
|
|
355
|
+
CREATE TABLE reauth_grants (
|
|
356
|
+
grant_hash TEXT PRIMARY KEY,
|
|
357
|
+
session_hash TEXT NOT NULL REFERENCES sessions(token_hash) ON DELETE CASCADE,
|
|
358
|
+
expires_at INTEGER NOT NULL
|
|
359
|
+
);
|
|
360
|
+
CREATE INDEX reauth_grants_session ON reauth_grants (session_hash);
|
|
361
|
+
PRAGMA user_version = 10;
|
|
362
|
+
COMMIT;
|
|
363
|
+
`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
function migrateTo9(db) {
|
|
367
|
+
db.exec("PRAGMA foreign_keys = OFF");
|
|
368
|
+
try {
|
|
369
|
+
db.exec("BEGIN");
|
|
370
|
+
try {
|
|
371
|
+
db.exec(`
|
|
372
|
+
DROP TABLE invites;
|
|
373
|
+
CREATE TABLE users_v9 (
|
|
374
|
+
id INTEGER PRIMARY KEY,
|
|
375
|
+
-- Legacy accounts only (ADR-0044 §8); NULL for email accounts.
|
|
376
|
+
username TEXT UNIQUE,
|
|
377
|
+
-- Lowercased; set only once verified, so email_verified_at goes with it.
|
|
378
|
+
email TEXT UNIQUE,
|
|
379
|
+
email_verified_at INTEGER,
|
|
380
|
+
-- NULL for an account that only signs in through a provider (#620).
|
|
381
|
+
password_hash TEXT,
|
|
382
|
+
created_at INTEGER NOT NULL,
|
|
383
|
+
CHECK (username IS NOT NULL OR email IS NOT NULL)
|
|
384
|
+
);
|
|
385
|
+
INSERT INTO users_v9 (id, username, password_hash, created_at) SELECT id, username, password_hash, created_at FROM users;
|
|
386
|
+
DROP TABLE users;
|
|
387
|
+
ALTER TABLE users_v9 RENAME TO users;
|
|
388
|
+
-- Sign-in providers bound to an account (#620): subject is the
|
|
389
|
+
-- provider's stable id for the person, never an email.
|
|
390
|
+
CREATE TABLE user_identities (
|
|
391
|
+
provider TEXT NOT NULL,
|
|
392
|
+
subject TEXT NOT NULL,
|
|
393
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
394
|
+
email TEXT,
|
|
395
|
+
created_at INTEGER NOT NULL,
|
|
396
|
+
PRIMARY KEY (provider, subject)
|
|
397
|
+
);
|
|
398
|
+
CREATE INDEX user_identities_user ON user_identities (user_id);
|
|
399
|
+
-- Mailed codes: only the hash. A sign-up code has no account yet.
|
|
400
|
+
-- AUTOINCREMENT, like every id here that could be reused.
|
|
401
|
+
CREATE TABLE email_codes (
|
|
402
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
403
|
+
purpose TEXT NOT NULL CHECK (purpose IN ('signup', 'reset', 'add_email', 'reauth')),
|
|
404
|
+
email TEXT NOT NULL,
|
|
405
|
+
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
406
|
+
code_hash TEXT NOT NULL,
|
|
407
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
408
|
+
created_at INTEGER NOT NULL,
|
|
409
|
+
expires_at INTEGER NOT NULL
|
|
410
|
+
);
|
|
411
|
+
CREATE INDEX email_codes_email ON email_codes (purpose, email);
|
|
412
|
+
CREATE INDEX email_codes_user ON email_codes (user_id);
|
|
413
|
+
CREATE INDEX email_codes_expiry ON email_codes (expires_at);
|
|
414
|
+
`);
|
|
415
|
+
const dangling = db.prepare("PRAGMA foreign_key_check").all();
|
|
416
|
+
if (dangling.length > 0)
|
|
417
|
+
throw new Error(`schema 9 migration left ${dangling.length} dangling references`);
|
|
418
|
+
db.exec("PRAGMA user_version = 9; COMMIT");
|
|
419
|
+
} catch (error) {
|
|
420
|
+
db.exec("ROLLBACK");
|
|
421
|
+
throw error;
|
|
422
|
+
}
|
|
423
|
+
} finally {
|
|
424
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
425
|
+
}
|
|
274
426
|
}
|
|
275
427
|
function migrateTo4(db) {
|
|
276
428
|
db.exec("BEGIN");
|
|
@@ -451,10 +603,12 @@ async function openAccounts(options) {
|
|
|
451
603
|
if (options.bootstrapUser === undefined || options.bootstrapPassword === undefined) {
|
|
452
604
|
throw new Error("PINOMAD_SERVER_USER and PINOMAD_SERVER_PASSWORD are required to create the first account");
|
|
453
605
|
}
|
|
454
|
-
|
|
606
|
+
const email = options.bootstrapUser.includes("@") ? requireEmail(options.bootstrapUser) : undefined;
|
|
607
|
+
if (email === undefined)
|
|
608
|
+
assertUsernamePolicy(options.bootstrapUser);
|
|
455
609
|
assertPasswordPolicy(options.bootstrapPassword);
|
|
456
610
|
const hash = await hashPassword(options.bootstrapPassword);
|
|
457
|
-
db.prepare("INSERT INTO users (username, password_hash, created_at
|
|
611
|
+
db.prepare("INSERT INTO users (username, email, email_verified_at, password_hash, created_at) VALUES (?, ?, ?, ?, ?)").run(email === undefined ? options.bootstrapUser : null, email ?? null, email === undefined ? null : now(), hash, now());
|
|
458
612
|
}
|
|
459
613
|
} catch (error) {
|
|
460
614
|
db?.close();
|
|
@@ -485,12 +639,91 @@ async function openAccounts(options) {
|
|
|
485
639
|
const linkStarts = new WindowedCounter(linkLimits.counterMaxEntries, now);
|
|
486
640
|
const linkUserFailures = new WindowedCounter(linkLimits.counterMaxEntries, now);
|
|
487
641
|
const linkIpFailures = new WindowedCounter(linkLimits.counterMaxEntries, now);
|
|
642
|
+
const codeLimits = {
|
|
643
|
+
ipMax: options.codeLimits?.ipMax ?? 10,
|
|
644
|
+
ipWindowMs: options.codeLimits?.ipWindowMs ?? 60 * 60000,
|
|
645
|
+
emailMax: options.codeLimits?.emailMax ?? 5,
|
|
646
|
+
emailWindowMs: options.codeLimits?.emailWindowMs ?? 60 * 60000,
|
|
647
|
+
dailyMax: options.codeLimits?.dailyMax ?? 1000,
|
|
648
|
+
counterMaxEntries: options.codeLimits?.counterMaxEntries ?? 4096
|
|
649
|
+
};
|
|
650
|
+
const codeIps = new WindowedCounter(codeLimits.counterMaxEntries, now);
|
|
651
|
+
const codeEmails = new WindowedCounter(codeLimits.counterMaxEntries, now);
|
|
652
|
+
const codesToday = new WindowedCounter(1, now);
|
|
653
|
+
const oauthLimits = {
|
|
654
|
+
startMaxPerIp: options.oauthLimits?.startMaxPerIp ?? 30,
|
|
655
|
+
startWindowMs: options.oauthLimits?.startWindowMs ?? 10 * 60000,
|
|
656
|
+
maxPending: options.oauthLimits?.maxPending ?? 1e4
|
|
657
|
+
};
|
|
658
|
+
const oauthStarts = new WindowedCounter(limits.counterMaxEntries, now);
|
|
659
|
+
const linkCodes = new Map;
|
|
660
|
+
const mailer = options.mailer;
|
|
661
|
+
const registrationOpen = options.registration === "open";
|
|
662
|
+
const emailSignup = registrationOpen && mailer !== undefined;
|
|
663
|
+
const codeAllowed = (ip, email) => {
|
|
664
|
+
if (codeIps.hit(ip, codeLimits.ipMax, codeLimits.ipWindowMs))
|
|
665
|
+
return false;
|
|
666
|
+
if (codeEmails.isOver(email, codeLimits.emailMax) || codesToday.isOver("all", codeLimits.dailyMax))
|
|
667
|
+
return false;
|
|
668
|
+
codeEmails.bump(email, codeLimits.emailWindowMs);
|
|
669
|
+
codesToday.bump("all", DAY_MS);
|
|
670
|
+
return true;
|
|
671
|
+
};
|
|
672
|
+
const deliver = (purpose, to, subject, text) => {
|
|
673
|
+
mailer?.send(to, subject, text).catch(() => {
|
|
674
|
+
console.error(JSON.stringify({ event: "mail_failed", purpose }));
|
|
675
|
+
});
|
|
676
|
+
};
|
|
677
|
+
const issueCode = (purpose, email, userId) => {
|
|
678
|
+
const at = now();
|
|
679
|
+
db.prepare("DELETE FROM email_codes WHERE expires_at <= ?").run(at);
|
|
680
|
+
if (purpose === "add_email")
|
|
681
|
+
db.prepare("DELETE FROM email_codes WHERE purpose = ? AND user_id = ?").run(purpose, userId);
|
|
682
|
+
else
|
|
683
|
+
db.prepare("DELETE FROM email_codes WHERE purpose = ? AND email = ?").run(purpose, email);
|
|
684
|
+
const code = String(randomInt(1e6)).padStart(6, "0");
|
|
685
|
+
db.prepare("INSERT INTO email_codes (purpose, email, user_id, code_hash, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)").run(purpose, email, userId, codeHash(purpose, email, code), at, at + EMAIL_CODE_TTL_MS);
|
|
686
|
+
return code;
|
|
687
|
+
};
|
|
688
|
+
const spendCode = (purpose, email, code, lockKey, userId) => {
|
|
689
|
+
const row = db.prepare(`SELECT id, code_hash AS codeHash, attempts FROM email_codes
|
|
690
|
+
WHERE purpose = ? AND email = ? AND user_id IS ? AND expires_at > ?`).get(purpose, email, userId ?? null, now());
|
|
691
|
+
if (row !== undefined && safeEqual(codeHash(purpose, email, code.replace(/\s/g, "")), row.codeHash)) {
|
|
692
|
+
db.prepare("DELETE FROM email_codes WHERE id = ?").run(row.id);
|
|
693
|
+
return true;
|
|
694
|
+
}
|
|
695
|
+
userFailures.bump(lockKey, limits.userWindowMs);
|
|
696
|
+
if (row !== undefined) {
|
|
697
|
+
if (row.attempts + 1 >= EMAIL_CODE_MAX_ATTEMPTS)
|
|
698
|
+
db.prepare("DELETE FROM email_codes WHERE id = ?").run(row.id);
|
|
699
|
+
else
|
|
700
|
+
db.prepare("UPDATE email_codes SET attempts = attempts + 1 WHERE id = ?").run(row.id);
|
|
701
|
+
}
|
|
702
|
+
return false;
|
|
703
|
+
};
|
|
704
|
+
const findUser = (identifier) => {
|
|
705
|
+
const email = normalizeEmail(identifier);
|
|
706
|
+
return db.prepare(`${USER_SELECT} WHERE email = ? OR username = ? ORDER BY email IS ? DESC LIMIT 1`).get(email ?? null, identifier, email ?? null);
|
|
707
|
+
};
|
|
708
|
+
const userById = (userId) => db.prepare(`${USER_SELECT} WHERE id = ?`).get(userId);
|
|
709
|
+
const lockKeyFor = (identifier, user) => user === undefined ? `name:${normalizeEmail(identifier) ?? identifier}` : accountLockKey(user.id);
|
|
488
710
|
const PENDING_SELECT = `SELECT host_id AS hostId, host_name AS name, host_os AS os, source_ip AS sourceIp, expires_at AS expiresAt,
|
|
489
711
|
redirect_uri AS redirectUri, client_state AS clientState FROM host_link_requests`;
|
|
712
|
+
const linkIdentity = ({ linkUserId: userId, linkSessionHash }, identity) => transaction(() => {
|
|
713
|
+
const live = db.prepare("SELECT 1 AS x FROM sessions WHERE token_hash = ? AND user_id = ? AND expires_at > ?").get(linkSessionHash, userId, now());
|
|
714
|
+
if (live === undefined)
|
|
715
|
+
return "session_ended";
|
|
716
|
+
const bound = identityOwner(identity.provider, identity.subject);
|
|
717
|
+
if (bound !== undefined && bound !== userId)
|
|
718
|
+
return "identity_taken";
|
|
719
|
+
bindIdentity(userId, identity);
|
|
720
|
+
return "linked";
|
|
721
|
+
});
|
|
490
722
|
const sweepExpired = () => {
|
|
491
723
|
const at = now();
|
|
492
|
-
|
|
493
|
-
|
|
724
|
+
for (const table of ["join_tokens", "host_link_requests", "email_codes", "oauth_flows", "oauth_pending", "oauth_codes", "reauth_grants"]) {
|
|
725
|
+
db.prepare(`DELETE FROM ${table} WHERE expires_at <= ?`).run(at);
|
|
726
|
+
}
|
|
494
727
|
};
|
|
495
728
|
const mintJoinToken = (userId, hostId) => {
|
|
496
729
|
const token = randomBytes(32).toString("base64url");
|
|
@@ -510,14 +743,14 @@ async function openAccounts(options) {
|
|
|
510
743
|
return "limited";
|
|
511
744
|
}
|
|
512
745
|
const code = normalizeUserCode(userCode);
|
|
513
|
-
const
|
|
514
|
-
const row =
|
|
515
|
-
if (
|
|
746
|
+
const codeHash2 = code === undefined ? undefined : sha256Hex(code);
|
|
747
|
+
const row = codeHash2 === undefined ? undefined : db.prepare(`${PENDING_SELECT} WHERE user_code_hash = ? AND state = 'pending' AND expires_at > ?`).get(codeHash2, now());
|
|
748
|
+
if (codeHash2 === undefined || row === undefined) {
|
|
516
749
|
linkUserFailures.bump(userKey, linkLimits.failureWindowMs);
|
|
517
750
|
linkIpFailures.bump(ip, linkLimits.failureWindowMs);
|
|
518
751
|
return "not_found";
|
|
519
752
|
}
|
|
520
|
-
return { column: "user_code_hash", hash:
|
|
753
|
+
return { column: "user_code_hash", hash: codeHash2, row };
|
|
521
754
|
};
|
|
522
755
|
const settleHostLink = (args, state) => {
|
|
523
756
|
const found = pendingByRef(args.userId, args, args.ip);
|
|
@@ -578,16 +811,17 @@ async function openAccounts(options) {
|
|
|
578
811
|
db.prepare("UPDATE vouchings SET revoked_at = ? WHERE device_id = ? AND revoked_at IS NULL").run(at, deviceId);
|
|
579
812
|
return hostIds;
|
|
580
813
|
};
|
|
581
|
-
const currentHash = (userId) =>
|
|
582
|
-
const checkPassword = async (userId,
|
|
583
|
-
|
|
814
|
+
const currentHash = (userId) => userById(userId)?.passwordHash;
|
|
815
|
+
const checkPassword = async (userId, password) => {
|
|
816
|
+
const lockKey = accountLockKey(userId);
|
|
817
|
+
if (userFailures.isOver(lockKey, limits.userMaxFailures))
|
|
584
818
|
return "limited";
|
|
585
|
-
const stored = currentHash(userId);
|
|
819
|
+
const stored = currentHash(userId) ?? undefined;
|
|
586
820
|
const outcome = await gate.run(async () => {
|
|
587
|
-
if (userFailures.isOver(
|
|
821
|
+
if (userFailures.isOver(lockKey, limits.userMaxFailures))
|
|
588
822
|
return "limited";
|
|
589
823
|
if (!await verifyPassword(password, stored ?? DUMMY_HASH) || stored === undefined) {
|
|
590
|
-
userFailures.bump(
|
|
824
|
+
userFailures.bump(lockKey, limits.userWindowMs);
|
|
591
825
|
return "wrong_password";
|
|
592
826
|
}
|
|
593
827
|
return "ok";
|
|
@@ -596,30 +830,85 @@ async function openAccounts(options) {
|
|
|
596
830
|
return "limited";
|
|
597
831
|
if (outcome === "wrong_password")
|
|
598
832
|
return "wrong_password";
|
|
599
|
-
userFailures.reset(
|
|
833
|
+
userFailures.reset(lockKey);
|
|
600
834
|
return { verifiedHash: stored };
|
|
601
835
|
};
|
|
602
|
-
const
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
836
|
+
const reauthValid = (userId, token, grant) => {
|
|
837
|
+
const at = now();
|
|
838
|
+
return db.prepare(`SELECT 1 AS x FROM reauth_grants g JOIN sessions s ON s.token_hash = g.session_hash
|
|
839
|
+
WHERE g.grant_hash = ? AND g.session_hash = ? AND g.expires_at > ? AND s.user_id = ? AND s.expires_at > ?`).get(sha256Hex(grant), sha256Hex(token), at, userId, at) !== undefined;
|
|
840
|
+
};
|
|
841
|
+
const sessionLive = (userId, token) => db.prepare("SELECT 1 AS x FROM sessions WHERE token_hash = ? AND user_id = ? AND expires_at > ?").get(sha256Hex(token), userId, now()) !== undefined;
|
|
842
|
+
const confirmAccount = async (userId, confirmation) => {
|
|
843
|
+
if ("reauth" in confirmation) {
|
|
844
|
+
return reauthValid(userId, confirmation.token, confirmation.reauth) ? { reauth: confirmation.reauth, token: confirmation.token } : "invalid_reauth";
|
|
845
|
+
}
|
|
846
|
+
return checkPassword(userId, confirmation.password);
|
|
847
|
+
};
|
|
848
|
+
const stillConfirmed = (userId, confirmed) => {
|
|
849
|
+
if ("verifiedHash" in confirmed)
|
|
850
|
+
return currentHash(userId) === confirmed.verifiedHash ? undefined : "wrong_password";
|
|
851
|
+
return reauthValid(userId, confirmed.token, confirmed.reauth) ? undefined : "invalid_reauth";
|
|
852
|
+
};
|
|
853
|
+
const appleGrants = (userId) => db.prepare(`SELECT refresh_token AS refreshToken, refresh_client_id AS clientId FROM user_identities
|
|
854
|
+
WHERE user_id = ? AND provider = 'apple' AND refresh_token IS NOT NULL AND refresh_client_id IS NOT NULL`).all(userId);
|
|
855
|
+
const identityOwner = (provider, subject) => db.prepare("SELECT user_id AS userId FROM user_identities WHERE provider = ? AND subject = ?").get(provider, subject)?.userId;
|
|
856
|
+
const bindIdentity = (userId, identity) => {
|
|
857
|
+
db.prepare(`INSERT INTO user_identities (provider, subject, user_id, email, name, refresh_token, refresh_client_id, created_at)
|
|
858
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
859
|
+
ON CONFLICT (provider, subject) DO UPDATE SET
|
|
860
|
+
email = COALESCE(excluded.email, email), name = COALESCE(name, excluded.name),
|
|
861
|
+
refresh_token = COALESCE(excluded.refresh_token, refresh_token),
|
|
862
|
+
refresh_client_id = COALESCE(excluded.refresh_client_id, refresh_client_id)
|
|
863
|
+
WHERE user_id = excluded.user_id`).run(identity.provider, identity.subject, userId, identity.email ?? null, identity.name ?? null, identity.refreshToken ?? null, identity.refreshClientId ?? null, now());
|
|
864
|
+
};
|
|
865
|
+
const noticeNewMethod = (email, provider) => {
|
|
866
|
+
const name = PROVIDER_NAMES[provider];
|
|
867
|
+
deliver("new_sign_in_method", email, `A new sign-in method was added: ${name}`, `${name} can now sign in to your PiNomad account, because it confirmed this email address.
|
|
868
|
+
|
|
869
|
+
` + `If this wasn't you, sign in, remove ${name} under Sign-in methods, and sign out everywhere.
|
|
870
|
+
`);
|
|
871
|
+
};
|
|
872
|
+
const pendingRow = (id) => db.prepare(`SELECT provider, subject, email, email_verified AS emailVerified, name, refresh_token AS refreshToken,
|
|
873
|
+
refresh_client_id AS refreshClientId, kind, binding_hash AS bindingHash, redirect_uri AS redirectUri,
|
|
874
|
+
code_challenge AS codeChallenge, client_state AS clientState
|
|
875
|
+
FROM oauth_pending WHERE id_hash = ? AND expires_at > ?`).get(sha256Hex(id), now());
|
|
876
|
+
const completionOf = (row) => row.kind === "web" ? { kind: "web", bindingHash: row.bindingHash ?? "" } : row.kind === "native" ? {
|
|
877
|
+
kind: "native",
|
|
878
|
+
bindingHash: row.bindingHash ?? "",
|
|
879
|
+
native: { redirectUri: row.redirectUri ?? "", codeChallenge: row.codeChallenge ?? "", state: row.clientState ?? "" }
|
|
880
|
+
} : { kind: "direct" };
|
|
881
|
+
const identityOf = (row) => ({
|
|
882
|
+
provider: row.provider,
|
|
883
|
+
subject: row.subject,
|
|
884
|
+
...row.email === null ? {} : { email: row.email },
|
|
885
|
+
emailVerified: row.emailVerified === 1,
|
|
886
|
+
...row.name === null ? {} : { name: row.name },
|
|
887
|
+
...row.refreshToken === null || row.refreshClientId === null ? {} : { refreshToken: row.refreshToken, refreshClientId: row.refreshClientId }
|
|
888
|
+
});
|
|
889
|
+
const transaction = (write) => inTransaction(db, write);
|
|
890
|
+
const emailOwner = (email) => db.prepare("SELECT id FROM users WHERE email = ?").get(email)?.id;
|
|
891
|
+
const weakPassword = (password) => {
|
|
892
|
+
const length = [...password].length;
|
|
893
|
+
return length < MIN_PASSWORD_CODEPOINTS || length > MAX_PASSWORD_CODEPOINTS;
|
|
607
894
|
};
|
|
608
|
-
const usernameTaken = (username) => db.prepare("SELECT 1 AS x FROM users WHERE lower(username) = ?").get(username) !== undefined;
|
|
609
895
|
return {
|
|
610
896
|
dbPath: join(options.dataDir, DB_FILE),
|
|
611
|
-
|
|
897
|
+
emailEnabled: mailer !== undefined,
|
|
898
|
+
registrationOpen,
|
|
899
|
+
async login(identifier, password, ip, device) {
|
|
612
900
|
if (ipAttempts.hit(ip, limits.ipMaxAttempts, limits.ipWindowMs))
|
|
613
901
|
return { status: "limited" };
|
|
614
|
-
|
|
902
|
+
const user = findUser(identifier);
|
|
903
|
+
const lockKey = lockKeyFor(identifier, user);
|
|
904
|
+
if (userFailures.isOver(lockKey, limits.userMaxFailures))
|
|
615
905
|
return { status: "limited" };
|
|
616
|
-
const user = db.prepare("SELECT id, username, password_hash AS passwordHash, admin FROM users WHERE username = ?").get(username);
|
|
617
906
|
const outcome = await gate.run(async () => {
|
|
618
|
-
if (userFailures.isOver(
|
|
907
|
+
if (userFailures.isOver(lockKey, limits.userMaxFailures))
|
|
619
908
|
return "limited";
|
|
620
909
|
const ok = await verifyPassword(password, user?.passwordHash ?? DUMMY_HASH);
|
|
621
|
-
if (!ok || user === undefined) {
|
|
622
|
-
userFailures.bump(
|
|
910
|
+
if (!ok || user === undefined || user.passwordHash === null) {
|
|
911
|
+
userFailures.bump(lockKey, limits.userWindowMs);
|
|
623
912
|
return "invalid";
|
|
624
913
|
}
|
|
625
914
|
return user.id;
|
|
@@ -630,89 +919,187 @@ async function openAccounts(options) {
|
|
|
630
919
|
return { status: "invalid" };
|
|
631
920
|
db.exec("BEGIN IMMEDIATE");
|
|
632
921
|
let session;
|
|
922
|
+
let current;
|
|
633
923
|
try {
|
|
634
|
-
|
|
924
|
+
current = userById(outcome);
|
|
925
|
+
if (current !== undefined && current.passwordHash === user?.passwordHash)
|
|
635
926
|
session = issueSession(outcome, device);
|
|
636
927
|
db.exec("COMMIT");
|
|
637
928
|
} catch (error) {
|
|
638
929
|
db.exec("ROLLBACK");
|
|
639
930
|
throw error;
|
|
640
931
|
}
|
|
641
|
-
if (session === undefined)
|
|
932
|
+
if (session === undefined || current === undefined)
|
|
642
933
|
return { status: "invalid" };
|
|
643
|
-
userFailures.reset(
|
|
644
|
-
return { status: "ok", ...session,
|
|
934
|
+
userFailures.reset(lockKey);
|
|
935
|
+
return { status: "ok", ...session, user: accountUser(current) };
|
|
645
936
|
},
|
|
646
|
-
|
|
937
|
+
requestSignupCode({ email, ip }) {
|
|
938
|
+
if (!emailSignup)
|
|
939
|
+
return "closed";
|
|
940
|
+
if (!codeAllowed(ip, email))
|
|
941
|
+
return "limited";
|
|
942
|
+
if (emailOwner(email) !== undefined) {
|
|
943
|
+
deliver("signup_existing", email, "Your PiNomad account", `Someone asked to create a PiNomad account with this email, but you already have an account.
|
|
944
|
+
|
|
945
|
+
` + `Sign in, or reset your password if you forgot it. If this wasn't you, ignore this email.
|
|
946
|
+
`);
|
|
947
|
+
return "sent";
|
|
948
|
+
}
|
|
949
|
+
const code = issueCode("signup", email, null);
|
|
950
|
+
deliver("signup", email, `${code} is your PiNomad sign-up code`, `Your PiNomad sign-up code is ${code}. It expires in 10 minutes.
|
|
951
|
+
|
|
952
|
+
If you didn't ask for it, ignore this email.
|
|
953
|
+
`);
|
|
954
|
+
return "sent";
|
|
955
|
+
},
|
|
956
|
+
async register({ email, code, password, ip, device }) {
|
|
957
|
+
if (!emailSignup)
|
|
958
|
+
return { status: "refused", reason: "registration_closed" };
|
|
959
|
+
if (weakPassword(password))
|
|
960
|
+
return { status: "refused", reason: "weak_password" };
|
|
647
961
|
if (ipAttempts.hit(ip, limits.ipMaxAttempts, limits.ipWindowMs))
|
|
648
962
|
return { status: "limited" };
|
|
649
|
-
const
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
return { status: "refused", reason: invited };
|
|
653
|
-
if (!SIGN_UP_USERNAME.test(username))
|
|
654
|
-
return { status: "refused", reason: "invalid_username" };
|
|
655
|
-
const length = [...password].length;
|
|
656
|
-
if (length < MIN_PASSWORD_CODEPOINTS || length > MAX_PASSWORD_CODEPOINTS) {
|
|
657
|
-
return { status: "refused", reason: "weak_password" };
|
|
658
|
-
}
|
|
659
|
-
if (usernameTaken(username))
|
|
660
|
-
return { status: "refused", reason: "username_taken" };
|
|
963
|
+
const lockKey = lockKeyFor(email, findUser(email));
|
|
964
|
+
if (userFailures.isOver(lockKey, limits.userMaxFailures))
|
|
965
|
+
return { status: "limited" };
|
|
661
966
|
const hash = await gate.run(() => hashPassword(password));
|
|
662
967
|
if (hash === undefined)
|
|
663
968
|
return { status: "limited" };
|
|
664
|
-
db.exec("BEGIN");
|
|
969
|
+
db.exec("BEGIN IMMEDIATE");
|
|
665
970
|
try {
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
db.exec("ROLLBACK");
|
|
670
|
-
return { status: "refused", reason: failure };
|
|
671
|
-
}
|
|
672
|
-
const userId = Number(db.prepare("INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)").run(username, hash, now()).lastInsertRowid);
|
|
673
|
-
const claimed = db.prepare("UPDATE invites SET used_at = ?, used_by = ? WHERE code_hash = ? AND used_at IS NULL").run(now(), userId, codeHash);
|
|
674
|
-
if (Number(claimed.changes) !== 1) {
|
|
675
|
-
db.exec("ROLLBACK");
|
|
676
|
-
return { status: "refused", reason: "invalid_invite" };
|
|
971
|
+
if (!spendCode("signup", email, code, lockKey) || emailOwner(email) !== undefined) {
|
|
972
|
+
db.exec("COMMIT");
|
|
973
|
+
return { status: "refused", reason: "invalid_code" };
|
|
677
974
|
}
|
|
975
|
+
const at = now();
|
|
976
|
+
const userId = Number(db.prepare("INSERT INTO users (email, email_verified_at, password_hash, created_at) VALUES (?, ?, ?, ?)").run(email, at, hash, at).lastInsertRowid);
|
|
678
977
|
const session = issueSession(userId, device);
|
|
679
978
|
db.exec("COMMIT");
|
|
680
|
-
|
|
979
|
+
userFailures.reset(lockKey);
|
|
980
|
+
return { status: "ok", ...session, user: { username: email, email } };
|
|
681
981
|
} catch (error) {
|
|
682
982
|
db.exec("ROLLBACK");
|
|
683
983
|
throw error;
|
|
684
984
|
}
|
|
685
985
|
},
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
const
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
986
|
+
requestPasswordReset({ email, ip }) {
|
|
987
|
+
if (mailer === undefined)
|
|
988
|
+
return "closed";
|
|
989
|
+
if (!codeAllowed(ip, email))
|
|
990
|
+
return "limited";
|
|
991
|
+
const userId = emailOwner(email);
|
|
992
|
+
if (userId === undefined)
|
|
993
|
+
return "sent";
|
|
994
|
+
const code = issueCode("reset", email, userId);
|
|
995
|
+
deliver("reset", email, `${code} is your PiNomad password reset code`, `Your PiNomad password reset code is ${code}. It expires in 10 minutes.
|
|
996
|
+
|
|
997
|
+
` + `If you didn't ask to reset your password, ignore this email; your password stays as it is.
|
|
998
|
+
`);
|
|
999
|
+
return "sent";
|
|
1000
|
+
},
|
|
1001
|
+
async resetPasswordWithCode({ email, code, password, ip }) {
|
|
1002
|
+
if (mailer === undefined)
|
|
1003
|
+
return { status: "refused", reason: "email_unavailable" };
|
|
1004
|
+
if (weakPassword(password))
|
|
1005
|
+
return { status: "refused", reason: "weak_password" };
|
|
1006
|
+
if (ipAttempts.hit(ip, limits.ipMaxAttempts, limits.ipWindowMs))
|
|
1007
|
+
return { status: "limited" };
|
|
1008
|
+
const lockKey = lockKeyFor(email, findUser(email));
|
|
1009
|
+
if (userFailures.isOver(lockKey, limits.userMaxFailures))
|
|
1010
|
+
return { status: "limited" };
|
|
1011
|
+
const hash = await gate.run(() => hashPassword(password));
|
|
1012
|
+
if (hash === undefined)
|
|
1013
|
+
return { status: "limited" };
|
|
1014
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1015
|
+
try {
|
|
1016
|
+
const userId = emailOwner(email);
|
|
1017
|
+
if (userId === undefined || !spendCode("reset", email, code, lockKey, userId)) {
|
|
1018
|
+
if (userId === undefined)
|
|
1019
|
+
userFailures.bump(lockKey, limits.userWindowMs);
|
|
1020
|
+
db.exec("COMMIT");
|
|
1021
|
+
return { status: "refused", reason: "invalid_code" };
|
|
1022
|
+
}
|
|
1023
|
+
db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hash, userId);
|
|
1024
|
+
dropCredentials(db, userId);
|
|
1025
|
+
db.exec("COMMIT");
|
|
1026
|
+
userFailures.reset(lockKey);
|
|
1027
|
+
return { status: "ok" };
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
db.exec("ROLLBACK");
|
|
1030
|
+
throw error;
|
|
1031
|
+
}
|
|
1032
|
+
},
|
|
1033
|
+
async requestEmailCode(args) {
|
|
1034
|
+
const { userId, email, ip } = args;
|
|
1035
|
+
if (mailer === undefined)
|
|
1036
|
+
return "closed";
|
|
1037
|
+
const checked = await confirmAccount(userId, args);
|
|
1038
|
+
if (typeof checked === "string")
|
|
1039
|
+
return checked;
|
|
1040
|
+
const owner = emailOwner(email);
|
|
1041
|
+
if (owner !== undefined && owner !== userId)
|
|
1042
|
+
return "taken";
|
|
1043
|
+
if (!codeAllowed(ip, email))
|
|
1044
|
+
return "limited";
|
|
1045
|
+
const code = issueCode("add_email", email, userId);
|
|
1046
|
+
deliver("add_email", email, `${code} is your PiNomad confirmation code`, `Your code to use this email for your PiNomad account is ${code}. It expires in 10 minutes.
|
|
1047
|
+
|
|
1048
|
+
` + `If you didn't ask for it, ignore this email.
|
|
1049
|
+
`);
|
|
1050
|
+
return "sent";
|
|
703
1051
|
},
|
|
704
|
-
|
|
705
|
-
|
|
1052
|
+
async setEmail(args) {
|
|
1053
|
+
const { userId, email, code } = args;
|
|
1054
|
+
if (mailer === undefined)
|
|
1055
|
+
return { status: "refused", reason: "email_unavailable" };
|
|
1056
|
+
const checked = await confirmAccount(userId, args);
|
|
1057
|
+
if (checked === "limited")
|
|
1058
|
+
return { status: "limited" };
|
|
1059
|
+
if (typeof checked === "string")
|
|
1060
|
+
return { status: "refused", reason: checked };
|
|
1061
|
+
const lockKey = accountLockKey(userId);
|
|
1062
|
+
let previous;
|
|
1063
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1064
|
+
try {
|
|
1065
|
+
const user = userById(userId);
|
|
1066
|
+
const stale = user === undefined ? "wrong_password" : stillConfirmed(userId, checked);
|
|
1067
|
+
if (user === undefined || stale !== undefined) {
|
|
1068
|
+
db.exec("ROLLBACK");
|
|
1069
|
+
return { status: "refused", reason: stale ?? "wrong_password" };
|
|
1070
|
+
}
|
|
1071
|
+
const owner = emailOwner(email);
|
|
1072
|
+
if (owner !== undefined && owner !== userId) {
|
|
1073
|
+
db.exec("COMMIT");
|
|
1074
|
+
return { status: "refused", reason: "email_taken" };
|
|
1075
|
+
}
|
|
1076
|
+
if (!spendCode("add_email", email, code, lockKey, userId)) {
|
|
1077
|
+
db.exec("COMMIT");
|
|
1078
|
+
return { status: "refused", reason: "invalid_code" };
|
|
1079
|
+
}
|
|
1080
|
+
db.prepare("UPDATE users SET email = ?, email_verified_at = ? WHERE id = ?").run(email, now(), userId);
|
|
1081
|
+
previous = user.email;
|
|
1082
|
+
db.exec("COMMIT");
|
|
1083
|
+
} catch (error) {
|
|
1084
|
+
db.exec("ROLLBACK");
|
|
1085
|
+
throw error;
|
|
1086
|
+
}
|
|
1087
|
+
if (previous !== null && previous !== undefined && previous !== email) {
|
|
1088
|
+
deliver("email_changed", previous, "Your PiNomad email was changed", `The email of your PiNomad account was changed to ${email}.
|
|
1089
|
+
|
|
1090
|
+
` + "If this wasn't you, someone else has your password and now controls the account's email. " + `Contact your server's operator.
|
|
1091
|
+
`);
|
|
1092
|
+
}
|
|
1093
|
+
return { status: "ok", user: { username: email, email } };
|
|
706
1094
|
},
|
|
707
1095
|
authenticate(token) {
|
|
708
|
-
const row = db.prepare(`SELECT s.user_id AS userId, u.username AS username, u.
|
|
1096
|
+
const row = db.prepare(`SELECT s.user_id AS userId, u.username AS username, u.email AS email, s.expires_at AS expiresAt, s.device_id AS deviceId
|
|
709
1097
|
FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ?`).get(sha256Hex(token));
|
|
710
1098
|
if (row === undefined || row.expiresAt <= now())
|
|
711
1099
|
return;
|
|
712
1100
|
return {
|
|
713
1101
|
userId: row.userId,
|
|
714
|
-
|
|
715
|
-
admin: row.admin === 1,
|
|
1102
|
+
user: accountUser(row),
|
|
716
1103
|
...row.deviceId === null ? {} : { deviceId: row.deviceId }
|
|
717
1104
|
};
|
|
718
1105
|
},
|
|
@@ -734,26 +1121,26 @@ async function openAccounts(options) {
|
|
|
734
1121
|
throw error;
|
|
735
1122
|
}
|
|
736
1123
|
},
|
|
737
|
-
async changePassword(
|
|
738
|
-
const
|
|
739
|
-
if (
|
|
1124
|
+
async changePassword(args) {
|
|
1125
|
+
const { userId, keepToken, newPassword } = args;
|
|
1126
|
+
if (weakPassword(newPassword))
|
|
740
1127
|
return { status: "refused", reason: "weak_password" };
|
|
741
|
-
}
|
|
742
|
-
const checked = await checkPassword(userId, username, currentPassword);
|
|
1128
|
+
const checked = await confirmAccount(userId, "reauth" in args ? { reauth: args.reauth, token: keepToken } : { password: args.currentPassword });
|
|
743
1129
|
if (checked === "limited")
|
|
744
1130
|
return { status: "limited" };
|
|
745
|
-
if (checked === "
|
|
746
|
-
return { status: "refused", reason:
|
|
1131
|
+
if (typeof checked === "string")
|
|
1132
|
+
return { status: "refused", reason: checked };
|
|
747
1133
|
const hash = await gate.run(() => hashPassword(newPassword));
|
|
748
1134
|
if (hash === undefined)
|
|
749
1135
|
return { status: "limited" };
|
|
750
1136
|
db.exec("BEGIN IMMEDIATE");
|
|
751
1137
|
try {
|
|
752
|
-
const
|
|
753
|
-
if (
|
|
1138
|
+
const stale = "verifiedHash" in checked && !sessionLive(userId, keepToken) ? "unauthenticated" : stillConfirmed(userId, checked);
|
|
1139
|
+
if (stale !== undefined) {
|
|
754
1140
|
db.exec("ROLLBACK");
|
|
755
|
-
return { status: "refused", reason:
|
|
1141
|
+
return { status: "refused", reason: stale };
|
|
756
1142
|
}
|
|
1143
|
+
db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hash, userId);
|
|
757
1144
|
dropCredentials(db, userId, sha256Hex(keepToken));
|
|
758
1145
|
db.exec("COMMIT");
|
|
759
1146
|
} catch (error) {
|
|
@@ -762,30 +1149,283 @@ async function openAccounts(options) {
|
|
|
762
1149
|
}
|
|
763
1150
|
return { status: "ok" };
|
|
764
1151
|
},
|
|
765
|
-
async deleteAccount(
|
|
766
|
-
const
|
|
767
|
-
|
|
768
|
-
return { status: "refused", reason: "admin_account" };
|
|
769
|
-
const checked = await checkPassword(userId, username, password);
|
|
1152
|
+
async deleteAccount(args) {
|
|
1153
|
+
const { userId, keepToken } = args;
|
|
1154
|
+
const checked = await confirmAccount(userId, args);
|
|
770
1155
|
if (checked === "limited")
|
|
771
1156
|
return { status: "limited" };
|
|
772
|
-
if (checked === "
|
|
773
|
-
return { status: "refused", reason:
|
|
1157
|
+
if (typeof checked === "string")
|
|
1158
|
+
return { status: "refused", reason: checked };
|
|
774
1159
|
db.exec("BEGIN IMMEDIATE");
|
|
775
1160
|
try {
|
|
776
|
-
const
|
|
777
|
-
|
|
778
|
-
if (Number(deleted.changes) !== 1) {
|
|
1161
|
+
const stale = "verifiedHash" in checked && !sessionLive(userId, keepToken) ? "unauthenticated" : stillConfirmed(userId, checked);
|
|
1162
|
+
if (stale !== undefined) {
|
|
779
1163
|
db.exec("ROLLBACK");
|
|
780
|
-
return { status: "refused", reason:
|
|
1164
|
+
return { status: "refused", reason: stale };
|
|
781
1165
|
}
|
|
1166
|
+
const hostIds = db.prepare("SELECT host_id AS hostId FROM hosts WHERE user_id = ?").all(userId).map((row) => row.hostId);
|
|
1167
|
+
const grants = appleGrants(userId);
|
|
1168
|
+
db.prepare("DELETE FROM users WHERE id = ?").run(userId);
|
|
782
1169
|
db.exec("COMMIT");
|
|
783
|
-
return { status: "ok", hostIds };
|
|
1170
|
+
return { status: "ok", hostIds, appleGrants: grants };
|
|
784
1171
|
} catch (error) {
|
|
785
1172
|
db.exec("ROLLBACK");
|
|
786
1173
|
throw error;
|
|
787
1174
|
}
|
|
788
1175
|
},
|
|
1176
|
+
async confirm(userId, confirmation) {
|
|
1177
|
+
const checked = await confirmAccount(userId, confirmation);
|
|
1178
|
+
return typeof checked === "string" ? checked : "ok";
|
|
1179
|
+
},
|
|
1180
|
+
requestReauthCode({ userId, ip }) {
|
|
1181
|
+
if (mailer === undefined)
|
|
1182
|
+
return "closed";
|
|
1183
|
+
const email = userById(userId)?.email ?? null;
|
|
1184
|
+
if (email === null)
|
|
1185
|
+
return "no_email";
|
|
1186
|
+
if (!codeAllowed(ip, email))
|
|
1187
|
+
return "limited";
|
|
1188
|
+
const code = issueCode("reauth", email, userId);
|
|
1189
|
+
deliver("reauth", email, `${code} is your PiNomad confirmation code`, `Your code to confirm a change to your PiNomad account is ${code}. It expires in 10 minutes.
|
|
1190
|
+
|
|
1191
|
+
` + `If you didn't ask for it, someone else may be signed in to your account: sign out everywhere.
|
|
1192
|
+
`);
|
|
1193
|
+
return "sent";
|
|
1194
|
+
},
|
|
1195
|
+
async grantReauth(args) {
|
|
1196
|
+
const { userId, token } = args;
|
|
1197
|
+
const lockKey = accountLockKey(userId);
|
|
1198
|
+
if ("password" in args) {
|
|
1199
|
+
const checked = await checkPassword(userId, args.password);
|
|
1200
|
+
if (checked === "limited")
|
|
1201
|
+
return { status: "limited" };
|
|
1202
|
+
if (checked === "wrong_password")
|
|
1203
|
+
return { status: "refused", reason: "wrong_password" };
|
|
1204
|
+
} else {
|
|
1205
|
+
if (userFailures.isOver(lockKey, limits.userMaxFailures))
|
|
1206
|
+
return { status: "limited" };
|
|
1207
|
+
const email = userById(userId)?.email ?? null;
|
|
1208
|
+
const spent = email !== null && transaction(() => spendCode("reauth", email, args.code, lockKey, userId));
|
|
1209
|
+
if (!spent) {
|
|
1210
|
+
if (email === null)
|
|
1211
|
+
userFailures.bump(lockKey, limits.userWindowMs);
|
|
1212
|
+
return { status: "refused", reason: "invalid_code" };
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
const reauth = randomBytes(32).toString("base64url");
|
|
1216
|
+
const at = now();
|
|
1217
|
+
const expiresAt = at + REAUTH_TTL_MS;
|
|
1218
|
+
const granted = transaction(() => {
|
|
1219
|
+
db.prepare("DELETE FROM reauth_grants WHERE expires_at <= ?").run(at);
|
|
1220
|
+
return db.prepare(`INSERT INTO reauth_grants (grant_hash, session_hash, expires_at)
|
|
1221
|
+
SELECT ?, token_hash, ? FROM sessions WHERE token_hash = ? AND user_id = ? AND expires_at > ?`).run(sha256Hex(reauth), expiresAt, sha256Hex(token), userId, at).changes;
|
|
1222
|
+
});
|
|
1223
|
+
return Number(granted) === 1 ? { status: "ok", reauth, expiresAt } : { status: "refused", reason: "unauthenticated" };
|
|
1224
|
+
},
|
|
1225
|
+
startOAuthFlow({ provider, kind, ip, bindingHash, native, linkUserId, linkSessionToken }) {
|
|
1226
|
+
if (oauthStarts.hit(ip, oauthLimits.startMaxPerIp, oauthLimits.startWindowMs))
|
|
1227
|
+
return { status: "limited" };
|
|
1228
|
+
const at = now();
|
|
1229
|
+
db.prepare("DELETE FROM oauth_flows WHERE expires_at <= ?").run(at);
|
|
1230
|
+
const live = db.prepare("SELECT COUNT(*) AS n FROM oauth_flows").get().n;
|
|
1231
|
+
if (live >= oauthLimits.maxPending)
|
|
1232
|
+
return { status: "busy" };
|
|
1233
|
+
const state = randomBytes(32).toString("base64url");
|
|
1234
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
1235
|
+
const nonce = randomBytes(16).toString("base64url");
|
|
1236
|
+
db.prepare(`INSERT INTO oauth_flows (state_hash, provider, kind, verifier, nonce, binding_hash, redirect_uri, code_challenge, client_state,
|
|
1237
|
+
link_user_id, link_session_hash, created_at, expires_at)
|
|
1238
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(sha256Hex(state), provider, kind, verifier, nonce, bindingHash ?? null, native?.redirectUri ?? null, native?.codeChallenge ?? null, native?.state ?? null, linkUserId ?? null, linkSessionToken === undefined ? null : sha256Hex(linkSessionToken), at, at + OAUTH_FLOW_TTL_MS);
|
|
1239
|
+
return { status: "ok", state, verifier, nonce };
|
|
1240
|
+
},
|
|
1241
|
+
startNativeLink({ provider, ticket, bindingHash, ip }) {
|
|
1242
|
+
if (oauthStarts.hit(ip, oauthLimits.startMaxPerIp, oauthLimits.startWindowMs))
|
|
1243
|
+
return { status: "limited" };
|
|
1244
|
+
const state = randomBytes(32).toString("base64url");
|
|
1245
|
+
const row = db.prepare(`UPDATE oauth_flows SET state_hash = ?, binding_hash = ?
|
|
1246
|
+
WHERE binding_hash = ? AND provider = ? AND kind = 'link' AND redirect_uri IS NOT NULL AND expires_at > ?
|
|
1247
|
+
RETURNING verifier, nonce`).get(sha256Hex(state), bindingHash, sha256Hex(ticket), provider, now());
|
|
1248
|
+
return row === undefined ? undefined : { status: "ok", state, verifier: row.verifier, nonce: row.nonce };
|
|
1249
|
+
},
|
|
1250
|
+
takeOAuthFlow(provider, state) {
|
|
1251
|
+
const hash = sha256Hex(state);
|
|
1252
|
+
const row = db.prepare(`SELECT provider, kind, verifier, nonce, binding_hash AS bindingHash, redirect_uri AS redirectUri, code_challenge AS codeChallenge,
|
|
1253
|
+
client_state AS clientState, link_user_id AS linkUserId, link_session_hash AS linkSessionHash, expires_at AS expiresAt
|
|
1254
|
+
FROM oauth_flows WHERE state_hash = ?`).get(hash);
|
|
1255
|
+
if (row !== undefined)
|
|
1256
|
+
db.prepare("DELETE FROM oauth_flows WHERE state_hash = ?").run(hash);
|
|
1257
|
+
if (row === undefined || row.expiresAt <= now() || row.provider !== provider)
|
|
1258
|
+
return;
|
|
1259
|
+
return {
|
|
1260
|
+
provider: row.provider,
|
|
1261
|
+
kind: row.kind,
|
|
1262
|
+
verifier: row.verifier,
|
|
1263
|
+
nonce: row.nonce,
|
|
1264
|
+
...row.bindingHash === null ? {} : { bindingHash: row.bindingHash },
|
|
1265
|
+
...row.redirectUri === null || row.codeChallenge === null ? {} : { native: { redirectUri: row.redirectUri, codeChallenge: row.codeChallenge, state: row.clientState ?? "" } },
|
|
1266
|
+
...row.linkUserId === null ? {} : { linkUserId: row.linkUserId },
|
|
1267
|
+
...row.linkSessionHash === null ? {} : { linkSessionHash: row.linkSessionHash }
|
|
1268
|
+
};
|
|
1269
|
+
},
|
|
1270
|
+
resolveIdentity(identity, completion) {
|
|
1271
|
+
const result = transaction(() => {
|
|
1272
|
+
const bound = identityOwner(identity.provider, identity.subject);
|
|
1273
|
+
if (bound !== undefined) {
|
|
1274
|
+
bindIdentity(bound, identity);
|
|
1275
|
+
return { status: "user", userId: bound };
|
|
1276
|
+
}
|
|
1277
|
+
if (identity.emailVerified && identity.email !== undefined) {
|
|
1278
|
+
const owner = db.prepare("SELECT id FROM users WHERE email = ? AND email_verified_at IS NOT NULL").get(identity.email);
|
|
1279
|
+
if (owner !== undefined) {
|
|
1280
|
+
bindIdentity(owner.id, identity);
|
|
1281
|
+
return { status: "user", userId: owner.id, notify: identity.email };
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
if (!registrationOpen)
|
|
1285
|
+
return { status: "refused", reason: "registration_closed" };
|
|
1286
|
+
const id = randomBytes(32).toString("base64url");
|
|
1287
|
+
const at = now();
|
|
1288
|
+
db.prepare("DELETE FROM oauth_pending WHERE expires_at <= ?").run(at);
|
|
1289
|
+
db.prepare(`INSERT INTO oauth_pending (id_hash, provider, subject, email, email_verified, name, refresh_token, refresh_client_id,
|
|
1290
|
+
kind, binding_hash, redirect_uri, code_challenge, client_state, created_at, expires_at)
|
|
1291
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(sha256Hex(id), identity.provider, identity.subject, identity.email ?? null, identity.emailVerified ? 1 : 0, identity.name ?? null, identity.refreshToken ?? null, identity.refreshClientId ?? null, completion.kind, completion.kind === "direct" ? null : completion.bindingHash, completion.kind === "native" ? completion.native.redirectUri : null, completion.kind === "native" ? completion.native.codeChallenge : null, completion.kind === "native" ? completion.native.state : null, at, at + OAUTH_PENDING_TTL_MS);
|
|
1292
|
+
return { status: "pending", id, ...identity.email === undefined ? {} : { email: identity.email } };
|
|
1293
|
+
});
|
|
1294
|
+
if (result.notify !== undefined)
|
|
1295
|
+
noticeNewMethod(result.notify, identity.provider);
|
|
1296
|
+
const { notify: _notify, ...answer } = result;
|
|
1297
|
+
return answer;
|
|
1298
|
+
},
|
|
1299
|
+
linkIdentity: (flow, identity) => linkIdentity(flow, identity),
|
|
1300
|
+
pendingSignIn(id) {
|
|
1301
|
+
const row = pendingRow(id);
|
|
1302
|
+
if (row === undefined)
|
|
1303
|
+
return;
|
|
1304
|
+
return {
|
|
1305
|
+
provider: row.provider,
|
|
1306
|
+
...row.email === null ? {} : { email: row.email },
|
|
1307
|
+
...row.name === null ? {} : { name: row.name },
|
|
1308
|
+
completion: completionOf(row)
|
|
1309
|
+
};
|
|
1310
|
+
},
|
|
1311
|
+
async createFromPending(id, password) {
|
|
1312
|
+
if (password === undefined && mailer === undefined)
|
|
1313
|
+
return { status: "refused", reason: "password_required" };
|
|
1314
|
+
if (password !== undefined && weakPassword(password))
|
|
1315
|
+
return { status: "refused", reason: "weak_password" };
|
|
1316
|
+
const hash = password === undefined ? null : await gate.run(() => hashPassword(password));
|
|
1317
|
+
if (hash === undefined)
|
|
1318
|
+
return { status: "limited" };
|
|
1319
|
+
return transaction(() => {
|
|
1320
|
+
const row = pendingRow(id);
|
|
1321
|
+
if (row === undefined)
|
|
1322
|
+
return { status: "refused", reason: "not_found" };
|
|
1323
|
+
if (!registrationOpen)
|
|
1324
|
+
return { status: "refused", reason: "registration_closed" };
|
|
1325
|
+
if (row.email === null || row.emailVerified !== 1)
|
|
1326
|
+
return { status: "refused", reason: "no_verified_email" };
|
|
1327
|
+
if (emailOwner(row.email) !== undefined)
|
|
1328
|
+
return { status: "refused", reason: "email_taken" };
|
|
1329
|
+
if (identityOwner(row.provider, row.subject) !== undefined)
|
|
1330
|
+
return { status: "refused", reason: "identity_taken" };
|
|
1331
|
+
const at = now();
|
|
1332
|
+
const userId = Number(db.prepare("INSERT INTO users (email, email_verified_at, password_hash, created_at) VALUES (?, ?, ?, ?)").run(row.email, at, hash, at).lastInsertRowid);
|
|
1333
|
+
bindIdentity(userId, identityOf(row));
|
|
1334
|
+
db.prepare("DELETE FROM oauth_pending WHERE id_hash = ?").run(sha256Hex(id));
|
|
1335
|
+
return { status: "ok", userId, completion: completionOf(row) };
|
|
1336
|
+
});
|
|
1337
|
+
},
|
|
1338
|
+
linkPending(id, userId) {
|
|
1339
|
+
return transaction(() => {
|
|
1340
|
+
const row = pendingRow(id);
|
|
1341
|
+
if (row === undefined)
|
|
1342
|
+
return { status: "refused", reason: "not_found" };
|
|
1343
|
+
const bound = identityOwner(row.provider, row.subject);
|
|
1344
|
+
if (bound !== undefined && bound !== userId)
|
|
1345
|
+
return { status: "refused", reason: "identity_taken" };
|
|
1346
|
+
bindIdentity(userId, identityOf(row));
|
|
1347
|
+
db.prepare("DELETE FROM oauth_pending WHERE id_hash = ?").run(sha256Hex(id));
|
|
1348
|
+
return { status: "ok", userId, completion: completionOf(row) };
|
|
1349
|
+
});
|
|
1350
|
+
},
|
|
1351
|
+
signIn(userId, device) {
|
|
1352
|
+
return transaction(() => {
|
|
1353
|
+
const user = userById(userId);
|
|
1354
|
+
return user === undefined ? undefined : { ...issueSession(userId, device), user: accountUser(user) };
|
|
1355
|
+
});
|
|
1356
|
+
},
|
|
1357
|
+
issueLinkCode({ identity, linkUserId, linkSessionHash, codeChallenge }) {
|
|
1358
|
+
const at = now();
|
|
1359
|
+
const live = db.prepare("SELECT 1 AS x FROM sessions WHERE token_hash = ? AND user_id = ? AND expires_at > ?").get(linkSessionHash, linkUserId, at);
|
|
1360
|
+
if (live === undefined)
|
|
1361
|
+
return "session_ended";
|
|
1362
|
+
for (const [hash, entry] of linkCodes)
|
|
1363
|
+
if (entry.expiresAt <= at)
|
|
1364
|
+
linkCodes.delete(hash);
|
|
1365
|
+
if (linkCodes.size >= oauthLimits.maxPending)
|
|
1366
|
+
linkCodes.delete(linkCodes.keys().next().value);
|
|
1367
|
+
const code = randomBytes(32).toString("base64url");
|
|
1368
|
+
linkCodes.set(sha256Hex(code), { identity, linkUserId, linkSessionHash, codeChallenge, expiresAt: at + OAUTH_CODE_TTL_MS });
|
|
1369
|
+
return { code };
|
|
1370
|
+
},
|
|
1371
|
+
completeLink({ code, codeVerifier, token }) {
|
|
1372
|
+
const hash = sha256Hex(code);
|
|
1373
|
+
const entry = linkCodes.get(hash);
|
|
1374
|
+
linkCodes.delete(hash);
|
|
1375
|
+
if (entry === undefined || entry.expiresAt <= now() || !safeEqual(sha256Hex(token), entry.linkSessionHash) || !safeEqual(createHash("sha256").update(codeVerifier).digest("base64url"), entry.codeChallenge)) {
|
|
1376
|
+
return "invalid_grant";
|
|
1377
|
+
}
|
|
1378
|
+
return linkIdentity({ linkUserId: entry.linkUserId, linkSessionHash: entry.linkSessionHash }, entry.identity);
|
|
1379
|
+
},
|
|
1380
|
+
issueOAuthCode(userId, codeChallenge) {
|
|
1381
|
+
const code = randomBytes(32).toString("base64url");
|
|
1382
|
+
const at = now();
|
|
1383
|
+
db.prepare("DELETE FROM oauth_codes WHERE expires_at <= ?").run(at);
|
|
1384
|
+
db.prepare("INSERT INTO oauth_codes (code_hash, user_id, code_challenge, expires_at) VALUES (?, ?, ?, ?)").run(sha256Hex(code), userId, codeChallenge, at + OAUTH_CODE_TTL_MS);
|
|
1385
|
+
return code;
|
|
1386
|
+
},
|
|
1387
|
+
exchangeOAuthCode(code, codeVerifier, device) {
|
|
1388
|
+
const hash = sha256Hex(code);
|
|
1389
|
+
return transaction(() => {
|
|
1390
|
+
const row = db.prepare("SELECT user_id AS userId, code_challenge AS challenge, expires_at AS expiresAt FROM oauth_codes WHERE code_hash = ?").get(hash);
|
|
1391
|
+
if (row === undefined)
|
|
1392
|
+
return;
|
|
1393
|
+
db.prepare("DELETE FROM oauth_codes WHERE code_hash = ?").run(hash);
|
|
1394
|
+
if (row.expiresAt <= now() || !safeEqual(createHash("sha256").update(codeVerifier).digest("base64url"), row.challenge))
|
|
1395
|
+
return;
|
|
1396
|
+
const user = userById(row.userId);
|
|
1397
|
+
return user === undefined ? undefined : { ...issueSession(row.userId, device), user: accountUser(user) };
|
|
1398
|
+
});
|
|
1399
|
+
},
|
|
1400
|
+
listIdentities(userId) {
|
|
1401
|
+
const rows = db.prepare("SELECT provider, email, created_at AS createdAt FROM user_identities WHERE user_id = ? ORDER BY created_at, provider").all(userId);
|
|
1402
|
+
return {
|
|
1403
|
+
identities: rows.map((row) => ({ provider: row.provider, ...row.email === null ? {} : { email: row.email }, createdAt: row.createdAt })),
|
|
1404
|
+
hasPassword: (userById(userId)?.passwordHash ?? null) !== null
|
|
1405
|
+
};
|
|
1406
|
+
},
|
|
1407
|
+
async removeIdentity(args) {
|
|
1408
|
+
const { userId, provider } = args;
|
|
1409
|
+
const checked = await confirmAccount(userId, args);
|
|
1410
|
+
if (checked === "limited")
|
|
1411
|
+
return { status: "limited" };
|
|
1412
|
+
if (typeof checked === "string")
|
|
1413
|
+
return { status: "refused", reason: checked };
|
|
1414
|
+
return transaction(() => {
|
|
1415
|
+
const stale = stillConfirmed(userId, checked);
|
|
1416
|
+
if (stale !== undefined)
|
|
1417
|
+
return { status: "refused", reason: stale };
|
|
1418
|
+
const counts = db.prepare("SELECT SUM(provider = ?) AS mine, SUM(provider <> ?) AS others FROM user_identities WHERE user_id = ?").get(provider, provider, userId);
|
|
1419
|
+
if ((counts.mine ?? 0) === 0)
|
|
1420
|
+
return { status: "refused", reason: "not_found" };
|
|
1421
|
+
if ((counts.others ?? 0) === 0 && (userById(userId)?.passwordHash ?? null) === null) {
|
|
1422
|
+
return { status: "refused", reason: "last_method" };
|
|
1423
|
+
}
|
|
1424
|
+
const grants = provider === "apple" ? appleGrants(userId) : [];
|
|
1425
|
+
db.prepare("DELETE FROM user_identities WHERE user_id = ? AND provider = ?").run(userId, provider);
|
|
1426
|
+
return { status: "ok", appleGrants: grants };
|
|
1427
|
+
});
|
|
1428
|
+
},
|
|
789
1429
|
revokeAllSessions(userId) {
|
|
790
1430
|
db.exec("BEGIN");
|
|
791
1431
|
try {
|
|
@@ -923,14 +1563,14 @@ async function openAccounts(options) {
|
|
|
923
1563
|
return settleHostLink(args, "denied");
|
|
924
1564
|
},
|
|
925
1565
|
exchangeHostLinkCode(code, codeVerifier) {
|
|
926
|
-
const
|
|
1566
|
+
const codeHash2 = sha256Hex(code);
|
|
927
1567
|
const at = now();
|
|
928
1568
|
db.exec("BEGIN IMMEDIATE");
|
|
929
1569
|
try {
|
|
930
1570
|
const row = db.prepare(`SELECT host_id AS hostId, user_id AS userId, code_challenge AS challenge, auth_code_expires_at AS codeExpiresAt
|
|
931
|
-
FROM host_link_requests WHERE auth_code_hash = ? AND state = 'confirmed'`).get(
|
|
1571
|
+
FROM host_link_requests WHERE auth_code_hash = ? AND state = 'confirmed'`).get(codeHash2);
|
|
932
1572
|
if (row !== undefined)
|
|
933
|
-
db.prepare("DELETE FROM host_link_requests WHERE auth_code_hash = ?").run(
|
|
1573
|
+
db.prepare("DELETE FROM host_link_requests WHERE auth_code_hash = ?").run(codeHash2);
|
|
934
1574
|
const valid = row !== undefined && row.userId !== null && row.challenge !== null && row.codeExpiresAt !== null && row.codeExpiresAt > at && safeEqual(createHash("sha256").update(codeVerifier).digest("base64url"), row.challenge);
|
|
935
1575
|
const result = valid ? { status: "ok", ...mintJoinToken(row.userId, row.hostId) } : { status: "invalid" };
|
|
936
1576
|
db.exec("COMMIT");
|
|
@@ -976,9 +1616,9 @@ async function openAccounts(options) {
|
|
|
976
1616
|
joined_at = excluded.joined_at,
|
|
977
1617
|
last_seen_at = excluded.last_seen_at`).run(hostId, row.userId, noisePublicKey, hostName, at, at);
|
|
978
1618
|
db.prepare("DELETE FROM removed_hosts WHERE host_id = ?").run(hostId);
|
|
979
|
-
const user =
|
|
1619
|
+
const user = userById(row.userId);
|
|
980
1620
|
db.exec("COMMIT");
|
|
981
|
-
return { status: "joined", username: user
|
|
1621
|
+
return { status: "joined", username: user === undefined ? "unknown" : accountUser(user).username };
|
|
982
1622
|
} catch (error) {
|
|
983
1623
|
db.exec("ROLLBACK");
|
|
984
1624
|
throw error;
|
|
@@ -987,20 +1627,22 @@ async function openAccounts(options) {
|
|
|
987
1627
|
leaveHost(hostId) {
|
|
988
1628
|
return Number(db.prepare("DELETE FROM hosts WHERE host_id = ?").run(hostId).changes) > 0;
|
|
989
1629
|
},
|
|
990
|
-
async removeHost(
|
|
1630
|
+
async removeHost(args) {
|
|
1631
|
+
const { userId, hostId } = args;
|
|
991
1632
|
const owned = () => db.prepare("SELECT 1 AS x FROM hosts WHERE host_id = ? AND user_id = ?").get(hostId, userId) !== undefined;
|
|
992
1633
|
if (!owned())
|
|
993
1634
|
return { status: "refused", reason: "not_found" };
|
|
994
|
-
const checked = await
|
|
1635
|
+
const checked = await confirmAccount(userId, args);
|
|
995
1636
|
if (checked === "limited")
|
|
996
1637
|
return { status: "limited" };
|
|
997
|
-
if (checked === "
|
|
998
|
-
return { status: "refused", reason:
|
|
1638
|
+
if (typeof checked === "string")
|
|
1639
|
+
return { status: "refused", reason: checked };
|
|
999
1640
|
db.exec("BEGIN IMMEDIATE");
|
|
1000
1641
|
try {
|
|
1001
|
-
|
|
1642
|
+
const stale = stillConfirmed(userId, checked);
|
|
1643
|
+
if (stale !== undefined) {
|
|
1002
1644
|
db.exec("ROLLBACK");
|
|
1003
|
-
return { status: "refused", reason:
|
|
1645
|
+
return { status: "refused", reason: stale };
|
|
1004
1646
|
}
|
|
1005
1647
|
if (!owned()) {
|
|
1006
1648
|
db.exec("ROLLBACK");
|
|
@@ -1097,22 +1739,24 @@ async function openAccounts(options) {
|
|
|
1097
1739
|
confirmRevocation(hostId, publicKey) {
|
|
1098
1740
|
db.prepare("DELETE FROM pending_revocations WHERE host_id = ? AND public_key = ?").run(hostId, publicKey);
|
|
1099
1741
|
},
|
|
1100
|
-
async kickDevice(
|
|
1742
|
+
async kickDevice(args) {
|
|
1743
|
+
const { userId, deviceId, currentDeviceId } = args;
|
|
1101
1744
|
const owned = () => db.prepare("SELECT 1 AS x FROM account_devices WHERE id = ? AND user_id = ?").get(deviceId, userId) !== undefined;
|
|
1102
1745
|
if (!owned())
|
|
1103
1746
|
return { status: "refused", reason: "not_found" };
|
|
1104
1747
|
if (deviceId === currentDeviceId)
|
|
1105
1748
|
return { status: "refused", reason: "current_device" };
|
|
1106
|
-
const checked = await
|
|
1749
|
+
const checked = await confirmAccount(userId, args);
|
|
1107
1750
|
if (checked === "limited")
|
|
1108
1751
|
return { status: "limited" };
|
|
1109
|
-
if (checked === "
|
|
1110
|
-
return { status: "refused", reason:
|
|
1752
|
+
if (typeof checked === "string")
|
|
1753
|
+
return { status: "refused", reason: checked };
|
|
1111
1754
|
db.exec("BEGIN IMMEDIATE");
|
|
1112
1755
|
try {
|
|
1113
|
-
|
|
1756
|
+
const stale = stillConfirmed(userId, checked);
|
|
1757
|
+
if (stale !== undefined) {
|
|
1114
1758
|
db.exec("ROLLBACK");
|
|
1115
|
-
return { status: "refused", reason:
|
|
1759
|
+
return { status: "refused", reason: stale };
|
|
1116
1760
|
}
|
|
1117
1761
|
if (!owned()) {
|
|
1118
1762
|
db.exec("ROLLBACK");
|
|
@@ -1143,43 +1787,82 @@ function dropCredentials(db, userId, keepTokenHash) {
|
|
|
1143
1787
|
db.prepare("DELETE FROM join_tokens WHERE user_id = ? AND used_at IS NULL").run(userId);
|
|
1144
1788
|
db.prepare("DELETE FROM host_link_requests WHERE user_id = ?").run(userId);
|
|
1145
1789
|
db.prepare("DELETE FROM push_tokens WHERE user_id = ?").run(userId);
|
|
1790
|
+
db.prepare("DELETE FROM email_codes WHERE user_id = ?").run(userId);
|
|
1146
1791
|
}
|
|
1147
|
-
async function
|
|
1148
|
-
|
|
1149
|
-
await
|
|
1150
|
-
const release = await lockDataDir(options.dataDir, { retries: 0 }).catch((error) => {
|
|
1792
|
+
async function withOperatorDatabase(dataDir, write) {
|
|
1793
|
+
await mkdir(dataDir, { recursive: true });
|
|
1794
|
+
const release = await lockDataDir(dataDir, { retries: 0 }).catch((error) => {
|
|
1151
1795
|
if (isDataDirInUse(error))
|
|
1152
1796
|
return;
|
|
1153
1797
|
throw error;
|
|
1154
1798
|
});
|
|
1155
|
-
const db = openDatabase(
|
|
1799
|
+
const db = openDatabase(dataDir);
|
|
1156
1800
|
try {
|
|
1157
1801
|
if (release !== undefined) {
|
|
1158
1802
|
migrate(db);
|
|
1159
1803
|
} else if (schemaVersion(db) !== SCHEMA_VERSION) {
|
|
1160
1804
|
throw new Error(`the running server's database schema (${schemaVersion(db)}) differs from this build's (${SCHEMA_VERSION}); restart the server on this version first`);
|
|
1161
1805
|
}
|
|
1162
|
-
|
|
1163
|
-
db.exec("BEGIN IMMEDIATE");
|
|
1164
|
-
try {
|
|
1165
|
-
const users = options.user === undefined ? db.prepare("SELECT id FROM users").all() : db.prepare("SELECT id FROM users WHERE username = ?").all(options.user);
|
|
1166
|
-
if (users.length === 0)
|
|
1167
|
-
throw new Error(options.user === undefined ? "no account exists yet" : `no such account: ${options.user}`);
|
|
1168
|
-
if (users.length > 1)
|
|
1169
|
-
throw new Error("more than one account exists — pass --user");
|
|
1170
|
-
const userId = users[0].id;
|
|
1171
|
-
db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hash, userId);
|
|
1172
|
-
dropCredentials(db, userId);
|
|
1173
|
-
db.exec("COMMIT");
|
|
1174
|
-
} catch (error) {
|
|
1175
|
-
db.exec("ROLLBACK");
|
|
1176
|
-
throw error;
|
|
1177
|
-
}
|
|
1806
|
+
return await write(db);
|
|
1178
1807
|
} finally {
|
|
1179
1808
|
db.close();
|
|
1180
1809
|
await release?.();
|
|
1181
1810
|
}
|
|
1182
1811
|
}
|
|
1812
|
+
function inTransaction(db, write) {
|
|
1813
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1814
|
+
try {
|
|
1815
|
+
const result = write();
|
|
1816
|
+
db.exec("COMMIT");
|
|
1817
|
+
return result;
|
|
1818
|
+
} catch (error) {
|
|
1819
|
+
db.exec("ROLLBACK");
|
|
1820
|
+
throw error;
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
function operatorTarget(db, user) {
|
|
1824
|
+
const users = user === undefined ? db.prepare("SELECT id FROM users").all() : db.prepare("SELECT id FROM users WHERE email = ? OR username = ?").all(normalizeEmail(user) ?? null, user);
|
|
1825
|
+
if (users.length === 0)
|
|
1826
|
+
throw new Error(user === undefined ? "no account exists yet" : `no such account: ${user}`);
|
|
1827
|
+
if (users.length > 1)
|
|
1828
|
+
throw new Error(user === undefined ? "more than one account exists — pass --user" : `${user} names more than one account`);
|
|
1829
|
+
return users[0].id;
|
|
1830
|
+
}
|
|
1831
|
+
async function resetPassword(options) {
|
|
1832
|
+
assertPasswordPolicy(options.password);
|
|
1833
|
+
await withOperatorDatabase(options.dataDir, async (db) => {
|
|
1834
|
+
const hash = await hashPassword(options.password);
|
|
1835
|
+
inTransaction(db, () => {
|
|
1836
|
+
const userId = operatorTarget(db, options.user);
|
|
1837
|
+
db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hash, userId);
|
|
1838
|
+
dropCredentials(db, userId);
|
|
1839
|
+
});
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
async function addUser(options) {
|
|
1843
|
+
const email = requireEmail(options.email);
|
|
1844
|
+
assertPasswordPolicy(options.password);
|
|
1845
|
+
await withOperatorDatabase(options.dataDir, async (db) => {
|
|
1846
|
+
const hash = await hashPassword(options.password);
|
|
1847
|
+
inTransaction(db, () => {
|
|
1848
|
+
if (db.prepare("SELECT 1 AS x FROM users WHERE email = ?").get(email) !== undefined) {
|
|
1849
|
+
throw new Error(`an account with ${email} already exists`);
|
|
1850
|
+
}
|
|
1851
|
+
const at = Date.now();
|
|
1852
|
+
db.prepare("INSERT INTO users (email, email_verified_at, password_hash, created_at) VALUES (?, ?, ?, ?)").run(email, at, hash, at);
|
|
1853
|
+
});
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
async function setUserEmail(options) {
|
|
1857
|
+
const email = requireEmail(options.email);
|
|
1858
|
+
await withOperatorDatabase(options.dataDir, (db) => inTransaction(db, () => {
|
|
1859
|
+
const userId = operatorTarget(db, options.user);
|
|
1860
|
+
const owner = db.prepare("SELECT id FROM users WHERE email = ?").get(email);
|
|
1861
|
+
if (owner !== undefined && owner.id !== userId)
|
|
1862
|
+
throw new Error(`another account already has ${email}`);
|
|
1863
|
+
db.prepare("UPDATE users SET email = ?, email_verified_at = ? WHERE id = ?").run(email, Date.now(), userId);
|
|
1864
|
+
}));
|
|
1865
|
+
}
|
|
1183
1866
|
|
|
1184
1867
|
// apps/server/src/apns.ts
|
|
1185
1868
|
import { createPrivateKey, sign } from "node:crypto";
|
|
@@ -1405,41 +2088,382 @@ function openApns(config) {
|
|
|
1405
2088
|
};
|
|
1406
2089
|
}
|
|
1407
2090
|
|
|
1408
|
-
// apps/server/src/
|
|
1409
|
-
import {
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
2091
|
+
// apps/server/src/mail.ts
|
|
2092
|
+
import { createTransport } from "nodemailer";
|
|
2093
|
+
function loadMailer(env = process.env) {
|
|
2094
|
+
const url = env.PINOMAD_SERVER_SMTP_URL;
|
|
2095
|
+
const from = env.PINOMAD_SERVER_MAIL_FROM;
|
|
2096
|
+
if (url === undefined && from === undefined)
|
|
2097
|
+
return;
|
|
2098
|
+
if (url === undefined || from === undefined) {
|
|
2099
|
+
throw new Error("PINOMAD_SERVER_SMTP_URL and PINOMAD_SERVER_MAIL_FROM go together");
|
|
2100
|
+
}
|
|
2101
|
+
const transport = createTransport({ url, ...url.startsWith("smtp:") ? { requireTLS: true } : {} });
|
|
2102
|
+
return {
|
|
2103
|
+
async send(to, subject, text) {
|
|
2104
|
+
await transport.sendMail({ from, to, subject, text });
|
|
2105
|
+
}
|
|
2106
|
+
};
|
|
2107
|
+
}
|
|
1413
2108
|
|
|
1414
|
-
// apps/server/src/
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
};
|
|
1424
|
-
var RELAY_LIMIT_OPTIONS = [
|
|
1425
|
-
{ field: "perHost", flag: "relay-max-per-host", env: "PINOMAD_SERVER_RELAY_MAX_PER_HOST", unit: "count" },
|
|
1426
|
-
{ field: "perAccount", flag: "relay-max-per-account", env: "PINOMAD_SERVER_RELAY_MAX_PER_ACCOUNT", unit: "count" },
|
|
1427
|
-
{
|
|
1428
|
-
field: "unauthenticatedPerHost",
|
|
1429
|
-
flag: "relay-max-unauthenticated-per-host",
|
|
1430
|
-
env: "PINOMAD_SERVER_RELAY_MAX_UNAUTHENTICATED_PER_HOST",
|
|
1431
|
-
unit: "count"
|
|
2109
|
+
// apps/server/src/oauth.ts
|
|
2110
|
+
import { createHash as createHash2, createPrivateKey as createPrivateKey2, createPublicKey, sign as sign2, verify } from "node:crypto";
|
|
2111
|
+
var OAUTH_PROVIDERS = ["google", "github", "apple"];
|
|
2112
|
+
var DEFAULT_ENDPOINTS = {
|
|
2113
|
+
google: {
|
|
2114
|
+
authorize: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
2115
|
+
token: "https://oauth2.googleapis.com/token",
|
|
2116
|
+
jwks: "https://www.googleapis.com/oauth2/v3/certs",
|
|
2117
|
+
issuer: "https://accounts.google.com"
|
|
1432
2118
|
},
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
2119
|
+
github: {
|
|
2120
|
+
authorize: "https://github.com/login/oauth/authorize",
|
|
2121
|
+
token: "https://github.com/login/oauth/access_token",
|
|
2122
|
+
user: "https://api.github.com/user",
|
|
2123
|
+
emails: "https://api.github.com/user/emails"
|
|
2124
|
+
},
|
|
2125
|
+
apple: {
|
|
2126
|
+
authorize: "https://appleid.apple.com/auth/authorize",
|
|
2127
|
+
token: "https://appleid.apple.com/auth/token",
|
|
2128
|
+
jwks: "https://appleid.apple.com/auth/keys",
|
|
2129
|
+
issuer: "https://appleid.apple.com",
|
|
2130
|
+
revoke: "https://appleid.apple.com/auth/revoke"
|
|
2131
|
+
}
|
|
2132
|
+
};
|
|
2133
|
+
var GOOGLE_BARE_ISSUER = "accounts.google.com";
|
|
2134
|
+
function loadOAuthConfig(env = process.env) {
|
|
2135
|
+
const read = (name) => {
|
|
2136
|
+
const value = env[`PINOMAD_SERVER_${name}`]?.replace(/\\n/g, `
|
|
2137
|
+
`).trim();
|
|
2138
|
+
return value === "" ? undefined : value;
|
|
2139
|
+
};
|
|
2140
|
+
const all = (provider, names) => {
|
|
2141
|
+
const values = Object.fromEntries(Object.entries(names).map(([key, name]) => [key, read(name)]));
|
|
2142
|
+
const set = Object.values(values).filter((value) => value !== undefined).length;
|
|
2143
|
+
if (set === 0)
|
|
2144
|
+
return;
|
|
2145
|
+
if (set < Object.keys(names).length) {
|
|
2146
|
+
console.error(`oauth: ${Object.values(names).map((name) => `PINOMAD_SERVER_${name}`).join(", ")} must all be set — ${provider} sign-in disabled`);
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2149
|
+
return values;
|
|
2150
|
+
};
|
|
2151
|
+
const google = all("Google", { clientId: "GOOGLE_CLIENT_ID", clientSecret: "GOOGLE_CLIENT_SECRET" });
|
|
2152
|
+
const github = all("GitHub", { clientId: "GITHUB_CLIENT_ID", clientSecret: "GITHUB_CLIENT_SECRET" });
|
|
2153
|
+
const apple = all("Apple", {
|
|
2154
|
+
servicesId: "APPLE_SERVICES_ID",
|
|
2155
|
+
teamId: "APPLE_TEAM_ID",
|
|
2156
|
+
keyId: "APPLE_KEY_ID",
|
|
2157
|
+
privateKey: "APPLE_PRIVATE_KEY"
|
|
2158
|
+
});
|
|
2159
|
+
const bundleId = read("APPLE_BUNDLE_ID");
|
|
2160
|
+
return {
|
|
2161
|
+
...google === undefined ? {} : { google },
|
|
2162
|
+
...github === undefined ? {} : { github },
|
|
2163
|
+
...apple === undefined ? {} : { apple: { ...apple, ...bundleId === undefined ? {} : { bundleId } } }
|
|
2164
|
+
};
|
|
2165
|
+
}
|
|
2166
|
+
var FETCH_TIMEOUT_MS = 1e4;
|
|
2167
|
+
var JWKS_REFETCH_MS = 60000;
|
|
2168
|
+
var APPLE_SECRET_TTL_S = 30 * 24 * 60 * 60;
|
|
2169
|
+
var APPLE_SECRET_RENEW_S = 24 * 60 * 60;
|
|
2170
|
+
var MAX_NAME_CODEPOINTS = 128;
|
|
2171
|
+
var isRecord = (value) => typeof value === "object" && value !== null;
|
|
2172
|
+
var failure = (error) => ({ error });
|
|
2173
|
+
var clipName = (name) => {
|
|
2174
|
+
const clipped = [...(name ?? "").trim()].slice(0, MAX_NAME_CODEPOINTS).join("").trim();
|
|
2175
|
+
return clipped === "" ? undefined : clipped;
|
|
2176
|
+
};
|
|
2177
|
+
var truthy = (value) => value === true || value === "true";
|
|
2178
|
+
async function fetchJson(url, init) {
|
|
2179
|
+
const response = await fetch(url, { ...init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), redirect: "error" });
|
|
2180
|
+
if (!response.ok)
|
|
2181
|
+
throw new Error(`${new URL(url).host} answered ${response.status}`);
|
|
2182
|
+
return await response.json();
|
|
2183
|
+
}
|
|
2184
|
+
var postForm = (url, form, headers = {}) => fetchJson(url, {
|
|
2185
|
+
method: "POST",
|
|
2186
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json", ...headers },
|
|
2187
|
+
body: new URLSearchParams(form).toString()
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
class JwksCache {
|
|
2191
|
+
sets = new Map;
|
|
2192
|
+
now;
|
|
2193
|
+
constructor(now) {
|
|
2194
|
+
this.now = now;
|
|
2195
|
+
}
|
|
2196
|
+
async key(url, kid) {
|
|
2197
|
+
const cached = this.sets.get(url);
|
|
2198
|
+
const known = cached?.keys.get(kid);
|
|
2199
|
+
if (known !== undefined)
|
|
2200
|
+
return known;
|
|
2201
|
+
if (cached !== undefined && this.now() - cached.fetchedAt < JWKS_REFETCH_MS)
|
|
2202
|
+
return;
|
|
2203
|
+
const body = await fetchJson(url, { headers: { accept: "application/json" } });
|
|
2204
|
+
const keys = new Map;
|
|
2205
|
+
for (const jwk of isRecord(body) && Array.isArray(body.keys) ? body.keys : []) {
|
|
2206
|
+
if (!isRecord(jwk) || typeof jwk.kid !== "string" || jwk.kty !== "RSA")
|
|
2207
|
+
continue;
|
|
2208
|
+
try {
|
|
2209
|
+
keys.set(jwk.kid, createPublicKey({ key: jwk, format: "jwk" }));
|
|
2210
|
+
} catch {}
|
|
2211
|
+
}
|
|
2212
|
+
this.sets.set(url, { keys, fetchedAt: this.now() });
|
|
2213
|
+
return keys.get(kid);
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
var decodeSegment = (segment) => JSON.parse(Buffer.from(segment, "base64url").toString("utf8"));
|
|
2217
|
+
async function verifyIdToken(jwks, token, expect) {
|
|
2218
|
+
const parts = token.split(".");
|
|
2219
|
+
if (parts.length !== 3)
|
|
2220
|
+
return;
|
|
2221
|
+
let header, claims;
|
|
2222
|
+
try {
|
|
2223
|
+
header = decodeSegment(parts[0]);
|
|
2224
|
+
claims = decodeSegment(parts[1]);
|
|
2225
|
+
} catch {
|
|
2226
|
+
return;
|
|
2227
|
+
}
|
|
2228
|
+
if (!isRecord(header) || header.alg !== "RS256" || typeof header.kid !== "string" || !isRecord(claims))
|
|
2229
|
+
return;
|
|
2230
|
+
const key = await jwks.key(expect.jwksUrl, header.kid);
|
|
2231
|
+
if (key === undefined)
|
|
2232
|
+
return;
|
|
2233
|
+
const signed = verify("RSA-SHA256", Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(parts[2], "base64url"));
|
|
2234
|
+
if (!signed)
|
|
2235
|
+
return;
|
|
2236
|
+
const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
2237
|
+
if (typeof claims.iss !== "string" || !expect.issuers.includes(claims.iss))
|
|
2238
|
+
return;
|
|
2239
|
+
if (!audience.includes(expect.audience))
|
|
2240
|
+
return;
|
|
2241
|
+
if (typeof claims.exp !== "number" || claims.exp <= expect.nowS)
|
|
2242
|
+
return;
|
|
2243
|
+
if (expect.nonce !== undefined && claims.nonce !== expect.nonce)
|
|
2244
|
+
return;
|
|
2245
|
+
if (typeof claims.sub !== "string" || claims.sub === "")
|
|
2246
|
+
return;
|
|
2247
|
+
return claims;
|
|
2248
|
+
}
|
|
2249
|
+
function tokenEmail(claims, relayCountsVerified) {
|
|
2250
|
+
const email = typeof claims.email === "string" ? normalizeEmail(claims.email) : undefined;
|
|
2251
|
+
if (email === undefined)
|
|
2252
|
+
return { emailVerified: false };
|
|
2253
|
+
const relay = relayCountsVerified && (truthy(claims.is_private_email) || email.endsWith("@privaterelay.appleid.com"));
|
|
2254
|
+
return { email, emailVerified: truthy(claims.email_verified) || relay };
|
|
2255
|
+
}
|
|
2256
|
+
function appleName(user) {
|
|
2257
|
+
if (user === undefined)
|
|
2258
|
+
return;
|
|
2259
|
+
try {
|
|
2260
|
+
const parsed = JSON.parse(user);
|
|
2261
|
+
const name = isRecord(parsed) && isRecord(parsed.name) ? parsed.name : undefined;
|
|
2262
|
+
const parts = [name?.firstName, name?.lastName].filter((part) => typeof part === "string" && part.trim() !== "");
|
|
2263
|
+
return clipName(parts.join(" "));
|
|
2264
|
+
} catch {
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
function createOAuth(config, options = {}) {
|
|
2269
|
+
const now = options.now ?? Date.now;
|
|
2270
|
+
const jwks = new JwksCache(now);
|
|
2271
|
+
const endpoints = (provider) => ({ ...DEFAULT_ENDPOINTS[provider], ...config[provider]?.endpoints });
|
|
2272
|
+
const enabled = OAUTH_PROVIDERS.filter((provider) => config[provider] !== undefined);
|
|
2273
|
+
const nowS = () => Math.floor(now() / 1000);
|
|
2274
|
+
const appleKey = config.apple === undefined ? undefined : createPrivateKey2(config.apple.privateKey);
|
|
2275
|
+
const appleSecrets = new Map;
|
|
2276
|
+
const appleSecret = (clientId) => {
|
|
2277
|
+
const apple = config.apple;
|
|
2278
|
+
const cached = appleSecrets.get(clientId);
|
|
2279
|
+
if (cached !== undefined && cached.expiresAt - nowS() > APPLE_SECRET_RENEW_S)
|
|
2280
|
+
return cached.secret;
|
|
2281
|
+
const iat = nowS();
|
|
2282
|
+
const header = Buffer.from(JSON.stringify({ alg: "ES256", kid: apple.keyId })).toString("base64url");
|
|
2283
|
+
const payload = Buffer.from(JSON.stringify({ iss: apple.teamId, iat, exp: iat + APPLE_SECRET_TTL_S, aud: endpoints("apple").issuer, sub: clientId })).toString("base64url");
|
|
2284
|
+
const signature = sign2("sha256", Buffer.from(`${header}.${payload}`), { key: appleKey, dsaEncoding: "ieee-p1363" });
|
|
2285
|
+
const secret = `${header}.${payload}.${signature.toString("base64url")}`;
|
|
2286
|
+
appleSecrets.set(clientId, { secret, expiresAt: iat + APPLE_SECRET_TTL_S });
|
|
2287
|
+
return secret;
|
|
2288
|
+
};
|
|
2289
|
+
const oidc = async (provider, idToken, audience, nonce) => {
|
|
2290
|
+
const { jwks: jwksUrl, issuer } = endpoints(provider);
|
|
2291
|
+
if (typeof idToken !== "string" || jwksUrl === undefined || issuer === undefined)
|
|
2292
|
+
return failure("exchange_failed");
|
|
2293
|
+
const issuers = provider === "google" && issuer === DEFAULT_ENDPOINTS.google.issuer ? [issuer, GOOGLE_BARE_ISSUER] : [issuer];
|
|
2294
|
+
const claims = await verifyIdToken(jwks, idToken, { jwksUrl, issuers, audience, nowS: nowS(), ...nonce === undefined ? {} : { nonce } });
|
|
2295
|
+
return claims === undefined ? failure("invalid_token") : { claims };
|
|
2296
|
+
};
|
|
2297
|
+
const exchangeGoogle = async (args) => {
|
|
2298
|
+
const google = config.google;
|
|
2299
|
+
const body = await postForm(endpoints("google").token, {
|
|
2300
|
+
grant_type: "authorization_code",
|
|
2301
|
+
code: args.code,
|
|
2302
|
+
client_id: google.clientId,
|
|
2303
|
+
client_secret: google.clientSecret,
|
|
2304
|
+
redirect_uri: args.redirectUri,
|
|
2305
|
+
code_verifier: args.verifier
|
|
2306
|
+
});
|
|
2307
|
+
const checked = await oidc("google", isRecord(body) ? body.id_token : undefined, google.clientId, args.nonce);
|
|
2308
|
+
if ("error" in checked)
|
|
2309
|
+
return checked;
|
|
2310
|
+
const { claims } = checked;
|
|
2311
|
+
const name = clipName(typeof claims.name === "string" ? claims.name : undefined);
|
|
2312
|
+
return { provider: "google", subject: claims.sub, ...tokenEmail(claims, false), ...name === undefined ? {} : { name } };
|
|
2313
|
+
};
|
|
2314
|
+
const exchangeGithub = async (args) => {
|
|
2315
|
+
const github = config.github;
|
|
2316
|
+
const { token, user: userUrl, emails: emailsUrl } = endpoints("github");
|
|
2317
|
+
const body = await postForm(token, {
|
|
2318
|
+
code: args.code,
|
|
2319
|
+
client_id: github.clientId,
|
|
2320
|
+
client_secret: github.clientSecret,
|
|
2321
|
+
redirect_uri: args.redirectUri,
|
|
2322
|
+
code_verifier: args.verifier
|
|
2323
|
+
});
|
|
2324
|
+
const accessToken = isRecord(body) && typeof body.access_token === "string" ? body.access_token : undefined;
|
|
2325
|
+
if (accessToken === undefined || userUrl === undefined || emailsUrl === undefined)
|
|
2326
|
+
return failure("exchange_failed");
|
|
2327
|
+
const headers = { authorization: `Bearer ${accessToken}`, accept: "application/vnd.github+json", "user-agent": "pinomad-server" };
|
|
2328
|
+
const [user, emails] = await Promise.all([fetchJson(userUrl, { headers }), fetchJson(emailsUrl, { headers })]);
|
|
2329
|
+
if (!isRecord(user) || typeof user.id !== "number" || !Number.isSafeInteger(user.id))
|
|
2330
|
+
return failure("exchange_failed");
|
|
2331
|
+
const primary = Array.isArray(emails) ? emails.find((entry) => isRecord(entry) && entry.primary === true) : undefined;
|
|
2332
|
+
const email = isRecord(primary) && typeof primary.email === "string" ? normalizeEmail(primary.email) : undefined;
|
|
2333
|
+
const name = clipName(typeof user.name === "string" ? user.name : undefined);
|
|
2334
|
+
return {
|
|
2335
|
+
provider: "github",
|
|
2336
|
+
subject: String(user.id),
|
|
2337
|
+
...email === undefined ? {} : { email },
|
|
2338
|
+
emailVerified: email !== undefined && isRecord(primary) && primary.verified === true,
|
|
2339
|
+
...name === undefined ? {} : { name }
|
|
2340
|
+
};
|
|
2341
|
+
};
|
|
2342
|
+
const exchangeApple = async (args) => {
|
|
2343
|
+
const body = await postForm(endpoints("apple").token, {
|
|
2344
|
+
grant_type: "authorization_code",
|
|
2345
|
+
code: args.code,
|
|
2346
|
+
client_id: args.clientId,
|
|
2347
|
+
client_secret: appleSecret(args.clientId),
|
|
2348
|
+
...args.redirectUri === undefined ? {} : { redirect_uri: args.redirectUri }
|
|
2349
|
+
});
|
|
2350
|
+
if (!isRecord(body))
|
|
2351
|
+
return failure("exchange_failed");
|
|
2352
|
+
const checked = await oidc("apple", body.id_token, args.clientId, args.nonce);
|
|
2353
|
+
if ("error" in checked)
|
|
2354
|
+
return checked;
|
|
2355
|
+
const { claims } = checked;
|
|
2356
|
+
return {
|
|
2357
|
+
provider: "apple",
|
|
2358
|
+
subject: claims.sub,
|
|
2359
|
+
...tokenEmail(claims, true),
|
|
2360
|
+
...args.name === undefined ? {} : { name: args.name },
|
|
2361
|
+
...typeof body.refresh_token === "string" ? { refreshToken: body.refresh_token, refreshClientId: args.clientId } : {}
|
|
2362
|
+
};
|
|
2363
|
+
};
|
|
2364
|
+
const guarded = async (run, provider) => {
|
|
2365
|
+
try {
|
|
2366
|
+
return await run();
|
|
2367
|
+
} catch (error) {
|
|
2368
|
+
console.error(JSON.stringify({ event: "oauth_failed", provider, error: error instanceof Error ? error.message : String(error) }));
|
|
2369
|
+
return failure("exchange_failed");
|
|
2370
|
+
}
|
|
2371
|
+
};
|
|
2372
|
+
return {
|
|
2373
|
+
enabled,
|
|
2374
|
+
appleNative: config.apple?.bundleId !== undefined,
|
|
2375
|
+
authorizeUrl(provider, { state, nonce, codeChallenge, redirectUri }) {
|
|
2376
|
+
const url = new URL(endpoints(provider).authorize);
|
|
2377
|
+
const params = provider === "google" ? {
|
|
2378
|
+
client_id: config.google.clientId,
|
|
2379
|
+
scope: "openid email profile",
|
|
2380
|
+
nonce,
|
|
2381
|
+
code_challenge: codeChallenge,
|
|
2382
|
+
code_challenge_method: "S256"
|
|
2383
|
+
} : provider === "github" ? { client_id: config.github.clientId, scope: "read:user user:email", code_challenge: codeChallenge, code_challenge_method: "S256" } : { client_id: config.apple.servicesId, scope: "name email", response_mode: "form_post", nonce };
|
|
2384
|
+
for (const [key, value] of Object.entries({ response_type: "code", redirect_uri: redirectUri, state, ...params })) {
|
|
2385
|
+
url.searchParams.set(key, value);
|
|
2386
|
+
}
|
|
2387
|
+
return url.toString();
|
|
2388
|
+
},
|
|
2389
|
+
exchange(provider, args) {
|
|
2390
|
+
return guarded(() => {
|
|
2391
|
+
if (provider === "google")
|
|
2392
|
+
return exchangeGoogle(args);
|
|
2393
|
+
if (provider === "github")
|
|
2394
|
+
return exchangeGithub(args);
|
|
2395
|
+
return exchangeApple({ code: args.code, clientId: config.apple.servicesId, redirectUri: args.redirectUri, nonce: args.nonce, name: appleName(args.user) });
|
|
2396
|
+
}, provider);
|
|
2397
|
+
},
|
|
2398
|
+
verifyAppleNative({ identityToken, authorizationCode, nonce, name }) {
|
|
2399
|
+
return guarded(async () => {
|
|
2400
|
+
const bundleId = config.apple?.bundleId;
|
|
2401
|
+
if (bundleId === undefined)
|
|
2402
|
+
return failure("exchange_failed");
|
|
2403
|
+
const hashed = nonce === undefined ? undefined : createHash2("sha256").update(nonce).digest("hex");
|
|
2404
|
+
const checked = await oidc("apple", identityToken, bundleId, hashed);
|
|
2405
|
+
if ("error" in checked)
|
|
2406
|
+
return checked;
|
|
2407
|
+
const traded = await exchangeApple({ code: authorizationCode, clientId: bundleId, nonce: undefined, name: clipName(name) });
|
|
2408
|
+
if ("error" in traded)
|
|
2409
|
+
return traded;
|
|
2410
|
+
return traded.subject === checked.claims.sub ? traded : failure("invalid_token");
|
|
2411
|
+
}, "apple");
|
|
2412
|
+
},
|
|
2413
|
+
revokeApple(refreshToken, clientId) {
|
|
2414
|
+
const revoke = endpoints("apple").revoke;
|
|
2415
|
+
if (config.apple === undefined || revoke === undefined)
|
|
2416
|
+
return;
|
|
2417
|
+
fetch(revoke, {
|
|
2418
|
+
method: "POST",
|
|
2419
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
2420
|
+
body: new URLSearchParams({ client_id: clientId, client_secret: appleSecret(clientId), token: refreshToken, token_type_hint: "refresh_token" }).toString(),
|
|
2421
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
2422
|
+
}).then((response) => {
|
|
2423
|
+
if (!response.ok)
|
|
2424
|
+
throw new Error(`apple answered ${response.status}`);
|
|
2425
|
+
}).catch((error) => {
|
|
2426
|
+
console.error(JSON.stringify({ event: "apple_revoke_failed", error: error instanceof Error ? error.message : String(error) }));
|
|
2427
|
+
});
|
|
2428
|
+
}
|
|
2429
|
+
};
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
// apps/server/src/relay.ts
|
|
2433
|
+
import { randomBytes as randomBytes3 } from "node:crypto";
|
|
2434
|
+
import { once } from "node:events";
|
|
2435
|
+
import { createServer } from "node:http";
|
|
2436
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
2437
|
+
|
|
2438
|
+
// apps/server/src/relay-limits.ts
|
|
2439
|
+
var DEFAULT_RELAY_LIMITS = {
|
|
2440
|
+
perHost: 64,
|
|
2441
|
+
perAccount: 256,
|
|
2442
|
+
unauthenticatedPerHost: 32,
|
|
2443
|
+
handshakeTimeoutMs: 30000,
|
|
2444
|
+
handshakeBytes: 64 * 1024,
|
|
2445
|
+
accountBytesPerSecond: 4 * 1024 * 1024,
|
|
2446
|
+
accountBurstBytes: 1024 * 1024 * 1024
|
|
2447
|
+
};
|
|
2448
|
+
var RELAY_LIMIT_OPTIONS = [
|
|
2449
|
+
{ field: "perHost", flag: "relay-max-per-host", env: "PINOMAD_SERVER_RELAY_MAX_PER_HOST", unit: "count" },
|
|
2450
|
+
{ field: "perAccount", flag: "relay-max-per-account", env: "PINOMAD_SERVER_RELAY_MAX_PER_ACCOUNT", unit: "count" },
|
|
2451
|
+
{
|
|
2452
|
+
field: "unauthenticatedPerHost",
|
|
2453
|
+
flag: "relay-max-unauthenticated-per-host",
|
|
2454
|
+
env: "PINOMAD_SERVER_RELAY_MAX_UNAUTHENTICATED_PER_HOST",
|
|
2455
|
+
unit: "count"
|
|
2456
|
+
},
|
|
2457
|
+
{ field: "handshakeTimeoutMs", flag: "relay-handshake-timeout", env: "PINOMAD_SERVER_RELAY_HANDSHAKE_TIMEOUT", unit: "seconds" },
|
|
2458
|
+
{ field: "handshakeBytes", flag: "relay-handshake-bytes", env: "PINOMAD_SERVER_RELAY_HANDSHAKE_BYTES", unit: "bytes" },
|
|
2459
|
+
{ field: "accountBytesPerSecond", flag: "relay-account-rate", env: "PINOMAD_SERVER_RELAY_ACCOUNT_RATE", unit: "bytes" },
|
|
2460
|
+
{ field: "accountBurstBytes", flag: "relay-account-burst", env: "PINOMAD_SERVER_RELAY_ACCOUNT_BURST", unit: "bytes" }
|
|
2461
|
+
];
|
|
2462
|
+
var BYTE_SUFFIX = { "": 1, k: 1024, m: 1024 ** 2, g: 1024 ** 3 };
|
|
2463
|
+
function readRelayLimits(flags, env) {
|
|
2464
|
+
const limits = {};
|
|
2465
|
+
for (const option of RELAY_LIMIT_OPTIONS) {
|
|
2466
|
+
const flag = flags[option.flag];
|
|
1443
2467
|
const raw = typeof flag === "string" ? flag : env[option.env];
|
|
1444
2468
|
if (raw === undefined)
|
|
1445
2469
|
continue;
|
|
@@ -1539,12 +2563,14 @@ function createRelayLimiter(limits, now = Date.now) {
|
|
|
1539
2563
|
}
|
|
1540
2564
|
|
|
1541
2565
|
// apps/server/src/api.ts
|
|
2566
|
+
import { createHash as createHash3, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
|
|
1542
2567
|
var SESSION_COOKIE = "__Host-pinomad-session";
|
|
1543
2568
|
var HOST_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
1544
2569
|
var PKCE_CHALLENGE = /^[A-Za-z0-9_-]{43}$/;
|
|
1545
2570
|
var PKCE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/;
|
|
1546
2571
|
var LOOPBACK_STATE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
1547
2572
|
var MAX_REDIRECT_URI = 256;
|
|
2573
|
+
var LINK_TICKET = /^[A-Za-z0-9_-]{43}$/;
|
|
1548
2574
|
function parseLoopback(value) {
|
|
1549
2575
|
const { redirectUri, codeChallenge, state } = value;
|
|
1550
2576
|
if (typeof redirectUri !== "string" || redirectUri.length > MAX_REDIRECT_URI)
|
|
@@ -1562,7 +2588,50 @@ function parseLoopback(value) {
|
|
|
1562
2588
|
const loopbackOnly = url.protocol === "http:" && url.hostname === "127.0.0.1" && url.port !== "" && url.username === "" && url.password === "" && url.search === "" && url.hash === "" && url.href === redirectUri;
|
|
1563
2589
|
return loopbackOnly ? { redirectUri, codeChallenge, state } : null;
|
|
1564
2590
|
}
|
|
2591
|
+
function parseNativeRedirect(redirectUri, codeChallenge, state) {
|
|
2592
|
+
if (redirectUri === null || codeChallenge === null || state === null)
|
|
2593
|
+
return null;
|
|
2594
|
+
if (!PKCE_CHALLENGE.test(codeChallenge) || !LOOPBACK_STATE.test(state))
|
|
2595
|
+
return null;
|
|
2596
|
+
const loopback = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})(?:\/|\/callback)?$/.exec(redirectUri);
|
|
2597
|
+
const allowed = redirectUri === "pinomad://auth" || loopback !== null && Number(loopback[1]) <= 65535;
|
|
2598
|
+
return allowed ? { redirectUri, codeChallenge, state } : null;
|
|
2599
|
+
}
|
|
2600
|
+
var withParams = (redirectUri, params) => {
|
|
2601
|
+
const url = new URL(redirectUri);
|
|
2602
|
+
for (const [key, value] of Object.entries(params))
|
|
2603
|
+
url.searchParams.set(key, value);
|
|
2604
|
+
return url.toString();
|
|
2605
|
+
};
|
|
1565
2606
|
var MAX_BODY = 8 * 1024;
|
|
2607
|
+
var MAX_CODE_LENGTH = 32;
|
|
2608
|
+
var ANONYMOUS_ACCOUNT_ROUTES = new Set([
|
|
2609
|
+
"/api/login",
|
|
2610
|
+
"/api/register/code",
|
|
2611
|
+
"/api/register",
|
|
2612
|
+
"/api/password/reset/code",
|
|
2613
|
+
"/api/password/reset"
|
|
2614
|
+
]);
|
|
2615
|
+
var bodyEmail = (value) => {
|
|
2616
|
+
const email = value?.email;
|
|
2617
|
+
return typeof email === "string" && email.length <= 1024 ? normalizeEmail(email) : undefined;
|
|
2618
|
+
};
|
|
2619
|
+
var bodyCode = (value) => {
|
|
2620
|
+
const code = value?.code;
|
|
2621
|
+
return typeof code === "string" && code.length <= MAX_CODE_LENGTH ? code : undefined;
|
|
2622
|
+
};
|
|
2623
|
+
var OAUTH_TOKEN_ROUTE = /^\/api\/oauth\/(?:token|apple\/native|pending\/[A-Za-z0-9_-]{43}\/(?:create|link))$/;
|
|
2624
|
+
function bodyConfirmation(value, token) {
|
|
2625
|
+
const { password, reauth } = typeof value === "object" && value !== null ? value : {};
|
|
2626
|
+
if (typeof reauth === "string" && password === undefined)
|
|
2627
|
+
return reauth.length <= 256 ? { reauth, token } : undefined;
|
|
2628
|
+
if (typeof password === "string" && reauth === undefined)
|
|
2629
|
+
return [...password].length <= MAX_PASSWORD_CODEPOINTS ? { password } : undefined;
|
|
2630
|
+
return;
|
|
2631
|
+
}
|
|
2632
|
+
var sha256Hex2 = (value) => createHash3("sha256").update(value, "utf8").digest("hex");
|
|
2633
|
+
var pkceChallenge = (verifier) => createHash3("sha256").update(verifier).digest("base64url");
|
|
2634
|
+
var sameHash = (a, b) => a.length === b.length && timingSafeEqual2(Buffer.from(a), Buffer.from(b));
|
|
1566
2635
|
var MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
1567
2636
|
var DESKTOP_ORIGIN = "app://pinomad";
|
|
1568
2637
|
var isLoopback = (address) => address !== undefined && (address.startsWith("127.") || address === "::1" || address.startsWith("::ffff:127."));
|
|
@@ -1609,23 +2678,53 @@ async function readJson(request, optional = false) {
|
|
|
1609
2678
|
return { ok: false, tooLarge: false };
|
|
1610
2679
|
}
|
|
1611
2680
|
}
|
|
1612
|
-
function
|
|
1613
|
-
const
|
|
1614
|
-
if (
|
|
1615
|
-
return
|
|
2681
|
+
async function authedJson(accounts, request, token, optional = false) {
|
|
2682
|
+
const body = await readJson(request, optional);
|
|
2683
|
+
if (!body.ok)
|
|
2684
|
+
return { ok: false, status: body.tooLarge ? 413 : 400, error: body.tooLarge ? "payload_too_large" : "invalid_json" };
|
|
2685
|
+
const session = accounts.authenticate(token);
|
|
2686
|
+
if (session === undefined)
|
|
2687
|
+
return { ok: false, status: 401, error: "unauthenticated" };
|
|
2688
|
+
return { ok: true, value: body.value, session };
|
|
2689
|
+
}
|
|
2690
|
+
function cookie(request, name) {
|
|
1616
2691
|
const header = request.headers.cookie;
|
|
1617
2692
|
if (header === undefined)
|
|
1618
2693
|
return;
|
|
1619
2694
|
for (const part of header.split(";")) {
|
|
1620
2695
|
const eq = part.indexOf("=");
|
|
1621
|
-
if (eq > 0 && part.slice(0, eq).trim() ===
|
|
2696
|
+
if (eq > 0 && part.slice(0, eq).trim() === name)
|
|
1622
2697
|
return part.slice(eq + 1).trim();
|
|
1623
2698
|
}
|
|
1624
2699
|
return;
|
|
1625
2700
|
}
|
|
2701
|
+
function sessionToken(request) {
|
|
2702
|
+
const authorization = request.headers.authorization;
|
|
2703
|
+
if (authorization !== undefined && authorization.startsWith("Bearer "))
|
|
2704
|
+
return authorization.slice("Bearer ".length);
|
|
2705
|
+
return cookie(request, SESSION_COOKIE);
|
|
2706
|
+
}
|
|
1626
2707
|
var cookieAttributes = "Path=/; Secure; HttpOnly; SameSite=Strict";
|
|
1627
2708
|
var setSessionCookie = (token) => `${SESSION_COOKIE}=${token}; ${cookieAttributes}`;
|
|
1628
2709
|
var clearSessionCookie = `${SESSION_COOKIE}=; ${cookieAttributes}; Max-Age=0`;
|
|
2710
|
+
var BINDING_COOKIE = "__Host-pinomad-oauth";
|
|
2711
|
+
var bindingAttributes = "Path=/; Secure; HttpOnly; SameSite=None";
|
|
2712
|
+
var setBindingCookie = (value) => `${BINDING_COOKIE}=${value}; ${bindingAttributes}; Max-Age=600`;
|
|
2713
|
+
var clearBindingCookie = `${BINDING_COOKIE}=; ${bindingAttributes}; Max-Age=0`;
|
|
2714
|
+
var bound = (request, bindingHash) => {
|
|
2715
|
+
const value = cookie(request, BINDING_COOKIE);
|
|
2716
|
+
return value !== undefined && bindingHash !== undefined && sameHash(sha256Hex2(value), bindingHash);
|
|
2717
|
+
};
|
|
2718
|
+
async function readForm(request) {
|
|
2719
|
+
const chunks = [];
|
|
2720
|
+
let size = 0;
|
|
2721
|
+
for await (const chunk of request) {
|
|
2722
|
+
size += chunk.length;
|
|
2723
|
+
if (size <= MAX_BODY)
|
|
2724
|
+
chunks.push(chunk);
|
|
2725
|
+
}
|
|
2726
|
+
return size > MAX_BODY ? undefined : new URLSearchParams(Buffer.concat(chunks).toString("utf8"));
|
|
2727
|
+
}
|
|
1629
2728
|
async function handleApiRequest(request, response, ctx) {
|
|
1630
2729
|
const { accounts } = ctx;
|
|
1631
2730
|
const origin = request.headers.origin;
|
|
@@ -1639,6 +2738,26 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
1639
2738
|
const cors = origin === DESKTOP_ORIGIN ? { "access-control-allow-origin": DESKTOP_ORIGIN, vary: "Origin" } : {};
|
|
1640
2739
|
const send = (status, body, headers) => respond(response, status, body, { ...cors, ...headers });
|
|
1641
2740
|
const deny = (status, error, headers) => send(status, { error }, headers);
|
|
2741
|
+
const profile = (user) => ({
|
|
2742
|
+
user,
|
|
2743
|
+
...user.email === undefined && accounts.emailEnabled ? { needsEmail: true } : {}
|
|
2744
|
+
});
|
|
2745
|
+
const signedIn = (session) => {
|
|
2746
|
+
const cookieMode = origin === ctx.publicOrigin;
|
|
2747
|
+
send(200, { ...profile(session.user), expiresAt: session.expiresAt, ...cookieMode ? {} : { token: session.token } }, cookieMode ? { "set-cookie": setSessionCookie(session.token) } : undefined);
|
|
2748
|
+
};
|
|
2749
|
+
const codeSent = (result, closed) => {
|
|
2750
|
+
if (result === "limited")
|
|
2751
|
+
deny(429, "rate_limited");
|
|
2752
|
+
else if (result === "closed")
|
|
2753
|
+
deny(403, closed);
|
|
2754
|
+
else if (result === "taken")
|
|
2755
|
+
deny(409, "email_taken");
|
|
2756
|
+
else if (result === "wrong_password" || result === "invalid_reauth")
|
|
2757
|
+
deny(403, result);
|
|
2758
|
+
else
|
|
2759
|
+
send(202);
|
|
2760
|
+
};
|
|
1642
2761
|
if (!secureTransport(request, ctx.trustProxy)) {
|
|
1643
2762
|
deny(403, "https_required");
|
|
1644
2763
|
return;
|
|
@@ -1651,9 +2770,10 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
1651
2770
|
return;
|
|
1652
2771
|
}
|
|
1653
2772
|
const bearer = (request.headers.authorization ?? "").startsWith("Bearer ");
|
|
1654
|
-
const loginTokenMode = (pathname
|
|
2773
|
+
const loginTokenMode = (ANONYMOUS_ACCOUNT_ROUTES.has(pathname) || OAUTH_TOKEN_ROUTE.test(pathname)) && (origin === undefined || origin === DESKTOP_ORIGIN);
|
|
2774
|
+
const appleFormPost = pathname === "/api/oauth/apple/callback";
|
|
1655
2775
|
const hostLinkHostMode = (pathname === "/api/host-link/start" || pathname === "/api/host-link/poll" || pathname === "/api/host-link/token") && origin === undefined;
|
|
1656
|
-
if (MUTATING.has(method) && !bearer && !loginTokenMode && !hostLinkHostMode && origin !== ctx.publicOrigin) {
|
|
2776
|
+
if (MUTATING.has(method) && !bearer && !loginTokenMode && !hostLinkHostMode && !appleFormPost && origin !== ctx.publicOrigin) {
|
|
1657
2777
|
deny(403, "origin_mismatch");
|
|
1658
2778
|
return;
|
|
1659
2779
|
}
|
|
@@ -1687,50 +2807,562 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
1687
2807
|
deny(401, "invalid_credentials");
|
|
1688
2808
|
return;
|
|
1689
2809
|
}
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
2810
|
+
signedIn(result);
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
if (pathname === "/api/auth/methods") {
|
|
2814
|
+
if (method !== "GET") {
|
|
2815
|
+
deny(405, "method_not_allowed", { allow: "GET" });
|
|
2816
|
+
return;
|
|
2817
|
+
}
|
|
2818
|
+
send(200, { registration: accounts.registrationOpen, email: accounts.emailEnabled, providers: ctx.oauth?.enabled ?? [] });
|
|
2819
|
+
return;
|
|
2820
|
+
}
|
|
2821
|
+
const oauth = ctx.oauth;
|
|
2822
|
+
const enabledProvider = (name) => oauth?.enabled.find((provider) => provider === name);
|
|
2823
|
+
const callbackUri = (provider) => `${ctx.publicOrigin}/api/oauth/${provider}/callback`;
|
|
2824
|
+
const redirect = (to, cookies = []) => {
|
|
2825
|
+
response.writeHead(302, { location: to, ...cookies.length === 0 ? {} : { "set-cookie": [...cookies] } });
|
|
2826
|
+
response.end();
|
|
2827
|
+
};
|
|
2828
|
+
const toWebClient = (param, value, cookies) => redirect(`${ctx.publicOrigin}/?${new URLSearchParams({ [param]: value })}`, cookies);
|
|
2829
|
+
const nativeCode = (userId, native) => withParams(native.redirectUri, { code: accounts.issueOAuthCode(userId, native.codeChallenge), state: native.state });
|
|
2830
|
+
const pendingRefused = (result) => {
|
|
2831
|
+
if (result.status === "limited") {
|
|
2832
|
+
deny(429, "rate_limited");
|
|
2833
|
+
return;
|
|
2834
|
+
}
|
|
2835
|
+
const status = {
|
|
2836
|
+
not_found: 404,
|
|
2837
|
+
registration_closed: 403,
|
|
2838
|
+
no_verified_email: 400,
|
|
2839
|
+
email_taken: 409,
|
|
2840
|
+
identity_taken: 409,
|
|
2841
|
+
password_required: 400,
|
|
2842
|
+
weak_password: 400
|
|
2843
|
+
}[result.reason];
|
|
2844
|
+
deny(status, result.reason);
|
|
2845
|
+
};
|
|
2846
|
+
const providerRoute = /^\/api\/oauth\/([a-z]+)\/(start|callback)$/.exec(pathname);
|
|
2847
|
+
if (providerRoute !== null) {
|
|
2848
|
+
const provider = enabledProvider(providerRoute[1]);
|
|
2849
|
+
const expected = providerRoute[2] === "callback" && provider === "apple" ? "POST" : "GET";
|
|
2850
|
+
if (provider === undefined) {
|
|
2851
|
+
deny(404, "not_found");
|
|
2852
|
+
return;
|
|
2853
|
+
}
|
|
2854
|
+
if (method !== expected) {
|
|
2855
|
+
deny(405, "method_not_allowed", { allow: expected });
|
|
2856
|
+
return;
|
|
2857
|
+
}
|
|
2858
|
+
if (providerRoute[2] === "start") {
|
|
2859
|
+
const query = new URL(request.url ?? "/", "http://server.invalid").searchParams;
|
|
2860
|
+
const ticket = query.get("link");
|
|
2861
|
+
if (ticket !== null) {
|
|
2862
|
+
const binding2 = randomBytes2(32).toString("base64url");
|
|
2863
|
+
const flow3 = LINK_TICKET.test(ticket) ? accounts.startNativeLink({ provider, ticket, bindingHash: sha256Hex2(binding2), ip: clientIp(request, ctx.trustProxy) }) : undefined;
|
|
2864
|
+
if (flow3 === undefined)
|
|
2865
|
+
toWebClient("oauth_error", "invalid_state");
|
|
2866
|
+
else if (flow3.status === "limited")
|
|
2867
|
+
deny(429, "rate_limited");
|
|
2868
|
+
else {
|
|
2869
|
+
const to = oauth.authorizeUrl(provider, {
|
|
2870
|
+
state: flow3.state,
|
|
2871
|
+
nonce: flow3.nonce,
|
|
2872
|
+
codeChallenge: pkceChallenge(flow3.verifier),
|
|
2873
|
+
redirectUri: callbackUri(provider)
|
|
2874
|
+
});
|
|
2875
|
+
redirect(to, [setBindingCookie(binding2)]);
|
|
2876
|
+
}
|
|
2877
|
+
return;
|
|
2878
|
+
}
|
|
2879
|
+
const client = query.get("client") ?? "web";
|
|
2880
|
+
const native = client === "native" ? parseNativeRedirect(query.get("redirect_uri"), query.get("code_challenge"), query.get("state")) : undefined;
|
|
2881
|
+
if (client !== "web" && client !== "native" || native === null) {
|
|
2882
|
+
deny(400, "invalid_request");
|
|
2883
|
+
return;
|
|
2884
|
+
}
|
|
2885
|
+
const binding = randomBytes2(32).toString("base64url");
|
|
2886
|
+
const flow2 = accounts.startOAuthFlow({
|
|
2887
|
+
provider,
|
|
2888
|
+
kind: native === undefined ? "web" : "native",
|
|
2889
|
+
ip: clientIp(request, ctx.trustProxy),
|
|
2890
|
+
bindingHash: sha256Hex2(binding),
|
|
2891
|
+
...native === undefined ? {} : { native }
|
|
2892
|
+
});
|
|
2893
|
+
if (flow2.status === "limited")
|
|
2894
|
+
deny(429, "rate_limited");
|
|
2895
|
+
else if (flow2.status === "busy")
|
|
2896
|
+
deny(503, "busy");
|
|
2897
|
+
else {
|
|
2898
|
+
const to = oauth.authorizeUrl(provider, {
|
|
2899
|
+
state: flow2.state,
|
|
2900
|
+
nonce: flow2.nonce,
|
|
2901
|
+
codeChallenge: pkceChallenge(flow2.verifier),
|
|
2902
|
+
redirectUri: callbackUri(provider)
|
|
2903
|
+
});
|
|
2904
|
+
redirect(to, [setBindingCookie(binding)]);
|
|
2905
|
+
}
|
|
2906
|
+
return;
|
|
2907
|
+
}
|
|
2908
|
+
const params = method === "POST" ? await readForm(request) : new URL(request.url ?? "/", "http://server.invalid").searchParams;
|
|
2909
|
+
const state = params?.get("state");
|
|
2910
|
+
const flow = state === null || state === undefined || state.length > 256 ? undefined : accounts.takeOAuthFlow(provider, state);
|
|
2911
|
+
if (flow === undefined) {
|
|
2912
|
+
toWebClient("oauth_error", "invalid_state");
|
|
2913
|
+
return;
|
|
2914
|
+
}
|
|
2915
|
+
const fail = (reason) => {
|
|
2916
|
+
if (flow.native === undefined)
|
|
2917
|
+
toWebClient("oauth_error", reason);
|
|
2918
|
+
else
|
|
2919
|
+
redirect(withParams(flow.native.redirectUri, { error: reason, state: flow.native.state }));
|
|
2920
|
+
};
|
|
2921
|
+
if (!bound(request, flow.bindingHash)) {
|
|
2922
|
+
fail("invalid_state");
|
|
2923
|
+
return;
|
|
2924
|
+
}
|
|
2925
|
+
const code = params.get("code");
|
|
2926
|
+
if (params.get("error") !== null || code === null) {
|
|
2927
|
+
fail(params.get("error") === null ? "invalid_request" : "access_denied");
|
|
2928
|
+
return;
|
|
2929
|
+
}
|
|
2930
|
+
const identity = await oauth.exchange(provider, {
|
|
2931
|
+
code,
|
|
2932
|
+
verifier: flow.verifier,
|
|
2933
|
+
nonce: flow.nonce,
|
|
2934
|
+
redirectUri: callbackUri(provider),
|
|
2935
|
+
...params.get("user") === null ? {} : { user: params.get("user") }
|
|
2936
|
+
});
|
|
2937
|
+
if ("error" in identity) {
|
|
2938
|
+
fail(identity.error);
|
|
2939
|
+
return;
|
|
2940
|
+
}
|
|
2941
|
+
if (flow.kind === "link") {
|
|
2942
|
+
if (flow.linkUserId === undefined || flow.linkSessionHash === undefined) {
|
|
2943
|
+
fail("session_ended");
|
|
2944
|
+
return;
|
|
2945
|
+
}
|
|
2946
|
+
const link = { linkUserId: flow.linkUserId, linkSessionHash: flow.linkSessionHash };
|
|
2947
|
+
if (flow.native !== undefined) {
|
|
2948
|
+
const issued = accounts.issueLinkCode({ identity, ...link, codeChallenge: flow.native.codeChallenge });
|
|
2949
|
+
if (issued === "session_ended")
|
|
2950
|
+
fail(issued);
|
|
2951
|
+
else
|
|
2952
|
+
redirect(withParams(flow.native.redirectUri, { code: issued.code, state: flow.native.state }), [clearBindingCookie]);
|
|
2953
|
+
return;
|
|
2954
|
+
}
|
|
2955
|
+
const linked = accounts.linkIdentity(link, identity);
|
|
2956
|
+
if (linked !== "linked")
|
|
2957
|
+
fail(linked);
|
|
2958
|
+
else
|
|
2959
|
+
toWebClient("oauth_linked", provider, [clearBindingCookie]);
|
|
2960
|
+
return;
|
|
2961
|
+
}
|
|
2962
|
+
const completion = flow.native === undefined ? { kind: "web", bindingHash: flow.bindingHash } : { kind: "native", bindingHash: flow.bindingHash, native: flow.native };
|
|
2963
|
+
const resolved = accounts.resolveIdentity(identity, completion);
|
|
2964
|
+
if (resolved.status === "refused") {
|
|
2965
|
+
fail(resolved.reason);
|
|
2966
|
+
return;
|
|
2967
|
+
}
|
|
2968
|
+
if (resolved.status === "pending") {
|
|
2969
|
+
toWebClient("oauth_pending", resolved.id, [setBindingCookie(cookie(request, BINDING_COOKIE))]);
|
|
2970
|
+
return;
|
|
2971
|
+
}
|
|
2972
|
+
if (flow.native !== undefined) {
|
|
2973
|
+
redirect(nativeCode(resolved.userId, flow.native));
|
|
2974
|
+
return;
|
|
2975
|
+
}
|
|
2976
|
+
const session = accounts.signIn(resolved.userId);
|
|
2977
|
+
if (session === undefined)
|
|
2978
|
+
fail("not_found");
|
|
2979
|
+
else
|
|
2980
|
+
redirect(`${ctx.publicOrigin}/`, [setSessionCookie(session.token), clearBindingCookie]);
|
|
1697
2981
|
return;
|
|
1698
2982
|
}
|
|
1699
|
-
if (pathname === "/api/
|
|
2983
|
+
if (pathname === "/api/oauth/token" || pathname === "/api/oauth/apple/native") {
|
|
1700
2984
|
if (method !== "POST") {
|
|
1701
2985
|
deny(405, "method_not_allowed", { allow: "POST" });
|
|
1702
2986
|
return;
|
|
1703
2987
|
}
|
|
2988
|
+
if (pathname === "/api/oauth/apple/native" && oauth?.appleNative !== true) {
|
|
2989
|
+
deny(404, "not_found");
|
|
2990
|
+
return;
|
|
2991
|
+
}
|
|
1704
2992
|
const body = await readJson(request);
|
|
1705
2993
|
if (!body.ok) {
|
|
1706
2994
|
deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
|
|
1707
2995
|
return;
|
|
1708
2996
|
}
|
|
1709
|
-
const value = body.value;
|
|
1710
|
-
const device = parseDeviceClaim(value
|
|
1711
|
-
if (
|
|
2997
|
+
const value = typeof body.value === "object" && body.value !== null ? body.value : {};
|
|
2998
|
+
const device = parseDeviceClaim(value.device);
|
|
2999
|
+
if (device === "invalid") {
|
|
3000
|
+
deny(400, "invalid_request");
|
|
3001
|
+
return;
|
|
3002
|
+
}
|
|
3003
|
+
if (pathname === "/api/oauth/token") {
|
|
3004
|
+
const { code, code_verifier: verifier } = value;
|
|
3005
|
+
if (typeof code !== "string" || code.length > 256 || typeof verifier !== "string" || !PKCE_VERIFIER.test(verifier)) {
|
|
3006
|
+
deny(400, "invalid_request");
|
|
3007
|
+
return;
|
|
3008
|
+
}
|
|
3009
|
+
const session = accounts.exchangeOAuthCode(code, verifier, device);
|
|
3010
|
+
if (session === undefined)
|
|
3011
|
+
deny(400, "invalid_grant");
|
|
3012
|
+
else
|
|
3013
|
+
signedIn(session);
|
|
3014
|
+
return;
|
|
3015
|
+
}
|
|
3016
|
+
const { identityToken, authorizationCode, nonce, name } = value;
|
|
3017
|
+
if (typeof identityToken !== "string" || typeof authorizationCode !== "string" || authorizationCode.length > 1024 || nonce !== undefined && (typeof nonce !== "string" || nonce.length > 256) || name !== undefined && typeof name !== "string") {
|
|
3018
|
+
deny(400, "invalid_request");
|
|
3019
|
+
return;
|
|
3020
|
+
}
|
|
3021
|
+
let linkToken;
|
|
3022
|
+
if (value.password !== undefined || value.reauth !== undefined) {
|
|
3023
|
+
const token = bearer ? sessionToken(request) : undefined;
|
|
3024
|
+
if (token === undefined) {
|
|
3025
|
+
deny(401, "unauthenticated");
|
|
3026
|
+
return;
|
|
3027
|
+
}
|
|
3028
|
+
const current = accounts.authenticate(token);
|
|
3029
|
+
if (current === undefined) {
|
|
3030
|
+
deny(401, "unauthenticated");
|
|
3031
|
+
return;
|
|
3032
|
+
}
|
|
3033
|
+
const confirmation = bodyConfirmation(value, token);
|
|
3034
|
+
if (confirmation === undefined) {
|
|
3035
|
+
deny(400, "invalid_request");
|
|
3036
|
+
return;
|
|
3037
|
+
}
|
|
3038
|
+
const confirmed = await accounts.confirm(current.userId, confirmation);
|
|
3039
|
+
if (confirmed !== "ok") {
|
|
3040
|
+
deny(confirmed === "limited" ? 429 : 403, confirmed === "limited" ? "rate_limited" : confirmed);
|
|
3041
|
+
return;
|
|
3042
|
+
}
|
|
3043
|
+
linkToken = token;
|
|
3044
|
+
}
|
|
3045
|
+
const identity = await oauth.verifyAppleNative({
|
|
3046
|
+
identityToken,
|
|
3047
|
+
authorizationCode,
|
|
3048
|
+
...nonce === undefined ? {} : { nonce },
|
|
3049
|
+
...name === undefined ? {} : { name }
|
|
3050
|
+
});
|
|
3051
|
+
if ("error" in identity) {
|
|
3052
|
+
deny(identity.error === "invalid_token" ? 401 : 400, identity.error === "invalid_token" ? "invalid_token" : "invalid_grant");
|
|
3053
|
+
return;
|
|
3054
|
+
}
|
|
3055
|
+
if (linkToken !== undefined) {
|
|
3056
|
+
const current = accounts.authenticate(linkToken);
|
|
3057
|
+
const linked = current === undefined ? "session_ended" : accounts.linkIdentity({ linkUserId: current.userId, linkSessionHash: sha256Hex2(linkToken) }, identity);
|
|
3058
|
+
if (linked === "linked")
|
|
3059
|
+
send(204);
|
|
3060
|
+
else
|
|
3061
|
+
deny(linked === "identity_taken" ? 409 : 401, linked);
|
|
3062
|
+
return;
|
|
3063
|
+
}
|
|
3064
|
+
const resolved = accounts.resolveIdentity(identity, { kind: "direct" });
|
|
3065
|
+
if (resolved.status === "refused")
|
|
3066
|
+
deny(403, resolved.reason);
|
|
3067
|
+
else if (resolved.status === "pending") {
|
|
3068
|
+
send(409, { error: "no_account", reason: "no_account", pending: resolved.id, ...resolved.email === undefined ? {} : { email: resolved.email } });
|
|
3069
|
+
} else {
|
|
3070
|
+
const session = accounts.signIn(resolved.userId, device);
|
|
3071
|
+
if (session === undefined)
|
|
3072
|
+
deny(404, "not_found");
|
|
3073
|
+
else
|
|
3074
|
+
signedIn(session);
|
|
3075
|
+
}
|
|
3076
|
+
return;
|
|
3077
|
+
}
|
|
3078
|
+
const pendingRoute = /^\/api\/oauth\/pending\/([A-Za-z0-9_-]{43})(?:\/(create|link))?$/.exec(pathname);
|
|
3079
|
+
if (pendingRoute !== null) {
|
|
3080
|
+
const id = pendingRoute[1];
|
|
3081
|
+
const action = pendingRoute[2];
|
|
3082
|
+
const expected = action === undefined ? "GET" : "POST";
|
|
3083
|
+
if (method !== expected) {
|
|
3084
|
+
deny(405, "method_not_allowed", { allow: expected });
|
|
3085
|
+
return;
|
|
3086
|
+
}
|
|
3087
|
+
const visible = () => {
|
|
3088
|
+
const pending = accounts.pendingSignIn(id);
|
|
3089
|
+
if (pending === undefined)
|
|
3090
|
+
return;
|
|
3091
|
+
const { completion } = pending;
|
|
3092
|
+
if (completion.kind !== "direct")
|
|
3093
|
+
return bound(request, completion.bindingHash) ? pending : undefined;
|
|
3094
|
+
const tokenMode = (origin === undefined || origin === DESKTOP_ORIGIN) && cookie(request, SESSION_COOKIE) === undefined;
|
|
3095
|
+
return (action === "link" ? bearer : tokenMode) ? pending : undefined;
|
|
3096
|
+
};
|
|
3097
|
+
if (action === undefined) {
|
|
3098
|
+
const pending = visible();
|
|
3099
|
+
if (pending === undefined)
|
|
3100
|
+
deny(404, "not_found");
|
|
3101
|
+
else {
|
|
3102
|
+
send(200, {
|
|
3103
|
+
provider: pending.provider,
|
|
3104
|
+
...pending.email === undefined ? {} : { email: pending.email },
|
|
3105
|
+
...pending.name === undefined ? {} : { name: pending.name }
|
|
3106
|
+
});
|
|
3107
|
+
}
|
|
3108
|
+
return;
|
|
3109
|
+
}
|
|
3110
|
+
if (action === "create") {
|
|
3111
|
+
const body2 = await readJson(request, true);
|
|
3112
|
+
if (!body2.ok) {
|
|
3113
|
+
deny(body2.tooLarge ? 413 : 400, body2.tooLarge ? "payload_too_large" : "invalid_json");
|
|
3114
|
+
return;
|
|
3115
|
+
}
|
|
3116
|
+
const value = body2.value;
|
|
3117
|
+
const device = parseDeviceClaim(value?.device);
|
|
3118
|
+
const password = value?.password;
|
|
3119
|
+
if (device === "invalid" || password !== undefined && (typeof password !== "string" || [...password].length > MAX_PASSWORD_CODEPOINTS)) {
|
|
3120
|
+
deny(400, "invalid_request");
|
|
3121
|
+
return;
|
|
3122
|
+
}
|
|
3123
|
+
if (visible() === undefined) {
|
|
3124
|
+
deny(404, "not_found");
|
|
3125
|
+
return;
|
|
3126
|
+
}
|
|
3127
|
+
const result2 = await accounts.createFromPending(id, password);
|
|
3128
|
+
if (result2.status !== "ok") {
|
|
3129
|
+
pendingRefused(result2);
|
|
3130
|
+
return;
|
|
3131
|
+
}
|
|
3132
|
+
if (result2.completion.kind === "native") {
|
|
3133
|
+
send(200, { redirect: nativeCode(result2.userId, result2.completion.native) });
|
|
3134
|
+
return;
|
|
3135
|
+
}
|
|
3136
|
+
const session = accounts.signIn(result2.userId, device);
|
|
3137
|
+
if (session === undefined)
|
|
3138
|
+
deny(404, "not_found");
|
|
3139
|
+
else
|
|
3140
|
+
signedIn(session);
|
|
3141
|
+
return;
|
|
3142
|
+
}
|
|
3143
|
+
const token = sessionToken(request);
|
|
3144
|
+
if (token === undefined || accounts.authenticate(token) === undefined) {
|
|
3145
|
+
deny(401, "unauthenticated");
|
|
3146
|
+
return;
|
|
3147
|
+
}
|
|
3148
|
+
const body = await authedJson(accounts, request, token, true);
|
|
3149
|
+
if (!body.ok) {
|
|
3150
|
+
deny(body.status, body.error);
|
|
3151
|
+
return;
|
|
3152
|
+
}
|
|
3153
|
+
const confirmation = bodyConfirmation(body.value, token);
|
|
3154
|
+
if (confirmation === undefined) {
|
|
3155
|
+
deny(400, "invalid_request");
|
|
3156
|
+
return;
|
|
3157
|
+
}
|
|
3158
|
+
if (visible() === undefined) {
|
|
3159
|
+
deny(404, "not_found");
|
|
3160
|
+
return;
|
|
3161
|
+
}
|
|
3162
|
+
const confirmed = await accounts.confirm(body.session.userId, confirmation);
|
|
3163
|
+
if (confirmed !== "ok") {
|
|
3164
|
+
deny(confirmed === "limited" ? 429 : 403, confirmed === "limited" ? "rate_limited" : confirmed);
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
3167
|
+
if (accounts.authenticate(token)?.userId !== body.session.userId) {
|
|
3168
|
+
deny(401, "unauthenticated");
|
|
3169
|
+
return;
|
|
3170
|
+
}
|
|
3171
|
+
const result = accounts.linkPending(id, body.session.userId);
|
|
3172
|
+
if (result.status !== "ok")
|
|
3173
|
+
pendingRefused(result);
|
|
3174
|
+
else if (result.completion.kind === "native")
|
|
3175
|
+
send(200, { redirect: nativeCode(result.userId, result.completion.native) });
|
|
3176
|
+
else
|
|
3177
|
+
send(204);
|
|
3178
|
+
return;
|
|
3179
|
+
}
|
|
3180
|
+
const linkRoute = /^\/api\/oauth\/([a-z]+)\/link$/.exec(pathname);
|
|
3181
|
+
const removeRoute = /^\/api\/me\/identities\/([a-z]+)\/remove$/.exec(pathname);
|
|
3182
|
+
const linkComplete = pathname === "/api/oauth/link/complete";
|
|
3183
|
+
if (pathname === "/api/me/identities" || pathname === "/api/reauth/code" || pathname === "/api/reauth" || linkComplete || linkRoute !== null || removeRoute !== null) {
|
|
3184
|
+
const expected = pathname === "/api/me/identities" ? "GET" : "POST";
|
|
3185
|
+
if (method !== expected) {
|
|
3186
|
+
deny(405, "method_not_allowed", { allow: expected });
|
|
3187
|
+
return;
|
|
3188
|
+
}
|
|
3189
|
+
const token = sessionToken(request);
|
|
3190
|
+
const session = token === undefined ? undefined : accounts.authenticate(token);
|
|
3191
|
+
if (session === undefined) {
|
|
3192
|
+
deny(401, "unauthenticated");
|
|
3193
|
+
return;
|
|
3194
|
+
}
|
|
3195
|
+
if (pathname === "/api/me/identities") {
|
|
3196
|
+
send(200, accounts.listIdentities(session.userId));
|
|
3197
|
+
return;
|
|
3198
|
+
}
|
|
3199
|
+
const body = await authedJson(accounts, request, token, true);
|
|
3200
|
+
if (!body.ok) {
|
|
3201
|
+
deny(body.status, body.error);
|
|
3202
|
+
return;
|
|
3203
|
+
}
|
|
3204
|
+
const current = body.session;
|
|
3205
|
+
if (linkComplete) {
|
|
3206
|
+
const { code, code_verifier: verifier } = typeof body.value === "object" && body.value !== null ? body.value : {};
|
|
3207
|
+
if (typeof code !== "string" || code.length > 256 || typeof verifier !== "string" || !PKCE_VERIFIER.test(verifier)) {
|
|
3208
|
+
deny(400, "invalid_request");
|
|
3209
|
+
return;
|
|
3210
|
+
}
|
|
3211
|
+
const linked = accounts.completeLink({ code, codeVerifier: verifier, token });
|
|
3212
|
+
if (linked === "linked")
|
|
3213
|
+
send(204);
|
|
3214
|
+
else
|
|
3215
|
+
deny({ invalid_grant: 400, identity_taken: 409, session_ended: 401 }[linked], linked);
|
|
3216
|
+
return;
|
|
3217
|
+
}
|
|
3218
|
+
if (pathname === "/api/reauth/code") {
|
|
3219
|
+
const result = accounts.requestReauthCode({ userId: current.userId, ip: clientIp(request, ctx.trustProxy) });
|
|
3220
|
+
if (result === "no_email")
|
|
3221
|
+
deny(409, "no_email");
|
|
3222
|
+
else
|
|
3223
|
+
codeSent(result, "email_unavailable");
|
|
3224
|
+
return;
|
|
3225
|
+
}
|
|
3226
|
+
if (pathname === "/api/reauth") {
|
|
3227
|
+
const { password, code } = typeof body.value === "object" && body.value !== null ? body.value : {};
|
|
3228
|
+
const proof = typeof password === "string" && code === undefined && [...password].length <= MAX_PASSWORD_CODEPOINTS ? { password } : typeof code === "string" && password === undefined && code.length <= MAX_CODE_LENGTH ? { code } : undefined;
|
|
3229
|
+
if (proof === undefined) {
|
|
3230
|
+
deny(400, "invalid_request");
|
|
3231
|
+
return;
|
|
3232
|
+
}
|
|
3233
|
+
const result = await accounts.grantReauth({ userId: current.userId, token, ...proof });
|
|
3234
|
+
if (result.status === "limited")
|
|
3235
|
+
deny(429, "rate_limited");
|
|
3236
|
+
else if (result.status === "refused")
|
|
3237
|
+
deny(result.reason === "unauthenticated" ? 401 : 403, result.reason);
|
|
3238
|
+
else
|
|
3239
|
+
send(200, { reauth: result.reauth, expiresAt: result.expiresAt });
|
|
3240
|
+
return;
|
|
3241
|
+
}
|
|
3242
|
+
const confirmation = bodyConfirmation(body.value, token);
|
|
3243
|
+
if (confirmation === undefined) {
|
|
1712
3244
|
deny(400, "invalid_request");
|
|
1713
3245
|
return;
|
|
1714
3246
|
}
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
3247
|
+
if (removeRoute !== null) {
|
|
3248
|
+
const provider2 = OAUTH_PROVIDERS.find((name) => name === removeRoute[1]);
|
|
3249
|
+
if (provider2 === undefined) {
|
|
3250
|
+
deny(404, "not_found");
|
|
3251
|
+
return;
|
|
3252
|
+
}
|
|
3253
|
+
const result = await accounts.removeIdentity({ userId: current.userId, provider: provider2, ...confirmation });
|
|
3254
|
+
if (result.status === "limited")
|
|
3255
|
+
deny(429, "rate_limited");
|
|
3256
|
+
else if (result.status === "refused") {
|
|
3257
|
+
deny(result.reason === "not_found" ? 404 : result.reason === "last_method" ? 409 : 403, result.reason);
|
|
3258
|
+
} else {
|
|
3259
|
+
for (const grant of result.appleGrants)
|
|
3260
|
+
oauth?.revokeApple(grant.refreshToken, grant.clientId);
|
|
3261
|
+
send(204);
|
|
3262
|
+
}
|
|
3263
|
+
return;
|
|
3264
|
+
}
|
|
3265
|
+
const provider = enabledProvider(linkRoute[1]);
|
|
3266
|
+
if (provider === undefined) {
|
|
3267
|
+
deny(404, "not_found");
|
|
3268
|
+
return;
|
|
3269
|
+
}
|
|
3270
|
+
const linkParams = typeof body.value === "object" && body.value !== null ? body.value : {};
|
|
3271
|
+
const client = linkParams.client ?? "web";
|
|
3272
|
+
const text = (key) => typeof linkParams[key] === "string" ? linkParams[key] : null;
|
|
3273
|
+
const native = client === "native" ? parseNativeRedirect(text("redirect_uri"), text("code_challenge"), text("state")) : undefined;
|
|
3274
|
+
if (client !== "web" && client !== "native" || native === null) {
|
|
3275
|
+
deny(400, "invalid_request");
|
|
3276
|
+
return;
|
|
3277
|
+
}
|
|
3278
|
+
const confirmed = await accounts.confirm(current.userId, confirmation);
|
|
3279
|
+
if (confirmed !== "ok") {
|
|
3280
|
+
deny(confirmed === "limited" ? 429 : 403, confirmed === "limited" ? "rate_limited" : confirmed);
|
|
3281
|
+
return;
|
|
3282
|
+
}
|
|
3283
|
+
const binding = randomBytes2(32).toString("base64url");
|
|
3284
|
+
const flow = accounts.startOAuthFlow({
|
|
3285
|
+
provider,
|
|
3286
|
+
kind: "link",
|
|
1720
3287
|
ip: clientIp(request, ctx.trustProxy),
|
|
1721
|
-
|
|
3288
|
+
bindingHash: sha256Hex2(binding),
|
|
3289
|
+
linkUserId: current.userId,
|
|
3290
|
+
linkSessionToken: token,
|
|
3291
|
+
...native === undefined ? {} : { native }
|
|
1722
3292
|
});
|
|
1723
|
-
if (
|
|
3293
|
+
if (flow.status === "limited")
|
|
1724
3294
|
deny(429, "rate_limited");
|
|
3295
|
+
else if (flow.status === "busy")
|
|
3296
|
+
deny(503, "busy");
|
|
3297
|
+
else if (native !== undefined) {
|
|
3298
|
+
send(200, { url: `${ctx.publicOrigin}/api/oauth/${provider}/start?${new URLSearchParams({ link: binding })}` });
|
|
3299
|
+
} else {
|
|
3300
|
+
const url = oauth.authorizeUrl(provider, {
|
|
3301
|
+
state: flow.state,
|
|
3302
|
+
nonce: flow.nonce,
|
|
3303
|
+
codeChallenge: pkceChallenge(flow.verifier),
|
|
3304
|
+
redirectUri: callbackUri(provider)
|
|
3305
|
+
});
|
|
3306
|
+
send(200, { url }, { "set-cookie": setBindingCookie(binding) });
|
|
3307
|
+
}
|
|
3308
|
+
return;
|
|
3309
|
+
}
|
|
3310
|
+
if (pathname === "/api/register/code" || pathname === "/api/password/reset/code") {
|
|
3311
|
+
if (method !== "POST") {
|
|
3312
|
+
deny(405, "method_not_allowed", { allow: "POST" });
|
|
1725
3313
|
return;
|
|
1726
3314
|
}
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
deny(
|
|
3315
|
+
const body = await readJson(request);
|
|
3316
|
+
if (!body.ok) {
|
|
3317
|
+
deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
|
|
1730
3318
|
return;
|
|
1731
3319
|
}
|
|
1732
|
-
const
|
|
1733
|
-
|
|
3320
|
+
const email = bodyEmail(body.value);
|
|
3321
|
+
if (email === undefined) {
|
|
3322
|
+
deny(400, "invalid_email");
|
|
3323
|
+
return;
|
|
3324
|
+
}
|
|
3325
|
+
const args = { email, ip: clientIp(request, ctx.trustProxy) };
|
|
3326
|
+
const signup = pathname === "/api/register/code";
|
|
3327
|
+
codeSent(signup ? accounts.requestSignupCode(args) : accounts.requestPasswordReset(args), signup ? "registration_closed" : "email_unavailable");
|
|
3328
|
+
return;
|
|
3329
|
+
}
|
|
3330
|
+
if (pathname === "/api/register" || pathname === "/api/password/reset") {
|
|
3331
|
+
if (method !== "POST") {
|
|
3332
|
+
deny(405, "method_not_allowed", { allow: "POST" });
|
|
3333
|
+
return;
|
|
3334
|
+
}
|
|
3335
|
+
const body = await readJson(request);
|
|
3336
|
+
if (!body.ok) {
|
|
3337
|
+
deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
|
|
3338
|
+
return;
|
|
3339
|
+
}
|
|
3340
|
+
const value = body.value;
|
|
3341
|
+
const email = bodyEmail(value);
|
|
3342
|
+
const code = bodyCode(value);
|
|
3343
|
+
const device = parseDeviceClaim(value?.device);
|
|
3344
|
+
if (email === undefined || code === undefined || typeof value?.password !== "string" || device === "invalid") {
|
|
3345
|
+
deny(400, "invalid_request");
|
|
3346
|
+
return;
|
|
3347
|
+
}
|
|
3348
|
+
const args = { email, code, password: value.password, ip: clientIp(request, ctx.trustProxy) };
|
|
3349
|
+
if (pathname === "/api/password/reset") {
|
|
3350
|
+
const result2 = await accounts.resetPasswordWithCode(args);
|
|
3351
|
+
if (result2.status === "limited")
|
|
3352
|
+
deny(429, "rate_limited");
|
|
3353
|
+
else if (result2.status === "refused")
|
|
3354
|
+
deny(result2.reason === "email_unavailable" ? 403 : 400, result2.reason);
|
|
3355
|
+
else
|
|
3356
|
+
send(204);
|
|
3357
|
+
return;
|
|
3358
|
+
}
|
|
3359
|
+
const result = await accounts.register({ ...args, ...device === undefined ? {} : { device } });
|
|
3360
|
+
if (result.status === "limited")
|
|
3361
|
+
deny(429, "rate_limited");
|
|
3362
|
+
else if (result.status === "refused")
|
|
3363
|
+
deny(result.reason === "registration_closed" ? 403 : 400, result.reason);
|
|
3364
|
+
else
|
|
3365
|
+
signedIn(result);
|
|
1734
3366
|
return;
|
|
1735
3367
|
}
|
|
1736
3368
|
if (pathname === "/api/host-link/start" || pathname === "/api/host-link/poll" || pathname === "/api/host-link/token") {
|
|
@@ -1829,9 +3461,9 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
1829
3461
|
deny(401, "unauthenticated");
|
|
1830
3462
|
return;
|
|
1831
3463
|
}
|
|
1832
|
-
const body = await
|
|
3464
|
+
const body = await authedJson(accounts, request, token);
|
|
1833
3465
|
if (!body.ok) {
|
|
1834
|
-
deny(body.
|
|
3466
|
+
deny(body.status, body.error);
|
|
1835
3467
|
return;
|
|
1836
3468
|
}
|
|
1837
3469
|
const named = body.value;
|
|
@@ -1840,12 +3472,7 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
1840
3472
|
deny(400, "invalid_request");
|
|
1841
3473
|
return;
|
|
1842
3474
|
}
|
|
1843
|
-
const
|
|
1844
|
-
if (session === undefined) {
|
|
1845
|
-
deny(401, "unauthenticated");
|
|
1846
|
-
return;
|
|
1847
|
-
}
|
|
1848
|
-
const args = { userId: session.userId, ip: clientIp(request, ctx.trustProxy), ...ref };
|
|
3475
|
+
const args = { userId: body.session.userId, ip: clientIp(request, ctx.trustProxy), ...ref };
|
|
1849
3476
|
const result = pathname === "/api/host-link/lookup" ? accounts.lookupHostLink(args) : pathname === "/api/host-link/confirm" ? accounts.confirmHostLink(args) : accounts.denyHostLink(args);
|
|
1850
3477
|
if (result.status === "limited") {
|
|
1851
3478
|
deny(429, "rate_limited");
|
|
@@ -1863,49 +3490,6 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
1863
3490
|
send(204);
|
|
1864
3491
|
return;
|
|
1865
3492
|
}
|
|
1866
|
-
if (pathname === "/api/invites" || pathname === "/api/invites/revoke") {
|
|
1867
|
-
const allowed = pathname === "/api/invites" ? ["GET", "POST"] : ["POST"];
|
|
1868
|
-
if (!allowed.includes(method)) {
|
|
1869
|
-
deny(405, "method_not_allowed", { allow: allowed.join(", ") });
|
|
1870
|
-
return;
|
|
1871
|
-
}
|
|
1872
|
-
const token = sessionToken(request);
|
|
1873
|
-
const session = token === undefined ? undefined : accounts.authenticate(token);
|
|
1874
|
-
if (session === undefined) {
|
|
1875
|
-
deny(401, "unauthenticated");
|
|
1876
|
-
return;
|
|
1877
|
-
}
|
|
1878
|
-
if (!session.admin) {
|
|
1879
|
-
deny(403, "forbidden");
|
|
1880
|
-
return;
|
|
1881
|
-
}
|
|
1882
|
-
if (pathname === "/api/invites") {
|
|
1883
|
-
if (method === "GET")
|
|
1884
|
-
send(200, accounts.listInvites());
|
|
1885
|
-
else
|
|
1886
|
-
send(200, accounts.createInvite(session.userId));
|
|
1887
|
-
return;
|
|
1888
|
-
}
|
|
1889
|
-
const body = await readJson(request);
|
|
1890
|
-
if (!body.ok) {
|
|
1891
|
-
deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
|
|
1892
|
-
return;
|
|
1893
|
-
}
|
|
1894
|
-
const id = body.value?.id;
|
|
1895
|
-
if (typeof id !== "number" || !Number.isSafeInteger(id)) {
|
|
1896
|
-
deny(400, "invalid_request");
|
|
1897
|
-
return;
|
|
1898
|
-
}
|
|
1899
|
-
if (accounts.authenticate(token)?.admin !== true) {
|
|
1900
|
-
deny(401, "unauthenticated");
|
|
1901
|
-
return;
|
|
1902
|
-
}
|
|
1903
|
-
if (accounts.revokeInvite(id))
|
|
1904
|
-
send(204);
|
|
1905
|
-
else
|
|
1906
|
-
deny(404, "not_found");
|
|
1907
|
-
return;
|
|
1908
|
-
}
|
|
1909
3493
|
const hostRemove = /^\/api\/hosts\/([A-Za-z0-9_-]{43})\/remove$/.exec(pathname);
|
|
1910
3494
|
if (hostRemove !== null) {
|
|
1911
3495
|
if (method !== "POST") {
|
|
@@ -1917,27 +3501,18 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
1917
3501
|
deny(401, "unauthenticated");
|
|
1918
3502
|
return;
|
|
1919
3503
|
}
|
|
1920
|
-
const body = await
|
|
3504
|
+
const body = await authedJson(accounts, request, token);
|
|
1921
3505
|
if (!body.ok) {
|
|
1922
|
-
deny(body.
|
|
3506
|
+
deny(body.status, body.error);
|
|
1923
3507
|
return;
|
|
1924
3508
|
}
|
|
1925
|
-
const
|
|
1926
|
-
if (
|
|
3509
|
+
const confirmation = bodyConfirmation(body.value, token);
|
|
3510
|
+
if (confirmation === undefined) {
|
|
1927
3511
|
deny(400, "invalid_request");
|
|
1928
3512
|
return;
|
|
1929
3513
|
}
|
|
1930
|
-
const current =
|
|
1931
|
-
|
|
1932
|
-
deny(401, "unauthenticated");
|
|
1933
|
-
return;
|
|
1934
|
-
}
|
|
1935
|
-
const result = await accounts.removeHost({
|
|
1936
|
-
userId: current.userId,
|
|
1937
|
-
username: current.username,
|
|
1938
|
-
password: value.password,
|
|
1939
|
-
hostId: hostRemove[1]
|
|
1940
|
-
});
|
|
3514
|
+
const current = body.session;
|
|
3515
|
+
const result = await accounts.removeHost({ userId: current.userId, hostId: hostRemove[1], ...confirmation });
|
|
1941
3516
|
if (result.status === "limited") {
|
|
1942
3517
|
deny(429, "rate_limited");
|
|
1943
3518
|
return;
|
|
@@ -2044,27 +3619,22 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
2044
3619
|
send(200, accounts.listDevices(session.userId).map((device) => ({ ...device, current: device.id === session.deviceId })));
|
|
2045
3620
|
return;
|
|
2046
3621
|
}
|
|
2047
|
-
const body = await
|
|
3622
|
+
const body = await authedJson(accounts, request, token);
|
|
2048
3623
|
if (!body.ok) {
|
|
2049
|
-
deny(body.
|
|
3624
|
+
deny(body.status, body.error);
|
|
2050
3625
|
return;
|
|
2051
3626
|
}
|
|
2052
|
-
const
|
|
2053
|
-
if (
|
|
3627
|
+
const confirmation = bodyConfirmation(body.value, token);
|
|
3628
|
+
if (confirmation === undefined) {
|
|
2054
3629
|
deny(400, "invalid_request");
|
|
2055
3630
|
return;
|
|
2056
3631
|
}
|
|
2057
|
-
const current =
|
|
2058
|
-
if (current === undefined) {
|
|
2059
|
-
deny(401, "unauthenticated");
|
|
2060
|
-
return;
|
|
2061
|
-
}
|
|
3632
|
+
const current = body.session;
|
|
2062
3633
|
const result = await accounts.kickDevice({
|
|
2063
3634
|
userId: current.userId,
|
|
2064
|
-
username: current.username,
|
|
2065
|
-
password: value.password,
|
|
2066
3635
|
deviceId: Number(deviceKick[1]),
|
|
2067
|
-
...current.deviceId === undefined ? {} : { currentDeviceId: current.deviceId }
|
|
3636
|
+
...current.deviceId === undefined ? {} : { currentDeviceId: current.deviceId },
|
|
3637
|
+
...confirmation
|
|
2068
3638
|
});
|
|
2069
3639
|
if (result.status === "limited") {
|
|
2070
3640
|
deny(429, "rate_limited");
|
|
@@ -2079,7 +3649,7 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
2079
3649
|
send(204);
|
|
2080
3650
|
return;
|
|
2081
3651
|
}
|
|
2082
|
-
if (pathname === "/api/me" || pathname === "/api/logout" || pathname === "/api/sessions/revoke-all" || pathname === "/api/password" || pathname === "/api/me/delete" || pathname === "/api/join-tokens" || pathname === "/api/push-tokens" || pathname === "/api/push-tokens/remove") {
|
|
3652
|
+
if (pathname === "/api/me" || pathname === "/api/me/email/code" || pathname === "/api/me/email" || pathname === "/api/logout" || pathname === "/api/sessions/revoke-all" || pathname === "/api/password" || pathname === "/api/me/delete" || pathname === "/api/join-tokens" || pathname === "/api/push-tokens" || pathname === "/api/push-tokens/remove") {
|
|
2083
3653
|
const expected = pathname === "/api/me" ? "GET" : "POST";
|
|
2084
3654
|
if (method !== expected) {
|
|
2085
3655
|
deny(405, "method_not_allowed", { allow: expected });
|
|
@@ -2092,13 +3662,49 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
2092
3662
|
return;
|
|
2093
3663
|
}
|
|
2094
3664
|
if (pathname === "/api/me") {
|
|
2095
|
-
send(200,
|
|
3665
|
+
send(200, profile(session.user));
|
|
3666
|
+
return;
|
|
3667
|
+
}
|
|
3668
|
+
if (pathname === "/api/me/email/code" || pathname === "/api/me/email") {
|
|
3669
|
+
const body = await authedJson(accounts, request, token);
|
|
3670
|
+
if (!body.ok) {
|
|
3671
|
+
deny(body.status, body.error);
|
|
3672
|
+
return;
|
|
3673
|
+
}
|
|
3674
|
+
const confirmation = bodyConfirmation(body.value, token);
|
|
3675
|
+
if (confirmation === undefined) {
|
|
3676
|
+
deny(400, "invalid_request");
|
|
3677
|
+
return;
|
|
3678
|
+
}
|
|
3679
|
+
const email = bodyEmail(body.value);
|
|
3680
|
+
if (email === undefined) {
|
|
3681
|
+
deny(400, "invalid_email");
|
|
3682
|
+
return;
|
|
3683
|
+
}
|
|
3684
|
+
const current = body.session;
|
|
3685
|
+
if (pathname === "/api/me/email/code") {
|
|
3686
|
+
codeSent(await accounts.requestEmailCode({ userId: current.userId, email, ip: clientIp(request, ctx.trustProxy), ...confirmation }), "email_unavailable");
|
|
3687
|
+
return;
|
|
3688
|
+
}
|
|
3689
|
+
const code = bodyCode(body.value);
|
|
3690
|
+
if (code === undefined) {
|
|
3691
|
+
deny(400, "invalid_request");
|
|
3692
|
+
return;
|
|
3693
|
+
}
|
|
3694
|
+
const result = await accounts.setEmail({ userId: current.userId, email, code, ...confirmation });
|
|
3695
|
+
if (result.status === "limited")
|
|
3696
|
+
deny(429, "rate_limited");
|
|
3697
|
+
else if (result.status === "refused") {
|
|
3698
|
+
const status = { email_taken: 409, email_unavailable: 403, wrong_password: 403, invalid_reauth: 403, invalid_code: 400 }[result.reason];
|
|
3699
|
+
deny(status, result.reason);
|
|
3700
|
+
} else
|
|
3701
|
+
send(200, profile(result.user));
|
|
2096
3702
|
return;
|
|
2097
3703
|
}
|
|
2098
3704
|
if (pathname === "/api/push-tokens" || pathname === "/api/push-tokens/remove") {
|
|
2099
|
-
const body = await
|
|
3705
|
+
const body = await authedJson(accounts, request, token);
|
|
2100
3706
|
if (!body.ok) {
|
|
2101
|
-
deny(body.
|
|
3707
|
+
deny(body.status, body.error);
|
|
2102
3708
|
return;
|
|
2103
3709
|
}
|
|
2104
3710
|
const value = body.value;
|
|
@@ -2108,68 +3714,64 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
2108
3714
|
deny(400, "invalid_request");
|
|
2109
3715
|
return;
|
|
2110
3716
|
}
|
|
2111
|
-
const current =
|
|
2112
|
-
if (current === undefined) {
|
|
2113
|
-
deny(401, "unauthenticated");
|
|
2114
|
-
return;
|
|
2115
|
-
}
|
|
3717
|
+
const current = body.session;
|
|
2116
3718
|
if (pathname === "/api/push-tokens")
|
|
2117
|
-
accounts.registerPushToken(
|
|
3719
|
+
accounts.registerPushToken(current.userId, apns, environment, current.deviceId);
|
|
2118
3720
|
else
|
|
2119
|
-
accounts.removePushToken(
|
|
3721
|
+
accounts.removePushToken(current.userId, apns, environment);
|
|
2120
3722
|
send(204);
|
|
2121
3723
|
return;
|
|
2122
3724
|
}
|
|
2123
3725
|
if (pathname === "/api/me/delete") {
|
|
2124
|
-
const body = await
|
|
3726
|
+
const body = await authedJson(accounts, request, token);
|
|
2125
3727
|
if (!body.ok) {
|
|
2126
|
-
deny(body.
|
|
3728
|
+
deny(body.status, body.error);
|
|
2127
3729
|
return;
|
|
2128
3730
|
}
|
|
2129
|
-
const
|
|
2130
|
-
|
|
3731
|
+
const current = body.session;
|
|
3732
|
+
const confirmation = bodyConfirmation(body.value, token);
|
|
3733
|
+
if (confirmation === undefined) {
|
|
2131
3734
|
deny(400, "invalid_request");
|
|
2132
3735
|
return;
|
|
2133
3736
|
}
|
|
2134
|
-
const result = await accounts.deleteAccount({ userId:
|
|
3737
|
+
const result = await accounts.deleteAccount({ userId: current.userId, keepToken: token, ...confirmation });
|
|
2135
3738
|
if (result.status === "limited") {
|
|
2136
3739
|
deny(429, "rate_limited");
|
|
2137
3740
|
return;
|
|
2138
3741
|
}
|
|
2139
3742
|
if (result.status === "refused") {
|
|
2140
|
-
deny(403, result.reason);
|
|
3743
|
+
deny(result.reason === "unauthenticated" ? 401 : 403, result.reason);
|
|
2141
3744
|
return;
|
|
2142
3745
|
}
|
|
2143
3746
|
for (const hostId of result.hostIds)
|
|
2144
3747
|
ctx.disconnectHost(hostId);
|
|
2145
|
-
ctx.forgetAccount(
|
|
3748
|
+
ctx.forgetAccount(current.userId);
|
|
3749
|
+
for (const grant of result.appleGrants)
|
|
3750
|
+
oauth?.revokeApple(grant.refreshToken, grant.clientId);
|
|
2146
3751
|
send(204, undefined, { "set-cookie": clearSessionCookie });
|
|
2147
3752
|
return;
|
|
2148
3753
|
}
|
|
2149
3754
|
if (pathname === "/api/password") {
|
|
2150
|
-
const body = await
|
|
3755
|
+
const body = await authedJson(accounts, request, token);
|
|
2151
3756
|
if (!body.ok) {
|
|
2152
|
-
deny(body.
|
|
3757
|
+
deny(body.status, body.error);
|
|
2153
3758
|
return;
|
|
2154
3759
|
}
|
|
3760
|
+
const current = body.session;
|
|
2155
3761
|
const value = body.value;
|
|
2156
|
-
|
|
3762
|
+
const proof = typeof value?.currentPassword === "string" && value.reauth === undefined && [...value.currentPassword].length <= MAX_PASSWORD_CODEPOINTS ? { currentPassword: value.currentPassword } : typeof value?.reauth === "string" && value.currentPassword === undefined && value.reauth.length <= 256 ? { reauth: value.reauth } : undefined;
|
|
3763
|
+
if (proof === undefined || typeof value?.newPassword !== "string") {
|
|
2157
3764
|
deny(400, "invalid_request");
|
|
2158
3765
|
return;
|
|
2159
3766
|
}
|
|
2160
|
-
const result = await accounts.changePassword({
|
|
2161
|
-
userId: session.userId,
|
|
2162
|
-
username: session.username,
|
|
2163
|
-
keepToken: token,
|
|
2164
|
-
currentPassword: value.currentPassword,
|
|
2165
|
-
newPassword: value.newPassword
|
|
2166
|
-
});
|
|
3767
|
+
const result = await accounts.changePassword({ userId: current.userId, keepToken: token, newPassword: value.newPassword, ...proof });
|
|
2167
3768
|
if (result.status === "limited") {
|
|
2168
3769
|
deny(429, "rate_limited");
|
|
2169
3770
|
return;
|
|
2170
3771
|
}
|
|
2171
3772
|
if (result.status === "refused") {
|
|
2172
|
-
|
|
3773
|
+
const status = result.reason === "weak_password" ? 400 : result.reason === "unauthenticated" ? 401 : 403;
|
|
3774
|
+
deny(status, result.reason);
|
|
2173
3775
|
return;
|
|
2174
3776
|
}
|
|
2175
3777
|
send(204);
|
|
@@ -2185,9 +3787,9 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
2185
3787
|
return;
|
|
2186
3788
|
}
|
|
2187
3789
|
if (pathname === "/api/logout") {
|
|
2188
|
-
const body = await
|
|
3790
|
+
const body = await authedJson(accounts, request, token, true);
|
|
2189
3791
|
if (!body.ok) {
|
|
2190
|
-
deny(body.
|
|
3792
|
+
deny(body.status, body.error);
|
|
2191
3793
|
return;
|
|
2192
3794
|
}
|
|
2193
3795
|
const value = body.value;
|
|
@@ -2312,7 +3914,7 @@ async function startRelay(options) {
|
|
|
2312
3914
|
return Promise.resolve({ status: "too_many" });
|
|
2313
3915
|
}
|
|
2314
3916
|
return new Promise((resolve) => {
|
|
2315
|
-
const requestId =
|
|
3917
|
+
const requestId = randomBytes3(16).toString("base64url");
|
|
2316
3918
|
const settle = (result) => {
|
|
2317
3919
|
if (!pairingRequests.delete(requestId))
|
|
2318
3920
|
return;
|
|
@@ -2432,7 +4034,7 @@ async function startRelay(options) {
|
|
|
2432
4034
|
}
|
|
2433
4035
|
function onControl(socket) {
|
|
2434
4036
|
track(socket);
|
|
2435
|
-
const nonce =
|
|
4037
|
+
const nonce = randomBytes3(32).toString("base64url");
|
|
2436
4038
|
socket.send(encodeRelayFrame({ t: "challenge", nonce }));
|
|
2437
4039
|
let hostId;
|
|
2438
4040
|
const deadline = setTimeout(() => socket.close(1008, "register timeout"), registerTimeoutMs);
|
|
@@ -2496,13 +4098,13 @@ async function startRelay(options) {
|
|
|
2496
4098
|
}
|
|
2497
4099
|
let noisePublicKey;
|
|
2498
4100
|
if (frame.signatureV2 !== undefined) {
|
|
2499
|
-
const
|
|
4101
|
+
const bound2 = frame.noisePublicKey !== undefined && verifyRelayChallengeV2(frame.signatureV2, {
|
|
2500
4102
|
relayOrigin: publicOrigin,
|
|
2501
4103
|
hostId: frame.hostId,
|
|
2502
4104
|
nonce,
|
|
2503
4105
|
noisePublicKey: frame.noisePublicKey
|
|
2504
4106
|
});
|
|
2505
|
-
if (!
|
|
4107
|
+
if (!bound2) {
|
|
2506
4108
|
socket.close(RELAY_CLOSE.badSignature);
|
|
2507
4109
|
return;
|
|
2508
4110
|
}
|
|
@@ -2576,7 +4178,7 @@ async function startRelay(options) {
|
|
|
2576
4178
|
if (devices.size === 0 && hostDevices.get(hostId) === devices)
|
|
2577
4179
|
hostDevices.delete(hostId);
|
|
2578
4180
|
});
|
|
2579
|
-
const connId =
|
|
4181
|
+
const connId = randomBytes3(16).toString("base64url");
|
|
2580
4182
|
const entry = {
|
|
2581
4183
|
control,
|
|
2582
4184
|
socket,
|
|
@@ -2648,7 +4250,8 @@ async function startRelay(options) {
|
|
|
2648
4250
|
pairing,
|
|
2649
4251
|
disconnectHost,
|
|
2650
4252
|
forgetAccount: (userId) => limiter.forgetAccount(accountKey(userId)),
|
|
2651
|
-
sendRevocations
|
|
4253
|
+
sendRevocations,
|
|
4254
|
+
...options.oauth === undefined ? {} : { oauth: options.oauth }
|
|
2652
4255
|
}).catch(() => {
|
|
2653
4256
|
if (!response.headersSent)
|
|
2654
4257
|
response.writeHead(500);
|
|
@@ -2764,6 +4367,12 @@ async function readStdin(limit) {
|
|
|
2764
4367
|
}
|
|
2765
4368
|
return Buffer.concat(chunks).toString("utf8");
|
|
2766
4369
|
}
|
|
4370
|
+
async function passwordFromStdin(command) {
|
|
4371
|
+
if (process.stdin.isTTY) {
|
|
4372
|
+
throw new Error(`pipe the password on stdin, e.g. \`printenv NEW_PINOMAD_SERVER_PASSWORD | ${commandHint("server")} ${command}\``);
|
|
4373
|
+
}
|
|
4374
|
+
return (await readStdin(16 * 1024)).replace(/[\r\n]+$/, "");
|
|
4375
|
+
}
|
|
2767
4376
|
async function passwdMain(argv) {
|
|
2768
4377
|
const { values } = parseArgs({
|
|
2769
4378
|
args: [...argv],
|
|
@@ -2773,18 +4382,36 @@ async function passwdMain(argv) {
|
|
|
2773
4382
|
},
|
|
2774
4383
|
strict: true
|
|
2775
4384
|
});
|
|
2776
|
-
if (process.stdin.isTTY) {
|
|
2777
|
-
console.error("pipe the new password on stdin, e.g. `printenv NEW_PINOMAD_SERVER_PASSWORD | " + commandHint("server") + " passwd`");
|
|
2778
|
-
process.exit(1);
|
|
2779
|
-
}
|
|
2780
4385
|
const dataDir = resolveDataDir(values["data-dir"]);
|
|
2781
|
-
const password =
|
|
4386
|
+
const password = await passwordFromStdin("passwd");
|
|
2782
4387
|
await resetPassword({ dataDir, user: values.user, password });
|
|
2783
4388
|
console.log(JSON.stringify({ event: "passwd", dataDir }));
|
|
2784
4389
|
}
|
|
4390
|
+
async function userMain(argv) {
|
|
4391
|
+
const { values, positionals } = parseArgs({
|
|
4392
|
+
args: [...argv],
|
|
4393
|
+
options: { "data-dir": { type: "string" } },
|
|
4394
|
+
allowPositionals: true,
|
|
4395
|
+
strict: true
|
|
4396
|
+
});
|
|
4397
|
+
const dataDir = resolveDataDir(values["data-dir"]);
|
|
4398
|
+
const [command, ...rest] = positionals;
|
|
4399
|
+
if (command === "add" && rest.length === 1) {
|
|
4400
|
+
await addUser({ dataDir, email: rest[0], password: await passwordFromStdin(`user add ${rest[0]}`) });
|
|
4401
|
+
console.log(JSON.stringify({ event: "user-added", dataDir }));
|
|
4402
|
+
return;
|
|
4403
|
+
}
|
|
4404
|
+
if (command === "set-email" && rest.length === 2) {
|
|
4405
|
+
await setUserEmail({ dataDir, user: rest[0], email: rest[1] });
|
|
4406
|
+
console.log(JSON.stringify({ event: "email-set", dataDir }));
|
|
4407
|
+
return;
|
|
4408
|
+
}
|
|
4409
|
+
throw new Error(`usage: ${commandHint("server")} user add EMAIL | user set-email NAME EMAIL [--data-dir DIR]`);
|
|
4410
|
+
}
|
|
2785
4411
|
async function main(argv) {
|
|
2786
|
-
|
|
2787
|
-
|
|
4412
|
+
const subcommand = argv[0] === "passwd" ? passwdMain : argv[0] === "user" ? userMain : undefined;
|
|
4413
|
+
if (subcommand !== undefined) {
|
|
4414
|
+
await subcommand(argv.slice(1)).catch((error) => {
|
|
2788
4415
|
console.error(error instanceof Error ? error.message : String(error));
|
|
2789
4416
|
process.exit(1);
|
|
2790
4417
|
});
|
|
@@ -2803,8 +4430,13 @@ async function main(argv) {
|
|
|
2803
4430
|
});
|
|
2804
4431
|
if (values["public-origin"] === undefined) {
|
|
2805
4432
|
console.error(`usage: ${commandHint("server")} --public-origin ORIGIN [--data-dir DIR] [--trust-proxy] [--port 7430] [--listen 127.0.0.1]
|
|
2806
|
-
` + ` ${commandHint("server")} passwd [--data-dir DIR] [--user NAME] (reset a password, server running or not; new password on stdin)
|
|
2807
|
-
` + `
|
|
4433
|
+
` + ` ${commandHint("server")} passwd [--data-dir DIR] [--user EMAIL|NAME] (reset a password, server running or not; new password on stdin)
|
|
4434
|
+
` + ` ${commandHint("server")} user add EMAIL [--data-dir DIR] (add an account; its password on stdin)
|
|
4435
|
+
` + ` ${commandHint("server")} user set-email NAME EMAIL [--data-dir DIR] (give an account its email, taken as verified)
|
|
4436
|
+
` + ` env: PINOMAD_SERVER_USER, PINOMAD_SERVER_PASSWORD, PINOMAD_SERVER_DATA_DIR,
|
|
4437
|
+
` + ` PINOMAD_SERVER_REGISTRATION (open|closed), PINOMAD_SERVER_SMTP_URL, PINOMAD_SERVER_MAIL_FROM,
|
|
4438
|
+
` + ` PINOMAD_SERVER_GOOGLE_CLIENT_ID/_SECRET, PINOMAD_SERVER_GITHUB_CLIENT_ID/_SECRET,
|
|
4439
|
+
` + ` PINOMAD_SERVER_APPLE_SERVICES_ID/_TEAM_ID/_KEY_ID/_PRIVATE_KEY [/_BUNDLE_ID]
|
|
2808
4440
|
` + " relay limits (flag or env, 0 = off): " + RELAY_LIMIT_OPTIONS.map((option) => `--${option.flag} / ${option.env}`).join(", "));
|
|
2809
4441
|
process.exit(1);
|
|
2810
4442
|
}
|
|
@@ -2815,10 +4447,25 @@ async function main(argv) {
|
|
|
2815
4447
|
console.error(error instanceof Error ? error.message : String(error));
|
|
2816
4448
|
process.exit(1);
|
|
2817
4449
|
}
|
|
4450
|
+
const registration = process.env.PINOMAD_SERVER_REGISTRATION ?? "closed";
|
|
4451
|
+
let mailer;
|
|
4452
|
+
let oauth;
|
|
4453
|
+
try {
|
|
4454
|
+
if (registration !== "open" && registration !== "closed") {
|
|
4455
|
+
throw new Error(`PINOMAD_SERVER_REGISTRATION must be open or closed, not ${registration}`);
|
|
4456
|
+
}
|
|
4457
|
+
mailer = loadMailer();
|
|
4458
|
+
oauth = createOAuth(loadOAuthConfig());
|
|
4459
|
+
} catch (error) {
|
|
4460
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
4461
|
+
process.exit(1);
|
|
4462
|
+
}
|
|
2818
4463
|
const accounts = await openAccounts({
|
|
2819
4464
|
dataDir: resolveDataDir(values["data-dir"]),
|
|
2820
4465
|
bootstrapUser: process.env.PINOMAD_SERVER_USER,
|
|
2821
|
-
bootstrapPassword: process.env.PINOMAD_SERVER_PASSWORD
|
|
4466
|
+
bootstrapPassword: process.env.PINOMAD_SERVER_PASSWORD,
|
|
4467
|
+
registration,
|
|
4468
|
+
...mailer === undefined ? {} : { mailer }
|
|
2822
4469
|
}).catch((error) => {
|
|
2823
4470
|
console.error(error instanceof Error ? error.message : String(error));
|
|
2824
4471
|
process.exit(1);
|
|
@@ -2833,6 +4480,7 @@ async function main(argv) {
|
|
|
2833
4480
|
accounts,
|
|
2834
4481
|
trustProxy: values["trust-proxy"],
|
|
2835
4482
|
...apns === undefined ? {} : { push: apns },
|
|
4483
|
+
oauth,
|
|
2836
4484
|
limits
|
|
2837
4485
|
});
|
|
2838
4486
|
console.log(JSON.stringify({
|
|
@@ -2840,6 +4488,9 @@ async function main(argv) {
|
|
|
2840
4488
|
url: relay.url,
|
|
2841
4489
|
publicOrigin: values["public-origin"],
|
|
2842
4490
|
push: apns?.environments ?? [],
|
|
4491
|
+
registration: accounts.registrationOpen ? "open" : "closed",
|
|
4492
|
+
mail: accounts.emailEnabled,
|
|
4493
|
+
providers: oauth.enabled,
|
|
2843
4494
|
relayLimits: { ...DEFAULT_RELAY_LIMITS, ...limits }
|
|
2844
4495
|
}));
|
|
2845
4496
|
let stopping = false;
|