@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.
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
@@ -3,7 +3,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createMeshTrpcContext = createMeshTrpcContext;
4
4
  exports.createTrpcContext = createTrpcContext;
5
5
  exports.createWsTrpcContext = createWsTrpcContext;
6
+ const system_1 = require("@camstack/system");
6
7
  const types_1 = require("@camstack/types");
8
+ const auth_network_policy_js_1 = require("../../auth/auth-network-policy.js");
7
9
  const share_token_service_js_1 = require("../../core/auth/share-token.service.js");
8
10
  const trpc_error_principal_js_1 = require("./trpc-error-principal.js");
9
11
  /**
@@ -86,20 +88,52 @@ function extractTokenFromRequest(req) {
86
88
  }
87
89
  return null;
88
90
  }
91
+ function requestFacts(req) {
92
+ if (req === undefined)
93
+ return {};
94
+ const ip = auth_network_policy_js_1.DEFAULT_AUTH_NETWORK_POLICY.clientAddress(req).ip;
95
+ const url = req.url;
96
+ const path = typeof url === 'string' ? url.split('?')[0] : undefined;
97
+ return { ...(path !== undefined ? { path } : {}), ...(ip !== null ? { ip } : {}) };
98
+ }
89
99
  /**
90
- * Resolve an AuthenticatedAgent from a raw token. Handles both JWT
91
- * (sync, via authService) and `cst_*` scoped tokens (async, via the
92
- * `user-management` cap singleton — same path addon-upload uses for
93
- * its REST auth chain).
94
- *
95
- * Returns `null` for: missing token, malformed JWT, unknown scoped
96
- * token, and a JWT that is not an ACCESS CREDENTIAL (see the classifier
97
- * branch below). Caller (protectedProcedure) decides the failure
98
- * response (typically UNAUTHORIZED).
100
+ * An admin token (`csa_`): an ADMIN principal, attributed to the admin who
101
+ * created it, every use audited. Validation (owner still exists and is still
102
+ * an admin, not revoked, not expired) is local-auth's (`AdminTokenManager`).
99
103
  */
100
- async function resolveUser(token, authService, addonRegistry, shareTokens = null, logger = null) {
104
+ async function resolveAdminToken(token, addonRegistry, facts) {
105
+ const internal = (0, system_1.getUserManagementInternal)(addonRegistry.getCapabilityRegistry().getSingleton('user-management'));
106
+ if (internal === null)
107
+ return null;
108
+ const record = await internal.adminTokens.validate(token).catch(() => null);
109
+ if (record === null)
110
+ return null;
111
+ void internal.audit.record({
112
+ action: 'admin-token-used',
113
+ userId: record.createdBy,
114
+ ...(facts.ip !== undefined ? { ip: facts.ip } : {}),
115
+ detail: {
116
+ tokenId: record.id,
117
+ name: record.name,
118
+ prefix: record.tokenPrefix,
119
+ ...(facts.path !== undefined ? { path: facts.path } : {}),
120
+ },
121
+ });
122
+ return {
123
+ id: record.createdBy,
124
+ username: `admin-token:${record.name}`,
125
+ isAdmin: true,
126
+ permissions: { isAdmin: true, allowedProviders: '*', allowedDevices: {} },
127
+ isApiKey: true,
128
+ credential: 'admin-token',
129
+ adminTokenId: record.id,
130
+ };
131
+ }
132
+ async function resolveUser(token, authService, addonRegistry, shareTokens = null, logger = null, facts = {}) {
101
133
  if (!token)
102
134
  return null;
135
+ if (token.startsWith(system_1.ADMIN_TOKEN_PREFIX))
136
+ return resolveAdminToken(token, addonRegistry, facts);
103
137
  // Share-token path (`csv_*`): resolve through the ShareTokenService.
104
138
  // The synthetic principal is maximally restricted — non-admin, no
105
139
  // scopes; the `shareView` grant routes it into the fail-closed
@@ -183,16 +217,15 @@ async function resolveUser(token, authService, addonRegistry, shareTokens = null
183
217
  // `verifyToken` succeeding means "we minted this", never "this is an API
184
218
  // credential" (D103). Exactly two kinds are credentials here:
185
219
  //
186
- // • `session` — no `kind` claim: `auth.login`'s JWT, the SSO-minted
187
- // session from `/api/auth/sso/finish`, a `type: 'service'` agent
188
- // token, a `type: 'api_key'` token.
220
+ // • `session` — no `kind` claim: `auth.login`'s JWT and the SSO-minted
221
+ // session from `/api/auth/sso/finish`.
189
222
  // • `integration` — `provider: 'oauth-access'`: the account-link
190
223
  // credential a component (Home Assistant) calls `/trpc` with.
191
224
  //
192
225
  // Everything else is minted for ONE hand-off step and is refused: an
193
226
  // `oauth-code` is single-use, lives 60s and travels in a browser redirect
194
227
  // URL (history, referrers, proxy logs); an `oauth-refresh` belongs to
195
- // `POST /oauth/token`; an `oidc`/`magic-link` bridge belongs to
228
+ // `POST /oauth/token`; an `oidc` login bridge belongs to
196
229
  // `?bridge=` on `/api/auth/sso/finish`; a `totp-challenge` is login leg 1.
197
230
  // `/addon/:addonId/*` has refused these since D103 — this closes the same
198
231
  // hole on the other surface.
@@ -203,7 +236,7 @@ async function resolveUser(token, authService, addonRegistry, shareTokens = null
203
236
  // `!requiresSecondFactor`) nor the admin UI ever puts it in a header.
204
237
  if (principal.kind === 'not-a-credential') {
205
238
  logger?.warn('trpc: refused a bearer that is not an API credential', {
206
- meta: { reason: principal.reason, userId: payload.userId ?? payload.keyId ?? 'unknown' },
239
+ meta: { reason: principal.reason, userId: payload.userId ?? 'unknown' },
207
240
  });
208
241
  return null;
209
242
  }
@@ -212,7 +245,7 @@ async function resolveUser(token, authService, addonRegistry, shareTokens = null
212
245
  // indistinguishable in the logs from a refusal of a browser session.
213
246
  const credential = (0, trpc_error_principal_js_1.readCredentialIdentity)(payload);
214
247
  return {
215
- id: payload.userId ?? payload.keyId ?? 'unknown',
248
+ id: payload.userId ?? 'unknown',
216
249
  username: payload.username ?? 'unknown',
217
250
  isAdmin: payload.isAdmin,
218
251
  permissions: {
@@ -220,8 +253,10 @@ async function resolveUser(token, authService, addonRegistry, shareTokens = null
220
253
  allowedProviders: payload.allowedProviders,
221
254
  allowedDevices: payload.allowedDevices,
222
255
  },
223
- isApiKey: payload.type === 'api_key',
224
- agentId: payload.agentId,
256
+ isApiKey: false,
257
+ ...(principal.kind === 'session' && typeof payload.sid === 'string'
258
+ ? { sessionId: payload.sid }
259
+ : {}),
225
260
  // Scopes are baked into the JWT at login; the middleware uses
226
261
  // them to gate every call until the user re-logs. Normalised at the
227
262
  // boundary — a v2 `device:targets` JWT (issued before the v3 model)
@@ -247,7 +282,7 @@ async function resolveUser(token, authService, addonRegistry, shareTokens = null
247
282
  ...(payload.scopes !== undefined ? { scopes: (0, types_1.normalizeTokenScopes)(payload.scopes) } : {}),
248
283
  ...(!payload.isAdmin && principal.kind === 'session'
249
284
  ? await (async () => {
250
- const fresh = await refreshScopesFromStore(payload.userId ?? payload.keyId ?? 'unknown', addonRegistry);
285
+ const fresh = await refreshScopesFromStore(payload.userId ?? 'unknown', addonRegistry);
251
286
  return fresh !== null ? { scopes: fresh } : {};
252
287
  })()
253
288
  : {}),
@@ -279,22 +314,27 @@ function makeDeviceScopeLookup(addonRegistry) {
279
314
  };
280
315
  }
281
316
  /**
282
- * Context factory for hub-internal calls originating from the trusted
283
- * Moleculer mesh (the `$core-caps` bridge service in `core-cap-bridge.ts`).
317
+ * Context factory for calls arriving over the Moleculer mesh (the `$core-caps`
318
+ * bridge service in `core-cap-bridge.ts`).
284
319
  *
285
- * A mesh-originated call is treated as a fully-trusted admin. NOTE (D797):
286
- * the cluster secret (System → Cluster) gates `$hub.registerNode` only — the
287
- * Moleculer TCP transport itself authenticates no peer, so anything that can
288
- * reach the hub's mesh port can call this. Keep that port off untrusted
289
- * networks; transport-level peer authentication is on the roadmap. It carries
290
- * a synthetic `isAdmin` user, which makes `protectedProcedure` /
291
- * `adminProcedure` pass without a JWT and skips the scope-access
292
- * matcher entirely. There is no HTTP request behind the call.
320
+ * `principal` names WHO is calling (D802): `mesh` for the hub's own runners
321
+ * (they reach the bridge over UDS, inside the hub's trust boundary), and
322
+ * `node:<nodeId>` for another node — whose id the hub's mesh gate has bound
323
+ * to a challenge–response proof before the call got here
324
+ * (`createMeshAccessMiddleware`; in `transition` mode an unauthenticated node
325
+ * is tolerated and its id is only what it claims).
326
+ *
327
+ * Both principals are still admins: that keeps every `adminProcedure` an
328
+ * agent's addons call working. What is NOT reachable from another node is
329
+ * decided by the bridge itself (`REMOTE_DENIED_NAMESPACES`), and `repl` is not
330
+ * bridged at all. Narrowing the remote principal to a per-method grant is the
331
+ * next step once the mesh census has measured what each node calls (D802).
332
+ * There is no HTTP request behind the call.
293
333
  */
294
- function createMeshTrpcContext() {
334
+ function createMeshTrpcContext(principal = 'mesh') {
295
335
  const user = {
296
- id: 'mesh',
297
- username: 'mesh',
336
+ id: principal,
337
+ username: principal,
298
338
  isAdmin: true,
299
339
  permissions: { isAdmin: true, allowedProviders: '*', allowedDevices: {} },
300
340
  isApiKey: true,
@@ -305,7 +345,7 @@ function createMeshTrpcContext() {
305
345
  async function createTrpcContext(req, authService, addonRegistry, shareTokens = null, logger = null) {
306
346
  const token = extractTokenFromRequest(req);
307
347
  return {
308
- user: await resolveUser(token, authService, addonRegistry, shareTokens, logger),
348
+ user: await resolveUser(token, authService, addonRegistry, shareTokens, logger, requestFacts(req)),
309
349
  req,
310
350
  deviceScopeLookup: makeDeviceScopeLookup(addonRegistry),
311
351
  deviceFleet: () => addonRegistry.getPersistedDeviceList(),
@@ -320,10 +360,22 @@ async function createWsTrpcContext(opts, authService, addonRegistry, shareTokens
320
360
  // 1. connectionParams.token (sent by BackendClient's createWSClient)
321
361
  const paramToken = opts.info.connectionParams?.['token'];
322
362
  const token = (typeof paramToken === 'string' ? paramToken : null) ?? extractTokenFromRequest(opts.req);
323
- const user = await resolveUser(token, authService, addonRegistry, shareTokens, logger);
363
+ const user = await resolveUser(token, authService, addonRegistry, shareTokens, logger, requestFacts(opts.req));
364
+ const sessionId = user?.sessionId;
324
365
  return {
325
366
  user,
326
367
  req: opts.req,
368
+ ...(user !== null && sessionId !== undefined
369
+ ? {
370
+ sessionLive: () => authService.checkSession({
371
+ sid: sessionId,
372
+ userId: user.id,
373
+ isAdmin: user.isAdmin,
374
+ allowedProviders: '*',
375
+ allowedDevices: {},
376
+ }) === null,
377
+ }
378
+ : {}),
327
379
  deviceScopeLookup: makeDeviceScopeLookup(addonRegistry),
328
380
  deviceFleet: () => addonRegistry.getPersistedDeviceList(),
329
381
  };
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.agentProcedure = exports.adminProcedure = exports.protectedProcedure = exports.createCallerFactory = exports.publicProcedure = exports.trpcRouter = void 0;
6
+ exports.adminProcedure = exports.protectedProcedure = exports.createCallerFactory = exports.publicProcedure = exports.trpcRouter = void 0;
7
7
  exports.iterableSubscription = iterableSubscription;
8
8
  exports.iterableInterval = iterableInterval;
9
9
  const system_1 = require("@camstack/system");
@@ -96,6 +96,12 @@ exports.protectedProcedure = t.procedure.use(async ({ ctx, next, path, getRawInp
96
96
  if (!ctx.user) {
97
97
  throw new server_1.TRPCError({ code: 'UNAUTHORIZED' });
98
98
  }
99
+ // A WebSocket resolves its principal once, at connect. Its login session is
100
+ // re-checked on every call, so a logout or revocation ends a socket that is
101
+ // already open — not only the next one.
102
+ if (ctx.sessionLive !== undefined && !ctx.sessionLive()) {
103
+ throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'Session ended — sign in again' });
104
+ }
99
105
  // Share-view principals (`csv_*` tokens) NEVER reach the admin bypass,
100
106
  // the hand-written-route pass-through, or the scope matcher below —
101
107
  // they are gated exclusively by the fail-closed embed-surface allowlist
@@ -133,7 +139,7 @@ exports.protectedProcedure = t.procedure.use(async ({ ctx, next, path, getRawInp
133
139
  return next({ ctx: { ...ctx, user: ctx.user } });
134
140
  }
135
141
  // Spread+reassign of `user` narrows downstream ctx from `User | null`
136
- // to `User` so `adminProcedure` / `agentProcedure` can read fields
142
+ // to `User` so `adminProcedure` can read fields
137
143
  // without re-checking.
138
144
  if (ctx.user.isAdmin) {
139
145
  return next({ ctx: { ...ctx, user: ctx.user } });
@@ -199,12 +205,3 @@ exports.adminProcedure = exports.protectedProcedure.use(({ ctx, next }) => {
199
205
  }
200
206
  return next({ ctx });
201
207
  });
202
- /**
203
- * Procedure for agent service accounts. After the v2 collapse, agents
204
- * are admin sessions issued via `createServiceToken` — they all get
205
- * `isAdmin: true`. This procedure is identical to `adminProcedure`;
206
- * kept for naming clarity on agent-specific routes.
207
- */
208
- exports.agentProcedure = exports.adminProcedure.use(({ ctx, next }) => {
209
- return next({ ctx: { ...ctx, agentId: ctx.user.agentId ?? ctx.user.id } });
210
- });
@@ -269,7 +269,12 @@ function buildCapabilityRouters(services) {
269
269
  // clusterNodes — fixed core API. Write-side purge for the durable
270
270
  // offline-node history (Track A "Forget node"); read side is push-only.
271
271
  clusterNodes: (0, cluster_nodes_router_js_1.createClusterNodesRouter)(services.agentRegistry),
272
- auth: (0, auth_router_js_1.createAuthRouter)(services.authService, services.capabilityRegistry, services.moleculer, services.shareTokenService, services.handoffCodeService, services.externalLinkIntents),
272
+ auth: (0, auth_router_js_1.createAuthRouter)(services.authService, services.capabilityRegistry, services.moleculer, services.shareTokenService, services.handoffCodeService, services.externalLinkIntents, services.nativeLink ?? null, {
273
+ ...(services.authNetworkPolicy !== undefined
274
+ ? { network: services.authNetworkPolicy }
275
+ : {}),
276
+ ...(services.sessionBacking !== undefined ? { sessions: services.sessionBacking } : {}),
277
+ }),
273
278
  // NOT MOUNTED — `mount: { kind: 'skip' }` legacy provider shapes
274
279
  // (positional args / sync returns) that don't match the codegen
275
280
  // routers' {input}-object + Promise<T> contract. The runtime builder
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_AUTH_NETWORK_POLICY = exports.TRUSTED_NETWORKS_KEY = exports.TRUSTED_PROXIES_KEY = void 0;
4
+ exports.createAuthNetworkPolicy = createAuthNetworkPolicy;
5
+ /**
6
+ * The two network facts the auth surface reads from settings:
7
+ *
8
+ * - `auth.trustedProxies` — CIDRs whose forwarding headers are believed, on
9
+ * top of loopback (`client-address.ts`);
10
+ * - `auth.trustedNetworks` — CIDRs a login may skip its second factor from
11
+ * (opt-in, empty = off). Only for a request made DIRECTLY from such a
12
+ * network: anything that came through a proxy — the Cloudflare tunnel, the
13
+ * public origin — never qualifies, whatever address it claims.
14
+ *
15
+ * Settings are read on every call (the hub's ConfigService answers from
16
+ * memory), so a change applies to the next request. A read the store cannot
17
+ * answer yet is treated as an EMPTY list: no extra proxy is trusted, and no
18
+ * network skips a factor — both fail closed.
19
+ */
20
+ const system_1 = require("@camstack/system");
21
+ const client_address_js_1 = require("./client-address.js");
22
+ exports.TRUSTED_PROXIES_KEY = 'auth.trustedProxies';
23
+ exports.TRUSTED_NETWORKS_KEY = 'auth.trustedNetworks';
24
+ function asRequest(req) {
25
+ if (req === null || typeof req !== 'object')
26
+ return null;
27
+ const headers = Reflect.get(req, 'headers');
28
+ if (headers === null || typeof headers !== 'object')
29
+ return null;
30
+ const socket = Reflect.get(req, 'socket');
31
+ const remoteAddress = socket !== null && typeof socket === 'object' ? Reflect.get(socket, 'remoteAddress') : undefined;
32
+ return {
33
+ headers: Object.fromEntries(Object.entries(headers).filter((e) => typeof e[1] === 'string' || Array.isArray(e[1]))),
34
+ socket: { remoteAddress: typeof remoteAddress === 'string' ? remoteAddress : undefined },
35
+ };
36
+ }
37
+ function safeList(read, key) {
38
+ try {
39
+ return (0, system_1.readCidrSetting)(read(key));
40
+ }
41
+ catch {
42
+ return [];
43
+ }
44
+ }
45
+ /** One memo per list: rebuilding a BlockList per request is wasted work. */
46
+ function memoMatcher() {
47
+ let lastKey = null;
48
+ let last = (0, system_1.createCidrMatcher)([]);
49
+ return (entries) => {
50
+ const key = entries.join('\n');
51
+ if (key !== lastKey) {
52
+ lastKey = key;
53
+ last = (0, system_1.createCidrMatcher)(entries);
54
+ }
55
+ return last;
56
+ };
57
+ }
58
+ function createAuthNetworkPolicy(read) {
59
+ const networks = memoMatcher();
60
+ return {
61
+ clientAddress(req) {
62
+ const request = asRequest(req);
63
+ if (request === null)
64
+ return { ip: null, peer: null, viaProxy: false };
65
+ return (0, client_address_js_1.resolveClientAddress)(request, { trustedProxies: safeList(read, exports.TRUSTED_PROXIES_KEY) });
66
+ },
67
+ isTrustedNetworkLogin(address) {
68
+ if (address.viaProxy || address.ip === null)
69
+ return false;
70
+ return networks(safeList(read, exports.TRUSTED_NETWORKS_KEY)).contains(address.ip);
71
+ },
72
+ };
73
+ }
74
+ /** No settings: loopback is the only trusted proxy, no network skips a factor. */
75
+ exports.DEFAULT_AUTH_NETWORK_POLICY = createAuthNetworkPolicy(() => undefined);
@@ -1,4 +1,7 @@
1
1
  "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createRateLimiter = createRateLimiter;
4
+ exports.clientKeyFromRequest = clientKeyFromRequest;
2
5
  /**
3
6
  * Public-auth rate limiting — fixed window per (bucket, client ip).
4
7
  *
@@ -13,9 +16,7 @@
13
16
  * BOUNDED (`maxKeys`, oldest-window evicted first) so an attacker
14
17
  * rotating spoofed source addresses cannot grow it without limit.
15
18
  */
16
- Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.createRateLimiter = createRateLimiter;
18
- exports.clientKeyFromRequest = clientKeyFromRequest;
19
+ const auth_network_policy_js_1 = require("./auth-network-policy.js");
19
20
  function createRateLimiter(options) {
20
21
  const maxKeys = options.maxKeys ?? 10_000;
21
22
  const now = options.now ?? Date.now;
@@ -49,28 +50,32 @@ function createRateLimiter(options) {
49
50
  const retryAfterSeconds = Math.max(1, Math.ceil((existing.windowStart + options.windowMs - at) / 1000));
50
51
  return { allowed: false, retryAfterSeconds };
51
52
  },
53
+ peek(key) {
54
+ const at = now();
55
+ const existing = entries.get(key);
56
+ if (!existing || at - existing.windowStart >= options.windowMs) {
57
+ return { allowed: true, retryAfterSeconds: 0 };
58
+ }
59
+ if (existing.count < options.max)
60
+ return { allowed: true, retryAfterSeconds: 0 };
61
+ return {
62
+ allowed: false,
63
+ retryAfterSeconds: Math.max(1, Math.ceil((existing.windowStart + options.windowMs - at) / 1000)),
64
+ };
65
+ },
52
66
  size() {
53
67
  return entries.size;
54
68
  },
55
69
  };
56
70
  }
57
71
  /**
58
- * Client key for a tRPC/Fastify request principal: `FastifyRequest.ip`
59
- * (proxy-aware when Fastify's `trustProxy` is on) or the raw socket
60
- * address for WS-upgrade `IncomingMessage`s. `null` = no originating
61
- * request (mesh-internal `createCaller` invocation) — never limited.
72
+ * The rate-limit key of a request: its REAL client address — the forwarded
73
+ * client behind a trusted proxy (the tunnel connector on loopback, or
74
+ * `auth.trustedProxies`), else the socket peer (`auth-network-policy.ts`).
75
+ * Keyed on the raw peer, every tunnelled request shared `127.0.0.1` and one
76
+ * bucket locked the whole internet out. `null` = no originating request
77
+ * (a mesh-internal `createCaller`) — never limited.
62
78
  */
63
- function clientKeyFromRequest(req) {
64
- if (req === null || typeof req !== 'object' || req === undefined)
65
- return null;
66
- const ip = Reflect.get(req, 'ip');
67
- if (typeof ip === 'string' && ip.length > 0)
68
- return ip;
69
- const socket = Reflect.get(req, 'socket');
70
- if (socket !== null && typeof socket === 'object') {
71
- const remote = Reflect.get(socket, 'remoteAddress');
72
- if (typeof remote === 'string' && remote.length > 0)
73
- return remote;
74
- }
75
- return null;
79
+ function clientKeyFromRequest(req, policy = auth_network_policy_js_1.DEFAULT_AUTH_NETWORK_POLICY) {
80
+ return policy.clientAddress(req).ip;
76
81
  }
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveClientAddress = resolveClientAddress;
4
+ /**
5
+ * The real client address of an HTTP / WS request.
6
+ *
7
+ * The hub is often published through a Cloudflare tunnel whose connector
8
+ * dials it on LOOPBACK, so the socket peer of every internet request is
9
+ * `127.0.0.1`. Fastify's `trustProxy` is off, so `req.ip` is that peer too.
10
+ * Keyed on it, every rate limiter shared one bucket among all remote users.
11
+ *
12
+ * The forwarding headers (`CF-Connecting-IP`, `X-Forwarded-For`) carry the
13
+ * real client — and are a plain claim from whoever sent them. They are read
14
+ * ONLY when the immediate peer is a proxy the hub trusts:
15
+ *
16
+ * - loopback, always: the tunnel connector, or a proxy on the same host;
17
+ * - the CIDRs the operator lists in `auth.trustedProxies` (a reverse proxy on
18
+ * the LAN, the Home Assistant relay).
19
+ *
20
+ * From any other peer the headers are ignored and the peer IS the client — a
21
+ * LAN host is not a proxy, so it cannot claim another LAN address either.
22
+ *
23
+ * `viaProxy` says the request came through a trusted proxy at all. Anything
24
+ * that must hold only for a request made directly on the LAN (skipping the
25
+ * second factor from a trusted network) refuses `viaProxy` outright, whatever
26
+ * address the headers name.
27
+ */
28
+ const system_1 = require("@camstack/system");
29
+ const node_net_1 = require("node:net");
30
+ const LOOPBACK = ['127.0.0.0/8', '::1/128'];
31
+ function header(req, name) {
32
+ const raw = req.headers[name];
33
+ const value = Array.isArray(raw) ? raw[0] : raw;
34
+ return typeof value === 'string' && value.trim().length > 0 ? value : null;
35
+ }
36
+ function validIp(value) {
37
+ const ip = (0, system_1.normalizeIp)(value);
38
+ return (0, node_net_1.isIP)(ip) === 0 ? null : ip;
39
+ }
40
+ function resolveClientAddress(req, config) {
41
+ const rawPeer = req.socket?.remoteAddress;
42
+ const peer = typeof rawPeer === 'string' && rawPeer.length > 0 ? validIp(rawPeer) : null;
43
+ if (peer === null)
44
+ return { ip: null, peer: null, viaProxy: false };
45
+ const trusted = (0, system_1.createCidrMatcher)([...LOOPBACK, ...config.trustedProxies]);
46
+ if (!trusted.contains(peer))
47
+ return { ip: peer, peer, viaProxy: false };
48
+ // Cloudflare stamps the connecting client itself; prefer it when it parses.
49
+ const cf = header(req, 'cf-connecting-ip');
50
+ const cfIp = cf === null ? null : validIp(cf);
51
+ if (cfIp !== null)
52
+ return { ip: cfIp, peer, viaProxy: true };
53
+ // X-Forwarded-For: each proxy APPENDS the address it received from, so the
54
+ // right-most entry that is not itself a trusted proxy is the client. The
55
+ // left-most is whatever the client chose to send.
56
+ const xff = header(req, 'x-forwarded-for');
57
+ if (xff !== null) {
58
+ const hops = xff.split(',').map((h) => h.trim());
59
+ for (let i = hops.length - 1; i >= 0; i -= 1) {
60
+ const hop = validIp(hops[i] ?? '');
61
+ if (hop === null)
62
+ continue;
63
+ if (!trusted.contains(hop))
64
+ return { ip: hop, peer, viaProxy: true };
65
+ }
66
+ }
67
+ return { ip: peer, peer, viaProxy: true };
68
+ }
@@ -30,7 +30,7 @@ class ExternalLinkIntents {
30
30
  this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
31
31
  this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
32
32
  }
33
- create(userId, provider) {
33
+ create(userId, provider, returnTo) {
34
34
  this.prune();
35
35
  while (this.intents.size >= this.maxEntries) {
36
36
  const oldest = this.intents.keys().next();
@@ -39,7 +39,12 @@ class ExternalLinkIntents {
39
39
  this.intents.delete(oldest.value);
40
40
  }
41
41
  const handle = `li_${(0, node_crypto_1.randomBytes)(24).toString('base64url')}`;
42
- this.intents.set(handle, { userId, provider, expiresAt: this.now() + this.ttlMs });
42
+ this.intents.set(handle, {
43
+ userId,
44
+ provider,
45
+ ...(returnTo !== undefined ? { returnTo } : {}),
46
+ expiresAt: this.now() + this.ttlMs,
47
+ });
43
48
  return handle;
44
49
  }
45
50
  /** The intent behind `handle`, removing it — or `null` (unknown, used, expired). */
@@ -50,7 +55,11 @@ class ExternalLinkIntents {
50
55
  this.intents.delete(handle);
51
56
  if (stored.expiresAt < this.now())
52
57
  return null;
53
- return { userId: stored.userId, provider: stored.provider };
58
+ return {
59
+ userId: stored.userId,
60
+ provider: stored.provider,
61
+ ...(stored.returnTo !== undefined ? { returnTo: stored.returnTo } : {}),
62
+ };
54
63
  }
55
64
  prune() {
56
65
  const now = this.now();
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.userManagementInternal = userManagementInternal;
4
+ exports.createSessionBacking = createSessionBacking;
5
+ exports.sessionBackingFrom = sessionBackingFrom;
6
+ exports.createSessionTokenCheck = createSessionTokenCheck;
7
+ /**
8
+ * The hub's two uses of the login-session table, which `local-auth` owns and
9
+ * reaches only through its hub-internal surface (`UserManagementInternal`):
10
+ *
11
+ * - {@link createSessionBacking} — what THE session mint opens, continues and
12
+ * rotates sessions through (`session-mint.ts`);
13
+ * - {@link createSessionTokenCheck} — the question `AuthService.verifyToken`
14
+ * asks of every session-grade token: does its session still exist?
15
+ *
16
+ * Both resolve `user-management` per call: a hub whose local-auth is not up
17
+ * mints nothing and accepts no session (fail closed).
18
+ */
19
+ const system_1 = require("@camstack/system");
20
+ /** The hub-internal user-management surface right now, or `null`. */
21
+ function userManagementInternal(registry) {
22
+ return (0, system_1.getUserManagementInternal)(registry.getSingleton('user-management'));
23
+ }
24
+ function createSessionBacking(sessions, lifetimes) {
25
+ return {
26
+ open: async (input) => {
27
+ const { session, refreshToken } = await sessions.create({
28
+ ...input,
29
+ lifetimeMs: lifetimes().sessionMs,
30
+ });
31
+ return { session, refreshToken };
32
+ },
33
+ extend: async (sessionId) => sessions.extend(sessionId, lifetimes().sessionMs),
34
+ rotate: async (refreshToken) => {
35
+ const result = await sessions.refresh(refreshToken, lifetimes().sessionMs);
36
+ return result.ok
37
+ ? { ok: true, session: result.session, refreshToken: result.refreshToken }
38
+ : { ok: false, reason: result.reason };
39
+ },
40
+ accessSec: () => lifetimes().accessSec,
41
+ };
42
+ }
43
+ /** The session backing behind `registry`'s user-management, or `null` while it is not up. */
44
+ function sessionBackingFrom(registry, lifetimes) {
45
+ return () => {
46
+ const internal = userManagementInternal(registry);
47
+ return internal === null ? null : createSessionBacking(internal.sessions, lifetimes);
48
+ };
49
+ }
50
+ /**
51
+ * Is the session behind this (already signature-verified, session-grade)
52
+ * token still live? A token with a `sid` must name an active session of the
53
+ * same user. A token WITHOUT one predates sessions: it is accepted for one
54
+ * release unless it was issued before the user's cutoff (logout everywhere,
55
+ * a password change, deletion).
56
+ */
57
+ function createSessionTokenCheck(registry) {
58
+ return (payload) => {
59
+ const internal = userManagementInternal(registry);
60
+ if (internal === null)
61
+ return 'session-store-unavailable';
62
+ const userId = payload.userId;
63
+ if (payload.sid !== undefined) {
64
+ const check = internal.sessions.check(payload.sid);
65
+ if (check.state !== 'active')
66
+ return `session-${check.state}`;
67
+ return check.session.userId === userId ? null : 'session-user-mismatch';
68
+ }
69
+ if (userId === undefined)
70
+ return 'session-unknown';
71
+ const issuedAtMs = (payload.iat ?? 0) * 1000;
72
+ return issuedAtMs < internal.sessions.legacyCutoff(userId) ? 'issued-before-revocation' : null;
73
+ };
74
+ }
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NativeLinkCompletions = exports.NATIVE_LINK_CODE_TTL_MS = exports.NATIVE_LINK_CODE_PREFIX = void 0;
4
+ /**
5
+ * Native-app account links waiting for the app to claim them (D801).
6
+ *
7
+ * A link started from the native viewer finishes in the SYSTEM browser, which
8
+ * holds no hub session — so the SSO finish cannot check "the browser is signed
9
+ * in as the intent's user" (D782's binding). Instead it parks what it verified
10
+ * — the intent's user and the IdP identity — under a one-time code, and sends
11
+ * the browser back to the app's custom scheme with that code. The app claims it
12
+ * over `auth.completeExternalLink` with its own bearer, and the router binds it
13
+ * there: the caller must BE the intent's user.
14
+ *
15
+ * Same shape as the handoff codes: the raw code exists once (only its hash is
16
+ * kept), lives 60 s, is consumed by its first claim attempt whatever the
17
+ * outcome, and the store is bounded. In memory: a hub restart mid-link costs
18
+ * one more tap.
19
+ */
20
+ const node_crypto_1 = require("node:crypto");
21
+ exports.NATIVE_LINK_CODE_PREFIX = 'nlc_';
22
+ exports.NATIVE_LINK_CODE_TTL_MS = 60_000;
23
+ const MAX_PENDING = 1_000;
24
+ function hash(code) {
25
+ return (0, node_crypto_1.createHash)('sha256').update(code).digest('hex');
26
+ }
27
+ class NativeLinkCompletions {
28
+ pending = new Map();
29
+ now;
30
+ ttlMs;
31
+ constructor(opts = {}) {
32
+ this.now = opts.now ?? Date.now;
33
+ this.ttlMs = opts.ttlMs ?? exports.NATIVE_LINK_CODE_TTL_MS;
34
+ }
35
+ /** Park a verified link; returns the raw one-time code. */
36
+ park(link) {
37
+ this.prune();
38
+ while (this.pending.size >= MAX_PENDING) {
39
+ const oldest = this.pending.keys().next();
40
+ if (oldest.done)
41
+ break;
42
+ this.pending.delete(oldest.value);
43
+ }
44
+ const code = `${exports.NATIVE_LINK_CODE_PREFIX}${(0, node_crypto_1.randomBytes)(32).toString('base64url')}`;
45
+ this.pending.set(hash(code), { ...link, expiresAt: this.now() + this.ttlMs });
46
+ return code;
47
+ }
48
+ /** The parked link, consumed — or `null` (unknown, used, expired). */
49
+ claim(code) {
50
+ const key = hash(code);
51
+ const entry = this.pending.get(key);
52
+ if (entry === undefined)
53
+ return null;
54
+ this.pending.delete(key);
55
+ if (entry.expiresAt <= this.now())
56
+ return null;
57
+ const { expiresAt: _expiresAt, ...link } = entry;
58
+ return link;
59
+ }
60
+ prune() {
61
+ const now = this.now();
62
+ for (const [key, entry] of this.pending) {
63
+ if (entry.expiresAt <= now)
64
+ this.pending.delete(key);
65
+ }
66
+ }
67
+ }
68
+ exports.NativeLinkCompletions = NativeLinkCompletions;