@fonlan/dsh-web-auth 0.1.7 → 0.1.9
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/cordis.patch.yml +5 -5
- package/lib/client/index.d.ts +3 -2
- package/lib/client.js +33 -73
- package/lib/gate.d.ts +7 -2
- package/lib/gate.js +18 -8
- package/lib/handlers.d.ts +15 -3
- package/lib/handlers.js +119 -3
- package/lib/index.d.ts +39 -4
- package/lib/index.js +29 -9
- package/lib/settings.d.ts +4 -4
- package/lib/settings.js +4 -4
- package/package.json +8 -8
package/cordis.patch.yml
CHANGED
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
# the auth gate wraps the HTTP listener after it is bound.
|
|
12
12
|
#
|
|
13
13
|
# The package also declares `dsh.client` (see package.json), so the web app
|
|
14
|
-
# loads the browser half (the plugin's own
|
|
15
|
-
#
|
|
16
|
-
# settings
|
|
17
|
-
#
|
|
14
|
+
# loads the browser half (the plugin's own settings page) automatically. The
|
|
15
|
+
# page registers on the `settings.section` slot as the 「访问认证」 entry in the
|
|
16
|
+
# settings sidebar and binds the `web-auth` settings namespace
|
|
17
|
+
# (see src/settings.ts) through the client settings scope.
|
|
18
18
|
#
|
|
19
19
|
# There is intentionally NO password here: passwords are maintained through
|
|
20
|
-
# the GUI (login page first setup / settings
|
|
20
|
+
# the GUI (login page first setup / settings page change), stored hashed
|
|
21
21
|
# under $DSH_HOME/web-auth/. Until a password is set, auth stays disabled
|
|
22
22
|
# (see the README for the warning this prints at boot).
|
|
23
23
|
- insert:
|
package/lib/client/index.d.ts
CHANGED
|
@@ -4,11 +4,12 @@ import type { Context } from '@deepseek-ai/cordis';
|
|
|
4
4
|
* `connection` and `remote` are required by `settingsScope.bind` (it resolves
|
|
5
5
|
* the settings transport and the forwarded-invalidation subscription through
|
|
6
6
|
* `ctx.get`), so the fiber must wait for them — exactly like
|
|
7
|
-
*
|
|
7
|
+
* the settings shell does for its own sections. dsh >= 0.1.7 dropped
|
|
8
|
+
* `settingsScope` itself, so it is resolved optionally below.
|
|
8
9
|
*/
|
|
9
10
|
export declare const inject: string[];
|
|
10
11
|
/**
|
|
11
|
-
* Client plugin body: register the settings
|
|
12
|
+
* Client plugin body: register the settings page. The shell's declaration
|
|
12
13
|
* may not be on the ledger yet, so the registration waits via slots.inject.
|
|
13
14
|
* @param ctx - the client cordis context.
|
|
14
15
|
*/
|
package/lib/client.js
CHANGED
|
@@ -37,36 +37,14 @@ var dshWebAuthModule = (() => {
|
|
|
37
37
|
});
|
|
38
38
|
var import_react = __require("react");
|
|
39
39
|
var import_jsx_runtime = __require("react/jsx-runtime");
|
|
40
|
-
var inject = ["slots"
|
|
40
|
+
var inject = ["slots"];
|
|
41
41
|
var WEB_AUTH_NS = "web-auth";
|
|
42
42
|
var CSS = `
|
|
43
|
-
.dwa-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
transition: border-color .16s, background .16s;
|
|
49
|
-
}
|
|
50
|
-
.dwa-card:hover { border-color: var(--dsw-alias-label-dimmed, #4a5468); }
|
|
51
|
-
.dwa-card[data-open] {
|
|
52
|
-
background: var(--dsw-alias-bg-layer-2, #141a26);
|
|
53
|
-
border-color: var(--dsw-alias-label-dimmed, #4a5468);
|
|
54
|
-
}
|
|
55
|
-
.dwa-head {
|
|
56
|
-
appearance: none; width: 100%; font: inherit; color: inherit; text-align: left; cursor: pointer;
|
|
57
|
-
background: transparent; border: 0; border-radius: 12px;
|
|
58
|
-
align-items: center; gap: 12px; padding: 14px 16px; display: flex;
|
|
59
|
-
}
|
|
60
|
-
.dwa-head:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, #4f8cff); outline-offset: -2px; }
|
|
61
|
-
.dwa-headText { flex-direction: column; flex: 1; gap: 4px; min-width: 0; display: flex; }
|
|
62
|
-
.dwa-title { color: var(--dsw-alias-label-primary, #e6e6e6); font-size: 15px; font-weight: 600; line-height: 1.4; }
|
|
63
|
-
.dwa-sub { color: var(--dsw-alias-label-tertiary, #8b93a7); font-size: 13px; line-height: 1.5; }
|
|
64
|
-
.dwa-chevron {
|
|
65
|
-
color: var(--dsw-alias-label-tertiary, #8b93a7); flex: none;
|
|
66
|
-
transition: transform .16s; display: flex; align-items: center; justify-content: center;
|
|
67
|
-
}
|
|
68
|
-
.dwa-chevron[data-open] { transform: rotate(180deg); }
|
|
69
|
-
.dwa-body { border-top: 1px solid var(--dsw-alias-border-l2, #2a3346); margin: 0 16px; padding: 12px 0 8px; display: flex; flex-direction: column; gap: 12px; }
|
|
43
|
+
.dwa-page { display: flex; flex-direction: column; gap: 16px; max-width: 720px; }
|
|
44
|
+
.dwa-page-head { display: flex; flex-direction: column; gap: 6px; }
|
|
45
|
+
.dwa-page-title { color: var(--dsw-alias-label-primary, #e6e6e6); margin: 0; font-size: 15px; font-weight: 600; line-height: 1.4; }
|
|
46
|
+
.dwa-page-sub { color: var(--dsw-alias-label-tertiary, #8b93a7); margin: 0; font-size: 13px; line-height: 1.5; }
|
|
47
|
+
.dwa-body { display: flex; flex-direction: column; gap: 12px; }
|
|
70
48
|
.dwa-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
|
71
49
|
.dwa-status { font-size: 13px; line-height: 1.6; }
|
|
72
50
|
.dwa-status[data-on] { color: var(--dsw-alias-label-primary, #e6e6e6); }
|
|
@@ -99,7 +77,7 @@ var dshWebAuthModule = (() => {
|
|
|
99
77
|
.dwa-divider { border: none; border-top: 1px solid var(--dsw-alias-border-l1, #232a38); margin: 2px 0; }
|
|
100
78
|
`;
|
|
101
79
|
function injectStyle() {
|
|
102
|
-
const id = "dsh-web-auth/
|
|
80
|
+
const id = "dsh-web-auth/settings-section.css";
|
|
103
81
|
if (document.getElementById(id) !== null) return;
|
|
104
82
|
const tag = document.createElement("style");
|
|
105
83
|
tag.id = id;
|
|
@@ -141,7 +119,7 @@ var dshWebAuthModule = (() => {
|
|
|
141
119
|
return null;
|
|
142
120
|
}
|
|
143
121
|
}
|
|
144
|
-
function
|
|
122
|
+
function WebAuthSettingsSection(props) {
|
|
145
123
|
const { scope } = props;
|
|
146
124
|
const snapshot = (0, import_react.useSyncExternalStore)(
|
|
147
125
|
(listener) => scope.subscribe(listener),
|
|
@@ -157,7 +135,6 @@ var dshWebAuthModule = (() => {
|
|
|
157
135
|
const [listenPick, setListenPick] = (0, import_react.useState)("127.0.0.1");
|
|
158
136
|
const [listenBusy, setListenBusy] = (0, import_react.useState)(false);
|
|
159
137
|
const [listenMsg, setListenMsg] = (0, import_react.useState)(null);
|
|
160
|
-
const [open, setOpen] = (0, import_react.useState)(false);
|
|
161
138
|
(0, import_react.useEffect)(() => {
|
|
162
139
|
let alive = true;
|
|
163
140
|
void fetchStatus().then((status) => {
|
|
@@ -220,34 +197,12 @@ var dshWebAuthModule = (() => {
|
|
|
220
197
|
setListenBusy(false);
|
|
221
198
|
}
|
|
222
199
|
};
|
|
223
|
-
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-
|
|
224
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
225
|
-
"
|
|
226
|
-
{
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
"aria-expanded": open,
|
|
230
|
-
"aria-label": (open ? "\u6536\u8D77" : "\u5C55\u5F00") + "\uFF1A\u8BBF\u95EE\u8BA4\u8BC1",
|
|
231
|
-
onClick: () => setOpen(!open),
|
|
232
|
-
children: [
|
|
233
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "dwa-headText", children: [
|
|
234
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dwa-title", children: "\u8BBF\u95EE\u8BA4\u8BC1" }),
|
|
235
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dwa-sub", children: "Web \u8BBF\u95EE\u5BC6\u7801\u4E0E\u76D1\u542C\u5730\u5740" })
|
|
236
|
-
] }),
|
|
237
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dwa-chevron", "data-open": open ? "" : void 0, "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { width: "14", height: "14", viewBox: "0 0 14 14", fill: "none", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
238
|
-
"path",
|
|
239
|
-
{
|
|
240
|
-
d: "M3.5 5.25L7 8.75L10.5 5.25",
|
|
241
|
-
stroke: "currentColor",
|
|
242
|
-
strokeWidth: "1.5",
|
|
243
|
-
strokeLinecap: "round",
|
|
244
|
-
strokeLinejoin: "round"
|
|
245
|
-
}
|
|
246
|
-
) }) })
|
|
247
|
-
]
|
|
248
|
-
}
|
|
249
|
-
),
|
|
250
|
-
open && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-body", children: [
|
|
200
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-page", children: [
|
|
201
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("header", { className: "dwa-page-head", children: [
|
|
202
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("h3", { className: "dwa-page-title", children: "\u8BBF\u95EE\u8BA4\u8BC1" }),
|
|
203
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-page-sub", children: "Web \u8BBF\u95EE\u5BC6\u7801\u4E0E\u76D1\u542C\u5730\u5740" })
|
|
204
|
+
] }),
|
|
205
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-body", children: [
|
|
251
206
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dwa-row", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-status", "data-on": configured ? void 0 : "", "data-off": configured ? "" : void 0, children: configured === null ? "\u52A0\u8F7D\u4E2D\u2026" : configured ? "\u8BBF\u95EE\u8BA4\u8BC1\uFF1A\u5DF2\u542F\u7528" : "\u8BBF\u95EE\u8BA4\u8BC1\uFF1A\u672A\u8BBE\u7F6E\u5BC6\u7801\uFF08\u5F53\u524D\u672A\u542F\u7528\uFF09" }) }),
|
|
252
207
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-hint", children: "\u8BBE\u7F6E\u540E\uFF0C\u8BBF\u95EE\u672C\u670D\u52A1\u9700\u8981\u8F93\u5165\u5BC6\u7801\uFF1B\u4F1A\u8BDD\u6709\u6548\u671F 7 \u5929\uFF08\u6D3B\u52A8\u81EA\u52A8\u7EED\u671F\uFF09\u3002\u4FEE\u6539\u5BC6\u7801\u4F1A\u4F7F\u6240\u6709\u5DF2\u767B\u5F55\u4F1A\u8BDD\u7ACB\u5373\u5931\u6548\u3002" }),
|
|
253
208
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("hr", { className: "dwa-divider" }),
|
|
@@ -330,20 +285,25 @@ var dshWebAuthModule = (() => {
|
|
|
330
285
|
] });
|
|
331
286
|
}
|
|
332
287
|
function apply(ctx) {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
288
|
+
ctx.inject(["settingsScope"], (raw) => {
|
|
289
|
+
const c = raw;
|
|
290
|
+
if (c.settingsScope === void 0) return;
|
|
291
|
+
injectStyle();
|
|
292
|
+
const scope = c.settingsScope.bind({ namespace: WEB_AUTH_NS });
|
|
293
|
+
c.slots.inject(
|
|
294
|
+
"settings.section",
|
|
295
|
+
() => c.slots.register(
|
|
296
|
+
{
|
|
297
|
+
name: "settings.section",
|
|
298
|
+
id: "web-auth",
|
|
299
|
+
order: 120,
|
|
300
|
+
label: "\u8BBF\u95EE\u8BA4\u8BC1",
|
|
301
|
+
inject: () => ({ scope })
|
|
302
|
+
},
|
|
303
|
+
WebAuthSettingsSection
|
|
304
|
+
)
|
|
305
|
+
);
|
|
306
|
+
});
|
|
347
307
|
}
|
|
348
308
|
return __toCommonJS(index_exports);
|
|
349
309
|
})();
|
package/lib/gate.d.ts
CHANGED
|
@@ -25,8 +25,13 @@ export interface GateHandlers {
|
|
|
25
25
|
* rejection must not prevent the downstream dispatch.
|
|
26
26
|
*/
|
|
27
27
|
passed?(req: IncomingMessage, res: ServerResponse): void | Promise<void>;
|
|
28
|
-
/**
|
|
29
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Decide whether one WebSocket upgrade may proceed. May be async: the gate
|
|
30
|
+
* awaits the decision before forwarding, letting a handler repair the
|
|
31
|
+
* request (for example by resolving a session cookie onto its headers)
|
|
32
|
+
* instead of only approving or refusing it.
|
|
33
|
+
*/
|
|
34
|
+
allowUpgrade(req: IncomingMessage): boolean | Promise<boolean>;
|
|
30
35
|
}
|
|
31
36
|
/** Standard HTTP rejection for a denied WebSocket upgrade. */
|
|
32
37
|
export declare function rejectUpgrade(socket: Duplex, status?: number, body?: string): void;
|
package/lib/gate.js
CHANGED
|
@@ -38,14 +38,24 @@ export function installGate(server, handlers) {
|
|
|
38
38
|
}
|
|
39
39
|
});
|
|
40
40
|
server.on('upgrade', (req, socket, head) => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
41
|
+
void (async () => {
|
|
42
|
+
let allowed;
|
|
43
|
+
try {
|
|
44
|
+
allowed = await handlers.allowUpgrade(req);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
allowed = false;
|
|
48
|
+
}
|
|
49
|
+
if (!allowed) {
|
|
50
|
+
if (!socket.destroyed && socket.writable)
|
|
51
|
+
rejectUpgrade(socket);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
for (const listener of upgradeListeners) {
|
|
55
|
+
;
|
|
56
|
+
listener(req, socket, head);
|
|
57
|
+
}
|
|
58
|
+
})();
|
|
49
59
|
});
|
|
50
60
|
return () => {
|
|
51
61
|
if (!gated.has(server))
|
package/lib/handlers.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-web-auth — request handlers: the gate decision (whitelist + session
|
|
3
3
|
* check), the /login page + form processing (including first-password setup),
|
|
4
|
-
* /logout, and the JSON change-password API for the settings
|
|
4
|
+
* /logout, and the JSON change-password API for the settings page.
|
|
5
5
|
*/
|
|
6
6
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
7
7
|
import { type RateLimiter } from './auth-core.ts';
|
|
@@ -45,7 +45,7 @@ export interface HandlerEnv {
|
|
|
45
45
|
loopbackAuthority?: string;
|
|
46
46
|
/**
|
|
47
47
|
* Listen-address controller (bound by the host half when the profile
|
|
48
|
-
* patch layer is reachable). Absent = the settings
|
|
48
|
+
* patch layer is reachable). Absent = the settings page cannot change
|
|
49
49
|
* the bind host.
|
|
50
50
|
*/
|
|
51
51
|
listen?: ListenController;
|
|
@@ -94,9 +94,21 @@ export declare function parseUrlEncoded(body: string): Record<string, string>;
|
|
|
94
94
|
export declare function setSessionCookie(res: ServerResponse, secret: Buffer, now: number, secure: boolean): void;
|
|
95
95
|
export interface Gate {
|
|
96
96
|
allow(req: IncomingMessage, res: ServerResponse): boolean;
|
|
97
|
-
allowUpgrade(req: IncomingMessage): boolean
|
|
97
|
+
allowUpgrade(req: IncomingMessage): boolean | Promise<boolean>;
|
|
98
98
|
passed?(req: IncomingMessage, res: ServerResponse): void | Promise<void>;
|
|
99
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Name of DSH's authority-bound browser-session cookie for one authority.
|
|
102
|
+
* Mirrors the host's own `dsh-auth-<base64url(sha256(authority))>` scheme; if
|
|
103
|
+
* the scheme ever drifts this only misreads "carried" as "missing", which
|
|
104
|
+
* costs one harmless extra redirect, never a denial.
|
|
105
|
+
*
|
|
106
|
+
* Exported for the integration suite, which models a browser jar: the gate's
|
|
107
|
+
* "already carries the cookie" check is name-based, so a test that stores a
|
|
108
|
+
* differently-named cookie would silently exercise the late-mint path instead
|
|
109
|
+
* of the steady state.
|
|
110
|
+
*/
|
|
111
|
+
export declare function dshAuthCookieName(authority: string): string;
|
|
100
112
|
/** Number of `token` query parameters in the request URL. */
|
|
101
113
|
export declare function tokenQueryCount(req: IncomingMessage): number;
|
|
102
114
|
/**
|
package/lib/handlers.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { COOKIE_NAME, MIN_PASSWORD_LENGTH, SESSION_TTL_SECONDS, hashPassword, needsSessionRefresh, sanitizeNext, signSession, verifyPassword, verifySession } from "./auth-core.js";
|
|
2
3
|
import { renderLoginPage } from "./login-page.js";
|
|
3
4
|
import { ALLOWED_LISTEN_HOSTS, isAllowedListenHost } from "./profile-patch.js";
|
|
@@ -109,6 +110,35 @@ function clearCookie(res, secure) {
|
|
|
109
110
|
}
|
|
110
111
|
/** Minimum interval between server-side DSH cookie mints (debounce). */
|
|
111
112
|
const DSH_MINT_DEBOUNCE_MS = 1000;
|
|
113
|
+
/** Per-peer cooldown between late-mint redirects (loop breaker). */
|
|
114
|
+
const LATE_MINT_COOLDOWN_MS = 5000;
|
|
115
|
+
/**
|
|
116
|
+
* Name of DSH's authority-bound browser-session cookie for one authority.
|
|
117
|
+
* Mirrors the host's own `dsh-auth-<base64url(sha256(authority))>` scheme; if
|
|
118
|
+
* the scheme ever drifts this only misreads "carried" as "missing", which
|
|
119
|
+
* costs one harmless extra redirect, never a denial.
|
|
120
|
+
*
|
|
121
|
+
* Exported for the integration suite, which models a browser jar: the gate's
|
|
122
|
+
* "already carries the cookie" check is name-based, so a test that stores a
|
|
123
|
+
* differently-named cookie would silently exercise the late-mint path instead
|
|
124
|
+
* of the steady state.
|
|
125
|
+
*/
|
|
126
|
+
export function dshAuthCookieName(authority) {
|
|
127
|
+
return 'dsh-auth-' + createHash('sha256').update(authority).digest('base64url');
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Whether the request already carries a DSH browser-session cookie for the
|
|
131
|
+
* loopback authority every authenticated request is rewritten to. Unknown
|
|
132
|
+
* authority (webserver not bound yet) counts as carried: no intervention.
|
|
133
|
+
*/
|
|
134
|
+
function carriesLoopbackDshCookie(req, authority) {
|
|
135
|
+
if (authority === undefined)
|
|
136
|
+
return true;
|
|
137
|
+
const raw = req.headers.cookie;
|
|
138
|
+
if (raw === undefined)
|
|
139
|
+
return false;
|
|
140
|
+
return cookieValue(raw, dshAuthCookieName(authority)) !== undefined;
|
|
141
|
+
}
|
|
112
142
|
/** Number of `token` query parameters in the request URL. */
|
|
113
143
|
export function tokenQueryCount(req) {
|
|
114
144
|
try {
|
|
@@ -181,6 +211,54 @@ export function createGate(env) {
|
|
|
181
211
|
console.error('[dsh-web-auth] DSH cookie mint failed:', error);
|
|
182
212
|
}
|
|
183
213
|
};
|
|
214
|
+
/** Last late-mint redirect per peer address (loop breaker). */
|
|
215
|
+
const lateMintAt = new Map();
|
|
216
|
+
/**
|
|
217
|
+
* One late-mint redirect per peer per cooldown window. A client that keeps
|
|
218
|
+
* refusing the relayed cookie must not be bounced forever; after the first
|
|
219
|
+
* bounce it passes through and DSH's own response (401 + the relayed
|
|
220
|
+
* cookie) is the fallback, exactly the pre-existing behavior.
|
|
221
|
+
*/
|
|
222
|
+
const lateMintDue = (req) => {
|
|
223
|
+
const peer = req.socket.remoteAddress ?? '';
|
|
224
|
+
const t = now();
|
|
225
|
+
const last = lateMintAt.get(peer);
|
|
226
|
+
if (last !== undefined && t - last < LATE_MINT_COOLDOWN_MS)
|
|
227
|
+
return false;
|
|
228
|
+
lateMintAt.set(peer, t);
|
|
229
|
+
return true;
|
|
230
|
+
};
|
|
231
|
+
/**
|
|
232
|
+
* Answer a session-authenticated request that carries no DSH browser-
|
|
233
|
+
* session cookie for the loopback authority: mint the cookie server-side
|
|
234
|
+
* and bounce once through the same URL so the browser stores it. Without
|
|
235
|
+
* this, a browser holding only the plugin session reaches DSH and gets its
|
|
236
|
+
* raw 401 dead end ("reopen the URL printed by dsh web"), recovering only
|
|
237
|
+
* if the user happens to visit again; the redirect makes the first visit
|
|
238
|
+
* succeed. Falls back to /login when the mint is unavailable — that page
|
|
239
|
+
* retries the mint and offers the password form.
|
|
240
|
+
*/
|
|
241
|
+
const respondWithLateMint = async (req, res) => {
|
|
242
|
+
const mint = env.mintDshCookie;
|
|
243
|
+
const cookie = mint === undefined ? undefined : await mint().catch(() => undefined);
|
|
244
|
+
// Share the mint throttle with the opportunistic mint in `passed()`: the
|
|
245
|
+
// retry that follows this bounce would otherwise spend a second loopback
|
|
246
|
+
// exchange to mint the very cookie it just stored.
|
|
247
|
+
if (cookie !== undefined)
|
|
248
|
+
lastMintAt = now();
|
|
249
|
+
if (res.headersSent)
|
|
250
|
+
return;
|
|
251
|
+
if (cookie === undefined) {
|
|
252
|
+
redirect(res, '/login?next=' + encodeURIComponent(sanitizeNext(req.url) ?? '/'));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
res.writeHead(302, {
|
|
256
|
+
'cache-control': 'no-store',
|
|
257
|
+
location: req.url ?? '/',
|
|
258
|
+
'set-cookie': cookie
|
|
259
|
+
});
|
|
260
|
+
res.end();
|
|
261
|
+
};
|
|
184
262
|
/**
|
|
185
263
|
* Present AUTHENTICATED traffic as loopback to every downstream
|
|
186
264
|
* browser-trust fence, without tracking plugin path prefixes. The /api
|
|
@@ -231,10 +309,22 @@ export function createGate(env) {
|
|
|
231
309
|
// response, so the post-login / request is already authenticated and
|
|
232
310
|
// the browser never needs the token itself.
|
|
233
311
|
if (method === 'GET' && pathname === '/' && tokenQueryCount(req) > 0) {
|
|
234
|
-
if (
|
|
312
|
+
if (isMintReentry(req)) {
|
|
235
313
|
rewriteAsLoopback(req);
|
|
236
314
|
return true;
|
|
237
315
|
}
|
|
316
|
+
const tokenedSession = sessionOf(req);
|
|
317
|
+
if (tokenedSession !== undefined) {
|
|
318
|
+
if (needsSessionRefresh(tokenedSession, now())) {
|
|
319
|
+
setSessionCookie(res, env.state.secret, now(), isSecureRequest(req));
|
|
320
|
+
}
|
|
321
|
+
rewriteAsLoopback(req);
|
|
322
|
+
if (!carriesLoopbackDshCookie(req, env.loopbackAuthority) && lateMintDue(req)) {
|
|
323
|
+
void respondWithLateMint(req, res);
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
238
328
|
redirect(res, '/login?next=' + encodeURIComponent(sanitizeNext(pathname) ?? '/'));
|
|
239
329
|
return false;
|
|
240
330
|
}
|
|
@@ -244,6 +334,12 @@ export function createGate(env) {
|
|
|
244
334
|
setSessionCookie(res, env.state.secret, now(), isSecureRequest(req));
|
|
245
335
|
}
|
|
246
336
|
rewriteAsLoopback(req);
|
|
337
|
+
if ((method === 'GET' || method === 'HEAD') &&
|
|
338
|
+
!carriesLoopbackDshCookie(req, env.loopbackAuthority) &&
|
|
339
|
+
lateMintDue(req)) {
|
|
340
|
+
void respondWithLateMint(req, res);
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
247
343
|
return true;
|
|
248
344
|
}
|
|
249
345
|
if (method === 'GET' || method === 'HEAD') {
|
|
@@ -255,10 +351,30 @@ export function createGate(env) {
|
|
|
255
351
|
}
|
|
256
352
|
return false;
|
|
257
353
|
},
|
|
258
|
-
allowUpgrade(req) {
|
|
354
|
+
async allowUpgrade(req) {
|
|
259
355
|
if (sessionOf(req) === undefined)
|
|
260
356
|
return false;
|
|
261
357
|
rewriteAsLoopback(req);
|
|
358
|
+
// A WebSocket upgrade carries cookies only through its request headers,
|
|
359
|
+
// and there is no redirect to repair a missing one — so resolve the DSH
|
|
360
|
+
// browser cookie onto this very request before the host's own gate reads
|
|
361
|
+
// it. Without this, a browser that reached the app through a cached page
|
|
362
|
+
// (no HTTP round-trip, so no late-mint) has every /api/remote.mux upgrade
|
|
363
|
+
// refused: all remote calls fail, surfacing as errors like the welcome
|
|
364
|
+
// acknowledgement failing to save.
|
|
365
|
+
if (!carriesLoopbackDshCookie(req, env.loopbackAuthority)) {
|
|
366
|
+
const mint = env.mintDshCookie;
|
|
367
|
+
const cookie = mint === undefined ? undefined : await mint().catch(() => undefined);
|
|
368
|
+
if (cookie !== undefined) {
|
|
369
|
+
// Same shared throttle as the other mint sites (see respondWithLateMint).
|
|
370
|
+
lastMintAt = now();
|
|
371
|
+
const pair = cookie.split(';')[0]?.trim();
|
|
372
|
+
if (pair !== undefined && pair.length > 0) {
|
|
373
|
+
req.headers.cookie =
|
|
374
|
+
req.headers.cookie === undefined ? pair : req.headers.cookie + '; ' + pair;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
262
378
|
return true;
|
|
263
379
|
},
|
|
264
380
|
async passed(req, res) {
|
|
@@ -353,7 +469,7 @@ export function handleLogout(req, res, env) {
|
|
|
353
469
|
clearCookie(res, isSecureRequest(req));
|
|
354
470
|
redirect(res, '/login?loggedOut=1');
|
|
355
471
|
}
|
|
356
|
-
// ── GET /api/web-auth/status — settings
|
|
472
|
+
// ── GET /api/web-auth/status — settings page state ─────────────────────────
|
|
357
473
|
export function handleStatus(req, res, env) {
|
|
358
474
|
const body = { configured: env.state.readHash() !== undefined };
|
|
359
475
|
const current = env.listen?.current();
|
package/lib/index.d.ts
CHANGED
|
@@ -16,12 +16,47 @@ export interface WebAuthConfig {
|
|
|
16
16
|
*/
|
|
17
17
|
unlockRemoteSettings: boolean;
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* One `volatile()` config field as the loader hands it to `apply()`: a live
|
|
21
|
+
* handle, not the value it currently holds. Spelled structurally rather than as
|
|
22
|
+
* cosmokit's `Volatile<T>` — cosmokit is a transitive dependency here, and
|
|
23
|
+
* importing it would put a second cosmokit/schemastery pair into the emitted
|
|
24
|
+
* declaration.
|
|
25
|
+
*/
|
|
26
|
+
export interface LiveSettingsField<T> {
|
|
27
|
+
get(): T;
|
|
28
|
+
}
|
|
29
|
+
/** Hand-written entry config (profile patch / test fixture): the field is optional. */
|
|
30
|
+
export interface WebAuthConfigInput {
|
|
31
|
+
unlockRemoteSettings?: boolean | null;
|
|
32
|
+
}
|
|
33
|
+
/** The resolved entry config `apply()` receives: the field is a live handle. */
|
|
34
|
+
export interface ResolvedWebAuthConfig {
|
|
35
|
+
unlockRemoteSettings: LiveSettingsField<boolean>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Composition config schema (cordis Standard-Schema path).
|
|
39
|
+
*
|
|
40
|
+
* `.volatile()` is REQUIRED here, not cosmetic: dsh >= 0.1.7 derives the
|
|
41
|
+
* plugin's settings page from this schema, and `dsh-settings` SKIPS an entry
|
|
42
|
+
* whose schema declares no volatile field (`volatileForm(schema)` is undefined)
|
|
43
|
+
* while refusing every write to a non-volatile path — so without it this
|
|
44
|
+
* plugin's page disappears from Settings entirely, whichever page renders it.
|
|
45
|
+
*
|
|
46
|
+
* The schema is annotated through the package's own default export with locally
|
|
47
|
+
* declared type arguments: schemastery >= 3.18.3 carries a third `Mode` type
|
|
48
|
+
* parameter, so a `volatile()` field's OUTPUT type is `Volatile<T>` and the
|
|
49
|
+
* old `z<WebAuthConfig>` annotation no longer compiles; naming it here also
|
|
50
|
+
* keeps the declaration build portable (TS2742 otherwise).
|
|
51
|
+
*/
|
|
52
|
+
export type WebAuthConfigSchema = z<WebAuthConfigInput, ResolvedWebAuthConfig>;
|
|
53
|
+
/** Resolved schema for the loader and the settings plane. */
|
|
54
|
+
export declare const Config: WebAuthConfigSchema;
|
|
21
55
|
/**
|
|
22
56
|
* Mount the auth gate and its routes.
|
|
23
57
|
* @param ctx - plugin context carrying the webServer service (listening).
|
|
24
58
|
* @param config - validated composition config (Config schema defaults when
|
|
25
|
-
* the patch row spells none)
|
|
59
|
+
* the patch row spells none); `unlockRemoteSettings` may arrive as a live
|
|
60
|
+
* volatile handle and is unwrapped below.
|
|
26
61
|
*/
|
|
27
|
-
export declare function apply(ctx: PluginContext, config: WebAuthConfig): void;
|
|
62
|
+
export declare function apply(ctx: PluginContext, config: WebAuthConfig | ResolvedWebAuthConfig): void;
|
package/lib/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Host half: wraps the webserver's HTTP/upgrade listeners so every request
|
|
5
5
|
* (routes, static fallback, WebSocket upgrades) must carry a valid session
|
|
6
6
|
* cookie, serves the /login page (including first-password setup), /logout,
|
|
7
|
-
* and the JSON change-password API used by the settings
|
|
7
|
+
* and the JSON change-password API used by the settings page (client half).
|
|
8
8
|
*
|
|
9
9
|
* State lives under $DSH_HOME/web-auth/ (password.hash, secret) — see
|
|
10
10
|
* state.ts. Until a password is configured the gate is OPEN and a boot
|
|
@@ -31,17 +31,37 @@ import z from '@deepseek-ai/schemastery';
|
|
|
31
31
|
export const name = 'web-auth';
|
|
32
32
|
/** Services required before the gate can be installed. */
|
|
33
33
|
export const inject = ['webServer'];
|
|
34
|
-
/**
|
|
34
|
+
/** Resolved schema for the loader and the settings plane. */
|
|
35
35
|
export const Config = z.object({
|
|
36
|
-
unlockRemoteSettings: z.boolean().default(false)
|
|
36
|
+
unlockRemoteSettings: z.boolean().default(false).volatile()
|
|
37
37
|
});
|
|
38
|
+
/**
|
|
39
|
+
* Read a config field that may be a plain value or a live (`.volatile()`) node.
|
|
40
|
+
*
|
|
41
|
+
* The loader resolves a volatile field to a cosmokit cell (`{ get() }`), which
|
|
42
|
+
* is an object and therefore always truthy — reading it directly would turn the
|
|
43
|
+
* opt-in flag on everywhere. Hand-written configs (patch fixtures, tests) pass
|
|
44
|
+
* plain values, so both shapes are accepted here.
|
|
45
|
+
*/
|
|
46
|
+
function liveValue(node, fallback) {
|
|
47
|
+
if (node === undefined || node === null)
|
|
48
|
+
return fallback;
|
|
49
|
+
const getter = node.get;
|
|
50
|
+
if (typeof getter === 'function') {
|
|
51
|
+
const value = getter.call(node);
|
|
52
|
+
return value === undefined ? fallback : value;
|
|
53
|
+
}
|
|
54
|
+
return node;
|
|
55
|
+
}
|
|
38
56
|
/**
|
|
39
57
|
* Mount the auth gate and its routes.
|
|
40
58
|
* @param ctx - plugin context carrying the webServer service (listening).
|
|
41
59
|
* @param config - validated composition config (Config schema defaults when
|
|
42
|
-
* the patch row spells none)
|
|
60
|
+
* the patch row spells none); `unlockRemoteSettings` may arrive as a live
|
|
61
|
+
* volatile handle and is unwrapped below.
|
|
43
62
|
*/
|
|
44
63
|
export function apply(ctx, config) {
|
|
64
|
+
const unlockRemoteSettings = liveValue(config.unlockRemoteSettings, false);
|
|
45
65
|
const dshHome = resolveDshHome();
|
|
46
66
|
const state = {
|
|
47
67
|
secret: readSecret(dshHome),
|
|
@@ -86,10 +106,10 @@ export function apply(ctx, config) {
|
|
|
86
106
|
// away; failures are logged, never thrown — the client was confirmed.
|
|
87
107
|
listen: buildListenController(ctx)
|
|
88
108
|
};
|
|
89
|
-
// The plugin's own
|
|
90
|
-
// `web-auth` settings namespace:
|
|
91
|
-
//
|
|
92
|
-
// when a settings service exists (CLI/headless profiles skip it).
|
|
109
|
+
// The plugin's own settings page (设置 → 侧栏「访问认证」) rides the
|
|
110
|
+
// `web-auth` settings namespace: binding the namespace is how the page reads
|
|
111
|
+
// and writes it, and committed listenHost changes are applied here. Attaches
|
|
112
|
+
// only when a settings service exists (CLI/headless profiles skip it).
|
|
93
113
|
installWebAuthSettings(ctx, env.listen);
|
|
94
114
|
if (state.readHash() === undefined) {
|
|
95
115
|
// console.error, not ctx.logger.warn: the cordis default logger level
|
|
@@ -116,7 +136,7 @@ export function apply(ctx, config) {
|
|
|
116
136
|
// ── remote settings unlock (opt-in): inject ownsHost into the boot page ───
|
|
117
137
|
// A config-only patch change restarts this fiber (cordis update →
|
|
118
138
|
// dispose + re-apply), so the tap flips live with the composition.
|
|
119
|
-
if (
|
|
139
|
+
if (unlockRemoteSettings) {
|
|
120
140
|
ctx.effect(() => ctx.webServer.tapIndex(injectOwnsHost), 'web-auth: remote settings unlock tap');
|
|
121
141
|
}
|
|
122
142
|
// ── routes (exact table; /api/web-auth/password wins over the /api prefix) ─
|
package/lib/settings.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-web-auth — the `web-auth` settings namespace.
|
|
3
3
|
*
|
|
4
|
-
* The namespace exists so the plugin's browser half can
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* The namespace exists so the plugin's browser half can read and write its
|
|
5
|
+
* own settings page: the page registers on the `settings.section` slot (the
|
|
6
|
+
* 「访问认证」 entry in the settings sidebar) and binds this namespace through
|
|
7
|
+
* the client settings scope, which is the only write path to `listenHost`.
|
|
8
8
|
* The namespace carries the one genuine configuration value the plugin owns:
|
|
9
9
|
* the web server's bind host (`listenHost`). The password is deliberately
|
|
10
10
|
* NOT here — it is a secret stored hashed under `$DSH_HOME/web-auth/` and
|
package/lib/settings.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-web-auth — the `web-auth` settings namespace.
|
|
3
3
|
*
|
|
4
|
-
* The namespace exists so the plugin's browser half can
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* The namespace exists so the plugin's browser half can read and write its
|
|
5
|
+
* own settings page: the page registers on the `settings.section` slot (the
|
|
6
|
+
* 「访问认证」 entry in the settings sidebar) and binds this namespace through
|
|
7
|
+
* the client settings scope, which is the only write path to `listenHost`.
|
|
8
8
|
* The namespace carries the one genuine configuration value the plugin owns:
|
|
9
9
|
* the web server's bind host (`listenHost`). The password is deliberately
|
|
10
10
|
* NOT here — it is a secret stored hashed under `$DSH_HOME/web-auth/` and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonlan/dsh-web-auth",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "DSH web plugin: password-gate authentication (login page + HttpOnly signed cookie) for exposing dsh web behind a reverse proxy",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -59,21 +59,21 @@
|
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
62
|
-
"@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
|
|
63
|
-
"@deepseek-ai/schemastery": "^3.18.
|
|
62
|
+
"@deepseek-ai/dsh-settings": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
|
|
63
|
+
"@deepseek-ai/schemastery": "^3.18.4",
|
|
64
64
|
"react": "^18.2.0",
|
|
65
65
|
"react-dom": "^18.2.0"
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
|
|
69
|
-
"@deepseek-ai/schemastery": "^3.18.
|
|
69
|
+
"@deepseek-ai/schemastery": "^3.18.4"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
73
|
-
"@deepseek-ai/dsh-client-ui-slots": "
|
|
74
|
-
"@deepseek-ai/dsh-host-webserver": "
|
|
75
|
-
"@deepseek-ai/dsh-settings": "
|
|
76
|
-
"@deepseek-ai/schemastery": "^3.18.
|
|
73
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.1",
|
|
74
|
+
"@deepseek-ai/dsh-host-webserver": "0.2.0-rc.1",
|
|
75
|
+
"@deepseek-ai/dsh-settings": "0.2.0-rc.1",
|
|
76
|
+
"@deepseek-ai/schemastery": "^3.18.4",
|
|
77
77
|
"@types/node": "^22.0.0",
|
|
78
78
|
"@types/react": "^18.3.0",
|
|
79
79
|
"esbuild": "^0.24.0",
|