fix(opencode): preserve valid MCP OAuth state

This commit is contained in:
Aiden Cline
2026-06-24 16:59:34 -05:00
parent ed486f0b49
commit bb388efae6
6 changed files with 25 additions and 25 deletions
+2 -3
View File
@@ -147,11 +147,10 @@ export const layer = Layer.effect(
return yield* Effect.gen(function* () {
const data = yield* read()
const entry = data[mcpName]
if (!entry?.oauthState) return false
const matches = entry.oauthState === oauthState
if (entry?.oauthState !== oauthState) return false
delete entry.oauthState
yield* fs.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600).pipe(Effect.orDie)
return matches
return true
}).pipe(flock.withLock(lockKey), Effect.orDie)
})
+6 -4
View File
@@ -951,14 +951,16 @@ export const layer = Layer.effect(
oauthState: string,
) {
yield* requireMcpConfig(mcpName)
const transport = pendingOAuthTransports.get(mcpName)
if (!transport) return yield* new OAuthError({ message: `No pending OAuth flow for MCP server: ${mcpName}` })
if (!(yield* auth.consumeOAuthState(mcpName, oauthState))) {
yield* cleanupAuth(mcpName)
return yield* new OAuthError({ message: "Invalid or expired OAuth state - potential CSRF attack" })
}
const transport = pendingOAuthTransports.get(mcpName)
if (!transport) {
yield* cleanupAuth(mcpName)
return yield* new OAuthError({ message: `No pending OAuth flow for MCP server: ${mcpName}` })
}
const result = yield* Effect.tryPromise({
try: () => transport.finishAuth(authorizationCode).then(() => true as const),
catch: (error) => {
@@ -88,7 +88,7 @@ export const McpApi = HttpApi.make("mcp")
identifier: "mcp.auth.callback",
summary: "Complete MCP OAuth",
description:
"Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.",
"Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code and state returned by the start endpoint.",
}),
),
HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
@@ -2,7 +2,7 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import Http from "node:http"
import path from "node:path"
import { describe, expect } from "bun:test"
import { Config, Context, Effect, Layer } from "effect"
import { Config, Context, Effect, Layer, Ref } from "effect"
import {
HttpClient,
HttpClientRequest,
@@ -32,7 +32,7 @@ const it = testEffect(
const callbackPath = McpPaths.authCallback.replace(":name", "secure-oauth")
const authPath = McpPaths.auth.replace(":name", "secure-oauth")
function listenOAuthServer(tokenCalls: { value: number }) {
function listenOAuthServer(tokenCalls: Ref.Ref<number>) {
return Effect.gen(function* () {
const context = yield* Layer.build(NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }))
const server = Context.get(context, HttpServer.HttpServer)
@@ -57,7 +57,7 @@ function listenOAuthServer(tokenCalls: { value: number }) {
if (url.pathname === "/token")
return Effect.gen(function* () {
yield* request.text
tokenCalls.value++
yield* Ref.update(tokenCalls, (value) => value + 1)
return yield* HttpServerResponse.json({ access_token: "access-token", token_type: "Bearer" })
})
if (url.pathname !== "/mcp") return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
@@ -130,7 +130,7 @@ function assertPortAvailable(port: number) {
function setup() {
return Effect.gen(function* () {
const directory = yield* tmpdirScoped({ git: true })
const tokenCalls = { value: 0 }
const tokenCalls = yield* Ref.make(0)
const upstream = yield* listenOAuthServer(tokenCalls)
const callbackPort = yield* availablePort()
yield* Effect.promise(() =>
@@ -169,24 +169,19 @@ describe("mcp HttpApi OAuth", () => {
const missing = yield* request(test.directory, callbackPath, { code: "missing-state" })
expect(missing.status).toBe(400)
expect(test.tokenCalls.value).toBe(0)
expect(yield* Ref.get(test.tokenCalls)).toBe(0)
const wrong = yield* request(test.directory, callbackPath, { code: "wrong-state", state: "wrong" })
expect(wrong.status).toBe(400)
expect(test.tokenCalls.value).toBe(0)
expect(yield* Ref.get(test.tokenCalls)).toBe(0)
const restarted = yield* request(test.directory, authPath)
expect(restarted.status).toBe(200)
const second = (yield* restarted.json) as { oauthState: string }
expect(second.oauthState).not.toBe(first.oauthState)
const correct = yield* request(test.directory, callbackPath, { code: "valid-code", state: second.oauthState })
const correct = yield* request(test.directory, callbackPath, { code: "valid-code", state: first.oauthState })
expect(correct.status).toBe(200)
expect(test.tokenCalls.value).toBe(1)
expect(yield* Ref.get(test.tokenCalls)).toBe(1)
const replayed = yield* request(test.directory, callbackPath, { code: "replayed-code", state: second.oauthState })
const replayed = yield* request(test.directory, callbackPath, { code: "replayed-code", state: first.oauthState })
expect(replayed.status).toBe(400)
expect(test.tokenCalls.value).toBe(1)
expect(yield* Ref.get(test.tokenCalls)).toBe(1)
}),
)
@@ -199,7 +199,11 @@ describe("mcp HttpApi", () => {
for (const input of [
{ method: "POST", route: "/mcp/missing/auth" },
{ method: "POST", route: "/mcp/missing/auth/authenticate" },
{ method: "POST", route: "/mcp/missing/auth/callback", body: JSON.stringify({ code: "code" }) },
{
method: "POST",
route: "/mcp/missing/auth/callback",
body: JSON.stringify({ code: "code", state: "state" }),
},
{ method: "DELETE", route: "/mcp/missing/auth" },
{ method: "POST", route: "/mcp/missing/connect" },
{ method: "POST", route: "/mcp/missing/disconnect" },
+1 -1
View File
@@ -2296,7 +2296,7 @@ export class Auth2 extends HeyApiClient {
/**
* Complete MCP OAuth
*
* Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.
* Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code and state returned by the start endpoint.
*/
public callback<ThrowOnError extends boolean = false>(
parameters: {