@camstack/server 1.2.603 → 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
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.CredentialTokenService = void 0;
|
|
37
|
+
/**
|
|
38
|
+
* The unified token store (D892).
|
|
39
|
+
*
|
|
40
|
+
* A token is a signed JWT `{ kind: 'credential', sub, jti, scopes }` and one
|
|
41
|
+
* row here: the SHA-256 of its `jti`, its label, its scopes, its ONE expiry,
|
|
42
|
+
* when it was last used and whether it was revoked. The verifier reads the
|
|
43
|
+
* row by `jti` on every request — that is what makes revocation immediate and
|
|
44
|
+
* the expiry slidable (a share link's `extend`). The raw token leaves this
|
|
45
|
+
* service exactly once, in `mint`'s answer; neither it nor the `jti` is ever
|
|
46
|
+
* stored or logged.
|
|
47
|
+
*
|
|
48
|
+
* Rows live in the `core-auth` records (`credential-tokens`), declared by the
|
|
49
|
+
* hub beside the share links. Until that binding happens the store refuses to
|
|
50
|
+
* mint and authenticates nothing — it never falls back to another table.
|
|
51
|
+
*/
|
|
52
|
+
const crypto = __importStar(require("node:crypto"));
|
|
53
|
+
const types_1 = require("@camstack/types");
|
|
54
|
+
const record_kinds_js_1 = require("./record-kinds.js");
|
|
55
|
+
const TOUCH_EVERY_MS = 60_000;
|
|
56
|
+
const DEAD_RETENTION_MS = 30 * 86_400_000;
|
|
57
|
+
const LABEL_MAX = 80;
|
|
58
|
+
const PAGE = 1000;
|
|
59
|
+
const MAX_PAGES = 100;
|
|
60
|
+
function hashJti(jti) {
|
|
61
|
+
return crypto.createHash('sha256').update(jti).digest('hex');
|
|
62
|
+
}
|
|
63
|
+
/** Constant-time equality of two hex digests. */
|
|
64
|
+
function sameDigest(a, b) {
|
|
65
|
+
const left = Buffer.from(a, 'hex');
|
|
66
|
+
const right = Buffer.from(b, 'hex');
|
|
67
|
+
return left.length === right.length && crypto.timingSafeEqual(left, right);
|
|
68
|
+
}
|
|
69
|
+
function toRecord(id, body) {
|
|
70
|
+
return {
|
|
71
|
+
id,
|
|
72
|
+
subjectId: body.subjectId,
|
|
73
|
+
createdBy: body.createdBy,
|
|
74
|
+
label: body.label,
|
|
75
|
+
scopes: body.scopes,
|
|
76
|
+
createdAt: body.createdAt,
|
|
77
|
+
expiresAt: body.expiresAt,
|
|
78
|
+
lastUsedAt: body.lastUsedAt ?? null,
|
|
79
|
+
revokedAt: body.revokedAt ?? null,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
class CredentialTokenService {
|
|
83
|
+
signer;
|
|
84
|
+
logger;
|
|
85
|
+
now;
|
|
86
|
+
resolveKind = () => null;
|
|
87
|
+
constructor(signer, logger = null, now = Date.now) {
|
|
88
|
+
this.signer = signer;
|
|
89
|
+
this.logger = logger;
|
|
90
|
+
this.now = now;
|
|
91
|
+
}
|
|
92
|
+
/** Hub wiring after `records.forOwner('core-auth').declare(...)`. */
|
|
93
|
+
bindKind(resolver) {
|
|
94
|
+
this.resolveKind = resolver;
|
|
95
|
+
}
|
|
96
|
+
kind() {
|
|
97
|
+
const kind = this.resolveKind();
|
|
98
|
+
if (kind === null) {
|
|
99
|
+
throw (0, types_1.userError)('auth.tokens-unavailable', undefined, 'Tokens are not available yet');
|
|
100
|
+
}
|
|
101
|
+
return kind;
|
|
102
|
+
}
|
|
103
|
+
/** Issue a token. The raw token is in this answer only. */
|
|
104
|
+
async mint(input) {
|
|
105
|
+
const label = input.label.trim().slice(0, LABEL_MAX);
|
|
106
|
+
if (label.length === 0) {
|
|
107
|
+
throw (0, types_1.userError)('auth.token-label-required', undefined, 'A token needs a name');
|
|
108
|
+
}
|
|
109
|
+
const at = this.now();
|
|
110
|
+
if (input.expiresAt !== null && input.expiresAt <= at) {
|
|
111
|
+
throw (0, types_1.userError)('auth.token-expiry-past', undefined, 'A token must expire in the future');
|
|
112
|
+
}
|
|
113
|
+
const jti = crypto.randomBytes(32).toString('base64url');
|
|
114
|
+
const id = crypto.randomUUID();
|
|
115
|
+
const body = {
|
|
116
|
+
subjectId: input.subjectId,
|
|
117
|
+
createdBy: input.createdBy,
|
|
118
|
+
label,
|
|
119
|
+
jtiHash: hashJti(jti),
|
|
120
|
+
scopes: [...input.scopes],
|
|
121
|
+
createdAt: at,
|
|
122
|
+
expiresAt: input.expiresAt,
|
|
123
|
+
lastUsedAt: null,
|
|
124
|
+
revokedAt: null,
|
|
125
|
+
};
|
|
126
|
+
const parsed = record_kinds_js_1.coreAuthRecordKinds['credential-tokens'].schema.safeParse(body);
|
|
127
|
+
if (!parsed.success)
|
|
128
|
+
throw new Error(parsed.error.message);
|
|
129
|
+
const kind = this.kind();
|
|
130
|
+
const token = this.signer.signCredentialToken({
|
|
131
|
+
kind: types_1.CREDENTIAL_TOKEN_KIND,
|
|
132
|
+
sub: input.subjectId,
|
|
133
|
+
jti,
|
|
134
|
+
scopes: [...input.scopes],
|
|
135
|
+
});
|
|
136
|
+
await kind.put({
|
|
137
|
+
id,
|
|
138
|
+
...(body.expiresAt !== null ? { expiresAt: body.expiresAt } : {}),
|
|
139
|
+
data: parsed.data,
|
|
140
|
+
});
|
|
141
|
+
this.logger?.info('Token minted', {
|
|
142
|
+
meta: { tokenId: id, subjectId: input.subjectId, label, expiresAt: input.expiresAt },
|
|
143
|
+
});
|
|
144
|
+
return { token, record: toRecord(id, parsed.data) };
|
|
145
|
+
}
|
|
146
|
+
/** The live row behind `jti`: unknown, revoked and expired answer `null`. */
|
|
147
|
+
async lookupLive(jti) {
|
|
148
|
+
const kind = this.resolveKind();
|
|
149
|
+
if (kind === null)
|
|
150
|
+
return null;
|
|
151
|
+
const digest = hashJti(jti);
|
|
152
|
+
const page = await kind.query({ where: { jtiHash: digest }, limit: 1 });
|
|
153
|
+
const row = page.records[0];
|
|
154
|
+
if (row === undefined || !sameDigest(row.data.jtiHash, digest))
|
|
155
|
+
return null;
|
|
156
|
+
const at = this.now();
|
|
157
|
+
if (row.data.revokedAt !== null && row.data.revokedAt !== undefined)
|
|
158
|
+
return null;
|
|
159
|
+
if (row.data.expiresAt !== null && row.data.expiresAt <= at)
|
|
160
|
+
return null;
|
|
161
|
+
const lastUsedAt = row.data.lastUsedAt ?? null;
|
|
162
|
+
if (lastUsedAt === null || at - lastUsedAt > TOUCH_EVERY_MS) {
|
|
163
|
+
void this.write(row.id, { ...row.data, lastUsedAt: at }).catch(() => { });
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
id: row.id,
|
|
167
|
+
subjectId: row.data.subjectId,
|
|
168
|
+
label: row.data.label,
|
|
169
|
+
scopes: row.data.scopes,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
async get(id) {
|
|
173
|
+
const row = await this.kind().get(id);
|
|
174
|
+
return row === null ? null : toRecord(row.id, row.data);
|
|
175
|
+
}
|
|
176
|
+
/** Every row not yet reaped, newest first; `subjectId` narrows to one user's. */
|
|
177
|
+
async list(subjectId) {
|
|
178
|
+
const kind = this.resolveKind();
|
|
179
|
+
if (kind === null)
|
|
180
|
+
return [];
|
|
181
|
+
const out = [];
|
|
182
|
+
let cursor;
|
|
183
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
184
|
+
const answer = await kind.query({
|
|
185
|
+
...(subjectId === undefined ? {} : { where: { subjectId } }),
|
|
186
|
+
limit: PAGE,
|
|
187
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
188
|
+
});
|
|
189
|
+
out.push(...answer.records.map((row) => toRecord(row.id, row.data)));
|
|
190
|
+
if (answer.nextCursor === null)
|
|
191
|
+
return out.toSorted((a, b) => b.createdAt - a.createdAt);
|
|
192
|
+
cursor = answer.nextCursor;
|
|
193
|
+
}
|
|
194
|
+
throw new Error('credential-tokens: row count exceeds pagination bound');
|
|
195
|
+
}
|
|
196
|
+
/** Revoke one token now. `false` when unknown or already revoked. */
|
|
197
|
+
async revoke(id) {
|
|
198
|
+
const row = await this.kind().get(id);
|
|
199
|
+
if (row === null || (row.data.revokedAt !== null && row.data.revokedAt !== undefined)) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
await this.write(id, { ...row.data, revokedAt: this.now() });
|
|
203
|
+
this.logger?.info('Token revoked', { meta: { tokenId: id, subjectId: row.data.subjectId } });
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
/** Move a live token's expiry (a share link's slide). `null` when not live. */
|
|
207
|
+
async setExpiry(id, expiresAt) {
|
|
208
|
+
const row = await this.kind().get(id);
|
|
209
|
+
if (row === null)
|
|
210
|
+
return null;
|
|
211
|
+
const at = this.now();
|
|
212
|
+
const dead = (row.data.revokedAt !== null && row.data.revokedAt !== undefined) ||
|
|
213
|
+
(row.data.expiresAt !== null && row.data.expiresAt <= at);
|
|
214
|
+
if (dead || expiresAt <= at)
|
|
215
|
+
return null;
|
|
216
|
+
const next = { ...row.data, expiresAt };
|
|
217
|
+
await this.write(id, next);
|
|
218
|
+
return toRecord(id, next);
|
|
219
|
+
}
|
|
220
|
+
/** Revoke every live token of `subjectId` (the user was deleted). */
|
|
221
|
+
async revokeAllFor(subjectId) {
|
|
222
|
+
let revoked = 0;
|
|
223
|
+
for (const token of await this.list(subjectId)) {
|
|
224
|
+
if (token.revokedAt === null && (await this.revoke(token.id)))
|
|
225
|
+
revoked += 1;
|
|
226
|
+
}
|
|
227
|
+
return revoked;
|
|
228
|
+
}
|
|
229
|
+
/** Delete rows expired or revoked more than 30 days ago. */
|
|
230
|
+
async reap() {
|
|
231
|
+
const horizon = this.now() - DEAD_RETENTION_MS;
|
|
232
|
+
let deleted = 0;
|
|
233
|
+
for (const token of await this.list()) {
|
|
234
|
+
const deadAt = token.revokedAt ?? token.expiresAt;
|
|
235
|
+
if (deadAt !== null && deadAt < horizon) {
|
|
236
|
+
await this.kind().delete(token.id);
|
|
237
|
+
deleted += 1;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return deleted;
|
|
241
|
+
}
|
|
242
|
+
async write(id, body) {
|
|
243
|
+
await this.kind().put({
|
|
244
|
+
id,
|
|
245
|
+
...(body.expiresAt !== null ? { expiresAt: body.expiresAt } : {}),
|
|
246
|
+
data: body,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
exports.CredentialTokenService = CredentialTokenService;
|
|
@@ -21,6 +21,22 @@ const ShareViewTokenBodySchema = zod_1.z.object({
|
|
|
21
21
|
expiresAt: zod_1.z.number().nullable(),
|
|
22
22
|
lastUsedAt: zod_1.z.number().nullish(),
|
|
23
23
|
});
|
|
24
|
+
/**
|
|
25
|
+
* The unified token (D892): one row per token `auth.mintToken` issued — the
|
|
26
|
+
* hashed `jti` the verifier looks up for revocation, the label, the scopes,
|
|
27
|
+
* the ONE expiry (`null` only for a never-expiring share link), last use.
|
|
28
|
+
*/
|
|
29
|
+
const CredentialTokenBodySchema = zod_1.z.object({
|
|
30
|
+
subjectId: zod_1.z.string(),
|
|
31
|
+
createdBy: zod_1.z.string(),
|
|
32
|
+
label: zod_1.z.string(),
|
|
33
|
+
jtiHash: zod_1.z.string(),
|
|
34
|
+
scopes: zod_1.z.array(types_1.CredentialScopeSchema).min(1),
|
|
35
|
+
createdAt: zod_1.z.number(),
|
|
36
|
+
expiresAt: zod_1.z.number().nullable(),
|
|
37
|
+
lastUsedAt: zod_1.z.number().nullish(),
|
|
38
|
+
revokedAt: zod_1.z.number().nullish(),
|
|
39
|
+
});
|
|
24
40
|
exports.coreAuthRecordKinds = (0, types_1.defineRecordKinds)({
|
|
25
41
|
'share-view-tokens': {
|
|
26
42
|
schema: ShareViewTokenBodySchema,
|
|
@@ -35,4 +51,19 @@ exports.coreAuthRecordKinds = (0, types_1.defineRecordKinds)({
|
|
|
35
51
|
retention: { none: 'revoke only; expiry enforced at validate' },
|
|
36
52
|
adoptsFrom: exports.SHARE_VIEW_TOKENS_LEGACY,
|
|
37
53
|
},
|
|
54
|
+
'credential-tokens': {
|
|
55
|
+
schema: CredentialTokenBodySchema,
|
|
56
|
+
class: 'config',
|
|
57
|
+
writeTrigger: 'a user mints a token (auth.mintToken or a wrapper); validate touches lastUsedAt at most once a minute; revoke stamps revokedAt',
|
|
58
|
+
device: 'none',
|
|
59
|
+
at: 'none',
|
|
60
|
+
indexes: [
|
|
61
|
+
{ name: 'jti', fields: ['jtiHash'], unique: true },
|
|
62
|
+
{ name: 'subject', fields: ['subjectId'] },
|
|
63
|
+
],
|
|
64
|
+
retention: {
|
|
65
|
+
owner: 'core-auth',
|
|
66
|
+
reason: 'the daily reap deletes rows expired or revoked more than 30 days ago',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
38
69
|
});
|
package/dist/main.js
CHANGED
|
@@ -88,7 +88,7 @@ const trpc_error_device_tags_1 = require("./api/trpc/trpc-error-device-tags");
|
|
|
88
88
|
const trpc_error_principal_1 = require("./api/trpc/trpc-error-principal");
|
|
89
89
|
const ws_request_census_1 = require("./api/trpc/ws-request-census");
|
|
90
90
|
const ws_slow_consumer_guard_js_1 = require("./api/trpc/ws-slow-consumer-guard.js");
|
|
91
|
-
const
|
|
91
|
+
const addon_route_auth_js_1 = require("./auth/addon-route-auth.js");
|
|
92
92
|
const addon_route_share_gate_js_1 = require("./auth/addon-route-share-gate.js");
|
|
93
93
|
const auth_network_policy_js_1 = require("./auth/auth-network-policy.js");
|
|
94
94
|
const hub_sessions_js_1 = require("./auth/hub-sessions.js");
|
|
@@ -121,6 +121,7 @@ const agent_registry_service_1 = require("./core/agent/agent-registry.service");
|
|
|
121
121
|
const well_known_app_links_1 = require("./core/app-links/well-known-app-links");
|
|
122
122
|
const auth_service_1 = require("./core/auth/auth.service");
|
|
123
123
|
const handoff_code_service_1 = require("./core/auth/handoff-code.service");
|
|
124
|
+
const credential_token_service_js_1 = require("./core/auth/credential-token.service.js");
|
|
124
125
|
const share_token_service_1 = require("./core/auth/share-token.service");
|
|
125
126
|
const config_service_1 = require("./core/config/config.service");
|
|
126
127
|
const event_bus_service_1 = require("./core/events/event-bus.service");
|
|
@@ -600,6 +601,12 @@ async function bootstrap() {
|
|
|
600
601
|
// builtin registers; the service resolves it per call).
|
|
601
602
|
const shareTokenService = new share_token_service_1.ShareTokenService(() => addonRegistry.getSettingsBackend(), loggingService.createLogger('share-tokens'));
|
|
602
603
|
addonRegistry.setShareTokenService(shareTokenService);
|
|
604
|
+
// The unified token store (D892): rows in the `core-auth` records, bound
|
|
605
|
+
// with the share links; the JWT is signed with the session signing key.
|
|
606
|
+
const credentialTokenService = new credential_token_service_js_1.CredentialTokenService(app.get(auth_service_1.AuthService), loggingService.createLogger('tokens'));
|
|
607
|
+
addonRegistry.setCredentialTokenService(credentialTokenService);
|
|
608
|
+
// Every surface that verifies a bearer reads the same stores (D892).
|
|
609
|
+
(0, trpc_context_1.bindHubCredentialStores)({ shareTokens: shareTokenService, tokenRows: credentialTokenService });
|
|
603
610
|
// One-time native-app login handoff codes (in-memory, 60s TTL, single-use) —
|
|
604
611
|
// minted by `auth.createHandoffCode`, redeemed by the viewer app via the
|
|
605
612
|
// public `auth.redeemHandoffCode`.
|
|
@@ -632,6 +639,7 @@ async function bootstrap() {
|
|
|
632
639
|
const buildRouter = () => (0, trpc_router_1.buildAppRouter)({
|
|
633
640
|
authService: app.get(auth_service_1.AuthService),
|
|
634
641
|
shareTokenService,
|
|
642
|
+
credentialTokenService,
|
|
635
643
|
handoffCodeService,
|
|
636
644
|
externalLinkIntents,
|
|
637
645
|
nativeLink: {
|
|
@@ -803,7 +811,7 @@ async function bootstrap() {
|
|
|
803
811
|
const trpcCtx = await (0, trpc_context_1.createTrpcContext)(req, authService, addonRegistry, shareTokenService,
|
|
804
812
|
// A bearer refused for NOT being an API credential never reaches a
|
|
805
813
|
// procedure, so `onError` below never sees it — the refusal would
|
|
806
|
-
// be silent without this. Same reason
|
|
814
|
+
// be silent without this. Same reason the addon gate logs (D103).
|
|
807
815
|
app.get(logging_service_1.LoggingService).createLogger('tRPC'));
|
|
808
816
|
// WHO called, counted here because this is the first point at which
|
|
809
817
|
// a principal exists AND the only one tRPC runs exactly once per
|
|
@@ -1075,6 +1083,12 @@ async function bootstrap() {
|
|
|
1075
1083
|
// about ("why did Home Assistant stop actuating?"). Its own scope so the
|
|
1076
1084
|
// reason is greppable rather than buried in the bootstrap logger.
|
|
1077
1085
|
const addonGateLogger = app.get(logging_service_1.LoggingService).createLogger('addon-route-auth');
|
|
1086
|
+
const addonVerifier = (0, trpc_context_1.hubCredentialVerifier)({
|
|
1087
|
+
authService,
|
|
1088
|
+
addonRegistry,
|
|
1089
|
+
shareTokens: shareTokenService,
|
|
1090
|
+
logger: addonGateLogger,
|
|
1091
|
+
});
|
|
1078
1092
|
// Addon HTTP API route catch-all: /addon/:addonId/*
|
|
1079
1093
|
// Only handles non-GET or routes that actually exist in the addon route registry.
|
|
1080
1094
|
// GET requests that don't match an addon route are SPA pages — handled by the /* fallback.
|
|
@@ -1113,40 +1127,20 @@ async function bootstrap() {
|
|
|
1113
1127
|
return reply.status(401).send({ error: 'Unauthorized' });
|
|
1114
1128
|
}
|
|
1115
1129
|
const token = authHeader ? authHeader.replace('Bearer ', '') : cookieToken;
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
return reply.status(503).send({ error: 'User management not available' });
|
|
1131
|
-
const scopedToken = await userMgmt.validateScopedToken({ token });
|
|
1132
|
-
const scopeOk = scopedToken?.scopes.some((scope) => scope.type === 'addon' && scope.target === addonId);
|
|
1133
|
-
if (!scopedToken || !scopeOk) {
|
|
1134
|
-
return reply.status(403).send({ error: 'Token scope mismatch' });
|
|
1135
|
-
}
|
|
1136
|
-
}
|
|
1137
|
-
else {
|
|
1138
|
-
let payload;
|
|
1139
|
-
try {
|
|
1140
|
-
payload = authService.verifyToken(token);
|
|
1141
|
-
}
|
|
1142
|
-
catch {
|
|
1143
|
-
return reply.status(401).send({ error: 'Invalid token' });
|
|
1144
|
-
}
|
|
1145
|
-
const gate = (0, addon_route_jwt_gate_js_1.gateAddonJwt)({ payload, addonId, method, access }, addonGateLogger);
|
|
1146
|
-
if (!gate.ok) {
|
|
1147
|
-
return reply.status(gate.status).send({ error: gate.error });
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1130
|
+
// The ONE verifier and the ONE scope matcher, as on tRPC (D892). A
|
|
1131
|
+
// share principal is admitted only by a plane that opted in to its
|
|
1132
|
+
// kind, and its devices travel in headers the HUB sets — whatever
|
|
1133
|
+
// the client sent under those names is dropped below.
|
|
1134
|
+
const auth = await (0, addon_route_auth_js_1.authorizeAddonBearer)({
|
|
1135
|
+
bearer: token,
|
|
1136
|
+
verifier: addonVerifier,
|
|
1137
|
+
target: { addonId, method, access, share: dpMatch.endpoint.share ?? [] },
|
|
1138
|
+
facts: (0, trpc_context_1.requestFacts)(request),
|
|
1139
|
+
logger: addonGateLogger,
|
|
1140
|
+
});
|
|
1141
|
+
if (!auth.ok)
|
|
1142
|
+
return reply.status(auth.status).send({ error: auth.error });
|
|
1143
|
+
shareHeaders = auth.shareHeaders;
|
|
1150
1144
|
}
|
|
1151
1145
|
const qIdx = request.url.indexOf('?');
|
|
1152
1146
|
const queryString = qIdx >= 0 ? request.url.slice(qIdx) : '';
|
|
@@ -1221,67 +1215,27 @@ async function bootstrap() {
|
|
|
1221
1215
|
return reply.status(401).send({ error: 'Unauthorized' });
|
|
1222
1216
|
}
|
|
1223
1217
|
const token = authHeader ? authHeader.replace('Bearer ', '') : cookieToken;
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
query,
|
|
1246
|
-
body: request.body,
|
|
1247
|
-
headers,
|
|
1248
|
-
scopedToken: {
|
|
1249
|
-
id: scopedToken.id,
|
|
1250
|
-
userId: scopedToken.userId,
|
|
1251
|
-
scopes: scopedToken.scopes,
|
|
1252
|
-
},
|
|
1253
|
-
};
|
|
1254
|
-
const addonReply = buildAddonReply(reply);
|
|
1255
|
-
return match.route.handler(addonRequest, addonReply);
|
|
1256
|
-
}
|
|
1257
|
-
else {
|
|
1258
|
-
let payload;
|
|
1259
|
-
try {
|
|
1260
|
-
payload = authService.verifyToken(token);
|
|
1261
|
-
}
|
|
1262
|
-
catch {
|
|
1263
|
-
return reply.status(401).send({ error: 'Invalid token' });
|
|
1264
|
-
}
|
|
1265
|
-
// NOTE: the handler call used to sit INSIDE this try, so a throw from
|
|
1266
|
-
// an addon's own route handler was reported as `401 Invalid token`.
|
|
1267
|
-
const gate = (0, addon_route_jwt_gate_js_1.gateAddonJwt)({ payload, addonId, method, access: match.route.access }, addonGateLogger);
|
|
1268
|
-
if (!gate.ok) {
|
|
1269
|
-
return reply.status(gate.status).send({ error: gate.error });
|
|
1270
|
-
}
|
|
1271
|
-
const addonRequest = {
|
|
1272
|
-
params: match.params,
|
|
1273
|
-
query,
|
|
1274
|
-
body: request.body,
|
|
1275
|
-
headers,
|
|
1276
|
-
user: {
|
|
1277
|
-
id: payload.userId ?? 'unknown',
|
|
1278
|
-
username: payload.username ?? 'unknown',
|
|
1279
|
-
isAdmin: payload.isAdmin,
|
|
1280
|
-
},
|
|
1281
|
-
};
|
|
1282
|
-
const addonReply = buildAddonReply(reply);
|
|
1283
|
-
return match.route.handler(addonRequest, addonReply);
|
|
1284
|
-
}
|
|
1218
|
+
// The ONE verifier and the ONE scope matcher, as on tRPC (D892).
|
|
1219
|
+
const auth = await (0, addon_route_auth_js_1.authorizeAddonBearer)({
|
|
1220
|
+
bearer: token,
|
|
1221
|
+
verifier: addonVerifier,
|
|
1222
|
+
target: { addonId, method, access: match.route.access },
|
|
1223
|
+
facts: (0, trpc_context_1.requestFacts)(request),
|
|
1224
|
+
logger: addonGateLogger,
|
|
1225
|
+
});
|
|
1226
|
+
if (!auth.ok)
|
|
1227
|
+
return reply.status(auth.status).send({ error: auth.error });
|
|
1228
|
+
// NOTE: the handler call used to sit INSIDE the JWT try, so a throw
|
|
1229
|
+
// from an addon's own route handler was reported as `401 Invalid token`.
|
|
1230
|
+
const addonRequest = {
|
|
1231
|
+
params: match.params,
|
|
1232
|
+
query,
|
|
1233
|
+
body: request.body,
|
|
1234
|
+
headers,
|
|
1235
|
+
...(0, addon_route_auth_js_1.addonRouteCaller)(auth.principal),
|
|
1236
|
+
};
|
|
1237
|
+
const addonReply = buildAddonReply(reply);
|
|
1238
|
+
return match.route.handler(addonRequest, addonReply);
|
|
1285
1239
|
}
|
|
1286
1240
|
// Public route — no auth required
|
|
1287
1241
|
const addonRequest = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.605",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"files": [
|
|
@@ -38,19 +38,19 @@
|
|
|
38
38
|
]
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@camstack/addon-admin-ui": "1.2.
|
|
42
|
-
"@camstack/addon-agent-ui": "1.2.
|
|
43
|
-
"@camstack/addon-auth": "1.2.
|
|
44
|
-
"@camstack/addon-decoder-nodeav": "1.2.
|
|
45
|
-
"@camstack/addon-notifiers": "1.2.
|
|
46
|
-
"@camstack/addon-pipeline": "1.2.
|
|
47
|
-
"@camstack/addon-pipeline-orchestrator": "1.2.
|
|
48
|
-
"@camstack/addon-post-analysis": "1.2.
|
|
49
|
-
"@camstack/develop": "0.1.
|
|
50
|
-
"@camstack/sdk": "1.2.
|
|
51
|
-
"@camstack/system": "1.2.
|
|
52
|
-
"@camstack/types": "1.2.
|
|
53
|
-
"@camstack/ui-library": "1.2.
|
|
41
|
+
"@camstack/addon-admin-ui": "1.2.441",
|
|
42
|
+
"@camstack/addon-agent-ui": "1.2.247",
|
|
43
|
+
"@camstack/addon-auth": "1.2.152",
|
|
44
|
+
"@camstack/addon-decoder-nodeav": "1.2.126",
|
|
45
|
+
"@camstack/addon-notifiers": "1.2.604",
|
|
46
|
+
"@camstack/addon-pipeline": "1.2.604",
|
|
47
|
+
"@camstack/addon-pipeline-orchestrator": "1.2.605",
|
|
48
|
+
"@camstack/addon-post-analysis": "1.2.610",
|
|
49
|
+
"@camstack/develop": "0.1.16",
|
|
50
|
+
"@camstack/sdk": "1.2.136",
|
|
51
|
+
"@camstack/system": "1.2.455",
|
|
52
|
+
"@camstack/types": "1.2.386",
|
|
53
|
+
"@camstack/ui-library": "1.2.303",
|
|
54
54
|
"@fastify/compress": "^9.0.0",
|
|
55
55
|
"@fastify/cookie": "^11.0.2",
|
|
56
56
|
"@fastify/cors": "^11.2.0",
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.gateAddonJwt = gateAddonJwt;
|
|
4
|
-
/**
|
|
5
|
-
* The JWT half of the `/addon/:addonId/*` gate — shared by the HTTP data-plane
|
|
6
|
-
* branch and the addon-route branch so there is exactly ONE answer to "may this
|
|
7
|
-
* bearer reach this addon".
|
|
8
|
-
*
|
|
9
|
-
* Before this existed the two branches disagreed with themselves and with the
|
|
10
|
-
* `cst_` branch: a scoped token needed an `addon:<id>` grant, while ANY valid
|
|
11
|
-
* hub JWT passed on nothing but a signature check. Measured on the live hub
|
|
12
|
-
* 2026-08-09, that let an `oauth-access` token minted for the `export-alexa`
|
|
13
|
-
* account link drive `POST /addon/homeassistant-export/command` — PTZ, reboot,
|
|
14
|
-
* the per-camera switches and notification snooze. A 60-second authorization
|
|
15
|
-
* CODE and a 30-day refresh token passed the same way. See D103.
|
|
16
|
-
*/
|
|
17
|
-
const types_1 = require("@camstack/types");
|
|
18
|
-
function readScopes(raw) {
|
|
19
|
-
return Array.isArray(raw) ? raw : [];
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Decide, and REPORT. A refusal always returns its reason to the log — a branch
|
|
23
|
-
* that drops a request silently reads as "never happened", and this one drops
|
|
24
|
-
* requests an operator will be trying to explain.
|
|
25
|
-
*
|
|
26
|
-
* Order is deliberate. `Admin required` is answered before the scope check so
|
|
27
|
-
* an admin-only route keeps the response it already had; nothing that is not a
|
|
28
|
-
* session or an `oauth-access` token gets that far at all.
|
|
29
|
-
*/
|
|
30
|
-
function gateAddonJwt(input, logger) {
|
|
31
|
-
const principal = (0, types_1.classifyBearerPrincipal)(input.payload);
|
|
32
|
-
const isAdmin = input.payload.isAdmin === true;
|
|
33
|
-
if (principal.kind === 'not-a-credential') {
|
|
34
|
-
logger.warn('addon route: refused a bearer that is not an API credential', {
|
|
35
|
-
meta: { addonId: input.addonId, method: input.method, reason: principal.reason },
|
|
36
|
-
});
|
|
37
|
-
return { ok: false, status: 401, error: 'Invalid token' };
|
|
38
|
-
}
|
|
39
|
-
if (input.access === 'admin' && !isAdmin) {
|
|
40
|
-
logger.warn('addon route: refused a non-admin bearer on an admin route', {
|
|
41
|
-
meta: { addonId: input.addonId, method: input.method, principal: principal.kind },
|
|
42
|
-
});
|
|
43
|
-
return { ok: false, status: 403, error: 'Admin required' };
|
|
44
|
-
}
|
|
45
|
-
const verdict = (0, types_1.principalMayReachAddon)({
|
|
46
|
-
principal,
|
|
47
|
-
scopes: readScopes(input.payload.scopes),
|
|
48
|
-
isAdmin,
|
|
49
|
-
addonId: input.addonId,
|
|
50
|
-
method: input.method,
|
|
51
|
-
});
|
|
52
|
-
if (!verdict.allowed) {
|
|
53
|
-
logger.warn('addon route: refused an integration token outside its declared grant', {
|
|
54
|
-
meta: { addonId: input.addonId, method: input.method, reason: verdict.reason },
|
|
55
|
-
});
|
|
56
|
-
return { ok: false, status: 403, error: 'Token scope mismatch' };
|
|
57
|
-
}
|
|
58
|
-
return { ok: true };
|
|
59
|
-
}
|