feat: OAuth 2.1 login, org/key management, and a local MCP server - #19
Conversation
Add the auth MVP and a local MCP server, both built on the same auth host.
Auth (internal/console, cmd/{login,auth,switch,orgs,keysetup,keys,config}):
- OAuth 2.1 authorization code + PKCE (S256) login over a 127.0.0.1 loopback
redirect, state + RFC 9207 iss checks, 2-minute timeout.
- Session manager: single-flight refresh with rotation, absolute expiry from
expires_in, renew ~60s early. A 401 session_expired refreshes once and retries
once; a 401 invalid_session is terminal without a refresh.
- Two credentials kept strictly apart by host: user session (auth host only) and
org-scoped API key (product host only).
- Org switching, selector resolution (id / name / unique prefix), and API-key
setup during login (create, or adopt an existing key, gated on key existence).
- Config v2 (TOML, 0600, atomic writes) with lossless migration of a legacy
top-level api_key; config show/path/drop-legacy-key.
MCP server (cmd/mcp.go, internal/mcp):
- `tabstack mcp` runs a local Model Context Protocol server over stdio using the
official go-sdk, exposing extract/generate/automate/research, the local schema
store (read-only), and read-only account context as tools.
- Product tools use the resolved API key; if none is stored but a session
exists, one is minted for the active org on startup. Management tools use the
session. stdout carries JSON-RPC only; diagnostics go to stderr.
- Streaming tools forward SSE events as MCP progress notifications and aggregate
the final answer plus in-band failure.
Docs and tooling: README (install via install.sh, MCP + Claude Desktop setup),
CLAUDE.md architecture notes, and scripts/smoke-mcp.sh (make smoke-mcp) driving
the stdio handshake. make lint and make test pass.
Adoption of an existing key was only reachable during login/switch when no key was stored. `tabstack keys use [key-id]` exposes it as its own command and, unlike the login path, replaces an already-stored key. With no id it adopts the sole candidate or prompts; with an id it adopts directly (non-interactive). Refactor the shared logic in cmd/keysetup.go: adoptExistingKey becomes adoptKey(orgID, keyID) with a chooseKey helper (exact id-match, or sole-candidate/prompt selection).
srbiv
left a comment
There was a problem hiding this comment.
Three small notes inline, none blocking.
| form.Set("code", code) | ||
| form.Set("redirect_uri", redirectURI) | ||
| form.Set("client_id", ClientID) | ||
| form.Set("code_verifier", verifier) |
There was a problem hiding this comment.
Small one: the token endpoint accepts an optional label here, and it's what the sessions list uses to name each device. Without it the server falls back to the User-Agent, so every session shows up as Go-http-client/1.1 and you can't tell a laptop from a CI box when deciding which one to revoke. Adding form.Set("label", hostname) gets you mac.lan instead.
| // SessionManager hands out a valid access token, refreshing when it has | ||
| // expired, and persists the rotated refresh token. | ||
| // | ||
| // Refresh is single-flight: concurrent callers that arrive during a refresh wait |
There was a problem hiding this comment.
This is exactly right within a process, and worth calling out because it's easy to miss. The one gap is across processes: with the MCP server running in Claude Desktop and someone also using the CLI in a terminal, two processes share the stored session, so when the hour-long access token expires both may refresh and one loses. It surfaces as "session expired, run tabstack auth login" when nothing is actually wrong. If it's useful, one option is to re-read the config on invalid_grant and retry once when the stored refresh token has changed, since that means a sibling already rotated it successfully.
|
|
||
| var payload struct { | ||
| Error string `json:"error"` | ||
| Message string `json:"message"` |
There was a problem hiding this comment.
The console sends its detail in error_description rather than message, so this drops the useful text. The case people will actually hit is a duplicate key name: keys create --name laptop twice returns 422 with "Name has already been taken for this organization", but it currently renders as console error (422): invalid_request. Reading error_description as a fallback covers it.
srbiv
left a comment
There was a problem hiding this comment.
One more, same category as the others — small and non-blocking.
| ReadHeaderTimeout: 10 * time.Second, | ||
| } | ||
| go func() { _ = srv.Serve(ln) }() | ||
| defer func() { _ = srv.Close() }() |
There was a problem hiding this comment.
Worth swapping Close() for Shutdown() here, with an explicit Flush() in the handler after writing the page. The handler writes the body and signals the channel, but the response only flushes when the handler returns, so Close() can tear the connection down mid-write and the browser gets a reset instead of the page. On the happy path the token exchange usually buys enough time; it's the failure path that bites, since a fast exchange error returns in milliseconds and that's exactly when someone most wants to see the "Sign-in failed" page. The terminal still prints the real error either way, so it's cosmetic, just a confusing kind of cosmetic.
- Send a `label` (hostname) on the token exchange so sessions are legible in `auth sessions` instead of showing the Go User-Agent. - decodeError falls back to `error_description` for the message, so console detail (e.g. "Name has already been taken for this organization") is kept instead of collapsing to the bare error code. - Loopback callback page is Flushed and the server is Shutdown (not Close), so the page reaches the browser on the fast failure path rather than a reset. - Session refresh recovers from cross-process rotation: on invalid_grant, if the stored refresh token changed, a sibling (e.g. the MCP server vs the CLI) already rotated, so adopt its session or retry once with its token instead of forcing a re-login. Tests added for each; make lint and make test pass.
The loopback callback previously left the browser parked on the local page. On success it now redirects to the console (the configured auth host) via a meta refresh with a manual-link fallback, so the redirect works without JavaScript and cannot be told to close a tab it did not open. The target is only ever the auth host, never a value from the callback query, so no code or state leaks onward. The failure page still stays put so the error is readable.
Point the post-login redirect at <auth-host>/oauth/connected rather than the auth host root, so the browser lands on the dedicated connected page. Target is still built only from the configured auth host, never the callback query.
Adds the auth MVP and a local MCP server, both against the auth host (
console.tabstack.ai).Auth
127.0.0.1loopback redirect (neverlocalhost), withstate+ RFC 9207isschecks and a 2-minute timeout.openBrowser/hasDisplayare package vars so the whole flow is testable againsthttptest.expires_in, renews ~60s early. A401 session_expiredrefreshes once and retries once; a401 invalid_sessionis terminal without a refresh (they used to be conflated).consolepackage never sees a key,clientnever sees the session.auth switch, selector resolution (id / case-insensitive name / unique prefix), and API-key setup during login (create, or adopt an existing key — offered only when the org has one;--api-key-setup=existingerrors when it has none).api_key.config show/path/drop-legacy-key.MCP server
tabstack mcpruns a local Model Context Protocol server over stdio (officialmodelcontextprotocol/go-sdk), exposingextract_markdown/extract_json/generate_json,automate/research,schema_list/schema_resolve(read-only), andwhoami/list_orgs/active_org.Docs & tooling
install.sh, plus an MCP section with Claude Desktop setup.scripts/smoke-mcp.sh(make smoke-mcp) drives the stdio handshake end to end (bash 3.2-safe, offline by default, live tool call when a key is present).Verification
make lintandmake testpass. New tests cover the OAuth flow, session/401 semantics, org/key setup, and the MCP tools (in-memory transport + mocked hosts); the smoke script was run offline (13/13) and live (15/15).🤖 Generated with Claude Code