@camstack/server 1.2.485 → 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.
Files changed (36) hide show
  1. package/dist/agent/agent-mesh-join.js +80 -0
  2. package/dist/agent/main.js +61 -2
  3. package/dist/api/auth-whoami.js +1 -1
  4. package/dist/api/core/auth-admin-tokens.procedures.js +138 -0
  5. package/dist/api/core/auth-limits.js +77 -0
  6. package/dist/api/core/auth-reauth.procedures.js +108 -0
  7. package/dist/api/core/auth-sessions.procedures.js +166 -0
  8. package/dist/api/core/auth.router.js +281 -80
  9. package/dist/api/health/adaptive-probe.routes.js +1 -1
  10. package/dist/api/trpc/core-cap-bridge.js +47 -5
  11. package/dist/api/trpc/generated-cap-mounts.js +19 -1
  12. package/dist/api/trpc/generated-cap-routers.js +512 -205
  13. package/dist/api/trpc/trpc.context.js +86 -34
  14. package/dist/api/trpc/trpc.middleware.js +8 -11
  15. package/dist/api/trpc/trpc.router.js +6 -1
  16. package/dist/auth/auth-network-policy.js +75 -0
  17. package/dist/auth/auth-rate-limit.js +25 -20
  18. package/dist/auth/client-address.js +68 -0
  19. package/dist/auth/external-link-intents.js +12 -3
  20. package/dist/auth/hub-sessions.js +74 -0
  21. package/dist/auth/native-link-completions.js +68 -0
  22. package/dist/auth/native-return.js +33 -0
  23. package/dist/auth/session-lifetimes.js +46 -0
  24. package/dist/auth/session-mint.js +110 -13
  25. package/dist/auth/sso-finish-deps.js +56 -1
  26. package/dist/auth/sso-finish.js +132 -17
  27. package/dist/auth/sso-routes.js +95 -0
  28. package/dist/auth/used-bridge-ids.js +52 -0
  29. package/dist/core/addon/addon-registry.service.js +5 -0
  30. package/dist/core/agent/agent-registry.service.js +10 -0
  31. package/dist/core/auth/auth.service.js +50 -1
  32. package/dist/core/auth/handoff-code.service.js +9 -3
  33. package/dist/core/moleculer/hub-mesh-guard.js +132 -0
  34. package/dist/core/moleculer/moleculer.service.js +33 -0
  35. package/dist/main.js +92 -28
  36. 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
+ }
@@ -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, config.secret ? (0, system_1.hashClusterSecret)(config.secret) : undefined, rootPackage.version === null
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('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');
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;
@@ -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 ?? payload.keyId ?? 'unknown',
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
+ }