From a3c45c493c98cd005d0e63c8f6dac1a7717c7abc Mon Sep 17 00:00:00 2001 From: Dave Hubbard <4305408+unsmith@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:22:32 -0400 Subject: [PATCH] wip - oauth2 refactoring --- pkg/custom_detectors/custom_detectors.go | 158 +++++++---------------- pkg/detectors/detectors.go | 21 ++- pkg/detectors/endpoint_customizer.go | 18 +-- pkg/engine/engine.go | 8 +- pkg/engine/oauth_verifier.go | 47 +++---- 5 files changed, 92 insertions(+), 160 deletions(-) diff --git a/pkg/custom_detectors/custom_detectors.go b/pkg/custom_detectors/custom_detectors.go index 36454e74e..528fa2600 100644 --- a/pkg/custom_detectors/custom_detectors.go +++ b/pkg/custom_detectors/custom_detectors.go @@ -5,17 +5,14 @@ import ( "context" "encoding/json" "errors" - "fmt" "io" "maps" "net/http" - "net/url" "regexp" //nolint:depguard // used instead of github.com/wasilibs/go-re2 due to differences in utf-8 handling "slices" "strings" - "sync" - "time" + "golang.org/x/oauth2" "golang.org/x/sync/errgroup" "github.com/trufflesecurity/trufflehog/v3/pkg/common" @@ -31,122 +28,64 @@ const maxTotalMatches = 100 // ─── OAuth2 token acquisition ──────────────────────────────────────── -// tokenExpiryDelta is subtracted from the token's expiry time to -// avoid race conditions where the token expires between the check -// and the HTTP request. -const tokenExpiryDelta = 10 * time.Second - -// ropcTokenSource implements TokenSource for the Resource Owner -// Password Credentials grant (RFC 6749 Section 4.3). It caches the -// current token and only contacts the token endpoint when the cached -// token is missing or about to expire. +// ropcTokenSource implements oauth2.TokenSource for the Resource Owner +// Password Credentials grant (RFC 6749 Section 4.3). Caching and +// expiry are handled by oauth2.ReuseTokenSource; this type only +// performs the token exchange. type ropcTokenSource struct { - tokenEndpoint string - username string - password string - clientID string - clientSecret string - scope string - - mu sync.Mutex - token string - expiry time.Time + conf *oauth2.Config + username string + password string + // httpCtx carries the HTTP client for TLS and timeout settings + // via the oauth2.HTTPClient context key. Stored at construction + // time so PasswordCredentialsToken uses our SaneHttpClient. + httpCtx context.Context } -// newROPCTokenSource builds a token source from the proto config. -func newROPCTokenSource(auth *custom_detectorspb.VerifierAuth, ropc *custom_detectorspb.ROPCConfig) *ropcTokenSource { - return &ropcTokenSource{ - tokenEndpoint: auth.GetTokenEndpoint(), - username: ropc.GetUsername(), - password: ropc.GetPassword(), - clientID: ropc.GetClientId(), - clientSecret: ropc.GetClientSecret(), - scope: ropc.GetScope(), - } +func (s *ropcTokenSource) Token() (*oauth2.Token, error) { + return s.conf.PasswordCredentialsToken(s.httpCtx, s.username, s.password) } -// ropcTokenResponse is the standard OAuth2 token response body -// (RFC 6749 Section 5.1). -type ropcTokenResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` -} - -func (s *ropcTokenSource) Token(ctx context.Context) (string, error) { - s.mu.Lock() - defer s.mu.Unlock() - - // Return the cached token if it's still valid. - if s.token != "" && time.Now().Before(s.expiry) { - return s.token, nil +// newROPCTokenSource builds an oauth2.TokenSource for the ROPC grant, +// wrapped in ReuseTokenSource for automatic caching with a 10-second +// expiry buffer. +func newROPCTokenSource(auth *custom_detectorspb.VerifierAuth, ropc *custom_detectorspb.ROPCConfig) oauth2.TokenSource { + conf := &oauth2.Config{ + ClientID: ropc.GetClientId(), + ClientSecret: ropc.GetClientSecret(), + Endpoint: oauth2.Endpoint{ + TokenURL: auth.GetTokenEndpoint(), + AuthStyle: oauth2.AuthStyleInParams, + }, } - - // POST form-encoded ROPC body per RFC 6749 Section 4.3.2. - // Only username, password, and grant_type are required. - // client_id and client_secret are conditional: included only when - // configured, supporting both public clients (no secret) and - // servers that use HTTP Basic auth for client authentication. - form := url.Values{ - "grant_type": {"password"}, - "username": {s.username}, - "password": {s.password}, + if scope := ropc.GetScope(); scope != "" { + conf.Scopes = strings.Split(scope, " ") } - if s.clientID != "" { - form.Set("client_id", s.clientID) + // Inject SaneHttpClient so the token endpoint request uses our + // TLS configuration and timeout settings. + httpCtx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient) + base := &ropcTokenSource{ + conf: conf, + username: ropc.GetUsername(), + password: ropc.GetPassword(), + httpCtx: httpCtx, } - if s.clientSecret != "" { - form.Set("client_secret", s.clientSecret) - } - if s.scope != "" { - form.Set("scope", s.scope) - } - - req, err := http.NewRequestWithContext(ctx, "POST", s.tokenEndpoint, strings.NewReader(form.Encode())) - if err != nil { - return "", fmt.Errorf("building ROPC token request: %w", err) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("ROPC token request to %s: %w", s.tokenEndpoint, err) - } - defer func() { - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - }() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("ROPC token endpoint returned %d: %s", resp.StatusCode, string(body)) - } - - var tokenResp ropcTokenResponse - if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { - return "", fmt.Errorf("decoding ROPC token response: %w", err) - } - - s.token = tokenResp.AccessToken - // Cache with a safety margin so we don't send an about-to-expire token. - s.expiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn)*time.Second - tokenExpiryDelta) - - return s.token, nil + return oauth2.ReuseTokenSource(nil, base) } // customDetectorVerifier binds a VerifierConfig to an optional -// TokenSource. This avoids parallel slices and ensures the auth +// OAuth2TokenSource. This avoids parallel slices and ensures the auth // config can never get out of sync with its verifier. type customDetectorVerifier struct { config *custom_detectorspb.VerifierConfig - tokenSource detectors.TokenSource // nil when no auth is configured + tokenSource detectors.OAuth2TokenSource // nil when no auth is configured } -// BuildTokenSource creates the appropriate TokenSource for a +// BuildTokenSource creates the appropriate OAuth2TokenSource for a // VerifierConfig's auth block, or returns nil if no auth is set. // Exported so the enterprise pipeline can build token sources from // proto config without duplicating grant-type logic. -func BuildTokenSource(auth *custom_detectorspb.VerifierAuth) detectors.TokenSource { +func BuildTokenSource(auth *custom_detectorspb.VerifierAuth) detectors.OAuth2TokenSource { if auth == nil { return nil } @@ -459,18 +398,17 @@ func (c *CustomRegexWebhook) createResults(ctx context.Context, match map[string req.Header.Set("Content-Type", "application/json") } - // If this verifier has OAuth2 auth, acquire a bearer token - // and attach it to the request. + // If this verifier has OAuth2 auth, build an authenticated client + // that transparently adds the Bearer token to each request. + client := httpClient if v.tokenSource != nil { - token, err := v.tokenSource.Token(ctx) - if err != nil { - // Token acquisition failed — skip this verifier. - continue - } - req.Header.Set("Authorization", "Bearer "+token) + client = oauth2.NewClient( + context.WithValue(ctx, oauth2.HTTPClient, httpClient), + v.tokenSource, + ) } - resp, err := httpClient.Do(req) + resp, err := client.Do(req) if err != nil { continue } diff --git a/pkg/detectors/detectors.go b/pkg/detectors/detectors.go index a683a2d5b..974fb0607 100644 --- a/pkg/detectors/detectors.go +++ b/pkg/detectors/detectors.go @@ -11,6 +11,8 @@ import ( "strings" "unicode" + "golang.org/x/oauth2" + "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb" "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb" "github.com/trufflesecurity/trufflehog/v3/pkg/pb/source_metadatapb" @@ -90,17 +92,14 @@ type EndpointCustomizer interface { SetCloudEndpoint(string) UseCloudEndpoint(bool) UseFoundEndpoints(bool) - SetTokenSource(TokenSource) + SetOAuth2TokenSource(OAuth2TokenSource) } -// TokenSource abstracts OAuth2 token acquisition. Each grant type -// implements this interface with its own credential exchange and -// caching logic. Used by custom detector inline verification and by -// the engine's OAuth2 custom verifier path. -type TokenSource interface { - // Token returns a valid access token, refreshing it if necessary. - Token(ctx context.Context) (string, error) -} +// OAuth2TokenSource is the standard oauth2 token source interface, +// named here to make the OAuth2 scope explicit. Other auth mechanisms +// (API key rotation, mTLS, etc.) would define their own interfaces +// rather than being shoehorned into this one. +type OAuth2TokenSource = oauth2.TokenSource // OAuthVerifier is satisfied by any detector whose EndpointSetter has // an OAuth2 token source configured. The engine checks this interface @@ -108,8 +107,8 @@ type TokenSource interface { // through an OAuth2-authenticated POST instead of the detector's // built-in verification logic. type OAuthVerifier interface { - HasTokenSource() bool - GetTokenSource() TokenSource + HasOAuth2() bool + OAuth2TokenSource() OAuth2TokenSource Endpoints(foundEndpoints ...string) []string } diff --git a/pkg/detectors/endpoint_customizer.go b/pkg/detectors/endpoint_customizer.go index 7d1614f6b..2b447f7a1 100644 --- a/pkg/detectors/endpoint_customizer.go +++ b/pkg/detectors/endpoint_customizer.go @@ -9,14 +9,14 @@ import ( // EndpointSetter implements a sensible default for the SetEndpoints function // of the EndpointCustomizer interface. A detector can embed this struct to // gain the functionality. When a custom verifier has OAuth2 auth configured, -// the engine sets a TokenSource here so the scan loop can route verification +// the engine sets a token source here so the scan loop can route verification // through the OAuthVerifier interface instead of the detector's built-in path. type EndpointSetter struct { configuredEndpoints []string cloudEndpoint string useCloudEndpoint bool useFoundEndpoints bool - tokenSource TokenSource + oauth2Source OAuth2TokenSource } func (e *EndpointSetter) SetConfiguredEndpoints(userConfiguredEndpoints ...string) error { @@ -54,15 +54,15 @@ func (e *EndpointSetter) Endpoints(foundEndpoints ...string) []string { return endpoints } -// SetTokenSource configures an OAuth2 token source for this detector's -// custom verifier. When set, the engine uses OAuthVerifier-based +// SetOAuth2TokenSource configures an OAuth2 token source for this +// detector's custom verifier. When set, the engine uses OAuthVerifier-based // verification instead of the detector's built-in verification logic. -func (e *EndpointSetter) SetTokenSource(ts TokenSource) { e.tokenSource = ts } +func (e *EndpointSetter) SetOAuth2TokenSource(ts OAuth2TokenSource) { e.oauth2Source = ts } -// HasTokenSource reports whether OAuth2 auth is configured for this +// HasOAuth2 reports whether OAuth2 auth is configured for this // detector's custom verifier endpoint. -func (e *EndpointSetter) HasTokenSource() bool { return e.tokenSource != nil } +func (e *EndpointSetter) HasOAuth2() bool { return e.oauth2Source != nil } -// GetTokenSource returns the configured OAuth2 token source, or nil +// OAuth2TokenSource returns the configured OAuth2 token source, or nil // if no auth is configured. -func (e *EndpointSetter) GetTokenSource() TokenSource { return e.tokenSource } +func (e *EndpointSetter) OAuth2TokenSource() OAuth2TokenSource { return e.oauth2Source } diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go index 1b3e8911a..08d0e94e1 100644 --- a/pkg/engine/engine.go +++ b/pkg/engine/engine.go @@ -115,7 +115,7 @@ type Config struct { // a detector has a matching entry here, its token source is set // on the detector's EndpointSetter so the engine can perform // OAuth2-authenticated verification via the OAuthVerifier interface. - VerifierAuth map[config.DetectorID]detectors.TokenSource + VerifierAuth map[config.DetectorID]detectors.OAuth2TokenSource // Verify determines whether the scanner will verify candidate secrets. Verify bool @@ -330,7 +330,7 @@ func NewEngine(ctx context.Context, cfg *Config) (*Engine, error) { // OAuthVerifier to route verification through OAuth2. // feature fm-oauth2: custom verifier OAuth2 verification if ts, hasAuth := getWithDetectorID(d, cfg.VerifierAuth); hasAuth { - customizer.SetTokenSource(ts) + customizer.SetOAuth2TokenSource(ts) } return true @@ -1184,12 +1184,12 @@ func (e *Engine) detectChunk(ctx context.Context, data detectableChunk) { // feature fm-oauth2: custom verifier OAuth2 verification var results []detectors.Result var err error - if oauthV, ok := data.detector.Detector.(detectors.OAuthVerifier); ok && oauthV.HasTokenSource() { + if oauthV, ok := data.detector.Detector.(detectors.OAuthVerifier); ok && oauthV.HasOAuth2() { results, err = data.detector.FromData(ctx, false, matchBytes) if err == nil && data.verify && len(results) > 0 { for i := range results { verified, verifyErr := OAuthVerify( - ctx, nil, oauthV.GetTokenSource(), oauthV.Endpoints(), &results[i]) + ctx, nil, oauthV.OAuth2TokenSource(), oauthV.Endpoints(), &results[i]) results[i].Verified = verified results[i].SetVerificationError(verifyErr, string(results[i].Raw)) } diff --git a/pkg/engine/oauth_verifier.go b/pkg/engine/oauth_verifier.go index 308af18ac..18783524c 100644 --- a/pkg/engine/oauth_verifier.go +++ b/pkg/engine/oauth_verifier.go @@ -1,7 +1,8 @@ // oauth_verifier.go provides the standalone OAuth2 verification function // used by the engine scan loop when a detector has a custom verifier with -// OAuth2 auth configured. The function acquires a Bearer token and POSTs -// the detected credential to the custom verifier endpoint. +// OAuth2 auth configured. The function builds an authenticated HTTP client +// via oauth2.NewClient and POSTs the detected credential to the custom +// verifier endpoint. // // This file contains only HTTP/verification logic. Configuration state // (token source, endpoints) lives on EndpointSetter; orchestration lives @@ -19,6 +20,8 @@ import ( "io" "net/http" + "golang.org/x/oauth2" + "github.com/trufflesecurity/trufflehog/v3/pkg/common" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" ) @@ -40,29 +43,34 @@ type oauthVerifyResponse struct { Verified bool `json:"verified"` } -var defaultOAuthHTTPClient = common.SaneHttpClient() +var defaultOAuthBaseClient = common.SaneHttpClient() -// OAuthVerify acquires a Bearer token from the given TokenSource and -// POSTs the credential to each endpoint until one returns a definitive -// answer. Response protocol: +// OAuthVerify builds an OAuth2-authenticated HTTP client and POSTs +// the credential to each endpoint until one returns a definitive +// answer. The token source handles caching and refresh via +// oauth2.ReuseTokenSource; the client adds the Bearer header +// transparently via oauth2.Transport. Response protocol: // // 200 {"verified": true} — credential is valid // 200 {"verified": false} — credential checked, not valid // 401 — token rejected, stop immediately // other — transient/unexpected, try next endpoint -func OAuthVerify(ctx context.Context, client *http.Client, ts detectors.TokenSource, endpoints []string, result *detectors.Result) (bool, error) { +func OAuthVerify(ctx context.Context, baseClient *http.Client, ts detectors.OAuth2TokenSource, endpoints []string, result *detectors.Result) (bool, error) { if len(endpoints) == 0 { return false, fmt.Errorf("no verification endpoints configured") } - if client == nil { - client = defaultOAuthHTTPClient + if baseClient == nil { + baseClient = defaultOAuthBaseClient } - token, err := ts.Token(ctx) - if err != nil { - return false, fmt.Errorf("acquiring OAuth2 token for verification: %w", err) - } + // Build an authenticated client. oauth2.NewClient wraps the base + // client's transport with oauth2.Transport, which calls + // ts.Token() per request and sets the Authorization header. + client := oauth2.NewClient( + context.WithValue(ctx, oauth2.HTTPClient, baseClient), + ts, + ) reqBody := oauthVerifyRequest{ DetectorType: result.DetectorType.String(), @@ -76,19 +84,16 @@ func OAuthVerify(ctx context.Context, client *http.Client, ts detectors.TokenSou // Try each endpoint until we get a definitive answer. var lastErr error - var retried bool for _, endpoint := range endpoints { if common.IsDone(ctx) { return false, ctx.Err() } - retryWithNewToken: req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(bodyBytes)) if err != nil { lastErr = err continue } - req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) @@ -108,16 +113,6 @@ func OAuthVerify(ctx context.Context, client *http.Client, ts detectors.TokenSou } return parsed.Verified, nil case http.StatusUnauthorized: - // Token may have expired. Re-acquire once and retry - // the same endpoint if we get a different token. - if !retried { - retried = true - newToken, err := ts.Token(ctx) - if err == nil && newToken != token { - token = newToken - goto retryWithNewToken - } - } return false, fmt.Errorf("OAuth2 token rejected by verifier (401)") default: lastErr = fmt.Errorf("verifier returned HTTP %d: %s", resp.StatusCode, string(respBody))