@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.
@@ -0,0 +1,435 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.toWireReason = toWireReason;
4
+ exports.refusal = refusal;
5
+ exports.createDeviceCertProcedures = createDeviceCertProcedures;
6
+ const system_1 = require("@camstack/system");
7
+ const types_1 = require("@camstack/types");
8
+ const server_1 = require("@trpc/server");
9
+ const zod_1 = require("zod");
10
+ const session_mint_js_1 = require("../../auth/session-mint.js");
11
+ const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
12
+ const auth_reauth_procedures_js_1 = require("./auth-reauth.procedures.js");
13
+ const DeviceLoginResultSchema = zod_1.z.discriminatedUnion('ok', [
14
+ zod_1.z.object({
15
+ ok: zod_1.z.literal(true),
16
+ token: zod_1.z.string(),
17
+ user: zod_1.z.object({ id: zod_1.z.string(), username: zod_1.z.string(), isAdmin: zod_1.z.boolean() }),
18
+ expiresAt: zod_1.z.number().optional(),
19
+ sessionId: zod_1.z.string().optional(),
20
+ sessionExpiresAt: zod_1.z.number().optional(),
21
+ refreshToken: zod_1.z.string().optional(),
22
+ credentialId: zod_1.z.string(),
23
+ /** The certificate's expiry (epoch ms): the app renews when < 30 days remain. */
24
+ certNotAfter: zod_1.z.number(),
25
+ }),
26
+ types_1.DeviceRefusalSchema,
27
+ ]);
28
+ /** Internal manager reason → the wire reason an app switches on. */
29
+ function toWireReason(reason) {
30
+ switch (reason) {
31
+ case 'revoked':
32
+ case 'unknown-credential':
33
+ case 'expired':
34
+ case 'bad-signature':
35
+ case 'user-gone':
36
+ case 'bad-public-key':
37
+ case 'self-enrollment-disabled':
38
+ return reason;
39
+ case 'unknown-or-used-code':
40
+ case 'expired-code':
41
+ return 'code-invalid';
42
+ case 'unknown-nonce':
43
+ case 'expired-nonce':
44
+ return 'nonce-invalid';
45
+ case 'ca-unavailable':
46
+ return 'unavailable';
47
+ case 'no-device-ca':
48
+ case 'malformed-certificate':
49
+ case 'not-issued-by-device-ca':
50
+ case 'not-yet-valid':
51
+ case 'missing-credential-san':
52
+ case 'missing-client-auth':
53
+ case 'key-mismatch':
54
+ case 'user-mismatch':
55
+ return 'invalid-certificate';
56
+ case 'superseded':
57
+ return 'superseded';
58
+ }
59
+ }
60
+ function refusal(reason, retryAfterSeconds) {
61
+ return {
62
+ ok: false,
63
+ reason,
64
+ definitive: (0, types_1.isDefinitiveDeviceRefusal)(reason),
65
+ ...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}),
66
+ };
67
+ }
68
+ function fromAddress(req, network) {
69
+ const ip = network.clientAddress(req).ip;
70
+ return ip ?? undefined;
71
+ }
72
+ function createDeviceCertProcedures(deps) {
73
+ const internal = () => {
74
+ const found = deps.registry === null
75
+ ? null
76
+ : (0, system_1.getUserManagementInternal)(deps.registry.getSingleton('user-management'));
77
+ if (found === null) {
78
+ throw new server_1.TRPCError({
79
+ code: 'PRECONDITION_FAILED',
80
+ message: 'Device certificates are not available',
81
+ });
82
+ }
83
+ return found;
84
+ };
85
+ /** `null` when allowed; otherwise a TYPED `rate-limited` refusal, logged. */
86
+ const limited = (limiter, path, req) => {
87
+ const ip = fromAddress(req, deps.network);
88
+ if (ip === undefined)
89
+ return null;
90
+ const verdict = limiter.check(`${path}:${ip}`);
91
+ if (verdict.allowed)
92
+ return null;
93
+ deps.logger?.warn(`Device certificate ${path} refused: rate-limited`, {
94
+ meta: { ip, retryAfterSeconds: verdict.retryAfterSeconds },
95
+ });
96
+ return refusal('rate-limited', verdict.retryAfterSeconds);
97
+ };
98
+ const audit = async (action, userId, req, detail) => {
99
+ const ip = fromAddress(req, deps.network);
100
+ try {
101
+ await internal().audit.record({
102
+ action,
103
+ ...(userId !== undefined ? { userId } : {}),
104
+ ...(ip !== undefined ? { ip } : {}),
105
+ detail,
106
+ });
107
+ }
108
+ catch (error) {
109
+ deps.logger?.warn('Device credential audit write failed', {
110
+ meta: { error: error instanceof Error ? error.message : String(error) },
111
+ });
112
+ }
113
+ };
114
+ const assertPerson = (user) => {
115
+ if (user.isScoped === true || user.shareView !== undefined || user.adminTokenId !== undefined) {
116
+ throw new server_1.TRPCError({ code: 'FORBIDDEN', message: 'Requires a signed-in user session' });
117
+ }
118
+ };
119
+ const selfLimit = (userId, path) => {
120
+ const verdict = deps.selfLimiter.check(`${path}:${userId}`);
121
+ if (!verdict.allowed) {
122
+ throw new server_1.TRPCError({
123
+ code: 'TOO_MANY_REQUESTS',
124
+ message: `Too many attempts — retry in ${verdict.retryAfterSeconds}s`,
125
+ });
126
+ }
127
+ };
128
+ return {
129
+ /** PUBLIC — redeem a QR enrollment code with the device's PUBLIC key; answers the certificate. */
130
+ completeEnrollment: deps.publicProcedure
131
+ .input(types_1.CompleteEnrollmentInputSchema)
132
+ .output(types_1.CompleteEnrollmentResultSchema)
133
+ .mutation(async ({ input, ctx }) => {
134
+ const slow = limited(deps.credentialLimiter, 'completeEnrollment', ctx.req);
135
+ if (slow !== null)
136
+ return slow;
137
+ const issued = await internal().deviceCredentials.completeEnrollment(input);
138
+ if (!issued.ok)
139
+ return refusal(toWireReason(issued.reason));
140
+ await audit('device-credential-enrolled', issued.userId, ctx.req, {
141
+ credentialId: issued.credentialId,
142
+ via: 'qr',
143
+ });
144
+ return {
145
+ ok: true,
146
+ credentialId: issued.credentialId,
147
+ certPem: issued.certPem,
148
+ chainPem: issued.chainPem,
149
+ notAfter: issued.notAfter,
150
+ user: { id: issued.userId, username: issued.username },
151
+ };
152
+ }),
153
+ /** PUBLIC — a single-use nonce (60 s), bound to the hub origin this request is entitled to. */
154
+ getDeviceChallenge: deps.publicProcedure
155
+ .input(zod_1.z.void())
156
+ .output(types_1.DeviceChallengeResultSchema)
157
+ .mutation(async ({ ctx }) => {
158
+ const slow = limited(deps.ceremonyLimiter, 'getDeviceChallenge', ctx.req);
159
+ if (slow !== null)
160
+ return slow;
161
+ // Nonces are bounded per client address. A request whose address cannot be resolved
162
+ // would share one partition with every other such request, so it gets no nonce.
163
+ const partition = fromAddress(ctx.req, deps.network);
164
+ if (partition === undefined) {
165
+ deps.logger?.warn('Device challenge refused: the client address could not be resolved');
166
+ return refusal('unavailable');
167
+ }
168
+ const origin = await deps.resolveOrigin(ctx.req);
169
+ if (origin === null)
170
+ return refusal('unavailable');
171
+ const { nonce, expiresAt } = internal().deviceCredentials.issueNonce(origin, partition);
172
+ return { ok: true, nonce, origin, expiresAt, signature: types_1.DEVICE_CERT_SIGNATURE };
173
+ }),
174
+ /**
175
+ * PUBLIC — sign in with the device certificate: chain to the device CA, not
176
+ * expired, not revoked, a valid signature over `login` + origin + nonce.
177
+ * Opens the SAME session a password login opens, carrying the credential id.
178
+ */
179
+ loginWithDeviceCertificate: deps.publicProcedure
180
+ .input(types_1.DeviceProofInputSchema.extend({ refreshable: zod_1.z.boolean().optional() }))
181
+ .output(DeviceLoginResultSchema)
182
+ .mutation(async ({ input, ctx }) => {
183
+ const slow = limited(deps.credentialLimiter, 'loginWithDeviceCertificate', ctx.req);
184
+ if (slow !== null)
185
+ return slow;
186
+ const from = fromAddress(ctx.req, deps.network);
187
+ const checked = await internal().deviceCredentials.authenticate({
188
+ certPem: input.certPem,
189
+ nonce: input.nonce,
190
+ signature: input.signature,
191
+ purpose: 'login',
192
+ ...(from !== undefined ? { from } : {}),
193
+ });
194
+ if (!checked.ok) {
195
+ await audit('device-credential-login-refused', undefined, ctx.req, {
196
+ reason: checked.reason,
197
+ });
198
+ return refusal(toWireReason(checked.reason));
199
+ }
200
+ const { credential } = checked;
201
+ try {
202
+ const session = await deps.minter.mintForUserId(credential.userId, {
203
+ ...deps.issueFor(ctx.req, 'device-certificate', input.refreshable ?? true),
204
+ method: 'device-certificate',
205
+ credentialId: credential.id,
206
+ });
207
+ await audit('device-credential-login', credential.userId, ctx.req, {
208
+ credentialId: credential.id,
209
+ });
210
+ return {
211
+ ok: true,
212
+ token: session.token,
213
+ user: session.user,
214
+ ...(session.expiresAt !== undefined ? { expiresAt: session.expiresAt } : {}),
215
+ ...(session.sessionId !== undefined ? { sessionId: session.sessionId } : {}),
216
+ ...(session.sessionExpiresAt !== undefined
217
+ ? { sessionExpiresAt: session.sessionExpiresAt }
218
+ : {}),
219
+ ...(session.refreshToken !== undefined ? { refreshToken: session.refreshToken } : {}),
220
+ credentialId: credential.id,
221
+ certNotAfter: credential.expiresAt,
222
+ };
223
+ }
224
+ catch (error) {
225
+ if (error instanceof session_mint_js_1.SessionUserGoneError) {
226
+ deps.logger?.warn('Device certificate login refused: user-gone', {
227
+ tags: { credentialId: credential.id, userId: credential.userId },
228
+ });
229
+ return refusal('user-gone');
230
+ }
231
+ throw error;
232
+ }
233
+ }),
234
+ /** PUBLIC — a new leaf for the SAME public key, while the certificate is valid and not revoked. */
235
+ renewDeviceCertificate: deps.publicProcedure
236
+ .input(types_1.DeviceProofInputSchema)
237
+ .output(types_1.RenewDeviceCertificateResultSchema)
238
+ .mutation(async ({ input, ctx }) => {
239
+ const slow = limited(deps.credentialLimiter, 'renewDeviceCertificate', ctx.req);
240
+ if (slow !== null)
241
+ return slow;
242
+ const checked = await internal().deviceCredentials.authenticate({
243
+ certPem: input.certPem,
244
+ nonce: input.nonce,
245
+ signature: input.signature,
246
+ purpose: 'renew',
247
+ });
248
+ if (!checked.ok)
249
+ return refusal(toWireReason(checked.reason));
250
+ const renewed = await internal().deviceCredentials.renew(checked.credential);
251
+ if (!renewed.ok)
252
+ return refusal(toWireReason(renewed.reason));
253
+ return {
254
+ ok: true,
255
+ credentialId: renewed.credentialId,
256
+ certPem: renewed.certPem,
257
+ chainPem: renewed.chainPem,
258
+ notAfter: renewed.notAfter,
259
+ };
260
+ }),
261
+ /**
262
+ * PUBLIC — revoke THIS credential, signed with the device key (`revoke`
263
+ * purpose): works with no session. Ends every session the credential opened.
264
+ */
265
+ revokeOwnDeviceCertificate: deps.publicProcedure
266
+ .input(types_1.DeviceProofInputSchema)
267
+ .output(types_1.RevokeOwnDeviceCertificateResultSchema)
268
+ .mutation(async ({ input, ctx }) => {
269
+ const slow = limited(deps.credentialLimiter, 'revokeOwnDeviceCertificate', ctx.req);
270
+ if (slow !== null)
271
+ return slow;
272
+ const checked = await internal().deviceCredentials.authenticate({
273
+ certPem: input.certPem,
274
+ nonce: input.nonce,
275
+ signature: input.signature,
276
+ purpose: 'revoke',
277
+ });
278
+ if (!checked.ok)
279
+ return refusal(toWireReason(checked.reason));
280
+ const id = checked.credential.id;
281
+ await internal().deviceCredentials.revoke(id, (0, types_1.formatDeviceRevoker)({ kind: 'device' }));
282
+ const sessionsEnded = await internal().sessions.revokeByCredential(id);
283
+ await audit('device-credential-revoked', checked.credential.userId, ctx.req, {
284
+ credentialId: id,
285
+ by: 'device',
286
+ sessionsEnded,
287
+ });
288
+ return { ok: true, credentialId: id, sessionsEnded };
289
+ }),
290
+ // ── Signed-in legs ───────────────────────────────────────────────
291
+ /** The caller's own device credentials (live ones, and revoked when asked). */
292
+ listOwnDeviceCredentials: trpc_middleware_js_1.protectedProcedure
293
+ .input(zod_1.z.object({ includeRevoked: zod_1.z.boolean().optional() }).optional())
294
+ .output(zod_1.z.array(types_1.DeviceCredentialSchema))
295
+ .query(({ ctx, input }) => {
296
+ assertPerson(ctx.user);
297
+ const manager = internal().deviceCredentials;
298
+ return manager
299
+ .list({ userId: ctx.user.id, includeRevoked: input?.includeRevoked ?? false })
300
+ .map((r) => ({
301
+ id: r.id,
302
+ userId: r.userId,
303
+ username: ctx.user.username,
304
+ deviceName: r.deviceName,
305
+ platform: r.platform,
306
+ enrolledVia: r.enrolledVia,
307
+ fingerprint: r.fingerprint,
308
+ issuedAt: r.issuedAt,
309
+ expiresAt: r.expiresAt,
310
+ lastUsedAt: r.lastUsedAt,
311
+ lastUsedFrom: r.lastUsedFrom,
312
+ revokedAt: r.revokedAt,
313
+ revokedBy: r.revokedBy,
314
+ status: r.revokedAt !== null
315
+ ? 'revoked'
316
+ : r.expiresAt <= Date.now()
317
+ ? 'expired'
318
+ : 'active',
319
+ }));
320
+ }),
321
+ /** Revoke one of the caller's OWN credentials by id, from a session (e.g. a lost phone). */
322
+ revokeOwnDeviceCredential: trpc_middleware_js_1.protectedProcedure
323
+ .input(zod_1.z.object({ id: zod_1.z.string().min(1) }))
324
+ .output(zod_1.z.object({ revoked: zod_1.z.boolean(), sessionsEnded: zod_1.z.number().int() }))
325
+ .mutation(async ({ ctx, input }) => {
326
+ assertPerson(ctx.user);
327
+ selfLimit(ctx.user.id, 'revokeOwnDeviceCredential');
328
+ const record = internal().deviceCredentials.get(input.id);
329
+ // Someone else's credential is indistinguishable from a missing one.
330
+ if (record === undefined || record.userId !== ctx.user.id) {
331
+ deps.logger?.warn("Own device credential revoke refused: not the caller's", {
332
+ tags: { userId: ctx.user.id, credentialId: input.id },
333
+ });
334
+ return { revoked: false, sessionsEnded: 0 };
335
+ }
336
+ const revoked = await internal().deviceCredentials.revoke(input.id, (0, types_1.formatDeviceRevoker)({ kind: 'user', userId: ctx.user.id }));
337
+ const sessionsEnded = await internal().sessions.revokeByCredential(input.id);
338
+ if (revoked) {
339
+ await audit('device-credential-revoked', ctx.user.id, ctx.req, {
340
+ credentialId: input.id,
341
+ by: 'user',
342
+ sessionsEnded,
343
+ });
344
+ }
345
+ return { revoked, sessionsEnded };
346
+ }),
347
+ /** Step-up, password / passkey: turn a fresh `reauthenticate` proof into a single-use device-enroll proof. */
348
+ deviceEnrollProofFromReauth: trpc_middleware_js_1.protectedProcedure
349
+ .input(zod_1.z.object({ reauthToken: auth_reauth_procedures_js_1.ReauthTokenInput }))
350
+ .output(types_1.DeviceEnrollProofSchema)
351
+ .mutation(({ ctx, input }) => {
352
+ assertPerson(ctx.user);
353
+ selfLimit(ctx.user.id, 'deviceEnrollProofFromReauth');
354
+ (0, auth_reauth_procedures_js_1.assertReauthenticated)(deps.auth, ctx.user, input.reauthToken);
355
+ // One reauthentication, one proof (or one step-up handoff code).
356
+ if (!deps.enrollProofs.consumeReauth(input.reauthToken)) {
357
+ throw new server_1.TRPCError({
358
+ code: 'FORBIDDEN',
359
+ message: 'This re-authentication was already used - confirm it is you again',
360
+ });
361
+ }
362
+ return deps.enrollProofs.mint(ctx.user.id);
363
+ }),
364
+ /**
365
+ * Step-up through the app login handoff (any web method: OIDC, passkey, …).
366
+ * `code` is a handoff code minted with purpose `step-up` by a browser sign-in
367
+ * of the SAME user. Opens no session and replaces none.
368
+ */
369
+ deviceEnrollProofFromHandoff: trpc_middleware_js_1.protectedProcedure
370
+ .input(zod_1.z.object({
371
+ code: zod_1.z.string().min(1).max(256),
372
+ codeVerifier: zod_1.z.string().min(1).max(256).optional(),
373
+ state: zod_1.z.string().min(1).max(256).optional(),
374
+ }))
375
+ .output(types_1.DeviceEnrollProofSchema)
376
+ .mutation(({ ctx, input }) => {
377
+ assertPerson(ctx.user);
378
+ selfLimit(ctx.user.id, 'deviceEnrollProofFromHandoff');
379
+ const { grant, reason } = deps.handoffCodes.redeemWithReason(input.code, {
380
+ ...(input.codeVerifier !== undefined ? { codeVerifier: input.codeVerifier } : {}),
381
+ ...(input.state !== undefined ? { state: input.state } : {}),
382
+ });
383
+ const wrong = grant === null || grant.purpose !== 'step-up' || grant.userId !== ctx.user.id;
384
+ if (wrong) {
385
+ deps.logger?.warn('Step-up handoff refused', {
386
+ tags: { userId: ctx.user.id },
387
+ meta: {
388
+ reason: reason ?? (grant?.purpose !== 'step-up' ? 'wrong-purpose' : 'other-user'),
389
+ },
390
+ });
391
+ throw new server_1.TRPCError({
392
+ code: 'UNAUTHORIZED',
393
+ message: 'Invalid, expired or mismatched step-up code — sign in again',
394
+ });
395
+ }
396
+ return deps.enrollProofs.mint(ctx.user.id);
397
+ }),
398
+ /**
399
+ * Enroll the CALLER's own device — no QR. Needs a signed-in person AND a
400
+ * single-use step-up proof (a sign-in made just now, for this same user);
401
+ * a merely refreshed session can never mint a permanent credential.
402
+ * Hub-wide `deviceCertificates.selfEnrollment = disabled` refuses.
403
+ */
404
+ enrollSelf: trpc_middleware_js_1.protectedProcedure
405
+ .input(types_1.EnrollSelfInputSchema)
406
+ .output(types_1.CompleteEnrollmentResultSchema)
407
+ .mutation(async ({ ctx, input }) => {
408
+ assertPerson(ctx.user);
409
+ selfLimit(ctx.user.id, 'enrollSelf');
410
+ const verdict = deps.enrollProofs.consume(input.proof, ctx.user.id);
411
+ if (verdict !== 'ok')
412
+ return refusal('step-up-required');
413
+ const issued = await internal().deviceCredentials.enrollSelf({
414
+ userId: ctx.user.id,
415
+ publicKeySpki: input.publicKeySpki,
416
+ deviceName: input.deviceName,
417
+ platform: input.platform,
418
+ });
419
+ if (!issued.ok)
420
+ return refusal(toWireReason(issued.reason));
421
+ await audit('device-credential-enrolled', ctx.user.id, ctx.req, {
422
+ credentialId: issued.credentialId,
423
+ via: 'self',
424
+ });
425
+ return {
426
+ ok: true,
427
+ credentialId: issued.credentialId,
428
+ certPem: issued.certPem,
429
+ chainPem: issued.chainPem,
430
+ notAfter: issued.notAfter,
431
+ user: { id: issued.userId, username: issued.username },
432
+ };
433
+ }),
434
+ };
435
+ }
@@ -4,6 +4,7 @@ exports.RefreshResultSchema = exports.LoginSessionSchema = void 0;
4
4
  exports.createSessionProcedures = createSessionProcedures;
5
5
  const server_1 = require("@trpc/server");
6
6
  const zod_1 = require("zod");
7
+ const types_1 = require("@camstack/types");
7
8
  const hub_sessions_js_1 = require("../../auth/hub-sessions.js");
8
9
  const session_mint_js_1 = require("../../auth/session-mint.js");
9
10
  const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
@@ -30,6 +31,13 @@ exports.RefreshResultSchema = zod_1.z.object({
30
31
  sessionExpiresAt: zod_1.z.number(),
31
32
  user: zod_1.z.object({ id: zod_1.z.string(), username: zod_1.z.string(), isAdmin: zod_1.z.boolean() }),
32
33
  });
34
+ /** Refresh refusals caused by the device credential behind the session, as wire reasons. */
35
+ const DEVICE_REFRESH_REASONS = {
36
+ 'credential-revoked': 'revoked',
37
+ 'credential-expired': 'expired',
38
+ 'credential-unknown': 'unknown-credential',
39
+ 'credential-unavailable': 'unavailable',
40
+ };
33
41
  function requireSessions(registry) {
34
42
  const internal = registry === null ? null : (0, hub_sessions_js_1.userManagementInternal)(registry);
35
43
  if (internal === null) {
@@ -92,8 +100,18 @@ function createSessionProcedures(deps) {
92
100
  };
93
101
  }
94
102
  catch (error) {
95
- if (error instanceof session_mint_js_1.SessionRefreshRefusedError ||
96
- error instanceof session_mint_js_1.SessionUserGoneError) {
103
+ if (error instanceof session_mint_js_1.SessionRefreshRefusedError) {
104
+ // A session opened by a device certificate says WHY, in a typed
105
+ // message the app switches on (`deviceRefusalFromMessage`).
106
+ const credentialReason = DEVICE_REFRESH_REASONS[error.reason];
107
+ throw new server_1.TRPCError({
108
+ code: 'UNAUTHORIZED',
109
+ message: credentialReason === undefined
110
+ ? 'Session expired — sign in again'
111
+ : `${types_1.DEVICE_REFRESH_REFUSAL_PREFIX}${credentialReason}`,
112
+ });
113
+ }
114
+ if (error instanceof session_mint_js_1.SessionUserGoneError) {
97
115
  throw new server_1.TRPCError({
98
116
  code: 'UNAUTHORIZED',
99
117
  message: 'Session expired — sign in again',