Skip to content

Commit 809f04f

Browse files
committed
Add OIDC incoming authenticator for vmcp
Implement IncomingAuthenticator interface using existing TokenValidator from pkg/auth. This adapter validates JWT tokens from clients connecting to the Virtual MCP Server and extracts identity information. Related: #2377
1 parent 3e51d71 commit 809f04f

File tree

4 files changed

+813
-0
lines changed

4 files changed

+813
-0
lines changed

pkg/vmcp/auth/auth.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@
99
// registered at runtime.
1010
package auth
1111

12+
//go:generate mockgen -destination=mocks/mock_token_authenticator.go -package=mocks github.com/stacklok/toolhive/pkg/vmcp/auth TokenAuthenticator
13+
1214
import (
1315
"context"
1416
"net/http"
17+
18+
"github.com/golang-jwt/jwt/v5"
1519
)
1620

1721
// IncomingAuthenticator handles authentication for clients connecting to the virtual MCP server.
@@ -93,6 +97,19 @@ type Identity struct {
9397
Metadata map[string]string
9498
}
9599

100+
// TokenAuthenticator validates JWT tokens and provides HTTP middleware for authentication.
101+
// This interface abstracts the token validation functionality from pkg/auth to enable
102+
// testing with mocks while the concrete *auth.TokenValidator implementation satisfies
103+
// this interface in production.
104+
type TokenAuthenticator interface {
105+
// ValidateToken validates a token string and returns the claims.
106+
ValidateToken(ctx context.Context, tokenString string) (jwt.MapClaims, error)
107+
108+
// Middleware returns an HTTP middleware function that validates tokens
109+
// from the Authorization header and injects claims into the request context.
110+
Middleware(next http.Handler) http.Handler
111+
}
112+
96113
// Authorizer handles authorization decisions.
97114
// This integrates with ToolHive's existing Cedar-based authorization.
98115
type Authorizer interface {

pkg/vmcp/auth/incoming_oidc.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"net/http"
8+
"strings"
9+
10+
"github.com/golang-jwt/jwt/v5"
11+
12+
"github.com/stacklok/toolhive/pkg/auth"
13+
)
14+
15+
const (
16+
// bearerTokenType is the token type used for Bearer authentication
17+
bearerTokenType = "Bearer"
18+
)
19+
20+
// OIDCIncomingAuthenticator is a minimal adapter that wraps ToolHive's existing
21+
// TokenValidator to implement the IncomingAuthenticator interface.
22+
//
23+
// This adapter provides a clean separation between OIDC token validation
24+
// (handled by TokenValidator) and the virtual MCP authentication interface.
25+
type OIDCIncomingAuthenticator struct {
26+
validator TokenAuthenticator
27+
}
28+
29+
// NewOIDCIncomingAuthenticator creates a new OIDC incoming authenticator.
30+
// Returns an error if the validator is nil.
31+
func NewOIDCIncomingAuthenticator(validator TokenAuthenticator) (*OIDCIncomingAuthenticator, error) {
32+
if validator == nil {
33+
return nil, errors.New("token validator cannot be nil")
34+
}
35+
36+
return &OIDCIncomingAuthenticator{
37+
validator: validator,
38+
}, nil
39+
}
40+
41+
// Authenticate validates the incoming HTTP request and extracts identity information.
42+
// It extracts the Bearer token from the Authorization header and validates it using
43+
// the underlying TokenValidator.
44+
func (o *OIDCIncomingAuthenticator) Authenticate(ctx context.Context, r *http.Request) (*Identity, error) {
45+
// Get the token from the Authorization header
46+
authHeader := r.Header.Get("Authorization")
47+
if authHeader == "" {
48+
return nil, errors.New("authorization header required")
49+
}
50+
51+
// Check if the Authorization header has the Bearer prefix
52+
bearerPrefix := bearerTokenType + " "
53+
if !strings.HasPrefix(authHeader, bearerPrefix) {
54+
return nil, errors.New("invalid authorization header format, expected 'Bearer <token>'")
55+
}
56+
57+
// Extract the token
58+
tokenString := strings.TrimPrefix(authHeader, bearerPrefix)
59+
if tokenString == "" {
60+
return nil, errors.New("empty token in authorization header")
61+
}
62+
63+
// Validate the token using the underlying TokenValidator
64+
claims, err := o.validator.ValidateToken(ctx, tokenString)
65+
if err != nil {
66+
return nil, fmt.Errorf("token validation failed: %w", err)
67+
}
68+
69+
// Convert claims to Identity
70+
identity, err := claimsToIdentity(claims)
71+
if err != nil {
72+
return nil, fmt.Errorf("invalid token claims: %w", err)
73+
}
74+
identity.Token = tokenString
75+
identity.TokenType = bearerTokenType
76+
77+
return identity, nil
78+
}
79+
80+
// Middleware returns an HTTP middleware that validates tokens and stores the
81+
// Identity in the request context. It wraps the TokenValidator's middleware
82+
// and converts the claims to an Identity.
83+
func (o *OIDCIncomingAuthenticator) Middleware() func(http.Handler) http.Handler {
84+
return func(next http.Handler) http.Handler {
85+
// Create a handler that processes claims and calls next
86+
claimsHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87+
// Extract claims from context (set by TokenValidator middleware)
88+
claims, ok := r.Context().Value(auth.ClaimsContextKey{}).(jwt.MapClaims)
89+
if !ok {
90+
// This should never happen if TokenValidator middleware worked correctly
91+
http.Error(w, "internal error: claims not found in context", http.StatusInternalServerError)
92+
return
93+
}
94+
95+
// Convert claims to Identity
96+
identity, err := claimsToIdentity(claims)
97+
if err != nil {
98+
http.Error(w, fmt.Sprintf("invalid token claims: %v", err), http.StatusUnauthorized)
99+
return
100+
}
101+
102+
// Extract the token from Authorization header for storage
103+
authHeader := r.Header.Get("Authorization")
104+
bearerPrefix := bearerTokenType + " "
105+
if strings.HasPrefix(authHeader, bearerPrefix) {
106+
identity.Token = strings.TrimPrefix(authHeader, bearerPrefix)
107+
identity.TokenType = bearerTokenType
108+
}
109+
110+
// Store Identity in context
111+
ctx := WithIdentity(r.Context(), identity)
112+
113+
// Continue with the next handler
114+
next.ServeHTTP(w, r.WithContext(ctx))
115+
})
116+
117+
// Wrap with TokenValidator's middleware
118+
return o.validator.Middleware(claimsHandler)
119+
}
120+
}
121+
122+
// claimsToIdentity converts JWT claims to an Identity structure.
123+
// It extracts only the universal JWT fields (sub, name, email) and stores
124+
// all claims in the Claims map. Groups extraction is left to authorization
125+
// logic that can interpret provider-specific claim structures.
126+
//
127+
// Returns an error if the required 'sub' claim is missing or invalid, as mandated
128+
// by OpenID Connect Core 1.0 specification section 5.1.
129+
func claimsToIdentity(claims jwt.MapClaims) (*Identity, error) {
130+
identity := &Identity{
131+
Claims: make(map[string]any),
132+
Groups: []string{}, // Empty slice, groups stay in Claims if present
133+
}
134+
135+
// Store all claims for later use
136+
for k, v := range claims {
137+
identity.Claims[k] = v
138+
}
139+
140+
// REQUIRED: Extract subject (sub claim is mandatory per OIDC Core 1.0 section 5.1)
141+
sub, ok := claims["sub"].(string)
142+
if !ok || sub == "" {
143+
return nil, errors.New("missing or invalid 'sub' claim (required by OpenID Connect Core 1.0)")
144+
}
145+
identity.Subject = sub
146+
147+
if name, ok := claims["name"].(string); ok {
148+
identity.Name = name
149+
}
150+
151+
if email, ok := claims["email"].(string); ok {
152+
identity.Email = email
153+
}
154+
155+
return identity, nil
156+
}

0 commit comments

Comments
 (0)