@camstack/server 1.1.52 → 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.
@@ -77,6 +77,8 @@ const FinishDiscoverableAuthenticationSchema = zod_1.z.object({
77
77
  userId: zod_1.z.string().nullable(),
78
78
  });
79
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) });
80
82
  function proxyPasskeyProvider(proxy) {
81
83
  const call = async (method, params) => {
82
84
  const fn = proxy[method];
@@ -93,6 +95,8 @@ function proxyPasskeyProvider(proxy) {
93
95
  finishDiscoverableAuthentication: async (input) => FinishDiscoverableAuthenticationSchema.parse(await call('finishDiscoverableAuthentication', input)),
94
96
  listPasskeys: async (input) => zod_1.z.array(types_1.PasskeySummarySchema).parse(await call('listPasskeys', input)),
95
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)),
96
100
  };
97
101
  }
98
102
  function resolvePasskeyProvider(registry, moleculer) {
@@ -118,6 +122,26 @@ async function userHasPasskey(userId, provider) {
118
122
  return false;
119
123
  }
120
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
+ }
121
145
  // ── Login-method aggregation (login-method cap) ──────────────────────
122
146
  //
123
147
  // The PUBLIC `listLoginMethods` procedure walks the `login-method`
@@ -241,7 +265,12 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
241
265
  * scopes / allowedDevices reflect any admin change made mid-flow —
242
266
  * signing from stale claims could mint a stale-scope session.
243
267
  */
244
- 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) => {
245
274
  const userMgmt = registry?.getSingleton('user-management');
246
275
  if (!userMgmt) {
247
276
  throw new Error('Login unavailable — `user-management` capability not registered');
@@ -252,6 +281,10 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
252
281
  if (!fresh) {
253
282
  throw new Error('User no longer exists');
254
283
  }
284
+ return fresh;
285
+ };
286
+ const mintSessionForUserId = async (userId) => {
287
+ const fresh = await findUserById(userId);
255
288
  const sessionToken = auth.signToken({
256
289
  userId: fresh.id,
257
290
  username: fresh.username,
@@ -284,6 +317,39 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
284
317
  throw error;
285
318
  }
286
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
+ };
287
353
  return (0, trpc_middleware_js_1.trpcRouter)({
288
354
  login: trpc_middleware_js_1.publicProcedure
289
355
  .input(zod_1.z.object({ username: zod_1.z.string(), password: zod_1.z.string() }))
@@ -307,23 +373,17 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
307
373
  if (!user)
308
374
  throw new Error('Invalid credentials');
309
375
  // ── Second-factor gate ───────────────────────────────────────
310
- // After credentials validate, check every enrolled second
311
- // factor. If ANY is present, mint a SHORT-LIVED challenge token
312
- // instead of the real session the client must complete one
313
- // factor before we hand out the actual JWT. The challenge token
314
- // carries `kind: 'totp-challenge'` so it can't be replayed
315
- // against protected endpoints (the auth middleware rejects
316
- // anything without the standard session shape); the same token
317
- // binds both the TOTP and the passkey second legs (same userId).
318
- const totpStatus = typeof userMgmt.getTotpStatus === 'function'
319
- ? await userMgmt.getTotpStatus({ userId: user.id })
320
- : { enabled: false };
321
- const passkeyEnrolled = await userHasPasskey(user.id, resolvePasskeyProvider(registry, moleculer));
322
- const secondFactors = [];
323
- if (totpStatus.enabled)
324
- secondFactors.push('totp');
325
- if (passkeyEnrolled)
326
- 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');
327
387
  if (secondFactors.length > 0) {
328
388
  const challengeToken = auth.signTotpChallengeToken({
329
389
  userId: user.id,
@@ -333,7 +393,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
333
393
  return {
334
394
  token: challengeToken,
335
395
  user: { id: user.id, username: user.username, isAdmin: user.isAdmin },
336
- requiresTotp: totpStatus.enabled,
396
+ requiresTotp: secondFactors.includes('totp'),
337
397
  secondFactors,
338
398
  };
339
399
  }
@@ -467,9 +527,12 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
467
527
  * `auth.login` mints — via the shared `mintSessionForUserId` tail
468
528
  * (fresh user re-fetch for up-to-date scopes).
469
529
  *
470
- * NO second-factor bounce: a discoverable passkey assertion with
471
- * user verification is already a strong (possession + inherence/
472
- * 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.
473
536
  */
474
537
  passkeyLoginFinish: trpc_middleware_js_1.publicProcedure
475
538
  .input(zod_1.z.object({ response: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()) }))
@@ -484,6 +547,25 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
484
547
  if (!result.verified || !result.userId) {
485
548
  throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'Passkey verification failed' });
486
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
+ }
487
569
  return mintSessionForUserId(result.userId);
488
570
  }),
489
571
  /**
@@ -713,6 +795,44 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
713
795
  throw new Error('Passkey management is not available');
714
796
  return provider.removePasskey({ userId: ctx.user.id, credentialId: input.credentialId });
715
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
+ }),
716
836
  // ── Share tokens — scoped, TTL'd view tokens for standalone share
717
837
  // links (grid rework R6). Minted by any REAL user session (admin
718
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.1.52",
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.44",
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
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.38",
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",