Repository navigation
Add mcp-oauth-console: OAuth sign-in for MCP servers - #77
Microbiosis wants to merge 5 commits into
Conversation
0fb4272 to
f543139
Compare
MyPrototypeWhat
left a comment
There was a problem hiding this comment.
Reviewed at f543139.
Thanks for this. The dual status model, device-code preference when the Host owns the loopback port, keeping tokens off the page, and the surgical Authorization patch are all solid. npm run check reports the package OK on this head, and GitHub Actions is green. A few things need to change before merge.
Must fix
-
miniapp/node/errors.mjsis imported but not in the package. Both the entry and the domain service import it (server.mjs:16→describeServiceError/isKnownServiceError;oauth-service.mjs:21→serviceError), and the client comments point at it too (app.js:36). The file is not among the 15 paths in this PR (confirmed withgit ls-treeonf543139and a directimport()ofoauth-service.mjs, which fails withCannot find module '.../errors.mjs').scripts/validate.mjsonly checks manifests, so it still prints OK. As shipped,start()cannot load. Please add the missing module (catalog of codes → HTTP status + user-facing message, plusserviceError/isKnownServiceError), or fold that catalog into an existing Node file and drop the dead imports. -
Credential paths must not follow
..in the server name.loadCredential/saveCredentialbuild the path asjoin(credentialDir, \${serverName}.json`)(oauth-service.mjs:570,:593).path.joinresolves.., so a name like../..//xlands outside this plugin'scontext.dataDir(and can touch another plugin's directory under the same parent). The authorize / device / apply routes takeserverNamefrom the request body (server.mjs:212,:227,:262), including names that are not yet in the Host MCP config, so this is reachable from the page's "add server" flow. Please restrict names the same way other packages do (for example^[A-Za-z0-9._-]+$, reject./.., and/or resolve the path and refuse anything that is not still undercredentialDir`). -
Host MCP config write: accepted, with conditions. We accept writing the Host MCP config. The README discloses which file is edited and how, and the backup / atomic / serialized write approach matches repo
docs/security.md("Writing outside dataDir"). Before merge, please meet these conditions:a. Only write the file the user confirmed, and
realpathit. Discovery currently triesMINIMAX_MCP_CONFIG, parents ofcontext.dataDir,cwd, the Node executable's directory, then the default Host location (resolveMcpConfigPath,oauth-service.mjs:154-176). The env var and default-location fallback are not a published contract, and paths are notrealpath'd, so an unrelatedmcp.jsonundercwdor next to the executable can win. Write only the resolved default file or one the user confirms in the UI,realpath'd before read and write.b. Hold the lock across the whole read-modify-write. The read / modify / verify in
patchMcpAuthorizationaround:987is not fully under the write lock, so another process (or a second apply) editing this file at the same time can lose entries. Take the lock before reading, re-read inside it, and release only after the write is verified.c. Background refresh must be opt-in or confirmed each time. Every 60s,
refreshExpiring(server.mjs:300-309) can write credentials and patch the Host MCP config with no second confirm; the firstapplyasks the user, the timer does not. Make background refresh an explicit opt-in, or ask for confirmation on each write.d. Write credential files with mode
0o600. Tokens are stored in cleartext under the plugin's credential dir (save path aroundoauth-service.mjs:593). Set the mode explicitly on create and keep it on rewrite, so a looser umask cannot leave the grant world-readable. This applies on macOS/Linux; on Windows the mode is effectively ignored and nothing extra is needed.e. Back up
mcp.jsonbefore every write, including background refresh writes. The plugin already backs up before writing (backupConfigintocontext.dataDir,oauth-service.mjs:509-511, README:73-74); please keep that.refreshServer(:919) goes through the samepatchMcpAuthorizationpath, so refresh writes are backed up today too (:1004,:1045). Two gaps remain:backupConfig(:190-199) swallows errors and returnsfalse, and the write still goes ahead, so a failed backup is silent; and every write overwrites the singlemcp.json.bak, so a quiet refresh replaces the backup taken before the user's last confirmed apply. Please abort the write if the backup fails, and keep the pre-apply backup from being clobbered by refresh writes (for example a separate or timestamped backup). -
Outbound fetches must share one URL guard, including private networks.
assertTargetUrl(oauth-service.mjs:316-333) runs on the MCP endpoint at authorize / device start, but theresource_metadataURL fromWWW-Authenticate(:377) and the issuer /.well-known/ token / device / registration endpoints taken from that metadata are fetched without the same check. The allowlist also lets RFC1918 and IPv4-mapped forms through (10/8,192.168/16,172.16/12,[::ffff:127.0.0.1]). Warm-up and/api/discoverprobeserver.urlfrom the Host config withoutassertTargetUrleither (server.mjs:186,:319). A hostile MCP can point metadata at an internal address (SSRF). Please run every fetch target through one checker that rejects private / link-local / metadata addresses (and, if you ever follow redirects, re-check after each hop). -
Callback page renders the
errorparam as raw HTML.handleCallbacktakeserrorstraight from the callback URL and concatenates it into the result message (`授权被拒绝:${error}`,oauth-service.mjs:847);renderCallbackPagethen inserts that message unescaped into<p>${detail}</p>(server.mjs:416). Anyone who can get the user to open a crafted callback URL (or a hostile authorization server redirecting with a chosenerror) can inject markup and script into a page served from the plugin's origin. Please HTML-escapedetail(andtitle) before interpolating it (&,<,>,",').
Optional
-
PKCE complete path treats a missing
stateas success. IncompleteAuthorization(oauth-service.mjs:798),if (state && state !== session.state)only fails when a state is present and wrong; a pasted callback URL with nostatestill exchanges the code. Prefer requiring an exact match whenever the session stored one. -
JSON key lookup is first-match substring of the key spelling.
findMemberObjectSpan/findMemberStringSpan(oauth-service.mjs:111-142) useindexOf(JSON.stringify(key)), so a server name that appears as a key elsewhere in the file (or a nested"Authorization"outside the intended entry) can patch the wrong span — confirmed by running it. Scoping the search tomcpServersfirst, or parsing and re-serializing only that entry while accepting format churn, would be safer. -
Creating a brand-new server entry rewrites the whole file (
:990-1007), which conflicts with the README claim that other bytes stay unchanged. -
Apply confirm is page-only. The confirm step lives in the page (
app.js:838-881);POST /api/applyhas no confirm token. Anything that can call that route can write the Host MCP config without the dialog. -
Browser open on non-Windows / non-macOS.
openInBrowser(oauth-service.mjs:706-719) usesexplorer.exeoropen. On Linux neither is reliable; the page already shows the URL to copy, so a short README / UI note for "open failed — use the link" is enough. -
Manifest / catalog copy still describes only authorization-code + PKCE.
.minimax-plugin/plugin.json:6and the root README table row omit the device-code path that this head recommends. Aligning those one-liners with the package README would avoid underselling the preferred flow. -
GRANT_TTL_MSis defined but unused. Either wire it or drop it so the lifetime story matches the code. -
No plugin tests. There is no
tests/tree. Even a few unit cases aroundassertTargetUrl, safe server-name → credential path, and the Authorization patch (replace / insert headers / create entry) would have caught item 2 above and would help the next review.
|
Update: please disregard condition d ( |
f543139 to
2674da1
Compare
…tighten the guards Must fix - package miniapp/node/errors.mjs, which the entry imports but the package did not ship, so the Mini App could not start at all - keep every error code the service can raise in one catalog; an unregistered code now fails fast instead of reaching the user - restrict a server name to a safe set, so it can no longer walk out of the credential directory - locate mcp.json only in the Host data-dir chain and the documented default location; the working directory and the Node executable's directory are no longer searched, and the resolved path is realpath'ed and shown in the page - bind a write to the exact path the user confirmed - run the whole read-modify-write under one lock and re-read inside it - refuse a write when the backup failed, and keep the confirmed snapshot apart from the one a background refresh writes - make the automatic refresh an explicit switch, off by default - validate every outbound URL, including the IPv4-mapped, IPv4-compatible and NAT64 IPv6 forms; URLs a remote server handed back are always strict - escape the callback page, and require the PKCE state to match exactly - limit the JSON lookup to mcpServers, and insert a new entry textually Optional - bind a confirmed write to a one-time token - report a browser that could not be opened, instead of claiming it opened - drop the unused grant TTL - add unit tests for the URL guard, the credential path, the surgical patch and the error catalog
Run the authorization-code + PKCE flow for MCP servers that require OAuth, keep the token inside the Node process, write the Authorization header into the MCP configuration, and refresh it before it expires. The page separates two facts that are easy to conflate: what the server demands, probed with a handshake that matches the declared transport and only read as OAuth when the server sends a Bearer resource_metadata challenge, and what this machine holds, the local credential. Credentials live under context.dataDir. The MCP configuration is patched surgically, written atomically, and backed up before every write.
From MiniMax Code 3.1.2 the Host authenticates every request to the assigned loopback port, so the browser redirect of an authorization-code flow answers 401 and the callback handler never runs. The device authorization grant (RFC 8628) has no redirect at all, so it is not affected; the page now offers it as the recommended method and keeps the PKCE method with the callback URL copied back by hand. Also rebase onto upstream main, document the Agent turns and the two sign-in methods, and tag the row action buttons with data-action so they can be targeted unambiguously.
…tighten the guards Must fix - package miniapp/node/errors.mjs, which the entry imports but the package did not ship, so the Mini App could not start at all - keep every error code the service can raise in one catalog; an unregistered code now fails fast instead of reaching the user - restrict a server name to a safe set, so it can no longer walk out of the credential directory - locate mcp.json only in the Host data-dir chain and the documented default location; the working directory and the Node executable's directory are no longer searched, and the resolved path is realpath'ed and shown in the page - bind a write to the exact path the user confirmed - run the whole read-modify-write under one lock and re-read inside it - refuse a write when the backup failed, and keep the confirmed snapshot apart from the one a background refresh writes - make the automatic refresh an explicit switch, off by default - validate every outbound URL, including the IPv4-mapped, IPv4-compatible and NAT64 IPv6 forms; URLs a remote server handed back are always strict - escape the callback page, and require the PKCE state to match exactly - limit the JSON lookup to mcpServers, and insert a new entry textually Optional - bind a confirmed write to a one-time token - report a browser that could not be opened, instead of claiming it opened - drop the unused grant TTL - add unit tests for the URL guard, the credential path, the surgical patch and the error catalog
- follow redirects by hand and check every hop again with the same rule, so a 302 cannot walk past the URL guard - resolve IPv4-mapped, IPv4-compatible and NAT64 IPv6 addresses back to their IPv4 address before the private-range check - let the configured MCP endpoint use a private address when the user opted into it, in the authorize and device paths as well as the probe - check the authorization endpoint too, so the browser is never sent to an address a hostile server chose - map an unregistered error code to the generic one before it reaches the page, and route the remaining raw throws through the catalog - add tests for the redirect guard and for the error contract
86acdcb to
55be487
Compare
All five Must fix items and the eight Optional ones are addressed on Supersedes my review comment above (GitHub does not allow deleting a submitted review). Must fix
Package tests: |
MyPrototypeWhat
left a comment
There was a problem hiding this comment.
Reviewed at 55be487. Thanks for the thorough round. The earlier Must fix items are resolved, but the new hand-written redirect loop introduces one problem that needs fixing before merge.
Earlier points: verified
errors.mjsadded. The error catalog is there,serviceError()throws on an unregistered code, and the plugin loads (theentry-loadstest passes).- Server name allowlist and path escape.
assertServerNameallows only^[A-Za-z0-9._-]{1,64}$and rejects.and...credentialPathresolves the path and checks it stays under the credential directory. - mcp.json write conditions.
- (a)
resolveMcpConfigPathchecks only the data-dir chain and~/.minimax/mcp.json. The working directory and the Node executable directory are no longer searched. - (b)
patchMcpAuthorizationruns the whole read-modify-write insidewithWriteLock, re-reads the file inside the lock, and refuses the write if the path differs from the one the user confirmed. - (c)
autoRefreshdefaults tofalse. The interval only refreshes when it is switched on, and "refresh all" passesforceonly for the button press. - (e)
backupConfigno longer swallows errors, so a failed backup aborts the write. Confirmed applies keep their own backup file.
- (a)
- Shared URL guard on outbound requests. Every
httpJsoncall passes a guard (strictHopfor URLs that come from a remote server,endpointHopfor the endpoint the user configured), andfetchGuardedchecks each hop again with at most 5 redirects. Theredirect-guardandurl-guardtests pass. - Callback page escaping. Title and detail both go through
escapeHtml.
Must fix
Credentials are re-sent to every redirect target. fetchGuarded follows redirects with fetch(current, { ...init, redirect: 'manual' }), so each hop gets the original init again: the same method, the same body and the same headers. A redirect from any of these requests to another origin therefore forwards:
Authorization: Bearer <access token>fromprobeMcpEndpoint(the token check after apply and refresh) and from the initialize probe;- the token request body (
refresh_token,client_secret,code,code_verifier) fromrequestTokenand the device-code poll.
fetch's own redirect handling drops Authorization on a cross-origin redirect. The manual loop removed that protection, and the URL guard does not cover it, because the guard only checks whether a host can be reached, not whether it should receive the credentials. In practice, an MCP endpoint that answers with a 302 to another host gets the user's token sent there.
Suggested fix, in fetchGuarded:
- If the next hop has a different origin from the current one, remove
authorization(and any cookie) from the headers before sending. - For 301, 302 and 303 on a POST, follow as a GET with no body (as browsers do), or simply refuse to follow redirects on token, registration and device endpoints. OAuth endpoints are not expected to redirect, so refusing is the simplest choice.
- Add a test: a request with an
Authorizationheader that is redirected to another origin must arrive there without the header, and a POST to a token endpoint that redirects must not resend its body.
Optional
resolveMcpConfigPathwalks up to 10 parent directories from the data dir and takes the firstmcp.jsonit finds. If none exists in the expected place, that can be an unrelatedmcp.jsonin a parent folder (the home directory, for example). The write is bound to the path the page shows, so this is not a blocker, but stopping the walk at the MiniMax data directory would make the result predictable.
Version
1.0.0 is right for a plugin that has not been published yet. There is no earlier release, so moving to 1.1.0 for the device-code addition is not needed.
Checks run
npx -y -p node@22 npm run checkat 55be487: passes.- Package tests (
node --test "tests/**/*.test.mjs", Node 22): 20 of 20 pass.
The manual redirect loop resent the original request on every hop, so a 302 to another origin carried the Authorization header and the request body - refresh_token, client_secret, code or code_verifier - to a host that never received it. fetch drops the Authorization header on a cross-origin redirect; the loop has to do the same. - a redirect to another origin is followed without the Authorization header and without cookies - a 301, 302 or 303 turns the POST into a GET without a body, like a browser - a request whose body holds a secret is never redirected at all: the token, registration and device endpoints are not expected to redirect, so the request is refused instead Also stop the search for mcp.json at the MiniMax data directory, so an unrelated mcp.json in a parent folder cannot be picked up. httpJson now takes an options object instead of six positional arguments.
Fixed on
Tests: The Optional point is in as well: |
MyPrototypeWhat
left a comment
There was a problem hiding this comment.
Reviewed at 10c5af9. Thanks, the redirect fix is complete and nothing else blocks merge. Approving.
Redirect fix: verified
- Credential headers on a cross-origin hop.
fetchGuardedcomparesnew URL(next).originwithnew URL(current).origin. That covers scheme, host and port, so anhttpstohttpdowngrade on the same host also counts as cross-origin.- On a cross-origin hop,
authorization,proxy-authorizationandcookieare removed, matched case-insensitively. Once removed, they stay removed for later hops. - The only credential header the plugin sends is
authorization(the Bearer token in the probes and the Basic client secret on token and device requests), so no custom auth header is left uncovered.
- POST after 301, 302 and 303. The request is resent as a
GETwith no body. - Requests that carry secrets are never redirected.
registerClient,requestToken(code exchange and refresh), the device authorization request and the device-code poll all passsensitive: true. A 3xx with aLocationheader fails withredirect_not_allowedbefore the next URL is even resolved.- The plugin makes no revocation or introspection calls.
- The remaining
httpJsoncallers are the two endpoint probes and the two metadata GETs, and none of them sends a secret in the body. The cross-origin rule above covers the Bearer header in the probes.
- Hop limit. Still at most 5 redirects, then
too_many_redirects. Every hop still goes through the guard. redirect_not_allowedis in the error catalog, soserviceError()accepts it.
mcp.json write conditions: still met
- (a)
resolveMcpConfigPathchecks only the data-dir chain and~/.minimax/mcp.json, and the write stays bound to the realpath'd file the user confirmed. This round makes the search narrower. - (b) The read-modify-write still runs entirely inside
withWriteLock. That code did not change. - (c)
autoRefreshstill defaults tofalse, and background refresh only writes when it is switched on. - (e) A failed backup still aborts the write, and confirmed applies keep their own backup file.
Config path search
The walk now stops at the first .minimax directory and never steps into the home directory. The home directory's own mcp.json is not checked, and if nothing is found the result is the default ~/.minimax/mcp.json. That settles the earlier optional point. config-path.test.mjs covers both the file inside the data directory and an unrelated file one level above it.
Optional
- When a POST becomes a GET,
content-typeis still sent with no body. It does no harm, but you could drop it along with the body. - A test for the 303 POST-to-GET path would round out
redirect-guard.test.mjs. - If the data directory is ever outside the home directory and outside a
.minimaxfolder, the walk can still go up to 10 levels toward the filesystem root. The write is still bound to the path the page shows, so this is fine as it is.
Tests and scope
The two new test files sit in the plugin's own tests/ folder. This round does not change .github/workflows, the root package.json scripts, or the root test/ folder.
Checks run
npx -y -p node@22 npm run checkat the repo root on10c5af9: passes.- Plugin tests (
node --test tests/*.test.mjs, Node 22, run inside the plugin folder): 24 of 24 pass.
Adds
mcp-oauth-console: sign in to MCP servers that require OAuth.This branch also answers the review on #77 — the reply lists the Must fix items.
What it does
Runs an OAuth sign-in for a server, keeps the token inside the Node process, writes the
Authorizationheader into the MiniMax Code MCP configuration, and can refresh the token before it expires.The page shows two independent facts per server: what the server demands (probed from the endpoint) and what this machine holds (the local credential). A server can require OAuth and still be satisfied by a local token, and one can need no OAuth and still have no token — a single status field hides one of the two.
Notes for the reviewer
401and the authorization code has to be copied back from the address bar.401alone is not OAuth. Only aWWW-Authenticate: Bearer resource_metadata="…"challenge (RFC 9728) counts, so the page distinguishes requires OAuth / requires a Bearer credential / requires a credential, scheme undeclared / not required / undetermined.initializecall forstreamable-http, aGETforsse.mcp.jsonis surgical, atomic, backed up, and bound to the path the user confirmed.Checks
npm run check:plugins/microbiosis/mcp-oauth-console: OK,repository: OK. CI green on10c5af9.Files read and written, network hosts, spawned processes, state, credentials, Agent turns and the tested environment are in the package README: README.md.
Package tests:
node --test "tests/**/*.test.mjs"from the package directory.