@camstack/server 1.2.604 → 1.2.605
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/auth-whoami.js +28 -43
- package/dist/api/core/auth-admin-tokens.procedures.js +42 -24
- package/dist/api/core/auth-device-cert.procedures.js +1 -1
- package/dist/api/core/auth-reauth.procedures.js +1 -1
- package/dist/api/core/auth-revoke-credentials.procedures.js +54 -7
- package/dist/api/core/auth-sessions.procedures.js +1 -1
- package/dist/api/core/auth-tokens.procedures.js +250 -0
- package/dist/api/core/auth.router.js +95 -12
- package/dist/api/core/live-events.router.js +1 -1
- package/dist/api/trpc/principal-authorization.js +82 -0
- package/dist/api/trpc/principal-visibility.js +1 -1
- package/dist/api/trpc/trpc-error-principal.js +10 -9
- package/dist/api/trpc/trpc.context.js +82 -256
- package/dist/api/trpc/trpc.middleware.js +42 -83
- package/dist/api/trpc/trpc.router.js +1 -0
- package/dist/api/upload-auth.js +29 -52
- package/dist/auth/addon-route-auth.js +44 -0
- package/dist/auth/addon-route-share-gate.js +18 -16
- package/dist/auth/credential-verifier.js +277 -0
- package/dist/auth/scope-escalation.js +58 -0
- package/dist/core/addon/addon-registry.service.js +17 -0
- package/dist/core/auth/credential-token.service.js +250 -0
- package/dist/core/auth/record-kinds.js +31 -0
- package/dist/main.js +51 -97
- package/package.json +14 -14
- package/dist/auth/addon-route-jwt-gate.js +0 -59
package/dist/api/upload-auth.js
CHANGED
|
@@ -2,31 +2,32 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.UPLOAD_TRPC_PATH = void 0;
|
|
4
4
|
exports.authorizeUploadRequest = authorizeUploadRequest;
|
|
5
|
-
const scope_access_js_1 = require("./trpc/scope-access.js");
|
|
6
|
-
exports.UPLOAD_TRPC_PATH = 'addons.installPackage';
|
|
7
5
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* Shared auth guard for the REST upload routes (`/api/addons/upload` and
|
|
7
|
+
* `/api/server-upload`).
|
|
8
|
+
*
|
|
9
|
+
* Every credential (a session, a unified token, a legacy `csa_` / `cst_`)
|
|
10
|
+
* goes through the hub's one verifier; what may upload is decided by its
|
|
11
|
+
* scopes alone.
|
|
12
|
+
*
|
|
13
|
+
* REST endpoints share the scope-gate of the `addons.installPackage` cap
|
|
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.)
|
|
11
20
|
*/
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
}
|
|
21
|
+
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';
|
|
25
24
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* The upload gate: the ONE verifier, then the ONE scope matcher on the
|
|
26
|
+
* `addons.installPackage` path (D892) — `'*'`, or a grant of `create` on
|
|
27
|
+
* `addons`. Answers:
|
|
28
28
|
* - 401 `Unauthorized` when no Authorization header is present,
|
|
29
|
-
* - 403 `Forbidden: <reason>` for
|
|
29
|
+
* - 403 `Forbidden: <reason>` for a bearer that authenticates nobody or
|
|
30
|
+
* holds no such grant.
|
|
30
31
|
*/
|
|
31
32
|
async function authorizeUploadRequest(args) {
|
|
32
33
|
const { authHeader, authService, addonRegistry } = args;
|
|
@@ -34,36 +35,12 @@ async function authorizeUploadRequest(args) {
|
|
|
34
35
|
return { ok: false, status: 401, error: 'Unauthorized' };
|
|
35
36
|
}
|
|
36
37
|
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)}`;
|
|
38
|
+
const principal = await (0, trpc_context_js_1.hubCredentialVerifier)({ authService, addonRegistry }).verify(token);
|
|
39
|
+
if (principal === null) {
|
|
40
|
+
return { ok: false, status: 403, error: 'Forbidden: token not recognised' };
|
|
63
41
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
};
|
|
42
|
+
const verdict = (0, principal_authorization_js_1.authorizeProcedure)(principal.scopes, exports.UPLOAD_TRPC_PATH, undefined);
|
|
43
|
+
if (verdict.ok)
|
|
44
|
+
return { ok: true };
|
|
45
|
+
return { ok: false, status: 403, error: `Forbidden: ${verdict.reason}` };
|
|
69
46
|
}
|
|
@@ -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 `csv_*` share 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
|
+
* `cst_*` 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,277 @@
|
|
|
1
|
+
"use strict";
|
|
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
|
+
exports.createCredentialVerifier = createCredentialVerifier;
|
|
5
|
+
const types_1 = require("@camstack/types");
|
|
6
|
+
const trpc_error_principal_js_1 = require("../api/trpc/trpc-error-principal.js");
|
|
7
|
+
/** Legacy wire prefixes — only to choose an adapter. */
|
|
8
|
+
exports.LEGACY_ADMIN_TOKEN_PREFIX = 'csa_';
|
|
9
|
+
exports.LEGACY_SCOPED_TOKEN_PREFIX = 'cst_';
|
|
10
|
+
exports.LEGACY_SHARE_TOKEN_PREFIX = 'csv_';
|
|
11
|
+
function stringClaim(value) {
|
|
12
|
+
return typeof value === 'string' ? value : undefined;
|
|
13
|
+
}
|
|
14
|
+
/** What a non-admin person's session reaches on addon routes: every flavour, as before D892. */
|
|
15
|
+
const SESSION_ADDON_ROUTES = {
|
|
16
|
+
type: 'addon-routes',
|
|
17
|
+
access: [...types_1.ALL_METHOD_ACCESS],
|
|
18
|
+
};
|
|
19
|
+
// ── Live scopes for a non-admin session ────────────────────────────────
|
|
20
|
+
/**
|
|
21
|
+
* The JWT embeds scopes at login, so an admin's scope edit reached a live
|
|
22
|
+
* session only at the next login (2026-08-15). The store is re-read per user,
|
|
23
|
+
* memoized: a change propagates within the TTL and a busy session costs one
|
|
24
|
+
* store read per window. The JWT's copy is the fallback when the store is
|
|
25
|
+
* unreachable, so a hiccup never locks anybody out mid-request.
|
|
26
|
+
*/
|
|
27
|
+
const SCOPE_REFRESH_TTL_MS = 30_000;
|
|
28
|
+
const SCOPE_REFRESH_MISS_TTL_MS = 5_000;
|
|
29
|
+
const SCOPE_MEMO_MAX = 512;
|
|
30
|
+
function createCredentialVerifier(deps) {
|
|
31
|
+
const now = deps.now ?? Date.now;
|
|
32
|
+
const scopeMemo = deps.scopeMemo ?? new Map();
|
|
33
|
+
const listUsersOnce = async () => {
|
|
34
|
+
const users = deps.users();
|
|
35
|
+
if (users === null || typeof users.listUsers !== 'function')
|
|
36
|
+
return null;
|
|
37
|
+
try {
|
|
38
|
+
return await users.listUsers();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const liveScopes = async (userId) => {
|
|
45
|
+
const at = now();
|
|
46
|
+
const cached = scopeMemo.get(userId);
|
|
47
|
+
if (cached !== undefined &&
|
|
48
|
+
at - cached.at < (cached.scopes === null ? SCOPE_REFRESH_MISS_TTL_MS : SCOPE_REFRESH_TTL_MS)) {
|
|
49
|
+
return cached.scopes;
|
|
50
|
+
}
|
|
51
|
+
const fresh = (await listUsersOnce())?.find((u) => u.id === userId);
|
|
52
|
+
const scopes = fresh === undefined ? null : (0, types_1.normalizeTokenScopes)(fresh.scopes ?? []);
|
|
53
|
+
scopeMemo.set(userId, { at, scopes });
|
|
54
|
+
if (scopeMemo.size > SCOPE_MEMO_MAX) {
|
|
55
|
+
for (const [key, entry] of scopeMemo) {
|
|
56
|
+
if (at - entry.at > SCOPE_REFRESH_TTL_MS * 4)
|
|
57
|
+
scopeMemo.delete(key);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return scopes;
|
|
61
|
+
};
|
|
62
|
+
const subjectFacts = async (userId) => {
|
|
63
|
+
const all = await listUsersOnce();
|
|
64
|
+
if (all === null)
|
|
65
|
+
return null;
|
|
66
|
+
const user = all.find((u) => u.id === userId);
|
|
67
|
+
return user === undefined
|
|
68
|
+
? { exists: false, isAdmin: false }
|
|
69
|
+
: { exists: true, isAdmin: user.isAdmin };
|
|
70
|
+
};
|
|
71
|
+
const auditAdminUse = (principal, facts, name) => {
|
|
72
|
+
const internal = deps.internal();
|
|
73
|
+
if (internal === null)
|
|
74
|
+
return;
|
|
75
|
+
void internal.audit
|
|
76
|
+
.record({
|
|
77
|
+
action: 'admin-token-used',
|
|
78
|
+
userId: principal.subjectId,
|
|
79
|
+
...(facts.ip !== undefined ? { ip: facts.ip } : {}),
|
|
80
|
+
detail: {
|
|
81
|
+
tokenId: principal.credentialId,
|
|
82
|
+
name,
|
|
83
|
+
format: principal.legacy ? 'legacy' : 'token',
|
|
84
|
+
...(facts.path !== undefined ? { path: facts.path } : {}),
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
.catch(() => { });
|
|
88
|
+
};
|
|
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
|
+
// ── The unified token ────────────────────────────────────────────────
|
|
163
|
+
const unifiedToken = async (payload, facts) => {
|
|
164
|
+
const claims = types_1.CredentialTokenClaimsSchema.safeParse(payload);
|
|
165
|
+
if (!claims.success)
|
|
166
|
+
return null;
|
|
167
|
+
const rows = deps.tokenRows();
|
|
168
|
+
if (rows === null)
|
|
169
|
+
return null;
|
|
170
|
+
// Revocation and expiry live on the row, looked up by `jti` every time.
|
|
171
|
+
const row = await rows.lookupLive(claims.data.jti).catch(() => null);
|
|
172
|
+
if (row === null || row.subjectId !== claims.data.sub)
|
|
173
|
+
return null;
|
|
174
|
+
// The subject must still exist; an admin token dies with its subject's admin bit.
|
|
175
|
+
const subject = await subjectFacts(row.subjectId);
|
|
176
|
+
if (subject === null || !subject.exists)
|
|
177
|
+
return null;
|
|
178
|
+
if ((0, types_1.scopesGrantAdmin)(row.scopes) && !subject.isAdmin)
|
|
179
|
+
return null;
|
|
180
|
+
const principal = {
|
|
181
|
+
subjectId: row.subjectId,
|
|
182
|
+
username: `token:${row.label}`,
|
|
183
|
+
// The ROW's scopes, never the claim's: the row is what the operator listed.
|
|
184
|
+
scopes: row.scopes,
|
|
185
|
+
isAdmin: (0, types_1.scopesGrantAdmin)(row.scopes),
|
|
186
|
+
credentialId: row.id,
|
|
187
|
+
auditTag: 'token',
|
|
188
|
+
interactive: false,
|
|
189
|
+
userDelegated: false,
|
|
190
|
+
legacy: false,
|
|
191
|
+
};
|
|
192
|
+
if (principal.isAdmin)
|
|
193
|
+
auditAdminUse(principal, facts, row.label);
|
|
194
|
+
return principal;
|
|
195
|
+
};
|
|
196
|
+
// ── Session and account-link JWTs ────────────────────────────────────
|
|
197
|
+
const hubJwt = async (token, facts) => {
|
|
198
|
+
let payload;
|
|
199
|
+
try {
|
|
200
|
+
payload = deps.verifyJwt(token);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
if (payload.kind === types_1.CREDENTIAL_TOKEN_KIND)
|
|
206
|
+
return unifiedToken(payload, facts);
|
|
207
|
+
// A pre-v2 JWT (no `isAdmin`) would degrade into "non-admin, no scopes";
|
|
208
|
+
// refuse it so the client signs in again.
|
|
209
|
+
if (typeof payload.isAdmin !== 'boolean')
|
|
210
|
+
return null;
|
|
211
|
+
// Every token this hub mints verifies under one key, so a valid signature
|
|
212
|
+
// means "we minted this", never "this is an API credential" (D103). Only a
|
|
213
|
+
// session and an `oauth-access` link are credentials; a code, a refresh
|
|
214
|
+
// token, a bridge, a TOTP challenge are refused.
|
|
215
|
+
const classified = (0, types_1.classifyBearerPrincipal)(payload);
|
|
216
|
+
if (classified.kind === 'not-a-credential') {
|
|
217
|
+
deps.logger?.warn('auth: refused a bearer that is not an API credential', {
|
|
218
|
+
meta: { reason: classified.reason, userId: stringClaim(payload.userId) ?? 'unknown' },
|
|
219
|
+
});
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
const userId = stringClaim(payload.userId) ?? 'unknown';
|
|
223
|
+
const username = stringClaim(payload.username) ?? 'unknown';
|
|
224
|
+
if (classified.kind === 'integration') {
|
|
225
|
+
// 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.
|
|
227
|
+
const identity = (0, trpc_error_principal_js_1.readCredentialIdentity)(payload);
|
|
228
|
+
return {
|
|
229
|
+
subjectId: userId,
|
|
230
|
+
username,
|
|
231
|
+
scopes: (0, types_1.normalizeTokenScopes)(payload.scopes ?? []),
|
|
232
|
+
isAdmin: false,
|
|
233
|
+
credentialId: identity.sessionId ?? `oauth:${userId}`,
|
|
234
|
+
auditTag: 'integration',
|
|
235
|
+
interactive: false,
|
|
236
|
+
userDelegated: true,
|
|
237
|
+
legacy: false,
|
|
238
|
+
...(identity.sessionId !== undefined ? { oauthSessionId: identity.sessionId } : {}),
|
|
239
|
+
...(identity.credential !== undefined ? { credential: identity.credential } : {}),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
const sessionId = stringClaim(payload.sid);
|
|
243
|
+
const base = {
|
|
244
|
+
subjectId: userId,
|
|
245
|
+
username,
|
|
246
|
+
credentialId: sessionId ?? `session:${userId}`,
|
|
247
|
+
auditTag: 'session',
|
|
248
|
+
interactive: true,
|
|
249
|
+
userDelegated: true,
|
|
250
|
+
legacy: false,
|
|
251
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
252
|
+
};
|
|
253
|
+
if (payload.isAdmin)
|
|
254
|
+
return { ...base, scopes: [types_1.ADMIN_SCOPE], isAdmin: true };
|
|
255
|
+
const fresh = await liveScopes(userId);
|
|
256
|
+
const capability = fresh ?? (0, types_1.normalizeTokenScopes)(payload.scopes ?? []);
|
|
257
|
+
return { ...base, scopes: [...capability, SESSION_ADDON_ROUTES], isAdmin: false };
|
|
258
|
+
};
|
|
259
|
+
return {
|
|
260
|
+
async verify(bearer, facts = {}) {
|
|
261
|
+
if (bearer === null || bearer === undefined || bearer === '')
|
|
262
|
+
return null;
|
|
263
|
+
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
|
+
return await hubJwt(bearer, facts);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.scopesWithin = scopesWithin;
|
|
4
|
+
const types_1 = require("@camstack/types");
|
|
5
|
+
const principal_visibility_js_1 = require("../api/trpc/principal-visibility.js");
|
|
6
|
+
function accessCovered(asked, held) {
|
|
7
|
+
return asked.every((a) => held.includes(a));
|
|
8
|
+
}
|
|
9
|
+
/** The grant without its access list, as a comparable key. */
|
|
10
|
+
function grantKey(scope) {
|
|
11
|
+
if (scope.type === 'device') {
|
|
12
|
+
return JSON.stringify({
|
|
13
|
+
type: scope.type,
|
|
14
|
+
selector: scope.selector,
|
|
15
|
+
includeLinked: scope.includeLinked ?? null,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return JSON.stringify({ type: scope.type, target: scope.target });
|
|
19
|
+
}
|
|
20
|
+
function describe(scope) {
|
|
21
|
+
if (scope === types_1.ADMIN_SCOPE)
|
|
22
|
+
return "'*'";
|
|
23
|
+
if (scope.type === 'share-view')
|
|
24
|
+
return `share-view:${scope.grant.kind}`;
|
|
25
|
+
if (scope.type === 'addon-routes')
|
|
26
|
+
return `addon-routes[${scope.access.join(',')}]`;
|
|
27
|
+
if (scope.type === 'device')
|
|
28
|
+
return `device[${scope.access.join(',')}]`;
|
|
29
|
+
return `${scope.type}:${scope.target}[${scope.access.join(',')}]`;
|
|
30
|
+
}
|
|
31
|
+
function scopesWithin(requested, held, lookup) {
|
|
32
|
+
if ((0, types_1.scopesGrantAdmin)(held))
|
|
33
|
+
return { ok: true };
|
|
34
|
+
const heldCapability = (0, types_1.capabilityScopesOf)(held);
|
|
35
|
+
for (const scope of requested) {
|
|
36
|
+
if (scope === types_1.ADMIN_SCOPE) {
|
|
37
|
+
return { ok: false, reason: "only an admin can mint '*'" };
|
|
38
|
+
}
|
|
39
|
+
if (scope.type === 'share-view') {
|
|
40
|
+
const viewable = (0, principal_visibility_js_1.filterViewableDeviceIds)(heldCapability, scope.grant.deviceIds, lookup);
|
|
41
|
+
if (viewable.length !== scope.grant.deviceIds.length) {
|
|
42
|
+
return { ok: false, reason: 'a share-view grant names a device you cannot view' };
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (scope.type === 'addon-routes') {
|
|
47
|
+
const covered = held.some((h) => h !== types_1.ADMIN_SCOPE && h.type === 'addon-routes' && accessCovered(scope.access, h.access));
|
|
48
|
+
if (!covered)
|
|
49
|
+
return { ok: false, reason: `you do not hold ${describe(scope)}` };
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const key = grantKey(scope);
|
|
53
|
+
const covered = heldCapability.some((h) => grantKey(h) === key && accessCovered(scope.access, h.access));
|
|
54
|
+
if (!covered)
|
|
55
|
+
return { ok: false, reason: `you do not hold ${describe(scope)}` };
|
|
56
|
+
}
|
|
57
|
+
return { ok: true };
|
|
58
|
+
}
|
|
@@ -391,6 +391,7 @@ class AddonRegistryService {
|
|
|
391
391
|
clusterNodeHistoryStore = null;
|
|
392
392
|
clusterAgentRecords = null;
|
|
393
393
|
shareTokenService = null;
|
|
394
|
+
credentialTokenService = null;
|
|
394
395
|
// Device architecture — in-memory registry of live IDevice instances.
|
|
395
396
|
// Persistence is owned by the `device-manager` capability addon.
|
|
396
397
|
deviceRegistry = new system_1.DeviceRegistry();
|
|
@@ -1381,6 +1382,10 @@ class AddonRegistryService {
|
|
|
1381
1382
|
setShareTokenService(service) {
|
|
1382
1383
|
this.shareTokenService = service;
|
|
1383
1384
|
}
|
|
1385
|
+
/** The unified token store (D892), bound to its `core-auth` records with the share links. */
|
|
1386
|
+
setCredentialTokenService(service) {
|
|
1387
|
+
this.credentialTokenService = service;
|
|
1388
|
+
}
|
|
1384
1389
|
async wireHubMainOrphanRecords(store) {
|
|
1385
1390
|
const recordsCap = this.capabilityRegistry.getSingleton('records');
|
|
1386
1391
|
if (recordsCap === null || recordsCap === undefined)
|
|
@@ -1400,6 +1405,18 @@ class AddonRegistryService {
|
|
|
1400
1405
|
await coreRecords.declare(record_kinds_js_1.coreAuthRecordKinds);
|
|
1401
1406
|
const shareKind = coreRecords.kind(record_kinds_js_1.coreAuthRecordKinds['share-view-tokens']);
|
|
1402
1407
|
shareTokens.bindShareTokensKind(() => shareKind);
|
|
1408
|
+
const credentialTokens = this.credentialTokenService;
|
|
1409
|
+
if (credentialTokens !== null) {
|
|
1410
|
+
const tokenKind = coreRecords.kind(record_kinds_js_1.coreAuthRecordKinds['credential-tokens']);
|
|
1411
|
+
credentialTokens.bindKind(() => tokenKind);
|
|
1412
|
+
// Dead rows (expired or revoked > 30 days) go at boot; the verifier
|
|
1413
|
+
// already refuses them, so this is housekeeping, never a gate.
|
|
1414
|
+
void credentialTokens.reap().catch((error) => {
|
|
1415
|
+
this.logger.warn('Token reap failed', {
|
|
1416
|
+
meta: { error: error instanceof Error ? error.message : String(error) },
|
|
1417
|
+
});
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1403
1420
|
}
|
|
1404
1421
|
}
|
|
1405
1422
|
/**
|