Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 11 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,8 @@ cd server && npm install && node src/index.js
**Host** (run on the machine where Claude runs):

```sh
./install.sh # interactive setup, writes run.sh, optionally installs systemd service
./run.sh
```

Or manually:

```sh
cd host && npm install
CLIENT_USERNAME=you CLIENT_PASSWORD=pass HOST_KEY=secret SERVER_URL=wss://yourserver node index.js
curl -fsSL https://yourserver/install.sh | sh
codette login
```

**Client** — the server serves the pre-built client from `client/dist/`. To rebuild:
Expand All @@ -69,13 +62,19 @@ Multiple clients can connect to the same host. Multiple hosts (different usernam
| `SERVER_URL` | `ws://localhost:3000` | Server WebSocket URL (host → server) |
| `CLIENT_USERNAME` | `whoami` | Username for web login |
| `CLIENT_PASSWORD` | `changeme` | Password for web login |
| `HOST_KEY` (server) | _(none)_ | Optional master-shortcut secret. If set, any host presenting it authenticates without going through `/install.sh`. Unset = only per-IP tokens accepted. |
| `CODETTE_HOST_KEY` (host) | _(none)_ | Token the host presents to the server. Normally written into `credentials.json` by `install.sh`. Required on the host side. |
| `OAUTH_DATA_DIR` | `/data/oauth` | Where OAuth state and signing keys are persisted |
| `COOKIE_SECRET` | _(required for non-localhost issuers)_ | Signs interaction/session cookies |
| `PUBLIC_URL` | `http://localhost:${PORT}` | Issuer URL; access tokens are bound to this value |
| `SERVER_HOSTNAME` | _(required for /install.sh)_ | Public hostname used by installer scripts |
| `PORT` | `3000` | Server listen port |
| `CODETTE_DATA_HOME` | platform default | Override data directory (host keys, session names) |
| `TRIAL_MAX_CLAIMS` | `5` | Max OAuth trial claims per IP per window |
| `TRIAL_WINDOW_MS` | `1296000000` (15 days) | Rolling window for trial claim rate limiting |
| `CODETTE_DATA_HOME` (host) | platform default | Override host data directory (credentials, session names) |
| `CODETTE_TRACE` | off | Set to `1` for protocol-level trace logging |
| `E2E` | on | Set to `0` to disable e2e encryption (debug only) |

The host no longer requires a pre-shared key. After running `codette login`, the host obtains OAuth credentials (a `refresh_token` persisted in `credentials.json`) and exchanges them for an `access_token` on each startup.

Change every default before exposing the server to the public internet.

## Related projects
Expand Down
20 changes: 20 additions & 0 deletions doc/auth.spec.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
## Host registration via OAuth (CLI login)

The host CLI registers with the server via OAuth 2.0 Authorization Code + PKCE. OAuth gates the CLI's right to connect to `/host` WS and own a host slot at the server (accounting domain). The browser authenticates to the host via the HMAC challenge/verify flow (chat domain, described below) — a separate auth surface.

**Flow:**

1. User runs `curl -fsSL https://your-server/install.sh | sh`. Install script downloads the binary only — no secrets are interpolated. Last line prints `Run: codette login`.
2. `codette login` prompts the user for a username (the host's authority for what it will be called), pre-flight-checks availability via `GET /auth/username-available/:name` and re-prompts on conflict, then prompts for a chat-domain password. It generates PKCE (`code_verifier` + SHA-256 `code_challenge`) and a random `state`, binds a free local port, and opens the browser at `GET /oauth/auth?response_type=code&client_id=codette-cli&redirect_uri=<server>/auth/success&code_challenge=…&code_challenge_method=S256&scope=openid+offline_access&prompt=consent&login_hint=<username>&state=…`. The `redirect_uri` is always `<server>/auth/success` — the only URI registered with the OAuth provider. The free local port is communicated to the server inside the `state` parameter as a base64url-encoded JSON object (e.g. `{"port":52341,"nonce":"…"}`); this avoids registering per-process localhost URIs with the OAuth server. The CLI requests `scope=openid offline_access` and `prompt=consent` so the server always issues a `refresh_token`. `login_hint` carries the username the host has chosen.
3. The server renders a consent page that displays the username read-only ("Sign in as `<username>`") with a single button: **Try without registration for N days**. (Sign-in-with-Google and similar IdP options are not implemented in v1.) The server validates the username (lowercase letter-led `[a-z0-9_-]{2,32}`, not already claimed) before rendering; an already-claimed name yields a `409` error page. Submitting POSTs `/oauth/interaction/<uid>/trial`.
4. The trial handler validates CSRF + interaction session, checks the per-IP claim limit (5 per 15 days by default), atomically claims `{username → sub}` in the persistent owner mapping at `$OAUTH_DATA_DIR/username-owners.json` (race-safe boundary; pre-flight in step 2 is advisory), mints a one-shot `code` bound to the `code_challenge` and `redirect_uri`, and redirects the browser to an intermediate page `GET /auth/success?code=…`. On `taken` the IP's rate-limit slot is refunded.
5. The `/auth/success` page extracts the local port from the `state` parameter and attempts `fetch('http://localhost:<port>/callback?code=…', { mode: 'no-cors' })`. If the local CLI listener responds, the page updates to "Authenticated" and auto-redirects to `/` (the chat UI) after 3 seconds. If the fetch fails (CLI is running on a remote machine), the page falls back to displaying the code with a copy button so the user can paste it into the CLI prompt; no auto-redirect in that case. The auth code therefore arrives at the CLI via the success page's JS fetch, not via a browser redirect to a localhost URI.
6. The CLI's local listener receives the code (or stdin paste resolves first — both paths race), then POSTs `/oauth/token` with `grant_type=authorization_code`, the `code`, the `code_verifier`, and `redirect_uri=<server>/auth/success`. The server validates PKCE, confirms the `redirect_uri` matches the one used in the authorization request, marks the code consumed, and returns `{access_token, refresh_token, expires_in, token_type}`.
7. The CLI persists `{server, refresh_token, username, password}` in `~/.config/codette/credentials.json` (mode 0600). The host is the authority for its own username; the CLI never re-reads it from the token (the binding lives server-side as a defensive record). The access token's `preferred_username` claim exists solely so the `/host` WS handler can re-validate the binding on connect; the host asserts the username via the existing `?clientUsername=` query parameter, and the server rejects the connection (`1008`) if it does not match the bound name for that token's `sub`. On each subsequent startup the CLI exchanges `refresh_token` for a fresh `access_token`. The `access_token` is the credential the CLI sends as `?token=…` when opening the `/host` WebSocket.

**Lifetime:** issued tokens carry a standard `exp` claim N days from issuance (default 7); the refresh token expires at the same time. After expiry, the refresh-token grant fails; the host process logs the error and exits. Server-side credentials and any slots they own are reaped per the retention policy.

**Rate limiting:** the `/oauth/interaction/<uid>/trial` handler limits successful claims per IP (default 5 / 15 days). The window and count are configurable via env. Failed attempts (bad CSRF, expired code) are not counted against the limit but are independently rate-limited via the existing `authRateLimit` middleware (10 / min).

**Server-resident OAuth implementation:** the OAuth Authorization Server is implemented using [`node-oidc-provider`](https://github.com/panva/node-oidc-provider). The library handles spec compliance for `/oauth/auth` (authorization endpoint), `/oauth/token`, the device flow (unused for now), token signing (ES256 with a server-owned `oauth_keypair`), JWKS publication, refresh, revocation, and code lifecycle. The provider is mounted at `/oauth`; oidc-provider's default path for the authorization endpoint is `/auth`, making the full path `/oauth/auth`. The consent page (single trial button) is the only custom UI; everything else is library-provided.

## Authentication via device pairing

Three credentials work in concert.
Expand Down
44 changes: 25 additions & 19 deletions doc/main.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ agent the syntax. See `inline-file.spec.md`.

### Install

Server serves a shell script at `GET /install.sh` with `HOST_KEY` and `SERVER_URL` baked in.
Server serves a shell script at `GET /install.sh` with `SERVER_URL` baked in. The script delivers the binary only — no credentials are interpolated.

```
curl -fsSL https://your-server:3000/install.sh | sh
Expand All @@ -131,23 +131,27 @@ curl -fsSL https://your-server:3000/install.sh | sh
The script:
1. Clones the GitHub repo into `~/.local/share/codette/`
2. Runs `npm install --prefix ~/.local/share/codette/host`
3. Prompts for username and password (enter to accept defaults):
```
Username [dan]:
Password [a3kR4mXq2p]:
```
Username defaults to `$(whoami)`. Password defaults to a random 10-char alphanumeric.
4. Writes `~/.config/codette/credentials.json` (mode 0600):
3. Writes `~/.config/codette/config.json` (mode 0644):
```json
{ "server": "wss://your-server:3000", "hostKey": "...", "username": "dan", "password": "a3kR4mXq2p" }
{ "server": "wss://your-server:3000" }
```
5. Symlinks `~/.local/bin/codette` → `~/.local/share/codette/host/index.js`
6. If `~/.local/bin` is not in `$PATH`, prints:
4. Symlinks `~/.local/bin/codette` → `~/.local/share/codette/host/index.js`
5. If `~/.local/bin` is not in `$PATH`, prints:
```
Add to your shell profile:
export PATH="$HOME/.local/bin:$PATH"
```
7. Prints `Run: codette`
6. Prints `Run: codette login`

### Activation (`codette login`)

The host CLI obtains credentials via OAuth 2.0 Authorization Code + PKCE. See `auth.spec.md` (Host registration via OAuth) for the full flow. Summary:

1. `codette login` opens a browser at `/oauth/auth` and listens on a free `localhost:<port>`.
2. User clicks **Try without registration for N days** on the consent page.
3. Browser returns to a server-rendered intermediate page that posts the auth code to `localhost:<port>` (or, if codette is on a remote machine, displays the code for copy-paste into the CLI prompt).
4. CLI exchanges the code for `{access_token, refresh_token}` and persists them to `~/.config/codette/credentials.json` (mode 0600).
5. CLI starts the host process. The `access_token` is sent as `?token=…` on the `/host` WebSocket connection.

### Startup

Expand All @@ -160,14 +164,16 @@ Connected to https://your-server:3000

### Config precedence

CLI flags → `~/.config/codette/credentials.json` → env vars → defaults.
CLI flags → env vars → `~/.config/codette/credentials.json` (secrets, 0600) → `~/.config/codette/config.json` (non-secret, 0644) → defaults. Env beats persisted state so debug overrides like `CODETTE_SERVER_URL=ws://localhost:3000` work against an installation that already has a saved server URL.

| Setting | Config file / key | Env var | CLI flag | Default |
|---------|-------------------|---------|----------|---------|
| Server URL | `config.json: server` | `CODETTE_SERVER_URL` | `--server`, `-s` | `ws://localhost:3000` |
| Refresh token | `credentials.json: refresh_token` | — | — | _obtained via `codette login`_ |
| Username | (chosen at OAuth claim time) | `CODETTE_USERNAME` | `--username`, `-u` | `$(whoami)` |
| Password | `credentials.json: password` | `CODETTE_PASSWORD` | `--password`, `-p` | _user-chosen at login_ |

| Setting | Config key | Env var | CLI flag | Default |
|---------|-----------|---------|----------|---------|
| Server URL | `server` | `CODETTE_SERVER_URL` | `--server`, `-s` | `ws://localhost:3000` |
| Host key | `hostKey` | `CODETTE_HOST_KEY` | — | _required (no default)_ |
| Username | `username` | `CODETTE_USERNAME` | `--username`, `-u` | `$(whoami)` |
| Password | `password` | `CODETTE_PASSWORD` | `--password`, `-p` | `changeme` |
Hosts authenticate to `/host` WS by sending a fresh `access_token` (obtained by exchanging the persisted `refresh_token` at startup) as the `?token=` query parameter.

### CLI

Expand Down
22 changes: 13 additions & 9 deletions doc/protocol.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ claude --dangerously-skip-permissions \

---

## Layer 2 — Host ↔ Server (WebSocket `/host?key=HOST_KEY`)
## Layer 2 — Host ↔ Server (WebSocket `/host?token=<access_token>`)

One persistent connection. Host reconnects on drop.

Expand Down Expand Up @@ -85,14 +85,18 @@ JWT in `Authorization: Bearer <token>` header (obtained via challenge/verify flo

| method | path | body / query | response | notes |
|--------|------|------|----------|-------|
| `POST` | `/api/auth/challenge` | `{username}` | `{nonce}` | no auth; server forwards to host RPC |
| `POST` | `/api/auth/verify` | `{username, nonce, response}` | `{token}` | HMAC-SHA256 response; sets `username` cookie; no capabilities — e2e is implicit from password |
| `GET` | `/api/sessions` | — | `{sessions: Session[], hostCwd: string}` | cached session list from host; clients should prefer WS `list_sessions` for fresh data |
| `GET` | `/api/sessions/:id/history` | `?offset=N` / `?limit=N` / `?offset=N&limit=M` | `{lines: string[], totalLines: number, incremental: bool}` | raw JSONL lines; `?limit=N` → last N lines; `?offset=N&limit=M` → lines [N, N+M); `?offset=N` → lines [N, end). Server dedup key: `sessionId:offset:limit` |
| `DELETE` | `/api/sessions/:id` | `?enc=<packed>` | 204 | broadcasts new `session_list` from host over WS. Under e2e the client sends `?enc=base64url(nonce ‖ ciphertext)` (encrypting `'{}'`); server `unpackParam`s and forwards `{nonce, ciphertext}` to the host alongside the plaintext `sessionId` routing field |
| `PUT` | `/api/sessions/:id/name` | `{enc}` or `{name}` | `{ok}` | rename a session. Under e2e the body is `{enc: base64url(nonce ‖ ciphertext)}` encrypting `{name}`; without e2e the plaintext `{name}` form is accepted for debug |
| `GET` | `/api/logs` | `?fmt=text` | JSON array or plain text | `x-host-key` auth |
| `GET` | `/*` | — | `index.html` | SPA fallback |
| `GET` | `/oauth/auth` | OAuth params (`response_type`, `client_id`, `redirect_uri`, `code_challenge`, `code_challenge_method=S256`, `scope`, `prompt`, `state`) | HTML consent page | CLI→server registration (accounting domain). Single button: "Try free for N days". oidc-provider default path `/auth` under the `/oauth` mount. |
| `POST` | `/oauth/interaction/:uid/trial` | OAuth interaction UID (path) | 302 → `/auth/success?code=…` | Mints one-shot auth code; rate-limited per IP (5/15d default) |
| `POST` | `/oauth/token` | `grant_type=authorization_code`, `code`, `code_verifier`, `redirect_uri`, `client_id` | `{access_token, refresh_token, expires_in, token_type}` | PKCE-validated. Also handles `grant_type=refresh_token` |
| `GET` | `/auth/success` | `?code=…` | HTML intermediate page | Posts code to `localhost:<port>/callback` via JS; falls back to copy-paste if unreachable |
| `POST` | `/api/auth/challenge` | `{username}` | `{nonce}` | no auth; server forwards to host RPC. Browser→host auth (chat domain) |
| `POST` | `/api/auth/verify` | `{username, nonce, response}` | `{token}` | HMAC-SHA256 response; sets `username` cookie; no capabilities — e2e is implicit from password. Browser→host auth (chat domain) |
| `GET` | `/api/sessions` | — | `{sessions: Session[], hostCwd: string}` | cached session list from host; clients should prefer WS `list_sessions` for fresh data |
| `GET` | `/api/sessions/:id/history` | `?offset=N` / `?limit=N` / `?offset=N&limit=M` | `{lines: string[], totalLines: number, incremental: bool}` | raw JSONL lines; `?limit=N` → last N lines; `?offset=N&limit=M` → lines [N, N+M); `?offset=N` → lines [N, end). Server dedup key: `sessionId:offset:limit` |
| `DELETE` | `/api/sessions/:id` | `?enc=<packed>` | 204 | broadcasts new `session_list` from host over WS. Under e2e the client sends `?enc=base64url(nonce ‖ ciphertext)` (encrypting `'{}'`); server `unpackParam`s and forwards `{nonce, ciphertext}` to the host alongside the plaintext `sessionId` routing field |
| `PUT` | `/api/sessions/:id/name` | `{enc}` or `{name}` | `{ok}` | rename a session. Under e2e the body is `{enc: base64url(nonce ‖ ciphertext)}` encrypting `{name}`; without e2e the plaintext `{name}` form is accepted for debug |
| `GET` | `/api/logs` | `?fmt=text` | JSON array or plain text | `x-host-key` auth |
| `GET` | `/*` | — | `index.html` | SPA fallback |

### WebSocket `/ws?token=JWT`

Expand Down
5 changes: 3 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ services:
ports:
- "127.0.0.1:3000:3000"
environment:
HOST_KEY: ${HOST_KEY:-}
HOST_TOKEN_TTL: ${HOST_TOKEN_TTL:-0}
COOKIE_SECRET: ${COOKIE_SECRET}
PUBLIC_URL: ${PUBLIC_URL:-https://${SERVER_HOSTNAME}}
SERVER_HOSTNAME: ${SERVER_HOSTNAME}
PORT: "3000"
volumes:
- server-data:/data
Expand Down
Loading