Skip to content
dsh.fish
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

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source