@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.
- package/dist/agent/agent-http.js +23 -2
- package/dist/agent/agent-mesh-enroll.js +2 -0
- package/dist/agent/agent-node-certificates.js +294 -0
- package/dist/agent/agent-reconnect.js +34 -0
- package/dist/agent/agent-sniffing-listener.js +263 -0
- package/dist/agent/agent-update-service.js +4 -1
- package/dist/agent/main.js +101 -37
- package/dist/agent/serial-queue.js +49 -0
- package/dist/api/core/auth-device-cert.procedures.js +435 -0
- package/dist/api/core/auth-sessions.procedures.js +20 -2
- package/dist/api/core/auth.router.js +190 -7
- package/dist/api/core/certificates.router.js +505 -0
- package/dist/api/mesh/enroll.routes.js +7 -1
- package/dist/api/trpc/generated-cap-mounts.js +4 -1
- package/dist/api/trpc/generated-cap-routers.js +2006 -1859
- package/dist/api/trpc/trpc.router.js +45 -0
- package/dist/auth/device-enroll-proofs.js +148 -0
- package/dist/auth/hub-sessions.js +2 -0
- package/dist/auth/session-mint.js +24 -2
- package/dist/auth/sso-routes.js +1 -0
- package/dist/core/agent/agent-registry.service.js +7 -0
- package/dist/core/agent/node-certificates-client.js +38 -0
- package/dist/core/auth/handoff-code.service.js +27 -4
- package/dist/core/moleculer/moleculer.service.js +5 -0
- package/package.json +9 -9
|
@@ -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
|
-
|
|
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',
|