@camstack/server 1.2.134 → 1.2.135

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.
@@ -93,10 +93,11 @@ function extractTokenFromRequest(req) {
93
93
  * its REST auth chain).
94
94
  *
95
95
  * Returns `null` for: missing token, malformed JWT, unknown scoped
96
- * token. Caller (protectedProcedure) decides the failure response
97
- * (typically UNAUTHORIZED).
96
+ * token, and a JWT that is not an ACCESS CREDENTIAL (see the classifier
97
+ * branch below). Caller (protectedProcedure) decides the failure
98
+ * response (typically UNAUTHORIZED).
98
99
  */
99
- async function resolveUser(token, authService, addonRegistry, shareTokens = null) {
100
+ async function resolveUser(token, authService, addonRegistry, shareTokens = null, logger = null) {
100
101
  if (!token)
101
102
  return null;
102
103
  // Share-token path (`csv_*`): resolve through the ShareTokenService.
@@ -174,14 +175,42 @@ async function resolveUser(token, authService, addonRegistry, shareTokens = null
174
175
  if (typeof payload.isAdmin !== 'boolean') {
175
176
  return null;
176
177
  }
178
+ // What the token IS decides both WHETHER it authenticates at all and
179
+ // WHERE its scopes come from.
180
+ const principal = (0, types_1.classifyBearerPrincipal)(payload);
181
+ // ── The allowlist ────────────────────────────────────────────────
182
+ // Every token this hub mints verifies under the same `auth.jwtSecret`, so
183
+ // `verifyToken` succeeding means "we minted this", never "this is an API
184
+ // credential" (D103). Exactly two kinds are credentials here:
185
+ //
186
+ // • `session` — no `kind` claim: `auth.login`'s JWT, the SSO-minted
187
+ // session from `/api/auth/sso/finish`, a `type: 'service'` agent
188
+ // token, a `type: 'api_key'` token.
189
+ // • `integration` — `provider: 'oauth-access'`: the account-link
190
+ // credential a component (Home Assistant) calls `/trpc` with.
191
+ //
192
+ // Everything else is minted for ONE hand-off step and is refused: an
193
+ // `oauth-code` is single-use, lives 60s and travels in a browser redirect
194
+ // URL (history, referrers, proxy logs); an `oauth-refresh` belongs to
195
+ // `POST /oauth/token`; an `oidc`/`magic-link` bridge belongs to
196
+ // `?bridge=` on `/api/auth/sso/finish`; a `totp-challenge` is login leg 1.
197
+ // `/addon/:addonId/*` has refused these since D103 — this closes the same
198
+ // hole on the other surface.
199
+ //
200
+ // The TOTP login is unaffected BY CONSTRUCTION: `loginVerifyTotp` takes
201
+ // the challenge as procedure INPUT on a public rate-limited procedure, and
202
+ // neither the SDK (`login()` calls `setToken` only when
203
+ // `!requiresSecondFactor`) nor the admin UI ever puts it in a header.
204
+ if (principal.kind === 'not-a-credential') {
205
+ logger?.warn('trpc: refused a bearer that is not an API credential', {
206
+ meta: { reason: principal.reason, userId: payload.userId ?? payload.keyId ?? 'unknown' },
207
+ });
208
+ return null;
209
+ }
177
210
  // What the token says it IS. An `oauth-access` token is an account link,
178
211
  // and until this was carried onto the principal a refusal of one was
179
212
  // indistinguishable in the logs from a refusal of a browser session.
180
213
  const credential = (0, trpc_error_principal_js_1.readCredentialIdentity)(payload);
181
- // What the token IS decides WHERE its scopes come from. A `session` JWT is
182
- // governed by its user's record; anything else carries its own grant and
183
- // must keep it — see the live-scope note on the spread below.
184
- const principal = (0, types_1.classifyBearerPrincipal)(payload);
185
214
  return {
186
215
  id: payload.userId ?? payload.keyId ?? 'unknown',
187
216
  username: payload.username ?? 'unknown',
@@ -270,10 +299,10 @@ function createMeshTrpcContext() {
270
299
  return { user };
271
300
  }
272
301
  /** Context factory for HTTP tRPC requests (Fastify adapter). */
273
- async function createTrpcContext(req, authService, addonRegistry, shareTokens = null) {
302
+ async function createTrpcContext(req, authService, addonRegistry, shareTokens = null, logger = null) {
274
303
  const token = extractTokenFromRequest(req);
275
304
  return {
276
- user: await resolveUser(token, authService, addonRegistry, shareTokens),
305
+ user: await resolveUser(token, authService, addonRegistry, shareTokens, logger),
277
306
  req,
278
307
  deviceScopeLookup: makeDeviceScopeLookup(addonRegistry),
279
308
  deviceFleet: () => addonRegistry.getPersistedDeviceList(),
@@ -284,11 +313,11 @@ async function createTrpcContext(req, authService, addonRegistry, shareTokens =
284
313
  * Token is sent via tRPC connectionParams (a JSON message sent right after
285
314
  * the WS handshake), which is more reliable than query params through proxies.
286
315
  */
287
- async function createWsTrpcContext(opts, authService, addonRegistry, shareTokens = null) {
316
+ async function createWsTrpcContext(opts, authService, addonRegistry, shareTokens = null, logger = null) {
288
317
  // 1. connectionParams.token (sent by BackendClient's createWSClient)
289
318
  const paramToken = opts.info.connectionParams?.['token'];
290
319
  const token = (typeof paramToken === 'string' ? paramToken : null) ?? extractTokenFromRequest(opts.req);
291
- const user = await resolveUser(token, authService, addonRegistry, shareTokens);
320
+ const user = await resolveUser(token, authService, addonRegistry, shareTokens, logger);
292
321
  return {
293
322
  user,
294
323
  req: opts.req,
package/dist/main.js CHANGED
@@ -504,7 +504,11 @@ async function bootstrap() {
504
504
  prefix: '/trpc',
505
505
  trpcOptions: {
506
506
  router: appRouter,
507
- createContext: ({ req }) => (0, trpc_context_1.createTrpcContext)(req, authService, addonRegistry, shareTokenService),
507
+ createContext: ({ req }) => (0, trpc_context_1.createTrpcContext)(req, authService, addonRegistry, shareTokenService,
508
+ // A bearer refused for NOT being an API credential never reaches a
509
+ // procedure, so `onError` below never sees it — the refusal would
510
+ // be silent without this. Same reason `gateAddonJwt` logs (D103).
511
+ app.get(logging_service_1.LoggingService).createLogger('tRPC')),
508
512
  onError: ({ path: trpcPath, error, ctx, }) => {
509
513
  const trpcLogger = app.get(logging_service_1.LoggingService).createLogger('tRPC');
510
514
  trpcLogger.warn('tRPC error', {
@@ -1065,7 +1069,7 @@ async function bootstrap() {
1065
1069
  (0, ws_1.applyWSSHandler)({
1066
1070
  wss,
1067
1071
  router: appRouter,
1068
- createContext: (opts) => (0, trpc_context_1.createWsTrpcContext)(opts, authService, addonRegistry, shareTokenService),
1072
+ createContext: (opts) => (0, trpc_context_1.createWsTrpcContext)(opts, authService, addonRegistry, shareTokenService, app.get(logging_service_1.LoggingService).createLogger('tRPC:ws')),
1069
1073
  onError: ({ path: trpcPath, error, ctx, }) => {
1070
1074
  const trpcLogger = app.get(logging_service_1.LoggingService).createLogger('tRPC:ws');
1071
1075
  trpcLogger.warn('tRPC error', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.134",
3
+ "version": "1.2.135",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -40,7 +40,7 @@
40
40
  "@camstack/addon-notifiers": "1.2.27",
41
41
  "@camstack/addon-pipeline": "1.2.98",
42
42
  "@camstack/addon-pipeline-orchestrator": "1.2.83",
43
- "@camstack/addon-post-analysis": "1.2.98",
43
+ "@camstack/addon-post-analysis": "1.2.99",
44
44
  "@camstack/sdk": "1.2.25",
45
45
  "@camstack/shm-ring": "1.1.22",
46
46
  "@camstack/system": "1.2.106",