@camstack/server 1.2.604 → 1.2.605
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/api/auth-whoami.js +28 -43
- package/dist/api/core/auth-admin-tokens.procedures.js +42 -24
- package/dist/api/core/auth-device-cert.procedures.js +1 -1
- package/dist/api/core/auth-reauth.procedures.js +1 -1
- package/dist/api/core/auth-revoke-credentials.procedures.js +54 -7
- package/dist/api/core/auth-sessions.procedures.js +1 -1
- package/dist/api/core/auth-tokens.procedures.js +250 -0
- package/dist/api/core/auth.router.js +95 -12
- package/dist/api/core/live-events.router.js +1 -1
- package/dist/api/trpc/principal-authorization.js +82 -0
- package/dist/api/trpc/principal-visibility.js +1 -1
- package/dist/api/trpc/trpc-error-principal.js +10 -9
- package/dist/api/trpc/trpc.context.js +82 -256
- package/dist/api/trpc/trpc.middleware.js +42 -83
- package/dist/api/trpc/trpc.router.js +1 -0
- package/dist/api/upload-auth.js +29 -52
- package/dist/auth/addon-route-auth.js +44 -0
- package/dist/auth/addon-route-share-gate.js +18 -16
- package/dist/auth/credential-verifier.js +277 -0
- package/dist/auth/scope-escalation.js +58 -0
- package/dist/core/addon/addon-registry.service.js +17 -0
- package/dist/core/auth/credential-token.service.js +250 -0
- package/dist/core/auth/record-kinds.js +31 -0
- package/dist/main.js +51 -97
- package/package.json +14 -14
- package/dist/auth/addon-route-jwt-gate.js +0 -59
|
@@ -1,56 +1,64 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.agentFromPrincipal = agentFromPrincipal;
|
|
4
|
+
exports.bindHubCredentialStores = bindHubCredentialStores;
|
|
5
|
+
exports.hubCredentialVerifier = hubCredentialVerifier;
|
|
6
|
+
exports.requestFacts = requestFacts;
|
|
3
7
|
exports.createMeshTrpcContext = createMeshTrpcContext;
|
|
4
8
|
exports.createTrpcContext = createTrpcContext;
|
|
5
9
|
exports.createWsTrpcContext = createWsTrpcContext;
|
|
6
10
|
const system_1 = require("@camstack/system");
|
|
7
|
-
const types_1 = require("@camstack/types");
|
|
8
11
|
const auth_network_policy_js_1 = require("../../auth/auth-network-policy.js");
|
|
9
|
-
const
|
|
12
|
+
const credential_verifier_js_1 = require("../../auth/credential-verifier.js");
|
|
10
13
|
const request_lang_js_1 = require("../../i18n/request-lang.js");
|
|
11
|
-
|
|
14
|
+
/** The tRPC principal a verified credential becomes. */
|
|
15
|
+
function agentFromPrincipal(principal) {
|
|
16
|
+
return {
|
|
17
|
+
id: principal.subjectId,
|
|
18
|
+
username: principal.username,
|
|
19
|
+
isAdmin: principal.isAdmin,
|
|
20
|
+
permissions: { isAdmin: principal.isAdmin, allowedProviders: '*', allowedDevices: {} },
|
|
21
|
+
isApiKey: !principal.interactive,
|
|
22
|
+
scopes: principal.scopes,
|
|
23
|
+
credentialId: principal.credentialId,
|
|
24
|
+
auditTag: principal.auditTag,
|
|
25
|
+
interactive: principal.interactive,
|
|
26
|
+
userDelegated: principal.userDelegated,
|
|
27
|
+
legacy: principal.legacy,
|
|
28
|
+
...(principal.sessionId !== undefined ? { sessionId: principal.sessionId } : {}),
|
|
29
|
+
...(principal.oauthSessionId !== undefined ? { oauthSessionId: principal.oauthSessionId } : {}),
|
|
30
|
+
...(principal.credential !== undefined ? { credential: principal.credential } : {}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
12
33
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* The JWT embeds scopes at login, so an admin's scope edit used to reach a
|
|
16
|
-
* live session only at the next login — a user granted cameras while logged
|
|
17
|
-
* in saw NONE until they signed out and back in (2026-08-15, user report).
|
|
18
|
-
* The store is therefore re-read per user, but memoized: a scope change
|
|
19
|
-
* propagates within {@link SCOPE_REFRESH_TTL_MS} and a busy session costs at
|
|
20
|
-
* most one store read per TTL window, not one per request.
|
|
34
|
+
* The live-scope memo of non-admin sessions, shared by every verifier this
|
|
35
|
+
* process builds (a request builds one; the memo must outlive it).
|
|
21
36
|
*/
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (liveScopeMemo.size > 512) {
|
|
48
|
-
for (const [key, entry] of liveScopeMemo) {
|
|
49
|
-
if (now - entry.at > SCOPE_REFRESH_TTL_MS * 4)
|
|
50
|
-
liveScopeMemo.delete(key);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return scopes;
|
|
37
|
+
const hubScopeMemo = new Map();
|
|
38
|
+
const hubStores = { shareTokens: null, tokenRows: null };
|
|
39
|
+
function bindHubCredentialStores(stores) {
|
|
40
|
+
if (stores.shareTokens !== undefined)
|
|
41
|
+
hubStores.shareTokens = stores.shareTokens;
|
|
42
|
+
if (stores.tokenRows !== undefined)
|
|
43
|
+
hubStores.tokenRows = stores.tokenRows;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The hub's verifier over its own stores — the same one for tRPC and the
|
|
47
|
+
* addon routes. Documented type boundary: the registry hands the
|
|
48
|
+
* `user-management` provider back untyped; the two methods the adapters read
|
|
49
|
+
* (`validateScopedToken`, `listUsers`) are asserted here.
|
|
50
|
+
*/
|
|
51
|
+
function hubCredentialVerifier(deps) {
|
|
52
|
+
const provider = () => deps.addonRegistry.getCapabilityRegistry().getSingleton('user-management');
|
|
53
|
+
return (0, credential_verifier_js_1.createCredentialVerifier)({
|
|
54
|
+
verifyJwt: (token) => deps.authService.verifyToken(token),
|
|
55
|
+
users: () => provider() ?? null,
|
|
56
|
+
internal: () => (0, system_1.getUserManagementInternal)(provider()),
|
|
57
|
+
shareTokens: () => deps.shareTokens ?? hubStores.shareTokens,
|
|
58
|
+
tokenRows: () => deps.tokenRows ?? hubStores.tokenRows,
|
|
59
|
+
logger: deps.logger ?? null,
|
|
60
|
+
scopeMemo: hubScopeMemo,
|
|
61
|
+
});
|
|
54
62
|
}
|
|
55
63
|
/** Read `req.query` if present (Fastify-only) without losing type safety. */
|
|
56
64
|
function readQuery(req) {
|
|
@@ -62,7 +70,7 @@ function readQuery(req) {
|
|
|
62
70
|
return { ...q };
|
|
63
71
|
}
|
|
64
72
|
/**
|
|
65
|
-
* Extract a
|
|
73
|
+
* Extract a bearer from an HTTP request.
|
|
66
74
|
* Priority: Authorization header → Fastify req.query → URL query string
|
|
67
75
|
*/
|
|
68
76
|
function extractTokenFromRequest(req) {
|
|
@@ -89,6 +97,7 @@ function extractTokenFromRequest(req) {
|
|
|
89
97
|
}
|
|
90
98
|
return null;
|
|
91
99
|
}
|
|
100
|
+
/** What the audit of an admin-capable token's use records about the request. */
|
|
92
101
|
function requestFacts(req) {
|
|
93
102
|
if (req === undefined)
|
|
94
103
|
return {};
|
|
@@ -97,203 +106,9 @@ function requestFacts(req) {
|
|
|
97
106
|
const path = typeof url === 'string' ? url.split('?')[0] : undefined;
|
|
98
107
|
return { ...(path !== undefined ? { path } : {}), ...(ip !== null ? { ip } : {}) };
|
|
99
108
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
* an admin, not revoked, not expired) is local-auth's (`AdminTokenManager`).
|
|
104
|
-
*/
|
|
105
|
-
async function resolveAdminToken(token, addonRegistry, facts) {
|
|
106
|
-
const internal = (0, system_1.getUserManagementInternal)(addonRegistry.getCapabilityRegistry().getSingleton('user-management'));
|
|
107
|
-
if (internal === null)
|
|
108
|
-
return null;
|
|
109
|
-
const record = await internal.adminTokens.validate(token).catch(() => null);
|
|
110
|
-
if (record === null)
|
|
111
|
-
return null;
|
|
112
|
-
void internal.audit.record({
|
|
113
|
-
action: 'admin-token-used',
|
|
114
|
-
userId: record.createdBy,
|
|
115
|
-
...(facts.ip !== undefined ? { ip: facts.ip } : {}),
|
|
116
|
-
detail: {
|
|
117
|
-
tokenId: record.id,
|
|
118
|
-
name: record.name,
|
|
119
|
-
prefix: record.tokenPrefix,
|
|
120
|
-
...(facts.path !== undefined ? { path: facts.path } : {}),
|
|
121
|
-
},
|
|
122
|
-
});
|
|
123
|
-
return {
|
|
124
|
-
id: record.createdBy,
|
|
125
|
-
username: `admin-token:${record.name}`,
|
|
126
|
-
isAdmin: true,
|
|
127
|
-
permissions: { isAdmin: true, allowedProviders: '*', allowedDevices: {} },
|
|
128
|
-
isApiKey: true,
|
|
129
|
-
credential: 'admin-token',
|
|
130
|
-
adminTokenId: record.id,
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
async function resolveUser(token, authService, addonRegistry, shareTokens = null, logger = null, facts = {}) {
|
|
134
|
-
if (!token)
|
|
135
|
-
return null;
|
|
136
|
-
if (token.startsWith(system_1.ADMIN_TOKEN_PREFIX))
|
|
137
|
-
return resolveAdminToken(token, addonRegistry, facts);
|
|
138
|
-
// Share-token path (`csv_*`): resolve through the ShareTokenService.
|
|
139
|
-
// The synthetic principal is maximally restricted — non-admin, no
|
|
140
|
-
// scopes; the `shareView` grant routes it into the fail-closed
|
|
141
|
-
// allowlist in `protectedProcedure` instead of the scope matcher.
|
|
142
|
-
if (token.startsWith(share_token_service_js_1.SHARE_TOKEN_PREFIX)) {
|
|
143
|
-
if (!shareTokens)
|
|
144
|
-
return null;
|
|
145
|
-
try {
|
|
146
|
-
const record = await shareTokens.validate(token);
|
|
147
|
-
if (!record)
|
|
148
|
-
return null;
|
|
149
|
-
return {
|
|
150
|
-
id: record.userId,
|
|
151
|
-
// Display label — `share:<prefix>` keeps audit logs readable
|
|
152
|
-
// without exposing the token hash.
|
|
153
|
-
username: `share:${record.tokenPrefix}`,
|
|
154
|
-
isAdmin: false,
|
|
155
|
-
permissions: {
|
|
156
|
-
isAdmin: false,
|
|
157
|
-
allowedProviders: '*',
|
|
158
|
-
allowedDevices: {},
|
|
159
|
-
},
|
|
160
|
-
isApiKey: true,
|
|
161
|
-
shareView: { tokenId: record.id, scope: record.scope },
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
catch {
|
|
165
|
-
return null;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
// Scoped-token path: hit the user-management cap. Synthetic user
|
|
169
|
-
// with `isAdmin: false` so admin-gated procedures bounce while
|
|
170
|
-
// protectedProcedure can still gate by scope match.
|
|
171
|
-
if (token.startsWith('cst_')) {
|
|
172
|
-
try {
|
|
173
|
-
const userMgmt = addonRegistry.getCapabilityRegistry().getSingleton('user-management');
|
|
174
|
-
if (!userMgmt)
|
|
175
|
-
return null;
|
|
176
|
-
const record = await userMgmt.validateScopedToken({ token });
|
|
177
|
-
if (!record)
|
|
178
|
-
return null;
|
|
179
|
-
return {
|
|
180
|
-
id: record.userId,
|
|
181
|
-
// Display label — `scoped:<prefix>` makes audit logs read
|
|
182
|
-
// naturally without exposing the token hash.
|
|
183
|
-
username: `scoped:${record.tokenPrefix}`,
|
|
184
|
-
isAdmin: false,
|
|
185
|
-
permissions: {
|
|
186
|
-
isAdmin: false,
|
|
187
|
-
allowedProviders: '*',
|
|
188
|
-
allowedDevices: {},
|
|
189
|
-
},
|
|
190
|
-
isApiKey: true,
|
|
191
|
-
isScoped: true,
|
|
192
|
-
// Migrate any v2 `device:targets` grant to a v3 selector at the
|
|
193
|
-
// boundary so the enforcement matcher only ever sees v3.
|
|
194
|
-
scopes: (0, types_1.normalizeTokenScopes)(record.scopes),
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
catch {
|
|
198
|
-
return null;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
// JWT path.
|
|
202
|
-
try {
|
|
203
|
-
const payload = authService.verifyToken(token);
|
|
204
|
-
// Reject pre-v2 JWTs at the boundary. Tokens issued before the
|
|
205
|
-
// role → isAdmin migration don't carry the `isAdmin` field, so
|
|
206
|
-
// letting them through would degrade silently into "non-admin
|
|
207
|
-
// with no scopes" → locked out of every cap. Returning null forces
|
|
208
|
-
// the client to land on 401 → re-login, where it picks up a fresh
|
|
209
|
-
// v2 token. No back-compat shim — the role enum is gone.
|
|
210
|
-
if (typeof payload.isAdmin !== 'boolean') {
|
|
211
|
-
return null;
|
|
212
|
-
}
|
|
213
|
-
// What the token IS decides both WHETHER it authenticates at all and
|
|
214
|
-
// WHERE its scopes come from.
|
|
215
|
-
const principal = (0, types_1.classifyBearerPrincipal)(payload);
|
|
216
|
-
// ── The allowlist ────────────────────────────────────────────────
|
|
217
|
-
// Every token this hub mints verifies under the same `auth.jwtSecret`, so
|
|
218
|
-
// `verifyToken` succeeding means "we minted this", never "this is an API
|
|
219
|
-
// credential" (D103). Exactly two kinds are credentials here:
|
|
220
|
-
//
|
|
221
|
-
// • `session` — no `kind` claim: `auth.login`'s JWT and the SSO-minted
|
|
222
|
-
// session from `/api/auth/sso/finish`.
|
|
223
|
-
// • `integration` — `provider: 'oauth-access'`: the account-link
|
|
224
|
-
// credential a component (Home Assistant) calls `/trpc` with.
|
|
225
|
-
//
|
|
226
|
-
// Everything else is minted for ONE hand-off step and is refused: an
|
|
227
|
-
// `oauth-code` is single-use, lives 60s and travels in a browser redirect
|
|
228
|
-
// URL (history, referrers, proxy logs); an `oauth-refresh` belongs to
|
|
229
|
-
// `POST /oauth/token`; an `oidc` login bridge belongs to
|
|
230
|
-
// `?bridge=` on `/api/auth/sso/finish`; a `totp-challenge` is login leg 1.
|
|
231
|
-
// `/addon/:addonId/*` has refused these since D103 — this closes the same
|
|
232
|
-
// hole on the other surface.
|
|
233
|
-
//
|
|
234
|
-
// The TOTP login is unaffected BY CONSTRUCTION: `loginVerifyTotp` takes
|
|
235
|
-
// the challenge as procedure INPUT on a public rate-limited procedure, and
|
|
236
|
-
// neither the SDK (`login()` calls `setToken` only when
|
|
237
|
-
// `!requiresSecondFactor`) nor the admin UI ever puts it in a header.
|
|
238
|
-
if (principal.kind === 'not-a-credential') {
|
|
239
|
-
logger?.warn('trpc: refused a bearer that is not an API credential', {
|
|
240
|
-
meta: { reason: principal.reason, userId: payload.userId ?? 'unknown' },
|
|
241
|
-
});
|
|
242
|
-
return null;
|
|
243
|
-
}
|
|
244
|
-
// What the token says it IS. An `oauth-access` token is an account link,
|
|
245
|
-
// and until this was carried onto the principal a refusal of one was
|
|
246
|
-
// indistinguishable in the logs from a refusal of a browser session.
|
|
247
|
-
const credential = (0, trpc_error_principal_js_1.readCredentialIdentity)(payload);
|
|
248
|
-
return {
|
|
249
|
-
id: payload.userId ?? 'unknown',
|
|
250
|
-
username: payload.username ?? 'unknown',
|
|
251
|
-
isAdmin: payload.isAdmin,
|
|
252
|
-
permissions: {
|
|
253
|
-
isAdmin: payload.isAdmin,
|
|
254
|
-
allowedProviders: payload.allowedProviders,
|
|
255
|
-
allowedDevices: payload.allowedDevices,
|
|
256
|
-
},
|
|
257
|
-
isApiKey: false,
|
|
258
|
-
...(principal.kind === 'session' && typeof payload.sid === 'string'
|
|
259
|
-
? { sessionId: payload.sid }
|
|
260
|
-
: {}),
|
|
261
|
-
// Scopes are baked into the JWT at login; the middleware uses
|
|
262
|
-
// them to gate every call until the user re-logs. Normalised at the
|
|
263
|
-
// boundary — a v2 `device:targets` JWT (issued before the v3 model)
|
|
264
|
-
// migrates to a selector here, so a pre-v3 token keeps working.
|
|
265
|
-
//
|
|
266
|
-
// …but a login-time snapshot is exactly the bug: an admin editing a
|
|
267
|
-
// user's scopes expects the change to APPLY, and the live session
|
|
268
|
-
// kept gating on the old set until re-login (2026-08-15: a user
|
|
269
|
-
// granted the whole fleet kept seeing zero cameras). For non-admin
|
|
270
|
-
// SESSION principals the store's current scopes win when readable
|
|
271
|
-
// (memoized — see above); the JWT's copy is the fallback when the
|
|
272
|
-
// user-management cap is unreachable, so a store hiccup never
|
|
273
|
-
// locks anybody out mid-request.
|
|
274
|
-
//
|
|
275
|
-
// `session` ONLY, and that word is the whole fix. An ACCOUNT-LINK token
|
|
276
|
-
// is governed by the grant the operator consented to, not by the linking
|
|
277
|
-
// user's record: it always carries `isAdmin: false` (`oauth-grants.ts`
|
|
278
|
-
// → `baseClaims`) and its owner is normally an admin, whose record is
|
|
279
|
-
// deliberately unscoped — so the refresh replaced a six-scope Home
|
|
280
|
-
// Assistant grant with `[]` and the hub refused the component's first
|
|
281
|
-
// call with `Have: (none)` (2026-08-19). The user store knows nothing
|
|
282
|
-
// about a link and must not get a vote on one.
|
|
283
|
-
...(payload.scopes !== undefined ? { scopes: (0, types_1.normalizeTokenScopes)(payload.scopes) } : {}),
|
|
284
|
-
...(!payload.isAdmin && principal.kind === 'session'
|
|
285
|
-
? await (async () => {
|
|
286
|
-
const fresh = await refreshScopesFromStore(payload.userId ?? 'unknown', addonRegistry);
|
|
287
|
-
return fresh !== null ? { scopes: fresh } : {};
|
|
288
|
-
})()
|
|
289
|
-
: {}),
|
|
290
|
-
...(credential.credential !== undefined ? { credential: credential.credential } : {}),
|
|
291
|
-
...(credential.sessionId !== undefined ? { oauthSessionId: credential.sessionId } : {}),
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
catch {
|
|
295
|
-
return null;
|
|
296
|
-
}
|
|
109
|
+
async function resolveUser(token, verifier, facts) {
|
|
110
|
+
const principal = await verifier.verify(token, facts);
|
|
111
|
+
return principal === null ? null : agentFromPrincipal(principal);
|
|
297
112
|
}
|
|
298
113
|
/**
|
|
299
114
|
* Build the parent-chain walker for the scope-access matcher. Returns every
|
|
@@ -304,8 +119,7 @@ async function resolveUser(token, authService, addonRegistry, shareTokens = null
|
|
|
304
119
|
* the device-manager's PERSISTED parentage (F0.6). It MUST NOT walk the hub
|
|
305
120
|
* `DeviceRegistry` directly: that registry only holds hub-local devices, so it
|
|
306
121
|
* is empty for every forked provider and the inheritance this function exists to
|
|
307
|
-
* provide silently did nothing (0/6 providers inherited).
|
|
308
|
-
* off the event path and read synchronously here.
|
|
122
|
+
* provide silently did nothing (0/6 providers inherited).
|
|
309
123
|
*/
|
|
310
124
|
function makeDeviceScopeLookup(addonRegistry) {
|
|
311
125
|
return {
|
|
@@ -321,15 +135,12 @@ function makeDeviceScopeLookup(addonRegistry) {
|
|
|
321
135
|
* `principal` names WHO is calling (D802): `mesh` for the hub's own runners
|
|
322
136
|
* (they reach the bridge over UDS, inside the hub's trust boundary), and
|
|
323
137
|
* `node:<nodeId>` for another node — whose id the hub's mesh gate has bound
|
|
324
|
-
* to its CERTIFICATE before the call got here (
|
|
325
|
-
* the TLS handshake chained to the mesh CA, the leaf CN is that id, D857).
|
|
138
|
+
* to its CERTIFICATE before the call got here (D857).
|
|
326
139
|
*
|
|
327
|
-
* Both principals
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
* next step once the mesh census has measured what each node calls (D802).
|
|
332
|
-
* There is no HTTP request behind the call.
|
|
140
|
+
* Both principals hold `'*'`: that keeps every `adminProcedure` an agent's
|
|
141
|
+
* addons call working. What is NOT reachable from another node is decided by
|
|
142
|
+
* the bridge itself (`REMOTE_DENIED_NAMESPACES`), and `repl` is not bridged at
|
|
143
|
+
* all. There is no HTTP request behind the call.
|
|
333
144
|
*/
|
|
334
145
|
function createMeshTrpcContext(principal = 'mesh') {
|
|
335
146
|
const user = {
|
|
@@ -338,14 +149,22 @@ function createMeshTrpcContext(principal = 'mesh') {
|
|
|
338
149
|
isAdmin: true,
|
|
339
150
|
permissions: { isAdmin: true, allowedProviders: '*', allowedDevices: {} },
|
|
340
151
|
isApiKey: true,
|
|
152
|
+
scopes: ['*'],
|
|
153
|
+
credentialId: principal,
|
|
341
154
|
};
|
|
342
155
|
return { user };
|
|
343
156
|
}
|
|
344
157
|
/** Context factory for HTTP tRPC requests (Fastify adapter). */
|
|
345
|
-
async function createTrpcContext(req, authService, addonRegistry, shareTokens = null, logger = null) {
|
|
346
|
-
const
|
|
158
|
+
async function createTrpcContext(req, authService, addonRegistry, shareTokens = null, logger = null, tokenRows = null) {
|
|
159
|
+
const verifier = hubCredentialVerifier({
|
|
160
|
+
authService,
|
|
161
|
+
addonRegistry,
|
|
162
|
+
shareTokens,
|
|
163
|
+
tokenRows,
|
|
164
|
+
logger,
|
|
165
|
+
});
|
|
347
166
|
return {
|
|
348
|
-
user: await resolveUser(
|
|
167
|
+
user: await resolveUser(extractTokenFromRequest(req), verifier, requestFacts(req)),
|
|
349
168
|
req,
|
|
350
169
|
lang: (0, request_lang_js_1.resolveRequestLang)((0, request_lang_js_1.requestLangInputFrom)(req.headers, readQuery(req))),
|
|
351
170
|
deviceScopeLookup: makeDeviceScopeLookup(addonRegistry),
|
|
@@ -357,11 +176,18 @@ async function createTrpcContext(req, authService, addonRegistry, shareTokens =
|
|
|
357
176
|
* Token is sent via tRPC connectionParams (a JSON message sent right after
|
|
358
177
|
* the WS handshake), which is more reliable than query params through proxies.
|
|
359
178
|
*/
|
|
360
|
-
async function createWsTrpcContext(opts, authService, addonRegistry, shareTokens = null, logger = null) {
|
|
179
|
+
async function createWsTrpcContext(opts, authService, addonRegistry, shareTokens = null, logger = null, tokenRows = null) {
|
|
361
180
|
// 1. connectionParams.token (sent by BackendClient's createWSClient)
|
|
362
181
|
const paramToken = opts.info.connectionParams?.['token'];
|
|
363
182
|
const token = (typeof paramToken === 'string' ? paramToken : null) ?? extractTokenFromRequest(opts.req);
|
|
364
|
-
const
|
|
183
|
+
const verifier = hubCredentialVerifier({
|
|
184
|
+
authService,
|
|
185
|
+
addonRegistry,
|
|
186
|
+
shareTokens,
|
|
187
|
+
tokenRows,
|
|
188
|
+
logger,
|
|
189
|
+
});
|
|
190
|
+
const user = await resolveUser(token, verifier, requestFacts(opts.req));
|
|
365
191
|
const sessionId = user?.sessionId;
|
|
366
192
|
return {
|
|
367
193
|
user,
|
|
@@ -14,6 +14,7 @@ const cap_route_error_formatter_js_1 = require("./cap-route-error-formatter.js")
|
|
|
14
14
|
const device_config_secret_redaction_js_1 = require("./device-config-secret-redaction.js");
|
|
15
15
|
const principal_visibility_js_1 = require("./principal-visibility.js");
|
|
16
16
|
const localized_schema_paths_js_1 = require("./localized-schema-paths.js");
|
|
17
|
+
const principal_authorization_js_1 = require("./principal-authorization.js");
|
|
17
18
|
const scope_access_js_1 = require("./scope-access.js");
|
|
18
19
|
const share_view_access_js_1 = require("./share-view-access.js");
|
|
19
20
|
const user_error_js_1 = require("./user-error.js");
|
|
@@ -91,24 +92,20 @@ exports.publicProcedure = t.procedure;
|
|
|
91
92
|
*/
|
|
92
93
|
exports.createCallerFactory = t.createCallerFactory;
|
|
93
94
|
/**
|
|
94
|
-
*
|
|
95
|
+
* The authenticated procedure (D892).
|
|
95
96
|
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* The caller's scope set must grant the required (capName, access)
|
|
99
|
-
* pair via one of the three forms (`category`/`capability`/`addon`).
|
|
97
|
+
* The principal's `scopes` decide, through ONE matcher
|
|
98
|
+
* (`authorizeProcedure`, `principal-authorization.ts`):
|
|
100
99
|
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* `
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* `
|
|
108
|
-
* pulling every core route into the codegen map.
|
|
100
|
+
* - a `share-view` grant restricts it to the fail-closed embed allowlist
|
|
101
|
+
* (`share-view-access.ts`) — enumerated methods, in-scope devices only —
|
|
102
|
+
* and its answers are projected to that perimeter;
|
|
103
|
+
* - `'*'` passes;
|
|
104
|
+
* - otherwise a cap route (`METHOD_ACCESS_MAP`) is scope-matched, and a
|
|
105
|
+
* hand-written core route (`auth.*`, `system.info` …) needs authentication
|
|
106
|
+
* only — `adminProcedure` on top gates what is destructive.
|
|
109
107
|
*
|
|
110
|
-
*
|
|
111
|
-
* collapsed onto this boolean in v2.
|
|
108
|
+
* What kind of credential the principal came from is never read here.
|
|
112
109
|
*/
|
|
113
110
|
exports.protectedProcedure = localized.use(async ({ ctx, next, path, getRawInput }) => {
|
|
114
111
|
if (!ctx.user) {
|
|
@@ -120,93 +117,55 @@ exports.protectedProcedure = localized.use(async ({ ctx, next, path, getRawInput
|
|
|
120
117
|
if (ctx.sessionLive !== undefined && !ctx.sessionLive()) {
|
|
121
118
|
throw (0, user_error_js_1.trpcUserError)('UNAUTHORIZED', 'auth.session-ended', undefined, 'Session ended — sign in again');
|
|
122
119
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
//
|
|
120
|
+
const user = ctx.user;
|
|
121
|
+
const verdict = (0, principal_authorization_js_1.authorizeProcedure)(user.scopes ?? [], path, await getRawInput(), ctx.deviceScopeLookup);
|
|
122
|
+
if (!verdict.ok) {
|
|
123
|
+
throw new server_1.TRPCError({ code: 'FORBIDDEN', message: verdict.reason });
|
|
124
|
+
}
|
|
125
|
+
// Spread+reassign of `user` narrows downstream ctx from `User | null` to
|
|
126
|
+
// `User` so `adminProcedure` can read fields without re-checking.
|
|
127
|
+
if (verdict.mode === 'all') {
|
|
128
|
+
return next({ ctx: { ...ctx, user } });
|
|
129
|
+
}
|
|
130
|
+
if (verdict.mode === 'share') {
|
|
131
|
+
const grant = verdict.grant;
|
|
132
|
+
// `deviceManager.listAll` is allowlisted for the embed's tile names, but
|
|
133
|
+
// its raw rows describe EVERY device (config incl. credentials): project
|
|
134
|
+
// it down to the in-scope whitelisted rows. The events surface answers
|
|
135
|
+
// with ROWS carrying a `deviceId`, and two of its methods can reach
|
|
136
|
+
// devices outside the scope — this is where that answer is cut.
|
|
136
137
|
if (path === 'deviceManager.listAll') {
|
|
137
|
-
const out = await next({ ctx: { ...ctx, user
|
|
138
|
-
|
|
139
|
-
return { ...out, data: (0, share_view_access_js_1.projectListAllForShareScope)(out.data, ctx.user.shareView.scope) };
|
|
140
|
-
}
|
|
141
|
-
return out;
|
|
138
|
+
const out = await next({ ctx: { ...ctx, user } });
|
|
139
|
+
return out.ok ? { ...out, data: (0, share_view_access_js_1.projectListAllForShareScope)(out.data, grant) } : out;
|
|
142
140
|
}
|
|
143
|
-
// The events surface answers with ROWS carrying a `deviceId`, and two of
|
|
144
|
-
// its three methods can legitimately reach devices outside the scope: a
|
|
145
|
-
// partially in-scope `listRecentTracks`, and a `searchObjectEvents` with
|
|
146
|
-
// no `deviceId` at all (which means EVERY camera). The enumeration lets
|
|
147
|
-
// those calls through — this is where the answer is cut. Without it the
|
|
148
|
-
// allowlist would be a doorman who checks the ticket and then hands over
|
|
149
|
-
// the whole building.
|
|
150
141
|
if (share_view_access_js_1.SHARE_SCOPE_PROJECTED_METHODS.has(path)) {
|
|
151
|
-
const out = await next({ ctx: { ...ctx, user
|
|
152
|
-
|
|
153
|
-
return { ...out, data: (0, share_view_access_js_1.projectShareScopeRows)(out.data, ctx.user.shareView.scope) };
|
|
154
|
-
}
|
|
155
|
-
return out;
|
|
156
|
-
}
|
|
157
|
-
return next({ ctx: { ...ctx, user: ctx.user } });
|
|
158
|
-
}
|
|
159
|
-
// Spread+reassign of `user` narrows downstream ctx from `User | null`
|
|
160
|
-
// to `User` so `adminProcedure` can read fields
|
|
161
|
-
// without re-checking.
|
|
162
|
-
if (ctx.user.isAdmin) {
|
|
163
|
-
return next({ ctx: { ...ctx, user: ctx.user } });
|
|
164
|
-
}
|
|
165
|
-
// Everything below this line is a NON-ADMIN principal.
|
|
166
|
-
//
|
|
167
|
-
// A hand-written core route has no cap entry. Authentication has
|
|
168
|
-
// already passed; defer further gating to any explicit
|
|
169
|
-
// `adminProcedure` chained on top of this one. A cap route is
|
|
170
|
-
// scope-matched first.
|
|
171
|
-
if (path in system_1.METHOD_ACCESS_MAP) {
|
|
172
|
-
// Device-scope caps may be gated by a `device:N` scope. Resolve the
|
|
173
|
-
// raw input once so the matcher can read `input.deviceId` without
|
|
174
|
-
// re-doing the Zod parse (tRPC caches the parsed input downstream).
|
|
175
|
-
// The `getDeviceAncestors` hook lets the matcher walk parent → child
|
|
176
|
-
// accessory inheritance (grant on Reolink also covers its siren / PIR).
|
|
177
|
-
const rawInput = await getRawInput();
|
|
178
|
-
const result = (0, scope_access_js_1.checkScopeAccess)(ctx.user.scopes ?? [], path, rawInput, ctx.deviceScopeLookup);
|
|
179
|
-
if (!result.ok) {
|
|
180
|
-
throw new server_1.TRPCError({ code: 'FORBIDDEN', message: result.reason });
|
|
142
|
+
const out = await next({ ctx: { ...ctx, user } });
|
|
143
|
+
return out.ok ? { ...out, data: (0, share_view_access_js_1.projectShareScopeRows)(out.data, grant) } : out;
|
|
181
144
|
}
|
|
145
|
+
return next({ ctx: { ...ctx, user } });
|
|
182
146
|
}
|
|
183
147
|
// A non-admin who is allowed to READ a device is not thereby allowed to
|
|
184
148
|
// read the credential that opens it. `deviceManager.getDevice` &co. are
|
|
185
149
|
// `auth: 'protected'`, so a read-only `category:system` grant reaches
|
|
186
|
-
// them — and the raw row carries `config.password` in plaintext
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
// orchestrator, the drivers that must actually connect) are unaffected.
|
|
150
|
+
// them — and the raw row carries `config.password` in plaintext. The value
|
|
151
|
+
// is withheld HERE, at the transport boundary, so in-process callers
|
|
152
|
+
// (`ctx.api`, the drivers that must actually connect) are unaffected.
|
|
190
153
|
const redactConfigSecrets = device_config_secret_redaction_js_1.NON_ADMIN_CONFIG_REDACTED_METHODS.get(path);
|
|
191
|
-
const out = await next({ ctx: { ...ctx, user
|
|
154
|
+
const out = await next({ ctx: { ...ctx, user } });
|
|
192
155
|
if (!out.ok)
|
|
193
156
|
return out;
|
|
194
157
|
// A device-scoped principal ENUMERATES only its own devices (F1 #4). The
|
|
195
158
|
// matcher lets a `device`-selector grant reach `deviceManager.listAll`
|
|
196
|
-
// precisely because the answer is cut here
|
|
197
|
-
// hand back the whole fleet — names, locations, online state.
|
|
159
|
+
// precisely because the answer is cut here.
|
|
198
160
|
if (scope_access_js_1.DEVICE_ENUMERATION_METHODS.has(path)) {
|
|
199
|
-
const projected = (0, principal_visibility_js_1.projectListAllForPrincipal)(out.data,
|
|
161
|
+
const projected = (0, principal_visibility_js_1.projectListAllForPrincipal)(out.data, false, verdict.scopes, ctx.deviceFleet?.() ?? []);
|
|
200
162
|
return {
|
|
201
163
|
...out,
|
|
202
164
|
data: redactConfigSecrets ? redactConfigSecrets(projected) : projected,
|
|
203
165
|
};
|
|
204
166
|
}
|
|
205
|
-
// Batch device methods
|
|
206
|
-
//
|
|
207
|
-
// projection: the matcher already required EVERY device the request names
|
|
208
|
-
// to be covered (see `referencedDeviceIds`), so the answer can only
|
|
209
|
-
// contain rows the caller is entitled to.
|
|
167
|
+
// Batch device methods need no response projection: the matcher already
|
|
168
|
+
// required EVERY device the request names to be covered.
|
|
210
169
|
if (redactConfigSecrets) {
|
|
211
170
|
return { ...out, data: redactConfigSecrets(out.data) };
|
|
212
171
|
}
|
|
@@ -362,6 +362,7 @@ function buildCapabilityRouters(services) {
|
|
|
362
362
|
? { network: services.authNetworkPolicy }
|
|
363
363
|
: {}),
|
|
364
364
|
...(services.sessionBacking !== undefined ? { sessions: services.sessionBacking } : {}),
|
|
365
|
+
credentialTokens: services.credentialTokenService ?? null,
|
|
365
366
|
logger: services.loggingService.createLogger('auth'),
|
|
366
367
|
}),
|
|
367
368
|
// NOT MOUNTED — `mount: { kind: 'skip' }` legacy provider shapes
|