Bundle
dsh-plugin-auth
Enterprise username/password authentication gate for the dsh Web UI. Out-of-tree plugin — no core changes; requires login before any page or API is reachable.
- Source
- lzyuan549
- License
- MIT
- Updated
- Updated 6 days ago
Readme
# dsh-plugin-auth
Enterprise username/password authentication gate for the **dsh** (DeepSeek Harness) Web UI.
Out-of-tree plugin — **no core changes**. Once installed into the `web` profile, every page, API
route, `/plugins` asset, and WebSocket upgrade requires a logged-in session. Unauthenticated
navigations are redirected to a self-contained login page; unauthenticated API/XHR calls get `401`.
The login page uses Simplified Chinese (`zh-CN`) and follows the operating system's light/dark
preference with the same palette and control geometry as the dsh Web UI; CLI and JSON API output
remain compatible.
> The stock Web UI ships only a DNS-rebinding "browser trust fence" (`isTrustedApiRequest`), which the
> source explicitly notes **is not an authentication layer**. This plugin adds the missing gate.
## How it works
The plugin's default export is `class AuthWebServer extends WebServer`. A bundle composition patch
(`cordis.patch.yml`) **disables** the stock `webserver` row and **inserts** a row that re-provides the
`webServer` service from this subclass — so the plugin becomes the *sole* provider and every consumer
registration flows through it:
- `register` / `registerFallback` / `registerUpgrade` are overridden to wrap each handler in an auth
gate, then delegate to `super.*`. One choke point covers the SPA fallback, `/api`, `/plugins`,
upgrades, and any route added later.
- The plugin's own `/__auth/*` surface (login page, login/logout, status) is registered with
`super.register` in the constructor, so it stays **ungated** and reachable while logged out.
- The subclass truly re-provides `webServer` (`super(ctx, 'webServer')` is inherited) and the insert row
keeps `inject: [webStartup]` and the `{host, port}` config, so downstream `web-runtime` and
`connection` rows still resolve.
## Requirements
- Node `^22.19 || >=24` (matches dsh).
- A dsh checkout/profile where the peers resolve: `@deepseek-ai/cordis` and
`@deepseek-ai/dsh-host-webserver`. Both are in the `web` profile's dependency closure already,
and the profile's module fallback (`healProfilesModuleFallback`) links them next to the plugin.
- The plugin **deliberately does not declare these as `peerDependencies`**. It subclasses the
harness's *own* `WebServer` (the `webServer`-service provider) and must share the harness's exact
`WebServer` **and** `cordis` module instances. Declaring `"*"` peers makes pnpm fetch a stale
published copy from the registry, install it inside the plugin's dependency closure, and *shadow*
the profile fallback — the subclass then extends the wrong build (an older one that provides
`httpServer`, not `webServer`, bound to a different `cordis`), so boot fails with every web
consumer "pending (waiting for service: webServer)". Leaving the peers undeclared lets Node's
parent-walk resolve them to the fallback — the same instances the loader uses.
## Install from npm
The public npm package is the recommended installation path once a release is published. dsh forwards
the package spec to pnpm and automatically adds the package to the profile layer list because this package
declares `dsh.bundle`.
Use the command form that matches how dsh is installed:
- Installed dsh CLI (the `dsh` executable is on `PATH`): use `dsh ...`.
- Source checkout (this repository): use `pnpm dsh ...` from the harness root.
```bash
# install the exact release into the web profile (installed CLI)
dsh plugin --profile web add -w dsh-plugin-auth@0.1.3
# from a deepseek-harness source checkout, run the equivalent command instead:
pnpm dsh plugin --profile web add -w dsh-plugin-auth@0.1.3
# create the first admin before first boot (writes $DSH_HOME/auth/users.json)
dsh plugin --profile web exec dsh-auth add-user admin
pnpm dsh plugin --profile web exec dsh-auth add-user admin # source checkout
# confirm the auth-webserver layer resolved and the stock webserver is disabled
dsh --profile web --dump-config
pnpm dsh --profile web --dump-config # source checkout
```
`dsh-plugin-auth` deliberately has no `peerDependencies`: the dsh profile supplies the exact
`@deepseek-ai/cordis` and `@deepseek-ai/dsh-host-webserver` instances required by the subclass.
## Install from a tarball
Use a tarball for local development or when the package is not available from a registry. The tarball's
realpath lands inside the profile tree, which lets its peer imports resolve through the profile module
fallback; a `link:` to an external directory would put the realpath outside the tree and fail to resolve:
```bash
# from the plugin directory
npm pack # → dsh-plugin-auth-<version>.tgz
# create the first admin BEFORE first boot (writes $DSH_HOME/auth/users.json)
node bin/dsh-auth.js add-user admin
# install into the web profile.
# -w is REQUIRED: a dsh profile is a pnpm workspace root (packages: - .), and
# adding a dependency to a workspace root without -w fails ERR_PNPM_ADDING_TO_ROOT.
pnpm dsh plugin --profile web add -w ./dsh-plugin-auth-<version>.tgz
```
### Pre-flight (go / no-go)
Before starting the server, confirm the composition resolved and the peer imports work:
```bash
dsh --profile web --dump-config
```
The dump must show the `webserver` row with `disabled: true` and exactly one inserted `auth-webserver`
row — with no "module resolution failed" errors. A resolution failure here fails loud; fix the install
(use a tarball, not a directory link) before booting.
### Start
```bash
pnpm dsh web # binds 127.0.0.1:3080 by default
pnpm dsh web --port 8080 # if 3080 is taken (e.g. a stale dsh still running)
```
Open `http://127.0.0.1:3080` → you are redirected to `/__auth/login`. After signing in you reach the
app; `/api` and `/plugins` work as normal.
> If boot fails with `EADDRINUSE ... 127.0.0.1:3080`, another process (often a stale `dsh web` from a
> previous run) already holds the port. Stop it, or start on a different `--port`. This is a distinct
> failure from the composition error below — it means the gate *did* activate and tried to bind.
## CLI: `dsh-auth`
Credentials live in `$DSH_HOME/auth/users.json` (atomic write, mode `0600` where POSIX modes apply) and
are managed **offline** — never through the Web settings UI. Passwords are read from the TTY with echo
masked, or from bootstrap env vars for non-interactive provisioning.
```
dsh-auth add-user <username> Create a user (prompts for a password).
dsh-auth passwd <username> Change a user's password.
dsh-auth list List users (with disabled flag + last-updated).
dsh-auth remove <username> Delete a user.
dsh-auth disable <username> Disable a user (keeps the record; blocks login).
dsh-auth enable <username> Re-enable a disabled user.
```
Environment:
- `DSH_HOME` — auth data root (default `~/.dsh`); data in `$DSH_HOME/auth`.
- `DSH_AUTH_BOOTSTRAP_USER` — username for non-interactive `add-user`.
- `DSH_AUTH_BOOTSTRAP_PASSWORD` — password for non-interactive `add-user` / `passwd`.
Passwords must be at least 12 characters and include at least 3 of: lowercase, uppercase, digit, symbol.
## Configuration (`$DSH_HOME/auth/config.json`, optional)
All keys are optional; out-of-range or wrong-typed values silently fall back to the secure default, so a
malformed file can never weaken the gate past sane bounds.
| Key | Default | Meaning |
| --- | --- | --- |
| `sessionAbsoluteTtlMs` | `43200000` (12h) | Hard session lifetime cap. |
| `sessionIdleTtlMs` | `7200000` (2h) | Sliding idle window. |
| `sweepIntervalMs` | `300000` (5m) | Background expired-session sweep cadence. |
| `lockoutThreshold` | `5` | Consecutive failures (per username+IP) before lockout. |
| `lockoutBaseMs` | `30000` | First lockout duration. |
| `lockoutMaxMs` | `900000` (15m) | Exponential-backoff cap. |
| `lockoutWindowMs` | `900000` (15m) | Idle time after which the failure counter resets. |
| `minPasswordLength` | `12` | Minimum password length (floor 8). |
| `secure` | `false` | `true` behind TLS: sets `Secure` + `__Host-` cookie, forces `Path=/`. |
| `sameSite` | `"Strict"` | Cookie `SameSite` (`Strict` or `Lax`). |
| `cookiePath` | `"/"` | Cookie `Path`. |
| `trustedOrigins` | `[]` | Extra `Origin` values accepted on state-changing POSTs. |
| `scrypt` | `{N:16384,r:8,p:1,keylen:64,maxmem:64MiB}` | Password hashing cost. |
> **Behind a TLS reverse proxy**, set `"secure": true` so the session cookie gets `Secure` and the
> `__Host-` prefix. On plain loopback HTTP the cookie cannot be `Secure` (browsers would drop it).
## Security notes
- **Passwords:** scrypt with a per-user random salt, self-describing cost params, constant-time compare.
Unknown/disabled users are verified against a fixed dummy record so there is no timing/enumeration
oracle.
- **Sessions:** 256-bit random tokens, `HttpOnly; SameSite=Strict; Path=/` cookies, absolute + sliding
expiry, login-time token rotation (anti-fixation), logout revocation, background sweep.
- **Brute force:** per-(username, IP) lockout with exponential backoff; a legit user on another IP is
unaffected by an attacker's failures.
- **CSRF:** state-changing POSTs require a same-origin `Origin`/`Referer` (or a `trustedOrigins` entry);
default-deny when both are absent. This backs up `SameSite=Strict`.
- **Open redirect:** the post-login `next` target is reduced to a safe single-slash local path.
- **Audit:** login success/failure/lockout/logout are logged via `ctx.logger` with username + IP (never
the password).
## Scope & tradeoffs (by design)
- Local username/password only — no SSO, no 2FA.
- Sessions are **in-memory**: a dsh restart requires everyone to log in again.
- No separate CSRF token — `SameSite=Strict` + Origin check is the chosen defense.
- `src/paths.js` is **self-contained** (it re-implements the `$DSH_HOME` → `~/.dsh` resolution rather
than importing `@deepseek-ai/dsh-home-paths`). This keeps the offline CLI working without the harness
on the module path and removes that package from the runtime peer set; only
`@deepseek-ai/dsh-host-webserver` is imported at runtime.
## Composition ordering caveat
This bundle disables the stock `webserver` row. Any *later* composition layer (the profile's own
`cordis.patch.yml`, `$DSH_HOME/cordis.patch.yml`, or `--patch`) that flips `webserver` back to
`disabled: false` would create two providers of `webServer` and boot fails **loud** (duplicate service),
not silently. Don't re-enable the stock row while this plugin is installed.
## Development / tests
```bash
node --test
```
The unit suites (policy, passwords, sessions, lockout, gate, login-page, users) are pure and run
standalone. `tests/integration.test.js` needs the runtime peers (`@deepseek-ai/cordis`,
`@deepseek-ai/dsh-host-webserver`); when they are not installed it **skips** with a reason. Run it inside
a built harness checkout to exercise the real subclass over HTTP.
## License
MIT
Install
dsh plugin --profile web add github:lzyuan549/dsh-plugin-auth
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-plugin-auth from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.