@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,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.UsedBridgeIds = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The `jti`s of login bridges already swapped for a session or a link.
|
|
6
|
+
*
|
|
7
|
+
* A bridge lives 5 minutes; until it expires a second presentation is refused.
|
|
8
|
+
* Entries are kept for {@link RETENTION_MS} (longer than any bridge), and the
|
|
9
|
+
* set is bounded — only a bridge that verified AND matched its browser's
|
|
10
|
+
* state cookie is ever recorded, so filling it needs that many real sign-ins.
|
|
11
|
+
*
|
|
12
|
+
* In memory: a hub restart within a bridge's 5 minutes forgets it, and the
|
|
13
|
+
* browser binding (the bridge is useless without the state cookie of the
|
|
14
|
+
* browser that started the sign-in) is what still stands then.
|
|
15
|
+
*/
|
|
16
|
+
const RETENTION_MS = 10 * 60_000;
|
|
17
|
+
const DEFAULT_MAX_ENTRIES = 10_000;
|
|
18
|
+
class UsedBridgeIds {
|
|
19
|
+
used = new Map();
|
|
20
|
+
now;
|
|
21
|
+
maxEntries;
|
|
22
|
+
constructor(opts = {}) {
|
|
23
|
+
this.now = opts.now ?? Date.now;
|
|
24
|
+
this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
25
|
+
}
|
|
26
|
+
/** Record `jti`; `true` the first time, `false` for every replay. */
|
|
27
|
+
consume(jti) {
|
|
28
|
+
const at = this.now();
|
|
29
|
+
this.prune(at);
|
|
30
|
+
if (this.used.has(jti))
|
|
31
|
+
return false;
|
|
32
|
+
while (this.used.size >= this.maxEntries) {
|
|
33
|
+
const oldest = this.used.keys().next();
|
|
34
|
+
if (oldest.done)
|
|
35
|
+
break;
|
|
36
|
+
this.used.delete(oldest.value);
|
|
37
|
+
}
|
|
38
|
+
this.used.set(jti, at + RETENTION_MS);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
size() {
|
|
42
|
+
return this.used.size;
|
|
43
|
+
}
|
|
44
|
+
prune(at) {
|
|
45
|
+
for (const [jti, until] of this.used) {
|
|
46
|
+
if (until > at)
|
|
47
|
+
break; // insertion order = expiry order
|
|
48
|
+
this.used.delete(jti);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
exports.UsedBridgeIds = UsedBridgeIds;
|
|
@@ -180,11 +180,15 @@ function parseSerializableRouteDescriptors(raw) {
|
|
|
180
180
|
throw new Error('addon-routes: route descriptor missing method/path');
|
|
181
181
|
}
|
|
182
182
|
const description = Reflect.get(entry, 'description');
|
|
183
|
+
// An unknown limit name is dropped, never widened into "no limit" silently
|
|
184
|
+
// by a typo on the hub side: only names the hub implements are kept.
|
|
185
|
+
const rateLimit = Reflect.get(entry, 'rateLimit') === 'auth' ? 'auth' : undefined;
|
|
183
186
|
return {
|
|
184
187
|
method: asRouteMethod(method),
|
|
185
188
|
path: routePath,
|
|
186
189
|
access: asRouteAccess(Reflect.get(entry, 'access')),
|
|
187
190
|
...(typeof description === 'string' ? { description } : {}),
|
|
191
|
+
...(rateLimit !== undefined ? { rateLimit } : {}),
|
|
188
192
|
};
|
|
189
193
|
});
|
|
190
194
|
}
|
|
@@ -2686,6 +2690,7 @@ class AddonRegistryService {
|
|
|
2686
2690
|
path: route.path,
|
|
2687
2691
|
access: route.access,
|
|
2688
2692
|
...(route.description !== undefined ? { description: route.description } : {}),
|
|
2693
|
+
...(route.rateLimit !== undefined ? { rateLimit: route.rateLimit } : {}),
|
|
2689
2694
|
handler: async (req, reply) => {
|
|
2690
2695
|
const envelope = await invoker.invoke({
|
|
2691
2696
|
method: route.method,
|
|
@@ -448,6 +448,16 @@ class AgentRegistryService {
|
|
|
448
448
|
this.moleculer.setOnAgentRegistered((agentId) => {
|
|
449
449
|
void this.reconcileAgentAddons(agentId, 'node-registration');
|
|
450
450
|
});
|
|
451
|
+
// D802: the mesh `transition` ends only once every agent this hub has
|
|
452
|
+
// EVER seen has authenticated — offline ones included. `null` (store not
|
|
453
|
+
// wired or not answering) never counts as "no agents".
|
|
454
|
+
this.moleculer.setKnownAgentsProvider(async () => {
|
|
455
|
+
const store = this.historyStore;
|
|
456
|
+
if (!store)
|
|
457
|
+
return null;
|
|
458
|
+
const rows = await store.tryGetAll();
|
|
459
|
+
return rows === null ? null : rows.filter((r) => !r.isHub).map((r) => r.id);
|
|
460
|
+
});
|
|
451
461
|
this.broker.localBus.on('$node.connected', ({ node }) => {
|
|
452
462
|
const kind = classifyNode(node.id);
|
|
453
463
|
if (!kind)
|
|
@@ -1,7 +1,56 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.AuthService = void 0;
|
|
3
|
+
exports.AuthService = exports.SessionRevokedError = void 0;
|
|
4
4
|
const system_1 = require("@camstack/system");
|
|
5
|
+
/**
|
|
6
|
+
* A session-grade token whose server-side session is gone (logged out,
|
|
7
|
+
* revoked, expired) or cannot be checked right now. Every caller of
|
|
8
|
+
* `verifyToken` already treats a throw as "not authenticated".
|
|
9
|
+
*/
|
|
10
|
+
class SessionRevokedError extends Error {
|
|
11
|
+
reason;
|
|
12
|
+
constructor(reason) {
|
|
13
|
+
super(`Session is no longer valid (${reason})`);
|
|
14
|
+
this.reason = reason;
|
|
15
|
+
this.name = 'SessionRevokedError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
exports.SessionRevokedError = SessionRevokedError;
|
|
19
|
+
/** A v2 session token: string `userId`, boolean `isAdmin`, no `kind` (not a bridge/challenge). */
|
|
20
|
+
function isSessionGrade(payload) {
|
|
21
|
+
return (Reflect.get(payload, 'kind') === undefined &&
|
|
22
|
+
typeof payload.userId === 'string' &&
|
|
23
|
+
typeof payload.isAdmin === 'boolean');
|
|
24
|
+
}
|
|
5
25
|
class AuthService extends system_1.AuthManager {
|
|
26
|
+
sessionCheck = null;
|
|
27
|
+
/**
|
|
28
|
+
* Wire the login-session check (`hub-sessions.ts`). From then on EVERY
|
|
29
|
+
* `verifyToken` of a session-grade token — the tRPC boundary, addon routes,
|
|
30
|
+
* the session cookie, the OAuth authorize page, health, uploads — also
|
|
31
|
+
* requires its session to be live, so a revocation applies everywhere at
|
|
32
|
+
* once and no surface can forget to ask.
|
|
33
|
+
*/
|
|
34
|
+
setSessionCheck(check) {
|
|
35
|
+
this.sessionCheck = check;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Why the session behind `payload` is no longer valid, or `null` when it is
|
|
39
|
+
* (or when no check is wired). For a principal resolved earlier — a
|
|
40
|
+
* WebSocket re-checks its session on every call without re-verifying an
|
|
41
|
+
* access token that may have expired since connect.
|
|
42
|
+
*/
|
|
43
|
+
checkSession(payload) {
|
|
44
|
+
return this.sessionCheck === null ? null : this.sessionCheck(payload);
|
|
45
|
+
}
|
|
46
|
+
verifyToken(token) {
|
|
47
|
+
const payload = super.verifyToken(token);
|
|
48
|
+
if (this.sessionCheck !== null && isSessionGrade(payload)) {
|
|
49
|
+
const refusal = this.sessionCheck(payload);
|
|
50
|
+
if (refusal !== null)
|
|
51
|
+
throw new SessionRevokedError(refusal);
|
|
52
|
+
}
|
|
53
|
+
return payload;
|
|
54
|
+
}
|
|
6
55
|
}
|
|
7
56
|
exports.AuthService = AuthService;
|
|
@@ -79,14 +79,19 @@ class HandoffCodeService {
|
|
|
79
79
|
/** Mint a single-use code bound to `userId`. Throws when the pending
|
|
80
80
|
* store is full even after pruning expired entries (fail fast — a
|
|
81
81
|
* legitimate flow never has anywhere near this many in flight). */
|
|
82
|
-
create(userId) {
|
|
82
|
+
create(userId, options = {}) {
|
|
83
83
|
this.prune();
|
|
84
84
|
if (this.pending.size >= exports.HANDOFF_CODE_MAX_PENDING) {
|
|
85
85
|
throw new Error('Too many pending handoff codes — try again shortly');
|
|
86
86
|
}
|
|
87
87
|
const code = `${exports.HANDOFF_CODE_PREFIX}${crypto.randomBytes(32).toString('hex')}`;
|
|
88
88
|
const expiresAt = this.now() + this.ttlMs;
|
|
89
|
-
this.pending.set(hashCode(code), {
|
|
89
|
+
this.pending.set(hashCode(code), {
|
|
90
|
+
userId,
|
|
91
|
+
...(options.sessionId !== undefined ? { sessionId: options.sessionId } : {}),
|
|
92
|
+
purpose: options.purpose ?? 'renew',
|
|
93
|
+
expiresAt,
|
|
94
|
+
});
|
|
90
95
|
return { code, expiresAt };
|
|
91
96
|
}
|
|
92
97
|
/** Redeem a raw code. Consumes the entry on the FIRST attempt no
|
|
@@ -99,7 +104,8 @@ class HandoffCodeService {
|
|
|
99
104
|
this.pending.delete(hashCode(code));
|
|
100
105
|
if (this.now() > entry.expiresAt)
|
|
101
106
|
return null;
|
|
102
|
-
|
|
107
|
+
const { expiresAt: _expiresAt, ...grant } = entry;
|
|
108
|
+
return grant;
|
|
103
109
|
}
|
|
104
110
|
/** Number of not-yet-redeemed (possibly expired) codes. Test/diag aid. */
|
|
105
111
|
get pendingCount() {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HubMeshGuard = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The hub's mesh guard (D802) — one object owning everything that decides
|
|
6
|
+
* whether another node may talk to the hub:
|
|
7
|
+
*
|
|
8
|
+
* - the {@link MeshAuthenticator} (who proved what, on which connection);
|
|
9
|
+
* - the broker middleware built on it (installed at broker construction);
|
|
10
|
+
* - the System → Cluster status slot;
|
|
11
|
+
* - the automatic end of `transition` ({@link decideMeshAutoEnforce}), checked
|
|
12
|
+
* after every successful join and once a minute.
|
|
13
|
+
*
|
|
14
|
+
* Kept out of `moleculer.service.ts` so that file only wires it.
|
|
15
|
+
*/
|
|
16
|
+
const system_1 = require("@camstack/system");
|
|
17
|
+
const types_1 = require("@camstack/types");
|
|
18
|
+
/** How often `transition` is re-checked when nothing joins. */
|
|
19
|
+
const AUTO_ENFORCE_INTERVAL_MS = 60_000;
|
|
20
|
+
class HubMeshGuard {
|
|
21
|
+
ownNodeId;
|
|
22
|
+
logger;
|
|
23
|
+
authenticator;
|
|
24
|
+
startedAt = Date.now();
|
|
25
|
+
knownAgents = null;
|
|
26
|
+
listConnected = () => [];
|
|
27
|
+
timer = null;
|
|
28
|
+
evaluating = false;
|
|
29
|
+
lastWaitingFor = '';
|
|
30
|
+
constructor(ownNodeId, logger) {
|
|
31
|
+
this.ownNodeId = ownNodeId;
|
|
32
|
+
this.logger = logger;
|
|
33
|
+
this.authenticator = new system_1.MeshAuthenticator({
|
|
34
|
+
ownNodeId,
|
|
35
|
+
gate: system_1.getProcessClusterSecretGate,
|
|
36
|
+
mode: system_1.getProcessMeshAuthMode,
|
|
37
|
+
onAuthenticated: (nodeId) => {
|
|
38
|
+
this.logger.info('node authenticated to the mesh (challenge–response)', {
|
|
39
|
+
meta: { nodeId },
|
|
40
|
+
});
|
|
41
|
+
void this.evaluateAutoEnforce();
|
|
42
|
+
},
|
|
43
|
+
onTolerated: (nodeId, action) => {
|
|
44
|
+
this.logger.warn('node is calling the hub WITHOUT having authenticated — tolerated while System → ' +
|
|
45
|
+
'Cluster is in Transition. Enter the cluster secret in this agent.', { meta: { nodeId, firstCall: action } });
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
(0, system_1.setProcessMeshAuthStatusSource)(() => this.authenticator.snapshot());
|
|
49
|
+
}
|
|
50
|
+
/** The middleware the hub broker must be constructed with. */
|
|
51
|
+
middleware() {
|
|
52
|
+
return (0, system_1.createMeshAccessMiddleware)({
|
|
53
|
+
ownNodeId: this.ownNodeId,
|
|
54
|
+
authenticator: this.authenticator,
|
|
55
|
+
logger: {
|
|
56
|
+
warn: (message, meta) => this.logger.warn(message, meta ? { meta } : undefined),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** Start the periodic auto-enforce check; `listConnected` reads the live node list. */
|
|
61
|
+
start(listConnected) {
|
|
62
|
+
this.listConnected = listConnected;
|
|
63
|
+
if (this.timer !== null)
|
|
64
|
+
return;
|
|
65
|
+
this.timer = setInterval(() => void this.evaluateAutoEnforce(), AUTO_ENFORCE_INTERVAL_MS);
|
|
66
|
+
this.timer.unref?.();
|
|
67
|
+
}
|
|
68
|
+
/** Wired by `AgentRegistryService`, which owns the cluster-node history. */
|
|
69
|
+
setKnownAgentsProvider(provider) {
|
|
70
|
+
this.knownAgents = provider;
|
|
71
|
+
}
|
|
72
|
+
/** A node's connection dropped. */
|
|
73
|
+
forget(nodeId) {
|
|
74
|
+
this.authenticator.forget(nodeId);
|
|
75
|
+
}
|
|
76
|
+
stop() {
|
|
77
|
+
if (this.timer !== null)
|
|
78
|
+
clearInterval(this.timer);
|
|
79
|
+
this.timer = null;
|
|
80
|
+
(0, system_1.setProcessMeshAuthStatusSource)(null);
|
|
81
|
+
}
|
|
82
|
+
/** Public for the spec; the timer and every join call it. */
|
|
83
|
+
async evaluateAutoEnforce() {
|
|
84
|
+
if (this.evaluating || (0, system_1.getProcessMeshAuthMode)() !== 'transition')
|
|
85
|
+
return;
|
|
86
|
+
this.evaluating = true;
|
|
87
|
+
try {
|
|
88
|
+
let knownAgents = null;
|
|
89
|
+
if (this.knownAgents !== null) {
|
|
90
|
+
try {
|
|
91
|
+
knownAgents = await this.knownAgents();
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
knownAgents = null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const decision = (0, system_1.decideMeshAutoEnforce)({
|
|
98
|
+
mode: (0, system_1.getProcessMeshAuthMode)(),
|
|
99
|
+
gate: (0, system_1.getProcessClusterSecretGate)().kind,
|
|
100
|
+
uptimeMs: Date.now() - this.startedAt,
|
|
101
|
+
knownAgents,
|
|
102
|
+
connected: this.listConnected(),
|
|
103
|
+
toleratedEver: this.authenticator.everToleratedNodeIds(),
|
|
104
|
+
authenticatedEver: this.authenticator.everAuthenticatedNodeIds(),
|
|
105
|
+
ownNodeId: this.ownNodeId,
|
|
106
|
+
});
|
|
107
|
+
if (!decision.enforce) {
|
|
108
|
+
const waiting = decision.waitingFor.join(',');
|
|
109
|
+
if (waiting.length > 0 && waiting !== this.lastWaitingFor) {
|
|
110
|
+
this.logger.info('mesh transition: still waiting for agents to authenticate', {
|
|
111
|
+
meta: { waitingFor: decision.waitingFor },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
this.lastWaitingFor = waiting;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const done = await (0, system_1.requestProcessMeshEnforcement)(decision.reason);
|
|
118
|
+
if (done) {
|
|
119
|
+
this.logger.info('mesh authentication switched to ENFORCED', {
|
|
120
|
+
meta: { reason: decision.reason },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
this.logger.warn('mesh auto-enforce check failed', { meta: { error: (0, types_1.errMsg)(err) } });
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
this.evaluating = false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
exports.HubMeshGuard = HubMeshGuard;
|
|
@@ -9,6 +9,7 @@ const types_1 = require("@camstack/types");
|
|
|
9
9
|
const cap_router_runtime_js_1 = require("../../api/trpc/cap-router-runtime.js");
|
|
10
10
|
const core_cap_bridge_js_1 = require("../../api/trpc/core-cap-bridge.js");
|
|
11
11
|
const agent_readiness_pull_js_1 = require("./agent-readiness-pull.js");
|
|
12
|
+
const hub_mesh_guard_js_1 = require("./hub-mesh-guard.js");
|
|
12
13
|
const cap_call_fn_js_1 = require("./cap-call-fn.js");
|
|
13
14
|
const cap_route_authority_js_1 = require("./cap-route-authority.js");
|
|
14
15
|
class MoleculerService {
|
|
@@ -23,6 +24,12 @@ class MoleculerService {
|
|
|
23
24
|
return this.broker;
|
|
24
25
|
}
|
|
25
26
|
logger;
|
|
27
|
+
/**
|
|
28
|
+
* D802 — who may talk to the hub over the mesh: the challenge–response join,
|
|
29
|
+
* the broker middleware that refuses / tolerates unauthenticated nodes and
|
|
30
|
+
* strips forged identities, and the automatic end of `transition`.
|
|
31
|
+
*/
|
|
32
|
+
meshGuard;
|
|
26
33
|
/**
|
|
27
34
|
* D3 authority: union of every node's manifest delivered via
|
|
28
35
|
* `$hub.registerNode`. Populated by `onRegisterNode` in `hubDeps`.
|
|
@@ -190,9 +197,12 @@ class MoleculerService {
|
|
|
190
197
|
// can answer, and the Moleculer namespace was never authentication (TCP
|
|
191
198
|
// ignores it; UDP discovery broadcasts it in clear). The secret gates
|
|
192
199
|
// `$hub.registerNode`, read from the keychain at call time.
|
|
200
|
+
this.meshGuard = new hub_mesh_guard_js_1.HubMeshGuard('hub', this.logging.createLogger('mesh-auth'));
|
|
193
201
|
const broker = (0, system_1.createBroker)({
|
|
194
202
|
nodeID: 'hub',
|
|
195
203
|
mode: 'hub',
|
|
204
|
+
// D802: every action and event another node sends passes the mesh gate.
|
|
205
|
+
middlewares: [this.meshGuard.middleware()],
|
|
196
206
|
logLevel: this.config.get('moleculer.logLevel') ?? 'warn',
|
|
197
207
|
...(tcpPort && !Number.isNaN(tcpPort) ? { tcpPort } : {}),
|
|
198
208
|
...(udpPort && !Number.isNaN(udpPort) ? { udpPort } : {}),
|
|
@@ -217,6 +227,23 @@ class MoleculerService {
|
|
|
217
227
|
setOnAgentRegistered(cb) {
|
|
218
228
|
this.onAgentRegisteredCb = cb;
|
|
219
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* D802: the cluster-node history, for the automatic end of the mesh
|
|
232
|
+
* `transition` — wired by `AgentRegistryService`, which owns the history.
|
|
233
|
+
*/
|
|
234
|
+
setKnownAgentsProvider(provider) {
|
|
235
|
+
this.meshGuard.setKnownAgentsProvider(provider);
|
|
236
|
+
}
|
|
237
|
+
/** Every node Moleculer currently lists as available (this hub included). */
|
|
238
|
+
listAvailableNodeIds() {
|
|
239
|
+
const registry = this.broker.registry;
|
|
240
|
+
try {
|
|
241
|
+
return (registry?.getNodeList?.({ onlyAvailable: true }) ?? []).map((n) => n.id);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
}
|
|
220
247
|
async onModuleInit() {
|
|
221
248
|
const logger = this.logging.createLogger('moleculer');
|
|
222
249
|
const hubDeps = {
|
|
@@ -270,6 +297,8 @@ class MoleculerService {
|
|
|
270
297
|
},
|
|
271
298
|
// System → Cluster, from the keychain (D797): off / on / unknown.
|
|
272
299
|
clusterSecretGate: system_1.getProcessClusterSecretGate,
|
|
300
|
+
// D802: challenge–response join + who may register.
|
|
301
|
+
meshAuth: this.meshGuard.authenticator,
|
|
273
302
|
};
|
|
274
303
|
const hubService = (0, system_1.createHubService)(hubDeps);
|
|
275
304
|
this.brokerSafe.createService(hubService);
|
|
@@ -613,6 +642,8 @@ class MoleculerService {
|
|
|
613
642
|
// the incarnation) and this call is refused if the id has been reclaimed;
|
|
614
643
|
// remote nodes register no owner at all, so they clean up as before.
|
|
615
644
|
this.removeNodeFromRegistry(node.id);
|
|
645
|
+
// D802: a node authenticates again on its next connection.
|
|
646
|
+
this.meshGuard.forget(node.id);
|
|
616
647
|
});
|
|
617
648
|
// D8 reverse-leg snapshot-reconcile: on every agent (re)connect, pull
|
|
618
649
|
// the agent's own readiness snapshot (`$agent-readiness.getSnapshot`)
|
|
@@ -661,6 +692,7 @@ class MoleculerService {
|
|
|
661
692
|
}));
|
|
662
693
|
await this.brokerSafe.start();
|
|
663
694
|
logger.info('Moleculer broker started (TCP transport)');
|
|
695
|
+
this.meshGuard.start(() => this.listAvailableNodeIds());
|
|
664
696
|
// Construct the CapRouteResolver now that both the broker and
|
|
665
697
|
// localChildRegistry are ready. The resolver reads live registry state
|
|
666
698
|
// via closure accessors on every call (not a frozen snapshot), so new
|
|
@@ -1290,6 +1322,7 @@ class MoleculerService {
|
|
|
1290
1322
|
}
|
|
1291
1323
|
}
|
|
1292
1324
|
async onModuleDestroy() {
|
|
1325
|
+
this.meshGuard.stop();
|
|
1293
1326
|
if (this.agentReadinessPullTimer !== null) {
|
|
1294
1327
|
clearInterval(this.agentReadinessPullTimer);
|
|
1295
1328
|
this.agentReadinessPullTimer = null;
|
package/dist/main.js
CHANGED
|
@@ -86,12 +86,17 @@ const ws_request_census_1 = require("./api/trpc/ws-request-census");
|
|
|
86
86
|
const ws_slow_consumer_guard_js_1 = require("./api/trpc/ws-slow-consumer-guard.js");
|
|
87
87
|
const addon_route_jwt_gate_js_1 = require("./auth/addon-route-jwt-gate.js");
|
|
88
88
|
const addon_route_share_gate_js_1 = require("./auth/addon-route-share-gate.js");
|
|
89
|
+
const auth_network_policy_js_1 = require("./auth/auth-network-policy.js");
|
|
90
|
+
const hub_sessions_js_1 = require("./auth/hub-sessions.js");
|
|
91
|
+
const session_lifetimes_js_1 = require("./auth/session-lifetimes.js");
|
|
89
92
|
const auth_rate_limit_1 = require("./auth/auth-rate-limit");
|
|
90
93
|
const external_link_intents_js_1 = require("./auth/external-link-intents.js");
|
|
91
94
|
const session_cookie_js_1 = require("./auth/session-cookie.js");
|
|
92
95
|
const session_mint_js_1 = require("./auth/session-mint.js");
|
|
93
|
-
const sso_finish_js_1 = require("./auth/sso-finish.js");
|
|
94
96
|
const sso_finish_deps_js_1 = require("./auth/sso-finish-deps.js");
|
|
97
|
+
const sso_routes_js_1 = require("./auth/sso-routes.js");
|
|
98
|
+
const used_bridge_ids_js_1 = require("./auth/used-bridge-ids.js");
|
|
99
|
+
const native_link_completions_js_1 = require("./auth/native-link-completions.js");
|
|
95
100
|
const boot_config_1 = require("./boot/boot-config");
|
|
96
101
|
const platform_keychain_key_1 = require("./boot/platform-keychain-key");
|
|
97
102
|
const integration_id_backfill_1 = require("./boot/integration-id-backfill");
|
|
@@ -410,6 +415,16 @@ async function bootstrap() {
|
|
|
410
415
|
storageService.setLocationManager(infra.locationManager);
|
|
411
416
|
// ConfigService — SettingsStore is wired later by the settings-store capability consumer
|
|
412
417
|
const config = app.get(config_service_1.ConfigService);
|
|
418
|
+
// The real client address behind a trusted proxy, and the opt-in trusted
|
|
419
|
+
// networks — read from settings per request (auth-network-policy.ts).
|
|
420
|
+
const authNetwork = (0, auth_network_policy_js_1.createAuthNetworkPolicy)((key) => config.get(key));
|
|
421
|
+
// Public addon routes that declare `rateLimit: 'auth'` (an IdP start /
|
|
422
|
+
// callback) and the SSO finish: 30 per minute per real client address.
|
|
423
|
+
const authRouteLimiter = (0, auth_rate_limit_1.createRateLimiter)({ windowMs: 60_000, max: 30 });
|
|
424
|
+
// Login sessions (local-auth's table, reached through its hub-internal
|
|
425
|
+
// surface): every session token minted from here on carries a `sid`, and
|
|
426
|
+
// every verify of one asks whether that session is still live.
|
|
427
|
+
const sessionLifetimes = () => (0, session_lifetimes_js_1.readSessionLifetimes)((key) => config.get(key));
|
|
413
428
|
// Register addon upload route (multipart — must be registered before tRPC).
|
|
414
429
|
// We pass AddonRegistryService so the handler can resolve the
|
|
415
430
|
// `user-management` cap singleton at request time (the only working
|
|
@@ -498,6 +513,11 @@ async function bootstrap() {
|
|
|
498
513
|
addonRegistry.setDataPlaneRegistry(dataPlaneRegistry);
|
|
499
514
|
// ── Configure the CapabilityRegistry (created in AddonRegistryService constructor) ──
|
|
500
515
|
const capabilityRegistry = addonRegistry.getCapabilityRegistry();
|
|
516
|
+
const sessionBacking = (0, hub_sessions_js_1.sessionBackingFrom)(capabilityRegistry, sessionLifetimes);
|
|
517
|
+
// From here on every verify of a session-grade token — tRPC, addon routes,
|
|
518
|
+
// the session cookie, OAuth authorize, uploads, health — requires its
|
|
519
|
+
// session to be live (`hub-sessions.ts`).
|
|
520
|
+
app.get(auth_service_1.AuthService).setSessionCheck((0, hub_sessions_js_1.createSessionTokenCheck)(capabilityRegistry));
|
|
501
521
|
// Declare every shipped capability BEFORE app.init() so addon
|
|
502
522
|
// registerProvider() calls (in-process or via the Moleculer bridge
|
|
503
523
|
// from forked workers / agents) find their definition during
|
|
@@ -507,7 +527,7 @@ async function bootstrap() {
|
|
|
507
527
|
capabilityRegistry.declareCapability(capDef);
|
|
508
528
|
}
|
|
509
529
|
// Hub-internal `sso-bridge` provider — gives auth-provider addons
|
|
510
|
-
// (OIDC, SAML,
|
|
530
|
+
// (OIDC, SAML, …) a typed gateway to mint an HMAC-signed
|
|
511
531
|
// bridge token before redirecting to `/api/auth/sso/finish`. Without
|
|
512
532
|
// this, the finish endpoint would have to trust unsigned query params
|
|
513
533
|
// (anyone could craft `?isAdmin=1`). Backed by AuthService.
|
|
@@ -538,12 +558,41 @@ async function bootstrap() {
|
|
|
538
558
|
// Pending "link this sign-in account" intents (D782) — minted by
|
|
539
559
|
// `auth.beginExternalLink`, consumed once by `/api/auth/sso/finish`.
|
|
540
560
|
const externalLinkIntents = new external_link_intents_js_1.ExternalLinkIntents();
|
|
561
|
+
// Native-app links parked for the app to claim, and one-time codes that
|
|
562
|
+
// open the account page signed in, in a browser (D801).
|
|
563
|
+
const nativeLinkCompletions = new native_link_completions_js_1.NativeLinkCompletions();
|
|
564
|
+
// `jti`s of login bridges already finished — a bridge is single-use.
|
|
565
|
+
const usedBridgeIds = new used_bridge_ids_js_1.UsedBridgeIds();
|
|
566
|
+
const browserHandoffCodes = new handoff_code_service_1.HandoffCodeService();
|
|
567
|
+
const nativeLinkMinter = (0, session_mint_js_1.createSessionMinter)({
|
|
568
|
+
signer: app.get(auth_service_1.AuthService),
|
|
569
|
+
directory: () => capabilityRegistry.getSingleton('user-management'),
|
|
570
|
+
sessions: sessionBacking,
|
|
571
|
+
});
|
|
572
|
+
const nativeLinkWiring = () => ({
|
|
573
|
+
auth: app.get(auth_service_1.AuthService),
|
|
574
|
+
registry: capabilityRegistry,
|
|
575
|
+
moleculer: app.get(moleculer_service_1.MoleculerService),
|
|
576
|
+
minter: nativeLinkMinter,
|
|
577
|
+
linkIntents: externalLinkIntents,
|
|
578
|
+
nativeLinks: nativeLinkCompletions,
|
|
579
|
+
usedBridgeIds,
|
|
580
|
+
});
|
|
541
581
|
const buildRouter = () => (0, trpc_router_1.buildAppRouter)({
|
|
542
582
|
authService: app.get(auth_service_1.AuthService),
|
|
543
583
|
shareTokenService,
|
|
544
584
|
handoffCodeService,
|
|
545
585
|
externalLinkIntents,
|
|
586
|
+
nativeLink: {
|
|
587
|
+
completions: nativeLinkCompletions,
|
|
588
|
+
browserHandoffs: browserHandoffCodes,
|
|
589
|
+
// The SSO finish's own link step — the same conflict rule, the same
|
|
590
|
+
// store — run for the app's claim instead of the browser's return.
|
|
591
|
+
link: (input) => (0, sso_finish_deps_js_1.createSsoFinishDeps)(nativeLinkWiring(), undefined).identities.link(input),
|
|
592
|
+
},
|
|
546
593
|
configService: config,
|
|
594
|
+
authNetworkPolicy: authNetwork,
|
|
595
|
+
sessionBacking,
|
|
547
596
|
featureService: app.get(feature_service_1.FeatureService),
|
|
548
597
|
loggingService,
|
|
549
598
|
eventBus: app.get(event_bus_service_1.EventBusService),
|
|
@@ -582,7 +631,11 @@ async function bootstrap() {
|
|
|
582
631
|
beforeAddonBoot: () => {
|
|
583
632
|
try {
|
|
584
633
|
appRouter = buildRouter();
|
|
585
|
-
|
|
634
|
+
{
|
|
635
|
+
const meshBroker = app.get(moleculer_service_1.MoleculerService);
|
|
636
|
+
// D802: another node's call runs as `node:<id>`, never the hub's own principal.
|
|
637
|
+
meshBroker.registerCoreCapService((0, core_cap_bridge_1.buildCoreCapService)(appRouter, undefined, { ownNodeId: meshBroker.nodeId }));
|
|
638
|
+
}
|
|
586
639
|
console.log('[bootstrap] core-cap mesh bridge registered ($core-caps) — before addon boot');
|
|
587
640
|
}
|
|
588
641
|
catch (err) {
|
|
@@ -629,6 +682,7 @@ async function bootstrap() {
|
|
|
629
682
|
(0, adaptive_probe_routes_1.registerAdaptiveProbeRoute)(fastify, {
|
|
630
683
|
verifyToken: (token) => app.get(auth_service_1.AuthService).verifyToken(token),
|
|
631
684
|
limiter: (0, auth_rate_limit_1.createRateLimiter)({ windowMs: 60_000, max: adaptive_probe_routes_1.ADAPTIVE_PROBE_PER_IP_PER_MINUTE }),
|
|
685
|
+
network: authNetwork,
|
|
632
686
|
});
|
|
633
687
|
}
|
|
634
688
|
catch (err) {
|
|
@@ -670,7 +724,11 @@ async function bootstrap() {
|
|
|
670
724
|
if (appRouter === null) {
|
|
671
725
|
appRouter = buildRouter();
|
|
672
726
|
try {
|
|
673
|
-
|
|
727
|
+
{
|
|
728
|
+
const meshBroker = app.get(moleculer_service_1.MoleculerService);
|
|
729
|
+
// D802: another node's call runs as `node:<id>`, never the hub's own principal.
|
|
730
|
+
meshBroker.registerCoreCapService((0, core_cap_bridge_1.buildCoreCapService)(appRouter, undefined, { ownNodeId: meshBroker.nodeId }));
|
|
731
|
+
}
|
|
674
732
|
console.log('[bootstrap] core-cap mesh bridge registered ($core-caps) — late fallback');
|
|
675
733
|
}
|
|
676
734
|
catch (err) {
|
|
@@ -820,37 +878,31 @@ async function bootstrap() {
|
|
|
820
878
|
reply.header('cache-control', 'public, max-age=86400');
|
|
821
879
|
return reply.send(fs.readFileSync(filePath));
|
|
822
880
|
});
|
|
823
|
-
// ── SSO finish
|
|
824
|
-
// OIDC
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
// `auth/sso-finish.ts
|
|
828
|
-
const ssoAuth = app.get(auth_service_1.AuthService);
|
|
829
|
-
const ssoLogger = app.get(logging_service_1.LoggingService).createLogger('sso-finish');
|
|
881
|
+
// ── SSO finish + browser handoff ────────────────────────────────
|
|
882
|
+
// OIDC redirects to the finish with an HMAC-signed, single-use LOGIN
|
|
883
|
+
// bridge bound to the browser that started it; the session is minted by
|
|
884
|
+
// THE session mint from the user record as it is now (D712). Rules in
|
|
885
|
+
// `auth/sso-finish.ts`, routes in `auth/sso-routes.ts`.
|
|
830
886
|
const ssoMinter = (0, session_mint_js_1.createSessionMinter)({
|
|
831
|
-
signer:
|
|
887
|
+
signer: app.get(auth_service_1.AuthService),
|
|
832
888
|
directory: () => capabilityRegistry.getSingleton('user-management'),
|
|
889
|
+
sessions: sessionBacking,
|
|
833
890
|
});
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
auth:
|
|
891
|
+
(0, sso_routes_js_1.registerSsoRoutes)(fastify, {
|
|
892
|
+
wiring: {
|
|
893
|
+
auth: app.get(auth_service_1.AuthService),
|
|
837
894
|
registry: capabilityRegistry,
|
|
838
895
|
moleculer: app.get(moleculer_service_1.MoleculerService),
|
|
839
896
|
minter: ssoMinter,
|
|
840
897
|
linkIntents: externalLinkIntents,
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
reply.code(302);
|
|
850
|
-
// Admin-ui paths are relative to the hub's mount (HA ingress etc.).
|
|
851
|
-
const ssoMount = (0, spa_mount_1.parseForwardedPrefix)(request.headers['x-forwarded-prefix']) ?? '';
|
|
852
|
-
reply.header('Location', `${ssoMount}${outcome.location}`);
|
|
853
|
-
return reply.send('');
|
|
898
|
+
nativeLinks: nativeLinkCompletions,
|
|
899
|
+
usedBridgeIds,
|
|
900
|
+
},
|
|
901
|
+
network: authNetwork,
|
|
902
|
+
limiter: authRouteLimiter,
|
|
903
|
+
browserHandoffCodes,
|
|
904
|
+
minter: ssoMinter,
|
|
905
|
+
logger: app.get(logging_service_1.LoggingService).createLogger('sso-finish'),
|
|
854
906
|
});
|
|
855
907
|
// ── Backup archive download ─────────────────────────────────────
|
|
856
908
|
// Streams an archive through the storage cap's chunked-download
|
|
@@ -1052,6 +1104,18 @@ async function bootstrap() {
|
|
|
1052
1104
|
}
|
|
1053
1105
|
return reply.status(404).send({ error: 'Not found' });
|
|
1054
1106
|
}
|
|
1107
|
+
// A route that asked for it (an IdP start / callback) is limited per
|
|
1108
|
+
// REAL client address before its handler runs.
|
|
1109
|
+
if (match.route.rateLimit === 'auth') {
|
|
1110
|
+
const routeClient = (0, auth_rate_limit_1.clientKeyFromRequest)(request, authNetwork);
|
|
1111
|
+
if (routeClient !== null) {
|
|
1112
|
+
const verdict = authRouteLimiter.check(`route:${addonId}:${routeClient}`);
|
|
1113
|
+
if (!verdict.allowed) {
|
|
1114
|
+
reply.header('retry-after', String(verdict.retryAfterSeconds));
|
|
1115
|
+
return reply.status(429).send({ error: 'Too many attempts' });
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1055
1119
|
// Auth check based on route.access
|
|
1056
1120
|
if (match.route.access !== 'public') {
|
|
1057
1121
|
const authHeader = request.headers.authorization;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.487",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"files": [
|
|
@@ -37,18 +37,18 @@
|
|
|
37
37
|
]
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@camstack/addon-admin-ui": "1.2.
|
|
41
|
-
"@camstack/addon-agent-ui": "1.2.
|
|
42
|
-
"@camstack/addon-auth": "1.2.
|
|
43
|
-
"@camstack/addon-decoder-nodeav": "1.2.
|
|
44
|
-
"@camstack/addon-notifiers": "1.2.
|
|
45
|
-
"@camstack/addon-pipeline": "1.2.
|
|
46
|
-
"@camstack/addon-pipeline-orchestrator": "1.2.
|
|
47
|
-
"@camstack/addon-post-analysis": "1.2.
|
|
48
|
-
"@camstack/sdk": "1.2.
|
|
49
|
-
"@camstack/system": "1.2.
|
|
50
|
-
"@camstack/types": "1.2.
|
|
51
|
-
"@camstack/ui-library": "1.2.
|
|
40
|
+
"@camstack/addon-admin-ui": "1.2.360",
|
|
41
|
+
"@camstack/addon-agent-ui": "1.2.196",
|
|
42
|
+
"@camstack/addon-auth": "1.2.135",
|
|
43
|
+
"@camstack/addon-decoder-nodeav": "1.2.122",
|
|
44
|
+
"@camstack/addon-notifiers": "1.2.129",
|
|
45
|
+
"@camstack/addon-pipeline": "1.2.347",
|
|
46
|
+
"@camstack/addon-pipeline-orchestrator": "1.2.299",
|
|
47
|
+
"@camstack/addon-post-analysis": "1.2.348",
|
|
48
|
+
"@camstack/sdk": "1.2.128",
|
|
49
|
+
"@camstack/system": "1.2.382",
|
|
50
|
+
"@camstack/types": "1.2.333",
|
|
51
|
+
"@camstack/ui-library": "1.2.254",
|
|
52
52
|
"@fastify/compress": "^9.0.0",
|
|
53
53
|
"@fastify/cookie": "^11.0.2",
|
|
54
54
|
"@fastify/cors": "^11.2.0",
|