@camstack/server 1.2.486 → 1.2.487

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.
Files changed (36) hide show
  1. package/dist/agent/agent-mesh-join.js +80 -0
  2. package/dist/agent/main.js +61 -2
  3. package/dist/api/auth-whoami.js +1 -1
  4. package/dist/api/core/auth-admin-tokens.procedures.js +138 -0
  5. package/dist/api/core/auth-limits.js +77 -0
  6. package/dist/api/core/auth-reauth.procedures.js +108 -0
  7. package/dist/api/core/auth-sessions.procedures.js +166 -0
  8. package/dist/api/core/auth.router.js +281 -80
  9. package/dist/api/health/adaptive-probe.routes.js +1 -1
  10. package/dist/api/trpc/core-cap-bridge.js +47 -5
  11. package/dist/api/trpc/generated-cap-mounts.js +19 -1
  12. package/dist/api/trpc/generated-cap-routers.js +512 -205
  13. package/dist/api/trpc/trpc.context.js +86 -34
  14. package/dist/api/trpc/trpc.middleware.js +8 -11
  15. package/dist/api/trpc/trpc.router.js +6 -1
  16. package/dist/auth/auth-network-policy.js +75 -0
  17. package/dist/auth/auth-rate-limit.js +25 -20
  18. package/dist/auth/client-address.js +68 -0
  19. package/dist/auth/external-link-intents.js +12 -3
  20. package/dist/auth/hub-sessions.js +74 -0
  21. package/dist/auth/native-link-completions.js +68 -0
  22. package/dist/auth/native-return.js +33 -0
  23. package/dist/auth/session-lifetimes.js +46 -0
  24. package/dist/auth/session-mint.js +110 -13
  25. package/dist/auth/sso-finish-deps.js +56 -1
  26. package/dist/auth/sso-finish.js +132 -17
  27. package/dist/auth/sso-routes.js +95 -0
  28. package/dist/auth/used-bridge-ids.js +52 -0
  29. package/dist/core/addon/addon-registry.service.js +5 -0
  30. package/dist/core/agent/agent-registry.service.js +10 -0
  31. package/dist/core/auth/auth.service.js +50 -1
  32. package/dist/core/auth/handoff-code.service.js +9 -3
  33. package/dist/core/moleculer/hub-mesh-guard.js +132 -0
  34. package/dist/core/moleculer/moleculer.service.js +33 -0
  35. package/dist/main.js +92 -28
  36. package/package.json +13 -13
@@ -8,58 +8,71 @@
8
8
  * JWT signing stays in the server — it's a transport-level concern
9
9
  * (the server owns the secret and the HTTP session).
10
10
  *
11
- * Pre-auth login surfaces (OIDC / magic-link redirect buttons + the
11
+ * Pre-auth login surfaces (OIDC redirect buttons + the
12
12
  * passkey second-factor widget) are aggregated for the login page by the
13
13
  * public `listLoginMethods` procedure from the `login-method` cap
14
14
  * collection — the single, generic mechanism every auth addon contributes
15
15
  * to (superseding the removed `auth.listProviders`).
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.EXCHANGE_SESSION_HEADER = void 0;
18
+ exports.EXCHANGE_SESSION_HEADER = exports.BROWSER_HANDOFF_PATH = void 0;
19
19
  exports.createAuthRouter = createAuthRouter;
20
20
  const types_1 = require("@camstack/types");
21
21
  const server_1 = require("@trpc/server");
22
22
  const zod_1 = require("zod");
23
+ const auth_network_policy_js_1 = require("../../auth/auth-network-policy.js");
23
24
  const auth_rate_limit_js_1 = require("../../auth/auth-rate-limit.js");
24
25
  const auth_provider_gate_js_1 = require("../../auth/auth-provider-gate.js");
25
26
  const external_link_intents_js_1 = require("../../auth/external-link-intents.js");
26
27
  const passkey_provider_js_1 = require("../../auth/passkey-provider.js");
27
28
  const second_factors_js_1 = require("../../auth/second-factors.js");
28
29
  const session_cookie_js_1 = require("../../auth/session-cookie.js");
30
+ const hub_sessions_js_1 = require("../../auth/hub-sessions.js");
29
31
  const session_mint_js_1 = require("../../auth/session-mint.js");
30
32
  const handoff_code_service_js_1 = require("../../core/auth/handoff-code.service.js");
31
33
  const share_token_service_js_1 = require("../../core/auth/share-token.service.js");
32
34
  const principal_visibility_js_1 = require("../trpc/principal-visibility.js");
33
35
  const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
34
- // ── Public-auth rate limiting ────────────────────────────────────────
35
- // The public surface below (credential validation, passkey ceremonies,
36
- // one-time-code redemption) had NO throttling — unlimited online
37
- // guessing. Two tiers, fixed window per (procedure, client ip):
38
- // • credential tier — anything that VERIFIES a secret (password, TOTP,
39
- // passkey assertion, handoff code, session exchange): 10/min,
40
- // • ceremony tier — begin-legs that only mint a challenge: 30/min.
41
- // Mesh-internal calls (no originating request) are never limited.
42
- const CREDENTIAL_ATTEMPT_LIMITER = (0, auth_rate_limit_js_1.createRateLimiter)({ windowMs: 60_000, max: 10 });
43
- const CEREMONY_LIMITER = (0, auth_rate_limit_js_1.createRateLimiter)({ windowMs: 60_000, max: 30 });
44
- function makeRateLimitedProcedure(limiter) {
36
+ const auth_limits_js_1 = require("./auth-limits.js");
37
+ const auth_admin_tokens_procedures_js_1 = require("./auth-admin-tokens.procedures.js");
38
+ const auth_reauth_procedures_js_1 = require("./auth-reauth.procedures.js");
39
+ const auth_sessions_procedures_js_1 = require("./auth-sessions.procedures.js");
40
+ /**
41
+ * A public procedure limited per (procedure, REAL client address) — see
42
+ * `auth-limits.ts` for the tiers and why the address is the forwarded client.
43
+ */
44
+ function makeRateLimitedProcedure(limiter, network) {
45
45
  return trpc_middleware_js_1.publicProcedure.use(({ ctx, path, next }) => {
46
- const key = (0, auth_rate_limit_js_1.clientKeyFromRequest)(ctx.req);
47
- if (key !== null) {
48
- const verdict = limiter.check(`${path}:${key}`);
49
- if (!verdict.allowed) {
50
- throw new server_1.TRPCError({
51
- code: 'TOO_MANY_REQUESTS',
52
- message: `Too many attempts — retry in ${verdict.retryAfterSeconds}s`,
53
- });
54
- }
55
- }
46
+ (0, auth_limits_js_1.limitByClient)(limiter, path, ctx.req, network);
56
47
  return next({ ctx });
57
48
  });
58
49
  }
59
- /** Secret-verifying legs (password / TOTP / passkey assertion / codes). */
60
- const credentialProcedure = makeRateLimitedProcedure(CREDENTIAL_ATTEMPT_LIMITER);
61
- /** Challenge-minting begin-legs — looser, they verify nothing. */
62
- const ceremonyProcedure = makeRateLimitedProcedure(CEREMONY_LIMITER);
50
+ /** Asks for the refresh-token flow (short access token + rotating refresh token). */
51
+ const RefreshableInput = zod_1.z.boolean().optional();
52
+ /** The request's User-Agent, trimmed, for the sessions list. */
53
+ function clientLabel(req) {
54
+ const ua = req?.headers['user-agent'];
55
+ const value = Array.isArray(ua) ? ua[0] : ua;
56
+ return typeof value === 'string' && value.length > 0 ? value.slice(0, 200) : undefined;
57
+ }
58
+ /**
59
+ * Signed-in legs that mint or redeem a one-time code for the CALLER (native
60
+ * link claim, browser handoff — D801): per user, not per ip, because the
61
+ * caller is known and a tunnel puts every client behind one address.
62
+ */
63
+ const SELF_CODE_LIMITER = (0, auth_rate_limit_js_1.createRateLimiter)({ windowMs: 60_000, max: 10 });
64
+ const selfCodeProcedure = trpc_middleware_js_1.protectedProcedure.use(({ ctx, path, next }) => {
65
+ const verdict = SELF_CODE_LIMITER.check(`${path}:${ctx.user?.id ?? 'anonymous'}`);
66
+ if (!verdict.allowed) {
67
+ throw new server_1.TRPCError({
68
+ code: 'TOO_MANY_REQUESTS',
69
+ message: `Too many attempts — retry in ${verdict.retryAfterSeconds}s`,
70
+ });
71
+ }
72
+ return next({ ctx });
73
+ });
74
+ /** The hub route a browser-handoff code is redeemed at (`main.ts`). */
75
+ exports.BROWSER_HANDOFF_PATH = '/api/auth/browser-handoff';
63
76
  /**
64
77
  * The available second-factor kinds a user may satisfy after the
65
78
  * password leg. Capability-driven — `totp` maps to the
@@ -92,12 +105,23 @@ const LoginResultSchema = zod_1.z.object({
92
105
  }),
93
106
  requiresTotp: zod_1.z.boolean().optional(),
94
107
  secondFactors: zod_1.z.array(second_factors_js_1.SecondFactorSchema).optional(),
108
+ /** When the access token expires (epoch ms). Absent on a challenge. */
109
+ expiresAt: zod_1.z.number().optional(),
110
+ /** The server-side login session. */
111
+ sessionId: zod_1.z.string().optional(),
112
+ /** When the session expires unless refreshed (epoch ms). */
113
+ sessionExpiresAt: zod_1.z.number().optional(),
114
+ /**
115
+ * Only when the client asked (`refreshable: true`): swap it at
116
+ * `auth.refreshSession` before `expiresAt`. Store it like a password.
117
+ */
118
+ refreshToken: zod_1.z.string().optional(),
95
119
  });
96
120
  // ── Login-method aggregation (login-method cap) ──────────────────────
97
121
  //
98
122
  // The PUBLIC `listLoginMethods` procedure walks the `login-method`
99
123
  // collection and returns the union that the login page renders directly:
100
- // `redirect` buttons (OIDC / magic-link), native `passkey` ceremonies, and
124
+ // `redirect` buttons (OIDC), native `passkey` ceremonies, and
101
125
  // (generic mechanism, unused by shipped addons) pre-auth `widget`s.
102
126
  // The widget arm is enriched server-side with a public `bundleUrl` from
103
127
  // `addonId` + `bundle`, so the addon never encodes the static-route
@@ -177,7 +201,9 @@ function toShareTokenSummary(record) {
177
201
  * a leaked restricted token would otherwise widen its own reach.
178
202
  */
179
203
  function assertRealUserSession(user, what = 'Share-token management') {
180
- if (user.isScoped || user.shareView) {
204
+ // An admin token is a script's credential, never a person's session: it
205
+ // must not turn itself into a session (handoff codes) or mint links.
206
+ if (user.isScoped || user.shareView || user.adminTokenId !== undefined) {
181
207
  throw new server_1.TRPCError({
182
208
  code: 'FORBIDDEN',
183
209
  message: `${what} requires a real user session`,
@@ -246,7 +272,6 @@ const MeSchema = zod_1.z
246
272
  allowedDevices: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
247
273
  }),
248
274
  isApiKey: zod_1.z.boolean(),
249
- agentId: zod_1.z.string().optional(),
250
275
  /**
251
276
  * The caller's resolved effective scope. Absent only on the (unreachable
252
277
  * here — `me` is `protected`) null-principal branch.
@@ -254,7 +279,13 @@ const MeSchema = zod_1.z
254
279
  scope: EffectiveScopeSchema.optional(),
255
280
  })
256
281
  .nullable();
257
- function createAuthRouter(auth, registry, moleculer = null, shareTokens = null, handoffCodes = new handoff_code_service_js_1.HandoffCodeService(), linkIntents = new external_link_intents_js_1.ExternalLinkIntents()) {
282
+ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null, handoffCodes = new handoff_code_service_js_1.HandoffCodeService(), linkIntents = new external_link_intents_js_1.ExternalLinkIntents(), nativeLink = null, options = {}) {
283
+ const network = options.network ?? auth_network_policy_js_1.DEFAULT_AUTH_NETWORK_POLICY;
284
+ const limiters = options.limiters ?? auth_limits_js_1.PROCESS_AUTH_LIMITERS;
285
+ /** Secret-verifying legs (password / TOTP / passkey assertion / codes). */
286
+ const credentialProcedure = makeRateLimitedProcedure(limiters.credential, network);
287
+ /** Challenge-minting begin-legs — looser, they verify nothing. */
288
+ const ceremonyProcedure = makeRateLimitedProcedure(limiters.ceremony, network);
258
289
  const requireShareTokens = () => {
259
290
  if (!shareTokens) {
260
291
  throw new Error('Share tokens unavailable — service not wired on this node');
@@ -271,9 +302,21 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
271
302
  const minter = (0, session_mint_js_1.createSessionMinter)({
272
303
  signer: auth,
273
304
  directory: () => registry?.getSingleton('user-management'),
305
+ ...(options.sessions !== undefined ? { sessions: options.sessions } : {}),
274
306
  });
275
307
  const findUserById = minter.findUser;
276
- const mintSessionForUserId = minter.mintForUserId;
308
+ /** How THIS request's login is recorded on its session. */
309
+ const issueFor = (req, method, refreshable) => {
310
+ const client = clientLabel(req);
311
+ const ip = network.clientAddress(req).ip;
312
+ return {
313
+ method,
314
+ refreshable: refreshable === true,
315
+ ...(client !== undefined ? { client } : {}),
316
+ ...(ip !== null ? { ip } : {}),
317
+ };
318
+ };
319
+ const mintSessionForUserId = (userId, issue) => minter.mintForUserId(userId, issue);
277
320
  /**
278
321
  * `mintSessionForUserId` with the "user vanished" failure mapped to a
279
322
  * clean 401 — for the token-exchange legs (`exchangeSession`,
@@ -281,9 +324,9 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
281
324
  * backing user may have been deleted mid-flight. Infrastructure
282
325
  * failures (user-management cap not registered) still surface as 500.
283
326
  */
284
- const mintSessionOrUnauthorized = async (userId) => {
327
+ const mintSessionOrUnauthorized = async (userId, issue) => {
285
328
  try {
286
- return await mintSessionForUserId(userId);
329
+ return await mintSessionForUserId(userId, issue);
287
330
  }
288
331
  catch (error) {
289
332
  if (error instanceof session_mint_js_1.SessionUserGoneError) {
@@ -302,6 +345,30 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
302
345
  passkeyProvider: () => (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer),
303
346
  }, userId, primary);
304
347
  };
348
+ /**
349
+ * The second factors THIS login owes: none when the operator listed a
350
+ * trusted network, the request came DIRECTLY from it (never through the
351
+ * tunnel or any proxy — `auth-network-policy.ts`), and the user has
352
+ * factors to skip. Every skip is audited.
353
+ */
354
+ const factorsOwedHere = async (userId, primary, req) => {
355
+ const factors = await collectSecondFactorsFor(userId, primary);
356
+ if (factors.length === 0)
357
+ return factors;
358
+ const address = network.clientAddress(req);
359
+ if (!network.isTrustedNetworkLogin(address))
360
+ return factors;
361
+ const internal = registry === null ? null : (0, hub_sessions_js_1.userManagementInternal)(registry);
362
+ if (internal === null)
363
+ return factors; // cannot audit it → do not skip it
364
+ await internal.audit.record({
365
+ action: 'second-factor-skipped',
366
+ userId,
367
+ ...(address.ip !== null ? { ip: address.ip } : {}),
368
+ detail: { method: primary, factors: factors.join(',') },
369
+ });
370
+ return [];
371
+ };
305
372
  /**
306
373
  * The login page's methods: every enabled auth addon's `login-method`
307
374
  * contributions (D777), validated and deduplicated. Shared by
@@ -312,7 +379,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
312
379
  return [];
313
380
  const out = [];
314
381
  // Defense-in-depth dedupe: a routing bug in a grouped runner once
315
- // resolved every provider to the same addon (3× identical magic-link
382
+ // resolved every provider to the same addon (3× identical login
316
383
  // buttons) — a duplicated contribution id must never render twice.
317
384
  const seenIds = new Set();
318
385
  for (const [addonId, provider] of registry.getCollectionEntries('login-method')) {
@@ -379,9 +446,9 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
379
446
  : []);
380
447
  return (0, trpc_middleware_js_1.trpcRouter)({
381
448
  login: credentialProcedure
382
- .input(zod_1.z.object({ username: zod_1.z.string(), password: zod_1.z.string() }))
449
+ .input(zod_1.z.object({ username: zod_1.z.string(), password: zod_1.z.string(), refreshable: RefreshableInput }))
383
450
  .output(LoginResultSchema)
384
- .mutation(async ({ input }) => {
451
+ .mutation(async ({ input, ctx }) => {
385
452
  const userMgmt = registry?.getSingleton('user-management');
386
453
  if (!userMgmt) {
387
454
  // The local-auth addon owns this cap. When this fires the
@@ -393,12 +460,15 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
393
460
  'The `local-auth` addon failed to initialize; check server logs ' +
394
461
  'for an error tagged `[hub/local-auth]`.');
395
462
  }
463
+ (0, auth_limits_js_1.assertAccountNotLocked)(limiters, input.username);
396
464
  const user = await userMgmt.validateCredentials({
397
465
  username: input.username,
398
466
  password: input.password,
399
467
  });
400
- if (!user)
468
+ if (!user) {
469
+ (0, auth_limits_js_1.recordAccountFailure)(limiters, input.username);
401
470
  throw new Error('Invalid credentials');
471
+ }
402
472
  // ── Second-factor gate ───────────────────────────────────────
403
473
  // After credentials validate, collect every ACTIVE second
404
474
  // factor (TOTP when enrolled; passkey only when enrolled AND
@@ -410,12 +480,13 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
410
480
  // protected endpoints (the auth middleware rejects anything
411
481
  // without the standard session shape); the same token binds
412
482
  // both the TOTP and the passkey second legs (same userId).
413
- const secondFactors = await collectSecondFactorsFor(user.id, 'password');
483
+ const secondFactors = await factorsOwedHere(user.id, 'password', ctx.req);
414
484
  if (secondFactors.length > 0) {
415
485
  const challengeToken = auth.signTotpChallengeToken({
416
486
  userId: user.id,
417
487
  username: user.username,
418
488
  isAdmin: user.isAdmin,
489
+ method: 'password',
419
490
  });
420
491
  return {
421
492
  token: challengeToken,
@@ -425,7 +496,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
425
496
  };
426
497
  }
427
498
  // `user` is the record `validateCredentials` just read — fresh.
428
- return minter.mintForRecord(user);
499
+ return minter.mintForRecord(user, issueFor(ctx.req, 'password', input.refreshable));
429
500
  }),
430
501
  /**
431
502
  * Second leg of the 2FA login. Accepts the challenge token minted
@@ -441,9 +512,9 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
441
512
  * deleted concurrently — rare).
442
513
  */
443
514
  loginVerifyTotp: credentialProcedure
444
- .input(zod_1.z.object({ challengeToken: zod_1.z.string(), code: zod_1.z.string() }))
515
+ .input(zod_1.z.object({ challengeToken: zod_1.z.string(), code: zod_1.z.string(), refreshable: RefreshableInput }))
445
516
  .output(LoginResultSchema)
446
- .mutation(async ({ input }) => {
517
+ .mutation(async ({ input, ctx }) => {
447
518
  const claims = auth.verifyTotpChallengeToken(input.challengeToken);
448
519
  if (!claims) {
449
520
  throw new Error('Invalid or expired TOTP challenge — please re-enter your password');
@@ -452,13 +523,15 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
452
523
  if (!userMgmt) {
453
524
  throw new Error('Login unavailable — `user-management` capability not registered');
454
525
  }
526
+ (0, auth_limits_js_1.assertAccountNotLocked)(limiters, claims.username);
455
527
  const verify = typeof userMgmt.verifyTotp === 'function'
456
528
  ? await userMgmt.verifyTotp({ userId: claims.userId, code: input.code.trim() })
457
529
  : { valid: false };
458
530
  if (!verify.valid) {
531
+ (0, auth_limits_js_1.recordAccountFailure)(limiters, claims.username);
459
532
  throw new Error('Invalid TOTP code');
460
533
  }
461
- return mintSessionForUserId(claims.userId);
534
+ return mintSessionForUserId(claims.userId, issueFor(ctx.req, claims.method ?? 'password', input.refreshable));
462
535
  }),
463
536
  /**
464
537
  * Passkey second leg — step 1 of 2. Accepts the challenge token
@@ -488,9 +561,13 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
488
561
  * `loginVerifyTotp` uses (fresh user re-fetch for up-to-date scopes).
489
562
  */
490
563
  loginVerifyPasskey: credentialProcedure
491
- .input(zod_1.z.object({ challengeToken: zod_1.z.string(), response: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()) }))
564
+ .input(zod_1.z.object({
565
+ challengeToken: zod_1.z.string(),
566
+ response: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
567
+ refreshable: RefreshableInput,
568
+ }))
492
569
  .output(LoginResultSchema)
493
- .mutation(async ({ input }) => {
570
+ .mutation(async ({ input, ctx }) => {
494
571
  const claims = auth.verifyTotpChallengeToken(input.challengeToken);
495
572
  if (!claims) {
496
573
  throw new Error('Invalid or expired login challenge — please re-enter your password');
@@ -498,14 +575,16 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
498
575
  const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
499
576
  if (!provider)
500
577
  throw new Error('Passkey authentication is not available');
578
+ (0, auth_limits_js_1.assertAccountNotLocked)(limiters, claims.username);
501
579
  const result = await provider.finishAuthentication({
502
580
  userId: claims.userId,
503
581
  response: input.response,
504
582
  });
505
583
  if (!result.verified) {
584
+ (0, auth_limits_js_1.recordAccountFailure)(limiters, claims.username);
506
585
  throw new Error('Passkey verification failed');
507
586
  }
508
- return mintSessionForUserId(claims.userId);
587
+ return mintSessionForUserId(claims.userId, issueFor(ctx.req, claims.method ?? 'password', input.refreshable));
509
588
  }),
510
589
  /**
511
590
  * Usernameless (passkey-FIRST) login — step 1 of 2. PUBLIC. Returns
@@ -515,9 +594,8 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
515
594
  * — no username, no password. The challenge is minted and stored by
516
595
  * the `user-passkeys` provider, deliberately NOT bound to any user.
517
596
  *
518
- * Abuse surface: mirrors `login` — no rate limiting exists on any
519
- * public auth procedure today; the challenge store is bounded only
520
- * by its 5-min TTL prune.
597
+ * Abuse surface: limited per client address (ceremony tier); the
598
+ * challenge store is bounded by its 5-min TTL prune.
521
599
  */
522
600
  passkeyLoginBegin: ceremonyProcedure
523
601
  .input(zod_1.z.void())
@@ -544,9 +622,9 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
544
622
  * re-proving it adds nothing.
545
623
  */
546
624
  passkeyLoginFinish: credentialProcedure
547
- .input(zod_1.z.object({ response: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()) }))
625
+ .input(zod_1.z.object({ response: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()), refreshable: RefreshableInput }))
548
626
  .output(LoginResultSchema)
549
- .mutation(async ({ input }) => {
627
+ .mutation(async ({ input, ctx }) => {
550
628
  const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
551
629
  if (!provider)
552
630
  throw new Error('Passkey authentication is not available');
@@ -556,13 +634,14 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
556
634
  if (!result.verified || !result.userId) {
557
635
  throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'Passkey verification failed' });
558
636
  }
559
- const secondFactors = await collectSecondFactorsFor(result.userId, 'passkey');
637
+ const secondFactors = await factorsOwedHere(result.userId, 'passkey', ctx.req);
560
638
  if (secondFactors.length > 0) {
561
639
  const challenged = await findUserById(result.userId);
562
640
  const challengeToken = auth.signTotpChallengeToken({
563
641
  userId: challenged.id,
564
642
  username: challenged.username,
565
643
  isAdmin: challenged.isAdmin,
644
+ method: 'passkey',
566
645
  });
567
646
  return {
568
647
  token: challengeToken,
@@ -575,7 +654,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
575
654
  secondFactors,
576
655
  };
577
656
  }
578
- return mintSessionForUserId(result.userId);
657
+ return mintSessionForUserId(result.userId, issueFor(ctx.req, 'passkey', input.refreshable));
579
658
  }),
580
659
  /**
581
660
  * PUBLIC — upgrade the browser's httpOnly session COOKIE into a
@@ -598,9 +677,9 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
598
677
  * scopes), equivalent to what `auth.login` hands out.
599
678
  */
600
679
  exchangeSession: credentialProcedure
601
- .input(zod_1.z.object({}).optional())
680
+ .input(zod_1.z.object({ refreshable: RefreshableInput }).optional())
602
681
  .output(LoginResultSchema)
603
- .mutation(async ({ ctx }) => {
682
+ .mutation(async ({ ctx, input }) => {
604
683
  if (readRequestHeader(ctx.req, exports.EXCHANGE_SESSION_HEADER) !== '1') {
605
684
  throw new server_1.TRPCError({
606
685
  code: 'FORBIDDEN',
@@ -627,7 +706,9 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
627
706
  typeof payload.userId !== 'string') {
628
707
  throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid session cookie' });
629
708
  }
630
- return mintSessionOrUnauthorized(payload.userId);
709
+ // A session of its own for the client that asked (the hub-served
710
+ // viewer): signing out of one never signs the other out.
711
+ return mintSessionOrUnauthorized(payload.userId, issueFor(ctx.req, 'exchange', input?.refreshable));
631
712
  }),
632
713
  // ── One-time handoff codes (native-app login handoff) ─────────────
633
714
  //
@@ -640,18 +721,24 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
640
721
  * sessions only — scoped (`cst_`) and share-view (`csv_`) callers
641
722
  * must never convert themselves into a full session. */
642
723
  createHandoffCode: trpc_middleware_js_1.protectedProcedure
643
- .input(zod_1.z.void())
724
+ .input(zod_1.z.object({ purpose: zod_1.z.enum(['renew', 'new-session']).optional() }).optional())
644
725
  .output(zod_1.z.object({ code: zod_1.z.string(), expiresAt: zod_1.z.number() }))
645
- .mutation(({ ctx }) => {
726
+ .mutation(({ ctx, input }) => {
646
727
  assertRealUserSession(ctx.user, 'Handoff-code minting');
647
- return handoffCodes.create(ctx.user.id);
728
+ // `renew` (the default — what installed viewer apps call this for)
729
+ // continues THIS session; `new-session` (signing the native app in)
730
+ // opens a separate one for the redeemer.
731
+ return handoffCodes.create(ctx.user.id, {
732
+ ...(ctx.user.sessionId !== undefined ? { sessionId: ctx.user.sessionId } : {}),
733
+ purpose: input?.purpose ?? 'renew',
734
+ });
648
735
  }),
649
736
  /** PUBLIC — redeem a one-time handoff code for a session bearer.
650
737
  * Unknown / expired / already-used codes → UNAUTHORIZED. */
651
738
  redeemHandoffCode: credentialProcedure
652
- .input(zod_1.z.object({ code: zod_1.z.string().min(1) }))
739
+ .input(zod_1.z.object({ code: zod_1.z.string().min(1), refreshable: RefreshableInput }))
653
740
  .output(LoginResultSchema)
654
- .mutation(async ({ input }) => {
741
+ .mutation(async ({ input, ctx }) => {
655
742
  const grant = handoffCodes.redeem(input.code);
656
743
  if (!grant) {
657
744
  throw new server_1.TRPCError({
@@ -659,7 +746,24 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
659
746
  message: 'Invalid or expired handoff code',
660
747
  });
661
748
  }
662
- return mintSessionOrUnauthorized(grant.userId);
749
+ if (grant.purpose === 'renew' && grant.sessionId !== undefined) {
750
+ // A legacy client renewing: the SAME session, its expiry slides. A
751
+ // session revoked meanwhile (logout elsewhere) renews nothing.
752
+ try {
753
+ return await minter.continueSession(grant.userId, grant.sessionId);
754
+ }
755
+ catch (error) {
756
+ if (error instanceof session_mint_js_1.SessionRefreshRefusedError ||
757
+ error instanceof session_mint_js_1.SessionUserGoneError) {
758
+ throw new server_1.TRPCError({
759
+ code: 'UNAUTHORIZED',
760
+ message: 'Session expired — sign in again',
761
+ });
762
+ }
763
+ throw error;
764
+ }
765
+ }
766
+ return mintSessionOrUnauthorized(grant.userId, issueFor(ctx.req, 'handoff', input.refreshable));
663
767
  }),
664
768
  me: trpc_middleware_js_1.protectedProcedure
665
769
  .input(zod_1.z.void())
@@ -735,7 +839,16 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
735
839
  });
736
840
  if (!ok)
737
841
  throw new Error('Current password is incorrect');
738
- await userMgmt.resetPassword({ id: ctx.user.id, newPassword: input.newPassword });
842
+ // Hub-internal: the password changes AND every other session of the
843
+ // user ends (this one stays signed in).
844
+ const internal = registry === null ? null : (0, hub_sessions_js_1.userManagementInternal)(registry);
845
+ if (internal === null)
846
+ throw new Error('user-management capability not available');
847
+ await internal.setPassword({
848
+ userId: ctx.user.id,
849
+ newPassword: input.newPassword,
850
+ ...(ctx.user.sessionId !== undefined ? { keepSessionId: ctx.user.sessionId } : {}),
851
+ });
739
852
  return { success: true };
740
853
  }),
741
854
  setupOwnTotp: trpc_middleware_js_1.protectedProcedure
@@ -761,9 +874,11 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
761
874
  return userMgmt.confirmTotp({ userId: ctx.user.id, code: input.code });
762
875
  }),
763
876
  disableOwnTotp: trpc_middleware_js_1.protectedProcedure
764
- .input(zod_1.z.void())
877
+ // Re-authentication required: a session alone must not strip 2FA.
878
+ .input(zod_1.z.object({ reauthToken: auth_reauth_procedures_js_1.ReauthTokenInput }).optional())
765
879
  .output(zod_1.z.object({ success: zod_1.z.literal(true) }))
766
- .mutation(async ({ ctx }) => {
880
+ .mutation(async ({ ctx, input }) => {
881
+ (0, auth_reauth_procedures_js_1.assertReauthenticated)(auth, ctx.user, input?.reauthToken);
767
882
  const userMgmt = registry?.getSingleton('user-management');
768
883
  if (!userMgmt)
769
884
  throw new Error('user-management capability not available');
@@ -831,11 +946,13 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
831
946
  }
832
947
  }),
833
948
  removeOwnPasskey: trpc_middleware_js_1.protectedProcedure
834
- .input(zod_1.z.object({ credentialId: zod_1.z.string() }))
949
+ // Re-authentication required: removing a passkey can strip a factor.
950
+ .input(zod_1.z.object({ credentialId: zod_1.z.string(), reauthToken: auth_reauth_procedures_js_1.ReauthTokenInput.optional() }))
835
951
  .output(zod_1.z.object({ success: zod_1.z.literal(true) }))
836
952
  .mutation(async ({ input, ctx }) => {
837
953
  if (!ctx.user)
838
954
  throw new Error('Not authenticated');
955
+ (0, auth_reauth_procedures_js_1.assertReauthenticated)(auth, ctx.user, input.reauthToken);
839
956
  const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
840
957
  if (!provider)
841
958
  throw new Error('Passkey management is not available');
@@ -939,22 +1056,30 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
939
1056
  });
940
1057
  }
941
1058
  }),
942
- /** Own tokens for regular users; the full set for admins. */
1059
+ /**
1060
+ * Share links. `owner: 'self'` — the caller's own (My access);
1061
+ * `owner: 'all'` — every user's, admin only. Omitted keeps the original
1062
+ * answer (own for a user, all for an admin) for clients that predate it.
1063
+ */
943
1064
  listShareTokens: trpc_middleware_js_1.protectedProcedure
944
- .input(zod_1.z.void())
1065
+ .input(zod_1.z.object({ owner: zod_1.z.enum(['self', 'all']).optional() }).optional())
945
1066
  .output(zod_1.z.array(ShareTokenSummarySchema))
946
- .query(async ({ ctx }) => {
1067
+ .query(async ({ ctx, input }) => {
947
1068
  assertRealUserSession(ctx.user);
948
1069
  const service = requireShareTokens();
949
- const records = ctx.user.isAdmin
950
- ? await service.listAll()
951
- : await service.listForUser(ctx.user.id);
1070
+ const owner = input?.owner ?? (ctx.user.isAdmin ? 'all' : 'self');
1071
+ if (owner === 'all' && !ctx.user.isAdmin) {
1072
+ throw new server_1.TRPCError({
1073
+ code: 'FORBIDDEN',
1074
+ message: "Listing every user's share links requires an admin",
1075
+ });
1076
+ }
1077
+ const records = owner === 'all' ? await service.listAll() : await service.listForUser(ctx.user.id);
952
1078
  return records.map(toShareTokenSummary);
953
1079
  }),
954
- logout: trpc_middleware_js_1.protectedProcedure
955
- .input(zod_1.z.void())
956
- .output(zod_1.z.object({ success: zod_1.z.literal(true) }))
957
- .mutation(() => ({ success: true })),
1080
+ ...(0, auth_sessions_procedures_js_1.createSessionProcedures)({ registry, minter, credentialProcedure }),
1081
+ ...(0, auth_reauth_procedures_js_1.createReauthProcedures)({ auth, registry, moleculer, limiters }),
1082
+ ...(0, auth_admin_tokens_procedures_js_1.createAdminTokenProcedures)({ auth, registry, network }),
958
1083
  // ── External sign-in identities (D782) ────────────────────────
959
1084
  //
960
1085
  // A signed-in user links an OIDC identity to THEIR OWN account. The
@@ -966,14 +1091,36 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
966
1091
  .input(zod_1.z.void())
967
1092
  .output(zod_1.z.array(zod_1.z.object({ provider: zod_1.z.string(), label: zod_1.z.string() })))
968
1093
  .query(async () => (await linkableProviders()).map(({ provider, label }) => ({ provider, label }))),
969
- /** Start linking `provider` to the caller; the client navigates to `url`. */
1094
+ /**
1095
+ * Start linking `provider` to the caller; the client navigates to `url`.
1096
+ * `returnTo` (D801): the native app's callback — allowlisted
1097
+ * (`APP_AUTH_CALLBACK_URLS`); the finish then parks the link for the app
1098
+ * to claim with `completeExternalLink` instead of needing a session cookie.
1099
+ */
970
1100
  beginExternalLink: trpc_middleware_js_1.protectedProcedure
971
- .input(zod_1.z.object({ provider: zod_1.z.string().min(1) }))
1101
+ .input(zod_1.z.object({
1102
+ provider: zod_1.z.string().min(1),
1103
+ returnTo: zod_1.z.string().optional(),
1104
+ /** From `auth.reauthenticate` — linking needs a fresh proof, not just a session. */
1105
+ reauthToken: auth_reauth_procedures_js_1.ReauthTokenInput.optional(),
1106
+ }))
972
1107
  .output(zod_1.z.object({ url: zod_1.z.string() }))
973
1108
  .mutation(async ({ ctx, input }) => {
974
1109
  if (!ctx.user)
975
1110
  throw new server_1.TRPCError({ code: 'UNAUTHORIZED' });
976
1111
  assertRealUserSession(ctx.user, 'Linking a sign-in account');
1112
+ // Per user AND per client: each call mints an entry in a bounded
1113
+ // shared map, so a flood must not evict other users' pending links.
1114
+ (0, auth_limits_js_1.limitByUser)(limiters.link, 'auth.beginExternalLink', ctx.user.id);
1115
+ (0, auth_limits_js_1.limitByClient)(limiters.ceremony, 'auth.beginExternalLink', ctx.req, network);
1116
+ (0, auth_reauth_procedures_js_1.assertReauthenticated)(auth, ctx.user, input.reauthToken);
1117
+ const returnTo = input.returnTo === undefined ? undefined : (0, types_1.parseAppAuthCallback)(input.returnTo);
1118
+ if (returnTo === null) {
1119
+ throw new server_1.TRPCError({
1120
+ code: 'BAD_REQUEST',
1121
+ message: 'returnTo is not an allowed app callback',
1122
+ });
1123
+ }
977
1124
  const target = (await linkableProviders()).find((p) => p.provider === input.provider);
978
1125
  if (target === undefined) {
979
1126
  throw new server_1.TRPCError({
@@ -981,10 +1128,64 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
981
1128
  message: `Sign-in provider "${input.provider}" is not available for linking`,
982
1129
  });
983
1130
  }
984
- const handle = linkIntents.create(ctx.user.id, target.provider);
1131
+ const handle = linkIntents.create(ctx.user.id, target.provider, returnTo);
985
1132
  const separator = target.startUrl.includes('?') ? '&' : '?';
986
1133
  return { url: `${target.startUrl}${separator}link=${encodeURIComponent(handle)}` };
987
1134
  }),
1135
+ /**
1136
+ * Claim a native link the SSO finish parked (D801). Bound HERE: the
1137
+ * caller must be the user the intent was minted for — a code delivered to
1138
+ * another account's app links nothing. The code is spent by this call
1139
+ * whatever the outcome.
1140
+ */
1141
+ completeExternalLink: selfCodeProcedure
1142
+ .input(zod_1.z.object({ code: zod_1.z.string().min(1).max(256) }))
1143
+ .output(zod_1.z.object({
1144
+ outcome: zod_1.z.enum(['linked', 'conflict', 'expired', 'session']),
1145
+ provider: zod_1.z.string().optional(),
1146
+ }))
1147
+ .mutation(async ({ ctx, input }) => {
1148
+ if (!ctx.user)
1149
+ throw new server_1.TRPCError({ code: 'UNAUTHORIZED' });
1150
+ assertRealUserSession(ctx.user, 'Linking a sign-in account');
1151
+ if (nativeLink === null) {
1152
+ throw new server_1.TRPCError({
1153
+ code: 'PRECONDITION_FAILED',
1154
+ message: 'Native linking is not available',
1155
+ });
1156
+ }
1157
+ const parked = nativeLink.completions.claim(input.code);
1158
+ if (parked === null)
1159
+ return { outcome: 'expired' };
1160
+ if (parked.userId !== ctx.user.id)
1161
+ return { outcome: 'session' };
1162
+ const outcome = await nativeLink.link(parked);
1163
+ return { outcome, provider: parked.provider };
1164
+ }),
1165
+ /**
1166
+ * A one-time code that opens the hub's account page signed in as the
1167
+ * caller, in a browser (D801) — for what only a browser can do at the
1168
+ * hub's origin: a passkey's WebAuthn ceremony. Single-use, 60 s, and it
1169
+ * lands on that page only: the route takes no destination.
1170
+ */
1171
+ createBrowserHandoff: selfCodeProcedure
1172
+ .input(zod_1.z.void())
1173
+ .output(zod_1.z.object({ path: zod_1.z.string(), expiresAt: zod_1.z.number() }))
1174
+ .mutation(({ ctx }) => {
1175
+ if (!ctx.user)
1176
+ throw new server_1.TRPCError({ code: 'UNAUTHORIZED' });
1177
+ assertRealUserSession(ctx.user, 'Opening the account page in a browser');
1178
+ if (nativeLink === null) {
1179
+ throw new server_1.TRPCError({
1180
+ code: 'PRECONDITION_FAILED',
1181
+ message: 'Browser handoff is not available',
1182
+ });
1183
+ }
1184
+ const { code, expiresAt } = nativeLink.browserHandoffs.create(ctx.user.id, {
1185
+ purpose: 'new-session',
1186
+ });
1187
+ return { path: `${exports.BROWSER_HANDOFF_PATH}?code=${encodeURIComponent(code)}`, expiresAt };
1188
+ }),
988
1189
  /** The caller's linked identities; `available: false` when the provider instance is gone or off. */
989
1190
  listOwnExternalIdentities: trpc_middleware_js_1.protectedProcedure
990
1191
  .input(zod_1.z.void())
@@ -1026,7 +1227,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
1026
1227
  /**
1027
1228
  * PUBLIC — the login page's single source of pre-auth login surfaces.
1028
1229
  * Aggregates the `login-method` collection: every auth addon (OIDC,
1029
- * magic-link, WebAuthn) contributes `redirect` buttons and/or pre-auth
1230
+ * WebAuthn) contributes `redirect` buttons and/or pre-auth
1030
1231
  * `widget`s, each tagged `stage: 'primary' | 'second-factor'`. The
1031
1232
  * login page renders `redirect` methods as generic buttons and mounts
1032
1233
  * `widget` methods via `loadRemoteBundle` — a future SSO addon plugs