Skip to content

Enterprise managed authorization#770

Open
radar07 wants to merge 13 commits intomodelcontextprotocol:mainfrom
radar07:enterprise-managed-authorization
Open

Enterprise managed authorization#770
radar07 wants to merge 13 commits intomodelcontextprotocol:mainfrom
radar07:enterprise-managed-authorization

Conversation

@radar07
Copy link

@radar07 radar07 commented Jan 30, 2026

auth, oauthex: implement Enterprise Managed Authorization (SEP-990)
This PR implements Enterprise Managed Authorization (SEP-990) for the Go MCP SDK, enabling MCP Clients and Servers to leverage enterprise Identity Providers for seamless authorization without requiring users to authenticate separately to each MCP Server.
Overview
Enterprise Managed Authorization follows the Identity Assertion Authorization Grant specification (draft-ietf-oauth-identity-assertion-authz-grant), implementing a three-step flow:

  1. Single Sign-On (SSO): User authenticates to the MCP Client via enterprise IdP (Okta, Auth0, Azure AD, etc.)
  2. Token Exchange (RFC 8693): Client exchanges ID Token for Identity Assertion JWT Authorization Grant (ID-JAG) at the IdP
  3. JWT Bearer Grant (RFC 7523): Client exchanges ID-JAG for Access Token at the MCP Server
    This enables:
  • For end users: Single sign-on across MCP Clients and Servers—no manual connection/authorization per server
  • For enterprise admins: Centralized visibility and control over which MCP Servers can be used within the organization
  • For MCP clients: Automatic token acquisition without user interaction for each server

Closes: #628

@maciej-kisiel
Copy link
Contributor

Hi @radar07, thanks for submitting this PR. Could you link the issue that it is addressing?

Also, as a heads-up: it will likely take some time to review your proposal. Both because it's quite large, but more importantly I'm also working on a proposal how to structure the client-side OAuth implementation and this change will need to be aligned with it.

@radar07
Copy link
Author

radar07 commented Jan 31, 2026

Thanks @maciej-kisiel. I updated the description with the SEP that this PR solves.

@radar07
Copy link
Author

radar07 commented Feb 3, 2026

@maciej-kisiel I'd be happy to contribute to OAuth implementation. Let me know if I can help with anything. Just want to know if I should add conformance tests to this because I can see that there are PRs related to conformance tests.

Copy link
Contributor

@maciej-kisiel maciej-kisiel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took a brief look at your PR and I believe we could utilize the oauth2 library more aggressively.

Please also take a look at my PR/proposal for client-side OAuth at #785. I think this PR could be expressed with the proposed abstractions, but your OAuth expertise would make the feedback valuable.


// JWTBearerResponse represents the response from a JWT Bearer grant request
// per RFC 7523. This uses the standard OAuth 2.0 token response format.
type JWTBearerResponse struct {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, changed to oauth2.Token.

}

// JWTBearerError represents an error response from a JWT Bearer grant request.
type JWTBearerError struct {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, changed to oauth2.RetrieveError.

// "mcp-client-secret",
// nil,
// )
func ExchangeJWTBearer(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels like https://pkg.go.dev/golang.org/x/oauth2#Config.Exchange could be used to replicate the behavior of the function. Is there any reason not to use it?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oauth2#Config.Exchange always expect a code which we don't have in our Token Ecxhange. IdPs can reject the request if we don't pass code or pass it as an empty string.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IdPs can reject the request if we don't pass code or pass it as an empty string.

I don't think that should happen. Based on RFC 6749, Section 3.2:

Parameters sent without a value MUST be treated as if they were
omitted from the request. The authorization server MUST ignore
unrecognized request parameters.

mediaType, _, err := mime.ParseMediaType(ct)
if err != nil || mediaType != "application/json" {
return nil, fmt.Errorf("bad content type %q", ct)
ct := strings.TrimSpace(strings.SplitN(res.Header.Get("Content-Type"), ";", 2)[0])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you comment why special behavior is needed in place of mime.ParseMediaType?

// }
//
// resp, err := ExchangeToken(ctx, idpTokenEndpoint, req, clientID, clientSecret, nil)
func ExchangeToken(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like https://pkg.go.dev/golang.org/x/oauth2#Config.Exchange can be used for Token Exchange as well? golang/oauth2#409

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. But it is the same comment as above.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, the Token struct don't have issued_token_type which is required to indicate the type of the token.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, the Token struct don't have issued_token_type which is required to indicate the type of the token.

I believe it could be accessible via Token.Extra, no?


// TokenExchangeResponse represents the response from a token exchange request
// per RFC 8693 Section 2.2.
type TokenExchangeResponse struct {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comments as in oauthex/jwt_bearer.go apply to the request and error types.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, changed to oauth2.

@maciej-kisiel
Copy link
Contributor

Hi @radar07! I have recently merged #785 which I wrote about a few comments above. I think it's a good time to see if you could fit your desired auth flow into the OAuthHandler interface. I believe the Enterprise Managed authorization, as defined by the ext-auth repository should just expose a new implementation of this interface, just as AuthorizationCodeHandler implements the main MCP flow.

I would also ask you to implement this handler and put all the related helper files in a new sub-package of the auth package called extauth to make it clear that the functionality comes from an extension and is subject to different freshness guarantees.

If there is any logic from authorization_code.go that you would like to reuse, feel free to do so. We can move it to a more generic file in the future.

Thanks for working on this project!

@radar07 radar07 force-pushed the enterprise-managed-authorization branch from b3cba20 to f94e462 Compare March 4, 2026 10:48
@radar07
Copy link
Author

radar07 commented Mar 9, 2026

@maciej-kisiel Thanks for the feedback. I made the changes as you suggested. Could you please take a look again?

Copy link
Contributor

@maciej-kisiel maciej-kisiel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't manage to look at all files, I'll continue tomorrow.

In general I still believe we can use the oauth2 package more to reduce the amount of code to maintain. This should be an implementation detail, so we can always go back to custom logic if oauth2 stops being sufficient.

I think I would also simplify the API for OIDC login, to be similar to AuthorizationCodeHandler. Consistency would be an additional plus.

type IDTokenFetcher func(ctx context.Context) (string, error)

// EnterpriseHandlerConfig is the configuration for [EnterpriseHandler].
type EnterpriseHandlerConfig struct {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we document which fields are required and which are optional in their doc comments?

Comment on lines +120 to +125
defer func() {
if resp != nil && resp.Body != nil {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
defer func() {
if resp != nil && resp.Body != nil {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}()
defer resp.Body.Close()
defer io.Copy(io.Discard, resp.Body)

I think we can assume that the response is correctly passed. Good idea with draining Body, could you also apply it in authorization_code.go?

// }
//
// // Use accessToken to call MCP Server APIs
func EnterpriseAuthFlow(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still need this if we have the EnterpriseHandler?


// GetAuthServerMetadatForIssuer fetches authorization server metadata for the given issuer URL.
// It tries standard well-known endpoints (OAuth 2.0 and OIDC) and returns the first successful result.
func GetAuthServerMetadatForIssuer(ctx context.Context, IssuerURL string, httpClient *httpClient) (*oauthex.AuthServerMeta, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to align it with authorization_code.go's getAuthServerMetadata. It can be generalized to not be a method on AuthorizationCodeHandler, but receive the URL as parameter instead of PRM and the client as a parameter instead of using the receiver. It should probably be moved to a different file, maybe something like shared.go?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move this file inside extauth/, it is Enterprise flow specific.


// generateCodeChallenge generates the PKCE code challenge from the verifier
// using SHA256 per RFC 7636 Section 4.2.
func generateCodeChallenge(verifier string) string {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


// generateState generates a cryptographically random state parameter
// for CSRF protection per RFC 6749 Section 10.12.
func generateState() (string, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExpiresAt int64
}

// InitiateOIDCLogin initiates an OIDC Authorization Code flow with PKCE.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you see a situation occurring of InitiateOICDLogin not being used with CompleteOIDCLogin? If not, maybe we could be more opinionated and implement the whole flow as a single function that just takes the "direct user to authorization URL and return the authorization result" as a functional argument, similar to how AuthorizationCodeHandlerConfig takes AuthorizationCodeFetcher?

In general this flow looks very similar to the normal authorization code flow, so maybe we could even reuse some of the data structures? Do you see a risk of the OAuth flow and the OIDC flow diverging in the future?

}

// buildAuthorizationURL constructs the OIDC authorization URL.
func buildAuthorizationURL(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you considered reusing https://godoc.corp.google.com/pkg/google3/third_party/golang/oauth2/oauth2#Config.AuthCodeURL? I believe it supports all that's needed here.

}

// exchangeAuthorizationCode exchanges the authorization code for tokens.
func exchangeAuthorizationCode(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think https://godoc.corp.google.com/pkg/google3/third_party/golang/oauth2/oauth2#Config.Exchange could be used here and if you would follow the suggestion to make the whole OIDC a single function, you could even reuse the oauth2.Config.

Copy link
Contributor

@maciej-kisiel maciej-kisiel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Few more comments. I looked at all Go files, excluding the tests. I will take a look at them when we align on the main logic.

// }
// fmt.Printf("Subject: %s\n", claims.Subject)
// fmt.Printf("Expires: %v\n", claims.Expiry())
func ParseIDJAG(jwt string) (*IDJAGClaims, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can use the golang-jwt/jwt library as an implementation detail for parsing to not replicate the logic?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, is this function used anywhere?

// "mcp-client-secret",
// nil,
// )
func ExchangeJWTBearer(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IdPs can reject the request if we don't pass code or pass it as an empty string.

I don't think that should happen. Based on RFC 6749, Section 3.2:

Parameters sent without a value MUST be treated as if they were
omitted from the request. The authorization server MUST ignore
unrecognized request parameters.

// }
//
// resp, err := ExchangeToken(ctx, idpTokenEndpoint, req, clientID, clientSecret, nil)
func ExchangeToken(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, the Token struct don't have issued_token_type which is required to indicate the type of the token.

I believe it could be accessible via Token.Extra, no?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ext-auth: Enterprise Managed Authorization

2 participants