@camstack/server 1.2.605 → 1.2.607
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 +0 -1
- package/dist/api/core/auth-audit.procedures.js +44 -0
- package/dist/api/core/auth-revoke-credentials.procedures.js +4 -78
- package/dist/api/core/auth-tokens.procedures.js +105 -72
- package/dist/api/core/auth.router.js +14 -280
- package/dist/api/core/cap-providers.js +38 -1
- package/dist/api/core/integration-secrets.js +22 -1
- package/dist/api/core/logs.router.js +219 -49
- 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 +53 -42
- package/dist/api/trpc/principal-visibility.js +2 -2
- package/dist/api/trpc/share-view-access.js +1 -1
- package/dist/api/trpc/trpc-error-principal.js +1 -2
- package/dist/api/trpc/trpc.context.js +16 -13
- package/dist/api/trpc/trpc.router.js +7 -2
- package/dist/api/upload-auth.js +13 -25
- package/dist/auth/addon-route-share-gate.js +2 -2
- package/dist/auth/credential-verifier.js +63 -104
- package/dist/auth/embed-auth-route.js +45 -0
- package/dist/auth/live-user.js +19 -0
- package/dist/auth/scope-escalation.js +77 -2
- package/dist/boot/boot-config.js +8 -1
- package/dist/core/addon/addon-registry.service.js +14 -20
- package/dist/core/auth/credential-token.service.js +9 -14
- package/dist/core/auth/handoff-code.service.js +1 -1
- package/dist/core/auth/record-kinds.js +7 -2
- package/dist/core/logging/log-history.js +119 -0
- package/dist/core/logging/logging.service.js +38 -1
- package/dist/main.js +18 -48
- package/package.json +14 -14
- package/dist/api/core/auth-admin-tokens.procedures.js +0 -156
- package/dist/core/auth/share-token.service.js +0 -459
|
@@ -10,7 +10,7 @@ exports.scopedEventVisibility = scopedEventVisibility;
|
|
|
10
10
|
* Non-admin principal device visibility — the shared answer to "which cameras
|
|
11
11
|
* may this scoped caller see?", reused by two hotfixes:
|
|
12
12
|
*
|
|
13
|
-
* - F0.4 `auth.
|
|
13
|
+
* - F0.4 `auth.mintToken` (a share-view grant) — a scoped user must not mint a share link
|
|
14
14
|
* share token for a device it cannot see. The requested deviceIds are
|
|
15
15
|
* INTERSECTED with what the caller can view.
|
|
16
16
|
* - F0.5 the event firehose (`live.onEvent`, `systemEvents.subscribe`, …) —
|
|
@@ -141,7 +141,7 @@ function projectListAllForPrincipal(data, isAdmin, scopes, fleet) {
|
|
|
141
141
|
* view; an event with NO device identity is DROPPED (fail closed — "probably in
|
|
142
142
|
* scope" is not a security argument).
|
|
143
143
|
*
|
|
144
|
-
* Share-view
|
|
144
|
+
* Share-view principals are NOT handled here — they keep their own,
|
|
145
145
|
* stricter `liveEventInShareScope` filter, applied by the routers first.
|
|
146
146
|
*/
|
|
147
147
|
function scopedEventVisibility(user, lookup) {
|
|
@@ -181,7 +181,7 @@ function projectListAllForShareScope(data, scope) {
|
|
|
181
181
|
* The projection ENGINE: drop every `listAll` row whose id is not in
|
|
182
182
|
* `allowed`, and project the survivors onto the whitelisted row.
|
|
183
183
|
*
|
|
184
|
-
* Shared by the
|
|
184
|
+
* Shared by the share-link path ({@link projectListAllForShareScope}) and
|
|
185
185
|
* the scoped-principal path (`projectListAllForPrincipal` in
|
|
186
186
|
* `principal-visibility.ts`) so enumeration is cut ONE way. Two projectors
|
|
187
187
|
* would be two chances to forget `config` — which carries the camera
|
|
@@ -34,9 +34,8 @@ function readCredentialIdentity(claims) {
|
|
|
34
34
|
*
|
|
35
35
|
* anonymous
|
|
36
36
|
* apocaliss92 (admin)
|
|
37
|
-
* share:ab12cd34 (legacy-share-token)
|
|
38
|
-
* scoped:1a2b3c4d (legacy-scoped-token)
|
|
39
37
|
* token:ci (token)
|
|
38
|
+
* token:ops (token admin)
|
|
40
39
|
* apocaliss92 (oauth-access session=910a6179-…)
|
|
41
40
|
* apocaliss92 (session)
|
|
42
41
|
*/
|
|
@@ -24,7 +24,6 @@ function agentFromPrincipal(principal) {
|
|
|
24
24
|
auditTag: principal.auditTag,
|
|
25
25
|
interactive: principal.interactive,
|
|
26
26
|
userDelegated: principal.userDelegated,
|
|
27
|
-
legacy: principal.legacy,
|
|
28
27
|
...(principal.sessionId !== undefined ? { sessionId: principal.sessionId } : {}),
|
|
29
28
|
...(principal.oauthSessionId !== undefined ? { oauthSessionId: principal.oauthSessionId } : {}),
|
|
30
29
|
...(principal.credential !== undefined ? { credential: principal.credential } : {}),
|
|
@@ -35,29 +34,31 @@ function agentFromPrincipal(principal) {
|
|
|
35
34
|
* process builds (a request builds one; the memo must outlive it).
|
|
36
35
|
*/
|
|
37
36
|
const hubScopeMemo = new Map();
|
|
38
|
-
|
|
37
|
+
/** The user-list memo, shared the same way (every token verify asks "does its subject exist?"). */
|
|
38
|
+
const hubUsersMemo = { entry: null };
|
|
39
|
+
const hubStores = { tokenRows: null };
|
|
39
40
|
function bindHubCredentialStores(stores) {
|
|
40
|
-
if (stores.shareTokens !== undefined)
|
|
41
|
-
hubStores.shareTokens = stores.shareTokens;
|
|
42
41
|
if (stores.tokenRows !== undefined)
|
|
43
42
|
hubStores.tokenRows = stores.tokenRows;
|
|
44
43
|
}
|
|
45
44
|
/**
|
|
46
45
|
* The hub's verifier over its own stores — the same one for tRPC and the
|
|
47
46
|
* addon routes. Documented type boundary: the registry hands the
|
|
48
|
-
* `user-management` provider back untyped; the
|
|
49
|
-
* (`
|
|
47
|
+
* `user-management` provider back untyped; the one method the verifier reads
|
|
48
|
+
* (`listUsers`) is asserted here.
|
|
50
49
|
*/
|
|
51
50
|
function hubCredentialVerifier(deps) {
|
|
52
51
|
const provider = () => deps.addonRegistry.getCapabilityRegistry().getSingleton('user-management');
|
|
53
52
|
return (0, credential_verifier_js_1.createCredentialVerifier)({
|
|
54
53
|
verifyJwt: (token) => deps.authService.verifyToken(token),
|
|
54
|
+
verifyCredentialJwt: (token) => deps.authService.verifyCredentialToken(token),
|
|
55
55
|
users: () => provider() ?? null,
|
|
56
56
|
internal: () => (0, system_1.getUserManagementInternal)(provider()),
|
|
57
|
-
shareTokens: () => deps.shareTokens ?? hubStores.shareTokens,
|
|
58
57
|
tokenRows: () => deps.tokenRows ?? hubStores.tokenRows,
|
|
59
58
|
logger: deps.logger ?? null,
|
|
60
59
|
scopeMemo: hubScopeMemo,
|
|
60
|
+
usersMemo: hubUsersMemo,
|
|
61
|
+
...(deps.deviceScopeLookup !== undefined ? { deviceScopeLookup: deps.deviceScopeLookup } : {}),
|
|
61
62
|
});
|
|
62
63
|
}
|
|
63
64
|
/** Read `req.query` if present (Fastify-only) without losing type safety. */
|
|
@@ -155,19 +156,20 @@ function createMeshTrpcContext(principal = 'mesh') {
|
|
|
155
156
|
return { user };
|
|
156
157
|
}
|
|
157
158
|
/** Context factory for HTTP tRPC requests (Fastify adapter). */
|
|
158
|
-
async function createTrpcContext(req, authService, addonRegistry,
|
|
159
|
+
async function createTrpcContext(req, authService, addonRegistry, logger = null, tokenRows = null) {
|
|
160
|
+
const deviceScopeLookup = makeDeviceScopeLookup(addonRegistry);
|
|
159
161
|
const verifier = hubCredentialVerifier({
|
|
160
162
|
authService,
|
|
161
163
|
addonRegistry,
|
|
162
|
-
shareTokens,
|
|
163
164
|
tokenRows,
|
|
164
165
|
logger,
|
|
166
|
+
deviceScopeLookup,
|
|
165
167
|
});
|
|
166
168
|
return {
|
|
167
169
|
user: await resolveUser(extractTokenFromRequest(req), verifier, requestFacts(req)),
|
|
168
170
|
req,
|
|
169
171
|
lang: (0, request_lang_js_1.resolveRequestLang)((0, request_lang_js_1.requestLangInputFrom)(req.headers, readQuery(req))),
|
|
170
|
-
deviceScopeLookup
|
|
172
|
+
deviceScopeLookup,
|
|
171
173
|
deviceFleet: () => addonRegistry.getPersistedDeviceList(),
|
|
172
174
|
};
|
|
173
175
|
}
|
|
@@ -176,16 +178,17 @@ async function createTrpcContext(req, authService, addonRegistry, shareTokens =
|
|
|
176
178
|
* Token is sent via tRPC connectionParams (a JSON message sent right after
|
|
177
179
|
* the WS handshake), which is more reliable than query params through proxies.
|
|
178
180
|
*/
|
|
179
|
-
async function createWsTrpcContext(opts, authService, addonRegistry,
|
|
181
|
+
async function createWsTrpcContext(opts, authService, addonRegistry, logger = null, tokenRows = null) {
|
|
180
182
|
// 1. connectionParams.token (sent by BackendClient's createWSClient)
|
|
181
183
|
const paramToken = opts.info.connectionParams?.['token'];
|
|
182
184
|
const token = (typeof paramToken === 'string' ? paramToken : null) ?? extractTokenFromRequest(opts.req);
|
|
185
|
+
const deviceScopeLookup = makeDeviceScopeLookup(addonRegistry);
|
|
183
186
|
const verifier = hubCredentialVerifier({
|
|
184
187
|
authService,
|
|
185
188
|
addonRegistry,
|
|
186
|
-
shareTokens,
|
|
187
189
|
tokenRows,
|
|
188
190
|
logger,
|
|
191
|
+
deviceScopeLookup,
|
|
189
192
|
});
|
|
190
193
|
const user = await resolveUser(token, verifier, requestFacts(opts.req));
|
|
191
194
|
const sessionId = user?.sessionId;
|
|
@@ -205,7 +208,7 @@ async function createWsTrpcContext(opts, authService, addonRegistry, shareTokens
|
|
|
205
208
|
}) === null,
|
|
206
209
|
}
|
|
207
210
|
: {}),
|
|
208
|
-
deviceScopeLookup
|
|
211
|
+
deviceScopeLookup,
|
|
209
212
|
deviceFleet: () => addonRegistry.getPersistedDeviceList(),
|
|
210
213
|
};
|
|
211
214
|
}
|
|
@@ -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");
|
|
@@ -330,7 +331,7 @@ function buildCapabilityRouters(services) {
|
|
|
330
331
|
repl: (0, repl_router_js_1.createReplRouter)(services.replEngine),
|
|
331
332
|
systemEvents: (0, system_events_router_js_1.createSystemEventsRouter)(services.eventBus),
|
|
332
333
|
capabilities: (0, capabilities_router_js_1.createCapabilitiesRouter)(services.capabilityRegistry, services.configService),
|
|
333
|
-
logs: (0, logs_router_js_1.createLogsRouter)(services.loggingService),
|
|
334
|
+
logs: (0, logs_router_js_1.createLogsRouter)(services.loggingService, services.capabilityRegistry),
|
|
334
335
|
live: (0, live_events_router_js_1.createLiveEventsRouter)(services.eventBus, services.addonRegistry),
|
|
335
336
|
// stream-probe — fixed core API (ffprobe wrapper), not a cap.
|
|
336
337
|
streamProbe: (0, stream_probe_router_js_1.createStreamProbeRouter)(services.streamProbe),
|
|
@@ -351,13 +352,17 @@ 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
|
: {}),
|
package/dist/api/upload-auth.js
CHANGED
|
@@ -1,33 +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
4
|
/**
|
|
6
5
|
* Shared auth guard for the REST upload routes (`/api/addons/upload` and
|
|
7
6
|
* `/api/server-upload`).
|
|
8
7
|
*
|
|
9
|
-
* Every credential
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* method (semantically equivalent — both install a tarball under the
|
|
15
|
-
* system-scope `addons` cap with `create` access). Reusing the shared scope
|
|
16
|
-
* matcher keeps the gate in sync with the tRPC middleware — no parallel
|
|
17
|
-
* REST-only ACL. (Why not `addons.upload`? There is no `upload` method on
|
|
18
|
-
* the cap definition, so `METHOD_ACCESS_MAP` has no row for it and
|
|
19
|
-
* `checkScopeAccess` would fall through to deny.)
|
|
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).
|
|
20
13
|
*/
|
|
21
14
|
const trpc_context_js_1 = require("./trpc/trpc.context.js");
|
|
22
|
-
const principal_authorization_js_1 = require("./trpc/principal-authorization.js");
|
|
23
|
-
exports.UPLOAD_TRPC_PATH = 'addons.installPackage';
|
|
24
15
|
/**
|
|
25
|
-
* The upload gate: the ONE verifier, then
|
|
26
|
-
* `
|
|
27
|
-
*
|
|
28
|
-
* -
|
|
29
|
-
* - 403 `Forbidden: <reason>` for a bearer that authenticates nobody or
|
|
30
|
-
* holds no such grant.
|
|
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.
|
|
31
20
|
*/
|
|
32
21
|
async function authorizeUploadRequest(args) {
|
|
33
22
|
const { authHeader, authService, addonRegistry } = args;
|
|
@@ -37,10 +26,9 @@ async function authorizeUploadRequest(args) {
|
|
|
37
26
|
const token = authHeader.replace('Bearer ', '');
|
|
38
27
|
const principal = await (0, trpc_context_js_1.hubCredentialVerifier)({ authService, addonRegistry }).verify(token);
|
|
39
28
|
if (principal === null) {
|
|
40
|
-
return { ok: false, status:
|
|
29
|
+
return { ok: false, status: 401, error: 'Unauthorized: token not recognised' };
|
|
41
30
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return { ok: false, status: 403, error: `Forbidden: ${verdict.reason}` };
|
|
31
|
+
if (!principal.isAdmin)
|
|
32
|
+
return { ok: false, status: 403, error: 'Forbidden: admin only' };
|
|
33
|
+
return { ok: true };
|
|
46
34
|
}
|
|
@@ -3,13 +3,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.SHARE_DEVICES_HEADER = exports.SHARE_KIND_HEADER = void 0;
|
|
4
4
|
exports.stripShareHeaders = stripShareHeaders;
|
|
5
5
|
/**
|
|
6
|
-
* The data-plane gate's answer to a
|
|
6
|
+
* The data-plane gate's answer to a share-link token.
|
|
7
7
|
*
|
|
8
8
|
* A share link is a bearer credential for ONE view of a FEW devices, not a
|
|
9
9
|
* session: the hub's tRPC layer already confines it to a fail-closed method
|
|
10
10
|
* list per scope kind (`share-view-access.ts`). The data planes are reached
|
|
11
11
|
* outside tRPC, and until this module the gate resolved session JWTs and
|
|
12
|
-
*
|
|
12
|
+
* scoped tokens only — so the events embed under a share link listed its events
|
|
13
13
|
* and got 401 on every thumbnail (`EVENTS-EMBED.md` §7).
|
|
14
14
|
*
|
|
15
15
|
* Two rules. The first lives in the one scope matcher
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.LEGACY_SHARE_TOKEN_PREFIX = exports.LEGACY_SCOPED_TOKEN_PREFIX = exports.LEGACY_ADMIN_TOKEN_PREFIX = void 0;
|
|
4
3
|
exports.createCredentialVerifier = createCredentialVerifier;
|
|
5
4
|
const types_1 = require("@camstack/types");
|
|
6
5
|
const trpc_error_principal_js_1 = require("../api/trpc/trpc-error-principal.js");
|
|
7
|
-
|
|
8
|
-
exports.LEGACY_ADMIN_TOKEN_PREFIX = 'csa_';
|
|
9
|
-
exports.LEGACY_SCOPED_TOKEN_PREFIX = 'cst_';
|
|
10
|
-
exports.LEGACY_SHARE_TOKEN_PREFIX = 'csv_';
|
|
6
|
+
const scope_escalation_js_1 = require("./scope-escalation.js");
|
|
11
7
|
function stringClaim(value) {
|
|
12
8
|
return typeof value === 'string' ? value : undefined;
|
|
13
9
|
}
|
|
@@ -27,15 +23,30 @@ const SESSION_ADDON_ROUTES = {
|
|
|
27
23
|
const SCOPE_REFRESH_TTL_MS = 30_000;
|
|
28
24
|
const SCOPE_REFRESH_MISS_TTL_MS = 5_000;
|
|
29
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;
|
|
30
33
|
function createCredentialVerifier(deps) {
|
|
31
34
|
const now = deps.now ?? Date.now;
|
|
32
35
|
const scopeMemo = deps.scopeMemo ?? new Map();
|
|
36
|
+
const usersMemo = deps.usersMemo ?? { entry: null };
|
|
33
37
|
const listUsersOnce = async () => {
|
|
38
|
+
const at = now();
|
|
34
39
|
const users = deps.users();
|
|
35
40
|
if (users === null || typeof users.listUsers !== 'function')
|
|
36
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
|
+
}
|
|
37
46
|
try {
|
|
38
|
-
|
|
47
|
+
const fresh = await users.listUsers();
|
|
48
|
+
usersMemo.entry = { at, source: users, users: fresh };
|
|
49
|
+
return fresh;
|
|
39
50
|
}
|
|
40
51
|
catch {
|
|
41
52
|
return null;
|
|
@@ -80,96 +91,26 @@ function createCredentialVerifier(deps) {
|
|
|
80
91
|
detail: {
|
|
81
92
|
tokenId: principal.credentialId,
|
|
82
93
|
name,
|
|
83
|
-
format: principal.legacy ? 'legacy' : 'token',
|
|
84
94
|
...(facts.path !== undefined ? { path: facts.path } : {}),
|
|
85
95
|
},
|
|
86
96
|
})
|
|
87
97
|
.catch(() => { });
|
|
88
98
|
};
|
|
89
|
-
// ── Legacy adapters ──────────────────────────────────────────────────
|
|
90
|
-
const legacyAdminToken = async (token, facts) => {
|
|
91
|
-
const internal = deps.internal();
|
|
92
|
-
if (internal === null)
|
|
93
|
-
return null;
|
|
94
|
-
// Validation (creator still exists and is still an admin, not revoked, not
|
|
95
|
-
// expired) is local-auth's.
|
|
96
|
-
const record = await internal.adminTokens.validate(token).catch(() => null);
|
|
97
|
-
if (record === null)
|
|
98
|
-
return null;
|
|
99
|
-
const principal = {
|
|
100
|
-
subjectId: record.createdBy,
|
|
101
|
-
username: `admin-token:${record.name}`,
|
|
102
|
-
scopes: [types_1.ADMIN_SCOPE],
|
|
103
|
-
isAdmin: true,
|
|
104
|
-
credentialId: record.id,
|
|
105
|
-
auditTag: 'legacy-admin-token',
|
|
106
|
-
interactive: false,
|
|
107
|
-
userDelegated: false,
|
|
108
|
-
legacy: true,
|
|
109
|
-
};
|
|
110
|
-
auditAdminUse(principal, facts, record.name);
|
|
111
|
-
return principal;
|
|
112
|
-
};
|
|
113
|
-
const legacyScopedToken = async (token) => {
|
|
114
|
-
const users = deps.users();
|
|
115
|
-
if (users === null)
|
|
116
|
-
return null;
|
|
117
|
-
const record = await users.validateScopedToken({ token }).catch(() => null);
|
|
118
|
-
if (record === null)
|
|
119
|
-
return null;
|
|
120
|
-
return {
|
|
121
|
-
subjectId: record.userId,
|
|
122
|
-
username: `scoped:${record.tokenPrefix}`,
|
|
123
|
-
// A v2 `device:targets` grant migrates to a v3 selector here, so the
|
|
124
|
-
// matcher only ever sees v3.
|
|
125
|
-
scopes: (0, types_1.normalizeTokenScopes)(record.scopes),
|
|
126
|
-
isAdmin: false,
|
|
127
|
-
credentialId: record.id,
|
|
128
|
-
auditTag: 'legacy-scoped-token',
|
|
129
|
-
interactive: false,
|
|
130
|
-
userDelegated: false,
|
|
131
|
-
legacy: true,
|
|
132
|
-
};
|
|
133
|
-
};
|
|
134
|
-
const legacyShareToken = async (token) => {
|
|
135
|
-
const shares = deps.shareTokens();
|
|
136
|
-
if (shares === null)
|
|
137
|
-
return null;
|
|
138
|
-
const record = await shares.validate(token).catch(() => null);
|
|
139
|
-
if (record === null)
|
|
140
|
-
return null;
|
|
141
|
-
return {
|
|
142
|
-
subjectId: record.userId,
|
|
143
|
-
username: `share:${record.tokenPrefix}`,
|
|
144
|
-
scopes: [
|
|
145
|
-
{
|
|
146
|
-
type: 'share-view',
|
|
147
|
-
grant: {
|
|
148
|
-
kind: record.scope.kind,
|
|
149
|
-
deviceIds: [...record.scope.deviceIds],
|
|
150
|
-
...(record.scope.talk !== undefined ? { talk: record.scope.talk } : {}),
|
|
151
|
-
},
|
|
152
|
-
},
|
|
153
|
-
],
|
|
154
|
-
isAdmin: false,
|
|
155
|
-
credentialId: record.id,
|
|
156
|
-
auditTag: 'legacy-share-token',
|
|
157
|
-
interactive: false,
|
|
158
|
-
userDelegated: false,
|
|
159
|
-
legacy: true,
|
|
160
|
-
};
|
|
161
|
-
};
|
|
162
99
|
// ── The unified token ────────────────────────────────────────────────
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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)
|
|
166
104
|
return null;
|
|
105
|
+
return (0, scope_escalation_js_1.intersectScopes)(row.scopes, live, deps.deviceScopeLookup);
|
|
106
|
+
};
|
|
107
|
+
const unifiedToken = async (claims, facts) => {
|
|
167
108
|
const rows = deps.tokenRows();
|
|
168
109
|
if (rows === null)
|
|
169
110
|
return null;
|
|
170
111
|
// Revocation and expiry live on the row, looked up by `jti` every time.
|
|
171
|
-
const row = await rows.lookupLive(claims.
|
|
172
|
-
if (row === null || row.subjectId !== claims.
|
|
112
|
+
const row = await rows.lookupLive(claims.jti).catch(() => null);
|
|
113
|
+
if (row === null || row.subjectId !== claims.sub)
|
|
173
114
|
return null;
|
|
174
115
|
// The subject must still exist; an admin token dies with its subject's admin bit.
|
|
175
116
|
const subject = await subjectFacts(row.subjectId);
|
|
@@ -177,33 +118,47 @@ function createCredentialVerifier(deps) {
|
|
|
177
118
|
return null;
|
|
178
119
|
if ((0, types_1.scopesGrantAdmin)(row.scopes) && !subject.isAdmin)
|
|
179
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;
|
|
180
127
|
const principal = {
|
|
181
128
|
subjectId: row.subjectId,
|
|
182
129
|
username: `token:${row.label}`,
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
isAdmin: (0, types_1.scopesGrantAdmin)(row.scopes),
|
|
130
|
+
scopes,
|
|
131
|
+
isAdmin: (0, types_1.scopesGrantAdmin)(scopes),
|
|
186
132
|
credentialId: row.id,
|
|
187
133
|
auditTag: 'token',
|
|
188
134
|
interactive: false,
|
|
189
135
|
userDelegated: false,
|
|
190
|
-
legacy: false,
|
|
191
136
|
};
|
|
192
137
|
if (principal.isAdmin)
|
|
193
138
|
auditAdminUse(principal, facts, row.label);
|
|
194
139
|
return principal;
|
|
195
140
|
};
|
|
196
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
|
+
};
|
|
197
151
|
const hubJwt = async (token, facts) => {
|
|
198
152
|
let payload;
|
|
199
153
|
try {
|
|
200
154
|
payload = deps.verifyJwt(token);
|
|
201
155
|
}
|
|
202
156
|
catch {
|
|
203
|
-
|
|
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);
|
|
204
161
|
}
|
|
205
|
-
if (payload.kind === types_1.CREDENTIAL_TOKEN_KIND)
|
|
206
|
-
return unifiedToken(payload, facts);
|
|
207
162
|
// A pre-v2 JWT (no `isAdmin`) would degrade into "non-admin, no scopes";
|
|
208
163
|
// refuse it so the client signs in again.
|
|
209
164
|
if (typeof payload.isAdmin !== 'boolean')
|
|
@@ -223,7 +178,12 @@ function createCredentialVerifier(deps) {
|
|
|
223
178
|
const username = stringClaim(payload.username) ?? 'unknown';
|
|
224
179
|
if (classified.kind === 'integration') {
|
|
225
180
|
// An account link is governed by the grant the operator consented to,
|
|
226
|
-
// never by the linking user's record (2026-08-19), and never admin.
|
|
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;
|
|
227
187
|
const identity = (0, trpc_error_principal_js_1.readCredentialIdentity)(payload);
|
|
228
188
|
return {
|
|
229
189
|
subjectId: userId,
|
|
@@ -234,7 +194,6 @@ function createCredentialVerifier(deps) {
|
|
|
234
194
|
auditTag: 'integration',
|
|
235
195
|
interactive: false,
|
|
236
196
|
userDelegated: true,
|
|
237
|
-
legacy: false,
|
|
238
197
|
...(identity.sessionId !== undefined ? { oauthSessionId: identity.sessionId } : {}),
|
|
239
198
|
...(identity.credential !== undefined ? { credential: identity.credential } : {}),
|
|
240
199
|
};
|
|
@@ -247,26 +206,26 @@ function createCredentialVerifier(deps) {
|
|
|
247
206
|
auditTag: 'session',
|
|
248
207
|
interactive: true,
|
|
249
208
|
userDelegated: true,
|
|
250
|
-
legacy: false,
|
|
251
209
|
...(sessionId !== undefined ? { sessionId } : {}),
|
|
252
210
|
};
|
|
253
|
-
|
|
254
|
-
|
|
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
|
+
}
|
|
255
216
|
const fresh = await liveScopes(userId);
|
|
256
217
|
const capability = fresh ?? (0, types_1.normalizeTokenScopes)(payload.scopes ?? []);
|
|
257
|
-
return {
|
|
218
|
+
return {
|
|
219
|
+
...base,
|
|
220
|
+
scopes: [...capability, SESSION_ADDON_ROUTES, types_1.PREFERENCES_SELF_SCOPE],
|
|
221
|
+
isAdmin: false,
|
|
222
|
+
};
|
|
258
223
|
};
|
|
259
224
|
return {
|
|
260
225
|
async verify(bearer, facts = {}) {
|
|
261
226
|
if (bearer === null || bearer === undefined || bearer === '')
|
|
262
227
|
return null;
|
|
263
228
|
try {
|
|
264
|
-
if (bearer.startsWith(exports.LEGACY_ADMIN_TOKEN_PREFIX))
|
|
265
|
-
return await legacyAdminToken(bearer, facts);
|
|
266
|
-
if (bearer.startsWith(exports.LEGACY_SHARE_TOKEN_PREFIX))
|
|
267
|
-
return await legacyShareToken(bearer);
|
|
268
|
-
if (bearer.startsWith(exports.LEGACY_SCOPED_TOKEN_PREFIX))
|
|
269
|
-
return await legacyScopedToken(bearer);
|
|
270
229
|
return await hubJwt(bearer, facts);
|
|
271
230
|
}
|
|
272
231
|
catch {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerEmbedAuthRoute = registerEmbedAuthRoute;
|
|
4
|
+
const spa_mount_js_1 = require("../api/static/spa-mount.js");
|
|
5
|
+
const session_cookie_js_1 = require("./session-cookie.js");
|
|
6
|
+
/** Seconds the cookie may live for `payload`, or `null` when it is not a live person's session. */
|
|
7
|
+
async function sessionCookieTtl(deps, token) {
|
|
8
|
+
let payload;
|
|
9
|
+
try {
|
|
10
|
+
payload = deps.verifyToken(token);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
if (typeof payload !== 'object' || payload === null || !(0, session_cookie_js_1.isSessionGradeJwtPayload)(payload)) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const userId = Reflect.get(payload, 'userId');
|
|
19
|
+
if (typeof userId !== 'string' || !(await deps.isLiveUser(userId)))
|
|
20
|
+
return null;
|
|
21
|
+
const exp = Reflect.get(payload, 'exp');
|
|
22
|
+
const expSec = typeof exp === 'number' ? exp : 0;
|
|
23
|
+
return Math.max(0, expSec - Math.floor(Date.now() / 1000));
|
|
24
|
+
}
|
|
25
|
+
function registerEmbedAuthRoute(fastify, deps) {
|
|
26
|
+
fastify.get('/api/embed-auth', async (request, reply) => {
|
|
27
|
+
const authHeader = request.headers.authorization;
|
|
28
|
+
const token = authHeader?.startsWith('Bearer ')
|
|
29
|
+
? authHeader.slice('Bearer '.length)
|
|
30
|
+
: undefined;
|
|
31
|
+
if (!token)
|
|
32
|
+
return reply.status(401).send({ error: 'Bearer token required' });
|
|
33
|
+
const next = request.query?.next ?? '';
|
|
34
|
+
if (!(0, session_cookie_js_1.isEmbedRedirectTarget)(next))
|
|
35
|
+
return reply.status(400).send({ error: 'invalid next' });
|
|
36
|
+
const ttlSec = await sessionCookieTtl(deps, token);
|
|
37
|
+
if (ttlSec === null)
|
|
38
|
+
return reply.status(401).send({ error: 'invalid token' });
|
|
39
|
+
const c = (0, session_cookie_js_1.buildSessionCookie)(token, ttlSec, {
|
|
40
|
+
secure: (0, session_cookie_js_1.sessionCookieIsSecure)(request.protocol, request.headers, spa_mount_js_1.FORWARDED_PREFIX_HEADER),
|
|
41
|
+
});
|
|
42
|
+
reply.setCookie(c.name, c.value, c.options);
|
|
43
|
+
return reply.redirect(next);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.liveUserCheck = liveUserCheck;
|
|
4
|
+
/** A check bound to `registry`'s `user-management` provider. Unreadable ⇒ `false` (fail closed). */
|
|
5
|
+
function liveUserCheck(registry) {
|
|
6
|
+
return async (userId) => {
|
|
7
|
+
if (userId === '')
|
|
8
|
+
return false;
|
|
9
|
+
const users = registry.getSingleton('user-management');
|
|
10
|
+
if (users === null || users === undefined)
|
|
11
|
+
return false;
|
|
12
|
+
try {
|
|
13
|
+
return (await users.listUsers()).some((user) => user.id === userId);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|