Skip to content

Commit ad40e16

Browse files
jhrozekMecatl
andauthored
Add auth server TLS listener and SPIFFE middleware (#6843)
SPIFFE X.509-SVID client authentication needs the client certificate from a TLS handshake, but the MCP proxy port serves plain HTTP. Give the embedded authorization server its own TLS listener on port 8443, so the token endpoint can see client certificates without moving MCP traffic to HTTPS. The listener requests client certificates only when spiffe_x509 is configured, sends the SPIFFE CAs as a hint, and reloads the serving certificate when its files change, keeping the last good pair. spiffeauth.Middleware extracts a claimed SPIFFE ID from the presented peer certificate into the request context; it does not verify the certificate. Malformed SPIFFE identities receive an OAuth JSON error. Credential validation remains the responsibility of the client authentication strategy. EmbeddedAuthServer gains Start, and the proxy runner starts the listener on the same host as the MCP port, rejecting a proxy or diagnostics port that collides with it. Constructors reject configured TLS listeners without a listener host so unsupported callers fail explicitly. Graceful shutdown force-closes connections on timeout. SPIFFE trust configuration remains rejected at startup; this change does not yet enable SPIFFE client authentication. Co-authored-by: Mecatl <noreply@mecatl.dev>
1 parent bf79521 commit ad40e16

21 files changed

Lines changed: 1243 additions & 76 deletions

‎docs/arch/09-operator-architecture.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ MCPExternalAuthConfig resources can be referenced via two paths:
238238

239239
**Controller**: `cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go`
240240

241-
`EmbeddedAuthServerConfig` can carry top-level SPIFFE trust-domain declarations in `spiffeTrustDomains` and canonical client associations in `inboundGrants.spiffeClientAuth` (`cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go`). The operator converts these CRD fields into runtime config via `buildSPIFFETrustDomainRunConfigs`/`buildSPIFFEClientAuthRunConfigs` in `cmd/thv-operator/pkg/controllerutil/authserver.go`, and projects file-source ConfigMap data read-only. `newServer` constructs the configured bundle source and passes its X.509 and JWT views to the authorization server. However, `RunConfig.Validate()` still rejects any non-empty SPIFFE trust configuration before the runner starts, so this wiring is not reachable in normal deployment. TLS listener startup and X.509-SVID verification are not implemented. See [SPIFFE Association Declarations](18-spiffe-association-declarations.md) for current scope.
241+
`EmbeddedAuthServerConfig` can carry top-level SPIFFE trust-domain declarations in `spiffeTrustDomains` and canonical client associations in `inboundGrants.spiffeClientAuth` (`cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go`). The operator converts these CRD fields into runtime config via `buildSPIFFETrustDomainRunConfigs`/`buildSPIFFEClientAuthRunConfigs` in `cmd/thv-operator/pkg/controllerutil/authserver.go`, and projects file-source ConfigMap data read-only. `newServer` constructs the configured bundle source and passes its X.509 and JWT views to the authorization server. However, `RunConfig.Validate()` still rejects any non-empty SPIFFE trust configuration before the runner starts, so this wiring is not reachable for SPIFFE authentication in normal deployment. The proxy runner can start the auth-server TLS listener on fixed port 8443, but X.509-SVID verification is not implemented. vMCP listener startup and operator configuration or network exposure for this listener are not supported; configuring it through the current vMCP constructor fails because no listener host is supplied. See [SPIFFE Association Declarations](18-spiffe-association-declarations.md) for current scope.
242242

243243
### MCPOIDCConfig
244244

‎docs/arch/11-auth-server-storage.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -640,7 +640,7 @@ When the embedded authorization server is deployed in an environment that cannot
640640

641641
**Current status: not reachable in this build.** `RunConfig.Validate()` rejects any non-empty `spiffeTrustDomains`/`inboundGrants.spiffeClientAuth` before the authorization-server runner is created (`validateSPIFFENotYetEnforced` in `pkg/authserver/config.go`; `pkg/authserver/runner/embeddedauthserver.go`), so storage creation and decorator installation never happen. The decorator, overlay, and durable-reservation behavior below is the design this epic has implemented and tested in isolation, not current operational behavior — see [SPIFFE Association Declarations](18-spiffe-association-declarations.md) for the full status.
642642

643-
When top-level `spiffeTrustDomains` and `inboundGrants.spiffeClientAuth` are configured, the embedded authorization server wraps its storage backend in a `SPIFFEStorageDecorator` — installed as the outermost decorator, after CIMD (`decorateStorageForSPIFFE` in `pkg/authserver/server_impl.go`). This decorator overlays statically configured OAuth clients ahead of the dynamic DCR/CIMD backend. `newServer` also wires the configured bundle source into the X.509 and JWT verification interfaces, but normal startup remains blocked by `RunConfig.Validate()`. The decorator reserves configured client IDs; it does not itself verify SVIDs. TLS listener startup and X.509-SVID verification are not implemented.
643+
When top-level `spiffeTrustDomains` and `inboundGrants.spiffeClientAuth` are configured, the embedded authorization server wraps its storage backend in a `SPIFFEStorageDecorator` — installed as the outermost decorator, after CIMD (`decorateStorageForSPIFFE` in `pkg/authserver/server_impl.go`). This decorator overlays statically configured OAuth clients ahead of the dynamic DCR/CIMD backend. `newServer` also wires the configured bundle source into the X.509 and JWT verification interfaces, but normal SPIFFE startup remains blocked by `RunConfig.Validate()`. The proxy runner can serve authorization-server routes on its TLS listener at fixed port 8443; the listener does not implement X.509-SVID verification, and configured SPIFFE authentication remains unavailable. vMCP does not start this listener, and operator configuration/exposure is not supported. The decorator reserves configured client IDs; it does not itself verify SVIDs.
644644

645645
### What it does
646646

‎docs/arch/18-spiffe-association-declarations.md‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,24 @@
11
# SPIFFE Association Declarations
22

3-
**Current status: SPIFFE configuration is still rejected at startup.** `RunConfig.Validate()` validates the trust declarations, then `validateSPIFFENotYetEnforced` rejects any non-empty `spiffeTrustDomains` before the runner starts. This branch does wire `newServer` to construct the configured bundle source and pass its X.509 and JWT views into the authorization server, but that wiring is not reachable through normal startup. TLS-listener startup and X.509-SVID verification are not implemented, so SPIFFE client authentication is not deployable yet.
3+
**Current status: SPIFFE configuration is still rejected at startup.** `RunConfig.Validate()` validates the trust declarations, then `validateSPIFFENotYetEnforced` rejects any non-empty `spiffeTrustDomains` before the runner starts. This branch wires `newServer` to construct the configured bundle source and pass its X.509 and JWT views into the authorization server, and the proxy runner can start the auth-server TLS listener. However, X.509-SVID verification is not implemented and SPIFFE client authentication is not deployable yet.
44

5-
The embedded authorization server carries the SPIFFE association model across the `RunConfig` boundary, and the file-bundle source can load and poll a local trust-bundle file. The source wiring is present in server construction; the startup validation gate still prevents configured associations from being used. This document describes the implemented components and remaining gaps, not an available deployment capability.
5+
The embedded authorization server carries the SPIFFE association model across the `RunConfig` boundary, and the file-bundle source can load and poll a local trust-bundle file. The source wiring is present in server construction; the startup validation gate still prevents configured associations from being used. This document describes the implemented components and remaining gaps, not an available SPIFFE deployment capability.
6+
7+
## Auth-server TLS listener
8+
9+
The proxy runner can serve authorization-server routes on an additional TLS listener at fixed port `8443`. Configure the serving certificate and private key in the embedded auth-server `RunConfig`; mount both files read-only into the runner and keep the private key accessible only to the runner:
10+
11+
```yaml
12+
tls_listener:
13+
cert_file: /etc/toolhive/auth-server/tls.crt
14+
key_file: /etc/toolhive/auth-server/tls.key
15+
```
16+
17+
The listener binds the runner's configured host (falling back to loopback when the runner host is unset). A missing or invalid initial certificate/key pair, a bind failure, or a port-8443 conflict with the proxy or enabled diagnostics listener fails startup; the listener does not select another port. Certificate files are checked lazily during TLS handshakes, at most once every 30 seconds. A valid changed pair is used for subsequent handshakes; a failed reload keeps serving the last good pair.
18+
19+
This listener serves OAuth/OIDC authorization-server routes only. Those routes remain mounted on the MCP transport port as well; enabling the listener does not make MCP traffic HTTPS. The listener uses the runner's bind host, so binding a non-loopback address exposes it on that network interface: restrict network reachability with deployment firewall/network policy controls and protect the certificate key. SPIFFE trust-domain configuration is still rejected at startup, and X.509-SVID verification is not implemented, so this listener does not make SPIFFE authentication deployable. vMCP does not start this listener: its current constructor call supplies no listener host, so configuring `tls_listener` there fails clearly at startup. Operator configuration and Service/port or certificate-mount exposure for this listener are not supported.
20+
21+
The token-endpoint middleware rejects certificates containing a malformed SPIFFE identity with HTTP 401 and JSON `{"error":"invalid_client"}`, using `Content-Type: application/json` and `Cache-Control: no-store`. Rejected requests do not reach the authentication strategy; certificates without a SPIFFE URI SAN pass through unchanged. Extracting a claimed SPIFFE ID does not verify the certificate or enable SPIFFE authentication.
622

723
## Model and boundaries
824

@@ -104,11 +120,11 @@ JWT-SVID assertions currently have no application-level replay protection, `jti`
104120

105121
Configuration and loaded bundles are not authentication by themselves. A client ID, a declared association, a request header, an unverified SPIFFE-looking URI, a client-supplied trust domain, or a loaded bundle is never workload identity. JWT-SVID validation establishes identity only after the assertion validates against configured trust material and the association registry authorizes the resulting SPIFFE ID and configured client ID.
106122

107-
The JWT-SVID client-authentication path described above is implemented by [#6203](https://github.com/stacklok/toolhive/issues/6203). `newServer` (`pkg/authserver/server_impl.go`) now constructs and wires the multi-domain bundle source into both JWT and X.509 verification interfaces. However, `RunConfig.Validate()` rejects every non-empty SPIFFE trust configuration before normal server startup, so neither authentication path is currently deployable. The file loader and its rotation logic are wired into server construction, but are not reachable through that startup path. TLS listener startup and X.509-SVID verification ([#6202](https://github.com/stacklok/toolhive/issues/6202)) remain unimplemented; Workload API and bundle-endpoint loaders, SPIFFE method integration with grants or discovery metadata ([#6204](https://github.com/stacklok/toolhive/issues/6204)), and deploying SPIRE or mounting Workload API sockets ([#6205](https://github.com/stacklok/toolhive/issues/6205)) also remain pending.
123+
The JWT-SVID client-authentication path described above is implemented by [#6203](https://github.com/stacklok/toolhive/issues/6203). `newServer` (`pkg/authserver/server_impl.go`) constructs and wires the multi-domain bundle source into both JWT and X.509 verification interfaces. However, `RunConfig.Validate()` rejects every non-empty SPIFFE trust configuration before normal server startup, so neither authentication path is currently deployable. The file loader and its rotation logic are wired into server construction, but are not reachable through that startup path. The proxy runner's auth-server TLS listener is implemented and documented above; X.509-SVID verification ([#6202](https://github.com/stacklok/toolhive/issues/6202)) remains unimplemented. Workload API and bundle-endpoint loaders, SPIFFE method integration with grants or discovery metadata ([#6204](https://github.com/stacklok/toolhive/issues/6204)), and deploying SPIRE or mounting Workload API sockets ([#6205](https://github.com/stacklok/toolhive/issues/6205)) also remain pending.
108124

109125
For [#6205](https://github.com/stacklok/toolhive/issues/6205), `workloadapi.X509Source` implements both `x509svid.Source` and `x509bundle.Source`, so one Workload API connection can also provide the authorization server's own certificate when deployment wiring is added. The v1alpha1 `ClientCASecretRef` plus `subPath` shape cannot support a rotating bundle and must not be reused for this purpose.
110126

111-
Configured SPIFFE associations remain non-deployable independent of the above: `RunConfig.Validate()` still hard-rejects any non-empty `spiffeTrustDomains` (see "Current status" above), so none of this runs against a real deployment yet.
127+
Configured SPIFFE associations remain non-deployable: `RunConfig.Validate()` still hard-rejects any non-empty `spiffeTrustDomains` (see "Current status" above), so none of the SPIFFE authentication paths runs against a real deployment yet.
112128

113129
Future X.509-SVID credential-validation code must establish identity from validated SVIDs and then resolve that verified identity through this registry. It must fail closed for missing associations, client-ID ownership mismatches, unknown trust domains, and methods not enabled by policy.
114130

‎docs/arch/README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,9 @@ Welcome to the ToolHive architecture documentation. This directory contains comp
142142

143143
18. **[SPIFFE Association Declarations](18-spiffe-association-declarations.md)**
144144
- SPIFFE configuration remains rejected at startup; the feature is not yet deployable
145-
- File-bundle source and `newServer` X.509/JWT bundle wiring are present but unreachable through normal startup
146-
- JWT-SVID validation logic exists; X.509-SVID verification and TLS listener startup remain unimplemented
145+
- Auth-server TLS listener runs on the proxy runner at fixed port 8443; vMCP and operator configuration/exposure are unsupported
146+
- File-bundle source and `newServer` X.509/JWT bundle wiring are present but unreachable through normal SPIFFE startup
147+
- JWT-SVID validation logic exists; X.509-SVID verification remains unimplemented
147148
- Fail-closed policy validation and durable, restart-safe static-client reservation
148149
- Endpoint/Workload API loading and SPIFFE method integration with grants or discovery remain pending
149150

‎docs/server/docs.go‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/server/swagger.json‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/server/swagger.yaml‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎pkg/authserver/config.go‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import (
3333
// CurrentSchemaVersion is the current version of the authserver RunConfig schema.
3434
const CurrentSchemaVersion = "v0.1.0"
3535

36-
// TLSListenerRunConfig describes the auth server TLS listener configuration.
36+
// TLSListenerRunConfig configures the auth server TLS listener on port 8443.
3737
type TLSListenerRunConfig struct {
3838
CertFile string `json:"cert_file" yaml:"cert_file"`
3939
KeyFile string `json:"key_file" yaml:"key_file"`
@@ -275,8 +275,8 @@ type RunConfig struct {
275275
// non-nil value explicitly controls grant-family enablement.
276276
InboundGrants *InboundGrantsRunConfig `json:"inbound_grants,omitempty" yaml:"inbound_grants,omitempty"`
277277

278-
// TLSListener configures auth server routes to be served over TLS on port 8443.
279-
// It is currently rejected because the runner does not start this listener.
278+
// TLSListener serves the auth server routes over TLS on port 8443, in
279+
// addition to the MCP port. Required for spiffe_x509 client authentication.
280280
TLSListener *TLSListenerRunConfig `json:"tls_listener,omitempty" yaml:"tls_listener,omitempty"`
281281

282282
// RegistrationRateLimit bounds the unauthenticated /oauth/register
@@ -354,11 +354,8 @@ type DelegateClientRunConfig struct {
354354
// catches operator-supplied misconfiguration early so server startup fails
355355
// loudly instead of degrading silently at runtime.
356356
func (c *RunConfig) Validate() error {
357-
if c.TLSListener != nil {
358-
return errors.New(
359-
"tls_listener: the TLS listener is not yet supported by this build; " +
360-
"remove this configuration until listener startup is implemented",
361-
)
357+
if err := c.TLSListener.validate(); err != nil {
358+
return err
362359
}
363360
if err := c.validateClientAdmission(); err != nil {
364361
return err
@@ -424,6 +421,25 @@ func (c *RunConfig) validateClientAdmission() error {
424421
return nil
425422
}
426423

424+
// validate requires both certificate paths when a TLS listener is configured.
425+
// A nil receiver means no listener and is valid.
426+
func (l *TLSListenerRunConfig) validate() error {
427+
if l == nil {
428+
return nil
429+
}
430+
missing := make([]string, 0, 2)
431+
if l.CertFile == "" {
432+
missing = append(missing, "tls_listener.cert_file")
433+
}
434+
if l.KeyFile == "" {
435+
missing = append(missing, "tls_listener.key_file")
436+
}
437+
if len(missing) > 0 {
438+
return fmt.Errorf("%s must be set", strings.Join(missing, " and "))
439+
}
440+
return nil
441+
}
442+
427443
// validateUpstreamFilter rejects an UpstreamFilter configuration whose rules or
428444
// defaults reference upstream names that are not configured, that reference the
429445
// mandatory first upstream, or that otherwise violate the filter's static

‎pkg/authserver/config_test.go‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2039,14 +2039,23 @@ func TestRunConfigValidateTLSListener(t *testing.T) {
20392039
}{
20402040
{name: "nil listener"},
20412041
{
2042-
name: "empty listener config is rejected as unsupported",
2043-
config: &TLSListenerRunConfig{},
2044-
wantErrMsg: "tls_listener: the TLS listener is not yet supported",
2042+
name: "complete listener config",
2043+
config: &TLSListenerRunConfig{CertFile: "cert", KeyFile: "key"},
2044+
},
2045+
{
2046+
name: "missing certificate",
2047+
config: &TLSListenerRunConfig{KeyFile: "key"},
2048+
wantErrMsg: "tls_listener.cert_file",
20452049
},
20462050
{
2047-
name: "complete listener config is rejected as unsupported",
2048-
config: &TLSListenerRunConfig{CertFile: "cert", KeyFile: "key"},
2049-
wantErrMsg: "tls_listener: the TLS listener is not yet supported",
2051+
name: "missing key",
2052+
config: &TLSListenerRunConfig{CertFile: "cert"},
2053+
wantErrMsg: "tls_listener.key_file",
2054+
},
2055+
{
2056+
name: "missing both",
2057+
config: &TLSListenerRunConfig{},
2058+
wantErrMsg: "tls_listener.cert_file and tls_listener.key_file",
20502059
},
20512060
}
20522061
for _, tt := range tests {

0 commit comments

Comments
 (0)