From f155989ee35e0ffbca908d1572e506355d92d6a6 Mon Sep 17 00:00:00 2001 From: Akshita <110122283+akshita317@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:57:30 +0530 Subject: [PATCH] auth/extauth: only forward an ID-JAG to the MCP authorization server EnterpriseHandler.Authorize asks the IdP to exchange the user's ID token for an ID-JAG, then sends the token it gets back to the MCP Server's authorization server as a JWT bearer assertion. It did not look at the issued_token_type of the exchange response, which ExchangeToken only checks is present. If the IdP issued another type of token, such as an access token, that token was handed to a different party than the one that issued it. Refuse the response unless issued_token_type is urn:ietf:params:oauth:token-type:id-jag, the value the Identity Assertion Authorization Grant specification defines for it. The fake IdP gets an IssuedTokenType option, and TestEnterpriseHandler_Authorize_NotIDJAG checks that such a token is not forwarded even to an authorization server that would accept it. It fails without the change. --- auth/extauth/enterprise_handler.go | 7 ++++ auth/extauth/enterprise_handler_test.go | 56 +++++++++++++++++++++++++ internal/oauthtest/fake_idp_server.go | 10 ++++- 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/auth/extauth/enterprise_handler.go b/auth/extauth/enterprise_handler.go index c5d4f12b6..f2cd91e1a 100644 --- a/auth/extauth/enterprise_handler.go +++ b/auth/extauth/enterprise_handler.go @@ -192,6 +192,13 @@ func (h *EnterpriseHandler) Authorize(ctx context.Context, req *http.Request, re if err != nil { return fmt.Errorf("token exchange failed: %w", err) } + // The token is sent on to the MCP Server's authorization server, a different + // party than the IdP. Only forward it if the IdP says it is an ID-JAG, so + // that any other token the IdP issued, such as an access token, does not + // leave it. + if issued, _ := idJAGToken.Extra("issued_token_type").(string); issued != oauthex.TokenTypeIDJAG { + return fmt.Errorf("token exchange issued a token of type %q, want %q", issued, oauthex.TokenTypeIDJAG) + } // Step 4: Discover MCP Server token endpoint mcpMeta, err := auth.GetAuthServerMetadata(ctx, h.config.MCPAuthServerURL, httpClient) diff --git a/auth/extauth/enterprise_handler_test.go b/auth/extauth/enterprise_handler_test.go index 90bcb02f5..840b5cfc6 100644 --- a/auth/extauth/enterprise_handler_test.go +++ b/auth/extauth/enterprise_handler_test.go @@ -303,6 +303,62 @@ func TestEnterpriseHandler_ConcurrentAuthorize(t *testing.T) { wg.Wait() } +// TestEnterpriseHandler_Authorize_NotIDJAG checks that a token the IdP did not +// issue as an ID-JAG is not sent on to the MCP Server's authorization server, +// even one that would accept it. +func TestEnterpriseHandler_Authorize_NotIDJAG(t *testing.T) { + const idpAccessToken = "idp-access-token" + + idpServer := oauthtest.NewFakeIdPServer(oauthtest.IdPConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "idp_client_id": {Secret: "idp_secret"}, + }, + TokenExchangeConfig: &oauthtest.TokenExchangeConfig{ + IDJAGToken: idpAccessToken, + IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token", + }, + }) + idpServer.Start(t) + + mcpAuthServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "mcp_client_id": {Secret: "mcp_secret"}, + }, + }, + JWTBearerConfig: &oauthtest.JWTBearerConfig{ + ValidAssertions: []string{idpAccessToken}, + }, + }) + mcpAuthServer.Start(t) + + config := validEnterpriseHandlerConfig() + config.IdPIssuerURL = idpServer.URL() + config.IdPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "idp_secret"} + config.MCPAuthServerURL = mcpAuthServer.URL() + config.MCPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "mcp_secret"} + + handler, err := NewEnterpriseHandler(config) + if err != nil { + t.Fatalf("NewEnterpriseHandler failed: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "https://mcp.example.com/api", nil) + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + Request: req, + } + err = handler.Authorize(context.Background(), req, resp) + if err == nil || !strings.Contains(err.Error(), "token of type") { + t.Fatalf("Authorize error = %v, want an issued token type error", err) + } + if ts, _ := handler.TokenSource(context.Background()); ts != nil { + t.Error("TokenSource is set after the token exchange issued a token that is not an ID-JAG") + } +} + func TestEnterpriseHandler_Authorize_IDTokenFetcherError(t *testing.T) { config := validEnterpriseHandlerConfig() config.IDTokenFetcher = func(ctx context.Context) (*oauth2.Token, error) { diff --git a/internal/oauthtest/fake_idp_server.go b/internal/oauthtest/fake_idp_server.go index b7057ffdb..e5c2c2ef5 100644 --- a/internal/oauthtest/fake_idp_server.go +++ b/internal/oauthtest/fake_idp_server.go @@ -25,6 +25,10 @@ type TokenExchangeConfig struct { // IDJAGToken is the ID-JAG value returned from token exchange. // Defaults to "test-id-jag-token" if empty. IDJAGToken string + + // IssuedTokenType is the issued_token_type returned from token exchange. + // Defaults to oauthex.TokenTypeIDJAG if empty. + IssuedTokenType string } // IdPConfig holds configuration for [FakeIdPServer]. @@ -218,11 +222,15 @@ func (s *FakeIdPServer) handleTokenExchangeGrant(w http.ResponseWriter, r *http. if idJAG == "" { idJAG = "test-id-jag-token" } + issuedTokenType := s.config.TokenExchangeConfig.IssuedTokenType + if issuedTokenType == "" { + issuedTokenType = oauthex.TokenTypeIDJAG + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "access_token": idJAG, - "issued_token_type": oauthex.TokenTypeIDJAG, + "issued_token_type": issuedTokenType, "token_type": "N_A", "expires_in": 300, })