@camstack/server 1.1.50 → 1.1.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EXCHANGE_SESSION_HEADER = void 0;
|
|
3
4
|
exports.createAuthRouter = createAuthRouter;
|
|
4
5
|
/**
|
|
5
6
|
* Auth router — core API for login/logout/me.
|
|
@@ -20,6 +21,8 @@ const zod_1 = require("zod");
|
|
|
20
21
|
const server_1 = require("@trpc/server");
|
|
21
22
|
const types_1 = require("@camstack/types");
|
|
22
23
|
const share_token_service_js_1 = require("../../core/auth/share-token.service.js");
|
|
24
|
+
const handoff_code_service_js_1 = require("../../core/auth/handoff-code.service.js");
|
|
25
|
+
const session_cookie_js_1 = require("../../auth/session-cookie.js");
|
|
23
26
|
const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
|
|
24
27
|
/**
|
|
25
28
|
* The available second-factor kinds a user may satisfy after the
|
|
@@ -175,19 +178,40 @@ function toShareTokenSummary(record) {
|
|
|
175
178
|
};
|
|
176
179
|
}
|
|
177
180
|
/**
|
|
178
|
-
*
|
|
179
|
-
* tokens (`cst_*`) and share-view principals (`csv_*`) must
|
|
180
|
-
* list, or revoke share links —
|
|
181
|
-
*
|
|
181
|
+
* Token minting/management surfaces are for REAL user sessions only.
|
|
182
|
+
* Scoped API tokens (`cst_*`) and share-view principals (`csv_*`) must
|
|
183
|
+
* never mint, list, or revoke share links — nor mint handoff codes —
|
|
184
|
+
* a leaked restricted token would otherwise widen its own reach.
|
|
182
185
|
*/
|
|
183
|
-
function assertRealUserSession(user) {
|
|
186
|
+
function assertRealUserSession(user, what = 'Share-token management') {
|
|
184
187
|
if (user.isScoped || user.shareView) {
|
|
185
188
|
throw new server_1.TRPCError({
|
|
186
189
|
code: 'FORBIDDEN',
|
|
187
|
-
message:
|
|
190
|
+
message: `${what} requires a real user session`,
|
|
188
191
|
});
|
|
189
192
|
}
|
|
190
193
|
}
|
|
194
|
+
// ── Session exchange (viewer same-origin session reuse) ──────────────
|
|
195
|
+
//
|
|
196
|
+
// The viewer web build is served by the hub under `/viewer/camstack/`
|
|
197
|
+
// — SAME ORIGIN as the admin-ui. After an admin-ui login the session
|
|
198
|
+
// JWT is mirrored into the httpOnly `camstack_session` cookie
|
|
199
|
+
// (`POST /api/auth/session`); `auth.exchangeSession` lets the viewer
|
|
200
|
+
// upgrade that cookie back into a bearer token WITHOUT ever reading the
|
|
201
|
+
// cookie from JS (it's httpOnly — the browser attaches it, the server
|
|
202
|
+
// answers with a freshly-minted session).
|
|
203
|
+
/** Custom header a cross-site form can never set — CSRF gate for the
|
|
204
|
+
* cookie-authenticated `exchangeSession` mutation. */
|
|
205
|
+
exports.EXCHANGE_SESSION_HEADER = 'x-camstack-exchange';
|
|
206
|
+
/** Read a single-valued request header off the tRPC context request. */
|
|
207
|
+
function readRequestHeader(req, name) {
|
|
208
|
+
const value = req?.headers[name];
|
|
209
|
+
if (typeof value === 'string')
|
|
210
|
+
return value;
|
|
211
|
+
if (Array.isArray(value))
|
|
212
|
+
return value[0] ?? null;
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
191
215
|
/** Wire shape of the authenticated user returned by `auth.me`. */
|
|
192
216
|
const MeSchema = zod_1.z
|
|
193
217
|
.object({
|
|
@@ -203,7 +227,7 @@ const MeSchema = zod_1.z
|
|
|
203
227
|
agentId: zod_1.z.string().optional(),
|
|
204
228
|
})
|
|
205
229
|
.nullable();
|
|
206
|
-
function createAuthRouter(auth, registry, moleculer = null, shareTokens = null) {
|
|
230
|
+
function createAuthRouter(auth, registry, moleculer = null, shareTokens = null, handoffCodes = new handoff_code_service_js_1.HandoffCodeService()) {
|
|
207
231
|
const requireShareTokens = () => {
|
|
208
232
|
if (!shareTokens) {
|
|
209
233
|
throw new Error('Share tokens unavailable — service not wired on this node');
|
|
@@ -242,6 +266,24 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null)
|
|
|
242
266
|
requiresTotp: false,
|
|
243
267
|
};
|
|
244
268
|
};
|
|
269
|
+
/**
|
|
270
|
+
* `mintSessionForUserId` with the "user vanished" failure mapped to a
|
|
271
|
+
* clean 401 — for the token-exchange legs (`exchangeSession`,
|
|
272
|
+
* `redeemHandoffCode`) where the caller presented a credential whose
|
|
273
|
+
* backing user may have been deleted mid-flight. Infrastructure
|
|
274
|
+
* failures (user-management cap not registered) still surface as 500.
|
|
275
|
+
*/
|
|
276
|
+
const mintSessionOrUnauthorized = async (userId) => {
|
|
277
|
+
try {
|
|
278
|
+
return await mintSessionForUserId(userId);
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (error instanceof Error && error.message === 'User no longer exists') {
|
|
282
|
+
throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'User no longer exists' });
|
|
283
|
+
}
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
};
|
|
245
287
|
return (0, trpc_middleware_js_1.trpcRouter)({
|
|
246
288
|
login: trpc_middleware_js_1.publicProcedure
|
|
247
289
|
.input(zod_1.z.object({ username: zod_1.z.string(), password: zod_1.z.string() }))
|
|
@@ -444,6 +486,90 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null)
|
|
|
444
486
|
}
|
|
445
487
|
return mintSessionForUserId(result.userId);
|
|
446
488
|
}),
|
|
489
|
+
/**
|
|
490
|
+
* PUBLIC — upgrade the browser's httpOnly session COOKIE into a
|
|
491
|
+
* bearer token (viewer same-origin session reuse). Authentication
|
|
492
|
+
* comes from the `camstack_session` cookie ONLY — never from a
|
|
493
|
+
* bearer header — so a hub-served SPA (the viewer under
|
|
494
|
+
* `/viewer/camstack/`) can bootstrap without its own login.
|
|
495
|
+
*
|
|
496
|
+
* Security properties:
|
|
497
|
+
* • CSRF: mutation (POST) + a REQUIRED custom header
|
|
498
|
+
* (`x-camstack-exchange: 1`) that a cross-site form can't set;
|
|
499
|
+
* belt-and-braces on top of the cookie's `SameSite=Lax`
|
|
500
|
+
* semantics (see `buildSessionCookie` — Lax already withholds
|
|
501
|
+
* the cookie from cross-site POSTs).
|
|
502
|
+
* • Only REAL session JWTs qualify: bridge tokens (`kind:
|
|
503
|
+
* 'totp-challenge'` / `'sso-bridge'`) and `cst_`/`csv_` opaque
|
|
504
|
+
* tokens are rejected — the cookie must carry a v2 session.
|
|
505
|
+
* • The returned bearer is minted FRESH through the shared
|
|
506
|
+
* `mintSessionForUserId` tail (re-fetched user → up-to-date
|
|
507
|
+
* scopes), equivalent to what `auth.login` hands out.
|
|
508
|
+
*/
|
|
509
|
+
exchangeSession: trpc_middleware_js_1.publicProcedure
|
|
510
|
+
.input(zod_1.z.object({}).optional())
|
|
511
|
+
.output(LoginResultSchema)
|
|
512
|
+
.mutation(async ({ ctx }) => {
|
|
513
|
+
if (readRequestHeader(ctx.req, exports.EXCHANGE_SESSION_HEADER) !== '1') {
|
|
514
|
+
throw new server_1.TRPCError({
|
|
515
|
+
code: 'FORBIDDEN',
|
|
516
|
+
message: `Missing ${exports.EXCHANGE_SESSION_HEADER} header`,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
const cookieToken = (0, session_cookie_js_1.readSessionCookieFromHeader)(readRequestHeader(ctx.req, 'cookie') ?? undefined);
|
|
520
|
+
if (!cookieToken) {
|
|
521
|
+
throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'No session cookie' });
|
|
522
|
+
}
|
|
523
|
+
let payload;
|
|
524
|
+
try {
|
|
525
|
+
payload = auth.verifyToken(cookieToken);
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid session cookie' });
|
|
529
|
+
}
|
|
530
|
+
// Reject non-session JWTs that verify under the same secret:
|
|
531
|
+
// challenge/bridge tokens carry a `kind` discriminator, and a
|
|
532
|
+
// v2 session always has a boolean `isAdmin` + string `userId`.
|
|
533
|
+
const kind = Reflect.get(payload, 'kind');
|
|
534
|
+
if (kind !== undefined ||
|
|
535
|
+
typeof payload.isAdmin !== 'boolean' ||
|
|
536
|
+
typeof payload.userId !== 'string') {
|
|
537
|
+
throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid session cookie' });
|
|
538
|
+
}
|
|
539
|
+
return mintSessionOrUnauthorized(payload.userId);
|
|
540
|
+
}),
|
|
541
|
+
// ── One-time handoff codes (native-app login handoff) ─────────────
|
|
542
|
+
//
|
|
543
|
+
// The admin-ui login page, when its `redirect` param is the app's
|
|
544
|
+
// custom-scheme callback, mints a code AFTER a successful login and
|
|
545
|
+
// bounces to `camstack://auth-callback?code=…`; the app redeems it
|
|
546
|
+
// for a real session. TTL 60s, single-use, bound to the minting
|
|
547
|
+
// user — see `HandoffCodeService`.
|
|
548
|
+
/** Mint a one-time handoff code for the CALLING user. Real user
|
|
549
|
+
* sessions only — scoped (`cst_`) and share-view (`csv_`) callers
|
|
550
|
+
* must never convert themselves into a full session. */
|
|
551
|
+
createHandoffCode: trpc_middleware_js_1.protectedProcedure
|
|
552
|
+
.input(zod_1.z.void())
|
|
553
|
+
.output(zod_1.z.object({ code: zod_1.z.string(), expiresAt: zod_1.z.number() }))
|
|
554
|
+
.mutation(({ ctx }) => {
|
|
555
|
+
assertRealUserSession(ctx.user, 'Handoff-code minting');
|
|
556
|
+
return handoffCodes.create(ctx.user.id);
|
|
557
|
+
}),
|
|
558
|
+
/** PUBLIC — redeem a one-time handoff code for a session bearer.
|
|
559
|
+
* Unknown / expired / already-used codes → UNAUTHORIZED. */
|
|
560
|
+
redeemHandoffCode: trpc_middleware_js_1.publicProcedure
|
|
561
|
+
.input(zod_1.z.object({ code: zod_1.z.string().min(1) }))
|
|
562
|
+
.output(LoginResultSchema)
|
|
563
|
+
.mutation(async ({ input }) => {
|
|
564
|
+
const grant = handoffCodes.redeem(input.code);
|
|
565
|
+
if (!grant) {
|
|
566
|
+
throw new server_1.TRPCError({
|
|
567
|
+
code: 'UNAUTHORIZED',
|
|
568
|
+
message: 'Invalid or expired handoff code',
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
return mintSessionOrUnauthorized(grant.userId);
|
|
572
|
+
}),
|
|
447
573
|
me: trpc_middleware_js_1.protectedProcedure
|
|
448
574
|
.input(zod_1.z.void())
|
|
449
575
|
.output(MeSchema)
|
|
@@ -166,7 +166,7 @@ function buildCapabilityRouters(services) {
|
|
|
166
166
|
// clusterNodes — fixed core API. Write-side purge for the durable
|
|
167
167
|
// offline-node history (Track A "Forget node"); read side is push-only.
|
|
168
168
|
clusterNodes: (0, cluster_nodes_router_js_1.createClusterNodesRouter)(services.agentRegistry),
|
|
169
|
-
auth: (0, auth_router_js_1.createAuthRouter)(services.authService, services.capabilityRegistry, services.moleculer, services.shareTokenService),
|
|
169
|
+
auth: (0, auth_router_js_1.createAuthRouter)(services.authService, services.capabilityRegistry, services.moleculer, services.shareTokenService, services.handoffCodeService),
|
|
170
170
|
// NOT MOUNTED — `mount: { kind: 'skip' }` legacy provider shapes
|
|
171
171
|
// (positional args / sync returns) that don't match the codegen
|
|
172
172
|
// routers' {input}-object + Promise<T> contract. The runtime builder
|
|
@@ -3,9 +3,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.SESSION_COOKIE = void 0;
|
|
4
4
|
exports.buildSessionCookie = buildSessionCookie;
|
|
5
5
|
exports.clearSessionCookie = clearSessionCookie;
|
|
6
|
+
exports.readSessionCookieFromHeader = readSessionCookieFromHeader;
|
|
6
7
|
exports.shouldRedirectToLogin = shouldRedirectToLogin;
|
|
7
8
|
exports.loginRedirectUrl = loginRedirectUrl;
|
|
8
9
|
exports.isEmbedRedirectTarget = isEmbedRedirectTarget;
|
|
10
|
+
exports.isSessionGradeJwtPayload = isSessionGradeJwtPayload;
|
|
9
11
|
/** Browser session cookie carrying the hub JWT. Set by POST /api/auth/session
|
|
10
12
|
* after a tRPC login; read by the addon-route catch-all for `authenticated`
|
|
11
13
|
* routes hit by a plain browser navigation. */
|
|
@@ -24,6 +26,34 @@ function clearSessionCookie() {
|
|
|
24
26
|
options: { httpOnly: true, sameSite: 'lax', secure: true, path: '/', maxAge: 0 },
|
|
25
27
|
};
|
|
26
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Extract the session JWT from a raw `Cookie` request header. Plugin-free
|
|
31
|
+
* (works on both Fastify requests and bare WS upgrade `IncomingMessage`s)
|
|
32
|
+
* so callers don't depend on `@fastify/cookie` decoration order. Returns
|
|
33
|
+
* `null` when the header is absent or carries no `camstack_session` pair.
|
|
34
|
+
*/
|
|
35
|
+
function readSessionCookieFromHeader(header) {
|
|
36
|
+
if (!header)
|
|
37
|
+
return null;
|
|
38
|
+
for (const pair of header.split(';')) {
|
|
39
|
+
const eq = pair.indexOf('=');
|
|
40
|
+
if (eq === -1)
|
|
41
|
+
continue;
|
|
42
|
+
const name = pair.slice(0, eq).trim();
|
|
43
|
+
if (name !== exports.SESSION_COOKIE)
|
|
44
|
+
continue;
|
|
45
|
+
const raw = pair.slice(eq + 1).trim();
|
|
46
|
+
if (raw === '')
|
|
47
|
+
return null;
|
|
48
|
+
try {
|
|
49
|
+
return decodeURIComponent(raw);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return raw;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
27
57
|
/** A browser navigation we can bounce to the login page: a top-level GET
|
|
28
58
|
* that wants HTML. Anything else (API call, POST, non-HTML) keeps the
|
|
29
59
|
* 401 behavior so programmatic clients get a clean error. */
|
|
@@ -45,3 +75,17 @@ function isEmbedRedirectTarget(next) {
|
|
|
45
75
|
return false;
|
|
46
76
|
return true;
|
|
47
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* True only for a payload with the v2 SESSION shape: string `userId`,
|
|
80
|
+
* boolean `isAdmin`, and NO `kind` discriminator. Challenge/bridge tokens
|
|
81
|
+
* (`kind: 'totp-challenge' | 'sso-bridge'`) verify under the same hub
|
|
82
|
+
* secret but are NOT sessions — accepting one as a session cookie lets a
|
|
83
|
+
* password-only attacker skip the second factor on every cookie-gated
|
|
84
|
+
* surface. Shared by `POST /api/auth/session` and `auth.exchangeSession`.
|
|
85
|
+
*/
|
|
86
|
+
function isSessionGradeJwtPayload(payload) {
|
|
87
|
+
if (payload === null || typeof payload !== 'object')
|
|
88
|
+
return false;
|
|
89
|
+
const p = payload;
|
|
90
|
+
return p.kind === undefined && typeof p.userId === 'string' && typeof p.isAdmin === 'boolean';
|
|
91
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.HandoffCodeService = exports.HANDOFF_CODE_MAX_PENDING = exports.HANDOFF_CODE_TTL_MS = exports.HANDOFF_CODE_PREFIX = void 0;
|
|
37
|
+
/**
|
|
38
|
+
* HandoffCodeService — one-time login handoff codes for the native-app
|
|
39
|
+
* auth handoff (viewer "Sign in with CamStack").
|
|
40
|
+
*
|
|
41
|
+
* Flow: an AUTHENTICATED browser session (the admin-ui login page, after
|
|
42
|
+
* any successful login leg) mints a short-lived single-use code bound to
|
|
43
|
+
* its user, then bounces to the app's custom-scheme callback
|
|
44
|
+
* (`camstack://auth-callback?code=…`). The app redeems the code over the
|
|
45
|
+
* PUBLIC `auth.redeemHandoffCode` procedure and receives a real session
|
|
46
|
+
* JWT minted through the same `mintSessionForUserId` tail every login
|
|
47
|
+
* leg uses.
|
|
48
|
+
*
|
|
49
|
+
* Design mirrors the pending-challenge / share-token patterns:
|
|
50
|
+
* • the raw code is returned exactly once; only its SHA-256 hash is
|
|
51
|
+
* kept server-side, so a memory dump never exposes redeemable codes;
|
|
52
|
+
* • TTL 60s — long enough to survive the browser → app bounce, short
|
|
53
|
+
* enough that a leaked callback URL goes stale before it travels;
|
|
54
|
+
* • single-use — the entry is consumed on FIRST redeem attempt
|
|
55
|
+
* (even an expired hit is deleted), so a replayed code is dead;
|
|
56
|
+
* • in-memory only — codes never need to survive a hub restart
|
|
57
|
+
* (the browser just re-runs the handoff), and a bounded store +
|
|
58
|
+
* prune-on-mint keeps the map from growing.
|
|
59
|
+
*/
|
|
60
|
+
const crypto = __importStar(require("node:crypto"));
|
|
61
|
+
/** Wire prefix — `chc_` = CamStack Handoff Code (cf. `cst_`/`csv_`). */
|
|
62
|
+
exports.HANDOFF_CODE_PREFIX = 'chc_';
|
|
63
|
+
exports.HANDOFF_CODE_TTL_MS = 60_000;
|
|
64
|
+
/** Hard bound on concurrently-pending codes (mint is auth-gated, so this
|
|
65
|
+
* only guards against a runaway authenticated client). */
|
|
66
|
+
exports.HANDOFF_CODE_MAX_PENDING = 1_000;
|
|
67
|
+
function hashCode(code) {
|
|
68
|
+
return crypto.createHash('sha256').update(code).digest('hex');
|
|
69
|
+
}
|
|
70
|
+
class HandoffCodeService {
|
|
71
|
+
now;
|
|
72
|
+
ttlMs;
|
|
73
|
+
/** Keyed by SHA-256(raw code). */
|
|
74
|
+
pending = new Map();
|
|
75
|
+
constructor(now = Date.now, ttlMs = exports.HANDOFF_CODE_TTL_MS) {
|
|
76
|
+
this.now = now;
|
|
77
|
+
this.ttlMs = ttlMs;
|
|
78
|
+
}
|
|
79
|
+
/** Mint a single-use code bound to `userId`. Throws when the pending
|
|
80
|
+
* store is full even after pruning expired entries (fail fast — a
|
|
81
|
+
* legitimate flow never has anywhere near this many in flight). */
|
|
82
|
+
create(userId) {
|
|
83
|
+
this.prune();
|
|
84
|
+
if (this.pending.size >= exports.HANDOFF_CODE_MAX_PENDING) {
|
|
85
|
+
throw new Error('Too many pending handoff codes — try again shortly');
|
|
86
|
+
}
|
|
87
|
+
const code = `${exports.HANDOFF_CODE_PREFIX}${crypto.randomBytes(32).toString('hex')}`;
|
|
88
|
+
const expiresAt = this.now() + this.ttlMs;
|
|
89
|
+
this.pending.set(hashCode(code), { userId, expiresAt });
|
|
90
|
+
return { code, expiresAt };
|
|
91
|
+
}
|
|
92
|
+
/** Redeem a raw code. Consumes the entry on the FIRST attempt no
|
|
93
|
+
* matter the outcome (single-use); returns `null` for unknown,
|
|
94
|
+
* already-used, or expired codes — the router maps that to 401. */
|
|
95
|
+
redeem(code) {
|
|
96
|
+
const entry = this.pending.get(hashCode(code));
|
|
97
|
+
if (!entry)
|
|
98
|
+
return null;
|
|
99
|
+
this.pending.delete(hashCode(code));
|
|
100
|
+
if (this.now() > entry.expiresAt)
|
|
101
|
+
return null;
|
|
102
|
+
return { userId: entry.userId };
|
|
103
|
+
}
|
|
104
|
+
/** Number of not-yet-redeemed (possibly expired) codes. Test/diag aid. */
|
|
105
|
+
get pendingCount() {
|
|
106
|
+
return this.pending.size;
|
|
107
|
+
}
|
|
108
|
+
prune() {
|
|
109
|
+
const cutoff = this.now();
|
|
110
|
+
for (const [key, entry] of this.pending) {
|
|
111
|
+
if (cutoff > entry.expiresAt)
|
|
112
|
+
this.pending.delete(key);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
exports.HandoffCodeService = HandoffCodeService;
|
package/dist/main.js
CHANGED
|
@@ -51,6 +51,7 @@ const event_bus_service_1 = require("./core/events/event-bus.service");
|
|
|
51
51
|
const config_service_1 = require("./core/config/config.service");
|
|
52
52
|
const auth_service_1 = require("./core/auth/auth.service");
|
|
53
53
|
const share_token_service_1 = require("./core/auth/share-token.service");
|
|
54
|
+
const handoff_code_service_1 = require("./core/auth/handoff-code.service");
|
|
54
55
|
// Boot-time capability declaration runs over the auto-generated
|
|
55
56
|
// `ALL_CAPABILITY_DEFINITIONS` array — every `*.cap.ts` file that ships
|
|
56
57
|
// with `@camstack/types` is included automatically. Adding a new cap
|
|
@@ -353,9 +354,14 @@ async function bootstrap() {
|
|
|
353
354
|
// the settings backend (lazy getter: the backend lands after the
|
|
354
355
|
// sqlite-storage builtin registers; the service resolves it per call).
|
|
355
356
|
const shareTokenService = new share_token_service_1.ShareTokenService(() => addonRegistry.getSettingsBackend(), loggingService.createLogger('share-tokens'));
|
|
357
|
+
// One-time native-app login handoff codes (in-memory, 60s TTL,
|
|
358
|
+
// single-use) — minted by `auth.createHandoffCode`, redeemed by the
|
|
359
|
+
// viewer app via the public `auth.redeemHandoffCode`.
|
|
360
|
+
const handoffCodeService = new handoff_code_service_1.HandoffCodeService();
|
|
356
361
|
appRouter = (0, trpc_router_1.buildAppRouter)({
|
|
357
362
|
authService,
|
|
358
363
|
shareTokenService,
|
|
364
|
+
handoffCodeService,
|
|
359
365
|
configService: config,
|
|
360
366
|
featureService: app.get(feature_service_1.FeatureService),
|
|
361
367
|
loggingService,
|
|
@@ -642,6 +648,13 @@ async function bootstrap() {
|
|
|
642
648
|
let ttlSec;
|
|
643
649
|
try {
|
|
644
650
|
const payload = authService.verifyToken(token); // throws on invalid/expired
|
|
651
|
+
// SESSION-grade JWTs only: challenge/bridge tokens (`kind`-tagged,
|
|
652
|
+
// e.g. the totp-challenge from login leg 1) verify under the same
|
|
653
|
+
// secret — accepting one here handed out a cookie that bypassed
|
|
654
|
+
// the second factor on every cookie-gated surface.
|
|
655
|
+
if (!(0, session_cookie_js_1.isSessionGradeJwtPayload)(payload)) {
|
|
656
|
+
return reply.status(401).send({ error: 'invalid token' });
|
|
657
|
+
}
|
|
645
658
|
const expSec = typeof payload.exp === 'number' ? payload.exp : 0;
|
|
646
659
|
ttlSec = Math.max(0, expSec - Math.floor(Date.now() / 1000));
|
|
647
660
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.52",
|
|
4
4
|
"private": false,
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -23,18 +23,18 @@
|
|
|
23
23
|
"test:watch": "vitest"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@camstack/addon-admin-ui": "1.1.
|
|
26
|
+
"@camstack/addon-admin-ui": "1.1.44",
|
|
27
27
|
"@camstack/addon-advanced-notifier": "1.1.21",
|
|
28
28
|
"@camstack/addon-auth": "1.1.5",
|
|
29
29
|
"@camstack/addon-decoder-nodeav": "1.1.9",
|
|
30
30
|
"@camstack/addon-notifiers": "1.1.21",
|
|
31
31
|
"@camstack/addon-pipeline": "1.1.51",
|
|
32
|
-
"@camstack/addon-pipeline-orchestrator": "1.1.
|
|
32
|
+
"@camstack/addon-pipeline-orchestrator": "1.1.39",
|
|
33
33
|
"@camstack/addon-post-analysis": "1.1.23",
|
|
34
34
|
"@camstack/sdk": "1.1.21",
|
|
35
35
|
"@camstack/shm-ring": "1.0.21",
|
|
36
|
-
"@camstack/system": "1.1.
|
|
37
|
-
"@camstack/types": "1.1.
|
|
36
|
+
"@camstack/system": "1.1.40",
|
|
37
|
+
"@camstack/types": "1.1.38",
|
|
38
38
|
"@camstack/ui-library": "1.1.31",
|
|
39
39
|
"@fastify/compress": "^9.0.0",
|
|
40
40
|
"@fastify/cookie": "^11.0.2",
|