@camstack/server 1.1.51 → 1.1.53

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
@@ -74,6 +77,8 @@ const FinishDiscoverableAuthenticationSchema = zod_1.z.object({
74
77
  userId: zod_1.z.string().nullable(),
75
78
  });
76
79
  const RemovePasskeySchema = zod_1.z.object({ success: zod_1.z.literal(true) });
80
+ const SecondFactorPreferenceSchema = zod_1.z.object({ enabled: zod_1.z.boolean() });
81
+ const SetSecondFactorPreferenceSchema = zod_1.z.object({ success: zod_1.z.literal(true) });
77
82
  function proxyPasskeyProvider(proxy) {
78
83
  const call = async (method, params) => {
79
84
  const fn = proxy[method];
@@ -90,6 +95,8 @@ function proxyPasskeyProvider(proxy) {
90
95
  finishDiscoverableAuthentication: async (input) => FinishDiscoverableAuthenticationSchema.parse(await call('finishDiscoverableAuthentication', input)),
91
96
  listPasskeys: async (input) => zod_1.z.array(types_1.PasskeySummarySchema).parse(await call('listPasskeys', input)),
92
97
  removePasskey: async (input) => RemovePasskeySchema.parse(await call('removePasskey', input)),
98
+ getSecondFactorPreference: async (input) => SecondFactorPreferenceSchema.parse(await call('getSecondFactorPreference', input)),
99
+ setSecondFactorPreference: async (input) => SetSecondFactorPreferenceSchema.parse(await call('setSecondFactorPreference', input)),
93
100
  };
94
101
  }
95
102
  function resolvePasskeyProvider(registry, moleculer) {
@@ -115,6 +122,26 @@ async function userHasPasskey(userId, provider) {
115
122
  return false;
116
123
  }
117
124
  }
125
+ /**
126
+ * Whether the user OPTED IN to their passkey doubling as a mandatory
127
+ * second factor after a password login. Enrolling a passkey alone only
128
+ * enables passkey-FIRST sign-in — this flag (default OFF, stored by the
129
+ * `user-passkeys` provider) is what re-arms the 2FA gate. Never throws:
130
+ * a missing provider, a provider predating the method, or a transport
131
+ * hiccup all read as `false` so the preference lookup can never lock a
132
+ * user out of the password flow.
133
+ */
134
+ async function passkeySecondFactorOptedIn(userId, provider) {
135
+ if (!provider || typeof provider.getSecondFactorPreference !== 'function')
136
+ return false;
137
+ try {
138
+ const pref = await provider.getSecondFactorPreference({ userId });
139
+ return pref.enabled;
140
+ }
141
+ catch {
142
+ return false;
143
+ }
144
+ }
118
145
  // ── Login-method aggregation (login-method cap) ──────────────────────
119
146
  //
120
147
  // The PUBLIC `listLoginMethods` procedure walks the `login-method`
@@ -175,19 +202,40 @@ function toShareTokenSummary(record) {
175
202
  };
176
203
  }
177
204
  /**
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.
205
+ * Token minting/management surfaces are for REAL user sessions only.
206
+ * Scoped API tokens (`cst_*`) and share-view principals (`csv_*`) must
207
+ * never mint, list, or revoke share links — nor mint handoff codes
208
+ * a leaked restricted token would otherwise widen its own reach.
182
209
  */
183
- function assertRealUserSession(user) {
210
+ function assertRealUserSession(user, what = 'Share-token management') {
184
211
  if (user.isScoped || user.shareView) {
185
212
  throw new server_1.TRPCError({
186
213
  code: 'FORBIDDEN',
187
- message: 'Share-token management requires a real user session',
214
+ message: `${what} requires a real user session`,
188
215
  });
189
216
  }
190
217
  }
218
+ // ── Session exchange (viewer same-origin session reuse) ──────────────
219
+ //
220
+ // The viewer web build is served by the hub under `/viewer/camstack/`
221
+ // — SAME ORIGIN as the admin-ui. After an admin-ui login the session
222
+ // JWT is mirrored into the httpOnly `camstack_session` cookie
223
+ // (`POST /api/auth/session`); `auth.exchangeSession` lets the viewer
224
+ // upgrade that cookie back into a bearer token WITHOUT ever reading the
225
+ // cookie from JS (it's httpOnly — the browser attaches it, the server
226
+ // answers with a freshly-minted session).
227
+ /** Custom header a cross-site form can never set — CSRF gate for the
228
+ * cookie-authenticated `exchangeSession` mutation. */
229
+ exports.EXCHANGE_SESSION_HEADER = 'x-camstack-exchange';
230
+ /** Read a single-valued request header off the tRPC context request. */
231
+ function readRequestHeader(req, name) {
232
+ const value = req?.headers[name];
233
+ if (typeof value === 'string')
234
+ return value;
235
+ if (Array.isArray(value))
236
+ return value[0] ?? null;
237
+ return null;
238
+ }
191
239
  /** Wire shape of the authenticated user returned by `auth.me`. */
192
240
  const MeSchema = zod_1.z
193
241
  .object({
@@ -203,7 +251,7 @@ const MeSchema = zod_1.z
203
251
  agentId: zod_1.z.string().optional(),
204
252
  })
205
253
  .nullable();
206
- function createAuthRouter(auth, registry, moleculer = null, shareTokens = null) {
254
+ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null, handoffCodes = new handoff_code_service_js_1.HandoffCodeService()) {
207
255
  const requireShareTokens = () => {
208
256
  if (!shareTokens) {
209
257
  throw new Error('Share tokens unavailable — service not wired on this node');
@@ -217,7 +265,12 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null)
217
265
  * scopes / allowedDevices reflect any admin change made mid-flow —
218
266
  * signing from stale claims could mint a stale-scope session.
219
267
  */
220
- const mintSessionForUserId = async (userId) => {
268
+ /**
269
+ * Fresh user record for `userId` via `user-management` — shared by the
270
+ * session-mint tail and the second-factor challenge legs (both need
271
+ * live identity claims, never stale ones).
272
+ */
273
+ const findUserById = async (userId) => {
221
274
  const userMgmt = registry?.getSingleton('user-management');
222
275
  if (!userMgmt) {
223
276
  throw new Error('Login unavailable — `user-management` capability not registered');
@@ -228,6 +281,10 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null)
228
281
  if (!fresh) {
229
282
  throw new Error('User no longer exists');
230
283
  }
284
+ return fresh;
285
+ };
286
+ const mintSessionForUserId = async (userId) => {
287
+ const fresh = await findUserById(userId);
231
288
  const sessionToken = auth.signToken({
232
289
  userId: fresh.id,
233
290
  username: fresh.username,
@@ -242,6 +299,57 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null)
242
299
  requiresTotp: false,
243
300
  };
244
301
  };
302
+ /**
303
+ * `mintSessionForUserId` with the "user vanished" failure mapped to a
304
+ * clean 401 — for the token-exchange legs (`exchangeSession`,
305
+ * `redeemHandoffCode`) where the caller presented a credential whose
306
+ * backing user may have been deleted mid-flight. Infrastructure
307
+ * failures (user-management cap not registered) still surface as 500.
308
+ */
309
+ const mintSessionOrUnauthorized = async (userId) => {
310
+ try {
311
+ return await mintSessionForUserId(userId);
312
+ }
313
+ catch (error) {
314
+ if (error instanceof Error && error.message === 'User no longer exists') {
315
+ throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'User no longer exists' });
316
+ }
317
+ throw error;
318
+ }
319
+ };
320
+ /**
321
+ * The second factors `userId` must still satisfy after a successful
322
+ * PRIMARY factor. 2FA applies uniformly to every primary method:
323
+ *
324
+ * • `totp` — whenever enrolled, regardless of how the user signed in.
325
+ * • `passkey` — ONLY when the user both has an enrolled passkey AND
326
+ * explicitly opted in (`getSecondFactorPreference`, default OFF —
327
+ * enrolling a passkey alone just enables passkey-first sign-in),
328
+ * and NEVER when the primary factor already WAS the passkey
329
+ * (re-proving the factor that just signed in adds nothing).
330
+ *
331
+ * Passkey introspection is resilient (missing provider / transport
332
+ * error ⇒ no passkey factor) so it can never block a login; a TOTP
333
+ * status failure still propagates — fail-open there would silently
334
+ * skip an enrolled factor.
335
+ */
336
+ const collectSecondFactors = async (userId, primary) => {
337
+ const userMgmt = registry?.getSingleton('user-management');
338
+ const totpStatus = userMgmt && typeof userMgmt.getTotpStatus === 'function'
339
+ ? await userMgmt.getTotpStatus({ userId })
340
+ : { enabled: false };
341
+ const factors = [];
342
+ if (totpStatus.enabled)
343
+ factors.push('totp');
344
+ if (primary !== 'passkey') {
345
+ const provider = resolvePasskeyProvider(registry, moleculer);
346
+ const enrolled = await userHasPasskey(userId, provider);
347
+ if (enrolled && (await passkeySecondFactorOptedIn(userId, provider))) {
348
+ factors.push('passkey');
349
+ }
350
+ }
351
+ return factors;
352
+ };
245
353
  return (0, trpc_middleware_js_1.trpcRouter)({
246
354
  login: trpc_middleware_js_1.publicProcedure
247
355
  .input(zod_1.z.object({ username: zod_1.z.string(), password: zod_1.z.string() }))
@@ -265,23 +373,17 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null)
265
373
  if (!user)
266
374
  throw new Error('Invalid credentials');
267
375
  // ── Second-factor gate ───────────────────────────────────────
268
- // After credentials validate, check every enrolled second
269
- // factor. If ANY is present, mint a SHORT-LIVED challenge token
270
- // instead of the real session the client must complete one
271
- // factor before we hand out the actual JWT. The challenge token
272
- // carries `kind: 'totp-challenge'` so it can't be replayed
273
- // against protected endpoints (the auth middleware rejects
274
- // anything without the standard session shape); the same token
275
- // binds both the TOTP and the passkey second legs (same userId).
276
- const totpStatus = typeof userMgmt.getTotpStatus === 'function'
277
- ? await userMgmt.getTotpStatus({ userId: user.id })
278
- : { enabled: false };
279
- const passkeyEnrolled = await userHasPasskey(user.id, resolvePasskeyProvider(registry, moleculer));
280
- const secondFactors = [];
281
- if (totpStatus.enabled)
282
- secondFactors.push('totp');
283
- if (passkeyEnrolled)
284
- secondFactors.push('passkey');
376
+ // After credentials validate, collect every ACTIVE second
377
+ // factor (TOTP when enrolled; passkey only when enrolled AND
378
+ // the user opted insee `collectSecondFactors`). If ANY is
379
+ // present, mint a SHORT-LIVED challenge token instead of the
380
+ // real session the client must complete one factor before we
381
+ // hand out the actual JWT. The challenge token carries
382
+ // `kind: 'totp-challenge'` so it can't be replayed against
383
+ // protected endpoints (the auth middleware rejects anything
384
+ // without the standard session shape); the same token binds
385
+ // both the TOTP and the passkey second legs (same userId).
386
+ const secondFactors = await collectSecondFactors(user.id, 'password');
285
387
  if (secondFactors.length > 0) {
286
388
  const challengeToken = auth.signTotpChallengeToken({
287
389
  userId: user.id,
@@ -291,7 +393,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null)
291
393
  return {
292
394
  token: challengeToken,
293
395
  user: { id: user.id, username: user.username, isAdmin: user.isAdmin },
294
- requiresTotp: totpStatus.enabled,
396
+ requiresTotp: secondFactors.includes('totp'),
295
397
  secondFactors,
296
398
  };
297
399
  }
@@ -425,9 +527,12 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null)
425
527
  * `auth.login` mints — via the shared `mintSessionForUserId` tail
426
528
  * (fresh user re-fetch for up-to-date scopes).
427
529
  *
428
- * NO second-factor bounce: a discoverable passkey assertion with
429
- * user verification is already a strong (possession + inherence/
430
- * knowledge) factor enrolled TOTP is irrelevant on this path.
530
+ * Second-factor gate SAME rules as `auth.login`: 2FA applies
531
+ * uniformly after any primary method, so an enrolled TOTP bounces
532
+ * this leg to the challenge stage (same challenge-token LoginResult
533
+ * shape, completed via `loginVerifyTotp`). The passkey factor
534
+ * itself is skipped here — the primary factor WAS the passkey, and
535
+ * re-proving it adds nothing.
431
536
  */
432
537
  passkeyLoginFinish: trpc_middleware_js_1.publicProcedure
433
538
  .input(zod_1.z.object({ response: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()) }))
@@ -442,8 +547,111 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null)
442
547
  if (!result.verified || !result.userId) {
443
548
  throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'Passkey verification failed' });
444
549
  }
550
+ const secondFactors = await collectSecondFactors(result.userId, 'passkey');
551
+ if (secondFactors.length > 0) {
552
+ const challenged = await findUserById(result.userId);
553
+ const challengeToken = auth.signTotpChallengeToken({
554
+ userId: challenged.id,
555
+ username: challenged.username,
556
+ isAdmin: challenged.isAdmin,
557
+ });
558
+ return {
559
+ token: challengeToken,
560
+ user: {
561
+ id: challenged.id,
562
+ username: challenged.username,
563
+ isAdmin: challenged.isAdmin,
564
+ },
565
+ requiresTotp: secondFactors.includes('totp'),
566
+ secondFactors,
567
+ };
568
+ }
445
569
  return mintSessionForUserId(result.userId);
446
570
  }),
571
+ /**
572
+ * PUBLIC — upgrade the browser's httpOnly session COOKIE into a
573
+ * bearer token (viewer same-origin session reuse). Authentication
574
+ * comes from the `camstack_session` cookie ONLY — never from a
575
+ * bearer header — so a hub-served SPA (the viewer under
576
+ * `/viewer/camstack/`) can bootstrap without its own login.
577
+ *
578
+ * Security properties:
579
+ * • CSRF: mutation (POST) + a REQUIRED custom header
580
+ * (`x-camstack-exchange: 1`) that a cross-site form can't set;
581
+ * belt-and-braces on top of the cookie's `SameSite=Lax`
582
+ * semantics (see `buildSessionCookie` — Lax already withholds
583
+ * the cookie from cross-site POSTs).
584
+ * • Only REAL session JWTs qualify: bridge tokens (`kind:
585
+ * 'totp-challenge'` / `'sso-bridge'`) and `cst_`/`csv_` opaque
586
+ * tokens are rejected — the cookie must carry a v2 session.
587
+ * • The returned bearer is minted FRESH through the shared
588
+ * `mintSessionForUserId` tail (re-fetched user → up-to-date
589
+ * scopes), equivalent to what `auth.login` hands out.
590
+ */
591
+ exchangeSession: trpc_middleware_js_1.publicProcedure
592
+ .input(zod_1.z.object({}).optional())
593
+ .output(LoginResultSchema)
594
+ .mutation(async ({ ctx }) => {
595
+ if (readRequestHeader(ctx.req, exports.EXCHANGE_SESSION_HEADER) !== '1') {
596
+ throw new server_1.TRPCError({
597
+ code: 'FORBIDDEN',
598
+ message: `Missing ${exports.EXCHANGE_SESSION_HEADER} header`,
599
+ });
600
+ }
601
+ const cookieToken = (0, session_cookie_js_1.readSessionCookieFromHeader)(readRequestHeader(ctx.req, 'cookie') ?? undefined);
602
+ if (!cookieToken) {
603
+ throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'No session cookie' });
604
+ }
605
+ let payload;
606
+ try {
607
+ payload = auth.verifyToken(cookieToken);
608
+ }
609
+ catch {
610
+ throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid session cookie' });
611
+ }
612
+ // Reject non-session JWTs that verify under the same secret:
613
+ // challenge/bridge tokens carry a `kind` discriminator, and a
614
+ // v2 session always has a boolean `isAdmin` + string `userId`.
615
+ const kind = Reflect.get(payload, 'kind');
616
+ if (kind !== undefined ||
617
+ typeof payload.isAdmin !== 'boolean' ||
618
+ typeof payload.userId !== 'string') {
619
+ throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid session cookie' });
620
+ }
621
+ return mintSessionOrUnauthorized(payload.userId);
622
+ }),
623
+ // ── One-time handoff codes (native-app login handoff) ─────────────
624
+ //
625
+ // The admin-ui login page, when its `redirect` param is the app's
626
+ // custom-scheme callback, mints a code AFTER a successful login and
627
+ // bounces to `camstack://auth-callback?code=…`; the app redeems it
628
+ // for a real session. TTL 60s, single-use, bound to the minting
629
+ // user — see `HandoffCodeService`.
630
+ /** Mint a one-time handoff code for the CALLING user. Real user
631
+ * sessions only — scoped (`cst_`) and share-view (`csv_`) callers
632
+ * must never convert themselves into a full session. */
633
+ createHandoffCode: trpc_middleware_js_1.protectedProcedure
634
+ .input(zod_1.z.void())
635
+ .output(zod_1.z.object({ code: zod_1.z.string(), expiresAt: zod_1.z.number() }))
636
+ .mutation(({ ctx }) => {
637
+ assertRealUserSession(ctx.user, 'Handoff-code minting');
638
+ return handoffCodes.create(ctx.user.id);
639
+ }),
640
+ /** PUBLIC — redeem a one-time handoff code for a session bearer.
641
+ * Unknown / expired / already-used codes → UNAUTHORIZED. */
642
+ redeemHandoffCode: trpc_middleware_js_1.publicProcedure
643
+ .input(zod_1.z.object({ code: zod_1.z.string().min(1) }))
644
+ .output(LoginResultSchema)
645
+ .mutation(async ({ input }) => {
646
+ const grant = handoffCodes.redeem(input.code);
647
+ if (!grant) {
648
+ throw new server_1.TRPCError({
649
+ code: 'UNAUTHORIZED',
650
+ message: 'Invalid or expired handoff code',
651
+ });
652
+ }
653
+ return mintSessionOrUnauthorized(grant.userId);
654
+ }),
447
655
  me: trpc_middleware_js_1.protectedProcedure
448
656
  .input(zod_1.z.void())
449
657
  .output(MeSchema)
@@ -587,6 +795,44 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null)
587
795
  throw new Error('Passkey management is not available');
588
796
  return provider.removePasskey({ userId: ctx.user.id, credentialId: input.credentialId });
589
797
  }),
798
+ /**
799
+ * Own passkey second-factor preference (opt-in, default OFF).
800
+ * Enrolling a passkey only enables passkey-first sign-in; flipping
801
+ * this on additionally demands the passkey as a second factor after
802
+ * every password login. Bound to `ctx.user.id` like the rest of the
803
+ * self-service block; reads degrade to `enabled: false` so the "My
804
+ * access" page renders even while the provider is booting.
805
+ */
806
+ getOwnPasskeySecondFactorPreference: trpc_middleware_js_1.protectedProcedure
807
+ .input(zod_1.z.void())
808
+ .output(SecondFactorPreferenceSchema)
809
+ .query(async ({ ctx }) => {
810
+ if (!ctx.user)
811
+ return { enabled: false };
812
+ const provider = resolvePasskeyProvider(registry, moleculer);
813
+ if (!provider)
814
+ return { enabled: false };
815
+ try {
816
+ return await provider.getSecondFactorPreference({ userId: ctx.user.id });
817
+ }
818
+ catch {
819
+ return { enabled: false };
820
+ }
821
+ }),
822
+ setOwnPasskeySecondFactorPreference: trpc_middleware_js_1.protectedProcedure
823
+ .input(zod_1.z.object({ enabled: zod_1.z.boolean() }))
824
+ .output(SetSecondFactorPreferenceSchema)
825
+ .mutation(async ({ input, ctx }) => {
826
+ if (!ctx.user)
827
+ throw new Error('Not authenticated');
828
+ const provider = resolvePasskeyProvider(registry, moleculer);
829
+ if (!provider)
830
+ throw new Error('Passkey management is not available');
831
+ return provider.setSecondFactorPreference({
832
+ userId: ctx.user.id,
833
+ enabled: input.enabled,
834
+ });
835
+ }),
590
836
  // ── Share tokens — scoped, TTL'd view tokens for standalone share
591
837
  // links (grid rework R6). Minted by any REAL user session (admin
592
838
  // or regular); the resulting `csv_*` token authenticates as a
@@ -7559,6 +7559,24 @@ function createCapRouter_userPasskeys(getProvider, createRemoteProxy) {
7559
7559
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
7560
7560
  return p.removePasskey(methodInput);
7561
7561
  }),
7562
+ getSecondFactorPreference: trpc_middleware_js_1.adminProcedure
7563
+ .input(types_105.userPasskeysCapability.methods.getSecondFactorPreference.input.loose())
7564
+ .output(types_105.userPasskeysCapability.methods.getSecondFactorPreference.output)
7565
+ .query(async ({ input, ctx }) => {
7566
+ const { nodeId, addonId, ...methodInput } = input;
7567
+ const p = resolveProvider('user-passkeys', nodeId, () => getProvider(ctx, addonId), createRemoteProxy);
7568
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
7569
+ return p.getSecondFactorPreference(methodInput);
7570
+ }),
7571
+ setSecondFactorPreference: trpc_middleware_js_1.adminProcedure
7572
+ .input(types_105.userPasskeysCapability.methods.setSecondFactorPreference.input.loose())
7573
+ .output(types_105.userPasskeysCapability.methods.setSecondFactorPreference.output)
7574
+ .mutation(async ({ input, ctx }) => {
7575
+ const { nodeId, addonId, ...methodInput } = input;
7576
+ const p = resolveProvider('user-passkeys', nodeId, () => getProvider(ctx, addonId), createRemoteProxy);
7577
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
7578
+ return p.setSecondFactorPreference(methodInput);
7579
+ }),
7562
7580
  });
7563
7581
  }
7564
7582
  function createCapRouter_vacuumControl(getProvider, createRemoteProxy) {
@@ -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.53",
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.45",
27
27
  "@camstack/addon-advanced-notifier": "1.1.21",
28
- "@camstack/addon-auth": "1.1.5",
28
+ "@camstack/addon-auth": "1.1.6",
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
- "@camstack/sdk": "1.1.21",
34
+ "@camstack/sdk": "1.1.22",
35
35
  "@camstack/shm-ring": "1.0.21",
36
- "@camstack/system": "1.1.40",
37
- "@camstack/types": "1.1.37",
36
+ "@camstack/system": "1.1.41",
37
+ "@camstack/types": "1.1.39",
38
38
  "@camstack/ui-library": "1.1.31",
39
39
  "@fastify/compress": "^9.0.0",
40
40
  "@fastify/cookie": "^11.0.2",