pinomad 0.13.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-kmzzjv53.js → chunk-1mjbt2z2.js} +143 -10
- package/dist/{chunk-s0h7c7da.js → chunk-1rdbnvy6.js} +1 -1
- package/dist/{chunk-53xv3fny.js → chunk-35szq73k.js} +2446 -416
- package/dist/{chunk-aapsc9g0.js → chunk-5j7ybhjj.js} +1952 -295
- package/dist/{chunk-m51mq8vh.js → chunk-5pv0g1nj.js} +12 -10
- package/dist/{chunk-7mn38n4f.js → chunk-63ymn5c9.js} +2 -2
- package/dist/{chunk-563fnad9.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-d9mq5d46.js → chunk-b0nwq1dt.js} +10 -8
- package/dist/chunk-dsr19q5h.js +23 -0
- package/dist/{chunk-6th9xv39.js → chunk-e53c0s8z.js} +9 -7
- package/dist/{chunk-4fferbxw.js → chunk-e59k3bmz.js} +213 -30
- package/dist/chunk-eerpgvy9.js +315 -0
- package/dist/chunk-eewxy4cp.js +240 -0
- package/dist/{chunk-maqv34cs.js → chunk-evbv1fkn.js} +3 -3
- package/dist/{chunk-ra86wjp5.js → chunk-ex5yr09h.js} +11 -6
- package/dist/{chunk-t91zt5f0.js → chunk-hg9gs396.js} +1 -1
- package/dist/{chunk-90g1cg9n.js → chunk-mtkvft88.js} +1 -1
- package/dist/{chunk-wd7aracm.js → chunk-mxwcnnkg.js} +1 -303
- package/dist/{chunk-d4tq62yr.js → chunk-w927q9m1.js} +12 -11
- package/dist/pinomad.js +12 -12
- package/package.json +5 -1
- package/web/assets/{core-BHm2gdHL.js → core-BWHGsFpV.js} +1 -1
- package/web/assets/{highlighted-diff-DrHvSLK9.js → highlighted-diff-DJ7KZCpS.js} +2 -2
- package/web/assets/{highlights-1aLwssz-.js → highlights-CyEmo6OI.js} +1 -1
- package/web/assets/{highlights-themes-C1p4pFYS.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-BwyCm3kZ.js → shiki-BzCmRG-v.js} +2 -2
- package/web/assets/{shiki-langs-CMlALLSg.js → shiki-langs-DOkjyV4d.js} +1 -1
- package/web/assets/{stream-tokenizer-BlgIhw0y.js → stream-tokenizer-CBPqFe4Z.js} +1 -1
- package/web/assets/{theming-themes-DDvoqEqT.js → theming-themes-wKVca0aB.js} +1 -1
- package/web/assets/{xterm-view-BnKOnF3C.js → xterm-view-CKa1-QCM.js} +12 -12
- package/web/index.html +3 -2
- package/web/theme-boot.js +11 -0
- package/dist/chunk-sd640dt7.js +0 -22
- package/dist/chunk-t6fw0wa9.js +0 -96
- package/web/assets/index-A_AUJoO6.css +0 -1
- package/web/assets/index-BafYMKiN.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."));
|
|
@@ -1618,23 +2687,44 @@ async function authedJson(accounts, request, token, optional = false) {
|
|
|
1618
2687
|
return { ok: false, status: 401, error: "unauthenticated" };
|
|
1619
2688
|
return { ok: true, value: body.value, session };
|
|
1620
2689
|
}
|
|
1621
|
-
function
|
|
1622
|
-
const authorization = request.headers.authorization;
|
|
1623
|
-
if (authorization !== undefined && authorization.startsWith("Bearer "))
|
|
1624
|
-
return authorization.slice("Bearer ".length);
|
|
2690
|
+
function cookie(request, name) {
|
|
1625
2691
|
const header = request.headers.cookie;
|
|
1626
2692
|
if (header === undefined)
|
|
1627
2693
|
return;
|
|
1628
2694
|
for (const part of header.split(";")) {
|
|
1629
2695
|
const eq = part.indexOf("=");
|
|
1630
|
-
if (eq > 0 && part.slice(0, eq).trim() ===
|
|
2696
|
+
if (eq > 0 && part.slice(0, eq).trim() === name)
|
|
1631
2697
|
return part.slice(eq + 1).trim();
|
|
1632
2698
|
}
|
|
1633
2699
|
return;
|
|
1634
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
|
+
}
|
|
1635
2707
|
var cookieAttributes = "Path=/; Secure; HttpOnly; SameSite=Strict";
|
|
1636
2708
|
var setSessionCookie = (token) => `${SESSION_COOKIE}=${token}; ${cookieAttributes}`;
|
|
1637
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
|
+
}
|
|
1638
2728
|
async function handleApiRequest(request, response, ctx) {
|
|
1639
2729
|
const { accounts } = ctx;
|
|
1640
2730
|
const origin = request.headers.origin;
|
|
@@ -1648,6 +2738,26 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
1648
2738
|
const cors = origin === DESKTOP_ORIGIN ? { "access-control-allow-origin": DESKTOP_ORIGIN, vary: "Origin" } : {};
|
|
1649
2739
|
const send = (status, body, headers) => respond(response, status, body, { ...cors, ...headers });
|
|
1650
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
|
+
};
|
|
1651
2761
|
if (!secureTransport(request, ctx.trustProxy)) {
|
|
1652
2762
|
deny(403, "https_required");
|
|
1653
2763
|
return;
|
|
@@ -1660,9 +2770,10 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
1660
2770
|
return;
|
|
1661
2771
|
}
|
|
1662
2772
|
const bearer = (request.headers.authorization ?? "").startsWith("Bearer ");
|
|
1663
|
-
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";
|
|
1664
2775
|
const hostLinkHostMode = (pathname === "/api/host-link/start" || pathname === "/api/host-link/poll" || pathname === "/api/host-link/token") && origin === undefined;
|
|
1665
|
-
if (MUTATING.has(method) && !bearer && !loginTokenMode && !hostLinkHostMode && origin !== ctx.publicOrigin) {
|
|
2776
|
+
if (MUTATING.has(method) && !bearer && !loginTokenMode && !hostLinkHostMode && !appleFormPost && origin !== ctx.publicOrigin) {
|
|
1666
2777
|
deny(403, "origin_mismatch");
|
|
1667
2778
|
return;
|
|
1668
2779
|
}
|
|
@@ -1696,50 +2807,562 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
1696
2807
|
deny(401, "invalid_credentials");
|
|
1697
2808
|
return;
|
|
1698
2809
|
}
|
|
1699
|
-
|
|
1700
|
-
send(200, {
|
|
1701
|
-
user: { username },
|
|
1702
|
-
...result.admin ? { admin: true } : {},
|
|
1703
|
-
expiresAt: result.expiresAt,
|
|
1704
|
-
...cookieMode ? {} : { token: result.token }
|
|
1705
|
-
}, cookieMode ? { "set-cookie": setSessionCookie(result.token) } : undefined);
|
|
2810
|
+
signedIn(result);
|
|
1706
2811
|
return;
|
|
1707
2812
|
}
|
|
1708
|
-
if (pathname === "/api/
|
|
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]);
|
|
2981
|
+
return;
|
|
2982
|
+
}
|
|
2983
|
+
if (pathname === "/api/oauth/token" || pathname === "/api/oauth/apple/native") {
|
|
1709
2984
|
if (method !== "POST") {
|
|
1710
2985
|
deny(405, "method_not_allowed", { allow: "POST" });
|
|
1711
2986
|
return;
|
|
1712
2987
|
}
|
|
2988
|
+
if (pathname === "/api/oauth/apple/native" && oauth?.appleNative !== true) {
|
|
2989
|
+
deny(404, "not_found");
|
|
2990
|
+
return;
|
|
2991
|
+
}
|
|
1713
2992
|
const body = await readJson(request);
|
|
1714
2993
|
if (!body.ok) {
|
|
1715
2994
|
deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
|
|
1716
2995
|
return;
|
|
1717
2996
|
}
|
|
1718
|
-
const value = body.value;
|
|
1719
|
-
const device = parseDeviceClaim(value
|
|
1720
|
-
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) {
|
|
3244
|
+
deny(400, "invalid_request");
|
|
3245
|
+
return;
|
|
3246
|
+
}
|
|
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) {
|
|
1721
3275
|
deny(400, "invalid_request");
|
|
1722
3276
|
return;
|
|
1723
3277
|
}
|
|
1724
|
-
const
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
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",
|
|
1729
3287
|
ip: clientIp(request, ctx.trustProxy),
|
|
1730
|
-
|
|
3288
|
+
bindingHash: sha256Hex2(binding),
|
|
3289
|
+
linkUserId: current.userId,
|
|
3290
|
+
linkSessionToken: token,
|
|
3291
|
+
...native === undefined ? {} : { native }
|
|
1731
3292
|
});
|
|
1732
|
-
if (
|
|
3293
|
+
if (flow.status === "limited")
|
|
1733
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" });
|
|
1734
3313
|
return;
|
|
1735
3314
|
}
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
deny(
|
|
3315
|
+
const body = await readJson(request);
|
|
3316
|
+
if (!body.ok) {
|
|
3317
|
+
deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
|
|
1739
3318
|
return;
|
|
1740
3319
|
}
|
|
1741
|
-
const
|
|
1742
|
-
|
|
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);
|
|
1743
3366
|
return;
|
|
1744
3367
|
}
|
|
1745
3368
|
if (pathname === "/api/host-link/start" || pathname === "/api/host-link/poll" || pathname === "/api/host-link/token") {
|
|
@@ -1867,49 +3490,6 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
1867
3490
|
send(204);
|
|
1868
3491
|
return;
|
|
1869
3492
|
}
|
|
1870
|
-
if (pathname === "/api/invites" || pathname === "/api/invites/revoke") {
|
|
1871
|
-
const allowed = pathname === "/api/invites" ? ["GET", "POST"] : ["POST"];
|
|
1872
|
-
if (!allowed.includes(method)) {
|
|
1873
|
-
deny(405, "method_not_allowed", { allow: allowed.join(", ") });
|
|
1874
|
-
return;
|
|
1875
|
-
}
|
|
1876
|
-
const token = sessionToken(request);
|
|
1877
|
-
const session = token === undefined ? undefined : accounts.authenticate(token);
|
|
1878
|
-
if (session === undefined) {
|
|
1879
|
-
deny(401, "unauthenticated");
|
|
1880
|
-
return;
|
|
1881
|
-
}
|
|
1882
|
-
if (!session.admin) {
|
|
1883
|
-
deny(403, "forbidden");
|
|
1884
|
-
return;
|
|
1885
|
-
}
|
|
1886
|
-
if (pathname === "/api/invites") {
|
|
1887
|
-
if (method === "GET")
|
|
1888
|
-
send(200, accounts.listInvites());
|
|
1889
|
-
else
|
|
1890
|
-
send(200, accounts.createInvite(session.userId));
|
|
1891
|
-
return;
|
|
1892
|
-
}
|
|
1893
|
-
const body = await authedJson(accounts, request, token);
|
|
1894
|
-
if (!body.ok) {
|
|
1895
|
-
deny(body.status, body.error);
|
|
1896
|
-
return;
|
|
1897
|
-
}
|
|
1898
|
-
if (!body.session.admin) {
|
|
1899
|
-
deny(401, "unauthenticated");
|
|
1900
|
-
return;
|
|
1901
|
-
}
|
|
1902
|
-
const id = body.value?.id;
|
|
1903
|
-
if (typeof id !== "number" || !Number.isSafeInteger(id)) {
|
|
1904
|
-
deny(400, "invalid_request");
|
|
1905
|
-
return;
|
|
1906
|
-
}
|
|
1907
|
-
if (accounts.revokeInvite(id))
|
|
1908
|
-
send(204);
|
|
1909
|
-
else
|
|
1910
|
-
deny(404, "not_found");
|
|
1911
|
-
return;
|
|
1912
|
-
}
|
|
1913
3493
|
const hostRemove = /^\/api\/hosts\/([A-Za-z0-9_-]{43})\/remove$/.exec(pathname);
|
|
1914
3494
|
if (hostRemove !== null) {
|
|
1915
3495
|
if (method !== "POST") {
|
|
@@ -1926,18 +3506,13 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
1926
3506
|
deny(body.status, body.error);
|
|
1927
3507
|
return;
|
|
1928
3508
|
}
|
|
1929
|
-
const
|
|
1930
|
-
if (
|
|
3509
|
+
const confirmation = bodyConfirmation(body.value, token);
|
|
3510
|
+
if (confirmation === undefined) {
|
|
1931
3511
|
deny(400, "invalid_request");
|
|
1932
3512
|
return;
|
|
1933
3513
|
}
|
|
1934
3514
|
const current = body.session;
|
|
1935
|
-
const result = await accounts.removeHost({
|
|
1936
|
-
userId: current.userId,
|
|
1937
|
-
username: current.username,
|
|
1938
|
-
password: value.password,
|
|
1939
|
-
hostId: hostRemove[1]
|
|
1940
|
-
});
|
|
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;
|
|
@@ -2049,18 +3624,17 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
2049
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
3632
|
const current = body.session;
|
|
2058
3633
|
const result = await accounts.kickDevice({
|
|
2059
3634
|
userId: current.userId,
|
|
2060
|
-
username: current.username,
|
|
2061
|
-
password: value.password,
|
|
2062
3635
|
deviceId: Number(deviceKick[1]),
|
|
2063
|
-
...current.deviceId === undefined ? {} : { currentDeviceId: current.deviceId }
|
|
3636
|
+
...current.deviceId === undefined ? {} : { currentDeviceId: current.deviceId },
|
|
3637
|
+
...confirmation
|
|
2064
3638
|
});
|
|
2065
3639
|
if (result.status === "limited") {
|
|
2066
3640
|
deny(429, "rate_limited");
|
|
@@ -2075,7 +3649,7 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
2075
3649
|
send(204);
|
|
2076
3650
|
return;
|
|
2077
3651
|
}
|
|
2078
|
-
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") {
|
|
2079
3653
|
const expected = pathname === "/api/me" ? "GET" : "POST";
|
|
2080
3654
|
if (method !== expected) {
|
|
2081
3655
|
deny(405, "method_not_allowed", { allow: expected });
|
|
@@ -2088,7 +3662,43 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
2088
3662
|
return;
|
|
2089
3663
|
}
|
|
2090
3664
|
if (pathname === "/api/me") {
|
|
2091
|
-
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));
|
|
2092
3702
|
return;
|
|
2093
3703
|
}
|
|
2094
3704
|
if (pathname === "/api/push-tokens" || pathname === "/api/push-tokens/remove") {
|
|
@@ -2119,23 +3729,25 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
2119
3729
|
return;
|
|
2120
3730
|
}
|
|
2121
3731
|
const current = body.session;
|
|
2122
|
-
const
|
|
2123
|
-
if (
|
|
3732
|
+
const confirmation = bodyConfirmation(body.value, token);
|
|
3733
|
+
if (confirmation === undefined) {
|
|
2124
3734
|
deny(400, "invalid_request");
|
|
2125
3735
|
return;
|
|
2126
3736
|
}
|
|
2127
|
-
const result = await accounts.deleteAccount({ userId: current.userId,
|
|
3737
|
+
const result = await accounts.deleteAccount({ userId: current.userId, keepToken: token, ...confirmation });
|
|
2128
3738
|
if (result.status === "limited") {
|
|
2129
3739
|
deny(429, "rate_limited");
|
|
2130
3740
|
return;
|
|
2131
3741
|
}
|
|
2132
3742
|
if (result.status === "refused") {
|
|
2133
|
-
deny(403, result.reason);
|
|
3743
|
+
deny(result.reason === "unauthenticated" ? 401 : 403, result.reason);
|
|
2134
3744
|
return;
|
|
2135
3745
|
}
|
|
2136
3746
|
for (const hostId of result.hostIds)
|
|
2137
3747
|
ctx.disconnectHost(hostId);
|
|
2138
3748
|
ctx.forgetAccount(current.userId);
|
|
3749
|
+
for (const grant of result.appleGrants)
|
|
3750
|
+
oauth?.revokeApple(grant.refreshToken, grant.clientId);
|
|
2139
3751
|
send(204, undefined, { "set-cookie": clearSessionCookie });
|
|
2140
3752
|
return;
|
|
2141
3753
|
}
|
|
@@ -2147,23 +3759,19 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
2147
3759
|
}
|
|
2148
3760
|
const current = body.session;
|
|
2149
3761
|
const value = body.value;
|
|
2150
|
-
|
|
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") {
|
|
2151
3764
|
deny(400, "invalid_request");
|
|
2152
3765
|
return;
|
|
2153
3766
|
}
|
|
2154
|
-
const result = await accounts.changePassword({
|
|
2155
|
-
userId: current.userId,
|
|
2156
|
-
username: current.username,
|
|
2157
|
-
keepToken: token,
|
|
2158
|
-
currentPassword: value.currentPassword,
|
|
2159
|
-
newPassword: value.newPassword
|
|
2160
|
-
});
|
|
3767
|
+
const result = await accounts.changePassword({ userId: current.userId, keepToken: token, newPassword: value.newPassword, ...proof });
|
|
2161
3768
|
if (result.status === "limited") {
|
|
2162
3769
|
deny(429, "rate_limited");
|
|
2163
3770
|
return;
|
|
2164
3771
|
}
|
|
2165
3772
|
if (result.status === "refused") {
|
|
2166
|
-
|
|
3773
|
+
const status = result.reason === "weak_password" ? 400 : result.reason === "unauthenticated" ? 401 : 403;
|
|
3774
|
+
deny(status, result.reason);
|
|
2167
3775
|
return;
|
|
2168
3776
|
}
|
|
2169
3777
|
send(204);
|
|
@@ -2306,7 +3914,7 @@ async function startRelay(options) {
|
|
|
2306
3914
|
return Promise.resolve({ status: "too_many" });
|
|
2307
3915
|
}
|
|
2308
3916
|
return new Promise((resolve) => {
|
|
2309
|
-
const requestId =
|
|
3917
|
+
const requestId = randomBytes3(16).toString("base64url");
|
|
2310
3918
|
const settle = (result) => {
|
|
2311
3919
|
if (!pairingRequests.delete(requestId))
|
|
2312
3920
|
return;
|
|
@@ -2426,7 +4034,7 @@ async function startRelay(options) {
|
|
|
2426
4034
|
}
|
|
2427
4035
|
function onControl(socket) {
|
|
2428
4036
|
track(socket);
|
|
2429
|
-
const nonce =
|
|
4037
|
+
const nonce = randomBytes3(32).toString("base64url");
|
|
2430
4038
|
socket.send(encodeRelayFrame({ t: "challenge", nonce }));
|
|
2431
4039
|
let hostId;
|
|
2432
4040
|
const deadline = setTimeout(() => socket.close(1008, "register timeout"), registerTimeoutMs);
|
|
@@ -2490,13 +4098,13 @@ async function startRelay(options) {
|
|
|
2490
4098
|
}
|
|
2491
4099
|
let noisePublicKey;
|
|
2492
4100
|
if (frame.signatureV2 !== undefined) {
|
|
2493
|
-
const
|
|
4101
|
+
const bound2 = frame.noisePublicKey !== undefined && verifyRelayChallengeV2(frame.signatureV2, {
|
|
2494
4102
|
relayOrigin: publicOrigin,
|
|
2495
4103
|
hostId: frame.hostId,
|
|
2496
4104
|
nonce,
|
|
2497
4105
|
noisePublicKey: frame.noisePublicKey
|
|
2498
4106
|
});
|
|
2499
|
-
if (!
|
|
4107
|
+
if (!bound2) {
|
|
2500
4108
|
socket.close(RELAY_CLOSE.badSignature);
|
|
2501
4109
|
return;
|
|
2502
4110
|
}
|
|
@@ -2570,7 +4178,7 @@ async function startRelay(options) {
|
|
|
2570
4178
|
if (devices.size === 0 && hostDevices.get(hostId) === devices)
|
|
2571
4179
|
hostDevices.delete(hostId);
|
|
2572
4180
|
});
|
|
2573
|
-
const connId =
|
|
4181
|
+
const connId = randomBytes3(16).toString("base64url");
|
|
2574
4182
|
const entry = {
|
|
2575
4183
|
control,
|
|
2576
4184
|
socket,
|
|
@@ -2642,7 +4250,8 @@ async function startRelay(options) {
|
|
|
2642
4250
|
pairing,
|
|
2643
4251
|
disconnectHost,
|
|
2644
4252
|
forgetAccount: (userId) => limiter.forgetAccount(accountKey(userId)),
|
|
2645
|
-
sendRevocations
|
|
4253
|
+
sendRevocations,
|
|
4254
|
+
...options.oauth === undefined ? {} : { oauth: options.oauth }
|
|
2646
4255
|
}).catch(() => {
|
|
2647
4256
|
if (!response.headersSent)
|
|
2648
4257
|
response.writeHead(500);
|
|
@@ -2758,6 +4367,12 @@ async function readStdin(limit) {
|
|
|
2758
4367
|
}
|
|
2759
4368
|
return Buffer.concat(chunks).toString("utf8");
|
|
2760
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
|
+
}
|
|
2761
4376
|
async function passwdMain(argv) {
|
|
2762
4377
|
const { values } = parseArgs({
|
|
2763
4378
|
args: [...argv],
|
|
@@ -2767,18 +4382,36 @@ async function passwdMain(argv) {
|
|
|
2767
4382
|
},
|
|
2768
4383
|
strict: true
|
|
2769
4384
|
});
|
|
2770
|
-
if (process.stdin.isTTY) {
|
|
2771
|
-
console.error("pipe the new password on stdin, e.g. `printenv NEW_PINOMAD_SERVER_PASSWORD | " + commandHint("server") + " passwd`");
|
|
2772
|
-
process.exit(1);
|
|
2773
|
-
}
|
|
2774
4385
|
const dataDir = resolveDataDir(values["data-dir"]);
|
|
2775
|
-
const password =
|
|
4386
|
+
const password = await passwordFromStdin("passwd");
|
|
2776
4387
|
await resetPassword({ dataDir, user: values.user, password });
|
|
2777
4388
|
console.log(JSON.stringify({ event: "passwd", dataDir }));
|
|
2778
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
|
+
}
|
|
2779
4411
|
async function main(argv) {
|
|
2780
|
-
|
|
2781
|
-
|
|
4412
|
+
const subcommand = argv[0] === "passwd" ? passwdMain : argv[0] === "user" ? userMain : undefined;
|
|
4413
|
+
if (subcommand !== undefined) {
|
|
4414
|
+
await subcommand(argv.slice(1)).catch((error) => {
|
|
2782
4415
|
console.error(error instanceof Error ? error.message : String(error));
|
|
2783
4416
|
process.exit(1);
|
|
2784
4417
|
});
|
|
@@ -2797,8 +4430,13 @@ async function main(argv) {
|
|
|
2797
4430
|
});
|
|
2798
4431
|
if (values["public-origin"] === undefined) {
|
|
2799
4432
|
console.error(`usage: ${commandHint("server")} --public-origin ORIGIN [--data-dir DIR] [--trust-proxy] [--port 7430] [--listen 127.0.0.1]
|
|
2800
|
-
` + ` ${commandHint("server")} passwd [--data-dir DIR] [--user NAME] (reset a password, server running or not; new password on stdin)
|
|
2801
|
-
` + `
|
|
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]
|
|
2802
4440
|
` + " relay limits (flag or env, 0 = off): " + RELAY_LIMIT_OPTIONS.map((option) => `--${option.flag} / ${option.env}`).join(", "));
|
|
2803
4441
|
process.exit(1);
|
|
2804
4442
|
}
|
|
@@ -2809,10 +4447,25 @@ async function main(argv) {
|
|
|
2809
4447
|
console.error(error instanceof Error ? error.message : String(error));
|
|
2810
4448
|
process.exit(1);
|
|
2811
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
|
+
}
|
|
2812
4463
|
const accounts = await openAccounts({
|
|
2813
4464
|
dataDir: resolveDataDir(values["data-dir"]),
|
|
2814
4465
|
bootstrapUser: process.env.PINOMAD_SERVER_USER,
|
|
2815
|
-
bootstrapPassword: process.env.PINOMAD_SERVER_PASSWORD
|
|
4466
|
+
bootstrapPassword: process.env.PINOMAD_SERVER_PASSWORD,
|
|
4467
|
+
registration,
|
|
4468
|
+
...mailer === undefined ? {} : { mailer }
|
|
2816
4469
|
}).catch((error) => {
|
|
2817
4470
|
console.error(error instanceof Error ? error.message : String(error));
|
|
2818
4471
|
process.exit(1);
|
|
@@ -2827,6 +4480,7 @@ async function main(argv) {
|
|
|
2827
4480
|
accounts,
|
|
2828
4481
|
trustProxy: values["trust-proxy"],
|
|
2829
4482
|
...apns === undefined ? {} : { push: apns },
|
|
4483
|
+
oauth,
|
|
2830
4484
|
limits
|
|
2831
4485
|
});
|
|
2832
4486
|
console.log(JSON.stringify({
|
|
@@ -2834,6 +4488,9 @@ async function main(argv) {
|
|
|
2834
4488
|
url: relay.url,
|
|
2835
4489
|
publicOrigin: values["public-origin"],
|
|
2836
4490
|
push: apns?.environments ?? [],
|
|
4491
|
+
registration: accounts.registrationOpen ? "open" : "closed",
|
|
4492
|
+
mail: accounts.emailEnabled,
|
|
4493
|
+
providers: oauth.enabled,
|
|
2837
4494
|
relayLimits: { ...DEFAULT_RELAY_LIMITS, ...limits }
|
|
2838
4495
|
}));
|
|
2839
4496
|
let stopping = false;
|