Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand All @@ -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)

Expand Down
11 changes: 8 additions & 3 deletions backend/cmd/server/servicemanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
77 changes: 30 additions & 47 deletions backend/internal/system/mcp/auth/token_verifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading