Compare commits

...
3 changed files with 86 additions and 6 deletions
+9 -1
View File
@@ -292,12 +292,19 @@ export const layer = (options?: Options) =>
// Tracks the refresh token this provider last presented, so invalidate can tell whether the SDK
// rejected the currently-stored credential or a snapshot another connection has already rotated past.
let presented = found.value.refresh
const bound = McpOAuth.issuerFromCredential(found.value)
let issuer: string | undefined
const readOAuthCredential = async () => {
const stored = await run(credentials.get(credentialID))
return stored?.value.type === "oauth" ? stored.value : undefined
}
return McpOAuth.provider({
...base,
onDiscovery: async (discovery) => {
issuer = discovery.authorizationServerMetadata?.issuer
if (bound && issuer && bound !== issuer)
await run(Effect.logWarning("mcp oauth issuer changed", { ...fields, expected: bound, actual: issuer }))
},
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth — but only if it is
// still the stored one. Rotating servers hand out a fresh refresh token per use, so a concurrent
// connection may have already replaced ours; deleting then would discard the newer valid credential and
@@ -332,7 +339,7 @@ export const layer = (options?: Options) =>
const oauth = await readOAuthCredential()
if (!oauth) return undefined
presented = oauth.refresh
return McpOAuth.toTokens(oauth)
return McpOAuth.toTokens(oauth, issuer)
},
saveTokens: async (tokens) => {
const previous = await readOAuthCredential()
@@ -341,6 +348,7 @@ export const layer = (options?: Options) =>
serverUrl: remote.url,
tokens,
client: previous ? McpOAuth.clientFromCredential(previous) : undefined,
issuer: (previous && McpOAuth.issuerFromCredential(previous)) || issuer,
})
presented = value.refresh
await run(
+29 -5
View File
@@ -5,6 +5,7 @@ import {
discoverOAuthServerInfo,
parseErrorResponse,
type OAuthClientProvider,
type OAuthDiscoveryState,
type OAuthServerInfo,
} from "@modelcontextprotocol/sdk/client/auth.js"
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
@@ -95,6 +96,8 @@ export interface Options {
readonly clientMetadataUrl?: string
/** Pre-fetched authorization server discovery so the SDK does not repeat it. */
readonly discovery?: OAuthServerInfo
/** Receives the SDK's discovery result before it refreshes or authorizes. */
readonly onDiscovery?: (discovery: OAuthDiscoveryState) => void | Promise<void>
/** Invoked by the SDK to drop credentials it has determined are invalid (e.g. a rejected refresh token). */
readonly invalidate?: (scope: "all" | "client" | "tokens" | "verifier" | "discovery") => void | Promise<void>
/** Receives the authorization URL so the caller can open a browser and capture the eventual code. */
@@ -113,6 +116,7 @@ export const provider = (options: Options): OAuthClientProvider => {
redirectUrl: options.redirectUrl,
...(options.clientMetadataUrl ? { clientMetadataUrl: options.clientMetadataUrl } : {}),
...(options.discovery ? { discoveryState: () => options.discovery } : {}),
...(options.onDiscovery ? { saveDiscoveryState: options.onDiscovery } : {}),
clientMetadata: {
redirect_uris: [options.redirectUrl],
client_name: "opencode",
@@ -169,12 +173,19 @@ export const memoryStore = (): Store => {
export const clientFromCredential = (credential: Credential.OAuth) =>
credential.metadata?.client as OAuthClientInformationMixed | undefined
/** Folds SDK tokens (plus DCR client info and the server URL) into a storable credential. */
/** Reads the authorization server issuer recorded when the credential was obtained. */
export const issuerFromCredential = (credential: Credential.OAuth) => {
const issuer = credential.metadata?.issuer
return typeof issuer === "string" ? issuer : undefined
}
/** Folds SDK tokens (plus client info, issuer, and the server URL) into a storable credential. */
export const toCredential = (input: {
readonly methodID: Integration.MethodID
readonly serverUrl: string
readonly tokens: OAuthTokens
readonly client: OAuthClientInformationMixed | undefined
readonly issuer?: string
}) =>
Credential.OAuth.make({
type: "oauth",
@@ -188,16 +199,23 @@ export const toCredential = (input: {
tokenType: input.tokens.token_type,
...(input.tokens.scope ? { scope: input.tokens.scope } : {}),
...(input.client ? { client: input.client } : {}),
...(input.issuer ? { issuer: input.issuer } : {}),
},
})
/** Reconstructs SDK tokens from a stored credential so the connect-time provider can present them. */
export const toTokens = (credential: Credential.OAuth): OAuthTokens => {
/**
* Reconstructs SDK tokens from a stored credential so the connect-time provider can present them. The refresh
* token is withheld when `issuer` differs from the one recorded at login, so the SDK re-authorizes instead of
* sending it to an authorization server that did not issue it.
*/
export const toTokens = (credential: Credential.OAuth, issuer?: string): OAuthTokens => {
const metadata = credential.metadata ?? {}
const bound = issuerFromCredential(credential)
const refresh = credential.refresh && (!bound || !issuer || bound === issuer)
return {
access_token: credential.access,
token_type: typeof metadata.tokenType === "string" ? metadata.tokenType : "Bearer",
...(credential.refresh ? { refresh_token: credential.refresh } : {}),
...(refresh ? { refresh_token: credential.refresh } : {}),
...(credential.expires ? { expires_in: Math.max(0, Math.floor((credential.expires - Date.now()) / 1000)) } : {}),
...(typeof metadata.scope === "string" ? { scope: metadata.scope } : {}),
}
@@ -312,7 +330,13 @@ export const authorize = (input: {
hasRefreshToken: Boolean(tokens.refresh_token),
expiresIn: tokens.expires_in,
})
return toCredential({ methodID: input.methodID, serverUrl: input.config.url, tokens, client })
return toCredential({
methodID: input.methodID,
serverUrl: input.config.url,
tokens,
client,
issuer: discovery.authorizationServerMetadata?.issuer,
})
})
yield* Effect.tryPromise({
+48
View File
@@ -118,6 +118,53 @@ describe("MCP OAuth", () => {
expect(tokenRequests[0]?.get("refresh_token")).toBe("refresh")
})
test("withholds the refresh token when the authorization server issuer changed", async () => {
const tokenRequests: unknown[] = []
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/.well-known/oauth-authorization-server")
return Response.json({
issuer: "https://other.example.com",
authorization_endpoint: `${url.origin}/authorize`,
token_endpoint: `${url.origin}/token`,
response_types_supported: ["code"],
})
if (request.method === "POST" && url.pathname === "/token") {
tokenRequests.push(await request.text())
return Response.json({ access_token: "next", token_type: "Bearer" })
}
return new Response(null, { status: 404 })
},
})
const credential = Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("oauth"),
access: "expired",
refresh: "refresh",
expires: Date.now() - 1000,
metadata: { serverUrl: server.url.href, tokenType: "Bearer", issuer: server.url.origin },
})
let issuer: string | undefined
const store = McpOAuth.memoryStore()
const oauthProvider = McpOAuth.provider({
redirectUrl: "http://127.0.0.1/callback",
client: { id: "client" },
onDiscovery: (discovery) => {
issuer = discovery.authorizationServerMetadata?.issuer
},
onRedirect: () => undefined,
store: { ...store, tokens: async () => McpOAuth.toTokens(credential, issuer) },
})
const result = await auth(oauthProvider, { serverUrl: server.url.href }).finally(() => server.stop(true))
expect(result).toBe("REDIRECT")
expect(tokenRequests).toHaveLength(0)
expect(McpOAuth.toTokens(credential, server.url.origin).refresh_token).toBe("refresh")
})
test("shares concurrent refreshes for the same token", async () => {
let requests = 0
const pending = Promise.withResolvers<void>()
@@ -228,6 +275,7 @@ describe("MCP OAuth", () => {
expect(registrations).toHaveLength(0)
expect(tokenRequests[0]?.get("client_id")).toBe(McpOAuth.CLIENT_METADATA_URL)
expect(McpOAuth.clientFromCredential(credential)).toEqual({ client_id: McpOAuth.CLIENT_METADATA_URL })
expect(McpOAuth.issuerFromCredential(credential)).toBe(server.url.origin)
})
test("registers dynamically when the server does not accept public clients", async () => {