Compare commits

..

1 Commits

Author SHA1 Message Date
appleboy be5114b976 feat: add CSRF crumb support and session management for Jenkins API
- 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:42:00 +08:00
3 changed files with 21 additions and 58 deletions
+5 -9
View File
@@ -224,7 +224,7 @@ func (jenkins *Jenkins) sendRequest(
req *http.Request, req *http.Request,
crumb *CrumbResponse, crumb *CrumbResponse,
) (*http.Response, error) { ) (*http.Response, error) {
if jenkins.Auth != nil && jenkins.Auth.Username != "" && jenkins.Auth.Token != "" { if jenkins.Auth != nil {
req.SetBasicAuth(jenkins.Auth.Username, jenkins.Auth.Token) req.SetBasicAuth(jenkins.Auth.Username, jenkins.Auth.Token)
} }
@@ -277,14 +277,10 @@ 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) // Fetch CSRF crumb before POST request
var crumb *CrumbResponse crumb, err := jenkins.getCrumb(ctx)
if jenkins.Auth != nil && jenkins.Auth.Username != "" && jenkins.Auth.Token != "" { if err != nil {
var err error return 0, fmt.Errorf("failed to get crumb: %w", err)
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)
+9 -15
View File
@@ -88,15 +88,12 @@ 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 == "" {
// Validate authentication: either (user + token) or remote-token must be provided return errors.New("jenkins username is required")
hasUserAuth := p.Username != "" && p.Token != "" }
hasRemoteToken := p.RemoteToken != "" if p.Token == "" {
return errors.New("jenkins API token is required")
if !hasUserAuth && !hasRemoteToken {
return errors.New("authentication required")
} }
return nil return nil
} }
@@ -116,13 +113,10 @@ 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 (only if username and token are provided) // Set up authentication
var auth *Auth auth := &Auth{
if p.Username != "" && p.Token != "" { Username: p.Username,
auth = &Auth{ Token: p.Token,
Username: p.Username,
Token: p.Token,
}
} }
// Initialize Jenkins client // Initialize Jenkins client
+7 -34
View File
@@ -32,33 +32,24 @@ func TestValidateConfig(t *testing.T) {
errorMsg: "jenkins base URL is required", errorMsg: "jenkins base URL is required",
}, },
{ {
name: "missing authentication", name: "missing username and token",
plugin: Plugin{ plugin: Plugin{
BaseURL: "http://example.com", BaseURL: "http://example.com",
}, },
wantError: true, wantError: true,
errorMsg: "authentication required", errorMsg: "jenkins username is required",
}, },
{ {
name: "missing token (only username)", name: "missing token",
plugin: Plugin{ plugin: Plugin{
BaseURL: "http://example.com", BaseURL: "http://example.com",
Username: "foo", Username: "foo",
}, },
wantError: true, wantError: true,
errorMsg: "authentication required", errorMsg: "jenkins API token is required",
}, },
{ {
name: "missing username (only token)", name: "all required config present",
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",
@@ -66,24 +57,6 @@ 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 {
@@ -259,7 +232,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(), "authentication required") assert.Contains(t, err.Error(), "jenkins username is required")
} }
// TestExecMissingJenkinsToken tests Exec with missing token // TestExecMissingJenkinsToken tests Exec with missing token
@@ -273,7 +246,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(), "authentication required") assert.Contains(t, err.Error(), "jenkins API token is required")
} }
// TestExecMissingJenkinsJob tests Exec with missing or empty job list // TestExecMissingJenkinsJob tests Exec with missing or empty job list