mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac8194f63b |
@@ -2014,6 +2014,7 @@ export type ConfigEntry =
|
||||
scope?: string
|
||||
callback_port?: number
|
||||
redirect_uri?: string
|
||||
auth_server_metadata_url?: string
|
||||
}
|
||||
| false
|
||||
disabled?: boolean
|
||||
@@ -4669,6 +4670,7 @@ export type McpAddInput = {
|
||||
readonly scope?: string
|
||||
readonly callback_port?: number
|
||||
readonly redirect_uri?: string
|
||||
readonly auth_server_metadata_url?: string
|
||||
}
|
||||
| false
|
||||
readonly disabled?: boolean
|
||||
|
||||
@@ -383,24 +383,27 @@ export const layer = (options?: Options) =>
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
const scope = yield* Scope.fork(root)
|
||||
entry.scope = scope
|
||||
const authProvider = yield* connectProvider(entry)
|
||||
const { McpClient } = yield* Effect.promise(() => import("./client.js"))
|
||||
// List tools as part of connect so a failure here marks the server failed rather than
|
||||
// leaving it connected with a silently empty tool list and no path to recover.
|
||||
const result = yield* McpClient.connect(
|
||||
name,
|
||||
entry.config,
|
||||
location.directory,
|
||||
authProvider,
|
||||
elicitation,
|
||||
options?.clientInfo,
|
||||
).pipe(
|
||||
Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))),
|
||||
// A stdio server is spawned on this location's execution plane, not the host's.
|
||||
Effect.provideService(Environment.Service, environment),
|
||||
Scope.provide(scope),
|
||||
Effect.exit,
|
||||
)
|
||||
const result = yield* Effect.gen(function* () {
|
||||
// Inside the exit capture so a misconfigured OAuth override fails the server with its
|
||||
// message instead of escaping startServer.
|
||||
const authProvider = yield* connectProvider(entry)
|
||||
return yield* McpClient.connect(
|
||||
name,
|
||||
entry.config,
|
||||
location.directory,
|
||||
authProvider,
|
||||
elicitation,
|
||||
options?.clientInfo,
|
||||
).pipe(
|
||||
Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))),
|
||||
// A stdio server is spawned on this location's execution plane, not the host's.
|
||||
Effect.provideService(Environment.Service, environment),
|
||||
Scope.provide(scope),
|
||||
)
|
||||
}).pipe(Effect.exit)
|
||||
if (Exit.isSuccess(result)) {
|
||||
entry.client = result.value.connection
|
||||
entry.tools = result.value.tools.map((tool) => toTool(name, entry, tool))
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
extractWWWAuthenticateParams,
|
||||
parseErrorResponse,
|
||||
UnauthorizedError,
|
||||
type AuthorizationServerMetadata,
|
||||
type FetchLike,
|
||||
type OAuthClientProvider,
|
||||
type OAuthDiscoveryState,
|
||||
@@ -237,6 +238,56 @@ export const toTokens = (credential: Credential.OAuth): StoredOAuthTokens => {
|
||||
}
|
||||
}
|
||||
|
||||
// An explicitly configured authorization-server metadata document replaces protected-resource
|
||||
// discovery. Trusted configuration for servers that publish no usable metadata: the document's
|
||||
// issuer identifies the authorization server, while token requests still bind to the configured URL.
|
||||
const isHttpUrl = (value: unknown): value is string => {
|
||||
if (typeof value !== "string" || !URL.canParse(value)) return false
|
||||
const protocol = new URL(value).protocol
|
||||
return protocol === "http:" || protocol === "https:"
|
||||
}
|
||||
|
||||
const requireMetadataUrl = (value: string) => {
|
||||
if (!URL.canParse(value)) throw new Error(`Invalid oauth.auth_server_metadata_url: not an absolute URL`)
|
||||
const url = new URL(value)
|
||||
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]"
|
||||
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback))
|
||||
throw new Error(`Invalid oauth.auth_server_metadata_url: must use https:// (http:// is allowed only for loopback)`)
|
||||
return url
|
||||
}
|
||||
|
||||
const requireAuthorizationServerMetadata = (input: unknown): AuthorizationServerMetadata => {
|
||||
if (typeof input !== "object" || input === null)
|
||||
throw new Error("Invalid oauth.auth_server_metadata_url document: expected a JSON object")
|
||||
const document = input as Partial<AuthorizationServerMetadata>
|
||||
if (!isHttpUrl(document.issuer))
|
||||
throw new Error("Invalid oauth.auth_server_metadata_url document: issuer must be an absolute http(s) URL")
|
||||
if (!isHttpUrl(document.authorization_endpoint))
|
||||
throw new Error(
|
||||
"Invalid oauth.auth_server_metadata_url document: authorization_endpoint must be an absolute http(s) URL",
|
||||
)
|
||||
if (!isHttpUrl(document.token_endpoint))
|
||||
throw new Error("Invalid oauth.auth_server_metadata_url document: token_endpoint must be an absolute http(s) URL")
|
||||
return input as AuthorizationServerMetadata
|
||||
}
|
||||
|
||||
const loadAuthorizationServerMetadata = async (
|
||||
metadataUrl: string,
|
||||
serverUrl: string,
|
||||
fetchFn: FetchLike,
|
||||
): Promise<OAuthDiscoveryState> => {
|
||||
const response = await fetchFn(requireMetadataUrl(metadataUrl), { headers: { accept: "application/json" } })
|
||||
if (!response.ok) throw new Error(`oauth.auth_server_metadata_url request failed with HTTP ${response.status}`)
|
||||
const metadata = requireAuthorizationServerMetadata(await response.json())
|
||||
const resource = new URL(serverUrl)
|
||||
resource.hash = ""
|
||||
return {
|
||||
authorizationServerUrl: metadata.issuer,
|
||||
authorizationServerMetadata: metadata,
|
||||
resourceMetadata: { resource: resource.toString() },
|
||||
}
|
||||
}
|
||||
|
||||
export const connectProvider = Effect.fnUntraced(function* (input: {
|
||||
readonly config: typeof ConfigMCP.Remote.Type
|
||||
readonly integrationID: Integration.ID
|
||||
@@ -253,8 +304,16 @@ export const connectProvider = Effect.fnUntraced(function* (input: {
|
||||
}
|
||||
// Refresh tokens rotate and the row is shared across connections: only drop it while it still holds ours.
|
||||
let presented = found.value.refresh
|
||||
const metadataUrl = (input.config.oauth || undefined)?.auth_server_metadata_url
|
||||
const discovery = metadataUrl
|
||||
? yield* Effect.tryPromise({
|
||||
try: () => loadAuthorizationServerMetadata(metadataUrl, input.config.url, fetch),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
})
|
||||
: undefined
|
||||
return provider({
|
||||
config: input.config,
|
||||
...(discovery ? { discovery } : {}),
|
||||
invalidate: async (scope) => {
|
||||
if (scope === "verifier" || scope === "discovery") return
|
||||
const oauth = await read()
|
||||
@@ -361,36 +420,49 @@ export const authorize = (input: {
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
|
||||
|
||||
// A configured metadata document is authoritative: it replaces the 401 probe and
|
||||
// protected-resource discovery entirely.
|
||||
const metadataUrl = oauth?.auth_server_metadata_url
|
||||
if (metadataUrl)
|
||||
yield* Effect.logInfo("mcp oauth using configured authorization server metadata", { ...fields, metadataUrl })
|
||||
|
||||
// The server's 401 names where its resource metadata lives and which scopes it wants; without it
|
||||
// discovery can only guess the well-known path, which not every server layout answers.
|
||||
const challenge = yield* Effect.tryPromise((signal) =>
|
||||
fetchFn(input.config.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...input.config.headers,
|
||||
"content-type": "application/json",
|
||||
accept: "application/json, text/event-stream",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 0,
|
||||
method: "initialize",
|
||||
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: "opencode" } },
|
||||
}),
|
||||
signal,
|
||||
}),
|
||||
).pipe(
|
||||
Effect.map((response) => extractWWWAuthenticateParams(response)),
|
||||
Effect.timeout("5 seconds"),
|
||||
Effect.orElseSucceed(() => ({ resourceMetadataUrl: undefined, scope: undefined })),
|
||||
)
|
||||
const challenge = metadataUrl
|
||||
? { resourceMetadataUrl: undefined, scope: undefined }
|
||||
: yield* Effect.tryPromise((signal) =>
|
||||
fetchFn(input.config.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...input.config.headers,
|
||||
"content-type": "application/json",
|
||||
accept: "application/json, text/event-stream",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 0,
|
||||
method: "initialize",
|
||||
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: "opencode" } },
|
||||
}),
|
||||
signal,
|
||||
}),
|
||||
).pipe(
|
||||
Effect.map((response) => extractWWWAuthenticateParams(response)),
|
||||
Effect.timeout("5 seconds"),
|
||||
Effect.orElseSucceed(() => ({ resourceMetadataUrl: undefined, scope: undefined })),
|
||||
)
|
||||
const resourceMetadataUrl = challenge.resourceMetadataUrl
|
||||
// CIMD needs the server to advertise it and accept public clients, and our published document only
|
||||
// lists the loopback redirect; a configured client_id always wins.
|
||||
const discovery = yield* Effect.tryPromise({
|
||||
try: () => discoverOAuthServerInfo(input.config.url, { resourceMetadataUrl, fetchFn }),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
})
|
||||
const discovery: OAuthDiscoveryState = metadataUrl
|
||||
? yield* Effect.tryPromise({
|
||||
try: () => loadAuthorizationServerMetadata(metadataUrl, input.config.url, fetchFn),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
})
|
||||
: yield* Effect.tryPromise({
|
||||
try: () => discoverOAuthServerInfo(input.config.url, { resourceMetadataUrl, fetchFn }),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
})
|
||||
const cimd =
|
||||
!oauth?.client_id &&
|
||||
!oauth?.redirect_uri &&
|
||||
|
||||
@@ -352,6 +352,123 @@ describe("MCP OAuth", () => {
|
||||
expect(url.pathname).toBe("/as/authorize")
|
||||
})
|
||||
|
||||
test("uses the configured authorization-server metadata instead of discovery", async () => {
|
||||
const authorization = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname !== "/custom/as-metadata") return new Response(null, { status: 404 })
|
||||
return Response.json({
|
||||
issuer: url.origin,
|
||||
authorization_endpoint: `${url.origin}/authorize`,
|
||||
token_endpoint: `${url.origin}/token`,
|
||||
response_types_supported: ["code"],
|
||||
})
|
||||
},
|
||||
})
|
||||
const probes: string[] = []
|
||||
const resource = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
probes.push(`${request.method} ${new URL(request.url).pathname}`)
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
})
|
||||
try {
|
||||
const { url } = await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
start(`${resource.url.origin}/mcp`, {
|
||||
client_id: "client",
|
||||
auth_server_metadata_url: `${authorization.url.origin}/custom/as-metadata`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(url.origin).toBe(authorization.url.origin)
|
||||
expect(url.pathname).toBe("/authorize")
|
||||
// The override replaces the 401 probe and the well-known lookups entirely.
|
||||
expect(probes).toEqual([])
|
||||
} finally {
|
||||
resource.stop(true)
|
||||
authorization.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("refreshes through the configured authorization server", async () => {
|
||||
const tokenRequests: URLSearchParams[] = []
|
||||
const authorization = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/custom/as-metadata")
|
||||
return Response.json({
|
||||
issuer: url.origin,
|
||||
authorization_endpoint: `${url.origin}/authorize`,
|
||||
token_endpoint: `${url.origin}/token`,
|
||||
response_types_supported: ["code"],
|
||||
})
|
||||
if (request.method === "POST" && url.pathname === "/token") {
|
||||
tokenRequests.push(new URLSearchParams(await request.text()))
|
||||
return Response.json({ access_token: "next", token_type: "Bearer" })
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
})
|
||||
const resource = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 404 }) })
|
||||
try {
|
||||
const url = `${resource.url.origin}/mcp`
|
||||
const config = new ConfigMCP.Remote({
|
||||
type: "remote",
|
||||
url,
|
||||
oauth: {
|
||||
client_id: "client",
|
||||
auth_server_metadata_url: `${authorization.url.origin}/custom/as-metadata`,
|
||||
},
|
||||
})
|
||||
const oauthProvider = await connectProvider(
|
||||
config,
|
||||
memoryCredentials([credential({ access: "expired", refresh: "refresh", url })]),
|
||||
)
|
||||
const result = await auth(oauthProvider, { serverUrl: url })
|
||||
expect(result).toBe("AUTHORIZED")
|
||||
expect(tokenRequests).toHaveLength(1)
|
||||
expect(tokenRequests[0]?.get("grant_type")).toBe("refresh_token")
|
||||
expect(tokenRequests[0]?.get("resource")).toBe(url)
|
||||
} finally {
|
||||
resource.stop(true)
|
||||
authorization.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects an invalid authorization-server metadata override", async () => {
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
Effect.scoped(start(authServer, { client_id: "client", auth_server_metadata_url: "not a url" })),
|
||||
),
|
||||
).rejects.toThrow("auth_server_metadata_url")
|
||||
const authorization = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname !== "/as-metadata") return new Response(null, { status: 404 })
|
||||
return Response.json({ issuer: url.origin, authorization_endpoint: `${url.origin}/authorize` })
|
||||
},
|
||||
})
|
||||
try {
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
Effect.scoped(
|
||||
start(authServer, {
|
||||
client_id: "client",
|
||||
auth_server_metadata_url: `${authorization.url.origin}/as-metadata`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("token_endpoint")
|
||||
} finally {
|
||||
authorization.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("forwards iss from the redirect so issuer-advertising servers can complete", async () => {
|
||||
const { server } = authorizationServer({ authorization_response_iss_parameter_supported: true })
|
||||
const result = await Effect.runPromise(
|
||||
|
||||
@@ -15250,6 +15250,9 @@
|
||||
},
|
||||
"redirect_uri": {
|
||||
"type": "string"
|
||||
},
|
||||
"auth_server_metadata_url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -44,6 +44,10 @@ export class OAuthConfig extends Schema.Class<OAuthConfig>("Mcp.OAuthConfig")({
|
||||
scope: Schema.String.pipe(optional),
|
||||
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(optional),
|
||||
redirect_uri: Schema.String.pipe(optional),
|
||||
auth_server_metadata_url: Schema.String.pipe(optional).annotate({
|
||||
description:
|
||||
"HTTPS authorization-server metadata document used authoritatively instead of protected-resource discovery. Use it only for a metadata endpoint you trust.",
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class RemoteConfig extends Schema.Class<RemoteConfig>("Mcp.RemoteConfig")({
|
||||
|
||||
@@ -35,3 +35,18 @@ describe("Mcp resources", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Mcp OAuth", () => {
|
||||
test("decodes the authorization-server metadata override and omits it when absent", () => {
|
||||
const value = Schema.decodeUnknownSync(Mcp.OAuthConfig)({
|
||||
client_id: "client",
|
||||
auth_server_metadata_url: "https://auth.example.com/.well-known/oauth-authorization-server",
|
||||
})
|
||||
expect(value.auth_server_metadata_url).toBe("https://auth.example.com/.well-known/oauth-authorization-server")
|
||||
expect(Schema.encodeSync(Mcp.OAuthConfig)(value)).toEqual({
|
||||
client_id: "client",
|
||||
auth_server_metadata_url: "https://auth.example.com/.well-known/oauth-authorization-server",
|
||||
})
|
||||
expect(Schema.encodeSync(Mcp.OAuthConfig)(Schema.decodeUnknownSync(Mcp.OAuthConfig)({}))).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15250,6 +15250,9 @@
|
||||
},
|
||||
"redirect_uri": {
|
||||
"type": "string"
|
||||
},
|
||||
"auth_server_metadata_url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -15250,6 +15250,9 @@
|
||||
},
|
||||
"redirect_uri": {
|
||||
"type": "string"
|
||||
},
|
||||
"auth_server_metadata_url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -230,6 +230,29 @@ When a provider gives you client credentials, use V2's snake_case OAuth fields:
|
||||
| `scope` | Space-delimited scopes to request. |
|
||||
| `callback_port` | Local callback port from `1` through `65535`. An available ephemeral port is the default. |
|
||||
| `redirect_uri` | Pre-registered loopback URI whose path and port reach the local callback listener. |
|
||||
| `auth_server_metadata_url` | Authorization-server metadata document used instead of protected-resource discovery. |
|
||||
|
||||
When the server publishes no usable authorization-server metadata, point `auth_server_metadata_url` at the metadata document directly:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"company-tools": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.example.com/mcp",
|
||||
"oauth": {
|
||||
"client_id": "{env:MCP_CLIENT_ID}",
|
||||
"auth_server_metadata_url": "https://auth.example.com/.well-known/oauth-authorization-server",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The URL must use `https://` (`http://` is allowed only for loopback). Only use a metadata endpoint you trust.
|
||||
|
||||
Remove stored OAuth credentials when you need to sign in again or switch accounts:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user