Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion api/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -2831,7 +2831,8 @@ const docTemplate = `{
"bearer",
"basic",
"oidc_client_credentials",
"oidc_user"
"oidc_user",
"oauth2_token_endpoint"
]
}
}
Expand Down
1 change: 1 addition & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2398,6 +2398,7 @@ components:
- basic
- oidc_client_credentials
- oidc_user
- oauth2_token_endpoint
type: string
required:
- type
Expand Down
3 changes: 2 additions & 1 deletion api/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -2825,7 +2825,8 @@
"bearer",
"basic",
"oidc_client_credentials",
"oidc_user"
"oidc_user",
"oauth2_token_endpoint"
]
}
}
Expand Down
1 change: 1 addition & 0 deletions api/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ definitions:
- basic
- oidc_client_credentials
- oidc_user
- oauth2_token_endpoint
type: string
required:
- type
Expand Down
9 changes: 9 additions & 0 deletions pkg/executors/http/auth/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ var (
ErrOIDCUserClientRequired = pkg.ValidationError{EntityType: "OIDCUserConfig", Message: "oidc_user config: client_id is required"}
ErrOIDCUserUsernameRequired = pkg.ValidationError{EntityType: "OIDCUserConfig", Message: "oidc_user config: username is required"}
ErrOIDCUserPasswordRequired = pkg.ValidationError{EntityType: "OIDCUserConfig", Message: "oidc_user config: password is required"}

ErrOAuth2TokenEndpointConfigRequired = pkg.ValidationError{EntityType: "OAuth2TokenEndpointConfig", Message: "oauth2_token_endpoint config is required"}
ErrOAuth2TokenEndpointURLRequired = pkg.ValidationError{EntityType: "OAuth2TokenEndpointConfig", Message: "oauth2_token_endpoint config: token_url is required"}
ErrOAuth2TokenEndpointClientRequired = pkg.ValidationError{EntityType: "OAuth2TokenEndpointConfig", Message: "oauth2_token_endpoint config: client_id is required"}
ErrOAuth2TokenEndpointSecretRequired = pkg.ValidationError{EntityType: "OAuth2TokenEndpointConfig", Message: "oauth2_token_endpoint config: client_secret is required"}
ErrOAuth2TokenEndpointLocationInvalid = pkg.ValidationError{EntityType: "OAuth2TokenEndpointConfig", Message: "oauth2_token_endpoint config: credentials_location must be 'body' or 'basic_header'"}
ErrOAuth2TokenEndpointReservedExtraParam = pkg.ValidationError{EntityType: "OAuth2TokenEndpointConfig", Message: "oauth2_token_endpoint config: extra_params cannot contain reserved keys (client_id, client_secret, scope, audience)"}
ErrOAuth2TokenEndpointUnsupportedTokenType = pkg.ValidationError{EntityType: "OAuth2TokenEndpointConfig", Message: "oauth2_token_endpoint: token endpoint returned unsupported token_type (only Bearer is accepted)"}
ErrOAuth2TokenEndpointEmptyAccessToken = pkg.ValidationError{EntityType: "OAuth2TokenEndpointConfig", Message: "oauth2_token_endpoint: token endpoint returned empty access_token"}
)

// Provider constructor errors - returned when creating providers directly.
Expand Down
44 changes: 44 additions & 0 deletions pkg/executors/http/auth/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ func NewFromConfig(authConfig map[string]any, httpClient *http.Client) (Provider

return NewOIDCUserProvider(cfg, cacheCfg, httpClient)

case TypeOAuth2TokenEndpoint:
cfg, err := parseOAuth2TokenEndpointConfig(configData)
if err != nil {
return nil, err
}

cacheCfg := parseCacheConfig(cacheData)

return NewOAuth2TokenEndpointProvider(cfg, cacheCfg, httpClient)

default:
return nil, fmt.Errorf("%w: %s", ErrUnknownAuthType, authType)
}
Expand Down Expand Up @@ -168,6 +178,40 @@ func parseOIDCUserConfig(data map[string]any) (*OIDCUserConfig, error) {
return cfg, nil
}

func parseOAuth2TokenEndpointConfig(data map[string]any) (*OAuth2TokenEndpointConfig, error) {
cfg := &OAuth2TokenEndpointConfig{}

if err := mapToStruct(data, cfg); err != nil {
return nil, fmt.Errorf("parse oauth2_token_endpoint config: %w", err)
}

if cfg.TokenURL == "" {
return nil, ErrOAuth2TokenEndpointURLRequired
}

if cfg.ClientID == "" {
return nil, ErrOAuth2TokenEndpointClientRequired
}

if cfg.ClientSecret == "" {
return nil, ErrOAuth2TokenEndpointSecretRequired
}

if cfg.CredentialsLocation != "" &&
cfg.CredentialsLocation != "body" &&
cfg.CredentialsLocation != "basic_header" {
return nil, ErrOAuth2TokenEndpointLocationInvalid
}

for k := range cfg.ExtraParams {
if reservedOAuth2ExtraParams[k] {
return nil, ErrOAuth2TokenEndpointReservedExtraParam
}
}

return cfg, nil
}
Comment on lines +181 to +213
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Use pkg/errors.go validation types for parser failures.

parseOAuth2TokenEndpointConfig currently returns auth-local sentinel errors; this diverges from the repository’s typed error contract. Please map these validation failures to ValidationError (with field-level context) for consistent downstream handling.

As per coding guidelines, Use custom error types from pkg/errors.go (EntityNotFoundError, ValidationError) for error handling.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/executors/http/auth/factory.go` around lines 181 - 213, Replace sentinel
auth errors in parseOAuth2TokenEndpointConfig with pkg/errors.go ValidationError
values: if mapToStruct returns an error, wrap it into a ValidationError (e.g.,
NewValidationError or constructing ValidationError) with field="" or more
specific context from the mapToStruct error; for empty
TokenURL/ClientID/ClientSecret return a ValidationError that identifies the
specific field ("token_url", "client_id", "client_secret") and an appropriate
message; for invalid CredentialsLocation return a ValidationError on field
"credentials_location"; and when reservedOAuth2ExtraParams[k] is true return a
ValidationError that references the offending extra param (field like
fmt.Sprintf("extra_params.%s", k)) and includes the reserved-param message; keep
the original checks (mapToStruct, reservedOAuth2ExtraParams loop) and replace
the ErrOAuth2TokenEndpoint* returns with constructed ValidationError instances
from pkg/errors.go so downstream code receives typed validation errors.


func parseCacheConfig(data map[string]any) *CacheCfg {
if data == nil {
return nil
Expand Down
153 changes: 153 additions & 0 deletions pkg/executors/http/auth/factory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,159 @@ func TestNewFromConfigOIDCUserMissingUsername(t *testing.T) {
require.ErrorIs(t, err, ErrOIDCUserUsernameRequired)
}

func TestNewFromConfigOAuth2TokenEndpoint(t *testing.T) {
tests := []struct {
name string
config map[string]any
wantErr error
}{
{
name: "full valid config",
config: map[string]any{
"token_url": "https://idp.example.com/token",
"client_id": "my-client",
"client_secret": "my-secret",
"credentials_location": "body",
"scopes": []any{"api:read"},
"audience": "my-audience",
"extra_params": map[string]any{"grant_type": "client_credentials"},
},
wantErr: nil,
},
{
name: "minimal valid config (Delorean-style)",
config: map[string]any{
"token_url": "https://idp.example.com/token",
"client_id": "my-client",
"client_secret": "my-secret",
},
wantErr: nil,
},
{
name: "basic_header credentials_location is valid",
config: map[string]any{
"token_url": "https://idp.example.com/token",
"client_id": "my-client",
"client_secret": "my-secret",
"credentials_location": "basic_header",
},
wantErr: nil,
},
{
name: "missing token_url",
config: map[string]any{
"client_id": "my-client",
"client_secret": "my-secret",
},
wantErr: ErrOAuth2TokenEndpointURLRequired,
},
{
name: "missing client_id",
config: map[string]any{
"token_url": "https://idp.example.com/token",
"client_secret": "my-secret",
},
wantErr: ErrOAuth2TokenEndpointClientRequired,
},
{
name: "missing client_secret",
config: map[string]any{
"token_url": "https://idp.example.com/token",
"client_id": "my-client",
},
wantErr: ErrOAuth2TokenEndpointSecretRequired,
},
{
name: "invalid credentials_location",
config: map[string]any{
"token_url": "https://idp.example.com/token",
"client_id": "my-client",
"client_secret": "my-secret",
"credentials_location": "query",
},
wantErr: ErrOAuth2TokenEndpointLocationInvalid,
},
{
name: "reserved key client_id in extra_params",
config: map[string]any{
"token_url": "https://idp.example.com/token",
"client_id": "my-client",
"client_secret": "my-secret",
"extra_params": map[string]any{"client_id": "override"},
},
wantErr: ErrOAuth2TokenEndpointReservedExtraParam,
},
{
name: "reserved key client_secret in extra_params",
config: map[string]any{
"token_url": "https://idp.example.com/token",
"client_id": "my-client",
"client_secret": "my-secret",
"extra_params": map[string]any{"client_secret": "override"},
},
wantErr: ErrOAuth2TokenEndpointReservedExtraParam,
},
{
name: "reserved key scope in extra_params",
config: map[string]any{
"token_url": "https://idp.example.com/token",
"client_id": "my-client",
"client_secret": "my-secret",
"extra_params": map[string]any{"scope": "override"},
},
wantErr: ErrOAuth2TokenEndpointReservedExtraParam,
},
{
name: "reserved key audience in extra_params",
config: map[string]any{
"token_url": "https://idp.example.com/token",
"client_id": "my-client",
"client_secret": "my-secret",
"extra_params": map[string]any{"audience": "override"},
},
wantErr: ErrOAuth2TokenEndpointReservedExtraParam,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := map[string]any{
"type": "oauth2_token_endpoint",
"config": tt.config,
}

provider, err := NewFromConfig(cfg, nil)

if tt.wantErr != nil {
require.ErrorIs(t, err, tt.wantErr)
return
}

require.NoError(t, err)
assert.Equal(t, TypeOAuth2TokenEndpoint, provider.Type())

// For the "full valid config" case, also verify that every field
// from the input map was actually parsed onto the underlying
// OAuth2TokenEndpointConfig. A regression in factory.go's field
// extraction would otherwise pass the type check while silently
// dropping scopes/audience/extra_params.
if tt.name == "full valid config" {
typed, ok := provider.(*OAuth2TokenEndpointProvider)
require.True(t, ok, "provider must be *OAuth2TokenEndpointProvider")
snap := typed.testConfigSnapshot()
require.NotNil(t, snap)
assert.Equal(t, "https://idp.example.com/token", snap.TokenURL)
assert.Equal(t, "my-client", snap.ClientID)
assert.Equal(t, "my-secret", snap.ClientSecret)
assert.Equal(t, "body", snap.CredentialsLocation)
assert.Equal(t, []string{"api:read"}, snap.Scopes)
assert.Equal(t, "my-audience", snap.Audience)
assert.Equal(t, "client_credentials", snap.ExtraParams["grant_type"])
}
})
}
}

func TestNewFromConfigUnknownType(t *testing.T) {
cfg := map[string]any{
"type": "unknown",
Expand Down
125 changes: 125 additions & 0 deletions pkg/executors/http/auth/oauth2_token_endpoint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Copyright (c) 2026 Lerian Studio. All rights reserved.
// Use of this source code is governed by the Elastic License 2.0
// that can be found in the LICENSE file.

package auth

import (
"context"
"fmt"
"net/http"
"strings"
)

// reservedOAuth2ExtraParams holds keys that cannot appear in extra_params.
// Prevents extra_params from silently overriding canonical fields (client_id,
// client_secret) or smuggling alternate scope/audience values that don't match
// the validated config.
var reservedOAuth2ExtraParams = map[string]bool{
"client_id": true,
"client_secret": true,
"scope": true,
"audience": true,
}

// OAuth2TokenEndpointProvider provides OAuth2 authentication against a custom
// token endpoint (no OIDC discovery).
type OAuth2TokenEndpointProvider struct {
config *OAuth2TokenEndpointConfig
cacheConfig *CacheCfg
tokenFetcher *TokenFetcher
cacheKey string
}

// NewOAuth2TokenEndpointProvider creates a new OAuth2 token endpoint provider.
// Returns an error if cfg is nil, required fields are missing, credentials_location
// is invalid, or extra_params contains reserved keys.
func NewOAuth2TokenEndpointProvider(cfg *OAuth2TokenEndpointConfig, cacheCfg *CacheCfg, httpClient *http.Client) (*OAuth2TokenEndpointProvider, error) {
if cfg == nil {
return nil, ErrOAuth2TokenEndpointConfigRequired
}

if cfg.TokenURL == "" {
return nil, ErrOAuth2TokenEndpointURLRequired
}

if cfg.ClientID == "" {
return nil, ErrOAuth2TokenEndpointClientRequired
}

if cfg.ClientSecret == "" {
return nil, ErrOAuth2TokenEndpointSecretRequired
}

if cfg.CredentialsLocation != "" &&
cfg.CredentialsLocation != "body" &&
cfg.CredentialsLocation != "basic_header" {
return nil, ErrOAuth2TokenEndpointLocationInvalid
}

for k := range cfg.ExtraParams {
if reservedOAuth2ExtraParams[k] {
return nil, ErrOAuth2TokenEndpointReservedExtraParam
}
}

// NOTE: credentials_location default ("body") is NOT mutated on the caller's
// *cfg here — that would surprise callers who reuse the struct or whose
// config originates from a shared/cached source. Instead, the empty value
// is treated as "body" wherever it is consumed (buildOAuth2TokenEndpointData
// and fetchNewOAuth2TokenEndpointToken).

if cacheCfg == nil {
cacheCfg = &CacheCfg{
Enabled: true,
RefreshBeforeExpirySeconds: 60,
}
}

// Distinct prefix "oauth2te" to avoid collision with OIDC ("cc", "user").
cacheKey := generateCacheKey("oauth2te", cfg.TokenURL, cfg.ClientID, cfg.Scopes)
Comment on lines +79 to +80
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Cache key is missing auth-shaping fields, causing token reuse collisions.

At Line [80], the key excludes Audience, CredentialsLocation, and ExtraParams. Two providers with same token_url + client_id + scopes but different request parameters can incorrectly share one cached token.

Suggested fix
-	cacheKey := generateCacheKey("oauth2te", cfg.TokenURL, cfg.ClientID, cfg.Scopes)
+	cacheKey := generateCacheKey(
+		"oauth2te",
+		cfg.TokenURL,
+		cfg.ClientID,
+		cfg.CredentialsLocation,
+		cfg.Scopes,
+		cfg.Audience,
+		cfg.ExtraParams,
+	)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/executors/http/auth/oauth2_token_endpoint.go` around lines 79 - 80, The
cache key built for the OAuth2 token endpoint is missing auth-shaping fields and
can cause token reuse collisions; update the generateCacheKey invocation that
assigns cacheKey (currently using "oauth2te", cfg.TokenURL, cfg.ClientID,
cfg.Scopes) to also include cfg.Audience, cfg.CredentialsLocation, and
cfg.ExtraParams (or their normalized/stringified form) so the key uniquely
represents request-shaping parameters; ensure any map/struct in ExtraParams is
deterministically serialized before passing to generateCacheKey.


return &OAuth2TokenEndpointProvider{
config: cfg,
cacheConfig: cacheCfg,
tokenFetcher: NewTokenFetcher(httpClient, nil),
cacheKey: cacheKey,
}, nil
Comment on lines +82 to +87
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Provider keeps a mutable caller config pointer; clone it to avoid races and drift.

At Lines [82]-[87], config: cfg stores external mutable state. If the caller mutates Scopes/ExtraParams after construction, Apply behavior and cache semantics can change unexpectedly, and concurrent map access can race.

Suggested fix
+func cloneOAuth2TokenEndpointConfig(in *OAuth2TokenEndpointConfig) *OAuth2TokenEndpointConfig {
+	if in == nil {
+		return nil
+	}
+	out := *in
+	if in.Scopes != nil {
+		out.Scopes = append([]string(nil), in.Scopes...)
+	}
+	if in.ExtraParams != nil {
+		out.ExtraParams = make(map[string]string, len(in.ExtraParams))
+		for k, v := range in.ExtraParams {
+			out.ExtraParams[k] = v
+		}
+	}
+	return &out
+}
+
 func NewOAuth2TokenEndpointProvider(cfg *OAuth2TokenEndpointConfig, cacheCfg *CacheCfg, httpClient *http.Client) (*OAuth2TokenEndpointProvider, error) {
@@
 	return &OAuth2TokenEndpointProvider{
-		config:       cfg,
+		config:       cloneOAuth2TokenEndpointConfig(cfg),
 		cacheConfig:  cacheCfg,
 		tokenFetcher: NewTokenFetcher(httpClient, nil),
 		cacheKey:     cacheKey,
 	}, nil
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return &OAuth2TokenEndpointProvider{
config: cfg,
cacheConfig: cacheCfg,
tokenFetcher: NewTokenFetcher(httpClient, nil),
cacheKey: cacheKey,
}, nil
func cloneOAuth2TokenEndpointConfig(in *OAuth2TokenEndpointConfig) *OAuth2TokenEndpointConfig {
if in == nil {
return nil
}
out := *in
if in.Scopes != nil {
out.Scopes = append([]string(nil), in.Scopes...)
}
if in.ExtraParams != nil {
out.ExtraParams = make(map[string]string, len(in.ExtraParams))
for k, v := range in.ExtraParams {
out.ExtraParams[k] = v
}
}
return &out
}
func NewOAuth2TokenEndpointProvider(cfg *OAuth2TokenEndpointConfig, cacheCfg *CacheCfg, httpClient *http.Client) (*OAuth2TokenEndpointProvider, error) {
// ... function body ...
return &OAuth2TokenEndpointProvider{
config: cloneOAuth2TokenEndpointConfig(cfg),
cacheConfig: cacheCfg,
tokenFetcher: NewTokenFetcher(httpClient, nil),
cacheKey: cacheKey,
}, nil
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/executors/http/auth/oauth2_token_endpoint.go` around lines 82 - 87, The
OAuth2TokenEndpointProvider constructor is storing the caller's mutable cfg
pointer (config: cfg), which can lead to races or drifting behavior if the
caller later mutates Scopes or ExtraParams; update the constructor that returns
*OAuth2TokenEndpointProvider to deep-clone cfg (copy primitive fields, duplicate
slices like Scopes and maps like ExtraParams) and store that copy in the
provider's config field so Apply and cacheKey semantics remain stable and
thread-safe; ensure any code referencing config inside
OAuth2TokenEndpointProvider and its Apply method uses the cloned copy and not
the original cfg.

}

// Apply implements Provider interface.
func (p *OAuth2TokenEndpointProvider) Apply(ctx context.Context, req *http.Request) error {
token, err := p.tokenFetcher.FetchOAuth2TokenEndpointToken(ctx, p.config, p.cacheKey, p.cacheConfig)
if err != nil {
return fmt.Errorf("fetch oauth2 token endpoint token: %w", err)
}

// token_type allowlist: Bearer only (case-insensitive). Rejects Basic/MAC/DPoP
// to prevent a malicious token endpoint from forcing a non-Bearer scheme that
// would expose the access token via an unintended Authorization header form.
if token.TokenType != "" && !strings.EqualFold(token.TokenType, "Bearer") {
return ErrOAuth2TokenEndpointUnsupportedTokenType
}

// Empty access_token guard: a token endpoint returning 200 OK with an
// empty access_token would otherwise produce the literal header
// "Authorization: Bearer " on the downstream request, which most servers
// either accept as an unauthenticated call or surface as a misleading 401.
// Fail fast at fetch time so the caller sees the actual contract
// violation.
if token.AccessToken == "" {
return ErrOAuth2TokenEndpointEmptyAccessToken
}

req.Header.Set("Authorization", "Bearer "+token.AccessToken)

return nil
}

// Type implements Provider interface.
func (p *OAuth2TokenEndpointProvider) Type() Type {
return TypeOAuth2TokenEndpoint
}

// Verify OAuth2TokenEndpointProvider implements Provider interface.
var _ Provider = (*OAuth2TokenEndpointProvider)(nil)
Loading
Loading