Compare commits

...

3 Commits

Author SHA1 Message Date
appleboy 1fbf3e5cd6 style: streamline authentication error handling in config validation
- Simplify authentication error message in config validation

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2025-12-27 11:23:25 +08:00
appleboy 95a5eb125f feat: refactor authentication logic and broaden test coverage
- Improve authentication checks to only require username and token when both are provided
- Update validation logic to allow either (username and token) or remote-token for authentication
- Enhance test coverage for various authentication scenarios
- Refine error messages to indicate a generic authentication requirement instead of specifying missing username or token

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2025-12-27 11:13:06 +08:00
Bo-Yi Wu 984ca01afc feat: add CSRF crumb support and session management for Jenkins API (#49)
- Add support for Jenkins CSRF protection by managing and adding CSRF crumb to POST requests
- Store and cache CSRF crumb after fetching from Jenkins for session reuse
- Use an HTTP client with CookieJar for session management to support CSRF crumb handling
- Update existing tests to check for presence of CookieJar in the HTTP client
- Improve test servers to only count job trigger POSTs, ignoring GET requests for crumbs

fix #48

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2025-12-27 10:48:26 +08:00
4 changed files with 137 additions and 32 deletions
+77 -13
View File
@@ -9,6 +9,7 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"net/http/cookiejar"
"net/url" "net/url"
"os" "os"
"strings" "strings"
@@ -31,7 +32,14 @@ type (
BaseURL string BaseURL string
Token string // Remote trigger token Token string // Remote trigger token
Client *http.Client Client *http.Client
Debug bool // Enable debug mode to show detailed information Debug bool // Enable debug mode to show detailed information
crumb *CrumbResponse // Cached CSRF crumb
}
// CrumbResponse represents Jenkins crumb issuer response for CSRF protection
CrumbResponse struct {
Crumb string `json:"crumb"`
CrumbRequestField string `json:"crumbRequestField"`
} }
// QueueItem represents a Jenkins queue item response // QueueItem represents a Jenkins queue item response
@@ -144,14 +152,21 @@ func NewJenkins(
} }
} }
// Create HTTP client // Create CookieJar for session management (required for CSRF crumb)
client := http.DefaultClient jar, err := cookiejar.New(nil)
if tlsConfig != nil { if err != nil {
client = &http.Client{ return nil, fmt.Errorf("failed to create cookie jar: %w", err)
Transport: &http.Transport{ }
TLSClientConfig: tlsConfig,
}, // Create HTTP Transport with optional TLS configuration
} transport := &http.Transport{
TLSClientConfig: tlsConfig,
}
// Create HTTP client with CookieJar and Transport
client := &http.Client{
Jar: jar,
Transport: transport,
} }
return &Jenkins{ return &Jenkins{
@@ -175,10 +190,49 @@ func (jenkins *Jenkins) buildURL(path string, params url.Values) (requestURL str
return return
} }
func (jenkins *Jenkins) sendRequest(req *http.Request) (*http.Response, error) { // getCrumb fetches CSRF crumb from Jenkins
if jenkins.Auth != nil { // Returns nil if CSRF protection is disabled on Jenkins
//
//nolint:unparam // Error return kept for future extensibility and API consistency
func (jenkins *Jenkins) getCrumb(ctx context.Context) (*CrumbResponse, error) {
// Return cached crumb if available
if jenkins.crumb != nil {
return jenkins.crumb, nil
}
path := "/crumbIssuer/api/json"
var crumb CrumbResponse
err := jenkins.get(ctx, path, nil, &crumb)
if err != nil {
// CSRF protection might be disabled, log and continue
if jenkins.Debug {
log.Printf("crumb not available (CSRF may be disabled): %v", err)
}
return nil, nil
}
// Cache the crumb for subsequent requests
jenkins.crumb = &crumb
if jenkins.Debug {
log.Printf("obtained crumb: %s=%s", crumb.CrumbRequestField, crumb.Crumb)
}
return jenkins.crumb, nil
}
func (jenkins *Jenkins) sendRequest(
req *http.Request,
crumb *CrumbResponse,
) (*http.Response, error) {
if jenkins.Auth != nil && jenkins.Auth.Username != "" && jenkins.Auth.Token != "" {
req.SetBasicAuth(jenkins.Auth.Username, jenkins.Auth.Token) req.SetBasicAuth(jenkins.Auth.Username, jenkins.Auth.Token)
} }
// Add CSRF crumb header if available
if crumb != nil && crumb.CrumbRequestField != "" {
req.Header.Set(crumb.CrumbRequestField, crumb.Crumb)
}
return jenkins.Client.Do(req) return jenkins.Client.Do(req)
} }
@@ -195,7 +249,7 @@ func (jenkins *Jenkins) get(
return err return err
} }
resp, err := jenkins.sendRequest(req) resp, err := jenkins.sendRequest(req, nil) // GET requests don't need crumb
if err != nil { if err != nil {
return err return err
} }
@@ -223,6 +277,16 @@ func (jenkins *Jenkins) postAndGetLocation(
path string, path string,
params url.Values, params url.Values,
) (int, error) { ) (int, error) {
// Fetch CSRF crumb before POST request (only if authenticated)
var crumb *CrumbResponse
if jenkins.Auth != nil && jenkins.Auth.Username != "" && jenkins.Auth.Token != "" {
var err error
crumb, err = jenkins.getCrumb(ctx)
if err != nil {
return 0, fmt.Errorf("failed to get crumb: %w", err)
}
}
requestURL := jenkins.buildURL(path, params) requestURL := jenkins.buildURL(path, params)
req, err := http.NewRequestWithContext(ctx, "POST", requestURL, nil) req, err := http.NewRequestWithContext(ctx, "POST", requestURL, nil)
@@ -230,7 +294,7 @@ func (jenkins *Jenkins) postAndGetLocation(
return 0, err return 0, err
} }
resp, err := jenkins.sendRequest(req) resp, err := jenkins.sendRequest(req, crumb)
if err != nil { if err != nil {
return 0, err return 0, err
} }
+3 -1
View File
@@ -751,6 +751,8 @@ func TestNewJenkinsWithCACert(t *testing.T) {
) )
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, jenkins) assert.NotNil(t, jenkins)
assert.Equal(t, http.DefaultClient, jenkins.Client) assert.NotNil(t, jenkins.Client)
// Client should have CookieJar for CSRF session management
assert.NotNil(t, jenkins.Client.Jar)
}) })
} }
+15 -9
View File
@@ -88,12 +88,15 @@ func (p Plugin) validateConfig() error {
if p.BaseURL == "" { if p.BaseURL == "" {
return errors.New("jenkins base URL is required") return errors.New("jenkins base URL is required")
} }
if p.Username == "" {
return errors.New("jenkins username is required") // Validate authentication: either (user + token) or remote-token must be provided
} hasUserAuth := p.Username != "" && p.Token != ""
if p.Token == "" { hasRemoteToken := p.RemoteToken != ""
return errors.New("jenkins API token is required")
if !hasUserAuth && !hasRemoteToken {
return errors.New("authentication required")
} }
return nil return nil
} }
@@ -113,10 +116,13 @@ func (p Plugin) Exec(ctx context.Context) error {
return errors.New("at least one Jenkins job name is required") return errors.New("at least one Jenkins job name is required")
} }
// Set up authentication // Set up authentication (only if username and token are provided)
auth := &Auth{ var auth *Auth
Username: p.Username, if p.Username != "" && p.Token != "" {
Token: p.Token, auth = &Auth{
Username: p.Username,
Token: p.Token,
}
} }
// Initialize Jenkins client // Initialize Jenkins client
+42 -9
View File
@@ -32,24 +32,33 @@ func TestValidateConfig(t *testing.T) {
errorMsg: "jenkins base URL is required", errorMsg: "jenkins base URL is required",
}, },
{ {
name: "missing username and token", name: "missing authentication",
plugin: Plugin{ plugin: Plugin{
BaseURL: "http://example.com", BaseURL: "http://example.com",
}, },
wantError: true, wantError: true,
errorMsg: "jenkins username is required", errorMsg: "authentication required",
}, },
{ {
name: "missing token", name: "missing token (only username)",
plugin: Plugin{ plugin: Plugin{
BaseURL: "http://example.com", BaseURL: "http://example.com",
Username: "foo", Username: "foo",
}, },
wantError: true, wantError: true,
errorMsg: "jenkins API token is required", errorMsg: "authentication required",
}, },
{ {
name: "all required config present", name: "missing username (only token)",
plugin: Plugin{
BaseURL: "http://example.com",
Token: "bar",
},
wantError: true,
errorMsg: "authentication required",
},
{
name: "user and token auth",
plugin: Plugin{ plugin: Plugin{
BaseURL: "http://example.com", BaseURL: "http://example.com",
Username: "foo", Username: "foo",
@@ -57,6 +66,24 @@ func TestValidateConfig(t *testing.T) {
}, },
wantError: false, wantError: false,
}, },
{
name: "remote token auth",
plugin: Plugin{
BaseURL: "http://example.com",
RemoteToken: "remote-token-123",
},
wantError: false,
},
{
name: "both auth methods",
plugin: Plugin{
BaseURL: "http://example.com",
Username: "foo",
Token: "bar",
RemoteToken: "remote-token-123",
},
wantError: false,
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -232,7 +259,7 @@ func TestExecMissingJenkinsUsername(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "configuration error") assert.Contains(t, err.Error(), "configuration error")
assert.Contains(t, err.Error(), "jenkins username is required") assert.Contains(t, err.Error(), "authentication required")
} }
// TestExecMissingJenkinsToken tests Exec with missing token // TestExecMissingJenkinsToken tests Exec with missing token
@@ -246,7 +273,7 @@ func TestExecMissingJenkinsToken(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "configuration error") assert.Contains(t, err.Error(), "configuration error")
assert.Contains(t, err.Error(), "jenkins API token is required") assert.Contains(t, err.Error(), "authentication required")
} }
// TestExecMissingJenkinsJob tests Exec with missing or empty job list // TestExecMissingJenkinsJob tests Exec with missing or empty job list
@@ -314,7 +341,10 @@ func TestExecTriggerMultipleJobs(t *testing.T) {
// Create a mock Jenkins server // Create a mock Jenkins server
jobsTriggered := 0 jobsTriggered := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
jobsTriggered++ // Only count POST requests (job triggers), not GET requests (crumb)
if r.Method == "POST" {
jobsTriggered++
}
w.Header(). w.Header().
Set("Location", fmt.Sprintf("http://jenkins.example.com/queue/item/%d/", jobsTriggered)) Set("Location", fmt.Sprintf("http://jenkins.example.com/queue/item/%d/", jobsTriggered))
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
@@ -390,7 +420,10 @@ func TestExecWithJobsContainingWhitespace(t *testing.T) {
// Create a mock Jenkins server // Create a mock Jenkins server
jobsTriggered := 0 jobsTriggered := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
jobsTriggered++ // Only count POST requests (job triggers), not GET requests (crumb)
if r.Method == "POST" {
jobsTriggered++
}
w.Header(). w.Header().
Set("Location", fmt.Sprintf("http://jenkins.example.com/queue/item/%d/", jobsTriggered)) Set("Location", fmt.Sprintf("http://jenkins.example.com/queue/item/%d/", jobsTriggered))
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)