-
Notifications
You must be signed in to change notification settings - Fork 126
Add middleware to swap the downstream ticket for the upstream ticket #2113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
72d8c1f
Add middleware to swap the downstream ticket for the upstream ticket
jhrozek 05309f0
review feedback: Change scopes in Config to []strings
jhrozek 5e2ac18
review feedback: Make the strategy selection a closure called by the …
jhrozek 82f579c
review feedback: move exhcnageConfig outside the handler to CreateTok…
jhrozek 20b61d4
throw an error instead of nil in case the middleware is misconfigured
jhrozek 98c4962
Review feedback: Make CreateTokenExchangeMiddlewareFromClaims return …
jhrozek File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,239 @@ | ||
package tokenexchange | ||
|
||
import ( | ||
"encoding/json" | ||
"errors" | ||
"fmt" | ||
"net/http" | ||
"strings" | ||
|
||
"github.com/golang-jwt/jwt/v5" | ||
|
||
"github.com/stacklok/toolhive/pkg/auth" | ||
"github.com/stacklok/toolhive/pkg/logger" | ||
"github.com/stacklok/toolhive/pkg/transport/types" | ||
) | ||
|
||
// Middleware type constant | ||
const ( | ||
MiddlewareType = "tokenexchange" | ||
) | ||
|
||
// Header injection strategy constants | ||
const ( | ||
// HeaderStrategyReplace replaces the Authorization header with the exchanged token | ||
HeaderStrategyReplace = "replace" | ||
// HeaderStrategyCustom adds the exchanged token to a custom header | ||
HeaderStrategyCustom = "custom" | ||
) | ||
|
||
var errUnknownStrategy = errors.New("unknown token injection strategy") | ||
|
||
// MiddlewareParams represents the parameters for token exchange middleware | ||
type MiddlewareParams struct { | ||
TokenExchangeConfig *Config `json:"token_exchange_config,omitempty"` | ||
} | ||
|
||
// Config holds configuration for token exchange middleware | ||
type Config struct { | ||
// TokenURL is the OAuth 2.0 token endpoint URL | ||
TokenURL string `json:"token_url"` | ||
|
||
// ClientID is the OAuth 2.0 client identifier | ||
ClientID string `json:"client_id"` | ||
|
||
// ClientSecret is the OAuth 2.0 client secret | ||
ClientSecret string `json:"client_secret"` | ||
|
||
// Audience is the target audience for the exchanged token | ||
Audience string `json:"audience"` | ||
|
||
// Scopes is the list of scopes to request for the exchanged token | ||
Scopes []string `json:"scopes,omitempty"` | ||
|
||
// HeaderStrategy determines how to inject the token | ||
// Valid values: HeaderStrategyReplace (default), HeaderStrategyCustom | ||
HeaderStrategy string `json:"header_strategy,omitempty"` | ||
|
||
// ExternalTokenHeaderName is the name of the custom header to use when HeaderStrategy is "custom" | ||
ExternalTokenHeaderName string `json:"external_token_header_name,omitempty"` | ||
} | ||
|
||
// Middleware wraps token exchange middleware functionality | ||
type Middleware struct { | ||
middleware types.MiddlewareFunction | ||
} | ||
|
||
// Handler returns the middleware function used by the proxy. | ||
func (m *Middleware) Handler() types.MiddlewareFunction { | ||
return m.middleware | ||
} | ||
|
||
// Close cleans up any resources used by the middleware. | ||
func (*Middleware) Close() error { | ||
// Token exchange middleware doesn't need cleanup | ||
return nil | ||
} | ||
|
||
// CreateMiddleware factory function for token exchange middleware | ||
func CreateMiddleware(config *types.MiddlewareConfig, runner types.MiddlewareRunner) error { | ||
var params MiddlewareParams | ||
if err := json.Unmarshal(config.Parameters, ¶ms); err != nil { | ||
return fmt.Errorf("failed to unmarshal token exchange middleware parameters: %w", err) | ||
jhrozek marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
// Token exchange config is required when this middleware type is specified | ||
if params.TokenExchangeConfig == nil { | ||
return fmt.Errorf("token exchange configuration is required but not provided") | ||
} | ||
|
||
// Validate configuration | ||
if err := validateTokenExchangeConfig(params.TokenExchangeConfig); err != nil { | ||
return fmt.Errorf("invalid token exchange configuration: %w", err) | ||
} | ||
|
||
middleware, err := CreateTokenExchangeMiddlewareFromClaims(*params.TokenExchangeConfig) | ||
if err != nil { | ||
return fmt.Errorf("invalid token exchange middleware config: %w", err) | ||
} | ||
|
||
tokenExchangeMw := &Middleware{ | ||
middleware: middleware, | ||
} | ||
|
||
// Add middleware to runner | ||
runner.AddMiddleware(tokenExchangeMw) | ||
blkt marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
return nil | ||
} | ||
|
||
// validateTokenExchangeConfig validates the token exchange configuration | ||
func validateTokenExchangeConfig(config *Config) error { | ||
if config.HeaderStrategy == HeaderStrategyCustom && config.ExternalTokenHeaderName == "" { | ||
return fmt.Errorf("external_token_header_name must be specified when header_strategy is '%s'", HeaderStrategyCustom) | ||
} | ||
|
||
if config.HeaderStrategy != "" && | ||
config.HeaderStrategy != HeaderStrategyReplace && | ||
config.HeaderStrategy != HeaderStrategyCustom { | ||
return fmt.Errorf("invalid header_strategy: %s (valid values: '%s', '%s')", | ||
config.HeaderStrategy, HeaderStrategyReplace, HeaderStrategyCustom) | ||
} | ||
|
||
return nil | ||
} | ||
|
||
// injectionFunc is a function that injects a token into an HTTP request | ||
type injectionFunc func(*http.Request, string) error | ||
|
||
// createReplaceInjector creates an injection function that replaces the Authorization header | ||
func createReplaceInjector() injectionFunc { | ||
return func(r *http.Request, token string) error { | ||
logger.Debugf("Token exchange successful, replacing Authorization header") | ||
r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) | ||
return nil | ||
} | ||
} | ||
|
||
// createCustomInjector creates an injection function that adds the token to a custom header | ||
func createCustomInjector(headerName string) injectionFunc { | ||
// Validate header name at creation time | ||
if headerName == "" { | ||
return func(_ *http.Request, _ string) error { | ||
return fmt.Errorf("external_token_header_name must be specified when header_strategy is '%s'", HeaderStrategyCustom) | ||
} | ||
} | ||
|
||
return func(r *http.Request, token string) error { | ||
logger.Debugf("Token exchange successful, adding token to custom header: %s", headerName) | ||
r.Header.Set(headerName, fmt.Sprintf("Bearer %s", token)) | ||
return nil | ||
} | ||
} | ||
|
||
// CreateTokenExchangeMiddlewareFromClaims creates a middleware that uses token claims | ||
// from the auth middleware to perform token exchange. | ||
// This is a public function for direct usage in proxy commands. | ||
func CreateTokenExchangeMiddlewareFromClaims(config Config) (types.MiddlewareFunction, error) { | ||
// Determine injection strategy at startup time | ||
strategy := config.HeaderStrategy | ||
if strategy == "" { | ||
strategy = HeaderStrategyReplace // Default to replace for backwards compatibility | ||
} | ||
|
||
var injectToken injectionFunc | ||
switch strategy { | ||
case HeaderStrategyReplace: | ||
injectToken = createReplaceInjector() | ||
case HeaderStrategyCustom: | ||
injectToken = createCustomInjector(config.ExternalTokenHeaderName) | ||
default: | ||
return nil, fmt.Errorf("%w: invalid header injection strategy %s", errUnknownStrategy, strategy) | ||
} | ||
|
||
// Create base exchange config at startup time with all static fields | ||
baseExchangeConfig := ExchangeConfig{ | ||
TokenURL: config.TokenURL, | ||
ClientID: config.ClientID, | ||
ClientSecret: config.ClientSecret, | ||
Audience: config.Audience, | ||
Scopes: config.Scopes, | ||
// SubjectTokenProvider will be set per request | ||
} | ||
|
||
return func(next http.Handler) http.Handler { | ||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
// Get claims from the auth middleware | ||
claims, ok := r.Context().Value(auth.ClaimsContextKey{}).(jwt.MapClaims) | ||
if !ok { | ||
logger.Debug("No claims found in context, proceeding without token exchange") | ||
next.ServeHTTP(w, r) | ||
return | ||
} | ||
|
||
// Extract the original token from the Authorization header | ||
authHeader := r.Header.Get("Authorization") | ||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { | ||
logger.Debug("No valid Bearer token found, proceeding without token exchange") | ||
next.ServeHTTP(w, r) | ||
return | ||
} | ||
|
||
subjectToken := strings.TrimPrefix(authHeader, "Bearer ") | ||
if subjectToken == "" { | ||
logger.Debug("Empty Bearer token, proceeding without token exchange") | ||
next.ServeHTTP(w, r) | ||
return | ||
} | ||
|
||
// Log some claim information for debugging | ||
if sub, exists := claims["sub"]; exists { | ||
logger.Debugf("Performing token exchange for subject: %v", sub) | ||
} | ||
|
||
// Create a copy of the base config with the request-specific subject token | ||
exchangeConfig := baseExchangeConfig | ||
exchangeConfig.SubjectTokenProvider = func() (string, error) { | ||
return subjectToken, nil | ||
} | ||
|
||
// Get token from token source | ||
tokenSource := exchangeConfig.TokenSource(r.Context()) | ||
exchangedToken, err := tokenSource.Token() | ||
if err != nil { | ||
logger.Warnf("Token exchange failed: %v", err) | ||
http.Error(w, "Token exchange failed", http.StatusUnauthorized) | ||
return | ||
} | ||
|
||
// Inject the exchanged token into the request using the pre-selected strategy | ||
if err := injectToken(r, exchangedToken.AccessToken); err != nil { | ||
logger.Warnf("Failed to inject token: %v", err) | ||
http.Error(w, "Token injection failed", http.StatusInternalServerError) | ||
return | ||
} | ||
|
||
next.ServeHTTP(w, r) | ||
}) | ||
}, nil | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.