diff --git a/README.md b/README.md index 80cc464..fc985f9 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 diff --git a/doc/auth.spec.md b/doc/auth.spec.md index 03f8453..6acfae1 100644 --- a/doc/auth.spec.md +++ b/doc/auth.spec.md @@ -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=/auth/success&code_challenge=…&code_challenge_method=S256&scope=openid+offline_access&prompt=consent&login_hint=&state=…`. The `redirect_uri` is always `/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 ``") 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//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:/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=/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//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. diff --git a/doc/main.spec.md b/doc/main.spec.md index 74d26f1..22fdf9c 100644 --- a/doc/main.spec.md +++ b/doc/main.spec.md @@ -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 @@ -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:`. +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:` (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 @@ -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 diff --git a/doc/protocol.spec.md b/doc/protocol.spec.md index 2fa602a..e56d22e 100644 --- a/doc/protocol.spec.md +++ b/doc/protocol.spec.md @@ -42,7 +42,7 @@ claude --dangerously-skip-permissions \ --- -## Layer 2 — Host ↔ Server (WebSocket `/host?key=HOST_KEY`) +## Layer 2 — Host ↔ Server (WebSocket `/host?token=`) One persistent connection. Host reconnects on drop. @@ -85,14 +85,18 @@ JWT in `Authorization: Bearer ` 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=` | 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:/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=` | 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` diff --git a/docker-compose.yml b/docker-compose.yml index 021b692..ca52f83 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/host/index.js b/host/index.js index 0d2fb4e..56f72d1 100755 --- a/host/index.js +++ b/host/index.js @@ -16,15 +16,23 @@ import { makeInlineFilePrompt, HTML_RENDER_PROMPT } from '../shared/prompts.js'; import { hmacVerify, deriveKey, deriveNonceKey, deriveAuthKey, encrypt, encryptDet, decrypt } from '../shared/crypto.js'; import { APP_NAME } from '../shared/constants.js'; // ── Config loading ────────────────────────────────────────────────────────── -// Precedence: CLI flags > env vars > credentials.json > defaults +// Precedence: CLI flags > credentials.json > config.json > env vars > defaults -const CREDS_PATH = join(homedir(), '.config', 'codette', 'credentials.json'); +const CREDS_PATH = join(homedir(), '.config', 'codette', 'credentials.json'); +const CONFIG_PATH = join(homedir(), '.config', 'codette', 'config.json'); function loadCredentials() { try { if (existsSync(CREDS_PATH)) return JSON.parse(readFileSync(CREDS_PATH, 'utf8')); } catch {} return {}; } +function loadConfig() { + try { if (existsSync(CONFIG_PATH)) return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); } catch {} + return {}; +} + +const _config = loadConfig(); + function parseCliFlags() { const flags = {}; const args = process.argv.slice(2); @@ -49,19 +57,24 @@ const _passwordFromCreds = !_cli.password && !process.env.CLIENT_PASSWORD && !!_creds.password; +// Precedence: CLI > env > credentials.json > config.json > default. +// Standard Unix convention — env vars override persisted state so debug +// overrides (e.g. CODETTE_SERVER_URL=ws://localhost:3000) actually work +// against an installation that already has a saved server URL. const SERVER_URL = _cli.server || process.env.CODETTE_SERVER_URL || process.env.SERVER_URL - || _creds.server || 'ws://localhost:3000'; + || _creds.server + || _config.server + || 'ws://localhost:3000'; const CLIENT_USERNAME = _cli.username || process.env.CODETTE_USERNAME || process.env.CLIENT_USERNAME || _creds.username || execSync('whoami').toString().trim(); const CLIENT_PASSWORD = _cli.password || process.env.CODETTE_PASSWORD || process.env.CLIENT_PASSWORD || _creds.password || 'changeme'; -// Token presented on /host WS. Normally written by install.sh into -// credentials.json. Required — fail-fast happens after early-exit subcommands. -const HOST_TOKEN = process.env.CODETTE_HOST_KEY || process.env.HOST_KEY - || _creds.hostKey || null; +// HOST_TOKEN is resolved at startup via getAccessToken() (called after +// subcommand dispatch). Declaration here is a placeholder; the real value is +// assigned below after the login/update early-exit blocks. const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'); const E2E_ENABLED = process.env.E2E !== '0'; // Client-originated types that must arrive encrypted under e2e. Server-initiated @@ -153,6 +166,19 @@ if (process.argv.includes('--version') || process.argv.includes('-v')) { } // ── Subcommands ────────────────────────────────────────────────────────────── +if (process.argv[2] === 'login') { + process.on('SIGTERM', () => process.exit(143)); + const { runLogin, PromptAborted } = await import('./login.js'); + try { + await runLogin({ serverUrl: SERVER_URL }); + } catch (e) { + if (e instanceof PromptAborted) process.exit(130); + process.stderr.write(`codette: login failed: ${e.message}\n`); + process.exit(1); + } + process.exit(0); +} + if (process.argv[2] === 'update') { const installDir = join(homedir(), '.local', 'share', 'codette'); const httpUrl = SERVER_URL.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:'); @@ -183,7 +209,8 @@ const NO_DIR_PRIVACY = process.argv.includes('--no-dir-privacy'); if (process.argv.includes('--help') || process.argv.includes('-h')) { process.stdout.write(`Usage: codette [options] - codette update Pull latest source + reinstall dependencies + codette login OAuth-authenticate with the server + codette update Pull latest source + reinstall dependencies Options: -s, --server Server WebSocket URL @@ -195,23 +222,54 @@ Options: -v, --version Print version -h, --help Show this help -Config precedence: CLI flags > env vars > ~/.config/codette/credentials.json > defaults +Config precedence: CLI flags > credentials.json > config.json > env vars > defaults Environment variables: CODETTE_SERVER_URL WebSocket server URL (default: ws://localhost:3000) CODETTE_USERNAME Username shown in chat (default: whoami) CODETTE_PASSWORD Password for web login (default: changeme) - CODETTE_HOST_KEY Token issued by the server (required; install.sh writes it) + CODETTE_ACCESS_TOKEN OAuth access token (bypasses refresh; for testing) -Legacy env vars also supported: SERVER_URL, CLIENT_USERNAME, CLIENT_PASSWORD, HOST_KEY +Legacy env vars also supported: SERVER_URL, CLIENT_USERNAME, CLIENT_PASSWORD `); process.exit(0); } +// ── OAuth access token ──────────────────────────────────────────────────────── +async function getAccessToken() { + // Test-env shortcut: skip refresh, use provided token directly + if (process.env.CODETTE_ACCESS_TOKEN) return process.env.CODETTE_ACCESS_TOKEN; + + if (!_creds.refresh_token) return null; + + const serverHttp = SERVER_URL.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:'); + const res = await fetch(`${serverHttp}/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: _creds.refresh_token, + client_id: 'codette-cli', + }), + }); + if (!res.ok) { + process.stderr.write(`codette: token refresh failed (${res.status}). Run \`codette login\` again.\n`); + return null; + } + const tokens = await res.json(); + + // Persist the new refresh token if it rotated + if (tokens.refresh_token && tokens.refresh_token !== _creds.refresh_token) { + const merged = { ..._creds, refresh_token: tokens.refresh_token }; + writeFileSync(CREDS_PATH, JSON.stringify(merged, null, 2), { mode: 0o600 }); + } + return tokens.access_token; +} + +const HOST_TOKEN = await getAccessToken(); if (!HOST_TOKEN) { - process.stderr.write('codette: no host token configured.\n'); - process.stderr.write(' Run the installer: curl -fsSL https:///install.sh | sh\n'); - process.stderr.write(' Or set CODETTE_HOST_KEY in the environment.\n'); + process.stderr.write('codette: not authenticated.\n'); + process.stderr.write(' Run: codette login\n'); process.exit(1); } diff --git a/host/login.js b/host/login.js new file mode 100644 index 0000000..8b81444 --- /dev/null +++ b/host/login.js @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov +// +// `codette login` — OAuth Authorization Code + PKCE. +// Opens browser at /oauth/auth, listens on a free localhost port, +// races against a paste prompt for remote-codette installs. + +import { createServer } from 'http'; +import { randomBytes, createHash } from 'crypto'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { spawn, execSync } from 'child_process'; +import readline from 'readline'; + +function base64UrlEncode(buf) { + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function generatePKCE() { + const verifier = base64UrlEncode(randomBytes(32)); + const challenge = base64UrlEncode(createHash('sha256').update(verifier).digest()); + return { verifier, challenge }; +} + +function openBrowser(url) { + const cmd = process.platform === 'darwin' ? 'open' + : process.platform === 'win32' ? 'start' + : 'xdg-open'; + try { + spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref(); + } catch { /* OK — user will use the printed URL */ } +} + +function findFreePort() { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.listen(0, '127.0.0.1', () => { + const port = srv.address().port; + srv.close(() => resolve(port)); + }); + srv.on('error', reject); + }); +} + +function listenForCode(port) { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + const u = new URL(req.url, `http://127.0.0.1:${port}`); + if (u.pathname === '/callback') { + const code = u.searchParams.get('code'); + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Authenticated. You can close this window.'); + server.close(); + resolve(code); + } else { + res.writeHead(404).end(); + } + }); + server.listen(port, '127.0.0.1'); + const timeout = setTimeout(() => { server.close(); reject(new Error('timeout')); }, 5 * 60 * 1000); + server.on('close', () => clearTimeout(timeout)); + }); +} + +// Queue-based prompt helper: a single readline interface pre-buffers lines so +// sequential asks work correctly even when stdin is piped (non-TTY). +// Sentinel thrown from ask() when the user hits Ctrl+C. The dispatch in +// host/index.js catches this and exits with code 130 — settling the await +// avoids Node's "unsettled top-level await" warning. +class PromptAborted extends Error { + constructor() { super('Aborted by user'); this.name = 'PromptAborted'; } +} + +function makePrompt() { + const lines = []; + const waiters = []; + let aborted = false; + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + + rl.on('line', (line) => { + if (waiters.length > 0) { + waiters.shift().resolve(line); + } else { + lines.push(line); + } + }); + + // readline intercepts SIGINT while active. Reject the pending ask() so the + // awaited promise settles cleanly — then the caller's try/catch handles + // exit, no top-level-unsettled warning. + rl.on('SIGINT', () => { + process.stdout.write('\n'); + aborted = true; + const err = new PromptAborted(); + while (waiters.length) waiters.shift().reject(err); + rl.close(); + }); + + const ask = (question, fallback) => new Promise((resolve, reject) => { + if (aborted) return reject(new PromptAborted()); + process.stdout.write(`${question}${fallback ? ` [${fallback}]` : ''}: `); + if (lines.length > 0) { + const answer = lines.shift(); + resolve(answer.trim() || fallback || ''); + } else { + waiters.push({ + resolve: (line) => resolve(line.trim() || fallback || ''), + reject, + }); + } + }); + + const close = () => rl.close(); + return { ask, close }; +} + +export { PromptAborted }; + +async function exchangeCode({ serverHttp, code, verifier, redirectUri }) { + const res = await fetch(`${serverHttp}/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + client_id: 'codette-cli', + redirect_uri: redirectUri, + code_verifier: verifier, + }), + }); + if (!res.ok) throw new Error(`token exchange failed: ${res.status} ${await res.text()}`); + return await res.json(); +} + +function defaultUsername() { + try { return execSync('whoami').toString().trim(); } catch { return 'user'; } +} + +function generatePassword() { + // 10-char base36 random — short enough to type, ample entropy + return randomBytes(8).toString('base64').replace(/[+/=]/g, '').slice(0, 10); +} + +export async function runLogin({ serverUrl }) { + const serverHttp = serverUrl.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:'); + console.log(`Server: ${serverUrl}`); + + // Browser↔host chat-domain credentials. These are unrelated to OAuth and + // are the credentials the user enters in the browser when signing in. + const configDir = join(homedir(), '.config', 'codette'); + let existing = {}; + const credsPath = join(configDir, 'credentials.json'); + try { + if (existsSync(credsPath)) existing = JSON.parse(readFileSync(credsPath, 'utf8')); + } catch {} + + const prompter = makePrompt(); + + // Loop until the user picks a username the server reports as available. + // Server-side claim at /oauth/interaction/:uid/trial is the race-safe + // boundary; this pre-flight just keeps the bad outcome (browser opens, user + // clicks, "username taken" error page) from happening when it was already + // knowable in the terminal. Fail-soft on non-JSON responses (older server + // without the endpoint, etc.) — let consent-time check be the authority. + async function checkAvailability(name) { + let resp; + try { + resp = await fetch(`${serverHttp}/auth/username-available/${encodeURIComponent(name)}`); + } catch (e) { + throw new Error(`Could not reach the server (${serverHttp}): ${e.message}`); + } + const body = await resp.text(); + try { + return JSON.parse(body); + } catch { + // Endpoint doesn't exist on this server (we got HTML/etc). Skip check. + const preview = body.slice(0, 60).replace(/\s+/g, ' '); + console.log(` (skipping availability check — HTTP ${resp.status} ${resp.headers.get('content-type') || ''}: ${preview}…)`); + return { available: true, _skipped: true }; + } + } + let username; + while (true) { + username = await prompter.ask('Username', existing.username || defaultUsername()); + if (!/^[a-z][a-z0-9_-]{1,31}$/.test(username)) { + console.log(" Invalid: lowercase, start with a letter, 2–32 chars from [a-z0-9_-]"); + continue; + } + const { available, reason } = await checkAvailability(username); + if (available) break; + console.log(reason === 'invalid' ? ' Invalid username.' : ` '${username}' is already taken.`); + } + + const password = await prompter.ask('Password', existing.password || generatePassword()); + + // OAuth dance + const { verifier, challenge } = generatePKCE(); + const port = await findFreePort(); + const stateObj = { port, nonce: randomBytes(8).toString('hex') }; + const state = base64UrlEncode(Buffer.from(JSON.stringify(stateObj))); + const redirectUri = new URL('/auth/success', serverHttp).href; + + const authUrl = `${serverHttp}/oauth/auth?` + new URLSearchParams({ + response_type: 'code', + client_id: 'codette-cli', + redirect_uri: redirectUri, + code_challenge: challenge, + code_challenge_method: 'S256', + state, + scope: 'openid offline_access', + prompt: 'consent', + login_hint: username, + }); + + console.log('\nOpen: ' + authUrl + '\n'); + openBrowser(authUrl); + + // Race localhost listener against paste prompt (same shared rl interface). + // Empty input (accidental Enter) silently re-prompts. + async function pastePromptLoop() { + while (true) { + const input = await prompter.ask('Enter the code here (Ctrl+C to cancel)', ''); + if (input) return input; + } + } + const code = await Promise.race([ + listenForCode(port).catch(() => new Promise(() => {})), // never resolves on error → paste wins + pastePromptLoop(), + ]); + prompter.close(); + if (!code) throw new Error('no code received'); + + const tokens = await exchangeCode({ serverHttp, code, verifier, redirectUri }); + + mkdirSync(configDir, { recursive: true, mode: 0o700 }); + writeFileSync( + credsPath, + JSON.stringify({ + server: serverUrl, + refresh_token: tokens.refresh_token, + username, + password, + }, null, 2), + { mode: 0o600 } + ); + + console.log('\n✓ Authenticated. Run `codette` to start the host.'); +} diff --git a/init.sh b/init.sh index c7a3ff8..8deb8b5 100755 --- a/init.sh +++ b/init.sh @@ -19,13 +19,22 @@ ask() { echo "${val:-$2}" } -HOST_KEY=$(openssl rand -hex 32) +COOKIE_SECRET=$(openssl rand -hex 32) DEFAULT_HOSTNAME=$(hostname -f 2>/dev/null || echo "localhost") SERVER_HOSTNAME=$(ask "Server hostname" "$DEFAULT_HOSTNAME") +# Derive a sensible PUBLIC_URL default +if [ "$SERVER_HOSTNAME" = "localhost" ] || [ "$SERVER_HOSTNAME" = "127.0.0.1" ]; then + DEFAULT_PUBLIC_URL="http://$SERVER_HOSTNAME:3000" +else + DEFAULT_PUBLIC_URL="https://$SERVER_HOSTNAME" +fi +PUBLIC_URL=$(ask "Public URL (OAuth issuer)" "$DEFAULT_PUBLIC_URL") + cat > "$ENV_FILE" < /dev/tty read -r val < /dev/tty echo "${val:-$2}" } -# Derive HTTP URL from WS URL for tarball fallback -http_url() { - echo "$SERVER_URL" | sed 's|^wss://|https://|;s|^ws://|http://|' -} +http_url() { echo "$SERVER_URL" | sed 's|^wss://|https://|;s|^ws://|http://|'; } if [ -z "$SERVER_URL" ]; then SERVER_URL=$(ask "Server URL" "ws://localhost:3000") fi -if [ -z "$HOST_KEY" ]; then - HOST_KEY=$(ask "Host key (required, no default)" "") -fi -if [ -z "$HOST_KEY" ]; then - echo "Error: host key is required." >&2 - echo "Obtain one by piping the server-hosted installer: curl -fsSL /install.sh | sh" >&2 - echo "Or set CODETTE_HOST_KEY in the environment before running this script." >&2 - exit 1 -fi echo "Installing codette host..." -# 1. Clone or update source — try git, fall back to server tarball +# Clone or update — try git, fall back to server tarball if [ -d "$INSTALL_DIR/.git" ]; then echo "Updating existing installation..." if ! git -C "$INSTALL_DIR" fetch --depth 1 origin --quiet 2>/dev/null; then @@ -58,73 +41,36 @@ if [ -d "$INSTALL_DIR/.git" ]; then git -C "$INSTALL_DIR" reset --hard origin/HEAD --quiet fi elif command -v git >/dev/null 2>&1 && git clone --depth 1 --quiet "$REPO_URL" "$INSTALL_DIR" 2>/dev/null; then - : # cloned silently + : else echo "git unavailable or clone failed, downloading tarball from server..." mkdir -p "$INSTALL_DIR" curl -fsSL "$(http_url)/host.tar.gz" | tar xz -C "$INSTALL_DIR" --strip-components=0 fi -# 2. Install host dependencies (cd "$INSTALL_DIR/host" && npm ci --silent) -# 3. Prompt for username and password -DEFAULT_USER="$(whoami)" -DEFAULT_PASS="$(LC_ALL=C tr -dc 'a-zA-Z0-9' /dev/tty < "$CONFIG_DIR/credentials.json" < "$CONFIG_DIR/config.json" < Building client (dev mode)..." (cd "$ROOT/client" && npx vite build --mode development) +mkdir -p "$ROOT/.dev-data/oauth" + echo "==> Starting server on :$PORT ($SERVER_LOG)" -(cd "$ROOT/server" && node src/index.js) >"$SERVER_LOG" 2>&1 & +(cd "$ROOT/server" && OAUTH_DATA_DIR="$OAUTH_DATA_DIR" COOKIE_SECRET="$COOKIE_SECRET" PUBLIC_URL="$PUBLIC_URL" node src/index.js) >"$SERVER_LOG" 2>&1 & SERVER_PID=$! +if [ "$SERVER_ONLY" = "1" ]; then + printf '%s\n' "$SERVER_PID" > "$PIDFILE" + echo "==> server-only mode; skipping host spawns. Ctrl+C to stop. (pid: $SERVER_PID)" + tail -f "$SERVER_LOG" + exit 0 +fi + mkdir -p "$ROOT/.dev-data/alice/.claude" "$ROOT/.dev-data/bob/.claude" # Symlink Claude credentials so dev hosts can spawn Claude for d in "$ROOT/.dev-data/alice/.claude" "$ROOT/.dev-data/bob/.claude"; do [ -L "$d/.credentials.json" ] || ln -sf ~/.claude/.credentials.json "$d/.credentials.json" done +# Mint OAuth tokens programmatically (no browser needed — mintAccessToken uses the +# headless PKCE flow in tests/oauth-flow.js against the local server). +echo "==> Minting OAuth tokens for alice and bob..." +sleep 2 # let server bind +TOKEN_ALICE=$(node -e " +import('./tests/oauth-flow.js').then(async ({ mintAccessToken }) => { + const t = await mintAccessToken({ serverBase: 'http://localhost:$PORT', username: 'alice' }); + process.stdout.write(t.access_token); +}).catch(e => { console.error(e.message); process.exit(1); }); +") +TOKEN_BOB=$(node -e " +import('./tests/oauth-flow.js').then(async ({ mintAccessToken }) => { + const t = await mintAccessToken({ serverBase: 'http://localhost:$PORT', username: 'bob' }); + process.stdout.write(t.access_token); +}).catch(e => { console.error(e.message); process.exit(1); }); +") + echo "==> Starting host1: alice ($HOST1_LOG)" -(cd "$ROOT/host" && CODETTE_DATA_HOME="$ROOT/.dev-data/alice" CLAUDE_CONFIG_DIR="$ROOT/.dev-data/alice/.claude" node index.js --server "$SERVER_URL" --username alice --password pass1 --no-dir-privacy --permission-mode default) >"$HOST1_LOG" 2>&1 & +(cd "$ROOT/host" && CODETTE_DATA_HOME="$ROOT/.dev-data/alice" CLAUDE_CONFIG_DIR="$ROOT/.dev-data/alice/.claude" CODETTE_ACCESS_TOKEN="$TOKEN_ALICE" node index.js --server "$SERVER_URL" --username alice --password pass1 --no-dir-privacy --permission-mode default) >"$HOST1_LOG" 2>&1 & HOST1_PID=$! echo "==> Starting host2: bob ($HOST2_LOG)" -(cd "$ROOT/host" && CODETTE_DATA_HOME="$ROOT/.dev-data/bob" CLAUDE_CONFIG_DIR="$ROOT/.dev-data/bob/.claude" node index.js --server "$SERVER_URL" --username bob --password pass2) >"$HOST2_LOG" 2>&1 & +(cd "$ROOT/host" && CODETTE_DATA_HOME="$ROOT/.dev-data/bob" CLAUDE_CONFIG_DIR="$ROOT/.dev-data/bob/.claude" CODETTE_ACCESS_TOKEN="$TOKEN_BOB" node index.js --server "$SERVER_URL" --username bob --password pass2) >"$HOST2_LOG" 2>&1 & HOST2_PID=$! printf '%s\n' "$SERVER_PID" "$HOST1_PID" "$HOST2_PID" > "$PIDFILE" diff --git a/run_dev_login.sh b/run_dev_login.sh new file mode 100755 index 0000000..62acdc0 --- /dev/null +++ b/run_dev_login.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Danylo Lykov +# +# Drive the interactive `codette login` flow against a local dev server. +# Server lifecycle is delegated to `run_dev.sh --server-only`; this script +# only owns the isolated $HOME, the login dance, and the host spawn. +# +# Re-running is cheap: credentials.json under .dev-data/login-user/ is reused. +# Delete that file (or .dev-data/oauth/) to force a fresh login dance. + +set -e + +ROOT="$(cd "$(dirname "$0")" && pwd)" +USER_HOME="$ROOT/.dev-data/login-user" +USER_CONFIG="$USER_HOME/.config/codette" +USER_CLAUDE="$USER_HOME/.claude" +USER_DATA="$USER_HOME/.local/share/codette" +CREDS_FILE="$USER_CONFIG/credentials.json" +HOST_LOG=/tmp/dev-login-host.log + +# Start the dev server via run_dev.sh, or reuse one that's already up. +if curl -sf http://localhost:3000/ >/dev/null 2>&1; then + echo "==> Reusing existing server on :3000" + RUNDEV_PID="" +else + echo "==> Starting run_dev.sh --server-only" + "$ROOT/run_dev.sh" --server-only & + RUNDEV_PID=$! + for _ in $(seq 1 20); do + curl -sf http://localhost:3000/ >/dev/null 2>&1 && break + sleep 0.3 + done +fi + +cleanup() { + echo "Stopping..." + [ -n "$HOST_PID" ] && kill "$HOST_PID" 2>/dev/null || true + [ -n "$RUNDEV_PID" ] && kill "$RUNDEV_PID" 2>/dev/null || true + wait 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +# Isolated $HOME so codette login writes credentials.json under .dev-data/. +mkdir -p "$USER_CONFIG" "$USER_CLAUDE" "$USER_DATA" +if [ -f ~/.claude/.credentials.json ] && [ ! -L "$USER_CLAUDE/.credentials.json" ]; then + ln -sf ~/.claude/.credentials.json "$USER_CLAUDE/.credentials.json" +fi +cat > "$USER_CONFIG/config.json" < credentials.json already at $CREDS_FILE — skipping login (delete to redo)" +else + HOME="$USER_HOME" \ + CODETTE_DATA_HOME="$USER_DATA" \ + CLAUDE_CONFIG_DIR="$USER_CLAUDE" \ + node "$ROOT/host/index.js" login + [ -f "$CREDS_FILE" ] || { echo "Login did not produce credentials.json — aborting."; exit 1; } +fi + +echo +echo "==> Starting host using $CREDS_FILE ($HOST_LOG)" +(cd "$ROOT/host" && \ + HOME="$USER_HOME" \ + CODETTE_DATA_HOME="$USER_DATA" \ + CLAUDE_CONFIG_DIR="$USER_CLAUDE" \ + node index.js --no-dir-privacy --permission-mode default \ +) >"$HOST_LOG" 2>&1 & +HOST_PID=$! + +USERNAME=$(node -e "process.stdout.write(JSON.parse(require('fs').readFileSync('$CREDS_FILE','utf8')).username)") +echo "==> Web UI: http://localhost:3000 (sign in as: $USERNAME, password in $CREDS_FILE)" +echo "==> Ctrl+C to stop." +tail -f "$HOST_LOG" diff --git a/server/package-lock.json b/server/package-lock.json index d715634..4737a34 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,11 +9,77 @@ "version": "0.1.2", "license": "Apache-2.0", "dependencies": { + "cookie-parser": "^1.4.7", "express": "^5.0.0", "jose": "^6.0.0", + "oidc-provider": "^8.8.1", "ws": "^8.20.1" } }, + "node_modules/@koa/cors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@koa/cors/-/cors-5.0.0.tgz", + "integrity": "sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==", + "license": "MIT", + "dependencies": { + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@koa/router": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@koa/router/-/router-13.1.1.tgz", + "integrity": "sha512-JQEuMANYRVHs7lm7KY9PCIjkgJk73h4m4J+g2mkw2Vo1ugPZ17UJVqEH8F+HeAdjKz5do1OaLe7ArDz+z308gw==", + "deprecated": "Please upgrade to v15 or higher. All reported bugs in this version are fixed in newer releases, dependencies have been updated, and security has been improved.", + "license": "MIT", + "dependencies": { + "debug": "^4.4.1", + "http-errors": "^2.0.0", + "koa-compose": "^4.1.0", + "path-to-regexp": "^6.3.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@koa/router/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", + "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", + "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.1" + }, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "license": "MIT" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -60,6 +126,67 @@ "node": ">= 0.8" } }, + "node_modules/cache-content-type": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", + "integrity": "sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==", + "license": "MIT", + "dependencies": { + "mime-types": "^2.1.18", + "ylru": "^1.2.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/cache-content-type/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cache-content-type/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cacheable-lookup": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/cacheable-request": { + "version": "10.2.14", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz", + "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==", + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "^4.0.2", + "get-stream": "^6.0.1", + "http-cache-semantics": "^4.1.1", + "keyv": "^4.5.3", + "mimic-response": "^4.0.0", + "normalize-url": "^8.0.0", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -89,6 +216,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -120,6 +257,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -129,6 +285,19 @@ "node": ">=6.6.0" } }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -146,6 +315,54 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==", + "license": "MIT" + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -155,6 +372,16 @@ "node": ">= 0.8" } }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -220,6 +447,18 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/eta": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-3.5.0.tgz", + "integrity": "sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + }, + "funding": { + "url": "https://github.com/eta-dev/eta?sponsor=1" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -293,6 +532,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/form-data-encoder": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", + "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==", + "license": "MIT", + "engines": { + "node": ">= 14.17" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -320,6 +568,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -357,6 +614,18 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -369,6 +638,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/got": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/got/-/got-13.0.0.tgz", + "integrity": "sha512-XfBk1CxOOScDcMr9O1yKkNaQyy865NbYs+F7dr4H0LZMVgCj2Le59k6PqbNHoL5ToeaEQUYh6c6yMfVcc6SJxA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^5.2.0", + "@szmarczak/http-timer": "^5.0.1", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^10.2.8", + "decompress-response": "^6.0.0", + "form-data-encoder": "^2.1.2", + "get-stream": "^6.0.1", + "http2-wrapper": "^2.1.10", + "lowercase-keys": "^3.0.0", + "p-cancelable": "^3.0.0", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -381,6 +675,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -393,6 +702,59 @@ "node": ">= 0.4" } }, + "node_modules/http-assert": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", + "license": "MIT", + "dependencies": { + "deep-equal": "~1.0.1", + "http-errors": "~1.8.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-assert/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -413,6 +775,31 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http2-wrapper": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/http2-wrapper/node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -444,12 +831,49 @@ "node": ">= 0.10" } }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/jose": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", @@ -459,6 +883,239 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "license": "MIT" + }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/koa": { + "version": "2.16.4", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.16.4.tgz", + "integrity": "sha512-3An0GCLDSR34tsCO4H8Tef8Pp2ngtaZDAZnsWJYelqXUK5wyiHvGItgK/xcSkmHLSTn1Jcho1mRQs2ehRzvKKw==", + "license": "MIT", + "dependencies": { + "accepts": "^1.3.5", + "cache-content-type": "^1.0.0", + "content-disposition": "~0.5.2", + "content-type": "^1.0.4", + "cookies": "~0.9.0", + "debug": "^4.3.2", + "delegates": "^1.0.0", + "depd": "^2.0.0", + "destroy": "^1.0.4", + "encodeurl": "^1.0.2", + "escape-html": "^1.0.3", + "fresh": "~0.5.2", + "http-assert": "^1.3.0", + "http-errors": "^1.6.3", + "is-generator-function": "^1.0.7", + "koa-compose": "^4.1.0", + "koa-convert": "^2.0.0", + "on-finished": "^2.3.0", + "only": "~0.0.2", + "parseurl": "^1.3.2", + "statuses": "^1.5.0", + "type-is": "^1.6.16", + "vary": "^1.1.2" + }, + "engines": { + "node": "^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4" + } + }, + "node_modules/koa-compose": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", + "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==", + "license": "MIT" + }, + "node_modules/koa-convert": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz", + "integrity": "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==", + "license": "MIT", + "dependencies": { + "co": "^4.6.0", + "koa-compose": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/koa/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/koa/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/http-errors/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -514,12 +1171,42 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mimic-response": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -529,6 +1216,27 @@ "node": ">= 0.6" } }, + "node_modules/normalize-url": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", + "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -541,6 +1249,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-provider": { + "version": "8.8.1", + "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-8.8.1.tgz", + "integrity": "sha512-qVChpayTwojUREJxLkFofUSK8kiSRIdzPrVSsoGibqRHl/YO60ege94OZS8vh7zaK+zxcG/Gu8UMaYB5ulohCQ==", + "license": "MIT", + "dependencies": { + "@koa/cors": "^5.0.0", + "@koa/router": "^13.1.0", + "debug": "^4.4.0", + "eta": "^3.5.0", + "got": "^13.0.0", + "jose": "^5.9.6", + "jsesc": "^3.1.0", + "koa": "^2.15.4", + "nanoid": "^5.0.9", + "object-hash": "^3.0.0", + "oidc-token-hash": "^5.0.3", + "quick-lru": "^7.0.0", + "raw-body": "^3.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/oidc-provider/node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -562,6 +1312,20 @@ "wrappy": "1" } }, + "node_modules/only": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", + "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==" + }, + "node_modules/p-cancelable": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", + "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -609,6 +1373,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-lru": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-7.3.0.tgz", + "integrity": "sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -633,6 +1409,27 @@ "node": ">= 0.10" } }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "license": "MIT" + }, + "node_modules/responselike": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", + "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", + "license": "MIT", + "dependencies": { + "lowercase-keys": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -649,6 +1446,43 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -796,6 +1630,15 @@ "node": ">=0.6" } }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -871,6 +1714,15 @@ "optional": true } } + }, + "node_modules/ylru": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.4.0.tgz", + "integrity": "sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } } } } diff --git a/server/package.json b/server/package.json index e92673a..019d88a 100644 --- a/server/package.json +++ b/server/package.json @@ -4,8 +4,10 @@ "license": "Apache-2.0", "type": "module", "dependencies": { + "cookie-parser": "^1.4.7", "express": "^5.0.0", "jose": "^6.0.0", + "oidc-provider": "^8.8.1", "ws": "^8.20.1" }, "overrides": { diff --git a/server/src/index.js b/server/src/index.js index 5e3f993..b9854cd 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -7,73 +7,26 @@ import { jwtVerify, importSPKI } from 'jose'; import { createServer } from 'http'; import { fileURLToPath } from 'url'; import path from 'path'; -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { readFileSync, existsSync } from 'fs'; import { execFileSync } from 'child_process'; -import { randomBytes } from 'crypto'; import { RpcClient } from './rpc.js'; import { unpackParam } from '../../shared/crypto.js'; +import { isValidUsername, isUsernameClaimed, lookupUsernameBySub } from './oauth/usernames.js'; +import { buildProvider } from './oauth/provider.js'; +import { makeValidateHostToken } from './oauth/host-auth.js'; +import cookieParser from 'cookie-parser'; +import { mountInteractions } from './oauth/interaction.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// Optional master-shortcut. Unset = only per-IP tokens accepted. -const HOST_KEY = process.env.HOST_KEY; -const HOST_TOKEN_TTL = parseInt(process.env.HOST_TOKEN_TTL || '0', 10); // 0 = never expires -if (HOST_TOKEN_TTL === 0) { - console.warn('[server] HOST_TOKEN_TTL=0 (host tokens never expire). Set a finite value (e.g. 2592000 = 30 days) in .env.'); -} - const PORT = parseInt(process.env.PORT || '3000', 10); -// ── Host token store ───────────────────────────────────────────────────────── -// Tokens are persisted in /data/host-tokens.json (Docker volume). -// Each entry is keyed by IP: { token, created, expires, label? } -const TOKEN_FILE = '/data/host-tokens.json'; - -function loadTokens() { - try { return existsSync(TOKEN_FILE) ? JSON.parse(readFileSync(TOKEN_FILE, 'utf8')) : {}; } catch { return {}; } -} - -function saveTokens(tokens) { - mkdirSync('/data', { recursive: true }); - writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2)); -} - -function validateToken(token) { - if (HOST_KEY && token === HOST_KEY) return true; // master shortcut, when configured - const tokens = loadTokens(); - const entry = Object.values(tokens).find(e => e.token === token); - if (!entry) return false; - if (entry.expires > 0 && Date.now() > entry.expires) return false; - return true; -} - -function getOrCreateTokenForIp(ip) { - const tokens = loadTokens(); - const existing = tokens[ip]; - if (existing) { - if (existing.expires > 0 && Date.now() > existing.expires) { - return { error: 'Token expired for this address' }; - } - return { token: existing.token }; - } - // Issue new token - const token = randomBytes(16).toString('hex'); - const now = Date.now(); - tokens[ip] = { - token, - created: now, - expires: HOST_TOKEN_TTL > 0 ? now + HOST_TOKEN_TTL * 1000 : 0, - }; - saveTokens(tokens); - return { token }; -} - - // ── Per-host state ──────────────────────────────────────────────────────────── class HostContext { constructor(clientUsername, ws) { this.clientUsername = clientUsername; this.ws = ws; + this.sub = null; // set from OAuth validated.sub at connect this.pubkey = null; // set when host sends host_pubkey this.sessionCache = []; this.hostCwd = null; @@ -99,6 +52,31 @@ const app = express(); app.set('trust proxy', true); app.use(express.json()); +const ISSUER = process.env.PUBLIC_URL || `http://localhost:${PORT}`; +const oidc = await buildProvider(ISSUER); +const validateHostToken = makeValidateHostToken(oidc); +app.use(cookieParser()); +mountInteractions(app, oidc); +app.use('/oauth', oidc.callback()); + +const SUCCESS_HTML = readFileSync( + path.join(__dirname, 'oauth/views/success.html'), + 'utf8' +); + +app.get('/auth/success', (req, res) => { + res.type('html').send(SUCCESS_HTML); +}); + +// Pre-flight check for username availability (CLI calls this before opening +// the browser). Advisory only — the binding race is resolved server-side +// in /oauth/interaction/:uid/trial via claimUsername (TOCTOU-safe there). +app.get('/auth/username-available/:name', (req, res) => { + const name = String(req.params.name || '').toLowerCase(); + if (!isValidUsername(name)) return res.json({ available: false, reason: 'invalid' }); + res.json({ available: !isUsernameClaimed(name) }); +}); + // ── REST request logging ────────────────────────────────────────────────────── // Query params that are bearer-credential-shaped and must never appear in logs. const REDACTED_QS_KEYS = new Set(['token', 'access_token', 'auth']); @@ -371,15 +349,11 @@ app.get('/install.sh', (req, res) => { return res.status(503).type('text/plain') .send('# SERVER_HOSTNAME not configured on server. Set it in .env before serving installs.\n'); } - const ip = req.ip; - const { token, error } = getOrCreateTokenForIp(ip); - if (error) return res.status(403).type('text/plain').send(`# Error: ${error}\n`); const isLocal = /^(localhost|127\.0\.0\.1)(:\d+)?$/.test(hostname); const wsProto = isLocal ? 'ws' : 'wss'; const serverUrl = `${wsProto}://${hostname}`; let script = readFileSync(installShPath, 'utf8'); script = script.replace('SERVER_URL="${CODETTE_SERVER_URL:-}"', `SERVER_URL="${serverUrl}"`); - script = script.replace('HOST_KEY="${CODETTE_HOST_KEY:-}"', `HOST_KEY="${token}"`); res.type('text/plain').send(script); }); @@ -415,12 +389,25 @@ wss.on('connection', async (ws, req) => { // ── Host connection ──────────────────────────────────────────────────────── if (url.pathname === '/host') { - if (!validateToken(url.searchParams.get('token'))) { ws.close(1008, 'Unauthorized'); return; } + const tokenStr = url.searchParams.get('token'); + const validated = await validateHostToken(tokenStr); + if (!validated) { ws.close(1008, 'Unauthorized'); return; } const clientUsername = url.searchParams.get('clientUsername'); if (!clientUsername) { ws.close(1008, 'clientUsername required'); return; } + + // Enforce the username binding made at OAuth consent time. The token is + // bound to exactly one username; rejecting any other clientUsername here + // closes the squatting hole (any valid token used to occupy any free name). + const boundUsername = lookupUsernameBySub(validated.sub); + if (!boundUsername) { ws.close(1008, 'Token is not bound to any username'); return; } + if (boundUsername !== clientUsername) { + ws.close(1008, `Token is bound to ${boundUsername}, not ${clientUsername}`); + return; + } if (hosts.has(clientUsername)) { ws.close(1008, 'Host already connected for this username'); return; } const host = new HostContext(clientUsername, ws); + host.sub = validated.sub; hosts.set(clientUsername, host); console.log(`[server] host connected: ${clientUsername} (${hosts.size} total)`); wtrace('host', 'server', 'connect', { username: clientUsername }); diff --git a/server/src/oauth/host-auth.js b/server/src/oauth/host-auth.js new file mode 100644 index 0000000..2f654bc --- /dev/null +++ b/server/src/oauth/host-auth.js @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov + +// Validate an OAuth access_token for /host WS connections. +// Returns { sub } if the token is a live AccessToken, null otherwise. +export function makeValidateHostToken(provider) { + return async function validateHostToken(token) { + if (!token) return null; + try { + const accessToken = await provider.AccessToken.find(token); + if (!accessToken) return null; + if (accessToken.isExpired) return null; + return { sub: accessToken.accountId }; + } catch { + return null; + } + }; +} diff --git a/server/src/oauth/host-auth.test.js b/server/src/oauth/host-auth.test.js new file mode 100644 index 0000000..b090875 --- /dev/null +++ b/server/src/oauth/host-auth.test.js @@ -0,0 +1,44 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { makeValidateHostToken } from './host-auth.js'; + +function mockProvider(tokenLookups) { + return { + AccessToken: { + find: async (t) => tokenLookups[t], + }, + }; +} + +test('returns null for missing token', async () => { + const v = makeValidateHostToken(mockProvider({})); + assert.equal(await v(''), null); + assert.equal(await v(null), null); + assert.equal(await v(undefined), null); +}); + +test('returns null for unknown token', async () => { + const v = makeValidateHostToken(mockProvider({})); + assert.equal(await v('bogus'), null); +}); + +test('returns null for expired token', async () => { + const v = makeValidateHostToken(mockProvider({ + 'exp-tok': { accountId: 'u1', isExpired: true }, + })); + assert.equal(await v('exp-tok'), null); +}); + +test('returns sub for valid token', async () => { + const v = makeValidateHostToken(mockProvider({ + 'valid-tok': { accountId: 'user-abc', isExpired: false }, + })); + assert.deepEqual(await v('valid-tok'), { sub: 'user-abc' }); +}); + +test('returns null on adapter throw', async () => { + const v = makeValidateHostToken({ + AccessToken: { find: async () => { throw new Error('boom'); } }, + }); + assert.equal(await v('any'), null); +}); diff --git a/server/src/oauth/interaction.js b/server/src/oauth/interaction.js new file mode 100644 index 0000000..834dd9f --- /dev/null +++ b/server/src/oauth/interaction.js @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov +// +// Custom interaction (consent) handler for the trial-only auth flow. + +import { readFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { randomBytes } from 'crypto'; +import express from 'express'; +import { claimIfAllowed, revokeTrialClaim } from './trial.js'; +import { isValidUsername, isUsernameClaimed, claimUsername } from './usernames.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CONSENT_HTML = readFileSync(join(__dirname, 'views', 'consent.html'), 'utf8'); +const ERROR_HTML = readFileSync(join(__dirname, 'views', 'error.html'), 'utf8'); + +const CSRF_COOKIE = 'oauth_csrf'; + +// Substitutes {title, message, hint} into error.html and returns the rendered +// page. `hint` may contain a small amount of trusted HTML (e.g. tags); the +// other fields are escaped. +const HTML_ESCAPE = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; +const esc = (s) => String(s).replace(/[&<>"']/g, (c) => HTML_ESCAPE[c]); + +function renderError({ title, message, hint }) { + return ERROR_HTML + .replace('__TITLE__', esc(title)) + .replace('__MESSAGE__', esc(message)) + .replace('__HINT__', hint || ''); // trusted: callers pass safe markup +} + +function sendError(res, status, opts) { + return res.status(status).type('html').send(renderError(opts)); +} + +const RESTART_HINT = + 'Re-run codette login in your terminal to start a fresh sign-in.'; + +export function mountInteractions(app, provider) { + // Render the consent page + app.get('/oauth/interaction/:uid', async (req, res) => { + let details; + try { + details = await provider.interactionDetails(req, res); + } catch (e) { + return sendError(res, 400, { + title: 'Sign-in session expired', + message: 'This sign-in link is no longer valid (expired or already used).', + hint: RESTART_HINT, + }); + } + if (details.prompt.name !== 'login' && details.prompt.name !== 'consent') { + return sendError(res, 400, { + title: 'Unexpected sign-in step', + message: `The server requested a step this UI does not handle (${details.prompt.name}).`, + hint: RESTART_HINT, + }); + } + const username = details.params.login_hint; + if (!isValidUsername(username)) { + return sendError(res, 400, { + title: 'Username missing or invalid', + message: 'No valid username was provided to bind this sign-in to.', + hint: 'Re-run codette login. Usernames must be lowercase, start with a letter, and be 2–32 chars (letters, digits, _, -).', + }); + } + if (isUsernameClaimed(username)) { + return sendError(res, 409, { + title: 'Username already taken', + message: `${esc(username)} is already claimed by another sign-in.`, + hint: 'Re-run codette login with a different username.', + }); + } + const csrf = randomBytes(16).toString('hex'); + res.cookie(CSRF_COOKIE, csrf, { httpOnly: true, sameSite: 'lax', secure: req.secure }); + const html = CONSENT_HTML + .replace(/__UID__/g, details.uid) + .replace(/__CSRF__/g, csrf) + .replace(/__USERNAME__/g, esc(username)); + res.type('html').send(html); + }); + + // Process the trial-button submission + app.post('/oauth/interaction/:uid/trial', express.urlencoded({ extended: false }), async (req, res) => { + const submitted = req.body?.csrf; + const cookieCsrf = req.cookies?.[CSRF_COOKIE]; + if (!submitted || submitted !== cookieCsrf) { + return sendError(res, 403, { + title: 'Sign-in request rejected', + message: 'The form could not be verified (CSRF mismatch). This usually means the page was opened in a different browser session than the one that started the sign-in.', + hint: RESTART_HINT, + }); + } + + // Atomic check-and-record: blocks concurrent requests from the same IP from + // both passing before either records (TOCTOU fix). + if (!claimIfAllowed(req.ip)) { + return sendError(res, 429, { + title: 'Rate limited', + message: "You've reached the unregistered-access limit from this network.", + hint: 'Please try again later.', + }); + } + + // Re-read login_hint from the interaction (don't trust the form). Server-side + // re-validates uniqueness — pre-flight check at /auth/username-available is + // advisory only; this is the race-safe boundary. + let details; + try { + details = await provider.interactionDetails(req, res); + } catch { + revokeTrialClaim(req.ip); + return sendError(res, 400, { + title: 'Sign-in session expired', + message: 'This sign-in link is no longer valid (expired or already used).', + hint: RESTART_HINT, + }); + } + const username = details.params.login_hint; + if (!isValidUsername(username)) { + revokeTrialClaim(req.ip); + return sendError(res, 400, { + title: 'Username missing or invalid', + message: 'No valid username was provided to bind this sign-in to.', + hint: RESTART_HINT, + }); + } + + // Trial user identity: random sub; no profile. + const sub = randomBytes(16).toString('hex'); + + const claim = claimUsername(username, sub); + if (claim === 'taken') { + revokeTrialClaim(req.ip); + return sendError(res, 409, { + title: 'Username already taken', + message: `${esc(username)} was claimed by another sign-in while you were on this page.`, + hint: 'Re-run codette login with a different username.', + }); + } + + try { + const grant = new provider.Grant({ accountId: sub, clientId: 'codette-cli' }); + grant.addOIDCScope('openid offline_access'); + const grantId = await grant.save(); + + return await provider.interactionFinished(req, res, { + login: { accountId: sub }, + consent: { grantId }, + }, { mergeWithLastSubmission: false }); + } catch (err) { + // Roll back the claim so the IP slot is not permanently consumed by a + // transient failure. The grant (if saved) will expire naturally. + revokeTrialClaim(req.ip); + console.error('[trial] grant/interactionFinished failed:', err); + + // SessionNotFound is by far the most common error here: the interaction + // record was wiped (server restart with OAUTH_DATA_DIR cleared, or the + // user took >10 minutes on the consent page). + const isSessionGone = err?.name === 'SessionNotFound' || + err?.error_description === 'interaction session not found'; + if (isSessionGone) { + return sendError(res, 400, { + title: 'Sign-in session expired', + message: 'The server could not find the sign-in session this form belongs to. It likely expired or the server was restarted.', + hint: RESTART_HINT, + }); + } + return sendError(res, 500, { + title: 'Something went wrong', + message: 'We could not complete the sign-in.', + hint: `${RESTART_HINT} If the problem persists, contact the server operator.`, + }); + } + }); +} diff --git a/server/src/oauth/provider.js b/server/src/oauth/provider.js new file mode 100644 index 0000000..0593689 --- /dev/null +++ b/server/src/oauth/provider.js @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov +// +// node-oidc-provider configuration. Trial-only consent flow. + +import Provider from 'oidc-provider'; +import { FileAdapter } from './storage.js'; +import { lookupUsernameBySub } from './usernames.js'; +import { generateKeyPairSync, createPrivateKey } from 'crypto'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; +import { join } from 'path'; + +function loadOrGenerateKeypair() { + const DATA_DIR = process.env.OAUTH_DATA_DIR || '/data/oauth'; + const KEY_FILE = join(DATA_DIR, 'oauth-key.pem'); + mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 }); + if (existsSync(KEY_FILE)) { + try { + const priv = readFileSync(KEY_FILE, 'utf8'); + return { privJwk: createPrivateKey(priv).export({ format: 'jwk' }) }; + } catch (err) { + throw new Error(`Failed to load OAuth signing key from ${KEY_FILE}: ${err.message}`); + } + } + const kp = generateKeyPairSync('ec', { + namedCurve: 'P-256', + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + writeFileSync(KEY_FILE, kp.privateKey, { mode: 0o600 }); + return { privJwk: createPrivateKey(kp.privateKey).export({ format: 'jwk' }) }; +} + +export async function buildProvider(issuer) { + const { privJwk } = loadOrGenerateKeypair(); + const cookieSecret = process.env.COOKIE_SECRET; + if (!cookieSecret) { + const issuerIsLocalhost = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?($|\/)/.test(issuer); + if (!issuerIsLocalhost) { + console.error('[oauth] FATAL: COOKIE_SECRET must be set for non-localhost deployments'); + process.exit(1); + } + console.warn('[oauth] COOKIE_SECRET not set; using insecure dev default (localhost only)'); + } + const provider = new Provider(issuer, { + adapter: FileAdapter, + clients: [{ + client_id: 'codette-cli', + client_secret: undefined, + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + redirect_uris: [new URL('/auth/success', issuer).href], + application_type: 'native', + id_token_signed_response_alg: 'ES256', + }], + pkce: { required: () => true, methods: ['S256'] }, + features: { + devInteractions: { enabled: false }, + revocation: { enabled: true }, + introspection: { enabled: false }, + }, + interactions: { + url(ctx, interaction) { + return `/oauth/interaction/${interaction.uid}`; + }, + }, + // JWKS is used for ID tokens / session signing. + // AccessTokens are opaque random strings (jti). validateHostToken looks them + // up directly via the adapter; no JWT verification on this path. + jwks: { keys: [privJwk] }, + ttl: { + AccessToken: 7 * 24 * 60 * 60, // 7 days + RefreshToken: 7 * 24 * 60 * 60, // 7 days + AuthorizationCode: 10 * 60, // 10 minutes + Session: 24 * 60 * 60, // 24 hours + Interaction: 10 * 60, // 10 minutes + Grant: 7 * 24 * 60 * 60, // 7 days (matches refresh-token lifetime) + IdToken: 60 * 60, // 1 hour (issued alongside AccessToken) + }, + cookies: { + keys: [cookieSecret || 'dev-cookie-secret-change-me'], + }, + findAccount: async (ctx, sub) => ({ + accountId: sub, + claims: async () => ({ sub, preferred_username: lookupUsernameBySub(sub) }), + }), + }); + + return provider; +} diff --git a/server/src/oauth/provider.test.js b/server/src/oauth/provider.test.js new file mode 100644 index 0000000..7058f7a --- /dev/null +++ b/server/src/oauth/provider.test.js @@ -0,0 +1,36 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { buildProvider } from './provider.js'; + +test('only canonical /auth/success redirect URI is allowed', async () => { + const dir = mkdtempSync(join(tmpdir(), 'oauth-')); + process.env.OAUTH_DATA_DIR = dir; + try { + const p = await buildProvider('http://localhost:3000'); + const c = await p.Client.find('codette-cli'); + assert.ok(c.redirectUriAllowed('http://localhost:3000/auth/success')); + assert.ok(!c.redirectUriAllowed('http://localhost:9999/callback')); + assert.ok(!c.redirectUriAllowed('https://evil.example.com/auth/success')); + } finally { + rmSync(dir, { recursive: true }); + delete process.env.OAUTH_DATA_DIR; + } +}); + +test('redirect_uri is normalized when issuer has trailing slash', async () => { + const dir = mkdtempSync(join(tmpdir(), 'oauth-')); + process.env.OAUTH_DATA_DIR = dir; + try { + const p = await buildProvider('http://localhost:3000/'); + const c = await p.Client.find('codette-cli'); + assert.ok(c.redirectUriAllowed('http://localhost:3000/auth/success')); + assert.ok(!c.redirectUriAllowed('http://localhost:3000//auth/success')); + } finally { + rmSync(dir, { recursive: true }); + delete process.env.OAUTH_DATA_DIR; + } +}); + diff --git a/server/src/oauth/storage.js b/server/src/oauth/storage.js new file mode 100644 index 0000000..37e3d44 --- /dev/null +++ b/server/src/oauth/storage.js @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov +// +// File-based OAuth state adapter for node-oidc-provider. +// Each model (AuthorizationCode, AccessToken, RefreshToken, Grant, Session, ...) +// gets a separate JSON file under $OAUTH_DATA_DIR. Atomic writes via tmp+rename. + +import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs'; +import { join } from 'path'; + +function dataDir() { return process.env.OAUTH_DATA_DIR || '/data/oauth'; } + +function ensureDir() { try { mkdirSync(dataDir(), { recursive: true, mode: 0o700 }); } catch {} } + +function load(model) { + try { return JSON.parse(readFileSync(join(dataDir(), model + '.json'), 'utf8')); } + catch { return {}; } +} + +function save(model, data) { + ensureDir(); + const path = join(dataDir(), model + '.json'); + const tmp = path + '.tmp'; + writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 }); + renameSync(tmp, path); +} + +export class FileAdapter { + constructor(name) { this.name = name; } + + async upsert(id, payload, expiresIn) { + const data = load(this.name); + data[id] = { payload, expiresAt: Date.now() + expiresIn * 1000 }; + if (payload.grantId) data[id].grantId = payload.grantId; + if (payload.userCode) data[id].userCode = payload.userCode; + if (payload.uid) data[id].uid = payload.uid; + save(this.name, data); + } + + async find(id) { + const data = load(this.name); + const entry = data[id]; + if (!entry) return undefined; + if (entry.expiresAt < Date.now()) { + delete data[id]; + save(this.name, data); + return undefined; + } + return entry.payload; + } + + async findByUserCode(userCode) { + const data = load(this.name); + for (const [id, entry] of Object.entries(data)) { + if (entry.userCode === userCode) return this.find(id); + } + return undefined; + } + + async findByUid(uid) { + const data = load(this.name); + for (const [id, entry] of Object.entries(data)) { + if (entry.uid === uid) return this.find(id); + } + return undefined; + } + + async consume(id) { + const data = load(this.name); + if (data[id]) { + data[id].payload.consumed = Math.floor(Date.now() / 1000); + save(this.name, data); + } + } + + async destroy(id) { + const data = load(this.name); + delete data[id]; + save(this.name, data); + } + + async revokeByGrantId(grantId) { + const data = load(this.name); + let changed = false; + for (const id of Object.keys(data)) { + if (data[id].grantId === grantId) { + delete data[id]; + changed = true; + } + } + if (changed) save(this.name, data); + } +} diff --git a/server/src/oauth/storage.test.js b/server/src/oauth/storage.test.js new file mode 100644 index 0000000..e5476e1 --- /dev/null +++ b/server/src/oauth/storage.test.js @@ -0,0 +1,38 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { FileAdapter } from './storage.js'; +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +test('upsert + find round-trip', async () => { + const dir = mkdtempSync(join(tmpdir(), 'oauth-')); + process.env.OAUTH_DATA_DIR = dir; + try { + const a = new FileAdapter('AccessToken'); + await a.upsert('id1', { sub: 'u1', grantId: 'g1' }, 60); + const found = await a.find('id1'); + assert.equal(found.sub, 'u1'); + } finally { + rmSync(dir, { recursive: true }); + delete process.env.OAUTH_DATA_DIR; + } +}); + +test('revokeByGrantId removes all entries for grantId', async () => { + const dir = mkdtempSync(join(tmpdir(), 'oauth-')); + process.env.OAUTH_DATA_DIR = dir; + try { + const a = new FileAdapter('AccessToken'); + await a.upsert('id1', { sub: 'u1', grantId: 'g1' }, 60); + await a.upsert('id2', { sub: 'u2', grantId: 'g1' }, 60); + await a.upsert('id3', { sub: 'u3', grantId: 'g2' }, 60); + await a.revokeByGrantId('g1'); + assert.equal(await a.find('id1'), undefined); + assert.equal(await a.find('id2'), undefined); + assert.ok(await a.find('id3')); + } finally { + rmSync(dir, { recursive: true }); + delete process.env.OAUTH_DATA_DIR; + } +}); diff --git a/server/src/oauth/trial.js b/server/src/oauth/trial.js new file mode 100644 index 0000000..1355e00 --- /dev/null +++ b/server/src/oauth/trial.js @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov +// +// Trial claim rate limiting. Tracks successful claims per IP in a sliding window. +// Persists to $OAUTH_DATA_DIR/trial-claims.json. + +import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs'; +import { join } from 'path'; + +function file() { + const dir = process.env.OAUTH_DATA_DIR || '/data/oauth'; + return join(dir, 'trial-claims.json'); +} + +function loadAll() { + try { return JSON.parse(readFileSync(file(), 'utf8')); } catch { return {}; } +} + +function saveAll(data) { + mkdirSync(process.env.OAUTH_DATA_DIR || '/data/oauth', { recursive: true, mode: 0o700 }); + const path = file(); + const tmp = path + '.tmp'; + writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 }); + renameSync(tmp, path); +} + +const DEFAULT_MAX = 5; +const DEFAULT_WINDOW_MS = 15 * 24 * 60 * 60 * 1000; + +function maxClaims() { return parseInt(process.env.TRIAL_MAX_CLAIMS || String(DEFAULT_MAX), 10); } +function windowMs() { return parseInt(process.env.TRIAL_WINDOW_MS || String(DEFAULT_WINDOW_MS), 10); } + +function pruneIp(claims, ip) { + const cutoff = Date.now() - windowMs(); + claims[ip] = (claims[ip] || []).filter(t => t > cutoff); + if (claims[ip].length === 0) delete claims[ip]; +} + +export function checkTrialRateLimit(ip) { + const claims = loadAll(); + pruneIp(claims, ip); + return (claims[ip] || []).length < maxClaims(); +} + +export function recordTrialClaim(ip) { + const claims = loadAll(); + pruneIp(claims, ip); + if (!claims[ip]) claims[ip] = []; + claims[ip].push(Date.now()); + saveAll(claims); +} + +// Atomic check-and-record: loads, prunes, checks the limit, and appends in one +// synchronous read-modify-write cycle. Returns true if the claim was recorded +// (allowed), false if blocked. Prevents TOCTOU races from concurrent requests. +export function claimIfAllowed(ip) { + const claims = loadAll(); + pruneIp(claims, ip); + if ((claims[ip] || []).length >= maxClaims()) return false; + if (!claims[ip]) claims[ip] = []; + claims[ip].push(Date.now()); + saveAll(claims); + return true; +} + +// Revoke the most recent claim for an IP. Used to roll back a claim when the +// downstream grant save or interactionFinished fails after claimIfAllowed +// already recorded the timestamp. +export function revokeTrialClaim(ip) { + const claims = loadAll(); + pruneIp(claims, ip); + if (!claims[ip] || claims[ip].length === 0) return; + claims[ip].pop(); + if (claims[ip].length === 0) delete claims[ip]; + saveAll(claims); +} + +// Test seam — file-backed implementation has no in-memory state to clear, but +// the symbol is exported for symmetry with cache-based variants. +export function _resetForTest() { /* no-op */ } diff --git a/server/src/oauth/trial.test.js b/server/src/oauth/trial.test.js new file mode 100644 index 0000000..43c02ef --- /dev/null +++ b/server/src/oauth/trial.test.js @@ -0,0 +1,49 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { checkTrialRateLimit, recordTrialClaim, _resetForTest } from './trial.js'; + +test('first claim from an IP is allowed', async () => { + const dir = mkdtempSync(join(tmpdir(), 'oauth-')); + process.env.OAUTH_DATA_DIR = dir; + _resetForTest(); + try { + assert.equal(checkTrialRateLimit('1.2.3.4'), true); + } finally { + rmSync(dir, { recursive: true }); + delete process.env.OAUTH_DATA_DIR; + } +}); + +test('after 5 claims, 6th from same IP is blocked', async () => { + const dir = mkdtempSync(join(tmpdir(), 'oauth-')); + process.env.OAUTH_DATA_DIR = dir; + _resetForTest(); + try { + for (let i = 0; i < 5; i++) recordTrialClaim('5.5.5.5'); + assert.equal(checkTrialRateLimit('5.5.5.5'), false); + assert.equal(checkTrialRateLimit('6.6.6.6'), true); + } finally { + rmSync(dir, { recursive: true }); + delete process.env.OAUTH_DATA_DIR; + } +}); + +test('claims older than the window expire', async () => { + const dir = mkdtempSync(join(tmpdir(), 'oauth-')); + process.env.OAUTH_DATA_DIR = dir; + process.env.TRIAL_WINDOW_MS = '1'; + _resetForTest(); + try { + recordTrialClaim('7.7.7.7'); + await new Promise(r => setTimeout(r, 10)); + assert.equal(checkTrialRateLimit('7.7.7.7'), true); + } finally { + rmSync(dir, { recursive: true }); + delete process.env.OAUTH_DATA_DIR; + delete process.env.TRIAL_WINDOW_MS; + } +}); + diff --git a/server/src/oauth/usernames.js b/server/src/oauth/usernames.js new file mode 100644 index 0000000..3b1d3fc --- /dev/null +++ b/server/src/oauth/usernames.js @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov +// +// Username ↔ OAuth-sub binding. The host CLI prompts for a username locally +// (host's authority). At consent time the server records {username → sub, +// sub → username} so subsequent /host WS connections can verify that the +// token-bearer's claimed clientUsername matches the binding (squatting fix). +// +// First-to-claim wins; once a (username, sub) pair is recorded the username +// is locked to that sub even after the token expires. Re-using the same sub +// is idempotent ('reclaimed'). + +import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs'; +import { join } from 'path'; + +function file() { + const dir = process.env.OAUTH_DATA_DIR || '/data/oauth'; + return join(dir, 'username-owners.json'); +} + +function load() { + try { return JSON.parse(readFileSync(file(), 'utf8')); } + catch { return { byName: {}, bySub: {} }; } +} + +function save(data) { + mkdirSync(process.env.OAUTH_DATA_DIR || '/data/oauth', { recursive: true, mode: 0o700 }); + const path = file(); + const tmp = path + '.tmp'; + writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 }); + renameSync(tmp, path); +} + +const NAME_RE = /^[a-z][a-z0-9_-]{1,31}$/; + +export function isValidUsername(name) { + return typeof name === 'string' && NAME_RE.test(name); +} + +export function isUsernameClaimed(name) { + if (!isValidUsername(name)) return false; + return !!load().byName[name]; +} + +export function lookupUsernameBySub(sub) { + return load().bySub[sub] || null; +} + +export function lookupOwnerByUsername(name) { + return load().byName[name] || null; +} + +// Atomic claim. Returns 'claimed' (newly bound), 'reclaimed' (already bound +// to this sub — idempotent), or 'taken' (owned by a different sub). +export function claimUsername(name, sub) { + if (!isValidUsername(name)) return 'invalid'; + const data = load(); + const existing = data.byName[name]; + if (existing && existing.sub === sub) return 'reclaimed'; + if (existing) return 'taken'; + data.byName[name] = { sub, claimedAt: Date.now() }; + data.bySub[sub] = name; + save(data); + return 'claimed'; +} diff --git a/server/src/oauth/views/consent.html b/server/src/oauth/views/consent.html new file mode 100644 index 0000000..aaf6ea4 --- /dev/null +++ b/server/src/oauth/views/consent.html @@ -0,0 +1,97 @@ + + + + + + + +codette — sign in + + + +
+
codette
+

Sign in as __USERNAME__.
No account required for 7 days.

+
+ + +
+
+ + diff --git a/server/src/oauth/views/error.html b/server/src/oauth/views/error.html new file mode 100644 index 0000000..2558daa --- /dev/null +++ b/server/src/oauth/views/error.html @@ -0,0 +1,103 @@ + + + + + + + +codette — sign in + + + +
+
codette
+

__TITLE__

+

__MESSAGE__

+
__HINT__
+
+ + diff --git a/server/src/oauth/views/success.html b/server/src/oauth/views/success.html new file mode 100644 index 0000000..06f8d29 --- /dev/null +++ b/server/src/oauth/views/success.html @@ -0,0 +1,215 @@ + + + + + + + +codette — sign in + + + +
+
codette
+

Trying to reach codette on your machine…

+ +
+ + + diff --git a/tests/e2e-oauth-login.spec.js b/tests/e2e-oauth-login.spec.js new file mode 100644 index 0000000..41fde80 --- /dev/null +++ b/tests/e2e-oauth-login.spec.js @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov +// +// E2E: simulate `codette login` (PKCE + localhost callback) by driving a browser +// through /oauth/auth and confirming the CLI's local listener receives the code. + +import { test, expect } from '@playwright/test'; +import { createServer } from 'http'; +import { randomBytes } from 'crypto'; +import { b64url, generatePKCE } from './oauth-flow.js'; + +const TEST_PORT = process.env.TEST_PORT || '3111'; +const SERVER_BASE = `http://localhost:${TEST_PORT}`; + +test('OAuth trial flow: browser click → access_token in callback', async ({ page }) => { + // Unique username per run — server-side binding records persist across the + // test suite's lifetime (within the per-run OAUTH_DATA_DIR isolation in + // start-test-env.js), so we can't reuse a name. + const username = 'e2e-' + randomBytes(4).toString('hex'); + + // Start a localhost listener (the "CLI" in this test) + const cliPort = 39000 + Math.floor(Math.random() * 1000); + const codePromise = new Promise((resolve) => { + const srv = createServer((req, res) => { + const u = new URL(req.url, `http://localhost:${cliPort}`); + if (u.pathname === '/callback') { + res.writeHead(200, { 'access-control-allow-origin': '*' }).end('ok'); + srv.close(); + resolve(u.searchParams.get('code')); + } else { res.writeHead(404).end(); } + }); + srv.listen(cliPort, '127.0.0.1'); + }); + + // PKCE + const { verifier, challenge } = generatePKCE(); + const state = b64url(JSON.stringify({ port: cliPort, nonce: '0123456789abcdef' })); + const redirectUri = `${SERVER_BASE}/auth/success`; + + const authUrl = `${SERVER_BASE}/oauth/auth?` + new URLSearchParams({ + response_type: 'code', + client_id: 'codette-cli', + redirect_uri: redirectUri, + code_challenge: challenge, + code_challenge_method: 'S256', + state, + scope: 'openid offline_access', + prompt: 'consent', + login_hint: username, + }); + + await page.goto(authUrl); + await expect(page.locator('.brand', { hasText: 'codette' })).toBeVisible(); + await expect(page.locator('.uname', { hasText: username })).toBeVisible(); + await page.locator('button', { hasText: /without registration/ }).click(); + + // After click: browser lands on /auth/success which JS-fetches the localhost callback + await expect(page.locator('.brand', { hasText: 'codette' })).toBeVisible(); + + // Localhost listener should receive the code + const code = await Promise.race([ + codePromise, + new Promise((_, rej) => setTimeout(() => rej(new Error('listener timeout')), 10_000)), + ]); + expect(code).toBeTruthy(); + + // Exchange code for tokens + const tokenRes = await fetch(`${SERVER_BASE}/oauth/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + client_id: 'codette-cli', + redirect_uri: redirectUri, + code_verifier: verifier, + }), + }); + expect(tokenRes.ok).toBe(true); + const tokens = await tokenRes.json(); + expect(tokens.access_token).toBeTruthy(); + expect(tokens.refresh_token).toBeTruthy(); +}); diff --git a/tests/oauth-flow.js b/tests/oauth-flow.js new file mode 100644 index 0000000..33b9ac4 --- /dev/null +++ b/tests/oauth-flow.js @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Danylo Lykov +// +// Programmatic OAuth Authorization Code + PKCE flow, used by the test harness +// to mint access_tokens without spawning a browser. The CSRF-protected +// interaction handler requires a cookie jar; we maintain one manually. + +import { randomBytes, createHash } from 'crypto'; + +export function b64url(buf) { + return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +export function generatePKCE() { + const verifier = b64url(randomBytes(32)); + const challenge = b64url(createHash('sha256').update(verifier).digest()); + return { verifier, challenge }; +} + +// A tiny cookie jar — collects Set-Cookie values and produces the Cookie header. +function makeJar() { + const jar = new Map(); // name → value + return { + consumeResponse(headers) { + // node-fetch / undici exposes setCookie via .getSetCookie() (Node 20+) + const setCookies = typeof headers.getSetCookie === 'function' + ? headers.getSetCookie() + : (headers.get('set-cookie') ? [headers.get('set-cookie')] : []); + for (const sc of setCookies) { + const [pair] = sc.split(';'); + const eq = pair.indexOf('='); + if (eq < 0) continue; + const name = pair.slice(0, eq).trim(); + const value = pair.slice(eq + 1).trim(); + if (value === '') jar.delete(name); + else jar.set(name, value); + } + }, + header() { + if (jar.size === 0) return undefined; + return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; '); + }, + get(name) { return jar.get(name); }, + }; +} + +/** + * Run a full headless OAuth dance against the given server. + * `username` (default: a unique random name) is bound to the issued token. + * Returns { access_token, refresh_token, expires_in, token_type, username }. + */ +export async function mintAccessToken({ serverBase, port = 0, username }) { + if (!username) username = 'test-' + randomBytes(6).toString('hex'); + const { verifier, challenge } = generatePKCE(); + const state = b64url(JSON.stringify({ port, nonce: randomBytes(8).toString('hex') })); + const redirectUri = new URL('/auth/success', serverBase).href; + + const jar = makeJar(); + const fetchWithJar = async (url, init = {}) => { + const headers = { ...(init.headers || {}) }; + const cookie = jar.header(); + if (cookie) headers.cookie = cookie; + const res = await fetch(url, { ...init, headers, redirect: 'manual' }); + jar.consumeResponse(res.headers); + return res; + }; + + // 1. /oauth/auth → 303 to /oauth/interaction/ + const authUrl = `${serverBase}/oauth/auth?` + new URLSearchParams({ + response_type: 'code', + client_id: 'codette-cli', + redirect_uri: redirectUri, + code_challenge: challenge, + code_challenge_method: 'S256', + state, + scope: 'openid offline_access', + prompt: 'consent', + login_hint: username, + }); + let res = await fetchWithJar(authUrl); + if (res.status !== 303 && res.status !== 302) { + throw new Error(`expected 303 from /oauth/auth, got ${res.status}: ${await res.text()}`); + } + const interactionUrl = new URL(res.headers.get('location'), serverBase).href; + + // 2. GET /oauth/interaction/ → consent HTML + CSRF cookie + res = await fetchWithJar(interactionUrl); + if (!res.ok) throw new Error(`interaction GET failed: ${res.status}`); + const html = await res.text(); + const csrfMatch = html.match(/name="csrf"\s+value="([^"]+)"/); + if (!csrfMatch) throw new Error('CSRF token not found in consent page'); + const csrf = csrfMatch[1]; + + // 3. POST /oauth/interaction//trial → 303 to /oauth/auth/ + const trialUrl = `${interactionUrl}/trial`; + res = await fetchWithJar(trialUrl, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ csrf }), + }); + if (res.status !== 303 && res.status !== 302) { + throw new Error(`expected 303 from /trial, got ${res.status}: ${await res.text()}`); + } + const resumeUrl = new URL(res.headers.get('location'), serverBase).href; + + // 4. GET /oauth/auth/ → 303 to /auth/success?code=... + res = await fetchWithJar(resumeUrl); + if (res.status !== 303 && res.status !== 302) { + throw new Error(`expected 303 from resume, got ${res.status}: ${await res.text()}`); + } + const finalUrl = new URL(res.headers.get('location'), serverBase); + const code = finalUrl.searchParams.get('code'); + if (!code) throw new Error(`no code in final redirect: ${finalUrl.href}`); + + // 5. POST /oauth/token + const tokenRes = await fetch(`${serverBase}/oauth/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + client_id: 'codette-cli', + redirect_uri: redirectUri, + code_verifier: verifier, + }), + }); + if (!tokenRes.ok) throw new Error(`token exchange failed: ${tokenRes.status} ${await tokenRes.text()}`); + const tokens = await tokenRes.json(); + return { ...tokens, username }; +} diff --git a/tests/start-test-env.js b/tests/start-test-env.js index 99b425e..1d07f2c 100644 --- a/tests/start-test-env.js +++ b/tests/start-test-env.js @@ -12,8 +12,9 @@ import { spawn } from 'child_process'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; -import { mkdirSync, symlinkSync, lstatSync, openSync, readFileSync, rmSync } from 'fs'; +import { mkdirSync, symlinkSync, openSync, readFileSync, rmSync } from 'fs'; import { homedir } from 'os'; +import { mintAccessToken } from './oauth-flow.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, '..'); @@ -21,17 +22,17 @@ const root = join(__dirname, '..'); const PORT = process.env.TEST_PORT || '3111'; const USERNAME = process.env.TEST_USERNAME || 'testuser'; const PASSWORD = process.env.TEST_PASSWORD || 'testpass'; -// Server refuses to start with the placeholder HOST_KEY; use a fixed test -// value so tests are deterministic but the server's safety check still works. -const HOST_KEY = process.env.HOST_KEY || 'test-host-key-do-not-use-in-prod'; // ── Isolated data dir (like run_dev.sh) ────────────────────────────────────── const dataDir = join(root, '.dev-data', USERNAME); const claudeDir = join(dataDir, '.claude'); +const oauthDataDir = join(dataDir, 'oauth'); +const cookieSecret = 'test-cookie-secret-' + USERNAME; // Clean slate: remove old session data but preserve credentials symlink rmSync(dataDir, { recursive: true, force: true }); mkdirSync(claudeDir, { recursive: true }); +mkdirSync(oauthDataDir, { recursive: true }); const credSrc = join(homedir(), '.claude', '.credentials.json'); const credDst = join(claudeDir, '.credentials.json'); @@ -60,7 +61,14 @@ const hostLogFd = openSync('/tmp/e2e-host.log', 'w'); // ── Start server ────────────────────────────────────────────────────────────── server = spawn('node', ['server/src/index.js'], { cwd: root, - env: { ...process.env, PORT, HOST_KEY }, + env: { + ...process.env, + PORT, + OAUTH_DATA_DIR: oauthDataDir, + COOKIE_SECRET: cookieSecret, + PUBLIC_URL: `http://localhost:${PORT}`, + SERVER_HOSTNAME: `localhost:${PORT}`, + }, stdio: ['ignore', serverLogFd, serverLogFd], }); @@ -92,6 +100,18 @@ try { process.exit(1); } +// Mint an access token via the headless OAuth dance so the host can connect +// without needing a saved refresh_token on disk. +let tokens; +try { + tokens = await mintAccessToken({ serverBase: `http://localhost:${PORT}`, username: USERNAME }); + console.log(`[test-env] OAuth dance succeeded; got access_token (len=${tokens.access_token.length})`); +} catch (e) { + console.error(`[test-env] OAuth dance failed: ${e.message}`); + cleanup(); + process.exit(1); +} + const hostEnv = { ...process.env, SERVER_URL: `ws://localhost:${PORT}`, @@ -99,11 +119,11 @@ const hostEnv = { CLIENT_PASSWORD: PASSWORD, CODETTE_DATA_HOME: dataDir, CLAUDE_CONFIG_DIR: claudeDir, - HOST_KEY, + CODETTE_ACCESS_TOKEN: tokens.access_token, }; delete hostEnv.CLAUDECODE; // allow Claude Code to spawn inside test env -const hostArgs = ['host/index.js']; +const hostArgs = ['host/index.js', '--server', `ws://localhost:${PORT}`, '--username', USERNAME]; if (process.env.TEST_BACKEND) hostArgs.push('--backend', process.env.TEST_BACKEND); host = spawn('node', hostArgs, {