@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
|
-
|
|
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,
|
|
311
|
-
// factor
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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 in — see `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:
|
|
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
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
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.
|
|
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.
|
|
26
|
+
"@camstack/addon-admin-ui": "1.1.45",
|
|
27
27
|
"@camstack/addon-advanced-notifier": "1.1.21",
|
|
28
|
-
"@camstack/addon-auth": "1.1.
|
|
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.
|
|
34
|
+
"@camstack/sdk": "1.1.22",
|
|
35
35
|
"@camstack/shm-ring": "1.0.21",
|
|
36
|
-
"@camstack/system": "1.1.
|
|
37
|
-
"@camstack/types": "1.1.
|
|
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",
|