@camstack/server 1.1.51 → 1.1.52

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.
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EXCHANGE_SESSION_HEADER = void 0;
3
4
  exports.createAuthRouter = createAuthRouter;
4
5
  /**
5
6
  * Auth router — core API for login/logout/me.
@@ -20,6 +21,8 @@ const zod_1 = require("zod");
20
21
  const server_1 = require("@trpc/server");
21
22
  const types_1 = require("@camstack/types");
22
23
  const share_token_service_js_1 = require("../../core/auth/share-token.service.js");
24
+ const handoff_code_service_js_1 = require("../../core/auth/handoff-code.service.js");
25
+ const session_cookie_js_1 = require("../../auth/session-cookie.js");
23
26
  const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
24
27
  /**
25
28
  * The available second-factor kinds a user may satisfy after the
@@ -175,19 +178,40 @@ function toShareTokenSummary(record) {
175
178
  };
176
179
  }
177
180
  /**
178
- * Share-token management is for REAL user sessions only. Scoped API
179
- * tokens (`cst_*`) and share-view principals (`csv_*`) must never mint,
180
- * list, or revoke share links — a leaked restricted token would
181
- * otherwise be able to widen its own reach.
181
+ * Token minting/management surfaces are for REAL user sessions only.
182
+ * Scoped API tokens (`cst_*`) and share-view principals (`csv_*`) must
183
+ * never mint, list, or revoke share links — nor mint handoff codes
184
+ * a leaked restricted token would otherwise widen its own reach.
182
185
  */
183
- function assertRealUserSession(user) {
186
+ function assertRealUserSession(user, what = 'Share-token management') {
184
187
  if (user.isScoped || user.shareView) {
185
188
  throw new server_1.TRPCError({
186
189
  code: 'FORBIDDEN',
187
- message: 'Share-token management requires a real user session',
190
+ message: `${what} requires a real user session`,
188
191
  });
189
192
  }
190
193
  }
194
+ // ── Session exchange (viewer same-origin session reuse) ──────────────
195
+ //
196
+ // The viewer web build is served by the hub under `/viewer/camstack/`
197
+ // — SAME ORIGIN as the admin-ui. After an admin-ui login the session
198
+ // JWT is mirrored into the httpOnly `camstack_session` cookie
199
+ // (`POST /api/auth/session`); `auth.exchangeSession` lets the viewer
200
+ // upgrade that cookie back into a bearer token WITHOUT ever reading the
201
+ // cookie from JS (it's httpOnly — the browser attaches it, the server
202
+ // answers with a freshly-minted session).
203
+ /** Custom header a cross-site form can never set — CSRF gate for the
204
+ * cookie-authenticated `exchangeSession` mutation. */
205
+ exports.EXCHANGE_SESSION_HEADER = 'x-camstack-exchange';
206
+ /** Read a single-valued request header off the tRPC context request. */
207
+ function readRequestHeader(req, name) {
208
+ const value = req?.headers[name];
209
+ if (typeof value === 'string')
210
+ return value;
211
+ if (Array.isArray(value))
212
+ return value[0] ?? null;
213
+ return null;
214
+ }
191
215
  /** Wire shape of the authenticated user returned by `auth.me`. */
192
216
  const MeSchema = zod_1.z
193
217
  .object({
@@ -203,7 +227,7 @@ const MeSchema = zod_1.z
203
227
  agentId: zod_1.z.string().optional(),
204
228
  })
205
229
  .nullable();
206
- function createAuthRouter(auth, registry, moleculer = null, shareTokens = null) {
230
+ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null, handoffCodes = new handoff_code_service_js_1.HandoffCodeService()) {
207
231
  const requireShareTokens = () => {
208
232
  if (!shareTokens) {
209
233
  throw new Error('Share tokens unavailable — service not wired on this node');
@@ -242,6 +266,24 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null)
242
266
  requiresTotp: false,
243
267
  };
244
268
  };
269
+ /**
270
+ * `mintSessionForUserId` with the "user vanished" failure mapped to a
271
+ * clean 401 — for the token-exchange legs (`exchangeSession`,
272
+ * `redeemHandoffCode`) where the caller presented a credential whose
273
+ * backing user may have been deleted mid-flight. Infrastructure
274
+ * failures (user-management cap not registered) still surface as 500.
275
+ */
276
+ const mintSessionOrUnauthorized = async (userId) => {
277
+ try {
278
+ return await mintSessionForUserId(userId);
279
+ }
280
+ catch (error) {
281
+ if (error instanceof Error && error.message === 'User no longer exists') {
282
+ throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'User no longer exists' });
283
+ }
284
+ throw error;
285
+ }
286
+ };
245
287
  return (0, trpc_middleware_js_1.trpcRouter)({
246
288
  login: trpc_middleware_js_1.publicProcedure
247
289
  .input(zod_1.z.object({ username: zod_1.z.string(), password: zod_1.z.string() }))
@@ -444,6 +486,90 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null)
444
486
  }
445
487
  return mintSessionForUserId(result.userId);
446
488
  }),
489
+ /**
490
+ * PUBLIC — upgrade the browser's httpOnly session COOKIE into a
491
+ * bearer token (viewer same-origin session reuse). Authentication
492
+ * comes from the `camstack_session` cookie ONLY — never from a
493
+ * bearer header — so a hub-served SPA (the viewer under
494
+ * `/viewer/camstack/`) can bootstrap without its own login.
495
+ *
496
+ * Security properties:
497
+ * • CSRF: mutation (POST) + a REQUIRED custom header
498
+ * (`x-camstack-exchange: 1`) that a cross-site form can't set;
499
+ * belt-and-braces on top of the cookie's `SameSite=Lax`
500
+ * semantics (see `buildSessionCookie` — Lax already withholds
501
+ * the cookie from cross-site POSTs).
502
+ * • Only REAL session JWTs qualify: bridge tokens (`kind:
503
+ * 'totp-challenge'` / `'sso-bridge'`) and `cst_`/`csv_` opaque
504
+ * tokens are rejected — the cookie must carry a v2 session.
505
+ * • The returned bearer is minted FRESH through the shared
506
+ * `mintSessionForUserId` tail (re-fetched user → up-to-date
507
+ * scopes), equivalent to what `auth.login` hands out.
508
+ */
509
+ exchangeSession: trpc_middleware_js_1.publicProcedure
510
+ .input(zod_1.z.object({}).optional())
511
+ .output(LoginResultSchema)
512
+ .mutation(async ({ ctx }) => {
513
+ if (readRequestHeader(ctx.req, exports.EXCHANGE_SESSION_HEADER) !== '1') {
514
+ throw new server_1.TRPCError({
515
+ code: 'FORBIDDEN',
516
+ message: `Missing ${exports.EXCHANGE_SESSION_HEADER} header`,
517
+ });
518
+ }
519
+ const cookieToken = (0, session_cookie_js_1.readSessionCookieFromHeader)(readRequestHeader(ctx.req, 'cookie') ?? undefined);
520
+ if (!cookieToken) {
521
+ throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'No session cookie' });
522
+ }
523
+ let payload;
524
+ try {
525
+ payload = auth.verifyToken(cookieToken);
526
+ }
527
+ catch {
528
+ throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid session cookie' });
529
+ }
530
+ // Reject non-session JWTs that verify under the same secret:
531
+ // challenge/bridge tokens carry a `kind` discriminator, and a
532
+ // v2 session always has a boolean `isAdmin` + string `userId`.
533
+ const kind = Reflect.get(payload, 'kind');
534
+ if (kind !== undefined ||
535
+ typeof payload.isAdmin !== 'boolean' ||
536
+ typeof payload.userId !== 'string') {
537
+ throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid session cookie' });
538
+ }
539
+ return mintSessionOrUnauthorized(payload.userId);
540
+ }),
541
+ // ── One-time handoff codes (native-app login handoff) ─────────────
542
+ //
543
+ // The admin-ui login page, when its `redirect` param is the app's
544
+ // custom-scheme callback, mints a code AFTER a successful login and
545
+ // bounces to `camstack://auth-callback?code=…`; the app redeems it
546
+ // for a real session. TTL 60s, single-use, bound to the minting
547
+ // user — see `HandoffCodeService`.
548
+ /** Mint a one-time handoff code for the CALLING user. Real user
549
+ * sessions only — scoped (`cst_`) and share-view (`csv_`) callers
550
+ * must never convert themselves into a full session. */
551
+ createHandoffCode: trpc_middleware_js_1.protectedProcedure
552
+ .input(zod_1.z.void())
553
+ .output(zod_1.z.object({ code: zod_1.z.string(), expiresAt: zod_1.z.number() }))
554
+ .mutation(({ ctx }) => {
555
+ assertRealUserSession(ctx.user, 'Handoff-code minting');
556
+ return handoffCodes.create(ctx.user.id);
557
+ }),
558
+ /** PUBLIC — redeem a one-time handoff code for a session bearer.
559
+ * Unknown / expired / already-used codes → UNAUTHORIZED. */
560
+ redeemHandoffCode: trpc_middleware_js_1.publicProcedure
561
+ .input(zod_1.z.object({ code: zod_1.z.string().min(1) }))
562
+ .output(LoginResultSchema)
563
+ .mutation(async ({ input }) => {
564
+ const grant = handoffCodes.redeem(input.code);
565
+ if (!grant) {
566
+ throw new server_1.TRPCError({
567
+ code: 'UNAUTHORIZED',
568
+ message: 'Invalid or expired handoff code',
569
+ });
570
+ }
571
+ return mintSessionOrUnauthorized(grant.userId);
572
+ }),
447
573
  me: trpc_middleware_js_1.protectedProcedure
448
574
  .input(zod_1.z.void())
449
575
  .output(MeSchema)
@@ -166,7 +166,7 @@ function buildCapabilityRouters(services) {
166
166
  // clusterNodes — fixed core API. Write-side purge for the durable
167
167
  // offline-node history (Track A "Forget node"); read side is push-only.
168
168
  clusterNodes: (0, cluster_nodes_router_js_1.createClusterNodesRouter)(services.agentRegistry),
169
- auth: (0, auth_router_js_1.createAuthRouter)(services.authService, services.capabilityRegistry, services.moleculer, services.shareTokenService),
169
+ auth: (0, auth_router_js_1.createAuthRouter)(services.authService, services.capabilityRegistry, services.moleculer, services.shareTokenService, services.handoffCodeService),
170
170
  // NOT MOUNTED — `mount: { kind: 'skip' }` legacy provider shapes
171
171
  // (positional args / sync returns) that don't match the codegen
172
172
  // routers' {input}-object + Promise<T> contract. The runtime builder
@@ -3,9 +3,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SESSION_COOKIE = void 0;
4
4
  exports.buildSessionCookie = buildSessionCookie;
5
5
  exports.clearSessionCookie = clearSessionCookie;
6
+ exports.readSessionCookieFromHeader = readSessionCookieFromHeader;
6
7
  exports.shouldRedirectToLogin = shouldRedirectToLogin;
7
8
  exports.loginRedirectUrl = loginRedirectUrl;
8
9
  exports.isEmbedRedirectTarget = isEmbedRedirectTarget;
10
+ exports.isSessionGradeJwtPayload = isSessionGradeJwtPayload;
9
11
  /** Browser session cookie carrying the hub JWT. Set by POST /api/auth/session
10
12
  * after a tRPC login; read by the addon-route catch-all for `authenticated`
11
13
  * routes hit by a plain browser navigation. */
@@ -24,6 +26,34 @@ function clearSessionCookie() {
24
26
  options: { httpOnly: true, sameSite: 'lax', secure: true, path: '/', maxAge: 0 },
25
27
  };
26
28
  }
29
+ /**
30
+ * Extract the session JWT from a raw `Cookie` request header. Plugin-free
31
+ * (works on both Fastify requests and bare WS upgrade `IncomingMessage`s)
32
+ * so callers don't depend on `@fastify/cookie` decoration order. Returns
33
+ * `null` when the header is absent or carries no `camstack_session` pair.
34
+ */
35
+ function readSessionCookieFromHeader(header) {
36
+ if (!header)
37
+ return null;
38
+ for (const pair of header.split(';')) {
39
+ const eq = pair.indexOf('=');
40
+ if (eq === -1)
41
+ continue;
42
+ const name = pair.slice(0, eq).trim();
43
+ if (name !== exports.SESSION_COOKIE)
44
+ continue;
45
+ const raw = pair.slice(eq + 1).trim();
46
+ if (raw === '')
47
+ return null;
48
+ try {
49
+ return decodeURIComponent(raw);
50
+ }
51
+ catch {
52
+ return raw;
53
+ }
54
+ }
55
+ return null;
56
+ }
27
57
  /** A browser navigation we can bounce to the login page: a top-level GET
28
58
  * that wants HTML. Anything else (API call, POST, non-HTML) keeps the
29
59
  * 401 behavior so programmatic clients get a clean error. */
@@ -45,3 +75,17 @@ function isEmbedRedirectTarget(next) {
45
75
  return false;
46
76
  return true;
47
77
  }
78
+ /**
79
+ * True only for a payload with the v2 SESSION shape: string `userId`,
80
+ * boolean `isAdmin`, and NO `kind` discriminator. Challenge/bridge tokens
81
+ * (`kind: 'totp-challenge' | 'sso-bridge'`) verify under the same hub
82
+ * secret but are NOT sessions — accepting one as a session cookie lets a
83
+ * password-only attacker skip the second factor on every cookie-gated
84
+ * surface. Shared by `POST /api/auth/session` and `auth.exchangeSession`.
85
+ */
86
+ function isSessionGradeJwtPayload(payload) {
87
+ if (payload === null || typeof payload !== 'object')
88
+ return false;
89
+ const p = payload;
90
+ return p.kind === undefined && typeof p.userId === 'string' && typeof p.isAdmin === 'boolean';
91
+ }
@@ -0,0 +1,116 @@
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.HandoffCodeService = exports.HANDOFF_CODE_MAX_PENDING = exports.HANDOFF_CODE_TTL_MS = exports.HANDOFF_CODE_PREFIX = void 0;
37
+ /**
38
+ * HandoffCodeService — one-time login handoff codes for the native-app
39
+ * auth handoff (viewer "Sign in with CamStack").
40
+ *
41
+ * Flow: an AUTHENTICATED browser session (the admin-ui login page, after
42
+ * any successful login leg) mints a short-lived single-use code bound to
43
+ * its user, then bounces to the app's custom-scheme callback
44
+ * (`camstack://auth-callback?code=…`). The app redeems the code over the
45
+ * PUBLIC `auth.redeemHandoffCode` procedure and receives a real session
46
+ * JWT minted through the same `mintSessionForUserId` tail every login
47
+ * leg uses.
48
+ *
49
+ * Design mirrors the pending-challenge / share-token patterns:
50
+ * • the raw code is returned exactly once; only its SHA-256 hash is
51
+ * kept server-side, so a memory dump never exposes redeemable codes;
52
+ * • TTL 60s — long enough to survive the browser → app bounce, short
53
+ * enough that a leaked callback URL goes stale before it travels;
54
+ * • single-use — the entry is consumed on FIRST redeem attempt
55
+ * (even an expired hit is deleted), so a replayed code is dead;
56
+ * • in-memory only — codes never need to survive a hub restart
57
+ * (the browser just re-runs the handoff), and a bounded store +
58
+ * prune-on-mint keeps the map from growing.
59
+ */
60
+ const crypto = __importStar(require("node:crypto"));
61
+ /** Wire prefix — `chc_` = CamStack Handoff Code (cf. `cst_`/`csv_`). */
62
+ exports.HANDOFF_CODE_PREFIX = 'chc_';
63
+ exports.HANDOFF_CODE_TTL_MS = 60_000;
64
+ /** Hard bound on concurrently-pending codes (mint is auth-gated, so this
65
+ * only guards against a runaway authenticated client). */
66
+ exports.HANDOFF_CODE_MAX_PENDING = 1_000;
67
+ function hashCode(code) {
68
+ return crypto.createHash('sha256').update(code).digest('hex');
69
+ }
70
+ class HandoffCodeService {
71
+ now;
72
+ ttlMs;
73
+ /** Keyed by SHA-256(raw code). */
74
+ pending = new Map();
75
+ constructor(now = Date.now, ttlMs = exports.HANDOFF_CODE_TTL_MS) {
76
+ this.now = now;
77
+ this.ttlMs = ttlMs;
78
+ }
79
+ /** Mint a single-use code bound to `userId`. Throws when the pending
80
+ * store is full even after pruning expired entries (fail fast — a
81
+ * legitimate flow never has anywhere near this many in flight). */
82
+ create(userId) {
83
+ this.prune();
84
+ if (this.pending.size >= exports.HANDOFF_CODE_MAX_PENDING) {
85
+ throw new Error('Too many pending handoff codes — try again shortly');
86
+ }
87
+ const code = `${exports.HANDOFF_CODE_PREFIX}${crypto.randomBytes(32).toString('hex')}`;
88
+ const expiresAt = this.now() + this.ttlMs;
89
+ this.pending.set(hashCode(code), { userId, expiresAt });
90
+ return { code, expiresAt };
91
+ }
92
+ /** Redeem a raw code. Consumes the entry on the FIRST attempt no
93
+ * matter the outcome (single-use); returns `null` for unknown,
94
+ * already-used, or expired codes — the router maps that to 401. */
95
+ redeem(code) {
96
+ const entry = this.pending.get(hashCode(code));
97
+ if (!entry)
98
+ return null;
99
+ this.pending.delete(hashCode(code));
100
+ if (this.now() > entry.expiresAt)
101
+ return null;
102
+ return { userId: entry.userId };
103
+ }
104
+ /** Number of not-yet-redeemed (possibly expired) codes. Test/diag aid. */
105
+ get pendingCount() {
106
+ return this.pending.size;
107
+ }
108
+ prune() {
109
+ const cutoff = this.now();
110
+ for (const [key, entry] of this.pending) {
111
+ if (cutoff > entry.expiresAt)
112
+ this.pending.delete(key);
113
+ }
114
+ }
115
+ }
116
+ exports.HandoffCodeService = HandoffCodeService;
package/dist/main.js CHANGED
@@ -51,6 +51,7 @@ const event_bus_service_1 = require("./core/events/event-bus.service");
51
51
  const config_service_1 = require("./core/config/config.service");
52
52
  const auth_service_1 = require("./core/auth/auth.service");
53
53
  const share_token_service_1 = require("./core/auth/share-token.service");
54
+ const handoff_code_service_1 = require("./core/auth/handoff-code.service");
54
55
  // Boot-time capability declaration runs over the auto-generated
55
56
  // `ALL_CAPABILITY_DEFINITIONS` array — every `*.cap.ts` file that ships
56
57
  // with `@camstack/types` is included automatically. Adding a new cap
@@ -353,9 +354,14 @@ async function bootstrap() {
353
354
  // the settings backend (lazy getter: the backend lands after the
354
355
  // sqlite-storage builtin registers; the service resolves it per call).
355
356
  const shareTokenService = new share_token_service_1.ShareTokenService(() => addonRegistry.getSettingsBackend(), loggingService.createLogger('share-tokens'));
357
+ // One-time native-app login handoff codes (in-memory, 60s TTL,
358
+ // single-use) — minted by `auth.createHandoffCode`, redeemed by the
359
+ // viewer app via the public `auth.redeemHandoffCode`.
360
+ const handoffCodeService = new handoff_code_service_1.HandoffCodeService();
356
361
  appRouter = (0, trpc_router_1.buildAppRouter)({
357
362
  authService,
358
363
  shareTokenService,
364
+ handoffCodeService,
359
365
  configService: config,
360
366
  featureService: app.get(feature_service_1.FeatureService),
361
367
  loggingService,
@@ -642,6 +648,13 @@ async function bootstrap() {
642
648
  let ttlSec;
643
649
  try {
644
650
  const payload = authService.verifyToken(token); // throws on invalid/expired
651
+ // SESSION-grade JWTs only: challenge/bridge tokens (`kind`-tagged,
652
+ // e.g. the totp-challenge from login leg 1) verify under the same
653
+ // secret — accepting one here handed out a cookie that bypassed
654
+ // the second factor on every cookie-gated surface.
655
+ if (!(0, session_cookie_js_1.isSessionGradeJwtPayload)(payload)) {
656
+ return reply.status(401).send({ error: 'invalid token' });
657
+ }
645
658
  const expSec = typeof payload.exp === 'number' ? payload.exp : 0;
646
659
  ttlSec = Math.max(0, expSec - Math.floor(Date.now() / 1000));
647
660
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.1.51",
3
+ "version": "1.1.52",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -23,18 +23,18 @@
23
23
  "test:watch": "vitest"
24
24
  },
25
25
  "dependencies": {
26
- "@camstack/addon-admin-ui": "1.1.43",
26
+ "@camstack/addon-admin-ui": "1.1.44",
27
27
  "@camstack/addon-advanced-notifier": "1.1.21",
28
28
  "@camstack/addon-auth": "1.1.5",
29
29
  "@camstack/addon-decoder-nodeav": "1.1.9",
30
30
  "@camstack/addon-notifiers": "1.1.21",
31
31
  "@camstack/addon-pipeline": "1.1.51",
32
- "@camstack/addon-pipeline-orchestrator": "1.1.38",
32
+ "@camstack/addon-pipeline-orchestrator": "1.1.39",
33
33
  "@camstack/addon-post-analysis": "1.1.23",
34
34
  "@camstack/sdk": "1.1.21",
35
35
  "@camstack/shm-ring": "1.0.21",
36
36
  "@camstack/system": "1.1.40",
37
- "@camstack/types": "1.1.37",
37
+ "@camstack/types": "1.1.38",
38
38
  "@camstack/ui-library": "1.1.31",
39
39
  "@fastify/compress": "^9.0.0",
40
40
  "@fastify/cookie": "^11.0.2",