@dudousxd/nestjs-agent-dashboard 0.9.0 → 0.10.0
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/README.md +43 -5
- package/dist/server/index.cjs +182 -80
- package/dist/server/index.cjs.map +1 -1
- package/dist/server/index.d.cts +94 -37
- package/dist/server/index.d.ts +94 -37
- package/dist/server/index.js +183 -81
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ AgentDashboardModule.forRoot({
|
|
|
42
42
|
basePath?: string; // where the SPA is served. Default '/ai-gateway'
|
|
43
43
|
apiBasePath?: string; // where the JSON/SSE API is mounted. Default '<basePath>/api'
|
|
44
44
|
guards?: Type<CanActivate>[]; // bring-your-own auth — see "Console auth" below
|
|
45
|
-
dashboardAuth?: DashboardAuthOptions; //
|
|
45
|
+
dashboardAuth?: DashboardAuthOptions; // signed-cookie gate (Mode A session / Mode B login) — see "Console auth" below
|
|
46
46
|
imports?: DynamicModule['imports'];
|
|
47
47
|
approvalActorRef?: (req) => string | undefined;
|
|
48
48
|
});
|
|
@@ -56,7 +56,7 @@ The console has no auth of its own by default — pick one of three postures, or
|
|
|
56
56
|
| ------- | ----------- | ----- |
|
|
57
57
|
| **Open** (default) | Local dev only. Never mount this way in production. | Omit both `guards` and `dashboardAuth`. |
|
|
58
58
|
| **`guards`** | You already have auth the console can reuse — an existing cookie-session guard, an SSO/OIDC session, a header your reverse proxy injects. | Pass guard classes; see below. |
|
|
59
|
-
| **`dashboardAuth`** | You have no ready-made guard for the console — e.g. your app authenticates with a header-only Bearer token a browser navigation can't attach — and want
|
|
59
|
+
| **`dashboardAuth`** | You have no ready-made guard for the console — e.g. your app authenticates with a header-only Bearer token a browser navigation can't attach — and want a protected console without building a whole `CanActivate`. Two modes: `session` (host mints the cookie from its own auth) or `login` (built-in login screen). | Pass `{ secret, session }` and/or `{ secret, login }`; see below. |
|
|
60
60
|
|
|
61
61
|
Both `guards` and `dashboardAuth` can be set together — **AND semantics**: a request must pass BOTH. `dashboardAuth`'s own gate runs first; a denied request never reaches your `guards`.
|
|
62
62
|
|
|
@@ -90,9 +90,31 @@ class ConsoleSessionGuard implements CanActivate {
|
|
|
90
90
|
export class AppModule {}
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
-
### Option B — `dashboardAuth` (
|
|
93
|
+
### Option B — `dashboardAuth` (signed session cookie)
|
|
94
94
|
|
|
95
|
-
Gates the console behind a stateless, signed session cookie (HMAC-SHA256, `node:crypto` only — no JWT dependency, no session store, mirrors `@dudousxd/nestjs-telescope`'s `dashboardAuth`).
|
|
95
|
+
Gates the console behind a stateless, signed session cookie (HMAC-SHA256, `node:crypto` only — no JWT dependency, no session store, mirrors `@dudousxd/nestjs-telescope`'s `dashboardAuth`). Two ways to mint that cookie — **Mode A** (`session`) and **Mode B** (`login`). At least one is required, or `forRoot`/`forRootAsync` throws at boot (an un-mintable gate is a boot error, not a silently-open console); you can also configure both together (e.g. Mode A for normal use, Mode B as a break-glass fallback). An unauthenticated API call always gets `401`, regardless of mode.
|
|
96
|
+
|
|
97
|
+
#### Mode A — `session` (the host mints the cookie)
|
|
98
|
+
|
|
99
|
+
Your host app already has its own auth (SSO/OIDC/whatever) — no need to teach this library a second credential. The host frontend, carrying that auth, `POST`s to `<basePath>/auth/session`; your `session` hook validates the raw request and returns the session user, or `null` to deny (`401`). No credential this library understands ever exists.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
AgentDashboardModule.forRoot({
|
|
103
|
+
dashboardAuth: {
|
|
104
|
+
secret: process.env.AI_GATEWAY_AUTH_SECRET, // REQUIRED — 32+ bytes recommended. Missing => boot error.
|
|
105
|
+
session: async (request) => {
|
|
106
|
+
const user = await sessions.verifyFromCookie(request); // read YOUR app's own session
|
|
107
|
+
return user ? { id: user.id, name: user.name, roles: user.roles } : null; // null => 401
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Have your host frontend call `POST <basePath>/auth/session` once it knows the visitor is signed in — no body this library reads. Success is `204` with the `Set-Cookie`; a denial is `401` and no cookie is set. An unauthenticated PAGE navigation under Mode-A-only has nowhere to redirect to (there's no login form) — it's served a small, static instruction page at `<basePath>/auth/session-required` instead, telling the visitor to sign in through the host app and reload.
|
|
114
|
+
|
|
115
|
+
#### Mode B — `login` (built-in login screen)
|
|
116
|
+
|
|
117
|
+
No host frontend/IdP to lean on — the console serves its own small, dependency-free, server-rendered login page. An unauthenticated page visit is redirected (302) to a built-in login form at `<basePath>/auth/login`.
|
|
96
118
|
|
|
97
119
|
```ts
|
|
98
120
|
AgentDashboardModule.forRoot({
|
|
@@ -107,7 +129,9 @@ AgentDashboardModule.forRoot({
|
|
|
107
129
|
});
|
|
108
130
|
```
|
|
109
131
|
|
|
110
|
-
Open `/ai-gateway`, enter the credentials, and you're in. Bad credentials get a **uniform** redirect back to the login page — the response is identical for an unknown user and a wrong password, so the endpoint never reveals which one was wrong. `POST <basePath>/auth/logout` clears the cookie.
|
|
132
|
+
Open `/ai-gateway`, enter the credentials, and you're in. Bad credentials get a **uniform** redirect back to the login page — the response is identical for an unknown user and a wrong password, so the endpoint never reveals which one was wrong. `POST <basePath>/auth/logout` clears the cookie (redirecting back to the login form, or — Mode-A-only — to the `session-required` instruction page above).
|
|
133
|
+
|
|
134
|
+
Only `username` is required (non-empty) — the password is passed through to `login` verbatim, including empty, so a host that authenticates by username/email alone (password deliberately ignored) can use the built-in screen unmodified; the hook owns whether an empty password is accepted or rejected.
|
|
111
135
|
|
|
112
136
|
Need the `login` hook to reach a DB (e.g. an `EntityManager`)? Use `forRootAsync` — `basePath`/`apiBasePath`/`guards` stay static (needed at module-build time for routing), only the auth config is resolved through DI:
|
|
113
137
|
|
|
@@ -127,6 +151,20 @@ AgentDashboardModule.forRootAsync({
|
|
|
127
151
|
});
|
|
128
152
|
```
|
|
129
153
|
|
|
154
|
+
#### Session lifetime: `ttl`, sliding renewal, and `revalidate`
|
|
155
|
+
|
|
156
|
+
Any request carrying a valid cookie past 50% of its `ttl` gets a freshly re-signed one, so an active tab never expires mid-use. `revalidate?: (session) => boolean | Promise<boolean>` hooks into that same sliding-renewal path — a chance to re-check a *live* session (e.g. "is this user still active?") before the fresh cookie ships, so a deactivated or demoted operator loses access instead of riding a self-renewing cookie for as long as the tab stays open. Return `false` (or throw) to revoke: the cookie is cleared and the request denied, the same treatment as an absent cookie. It runs at most once per `ttl/2` per cookie generation, per in-flight request — concurrent requests still carrying the same not-yet-renewed cookie each invoke it (a page load firing several parallel API calls can trigger several calls before the refreshed cookie lands), so size it for occasional concurrent invocations, not a strict once-per-session cap. `revalidate` alone can't mint a session — it doesn't count toward the `session`/`login` "at least one" requirement.
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
AgentDashboardModule.forRoot({
|
|
160
|
+
dashboardAuth: {
|
|
161
|
+
secret: process.env.AI_GATEWAY_AUTH_SECRET,
|
|
162
|
+
session: async (request) => /* ... */,
|
|
163
|
+
revalidate: async (session) => (await users.findActive(session.id)) !== null,
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
```
|
|
167
|
+
|
|
130
168
|
## JSON + SSE API
|
|
131
169
|
|
|
132
170
|
Mounted at `apiBasePath`:
|
package/dist/server/index.cjs
CHANGED
|
@@ -303,6 +303,39 @@ function clearSessionCookie(context) {
|
|
|
303
303
|
appendSetCookie(context.response, cookie);
|
|
304
304
|
}
|
|
305
305
|
__name(clearSessionCookie, "clearSessionCookie");
|
|
306
|
+
async function maybeRenewSession(auth, session, request, response, now = Date.now()) {
|
|
307
|
+
if (now - session.iat <= auth.ttlMs / 2) return true;
|
|
308
|
+
const user = {
|
|
309
|
+
id: session.sub,
|
|
310
|
+
...session.name !== void 0 ? {
|
|
311
|
+
name: session.name
|
|
312
|
+
} : {},
|
|
313
|
+
roles: session.roles
|
|
314
|
+
};
|
|
315
|
+
if (auth.revalidate) {
|
|
316
|
+
let allowed;
|
|
317
|
+
try {
|
|
318
|
+
allowed = await auth.revalidate(user);
|
|
319
|
+
} catch {
|
|
320
|
+
allowed = false;
|
|
321
|
+
}
|
|
322
|
+
if (!allowed) {
|
|
323
|
+
clearSessionCookie({
|
|
324
|
+
request,
|
|
325
|
+
response
|
|
326
|
+
});
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
issueSessionCookie(user, {
|
|
331
|
+
auth,
|
|
332
|
+
request,
|
|
333
|
+
response,
|
|
334
|
+
now
|
|
335
|
+
});
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
__name(maybeRenewSession, "maybeRenewSession");
|
|
306
339
|
|
|
307
340
|
// src/server/auth/dashboard-auth.guard.ts
|
|
308
341
|
function _ts_decorate(decorators, target, key, desc) {
|
|
@@ -330,14 +363,16 @@ var DashboardAuthGuard = class {
|
|
|
330
363
|
constructor(auth) {
|
|
331
364
|
this.auth = auth;
|
|
332
365
|
}
|
|
333
|
-
canActivate(context) {
|
|
366
|
+
async canActivate(context) {
|
|
334
367
|
if (!this.auth) return true;
|
|
335
368
|
const http = context.switchToHttp();
|
|
336
369
|
const request = http.getRequest();
|
|
337
370
|
const session = this.verifyRequestSession(request);
|
|
338
371
|
if (!session) throw new import_common.UnauthorizedException();
|
|
339
372
|
attachSession(request, session);
|
|
340
|
-
this.
|
|
373
|
+
if (!await maybeRenewSession(this.auth, session, request, http.getResponse())) {
|
|
374
|
+
throw new import_common.UnauthorizedException();
|
|
375
|
+
}
|
|
341
376
|
return true;
|
|
342
377
|
}
|
|
343
378
|
verifyRequestSession(request) {
|
|
@@ -349,28 +384,6 @@ var DashboardAuthGuard = class {
|
|
|
349
384
|
secret: auth.secret
|
|
350
385
|
});
|
|
351
386
|
}
|
|
352
|
-
/**
|
|
353
|
-
* Sliding renewal: a valid cookie past 50% of its TTL is transparently re-issued on the
|
|
354
|
-
* response, so an active user never gets logged out mid-session.
|
|
355
|
-
*/
|
|
356
|
-
maybeRenew(response, request, session) {
|
|
357
|
-
const auth = this.auth;
|
|
358
|
-
if (!auth) return;
|
|
359
|
-
const now = Date.now();
|
|
360
|
-
if (now - session.iat <= auth.ttlMs / 2) return;
|
|
361
|
-
issueSessionCookie({
|
|
362
|
-
id: session.sub,
|
|
363
|
-
...session.name !== void 0 ? {
|
|
364
|
-
name: session.name
|
|
365
|
-
} : {},
|
|
366
|
-
roles: session.roles
|
|
367
|
-
}, {
|
|
368
|
-
auth,
|
|
369
|
-
request,
|
|
370
|
-
response,
|
|
371
|
-
now
|
|
372
|
-
});
|
|
373
|
-
}
|
|
374
387
|
};
|
|
375
388
|
DashboardAuthGuard = _ts_decorate([
|
|
376
389
|
(0, import_common.Injectable)(),
|
|
@@ -1166,17 +1179,14 @@ function escapeHtml(value) {
|
|
|
1166
1179
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1167
1180
|
}
|
|
1168
1181
|
__name(escapeHtml, "escapeHtml");
|
|
1169
|
-
function
|
|
1170
|
-
const action = escapeHtml(options.actionUrl);
|
|
1171
|
-
const returnTo = escapeHtml(options.returnTo);
|
|
1172
|
-
const errorBanner = options.error === true ? '<p role="alert" class="error">Invalid username or password.</p>' : "";
|
|
1182
|
+
function pageShell(content, title) {
|
|
1173
1183
|
return `<!doctype html>
|
|
1174
1184
|
<html lang="en">
|
|
1175
1185
|
<head>
|
|
1176
1186
|
<meta charset="utf-8" />
|
|
1177
1187
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1178
1188
|
<meta name="robots" content="noindex, nofollow" />
|
|
1179
|
-
<title
|
|
1189
|
+
<title>${title}</title>
|
|
1180
1190
|
<style>
|
|
1181
1191
|
:root { color-scheme: dark; }
|
|
1182
1192
|
* { box-sizing: border-box; }
|
|
@@ -1207,6 +1217,13 @@ function renderLoginPage(options) {
|
|
|
1207
1217
|
font-weight: 600;
|
|
1208
1218
|
color: #34d399;
|
|
1209
1219
|
}
|
|
1220
|
+
h1 {
|
|
1221
|
+
margin: 0 0 12px;
|
|
1222
|
+
font-size: 16px;
|
|
1223
|
+
font-weight: 600;
|
|
1224
|
+
color: #f4f4f5;
|
|
1225
|
+
}
|
|
1226
|
+
p { margin: 0 0 16px; color: #a1a1aa; }
|
|
1210
1227
|
form { display: flex; flex-direction: column; gap: 16px; }
|
|
1211
1228
|
label { display: flex; flex-direction: column; gap: 6px; }
|
|
1212
1229
|
.field-label {
|
|
@@ -1245,7 +1262,17 @@ function renderLoginPage(options) {
|
|
|
1245
1262
|
</head>
|
|
1246
1263
|
<body>
|
|
1247
1264
|
<div class="card">
|
|
1248
|
-
|
|
1265
|
+
${content}
|
|
1266
|
+
</div>
|
|
1267
|
+
</body>
|
|
1268
|
+
</html>`;
|
|
1269
|
+
}
|
|
1270
|
+
__name(pageShell, "pageShell");
|
|
1271
|
+
function renderLoginPage(options) {
|
|
1272
|
+
const action = escapeHtml(options.actionUrl);
|
|
1273
|
+
const returnTo = escapeHtml(options.returnTo);
|
|
1274
|
+
const errorBanner = options.error === true ? '<p role="alert" class="error">Invalid username or password.</p>' : "";
|
|
1275
|
+
return pageShell(`<p class="brand">AI Gateway</p>
|
|
1249
1276
|
<form method="post" action="${action}">
|
|
1250
1277
|
<input type="hidden" name="returnTo" value="${returnTo}" />
|
|
1251
1278
|
<label>
|
|
@@ -1258,12 +1285,15 @@ function renderLoginPage(options) {
|
|
|
1258
1285
|
</label>
|
|
1259
1286
|
${errorBanner}
|
|
1260
1287
|
<button type="submit">Sign in</button>
|
|
1261
|
-
</form
|
|
1262
|
-
</div>
|
|
1263
|
-
</body>
|
|
1264
|
-
</html>`;
|
|
1288
|
+
</form>`, "Sign in \u2014 AI Gateway");
|
|
1265
1289
|
}
|
|
1266
1290
|
__name(renderLoginPage, "renderLoginPage");
|
|
1291
|
+
function renderSessionRequiredPage() {
|
|
1292
|
+
return pageShell(`<p class="brand">AI Gateway</p>
|
|
1293
|
+
<h1>Open this console from your application</h1>
|
|
1294
|
+
<p>Your session is minted by the host app. Use its console launcher to sign in, then reload.</p>`, "Sign in \u2014 AI Gateway");
|
|
1295
|
+
}
|
|
1296
|
+
__name(renderSessionRequiredPage, "renderSessionRequiredPage");
|
|
1267
1297
|
|
|
1268
1298
|
// src/server/auth/sanitize-return-to.ts
|
|
1269
1299
|
function sanitizeReturnTo(basePath, returnTo) {
|
|
@@ -1295,9 +1325,13 @@ function _ts_param4(paramIndex, decorator) {
|
|
|
1295
1325
|
}
|
|
1296
1326
|
__name(_ts_param4, "_ts_param");
|
|
1297
1327
|
function isNonEmptyString(value) {
|
|
1298
|
-
return typeof value === "string" && value !== "";
|
|
1328
|
+
return typeof value === "string" && value.trim() !== "";
|
|
1299
1329
|
}
|
|
1300
1330
|
__name(isNonEmptyString, "isNonEmptyString");
|
|
1331
|
+
function isString(value) {
|
|
1332
|
+
return typeof value === "string";
|
|
1333
|
+
}
|
|
1334
|
+
__name(isString, "isString");
|
|
1301
1335
|
var AgentDashboardAuthController = class _AgentDashboardAuthController {
|
|
1302
1336
|
static {
|
|
1303
1337
|
__name(this, "AgentDashboardAuthController");
|
|
@@ -1305,14 +1339,15 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
|
|
|
1305
1339
|
auth;
|
|
1306
1340
|
basePath;
|
|
1307
1341
|
logger = new import_common7.Logger(_AgentDashboardAuthController.name);
|
|
1308
|
-
/** Warn once (not per request) so a broken hook can't be used to flood the logs. */
|
|
1309
|
-
|
|
1342
|
+
/** Warn once PER HOOK KIND (not per request) so a broken hook can't be used to flood the logs. */
|
|
1343
|
+
warnedHooks = /* @__PURE__ */ new Set();
|
|
1310
1344
|
constructor(auth, basePath) {
|
|
1311
1345
|
this.auth = auth;
|
|
1312
1346
|
this.basePath = basePath;
|
|
1313
1347
|
}
|
|
1314
1348
|
loginPage(req, res, returnToQuery, error) {
|
|
1315
|
-
this.requireAuth();
|
|
1349
|
+
const auth = this.requireAuth();
|
|
1350
|
+
if (!auth.login) throw new import_common7.NotFoundException();
|
|
1316
1351
|
const returnTo = sanitizeReturnTo(this.basePath, returnToQuery);
|
|
1317
1352
|
if (this.currentSession(req)) {
|
|
1318
1353
|
res.status(302);
|
|
@@ -1327,14 +1362,15 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
|
|
|
1327
1362
|
}
|
|
1328
1363
|
async login(body, req, res) {
|
|
1329
1364
|
const auth = this.requireAuth();
|
|
1365
|
+
if (!auth.login) throw new import_common7.NotFoundException();
|
|
1330
1366
|
const returnTo = sanitizeReturnTo(this.basePath, body?.returnTo);
|
|
1331
1367
|
const username = body?.username;
|
|
1332
1368
|
const password = body?.password;
|
|
1333
|
-
if (!isNonEmptyString(username) || !
|
|
1369
|
+
if (!isNonEmptyString(username) || password !== void 0 && !isString(password)) {
|
|
1334
1370
|
this.redirectToLoginError(res, returnTo);
|
|
1335
1371
|
return "";
|
|
1336
1372
|
}
|
|
1337
|
-
const user = await this.
|
|
1373
|
+
const user = await this.runHook("login", () => auth.login?.(username.trim(), isString(password) ? password : "") ?? null);
|
|
1338
1374
|
if (!user) {
|
|
1339
1375
|
this.redirectToLoginError(res, returnTo);
|
|
1340
1376
|
return "";
|
|
@@ -1354,9 +1390,30 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
|
|
|
1354
1390
|
response: res
|
|
1355
1391
|
});
|
|
1356
1392
|
res.status(302);
|
|
1357
|
-
res.setHeader("Location", `${this.basePath}/auth/login`);
|
|
1393
|
+
res.setHeader("Location", this.auth && !this.auth.login ? `${this.basePath}/auth/session-required` : `${this.basePath}/auth/login`);
|
|
1358
1394
|
return "";
|
|
1359
1395
|
}
|
|
1396
|
+
// Mode A: the host frontend (carrying its own auth) POSTs here; the host hook validates the raw
|
|
1397
|
+
// request and returns the session user, or `null` to deny. No credential reaches this library.
|
|
1398
|
+
async session(request, response) {
|
|
1399
|
+
const auth = this.requireAuth();
|
|
1400
|
+
if (!auth.session) throw new import_common7.NotFoundException();
|
|
1401
|
+
const user = await this.runHook("session", () => auth.session?.(request) ?? null);
|
|
1402
|
+
if (!user) throw new import_common7.UnauthorizedException();
|
|
1403
|
+
issueSessionCookie(user, {
|
|
1404
|
+
auth,
|
|
1405
|
+
request,
|
|
1406
|
+
response
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
// Mode-A-only landing: the host mints the session, so there is nothing to submit here. Serves
|
|
1410
|
+
// as the redirect target `DashboardAuthPageGuard` bounces to instead of `/auth/login`, which
|
|
1411
|
+
// 404s under Mode A (see `loginPage` above).
|
|
1412
|
+
sessionRequiredPage() {
|
|
1413
|
+
const auth = this.requireAuth();
|
|
1414
|
+
if (auth.login) throw new import_common7.NotFoundException();
|
|
1415
|
+
return renderSessionRequiredPage();
|
|
1416
|
+
}
|
|
1360
1417
|
/** Uniform failure: same redirect (generic error flag) whether the user is unknown or the password is wrong. */
|
|
1361
1418
|
redirectToLoginError(res, returnTo) {
|
|
1362
1419
|
res.status(303);
|
|
@@ -1372,17 +1429,17 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
|
|
|
1372
1429
|
}) !== null;
|
|
1373
1430
|
}
|
|
1374
1431
|
/**
|
|
1375
|
-
* Run
|
|
1376
|
-
* (`null`) and warn-logged ONCE, so a buggy/unreachable hook never 500s the
|
|
1377
|
-
* stack-trace leak nor floods the logs.
|
|
1432
|
+
* Run a host hook (`login` or `session`) defensively: a throw (sync or async) is treated as a
|
|
1433
|
+
* denial (`null`) and warn-logged ONCE PER KIND, so a buggy/unreachable hook never 500s the
|
|
1434
|
+
* endpoint into a stack-trace leak nor floods the logs on repeated bad attempts.
|
|
1378
1435
|
*/
|
|
1379
|
-
async
|
|
1436
|
+
async runHook(kind, run) {
|
|
1380
1437
|
try {
|
|
1381
|
-
return await
|
|
1438
|
+
return await run() ?? null;
|
|
1382
1439
|
} catch (error) {
|
|
1383
|
-
if (!this.
|
|
1384
|
-
this.
|
|
1385
|
-
this.logger.warn(`AgentDashboardModule dashboardAuth
|
|
1440
|
+
if (!this.warnedHooks.has(kind)) {
|
|
1441
|
+
this.warnedHooks.add(kind);
|
|
1442
|
+
this.logger.warn(`AgentDashboardModule dashboardAuth ${kind} hook threw; treating as denial. ${error instanceof Error ? error.message : String(error)}`);
|
|
1386
1443
|
}
|
|
1387
1444
|
return null;
|
|
1388
1445
|
}
|
|
@@ -1439,6 +1496,28 @@ _ts_decorate4([
|
|
|
1439
1496
|
]),
|
|
1440
1497
|
_ts_metadata4("design:returntype", String)
|
|
1441
1498
|
], AgentDashboardAuthController.prototype, "logout", null);
|
|
1499
|
+
_ts_decorate4([
|
|
1500
|
+
(0, import_common7.Post)("session"),
|
|
1501
|
+
(0, import_common7.HttpCode)(204),
|
|
1502
|
+
_ts_param4(0, (0, import_common7.Req)()),
|
|
1503
|
+
_ts_param4(1, (0, import_common7.Res)({
|
|
1504
|
+
passthrough: true
|
|
1505
|
+
})),
|
|
1506
|
+
_ts_metadata4("design:type", Function),
|
|
1507
|
+
_ts_metadata4("design:paramtypes", [
|
|
1508
|
+
Object,
|
|
1509
|
+
Object
|
|
1510
|
+
]),
|
|
1511
|
+
_ts_metadata4("design:returntype", Promise)
|
|
1512
|
+
], AgentDashboardAuthController.prototype, "session", null);
|
|
1513
|
+
_ts_decorate4([
|
|
1514
|
+
(0, import_common7.Get)("session-required"),
|
|
1515
|
+
(0, import_common7.Header)("Content-Type", "text/html; charset=utf-8"),
|
|
1516
|
+
(0, import_common7.Header)("Cache-Control", "no-store, must-revalidate"),
|
|
1517
|
+
_ts_metadata4("design:type", Function),
|
|
1518
|
+
_ts_metadata4("design:paramtypes", []),
|
|
1519
|
+
_ts_metadata4("design:returntype", String)
|
|
1520
|
+
], AgentDashboardAuthController.prototype, "sessionRequiredPage", null);
|
|
1442
1521
|
AgentDashboardAuthController = _ts_decorate4([
|
|
1443
1522
|
(0, import_common7.Controller)("auth"),
|
|
1444
1523
|
_ts_param4(0, (0, import_common7.Optional)()),
|
|
@@ -1551,19 +1630,39 @@ var DashboardAuthPageGuard = class {
|
|
|
1551
1630
|
this.auth = auth;
|
|
1552
1631
|
this.basePath = basePath;
|
|
1553
1632
|
}
|
|
1554
|
-
canActivate(context) {
|
|
1633
|
+
async canActivate(context) {
|
|
1555
1634
|
if (!this.auth) return true;
|
|
1635
|
+
const auth = this.auth;
|
|
1556
1636
|
const http = context.switchToHttp();
|
|
1557
1637
|
const request = http.getRequest();
|
|
1558
1638
|
const session = this.verifyRequestSession(request);
|
|
1559
|
-
if (!session)
|
|
1560
|
-
const returnTo = readOriginalUrl(request);
|
|
1561
|
-
throw new DashboardAuthRedirect(`${this.basePath}/auth/login?returnTo=${encodeURIComponent(returnTo)}`);
|
|
1562
|
-
}
|
|
1639
|
+
if (!session) this.deny(auth, request);
|
|
1563
1640
|
attachSession(request, session);
|
|
1564
|
-
|
|
1641
|
+
if (!await maybeRenewSession(auth, session, request, http.getResponse())) {
|
|
1642
|
+
this.deny(auth, request);
|
|
1643
|
+
}
|
|
1565
1644
|
return true;
|
|
1566
1645
|
}
|
|
1646
|
+
/**
|
|
1647
|
+
* No valid session: bounce the browser navigation onward. Under Mode B (`login` configured)
|
|
1648
|
+
* that's the built-in login screen, carrying `returnTo` back to the page that was requested.
|
|
1649
|
+
* Under Mode-A-only there is no login screen to redirect to — the host mints the session
|
|
1650
|
+
* itself — so this instead serves the instruction page rather than redirecting into a 404.
|
|
1651
|
+
* Pulled out of `canActivate` so both the "no session at all" and the "revalidate revoked a
|
|
1652
|
+
* renewable session" paths reuse the exact same mode-aware deny logic.
|
|
1653
|
+
*
|
|
1654
|
+
* Takes `auth` explicitly rather than reading `this.auth`: `canActivate`'s `!this.auth` early
|
|
1655
|
+
* return narrows the field within that method only — TS doesn't carry it across a method call —
|
|
1656
|
+
* so the caller passes the already-narrowed value instead of this method re-deriving (or
|
|
1657
|
+
* asserting away) the nullability.
|
|
1658
|
+
*/
|
|
1659
|
+
deny(auth, request) {
|
|
1660
|
+
if (!auth.login) {
|
|
1661
|
+
throw new DashboardAuthRedirect(`${this.basePath}/auth/session-required`);
|
|
1662
|
+
}
|
|
1663
|
+
const returnTo = readOriginalUrl(request);
|
|
1664
|
+
throw new DashboardAuthRedirect(`${this.basePath}/auth/login?returnTo=${encodeURIComponent(returnTo)}`);
|
|
1665
|
+
}
|
|
1567
1666
|
verifyRequestSession(request) {
|
|
1568
1667
|
const auth = this.auth;
|
|
1569
1668
|
if (!auth) return null;
|
|
@@ -1573,28 +1672,6 @@ var DashboardAuthPageGuard = class {
|
|
|
1573
1672
|
secret: auth.secret
|
|
1574
1673
|
});
|
|
1575
1674
|
}
|
|
1576
|
-
/**
|
|
1577
|
-
* Sliding renewal: a valid cookie past 50% of its TTL is transparently re-issued on the
|
|
1578
|
-
* response, so an active user never gets logged out mid-session.
|
|
1579
|
-
*/
|
|
1580
|
-
maybeRenew(response, request, session) {
|
|
1581
|
-
const auth = this.auth;
|
|
1582
|
-
if (!auth) return;
|
|
1583
|
-
const now = Date.now();
|
|
1584
|
-
if (now - session.iat <= auth.ttlMs / 2) return;
|
|
1585
|
-
issueSessionCookie({
|
|
1586
|
-
id: session.sub,
|
|
1587
|
-
...session.name !== void 0 ? {
|
|
1588
|
-
name: session.name
|
|
1589
|
-
} : {},
|
|
1590
|
-
roles: session.roles
|
|
1591
|
-
}, {
|
|
1592
|
-
auth,
|
|
1593
|
-
request,
|
|
1594
|
-
response,
|
|
1595
|
-
now
|
|
1596
|
-
});
|
|
1597
|
-
}
|
|
1598
1675
|
};
|
|
1599
1676
|
DashboardAuthPageGuard = _ts_decorate6([
|
|
1600
1677
|
(0, import_common9.Injectable)(),
|
|
@@ -1753,13 +1830,38 @@ function resolveDashboardAuth(options) {
|
|
|
1753
1830
|
if (typeof options.secret !== "string" || options.secret === "") {
|
|
1754
1831
|
throw new Error("AgentDashboardModule dashboardAuth: `secret` is required and must be a non-empty string (HMAC-SHA256 signing key, 32+ bytes recommended). Failing closed.");
|
|
1755
1832
|
}
|
|
1756
|
-
|
|
1757
|
-
|
|
1833
|
+
const modes = [];
|
|
1834
|
+
if (options.session !== void 0) {
|
|
1835
|
+
if (typeof options.session !== "function") {
|
|
1836
|
+
throw new Error("AgentDashboardModule dashboardAuth: `session` must be a function.");
|
|
1837
|
+
}
|
|
1838
|
+
modes.push("session");
|
|
1839
|
+
}
|
|
1840
|
+
if (options.login !== void 0) {
|
|
1841
|
+
if (typeof options.login !== "function") {
|
|
1842
|
+
throw new Error("AgentDashboardModule dashboardAuth: `login` must be a function.");
|
|
1843
|
+
}
|
|
1844
|
+
modes.push("login");
|
|
1845
|
+
}
|
|
1846
|
+
if (options.revalidate !== void 0 && typeof options.revalidate !== "function") {
|
|
1847
|
+
throw new Error("AgentDashboardModule dashboardAuth: `revalidate` must be a function.");
|
|
1848
|
+
}
|
|
1849
|
+
if (modes.length === 0) {
|
|
1850
|
+
throw new Error("AgentDashboardModule dashboardAuth: at least one of `session` (the host mints the session from its own auth) or `login` (the built-in login screen) is required \u2014 otherwise the cookie can never be minted. Failing closed.");
|
|
1758
1851
|
}
|
|
1759
1852
|
return {
|
|
1760
1853
|
secret: options.secret,
|
|
1761
1854
|
ttlMs: durationToMs(options.ttl ?? DEFAULT_TTL),
|
|
1762
|
-
|
|
1855
|
+
modes,
|
|
1856
|
+
...options.session !== void 0 ? {
|
|
1857
|
+
session: options.session
|
|
1858
|
+
} : {},
|
|
1859
|
+
...options.login !== void 0 ? {
|
|
1860
|
+
login: options.login
|
|
1861
|
+
} : {},
|
|
1862
|
+
...options.revalidate !== void 0 ? {
|
|
1863
|
+
revalidate: options.revalidate
|
|
1864
|
+
} : {}
|
|
1763
1865
|
};
|
|
1764
1866
|
}
|
|
1765
1867
|
__name(resolveDashboardAuth, "resolveDashboardAuth");
|