@camstack/server 1.2.563 → 1.2.565

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.
@@ -23,6 +23,7 @@ const zod_1 = require("zod");
23
23
  const auth_network_policy_js_1 = require("../../auth/auth-network-policy.js");
24
24
  const auth_rate_limit_js_1 = require("../../auth/auth-rate-limit.js");
25
25
  const auth_provider_gate_js_1 = require("../../auth/auth-provider-gate.js");
26
+ const device_enroll_proofs_js_1 = require("../../auth/device-enroll-proofs.js");
26
27
  const external_link_intents_js_1 = require("../../auth/external-link-intents.js");
27
28
  const passkey_provider_js_1 = require("../../auth/passkey-provider.js");
28
29
  const second_factors_js_1 = require("../../auth/second-factors.js");
@@ -37,6 +38,7 @@ const auth_limits_js_1 = require("./auth-limits.js");
37
38
  const auth_admin_tokens_procedures_js_1 = require("./auth-admin-tokens.procedures.js");
38
39
  const auth_revoke_credentials_procedures_js_1 = require("./auth-revoke-credentials.procedures.js");
39
40
  const auth_reauth_procedures_js_1 = require("./auth-reauth.procedures.js");
41
+ const auth_device_cert_procedures_js_1 = require("./auth-device-cert.procedures.js");
40
42
  const auth_sessions_procedures_js_1 = require("./auth-sessions.procedures.js");
41
43
  /**
42
44
  * A public procedure limited per (procedure, REAL client address) — see
@@ -48,6 +50,10 @@ function makeRateLimitedProcedure(limiter, network) {
48
50
  return next({ ctx });
49
51
  });
50
52
  }
53
+ /** A step-up's sign-in may be at most this old (D855). */
54
+ const STEP_UP_FRESH_MS = 120_000;
55
+ /** Login methods that count as an authentication (a handoff / exchange / certificate session does not). */
56
+ const STEP_UP_LOGIN_METHODS = ['password', 'passkey', 'sso'];
51
57
  /** Asks for the refresh-token flow (short access token + rotating refresh token). */
52
58
  const RefreshableInput = zod_1.z.boolean().optional();
53
59
  /** The request's User-Agent, trimmed, for the sessions list. */
@@ -140,6 +146,7 @@ const PublicLoginMethodSchema = zod_1.z.discriminatedUnion('kind', [
140
146
  types_1.RedirectLoginMethodSchema,
141
147
  PublicWidgetLoginMethodSchema,
142
148
  types_1.PasskeyLoginMethodSchema,
149
+ types_1.DeviceCertificateLoginMethodSchema,
143
150
  ]);
144
151
  /** One of the caller's linked sign-in identities, as the profile shows it (D782). */
145
152
  const OwnExternalIdentitySchema = zod_1.z.object({
@@ -304,6 +311,11 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
304
311
  signer: auth,
305
312
  directory: () => registry?.getSingleton('user-management'),
306
313
  ...(options.sessions !== undefined ? { sessions: options.sessions } : {}),
314
+ credentials: options.credentials ??
315
+ (() => {
316
+ const internal = registry === null ? null : (0, hub_sessions_js_1.userManagementInternal)(registry);
317
+ return internal === null ? null : internal.deviceCredentials;
318
+ }),
307
319
  });
308
320
  const findUserById = minter.findUser;
309
321
  /** How THIS request's login is recorded on its session. */
@@ -375,6 +387,71 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
375
387
  * contributions (D777), validated and deduplicated. Shared by
376
388
  * `listLoginMethods` and the D782 linking procedures.
377
389
  */
390
+ /**
391
+ * The hub origin a device-certificate challenge is bound to: the origin the
392
+ * request CLAIMS when the hub advertises it (Network page / address book),
393
+ * else the public endpoint — never a forged `Host` (same rule as the OIDC
394
+ * return, D791).
395
+ */
396
+ const resolveDeviceOrigin = options.resolveOrigin ??
397
+ (async (req) => {
398
+ const provider = registry?.getSingleton('local-network');
399
+ if (!provider)
400
+ return null;
401
+ const book = await provider.getAddresses({});
402
+ const headers = {};
403
+ for (const [name, value] of Object.entries(req?.headers ?? {})) {
404
+ headers[name.toLowerCase()] = Array.isArray(value) ? value[0] : value;
405
+ }
406
+ const picked = (0, types_1.pickRequestOrigin)(headers, (0, types_1.advertisedHubOrigins)(book));
407
+ return (picked ??
408
+ book.roles.publicEndpoint.resolved ??
409
+ book.roles.appConnections.resolved[0] ??
410
+ null);
411
+ });
412
+ const enrollProofs = options.enrollProofs ?? new device_enroll_proofs_js_1.DeviceEnrollProofs(Date.now, options.logger ?? null);
413
+ /** The device credential behind a session, so a session minted FROM it inherits it (D855). */
414
+ const credentialOfSession = (sessionId) => {
415
+ if (sessionId === undefined || registry === null)
416
+ return undefined;
417
+ const check = (0, hub_sessions_js_1.userManagementInternal)(registry)?.sessions.check(sessionId);
418
+ return check?.state === 'active' ? (check.session.credentialId ?? undefined) : undefined;
419
+ };
420
+ /**
421
+ * A step-up (the proof that lets a device enroll itself) needs an
422
+ * authentication that JUST happened - never a session that merely exists:
423
+ * - a single-use `reauthToken` (password / passkey re-authentication), or
424
+ * - a session that was OPENED by a primary login (password, passkey, SSO)
425
+ * at most {@link STEP_UP_FRESH_MS} ago, once. A session born of a handoff,
426
+ * a cookie exchange or a device certificate is not an authentication.
427
+ */
428
+ const assertFreshAuthentication = (user, reauthToken) => {
429
+ const refuse = (message) => {
430
+ options.logger?.warn(`Step-up refused: ${message}`, { tags: { userId: user.id } });
431
+ throw new server_1.TRPCError({ code: 'FORBIDDEN', message: `Sign in again first: ${message}` });
432
+ };
433
+ if (reauthToken !== undefined) {
434
+ (0, auth_reauth_procedures_js_1.assertReauthenticated)(auth, user, reauthToken);
435
+ if (!enrollProofs.consumeReauth(reauthToken))
436
+ refuse('that confirmation was already used');
437
+ return;
438
+ }
439
+ const sid = user.sessionId;
440
+ const check = sid === undefined || registry === null
441
+ ? null
442
+ : (0, hub_sessions_js_1.userManagementInternal)(registry)?.sessions.check(sid);
443
+ if (check?.state !== 'active')
444
+ return refuse('no live session');
445
+ if (!STEP_UP_LOGIN_METHODS.includes(check.session.method)) {
446
+ return refuse('this session was not opened by a sign-in');
447
+ }
448
+ if (Date.now() - check.session.createdAt > STEP_UP_FRESH_MS) {
449
+ return refuse('the sign-in is too old');
450
+ }
451
+ if (sid === undefined || !enrollProofs.claimFreshSession(sid, check.session.createdAt)) {
452
+ return refuse('this sign-in already started a step-up');
453
+ }
454
+ };
378
455
  const aggregateLoginMethods = async () => {
379
456
  if (!registry)
380
457
  return [];
@@ -436,6 +513,24 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
436
513
  }
437
514
  }
438
515
  }
516
+ // The hub's own method (D855): a device certificate. Not an addon
517
+ // contribution - the device CA lives in `local-auth` - so it is added here.
518
+ // Offered only where the hub can actually serve it (local-auth up).
519
+ if ((0, hub_sessions_js_1.userManagementInternal)(registry) !== null)
520
+ out.push({
521
+ kind: 'device-certificate',
522
+ id: 'device-certificate',
523
+ label: 'This device',
524
+ stage: 'primary',
525
+ challengePath: 'auth.getDeviceChallenge',
526
+ loginPath: 'auth.loginWithDeviceCertificate',
527
+ renewPath: 'auth.renewDeviceCertificate',
528
+ revokePath: 'auth.revokeOwnDeviceCertificate',
529
+ enrollSelfPath: 'auth.enrollSelf',
530
+ signature: types_1.DEVICE_CERT_SIGNATURE,
531
+ messageVersion: types_1.DEVICE_SIGNATURE_MESSAGE_VERSION,
532
+ clients: ['native'],
533
+ });
439
534
  return out;
440
535
  };
441
536
  /**
@@ -709,7 +804,10 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
709
804
  }
710
805
  // A session of its own for the client that asked (the hub-served
711
806
  // viewer): signing out of one never signs the other out.
712
- return mintSessionOrUnauthorized(payload.userId, issueFor(ctx.req, 'exchange', input?.refreshable));
807
+ return mintSessionOrUnauthorized(payload.userId, {
808
+ ...issueFor(ctx.req, 'exchange', input?.refreshable),
809
+ ...(payload.cid !== undefined ? { credentialId: payload.cid } : {}),
810
+ });
713
811
  }),
714
812
  // ── One-time handoff codes (native-app login handoff) ─────────────
715
813
  //
@@ -722,26 +820,90 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
722
820
  * sessions only — scoped (`cst_`) and share-view (`csv_`) callers
723
821
  * must never convert themselves into a full session. */
724
822
  createHandoffCode: trpc_middleware_js_1.protectedProcedure
725
- .input(zod_1.z.object({ purpose: zod_1.z.enum(['renew', 'new-session']).optional() }).optional())
823
+ .input(zod_1.z
824
+ .object({
825
+ purpose: zod_1.z.enum(['renew', 'new-session', 'step-up']).optional(),
826
+ /** PKCE S256 challenge of the app's verifier: redeem then requires the verifier. */
827
+ codeChallenge: zod_1.z.string().min(43).max(128).optional(),
828
+ /** The app's anti-CSRF state, required again at redeem. */
829
+ state: zod_1.z.string().min(1).max(256).optional(),
830
+ /** `step-up`: a fresh single-use re-authentication, when the session itself is not a fresh sign-in. */
831
+ reauthToken: auth_reauth_procedures_js_1.ReauthTokenInput.optional(),
832
+ })
833
+ .optional())
726
834
  .output(zod_1.z.object({ code: zod_1.z.string(), expiresAt: zod_1.z.number() }))
727
835
  .mutation(({ ctx, input }) => {
728
836
  assertRealUserSession(ctx.user, 'Handoff-code minting');
837
+ const purpose = input?.purpose ?? 'renew';
838
+ // A code that ends at an app callback is PKCE-bound and state-bound, always.
839
+ // Only the legacy `renew` pair (installed viewers renewing their own session)
840
+ // may omit them; it ends with D812's retirement of the `renew` handoff.
841
+ if (purpose !== 'renew' &&
842
+ (input?.codeChallenge === undefined || input?.state === undefined)) {
843
+ options.logger?.warn('Handoff code refused: PKCE challenge and state are required', {
844
+ tags: { userId: ctx.user.id },
845
+ meta: { purpose },
846
+ });
847
+ throw new server_1.TRPCError({
848
+ code: 'BAD_REQUEST',
849
+ message: 'codeChallenge (S256) and state are required for this handoff',
850
+ });
851
+ }
852
+ if (purpose === 'step-up')
853
+ assertFreshAuthentication(ctx.user, input?.reauthToken);
729
854
  // `renew` (the default — what installed viewer apps call this for)
730
855
  // continues THIS session; `new-session` (signing the native app in)
731
856
  // opens a separate one for the redeemer.
857
+ const credentialId = credentialOfSession(ctx.user.sessionId);
732
858
  return handoffCodes.create(ctx.user.id, {
733
859
  ...(ctx.user.sessionId !== undefined ? { sessionId: ctx.user.sessionId } : {}),
734
- purpose: input?.purpose ?? 'renew',
860
+ ...(credentialId !== undefined ? { credentialId } : {}),
861
+ purpose,
862
+ ...(input?.codeChallenge !== undefined ? { codeChallenge: input.codeChallenge } : {}),
863
+ ...(input?.state !== undefined ? { state: input.state } : {}),
735
864
  });
736
865
  }),
737
866
  /** PUBLIC — redeem a one-time handoff code for a session bearer.
738
867
  * Unknown / expired / already-used codes → UNAUTHORIZED. */
739
868
  redeemHandoffCode: credentialProcedure
740
- .input(zod_1.z.object({ code: zod_1.z.string().min(1), refreshable: RefreshableInput }))
869
+ .input(zod_1.z.object({
870
+ code: zod_1.z.string().min(1),
871
+ refreshable: RefreshableInput,
872
+ /** PKCE: required when the code was minted with a `codeChallenge`. */
873
+ codeVerifier: zod_1.z.string().min(1).max(256).optional(),
874
+ state: zod_1.z.string().min(1).max(256).optional(),
875
+ }))
741
876
  .output(LoginResultSchema)
742
877
  .mutation(async ({ input, ctx }) => {
743
- const grant = handoffCodes.redeem(input.code);
744
- if (!grant) {
878
+ const redeemed = handoffCodes.redeemWithReason(input.code, {
879
+ ...(input.codeVerifier !== undefined ? { codeVerifier: input.codeVerifier } : {}),
880
+ ...(input.state !== undefined ? { state: input.state } : {}),
881
+ });
882
+ const grant = redeemed.grant;
883
+ // A step-up code only ever yields a device-enroll proof (at
884
+ // `deviceEnrollProofFromHandoff`), never a session.
885
+ if (grant !== null && grant.purpose === 'step-up') {
886
+ options.logger?.warn('Handoff redeem refused: a step-up code is not a login code', {
887
+ tags: { userId: grant.userId },
888
+ });
889
+ }
890
+ else if (grant === null) {
891
+ options.logger?.warn('Handoff redeem refused', {
892
+ meta: { reason: redeemed.reason ?? 'unknown' },
893
+ });
894
+ }
895
+ if (!grant || grant.purpose === 'step-up') {
896
+ throw new server_1.TRPCError({
897
+ code: 'UNAUTHORIZED',
898
+ message: 'Invalid or expired handoff code',
899
+ });
900
+ }
901
+ // A `renew` code continues THE SESSION IT WAS MINTED FROM. One without a session would
902
+ // open a brand-new session with no PKCE behind it: refused.
903
+ if (grant.purpose === 'renew' && grant.sessionId === undefined) {
904
+ options.logger?.warn('Handoff redeem refused: a renew code with no session behind it', {
905
+ tags: { userId: grant.userId },
906
+ });
745
907
  throw new server_1.TRPCError({
746
908
  code: 'UNAUTHORIZED',
747
909
  message: 'Invalid or expired handoff code',
@@ -764,7 +926,11 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
764
926
  throw error;
765
927
  }
766
928
  }
767
- return mintSessionOrUnauthorized(grant.userId, issueFor(ctx.req, 'handoff', input.refreshable));
929
+ return mintSessionOrUnauthorized(grant.userId, {
930
+ ...issueFor(ctx.req, 'handoff', input.refreshable),
931
+ // Minted FROM a credential session: the child dies with the credential.
932
+ ...(grant.credentialId !== undefined ? { credentialId: grant.credentialId } : {}),
933
+ });
768
934
  }),
769
935
  me: trpc_middleware_js_1.protectedProcedure
770
936
  .input(zod_1.z.void())
@@ -1137,6 +1303,21 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
1137
1303
  return records.map(toShareTokenSummary);
1138
1304
  }),
1139
1305
  ...(0, auth_sessions_procedures_js_1.createSessionProcedures)({ registry, minter, credentialProcedure }),
1306
+ ...(0, auth_device_cert_procedures_js_1.createDeviceCertProcedures)({
1307
+ registry,
1308
+ minter,
1309
+ publicProcedure: trpc_middleware_js_1.publicProcedure,
1310
+ auth,
1311
+ credentialLimiter: limiters.credential,
1312
+ ceremonyLimiter: limiters.ceremony,
1313
+ selfLimiter: SELF_CODE_LIMITER,
1314
+ ...(options.logger !== undefined ? { logger: options.logger } : {}),
1315
+ network,
1316
+ enrollProofs,
1317
+ handoffCodes,
1318
+ resolveOrigin: resolveDeviceOrigin,
1319
+ issueFor,
1320
+ }),
1140
1321
  ...(0, auth_reauth_procedures_js_1.createReauthProcedures)({ auth, registry, moleculer, limiters }),
1141
1322
  ...(0, auth_admin_tokens_procedures_js_1.createAdminTokenProcedures)({ auth, registry, network }),
1142
1323
  ...(0, auth_revoke_credentials_procedures_js_1.createRevokeCredentialsProcedures)({ registry, shareTokens, network }),
@@ -1241,8 +1422,10 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
1241
1422
  message: 'Browser handoff is not available',
1242
1423
  });
1243
1424
  }
1425
+ const parentCredential = credentialOfSession(ctx.user.sessionId);
1244
1426
  const { code, expiresAt } = nativeLink.browserHandoffs.create(ctx.user.id, {
1245
1427
  purpose: 'new-session',
1428
+ ...(parentCredential !== undefined ? { credentialId: parentCredential } : {}),
1246
1429
  });
1247
1430
  return { path: `${exports.BROWSER_HANDOFF_PATH}?code=${encodeURIComponent(code)}`, expiresAt };
1248
1431
  }),