diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 25711856d7..b6926e4206 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -30,6 +30,7 @@ import ( "github.com/thunder-id/thunderid/internal/system/jose/jwt" "github.com/thunder-id/thunderid/internal/system/kmprovider/common" "github.com/thunder-id/thunderid/internal/system/log" + "github.com/thunder-id/thunderid/internal/system/mcp" "github.com/thunder-id/thunderid/internal/system/middleware" "github.com/thunder-id/thunderid/internal/system/revocationcache" "github.com/thunder-id/thunderid/internal/system/security" @@ -83,7 +84,7 @@ func main() { } // Register the services. - jwtService, runtimeCryptoSvc, importService := registerServices(mux, cacheManager) + jwtService, runtimeCryptoSvc, importService, mcpServer := registerServices(mux, cacheManager) // When invoked as the bootstrap one-shot (`thunderid bootstrap`), create the // default resources in-process and exit without starting the HTTP server. @@ -102,6 +103,11 @@ func main() { revocationEnforcer, revocationSyncer := initRevocationCache(ctx, logger, cfg) revocationSyncer.Start(ctx) + // Mount the MCP server's routes now that the revocation enforcer exists — DefaultGuard uses it + // to authenticate MCP requests with the same verification and revocation logic as the REST gate. + mcpGuard, mcpResourceMeta := mcp.DefaultGuard(jwtService, revocationEnforcer) + mcp.Initialize(mux, mcpServer, mcpGuard, mcpResourceMeta) + // Register static file handlers for frontend applications. registerStaticFileHandlers(ctx, logger, mux, serverHome) diff --git a/backend/cmd/server/servicemanager.go b/backend/cmd/server/servicemanager.go index 46223c73a0..a645f2a6d9 100644 --- a/backend/cmd/server/servicemanager.go +++ b/backend/cmd/server/servicemanager.go @@ -11,6 +11,8 @@ import ( "strings" "time" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/thunder-id/thunderid/internal/actorprovider" "github.com/thunder-id/thunderid/internal/agent" "github.com/thunder-id/thunderid/internal/application" @@ -86,6 +88,7 @@ import ( "github.com/thunder-id/thunderid/internal/system/kmprovider" "github.com/thunder-id/thunderid/internal/system/kmprovider/defaultkm/pki" "github.com/thunder-id/thunderid/internal/system/log" + "github.com/thunder-id/thunderid/internal/system/mcp" "github.com/thunder-id/thunderid/internal/system/observability" "github.com/thunder-id/thunderid/internal/system/resourcedependency" @@ -108,7 +111,7 @@ var observabilitySvc observability.ObservabilityServiceInterface // to the number of services. Eventhough it has many branching statements, almost all are early exits so cognitive // complexity is low. func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterface) ( - jwt.JWTServiceInterface, kmprovider.RuntimeCryptoProvider, importer.ImportServiceInterface) { + jwt.JWTServiceInterface, kmprovider.RuntimeCryptoProvider, importer.ImportServiceInterface, *mcpsdk.Server) { logger := log.GetLogger() // Service registration runs during application startup, outside any request. @@ -139,7 +142,9 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa observabilitySvc = observability.Initialize(config.GetServerRuntime().Config.Observability) // Initialize MCP server early so packages initializing below can register tools. - mcpServer := mcp.Initialize(mux, jwtService) + // Route mounting (mcp.Initialize) happens later in main(), once the token-revocation enforcer + // exists — mcp.DefaultGuard needs it to reject revoked tokens the same way the REST gate does. + mcpServer := mcp.NewServer() // List to collect exporters from each package var exporters []declarativeresource.ResourceExporter @@ -497,7 +502,7 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa healthSvc := healthcheckservice.Initialize(dbprovider.GetDBProvider(), dbprovider.GetRedisProvider()) services.NewHealthCheckService(mux, healthSvc) - return jwtService, runtimeCryptoSvc, importService + return jwtService, runtimeCryptoSvc, importService, mcpServer } // initAttestationProvider initializes the platform attestation provider, terminating server startup diff --git a/backend/internal/system/mcp/auth/token_verifier.go b/backend/internal/system/mcp/auth/token_verifier.go index c4f83c2fcc..3e979d46c7 100644 --- a/backend/internal/system/mcp/auth/token_verifier.go +++ b/backend/internal/system/mcp/auth/token_verifier.go @@ -7,69 +7,52 @@ package auth import ( "context" "net/http" - "strings" "time" "github.com/modelcontextprotocol/go-sdk/auth" - "github.com/thunder-id/thunderid/internal/system/jose/jwt" - "github.com/thunder-id/thunderid/internal/system/log" + "github.com/thunder-id/thunderid/internal/system/security" ) -// NewTokenVerifier creates a TokenVerifier function that verifies tokens -// issued by the OAuth server. This implements the auth.TokenVerifier -// function type from the MCP SDK. -func NewTokenVerifier( - jwtService jwt.JWTServiceInterface, - issuer string, - mcpURL string, -) auth.TokenVerifier { - logger := log.GetLogger().With(log.String(log.LoggerKeyComponentName, "MCPTokenVerifier")) - +// securityContextExtraKey is the key under which the authenticated security.SecurityContext is +// stored in TokenInfo.Extra. The go-sdk's TokenVerifier can only return a *TokenInfo — it has no +// way to attach anything else to the request context that RequireBearerToken hands to the next +// handler — so this is how the SecurityContext reaches the caller that mounts the guard (see +// mcp.DefaultGuard, which reads it back via SecurityContextFromTokenInfo). +const securityContextExtraKey = "securityContext" + +// NewTokenVerifier creates a TokenVerifier that authenticates MCP requests using +// bearerAuthenticator — the same verification and revocation logic the REST API gate uses. This +// implements the auth.TokenVerifier function type from the MCP SDK. +func NewTokenVerifier(bearerAuthenticator *security.BearerAuthenticator) auth.TokenVerifier { return func(ctx context.Context, token string, req *http.Request) (*auth.TokenInfo, error) { - // Verify JWT signature and claims (iss, aud, exp, nbf) - if err := jwtService.VerifyJWT(ctx, token, mcpURL, issuer); err != nil { - logger.Error(ctx, "JWT verification failed", log.String("error", err.Error.DefaultValue)) - return nil, auth.ErrInvalidToken - } - - // Decode payload to extract claims for TokenInfo - payload, err := jwt.DecodeJWTPayload(token) + securityCtx, err := bearerAuthenticator.Authenticate(ctx, token) if err != nil { - logger.Error(ctx, "Failed to decode JWT payload", log.Error(err)) return nil, auth.ErrInvalidToken } - // Extract expiration time for SDK middleware + enrichedCtx := security.WithSecurityContext(ctx, securityCtx) + var expiration time.Time - if exp, ok := payload["exp"].(float64); ok { + if exp, ok := security.GetAttribute(enrichedCtx, "exp").(float64); ok { expiration = time.Unix(int64(exp), 0) } - // Extract scopes from token - var scopes []string - if scopeStr, ok := payload["scope"].(string); ok && scopeStr != "" { - scopes = strings.Fields(scopeStr) - logger.Debug(ctx, "Token scopes extracted", - log.String("scopes", strings.Join(scopes, ",")), - log.String("path", req.URL.Path)) - } else { - logger.Warn(ctx, "Token missing 'scope' claim", log.String("path", req.URL.Path)) - } - - // Extract user ID from 'sub' claim - userID := "" - if sub, ok := payload["sub"].(string); ok && sub != "" { - userID = sub - } - - // Build TokenInfo with user ID, scopes, and expiration - tokenInfo := &auth.TokenInfo{ - UserID: userID, - Scopes: scopes, + return &auth.TokenInfo{ + UserID: security.GetSubject(enrichedCtx), + Scopes: security.GetPermissions(enrichedCtx), Expiration: expiration, - } + Extra: map[string]any{securityContextExtraKey: securityCtx}, + }, nil + } +} - return tokenInfo, nil +// SecurityContextFromTokenInfo returns the security.SecurityContext embedded in ti.Extra by the +// verifier built by NewTokenVerifier, or nil if ti is nil or carries none. +func SecurityContextFromTokenInfo(ti *auth.TokenInfo) *security.SecurityContext { + if ti == nil { + return nil } + sc, _ := ti.Extra[securityContextExtraKey].(*security.SecurityContext) + return sc } diff --git a/backend/internal/system/mcp/auth/token_verifier_test.go b/backend/internal/system/mcp/auth/token_verifier_test.go index bb830029d2..54b1c4b6a2 100644 --- a/backend/internal/system/mcp/auth/token_verifier_test.go +++ b/backend/internal/system/mcp/auth/token_verifier_test.go @@ -5,163 +5,86 @@ package auth import ( "context" - "crypto" "encoding/base64" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" "time" - tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" - "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" - "github.com/modelcontextprotocol/go-sdk/auth" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" -) -const ( - testIssuer = "https://localhost:8090" - testMCPURL = "https://localhost:8090/mcp" + "github.com/thunder-id/thunderid/internal/system/config" + "github.com/thunder-id/thunderid/internal/system/security" + "github.com/thunder-id/thunderid/tests/mocks/jose/jwtmock" ) -// MockJWTService is a mock implementation of jwt.JWTServiceInterface -type MockJWTService struct { - mock.Mock -} - -func (m *MockJWTService) GetPublicKey() crypto.PublicKey { - args := m.Called() - if args.Get(0) == nil { - return nil - } - return args.Get(0).(crypto.PublicKey) -} - -func (m *MockJWTService) GenerateJWT( - ctx context.Context, - sub, iss string, - validityPeriod int64, - claims map[string]interface{}, - typ, alg string, -) (string, int64, *tidcommon.ServiceError) { - args := m.Called(ctx, sub, iss, validityPeriod, claims, typ, alg) - return args.String(0), args.Get(1).(int64), args.Get(2).(*tidcommon.ServiceError) -} +const testMCPURL = "https://localhost:8090/mcp" -func (m *MockJWTService) VerifyJWT( - ctx context.Context, - jwtToken string, - expectedAud string, - expectedIss string, -) *tidcommon.ServiceError { - args := m.Called(ctx, jwtToken, expectedAud, expectedIss) - if args.Get(0) == nil { - return nil - } - return args.Get(0).(*tidcommon.ServiceError) +// fakeRevocationEnforcer is a minimal test double for security.RevocationEnforcerInterface — that +// interface has no mockery-generated mock exported outside the security package, so this is +// standalone rather than a generated mock. +type fakeRevocationEnforcer struct { + err error } -func (m *MockJWTService) VerifyJWTWithPublicKey( - ctx context.Context, - jwtToken string, - keyRef providers.KeyRef, - expectedAud string, - expectedIss string, -) *tidcommon.ServiceError { - args := m.Called(ctx, jwtToken, keyRef, expectedAud, expectedIss) - if args.Get(0) == nil { - return nil - } - return args.Get(0).(*tidcommon.ServiceError) +func (f *fakeRevocationEnforcer) EnsureNotRevoked(context.Context, security.RevocationIdentity) error { + return f.err } -func (m *MockJWTService) VerifyJWTWithJWKS( - ctx context.Context, - jwtToken string, - jwksURL string, - expectedAud string, - expectedIss string, -) *tidcommon.ServiceError { - args := m.Called(ctx, jwtToken, jwksURL, expectedAud, expectedIss) - if args.Get(0) == nil { - return nil - } - return args.Get(0).(*tidcommon.ServiceError) +type TokenVerifierTestSuite struct { + suite.Suite + mockJWT *jwtmock.JWTServiceInterfaceMock + revocation *fakeRevocationEnforcer } -func (m *MockJWTService) VerifyJWTSignature(ctx context.Context, jwtToken string) *tidcommon.ServiceError { - args := m.Called(ctx, jwtToken) - if args.Get(0) == nil { - return nil - } - return args.Get(0).(*tidcommon.ServiceError) +func (suite *TokenVerifierTestSuite) SetupTest() { + suite.mockJWT = jwtmock.NewJWTServiceInterfaceMock(suite.T()) + suite.revocation = &fakeRevocationEnforcer{} + // Empty runtime config so the token's absent "iss" claim ("") matches the self-issued branch + // (Config.JWT.Issuer == ""), same setup the security package's own authenticator tests use. + config.ResetServerRuntime() + _ = config.InitializeServerRuntime("", &config.Config{}) } -func (m *MockJWTService) VerifyJWTSignatureWithPublicKey( - ctx context.Context, - jwtToken string, - keyRef providers.KeyRef, -) *tidcommon.ServiceError { - args := m.Called(ctx, jwtToken, keyRef) - if args.Get(0) == nil { - return nil - } - return args.Get(0).(*tidcommon.ServiceError) +func (suite *TokenVerifierTestSuite) TearDownTest() { + suite.mockJWT.AssertExpectations(suite.T()) + config.ResetServerRuntime() } -func (m *MockJWTService) VerifyJWTSignatureWithJWKS( - ctx context.Context, - jwtToken string, - jwksURL string, -) *tidcommon.ServiceError { - args := m.Called(ctx, jwtToken, jwksURL) - if args.Get(0) == nil { - return nil - } - return args.Get(0).(*tidcommon.ServiceError) +func TestTokenVerifierTestSuite(t *testing.T) { + suite.Run(t, new(TokenVerifierTestSuite)) } -type TokenVerifierTestSuite struct { - suite.Suite +func encodeTestToken(payload map[string]interface{}) string { + payloadJSON, _ := json.Marshal(payload) + payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON) + return "header." + payloadB64 + ".signature" } -func TestTokenVerifierTestSuite(t *testing.T) { - suite.Run(t, new(TokenVerifierTestSuite)) +func (suite *TokenVerifierTestSuite) newVerifier() auth.TokenVerifier { + bearerAuthenticator := security.NewBearerAuthenticator(suite.mockJWT, suite.revocation, testMCPURL) + return NewTokenVerifier(bearerAuthenticator) } func (suite *TokenVerifierTestSuite) TestNewTokenVerifier_Success() { - mockJWTService := new(MockJWTService) - issuer := testIssuer - mcpURL := testMCPURL - - // Create test JWT payload now := time.Now().Unix() - payload := map[string]interface{}{ + testToken := encodeTestToken(map[string]interface{}{ "sub": "user123", "exp": float64(now + 3600), "scope": "openid profile email", - } - payloadJSON, _ := json.Marshal(payload) - payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON) - testToken := "header." + payloadB64 + ".signature" - - // Mock JWT verification to succeed - mockJWTService.On("VerifyJWT", mock.Anything, testToken, mcpURL, issuer).Return(nil) - - // Create token verifier - verifier := NewTokenVerifier(mockJWTService, issuer, mcpURL) + }) - // Create test request - req := httptest.NewRequest(http.MethodGet, "/mcp/tools", nil) - ctx := context.Background() + suite.mockJWT.On("VerifyJWT", mock.Anything, testToken, testMCPURL, "").Return(nil) - // Call verifier - tokenInfo, err := verifier(ctx, testToken, req) + verifier := suite.newVerifier() + req := httptest.NewRequest(http.MethodGet, "/mcp", nil) + tokenInfo, err := verifier(context.Background(), testToken, req) - // Assertions assert.NoError(suite.T(), err) assert.NotNil(suite.T(), tokenInfo) assert.Equal(suite.T(), "user123", tokenInfo.UserID) @@ -170,137 +93,97 @@ func (suite *TokenVerifierTestSuite) TestNewTokenVerifier_Success() { assert.Contains(suite.T(), tokenInfo.Scopes, "email") assert.False(suite.T(), tokenInfo.Expiration.IsZero()) - mockJWTService.AssertExpectations(suite.T()) + // The SecurityContext survives the round trip through TokenInfo.Extra, so mcp.DefaultGuard can + // attach it to the outgoing request context exactly as the REST gate would. + secCtx := SecurityContextFromTokenInfo(tokenInfo) + if assert.NotNil(suite.T(), secCtx) { + enrichedCtx := security.WithSecurityContext(context.Background(), secCtx) + assert.Equal(suite.T(), "user123", security.GetSubject(enrichedCtx)) + } } func (suite *TokenVerifierTestSuite) TestNewTokenVerifier_JWTVerificationFailed() { - mockJWTService := new(MockJWTService) - issuer := testIssuer - mcpURL := testMCPURL testToken := "invalid.token.here" - // Mock JWT verification to fail - mockJWTService.On("VerifyJWT", mock.Anything, testToken, mcpURL, issuer).Return(&tidcommon.ServiceError{ - ErrorDescription: tidcommon.I18nMessage{DefaultValue: "invalid token"}, - }) + suite.mockJWT.On("VerifyJWT", mock.Anything, testToken, testMCPURL, "").Return(nil) + + verifier := suite.newVerifier() + req := httptest.NewRequest(http.MethodGet, "/mcp", nil) + tokenInfo, err := verifier(context.Background(), testToken, req) + + assert.Error(suite.T(), err) + assert.Nil(suite.T(), tokenInfo) + assert.Equal(suite.T(), auth.ErrInvalidToken, err) +} - // Create token verifier - verifier := NewTokenVerifier(mockJWTService, issuer, mcpURL) +func (suite *TokenVerifierTestSuite) TestNewTokenVerifier_RevokedTokenRejected() { + now := time.Now().Unix() + testToken := encodeTestToken(map[string]interface{}{ + "sub": "user123", + "exp": float64(now + 3600), + "jti": "revoked-jti", + }) - // Create test request - req := httptest.NewRequest(http.MethodGet, "/mcp/tools", nil) - ctx := context.Background() + suite.mockJWT.On("VerifyJWT", mock.Anything, testToken, testMCPURL, "").Return(nil) + suite.revocation.err = errors.New("token revoked") - // Call verifier - tokenInfo, err := verifier(ctx, testToken, req) + verifier := suite.newVerifier() + req := httptest.NewRequest(http.MethodGet, "/mcp", nil) + tokenInfo, err := verifier(context.Background(), testToken, req) - // Assertions assert.Error(suite.T(), err) assert.Nil(suite.T(), tokenInfo) assert.Equal(suite.T(), auth.ErrInvalidToken, err) - - mockJWTService.AssertExpectations(suite.T()) } func (suite *TokenVerifierTestSuite) TestNewTokenVerifier_InvalidPayload() { - mockJWTService := new(MockJWTService) - issuer := testIssuer - mcpURL := testMCPURL - - // Create invalid JWT payload (not base64) testToken := "header.invalid-payload.signature" - // Mock JWT verification to succeed - mockJWTService.On("VerifyJWT", mock.Anything, testToken, mcpURL, issuer).Return(nil) - - // Create token verifier - verifier := NewTokenVerifier(mockJWTService, issuer, mcpURL) + suite.mockJWT.On("VerifyJWT", mock.Anything, testToken, testMCPURL, "").Return(nil) - // Create test request - req := httptest.NewRequest(http.MethodGet, "/mcp/tools", nil) - ctx := context.Background() + verifier := suite.newVerifier() + req := httptest.NewRequest(http.MethodGet, "/mcp", nil) + tokenInfo, err := verifier(context.Background(), testToken, req) - // Call verifier - tokenInfo, err := verifier(ctx, testToken, req) - - // Assertions assert.Error(suite.T(), err) assert.Nil(suite.T(), tokenInfo) assert.Equal(suite.T(), auth.ErrInvalidToken, err) - - mockJWTService.AssertExpectations(suite.T()) } func (suite *TokenVerifierTestSuite) TestNewTokenVerifier_NoScopes() { - mockJWTService := new(MockJWTService) - issuer := testIssuer - mcpURL := testMCPURL - - // Create test JWT payload without scopes now := time.Now().Unix() - payload := map[string]interface{}{ + testToken := encodeTestToken(map[string]interface{}{ "sub": "user123", "exp": float64(now + 3600), - } - payloadJSON, _ := json.Marshal(payload) - payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON) - testToken := "header." + payloadB64 + ".signature" - - // Mock JWT verification to succeed - mockJWTService.On("VerifyJWT", mock.Anything, testToken, mcpURL, issuer).Return(nil) - - // Create token verifier - verifier := NewTokenVerifier(mockJWTService, issuer, mcpURL) + }) - // Create test request - req := httptest.NewRequest(http.MethodGet, "/mcp/tools", nil) - ctx := context.Background() + suite.mockJWT.On("VerifyJWT", mock.Anything, testToken, testMCPURL, "").Return(nil) - // Call verifier - tokenInfo, err := verifier(ctx, testToken, req) + verifier := suite.newVerifier() + req := httptest.NewRequest(http.MethodGet, "/mcp", nil) + tokenInfo, err := verifier(context.Background(), testToken, req) - // Assertions assert.NoError(suite.T(), err) assert.NotNil(suite.T(), tokenInfo) assert.Equal(suite.T(), "user123", tokenInfo.UserID) assert.Empty(suite.T(), tokenInfo.Scopes) - - mockJWTService.AssertExpectations(suite.T()) } func (suite *TokenVerifierTestSuite) TestNewTokenVerifier_EmptyUserID() { - mockJWTService := new(MockJWTService) - issuer := testIssuer - mcpURL := testMCPURL - - // Create test JWT payload without sub claim now := time.Now().Unix() - payload := map[string]interface{}{ + testToken := encodeTestToken(map[string]interface{}{ "exp": float64(now + 3600), "scope": "openid", - } - payloadJSON, _ := json.Marshal(payload) - payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON) - testToken := "header." + payloadB64 + ".signature" - - // Mock JWT verification to succeed - mockJWTService.On("VerifyJWT", mock.Anything, testToken, mcpURL, issuer).Return(nil) - - // Create token verifier - verifier := NewTokenVerifier(mockJWTService, issuer, mcpURL) + }) - // Create test request - req := httptest.NewRequest(http.MethodGet, "/mcp/tools", nil) - ctx := context.Background() + suite.mockJWT.On("VerifyJWT", mock.Anything, testToken, testMCPURL, "").Return(nil) - // Call verifier - tokenInfo, err := verifier(ctx, testToken, req) + verifier := suite.newVerifier() + req := httptest.NewRequest(http.MethodGet, "/mcp", nil) + tokenInfo, err := verifier(context.Background(), testToken, req) - // Assertions assert.NoError(suite.T(), err) assert.NotNil(suite.T(), tokenInfo) assert.Equal(suite.T(), "", tokenInfo.UserID) assert.Contains(suite.T(), tokenInfo.Scopes, "openid") - - mockJWTService.AssertExpectations(suite.T()) } diff --git a/backend/internal/system/mcp/init.go b/backend/internal/system/mcp/init.go index 3a0a5cdf96..45d591ae61 100644 --- a/backend/internal/system/mcp/init.go +++ b/backend/internal/system/mcp/init.go @@ -17,44 +17,97 @@ import ( "github.com/thunder-id/thunderid/internal/system/security" ) -// Initialize initializes the MCP server and registers its routes with the provided mux. +// NewServer creates the MCP server so other packages initializing during startup can register +// tools on it. Call this first; call Initialize once a guard is available to actually mount its +// HTTP routes (which may depend on services, such as token revocation, that aren't ready this +// early in startup). +func NewServer() *mcpsdk.Server { + return newServer() +} + +// Initialize mounts mcpServer's routes on mux, securing them with the given guard. resourceMeta, if +// non-nil, is published at OAuthProtectedResourceMetadataPath for MCP client discovery; callers +// that pass a guard with no discoverable authorization server (e.g. one that accepts every +// request) should pass nil. func Initialize( mux *http.ServeMux, - jwtService jwt.JWTServiceInterface, -) *mcpsdk.Server { + mcpServer *mcpsdk.Server, + guard func(http.Handler) http.Handler, + resourceMeta *oauthex.ProtectedResourceMetadata, +) { + httpHandler := mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server { + return mcpServer + }, nil) + + securedHandler := guard(httpHandler) + + // Register protected resource metadata endpoint, if the guard has an authorization server to + // advertise. + if resourceMeta != nil { + mux.Handle(OAuthProtectedResourceMetadataPath, auth.ProtectedResourceMetadataHandler(resourceMeta)) + } + + // Register MCP routes + mux.Handle(MCPEndpointPath, securedHandler) + mux.Handle(MCPEndpointPath+"/", securedHandler) +} + +// DefaultGuard builds the bearer-token guard and resource metadata for a binary that runs its own +// OAuth2 authorization server: requests must present a JWT issued by this server's own token +// endpoint, and must not be revoked. It authenticates using the exact same verification and +// revocation logic as the REST API gate (security.BearerAuthenticator), so a token that is +// rejected by one is rejected by the other for the same reason. +func DefaultGuard(jwtService jwt.JWTServiceInterface, revocationEnforcer security.RevocationEnforcerInterface, +) (func(http.Handler) http.Handler, *oauthex.ProtectedResourceMetadata) { cfg := config.GetServerRuntime().Config baseURL := config.GetServerURL(&cfg.Server) mcpURL := baseURL + MCPEndpointPath resourceMetadataURL := baseURL + OAuthProtectedResourceMetadataPath - - // Create MCP server and register standalone tools - mcpServer := newServer() - rootPerm := security.GetSystemRootPermission() - tokenVerifier := mcpauth.NewTokenVerifier(jwtService, cfg.JWT.Issuer, mcpURL) - httpHandler := mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server { - return mcpServer - }, nil) + // When a trusted issuer is configured, BearerAuthenticator already accepts tokens from it + // (routed to JWKS verification in jwtAuthenticator.verifyToken) alongside self-issued ones. MCP + // client discovery (RFC 9728) must point at that same issuer, not this server's own — otherwise + // a client following the metadata is sent to authenticate against the wrong authorization + // server. + authServer := cfg.JWT.Issuer + if trustedIssuer := cfg.Server.SecurityConfig.TrustedIssuer; trustedIssuer.IsConfigured() { + authServer = trustedIssuer.Issuer + } - // Secure MCP handler with bearer token authentication - securedHandler := auth.RequireBearerToken(tokenVerifier, &auth.RequireBearerTokenOptions{ + // Self-issued tokens must be scoped to this MCP resource specifically (RFC 8707 resource + // indicator) — a token minted for some other purpose does not authenticate here just because + // it happens to carry the required scope. + bearerAuthenticator := security.NewBearerAuthenticator(jwtService, revocationEnforcer, mcpURL) + tokenVerifier := mcpauth.NewTokenVerifier(bearerAuthenticator) + sdkGuard := auth.RequireBearerToken(tokenVerifier, &auth.RequireBearerTokenOptions{ ResourceMetadataURL: resourceMetadataURL, Scopes: []string{rootPerm}, - })(httpHandler) + }) + + // The go-sdk's TokenVerifier can only return a *auth.TokenInfo — it has no way to attach + // anything else to the request context that reaches the next handler, since RequireBearerToken + // controls that itself. tokenVerifier stashes the authenticated security.SecurityContext in + // TokenInfo.Extra to get it out; this wraps the SDK's guard to pull it back out and attach it + // via security.WithSecurityContext, so MCP tool handlers see the same SecurityContext a REST + // handler would for an equivalent token. + guard := func(next http.Handler) http.Handler { + return sdkGuard(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if ti := auth.TokenInfoFromContext(r.Context()); ti != nil { + if secCtx := mcpauth.SecurityContextFromTokenInfo(ti); secCtx != nil { + r = r.WithContext(security.WithSecurityContext(r.Context(), secCtx)) + } + } + next.ServeHTTP(w, r) + })) + } - // Register protected resource metadata endpoint metadata := &oauthex.ProtectedResourceMetadata{ Resource: mcpURL, - AuthorizationServers: []string{cfg.JWT.Issuer}, + AuthorizationServers: []string{authServer}, ScopesSupported: []string{rootPerm}, } - mux.Handle(OAuthProtectedResourceMetadataPath, auth.ProtectedResourceMetadataHandler(metadata)) - - // Register MCP routes - mux.Handle(MCPEndpointPath, securedHandler) - mux.Handle(MCPEndpointPath+"/", securedHandler) - return mcpServer + return guard, metadata } diff --git a/backend/internal/system/security/bearer_authenticator.go b/backend/internal/system/security/bearer_authenticator.go new file mode 100644 index 0000000000..7b0fad3d01 --- /dev/null +++ b/backend/internal/system/security/bearer_authenticator.go @@ -0,0 +1,55 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package security + +import ( + "context" + + "github.com/thunder-id/thunderid/internal/system/jose/jwt" +) + +// BearerAuthenticator verifies bearer tokens and enforces revocation — the single authentication +// implementation shared by every HTTP surface that accepts this server's tokens (the REST API gate +// and the MCP server). A token rejected by one is rejected by the other, for the same reason. +type BearerAuthenticator struct { + jwtService jwt.JWTServiceInterface + revocationEnforcer RevocationEnforcerInterface + expectedAud string +} + +// NewBearerAuthenticator creates a new BearerAuthenticator. expectedAud, if non-empty, is required +// as the audience (RFC 8707 resource indicator) of a self-issued token; pass "" for the REST gate's +// behavior of not restricting by audience. It has no effect on a trusted-issuer token, which is +// always checked against that issuer's own configured audience regardless of expectedAud. +func NewBearerAuthenticator( + jwtService jwt.JWTServiceInterface, revocationEnforcer RevocationEnforcerInterface, expectedAud string, +) *BearerAuthenticator { + return &BearerAuthenticator{ + jwtService: jwtService, revocationEnforcer: revocationEnforcer, expectedAud: expectedAud, + } +} + +// Authenticate verifies token — routing on issuer exactly as the REST gate does (self-issued, or a +// configured trusted issuer verified via JWKS), and against expectedAud if this authenticator was +// constructed with one — and rejects it if revocationEnforcer reports it as revoked. It returns the +// resulting SecurityContext on success. +func (a *BearerAuthenticator) Authenticate(ctx context.Context, token string) (*SecurityContext, error) { + securityCtx, err := AuthenticateBearerToken(ctx, a.jwtService, token, a.expectedAud) + if err != nil { + return nil, err + } + + // Revoked tokens are rejected as invalid, not disclosed as specifically revoked — same as the + // REST gate's securityService.Process. + if err := a.revocationEnforcer.EnsureNotRevoked(ctx, RevocationIdentity{ + JTI: securityCtx.revocationID, + TokenFamilyID: securityCtx.tokenFamilyID, + Subject: securityCtx.revocationSubject, + EstablishedAt: securityCtx.establishedAt, + }); err != nil { + return nil, errInvalidToken + } + + return securityCtx, nil +} diff --git a/backend/internal/system/security/context.go b/backend/internal/system/security/context.go index d4992fe371..544b2cb0b8 100644 --- a/backend/internal/system/security/context.go +++ b/backend/internal/system/security/context.go @@ -119,6 +119,14 @@ func GetAttribute(ctx context.Context, key string) interface{} { } } +// WithSecurityContext attaches sc to ctx, so GetSubject, GetOUID, GetPermissions, and GetAttribute +// resolve for it. Exported for authenticated surfaces outside the REST gate (e.g. the MCP server) +// that build their own request context after calling BearerAuthenticator.Authenticate directly, +// rather than going through the securityService middleware that attaches it for REST requests. +func WithSecurityContext(ctx context.Context, sc *SecurityContext) context.Context { + return withSecurityContext(ctx, sc) +} + // WithRuntimeContext marks the context as an internal runtime caller. // Runtime contexts bypass standard subject-based authorization checks without requiring an // authenticated subject. This is intended for internal system operations initiated from public diff --git a/backend/internal/system/security/jwt_authenticator.go b/backend/internal/system/security/jwt_authenticator.go index 76e5c2fbb7..8f8d895e67 100644 --- a/backend/internal/system/security/jwt_authenticator.go +++ b/backend/internal/system/security/jwt_authenticator.go @@ -61,7 +61,6 @@ func (h *jwtAuthenticator) CanHandle(r *http.Request) bool { // Authenticate validates the JWT token and builds a SecurityContext. func (h *jwtAuthenticator) Authenticate(r *http.Request) (*SecurityContext, error) { - ctx := r.Context() // Step 1: Extract Bearer token authHeader := r.Header.Get(constants.AuthorizationHeaderName) token, err := extractToken(authHeader) @@ -69,6 +68,19 @@ func (h *jwtAuthenticator) Authenticate(r *http.Request) (*SecurityContext, erro return nil, err } + // The REST gate does not restrict self-issued tokens to a particular audience/resource — a + // token valid for one REST endpoint is valid for all of them, gated by scope, not audience. + return h.authenticateToken(r.Context(), token, "") +} + +// authenticateToken verifies token and builds the resulting SecurityContext. expectedAud, if +// non-empty, is required as the audience of a self-issued token (RFC 8707 resource indicator); +// empty skips that check, same as VerifyJWT itself treats it. It has no effect on a trusted-issuer +// token, which is always checked against the issuer's own configured audience. authenticateToken +// performs no revocation check on its own — the REST gate applies that separately in +// securityService.Process; BearerAuthenticator.Authenticate applies it here for other callers that +// need identical behavior in one call. +func (h *jwtAuthenticator) authenticateToken(ctx context.Context, token, expectedAud string) (*SecurityContext, error) { if token == "" { return nil, errInvalidToken } @@ -76,7 +88,7 @@ func (h *jwtAuthenticator) Authenticate(r *http.Request) (*SecurityContext, erro // Step 2: Verify the JWT, routing on its issuer. Tokens this server issued // are verified with its own signing key; Additionally when a trusted issuer is // configured, tokens from that issuer are verified against its JWKS. - if err := h.verifyToken(ctx, token); err != nil { + if err := h.verifyToken(ctx, token, expectedAud); err != nil { return nil, err } @@ -116,12 +128,25 @@ func (h *jwtAuthenticator) Authenticate(r *http.Request) (*SecurityContext, erro return securityCtx, nil } +// AuthenticateBearerToken verifies a bearer token exactly as the REST gate's JWT authenticator does +// — routing on issuer (self-issued, or a configured trusted issuer verified via JWKS) — and returns +// the resulting SecurityContext. expectedAud is required as a self-issued token's audience (RFC 8707 +// resource indicator); pass "" to skip that check, as the REST gate does. It performs no revocation +// check; use BearerAuthenticator.Authenticate for the REST gate's full behavior (verification plus +// revocation) in one call. +func AuthenticateBearerToken( + ctx context.Context, jwtService jwt.JWTServiceInterface, token, expectedAud string, +) (*SecurityContext, error) { + return (&jwtAuthenticator{jwtService: jwtService}).authenticateToken(ctx, token, expectedAud) +} + // verifyToken verifies the bearer token by routing on its iss claim against // an explicit allowlist of accepted issuers. Tokens from the configured // trusted issuer (when set) are verified against its JWKS. Tokens whose iss // matches this server's own JWT issuer are verified with the local signing -// key. Any other iss is rejected. There is no cross-issuer fallback. -func (h *jwtAuthenticator) verifyToken(ctx context.Context, token string) error { +// key, and against expectedAud if it is non-empty. Any other iss is rejected. +// There is no cross-issuer fallback. +func (h *jwtAuthenticator) verifyToken(ctx context.Context, token, expectedAud string) error { trustedIssuer := config.GetServerRuntime().Config.Server.SecurityConfig.TrustedIssuer iss := extractIssuer(token) switch { @@ -130,7 +155,7 @@ func (h *jwtAuthenticator) verifyToken(ctx context.Context, token string) error return errInvalidToken } case iss == config.GetServerRuntime().Config.JWT.Issuer: - if err := h.jwtService.VerifyJWT(ctx, token, "", ""); err != nil { + if err := h.jwtService.VerifyJWT(ctx, token, expectedAud, ""); err != nil { return errInvalidToken } default: diff --git a/backend/internal/system/security/jwt_authenticator_test.go b/backend/internal/system/security/jwt_authenticator_test.go index eeb93ae426..4f4005b889 100644 --- a/backend/internal/system/security/jwt_authenticator_test.go +++ b/backend/internal/system/security/jwt_authenticator_test.go @@ -226,6 +226,32 @@ func (suite *JWTAuthenticatorTestSuite) TestAuthenticate() { } } +// TestAuthenticate_DoesNotValidateAudience locks in that the REST gate does not restrict a +// self-issued token to a particular audience/resource, regardless of what "aud" claim it carries. +// Authorization for REST is by scope (apiPermissionEntries), not audience. Only MCP (via +// BearerAuthenticator, constructed with expectedAud = its own resource URL) enforces an RFC 8707 +// resource-indicator audience check; the REST gate's own jwtAuthenticator always passes "" here. +func (suite *JWTAuthenticatorTestSuite) TestAuthenticate_DoesNotValidateAudience() { + token := buildFakeJWT( + map[string]interface{}{"alg": "RS256", "kid": "test-kid"}, + map[string]interface{}{"sub": "user123", "aud": "https://some-other-resource/mcp"}, + ) + + // Registering the expectation with expectedAud "" only (not the token's own "aud" claim, and + // not any other value) means the mock call itself fails this test if Authenticate ever starts + // passing a real expected audience for the REST gate. + suite.mockJWT.On("VerifyJWT", mock.Anything, token, "", "").Return(nil) + + req := httptest.NewRequest(http.MethodGet, "/users", nil) + req.Header.Set("Authorization", "Bearer "+token) + + authCtx, err := suite.authenticator.Authenticate(req) + + assert.NoError(suite.T(), err) + assert.NotNil(suite.T(), authCtx) + suite.mockJWT.AssertExpectations(suite.T()) +} + func (suite *JWTAuthenticatorTestSuite) TestExtractPermissionsFromJWTClaims() { tests := []struct { name string diff --git a/docs/content/working-with-ai/mcp-server.mdx b/docs/content/working-with-ai/mcp-server.mdx index 9dccbb7c52..e17d47126b 100644 --- a/docs/content/working-with-ai/mcp-server.mdx +++ b/docs/content/working-with-ai/mcp-server.mdx @@ -20,7 +20,7 @@ The MCP server is available through the `/mcp` endpoint of your ### Authentication -The endpoint follows the [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization). The MCP server publishes protected resource metadata at `/.well-known/oauth-protected-resource`, advertising as the OAuth authorization server. Spec-compliant MCP clients discover this automatically and run an OAuth authorization code + PKCE flow: you sign in through the browser, and the client obtains and refreshes tokens on its own. There is no manual token handling. +The endpoint follows the [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization). The MCP server publishes protected resource metadata at `/.well-known/oauth-protected-resource`, advertising as the OAuth authorization server. If a [trusted issuer](../../guides/trusted-issuer) is configured, the metadata advertises that issuer instead. Spec-compliant MCP clients discover this automatically and run an OAuth authorization code + PKCE flow: you sign in through the browser, and the client obtains and refreshes tokens on its own. There is no manual token handling. Access requires the `system` scope, and permissions come from the signed-in user, so sign in with an administrator account. diff --git a/tests/integration/mcp/mcp_test.go b/tests/integration/mcp/mcp_test.go new file mode 100644 index 0000000000..8935a0cd89 --- /dev/null +++ b/tests/integration/mcp/mcp_test.go @@ -0,0 +1,266 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package mcp + +import ( + "bytes" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/suite" + + "github.com/thunder-id/thunderid/tests/integration/testutils" +) + +const ( + testServerURL = "https://localhost:8095" + + // mcpResourceIdentifier matches the MCP resource identifier the running integration server + // itself computes (baseURL + "/mcp", derived from the server's own configured hostname/port — + // see tests/integration/resources/deployment.yaml). It intentionally does NOT reuse + // testutils.SystemResourceIdentifier ("https://localhost:8090/mcp"), which reflects the + // product's generic bootstrapped default rather than this harness's actual port (8095) — a + // token audience-bound to that default would fail mcp.DefaultGuard's RFC 8707 audience check + // against this server. + mcpResourceIdentifier = testServerURL + "/mcp" + + mcpEndpoint = testServerURL + "/mcp" + revokeEndpoint = testServerURL + "/oauth2/revoke" + + mcpProtocolVersion = "2025-06-18" + + // adminGroupID is the bootstrapped "Administrator" role's group assignment + // (backend/cmd/server/bootstrap/01-default-resources.yaml, resource_type: role, id + // 01900000-0000-7000-8000-000000000050, assignments[0].id) — the same well-known, fixed + // bootstrap ID the product's own default Administrator role targets to grant the "system" + // permission on the default System resource server. Reused here so the admin ends up in that + // same group for this suite's own resource server too. + adminGroupID = "01900000-0000-7000-8000-000000000040" + + systemActionHandle = "system" +) + +// MCPTestSuite exercises the MCP server's authentication end-to-end: a real signed token, the +// shared security.BearerAuthenticator path, and real revocation enforcement — as opposed to the +// mocked unit tests in backend/internal/system/mcp/auth, which don't exercise the real JWT service, +// database-backed revocation cache, or the actual /mcp route wiring. +// +// The product's bootstrapped "System" resource server (testutils.SystemResourceIdentifier) that +// normally grants admin users "system" scope has a fixed identifier +// ("https://localhost:8090/mcp") baked into backend/cmd/server/bootstrap/01-default-resources.yaml, +// which does not match this integration harness's actual server URL (port 8095, see +// tests/integration/resources/deployment.yaml) — a token bound to it fails mcp.DefaultGuard's +// audience check here (see TestInitialize_WrongAudienceToken_Rejected, which exercises exactly +// that mismatch). So this suite provisions its own resource server at the harness's real MCP +// identifier, plus a role granting the "system" permission on it to the same bootstrapped admin +// group, to obtain a token that is genuinely valid for this server's own /mcp endpoint. +type MCPTestSuite struct { + suite.Suite + client *http.Client + ouID string + resourceServerID string + roleID string +} + +func TestMCPTestSuite(t *testing.T) { + suite.Run(t, new(MCPTestSuite)) +} + +func (ts *MCPTestSuite) SetupSuite() { + ts.client = testutils.GetRawHTTPClient() + + ouID, err := testutils.CreateOrganizationUnit(testutils.OrganizationUnit{ + Handle: "mcp-auth-test-ou", + Name: "MCP Auth Test OU", + Description: "Organization unit for MCP authentication integration tests", + Parent: nil, + }) + ts.Require().NoError(err, "failed to create test organization unit") + ts.ouID = ouID + + // Registers the resource server whose identifier matches this server's real mcpURL, so a token + // requested with resource=mcpResourceIdentifier carries a matching "aud" claim. The "system" + // action mirrors the handle the bootstrapped System resource server defines, so a role can + // grant the same "system" permission string against this resource server too. + resourceServerID, err := testutils.CreateResourceServerWithActions(testutils.ResourceServer{ + Name: "MCP Auth Test Resource Server", + Description: "Resource server matching this server's own MCP endpoint identifier", + Identifier: mcpResourceIdentifier, + OUID: ts.ouID, + }, []testutils.Action{ + {Name: "System", Handle: systemActionHandle, Description: "System resource"}, + }) + ts.Require().NoError(err, "failed to create resource server") + ts.resourceServerID = resourceServerID + + // Grants the "system" permission on this resource server to the same group the bootstrapped + // admin user belongs to, so the admin token request below can obtain "system" scope bound to + // this resource server's identifier instead of only the default one. + roleID, err := testutils.CreateRole(testutils.Role{ + Name: "MCP Auth Test System Role", + Description: "Grants system permission on the MCP auth test resource server", + OUID: ts.ouID, + Permissions: []testutils.ResourcePermissions{ + {ResourceServerID: ts.resourceServerID, Permissions: []string{systemActionHandle}}, + }, + Assignments: []testutils.Assignment{ + {ID: adminGroupID, Type: "group"}, + }, + }) + ts.Require().NoError(err, "failed to create role granting system permission") + ts.roleID = roleID +} + +func (ts *MCPTestSuite) TearDownSuite() { + if ts.roleID != "" { + if err := testutils.DeleteRole(ts.roleID); err != nil { + ts.T().Logf("Failed to delete role: %v", err) + } + } + if ts.resourceServerID != "" { + // The suite creates a "system" action under this resource server (see SetupSuite), which + // blocks a plain delete until it's removed first — DeleteResourceServerWithChildren handles + // that ordering. + if err := testutils.DeleteResourceServerWithChildren(ts.resourceServerID); err != nil { + ts.T().Logf("Failed to delete resource server: %v", err) + } + } + if ts.ouID != "" { + if err := testutils.DeleteOrganizationUnit(ts.ouID); err != nil { + ts.T().Logf("Failed to delete test organization unit: %v", err) + } + } +} + +// mcpScopedAdminToken obtains a fresh admin access token (password grant against the public +// CONSOLE client, matching testutils.ObtainAdminAccessToken's pattern) bound to +// mcpResourceIdentifier via the RFC 8707 "resource" parameter, carrying the "system" scope +// mcp.DefaultGuard requires. +func (ts *MCPTestSuite) mcpScopedAdminToken() string { + tokenResp, err := testutils.ObtainAccessTokenWithPassword( + "CONSOLE", + testServerURL+"/console", + "system", + testutils.AdminUsername, + testutils.AdminPassword, + true, + "", // no client secret — CONSOLE is a public client + mcpResourceIdentifier, + ) + ts.Require().NoError(err, "failed to obtain MCP-scoped admin token") + ts.Require().NotEmpty(tokenResp.AccessToken, "no access token returned") + return tokenResp.AccessToken +} + +// initializeMCPSession sends the JSON-RPC "initialize" request that starts an MCP Streamable HTTP +// session — the first request any real MCP client sends, and (per RequireBearerToken's wrapping in +// mcp.DefaultGuard) authenticated the same way as every subsequent request on that session. Using a +// bare GET or an empty body here would fail at the MCP transport layer regardless of token +// validity, proving nothing about authentication. +func (ts *MCPTestSuite) initializeMCPSession(token string) *http.Response { + body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{` + + `"protocolVersion":"` + mcpProtocolVersion + `",` + + `"capabilities":{},` + + `"clientInfo":{"name":"thunderid-integration-test","version":"1.0.0"}}}` + + req, err := http.NewRequest(http.MethodPost, mcpEndpoint, bytes.NewBufferString(body)) + ts.Require().NoError(err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := ts.client.Do(req) + ts.Require().NoError(err) + return resp +} + +func (ts *MCPTestSuite) revoke(token string) { + form := "token=" + token + "&client_id=CONSOLE" + req, err := http.NewRequest(http.MethodPost, revokeEndpoint, strings.NewReader(form)) + ts.Require().NoError(err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := ts.client.Do(req) + ts.Require().NoError(err) + defer resp.Body.Close() + ts.Require().Equal(http.StatusOK, resp.StatusCode, "revoke request failed") +} + +// A token minted for the MCP resource, carrying the required "system" scope, and not revoked +// authenticates successfully — exercising the full shared BearerAuthenticator path (verification, +// audience check, no-revocation) through the real /mcp route and its guard wiring, not a mock. +func (ts *MCPTestSuite) TestInitialize_ValidMCPScopedToken_Succeeds() { + token := ts.mcpScopedAdminToken() + + resp := ts.initializeMCPSession(token) + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + ts.Assert().Equalf(http.StatusOK, resp.StatusCode, "initialize failed: %s", string(body)) + ts.Assert().NotEmpty(resp.Header.Get("Mcp-Session-Id"), + "a successful initialize should establish an MCP session") +} + +// A token that is otherwise valid but has been revoked is rejected — proving revocation is enforced +// on the MCP path via the same security.RevocationEnforcerInterface the REST gate uses, not just +// verified in isolation against a mock as in the unit tests. +// +// Per-request enforcement (BearerAuthenticator.Authenticate -> EnsureNotRevoked) is served from an +// in-process cache synced on token_revocation.sync_interval_seconds (tests/integration/resources/ +// deployment.yaml sets it to 2s for this reason), not a direct store read on every request — unlike +// RFC 7009 introspection, which the existing oauth/revocation suite proves reflects revocation +// immediately. Retrying past that interval, rather than sleeping the full duration up front, keeps +// this test fast in the common case and only pays the wait when the first attempt is too early. +func (ts *MCPTestSuite) TestInitialize_RevokedToken_Rejected() { + token := ts.mcpScopedAdminToken() + ts.revoke(token) + + ts.Require().Eventually(func() bool { + resp := ts.initializeMCPSession(token) + defer resp.Body.Close() + return resp.StatusCode == http.StatusUnauthorized + }, 5*time.Second, 250*time.Millisecond, + "a revoked token must be rejected once the revocation cache has synced") +} + +// A token that is valid and carries the required scope, but was minted for a different — still +// real, registered — resource (testutils.SystemResourceIdentifier, the product's generic +// bootstrapped default, distinct from this harness's actual mcpResourceIdentifier), is rejected. +// This is also the exact scenario testutils.ObtainAdminAccessToken's default token is in: without +// an explicit resource override it is bound to SystemResourceIdentifier, not this server's real +// mcpURL, so it would fail this same check. Proves the RFC 8707 audience requirement restored in +// mcp.DefaultGuard is enforced end-to-end, not just unit-tested against a mock JWT service. +func (ts *MCPTestSuite) TestInitialize_WrongAudienceToken_Rejected() { + tokenResp, err := testutils.ObtainAccessTokenWithPassword( + "CONSOLE", + testServerURL+"/console", + "system", + testutils.AdminUsername, + testutils.AdminPassword, + true, + // no optional resource override -> defaults to testutils.SystemResourceIdentifier, which + // does not match mcpResourceIdentifier for this test harness (see its doc comment above). + ) + ts.Require().NoError(err, "failed to obtain admin token for the default system resource") + ts.Require().NotEmpty(tokenResp.AccessToken) + + resp := ts.initializeMCPSession(tokenResp.AccessToken) + defer resp.Body.Close() + + ts.Assert().Equal(http.StatusUnauthorized, resp.StatusCode, + "a token not scoped to the MCP resource must be rejected") +} + +// No Authorization header at all is rejected, and does not panic or hang the MCP transport. +func (ts *MCPTestSuite) TestInitialize_NoToken_Rejected() { + resp := ts.initializeMCPSession("") + defer resp.Body.Close() + + ts.Assert().Equal(http.StatusUnauthorized, resp.StatusCode) +} diff --git a/tests/integration/resources/deployment.yaml b/tests/integration/resources/deployment.yaml index f24a69a4fa..4dcaa6c043 100644 --- a/tests/integration/resources/deployment.yaml +++ b/tests/integration/resources/deployment.yaml @@ -3,6 +3,12 @@ server: port: 8095 security: direct_auth_secret: "integration-direct-auth-secret" + # Shortened from the 60s default so revocation-enforcement tests (which check a token + # immediately after revoking it) don't need a long sleep to observe the deny-list cache pick + # up the revocation. Introspection (RFC 7009 hot path) is unaffected — it reads the store + # directly rather than through this periodic cache. + token_revocation: + sync_interval_seconds: 2 tls: cert_file: "config/certs/server.cert" diff --git a/tests/integration/resources/scripts/setup-test-config.sh b/tests/integration/resources/scripts/setup-test-config.sh old mode 100644 new mode 100755 index a81ea48c8d..c488152f21 --- a/tests/integration/resources/scripts/setup-test-config.sh +++ b/tests/integration/resources/scripts/setup-test-config.sh @@ -6,6 +6,13 @@ cat > tests/integration/resources/deployment.yaml <