How OAuth Discovery and Authorization Binding Works for MCP Connections in OpenWork

OpenWork implements a two-step OAuth discovery and authorization binding process that validates the MCP server's advertised issuer against the configured authorization server before persisting credentials, preventing mix-up attacks by ensuring connections only communicate with their intended OAuth provider.

OpenWork's enterprise MCP (Model-Context-Protocol) client manages secure connections to external MCP servers through a rigorous OAuth 2.0 discovery and binding mechanism. This system ensures that when users connect to protected resources, the authorization server used for token issuance is cryptographically bound to the metadata advertised by that resource. Understanding how OAuth discovery and authorization binding works for MCP connections in OpenWork is essential for administrators configuring enterprise deployments and developers extending the platform.

OAuth Discovery Phase

When a user initiates an MCP connection, OpenWork begins by gathering OAuth metadata from the target server. This discovery phase retrieves the authorization server URL and associated metadata required to establish trust.

The OAuthDiscoveryState Interface

The discovery response populates an OAuthDiscoveryState object containing three critical fields. The authorizationServerUrl identifies the OAuth authorization server advertised by the MCP resource. The optional authorizationServerMetadata contains RFC 9207 metadata including the issuer claim. Most importantly, resourceMetadata includes an optional authorization_servers list that may contain resource-scoped discovery aliases—alternative URLs that resolve to the same authorization server.

Discovery Persistence in EnterpriseMcpOAuthProvider

The EnterpriseMcpOAuthProvider class in packages/enterprise-mcp-client/src/oauth-provider.ts (lines 148-156) persists this discovery state. Before storage, the provider validates that the discovered metadata matches any explicitly configured issuer, ensuring that the connection only proceeds if the advertised server aligns with administrative policy.

Authorization Server Binding

Binding ensures that an explicitly selected authorizationServerIssuer matches the discovery metadata, preventing a compromised resource from redirecting authentication to a malicious provider.

The isAuthorizationServerDiscoveryBound Function

Located in packages/enterprise-mcp-client/src/oauth-discovery-binding.ts (lines 22-48), the isAuthorizationServerDiscoveryBound function implements the core validation logic. This function performs three sequential checks to verify that the issuer selected by the administrator or user is legitimate for the discovered resource.

Direct Binding and Resource-Scoped Aliases

The binding logic first checks direct binding using isEquivalentOAuthDiscoveryAlias, which normalizes the scheme and root URL to compare the discovered authorizationServerUrl against the expected issuer. It also verifies the issuer appears in the resource-advertised authorization_servers list if present.

Second, the function checks for resource-scoped discovery aliases via isResourceScopedDiscoveryAlias. This handles cases where the resource advertises the same issuer using different URL formats—such as variations with or without trailing slashes—ensuring semantic equivalence without requiring exact string matches.

Canonical Issuer Verification

Finally, the function performs a canonical issuer check against the optional authorizationServerMetadata. If metadata is present, the issuer field must either match the expected issuer exactly or be undefined. Any mismatch triggers an MCP_OAUTH_ISSUER_MISMATCH error, an EnterpriseMcpOAuthContractError that halts the connection process.

Validation During Authorization Response

Binding validation occurs at two critical lifecycle points: during initial discovery persistence and when loading stored discovery state.

Preventing Mix-Up Attacks

When the OAuth callback is received at the redirect endpoint, OpenWork validates the response issuer against the bound discovery state using validateMcpAuthorizationResponseIssuer in packages/enterprise-mcp-client/src/authorization-response.ts (lines 33-108). This function ensures the iss claim from the authorization response matches the bound issuer discovered earlier. For providers supporting the authorization_response_iss_parameter, the implementation also supports distinct-redirect-uri defenses as an additional mix-up protection mechanism.

The saveDiscoveryState method invokes assertDiscoveryBinding before persisting state, while discoveryState() re-runs binding checks when loading credentials. This dual validation protects against configuration drift or compromised persisted data.

Security and Operational Benefits

This binding architecture provides three primary advantages:

  • Attack Prevention: Blocks mix-up attacks where a malicious resource attempts to redirect users to an attacker-controlled OAuth provider
  • Credential Scoping: Ensures tokens, refresh tokens, PKCE verifiers, and client registrations remain strictly scoped to the bound issuer
  • Operational Safety: Changing the selected issuer automatically invalidates stored discovery state and credentials, forcing a fresh discovery and registration flow that prevents credential leakage across identity providers

Implementation Example

The following TypeScript demonstrates the complete lifecycle from provider initialization through callback validation:

// 1️⃣ Create the provider (used by Den to manage the MCP connection)
import { EnterpriseMcpOAuthProvider } from "@openwork/enterprise-mcp-client"

const provider = new EnterpriseMcpOAuthProvider({
  redirectUri: "https://my.den/api/v1/mcp-connections/oauth/callback",
  connectionId: "conn-123",
  persistence,                 // Your persistence adapters (client, tokens, discovery, …)
  flow: { kind: "connect", authorizationId: "auth-sig-xyz" },
  clientName: "OpenWork Desktop",
  clock,
  lifecycle,
  authorizationTransactionTtlMs: 10 * 60_000,
  expirationSkewMs: 5_000,
  oauthConfiguration: {
    // ← optional: admin-selected issuer
    authorizationServerIssuer: "https://login.example.com",
    requestedScopes: ["offline_access", "profile"],
    applicationType: "web",
  },
})

// 2️⃣ Run discovery – loads and validates binding
const discovery = await provider.discoveryState()
if (!discovery) {
  // No persisted discovery – trigger a fresh fetch from the MCP server
  // (the client code would call the MCP discovery endpoint here)
}

// 3️⃣ Save discovery after a successful fetch
await provider.saveDiscoveryState({
  authorizationServerUrl: "https://login.example.com",
  resourceMetadata: {
    resource: "https://api.example.com",
    authorization_servers: ["https://login.example.com"],
  },
})

// 4️⃣ Authorize – creates the URL the user must visit
const authUrl = new URL("https://login.example.com/authorize")
authUrl.searchParams.set("client_id", "my-client")
authUrl.searchParams.set("redirect_uri", provider.redirectUrl)
authUrl.searchParams.set("response_type", "code")
authUrl.searchParams.set("scope", "offline_access profile")
provider.redirectToAuthorization(authUrl)

// 5️⃣ After callback, validate the response issuer
import { validateMcpAuthorizationResponseIssuer } from "@openwork/enterprise-mcp-client"
const validation = validateMcpAuthorizationResponseIssuer({
  discoveryState: discovery,
  expectedIssuer: provider.authorizationServerIssuer,
  responseIssuer: request.query.iss as string | undefined,
  mixUpDefense: "response-issuer",
})
// → throws if the issuer does not match the bound discovery state

Summary

  • Discovery retrieves metadata: OpenWork fetches OAuthDiscoveryState from the MCP server, capturing the authorizationServerUrl and optional resource metadata
  • Binding validates trust: The isAuthorizationServerDiscoveryBound function in oauth-discovery-binding.ts verifies that the configured issuer matches the discovered metadata through direct alias comparison and resource-scoped validation
  • Validation occurs at multiple stages: Binding checks run during saveDiscoveryState and discoveryState() loading, with additional issuer validation during the OAuth callback via validateMcpAuthorizationResponseIssuer
  • Security against mix-up attacks: The system throws MCP_OAUTH_ISSUER_MISMATCH if the authorization response issuer does not match the bound discovery state, preventing credential leakage to unauthorized providers
  • File locations: Core logic resides in packages/enterprise-mcp-client/src/oauth-discovery-binding.ts, packages/enterprise-mcp-client/src/oauth-provider.ts, and packages/enterprise-mcp-client/src/authorization-response.ts

Frequently Asked Questions

What happens if the MCP server advertises a different authorization server than configured?

OpenWork throws an MCP_OAUTH_ISSUER_MISMATCH error and refuses to persist the discovery state or proceed with authentication. The isAuthorizationServerDiscoveryBound function checks that the discovered authorizationServerUrl is an equivalent alias of the expected issuer and appears in the resource's authorization_servers list, preventing connections to unverified providers.

How does OpenWork handle URL variations for the same OAuth issuer?

The binding logic uses isEquivalentOAuthDiscoveryAlias to normalize scheme and root URL comparisons, while isResourceScopedDiscoveryAlias handles semantic equivalents like trailing slash variations. This ensures that https://login.example.com and https://login.example.com/ are treated as the same issuer without requiring exact string matching.

Where is the OAuth callback validation implemented?

The validateMcpAuthorizationResponseIssuer function in packages/enterprise-mcp-client/src/authorization-response.ts validates the OAuth callback. It compares the iss parameter from the authorization response against the bound discovery state, supporting both standard issuer validation and the authorization_response_iss_parameter defense for distinct redirect URIs.

Can changing the authorization server issuer invalidate existing MCP connections?

Yes. When the authorizationServerIssuer configuration changes, the binding check in discoveryState() fails because the new issuer does not match the persisted discovery metadata. This invalidates stored credentials and forces a fresh discovery flow, ensuring tokens remain scoped to the correct provider and preventing cross-issuer credential reuse.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →