Skip to content

Commit c829eb5

Browse files
authored
Merge branch 'main' into ratelimitingVMCP
2 parents 9b5235e + 110b9a7 commit c829eb5

8 files changed

Lines changed: 564 additions & 3 deletions

File tree

deploy/charts/operator/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ The command removes all the Kubernetes components associated with the chart and
5555
| operator.autoscaling.targetCPUUtilizationPercentage | int | `80` | Target CPU utilization percentage for autoscaling |
5656
| operator.containerSecurityContext | object | `{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}}` | Container security context settings for the operator |
5757
| operator.defaultImagePullSecrets | list | `[]` | List of image pull secrets that the operator applies as defaults to every workload it spawns (proxy runners, vMCP servers, registry API, etc.). Per-CR `imagePullSecrets` take precedence on name collisions; chart-level entries are appended additively. The operator parses these once at startup from the TOOLHIVE_DEFAULT_IMAGE_PULL_SECRETS environment variable. The Secrets must exist in the namespace where each workload is created. Each entry may be either a plain string (the Secret name) or an object with a `name` field, e.g.: defaultImagePullSecrets: - regcred - name: otherscred The two shapes are equivalent; the object form matches `operator.imagePullSecrets` above for convenience. |
58-
| operator.env | list | `[]` | Environment variables to set in the operator container |
58+
| operator.env | list | `[]` | Environment variables to set in the operator container. Supported toolhive-specific variables include: - TOOLHIVE_SKIP_UPDATE_CHECK: set to "true" to disable the operator's periodic update check against the ToolHive update API. Also disables the usage-metrics collection that is gated on the same check. |
5959
| operator.features.experimental | bool | `false` | Enable experimental features |
6060
| operator.features.registry | bool | `true` | Enable registry controller (MCPRegistry). This automatically sets ENABLE_REGISTRY environment variable. |
6161
| operator.features.server | bool | `true` | Enable server-related controllers (MCPServer, MCPExternalAuthConfig, MCPRemoteProxy, and ToolConfig). This automatically sets ENABLE_SERVER environment variable. |

deploy/charts/operator/values.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,11 @@ operator:
5555
# -- Host for the proxy deployed by the operator
5656
proxyHost: 0.0.0.0
5757

58-
# -- Environment variables to set in the operator container
58+
# -- Environment variables to set in the operator container. Supported
59+
# toolhive-specific variables include:
60+
# - TOOLHIVE_SKIP_UPDATE_CHECK: set to "true" to disable the operator's
61+
# periodic update check against the ToolHive update API. Also disables
62+
# the usage-metrics collection that is gated on the same check.
5963
env: []
6064

6165
# -- List of ports to expose from the operator container

pkg/oauthproto/constants.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ const (
6969
const (
7070
// GrantTypeTokenExchange is the OAuth 2.0 Token Exchange grant type (RFC 8693).
7171
GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange"
72+
73+
// GrantTypeJWTBearer is the JWT Bearer grant type (RFC 7523).
74+
GrantTypeJWTBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer"
7275
)
7376

7477
// HTTP client constants.

pkg/oauthproto/jwtbearer/doc.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Package jwtbearer provides an OAuth 2.0 JWT Bearer Grant (RFC 7523) implementation.
5+
// It exchanges a JWT assertion (such as an ID-JAG) for an access token at a target
6+
// authorization server.
7+
package jwtbearer

pkg/oauthproto/jwtbearer/grant.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package jwtbearer
5+
6+
import (
7+
"context"
8+
"errors"
9+
"fmt"
10+
"net/http"
11+
"net/url"
12+
"strings"
13+
14+
"golang.org/x/oauth2"
15+
16+
"github.com/stacklok/toolhive/pkg/networking"
17+
"github.com/stacklok/toolhive/pkg/oauthproto"
18+
)
19+
20+
// Config holds configuration for an OAuth 2.0 JWT Bearer Grant (RFC 7523).
21+
type Config struct {
22+
// TokenURL is the target authorization server's token endpoint (required).
23+
TokenURL string
24+
25+
// ClientID is the OAuth client identifier at the target AS. When both ClientID
26+
// and ClientSecret are set, the request is authenticated with HTTP Basic per
27+
// RFC 6749 Section 2.3.1. Public-client identification via a body client_id
28+
// parameter (RFC 6749 Section 3.2.1) is not supported — XAA / ID-JAG §8.1
29+
// requires confidential clients, and that is the only intended consumer.
30+
ClientID string
31+
32+
// ClientSecret is the OAuth client secret at the target AS.
33+
ClientSecret string //nolint:gosec // G101: field name, not a credential
34+
35+
// Scopes are the requested scopes for the access token.
36+
Scopes []string
37+
38+
// AssertionProvider returns the JWT assertion (e.g., the ID-JAG from Step A).
39+
// Called on each Token() invocation; must not be nil. The returned JWT must
40+
// satisfy RFC 7523 Section 3 (iss/sub/aud/exp); aud should typically be the
41+
// target AS's token endpoint (TokenURL). The provider must be safe for
42+
// concurrent use — Token() may be called from multiple goroutines (e.g.,
43+
// when wrapped in oauth2.ReuseTokenSource).
44+
AssertionProvider func() (string, error)
45+
46+
// HTTPClient is the HTTP client to use. If nil, oauthproto.DefaultHTTPClient()
47+
// is used.
48+
HTTPClient *http.Client
49+
}
50+
51+
// Validate checks that the Config contains all required fields.
52+
func (c *Config) Validate() error {
53+
if c.TokenURL == "" {
54+
return fmt.Errorf("TokenURL is required")
55+
}
56+
57+
if c.AssertionProvider == nil {
58+
return fmt.Errorf("AssertionProvider is required")
59+
}
60+
61+
// Validate TokenURL: must be https (or http on localhost) per RFC 6749 Section 3.2
62+
// and RFC 7523 Section 7. Reuses the repo-wide endpoint validator for scheme,
63+
// and adds host and fragment checks that it does not perform.
64+
if err := networking.ValidateEndpointURL(c.TokenURL); err != nil {
65+
return fmt.Errorf("TokenURL: %w", err)
66+
}
67+
u, err := url.Parse(c.TokenURL)
68+
if err != nil {
69+
return fmt.Errorf("TokenURL is not a valid URL: %w", err)
70+
}
71+
if u.Host == "" {
72+
return fmt.Errorf("TokenURL must include a host")
73+
}
74+
if u.Fragment != "" {
75+
return fmt.Errorf("TokenURL must not contain a fragment")
76+
}
77+
if u.User != nil {
78+
return fmt.Errorf("TokenURL must not contain embedded credentials")
79+
}
80+
81+
return nil
82+
}
83+
84+
// String implements fmt.Stringer for Config, redacting sensitive fields.
85+
func (c *Config) String() string {
86+
assertion := oauthproto.Redact("")
87+
if c.AssertionProvider != nil {
88+
assertion = oauthproto.Redact("set")
89+
}
90+
91+
return fmt.Sprintf("Config{TokenURL: %s, ClientID: %s, ClientSecret: %s, Scopes: %v, Assertion: %s}",
92+
c.TokenURL, c.ClientID, oauthproto.Redact(c.ClientSecret), c.Scopes, assertion)
93+
}
94+
95+
// Compile-time assertion that *tokenSource implements oauth2.TokenSource.
96+
var _ oauth2.TokenSource = (*tokenSource)(nil)
97+
98+
// TokenSource returns an oauth2.TokenSource that performs the JWT Bearer grant.
99+
func (c *Config) TokenSource(ctx context.Context) oauth2.TokenSource {
100+
return &tokenSource{
101+
ctx: ctx,
102+
conf: c,
103+
}
104+
}
105+
106+
// tokenSource implements oauth2.TokenSource for the JWT Bearer grant.
107+
type tokenSource struct {
108+
ctx context.Context
109+
conf *Config
110+
}
111+
112+
// Token implements the oauth2.TokenSource interface.
113+
// It performs the JWT Bearer grant and returns an oauth2.Token.
114+
func (ts *tokenSource) Token() (*oauth2.Token, error) {
115+
conf := ts.conf
116+
117+
if err := conf.Validate(); err != nil {
118+
return nil, fmt.Errorf("invalid config: %w", err)
119+
}
120+
121+
assertion, err := conf.AssertionProvider()
122+
if err != nil {
123+
return nil, fmt.Errorf("failed to get assertion: %w", err)
124+
}
125+
126+
data := buildFormData(assertion, conf.Scopes)
127+
128+
req, err := oauthproto.NewFormRequest(ts.ctx, conf.TokenURL, data, conf.ClientID, conf.ClientSecret)
129+
if err != nil {
130+
return nil, fmt.Errorf("jwtbearer: build request: %w", err)
131+
}
132+
133+
resp, err := oauthproto.DoTokenRequest(conf.HTTPClient, req)
134+
if err != nil {
135+
// Scrub RetrieveError.Body so raw upstream content cannot leak into
136+
// error strings via err.Error(). pkg/oauthproto deliberately preserves
137+
// Body for general-purpose callers; jwtbearer opts back into the
138+
// stricter behavior because its errors propagate through vmcp / runner
139+
// paths that may log them. Matches pkg/oauthproto/tokenexchange.
140+
var retrieveErr *oauth2.RetrieveError
141+
if errors.As(err, &retrieveErr) {
142+
retrieveErr.Body = nil
143+
}
144+
return nil, err
145+
}
146+
147+
// RFC 6749 Section 5.1 requires token_type in the response. The shared
148+
// oauthproto.ParseTokenResponse is intentionally permissive on this field
149+
// (matching x/oauth2); the JWT Bearer grant tightens it back.
150+
if resp.Token.TokenType == "" {
151+
return nil, fmt.Errorf("jwtbearer: server returned empty token_type (required by RFC 6749 Section 5.1)")
152+
}
153+
154+
return resp.Token, nil
155+
}
156+
157+
// buildFormData constructs the form data for a JWT Bearer grant request.
158+
func buildFormData(assertion string, scopes []string) url.Values {
159+
data := url.Values{}
160+
data.Set("grant_type", oauthproto.GrantTypeJWTBearer)
161+
data.Set("assertion", assertion)
162+
163+
if len(scopes) > 0 {
164+
data.Set("scope", strings.Join(scopes, " "))
165+
}
166+
167+
return data
168+
}

0 commit comments

Comments
 (0)