Skip to content

Oauth login flow#1

Open
danlkv wants to merge 14 commits into
masterfrom
oauth-login
Open

Oauth login flow#1
danlkv wants to merge 14 commits into
masterfrom
oauth-login

Conversation

@danlkv
Copy link
Copy Markdown
Owner

@danlkv danlkv commented Jun 5, 2026

No description provided.

Comment thread server/src/index.js
const ISSUER = process.env.PUBLIC_URL || `http://localhost:${PORT}`;
const oidc = await buildProvider(ISSUER);
const validateHostToken = makeValidateHostToken(oidc);
app.use(cookieParser());
danlkv and others added 13 commits June 4, 2026 21:43
Add "Host registration via OAuth (CLI login)" section to auth.spec.md
describing the PKCE flow, lifetime, rate limiting, and node-oidc-provider
as the implementation. Add /oauth/* endpoints + /auth/success to the REST
table in protocol.spec.md. Rewrite Install + Activation sections of
main.spec.md to remove HOST_KEY interpolation and introduce `codette
login`. Save implementation plan.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the per-IP HOST_KEY token mechanism with a full OAuth Authorization
Server using node-oidc-provider, mounted at /oauth.

- File-backed storage adapter for AccessToken, RefreshToken,
  AuthorizationCode, Grant, Session, Interaction (atomic tmp+rename writes
  under \$OAUTH_DATA_DIR; lazy GC on read).
- Provider config: codette-cli public client, PKCE S256 required, ES256
  signing keypair persisted to disk, COOKIE_SECRET safety gate for
  non-localhost issuers, canonical redirect_uri pinned to
  \${PUBLIC_URL}/auth/success (URL-normalized to handle trailing slash).
- Custom consent page (views/consent.html) with single trial button, CSRF
  cookie + form-value double-submit, per-IP sliding-window rate limit
  (default 5 / 15 days, env-configurable). Atomic claimIfAllowed check +
  revokeTrialClaim rollback on grant/interactionFinished failure.
- /auth/success intermediate page (views/success.html) extracts the CLI's
  localhost port from OAuth state, fetches the callback with mode:'no-cors',
  falls back to copy-paste UI on failure. Guards against missing code param.
- /host WS connection now validates ?token=<access_token> via
  provider.AccessToken.find() (validateHostToken extracted to
  oauth/host-auth.js with unit tests). Legacy HOST_KEY env handling,
  TOKEN_FILE, loadTokens/saveTokens, getOrCreateTokenForIp, and the
  HOST_KEY interpolation in /install.sh are removed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add \`codette login\` PKCE-driven OAuth flow:
- Opens browser at /oauth/auth with PKCE challenge + base64url-encoded
  state containing a free localhost port.
- Race between localhost listener for the callback and a paste prompt
  (for remote codette installs where the browser cannot reach the CLI).
- Token exchange via /oauth/token; persists {server, refresh_token,
  username, password} to ~/.config/codette/credentials.json (mode 0600).
  Username and password are the chat-domain creds for the browser HMAC
  flow; collected via interactive prompts with auto-generated defaults.

Replace the static HOST_TOKEN read from credentials.json.hostKey with
getAccessToken() at startup: exchange the persisted refresh_token for a
fresh access_token, persist the rotated refresh_token if it changed,
exit cleanly with "Run: codette login" if refresh fails. Honors
CODETTE_ACCESS_TOKEN env var as a test-env bypass.

SERVER_URL precedence updated to CLI > credentials.json > config.json >
env > default (matches main.spec.md config table). install.sh is slimmed
to deliver only the binary + config.json with server URL — no
credentials prompts, no HOST_KEY interpolation. Clean error message on
login failure (no stack dumps).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- tests/oauth-flow.js: headless OAuth Authorization Code + PKCE dance
  helper. Drives the full 5-step flow with a manual cookie jar
  (CSRF + session cookies). Returns {access_token, refresh_token, ...}.
  Used by the harness to mint host tokens without spawning a browser, and
  by the e2e spec for the token-exchange step.
- tests/e2e-oauth-login.spec.js: Playwright test that drives a real
  browser through /oauth/auth → consent page → /auth/success → the
  localhost callback fetch, then asserts the listener received the code
  and /oauth/token returns both access and refresh tokens.
- tests/start-test-env.js: drop HOST_KEY env-var passing; add
  OAUTH_DATA_DIR (per-run isolation) + COOKIE_SECRET + PUBLIC_URL +
  SERVER_HOSTNAME to the server child; mint an access token via
  mintAccessToken and pass it as CODETTE_ACCESS_TOKEN to the host.

Request scope=openid offline_access and prompt=consent explicitly in all
auth URLs, so oidc-provider's default issueRefreshToken check passes and
the trial flow always returns a refresh_token (no override needed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…DME, dev-script)

- docker-compose.yml: swap HOST_KEY/HOST_TOKEN_TTL for COOKIE_SECRET, PUBLIC_URL, SERVER_HOSTNAME
- init.sh: generate COOKIE_SECRET instead of HOST_KEY; prompt for PUBLIC_URL with localhost vs TLS default; mention `codette login` in bootstrap echo
- README.md: remove HOST_KEY rows; add OAUTH_DATA_DIR, COOKIE_SECRET, PUBLIC_URL, SERVER_HOSTNAME, TRIAL_* rows; note host obtains credentials via `codette login`
- run_dev.sh: remove HOST_KEY; add OAUTH_DATA_DIR/COOKIE_SECRET/PUBLIC_URL to server env; mint tokens via tests/oauth-flow.js and pass as CODETTE_ACCESS_TOKEN to each dev host
- doc/auth.spec.md: fix steps 2/5/6 — redirect_uri is <server>/auth/success; local port travels in base64url state; auth code arrives at CLI via success page JS fetch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Restyle the OAuth HTML pages to match the chat UI's design tokens (dark
default + light via prefers-color-scheme, mono font, accent #cc5500,
520px-wide cards).

- consent.html: rename trial button to "Try without registration for N
  days". Single brand mark + lede + button.
- success.html: paste-fallback path shows a 3-step instruction with
  <kbd>-styled \`codette login\` / "Or paste the code here:" references so
  the user knows which prompt to look for. Copy button with green-flash
  feedback (debounced timer so a second click cleanly restarts the 1.5s
  window). Successful localhost-callback delivery shows a 3-second
  countdown then redirects to /.
- error.html (new): shared error-card template used by every
  interaction.js failure path (expired/missing interaction session, CSRF
  mismatch, rate limit, grant.save / interactionFinished failure). Each
  path gets a specific title + actionable hint pointing at \`codette
  login\` recovery. SessionNotFound is detected and routed to the same
  friendly "session expired" message.
- provider.js: explicit Grant + IdToken TTLs (silences oidc-provider's
  default-TTL NOTICE warnings on startup).
- e2e test updated for the new selectors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
host/login.js: paste prompt now loops on empty input (accidental Enter
no longer fails the flow with "no code received"). Cancel affordance
baked into the prompt text — "Enter the code here (Ctrl+C to cancel):"
— so a re-prompt needs no extra nag line. Drop chatty preamble /
separator lines around the authorize URL.

run_dev_login.sh: preserve OAuth state and credentials.json between
runs by default (the persisted refresh_token stays valid; the script
skips \`codette login\` entirely when credentials are present). Add
--fresh flag to wipe both and force a new sign-in dance. Replace 17-line
login-flow explainer banner with a single status line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Audit flagged these as low-value (each tests one function in isolation
and either restates a behavior covered by a broader test or makes a
claim the assertions don't verify):

- storage: "expired entries return undefined and are GC'd" — the GC
  side-effect was never observed
- trial: "claimIfAllowed is atomic" — restated blocking, didn't verify
  atomicity
- trial: "revokeTrialClaim removes the most recent claim" — only
  verified one slot freed
- provider: "buildProvider returns an oidc-provider instance" — smoke
  test asserting library-level properties
- provider: "non-localhost issuer without COOKIE_SECRET exits" —
  monkey-patched process.exit; couples to exit mechanism not behavior

Remaining 12 unit tests cover the broader behaviors (round-trip,
rate-limit threshold, redirect-uri policy, host-auth branches) plus
the Playwright e2e exercises the full flow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the username-squatting hole flagged in Task 5. CLI is authoritative
for the username (host operator picks at \`codette login\` prompt); server
records the {username → sub} binding and enforces it on every /host WS
connect.

Flow:
- CLI passes login_hint=<username> in the /oauth/auth URL.
- Consent page renders username read-only ("Sign in as <alice>").
- Server's /oauth/interaction/:uid/trial handler re-reads login_hint, calls
  claimUsername(name, sub) atomically (file-backed mapping under
  \$OAUTH_DATA_DIR/username-owners.json). On 'taken' returns the styled
  error page and refunds the per-IP trial slot.
- findAccount populates preferred_username on the access token's claims.
- /host WS handler looks up the bound username by sub and rejects any
  clientUsername that does not match. Token is bound to exactly one name.

Adds:
- server/src/oauth/usernames.js — isValidUsername (lowercase, leading
  letter, 2–32 chars, [a-z0-9_-]), isUsernameClaimed, lookupUsernameBySub,
  claimUsername (atomic, returns claimed/reclaimed/taken/invalid).
- /auth/username-available/:name pre-flight endpoint (advisory; race
  resolved at claim time).
- Read-only username display on consent.html with accent styling.

The host's credentials.json is never overwritten by the server — CLI keeps
its own copy of the username from the prompt; server-side binding is a
parallel record used only to enforce the slot at WS connect time.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
\`codette login\` now:
- Prompts for username, validates format locally, then calls
  GET /auth/username-available/:name and re-prompts on conflict.
- Fails soft if the server returns non-JSON (older deployment without the
  endpoint): prints "(server does not support availability check —
  skipping)" and lets the consent-time check be the only authority.
- Passes login_hint=<username> in the /oauth/auth URL so the server can
  bind it at consent time and display read-only on the consent page.

Test harness updates: mintAccessToken accepts an optional username and
returns it; start-test-env.js threads the test username through. run_dev.sh
passes alice/bob as usernames for the dev fixtures. Playwright e2e
generates a unique random name per run and asserts it appears on the
consent page.

Spec: auth.spec.md updated for the new login_hint + binding flow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…-flight

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Task 6 commit shuffled SERVER_URL precedence to put credentials.json
above env vars in pursuit of "credentials.json records the canonical
server" semantics. That broke the standard expectation that env vars
override persisted config: setting CODETTE_SERVER_URL=ws://localhost:3000
to redirect a debug run had no effect when credentials.json had a saved
server URL (e.g. a deployed instance) — silently routing the CLI to the
wrong host and producing a confusing "skipping availability check, got
HTML" message.

Reorder to: CLI > env > credentials > config > default (now consistent
with CLIENT_USERNAME and CLIENT_PASSWORD).

Also:
- Print "Server: <url>" at the start of \`codette login\` so the resolved
  URL is visible before any HTTP traffic.
- Install SIGINT/SIGTERM handlers in the login subcommand block so Ctrl+C
  during the prompt exits cleanly (no "unsettled top-level await" warning).
- doc/main.spec.md precedence statement updated to match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reject the pending ask() promise on readline SIGINT (PromptAborted) and
catch it in the dispatch try/catch. Settling the awaited promise avoids
Node's "Detected unsettled top-level await" warning that previously fired
when the user aborted the prompt.

(Note: the warning persists in some non-TTY signal-injection test paths
because readline's SIGINT event only fires for terminal-delivered Ctrl+C.
In an interactive terminal — the actual use case — the handler runs and
the await settles cleanly.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…server-only

run_dev.sh learns a --server-only flag (or SERVER_ONLY=1 env): start only
the server, no alice/bob host spawns, tail the server log. Used by
run_dev_login.sh which now owns just the login UX dance — isolated \$HOME,
the interactive codette login call, and the host spawn.

Removes the duplicated env-var setup, cleanup trap, stale-pid kill,
client build, and server-startup boilerplate from run_dev_login.sh
(was 148 lines, now 76). Total dev-script LOC: 248 → 176.

run_dev_login.sh also reuses an existing server on :3000 when one is
already up — so you can run \`./run_dev.sh\` in one terminal and
\`./run_dev_login.sh\` in another to drive the login flow without
restarting the server.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants