@camstack/server 1.2.486 → 1.2.487
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/agent-mesh-join.js +80 -0
- package/dist/agent/main.js +61 -2
- package/dist/api/auth-whoami.js +1 -1
- package/dist/api/core/auth-admin-tokens.procedures.js +138 -0
- package/dist/api/core/auth-limits.js +77 -0
- package/dist/api/core/auth-reauth.procedures.js +108 -0
- package/dist/api/core/auth-sessions.procedures.js +166 -0
- package/dist/api/core/auth.router.js +281 -80
- package/dist/api/health/adaptive-probe.routes.js +1 -1
- package/dist/api/trpc/core-cap-bridge.js +47 -5
- package/dist/api/trpc/generated-cap-mounts.js +19 -1
- package/dist/api/trpc/generated-cap-routers.js +512 -205
- package/dist/api/trpc/trpc.context.js +86 -34
- package/dist/api/trpc/trpc.middleware.js +8 -11
- package/dist/api/trpc/trpc.router.js +6 -1
- package/dist/auth/auth-network-policy.js +75 -0
- package/dist/auth/auth-rate-limit.js +25 -20
- package/dist/auth/client-address.js +68 -0
- package/dist/auth/external-link-intents.js +12 -3
- package/dist/auth/hub-sessions.js +74 -0
- package/dist/auth/native-link-completions.js +68 -0
- package/dist/auth/native-return.js +33 -0
- package/dist/auth/session-lifetimes.js +46 -0
- package/dist/auth/session-mint.js +110 -13
- package/dist/auth/sso-finish-deps.js +56 -1
- package/dist/auth/sso-finish.js +132 -17
- package/dist/auth/sso-routes.js +95 -0
- package/dist/auth/used-bridge-ids.js +52 -0
- package/dist/core/addon/addon-registry.service.js +5 -0
- package/dist/core/agent/agent-registry.service.js +10 -0
- package/dist/core/auth/auth.service.js +50 -1
- package/dist/core/auth/handoff-code.service.js +9 -3
- package/dist/core/moleculer/hub-mesh-guard.js +132 -0
- package/dist/core/moleculer/moleculer.service.js +33 -0
- package/dist/main.js +92 -28
- package/package.json +13 -13
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createAgentMeshJoin = createAgentMeshJoin;
|
|
4
|
+
/**
|
|
5
|
+
* The agent's mesh join (D802): authenticate to the hub on every connection,
|
|
6
|
+
* before anything else reaches it, and check that the hub is the hub.
|
|
7
|
+
*
|
|
8
|
+
* `MeshJoinClient` (in `@camstack/system`) holds the protocol and the call
|
|
9
|
+
* gate; this file binds it to the agent's broker lifecycle:
|
|
10
|
+
* - the hub connects → a join runs;
|
|
11
|
+
* - the hub disconnects → back to `pending` (calls wait for the next join);
|
|
12
|
+
* - the hub says "not authenticated" (it restarted and lost us) → join again;
|
|
13
|
+
* - the join settles `unavailable` (the hub cannot read its secret yet) →
|
|
14
|
+
* retried every {@link REJOIN_INTERVAL_MS} while connected.
|
|
15
|
+
*/
|
|
16
|
+
const system_1 = require("@camstack/system");
|
|
17
|
+
const REJOIN_INTERVAL_MS = 15_000;
|
|
18
|
+
const HUB_NODE_ID = 'hub';
|
|
19
|
+
function nodeIdOf(data) {
|
|
20
|
+
if (data === null || typeof data !== 'object')
|
|
21
|
+
return undefined;
|
|
22
|
+
const node = Reflect.get(data, 'node');
|
|
23
|
+
if (node === null || typeof node !== 'object')
|
|
24
|
+
return undefined;
|
|
25
|
+
const id = Reflect.get(node, 'id');
|
|
26
|
+
return typeof id === 'string' ? id : undefined;
|
|
27
|
+
}
|
|
28
|
+
function createAgentMeshJoin(opts) {
|
|
29
|
+
let connected = false;
|
|
30
|
+
let rejoinTimer = null;
|
|
31
|
+
const clearRejoin = () => {
|
|
32
|
+
if (rejoinTimer !== null)
|
|
33
|
+
clearTimeout(rejoinTimer);
|
|
34
|
+
rejoinTimer = null;
|
|
35
|
+
};
|
|
36
|
+
const client = new system_1.MeshJoinClient({
|
|
37
|
+
nodeId: opts.nodeId,
|
|
38
|
+
secret: opts.secret,
|
|
39
|
+
call: opts.call,
|
|
40
|
+
hubNodeId: HUB_NODE_ID,
|
|
41
|
+
...(opts.log !== undefined ? { log: opts.log } : {}),
|
|
42
|
+
onSettled: (state) => {
|
|
43
|
+
opts.onState?.(state);
|
|
44
|
+
clearRejoin();
|
|
45
|
+
if (state === 'unavailable' && connected) {
|
|
46
|
+
rejoinTimer = setTimeout(() => void runJoin(), REJOIN_INTERVAL_MS);
|
|
47
|
+
rejoinTimer.unref?.();
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
const runJoin = async () => {
|
|
52
|
+
if (!connected)
|
|
53
|
+
return;
|
|
54
|
+
await client.join();
|
|
55
|
+
};
|
|
56
|
+
const rejoin = () => {
|
|
57
|
+
client.reset();
|
|
58
|
+
void runJoin();
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
client,
|
|
62
|
+
middleware: client.middleware(rejoin),
|
|
63
|
+
attach(broker) {
|
|
64
|
+
broker.localBus.on('$node.connected', (data) => {
|
|
65
|
+
if (nodeIdOf(data) !== HUB_NODE_ID)
|
|
66
|
+
return;
|
|
67
|
+
connected = true;
|
|
68
|
+
rejoin();
|
|
69
|
+
});
|
|
70
|
+
broker.localBus.on('$node.disconnected', (data) => {
|
|
71
|
+
if (nodeIdOf(data) !== HUB_NODE_ID)
|
|
72
|
+
return;
|
|
73
|
+
connected = false;
|
|
74
|
+
clearRejoin();
|
|
75
|
+
client.reset();
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
stop: clearRejoin,
|
|
79
|
+
};
|
|
80
|
+
}
|
package/dist/agent/main.js
CHANGED
|
@@ -44,6 +44,7 @@ const package_dir_utils_js_1 = require("../core/addon/package-dir-utils.js");
|
|
|
44
44
|
const single_copy_cleanup_runner_js_1 = require("../single-copy-cleanup-runner.js");
|
|
45
45
|
const addon_scan_js_1 = require("./addon-scan.js");
|
|
46
46
|
const agent_addon_updates_js_1 = require("./agent-addon-updates.js");
|
|
47
|
+
const agent_mesh_join_js_1 = require("./agent-mesh-join.js");
|
|
47
48
|
const agent_config_js_1 = require("./agent-config.js");
|
|
48
49
|
const agent_group_runner_js_1 = require("./agent-group-runner.js");
|
|
49
50
|
const agent_http_js_1 = require("./agent-http.js");
|
|
@@ -144,12 +145,46 @@ async function startAgent(configPath) {
|
|
|
144
145
|
}
|
|
145
146
|
}
|
|
146
147
|
console.log(`[Agent] Addon packages ready in ${config.addonsDir}`);
|
|
148
|
+
// D802: the mesh join. Every call to the hub waits for the challenge–response
|
|
149
|
+
// (and the hub's own proof) on each connection. The secret comes from
|
|
150
|
+
// `agent.json`; a secret saved on the status page / desktop Setup takes
|
|
151
|
+
// effect through `reconnect()` below, which re-reads the file.
|
|
152
|
+
let currentSecret = config.secret ?? null;
|
|
153
|
+
let joinState = 'pending';
|
|
154
|
+
/** A registration waited for a join that had not settled; the join re-fires it. */
|
|
155
|
+
let registrationDeferred = false;
|
|
156
|
+
const meshJoin = (0, agent_mesh_join_js_1.createAgentMeshJoin)({
|
|
157
|
+
nodeId: config.nodeId,
|
|
158
|
+
secret: () => currentSecret,
|
|
159
|
+
call: (action, params, opts) => broker.call(action, params, opts),
|
|
160
|
+
log: (msg) => console.log(`[Agent] ${msg}`),
|
|
161
|
+
onState: (state) => {
|
|
162
|
+
joinState = state;
|
|
163
|
+
if (state === 'secret-mismatch' || state === 'hub-unverified') {
|
|
164
|
+
hubConnectionStateOverride = 'secret-mismatch';
|
|
165
|
+
consoleLogger.error(state === 'secret-mismatch'
|
|
166
|
+
? "mesh join refused: cluster secret mismatch — copy the cluster secret from the hub's System → Cluster page into this agent's setup"
|
|
167
|
+
: 'mesh join refused: the node answering as the hub could not prove the cluster secret — check the hub address and the secret');
|
|
168
|
+
}
|
|
169
|
+
else if (hubConnectionStateOverride === 'secret-mismatch') {
|
|
170
|
+
hubConnectionStateOverride = null;
|
|
171
|
+
}
|
|
172
|
+
const blocking = state === 'secret-mismatch' || state === 'hub-unverified';
|
|
173
|
+
if (!blocking && registrationDeferred)
|
|
174
|
+
triggerUpwardRegistration();
|
|
175
|
+
if (state === 'no-secret') {
|
|
176
|
+
consoleLogger.warn("no cluster secret configured — the hub tolerates this agent only while its System → Cluster is in Transition. Copy the hub's cluster secret into this agent's setup");
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
});
|
|
147
180
|
let broker = (0, system_1.createBroker)({
|
|
148
181
|
nodeID: config.nodeId,
|
|
149
182
|
mode: 'agent',
|
|
150
183
|
hubAddress: config.hubAddress,
|
|
151
184
|
logLevel: config.logLevel,
|
|
185
|
+
middlewares: [meshJoin.middleware],
|
|
152
186
|
});
|
|
187
|
+
meshJoin.attach(broker);
|
|
153
188
|
/**
|
|
154
189
|
* Reconnect: stop the current broker, reload config from file,
|
|
155
190
|
* create a new broker with the updated hub/secret, and restart.
|
|
@@ -178,12 +213,18 @@ async function startAgent(configPath) {
|
|
|
178
213
|
process.env['CAMSTACK_HUB_URL'] = freshHubUrl;
|
|
179
214
|
console.log(`[Agent] Derived CAMSTACK_HUB_URL=${freshHubUrl} from hub address "${fresh.hubAddress}"`);
|
|
180
215
|
}
|
|
216
|
+
currentSecret = fresh.secret ?? null;
|
|
181
217
|
broker = (0, system_1.createBroker)({
|
|
182
218
|
nodeID: config.nodeId,
|
|
183
219
|
mode: 'agent',
|
|
184
220
|
hubAddress: fresh.hubAddress,
|
|
185
221
|
logLevel: fresh.logLevel,
|
|
222
|
+
middlewares: [meshJoin.middleware],
|
|
186
223
|
});
|
|
224
|
+
meshJoin.attach(broker);
|
|
225
|
+
// The boot-time `$node.connected` listeners belong to the old broker: let
|
|
226
|
+
// the next settled join on this one re-send the manifest (D802).
|
|
227
|
+
registrationDeferred = true;
|
|
187
228
|
await broker.start();
|
|
188
229
|
console.log('[Agent] Reconnected successfully');
|
|
189
230
|
};
|
|
@@ -301,7 +342,12 @@ async function startAgent(configPath) {
|
|
|
301
342
|
// carries the agent's root-package identity so the hub's HubNodeRegistry
|
|
302
343
|
// (and the Server management UI) sees every node's exact version.
|
|
303
344
|
const rootPackage = agentUpdateService.getRunningRootPackage();
|
|
304
|
-
return (0, system_1.buildNodeManifest)(config.nodeId, allAddons, allNativeCaps.length > 0 ? allNativeCaps : undefined,
|
|
345
|
+
return (0, system_1.buildNodeManifest)(config.nodeId, allAddons, allNativeCaps.length > 0 ? allNativeCaps : undefined,
|
|
346
|
+
// D802: the replayable D797 hash goes out ONLY to a hub that predates the
|
|
347
|
+
// challenge–response join. After a successful join nothing is sent.
|
|
348
|
+
joinState === 'unsupported' && currentSecret !== null && currentSecret.length > 0
|
|
349
|
+
? (0, system_1.hashClusterSecret)(currentSecret)
|
|
350
|
+
: undefined, rootPackage.version === null
|
|
305
351
|
? undefined
|
|
306
352
|
: { name: rootPackage.name, version: rootPackage.version });
|
|
307
353
|
}
|
|
@@ -313,6 +359,19 @@ async function startAgent(configPath) {
|
|
|
313
359
|
* - on hub reconnect (via `$node.connected`)
|
|
314
360
|
*/
|
|
315
361
|
function triggerUpwardRegistration() {
|
|
362
|
+
// D802: the manifest goes to the hub only once the mesh join has settled —
|
|
363
|
+
// never to a node that could not prove it is the hub — and it is built
|
|
364
|
+
// AFTER the join, so it carries the legacy hash only for a hub that needs it.
|
|
365
|
+
meshJoin.client.whenCallable().then(() => {
|
|
366
|
+
registrationDeferred = false;
|
|
367
|
+
sendUpwardRegistration();
|
|
368
|
+
}, () => {
|
|
369
|
+
// Blocked (mismatch / unverified hub) or the join is still pending:
|
|
370
|
+
// the next settled join re-fires it (`onState`).
|
|
371
|
+
registrationDeferred = true;
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
function sendUpwardRegistration() {
|
|
316
375
|
(0, system_1.callRegisterNodeWithRetry)(broker, aggregateManifest(), {
|
|
317
376
|
target: 'hub',
|
|
318
377
|
signal: registerAbortController.signal,
|
|
@@ -325,7 +384,7 @@ async function startAgent(configPath) {
|
|
|
325
384
|
})
|
|
326
385
|
.catch((err) => {
|
|
327
386
|
if ((0, system_1.isClusterSecretMismatchError)(err)) {
|
|
328
|
-
consoleLogger.error(
|
|
387
|
+
consoleLogger.error("hub registration rejected: cluster secret mismatch — enter the hub's cluster secret (System → Cluster) in this agent's setup (agent.json) and restart the agent");
|
|
329
388
|
// Surface the mismatch in the UI status.
|
|
330
389
|
hubConnectionStateOverride = 'secret-mismatch';
|
|
331
390
|
return;
|
package/dist/api/auth-whoami.js
CHANGED
|
@@ -14,7 +14,7 @@ async function registerAuthWhoamiRoute(fastify, authService, addonRegistry) {
|
|
|
14
14
|
const ok = {
|
|
15
15
|
ok: true,
|
|
16
16
|
kind: 'jwt',
|
|
17
|
-
userId: payload.userId ??
|
|
17
|
+
userId: payload.userId ?? 'unknown',
|
|
18
18
|
username: payload.username ?? 'unknown',
|
|
19
19
|
isAdmin: payload.isAdmin,
|
|
20
20
|
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AuthAuditRecordSchema = exports.AdminTokenSchema = exports.ADMIN_TOKEN_MAX_DAYS = exports.ADMIN_TOKEN_DEFAULT_DAYS = void 0;
|
|
4
|
+
exports.createAdminTokenProcedures = createAdminTokenProcedures;
|
|
5
|
+
/**
|
|
6
|
+
* Admin tokens (`csa_`) and the auth audit trail, as the `auth` router serves
|
|
7
|
+
* them. The token store and its rules are local-auth's
|
|
8
|
+
* (`admin-token-manager.ts`); this layer adds who may mint one: an ADMIN, in a
|
|
9
|
+
* real signed-in session (never a token minting a token), right after
|
|
10
|
+
* re-authenticating. Every create / use / revoke is audited.
|
|
11
|
+
*/
|
|
12
|
+
const system_1 = require("@camstack/system");
|
|
13
|
+
const server_1 = require("@trpc/server");
|
|
14
|
+
const zod_1 = require("zod");
|
|
15
|
+
const hub_sessions_js_1 = require("../../auth/hub-sessions.js");
|
|
16
|
+
const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
|
|
17
|
+
const auth_reauth_procedures_js_1 = require("./auth-reauth.procedures.js");
|
|
18
|
+
const DAY_MS = 86_400_000;
|
|
19
|
+
exports.ADMIN_TOKEN_DEFAULT_DAYS = 7;
|
|
20
|
+
exports.ADMIN_TOKEN_MAX_DAYS = system_1.ADMIN_TOKEN_MAX_LIFETIME_MS / DAY_MS;
|
|
21
|
+
exports.AdminTokenSchema = zod_1.z.object({
|
|
22
|
+
id: zod_1.z.string(),
|
|
23
|
+
createdBy: zod_1.z.string(),
|
|
24
|
+
name: zod_1.z.string(),
|
|
25
|
+
tokenPrefix: zod_1.z.string(),
|
|
26
|
+
createdAt: zod_1.z.number(),
|
|
27
|
+
expiresAt: zod_1.z.number(),
|
|
28
|
+
lastUsedAt: zod_1.z.number().nullable(),
|
|
29
|
+
revokedAt: zod_1.z.number().nullable(),
|
|
30
|
+
});
|
|
31
|
+
exports.AuthAuditRecordSchema = zod_1.z.object({
|
|
32
|
+
id: zod_1.z.string(),
|
|
33
|
+
at: zod_1.z.number(),
|
|
34
|
+
action: zod_1.z.string(),
|
|
35
|
+
userId: zod_1.z.string().optional(),
|
|
36
|
+
ip: zod_1.z.string().optional(),
|
|
37
|
+
detail: zod_1.z.record(zod_1.z.string(), zod_1.z.union([zod_1.z.string(), zod_1.z.number(), zod_1.z.boolean()])).optional(),
|
|
38
|
+
});
|
|
39
|
+
function requireInternal(registry) {
|
|
40
|
+
const internal = registry === null ? null : (0, hub_sessions_js_1.userManagementInternal)(registry);
|
|
41
|
+
if (internal === null) {
|
|
42
|
+
throw new server_1.TRPCError({
|
|
43
|
+
code: 'PRECONDITION_FAILED',
|
|
44
|
+
message: 'User management is not available',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return internal;
|
|
48
|
+
}
|
|
49
|
+
function createAdminTokenProcedures(deps) {
|
|
50
|
+
return {
|
|
51
|
+
/**
|
|
52
|
+
* ADMIN — mint an admin token for an ops script. Requires a real
|
|
53
|
+
* signed-in session (an admin token cannot mint another) and a fresh
|
|
54
|
+
* re-authentication. Expiry mandatory: 1–90 days, default 7. The token
|
|
55
|
+
* is in this answer only.
|
|
56
|
+
*/
|
|
57
|
+
createAdminToken: trpc_middleware_js_1.adminProcedure
|
|
58
|
+
.input(zod_1.z.object({
|
|
59
|
+
name: zod_1.z.string().trim().min(1).max(80),
|
|
60
|
+
expiresInDays: zod_1.z
|
|
61
|
+
.number()
|
|
62
|
+
.int()
|
|
63
|
+
.min(1)
|
|
64
|
+
.max(exports.ADMIN_TOKEN_MAX_DAYS)
|
|
65
|
+
.default(exports.ADMIN_TOKEN_DEFAULT_DAYS),
|
|
66
|
+
reauthToken: auth_reauth_procedures_js_1.ReauthTokenInput.optional(),
|
|
67
|
+
}))
|
|
68
|
+
.output(zod_1.z.object({ token: zod_1.z.string(), record: exports.AdminTokenSchema }))
|
|
69
|
+
.mutation(async ({ ctx, input }) => {
|
|
70
|
+
if (ctx.user.isScoped === true ||
|
|
71
|
+
ctx.user.shareView !== undefined ||
|
|
72
|
+
ctx.user.credential !== undefined) {
|
|
73
|
+
throw new server_1.TRPCError({
|
|
74
|
+
code: 'FORBIDDEN',
|
|
75
|
+
message: 'An admin token can only be created from a signed-in admin session',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
(0, auth_reauth_procedures_js_1.assertReauthenticated)(deps.auth, ctx.user, input.reauthToken);
|
|
79
|
+
const internal = requireInternal(deps.registry);
|
|
80
|
+
const created = await internal.adminTokens.create({
|
|
81
|
+
createdBy: ctx.user.id,
|
|
82
|
+
name: input.name,
|
|
83
|
+
expiresAt: Date.now() + input.expiresInDays * DAY_MS,
|
|
84
|
+
});
|
|
85
|
+
const ip = deps.network.clientAddress(ctx.req).ip;
|
|
86
|
+
await internal.audit.record({
|
|
87
|
+
action: 'admin-token-created',
|
|
88
|
+
userId: ctx.user.id,
|
|
89
|
+
...(ip !== null ? { ip } : {}),
|
|
90
|
+
detail: {
|
|
91
|
+
tokenId: created.record.id,
|
|
92
|
+
name: created.record.name,
|
|
93
|
+
prefix: created.record.tokenPrefix,
|
|
94
|
+
expiresAt: created.record.expiresAt,
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
return created;
|
|
98
|
+
}),
|
|
99
|
+
/** ADMIN — every admin token not yet reaped (no secrets). */
|
|
100
|
+
listAdminTokens: trpc_middleware_js_1.adminProcedure
|
|
101
|
+
.input(zod_1.z.void())
|
|
102
|
+
.output(zod_1.z.array(exports.AdminTokenSchema))
|
|
103
|
+
.query(async () => [...(await requireInternal(deps.registry).adminTokens.list())]),
|
|
104
|
+
/** ADMIN — revoke one admin token; it stops working at once. */
|
|
105
|
+
revokeAdminToken: trpc_middleware_js_1.adminProcedure
|
|
106
|
+
.input(zod_1.z.object({ id: zod_1.z.string().min(1) }))
|
|
107
|
+
.output(zod_1.z.object({ success: zod_1.z.boolean() }))
|
|
108
|
+
.mutation(async ({ ctx, input }) => {
|
|
109
|
+
const internal = requireInternal(deps.registry);
|
|
110
|
+
const success = await internal.adminTokens.revoke(input.id);
|
|
111
|
+
if (success) {
|
|
112
|
+
const ip = deps.network.clientAddress(ctx.req).ip;
|
|
113
|
+
await internal.audit.record({
|
|
114
|
+
action: 'admin-token-revoked',
|
|
115
|
+
userId: ctx.user.id,
|
|
116
|
+
...(ip !== null ? { ip } : {}),
|
|
117
|
+
detail: { tokenId: input.id },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return { success };
|
|
121
|
+
}),
|
|
122
|
+
/** ADMIN — the authentication audit trail, newest first. */
|
|
123
|
+
listAuthAudit: trpc_middleware_js_1.adminProcedure
|
|
124
|
+
.input(zod_1.z
|
|
125
|
+
.object({
|
|
126
|
+
limit: zod_1.z.number().int().min(1).max(500).optional(),
|
|
127
|
+
userId: zod_1.z.string().min(1).optional(),
|
|
128
|
+
})
|
|
129
|
+
.optional())
|
|
130
|
+
.output(zod_1.z.array(exports.AuthAuditRecordSchema))
|
|
131
|
+
.query(async ({ input }) => [
|
|
132
|
+
...(await requireInternal(deps.registry).audit.list({
|
|
133
|
+
...(input?.limit !== undefined ? { limit: input.limit } : {}),
|
|
134
|
+
...(input?.userId !== undefined ? { userId: input.userId } : {}),
|
|
135
|
+
})),
|
|
136
|
+
]),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PROCESS_AUTH_LIMITERS = void 0;
|
|
4
|
+
exports.createAuthLimiters = createAuthLimiters;
|
|
5
|
+
exports.tooMany = tooMany;
|
|
6
|
+
exports.limitByClient = limitByClient;
|
|
7
|
+
exports.assertAccountNotLocked = assertAccountNotLocked;
|
|
8
|
+
exports.recordAccountFailure = recordAccountFailure;
|
|
9
|
+
exports.limitByUser = limitByUser;
|
|
10
|
+
/**
|
|
11
|
+
* Rate limits of the public auth surface, in one place.
|
|
12
|
+
*
|
|
13
|
+
* Two kinds of bucket, both fixed-window (`auth-rate-limit.ts`):
|
|
14
|
+
*
|
|
15
|
+
* - **per client address** — the REAL client behind a trusted proxy
|
|
16
|
+
* (`auth-network-policy.ts`), so one remote attacker no longer shares a
|
|
17
|
+
* bucket with every other user of the tunnel:
|
|
18
|
+
* • credential tier — anything that VERIFIES a secret (password, TOTP,
|
|
19
|
+
* passkey assertion, handoff code, session exchange, refresh): 10/min;
|
|
20
|
+
* • ceremony tier — begin-legs that only mint a challenge: 30/min.
|
|
21
|
+
* - **per account** — FAILURES only, 20 per 15 min. Peeked before the secret
|
|
22
|
+
* is checked and counted only when it is wrong, so guessing one account
|
|
23
|
+
* from many addresses is slowed while the owner's own logins never spend
|
|
24
|
+
* it. The price: someone hammering a known username can hold that account
|
|
25
|
+
* at the limit for the window. That is the documented trade.
|
|
26
|
+
* - **per user** — authenticated legs a hijacked session could flood:
|
|
27
|
+
* `beginExternalLink` (5/min; its intents are a bounded shared map) and
|
|
28
|
+
* `reauthenticate` (10/min).
|
|
29
|
+
*
|
|
30
|
+
* Mesh-internal calls (no originating request) are never limited.
|
|
31
|
+
*/
|
|
32
|
+
const server_1 = require("@trpc/server");
|
|
33
|
+
const auth_network_policy_js_1 = require("../../auth/auth-network-policy.js");
|
|
34
|
+
const auth_rate_limit_js_1 = require("../../auth/auth-rate-limit.js");
|
|
35
|
+
function createAuthLimiters(now) {
|
|
36
|
+
const clock = now !== undefined ? { now } : {};
|
|
37
|
+
return {
|
|
38
|
+
credential: (0, auth_rate_limit_js_1.createRateLimiter)({ windowMs: 60_000, max: 10, ...clock }),
|
|
39
|
+
ceremony: (0, auth_rate_limit_js_1.createRateLimiter)({ windowMs: 60_000, max: 30, ...clock }),
|
|
40
|
+
accountFailures: (0, auth_rate_limit_js_1.createRateLimiter)({ windowMs: 15 * 60_000, max: 20, ...clock }),
|
|
41
|
+
link: (0, auth_rate_limit_js_1.createRateLimiter)({ windowMs: 60_000, max: 5, ...clock }),
|
|
42
|
+
reauth: (0, auth_rate_limit_js_1.createRateLimiter)({ windowMs: 60_000, max: 10, ...clock }),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** The process-wide limiters: router rebuilds must not reset an attacker's budget. */
|
|
46
|
+
exports.PROCESS_AUTH_LIMITERS = createAuthLimiters();
|
|
47
|
+
function tooMany(retryAfterSeconds) {
|
|
48
|
+
return new server_1.TRPCError({
|
|
49
|
+
code: 'TOO_MANY_REQUESTS',
|
|
50
|
+
message: `Too many attempts — retry in ${retryAfterSeconds}s`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/** Count one attempt from the request's client address; throws when over. */
|
|
54
|
+
function limitByClient(limiter, bucket, req, network = auth_network_policy_js_1.DEFAULT_AUTH_NETWORK_POLICY) {
|
|
55
|
+
const ip = network.clientAddress(req).ip;
|
|
56
|
+
if (ip === null)
|
|
57
|
+
return;
|
|
58
|
+
const verdict = limiter.check(`${bucket}:${ip}`);
|
|
59
|
+
if (!verdict.allowed)
|
|
60
|
+
throw tooMany(verdict.retryAfterSeconds);
|
|
61
|
+
}
|
|
62
|
+
/** Refuse before checking a secret when `account` already failed too often. */
|
|
63
|
+
function assertAccountNotLocked(limiters, account) {
|
|
64
|
+
const verdict = limiters.accountFailures.peek(`account:${account.toLowerCase()}`);
|
|
65
|
+
if (!verdict.allowed)
|
|
66
|
+
throw tooMany(verdict.retryAfterSeconds);
|
|
67
|
+
}
|
|
68
|
+
/** Count one failed secret against `account`. */
|
|
69
|
+
function recordAccountFailure(limiters, account) {
|
|
70
|
+
limiters.accountFailures.check(`account:${account.toLowerCase()}`);
|
|
71
|
+
}
|
|
72
|
+
/** Count one call of an authenticated leg against the user; throws when over. */
|
|
73
|
+
function limitByUser(limiter, bucket, userId) {
|
|
74
|
+
const verdict = limiter.check(`${bucket}:${userId}`);
|
|
75
|
+
if (!verdict.allowed)
|
|
76
|
+
throw tooMany(verdict.retryAfterSeconds);
|
|
77
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.REAUTH_REQUIRED_MESSAGE = exports.ReauthTokenInput = void 0;
|
|
4
|
+
exports.assertReauthenticated = assertReauthenticated;
|
|
5
|
+
exports.createReauthProcedures = createReauthProcedures;
|
|
6
|
+
const server_1 = require("@trpc/server");
|
|
7
|
+
const zod_1 = require("zod");
|
|
8
|
+
const passkey_provider_js_1 = require("../../auth/passkey-provider.js");
|
|
9
|
+
const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
|
|
10
|
+
const auth_limits_js_1 = require("./auth-limits.js");
|
|
11
|
+
/** The input field a re-auth-gated procedure takes. */
|
|
12
|
+
exports.ReauthTokenInput = zod_1.z.string().min(1).max(4096);
|
|
13
|
+
exports.REAUTH_REQUIRED_MESSAGE = 'Confirm it is you first: re-enter your password or use a passkey (update the app if it cannot ask).';
|
|
14
|
+
/**
|
|
15
|
+
* Throw unless `reauthToken` is a live proof by THIS user, made in THIS
|
|
16
|
+
* session. A missing token is the same refusal: the action needs one.
|
|
17
|
+
*/
|
|
18
|
+
function assertReauthenticated(auth, user, reauthToken) {
|
|
19
|
+
const claims = reauthToken === undefined ? null : auth.verifyReauthToken(reauthToken);
|
|
20
|
+
const valid = claims !== null &&
|
|
21
|
+
claims.userId === user.id &&
|
|
22
|
+
(user.sessionId === undefined || claims.sessionId === user.sessionId);
|
|
23
|
+
if (!valid)
|
|
24
|
+
throw new server_1.TRPCError({ code: 'FORBIDDEN', message: exports.REAUTH_REQUIRED_MESSAGE });
|
|
25
|
+
}
|
|
26
|
+
function isPasswordChecker(value) {
|
|
27
|
+
return (value !== null &&
|
|
28
|
+
typeof value === 'object' &&
|
|
29
|
+
typeof Reflect.get(value, 'validateCredentials') === 'function');
|
|
30
|
+
}
|
|
31
|
+
/** Real user sessions only: a scoped or share token cannot prove a password. */
|
|
32
|
+
function assertPerson(user) {
|
|
33
|
+
if (user.isScoped === true || user.shareView !== undefined || user.adminTokenId !== undefined) {
|
|
34
|
+
throw new server_1.TRPCError({ code: 'FORBIDDEN', message: 'Requires a signed-in user session' });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function createReauthProcedures(deps) {
|
|
38
|
+
return {
|
|
39
|
+
/** Passkey re-authentication, step 1: assertion options for the caller's own credentials. */
|
|
40
|
+
reauthBeginPasskey: trpc_middleware_js_1.protectedProcedure
|
|
41
|
+
.input(zod_1.z.void())
|
|
42
|
+
.output(passkey_provider_js_1.OptionsJsonSchema)
|
|
43
|
+
.mutation(async ({ ctx }) => {
|
|
44
|
+
assertPerson(ctx.user);
|
|
45
|
+
(0, auth_limits_js_1.limitByUser)(deps.limiters.reauth, 'auth.reauthBeginPasskey', ctx.user.id);
|
|
46
|
+
const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(deps.registry, deps.moleculer);
|
|
47
|
+
if (!provider)
|
|
48
|
+
throw new Error('Passkey authentication is not available');
|
|
49
|
+
return provider.beginAuthentication({ userId: ctx.user.id });
|
|
50
|
+
}),
|
|
51
|
+
/**
|
|
52
|
+
* Prove it is you — the current password, or the passkey assertion from
|
|
53
|
+
* `reauthBeginPasskey`. Answers a 5-minute proof for the re-auth-gated
|
|
54
|
+
* actions, valid only for this user in this session.
|
|
55
|
+
*/
|
|
56
|
+
reauthenticate: trpc_middleware_js_1.protectedProcedure
|
|
57
|
+
.input(zod_1.z.union([
|
|
58
|
+
zod_1.z.object({ password: zod_1.z.string().min(1).max(1024) }),
|
|
59
|
+
zod_1.z.object({ passkeyResponse: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()) }),
|
|
60
|
+
]))
|
|
61
|
+
.output(zod_1.z.object({
|
|
62
|
+
reauthToken: zod_1.z.string(),
|
|
63
|
+
expiresAt: zod_1.z.number(),
|
|
64
|
+
method: zod_1.z.enum(['password', 'passkey']),
|
|
65
|
+
}))
|
|
66
|
+
.mutation(async ({ ctx, input }) => {
|
|
67
|
+
assertPerson(ctx.user);
|
|
68
|
+
(0, auth_limits_js_1.limitByUser)(deps.limiters.reauth, 'auth.reauthenticate', ctx.user.id);
|
|
69
|
+
(0, auth_limits_js_1.assertAccountNotLocked)(deps.limiters, ctx.user.username);
|
|
70
|
+
let method;
|
|
71
|
+
if ('password' in input) {
|
|
72
|
+
const userMgmt = deps.registry?.getSingleton('user-management');
|
|
73
|
+
if (!isPasswordChecker(userMgmt)) {
|
|
74
|
+
throw new Error('user-management capability not available');
|
|
75
|
+
}
|
|
76
|
+
const record = await userMgmt.validateCredentials({
|
|
77
|
+
username: ctx.user.username,
|
|
78
|
+
password: input.password,
|
|
79
|
+
});
|
|
80
|
+
if (record === null || record.id !== ctx.user.id) {
|
|
81
|
+
(0, auth_limits_js_1.recordAccountFailure)(deps.limiters, ctx.user.username);
|
|
82
|
+
throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'Password is incorrect' });
|
|
83
|
+
}
|
|
84
|
+
method = 'password';
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(deps.registry, deps.moleculer);
|
|
88
|
+
if (!provider)
|
|
89
|
+
throw new Error('Passkey authentication is not available');
|
|
90
|
+
const result = await provider.finishAuthentication({
|
|
91
|
+
userId: ctx.user.id,
|
|
92
|
+
response: input.passkeyResponse,
|
|
93
|
+
});
|
|
94
|
+
if (!result.verified) {
|
|
95
|
+
(0, auth_limits_js_1.recordAccountFailure)(deps.limiters, ctx.user.username);
|
|
96
|
+
throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'Passkey verification failed' });
|
|
97
|
+
}
|
|
98
|
+
method = 'passkey';
|
|
99
|
+
}
|
|
100
|
+
const reauthToken = deps.auth.signReauthToken({
|
|
101
|
+
userId: ctx.user.id,
|
|
102
|
+
method,
|
|
103
|
+
...(ctx.user.sessionId !== undefined ? { sessionId: ctx.user.sessionId } : {}),
|
|
104
|
+
});
|
|
105
|
+
return { reauthToken, expiresAt: Date.now() + 300_000, method };
|
|
106
|
+
}),
|
|
107
|
+
};
|
|
108
|
+
}
|