Skip to content

Authenticate MCP requests with per-user API tokens - #1943

Open
plusky wants to merge 13 commits into
SUSE:masterfrom
plusky:mcp-tokens
Open

plusky wants to merge 13 commits into
SUSE:masterfrom
plusky:mcp-tokens

Conversation

@plusky

@plusky plusky commented Oct 13, 2026 •

Copy link
Copy Markdown

Headless MCP clients cannot complete the browser login, so users mint per-client Bearer secrets in profile settings (stacked on #1942). Only a SHA256 digest reaches the database; the raw secret renders once with Cache-Control: no-store. A present Bearer owns the request (the session is ignored), forgery protection is skipped only for authenticated tokens, forged posts answer exactly like a guest, and new tokens expire after a year. The token scope travels in the server context for the write tools of the next stack. Split from #1939, where this code was already reviewed.

To verify:

  • bundle exec rspec and bundle exec rubocop
  • Log in, mint a token in profile settings, then tools/call server_info with Authorization: Bearer ; junk, revoked and expired secrets answer as guest
  • DEPLOY NOTE: requires database migration (api_tokens table plus token expiry); run db:migrate on deploy.

@plusky
plusky force-pushed the mcp-tokens branch 4 times, most recently from ce25546 to 79889b4 Compare October 16, 2026 13:44
plusky added 13 commits October 16, 2026 17:00
The official Ruby SDK for the Model Context Protocol powers the new machine interface. Only the server half is used, over the per-request controller transport, so no Rails version pin is needed.

Assisted-by: opencode/GLM-5.3-Flash
POST /mcp speaks Streamable HTTP through a per-request server, so multiple Puma workers need no shared session state. The caller authenticates with the normal login identity; Bearer tokens arrive with the token stack. Tools receive the user as server context and answer guests without leaking email.

Assisted-by: opencode/GLM-5.3-Flash
The episode listing tool authorizes through the same Ability as the HTML pages, and without a read rule every non-admin is denied. This grants Episode read alongside the other public listings, which also makes the episode index and show pages - already authentication-skipped in the controller - actually readable. Split out from the endpoint commit on review feedback.

Assisted-by: opencode/GLM-5.3-Flash
List, fetch, LIKE-search and episode listing go through the same Ability as the HTML pages, with a uniform denial that reveals nothing about missing versus forbidden records. Search stays clear of Sphinx so the default suite needs no searchd.

Assisted-by: opencode/GLM-5.3-Flash
The search tool runs a full-table LIKE per request, so unbounded clients can saturate the database. Throttle to 300 requests per 5 minutes per IP; production shares the file cache across Puma workers, the spec swaps in a memory store because test runs null_store.

Assisted-by: opencode/GLM-5.3-Flash
The only tool without a bound; episodes are few today but the endpoint should not serialize unbounded relations.

Assisted-by: opencode/GLM-5.3-Flash
Satisfies Style/ClassAndModuleChildren; no behavior change.

Assisted-by: opencode/GLM-5.3-Flash
Headless MCP clients cannot complete the browser login, so users mint per-client Bearer secrets in the web UI. Only a SHA256 digest hits the database; the raw secret renders once at mint time and is never stored or logged. Scopes separate read clients from write clients; revocation is an instant timestamp.

Assisted-by: opencode/GLM-5.3-Flash
Token management lives in profile settings and binds strictly to the requester, so even admins cannot mint credentials for somebody else. Creation caps at 50 tokens per user and the minted secret page carries Cache-Control: no-store.

Assisted-by: opencode/GLM-5.3-Flash
A present Bearer header owns the request: the session is ignored, so a junk secret degrades to guest instead of inheriting the cookie identity. Forgery protection is skipped only for authenticated tokens, authentication runs once per request, and last_used_at refreshes at most once a minute. Scope travels in the server context for the write tools of the next stack.

Assisted-by: opencode/GLM-5.3-Flash
A stolen bearer was valid until manual revoke. New tokens expire a year after mint; nil means never for tokens minted before this change. The UI shows the date; authentication rejects the expired.

Assisted-by: opencode/GLM-5.3-Flash
With forgery protection on, a junk bearer plus session cookie raised an HTML error instead of the uniform guest denial, fingerprinting sessions. Re-run the dispatch with a guest identity so the bytes match. Also drops two dead McpAuth wrappers nobody calls.

Assisted-by: opencode/GLM-5.3-Flash
Left over from extracting the dispatch helper; no behavior change.

Assisted-by: opencode/GLM-5.3-Flash
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