@camstack/system 1.2.454 → 1.2.456
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/{agent-https-oXsR24R2.mjs → agent-https-fm4AR8XL.mjs} +32 -5
- package/dist/{agent-https-B7IjRRzT.js → agent-https-yjxdfJDA.js} +37 -4
- package/dist/auth/auth-audit-log.d.ts +1 -1
- package/dist/auth/auth-manager.d.ts +16 -1
- package/dist/auth/legacy-token-tables.d.ts +16 -0
- package/dist/auth/user-management-internal.d.ts +9 -28
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +2 -2
- package/dist/builtins/alerts/alerts.addon.mjs +2 -2
- package/dist/builtins/autotrack/index.js +1 -1
- package/dist/builtins/autotrack/index.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +2 -2
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2 -2
- package/dist/builtins/calendars/calendars.addon.js +1 -1
- package/dist/builtins/calendars/calendars.addon.mjs +1 -1
- package/dist/builtins/camera-grid/index.js +1 -1
- package/dist/builtins/camera-grid/index.mjs +1 -1
- package/dist/builtins/composer/composer.addon.js +2 -2
- package/dist/builtins/composer/composer.addon.mjs +2 -2
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
- package/dist/builtins/device-history/device-history.addon.js +1 -1
- package/dist/builtins/device-history/device-history.addon.mjs +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +1 -1
- package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/keychain/index.js +4 -4
- package/dist/builtins/keychain/index.mjs +4 -4
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/auth-schema.d.ts +4 -7
- package/dist/builtins/local-auth/local-auth.addon.d.ts +7 -8
- package/dist/builtins/local-auth/local-auth.addon.js +1094 -1327
- package/dist/builtins/local-auth/local-auth.addon.mjs +1093 -1324
- package/dist/builtins/local-auth/preferences-record-kinds.d.ts +27 -0
- package/dist/builtins/local-auth/record-kinds.d.ts +4 -3
- package/dist/builtins/local-auth/user-preferences-store.d.ts +56 -0
- package/dist/builtins/local-network/local-network.addon.d.ts +8 -0
- package/dist/builtins/local-network/local-network.addon.js +30 -5
- package/dist/builtins/local-network/local-network.addon.mjs +30 -5
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/record-entries/index.js +1 -1
- package/dist/builtins/record-entries/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/index.js +1 -1
- package/dist/builtins/sqlite-storage/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/records/records-engine-types.d.ts +1 -0
- package/dist/builtins/sqlite-storage/records/records-provider.d.ts +13 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +52 -6
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +52 -6
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +3 -3
- package/dist/builtins/system-config/system-config.addon.mjs +3 -3
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{composition-sources-bt6IQDLu.mjs → composition-sources-Ds0Vr6eD.mjs} +1 -1
- package/dist/{composition-sources-Cukc78eu.js → composition-sources-gB4coDJR.js} +1 -1
- package/dist/{constants-DBGG6Zu5.js → constants-CavyKfHZ.js} +1 -1
- package/dist/{constants-DQcVrhVI.mjs → constants-i5oD_gLB.mjs} +1 -1
- package/dist/{device-ca-B3hUVfYk.js → device-ca-bpoEr0zG.js} +1 -1
- package/dist/{device-ca-Utfvsx5x.mjs → device-ca-qz1Pmeu1.mjs} +1 -1
- package/dist/{dist-7ZciHCIp.mjs → dist-BSF_C8W2.mjs} +446 -300
- package/dist/{dist-Bi4Ioxop.js → dist-BpqA2CT0.js} +487 -299
- package/dist/file-host-worker.js +1 -1
- package/dist/file-host-worker.mjs +1 -1
- package/dist/{hub-mdns-ciao-BRs3DUT-.mjs → hub-mdns-ciao-CHaGDzyz.mjs} +20 -13
- package/dist/{hub-mdns-ciao-2FzLWfbD.js → hub-mdns-ciao-CZlZRxxA.js} +20 -13
- package/dist/index.d.ts +2 -4
- package/dist/index.js +17 -11
- package/dist/index.mjs +17 -9
- package/dist/{phase6-declare-gate-D-2LMw9o.mjs → phase6-declare-gate-B4yfsf2n.mjs} +1 -1
- package/dist/{phase6-declare-gate-DwYAep7y.js → phase6-declare-gate-n-IZRklX.js} +1 -1
- package/dist/{restore-archive-C0ymYQk7.js → restore-archive-B4QG7XmX.js} +1 -1
- package/dist/{restore-archive-UVZv_hmr.mjs → restore-archive-BGUM4FKf.mjs} +1 -1
- package/dist/tls/cert-issue.d.ts +16 -1
- package/dist/tls/cert-manager.d.ts +6 -0
- package/dist/tls/index.d.ts +1 -1
- package/dist/{wal-maintenance-Df89xxrK.mjs → wal-maintenance-AerF4XE6.mjs} +0 -0
- package/dist/{wal-maintenance-Bo_lo3Dh.js → wal-maintenance-CNEs_I94.js} +0 -0
- package/package.json +1 -1
- package/dist/auth/admin-token-manager.d.ts +0 -51
- package/dist/auth/scoped-token-manager.d.ts +0 -25
|
@@ -3,12 +3,12 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-BpqA2CT0.js");
|
|
7
7
|
const require_read_all_rows$1 = require("../../read-all-rows-CVoO1ONM.js");
|
|
8
8
|
const require_safe_buffer$1 = require("../../safe-buffer-D3aoa-7X.js");
|
|
9
9
|
const require_ms$1 = require("../../ms-LB3giGoU.js");
|
|
10
|
-
const require_device_ca = require("../../device-ca-
|
|
11
|
-
const require_phase6_declare_gate = require("../../phase6-declare-gate-
|
|
10
|
+
const require_device_ca = require("../../device-ca-bpoEr0zG.js");
|
|
11
|
+
const require_phase6_declare_gate = require("../../phase6-declare-gate-n-IZRklX.js");
|
|
12
12
|
let zod = require("zod");
|
|
13
13
|
let node_crypto = require("node:crypto");
|
|
14
14
|
node_crypto = require_chunk.__toESM(node_crypto);
|
|
@@ -36,7 +36,7 @@ function parseRecord$2(kind, schema, data) {
|
|
|
36
36
|
*
|
|
37
37
|
* No boundary accepts one (the tRPC context has no branch for them) and
|
|
38
38
|
* nothing creates them any more: what is left is the rows a hub may still
|
|
39
|
-
* hold, so an operator can see and delete them.
|
|
39
|
+
* hold, so an operator can see and delete them. Tokens (`auth.mintToken`) are
|
|
40
40
|
* the credential for scripts and integrations.
|
|
41
41
|
*/
|
|
42
42
|
var import_read_all_rows = require_read_all_rows$1.require_read_all_rows();
|
|
@@ -189,7 +189,7 @@ function genSalt(rounds, seed_length, callback) {
|
|
|
189
189
|
* @returns {!Promise} If `callback` has been omitted
|
|
190
190
|
* @throws {Error} If `callback` is present but not a function
|
|
191
191
|
*/
|
|
192
|
-
function hash$
|
|
192
|
+
function hash$1(password, salt, callback, progressCallback) {
|
|
193
193
|
function _async(callback) {
|
|
194
194
|
if (typeof password === "string" && typeof salt === "number") genSalt(salt, function(err, salt) {
|
|
195
195
|
_hash(password, salt, callback, progressCallback);
|
|
@@ -242,7 +242,7 @@ function compare(password, hashValue, callback, progressCallback) {
|
|
|
242
242
|
nextTick(callback.bind(this, null, false));
|
|
243
243
|
return;
|
|
244
244
|
}
|
|
245
|
-
hash$
|
|
245
|
+
hash$1(password, hashValue.substring(0, 29), function(err, comp) {
|
|
246
246
|
if (err) callback(err);
|
|
247
247
|
else callback(null, safeStringCompare(comp, hashValue));
|
|
248
248
|
}, progressCallback);
|
|
@@ -6112,11 +6112,35 @@ var AuthManager = class {
|
|
|
6112
6112
|
return;
|
|
6113
6113
|
}
|
|
6114
6114
|
}
|
|
6115
|
+
/**
|
|
6116
|
+
* Sign a unified token (D892). No `exp`: the token's row — read by `jti` on
|
|
6117
|
+
* every verify for revocation — holds its one expiry. The claims are parsed
|
|
6118
|
+
* before signing, so nothing outside the schema is ever signed.
|
|
6119
|
+
*/
|
|
6120
|
+
signCredentialToken(claims) {
|
|
6121
|
+
return import_jsonwebtoken.sign(require_dist.CredentialTokenClaimsSchema.parse(claims), this.jwtSecret, { audience: require_dist.CREDENTIAL_TOKEN_AUDIENCE });
|
|
6122
|
+
}
|
|
6123
|
+
/**
|
|
6124
|
+
* Verify a session-family JWT: a login session, an account link, a bridge or
|
|
6125
|
+
* challenge token. A MINTED token is refused here even though it carries a
|
|
6126
|
+
* valid signature (D895): every surface that calls this wants a person's
|
|
6127
|
+
* session, and a share link or a script's token must never pass for one.
|
|
6128
|
+
* Minted tokens verify only through {@link verifyCredentialToken}.
|
|
6129
|
+
*/
|
|
6115
6130
|
verifyToken(token) {
|
|
6116
|
-
|
|
6131
|
+
const payload = import_jsonwebtoken.verify(token, this.jwtSecret);
|
|
6132
|
+
if (require_dist.isCredentialTokenPayload(payload)) throw new Error("A minted token is not a session credential");
|
|
6133
|
+
return payload;
|
|
6134
|
+
}
|
|
6135
|
+
/** Verify a minted token (`auth.mintToken`): its signature AND its audience. Throws when invalid. */
|
|
6136
|
+
verifyCredentialToken(token) {
|
|
6137
|
+
const payload = import_jsonwebtoken.verify(token, this.jwtSecret, { audience: require_dist.CREDENTIAL_TOKEN_AUDIENCE });
|
|
6138
|
+
const claims = require_dist.CredentialTokenClaimsSchema.safeParse(payload);
|
|
6139
|
+
if (!claims.success) throw new Error("Not a minted token");
|
|
6140
|
+
return claims.data;
|
|
6117
6141
|
}
|
|
6118
6142
|
async hashPassword(password) {
|
|
6119
|
-
return hash$
|
|
6143
|
+
return hash$1(password, 10);
|
|
6120
6144
|
}
|
|
6121
6145
|
async comparePassword(password, hash) {
|
|
6122
6146
|
return compare(password, hash);
|
|
@@ -6370,7 +6394,9 @@ var AUTH_AUDIT_ACTIONS = [
|
|
|
6370
6394
|
"device-credential-enrolled",
|
|
6371
6395
|
"device-credential-revoked",
|
|
6372
6396
|
"device-credential-login",
|
|
6373
|
-
"device-credential-login-refused"
|
|
6397
|
+
"device-credential-login-refused",
|
|
6398
|
+
"token-minted",
|
|
6399
|
+
"token-revoked"
|
|
6374
6400
|
];
|
|
6375
6401
|
var RETENTION_MS = 90 * 864e5;
|
|
6376
6402
|
var MAX_LIST = 500;
|
|
@@ -6505,19 +6531,11 @@ var AuthAuditLog = class {
|
|
|
6505
6531
|
}
|
|
6506
6532
|
};
|
|
6507
6533
|
//#endregion
|
|
6508
|
-
//#region src/auth/
|
|
6509
|
-
/**
|
|
6510
|
-
* Admin tokens (`csa_…`) — the one credential an ops script can hold that
|
|
6511
|
-
* acts as an administrator, minted deliberately and kept on a short leash.
|
|
6512
|
-
*
|
|
6513
|
-
* Before phase-6 cutover: mint a spare `csa_` token, verify it against the hub,
|
|
6514
|
-
* and keep the records-adopt snapshot — a boot guard mistake locks operators out.
|
|
6515
|
-
*/
|
|
6534
|
+
//#region src/auth/legacy-token-tables.ts
|
|
6516
6535
|
/**
|
|
6517
6536
|
* @durable class=config owner=local-auth
|
|
6518
|
-
* write="
|
|
6519
|
-
*
|
|
6520
|
-
* retention="the daily reap deletes rows expired or revoked more than 30 days ago"
|
|
6537
|
+
* write="none since D895 — the pre-D892 rows are kept on disk, read by nothing"
|
|
6538
|
+
* retention="the operator drops the table by hand"
|
|
6521
6539
|
*/
|
|
6522
6540
|
var ADMIN_TOKENS_COLLECTION = "admin_tokens";
|
|
6523
6541
|
var ADMIN_TOKENS_COLUMNS = [
|
|
@@ -6567,210 +6585,6 @@ var ADMIN_TOKENS_COLUMNS = [
|
|
|
6567
6585
|
type: "INTEGER"
|
|
6568
6586
|
}
|
|
6569
6587
|
];
|
|
6570
|
-
var ADMIN_TOKEN_PREFIX = "csa_";
|
|
6571
|
-
var ADMIN_TOKEN_MAX_LIFETIME_MS = 90 * 864e5;
|
|
6572
|
-
var DEAD_RETENTION_MS = 30 * 864e5;
|
|
6573
|
-
var TOUCH_EVERY_MS = 6e4;
|
|
6574
|
-
function hash$1(token) {
|
|
6575
|
-
return node_crypto.createHash("sha256").update(token).digest("hex");
|
|
6576
|
-
}
|
|
6577
|
-
function parse(data) {
|
|
6578
|
-
const { id, createdBy, name, tokenHash, tokenPrefix, createdAt, expiresAt } = data;
|
|
6579
|
-
if (typeof id !== "string" || typeof createdBy !== "string" || typeof name !== "string" || typeof tokenHash !== "string" || typeof tokenPrefix !== "string" || typeof createdAt !== "number" || typeof expiresAt !== "number") return null;
|
|
6580
|
-
return {
|
|
6581
|
-
id,
|
|
6582
|
-
createdBy,
|
|
6583
|
-
name,
|
|
6584
|
-
tokenHash,
|
|
6585
|
-
tokenPrefix,
|
|
6586
|
-
createdAt,
|
|
6587
|
-
expiresAt,
|
|
6588
|
-
lastUsedAt: typeof data["lastUsedAt"] === "number" ? data["lastUsedAt"] : null,
|
|
6589
|
-
revokedAt: typeof data["revokedAt"] === "number" ? data["revokedAt"] : null
|
|
6590
|
-
};
|
|
6591
|
-
}
|
|
6592
|
-
function parseBody(id, body) {
|
|
6593
|
-
return parse({
|
|
6594
|
-
id,
|
|
6595
|
-
...body
|
|
6596
|
-
});
|
|
6597
|
-
}
|
|
6598
|
-
function storedToBody(stored) {
|
|
6599
|
-
return {
|
|
6600
|
-
createdBy: stored.createdBy,
|
|
6601
|
-
name: stored.name,
|
|
6602
|
-
tokenHash: stored.tokenHash,
|
|
6603
|
-
tokenPrefix: stored.tokenPrefix,
|
|
6604
|
-
createdAt: stored.createdAt,
|
|
6605
|
-
expiresAt: stored.expiresAt,
|
|
6606
|
-
lastUsedAt: stored.lastUsedAt,
|
|
6607
|
-
revokedAt: stored.revokedAt
|
|
6608
|
-
};
|
|
6609
|
-
}
|
|
6610
|
-
function summary(stored) {
|
|
6611
|
-
const { tokenHash: _hash, ...record } = stored;
|
|
6612
|
-
return record;
|
|
6613
|
-
}
|
|
6614
|
-
var AdminTokenManager = class {
|
|
6615
|
-
now;
|
|
6616
|
-
kind;
|
|
6617
|
-
store;
|
|
6618
|
-
constructor(deps, now = Date.now) {
|
|
6619
|
-
this.now = now;
|
|
6620
|
-
if ("adminTokensKind" in deps) {
|
|
6621
|
-
this.kind = deps.adminTokensKind;
|
|
6622
|
-
this.store = deps.store ?? null;
|
|
6623
|
-
} else {
|
|
6624
|
-
this.kind = null;
|
|
6625
|
-
this.store = deps;
|
|
6626
|
-
}
|
|
6627
|
-
}
|
|
6628
|
-
/** Mint a token for `createdBy`. Refuses an expiry in the past or past 90 days. */
|
|
6629
|
-
async create(input) {
|
|
6630
|
-
const at = this.now();
|
|
6631
|
-
if (!(input.expiresAt > at) || input.expiresAt > at + 7776e6) throw require_dist.userError("system.admin-token-expiry-too-long", void 0, "An admin token must expire within 90 days");
|
|
6632
|
-
const name = input.name.trim().slice(0, 80);
|
|
6633
|
-
if (name.length === 0) throw require_dist.userError("system.admin-token-name-required", void 0, "An admin token needs a name");
|
|
6634
|
-
const token = `${ADMIN_TOKEN_PREFIX}${node_crypto.randomBytes(32).toString("base64url")}`;
|
|
6635
|
-
const stored = {
|
|
6636
|
-
id: node_crypto.randomUUID(),
|
|
6637
|
-
createdBy: input.createdBy,
|
|
6638
|
-
name,
|
|
6639
|
-
tokenHash: hash$1(token),
|
|
6640
|
-
tokenPrefix: token.slice(0, 12),
|
|
6641
|
-
createdAt: at,
|
|
6642
|
-
expiresAt: input.expiresAt,
|
|
6643
|
-
lastUsedAt: null,
|
|
6644
|
-
revokedAt: null
|
|
6645
|
-
};
|
|
6646
|
-
const kind = this.kind;
|
|
6647
|
-
if (kind !== null) {
|
|
6648
|
-
const body = storedToBody(stored);
|
|
6649
|
-
const parsed = localAuthRecordKinds["admin-tokens"].schema.safeParse(body);
|
|
6650
|
-
if (!parsed.success) throw new Error(parsed.error.message);
|
|
6651
|
-
await kind.put({
|
|
6652
|
-
id: stored.id,
|
|
6653
|
-
expiresAt: stored.expiresAt,
|
|
6654
|
-
data: parsed.data
|
|
6655
|
-
});
|
|
6656
|
-
return {
|
|
6657
|
-
token,
|
|
6658
|
-
record: summary(stored)
|
|
6659
|
-
};
|
|
6660
|
-
}
|
|
6661
|
-
await this.store.insert.mutate({
|
|
6662
|
-
collection: ADMIN_TOKENS_COLLECTION,
|
|
6663
|
-
record: {
|
|
6664
|
-
id: stored.id,
|
|
6665
|
-
data: { ...stored }
|
|
6666
|
-
}
|
|
6667
|
-
});
|
|
6668
|
-
return {
|
|
6669
|
-
token,
|
|
6670
|
-
record: summary(stored)
|
|
6671
|
-
};
|
|
6672
|
-
}
|
|
6673
|
-
/** The live record behind `token`, or `null` (unknown, revoked, expired, not a `csa_`). */
|
|
6674
|
-
async validate(token) {
|
|
6675
|
-
if (!token.startsWith("csa_")) return null;
|
|
6676
|
-
const tokenHash = hash$1(token);
|
|
6677
|
-
const kind = this.kind;
|
|
6678
|
-
let stored = null;
|
|
6679
|
-
if (kind !== null) {
|
|
6680
|
-
const row = (await kind.query({
|
|
6681
|
-
where: { tokenHash },
|
|
6682
|
-
limit: 1
|
|
6683
|
-
})).records[0];
|
|
6684
|
-
stored = row === void 0 ? null : parseBody(row.id, row.data);
|
|
6685
|
-
} else {
|
|
6686
|
-
const first = (await this.store.query.query({
|
|
6687
|
-
collection: ADMIN_TOKENS_COLLECTION,
|
|
6688
|
-
filter: { where: { tokenHash } }
|
|
6689
|
-
}))[0];
|
|
6690
|
-
stored = first === void 0 ? null : parse(first.data);
|
|
6691
|
-
}
|
|
6692
|
-
if (stored === null || stored.revokedAt !== null) return null;
|
|
6693
|
-
const at = this.now();
|
|
6694
|
-
if (stored.expiresAt <= at) return null;
|
|
6695
|
-
if (stored.lastUsedAt === null || at - stored.lastUsedAt > TOUCH_EVERY_MS) {
|
|
6696
|
-
const next = {
|
|
6697
|
-
...stored,
|
|
6698
|
-
lastUsedAt: at
|
|
6699
|
-
};
|
|
6700
|
-
if (kind !== null) kind.put({
|
|
6701
|
-
id: stored.id,
|
|
6702
|
-
expiresAt: stored.expiresAt,
|
|
6703
|
-
data: storedToBody(next)
|
|
6704
|
-
}).catch(() => {});
|
|
6705
|
-
else this.store.update.mutate({
|
|
6706
|
-
collection: ADMIN_TOKENS_COLLECTION,
|
|
6707
|
-
id: stored.id,
|
|
6708
|
-
data: { ...next }
|
|
6709
|
-
}).catch(() => {});
|
|
6710
|
-
}
|
|
6711
|
-
return summary(stored);
|
|
6712
|
-
}
|
|
6713
|
-
/** Every token not yet reaped, newest first (no hashes). */
|
|
6714
|
-
async list() {
|
|
6715
|
-
const kind = this.kind;
|
|
6716
|
-
if (kind !== null) return (await (0, import_read_all_rows.readAllRows)(kind)).map((row) => parseBody(row.id, row.data)).filter((r) => r !== null).map(summary).toSorted((a, b) => b.createdAt - a.createdAt);
|
|
6717
|
-
return (await this.store.query.query({ collection: ADMIN_TOKENS_COLLECTION })).map((row) => parse(row.data)).filter((r) => r !== null).map(summary).toSorted((a, b) => b.createdAt - a.createdAt);
|
|
6718
|
-
}
|
|
6719
|
-
/** Revoke one token. `false` when unknown or already revoked. */
|
|
6720
|
-
async revoke(id) {
|
|
6721
|
-
const kind = this.kind;
|
|
6722
|
-
let stored = null;
|
|
6723
|
-
if (kind !== null) {
|
|
6724
|
-
const row = await kind.get(id);
|
|
6725
|
-
stored = row === null ? null : parseBody(row.id, row.data);
|
|
6726
|
-
} else {
|
|
6727
|
-
const first = (await this.store.query.query({
|
|
6728
|
-
collection: ADMIN_TOKENS_COLLECTION,
|
|
6729
|
-
filter: { where: { id } }
|
|
6730
|
-
}))[0];
|
|
6731
|
-
stored = first === void 0 ? null : parse(first.data);
|
|
6732
|
-
}
|
|
6733
|
-
if (stored === null || stored.revokedAt !== null) return false;
|
|
6734
|
-
const next = {
|
|
6735
|
-
...stored,
|
|
6736
|
-
revokedAt: this.now()
|
|
6737
|
-
};
|
|
6738
|
-
if (kind !== null) await kind.put({
|
|
6739
|
-
id,
|
|
6740
|
-
expiresAt: next.expiresAt,
|
|
6741
|
-
data: storedToBody(next)
|
|
6742
|
-
});
|
|
6743
|
-
else await this.store.update.mutate({
|
|
6744
|
-
collection: ADMIN_TOKENS_COLLECTION,
|
|
6745
|
-
id,
|
|
6746
|
-
data: { ...next }
|
|
6747
|
-
});
|
|
6748
|
-
return true;
|
|
6749
|
-
}
|
|
6750
|
-
/** Revoke every live token `userId` created (the user was deleted). */
|
|
6751
|
-
async revokeAllCreatedBy(userId) {
|
|
6752
|
-
let revoked = 0;
|
|
6753
|
-
for (const token of await this.list()) if (token.createdBy === userId && token.revokedAt === null) {
|
|
6754
|
-
if (await this.revoke(token.id)) revoked++;
|
|
6755
|
-
}
|
|
6756
|
-
return revoked;
|
|
6757
|
-
}
|
|
6758
|
-
/** Delete rows expired or revoked more than 30 days ago. */
|
|
6759
|
-
async reap() {
|
|
6760
|
-
const horizon = this.now() - DEAD_RETENTION_MS;
|
|
6761
|
-
let deleted = 0;
|
|
6762
|
-
for (const token of await this.list()) if ((token.revokedAt ?? token.expiresAt) < horizon) {
|
|
6763
|
-
const kind = this.kind;
|
|
6764
|
-
if (kind !== null) await kind.delete(token.id);
|
|
6765
|
-
else await this.store.delete.mutate({
|
|
6766
|
-
collection: ADMIN_TOKENS_COLLECTION,
|
|
6767
|
-
key: token.id
|
|
6768
|
-
});
|
|
6769
|
-
deleted++;
|
|
6770
|
-
}
|
|
6771
|
-
return deleted;
|
|
6772
|
-
}
|
|
6773
|
-
};
|
|
6774
6588
|
//#endregion
|
|
6775
6589
|
//#region src/auth/device-credential-manager.ts
|
|
6776
6590
|
/**
|
|
@@ -7382,82 +7196,80 @@ var DeviceCredentialManager = class {
|
|
|
7382
7196
|
}
|
|
7383
7197
|
};
|
|
7384
7198
|
//#endregion
|
|
7385
|
-
//#region src/auth/
|
|
7386
|
-
var BUS_KEY = Symbol.for("camstack.login-sessions.revocation-bus");
|
|
7387
|
-
function listeners() {
|
|
7388
|
-
const existing = Reflect.get(globalThis, BUS_KEY);
|
|
7389
|
-
if (existing instanceof Set) return existing;
|
|
7390
|
-
const created = /* @__PURE__ */ new Set();
|
|
7391
|
-
Reflect.set(globalThis, BUS_KEY, created);
|
|
7392
|
-
return created;
|
|
7393
|
-
}
|
|
7394
|
-
/** Subscribe; returns the unsubscribe. */
|
|
7395
|
-
function onSessionsRevoked(listener) {
|
|
7396
|
-
const set = listeners();
|
|
7397
|
-
set.add(listener);
|
|
7398
|
-
return () => {
|
|
7399
|
-
set.delete(listener);
|
|
7400
|
-
};
|
|
7401
|
-
}
|
|
7402
|
-
/** Announce an ended batch. A listener that throws never reaches the revoke. */
|
|
7403
|
-
function announceSessionsRevoked(event, onListenerError) {
|
|
7404
|
-
if (event.sessionIds.length === 0 && !event.allOfUser) return;
|
|
7405
|
-
for (const listener of [...listeners()]) try {
|
|
7406
|
-
listener(event);
|
|
7407
|
-
} catch (error) {
|
|
7408
|
-
onListenerError?.(error);
|
|
7409
|
-
}
|
|
7410
|
-
}
|
|
7411
|
-
//#endregion
|
|
7412
|
-
//#region src/auth/login-session-manager.ts
|
|
7199
|
+
//#region src/builtins/local-auth/auth-schema.ts
|
|
7413
7200
|
/**
|
|
7414
|
-
*
|
|
7415
|
-
*
|
|
7416
|
-
*
|
|
7417
|
-
*
|
|
7418
|
-
*
|
|
7419
|
-
*
|
|
7420
|
-
*
|
|
7421
|
-
*
|
|
7422
|
-
|
|
7423
|
-
|
|
7424
|
-
|
|
7425
|
-
*
|
|
7426
|
-
*
|
|
7427
|
-
*
|
|
7428
|
-
*
|
|
7429
|
-
*
|
|
7430
|
-
*
|
|
7431
|
-
|
|
7432
|
-
|
|
7433
|
-
|
|
7434
|
-
*
|
|
7435
|
-
*
|
|
7436
|
-
*
|
|
7437
|
-
*
|
|
7438
|
-
* Mirror: every row is held in memory (a homelab hub has tens of sessions) and
|
|
7439
|
-
* written through to the store; revocations update the mirror FIRST, so a
|
|
7440
|
-
* store hiccup can delay persistence but never the revocation itself.
|
|
7201
|
+
* @durable class=config owner=local-auth
|
|
7202
|
+
* write="an operator creates a user, or `ensureAdminExists` seeds the configured
|
|
7203
|
+
* admin on first boot (or resets its password on the operator's one-shot
|
|
7204
|
+
* RESET_ADMIN_PASSWORD trigger); updated on scope, isAdmin and password edits"
|
|
7205
|
+
* retention="none — a row goes only when an operator deletes the user. A
|
|
7206
|
+
* `declareCollection` that adds a PK/UNIQUE/NOT-NULL-without-default column
|
|
7207
|
+
* REBUILDS the table: every account is wiped and only the configured admin
|
|
7208
|
+
* comes back on the next boot."
|
|
7209
|
+
*/
|
|
7210
|
+
var USERS_COLLECTION$1 = "users";
|
|
7211
|
+
/**
|
|
7212
|
+
* @durable class=config owner=local-auth
|
|
7213
|
+
* write="an operator mints an API key; `validateToken` rewrites `lastUsedAt` on
|
|
7214
|
+
* every request the key authorises"
|
|
7215
|
+
* retention="none — an explicit `revoke` is the only delete. API keys have no
|
|
7216
|
+
* expiry column and no sweep, so a key lives until someone removes it (or the
|
|
7217
|
+
* destructive `declareCollection` rebuild takes the table)."
|
|
7218
|
+
*/
|
|
7219
|
+
var API_KEYS_COLLECTION = "api_keys";
|
|
7220
|
+
/**
|
|
7221
|
+
* @durable class=config owner=local-auth
|
|
7222
|
+
* write="none since D895 — the pre-D892 scoped-token rows are kept on disk,
|
|
7223
|
+
* read by nothing (a scoped token is a `credential-tokens` row)"
|
|
7224
|
+
* retention="the operator drops the table by hand"
|
|
7441
7225
|
*/
|
|
7226
|
+
var SCOPED_TOKENS_COLLECTION = "scoped_tokens";
|
|
7442
7227
|
/**
|
|
7443
7228
|
* @durable class=ledger owner=local-auth
|
|
7444
|
-
* write="one row per
|
|
7445
|
-
*
|
|
7446
|
-
*
|
|
7447
|
-
*
|
|
7448
|
-
*
|
|
7449
|
-
*
|
|
7229
|
+
* write="one row per redeemed OAuth code — every completed account link through
|
|
7230
|
+
* `oauthExchangeCode`; `touch` rewrites `lastUsedAt` on each refresh"
|
|
7231
|
+
* retention="ONLY revoked rows are pruned: the daily sweep deletes sessions
|
|
7232
|
+
* revoked more than OAUTH_REVOKED_RETENTION_MS (30 d) ago. An ACTIVE session
|
|
7233
|
+
* has `revokedAt` NULL, never expires and is never swept — the table grows one
|
|
7234
|
+
* row per login until an operator revokes the link."
|
|
7450
7235
|
*/
|
|
7451
|
-
var
|
|
7236
|
+
var OAUTH_SESSIONS_COLLECTION = "oauth_sessions";
|
|
7237
|
+
/**
|
|
7238
|
+
* TOTP enrollment table — split off from `users` to avoid the
|
|
7239
|
+
* destructive drop-+-recreate migration `ensureTable` performs when a
|
|
7240
|
+
* new column is added to a typed schema (see `sqlite-settings-backend.ts`).
|
|
7241
|
+
*
|
|
7242
|
+
* Lifecycle:
|
|
7243
|
+
* - Setup: row inserted with `confirmedAt = null` and a fresh secret.
|
|
7244
|
+
* - Confirm: a valid code from the secret flips `confirmedAt` to now().
|
|
7245
|
+
* - Disable: the row is deleted (next setup starts fresh).
|
|
7246
|
+
*
|
|
7247
|
+
* "Enabled" means `confirmedAt != null`. A half-enrolled row (`confirmedAt
|
|
7248
|
+
* = null`) is INACTIVE — `verifyTotp` returns false until confirmation.
|
|
7249
|
+
*
|
|
7250
|
+
* @durable class=config owner=local-auth
|
|
7251
|
+
* write="`setup` inserts a keychain marker for the user's seed (D878; deleting any pending row
|
|
7252
|
+
* first); `confirm` flips `confirmedAt` once a valid code proves possession"
|
|
7253
|
+
* retention="`disable` deletes the row, and `setup` deletes before re-inserting.
|
|
7254
|
+
* Nothing else prunes — deleting a USER does not cascade here, so their
|
|
7255
|
+
* enrollment row outlives the account."
|
|
7256
|
+
*/
|
|
7257
|
+
var USER_TOTP_COLLECTION = "user_totp";
|
|
7452
7258
|
/**
|
|
7259
|
+
* External sign-in identities linked to local users (D782): one row per
|
|
7260
|
+
* `(provider, subject)` — the row id IS the identity, so one identity maps to
|
|
7261
|
+
* one user by primary key. Written only by the identity's owner completing a
|
|
7262
|
+
* "Link" flow from their profile (bound to their session).
|
|
7263
|
+
*
|
|
7453
7264
|
* @durable class=config owner=local-auth
|
|
7454
|
-
* write="
|
|
7455
|
-
*
|
|
7456
|
-
* retention="
|
|
7457
|
-
*
|
|
7265
|
+
* write="`ExternalIdentityManager.link` when a signed-in user completes a
|
|
7266
|
+
* link flow; `unlink` from the profile or the Users page"
|
|
7267
|
+
* retention="`unlink` deletes one row; deleting the user deletes all of its
|
|
7268
|
+
* rows (`deleteUser` → `removeAllForUser`). A row whose provider instance
|
|
7269
|
+
* was renamed or removed stays, inert, until someone unlinks it."
|
|
7458
7270
|
*/
|
|
7459
|
-
var
|
|
7460
|
-
var
|
|
7271
|
+
var USER_EXTERNAL_IDENTITIES_COLLECTION = "user_external_identities";
|
|
7272
|
+
var USERS_COLUMNS = [
|
|
7461
7273
|
{
|
|
7462
7274
|
name: "id",
|
|
7463
7275
|
type: "TEXT",
|
|
@@ -7465,813 +7277,241 @@ var LOGIN_SESSIONS_COLUMNS = [
|
|
|
7465
7277
|
notNull: true
|
|
7466
7278
|
},
|
|
7467
7279
|
{
|
|
7468
|
-
name: "
|
|
7280
|
+
name: "username",
|
|
7469
7281
|
type: "TEXT",
|
|
7470
|
-
notNull: true
|
|
7282
|
+
notNull: true,
|
|
7283
|
+
unique: true
|
|
7471
7284
|
},
|
|
7472
7285
|
{
|
|
7473
|
-
name: "
|
|
7286
|
+
name: "passwordHash",
|
|
7474
7287
|
type: "TEXT",
|
|
7475
7288
|
notNull: true
|
|
7476
7289
|
},
|
|
7477
7290
|
{
|
|
7478
|
-
name: "
|
|
7291
|
+
name: "isAdmin",
|
|
7479
7292
|
type: "BOOLEAN",
|
|
7480
7293
|
notNull: true
|
|
7481
7294
|
},
|
|
7482
7295
|
{
|
|
7483
|
-
name: "
|
|
7484
|
-
type: "
|
|
7485
|
-
notNull: true
|
|
7296
|
+
name: "allowedProviders",
|
|
7297
|
+
type: "JSON"
|
|
7486
7298
|
},
|
|
7487
7299
|
{
|
|
7488
|
-
name: "
|
|
7489
|
-
type: "
|
|
7490
|
-
notNull: true
|
|
7300
|
+
name: "allowedDevices",
|
|
7301
|
+
type: "JSON"
|
|
7491
7302
|
},
|
|
7492
7303
|
{
|
|
7493
|
-
name: "
|
|
7304
|
+
name: "scopes",
|
|
7305
|
+
type: "JSON"
|
|
7306
|
+
},
|
|
7307
|
+
{
|
|
7308
|
+
name: "createdAt",
|
|
7494
7309
|
type: "INTEGER",
|
|
7495
7310
|
notNull: true
|
|
7496
7311
|
},
|
|
7497
7312
|
{
|
|
7498
|
-
name: "
|
|
7499
|
-
type: "INTEGER"
|
|
7313
|
+
name: "updatedAt",
|
|
7314
|
+
type: "INTEGER",
|
|
7315
|
+
notNull: true
|
|
7316
|
+
}
|
|
7317
|
+
];
|
|
7318
|
+
var API_KEYS_COLUMNS = [
|
|
7319
|
+
{
|
|
7320
|
+
name: "id",
|
|
7321
|
+
type: "TEXT",
|
|
7322
|
+
primaryKey: true,
|
|
7323
|
+
notNull: true
|
|
7500
7324
|
},
|
|
7501
7325
|
{
|
|
7502
|
-
name: "
|
|
7503
|
-
type: "TEXT"
|
|
7326
|
+
name: "label",
|
|
7327
|
+
type: "TEXT",
|
|
7328
|
+
notNull: true
|
|
7504
7329
|
},
|
|
7505
7330
|
{
|
|
7506
|
-
name: "
|
|
7507
|
-
type: "
|
|
7331
|
+
name: "isAdmin",
|
|
7332
|
+
type: "BOOLEAN",
|
|
7333
|
+
notNull: true
|
|
7508
7334
|
},
|
|
7509
7335
|
{
|
|
7510
|
-
name: "
|
|
7511
|
-
type: "TEXT"
|
|
7336
|
+
name: "tokenHash",
|
|
7337
|
+
type: "TEXT",
|
|
7338
|
+
notNull: true,
|
|
7339
|
+
unique: true
|
|
7512
7340
|
},
|
|
7513
7341
|
{
|
|
7514
|
-
name: "
|
|
7342
|
+
name: "tokenPrefix",
|
|
7343
|
+
type: "TEXT",
|
|
7344
|
+
notNull: true
|
|
7345
|
+
},
|
|
7346
|
+
{
|
|
7347
|
+
name: "allowedProviders",
|
|
7348
|
+
type: "JSON"
|
|
7349
|
+
},
|
|
7350
|
+
{
|
|
7351
|
+
name: "allowedDevices",
|
|
7352
|
+
type: "JSON"
|
|
7353
|
+
},
|
|
7354
|
+
{
|
|
7355
|
+
name: "createdAt",
|
|
7356
|
+
type: "INTEGER",
|
|
7357
|
+
notNull: true
|
|
7358
|
+
},
|
|
7359
|
+
{
|
|
7360
|
+
name: "lastUsedAt",
|
|
7515
7361
|
type: "INTEGER"
|
|
7362
|
+
}
|
|
7363
|
+
];
|
|
7364
|
+
var USER_TOTP_COLUMNS = [
|
|
7365
|
+
{
|
|
7366
|
+
name: "userId",
|
|
7367
|
+
type: "TEXT",
|
|
7368
|
+
primaryKey: true,
|
|
7369
|
+
notNull: true
|
|
7516
7370
|
},
|
|
7517
7371
|
{
|
|
7518
|
-
name: "
|
|
7519
|
-
type: "TEXT"
|
|
7372
|
+
name: "secret",
|
|
7373
|
+
type: "TEXT",
|
|
7374
|
+
notNull: true
|
|
7520
7375
|
},
|
|
7521
7376
|
{
|
|
7522
|
-
name: "
|
|
7523
|
-
type: "
|
|
7377
|
+
name: "confirmedAt",
|
|
7378
|
+
type: "INTEGER"
|
|
7524
7379
|
},
|
|
7525
7380
|
{
|
|
7526
|
-
name: "
|
|
7381
|
+
name: "createdAt",
|
|
7382
|
+
type: "INTEGER",
|
|
7383
|
+
notNull: true
|
|
7384
|
+
}
|
|
7385
|
+
];
|
|
7386
|
+
var USER_EXTERNAL_IDENTITIES_COLUMNS = [
|
|
7387
|
+
{
|
|
7388
|
+
name: "id",
|
|
7389
|
+
type: "TEXT",
|
|
7390
|
+
primaryKey: true,
|
|
7391
|
+
notNull: true
|
|
7392
|
+
},
|
|
7393
|
+
{
|
|
7394
|
+
name: "provider",
|
|
7395
|
+
type: "TEXT",
|
|
7396
|
+
notNull: true
|
|
7397
|
+
},
|
|
7398
|
+
{
|
|
7399
|
+
name: "subject",
|
|
7400
|
+
type: "TEXT",
|
|
7401
|
+
notNull: true
|
|
7402
|
+
},
|
|
7403
|
+
{
|
|
7404
|
+
name: "issuer",
|
|
7405
|
+
type: "TEXT",
|
|
7406
|
+
notNull: true
|
|
7407
|
+
},
|
|
7408
|
+
{
|
|
7409
|
+
name: "userId",
|
|
7410
|
+
type: "TEXT",
|
|
7411
|
+
notNull: true
|
|
7412
|
+
},
|
|
7413
|
+
{
|
|
7414
|
+
name: "label",
|
|
7527
7415
|
type: "TEXT"
|
|
7416
|
+
},
|
|
7417
|
+
{
|
|
7418
|
+
name: "linkedAt",
|
|
7419
|
+
type: "INTEGER",
|
|
7420
|
+
notNull: true
|
|
7528
7421
|
}
|
|
7529
7422
|
];
|
|
7530
|
-
var
|
|
7531
|
-
|
|
7532
|
-
|
|
7533
|
-
|
|
7534
|
-
|
|
7535
|
-
|
|
7536
|
-
|
|
7537
|
-
|
|
7538
|
-
|
|
7539
|
-
|
|
7540
|
-
|
|
7541
|
-
|
|
7542
|
-
|
|
7543
|
-
|
|
7544
|
-
|
|
7545
|
-
|
|
7546
|
-
|
|
7423
|
+
var SCOPED_TOKENS_COLUMNS = [
|
|
7424
|
+
{
|
|
7425
|
+
name: "id",
|
|
7426
|
+
type: "TEXT",
|
|
7427
|
+
primaryKey: true,
|
|
7428
|
+
notNull: true
|
|
7429
|
+
},
|
|
7430
|
+
{
|
|
7431
|
+
name: "userId",
|
|
7432
|
+
type: "TEXT",
|
|
7433
|
+
notNull: true
|
|
7434
|
+
},
|
|
7435
|
+
{
|
|
7436
|
+
name: "name",
|
|
7437
|
+
type: "TEXT",
|
|
7438
|
+
notNull: true
|
|
7439
|
+
},
|
|
7440
|
+
{
|
|
7441
|
+
name: "tokenHash",
|
|
7442
|
+
type: "TEXT",
|
|
7443
|
+
notNull: true,
|
|
7444
|
+
unique: true
|
|
7445
|
+
},
|
|
7446
|
+
{
|
|
7447
|
+
name: "tokenPrefix",
|
|
7448
|
+
type: "TEXT",
|
|
7449
|
+
notNull: true
|
|
7450
|
+
},
|
|
7451
|
+
{
|
|
7452
|
+
name: "scopes",
|
|
7453
|
+
type: "JSON"
|
|
7454
|
+
},
|
|
7455
|
+
{
|
|
7456
|
+
name: "expiresAt",
|
|
7457
|
+
type: "INTEGER"
|
|
7458
|
+
},
|
|
7459
|
+
{
|
|
7460
|
+
name: "lastUsedAt",
|
|
7461
|
+
type: "INTEGER"
|
|
7462
|
+
},
|
|
7463
|
+
{
|
|
7464
|
+
name: "createdAt",
|
|
7465
|
+
type: "INTEGER",
|
|
7466
|
+
notNull: true
|
|
7467
|
+
}
|
|
7468
|
+
];
|
|
7469
|
+
var OAUTH_SESSIONS_COLUMNS = [
|
|
7470
|
+
{
|
|
7471
|
+
name: "id",
|
|
7472
|
+
type: "TEXT",
|
|
7473
|
+
primaryKey: true,
|
|
7474
|
+
notNull: true
|
|
7475
|
+
},
|
|
7476
|
+
{
|
|
7477
|
+
name: "userId",
|
|
7478
|
+
type: "TEXT",
|
|
7479
|
+
notNull: true
|
|
7480
|
+
},
|
|
7481
|
+
{
|
|
7482
|
+
name: "username",
|
|
7483
|
+
type: "TEXT",
|
|
7484
|
+
notNull: true
|
|
7485
|
+
},
|
|
7486
|
+
{
|
|
7487
|
+
name: "integrationId",
|
|
7488
|
+
type: "TEXT",
|
|
7489
|
+
notNull: true
|
|
7490
|
+
},
|
|
7491
|
+
{
|
|
7492
|
+
name: "scopes",
|
|
7493
|
+
type: "JSON"
|
|
7494
|
+
},
|
|
7495
|
+
{
|
|
7496
|
+
name: "createdAt",
|
|
7497
|
+
type: "INTEGER",
|
|
7498
|
+
notNull: true
|
|
7499
|
+
},
|
|
7500
|
+
{
|
|
7501
|
+
name: "lastUsedAt",
|
|
7502
|
+
type: "INTEGER",
|
|
7503
|
+
notNull: true
|
|
7504
|
+
},
|
|
7505
|
+
{
|
|
7506
|
+
name: "revokedAt",
|
|
7507
|
+
type: "INTEGER"
|
|
7508
|
+
}
|
|
7547
7509
|
];
|
|
7548
|
-
var REFRESH_TOKEN_PREFIX = "csr_";
|
|
7549
7510
|
/**
|
|
7550
|
-
*
|
|
7551
|
-
*
|
|
7552
|
-
*
|
|
7553
|
-
|
|
7554
|
-
var REFRESH_REUSE_GRACE_MS = 3e4;
|
|
7555
|
-
var EXPIRED_RETENTION_MS = 7 * 864e5;
|
|
7556
|
-
var REVOKED_RETENTION_MS = 30 * 864e5;
|
|
7557
|
-
/** Longest client label kept (a User-Agent can be huge). */
|
|
7558
|
-
var MAX_CLIENT_LABEL = 200;
|
|
7559
|
-
function hashToken(token) {
|
|
7560
|
-
return node_crypto.createHash("sha256").update(token).digest("hex");
|
|
7561
|
-
}
|
|
7562
|
-
function str(data, key) {
|
|
7563
|
-
const value = data[key];
|
|
7564
|
-
return typeof value === "string" && value.length > 0 ? value : null;
|
|
7565
|
-
}
|
|
7566
|
-
function num(data, key) {
|
|
7567
|
-
const value = data[key];
|
|
7568
|
-
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
7569
|
-
}
|
|
7570
|
-
function parseSession$1(data) {
|
|
7571
|
-
const id = str(data, "id");
|
|
7572
|
-
const userId = str(data, "userId");
|
|
7573
|
-
const createdAt = num(data, "createdAt");
|
|
7574
|
-
const lastUsedAt = num(data, "lastUsedAt");
|
|
7575
|
-
const expiresAt = num(data, "expiresAt");
|
|
7576
|
-
const method = METHODS.find((m) => m === data["method"]);
|
|
7577
|
-
if (id === null || userId === null || createdAt === null || expiresAt === null) return null;
|
|
7578
|
-
return {
|
|
7579
|
-
id,
|
|
7580
|
-
userId,
|
|
7581
|
-
method: method ?? "password",
|
|
7582
|
-
refreshable: data["refreshable"] === true || data["refreshable"] === 1,
|
|
7583
|
-
createdAt,
|
|
7584
|
-
lastUsedAt: lastUsedAt ?? createdAt,
|
|
7585
|
-
expiresAt,
|
|
7586
|
-
revokedAt: num(data, "revokedAt"),
|
|
7587
|
-
revokedReason: str(data, "revokedReason"),
|
|
7588
|
-
refreshHash: str(data, "refreshHash"),
|
|
7589
|
-
previousRefreshHash: str(data, "previousRefreshHash"),
|
|
7590
|
-
rotatedAt: num(data, "rotatedAt"),
|
|
7591
|
-
client: str(data, "client"),
|
|
7592
|
-
ip: str(data, "ip"),
|
|
7593
|
-
credentialId: str(data, "credentialId")
|
|
7594
|
-
};
|
|
7595
|
-
}
|
|
7596
|
-
function toLoginSessionSummary(record) {
|
|
7597
|
-
const { refreshHash: _r, previousRefreshHash: _p, rotatedAt: _t, ...summary } = record;
|
|
7598
|
-
return summary;
|
|
7599
|
-
}
|
|
7600
|
-
function sessionToBody$1(session) {
|
|
7601
|
-
const { id: _id, ...body } = session;
|
|
7602
|
-
return body;
|
|
7603
|
-
}
|
|
7604
|
-
var LoginSessionManager = class {
|
|
7605
|
-
sessions = /* @__PURE__ */ new Map();
|
|
7606
|
-
/** refresh hash (current or previous) → session id */
|
|
7607
|
-
byRefreshHash = /* @__PURE__ */ new Map();
|
|
7608
|
-
cutoffs = /* @__PURE__ */ new Map();
|
|
7609
|
-
now;
|
|
7610
|
-
logger;
|
|
7611
|
-
sessionsKind;
|
|
7612
|
-
cutoffsKind;
|
|
7613
|
-
store;
|
|
7614
|
-
constructor(deps, options = {}) {
|
|
7615
|
-
if ("sessionsKind" in deps) {
|
|
7616
|
-
this.sessionsKind = deps.sessionsKind;
|
|
7617
|
-
this.cutoffsKind = deps.cutoffsKind;
|
|
7618
|
-
this.store = deps.legacyStore ?? null;
|
|
7619
|
-
} else {
|
|
7620
|
-
this.sessionsKind = null;
|
|
7621
|
-
this.cutoffsKind = null;
|
|
7622
|
-
this.store = deps;
|
|
7623
|
-
}
|
|
7624
|
-
this.now = options.now ?? Date.now;
|
|
7625
|
-
this.logger = options.logger ?? null;
|
|
7626
|
-
}
|
|
7627
|
-
/** Load every row into the mirror. Call once at init, before serving. */
|
|
7628
|
-
async load() {
|
|
7629
|
-
const sessionsKind = this.sessionsKind;
|
|
7630
|
-
if (sessionsKind !== null) {
|
|
7631
|
-
const rows = await (0, import_read_all_rows.readAllRows)(sessionsKind);
|
|
7632
|
-
for (const row of rows) {
|
|
7633
|
-
const record = parseSession$1({
|
|
7634
|
-
id: row.id,
|
|
7635
|
-
...row.data
|
|
7636
|
-
});
|
|
7637
|
-
if (record !== null) this.remember(record);
|
|
7638
|
-
}
|
|
7639
|
-
const cutoffs = await (0, import_read_all_rows.readAllRows)(this.cutoffsKind);
|
|
7640
|
-
for (const row of cutoffs) this.cutoffs.set(row.id, row.data.notBefore);
|
|
7641
|
-
return;
|
|
7642
|
-
}
|
|
7643
|
-
const rows = await this.store.query.query({ collection: LOGIN_SESSIONS_COLLECTION });
|
|
7644
|
-
for (const row of rows) {
|
|
7645
|
-
const record = parseSession$1(row.data);
|
|
7646
|
-
if (record !== null) this.remember(record);
|
|
7647
|
-
}
|
|
7648
|
-
const cutoffs = await this.store.query.query({ collection: LOGIN_SESSION_CUTOFFS_COLLECTION });
|
|
7649
|
-
for (const row of cutoffs) {
|
|
7650
|
-
const userId = str(row.data, "userId");
|
|
7651
|
-
const notBefore = num(row.data, "notBefore");
|
|
7652
|
-
if (userId !== null && notBefore !== null) this.cutoffs.set(userId, notBefore);
|
|
7653
|
-
}
|
|
7654
|
-
}
|
|
7655
|
-
async create(input) {
|
|
7656
|
-
const at = this.now();
|
|
7657
|
-
const refreshToken = input.refreshable ? this.newRefreshToken() : null;
|
|
7658
|
-
const session = {
|
|
7659
|
-
id: node_crypto.randomUUID(),
|
|
7660
|
-
userId: input.userId,
|
|
7661
|
-
method: input.method,
|
|
7662
|
-
refreshable: input.refreshable,
|
|
7663
|
-
createdAt: at,
|
|
7664
|
-
lastUsedAt: at,
|
|
7665
|
-
expiresAt: at + input.lifetimeMs,
|
|
7666
|
-
revokedAt: null,
|
|
7667
|
-
revokedReason: null,
|
|
7668
|
-
refreshHash: refreshToken === null ? null : hashToken(refreshToken),
|
|
7669
|
-
previousRefreshHash: null,
|
|
7670
|
-
rotatedAt: null,
|
|
7671
|
-
client: input.client === void 0 ? null : input.client.slice(0, MAX_CLIENT_LABEL),
|
|
7672
|
-
ip: input.ip ?? null,
|
|
7673
|
-
credentialId: input.credentialId ?? null
|
|
7674
|
-
};
|
|
7675
|
-
const sessionsKind = this.sessionsKind;
|
|
7676
|
-
if (sessionsKind !== null) {
|
|
7677
|
-
const parsed = localAuthRecordKinds["login-sessions"].schema.safeParse(sessionToBody$1(session));
|
|
7678
|
-
if (!parsed.success) throw new Error(parsed.error.message);
|
|
7679
|
-
await sessionsKind.put({
|
|
7680
|
-
id: session.id,
|
|
7681
|
-
at: session.createdAt,
|
|
7682
|
-
expiresAt: session.expiresAt,
|
|
7683
|
-
data: parsed.data
|
|
7684
|
-
});
|
|
7685
|
-
} else await this.store.insert.mutate({
|
|
7686
|
-
collection: LOGIN_SESSIONS_COLLECTION,
|
|
7687
|
-
record: {
|
|
7688
|
-
id: session.id,
|
|
7689
|
-
data: { ...session }
|
|
7690
|
-
}
|
|
7691
|
-
});
|
|
7692
|
-
this.remember(session);
|
|
7693
|
-
return {
|
|
7694
|
-
session,
|
|
7695
|
-
refreshToken
|
|
7696
|
-
};
|
|
7697
|
-
}
|
|
7698
|
-
/** Is `sessionId` a live session right now? Synchronous: the hub asks on every request. */
|
|
7699
|
-
check(sessionId) {
|
|
7700
|
-
const session = this.sessions.get(sessionId);
|
|
7701
|
-
if (session === void 0) return { state: "unknown" };
|
|
7702
|
-
if (session.revokedAt !== null) return { state: "revoked" };
|
|
7703
|
-
if (session.expiresAt <= this.now()) return { state: "expired" };
|
|
7704
|
-
return {
|
|
7705
|
-
state: "active",
|
|
7706
|
-
session
|
|
7707
|
-
};
|
|
7708
|
-
}
|
|
7709
|
-
/** Epoch ms before which a pre-session (no `sid`) token of `userId` is refused; 0 = none. */
|
|
7710
|
-
legacyCutoff(userId) {
|
|
7711
|
-
return this.cutoffs.get(userId) ?? 0;
|
|
7712
|
-
}
|
|
7713
|
-
/**
|
|
7714
|
-
* The session a refresh token belongs to, WITHOUT rotating it - so the
|
|
7715
|
-
* refresh path can judge the session (its device credential) before it
|
|
7716
|
-
* spends the token. `null` when the token names no session.
|
|
7717
|
-
*/
|
|
7718
|
-
peekByRefreshToken(refreshToken) {
|
|
7719
|
-
if (!refreshToken.startsWith("csr_")) return null;
|
|
7720
|
-
const sessionId = this.byRefreshHash.get(hashToken(refreshToken));
|
|
7721
|
-
return sessionId === void 0 ? null : this.sessions.get(sessionId) ?? null;
|
|
7722
|
-
}
|
|
7723
|
-
/** Swap a refresh token for a rotated one, sliding the session's expiry to now + `lifetimeMs`. */
|
|
7724
|
-
async refresh(refreshToken, lifetimeMs) {
|
|
7725
|
-
if (!refreshToken.startsWith("csr_")) return {
|
|
7726
|
-
ok: false,
|
|
7727
|
-
reason: "unknown"
|
|
7728
|
-
};
|
|
7729
|
-
const presented = hashToken(refreshToken);
|
|
7730
|
-
const sessionId = this.byRefreshHash.get(presented);
|
|
7731
|
-
const session = sessionId === void 0 ? void 0 : this.sessions.get(sessionId);
|
|
7732
|
-
if (session === void 0) return {
|
|
7733
|
-
ok: false,
|
|
7734
|
-
reason: "unknown"
|
|
7735
|
-
};
|
|
7736
|
-
if (session.revokedAt !== null) return {
|
|
7737
|
-
ok: false,
|
|
7738
|
-
reason: "revoked"
|
|
7739
|
-
};
|
|
7740
|
-
const at = this.now();
|
|
7741
|
-
if (session.expiresAt <= at) return {
|
|
7742
|
-
ok: false,
|
|
7743
|
-
reason: "expired"
|
|
7744
|
-
};
|
|
7745
|
-
if (presented !== session.refreshHash) {
|
|
7746
|
-
if (!(presented === session.previousRefreshHash && session.rotatedAt !== null && at - session.rotatedAt <= REFRESH_REUSE_GRACE_MS)) {
|
|
7747
|
-
await this.revoke(session.id, "refresh-reuse");
|
|
7748
|
-
this.logger?.warn("A rotated-out refresh token was presented — session revoked", { meta: {
|
|
7749
|
-
sessionId: session.id,
|
|
7750
|
-
userId: session.userId
|
|
7751
|
-
} });
|
|
7752
|
-
return {
|
|
7753
|
-
ok: false,
|
|
7754
|
-
reason: "reused"
|
|
7755
|
-
};
|
|
7756
|
-
}
|
|
7757
|
-
}
|
|
7758
|
-
const next = this.newRefreshToken();
|
|
7759
|
-
const updated = {
|
|
7760
|
-
...session,
|
|
7761
|
-
lastUsedAt: at,
|
|
7762
|
-
expiresAt: at + lifetimeMs,
|
|
7763
|
-
previousRefreshHash: session.refreshHash,
|
|
7764
|
-
refreshHash: hashToken(next),
|
|
7765
|
-
rotatedAt: at
|
|
7766
|
-
};
|
|
7767
|
-
this.replace(session, updated);
|
|
7768
|
-
await this.persist(updated);
|
|
7769
|
-
return {
|
|
7770
|
-
ok: true,
|
|
7771
|
-
session: updated,
|
|
7772
|
-
refreshToken: next
|
|
7773
|
-
};
|
|
7774
|
-
}
|
|
7775
|
-
/** Renew an active session (legacy client): slide its expiry. `null` when it is not active. */
|
|
7776
|
-
async extend(sessionId, lifetimeMs) {
|
|
7777
|
-
const check = this.check(sessionId);
|
|
7778
|
-
if (check.state !== "active") return null;
|
|
7779
|
-
const at = this.now();
|
|
7780
|
-
const updated = {
|
|
7781
|
-
...check.session,
|
|
7782
|
-
lastUsedAt: at,
|
|
7783
|
-
expiresAt: at + lifetimeMs
|
|
7784
|
-
};
|
|
7785
|
-
this.replace(check.session, updated);
|
|
7786
|
-
await this.persist(updated);
|
|
7787
|
-
return updated;
|
|
7788
|
-
}
|
|
7789
|
-
/** End one session. `false` when it was unknown or already ended. */
|
|
7790
|
-
async revoke(sessionId, reason) {
|
|
7791
|
-
const session = this.sessions.get(sessionId);
|
|
7792
|
-
if (session === void 0 || session.revokedAt !== null) return false;
|
|
7793
|
-
const updated = {
|
|
7794
|
-
...session,
|
|
7795
|
-
revokedAt: this.now(),
|
|
7796
|
-
revokedReason: reason
|
|
7797
|
-
};
|
|
7798
|
-
this.replace(session, updated);
|
|
7799
|
-
this.announce([updated.id], session.userId, false, null, reason);
|
|
7800
|
-
await this.persist(updated);
|
|
7801
|
-
return true;
|
|
7802
|
-
}
|
|
7803
|
-
/**
|
|
7804
|
-
* End every live session of `userId` except `exceptSessionId`, and move the
|
|
7805
|
-
* user's legacy cutoff to now (pre-session tokens die too). Returns how many
|
|
7806
|
-
* sessions ended.
|
|
7807
|
-
*/
|
|
7808
|
-
async revokeAllForUser(userId, options) {
|
|
7809
|
-
const at = this.now();
|
|
7810
|
-
this.cutoffs.set(userId, at);
|
|
7811
|
-
const ended = [];
|
|
7812
|
-
for (const session of this.sessions.values()) {
|
|
7813
|
-
if (session.userId !== userId || session.revokedAt !== null) continue;
|
|
7814
|
-
if (session.id === options.exceptSessionId) continue;
|
|
7815
|
-
const updated = {
|
|
7816
|
-
...session,
|
|
7817
|
-
revokedAt: at,
|
|
7818
|
-
revokedReason: options.reason
|
|
7819
|
-
};
|
|
7820
|
-
this.replace(session, updated);
|
|
7821
|
-
ended.push(updated);
|
|
7822
|
-
}
|
|
7823
|
-
this.announce(ended.map((s) => s.id), userId, true, options.exceptSessionId ?? null, options.reason);
|
|
7824
|
-
await this.persistCutoff(userId, at);
|
|
7825
|
-
for (const session of ended) await this.persist(session);
|
|
7826
|
-
return ended.length;
|
|
7827
|
-
}
|
|
7828
|
-
/** End every live session opened by device credential `credentialId`. Returns how many. */
|
|
7829
|
-
async revokeByCredential(credentialId, reason = "credential-revoked") {
|
|
7830
|
-
const at = this.now();
|
|
7831
|
-
const ended = [];
|
|
7832
|
-
for (const session of this.sessions.values()) {
|
|
7833
|
-
if (session.credentialId !== credentialId || session.revokedAt !== null) continue;
|
|
7834
|
-
const updated = {
|
|
7835
|
-
...session,
|
|
7836
|
-
revokedAt: at,
|
|
7837
|
-
revokedReason: reason
|
|
7838
|
-
};
|
|
7839
|
-
this.replace(session, updated);
|
|
7840
|
-
ended.push(updated);
|
|
7841
|
-
}
|
|
7842
|
-
this.announce(ended.map((s) => s.id), null, false, null, reason);
|
|
7843
|
-
for (const session of ended) await this.persist(session);
|
|
7844
|
-
return ended.length;
|
|
7845
|
-
}
|
|
7846
|
-
/** `userId`'s live sessions, newest first. */
|
|
7847
|
-
listForUser(userId) {
|
|
7848
|
-
return this.listActive().filter((s) => s.userId === userId);
|
|
7849
|
-
}
|
|
7850
|
-
/** Every live session, newest first. */
|
|
7851
|
-
listActive() {
|
|
7852
|
-
const at = this.now();
|
|
7853
|
-
return [...this.sessions.values()].filter((s) => s.revokedAt === null && s.expiresAt > at).toSorted((a, b) => b.createdAt - a.createdAt).map(toLoginSessionSummary);
|
|
7854
|
-
}
|
|
7855
|
-
/** Delete rows expired more than 7 days ago or revoked more than 30. Returns how many. */
|
|
7856
|
-
async reap() {
|
|
7857
|
-
const at = this.now();
|
|
7858
|
-
const dead = [...this.sessions.values()].filter((s) => s.revokedAt !== null && at - s.revokedAt > REVOKED_RETENTION_MS || s.revokedAt === null && at - s.expiresAt > EXPIRED_RETENTION_MS);
|
|
7859
|
-
for (const session of dead) {
|
|
7860
|
-
this.forget(session);
|
|
7861
|
-
const sessionsKind = this.sessionsKind;
|
|
7862
|
-
if (sessionsKind !== null) await sessionsKind.delete(session.id);
|
|
7863
|
-
else await this.store.delete.mutate({
|
|
7864
|
-
collection: LOGIN_SESSIONS_COLLECTION,
|
|
7865
|
-
key: session.id
|
|
7866
|
-
});
|
|
7867
|
-
}
|
|
7868
|
-
return dead.length;
|
|
7869
|
-
}
|
|
7870
|
-
/** Tell the hub which sessions just ended — right after the mirror, before the store write. */
|
|
7871
|
-
announce(sessionIds, userId, allOfUser, exceptSessionId, reason) {
|
|
7872
|
-
announceSessionsRevoked({
|
|
7873
|
-
sessionIds,
|
|
7874
|
-
userId,
|
|
7875
|
-
allOfUser,
|
|
7876
|
-
exceptSessionId,
|
|
7877
|
-
reason
|
|
7878
|
-
}, (error) => this.logger?.error("A session-revocation listener threw — the revoke stands", { meta: { error: error instanceof Error ? error.message : String(error) } }));
|
|
7879
|
-
}
|
|
7880
|
-
newRefreshToken() {
|
|
7881
|
-
return `${REFRESH_TOKEN_PREFIX}${node_crypto.randomBytes(32).toString("base64url")}`;
|
|
7882
|
-
}
|
|
7883
|
-
remember(session) {
|
|
7884
|
-
this.sessions.set(session.id, session);
|
|
7885
|
-
if (session.refreshHash !== null) this.byRefreshHash.set(session.refreshHash, session.id);
|
|
7886
|
-
if (session.previousRefreshHash !== null) this.byRefreshHash.set(session.previousRefreshHash, session.id);
|
|
7887
|
-
}
|
|
7888
|
-
forget(session) {
|
|
7889
|
-
this.sessions.delete(session.id);
|
|
7890
|
-
if (session.refreshHash !== null) this.byRefreshHash.delete(session.refreshHash);
|
|
7891
|
-
if (session.previousRefreshHash !== null) this.byRefreshHash.delete(session.previousRefreshHash);
|
|
7892
|
-
}
|
|
7893
|
-
replace(previous, next) {
|
|
7894
|
-
this.forget(previous);
|
|
7895
|
-
this.remember(next);
|
|
7896
|
-
}
|
|
7897
|
-
async persist(session) {
|
|
7898
|
-
try {
|
|
7899
|
-
const sessionsKind = this.sessionsKind;
|
|
7900
|
-
if (sessionsKind !== null) {
|
|
7901
|
-
const parsed = localAuthRecordKinds["login-sessions"].schema.safeParse(sessionToBody$1(session));
|
|
7902
|
-
if (!parsed.success) throw new Error(parsed.error.message);
|
|
7903
|
-
await sessionsKind.put({
|
|
7904
|
-
id: session.id,
|
|
7905
|
-
at: session.createdAt,
|
|
7906
|
-
expiresAt: session.expiresAt,
|
|
7907
|
-
data: parsed.data
|
|
7908
|
-
});
|
|
7909
|
-
} else await this.store.update.mutate({
|
|
7910
|
-
collection: LOGIN_SESSIONS_COLLECTION,
|
|
7911
|
-
id: session.id,
|
|
7912
|
-
data: { ...session }
|
|
7913
|
-
});
|
|
7914
|
-
} catch (error) {
|
|
7915
|
-
this.logger?.error("Login session write failed — the change holds until restart", { meta: {
|
|
7916
|
-
sessionId: session.id,
|
|
7917
|
-
error: error instanceof Error ? error.message : String(error)
|
|
7918
|
-
} });
|
|
7919
|
-
}
|
|
7920
|
-
}
|
|
7921
|
-
async persistCutoff(userId, notBefore) {
|
|
7922
|
-
try {
|
|
7923
|
-
const cutoffsKind = this.cutoffsKind;
|
|
7924
|
-
if (cutoffsKind !== null) {
|
|
7925
|
-
const parsed = localAuthRecordKinds["login-session-cutoffs"].schema.safeParse({ notBefore });
|
|
7926
|
-
if (!parsed.success) throw new Error(parsed.error.message);
|
|
7927
|
-
await cutoffsKind.put({
|
|
7928
|
-
id: userId,
|
|
7929
|
-
data: parsed.data
|
|
7930
|
-
});
|
|
7931
|
-
} else {
|
|
7932
|
-
await this.store.delete.mutate({
|
|
7933
|
-
collection: LOGIN_SESSION_CUTOFFS_COLLECTION,
|
|
7934
|
-
key: userId
|
|
7935
|
-
});
|
|
7936
|
-
await this.store.insert.mutate({
|
|
7937
|
-
collection: LOGIN_SESSION_CUTOFFS_COLLECTION,
|
|
7938
|
-
record: {
|
|
7939
|
-
id: userId,
|
|
7940
|
-
data: {
|
|
7941
|
-
userId,
|
|
7942
|
-
notBefore
|
|
7943
|
-
}
|
|
7944
|
-
}
|
|
7945
|
-
});
|
|
7946
|
-
}
|
|
7947
|
-
} catch (error) {
|
|
7948
|
-
this.logger?.error("Login session cutoff write failed — it holds until restart", { meta: {
|
|
7949
|
-
userId,
|
|
7950
|
-
error: error instanceof Error ? error.message : String(error)
|
|
7951
|
-
} });
|
|
7952
|
-
}
|
|
7953
|
-
}
|
|
7954
|
-
};
|
|
7955
|
-
//#endregion
|
|
7956
|
-
//#region src/builtins/local-auth/auth-schema.ts
|
|
7957
|
-
/**
|
|
7958
|
-
* @durable class=config owner=local-auth
|
|
7959
|
-
* write="an operator creates a user, or `ensureAdminExists` seeds the configured
|
|
7960
|
-
* admin on first boot (or resets its password on the operator's one-shot
|
|
7961
|
-
* RESET_ADMIN_PASSWORD trigger); updated on scope, isAdmin and password edits"
|
|
7962
|
-
* retention="none — a row goes only when an operator deletes the user. A
|
|
7963
|
-
* `declareCollection` that adds a PK/UNIQUE/NOT-NULL-without-default column
|
|
7964
|
-
* REBUILDS the table: every account is wiped and only the configured admin
|
|
7965
|
-
* comes back on the next boot."
|
|
7966
|
-
*/
|
|
7967
|
-
var USERS_COLLECTION$1 = "users";
|
|
7968
|
-
/**
|
|
7969
|
-
* @durable class=config owner=local-auth
|
|
7970
|
-
* write="an operator mints an API key; `validateToken` rewrites `lastUsedAt` on
|
|
7971
|
-
* every request the key authorises"
|
|
7972
|
-
* retention="none — an explicit `revoke` is the only delete. API keys have no
|
|
7973
|
-
* expiry column and no sweep, so a key lives until someone removes it (or the
|
|
7974
|
-
* destructive `declareCollection` rebuild takes the table)."
|
|
7975
|
-
*/
|
|
7976
|
-
var API_KEYS_COLLECTION = "api_keys";
|
|
7977
|
-
/**
|
|
7978
|
-
* @durable class=config owner=local-auth
|
|
7979
|
-
* write="an operator or the CLI mints a scoped token; `validate` fires
|
|
7980
|
-
* `updateLastUsed` on every accepted call"
|
|
7981
|
-
* retention="`revoke` deletes one. The daily sweep `reapExpired` drops rows past
|
|
7982
|
-
* `expiresAt` — a NULL expiry is a deliberate never-expires token and BETWEEN
|
|
7983
|
-
* never matches it. `cleanupOrphans` drops tokens owned by the legacy 'system'
|
|
7984
|
-
* id or by a user that no longer exists."
|
|
7985
|
-
*/
|
|
7986
|
-
var SCOPED_TOKENS_COLLECTION = "scoped_tokens";
|
|
7987
|
-
/**
|
|
7988
|
-
* @durable class=ledger owner=local-auth
|
|
7989
|
-
* write="one row per redeemed OAuth code — every completed account link through
|
|
7990
|
-
* `oauthExchangeCode`; `touch` rewrites `lastUsedAt` on each refresh"
|
|
7991
|
-
* retention="ONLY revoked rows are pruned: the daily sweep deletes sessions
|
|
7992
|
-
* revoked more than OAUTH_REVOKED_RETENTION_MS (30 d) ago. An ACTIVE session
|
|
7993
|
-
* has `revokedAt` NULL, never expires and is never swept — the table grows one
|
|
7994
|
-
* row per login until an operator revokes the link."
|
|
7995
|
-
*/
|
|
7996
|
-
var OAUTH_SESSIONS_COLLECTION = "oauth_sessions";
|
|
7997
|
-
/**
|
|
7998
|
-
* TOTP enrollment table — split off from `users` to avoid the
|
|
7999
|
-
* destructive drop-+-recreate migration `ensureTable` performs when a
|
|
8000
|
-
* new column is added to a typed schema (see `sqlite-settings-backend.ts`).
|
|
8001
|
-
*
|
|
8002
|
-
* Lifecycle:
|
|
8003
|
-
* - Setup: row inserted with `confirmedAt = null` and a fresh secret.
|
|
8004
|
-
* - Confirm: a valid code from the secret flips `confirmedAt` to now().
|
|
8005
|
-
* - Disable: the row is deleted (next setup starts fresh).
|
|
8006
|
-
*
|
|
8007
|
-
* "Enabled" means `confirmedAt != null`. A half-enrolled row (`confirmedAt
|
|
8008
|
-
* = null`) is INACTIVE — `verifyTotp` returns false until confirmation.
|
|
8009
|
-
*
|
|
8010
|
-
* @durable class=config owner=local-auth
|
|
8011
|
-
* write="`setup` inserts a keychain marker for the user's seed (D878; deleting any pending row
|
|
8012
|
-
* first); `confirm` flips `confirmedAt` once a valid code proves possession"
|
|
8013
|
-
* retention="`disable` deletes the row, and `setup` deletes before re-inserting.
|
|
8014
|
-
* Nothing else prunes — deleting a USER does not cascade here, so their
|
|
8015
|
-
* enrollment row outlives the account."
|
|
8016
|
-
*/
|
|
8017
|
-
var USER_TOTP_COLLECTION = "user_totp";
|
|
8018
|
-
/**
|
|
8019
|
-
* External sign-in identities linked to local users (D782): one row per
|
|
8020
|
-
* `(provider, subject)` — the row id IS the identity, so one identity maps to
|
|
8021
|
-
* one user by primary key. Written only by the identity's owner completing a
|
|
8022
|
-
* "Link" flow from their profile (bound to their session).
|
|
8023
|
-
*
|
|
8024
|
-
* @durable class=config owner=local-auth
|
|
8025
|
-
* write="`ExternalIdentityManager.link` when a signed-in user completes a
|
|
8026
|
-
* link flow; `unlink` from the profile or the Users page"
|
|
8027
|
-
* retention="`unlink` deletes one row; deleting the user deletes all of its
|
|
8028
|
-
* rows (`deleteUser` → `removeAllForUser`). A row whose provider instance
|
|
8029
|
-
* was renamed or removed stays, inert, until someone unlinks it."
|
|
8030
|
-
*/
|
|
8031
|
-
var USER_EXTERNAL_IDENTITIES_COLLECTION = "user_external_identities";
|
|
8032
|
-
var USERS_COLUMNS = [
|
|
8033
|
-
{
|
|
8034
|
-
name: "id",
|
|
8035
|
-
type: "TEXT",
|
|
8036
|
-
primaryKey: true,
|
|
8037
|
-
notNull: true
|
|
8038
|
-
},
|
|
8039
|
-
{
|
|
8040
|
-
name: "username",
|
|
8041
|
-
type: "TEXT",
|
|
8042
|
-
notNull: true,
|
|
8043
|
-
unique: true
|
|
8044
|
-
},
|
|
8045
|
-
{
|
|
8046
|
-
name: "passwordHash",
|
|
8047
|
-
type: "TEXT",
|
|
8048
|
-
notNull: true
|
|
8049
|
-
},
|
|
8050
|
-
{
|
|
8051
|
-
name: "isAdmin",
|
|
8052
|
-
type: "BOOLEAN",
|
|
8053
|
-
notNull: true
|
|
8054
|
-
},
|
|
8055
|
-
{
|
|
8056
|
-
name: "allowedProviders",
|
|
8057
|
-
type: "JSON"
|
|
8058
|
-
},
|
|
8059
|
-
{
|
|
8060
|
-
name: "allowedDevices",
|
|
8061
|
-
type: "JSON"
|
|
8062
|
-
},
|
|
8063
|
-
{
|
|
8064
|
-
name: "scopes",
|
|
8065
|
-
type: "JSON"
|
|
8066
|
-
},
|
|
8067
|
-
{
|
|
8068
|
-
name: "createdAt",
|
|
8069
|
-
type: "INTEGER",
|
|
8070
|
-
notNull: true
|
|
8071
|
-
},
|
|
8072
|
-
{
|
|
8073
|
-
name: "updatedAt",
|
|
8074
|
-
type: "INTEGER",
|
|
8075
|
-
notNull: true
|
|
8076
|
-
}
|
|
8077
|
-
];
|
|
8078
|
-
var API_KEYS_COLUMNS = [
|
|
8079
|
-
{
|
|
8080
|
-
name: "id",
|
|
8081
|
-
type: "TEXT",
|
|
8082
|
-
primaryKey: true,
|
|
8083
|
-
notNull: true
|
|
8084
|
-
},
|
|
8085
|
-
{
|
|
8086
|
-
name: "label",
|
|
8087
|
-
type: "TEXT",
|
|
8088
|
-
notNull: true
|
|
8089
|
-
},
|
|
8090
|
-
{
|
|
8091
|
-
name: "isAdmin",
|
|
8092
|
-
type: "BOOLEAN",
|
|
8093
|
-
notNull: true
|
|
8094
|
-
},
|
|
8095
|
-
{
|
|
8096
|
-
name: "tokenHash",
|
|
8097
|
-
type: "TEXT",
|
|
8098
|
-
notNull: true,
|
|
8099
|
-
unique: true
|
|
8100
|
-
},
|
|
8101
|
-
{
|
|
8102
|
-
name: "tokenPrefix",
|
|
8103
|
-
type: "TEXT",
|
|
8104
|
-
notNull: true
|
|
8105
|
-
},
|
|
8106
|
-
{
|
|
8107
|
-
name: "allowedProviders",
|
|
8108
|
-
type: "JSON"
|
|
8109
|
-
},
|
|
8110
|
-
{
|
|
8111
|
-
name: "allowedDevices",
|
|
8112
|
-
type: "JSON"
|
|
8113
|
-
},
|
|
8114
|
-
{
|
|
8115
|
-
name: "createdAt",
|
|
8116
|
-
type: "INTEGER",
|
|
8117
|
-
notNull: true
|
|
8118
|
-
},
|
|
8119
|
-
{
|
|
8120
|
-
name: "lastUsedAt",
|
|
8121
|
-
type: "INTEGER"
|
|
8122
|
-
}
|
|
8123
|
-
];
|
|
8124
|
-
var USER_TOTP_COLUMNS = [
|
|
8125
|
-
{
|
|
8126
|
-
name: "userId",
|
|
8127
|
-
type: "TEXT",
|
|
8128
|
-
primaryKey: true,
|
|
8129
|
-
notNull: true
|
|
8130
|
-
},
|
|
8131
|
-
{
|
|
8132
|
-
name: "secret",
|
|
8133
|
-
type: "TEXT",
|
|
8134
|
-
notNull: true
|
|
8135
|
-
},
|
|
8136
|
-
{
|
|
8137
|
-
name: "confirmedAt",
|
|
8138
|
-
type: "INTEGER"
|
|
8139
|
-
},
|
|
8140
|
-
{
|
|
8141
|
-
name: "createdAt",
|
|
8142
|
-
type: "INTEGER",
|
|
8143
|
-
notNull: true
|
|
8144
|
-
}
|
|
8145
|
-
];
|
|
8146
|
-
var USER_EXTERNAL_IDENTITIES_COLUMNS = [
|
|
8147
|
-
{
|
|
8148
|
-
name: "id",
|
|
8149
|
-
type: "TEXT",
|
|
8150
|
-
primaryKey: true,
|
|
8151
|
-
notNull: true
|
|
8152
|
-
},
|
|
8153
|
-
{
|
|
8154
|
-
name: "provider",
|
|
8155
|
-
type: "TEXT",
|
|
8156
|
-
notNull: true
|
|
8157
|
-
},
|
|
8158
|
-
{
|
|
8159
|
-
name: "subject",
|
|
8160
|
-
type: "TEXT",
|
|
8161
|
-
notNull: true
|
|
8162
|
-
},
|
|
8163
|
-
{
|
|
8164
|
-
name: "issuer",
|
|
8165
|
-
type: "TEXT",
|
|
8166
|
-
notNull: true
|
|
8167
|
-
},
|
|
8168
|
-
{
|
|
8169
|
-
name: "userId",
|
|
8170
|
-
type: "TEXT",
|
|
8171
|
-
notNull: true
|
|
8172
|
-
},
|
|
8173
|
-
{
|
|
8174
|
-
name: "label",
|
|
8175
|
-
type: "TEXT"
|
|
8176
|
-
},
|
|
8177
|
-
{
|
|
8178
|
-
name: "linkedAt",
|
|
8179
|
-
type: "INTEGER",
|
|
8180
|
-
notNull: true
|
|
8181
|
-
}
|
|
8182
|
-
];
|
|
8183
|
-
var SCOPED_TOKENS_COLUMNS = [
|
|
8184
|
-
{
|
|
8185
|
-
name: "id",
|
|
8186
|
-
type: "TEXT",
|
|
8187
|
-
primaryKey: true,
|
|
8188
|
-
notNull: true
|
|
8189
|
-
},
|
|
8190
|
-
{
|
|
8191
|
-
name: "userId",
|
|
8192
|
-
type: "TEXT",
|
|
8193
|
-
notNull: true
|
|
8194
|
-
},
|
|
8195
|
-
{
|
|
8196
|
-
name: "name",
|
|
8197
|
-
type: "TEXT",
|
|
8198
|
-
notNull: true
|
|
8199
|
-
},
|
|
8200
|
-
{
|
|
8201
|
-
name: "tokenHash",
|
|
8202
|
-
type: "TEXT",
|
|
8203
|
-
notNull: true,
|
|
8204
|
-
unique: true
|
|
8205
|
-
},
|
|
8206
|
-
{
|
|
8207
|
-
name: "tokenPrefix",
|
|
8208
|
-
type: "TEXT",
|
|
8209
|
-
notNull: true
|
|
8210
|
-
},
|
|
8211
|
-
{
|
|
8212
|
-
name: "scopes",
|
|
8213
|
-
type: "JSON"
|
|
8214
|
-
},
|
|
8215
|
-
{
|
|
8216
|
-
name: "expiresAt",
|
|
8217
|
-
type: "INTEGER"
|
|
8218
|
-
},
|
|
8219
|
-
{
|
|
8220
|
-
name: "lastUsedAt",
|
|
8221
|
-
type: "INTEGER"
|
|
8222
|
-
},
|
|
8223
|
-
{
|
|
8224
|
-
name: "createdAt",
|
|
8225
|
-
type: "INTEGER",
|
|
8226
|
-
notNull: true
|
|
8227
|
-
}
|
|
8228
|
-
];
|
|
8229
|
-
var OAUTH_SESSIONS_COLUMNS = [
|
|
8230
|
-
{
|
|
8231
|
-
name: "id",
|
|
8232
|
-
type: "TEXT",
|
|
8233
|
-
primaryKey: true,
|
|
8234
|
-
notNull: true
|
|
8235
|
-
},
|
|
8236
|
-
{
|
|
8237
|
-
name: "userId",
|
|
8238
|
-
type: "TEXT",
|
|
8239
|
-
notNull: true
|
|
8240
|
-
},
|
|
8241
|
-
{
|
|
8242
|
-
name: "username",
|
|
8243
|
-
type: "TEXT",
|
|
8244
|
-
notNull: true
|
|
8245
|
-
},
|
|
8246
|
-
{
|
|
8247
|
-
name: "integrationId",
|
|
8248
|
-
type: "TEXT",
|
|
8249
|
-
notNull: true
|
|
8250
|
-
},
|
|
8251
|
-
{
|
|
8252
|
-
name: "scopes",
|
|
8253
|
-
type: "JSON"
|
|
8254
|
-
},
|
|
8255
|
-
{
|
|
8256
|
-
name: "createdAt",
|
|
8257
|
-
type: "INTEGER",
|
|
8258
|
-
notNull: true
|
|
8259
|
-
},
|
|
8260
|
-
{
|
|
8261
|
-
name: "lastUsedAt",
|
|
8262
|
-
type: "INTEGER",
|
|
8263
|
-
notNull: true
|
|
8264
|
-
},
|
|
8265
|
-
{
|
|
8266
|
-
name: "revokedAt",
|
|
8267
|
-
type: "INTEGER"
|
|
8268
|
-
}
|
|
8269
|
-
];
|
|
8270
|
-
/**
|
|
8271
|
-
* Declare every auth collection with the typed schema. Called by
|
|
8272
|
-
* `LocalAuthAddon.onInitialize` before constructing the per-collection
|
|
8273
|
-
* managers. Idempotent — `declareCollection` is a no-op when the
|
|
8274
|
-
* collection has already been declared (same shape).
|
|
7511
|
+
* Declare every auth collection with the typed schema. Called by
|
|
7512
|
+
* `LocalAuthAddon.onInitialize` before constructing the per-collection
|
|
7513
|
+
* managers. Idempotent — `declareCollection` is a no-op when the
|
|
7514
|
+
* collection has already been declared (same shape).
|
|
8275
7515
|
*/
|
|
8276
7516
|
async function declareAuthSchema(store, options) {
|
|
8277
7517
|
const skip = new Set(options?.skipCollections ?? []);
|
|
@@ -8361,7 +7601,10 @@ async function declareAuthSchema(store, options) {
|
|
|
8361
7601
|
* use `storageAdmin.dropCollection` with `expectRows` after verify (see
|
|
8362
7602
|
* `docs/design/records-phase-6-auth-migration.md`).
|
|
8363
7603
|
*
|
|
8364
|
-
* Before hub restart: mint and test an admin token (`
|
|
7604
|
+
* Before hub restart: mint and test an admin token (`auth.mintToken` with `'*'`); snapshot every kind.
|
|
7605
|
+
*
|
|
7606
|
+
* `scoped-tokens` and `admin-tokens` hold pre-D892 rows read by nothing since
|
|
7607
|
+
* D895; they stay declared so the layout is unchanged.
|
|
8365
7608
|
*/
|
|
8366
7609
|
var DAY_MS = 864e5;
|
|
8367
7610
|
var UserBodySchema = require_dist.UserRecordSchema.omit({ id: true });
|
|
@@ -8470,7 +7713,7 @@ var _localAuthRecordKinds = require_dist.defineRecordKinds({
|
|
|
8470
7713
|
"scoped-tokens": {
|
|
8471
7714
|
schema: ScopedTokenBodySchema,
|
|
8472
7715
|
class: "config",
|
|
8473
|
-
writeTrigger: "
|
|
7716
|
+
writeTrigger: "none since D895 — legacy rows kept on disk for the operator to drop",
|
|
8474
7717
|
device: "none",
|
|
8475
7718
|
at: "none",
|
|
8476
7719
|
indexes: [{
|
|
@@ -8481,16 +7724,13 @@ var _localAuthRecordKinds = require_dist.defineRecordKinds({
|
|
|
8481
7724
|
name: "user",
|
|
8482
7725
|
fields: ["userId"]
|
|
8483
7726
|
}],
|
|
8484
|
-
retention: {
|
|
8485
|
-
owner: "local-auth",
|
|
8486
|
-
reason: "daily reap of expired and orphan tokens; NULL expiresAt never matches BETWEEN"
|
|
8487
|
-
},
|
|
7727
|
+
retention: { none: "the operator drops the legacy rows by hand (D895)" },
|
|
8488
7728
|
adoptsFrom: SCOPED_TOKENS_COLLECTION
|
|
8489
7729
|
},
|
|
8490
7730
|
"admin-tokens": {
|
|
8491
7731
|
schema: AdminTokenBodySchema,
|
|
8492
7732
|
class: "config",
|
|
8493
|
-
writeTrigger: "
|
|
7733
|
+
writeTrigger: "none since D895 — legacy rows kept on disk for the operator to drop",
|
|
8494
7734
|
device: "none",
|
|
8495
7735
|
at: "none",
|
|
8496
7736
|
indexes: [{
|
|
@@ -8501,10 +7741,7 @@ var _localAuthRecordKinds = require_dist.defineRecordKinds({
|
|
|
8501
7741
|
name: "created_by",
|
|
8502
7742
|
fields: ["createdBy"]
|
|
8503
7743
|
}],
|
|
8504
|
-
retention: {
|
|
8505
|
-
owner: "local-auth",
|
|
8506
|
-
reason: "daily reap deletes rows expired or revoked more than 30 days ago"
|
|
8507
|
-
},
|
|
7744
|
+
retention: { none: "the operator drops the legacy rows by hand (D895)" },
|
|
8508
7745
|
adoptsFrom: ADMIN_TOKENS_COLLECTION
|
|
8509
7746
|
},
|
|
8510
7747
|
"login-sessions": {
|
|
@@ -8616,8 +7853,8 @@ var _localAuthRecordKinds = require_dist.defineRecordKinds({
|
|
|
8616
7853
|
var localAuthRecordKinds = _localAuthRecordKinds;
|
|
8617
7854
|
var localAuthUsersRecordKind = _localAuthRecordKinds.users;
|
|
8618
7855
|
var localAuthApiKeysRecordKind = _localAuthRecordKinds["api-keys"];
|
|
8619
|
-
|
|
8620
|
-
|
|
7856
|
+
_localAuthRecordKinds["scoped-tokens"];
|
|
7857
|
+
_localAuthRecordKinds["admin-tokens"];
|
|
8621
7858
|
var localAuthLoginSessionsRecordKind = _localAuthRecordKinds["login-sessions"];
|
|
8622
7859
|
var localAuthLoginSessionCutoffsRecordKind = _localAuthRecordKinds["login-session-cutoffs"];
|
|
8623
7860
|
var localAuthOauthSessionsRecordKind = _localAuthRecordKinds["oauth-sessions"];
|
|
@@ -8641,214 +7878,586 @@ var LOCAL_AUTH_RECORDS_LEGACY_COLLECTIONS = [
|
|
|
8641
7878
|
];
|
|
8642
7879
|
function localAuthRecordStoresOf(records) {
|
|
8643
7880
|
return {
|
|
8644
|
-
users: records.kind(localAuthUsersRecordKind),
|
|
8645
|
-
apiKeys: records.kind(localAuthApiKeysRecordKind),
|
|
8646
|
-
|
|
8647
|
-
|
|
8648
|
-
|
|
8649
|
-
|
|
8650
|
-
|
|
8651
|
-
|
|
8652
|
-
|
|
8653
|
-
|
|
8654
|
-
|
|
7881
|
+
users: records.kind(localAuthUsersRecordKind),
|
|
7882
|
+
apiKeys: records.kind(localAuthApiKeysRecordKind),
|
|
7883
|
+
loginSessions: records.kind(localAuthLoginSessionsRecordKind),
|
|
7884
|
+
loginSessionCutoffs: records.kind(localAuthLoginSessionCutoffsRecordKind),
|
|
7885
|
+
oauthSessions: records.kind(localAuthOauthSessionsRecordKind),
|
|
7886
|
+
userTotp: records.kind(localAuthUserTotpRecordKind),
|
|
7887
|
+
userExternalIdentities: records.kind(localAuthUserExternalIdentitiesRecordKind),
|
|
7888
|
+
deviceCredentials: records.kind(localAuthDeviceCredentialsRecordKind),
|
|
7889
|
+
authAudit: records.kind(localAuthAuthAuditRecordKind)
|
|
7890
|
+
};
|
|
7891
|
+
}
|
|
7892
|
+
//#endregion
|
|
7893
|
+
//#region src/auth/session-revocation-bus.ts
|
|
7894
|
+
var BUS_KEY = Symbol.for("camstack.login-sessions.revocation-bus");
|
|
7895
|
+
function listeners() {
|
|
7896
|
+
const existing = Reflect.get(globalThis, BUS_KEY);
|
|
7897
|
+
if (existing instanceof Set) return existing;
|
|
7898
|
+
const created = /* @__PURE__ */ new Set();
|
|
7899
|
+
Reflect.set(globalThis, BUS_KEY, created);
|
|
7900
|
+
return created;
|
|
7901
|
+
}
|
|
7902
|
+
/** Subscribe; returns the unsubscribe. */
|
|
7903
|
+
function onSessionsRevoked(listener) {
|
|
7904
|
+
const set = listeners();
|
|
7905
|
+
set.add(listener);
|
|
7906
|
+
return () => {
|
|
7907
|
+
set.delete(listener);
|
|
7908
|
+
};
|
|
7909
|
+
}
|
|
7910
|
+
/** Announce an ended batch. A listener that throws never reaches the revoke. */
|
|
7911
|
+
function announceSessionsRevoked(event, onListenerError) {
|
|
7912
|
+
if (event.sessionIds.length === 0 && !event.allOfUser) return;
|
|
7913
|
+
for (const listener of [...listeners()]) try {
|
|
7914
|
+
listener(event);
|
|
7915
|
+
} catch (error) {
|
|
7916
|
+
onListenerError?.(error);
|
|
7917
|
+
}
|
|
7918
|
+
}
|
|
7919
|
+
//#endregion
|
|
7920
|
+
//#region src/auth/login-session-manager.ts
|
|
7921
|
+
/**
|
|
7922
|
+
* Login sessions — the server-side record behind every session token.
|
|
7923
|
+
*
|
|
7924
|
+
* A session token used to be a self-contained 30-day JWT: nothing on the hub
|
|
7925
|
+
* knew it existed, so logout revoked nothing, a password change left every
|
|
7926
|
+
* other device signed in, and a deleted user's sessions lived until they
|
|
7927
|
+
* expired. Now every login creates a row here and the token carries its id
|
|
7928
|
+
* (`sid`); the hub checks the row on every request (`check`, an in-memory
|
|
7929
|
+
* lookup), so revoking a row ends the session at once.
|
|
7930
|
+
*
|
|
7931
|
+
* Two kinds of client:
|
|
7932
|
+
* - **refreshable** — gets a short access token (default 2 h) and an opaque
|
|
7933
|
+
* refresh token (`csr_…`, stored hashed). `refresh` rotates the refresh
|
|
7934
|
+
* token and slides the session's expiry; presenting a rotated-out refresh
|
|
7935
|
+
* token after a short grace window is treated as theft and revokes the
|
|
7936
|
+
* whole session.
|
|
7937
|
+
* - **legacy** — a client from before refresh tokens: its access token lives
|
|
7938
|
+
* as long as the session, and it renews through the handoff-code pair
|
|
7939
|
+
* (`extend`). Kept for one release so installed viewer apps keep working.
|
|
7940
|
+
*
|
|
7941
|
+
* `legacyCutoff(userId)` covers tokens minted before sessions existed (no
|
|
7942
|
+
* `sid`): a token of that user issued before the cutoff is refused. Revoking
|
|
7943
|
+
* all of a user's sessions moves the cutoff, so a password change or a user
|
|
7944
|
+
* deletion also ends their pre-session tokens.
|
|
7945
|
+
*
|
|
7946
|
+
* Mirror: every row is held in memory (a homelab hub has tens of sessions) and
|
|
7947
|
+
* written through to the store; revocations update the mirror FIRST, so a
|
|
7948
|
+
* store hiccup can delay persistence but never the revocation itself.
|
|
7949
|
+
*/
|
|
7950
|
+
/**
|
|
7951
|
+
* @durable class=ledger owner=local-auth
|
|
7952
|
+
* write="one row per login (password, passkey, SSO, handoff, session exchange);
|
|
7953
|
+
* rewritten on every refresh / renewal (expiry slides, refresh hash rotates)
|
|
7954
|
+
* and on revocation"
|
|
7955
|
+
* retention="the daily reap deletes rows expired more than 7 days ago and rows
|
|
7956
|
+
* revoked more than 30 days ago. A session the user keeps refreshing lives as
|
|
7957
|
+
* long as it is used."
|
|
7958
|
+
*/
|
|
7959
|
+
var LOGIN_SESSIONS_COLLECTION = "login_sessions";
|
|
7960
|
+
/**
|
|
7961
|
+
* @durable class=config owner=local-auth
|
|
7962
|
+
* write="revoking all of a user's sessions (logout everywhere, password change or
|
|
7963
|
+
* reset, user deletion) sets that user's cutoff to now"
|
|
7964
|
+
* retention="none — one row per user who ever had all sessions revoked; it only
|
|
7965
|
+
* ever matters for tokens minted before sessions existed, and is harmless after."
|
|
7966
|
+
*/
|
|
7967
|
+
var LOGIN_SESSION_CUTOFFS_COLLECTION = "login_session_cutoffs";
|
|
7968
|
+
var LOGIN_SESSIONS_COLUMNS = [
|
|
7969
|
+
{
|
|
7970
|
+
name: "id",
|
|
7971
|
+
type: "TEXT",
|
|
7972
|
+
primaryKey: true,
|
|
7973
|
+
notNull: true
|
|
7974
|
+
},
|
|
7975
|
+
{
|
|
7976
|
+
name: "userId",
|
|
7977
|
+
type: "TEXT",
|
|
7978
|
+
notNull: true
|
|
7979
|
+
},
|
|
7980
|
+
{
|
|
7981
|
+
name: "method",
|
|
7982
|
+
type: "TEXT",
|
|
7983
|
+
notNull: true
|
|
7984
|
+
},
|
|
7985
|
+
{
|
|
7986
|
+
name: "refreshable",
|
|
7987
|
+
type: "BOOLEAN",
|
|
7988
|
+
notNull: true
|
|
7989
|
+
},
|
|
7990
|
+
{
|
|
7991
|
+
name: "createdAt",
|
|
7992
|
+
type: "INTEGER",
|
|
7993
|
+
notNull: true
|
|
7994
|
+
},
|
|
7995
|
+
{
|
|
7996
|
+
name: "lastUsedAt",
|
|
7997
|
+
type: "INTEGER",
|
|
7998
|
+
notNull: true
|
|
7999
|
+
},
|
|
8000
|
+
{
|
|
8001
|
+
name: "expiresAt",
|
|
8002
|
+
type: "INTEGER",
|
|
8003
|
+
notNull: true
|
|
8004
|
+
},
|
|
8005
|
+
{
|
|
8006
|
+
name: "revokedAt",
|
|
8007
|
+
type: "INTEGER"
|
|
8008
|
+
},
|
|
8009
|
+
{
|
|
8010
|
+
name: "revokedReason",
|
|
8011
|
+
type: "TEXT"
|
|
8012
|
+
},
|
|
8013
|
+
{
|
|
8014
|
+
name: "refreshHash",
|
|
8015
|
+
type: "TEXT"
|
|
8016
|
+
},
|
|
8017
|
+
{
|
|
8018
|
+
name: "previousRefreshHash",
|
|
8019
|
+
type: "TEXT"
|
|
8020
|
+
},
|
|
8021
|
+
{
|
|
8022
|
+
name: "rotatedAt",
|
|
8023
|
+
type: "INTEGER"
|
|
8024
|
+
},
|
|
8025
|
+
{
|
|
8026
|
+
name: "client",
|
|
8027
|
+
type: "TEXT"
|
|
8028
|
+
},
|
|
8029
|
+
{
|
|
8030
|
+
name: "ip",
|
|
8031
|
+
type: "TEXT"
|
|
8032
|
+
},
|
|
8033
|
+
{
|
|
8034
|
+
name: "credentialId",
|
|
8035
|
+
type: "TEXT"
|
|
8036
|
+
}
|
|
8037
|
+
];
|
|
8038
|
+
var LOGIN_SESSION_CUTOFFS_COLUMNS = [{
|
|
8039
|
+
name: "userId",
|
|
8040
|
+
type: "TEXT",
|
|
8041
|
+
primaryKey: true,
|
|
8042
|
+
notNull: true
|
|
8043
|
+
}, {
|
|
8044
|
+
name: "notBefore",
|
|
8045
|
+
type: "INTEGER",
|
|
8046
|
+
notNull: true
|
|
8047
|
+
}];
|
|
8048
|
+
var METHODS = [
|
|
8049
|
+
"password",
|
|
8050
|
+
"passkey",
|
|
8051
|
+
"sso",
|
|
8052
|
+
"handoff",
|
|
8053
|
+
"exchange",
|
|
8054
|
+
"device-certificate"
|
|
8055
|
+
];
|
|
8056
|
+
var REFRESH_TOKEN_PREFIX = "csr_";
|
|
8057
|
+
/**
|
|
8058
|
+
* A rotated-out refresh token is still honoured this long: two tabs (or a
|
|
8059
|
+
* retry after a dropped response) may present the same token a moment apart.
|
|
8060
|
+
* After it, the old token is evidence of a copy and ends the session.
|
|
8061
|
+
*/
|
|
8062
|
+
var REFRESH_REUSE_GRACE_MS = 3e4;
|
|
8063
|
+
var EXPIRED_RETENTION_MS = 7 * 864e5;
|
|
8064
|
+
var REVOKED_RETENTION_MS = 30 * 864e5;
|
|
8065
|
+
/** Longest client label kept (a User-Agent can be huge). */
|
|
8066
|
+
var MAX_CLIENT_LABEL = 200;
|
|
8067
|
+
function hashToken(token) {
|
|
8068
|
+
return node_crypto.createHash("sha256").update(token).digest("hex");
|
|
8069
|
+
}
|
|
8070
|
+
function str(data, key) {
|
|
8071
|
+
const value = data[key];
|
|
8072
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
8073
|
+
}
|
|
8074
|
+
function num(data, key) {
|
|
8075
|
+
const value = data[key];
|
|
8076
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
8077
|
+
}
|
|
8078
|
+
function parseSession$1(data) {
|
|
8079
|
+
const id = str(data, "id");
|
|
8080
|
+
const userId = str(data, "userId");
|
|
8081
|
+
const createdAt = num(data, "createdAt");
|
|
8082
|
+
const lastUsedAt = num(data, "lastUsedAt");
|
|
8083
|
+
const expiresAt = num(data, "expiresAt");
|
|
8084
|
+
const method = METHODS.find((m) => m === data["method"]);
|
|
8085
|
+
if (id === null || userId === null || createdAt === null || expiresAt === null) return null;
|
|
8086
|
+
return {
|
|
8087
|
+
id,
|
|
8088
|
+
userId,
|
|
8089
|
+
method: method ?? "password",
|
|
8090
|
+
refreshable: data["refreshable"] === true || data["refreshable"] === 1,
|
|
8091
|
+
createdAt,
|
|
8092
|
+
lastUsedAt: lastUsedAt ?? createdAt,
|
|
8093
|
+
expiresAt,
|
|
8094
|
+
revokedAt: num(data, "revokedAt"),
|
|
8095
|
+
revokedReason: str(data, "revokedReason"),
|
|
8096
|
+
refreshHash: str(data, "refreshHash"),
|
|
8097
|
+
previousRefreshHash: str(data, "previousRefreshHash"),
|
|
8098
|
+
rotatedAt: num(data, "rotatedAt"),
|
|
8099
|
+
client: str(data, "client"),
|
|
8100
|
+
ip: str(data, "ip"),
|
|
8101
|
+
credentialId: str(data, "credentialId")
|
|
8655
8102
|
};
|
|
8656
8103
|
}
|
|
8657
|
-
|
|
8658
|
-
|
|
8659
|
-
|
|
8660
|
-
var TOKEN_PREFIX = "cst_";
|
|
8661
|
-
function parseToken(data) {
|
|
8662
|
-
return parseRecord$2("scoped-token", require_dist.ScopedTokenSchema, data);
|
|
8663
|
-
}
|
|
8664
|
-
function tokenToBody(token) {
|
|
8665
|
-
const { id: _id, ...body } = token;
|
|
8666
|
-
return body;
|
|
8104
|
+
function toLoginSessionSummary(record) {
|
|
8105
|
+
const { refreshHash: _r, previousRefreshHash: _p, rotatedAt: _t, ...summary } = record;
|
|
8106
|
+
return summary;
|
|
8667
8107
|
}
|
|
8668
|
-
function
|
|
8669
|
-
|
|
8670
|
-
|
|
8671
|
-
...body
|
|
8672
|
-
});
|
|
8108
|
+
function sessionToBody$1(session) {
|
|
8109
|
+
const { id: _id, ...body } = session;
|
|
8110
|
+
return body;
|
|
8673
8111
|
}
|
|
8674
|
-
|
|
8675
|
-
|
|
8676
|
-
*/
|
|
8677
|
-
|
|
8678
|
-
|
|
8112
|
+
var LoginSessionManager = class {
|
|
8113
|
+
sessions = /* @__PURE__ */ new Map();
|
|
8114
|
+
/** refresh hash (current or previous) → session id */
|
|
8115
|
+
byRefreshHash = /* @__PURE__ */ new Map();
|
|
8116
|
+
cutoffs = /* @__PURE__ */ new Map();
|
|
8117
|
+
now;
|
|
8118
|
+
logger;
|
|
8119
|
+
sessionsKind;
|
|
8120
|
+
cutoffsKind;
|
|
8679
8121
|
store;
|
|
8680
|
-
constructor(deps) {
|
|
8681
|
-
if ("
|
|
8682
|
-
this.
|
|
8122
|
+
constructor(deps, options = {}) {
|
|
8123
|
+
if ("sessionsKind" in deps) {
|
|
8124
|
+
this.sessionsKind = deps.sessionsKind;
|
|
8125
|
+
this.cutoffsKind = deps.cutoffsKind;
|
|
8683
8126
|
this.store = deps.legacyStore ?? null;
|
|
8684
8127
|
} else {
|
|
8685
|
-
this.
|
|
8128
|
+
this.sessionsKind = null;
|
|
8129
|
+
this.cutoffsKind = null;
|
|
8686
8130
|
this.store = deps;
|
|
8687
8131
|
}
|
|
8132
|
+
this.now = options.now ?? Date.now;
|
|
8133
|
+
this.logger = options.logger ?? null;
|
|
8134
|
+
}
|
|
8135
|
+
/** Load every row into the mirror. Call once at init, before serving. */
|
|
8136
|
+
async load() {
|
|
8137
|
+
const sessionsKind = this.sessionsKind;
|
|
8138
|
+
if (sessionsKind !== null) {
|
|
8139
|
+
const rows = await (0, import_read_all_rows.readAllRows)(sessionsKind);
|
|
8140
|
+
for (const row of rows) {
|
|
8141
|
+
const record = parseSession$1({
|
|
8142
|
+
id: row.id,
|
|
8143
|
+
...row.data
|
|
8144
|
+
});
|
|
8145
|
+
if (record !== null) this.remember(record);
|
|
8146
|
+
}
|
|
8147
|
+
const cutoffs = await (0, import_read_all_rows.readAllRows)(this.cutoffsKind);
|
|
8148
|
+
for (const row of cutoffs) this.cutoffs.set(row.id, row.data.notBefore);
|
|
8149
|
+
return;
|
|
8150
|
+
}
|
|
8151
|
+
const rows = await this.store.query.query({ collection: LOGIN_SESSIONS_COLLECTION });
|
|
8152
|
+
for (const row of rows) {
|
|
8153
|
+
const record = parseSession$1(row.data);
|
|
8154
|
+
if (record !== null) this.remember(record);
|
|
8155
|
+
}
|
|
8156
|
+
const cutoffs = await this.store.query.query({ collection: LOGIN_SESSION_CUTOFFS_COLLECTION });
|
|
8157
|
+
for (const row of cutoffs) {
|
|
8158
|
+
const userId = str(row.data, "userId");
|
|
8159
|
+
const notBefore = num(row.data, "notBefore");
|
|
8160
|
+
if (userId !== null && notBefore !== null) this.cutoffs.set(userId, notBefore);
|
|
8161
|
+
}
|
|
8162
|
+
}
|
|
8163
|
+
async create(input) {
|
|
8164
|
+
const at = this.now();
|
|
8165
|
+
const refreshToken = input.refreshable ? this.newRefreshToken() : null;
|
|
8166
|
+
const session = {
|
|
8167
|
+
id: node_crypto.randomUUID(),
|
|
8168
|
+
userId: input.userId,
|
|
8169
|
+
method: input.method,
|
|
8170
|
+
refreshable: input.refreshable,
|
|
8171
|
+
createdAt: at,
|
|
8172
|
+
lastUsedAt: at,
|
|
8173
|
+
expiresAt: at + input.lifetimeMs,
|
|
8174
|
+
revokedAt: null,
|
|
8175
|
+
revokedReason: null,
|
|
8176
|
+
refreshHash: refreshToken === null ? null : hashToken(refreshToken),
|
|
8177
|
+
previousRefreshHash: null,
|
|
8178
|
+
rotatedAt: null,
|
|
8179
|
+
client: input.client === void 0 ? null : input.client.slice(0, MAX_CLIENT_LABEL),
|
|
8180
|
+
ip: input.ip ?? null,
|
|
8181
|
+
credentialId: input.credentialId ?? null
|
|
8182
|
+
};
|
|
8183
|
+
const sessionsKind = this.sessionsKind;
|
|
8184
|
+
if (sessionsKind !== null) {
|
|
8185
|
+
const parsed = localAuthRecordKinds["login-sessions"].schema.safeParse(sessionToBody$1(session));
|
|
8186
|
+
if (!parsed.success) throw new Error(parsed.error.message);
|
|
8187
|
+
await sessionsKind.put({
|
|
8188
|
+
id: session.id,
|
|
8189
|
+
at: session.createdAt,
|
|
8190
|
+
expiresAt: session.expiresAt,
|
|
8191
|
+
data: parsed.data
|
|
8192
|
+
});
|
|
8193
|
+
} else await this.store.insert.mutate({
|
|
8194
|
+
collection: LOGIN_SESSIONS_COLLECTION,
|
|
8195
|
+
record: {
|
|
8196
|
+
id: session.id,
|
|
8197
|
+
data: { ...session }
|
|
8198
|
+
}
|
|
8199
|
+
});
|
|
8200
|
+
this.remember(session);
|
|
8201
|
+
return {
|
|
8202
|
+
session,
|
|
8203
|
+
refreshToken
|
|
8204
|
+
};
|
|
8205
|
+
}
|
|
8206
|
+
/** Is `sessionId` a live session right now? Synchronous: the hub asks on every request. */
|
|
8207
|
+
check(sessionId) {
|
|
8208
|
+
const session = this.sessions.get(sessionId);
|
|
8209
|
+
if (session === void 0) return { state: "unknown" };
|
|
8210
|
+
if (session.revokedAt !== null) return { state: "revoked" };
|
|
8211
|
+
if (session.expiresAt <= this.now()) return { state: "expired" };
|
|
8212
|
+
return {
|
|
8213
|
+
state: "active",
|
|
8214
|
+
session
|
|
8215
|
+
};
|
|
8216
|
+
}
|
|
8217
|
+
/** Epoch ms before which a pre-session (no `sid`) token of `userId` is refused; 0 = none. */
|
|
8218
|
+
legacyCutoff(userId) {
|
|
8219
|
+
return this.cutoffs.get(userId) ?? 0;
|
|
8220
|
+
}
|
|
8221
|
+
/**
|
|
8222
|
+
* The session a refresh token belongs to, WITHOUT rotating it - so the
|
|
8223
|
+
* refresh path can judge the session (its device credential) before it
|
|
8224
|
+
* spends the token. `null` when the token names no session.
|
|
8225
|
+
*/
|
|
8226
|
+
peekByRefreshToken(refreshToken) {
|
|
8227
|
+
if (!refreshToken.startsWith("csr_")) return null;
|
|
8228
|
+
const sessionId = this.byRefreshHash.get(hashToken(refreshToken));
|
|
8229
|
+
return sessionId === void 0 ? null : this.sessions.get(sessionId) ?? null;
|
|
8230
|
+
}
|
|
8231
|
+
/** Swap a refresh token for a rotated one, sliding the session's expiry to now + `lifetimeMs`. */
|
|
8232
|
+
async refresh(refreshToken, lifetimeMs) {
|
|
8233
|
+
if (!refreshToken.startsWith("csr_")) return {
|
|
8234
|
+
ok: false,
|
|
8235
|
+
reason: "unknown"
|
|
8236
|
+
};
|
|
8237
|
+
const presented = hashToken(refreshToken);
|
|
8238
|
+
const sessionId = this.byRefreshHash.get(presented);
|
|
8239
|
+
const session = sessionId === void 0 ? void 0 : this.sessions.get(sessionId);
|
|
8240
|
+
if (session === void 0) return {
|
|
8241
|
+
ok: false,
|
|
8242
|
+
reason: "unknown"
|
|
8243
|
+
};
|
|
8244
|
+
if (session.revokedAt !== null) return {
|
|
8245
|
+
ok: false,
|
|
8246
|
+
reason: "revoked"
|
|
8247
|
+
};
|
|
8248
|
+
const at = this.now();
|
|
8249
|
+
if (session.expiresAt <= at) return {
|
|
8250
|
+
ok: false,
|
|
8251
|
+
reason: "expired"
|
|
8252
|
+
};
|
|
8253
|
+
if (presented !== session.refreshHash) {
|
|
8254
|
+
if (!(presented === session.previousRefreshHash && session.rotatedAt !== null && at - session.rotatedAt <= REFRESH_REUSE_GRACE_MS)) {
|
|
8255
|
+
await this.revoke(session.id, "refresh-reuse");
|
|
8256
|
+
this.logger?.warn("A rotated-out refresh token was presented — session revoked", { meta: {
|
|
8257
|
+
sessionId: session.id,
|
|
8258
|
+
userId: session.userId
|
|
8259
|
+
} });
|
|
8260
|
+
return {
|
|
8261
|
+
ok: false,
|
|
8262
|
+
reason: "reused"
|
|
8263
|
+
};
|
|
8264
|
+
}
|
|
8265
|
+
}
|
|
8266
|
+
const next = this.newRefreshToken();
|
|
8267
|
+
const updated = {
|
|
8268
|
+
...session,
|
|
8269
|
+
lastUsedAt: at,
|
|
8270
|
+
expiresAt: at + lifetimeMs,
|
|
8271
|
+
previousRefreshHash: session.refreshHash,
|
|
8272
|
+
refreshHash: hashToken(next),
|
|
8273
|
+
rotatedAt: at
|
|
8274
|
+
};
|
|
8275
|
+
this.replace(session, updated);
|
|
8276
|
+
await this.persist(updated);
|
|
8277
|
+
return {
|
|
8278
|
+
ok: true,
|
|
8279
|
+
session: updated,
|
|
8280
|
+
refreshToken: next
|
|
8281
|
+
};
|
|
8282
|
+
}
|
|
8283
|
+
/** Renew an active session (legacy client): slide its expiry. `null` when it is not active. */
|
|
8284
|
+
async extend(sessionId, lifetimeMs) {
|
|
8285
|
+
const check = this.check(sessionId);
|
|
8286
|
+
if (check.state !== "active") return null;
|
|
8287
|
+
const at = this.now();
|
|
8288
|
+
const updated = {
|
|
8289
|
+
...check.session,
|
|
8290
|
+
lastUsedAt: at,
|
|
8291
|
+
expiresAt: at + lifetimeMs
|
|
8292
|
+
};
|
|
8293
|
+
this.replace(check.session, updated);
|
|
8294
|
+
await this.persist(updated);
|
|
8295
|
+
return updated;
|
|
8296
|
+
}
|
|
8297
|
+
/** End one session. `false` when it was unknown or already ended. */
|
|
8298
|
+
async revoke(sessionId, reason) {
|
|
8299
|
+
const session = this.sessions.get(sessionId);
|
|
8300
|
+
if (session === void 0 || session.revokedAt !== null) return false;
|
|
8301
|
+
const updated = {
|
|
8302
|
+
...session,
|
|
8303
|
+
revokedAt: this.now(),
|
|
8304
|
+
revokedReason: reason
|
|
8305
|
+
};
|
|
8306
|
+
this.replace(session, updated);
|
|
8307
|
+
this.announce([updated.id], session.userId, false, null, reason);
|
|
8308
|
+
await this.persist(updated);
|
|
8309
|
+
return true;
|
|
8310
|
+
}
|
|
8311
|
+
/**
|
|
8312
|
+
* End every live session of `userId` except `exceptSessionId`, and move the
|
|
8313
|
+
* user's legacy cutoff to now (pre-session tokens die too). Returns how many
|
|
8314
|
+
* sessions ended.
|
|
8315
|
+
*/
|
|
8316
|
+
async revokeAllForUser(userId, options) {
|
|
8317
|
+
const at = this.now();
|
|
8318
|
+
this.cutoffs.set(userId, at);
|
|
8319
|
+
const ended = [];
|
|
8320
|
+
for (const session of this.sessions.values()) {
|
|
8321
|
+
if (session.userId !== userId || session.revokedAt !== null) continue;
|
|
8322
|
+
if (session.id === options.exceptSessionId) continue;
|
|
8323
|
+
const updated = {
|
|
8324
|
+
...session,
|
|
8325
|
+
revokedAt: at,
|
|
8326
|
+
revokedReason: options.reason
|
|
8327
|
+
};
|
|
8328
|
+
this.replace(session, updated);
|
|
8329
|
+
ended.push(updated);
|
|
8330
|
+
}
|
|
8331
|
+
this.announce(ended.map((s) => s.id), userId, true, options.exceptSessionId ?? null, options.reason);
|
|
8332
|
+
await this.persistCutoff(userId, at);
|
|
8333
|
+
for (const session of ended) await this.persist(session);
|
|
8334
|
+
return ended.length;
|
|
8688
8335
|
}
|
|
8689
|
-
|
|
8690
|
-
|
|
8691
|
-
const
|
|
8692
|
-
const
|
|
8693
|
-
const
|
|
8694
|
-
|
|
8695
|
-
|
|
8696
|
-
|
|
8697
|
-
|
|
8698
|
-
|
|
8699
|
-
scopes: scopes.map((s) => ({ ...s })),
|
|
8700
|
-
expiresAt,
|
|
8701
|
-
lastUsedAt: void 0,
|
|
8702
|
-
createdAt: Date.now()
|
|
8703
|
-
};
|
|
8704
|
-
const kind = this.kind;
|
|
8705
|
-
if (kind !== null) {
|
|
8706
|
-
const body = tokenToBody(record);
|
|
8707
|
-
const parsed = localAuthRecordKinds["scoped-tokens"].schema.safeParse(body);
|
|
8708
|
-
if (!parsed.success) throw new Error(parsed.error.message);
|
|
8709
|
-
await kind.put({
|
|
8710
|
-
id: record.id,
|
|
8711
|
-
expiresAt: record.expiresAt ?? void 0,
|
|
8712
|
-
data: parsed.data
|
|
8713
|
-
});
|
|
8714
|
-
return {
|
|
8715
|
-
token: rawToken,
|
|
8716
|
-
record
|
|
8336
|
+
/** End every live session opened by device credential `credentialId`. Returns how many. */
|
|
8337
|
+
async revokeByCredential(credentialId, reason = "credential-revoked") {
|
|
8338
|
+
const at = this.now();
|
|
8339
|
+
const ended = [];
|
|
8340
|
+
for (const session of this.sessions.values()) {
|
|
8341
|
+
if (session.credentialId !== credentialId || session.revokedAt !== null) continue;
|
|
8342
|
+
const updated = {
|
|
8343
|
+
...session,
|
|
8344
|
+
revokedAt: at,
|
|
8345
|
+
revokedReason: reason
|
|
8717
8346
|
};
|
|
8347
|
+
this.replace(session, updated);
|
|
8348
|
+
ended.push(updated);
|
|
8718
8349
|
}
|
|
8719
|
-
|
|
8720
|
-
|
|
8721
|
-
|
|
8722
|
-
id: record.id,
|
|
8723
|
-
data: { ...record }
|
|
8724
|
-
}
|
|
8725
|
-
});
|
|
8726
|
-
return {
|
|
8727
|
-
token: rawToken,
|
|
8728
|
-
record
|
|
8729
|
-
};
|
|
8350
|
+
this.announce(ended.map((s) => s.id), null, false, null, reason);
|
|
8351
|
+
for (const session of ended) await this.persist(session);
|
|
8352
|
+
return ended.length;
|
|
8730
8353
|
}
|
|
8731
|
-
|
|
8732
|
-
|
|
8733
|
-
|
|
8734
|
-
|
|
8735
|
-
|
|
8736
|
-
|
|
8737
|
-
|
|
8738
|
-
|
|
8739
|
-
|
|
8740
|
-
|
|
8741
|
-
|
|
8742
|
-
|
|
8743
|
-
|
|
8744
|
-
|
|
8745
|
-
|
|
8354
|
+
/** `userId`'s live sessions, newest first. */
|
|
8355
|
+
listForUser(userId) {
|
|
8356
|
+
return this.listActive().filter((s) => s.userId === userId);
|
|
8357
|
+
}
|
|
8358
|
+
/** Every live session, newest first. */
|
|
8359
|
+
listActive() {
|
|
8360
|
+
const at = this.now();
|
|
8361
|
+
return [...this.sessions.values()].filter((s) => s.revokedAt === null && s.expiresAt > at).toSorted((a, b) => b.createdAt - a.createdAt).map(toLoginSessionSummary);
|
|
8362
|
+
}
|
|
8363
|
+
/** Delete rows expired more than 7 days ago or revoked more than 30. Returns how many. */
|
|
8364
|
+
async reap() {
|
|
8365
|
+
const at = this.now();
|
|
8366
|
+
const dead = [...this.sessions.values()].filter((s) => s.revokedAt !== null && at - s.revokedAt > REVOKED_RETENTION_MS || s.revokedAt === null && at - s.expiresAt > EXPIRED_RETENTION_MS);
|
|
8367
|
+
for (const session of dead) {
|
|
8368
|
+
this.forget(session);
|
|
8369
|
+
const sessionsKind = this.sessionsKind;
|
|
8370
|
+
if (sessionsKind !== null) await sessionsKind.delete(session.id);
|
|
8371
|
+
else await this.store.delete.mutate({
|
|
8372
|
+
collection: LOGIN_SESSIONS_COLLECTION,
|
|
8373
|
+
key: session.id
|
|
8746
8374
|
});
|
|
8747
|
-
record = results.length === 0 ? null : parseToken(results[0].data);
|
|
8748
8375
|
}
|
|
8749
|
-
|
|
8750
|
-
if (record.expiresAt !== void 0 && record.expiresAt !== null && Date.now() > record.expiresAt) return null;
|
|
8751
|
-
this.updateLastUsed(record.id).catch(() => {});
|
|
8752
|
-
return record;
|
|
8376
|
+
return dead.length;
|
|
8753
8377
|
}
|
|
8754
|
-
|
|
8755
|
-
|
|
8756
|
-
|
|
8757
|
-
|
|
8758
|
-
|
|
8759
|
-
|
|
8760
|
-
|
|
8761
|
-
|
|
8762
|
-
}
|
|
8763
|
-
return false;
|
|
8378
|
+
/** Tell the hub which sessions just ended — right after the mirror, before the store write. */
|
|
8379
|
+
announce(sessionIds, userId, allOfUser, exceptSessionId, reason) {
|
|
8380
|
+
announceSessionsRevoked({
|
|
8381
|
+
sessionIds,
|
|
8382
|
+
userId,
|
|
8383
|
+
allOfUser,
|
|
8384
|
+
exceptSessionId,
|
|
8385
|
+
reason
|
|
8386
|
+
}, (error) => this.logger?.error("A session-revocation listener threw — the revoke stands", { meta: { error: error instanceof Error ? error.message : String(error) } }));
|
|
8764
8387
|
}
|
|
8765
|
-
|
|
8766
|
-
|
|
8767
|
-
if (kind !== null) {
|
|
8768
|
-
if (await kind.get(tokenId) === null) return false;
|
|
8769
|
-
await kind.delete(tokenId);
|
|
8770
|
-
return true;
|
|
8771
|
-
}
|
|
8772
|
-
if ((await this.store.query.query({
|
|
8773
|
-
collection: TOKENS_COLLECTION,
|
|
8774
|
-
filter: { where: { id: tokenId } }
|
|
8775
|
-
})).length === 0) return false;
|
|
8776
|
-
await this.store.delete.mutate({
|
|
8777
|
-
collection: TOKENS_COLLECTION,
|
|
8778
|
-
key: tokenId
|
|
8779
|
-
});
|
|
8780
|
-
return true;
|
|
8388
|
+
newRefreshToken() {
|
|
8389
|
+
return `${REFRESH_TOKEN_PREFIX}${node_crypto.randomBytes(32).toString("base64url")}`;
|
|
8781
8390
|
}
|
|
8782
|
-
|
|
8783
|
-
|
|
8784
|
-
if (
|
|
8785
|
-
|
|
8786
|
-
collection: TOKENS_COLLECTION,
|
|
8787
|
-
filter: { where: { userId } }
|
|
8788
|
-
})).map((r) => parseToken(r.data));
|
|
8391
|
+
remember(session) {
|
|
8392
|
+
this.sessions.set(session.id, session);
|
|
8393
|
+
if (session.refreshHash !== null) this.byRefreshHash.set(session.refreshHash, session.id);
|
|
8394
|
+
if (session.previousRefreshHash !== null) this.byRefreshHash.set(session.previousRefreshHash, session.id);
|
|
8789
8395
|
}
|
|
8790
|
-
|
|
8791
|
-
|
|
8792
|
-
if (
|
|
8793
|
-
|
|
8794
|
-
if (row === null) return;
|
|
8795
|
-
const next = {
|
|
8796
|
-
...rowToToken(row.id, row.data),
|
|
8797
|
-
lastUsedAt: Date.now()
|
|
8798
|
-
};
|
|
8799
|
-
const body = tokenToBody(next);
|
|
8800
|
-
const parsed = localAuthRecordKinds["scoped-tokens"].schema.safeParse(body);
|
|
8801
|
-
if (!parsed.success) return;
|
|
8802
|
-
await kind.put({
|
|
8803
|
-
id: tokenId,
|
|
8804
|
-
expiresAt: next.expiresAt ?? void 0,
|
|
8805
|
-
data: parsed.data
|
|
8806
|
-
});
|
|
8807
|
-
return;
|
|
8808
|
-
}
|
|
8809
|
-
const results = await this.store.query.query({
|
|
8810
|
-
collection: TOKENS_COLLECTION,
|
|
8811
|
-
filter: { where: { id: tokenId } }
|
|
8812
|
-
});
|
|
8813
|
-
if (results.length === 0) return;
|
|
8814
|
-
const existing = parseToken(results[0].data);
|
|
8815
|
-
await this.store.update.mutate({
|
|
8816
|
-
collection: TOKENS_COLLECTION,
|
|
8817
|
-
id: tokenId,
|
|
8818
|
-
data: {
|
|
8819
|
-
...existing,
|
|
8820
|
-
lastUsedAt: Date.now()
|
|
8821
|
-
}
|
|
8822
|
-
});
|
|
8396
|
+
forget(session) {
|
|
8397
|
+
this.sessions.delete(session.id);
|
|
8398
|
+
if (session.refreshHash !== null) this.byRefreshHash.delete(session.refreshHash);
|
|
8399
|
+
if (session.previousRefreshHash !== null) this.byRefreshHash.delete(session.previousRefreshHash);
|
|
8823
8400
|
}
|
|
8824
|
-
|
|
8825
|
-
|
|
8826
|
-
|
|
8827
|
-
gte: 1,
|
|
8828
|
-
lte: nowMs - 1
|
|
8829
|
-
} } });
|
|
8830
|
-
const { deleted } = await this.store.deleteWhere.mutate({
|
|
8831
|
-
collection: TOKENS_COLLECTION,
|
|
8832
|
-
filter: { whereBetween: { expiresAt: [1, nowMs - 1] } }
|
|
8833
|
-
});
|
|
8834
|
-
return deleted;
|
|
8401
|
+
replace(previous, next) {
|
|
8402
|
+
this.forget(previous);
|
|
8403
|
+
this.remember(next);
|
|
8835
8404
|
}
|
|
8836
|
-
async
|
|
8837
|
-
|
|
8838
|
-
|
|
8839
|
-
|
|
8840
|
-
|
|
8841
|
-
|
|
8842
|
-
|
|
8843
|
-
|
|
8844
|
-
|
|
8845
|
-
|
|
8846
|
-
|
|
8847
|
-
|
|
8405
|
+
async persist(session) {
|
|
8406
|
+
try {
|
|
8407
|
+
const sessionsKind = this.sessionsKind;
|
|
8408
|
+
if (sessionsKind !== null) {
|
|
8409
|
+
const parsed = localAuthRecordKinds["login-sessions"].schema.safeParse(sessionToBody$1(session));
|
|
8410
|
+
if (!parsed.success) throw new Error(parsed.error.message);
|
|
8411
|
+
await sessionsKind.put({
|
|
8412
|
+
id: session.id,
|
|
8413
|
+
at: session.createdAt,
|
|
8414
|
+
expiresAt: session.expiresAt,
|
|
8415
|
+
data: parsed.data
|
|
8416
|
+
});
|
|
8417
|
+
} else await this.store.update.mutate({
|
|
8418
|
+
collection: LOGIN_SESSIONS_COLLECTION,
|
|
8419
|
+
id: session.id,
|
|
8420
|
+
data: { ...session }
|
|
8848
8421
|
});
|
|
8849
|
-
|
|
8422
|
+
} catch (error) {
|
|
8423
|
+
this.logger?.error("Login session write failed — the change holds until restart", { meta: {
|
|
8424
|
+
sessionId: session.id,
|
|
8425
|
+
error: error instanceof Error ? error.message : String(error)
|
|
8426
|
+
} });
|
|
8427
|
+
}
|
|
8428
|
+
}
|
|
8429
|
+
async persistCutoff(userId, notBefore) {
|
|
8430
|
+
try {
|
|
8431
|
+
const cutoffsKind = this.cutoffsKind;
|
|
8432
|
+
if (cutoffsKind !== null) {
|
|
8433
|
+
const parsed = localAuthRecordKinds["login-session-cutoffs"].schema.safeParse({ notBefore });
|
|
8434
|
+
if (!parsed.success) throw new Error(parsed.error.message);
|
|
8435
|
+
await cutoffsKind.put({
|
|
8436
|
+
id: userId,
|
|
8437
|
+
data: parsed.data
|
|
8438
|
+
});
|
|
8439
|
+
} else {
|
|
8440
|
+
await this.store.delete.mutate({
|
|
8441
|
+
collection: LOGIN_SESSION_CUTOFFS_COLLECTION,
|
|
8442
|
+
key: userId
|
|
8443
|
+
});
|
|
8444
|
+
await this.store.insert.mutate({
|
|
8445
|
+
collection: LOGIN_SESSION_CUTOFFS_COLLECTION,
|
|
8446
|
+
record: {
|
|
8447
|
+
id: userId,
|
|
8448
|
+
data: {
|
|
8449
|
+
userId,
|
|
8450
|
+
notBefore
|
|
8451
|
+
}
|
|
8452
|
+
}
|
|
8453
|
+
});
|
|
8454
|
+
}
|
|
8455
|
+
} catch (error) {
|
|
8456
|
+
this.logger?.error("Login session cutoff write failed — it holds until restart", { meta: {
|
|
8457
|
+
userId,
|
|
8458
|
+
error: error instanceof Error ? error.message : String(error)
|
|
8459
|
+
} });
|
|
8850
8460
|
}
|
|
8851
|
-
return removed;
|
|
8852
8461
|
}
|
|
8853
8462
|
};
|
|
8854
8463
|
//#endregion
|
|
@@ -8963,6 +8572,212 @@ var DeviceCaStore = class {
|
|
|
8963
8572
|
return this.loaded;
|
|
8964
8573
|
}
|
|
8965
8574
|
};
|
|
8575
|
+
var UserPreferenceBodySchema = zod.z.object({
|
|
8576
|
+
userId: zod.z.string().min(1),
|
|
8577
|
+
namespace: require_dist.PreferenceNamespaceSchema,
|
|
8578
|
+
value: require_dist.PreferenceValueSchema,
|
|
8579
|
+
revision: zod.z.number().int().positive(),
|
|
8580
|
+
updatedAt: zod.z.number().int(),
|
|
8581
|
+
updatedByDevice: require_dist.PreferenceDeviceIdSchema.nullable()
|
|
8582
|
+
});
|
|
8583
|
+
var localAuthPreferencesRecordKinds = require_dist.defineRecordKinds({ "user-preferences": {
|
|
8584
|
+
schema: UserPreferenceBodySchema,
|
|
8585
|
+
class: "config",
|
|
8586
|
+
writeTrigger: "a signed-in user saves one of their account preference namespaces (preferences.put) or deletes it",
|
|
8587
|
+
device: "none",
|
|
8588
|
+
at: "none",
|
|
8589
|
+
indexes: [{
|
|
8590
|
+
name: "user",
|
|
8591
|
+
fields: ["userId"]
|
|
8592
|
+
}],
|
|
8593
|
+
retention: { none: "a row goes when its user deletes the namespace, or with the user (deleteUser wipes every row of theirs)" },
|
|
8594
|
+
maxDataBytes: require_dist.PREFERENCES_MAX_VALUE_BYTES + 4 * 1024
|
|
8595
|
+
} });
|
|
8596
|
+
/** Namespaces cannot hold `:`, so the id splits unambiguously. */
|
|
8597
|
+
function userPreferenceRecordId(userId, namespace) {
|
|
8598
|
+
return `${userId}:${namespace}`;
|
|
8599
|
+
}
|
|
8600
|
+
//#endregion
|
|
8601
|
+
//#region src/builtins/local-auth/user-preferences-store.ts
|
|
8602
|
+
/**
|
|
8603
|
+
* Per-user account preferences (D894): one row per `(userId, namespace)`.
|
|
8604
|
+
*
|
|
8605
|
+
* Every method takes the OWNER's id as its first argument and touches only
|
|
8606
|
+
* that user's rows; the hub router stamps it from the principal, and nothing
|
|
8607
|
+
* here accepts a second user id.
|
|
8608
|
+
*
|
|
8609
|
+
* The poll (`changedSince`) is answered from an in-memory mirror of each
|
|
8610
|
+
* user's `namespace → revision`, loaded once on first touch. This store is the
|
|
8611
|
+
* kind's only writer (one in-process instance in `local-auth`), so the mirror
|
|
8612
|
+
* cannot fall behind the rows; a poll that finds nothing changed reads no row.
|
|
8613
|
+
* Writes of one user are serialized, so a read-modify-write never interleaves.
|
|
8614
|
+
*/
|
|
8615
|
+
/** A write the limits refuse; nothing was stored. */
|
|
8616
|
+
var PreferencesLimitError = class extends Error {
|
|
8617
|
+
reason;
|
|
8618
|
+
constructor(reason, message) {
|
|
8619
|
+
super(message);
|
|
8620
|
+
this.reason = reason;
|
|
8621
|
+
this.name = "PreferencesLimitError";
|
|
8622
|
+
}
|
|
8623
|
+
};
|
|
8624
|
+
/**
|
|
8625
|
+
* The limit refusal behind `error`, or `null`. By name and field, not
|
|
8626
|
+
* `instanceof`: the store and its caller may load different copies of this
|
|
8627
|
+
* module.
|
|
8628
|
+
*/
|
|
8629
|
+
function preferencesLimitReasonOf(error) {
|
|
8630
|
+
if (!(error instanceof Error) || error.name !== "PreferencesLimitError") return null;
|
|
8631
|
+
const reason = Reflect.get(error, "reason");
|
|
8632
|
+
return reason === "value-too-large" || reason === "too-many-namespaces" ? reason : null;
|
|
8633
|
+
}
|
|
8634
|
+
/** The page size of a per-user scan; one page covers a user within the limit. */
|
|
8635
|
+
var USER_SCAN_PAGE = 40;
|
|
8636
|
+
/** The page size of the boot orphan sweep over every user's rows. */
|
|
8637
|
+
var ORPHAN_SCAN_PAGE = 500;
|
|
8638
|
+
function toRow(body) {
|
|
8639
|
+
return {
|
|
8640
|
+
namespace: body.namespace,
|
|
8641
|
+
value: body.value,
|
|
8642
|
+
revision: body.revision,
|
|
8643
|
+
updatedAt: body.updatedAt,
|
|
8644
|
+
updatedByDevice: body.updatedByDevice
|
|
8645
|
+
};
|
|
8646
|
+
}
|
|
8647
|
+
function byNamespace(a, b) {
|
|
8648
|
+
return a.namespace < b.namespace ? -1 : a.namespace > b.namespace ? 1 : 0;
|
|
8649
|
+
}
|
|
8650
|
+
var UserPreferencesStore = class {
|
|
8651
|
+
kind;
|
|
8652
|
+
now;
|
|
8653
|
+
/** `userId → namespace → revision`; absent until the user is first touched. */
|
|
8654
|
+
mirrors = /* @__PURE__ */ new Map();
|
|
8655
|
+
locks = /* @__PURE__ */ new Map();
|
|
8656
|
+
constructor(kind, options = {}) {
|
|
8657
|
+
this.kind = kind;
|
|
8658
|
+
this.now = options.now ?? Date.now;
|
|
8659
|
+
}
|
|
8660
|
+
/** The caller's rows; `namespaces` narrows them (unknown ones are simply absent). */
|
|
8661
|
+
async get(userId, namespaces) {
|
|
8662
|
+
if (namespaces === void 0) return (await this.scanUser(userId)).map((r) => toRow(r.data)).toSorted(byNamespace);
|
|
8663
|
+
return (await this.kind.getMany([...new Set(namespaces)].map((ns) => userPreferenceRecordId(userId, ns)))).filter((r) => r.data.userId === userId).map((r) => toRow(r.data)).toSorted(byNamespace);
|
|
8664
|
+
}
|
|
8665
|
+
async put(userId, input) {
|
|
8666
|
+
const bytes = Buffer.byteLength(JSON.stringify(input.value), "utf8");
|
|
8667
|
+
if (bytes > 262144) throw new PreferencesLimitError("value-too-large", `preferences: ${input.namespace} is ${bytes} bytes, the limit is ${require_dist.PREFERENCES_MAX_VALUE_BYTES}`);
|
|
8668
|
+
return this.serialized(userId, async () => {
|
|
8669
|
+
const mirror = await this.mirrorOf(userId);
|
|
8670
|
+
const id = userPreferenceRecordId(userId, input.namespace);
|
|
8671
|
+
const existing = await this.kind.get(id);
|
|
8672
|
+
const currentRevision = existing?.data.revision ?? 0;
|
|
8673
|
+
if (input.baseRevision !== void 0 && input.baseRevision !== currentRevision) return {
|
|
8674
|
+
status: "conflict",
|
|
8675
|
+
current: existing === null ? null : toRow(existing.data)
|
|
8676
|
+
};
|
|
8677
|
+
if (existing === null && mirror.size >= 32) throw new PreferencesLimitError("too-many-namespaces", `preferences: a user holds at most 32 namespaces`);
|
|
8678
|
+
const body = {
|
|
8679
|
+
userId,
|
|
8680
|
+
namespace: input.namespace,
|
|
8681
|
+
value: input.value,
|
|
8682
|
+
revision: currentRevision + 1,
|
|
8683
|
+
updatedAt: this.now(),
|
|
8684
|
+
updatedByDevice: input.deviceId ?? null
|
|
8685
|
+
};
|
|
8686
|
+
await this.kind.put({
|
|
8687
|
+
id,
|
|
8688
|
+
data: body
|
|
8689
|
+
}, { ifRev: existing?.rev ?? 0 });
|
|
8690
|
+
mirror.set(input.namespace, body.revision);
|
|
8691
|
+
return {
|
|
8692
|
+
status: "saved",
|
|
8693
|
+
row: toRow(body)
|
|
8694
|
+
};
|
|
8695
|
+
});
|
|
8696
|
+
}
|
|
8697
|
+
/** `false` when the namespace did not exist. */
|
|
8698
|
+
async delete(userId, namespace) {
|
|
8699
|
+
return this.serialized(userId, async () => {
|
|
8700
|
+
const mirror = await this.mirrorOf(userId);
|
|
8701
|
+
const deleted = await this.kind.delete(userPreferenceRecordId(userId, namespace));
|
|
8702
|
+
mirror.delete(namespace);
|
|
8703
|
+
return deleted;
|
|
8704
|
+
});
|
|
8705
|
+
}
|
|
8706
|
+
/**
|
|
8707
|
+
* What the client is missing: every row whose revision differs from the one
|
|
8708
|
+
* it holds (or that it does not list), and every listed namespace now gone.
|
|
8709
|
+
*/
|
|
8710
|
+
async changedSince(userId, known) {
|
|
8711
|
+
const mirror = await this.mirrorOf(userId);
|
|
8712
|
+
const changed = [];
|
|
8713
|
+
for (const [namespace, revision] of mirror) if (known[namespace] !== revision) changed.push(namespace);
|
|
8714
|
+
const deleted = Object.keys(known).filter((namespace) => !mirror.has(namespace)).toSorted();
|
|
8715
|
+
return {
|
|
8716
|
+
rows: [...changed.length === 0 ? [] : await this.get(userId, changed)],
|
|
8717
|
+
deleted
|
|
8718
|
+
};
|
|
8719
|
+
}
|
|
8720
|
+
/** Every row of `userId` goes (user delete). Returns how many. */
|
|
8721
|
+
async wipeUser(userId) {
|
|
8722
|
+
return this.serialized(userId, async () => {
|
|
8723
|
+
const rows = await this.scanUser(userId);
|
|
8724
|
+
const removed = rows.length === 0 ? 0 : await this.kind.deleteMany(rows.map((r) => r.id));
|
|
8725
|
+
this.mirrors.delete(userId);
|
|
8726
|
+
return removed;
|
|
8727
|
+
});
|
|
8728
|
+
}
|
|
8729
|
+
/** Rows whose user is not in `liveUserIds` go (boot sweep). Returns how many. */
|
|
8730
|
+
async wipeOrphans(liveUserIds) {
|
|
8731
|
+
const orphans = /* @__PURE__ */ new Set();
|
|
8732
|
+
let cursor;
|
|
8733
|
+
do {
|
|
8734
|
+
const page = await this.kind.query({
|
|
8735
|
+
order: "id",
|
|
8736
|
+
limit: ORPHAN_SCAN_PAGE,
|
|
8737
|
+
...cursor !== void 0 ? { cursor } : {}
|
|
8738
|
+
});
|
|
8739
|
+
for (const row of page.records) if (!liveUserIds.has(row.data.userId)) orphans.add(row.data.userId);
|
|
8740
|
+
cursor = page.nextCursor ?? void 0;
|
|
8741
|
+
} while (cursor !== void 0);
|
|
8742
|
+
let removed = 0;
|
|
8743
|
+
for (const userId of orphans) removed += await this.wipeUser(userId);
|
|
8744
|
+
return removed;
|
|
8745
|
+
}
|
|
8746
|
+
async mirrorOf(userId) {
|
|
8747
|
+
const cached = this.mirrors.get(userId);
|
|
8748
|
+
if (cached !== void 0) return cached;
|
|
8749
|
+
const rows = await this.scanUser(userId);
|
|
8750
|
+
const raced = this.mirrors.get(userId);
|
|
8751
|
+
if (raced !== void 0) return raced;
|
|
8752
|
+
const mirror = new Map(rows.map((r) => [r.data.namespace, r.data.revision]));
|
|
8753
|
+
this.mirrors.set(userId, mirror);
|
|
8754
|
+
return mirror;
|
|
8755
|
+
}
|
|
8756
|
+
async scanUser(userId) {
|
|
8757
|
+
const out = [];
|
|
8758
|
+
let cursor;
|
|
8759
|
+
do {
|
|
8760
|
+
const page = await this.kind.query({
|
|
8761
|
+
where: { userId },
|
|
8762
|
+
limit: USER_SCAN_PAGE,
|
|
8763
|
+
...cursor !== void 0 ? { cursor } : {}
|
|
8764
|
+
});
|
|
8765
|
+
out.push(...page.records);
|
|
8766
|
+
cursor = page.nextCursor ?? void 0;
|
|
8767
|
+
} while (cursor !== void 0);
|
|
8768
|
+
return out;
|
|
8769
|
+
}
|
|
8770
|
+
/** Run `task` after every earlier write of the same user has settled. */
|
|
8771
|
+
serialized(userId, task) {
|
|
8772
|
+
const next = (this.locks.get(userId) ?? Promise.resolve()).then(task, task);
|
|
8773
|
+
const settled = next.catch(() => void 0);
|
|
8774
|
+
this.locks.set(userId, settled);
|
|
8775
|
+
settled.then(() => {
|
|
8776
|
+
if (this.locks.get(userId) === settled) this.locks.delete(userId);
|
|
8777
|
+
});
|
|
8778
|
+
return next;
|
|
8779
|
+
}
|
|
8780
|
+
};
|
|
8966
8781
|
//#endregion
|
|
8967
8782
|
//#region src/auth/user-management-internal.ts
|
|
8968
8783
|
/** Process-global, so every bundled copy of this module agrees on the key. */
|
|
@@ -12466,17 +12281,16 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
|
|
|
12466
12281
|
authManager = null;
|
|
12467
12282
|
userManager = null;
|
|
12468
12283
|
apiKeyManager = null;
|
|
12469
|
-
scopedTokenManager = null;
|
|
12470
12284
|
oauthSessionManager = null;
|
|
12471
12285
|
reapTimer = null;
|
|
12472
12286
|
totpManager = null;
|
|
12473
12287
|
externalIdentities = null;
|
|
12474
12288
|
signingKeyKeeper = null;
|
|
12475
12289
|
loginSessions = null;
|
|
12476
|
-
adminTokens = null;
|
|
12477
12290
|
auditLog = null;
|
|
12478
12291
|
deviceCredentials = null;
|
|
12479
12292
|
deviceCa = null;
|
|
12293
|
+
preferences = null;
|
|
12480
12294
|
constructor() {
|
|
12481
12295
|
super({});
|
|
12482
12296
|
}
|
|
@@ -12495,21 +12309,18 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
|
|
|
12495
12309
|
if (store && records) {
|
|
12496
12310
|
const useRecords = await require_phase6_declare_gate.resolvePhase6UseRecordsPathForHub(this.ctx, "local-auth", localAuthRecordKinds, LOCAL_AUTH_RECORDS_LEGACY_COLLECTIONS);
|
|
12497
12311
|
const authStores = useRecords ? localAuthRecordStoresOf(records) : null;
|
|
12312
|
+
await records.declare(localAuthPreferencesRecordKinds);
|
|
12313
|
+
this.preferences = new UserPreferencesStore(records.kind(localAuthPreferencesRecordKinds["user-preferences"]));
|
|
12498
12314
|
const legacyStoreAccess = { getStore: () => store };
|
|
12499
12315
|
await declareAuthSchema(store, { ...useRecords ? { skipCollections: LOCAL_AUTH_RECORDS_LEGACY_COLLECTIONS } : {} });
|
|
12500
12316
|
this.userManager = authStores === null ? new UserManager(legacyStoreAccess, this.authManager) : new UserManager({
|
|
12501
12317
|
usersKind: authStores.users,
|
|
12502
12318
|
legacyStore: store
|
|
12503
12319
|
}, this.authManager);
|
|
12504
|
-
this.adminTokens = authStores === null ? new AdminTokenManager(store) : new AdminTokenManager({ adminTokensKind: authStores.adminTokens });
|
|
12505
12320
|
this.apiKeyManager = authStores === null ? new ApiKeyManager(legacyStoreAccess) : new ApiKeyManager({
|
|
12506
12321
|
apiKeysKind: authStores.apiKeys,
|
|
12507
12322
|
legacyStore: store
|
|
12508
12323
|
});
|
|
12509
|
-
this.scopedTokenManager = authStores === null ? new ScopedTokenManager(store) : new ScopedTokenManager({
|
|
12510
|
-
scopedTokensKind: authStores.scopedTokens,
|
|
12511
|
-
legacyStore: store
|
|
12512
|
-
});
|
|
12513
12324
|
this.oauthSessionManager = authStores === null ? new OauthSessionManager(store) : new OauthSessionManager({
|
|
12514
12325
|
oauthSessionsKind: authStores.oauthSessions,
|
|
12515
12326
|
legacyStore: store
|
|
@@ -12570,8 +12381,12 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
|
|
|
12570
12381
|
});
|
|
12571
12382
|
const liveUsers = await this.userManager.listAll();
|
|
12572
12383
|
const liveIds = new Set(liveUsers.map((u) => u.id));
|
|
12573
|
-
|
|
12574
|
-
|
|
12384
|
+
try {
|
|
12385
|
+
const orphanPrefs = await this.preferences.wipeOrphans(liveIds);
|
|
12386
|
+
if (orphanPrefs > 0) this.ctx.logger.warn(`cleaned up ${orphanPrefs} orphan account-preference row(s) on boot`);
|
|
12387
|
+
} catch (err) {
|
|
12388
|
+
this.ctx.logger.warn("account-preference orphan sweep failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
12389
|
+
}
|
|
12575
12390
|
await this.reapAuthRows();
|
|
12576
12391
|
this.reapTimer = setInterval(() => {
|
|
12577
12392
|
this.reapAuthRows();
|
|
@@ -12685,29 +12500,6 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
|
|
|
12685
12500
|
await this.apiKeyManager.revoke(input.id);
|
|
12686
12501
|
return { success: true };
|
|
12687
12502
|
},
|
|
12688
|
-
createScopedToken: async (input) => {
|
|
12689
|
-
if (!this.scopedTokenManager) throw new Error("Scoped token management not available");
|
|
12690
|
-
const { token, record } = await this.scopedTokenManager.create(input.userId, input.name, input.scopes, input.expiresAt);
|
|
12691
|
-
return {
|
|
12692
|
-
token,
|
|
12693
|
-
record
|
|
12694
|
-
};
|
|
12695
|
-
},
|
|
12696
|
-
revokeScopedToken: async (input) => {
|
|
12697
|
-
if (!this.scopedTokenManager) throw new Error("Scoped token management not available");
|
|
12698
|
-
if (!await this.scopedTokenManager.revoke(input.id)) throw new Error(`Scoped token not found: ${input.id}`);
|
|
12699
|
-
return { success: true };
|
|
12700
|
-
},
|
|
12701
|
-
validateScopedToken: async (input) => {
|
|
12702
|
-
if (!this.scopedTokenManager || !this.userManager) return null;
|
|
12703
|
-
const record = await this.scopedTokenManager.validate(input.token);
|
|
12704
|
-
if (record === null) return null;
|
|
12705
|
-
return await this.userManager.findById(record.userId) === null ? null : record;
|
|
12706
|
-
},
|
|
12707
|
-
listScopedTokens: async (input) => {
|
|
12708
|
-
if (!this.scopedTokenManager) return [];
|
|
12709
|
-
return this.scopedTokenManager.listForUser(input.userId);
|
|
12710
|
-
},
|
|
12711
12503
|
oauthIssueCode: async (input) => {
|
|
12712
12504
|
return lazyOauthGrants().oauthIssueCode(input);
|
|
12713
12505
|
},
|
|
@@ -12949,7 +12741,9 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
|
|
|
12949
12741
|
if (this.deviceCredentials === null) throw require_dist.userError("device.credentials-unavailable", void 0, "Device credentials not available");
|
|
12950
12742
|
return this.deviceCredentials;
|
|
12951
12743
|
};
|
|
12744
|
+
const preferences = this.preferences;
|
|
12952
12745
|
return {
|
|
12746
|
+
...preferences !== null ? { preferences } : {},
|
|
12953
12747
|
sessions: {
|
|
12954
12748
|
create: (input) => sessions().create(input),
|
|
12955
12749
|
refresh: (token, lifetimeMs) => sessions().refresh(token, lifetimeMs),
|
|
@@ -12974,22 +12768,6 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
|
|
|
12974
12768
|
revoke: (id, by) => devices().revoke(id, by),
|
|
12975
12769
|
list: (input) => devices().list(input)
|
|
12976
12770
|
},
|
|
12977
|
-
adminTokens: {
|
|
12978
|
-
create: async (input) => {
|
|
12979
|
-
if (this.adminTokens === null) throw new Error("Admin tokens not available");
|
|
12980
|
-
return this.adminTokens.create(input);
|
|
12981
|
-
},
|
|
12982
|
-
validate: async (token) => {
|
|
12983
|
-
if (this.adminTokens === null || this.userManager === null) return null;
|
|
12984
|
-
const record = await this.adminTokens.validate(token);
|
|
12985
|
-
if (record === null) return null;
|
|
12986
|
-
const creator = await this.userManager.findById(record.createdBy);
|
|
12987
|
-
return creator !== null && creator.isAdmin ? record : null;
|
|
12988
|
-
},
|
|
12989
|
-
list: async () => this.adminTokens === null ? [] : this.adminTokens.list(),
|
|
12990
|
-
revoke: async (id) => this.adminTokens === null ? false : this.adminTokens.revoke(id)
|
|
12991
|
-
},
|
|
12992
|
-
scopedTokens: { revoke: async (id) => this.scopedTokenManager === null ? false : this.scopedTokenManager.revoke(id) },
|
|
12993
12771
|
oauthSessions: { revoke: async (id) => {
|
|
12994
12772
|
if (this.oauthSessionManager === null) return false;
|
|
12995
12773
|
const session = await this.oauthSessionManager.getById(id);
|
|
@@ -13030,18 +12808,15 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
|
|
|
13030
12808
|
async endAllAccess(userId) {
|
|
13031
12809
|
const steps = [
|
|
13032
12810
|
["sessions", async () => this.loginSessions?.revokeAllForUser(userId, { reason: "user-deleted" })],
|
|
13033
|
-
["scoped tokens", async () => {
|
|
13034
|
-
for (const token of await this.scopedTokenManager?.listForUser(userId) ?? []) await this.scopedTokenManager?.revoke(token.id);
|
|
13035
|
-
}],
|
|
13036
12811
|
["oauth sessions", async () => {
|
|
13037
12812
|
for (const session of await this.oauthSessionManager?.list() ?? []) if (session.userId === userId && session.revokedAt === null) await this.oauthSessionManager?.markRevoked(session.id);
|
|
13038
12813
|
}],
|
|
13039
|
-
["admin tokens", async () => this.adminTokens?.revokeAllCreatedBy(userId)],
|
|
13040
12814
|
["device credentials", async () => {
|
|
13041
12815
|
for (const id of await this.deviceCredentials?.revokeAllForUser(userId, "user-deleted") ?? []) await this.loginSessions?.revokeByCredential(id);
|
|
13042
12816
|
}],
|
|
13043
12817
|
["totp", async () => this.totpManager?.disable(userId)],
|
|
13044
|
-
["sign-in links", async () => this.externalIdentities?.removeAllForUser(userId)]
|
|
12818
|
+
["sign-in links", async () => this.externalIdentities?.removeAllForUser(userId)],
|
|
12819
|
+
["account preferences", async () => this.preferences?.wipeUser(userId)]
|
|
13045
12820
|
];
|
|
13046
12821
|
for (const [what, step] of steps) try {
|
|
13047
12822
|
await step();
|
|
@@ -13053,25 +12828,22 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
|
|
|
13053
12828
|
}
|
|
13054
12829
|
}
|
|
13055
12830
|
/**
|
|
13056
|
-
* Drop auth rows that are dead but were never removed:
|
|
13057
|
-
*
|
|
13058
|
-
*
|
|
12831
|
+
* Drop auth rows that are dead but were never removed: OAuth sessions
|
|
12832
|
+
* revoked longer ago than {@link OAUTH_REVOKED_RETENTION_MS}, dead login
|
|
12833
|
+
* sessions, old audit rows, dead device credentials. (Tokens are the hub's
|
|
12834
|
+
* `credential-tokens` rows, reaped there.)
|
|
13059
12835
|
*
|
|
13060
|
-
*
|
|
13061
|
-
*
|
|
13062
|
-
* one. So this is hygiene, and it is best-effort: a failed sweep is logged
|
|
12836
|
+
* No such row could authorise anything — every consumer treats a missing
|
|
12837
|
+
* session exactly like a revoked one. So this is hygiene, and it is best-effort: a failed sweep is logged
|
|
13063
12838
|
* and the next tick tries again. It must never be able to fail boot.
|
|
13064
12839
|
*/
|
|
13065
12840
|
async reapAuthRows() {
|
|
13066
12841
|
try {
|
|
13067
|
-
const tokens = await this.scopedTokenManager?.reapExpired() ?? 0;
|
|
13068
12842
|
const sessions = await this.oauthSessionManager?.reapRevoked(Date.now() - OAUTH_REVOKED_RETENTION_MS) ?? 0;
|
|
13069
12843
|
const logins = await this.loginSessions?.reap() ?? 0;
|
|
13070
|
-
await this.adminTokens?.reap();
|
|
13071
12844
|
await this.auditLog?.reap();
|
|
13072
12845
|
await this.deviceCredentials?.reap();
|
|
13073
|
-
if (
|
|
13074
|
-
expiredTokens: tokens,
|
|
12846
|
+
if (sessions > 0 || logins > 0) this.ctx.logger.info("auth retention swept", { meta: {
|
|
13075
12847
|
revokedSessions: sessions,
|
|
13076
12848
|
deadLoginSessions: logins
|
|
13077
12849
|
} });
|
|
@@ -13089,20 +12861,15 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
|
|
|
13089
12861
|
this.authManager = null;
|
|
13090
12862
|
this.userManager = null;
|
|
13091
12863
|
this.apiKeyManager = null;
|
|
13092
|
-
this.scopedTokenManager = null;
|
|
13093
12864
|
this.oauthSessionManager = null;
|
|
13094
12865
|
this.externalIdentities = null;
|
|
13095
12866
|
this.loginSessions = null;
|
|
13096
|
-
this.adminTokens = null;
|
|
13097
12867
|
this.auditLog = null;
|
|
13098
12868
|
this.deviceCredentials = null;
|
|
13099
12869
|
this.deviceCa = null;
|
|
13100
12870
|
}
|
|
13101
12871
|
};
|
|
13102
12872
|
//#endregion
|
|
13103
|
-
exports.ADMIN_TOKEN_MAX_LIFETIME_MS = ADMIN_TOKEN_MAX_LIFETIME_MS;
|
|
13104
|
-
exports.ADMIN_TOKEN_PREFIX = ADMIN_TOKEN_PREFIX;
|
|
13105
|
-
exports.AdminTokenManager = AdminTokenManager;
|
|
13106
12873
|
exports.ApiKeyManager = ApiKeyManager;
|
|
13107
12874
|
exports.AuthAuditLog = AuthAuditLog;
|
|
13108
12875
|
exports.AuthManager = AuthManager;
|
|
@@ -13115,7 +12882,6 @@ exports.default = LocalAuthAddon;
|
|
|
13115
12882
|
exports.LoginSessionManager = LoginSessionManager;
|
|
13116
12883
|
exports.PREVIOUS_LEAF_GRACE_MS = PREVIOUS_LEAF_GRACE_MS;
|
|
13117
12884
|
exports.REFRESH_TOKEN_PREFIX = REFRESH_TOKEN_PREFIX;
|
|
13118
|
-
exports.ScopedTokenManager = ScopedTokenManager;
|
|
13119
12885
|
exports.SettingsReadUnavailableError = SettingsReadUnavailableError;
|
|
13120
12886
|
exports.USER_MANAGEMENT_INTERNAL = USER_MANAGEMENT_INTERNAL;
|
|
13121
12887
|
exports.UserManager = UserManager;
|
|
@@ -13124,3 +12890,4 @@ exports.getUserManagementInternal = getUserManagementInternal;
|
|
|
13124
12890
|
exports.isSettingsReadUnavailable = isSettingsReadUnavailable;
|
|
13125
12891
|
exports.onSessionsRevoked = onSessionsRevoked;
|
|
13126
12892
|
exports.parseTokenExpiry = parseTokenExpiry;
|
|
12893
|
+
exports.preferencesLimitReasonOf = preferencesLimitReasonOf;
|