|
|
@@ -23,6 +23,8 @@ let simulateAuthFlow = true
|
|
|
let connectSucceedsImmediately = false
|
|
|
let serverCapabilities: { tools?: object; resources?: object } = { tools: {} }
|
|
|
let listToolsCalls = 0
|
|
|
+let finishAuthFails = false
|
|
|
+let finishAuthStoresCredentials = false
|
|
|
|
|
|
// Mock the transport constructors to simulate OAuth auto-auth on 401
|
|
|
void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
|
|
@@ -32,6 +34,10 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
|
|
state?: () => Promise<string>
|
|
|
redirectToAuthorization?: (url: URL) => Promise<void>
|
|
|
saveCodeVerifier?: (v: string) => Promise<void>
|
|
|
+ tokens?: () => Promise<{ access_token: string } | undefined>
|
|
|
+ clientInformation?: () => Promise<{ client_id: string } | undefined>
|
|
|
+ saveClientInformation?: (info: { client_id: string; client_secret?: string }) => Promise<void>
|
|
|
+ saveTokens?: (tokens: { access_token: string; token_type: string }) => Promise<void>
|
|
|
}
|
|
|
| undefined
|
|
|
constructor(url: URL, options?: { authProvider?: unknown }) {
|
|
|
@@ -49,6 +55,8 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
|
|
// It calls auth() which eventually calls provider.state(), then
|
|
|
// provider.redirectToAuthorization(), then throws UnauthorizedError.
|
|
|
if (simulateAuthFlow && this.authProvider) {
|
|
|
+ if (await this.authProvider.tokens?.()) throw new MockUnauthorizedError()
|
|
|
+ if (await this.authProvider.clientInformation?.()) throw new MockUnauthorizedError()
|
|
|
// The SDK calls provider.state() to get the OAuth state parameter
|
|
|
if (this.authProvider.state) {
|
|
|
await this.authProvider.state()
|
|
|
@@ -65,7 +73,14 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
|
|
}
|
|
|
throw new MockUnauthorizedError()
|
|
|
}
|
|
|
- async finishAuth(_code: string) {}
|
|
|
+ async finishAuth(_code: string) {
|
|
|
+ if (finishAuthFails) throw new Error("Token exchange failed")
|
|
|
+ if (finishAuthStoresCredentials) {
|
|
|
+ await this.authProvider?.saveClientInformation?.({ client_id: "replacement-client" })
|
|
|
+ await this.authProvider?.saveTokens?.({ access_token: "replacement-token", token_type: "Bearer" })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ async close() {}
|
|
|
},
|
|
|
}))
|
|
|
|
|
|
@@ -125,6 +140,8 @@ beforeEach(() => {
|
|
|
connectSucceedsImmediately = false
|
|
|
serverCapabilities = { tools: {} }
|
|
|
listToolsCalls = 0
|
|
|
+ finishAuthFails = false
|
|
|
+ finishAuthStoresCredentials = false
|
|
|
})
|
|
|
|
|
|
// Import modules after mocking
|
|
|
@@ -133,6 +150,7 @@ const { EventV2Bridge } = await import("../../src/event-v2-bridge")
|
|
|
const { Config } = await import("../../src/config/config")
|
|
|
const { McpAuth } = await import("../../src/mcp/auth")
|
|
|
const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
|
|
|
+const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
|
|
|
const { FSUtil } = await import("@opencode-ai/core/fs-util")
|
|
|
const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner")
|
|
|
|
|
|
@@ -227,6 +245,59 @@ mcpTest.instance("state() returns existing state when one is saved", () =>
|
|
|
}),
|
|
|
)
|
|
|
|
|
|
+mcpTest.instance(
|
|
|
+ "failed reauthentication preserves existing credentials",
|
|
|
+ () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ yield* Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ const auth = yield* McpAuth.Service
|
|
|
+ const name = "test-reauth-failure"
|
|
|
+ const url = "https://example.com/mcp"
|
|
|
+ const clientInfo = { clientId: "dynamic-client", clientSecret: "dynamic-secret" }
|
|
|
+
|
|
|
+ yield* auth.updateClientInfo(name, clientInfo, url)
|
|
|
+ yield* auth.updateTokens(name, { accessToken: "working-token" }, url)
|
|
|
+ expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("https://auth.example.com/authorize")
|
|
|
+ finishAuthFails = true
|
|
|
+
|
|
|
+ expect(yield* mcp.finishAuth(name, "invalid-code")).toEqual({
|
|
|
+ status: "failed",
|
|
|
+ error: "OAuth completion failed",
|
|
|
+ })
|
|
|
+ const entry = yield* auth.get(name)
|
|
|
+ expect(entry?.tokens?.accessToken).toBe("working-token")
|
|
|
+ expect(entry?.clientInfo).toEqual(clientInfo)
|
|
|
+ }),
|
|
|
+ { config: config("test-reauth-failure") },
|
|
|
+)
|
|
|
+
|
|
|
+mcpTest.instance(
|
|
|
+ "successful reauthentication commits replacement credentials",
|
|
|
+ () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ yield* Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
|
|
|
+ const mcp = yield* MCP.Service
|
|
|
+ const auth = yield* McpAuth.Service
|
|
|
+ const name = "test-reauth-success"
|
|
|
+ const url = "https://example.com/mcp"
|
|
|
+
|
|
|
+ yield* auth.updateClientInfo(name, { clientId: "old-client" }, url)
|
|
|
+ yield* auth.updateTokens(name, { accessToken: "old-token" }, url)
|
|
|
+ expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("https://auth.example.com/authorize")
|
|
|
+ expect((yield* auth.get(name))?.tokens?.accessToken).toBe("old-token")
|
|
|
+ finishAuthStoresCredentials = true
|
|
|
+ connectSucceedsImmediately = true
|
|
|
+
|
|
|
+ expect((yield* mcp.finishAuth(name, "valid-code")).status).toBe("connected")
|
|
|
+ const entry = yield* auth.get(name)
|
|
|
+ expect(entry?.tokens?.accessToken).toBe("replacement-token")
|
|
|
+ expect(entry?.clientInfo?.clientId).toBe("replacement-client")
|
|
|
+ expect(entry?.serverUrl).toBe(url)
|
|
|
+ }),
|
|
|
+ { config: config("test-reauth-success") },
|
|
|
+)
|
|
|
+
|
|
|
mcpTest.instance(
|
|
|
"auth status only reports credentials stored for the configured server URL",
|
|
|
() =>
|