@fonlan/dsh-web-auth 0.1.3 → 0.1.5

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 CHANGED
@@ -42,6 +42,8 @@ dsh plugin --profile web update @fonlan/dsh-web-auth
42
42
  |---|---|
43
43
  | 未登录访问页面(GET/HEAD) | `302` → `/login?next=原路径`,登录后跳回 |
44
44
  | 未登录访问 API(POST 等) | `401` JSON |
45
+ | DSH 启动令牌交换(`GET /?token=…`) | 放行并改写为回环来源转发:DSH 自行校验令牌、签发自己的 `dsh-auth` cookie(`303` → 干净 `/`);令牌无效则收到 DSH 自带的 `401` |
46
+ | DSH 会话 cookie 自动签发 | 插件在放行响应上**服务端代理 DSH 的令牌交换**(走本机回环 `/?token=`),把 `dsh-auth` Set-Cookie 随响应转发给浏览器——新浏览器无需再手工打开打印的 token URL;每分钟最多一次(结果会持续续期 DSH 的 30 天 cookie) |
45
47
  | 反代域名下已登录访问任意路径(含 `/api`、插件前缀) | 改写为回环来源放行,网关特权方法与插件围栏不再 403 |
46
48
  | 跨站请求(即使带 Cookie) | 仍被围栏 `403` 拒绝 |
47
49
  | 未登录 WebSocket 升级 | 握手直接 `401` 拒绝 |
@@ -52,6 +54,8 @@ dsh plugin --profile web update @fonlan/dsh-web-auth
52
54
  | 切换监听地址(设置卡片) | 校验取值(仅 `127.0.0.1` / `0.0.0.0`)→ 写入 `web-auth` settings 命名空间(revision 设栅)→ host 半区应用 patch(profile 优先,home 兜底)→ HMR 热重载 webserver 重新绑定;**WebSocket 短暂断连后自动重连** |
53
55
  | `/logout` | 清除 Cookie,跳转登录页 |
54
56
 
57
+ > **无需再处理 DSH 自己的启动令牌**:DSH 的浏览器会话认证(`dsh web` 启动时打印的 `http://127.0.0.1:3080/?token=…`)用 cookie 绑定 Host。插件会把所有已认证请求的 Host 改写为回环地址,而 DSH 的 cookie 必须与「DSH 看到的 Host」一致——`0.1.5` 起插件会在登录/会话期间**自动在服务端完成 DSH 令牌交换**并把 `dsh-auth` cookie 转交给浏览器(含持续续期),所以外部设备、新浏览器都不需要再手工访问带 token 的 URL。唯一的例外:旧版本插件(< 0.1.5)签发的 cookie 过期后,需要手动以 `https://<域名>/?token=<dsh web 启动打印的令牌>` 打开一次完成引导。
58
+
55
59
  ### 切换监听地址
56
60
 
57
61
  dsh 默认只监听 `127.0.0.1`。在 **设置 → 插件 → 插件配置 → 访问认证** 卡片底部可以把它切换为 `0.0.0.0`(所有网卡,局域网可访问),或切回仅本机——无需重启 dsh 进程:
package/lib/gate.d.ts CHANGED
@@ -18,8 +18,13 @@ import type { Duplex } from 'node:stream';
18
18
  export interface GateHandlers {
19
19
  /** Decide whether one request may proceed; false = gate answers it. */
20
20
  allow(req: IncomingMessage, res: ServerResponse): boolean;
21
- /** Hook after an allowed request (cookie refresh). */
22
- passed?(req: IncomingMessage, res: ServerResponse): void;
21
+ /**
22
+ * Hook after an allowed request (cookie refresh / DSH cookie mint).
23
+ * May be async: the gate awaits it BEFORE forwarding, so response
24
+ * headers it appends reach the client; it must never throw, and a
25
+ * rejection must not prevent the downstream dispatch.
26
+ */
27
+ passed?(req: IncomingMessage, res: ServerResponse): void | Promise<void>;
23
28
  /** Decide whether one WebSocket upgrade may proceed. */
24
29
  allowUpgrade(req: IncomingMessage): boolean;
25
30
  }
package/lib/gate.js CHANGED
@@ -22,10 +22,16 @@ export function installGate(server, handlers) {
22
22
  const upgradeListeners = server.listeners('upgrade');
23
23
  server.removeAllListeners('request');
24
24
  server.removeAllListeners('upgrade');
25
- server.on('request', (req, res) => {
25
+ server.on('request', async (req, res) => {
26
26
  if (!handlers.allow(req, res))
27
27
  return;
28
- handlers.passed?.(req, res);
28
+ try {
29
+ await handlers.passed?.(req, res);
30
+ }
31
+ catch {
32
+ // A failing passed() hook must never block the downstream dispatch
33
+ // (response headers it could not attach are a soft failure).
34
+ }
29
35
  for (const listener of requestListeners) {
30
36
  ;
31
37
  listener(req, res);
package/lib/handlers.d.ts CHANGED
@@ -21,6 +21,14 @@ export interface HandlerEnv {
21
21
  state: AuthState;
22
22
  limiter: RateLimiter;
23
23
  now?: () => number;
24
+ /**
25
+ * Mint DSH's own browser-session cookie (the one dsh-client-connection
26
+ * validates against the process launch token) server-side, returning the
27
+ * raw Set-Cookie value to relay to the user's browser, or undefined when
28
+ * the exchange cannot run (no connection service, unreachable loopback,
29
+ * fetch failure). See buildDshCookieMinter in index.ts.
30
+ */
31
+ mintDshCookie?(): Promise<string | undefined>;
24
32
  /**
25
33
  * Loopback authority ("127.0.0.1:<port>") used to rewrite the Host/Origin
26
34
  * of AUTHENTICATED requests (any path prefix).
@@ -87,7 +95,10 @@ export declare function setSessionCookie(res: ServerResponse, secret: Buffer, no
87
95
  export interface Gate {
88
96
  allow(req: IncomingMessage, res: ServerResponse): boolean;
89
97
  allowUpgrade(req: IncomingMessage): boolean;
98
+ passed?(req: IncomingMessage, res: ServerResponse): void | Promise<void>;
90
99
  }
100
+ /** Number of `token` query parameters in the request URL. */
101
+ export declare function tokenQueryCount(req: IncomingMessage): number;
91
102
  /**
92
103
  * Build the gate used to wrap the HTTP server listeners. Whitelisted paths
93
104
  * (login page/form, logout) pass through; everything else requires a valid
package/lib/handlers.js CHANGED
@@ -97,13 +97,26 @@ export function setSessionCookie(res, secret, now, secure) {
97
97
  ];
98
98
  if (secure)
99
99
  parts.push('Secure');
100
- res.setHeader('set-cookie', parts.join('; '));
100
+ // appendHeader (not setHeader): the gate may have already attached the
101
+ // DSH cookie to this response — a setHeader would silently drop it.
102
+ res.appendHeader('set-cookie', parts.join('; '));
101
103
  }
102
104
  function clearCookie(res, secure) {
103
105
  const parts = [COOKIE_NAME + '=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'];
104
106
  if (secure)
105
107
  parts.push('Secure');
106
- res.setHeader('set-cookie', parts.join('; '));
108
+ res.appendHeader('set-cookie', parts.join('; '));
109
+ }
110
+ /** Minimum interval between server-side DSH cookie mints (debounce). */
111
+ const DSH_MINT_DEBOUNCE_MS = 1000;
112
+ /** Number of `token` query parameters in the request URL. */
113
+ export function tokenQueryCount(req) {
114
+ try {
115
+ return new URL(req.url ?? '/', 'http://x').searchParams.getAll('token').length;
116
+ }
117
+ catch {
118
+ return 0;
119
+ }
107
120
  }
108
121
  /**
109
122
  * Build the gate used to wrap the HTTP server listeners. Whitelisted paths
@@ -114,6 +127,8 @@ function clearCookie(res, secure) {
114
127
  */
115
128
  export function createGate(env) {
116
129
  const now = env.now ?? Date.now;
130
+ /** Last server-side DSH cookie mint attempt (epoch ms). */
131
+ let lastMintAt = 0;
117
132
  const whitelisted = (method, pathname) => {
118
133
  if (pathname === '/login')
119
134
  return method === 'GET' || method === 'HEAD' || method === 'POST';
@@ -122,6 +137,32 @@ export function createGate(env) {
122
137
  return false;
123
138
  };
124
139
  const sessionOf = (req) => verifySession(cookieValue(req.headers.cookie, COOKIE_NAME), env.state.secret, now());
140
+ /**
141
+ * Mint DSH's browser-session cookie and attach it to the response when due
142
+ * (debounced globally, ~1/s — the mint is one cheap loopback exchange).
143
+ * Runs for ANY allowed request, authenticated or not: the minted DSH
144
+ * cookie alone never opens the gate (every other path still requires the
145
+ * plugin session), and minting early (login page, setup redirect) means a
146
+ * fresh browser carries both cookies by the time it first reaches DSH.
147
+ */
148
+ const maybeMintDshCookie = async (res) => {
149
+ const mint = env.mintDshCookie;
150
+ if (mint === undefined)
151
+ return;
152
+ const t = now();
153
+ if (t - lastMintAt < DSH_MINT_DEBOUNCE_MS)
154
+ return;
155
+ lastMintAt = t;
156
+ try {
157
+ const cookie = await mint();
158
+ if (cookie !== undefined && !res.headersSent) {
159
+ res.appendHeader('set-cookie', cookie);
160
+ }
161
+ }
162
+ catch (error) {
163
+ console.error('[dsh-web-auth] DSH cookie mint failed:', error);
164
+ }
165
+ };
125
166
  /**
126
167
  * Present AUTHENTICATED traffic as loopback to every downstream
127
168
  * browser-trust fence, without tracking plugin path prefixes. The /api
@@ -155,6 +196,19 @@ export function createGate(env) {
155
196
  const method = req.method ?? 'GET';
156
197
  if (whitelisted(method, pathname))
157
198
  return true;
199
+ // DSH launch-token exchange: GET / with a `token` query param is DSH's
200
+ // own bootstrap surface (the URL printed at startup). DSH validates the
201
+ // token itself and either mints its browser-session cookie (303 → clean
202
+ // /) or answers its own 401. Let it through WITHOUT a plugin session —
203
+ // the minted cookie alone cannot open the gate (every other path still
204
+ // demands the session) — but still present it as loopback, so the
205
+ // cookie DSH mints binds to the same authority every authenticated
206
+ // request uses after the rewrite. This is also how the server-side
207
+ // mint (mintDshCookie) re-enters the server.
208
+ if (method === 'GET' && pathname === '/' && tokenQueryCount(req) > 0) {
209
+ rewriteAsLoopback(req);
210
+ return true;
211
+ }
158
212
  const session = sessionOf(req);
159
213
  if (session !== undefined) {
160
214
  if (needsSessionRefresh(session, now())) {
@@ -177,6 +231,9 @@ export function createGate(env) {
177
231
  return false;
178
232
  rewriteAsLoopback(req);
179
233
  return true;
234
+ },
235
+ async passed(req, res) {
236
+ await maybeMintDshCookie(res);
180
237
  }
181
238
  };
182
239
  }
package/lib/index.js CHANGED
@@ -43,6 +43,12 @@ export function apply(ctx) {
43
43
  const env = {
44
44
  state,
45
45
  limiter: new RateLimiter(),
46
+ // Server-side mint of DSH's own browser-session cookie: relayed to the
47
+ // browser on allowed responses so DSH's launch-token auth no longer
48
+ // requires opening the printed ?token= URL per browser. Bound lazily —
49
+ // a headless/CLI profile without the connection service simply never
50
+ // mints (each request then fails DSH auth silently, as before).
51
+ mintDshCookie: buildDshCookieMinter(ctx),
46
52
  // Authenticated /api requests are presented to the gateway's trust fence
47
53
  // as loopback, so the privileged-method pinning (settings.*, credentials.*,
48
54
  // discoverModels, …) passes through the reverse proxy. See handlers.ts.
@@ -131,6 +137,50 @@ export function apply(ctx) {
131
137
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: route.path, handler: route.handler }), `web-auth: ${route.path} route`);
132
138
  }
133
139
  }
140
+ /**
141
+ * Server-side DSH browser-session cookie mint.
142
+ *
143
+ * dsh-client-connection exposes the process launch-token URL through its
144
+ * `connection` service (authenticatedUrl — the same call dsh-web-app uses to
145
+ * print the startup URL). This minter fetches that URL over the server's own
146
+ * loopback: the gate's token-exchange branch forwards it (still rewriting
147
+ * Host to loopback), DSH validates the token and answers a 303 with the
148
+ * signed `dsh-auth-<sha256(loopback)>` cookie — exactly the cookie every
149
+ * authenticated request needs after the Host rewrite. The Set-Cookie value is
150
+ * returned so the gate can relay it to the user's browser on ordinary
151
+ * responses, meaning new browsers/endpoints no longer need the printed token
152
+ * (and DSH's 30-day cookie is refreshed continuously).
153
+ *
154
+ * Returns undefined when the exchange cannot run (no connection service,
155
+ * non-loopback-only bind, fetch failure) — the caller degrades to the old
156
+ * behavior where the cookie must arrive via the printed token URL.
157
+ */
158
+ function buildDshCookieMinter(ctx) {
159
+ let connection;
160
+ ctx.inject(['connection'], (cctx) => {
161
+ connection = cctx.connection;
162
+ });
163
+ return async () => {
164
+ if (connection === undefined)
165
+ return undefined;
166
+ const host = ctx.webServer.host === '0.0.0.0' || ctx.webServer.host === '::' ? '127.0.0.1' : ctx.webServer.host;
167
+ try {
168
+ const url = connection.authenticatedUrl(`http://${host}:${String(ctx.webServer.port)}`);
169
+ const response = await fetch(url, { redirect: 'manual' });
170
+ const cookies = response.headers.getSetCookie?.() ?? [];
171
+ const all = cookies.length > 0
172
+ ? cookies
173
+ : (response.headers.get('set-cookie') ?? '').length > 0
174
+ ? [response.headers.get('set-cookie') ?? '']
175
+ : [];
176
+ return all.find((cookie) => cookie.startsWith('dsh-auth-')) ?? all[0];
177
+ }
178
+ catch (error) {
179
+ console.error('[dsh-web-auth] could not mint DSH browser-session cookie:', error instanceof Error ? error.message : String(error));
180
+ return undefined;
181
+ }
182
+ };
183
+ }
134
184
  /**
135
185
  * Bind the live listen-host controller to this deployment's patch layer.
136
186
  * Resolves which layer owns the webserver row (home outranks the profile),
package/lib/settings.d.ts CHANGED
@@ -22,7 +22,7 @@ import type { ListenController } from './handlers.ts';
22
22
  /** The settings namespace string (the card's dispatch key, spelled the same client-side). */
23
23
  export declare const WEB_AUTH_NS = "web-auth";
24
24
  /** Branded namespace for the settings provider. */
25
- export declare const NS: import("@deepseek-ai/dsh-settings").SettingsNamespace;
25
+ export declare const NS = "web-auth";
26
26
  /** The namespace's resolved shape. */
27
27
  export interface WebAuthSettings {
28
28
  /** The web server's bind host. */
package/lib/settings.js CHANGED
@@ -17,12 +17,11 @@
17
17
  * prepare → deferred-commit path the REST API used before.
18
18
  */
19
19
  import z from '@deepseek-ai/schemastery';
20
- import { settingsNamespace } from '@deepseek-ai/dsh-settings';
21
20
  import { isAllowedListenHost } from "./profile-patch.js";
22
21
  /** The settings namespace string (the card's dispatch key, spelled the same client-side). */
23
22
  export const WEB_AUTH_NS = 'web-auth';
24
23
  /** Branded namespace for the settings provider. */
25
- export const NS = settingsNamespace(WEB_AUTH_NS);
24
+ export const NS = WEB_AUTH_NS;
26
25
  /** Schema resolving the namespace (schemastery, matching the webserver's own).
27
26
  *
28
27
  * Deliberately NO `.default(...)`: an invalid user-layer value (e.g. `null`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonlan/dsh-web-auth",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",
@@ -49,7 +49,6 @@
49
49
  },
50
50
  "client": {
51
51
  "inject": [
52
- "@deepseek-ai/dsh-client-runtime",
53
52
  "@deepseek-ai/dsh-client-ui-slots",
54
53
  "@deepseek-ai/dsh-client-ui-settings",
55
54
  "@deepseek-ai/dsh-client-connection",
@@ -60,22 +59,21 @@
60
59
  },
61
60
  "peerDependencies": {
62
61
  "@deepseek-ai/cordis": "^4.0.1",
63
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
64
- "@deepseek-ai/schemastery": "^3.18.1",
62
+ "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
63
+ "@deepseek-ai/schemastery": "^3.18.2",
65
64
  "react": "^18.2.0",
66
65
  "react-dom": "^18.2.0"
67
66
  },
68
67
  "dependencies": {
69
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
70
- "@deepseek-ai/schemastery": "^3.18.1"
68
+ "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
69
+ "@deepseek-ai/schemastery": "^3.18.2"
71
70
  },
72
71
  "devDependencies": {
73
72
  "@deepseek-ai/cordis": "^4.0.1",
74
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
75
- "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
76
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
77
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
78
- "@deepseek-ai/schemastery": "^3.18.1",
73
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
74
+ "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
75
+ "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
76
+ "@deepseek-ai/schemastery": "^3.18.2",
79
77
  "@types/node": "^22.0.0",
80
78
  "@types/react": "^18.3.0",
81
79
  "esbuild": "^0.24.0",