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 <