Skip to content

Commit d78b6ab

Browse files
committed
Integrate token exchange middleware
Adds OAuth 2.0 Token Exchange (RFC 8693) support to both 'thv proxy' and 'thv run' commands for secure downstream token swapping. Changes include: - New token exchange configuration flags in RemoteAuthFlags - Refactored secret resolution into reusable resolveSecretFromSources - Token exchange middleware registration in runner config builder - Conditional middleware application based on configuration When token exchange is configured via --token-exchange-url, the middleware exchanges the user's bearer token for a downstream token suitable for backend authentication. Fixes: #2066
1 parent 866b10d commit d78b6ab

File tree

8 files changed

+249
-101
lines changed

8 files changed

+249
-101
lines changed

cmd/thv/app/auth_flags.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55

66
"github.com/spf13/cobra"
77

8+
"github.com/stacklok/toolhive/pkg/auth/tokenexchange"
9+
"github.com/stacklok/toolhive/pkg/logger"
810
"github.com/stacklok/toolhive/pkg/runner"
911
)
1012

@@ -21,6 +23,56 @@ type RemoteAuthFlags struct {
2123
RemoteAuthIssuer string
2224
RemoteAuthAuthorizeURL string
2325
RemoteAuthTokenURL string
26+
27+
// Token Exchange Configuration
28+
TokenExchangeURL string
29+
TokenExchangeClientID string
30+
TokenExchangeClientSecret string
31+
TokenExchangeClientSecretFile string
32+
TokenExchangeAudience string
33+
TokenExchangeScopes []string
34+
TokenExchangeHeaderName string
35+
}
36+
37+
// BuildTokenExchangeConfig creates a TokenExchangeConfig from the RemoteAuthFlags
38+
// Returns nil if TokenExchangeURL is empty (token exchange is not configured)
39+
func (f *RemoteAuthFlags) BuildTokenExchangeConfig() *tokenexchange.Config {
40+
// Only create config if token exchange URL is provided
41+
if f.TokenExchangeURL == "" {
42+
return nil
43+
}
44+
45+
// Resolve token exchange client secret from multiple sources
46+
clientSecret, err := resolveSecretFromSources(
47+
f.TokenExchangeClientSecret,
48+
f.TokenExchangeClientSecretFile,
49+
envTokenExchangeClientSecret,
50+
"token exchange client secret",
51+
)
52+
if err != nil {
53+
logger.Warnf("Failed to resolve token exchange client secret: %v", err)
54+
clientSecret = ""
55+
}
56+
57+
// Determine header strategy based on whether custom header name is provided
58+
var headerStrategy string
59+
var externalTokenHeaderName string
60+
if f.TokenExchangeHeaderName != "" {
61+
headerStrategy = tokenexchange.HeaderStrategyCustom
62+
externalTokenHeaderName = f.TokenExchangeHeaderName
63+
} else {
64+
headerStrategy = tokenexchange.HeaderStrategyReplace
65+
}
66+
67+
return &tokenexchange.Config{
68+
TokenURL: f.TokenExchangeURL,
69+
ClientID: f.TokenExchangeClientID,
70+
ClientSecret: clientSecret,
71+
Audience: f.TokenExchangeAudience,
72+
Scopes: f.TokenExchangeScopes,
73+
HeaderStrategy: headerStrategy,
74+
ExternalTokenHeaderName: externalTokenHeaderName,
75+
}
2476
}
2577

2678
// AddRemoteAuthFlags adds the common remote authentication flags to a command
@@ -47,4 +99,20 @@ func AddRemoteAuthFlags(cmd *cobra.Command, config *RemoteAuthFlags) {
4799
"OAuth authorization endpoint URL (alternative to --remote-auth-issuer for non-OIDC OAuth)")
48100
cmd.Flags().StringVar(&config.RemoteAuthTokenURL, "remote-auth-token-url", "",
49101
"OAuth token endpoint URL (alternative to --remote-auth-issuer for non-OIDC OAuth)")
102+
103+
// Token Exchange flags
104+
cmd.Flags().StringVar(&config.TokenExchangeURL, "token-exchange-url", "",
105+
"OAuth 2.0 token exchange endpoint URL (enables token exchange when provided)")
106+
cmd.Flags().StringVar(&config.TokenExchangeClientID, "token-exchange-client-id", "",
107+
"OAuth client ID for token exchange operations")
108+
cmd.Flags().StringVar(&config.TokenExchangeClientSecret, "token-exchange-client-secret", "",
109+
"OAuth client secret for token exchange operations")
110+
cmd.Flags().StringVar(&config.TokenExchangeClientSecretFile, "token-exchange-client-secret-file", "",
111+
"Path to file containing OAuth client secret for token exchange (alternative to --token-exchange-client-secret)")
112+
cmd.Flags().StringVar(&config.TokenExchangeAudience, "token-exchange-audience", "",
113+
"Target audience for exchanged tokens")
114+
cmd.Flags().StringSliceVar(&config.TokenExchangeScopes, "token-exchange-scopes", []string{},
115+
"Scopes to request for exchanged tokens")
116+
cmd.Flags().StringVar(&config.TokenExchangeHeaderName, "token-exchange-header-name", "",
117+
"Custom header name for injecting exchanged token (default: replaces Authorization header)")
50118
}

cmd/thv/app/proxy.go

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/stacklok/toolhive/pkg/auth"
1919
"github.com/stacklok/toolhive/pkg/auth/discovery"
2020
"github.com/stacklok/toolhive/pkg/auth/oauth"
21+
"github.com/stacklok/toolhive/pkg/auth/tokenexchange"
2122
"github.com/stacklok/toolhive/pkg/logger"
2223
"github.com/stacklok/toolhive/pkg/networking"
2324
"github.com/stacklok/toolhive/pkg/transport"
@@ -121,6 +122,8 @@ var (
121122
const (
122123
// #nosec G101 - this is an environment variable name, not a credential
123124
envOAuthClientSecret = "TOOLHIVE_REMOTE_OAUTH_CLIENT_SECRET"
125+
// #nosec G101 - this is an environment variable name, not a credential
126+
envTokenExchangeClientSecret = "TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET"
124127
)
125128

126129
func init() {
@@ -227,8 +230,19 @@ func proxyCmdFunc(cmd *cobra.Command, args []string) error {
227230
}
228231
middlewares = append(middlewares, authMiddleware)
229232

230-
// Add OAuth token injection middleware for outgoing requests if we have an access token
231-
if tokenSource != nil {
233+
// Add OAuth token injection or token exchange middleware for outgoing requests
234+
if remoteAuthFlags.TokenExchangeURL != "" {
235+
// Use token exchange middleware when token exchange is configured
236+
tokenExchangeConfig := createTokenExchangeConfig()
237+
if tokenExchangeConfig != nil {
238+
tokenExchangeMiddleware, teMwErr := tokenexchange.CreateTokenExchangeMiddlewareFromClaims(*tokenExchangeConfig)
239+
if teMwErr != nil {
240+
return fmt.Errorf("failed to create token exchange middleware: %v", teMwErr)
241+
}
242+
middlewares = append(middlewares, tokenExchangeMiddleware)
243+
}
244+
} else if tokenSource != nil {
245+
// Fallback to direct token injection when no token exchange is configured
232246
tokenMiddleware := createTokenInjectionMiddleware(tokenSource)
233247
middlewares = append(middlewares, tokenMiddleware)
234248
}
@@ -346,43 +360,70 @@ func handleOutgoingAuthentication(ctx context.Context) (*oauth2.TokenSource, *oa
346360
return nil, nil, nil // No authentication required
347361
}
348362

349-
// resolveClientSecret resolves the OAuth client secret from multiple sources
350-
// Priority: 1. Flag value, 2. File, 3. Environment variable
351-
func resolveClientSecret() (string, error) {
363+
// resolveSecretFromSources resolves a secret from multiple sources with priority ordering
364+
// Priority: 1. Direct value (flag), 2. File path, 3. Environment variable
365+
// Returns empty string if no source provides a value (not an error)
366+
func resolveSecretFromSources(directValue, filePath, envVarName, secretType string) (string, error) {
352367
// 1. Check if provided directly via flag
353-
if remoteAuthFlags.RemoteAuthClientSecret != "" {
354-
logger.Debug("Using client secret from command-line flag")
355-
return remoteAuthFlags.RemoteAuthClientSecret, nil
368+
if directValue != "" {
369+
logger.Debugf("Using %s from command-line flag", secretType)
370+
return directValue, nil
356371
}
357372

358373
// 2. Check if provided via file
359-
if remoteAuthFlags.RemoteAuthClientSecretFile != "" {
374+
if filePath != "" {
360375
// Clean the file path to prevent path traversal
361-
cleanPath := filepath.Clean(remoteAuthFlags.RemoteAuthClientSecretFile)
362-
logger.Debugf("Reading client secret from file: %s", cleanPath)
376+
cleanPath := filepath.Clean(filePath)
377+
logger.Debugf("Reading %s from file: %s", secretType, cleanPath)
363378
// #nosec G304 - file path is cleaned above
364379
secretBytes, err := os.ReadFile(cleanPath)
365380
if err != nil {
366-
return "", fmt.Errorf("failed to read client secret file %s: %w", cleanPath, err)
381+
return "", fmt.Errorf("failed to read %s file %s: %w", secretType, cleanPath, err)
367382
}
368383
secret := strings.TrimSpace(string(secretBytes))
369384
if secret == "" {
370-
return "", fmt.Errorf("client secret file %s is empty", cleanPath)
385+
return "", fmt.Errorf("%s file %s is empty", secretType, cleanPath)
371386
}
372387
return secret, nil
373388
}
374389

375390
// 3. Check environment variable
376-
if secret := os.Getenv(envOAuthClientSecret); secret != "" {
377-
logger.Debugf("Using client secret from %s environment variable", envOAuthClientSecret)
378-
return secret, nil
391+
if envVarName != "" {
392+
if secret := os.Getenv(envVarName); secret != "" {
393+
logger.Debugf("Using %s from %s environment variable", secretType, envVarName)
394+
return secret, nil
395+
}
379396
}
380397

381-
// No client secret found - this is acceptable for PKCE flows
382-
logger.Debug("No client secret provided - using PKCE flow")
398+
// No secret found - return empty string (caller decides if this is an error)
399+
logger.Debugf("No %s provided", secretType)
383400
return "", nil
384401
}
385402

403+
// resolveClientSecret resolves the OAuth client secret from multiple sources
404+
// Priority: 1. Flag value, 2. File, 3. Environment variable
405+
func resolveClientSecret() (string, error) {
406+
secret, err := resolveSecretFromSources(
407+
remoteAuthFlags.RemoteAuthClientSecret,
408+
remoteAuthFlags.RemoteAuthClientSecretFile,
409+
envOAuthClientSecret,
410+
"client secret",
411+
)
412+
if err != nil {
413+
return "", err
414+
}
415+
if secret == "" {
416+
// No client secret found - this is acceptable for PKCE flows
417+
logger.Debug("No client secret provided - using PKCE flow")
418+
}
419+
return secret, nil
420+
}
421+
422+
// createTokenExchangeConfig creates a TokenExchangeConfig from remoteAuthFlags
423+
func createTokenExchangeConfig() *tokenexchange.Config {
424+
return remoteAuthFlags.BuildTokenExchangeConfig()
425+
}
426+
386427
// createTokenInjectionMiddleware creates a middleware that injects the OAuth token into requests
387428
func createTokenInjectionMiddleware(tokenSource *oauth2.TokenSource) types.MiddlewareFunction {
388429
return func(next http.Handler) http.Handler {

cmd/thv/app/run_flags.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/spf13/cobra"
99

1010
"github.com/stacklok/toolhive/pkg/auth"
11+
"github.com/stacklok/toolhive/pkg/auth/tokenexchange"
1112
"github.com/stacklok/toolhive/pkg/authz"
1213
"github.com/stacklok/toolhive/pkg/cli"
1314
cfg "github.com/stacklok/toolhive/pkg/config"
@@ -458,10 +459,12 @@ func buildRunnerConfig(
458459
opts = append(opts, runner.WithToolsOverride(toolsOverride))
459460
// Configure middleware from flags
460461
// Use computed serverName and transportType for correct telemetry labels
462+
tokenExchangeConfig := getTokenExchangeConfigFromRunFlags(runFlags)
461463
opts = append(
462464
opts,
463465
runner.WithMiddlewareFromFlags(
464466
oidcConfig,
467+
tokenExchangeConfig,
465468
runFlags.ToolsFilter,
466469
toolsOverride,
467470
telemetryConfig,
@@ -605,6 +608,11 @@ func getRemoteAuthFromRunFlags(runFlags *RunFlags) *runner.RemoteAuthConfig {
605608
}
606609
}
607610

611+
// getTokenExchangeConfigFromRunFlags creates TokenExchangeConfig from RunFlags
612+
func getTokenExchangeConfigFromRunFlags(runFlags *RunFlags) *tokenexchange.Config {
613+
return runFlags.RemoteAuthFlags.BuildTokenExchangeConfig()
614+
}
615+
608616
// getOidcFromFlags extracts OIDC configuration from command flags
609617
func getOidcFromFlags(cmd *cobra.Command) (string, string, string, string, string, string) {
610618
oidcIssuer := GetStringFlagOrEmpty(cmd, "oidc-issuer")

docs/cli/thv_proxy.md

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

0 commit comments

Comments
 (0)