@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
|
-
*
|
|
179
|
-
* tokens (`cst_*`) and share-view principals (`csv_*`) must
|
|
180
|
-
* list, or revoke share links —
|
|
181
|
-
*
|
|
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:
|
|
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
|
-
|
|
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,
|
|
269
|
-
// factor
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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 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');
|
|
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:
|
|
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
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
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.
|
|
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
|
-
"@camstack/addon-pipeline-orchestrator": "1.1.
|
|
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",
|