@camstack/server 1.2.604 → 1.2.606
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 +27 -43
- package/dist/api/core/auth-audit.procedures.js +44 -0
- 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 +25 -52
- package/dist/api/core/auth-sessions.procedures.js +1 -1
- package/dist/api/core/auth-tokens.procedures.js +283 -0
- package/dist/api/core/auth.router.js +20 -203
- package/dist/api/core/live-events.router.js +1 -1
- package/dist/api/core/preferences.router.js +98 -0
- package/dist/api/oauth2/oauth2-routes.js +40 -15
- package/dist/api/trpc/device-config-secret-redaction.js +2 -2
- package/dist/api/trpc/generated-cap-mounts.js +0 -1
- package/dist/api/trpc/generated-cap-routers.js +2 -38
- package/dist/api/trpc/principal-authorization.js +82 -0
- package/dist/api/trpc/principal-visibility.js +3 -3
- package/dist/api/trpc/share-view-access.js +1 -1
- package/dist/api/trpc/trpc-error-principal.js +9 -9
- package/dist/api/trpc/trpc.context.js +87 -258
- package/dist/api/trpc/trpc.middleware.js +42 -83
- package/dist/api/trpc/trpc.router.js +7 -1
- package/dist/api/upload-auth.js +19 -54
- 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 +236 -0
- package/dist/auth/embed-auth-route.js +45 -0
- package/dist/auth/live-user.js +19 -0
- package/dist/auth/scope-escalation.js +133 -0
- package/dist/boot/boot-config.js +8 -1
- package/dist/core/addon/addon-registry.service.js +18 -7
- package/dist/core/auth/credential-token.service.js +245 -0
- package/dist/core/auth/handoff-code.service.js +1 -1
- package/dist/core/auth/record-kinds.js +38 -2
- package/dist/main.js +65 -141
- package/package.json +14 -14
- package/dist/api/core/auth-admin-tokens.procedures.js +0 -138
- package/dist/auth/addon-route-jwt-gate.js +0 -59
- package/dist/core/auth/share-token.service.js +0 -459
|
@@ -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
|
}
|
|
@@ -54,6 +54,7 @@ const hwaccel_router_js_1 = require("../core/hwaccel.router.js");
|
|
|
54
54
|
const live_events_router_js_1 = require("../core/live-events.router.js");
|
|
55
55
|
const logs_router_js_1 = require("../core/logs.router.js");
|
|
56
56
|
const notifications_router_js_1 = require("../core/notifications.router.js");
|
|
57
|
+
const preferences_router_js_1 = require("../core/preferences.router.js");
|
|
57
58
|
const repl_router_js_1 = require("../core/repl.router.js");
|
|
58
59
|
const server_management_provider_js_1 = require("../core/server-management.provider.js");
|
|
59
60
|
const stream_probe_router_js_1 = require("../core/stream-probe.router.js");
|
|
@@ -351,17 +352,22 @@ function buildCapabilityRouters(services) {
|
|
|
351
352
|
hubDataDir: path.resolve(services.configService.get('server.dataPath') ?? 'camstack-data'),
|
|
352
353
|
log: services.loggingService.createLogger('certificates'),
|
|
353
354
|
}),
|
|
355
|
+
// preferences - the caller's own account preferences, synced across their devices (D894).
|
|
356
|
+
preferences: (0, preferences_router_js_1.createPreferencesRouter)({
|
|
357
|
+
preferences: (0, preferences_router_js_1.preferencesFromRegistry)(services.capabilityRegistry),
|
|
358
|
+
}),
|
|
354
359
|
// agentPairing - hub-initiated pairing of agents, unpairing (D857).
|
|
355
360
|
agentPairing: (0, agent_pairing_router_js_1.createAgentPairingRouter)({
|
|
356
361
|
registry: services.capabilityRegistry,
|
|
357
362
|
nodeClient: (nodeId) => (0, node_certificates_client_js_1.createNodeCertificatesClient)(services.moleculer, nodeId),
|
|
358
363
|
log: services.loggingService.createLogger('agent-pairing'),
|
|
359
364
|
}),
|
|
360
|
-
auth: (0, auth_router_js_1.createAuthRouter)(services.authService, services.capabilityRegistry, services.moleculer, services.
|
|
365
|
+
auth: (0, auth_router_js_1.createAuthRouter)(services.authService, services.capabilityRegistry, services.moleculer, services.handoffCodeService, services.externalLinkIntents, services.nativeLink ?? null, {
|
|
361
366
|
...(services.authNetworkPolicy !== undefined
|
|
362
367
|
? { network: services.authNetworkPolicy }
|
|
363
368
|
: {}),
|
|
364
369
|
...(services.sessionBacking !== undefined ? { sessions: services.sessionBacking } : {}),
|
|
370
|
+
credentialTokens: services.credentialTokenService ?? null,
|
|
365
371
|
logger: services.loggingService.createLogger('auth'),
|
|
366
372
|
}),
|
|
367
373
|
// NOT MOUNTED — `mount: { kind: 'skip' }` legacy provider shapes
|
package/dist/api/upload-auth.js
CHANGED
|
@@ -1,32 +1,22 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.UPLOAD_TRPC_PATH = void 0;
|
|
4
3
|
exports.authorizeUploadRequest = authorizeUploadRequest;
|
|
5
|
-
const scope_access_js_1 = require("./trpc/scope-access.js");
|
|
6
|
-
exports.UPLOAD_TRPC_PATH = 'addons.installPackage';
|
|
7
4
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* Shared auth guard for the REST upload routes (`/api/addons/upload` and
|
|
6
|
+
* `/api/server-upload`).
|
|
7
|
+
*
|
|
8
|
+
* Every credential goes through the hub's one verifier. An upload installs
|
|
9
|
+
* code that runs on the hub, so it is ADMIN only: the principal's scopes must
|
|
10
|
+
* hold `'*'`. A grant of `create` on the `addons` capability is NOT enough —
|
|
11
|
+
* that reach is a capability permission, and an addon tarball or a server
|
|
12
|
+
* closure is arbitrary code execution (security review 2026-10-21).
|
|
11
13
|
*/
|
|
12
|
-
|
|
13
|
-
const capRegistry = addonRegistry.getCapabilityRegistry();
|
|
14
|
-
// Documented type boundary: the registry returns the provider untyped; the
|
|
15
|
-
// `user-management` cap's `validateScopedToken` surface is asserted here
|
|
16
|
-
// (same shape the tRPC middleware relies on).
|
|
17
|
-
const userMgmt = capRegistry.getSingleton('user-management');
|
|
18
|
-
if (!userMgmt)
|
|
19
|
-
return null;
|
|
20
|
-
return userMgmt.validateScopedToken({ token });
|
|
21
|
-
}
|
|
22
|
-
function isUploadAllowed(scoped) {
|
|
23
|
-
return (0, scope_access_js_1.checkScopeAccess)(scoped.scopes, exports.UPLOAD_TRPC_PATH).ok;
|
|
24
|
-
}
|
|
14
|
+
const trpc_context_js_1 = require("./trpc/trpc.context.js");
|
|
25
15
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* - 403 `Forbidden:
|
|
16
|
+
* The upload gate: the ONE verifier, then `'*'` (D892, D895). Answers:
|
|
17
|
+
* - 401 `Unauthorized` when no Authorization header is present, or the
|
|
18
|
+
* bearer authenticates nobody (revoked, expired, never issued),
|
|
19
|
+
* - 403 `Forbidden: admin only` for a principal that is not an admin.
|
|
30
20
|
*/
|
|
31
21
|
async function authorizeUploadRequest(args) {
|
|
32
22
|
const { authHeader, authService, addonRegistry } = args;
|
|
@@ -34,36 +24,11 @@ async function authorizeUploadRequest(args) {
|
|
|
34
24
|
return { ok: false, status: 401, error: 'Unauthorized' };
|
|
35
25
|
}
|
|
36
26
|
const token = authHeader.replace('Bearer ', '');
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const payload = authService.verifyToken(token);
|
|
41
|
-
if (payload.isAdmin) {
|
|
42
|
-
return { ok: true };
|
|
43
|
-
}
|
|
44
|
-
authReason = 'JWT is not admin';
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
// Not a JWT (or invalid signature) — fall through to scoped-token path.
|
|
48
|
-
}
|
|
49
|
-
try {
|
|
50
|
-
const record = await validateScopedTokenViaCap(addonRegistry, token);
|
|
51
|
-
if (!record) {
|
|
52
|
-
authReason = authReason ?? 'token not recognised';
|
|
53
|
-
}
|
|
54
|
-
else if (isUploadAllowed(record)) {
|
|
55
|
-
return { ok: true };
|
|
56
|
-
}
|
|
57
|
-
else {
|
|
58
|
-
authReason = `scoped token lacks create access on '${exports.UPLOAD_TRPC_PATH}'`;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
catch (err) {
|
|
62
|
-
authReason = `scoped token validation failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
27
|
+
const principal = await (0, trpc_context_js_1.hubCredentialVerifier)({ authService, addonRegistry }).verify(token);
|
|
28
|
+
if (principal === null) {
|
|
29
|
+
return { ok: false, status: 401, error: 'Unauthorized: token not recognised' };
|
|
63
30
|
}
|
|
64
|
-
|
|
65
|
-
ok: false,
|
|
66
|
-
|
|
67
|
-
error: `Forbidden: ${authReason ?? 'admin or upload-scoped token required'}`,
|
|
68
|
-
};
|
|
31
|
+
if (!principal.isAdmin)
|
|
32
|
+
return { ok: false, status: 403, error: 'Forbidden: admin only' };
|
|
33
|
+
return { ok: true };
|
|
69
34
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.authorizeAddonBearer = authorizeAddonBearer;
|
|
4
|
+
exports.addonRouteCaller = addonRouteCaller;
|
|
5
|
+
const types_1 = require("@camstack/types");
|
|
6
|
+
const principal_authorization_js_1 = require("../api/trpc/principal-authorization.js");
|
|
7
|
+
async function authorizeAddonBearer(input) {
|
|
8
|
+
const principal = await input.verifier.verify(input.bearer, input.facts ?? {});
|
|
9
|
+
if (principal === null) {
|
|
10
|
+
input.logger.warn('addon route: refused a bearer that authenticates nobody', {
|
|
11
|
+
meta: { addonId: input.target.addonId, method: input.target.method },
|
|
12
|
+
});
|
|
13
|
+
return { ok: false, status: 401, error: 'Invalid token' };
|
|
14
|
+
}
|
|
15
|
+
const verdict = (0, principal_authorization_js_1.authorizeAddonRoute)(principal.scopes, input.target);
|
|
16
|
+
if (!verdict.ok) {
|
|
17
|
+
input.logger.warn('addon route: refused a principal outside its scopes', {
|
|
18
|
+
meta: {
|
|
19
|
+
addonId: input.target.addonId,
|
|
20
|
+
method: input.target.method,
|
|
21
|
+
reason: verdict.reason,
|
|
22
|
+
credential: principal.auditTag,
|
|
23
|
+
credentialId: principal.credentialId,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
return { ok: false, status: verdict.status, error: verdict.error };
|
|
27
|
+
}
|
|
28
|
+
return { ok: true, principal, shareHeaders: verdict.shareHeaders };
|
|
29
|
+
}
|
|
30
|
+
function addonRouteCaller(principal) {
|
|
31
|
+
const scoped = principal.scopes;
|
|
32
|
+
return {
|
|
33
|
+
user: { id: principal.subjectId, username: principal.username, isAdmin: principal.isAdmin },
|
|
34
|
+
...(principal.isAdmin || principal.userDelegated
|
|
35
|
+
? {}
|
|
36
|
+
: {
|
|
37
|
+
scopedToken: {
|
|
38
|
+
id: principal.credentialId,
|
|
39
|
+
userId: principal.subjectId,
|
|
40
|
+
scopes: [...(0, types_1.capabilityScopesOf)(scoped)],
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -1,25 +1,27 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.SHARE_DEVICES_HEADER = exports.SHARE_KIND_HEADER = void 0;
|
|
4
|
-
exports.gateAddonShareToken = gateAddonShareToken;
|
|
5
4
|
exports.stripShareHeaders = stripShareHeaders;
|
|
5
|
+
/**
|
|
6
|
+
* The data-plane gate's answer to a share-link token.
|
|
7
|
+
*
|
|
8
|
+
* A share link is a bearer credential for ONE view of a FEW devices, not a
|
|
9
|
+
* session: the hub's tRPC layer already confines it to a fail-closed method
|
|
10
|
+
* list per scope kind (`share-view-access.ts`). The data planes are reached
|
|
11
|
+
* outside tRPC, and until this module the gate resolved session JWTs and
|
|
12
|
+
* scoped tokens only — so the events embed under a share link listed its events
|
|
13
|
+
* and got 401 on every thumbnail (`EVENTS-EMBED.md` §7).
|
|
14
|
+
*
|
|
15
|
+
* Two rules. The first lives in the one scope matcher
|
|
16
|
+
* (`authorizeAddonRoute`, D892): a plane serves a share principal only when it
|
|
17
|
+
* OPTED IN to that kind (`AddonDataPlaneServeOptions.share`) — a plane that
|
|
18
|
+
* never reads the scope would otherwise serve every device to a link scoped to
|
|
19
|
+
* two. The second is here: the scope's devices reach the plane in headers the
|
|
20
|
+
* HUB sets after validation, and whatever the client sent under those names is
|
|
21
|
+
* stripped first — the gate is the only writer.
|
|
22
|
+
*/
|
|
6
23
|
exports.SHARE_KIND_HEADER = 'x-camstack-share-kind';
|
|
7
24
|
exports.SHARE_DEVICES_HEADER = 'x-camstack-share-devices';
|
|
8
|
-
function gateAddonShareToken(input) {
|
|
9
|
-
if (input.record === null)
|
|
10
|
-
return { ok: false, status: 401, error: 'Invalid token' };
|
|
11
|
-
const kind = input.record.scope.kind;
|
|
12
|
-
if (!(input.endpoint.share ?? []).includes(kind)) {
|
|
13
|
-
return { ok: false, status: 403, error: 'Share token not accepted here' };
|
|
14
|
-
}
|
|
15
|
-
return {
|
|
16
|
-
ok: true,
|
|
17
|
-
headers: {
|
|
18
|
-
[exports.SHARE_KIND_HEADER]: kind,
|
|
19
|
-
[exports.SHARE_DEVICES_HEADER]: input.record.scope.deviceIds.join(','),
|
|
20
|
-
},
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
25
|
/** The forwarded header set without any share header the client supplied. */
|
|
24
26
|
function stripShareHeaders(headers) {
|
|
25
27
|
const out = {};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createCredentialVerifier = createCredentialVerifier;
|
|
4
|
+
const types_1 = require("@camstack/types");
|
|
5
|
+
const trpc_error_principal_js_1 = require("../api/trpc/trpc-error-principal.js");
|
|
6
|
+
const scope_escalation_js_1 = require("./scope-escalation.js");
|
|
7
|
+
function stringClaim(value) {
|
|
8
|
+
return typeof value === 'string' ? value : undefined;
|
|
9
|
+
}
|
|
10
|
+
/** What a non-admin person's session reaches on addon routes: every flavour, as before D892. */
|
|
11
|
+
const SESSION_ADDON_ROUTES = {
|
|
12
|
+
type: 'addon-routes',
|
|
13
|
+
access: [...types_1.ALL_METHOD_ACCESS],
|
|
14
|
+
};
|
|
15
|
+
// ── Live scopes for a non-admin session ────────────────────────────────
|
|
16
|
+
/**
|
|
17
|
+
* The JWT embeds scopes at login, so an admin's scope edit reached a live
|
|
18
|
+
* session only at the next login (2026-08-15). The store is re-read per user,
|
|
19
|
+
* memoized: a change propagates within the TTL and a busy session costs one
|
|
20
|
+
* store read per window. The JWT's copy is the fallback when the store is
|
|
21
|
+
* unreachable, so a hiccup never locks anybody out mid-request.
|
|
22
|
+
*/
|
|
23
|
+
const SCOPE_REFRESH_TTL_MS = 30_000;
|
|
24
|
+
const SCOPE_REFRESH_MISS_TTL_MS = 5_000;
|
|
25
|
+
const SCOPE_MEMO_MAX = 512;
|
|
26
|
+
/**
|
|
27
|
+
* The user list, read at most once per {@link USERS_MEMO_TTL_MS}: every token
|
|
28
|
+
* and account-link verify asks whether its subject still exists and is still
|
|
29
|
+
* an admin, and that must not cost a full store read per request. A deletion
|
|
30
|
+
* or a demotion therefore applies within the window.
|
|
31
|
+
*/
|
|
32
|
+
const USERS_MEMO_TTL_MS = 5_000;
|
|
33
|
+
function createCredentialVerifier(deps) {
|
|
34
|
+
const now = deps.now ?? Date.now;
|
|
35
|
+
const scopeMemo = deps.scopeMemo ?? new Map();
|
|
36
|
+
const usersMemo = deps.usersMemo ?? { entry: null };
|
|
37
|
+
const listUsersOnce = async () => {
|
|
38
|
+
const at = now();
|
|
39
|
+
const users = deps.users();
|
|
40
|
+
if (users === null || typeof users.listUsers !== 'function')
|
|
41
|
+
return null;
|
|
42
|
+
const cached = usersMemo.entry;
|
|
43
|
+
if (cached !== null && cached.source === users && at - cached.at < USERS_MEMO_TTL_MS) {
|
|
44
|
+
return cached.users;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const fresh = await users.listUsers();
|
|
48
|
+
usersMemo.entry = { at, source: users, users: fresh };
|
|
49
|
+
return fresh;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const liveScopes = async (userId) => {
|
|
56
|
+
const at = now();
|
|
57
|
+
const cached = scopeMemo.get(userId);
|
|
58
|
+
if (cached !== undefined &&
|
|
59
|
+
at - cached.at < (cached.scopes === null ? SCOPE_REFRESH_MISS_TTL_MS : SCOPE_REFRESH_TTL_MS)) {
|
|
60
|
+
return cached.scopes;
|
|
61
|
+
}
|
|
62
|
+
const fresh = (await listUsersOnce())?.find((u) => u.id === userId);
|
|
63
|
+
const scopes = fresh === undefined ? null : (0, types_1.normalizeTokenScopes)(fresh.scopes ?? []);
|
|
64
|
+
scopeMemo.set(userId, { at, scopes });
|
|
65
|
+
if (scopeMemo.size > SCOPE_MEMO_MAX) {
|
|
66
|
+
for (const [key, entry] of scopeMemo) {
|
|
67
|
+
if (at - entry.at > SCOPE_REFRESH_TTL_MS * 4)
|
|
68
|
+
scopeMemo.delete(key);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return scopes;
|
|
72
|
+
};
|
|
73
|
+
const subjectFacts = async (userId) => {
|
|
74
|
+
const all = await listUsersOnce();
|
|
75
|
+
if (all === null)
|
|
76
|
+
return null;
|
|
77
|
+
const user = all.find((u) => u.id === userId);
|
|
78
|
+
return user === undefined
|
|
79
|
+
? { exists: false, isAdmin: false }
|
|
80
|
+
: { exists: true, isAdmin: user.isAdmin };
|
|
81
|
+
};
|
|
82
|
+
const auditAdminUse = (principal, facts, name) => {
|
|
83
|
+
const internal = deps.internal();
|
|
84
|
+
if (internal === null)
|
|
85
|
+
return;
|
|
86
|
+
void internal.audit
|
|
87
|
+
.record({
|
|
88
|
+
action: 'admin-token-used',
|
|
89
|
+
userId: principal.subjectId,
|
|
90
|
+
...(facts.ip !== undefined ? { ip: facts.ip } : {}),
|
|
91
|
+
detail: {
|
|
92
|
+
tokenId: principal.credentialId,
|
|
93
|
+
name,
|
|
94
|
+
...(facts.path !== undefined ? { path: facts.path } : {}),
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
.catch(() => { });
|
|
98
|
+
};
|
|
99
|
+
// ── The unified token ────────────────────────────────────────────────
|
|
100
|
+
/** `row`'s scopes cut down to what its (non-admin) subject holds now; `null` when unreadable. */
|
|
101
|
+
const narrowedToSubject = async (row) => {
|
|
102
|
+
const live = await liveScopes(row.subjectId);
|
|
103
|
+
if (live === null)
|
|
104
|
+
return null;
|
|
105
|
+
return (0, scope_escalation_js_1.intersectScopes)(row.scopes, live, deps.deviceScopeLookup);
|
|
106
|
+
};
|
|
107
|
+
const unifiedToken = async (claims, facts) => {
|
|
108
|
+
const rows = deps.tokenRows();
|
|
109
|
+
if (rows === null)
|
|
110
|
+
return null;
|
|
111
|
+
// Revocation and expiry live on the row, looked up by `jti` every time.
|
|
112
|
+
const row = await rows.lookupLive(claims.jti).catch(() => null);
|
|
113
|
+
if (row === null || row.subjectId !== claims.sub)
|
|
114
|
+
return null;
|
|
115
|
+
// The subject must still exist; an admin token dies with its subject's admin bit.
|
|
116
|
+
const subject = await subjectFacts(row.subjectId);
|
|
117
|
+
if (subject === null || !subject.exists)
|
|
118
|
+
return null;
|
|
119
|
+
if ((0, types_1.scopesGrantAdmin)(row.scopes) && !subject.isAdmin)
|
|
120
|
+
return null;
|
|
121
|
+
// The ROW's scopes, never the claim's: the row is what the operator listed.
|
|
122
|
+
// A non-admin's token never outgrows its subject: an admin who narrows the
|
|
123
|
+
// user narrows every token the user minted, at the next verify.
|
|
124
|
+
const scopes = subject.isAdmin ? row.scopes : await narrowedToSubject(row);
|
|
125
|
+
if (scopes === null || scopes.length === 0)
|
|
126
|
+
return null;
|
|
127
|
+
const principal = {
|
|
128
|
+
subjectId: row.subjectId,
|
|
129
|
+
username: `token:${row.label}`,
|
|
130
|
+
scopes,
|
|
131
|
+
isAdmin: (0, types_1.scopesGrantAdmin)(scopes),
|
|
132
|
+
credentialId: row.id,
|
|
133
|
+
auditTag: 'token',
|
|
134
|
+
interactive: false,
|
|
135
|
+
userDelegated: false,
|
|
136
|
+
};
|
|
137
|
+
if (principal.isAdmin)
|
|
138
|
+
auditAdminUse(principal, facts, row.label);
|
|
139
|
+
return principal;
|
|
140
|
+
};
|
|
141
|
+
// ── Session and account-link JWTs ────────────────────────────────────
|
|
142
|
+
/** A minted token's claims, or `null` when `token` is not one (D895: its own audience). */
|
|
143
|
+
const credentialClaims = (token) => {
|
|
144
|
+
try {
|
|
145
|
+
return deps.verifyCredentialJwt(token);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const hubJwt = async (token, facts) => {
|
|
152
|
+
let payload;
|
|
153
|
+
try {
|
|
154
|
+
payload = deps.verifyJwt(token);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Not a session-family JWT. A minted token is refused by `verifyJwt` by
|
|
158
|
+
// construction (its audience), and verifies only through its own door.
|
|
159
|
+
const claims = credentialClaims(token);
|
|
160
|
+
return claims === null ? null : unifiedToken(claims, facts);
|
|
161
|
+
}
|
|
162
|
+
// A pre-v2 JWT (no `isAdmin`) would degrade into "non-admin, no scopes";
|
|
163
|
+
// refuse it so the client signs in again.
|
|
164
|
+
if (typeof payload.isAdmin !== 'boolean')
|
|
165
|
+
return null;
|
|
166
|
+
// Every token this hub mints verifies under one key, so a valid signature
|
|
167
|
+
// means "we minted this", never "this is an API credential" (D103). Only a
|
|
168
|
+
// session and an `oauth-access` link are credentials; a code, a refresh
|
|
169
|
+
// token, a bridge, a TOTP challenge are refused.
|
|
170
|
+
const classified = (0, types_1.classifyBearerPrincipal)(payload);
|
|
171
|
+
if (classified.kind === 'not-a-credential') {
|
|
172
|
+
deps.logger?.warn('auth: refused a bearer that is not an API credential', {
|
|
173
|
+
meta: { reason: classified.reason, userId: stringClaim(payload.userId) ?? 'unknown' },
|
|
174
|
+
});
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const userId = stringClaim(payload.userId) ?? 'unknown';
|
|
178
|
+
const username = stringClaim(payload.username) ?? 'unknown';
|
|
179
|
+
if (classified.kind === 'integration') {
|
|
180
|
+
// An account link is governed by the grant the operator consented to,
|
|
181
|
+
// never by the linking user's record (2026-08-19), and never admin. Its
|
|
182
|
+
// subject must still be a user: a link minted for nobody (an empty
|
|
183
|
+
// `userId`) or for a deleted account authenticates nobody.
|
|
184
|
+
const subject = userId === 'unknown' || userId === '' ? null : await subjectFacts(userId);
|
|
185
|
+
if (subject === null || !subject.exists)
|
|
186
|
+
return null;
|
|
187
|
+
const identity = (0, trpc_error_principal_js_1.readCredentialIdentity)(payload);
|
|
188
|
+
return {
|
|
189
|
+
subjectId: userId,
|
|
190
|
+
username,
|
|
191
|
+
scopes: (0, types_1.normalizeTokenScopes)(payload.scopes ?? []),
|
|
192
|
+
isAdmin: false,
|
|
193
|
+
credentialId: identity.sessionId ?? `oauth:${userId}`,
|
|
194
|
+
auditTag: 'integration',
|
|
195
|
+
interactive: false,
|
|
196
|
+
userDelegated: true,
|
|
197
|
+
...(identity.sessionId !== undefined ? { oauthSessionId: identity.sessionId } : {}),
|
|
198
|
+
...(identity.credential !== undefined ? { credential: identity.credential } : {}),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const sessionId = stringClaim(payload.sid);
|
|
202
|
+
const base = {
|
|
203
|
+
subjectId: userId,
|
|
204
|
+
username,
|
|
205
|
+
credentialId: sessionId ?? `session:${userId}`,
|
|
206
|
+
auditTag: 'session',
|
|
207
|
+
interactive: true,
|
|
208
|
+
userDelegated: true,
|
|
209
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
210
|
+
};
|
|
211
|
+
// A person's session always reaches its OWN account preferences (D894);
|
|
212
|
+
// `'*'` does not imply that grant, so the admin session names it too.
|
|
213
|
+
if (payload.isAdmin) {
|
|
214
|
+
return { ...base, scopes: [types_1.ADMIN_SCOPE, types_1.PREFERENCES_SELF_SCOPE], isAdmin: true };
|
|
215
|
+
}
|
|
216
|
+
const fresh = await liveScopes(userId);
|
|
217
|
+
const capability = fresh ?? (0, types_1.normalizeTokenScopes)(payload.scopes ?? []);
|
|
218
|
+
return {
|
|
219
|
+
...base,
|
|
220
|
+
scopes: [...capability, SESSION_ADDON_ROUTES, types_1.PREFERENCES_SELF_SCOPE],
|
|
221
|
+
isAdmin: false,
|
|
222
|
+
};
|
|
223
|
+
};
|
|
224
|
+
return {
|
|
225
|
+
async verify(bearer, facts = {}) {
|
|
226
|
+
if (bearer === null || bearer === undefined || bearer === '')
|
|
227
|
+
return null;
|
|
228
|
+
try {
|
|
229
|
+
return await hubJwt(bearer, facts);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|