Skip to content

feat(auth): keyless OAuth (loopback PKCE) — stop requiring an API key [BLOCKED on AS client registration]#41

Open
izgorodin wants to merge 2 commits into
mainfrom
feat/oauth-keyless
Open

feat(auth): keyless OAuth (loopback PKCE) — stop requiring an API key [BLOCKED on AS client registration]#41
izgorodin wants to merge 2 commits into
mainfrom
feat/oauth-keyless

Conversation

@izgorodin

Copy link
Copy Markdown
Member

Summary

Make @mnemoverse/mcp-memory-server keyless. With no MNEMOVERSE_API_KEY, the stdio server now runs a one-time browser sign-in (OAuth 2.1 authorization-code + PKCE, loopback redirect RFC 8252), caches + silently refreshes the token in ~/.mnemoverse/tokens.json, and calls core with Authorization: Bearer. API key stays a fallback (CI / headless / self-host) and wins when set. Server still starts auth-free (tools/list works); auth resolves lazily on the first tool call.

New: src/oauth.ts. Wired in src/index.ts (apiFetch auth resolution + comment). tsc builds clean.

Why loopback (not device-code)

Verified against the live AS — auth.mnemoverse.com/.well-known/oauth-authorization-server advertises only authorization_code + refresh_token (no device_authorization_endpoint). So the loopback browser flow is the correct headless path.

🔴 BLOCKED — AS-side change required (verified, not assumed)

GET …/oauth2/authorize?client_id=mnemoverse-cli&redirect_uri=http://127.0.0.1:PORT/callback302 → error=invalid_client "client_id is required". The public CLI client is not registered.

Needed in better-auth (auth service): register a public OAuth client mnemoverse-cli (PKCE, no secret) with loopback redirect http://127.0.0.1:*/callback (and http://localhost:*/callback) allowed. The hosted AS host-allowlists DCR to claude.ai/claude.com, so this must be pre-registered server-side. This is a sensitive auth change — founder-gated, separate repo.

Test plan (e2e gated on the AS change)

  • tsc builds; server starts auth-free; tools/list works keyless
  • No-key tool call triggers the OAuth flow (opens browser, starts loopback server)
  • e2e: after AS registers mnemoverse-cli, full sign-in → token cached → write/read succeed (blocked)
  • Refresh path (expired access token → silent refresh)
  • API-key fallback unchanged

🎯 Reviewer-voice (security — please scrutinize before publish)

  • PKCE: S256 challenge from a 32-byte verifier; state generated + verified on callback.
  • Token at rest: ~/.mnemoverse/tokens.json, written 0o600.
  • No token/secret to stdout (stdout = MCP JSON-RPC); sign-in URL goes to stderr.
  • Loopback only on 127.0.0.1, ephemeral port; callback validates state + closes server; 5-min timeout.

Self-review

  • Client code complete + builds; do NOT npm publish until the AS client lands + e2e passes (this PR is draft)
  • Backward compatible (API key path unchanged)
  • Full security sieve to run right before publish (when e2e is unblocked)

🤖 Generated with Claude Code

The stdio server required a pasted MNEMOVERSE_API_KEY. Now, with no key,
it runs a one-time browser sign-in (OAuth 2.1 authorization-code + PKCE,
loopback redirect per RFC 8252), caches + silently refreshes the token in
~/.mnemoverse/tokens.json, and authorizes with Bearer. The API key stays
supported as a fallback (CI / headless / self-host) and still wins when set.

Server still starts auth-free (tools/list works); auth resolves lazily on
the first tool call.

Verified against the live AS: device_authorization grant is NOT offered
(well-known = authorization_code + refresh_token only), so loopback is the
correct browser flow. 🔴 BLOCKED on an AS-side change: GET /authorize with
client_id=mnemoverse-cli returns 302 error=invalid_client — the public CLI
client is not registered. Needs better-auth to register a public client
'mnemoverse-cli' (PKCE, no secret) with loopback redirect http://127.0.0.1:*/callback
allowed. Do NOT npm-publish until that lands and e2e sign-in is tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@izgorodin, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 7 minutes and 7 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 65943104-da84-4505-a27c-44ebdb5c0801

📥 Commits

Reviewing files that changed from the base of the PR and between 428a9c0 and 21696cf.

📒 Files selected for processing (4)
  • package.json
  • src/index.ts
  • src/oauth.ts
  • test/oauth.test.mjs

Comment @coderabbitai help to get the list of available commands.

Comment thread src/oauth.ts Fixed
… 8 blockers)

A two-wave hostile review of the keyless loopback-PKCE flow surfaced one
CRITICAL and several HIGH issues; all are now closed and unit-tested.

- CRITICAL (Windows local RCE + functional break): the old browser launcher
  `cmd /c start "" <url>` ran the OAuth URL through cmd's parser — it truncated
  the URL at the first '&' (keyless OAuth never worked on Windows) AND executed
  `&segment` as a shell command (reproduced: `&ver`). Replace with a shell-free
  `rundll32 url.dll,FileProtocolHandler <url>` passing the URL as one argv
  element; add assertSafeEndpoint() so discovery endpoints (and MNEMOVERSE_AUTH_URL)
  must be https (http only for loopback dev) — defense in depth against a
  poisoned/MITM'd discovery doc.
- HIGH: redirect_uri was rebuilt from server.address() AFTER server.close()
  (null-deref + broke OAuth exact-match) → capture redirectUri once in listen()
  and reuse it verbatim.
- HIGH: no server.on('error') → unhandled error hung the Promise → add handler
  that settles it (and timeout settles too).
- HIGH: callback tore down the listener on the FIRST request before validating
  code/state → DoS + masked CSRF + double-exchange. Rewrite: single-shot
  `settled` guard, validate code+state+Host BEFORE any teardown, invalid → 400
  and IGNORE so the legitimate redirect can still win.
- HIGH: no in-flight dedupe → concurrent first calls spawned N servers+browsers
  and raced token writes → module-level `inflight` memoizes the sign-in.
- HIGH: keyless-by-default hung CI/headless for the full timeout → fast-fail via
  browserSignInAvailable() gated on env (CI / MNEMOVERSE_NO_BROWSER), NOT isTTY
  (a stdio MCP server is non-TTY even under Claude Desktop).
- MEDIUM: secrets dir created world-traversable → mkdir 0o700 + POSIX chmod
  fallback (file stays 0o600).
- MEDIUM: raw token-endpoint error body spliced into the thrown Error →
  bounded + stripped; invalid_client special-cased to a self-explaining message.

Also: in-memory token cache (no readFileSync per tool call), AUTH_TIMEOUT_MS
env-configurable, fallback-first stderr wording. Unit tests (node --test, 8/8)
lock the single-argv browser launch (the RCE fix) + https endpoint validation.

Still draft + BLOCKED on AS: the public client `mnemoverse-cli` must be
registered on auth.mnemoverse.com before e2e. Deferred to the post-AS e2e pass:
401-driven local token invalidation (recoverability, not security).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@izgorodin

Copy link
Copy Markdown
Member Author

🔒 Two-round adversarial security review — done; hardened, still gated

Why this update: before merge I ran two hostile review waves over the keyless OAuth flow (auth = critical/dangerous). The first wave found a CRITICAL and 6×HIGH; this commit closes all of them; the second wave verified each is closed with no new critical/high regression.

Round 1 — closed (8 blockers)

  • 🔴 Windows local RCE + functional breakcmd /c start "" <url> truncated the OAuth URL at the first & (keyless never worked on Windows) and executed &segment as a shell command (reproduced with &ver). → shell-free rundll32 url.dll,FileProtocolHandler <url> (URL as one argv) + assertSafeEndpoint() https-only validation of discovery endpoints & MNEMOVERSE_AUTH_URL (defense-in-depth).
  • 🟠 server.address() after close() (null-deref + broke redirect_uri exact-match) → redirectUri captured once in listen() and reused verbatim.
  • 🟠 no server.on('error') → Promise hung → handler settles it.
  • 🟠 callback torn down on the first request before validating → DoS + masked CSRF + double-exchange → single-shot settled guard, validate code+state+Host before teardown, invalid → 400 & ignore so the real redirect wins.
  • 🟠 no in-flight dedupe → N browsers + token-write race → module-level inflight.
  • 🟠 CI/headless hung the full timeout → browserSignInAvailable() fast-fail on env (CI/MNEMOVERSE_NO_BROWSER), not isTTY (stdio servers are non-TTY even on desktop).
  • 🟡 secrets dir world-traversable → mkdir 0o700 + chmod fallback. 🟡 raw token-endpoint error body leaked → bounded/stripped + invalid_client self-explains.

Tests: node --test (8/8) lock the single-argv launch (RCE fix) + https validation. tsc + npm test green.

Round 2 — verdict: round-1 complete, no new blockers

Non-blocking follow-ups, folded into the post-AS e2e pass: 401-driven local token invalidation (recoverability, not a hole). The core-API error-body passthrough in index.ts is intentional first-party diagnostics (documented @throws) — left as-is.

Remaining gate (unchanged — still 🔴 blocked)

  1. AS: register public client mnemoverse-cli (loopback http://127.0.0.1:*/callback, S256) on auth.mnemoverse.com — currently invalid_client.
  2. e2e on Windows + POSIX (browser opens with the FULL url, callback round-trips, refresh works) + fold the 401 fix.
  3. Founder merge (explicit go) → 4. npm publish (separate explicit go).

@izgorodin izgorodin requested a review from Copilot June 25, 2026 20:16
@izgorodin izgorodin marked this pull request as ready for review June 25, 2026 20:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR makes @mnemoverse/mcp-memory-server usable without a pre-provisioned MNEMOVERSE_API_KEY by adding a lazy, loopback OAuth 2.1 authorization-code + PKCE flow that runs on first authenticated tool call, caches tokens in ~/.mnemoverse/tokens.json, and silently refreshes them thereafter (while keeping the API key path as the preferred fallback when set).

Changes:

  • Add a new src/oauth.ts module implementing discovery, browser launch (no-shell), loopback callback handling, token exchange/refresh, and token caching.
  • Wire OAuth fallback into apiFetch in src/index.ts (API key still wins when present).
  • Add unit tests for security-critical pure helpers and a npm test script.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
test/oauth.test.mjs Adds Node test-runner unit tests for browserCommand, b64url, and assertSafeEndpoint against compiled dist/ output.
src/oauth.ts Implements the keyless OAuth loopback PKCE flow, token persistence, refresh, and endpoint validation helpers.
src/index.ts Switches request auth from “API key required” to “API key else OAuth Bearer token” with lazy resolution in apiFetch.
package.json Adds a test script to build and run Node tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/oauth.ts
Comment on lines +107 to +114
mkdirSync(TOKEN_DIR, { recursive: true, mode: 0o700 });
try {
if (platform() !== "win32") chmodSync(TOKEN_DIR, 0o700);
} catch {
/* best effort — tighten a pre-existing loose dir; ignore if not permitted */
}
writeFileSync(TOKEN_FILE, JSON.stringify(t, null, 2), { mode: 0o600 });
memTokens = t;
Comment thread package.json
],
"scripts": {
"build": "tsc",
"test": "tsc && node --test test/*.test.mjs",
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.

3 participants