@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
|
@@ -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
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
|
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
|
|
187
|
-
// session from `/api/auth/sso/finish
|
|
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
|
|
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 ??
|
|
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 ??
|
|
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:
|
|
224
|
-
|
|
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 ??
|
|
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
|
|
283
|
-
*
|
|
317
|
+
* Context factory for calls arriving over the Moleculer mesh (the `$core-caps`
|
|
318
|
+
* bridge service in `core-cap-bridge.ts`).
|
|
284
319
|
*
|
|
285
|
-
*
|
|
286
|
-
* the
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
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:
|
|
297
|
-
username:
|
|
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.
|
|
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`
|
|
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
|
-
|
|
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
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* request
|
|
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
|
-
|
|
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, {
|
|
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 {
|
|
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;
|