@camstack/server 1.2.605 → 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 +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/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-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 +6 -1
- 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/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
|
@@ -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");
|
|
@@ -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
|
+
}
|
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.scopesWithin = scopesWithin;
|
|
4
|
+
exports.intersectScopes = intersectScopes;
|
|
4
5
|
const types_1 = require("@camstack/types");
|
|
5
6
|
const principal_visibility_js_1 = require("../api/trpc/principal-visibility.js");
|
|
7
|
+
const scope_access_js_1 = require("../api/trpc/scope-access.js");
|
|
8
|
+
/**
|
|
9
|
+
* The method a share link's talk-back opens first. Holding it on a device is
|
|
10
|
+
* what lets a caller hand talk-back on that device to whoever holds the link.
|
|
11
|
+
*/
|
|
12
|
+
const TALK_BACK_PATH = 'intercom.startSession';
|
|
13
|
+
/** May the holder of `held` talk back on every device of `grant`? */
|
|
14
|
+
function canTalkOn(held, deviceIds, lookup) {
|
|
15
|
+
return deviceIds.every((deviceId) => (0, scope_access_js_1.checkScopeAccess)(held, TALK_BACK_PATH, { deviceId }, lookup).ok);
|
|
16
|
+
}
|
|
6
17
|
function accessCovered(asked, held) {
|
|
7
18
|
return asked.every((a) => held.includes(a));
|
|
8
19
|
}
|
|
@@ -24,6 +35,8 @@ function describe(scope) {
|
|
|
24
35
|
return `share-view:${scope.grant.kind}`;
|
|
25
36
|
if (scope.type === 'addon-routes')
|
|
26
37
|
return `addon-routes[${scope.access.join(',')}]`;
|
|
38
|
+
if (scope.type === 'preferences-self')
|
|
39
|
+
return 'preferences-self';
|
|
27
40
|
if (scope.type === 'device')
|
|
28
41
|
return `device[${scope.access.join(',')}]`;
|
|
29
42
|
return `${scope.type}:${scope.target}[${scope.access.join(',')}]`;
|
|
@@ -41,14 +54,23 @@ function scopesWithin(requested, held, lookup) {
|
|
|
41
54
|
if (viewable.length !== scope.grant.deviceIds.length) {
|
|
42
55
|
return { ok: false, reason: 'a share-view grant names a device you cannot view' };
|
|
43
56
|
}
|
|
57
|
+
if (scope.grant.talk === true && !canTalkOn(heldCapability, scope.grant.deviceIds, lookup)) {
|
|
58
|
+
return { ok: false, reason: 'a share-view grant asks talk-back on a device you cannot talk on' };
|
|
59
|
+
}
|
|
44
60
|
continue;
|
|
45
61
|
}
|
|
46
|
-
if (scope.type === '
|
|
47
|
-
const covered = held.some((h) => h !== types_1.ADMIN_SCOPE && h.type === '
|
|
62
|
+
if (scope.type === 'preferences-self') {
|
|
63
|
+
const covered = held.some((h) => h !== types_1.ADMIN_SCOPE && h.type === 'preferences-self');
|
|
48
64
|
if (!covered)
|
|
49
65
|
return { ok: false, reason: `you do not hold ${describe(scope)}` };
|
|
50
66
|
continue;
|
|
51
67
|
}
|
|
68
|
+
if (scope.type === 'addon-routes') {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
reason: `only an admin can mint ${describe(scope)} — name the addon instead (addon:<id>)`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
52
74
|
const key = grantKey(scope);
|
|
53
75
|
const covered = heldCapability.some((h) => grantKey(h) === key && accessCovered(scope.access, h.access));
|
|
54
76
|
if (!covered)
|
|
@@ -56,3 +78,56 @@ function scopesWithin(requested, held, lookup) {
|
|
|
56
78
|
}
|
|
57
79
|
return { ok: true };
|
|
58
80
|
}
|
|
81
|
+
/** A share-view grant cut down to what `held` may still see (and talk on). `null` = nothing left. */
|
|
82
|
+
function narrowShareGrant(grant, held, lookup) {
|
|
83
|
+
const deviceIds = (0, principal_visibility_js_1.filterViewableDeviceIds)(held, grant.deviceIds, lookup);
|
|
84
|
+
if (deviceIds.length === 0)
|
|
85
|
+
return null;
|
|
86
|
+
const talk = grant.talk === true && canTalkOn(held, deviceIds, lookup);
|
|
87
|
+
return {
|
|
88
|
+
kind: grant.kind,
|
|
89
|
+
deviceIds,
|
|
90
|
+
...(grant.talk !== undefined ? { talk } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* `token`'s scopes cut down to what `held` — its non-admin subject's scopes
|
|
95
|
+
* NOW — still covers (security review 2026-10-21): an admin who narrows a user
|
|
96
|
+
* narrows every token that user minted, at the next verify, rather than
|
|
97
|
+
* leaving the token the reach the user no longer has.
|
|
98
|
+
*
|
|
99
|
+
* - a capability grant keeps the flavours the subject still holds on the
|
|
100
|
+
* SAME grant, and goes when none is left;
|
|
101
|
+
* - a share-view grant keeps the devices the subject can still view (and
|
|
102
|
+
* talk-back only where it can still talk);
|
|
103
|
+
* - `'*'` and `addon-routes` are admin-only and never survive here.
|
|
104
|
+
*
|
|
105
|
+
* Never widens: every output scope is covered by an input scope.
|
|
106
|
+
*/
|
|
107
|
+
function intersectScopes(token, held, lookup) {
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const scope of token) {
|
|
110
|
+
if (scope === types_1.ADMIN_SCOPE || scope.type === 'addon-routes')
|
|
111
|
+
continue;
|
|
112
|
+
if (scope.type === 'share-view') {
|
|
113
|
+
const grant = narrowShareGrant(scope.grant, held, lookup);
|
|
114
|
+
if (grant !== null)
|
|
115
|
+
out.push({ type: 'share-view', grant });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
// The subject's OWN preferences (D894): every person holds it, so a live
|
|
119
|
+
// subject never loses it — except on a share link. A share-view grant cut
|
|
120
|
+
// to nothing must not leave a preferences token behind.
|
|
121
|
+
if (scope.type === 'preferences-self') {
|
|
122
|
+
if (!token.some((s) => s !== types_1.ADMIN_SCOPE && s.type === 'share-view'))
|
|
123
|
+
out.push(scope);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const key = grantKey(scope);
|
|
127
|
+
const heldAccess = new Set(held.filter((h) => grantKey(h) === key).flatMap((h) => [...h.access]));
|
|
128
|
+
const access = scope.access.filter((a) => heldAccess.has(a));
|
|
129
|
+
if (access.length > 0)
|
|
130
|
+
out.push({ ...scope, access });
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|