This is an automated email from the ASF dual-hosted git repository.

klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/devlake.git


The following commit(s) were added to refs/heads/main by this push:
     new 9abdf41b1 feat(jira): add Cloud OAuth 2.0 client-credentials auth 
(#9159)
9abdf41b1 is described below

commit 9abdf41b1d63362547d622e9ecb5475254ed56e0
Author: Francisc Munteanu <[email protected]>
AuthorDate: Wed Sep 23 14:29:30 2026 +0200

    feat(jira): add Cloud OAuth 2.0 client-credentials auth (#9159)
    
    Service-account 2LO can mint 60-minute Bearer tokens and call the
    Atlassian API gateway, so Jira Cloud connections no longer require a
    user API token.
    
    Co-authored-by: Cursor <[email protected]>
---
 backend/plugins/jira/api/connection_api.go         |  87 ++++-
 backend/plugins/jira/api/connection_api_test.go    |  58 ++++
 backend/plugins/jira/models/connection.go          | 119 ++++++-
 backend/plugins/jira/models/connection_test.go     | 163 ++++++++++
 .../20260907_add_oauth2_credentials.go             |  48 +++
 .../jira/models/migrationscripts/register.go       |   1 +
 backend/plugins/jira/models/oauth.go               | 165 ++++++++++
 backend/plugins/jira/models/oauth_test.go          | 146 +++++++++
 backend/plugins/jira/tasks/api_client.go           |  37 ++-
 backend/plugins/jira/token/round_tripper.go        | 136 ++++++++
 backend/plugins/jira/token/token_provider.go       | 122 +++++++
 backend/plugins/jira/token/token_provider_test.go  | 360 +++++++++++++++++++++
 config-ui/src/api/connection/index.ts              |   6 +
 .../plugins/components/connection-form/index.tsx   |   6 +
 .../register/jira/connection-fields/auth.tsx       | 111 +++++--
 config-ui/src/types/connection.ts                  |   6 +
 config-ui/src/utils/request.ts                     |  13 +-
 17 files changed, 1550 insertions(+), 34 deletions(-)

diff --git a/backend/plugins/jira/api/connection_api.go 
b/backend/plugins/jira/api/connection_api.go
index 4562bf275..cea763172 100644
--- a/backend/plugins/jira/api/connection_api.go
+++ b/backend/plugins/jira/api/connection_api.go
@@ -19,7 +19,9 @@ package api
 
 import (
        "context"
+       "encoding/json"
        "fmt"
+       "io"
        "net/http"
        "net/url"
        "strings"
@@ -39,8 +41,13 @@ type JiraTestConnResponse struct {
 }
 
 func testConnection(ctx context.Context, connection models.JiraConn) 
(*JiraTestConnResponse, errors.Error) {
-       // validate
-       if vld != nil {
+       // OAuth2 is plugin-specific and is not in MultiAuth's oneof, so 
validate it
+       // separately. Other auth methods keep the existing StructExcept path.
+       if connection.IsOAuth2() {
+               if err := connection.ValidateConnection(&connection, vld); err 
!= nil {
+                       return nil, err
+               }
+       } else if vld != nil {
                e := vld.StructExcept(connection, "BasicAuth", "AccessToken")
                if e != nil {
                        return nil, errors.Convert(e)
@@ -74,10 +81,12 @@ func testConnection(ctx context.Context, connection 
models.JiraConn) (*JiraTestC
                return nil, errors.NotFound.New(fmt.Sprintf("Seems like an 
invalid Endpoint URL, please try %s", restUrl.String()))
        }
        if res.StatusCode == http.StatusUnauthorized || res.StatusCode == 
http.StatusForbidden {
-               return nil, errors.HttpStatus(res.StatusCode).New("Please check 
your credential")
+               // 400 not 401: Config UI treats HTTP 401 as a DevLake session 
expiry and
+               // navigates to /login, wiping the connection form.
+               return nil, errors.BadInput.New(withJiraHTTPDetail(res, "Please 
check your credential"))
        }
        if res.StatusCode != http.StatusOK {
-               return nil, 
errors.HttpStatus(res.StatusCode).New(fmt.Sprintf("%s unexpected status code: 
%d", serverInfoFail, res.StatusCode))
+               return nil, 
errors.HttpStatus(res.StatusCode).New(withJiraHTTPDetail(res, fmt.Sprintf("%s 
unexpected status code: %d", serverInfoFail, res.StatusCode)))
        }
 
        resBody := &models.JiraServerInfo{}
@@ -101,13 +110,16 @@ func testConnection(ctx context.Context, connection 
models.JiraConn) (*JiraTestC
        }
        getStatusFail += ": [ " + res.Request.URL.String() + " ]"
 
-       errMsg := ""
-       if res.StatusCode == http.StatusUnauthorized {
-               return nil, errors.HttpStatus(res.StatusCode).New("Please check 
your credential")
+       if res.StatusCode == http.StatusUnauthorized || res.StatusCode == 
http.StatusForbidden {
+               return nil, errors.BadInput.New(withJiraHTTPDetail(res, 
fmt.Sprintf(
+                       "Jira accepted the connection (serverInfo OK) but GET 
%s returned %d",
+                       res.Request.URL.String(),
+                       res.StatusCode,
+               )))
        }
 
        if res.StatusCode != http.StatusOK {
-               return nil, 
errors.HttpStatus(res.StatusCode).New(fmt.Sprintf("%s Unexpected [%s] status 
code: %d %s", getStatusFail, res.Request.URL, res.StatusCode, errMsg))
+               return nil, 
errors.HttpStatus(res.StatusCode).New(withJiraHTTPDetail(res, fmt.Sprintf("%s 
Unexpected [%s] status code: %d", getStatusFail, res.Request.URL, 
res.StatusCode)))
        }
        connection = connection.Sanitize()
        body := JiraTestConnResponse{}
@@ -222,3 +234,62 @@ func ListConnections(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput,
 func GetConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, 
errors.Error) {
        return dsHelper.ConnApi.GetDetail(input)
 }
+
+func withJiraHTTPDetail(res *http.Response, msg string) string {
+       if detail := jiraHTTPErrorDetail(res); detail != "" {
+               return msg + ": " + detail
+       }
+       return msg
+}
+
+// jiraHTTPErrorDetail reads Jira's error payload. Classic REST uses
+// errorMessages; the Atlassian OAuth gateway often uses message and/or
+// WWW-Authenticate (e.g. insufficient_scope).
+func jiraHTTPErrorDetail(res *http.Response) string {
+       if res == nil || res.Body == nil {
+               return ""
+       }
+       defer res.Body.Close()
+       raw, err := io.ReadAll(io.LimitReader(res.Body, 4096))
+       if err != nil {
+               return ""
+       }
+       if parsed := parseJiraErrorBody(strings.TrimSpace(string(raw))); parsed 
!= "" {
+               return parsed
+       }
+       if auth := strings.TrimSpace(res.Header.Get("WWW-Authenticate")); auth 
!= "" {
+               return auth
+       }
+       text := strings.TrimSpace(string(raw))
+       if text == "" || strings.HasPrefix(text, "<") {
+               return ""
+       }
+       const maxLen = 500
+       if len(text) > maxLen {
+               return text[:maxLen] + "..."
+       }
+       return text
+}
+
+func parseJiraErrorBody(text string) string {
+       if text == "" || text[0] != '{' {
+               return ""
+       }
+       var payload struct {
+               ErrorMessages []string `json:"errorMessages"`
+               Message       string   `json:"message"`
+       }
+       if json.Unmarshal([]byte(text), &payload) != nil {
+               return ""
+       }
+       parts := make([]string, 0, len(payload.ErrorMessages)+1)
+       for _, m := range payload.ErrorMessages {
+               if m = strings.TrimSpace(m); m != "" {
+                       parts = append(parts, m)
+               }
+       }
+       if m := strings.TrimSpace(payload.Message); m != "" {
+               parts = append(parts, m)
+       }
+       return strings.Join(parts, "; ")
+}
diff --git a/backend/plugins/jira/api/connection_api_test.go 
b/backend/plugins/jira/api/connection_api_test.go
index c06581b82..12e5bcdda 100644
--- a/backend/plugins/jira/api/connection_api_test.go
+++ b/backend/plugins/jira/api/connection_api_test.go
@@ -20,6 +20,7 @@ package api
 import (
        "context"
        "encoding/json"
+       "io"
        "net/http"
        "net/http/httptest"
        "strings"
@@ -142,3 +143,60 @@ func TestTestConnection_NonGateway404ShowsHint(t 
*testing.T) {
        assert.Contains(t, errMissingRest.Error(), "please try",
                "non-gateway 404 should include the /rest/ hint")
 }
+
+func TestTestConnection_OAuth2RequiresCredentials(t *testing.T) {
+       initTestDeps(t)
+
+       conn := models.JiraConn{}
+       conn.AuthMethod = models.AUTH_METHOD_OAUTH2
+       conn.CloudId = "cloud-1"
+
+       _, err := testConnection(context.Background(), conn)
+       require.NotNil(t, err)
+       assert.Contains(t, err.Error(), "clientId, clientSecret and cloudId are 
required")
+}
+
+func TestJiraHTTPErrorDetail(t *testing.T) {
+       tests := []struct {
+               name   string
+               body   string
+               header string
+               want   string
+       }{
+               {
+                       name: "classic jira errorMessages",
+                       body: `{"errorMessages":["Client must be authenticated 
to access this resource."],"errors":{}}`,
+                       want: "Client must be authenticated to access this 
resource.",
+               },
+               {
+                       name: "atlassian gateway message",
+                       body: `{"code":401,"message":"Unauthorized; scope does 
not match"}`,
+                       want: "Unauthorized; scope does not match",
+               },
+               {
+                       name:   "www-authenticate when body empty",
+                       header: `Bearer error="insufficient_scope"`,
+                       want:   `Bearer error="insufficient_scope"`,
+               },
+               {
+                       name: "html body ignored",
+                       body: `<html><body>Unauthorized</body></html>`,
+                       want: "",
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       res := &http.Response{
+                               StatusCode: http.StatusUnauthorized,
+                               Header:     make(http.Header),
+                               Body:       
io.NopCloser(strings.NewReader(tt.body)),
+                       }
+                       if tt.header != "" {
+                               res.Header.Set("WWW-Authenticate", tt.header)
+                       }
+                       if got := jiraHTTPErrorDetail(res); got != tt.want {
+                               t.Errorf("jiraHTTPErrorDetail() = %q, want %q", 
got, tt.want)
+                       }
+               })
+       }
+}
diff --git a/backend/plugins/jira/models/connection.go 
b/backend/plugins/jira/models/connection.go
index 0a54e31a5..da88c1f6a 100644
--- a/backend/plugins/jira/models/connection.go
+++ b/backend/plugins/jira/models/connection.go
@@ -18,13 +18,30 @@ limitations under the License.
 package models
 
 import (
-       "github.com/apache/devlake/core/utils"
+       "fmt"
        "net/http"
+       "regexp"
+       "strings"
+       "time"
+
+       "github.com/go-playground/validator/v10"
 
        "github.com/apache/devlake/core/errors"
+       "github.com/apache/devlake/core/plugin"
+       "github.com/apache/devlake/core/utils"
        helper "github.com/apache/devlake/helpers/pluginhelper/api"
 )
 
+const (
+       // AUTH_METHOD_OAUTH2 is Jira Cloud service-account OAuth 2.0 (client 
credentials / 2LO).
+       // It is plugin-specific and is not registered in core MultiAuth.
+       AUTH_METHOD_OAUTH2 = "OAuth2"
+)
+
+// cloudIDPattern allows Atlassian Cloud IDs (UUIDs) and test fixtures such as 
"cloud-1".
+// It rejects path separators and URL metacharacters so CloudId cannot change 
the gateway path.
+var cloudIDPattern = regexp.MustCompile(`^[a-zA-Z0-9-]{1,64}$`)
+
 type EpicResponse struct {
        Id    int
        Title string
@@ -43,20 +60,107 @@ type JiraConn struct {
        helper.MultiAuth      `mapstructure:",squash"`
        helper.BasicAuth      `mapstructure:",squash"`
        helper.AccessToken    `mapstructure:",squash"`
+
+       ClientId     string `mapstructure:"clientId" json:"clientId" 
gorm:"type:varchar(255)"`
+       ClientSecret string `mapstructure:"clientSecret" json:"clientSecret" 
gorm:"type:text;serializer:encdec"`
+       CloudId      string `mapstructure:"cloudId" json:"cloudId" 
gorm:"type:varchar(255)"`
+
+       // OAuthTokenURL overrides the Atlassian token endpoint (tests only).
+       OAuthTokenURL string `json:"-" mapstructure:"-" gorm:"-"`
+
+       oauthToken          string
+       oauthTokenExpiresAt *time.Time
 }
 
 func (jc *JiraConn) Sanitize() JiraConn {
        jc.Password = ""
        jc.AccessToken.Token = utils.SanitizeString(jc.AccessToken.Token)
+       jc.ClientSecret = utils.SanitizeString(jc.ClientSecret)
+       jc.oauthToken = ""
+       jc.oauthTokenExpiresAt = nil
        return *jc
 }
 
+func (jc *JiraConn) IsOAuth2() bool {
+       return jc.AuthMethod == AUTH_METHOD_OAUTH2
+}
+
+func sanitizedCloudID(cloudId string) string {
+       cloudId = strings.TrimSpace(cloudId)
+       if !cloudIDPattern.MatchString(cloudId) {
+               return ""
+       }
+       return cloudId
+}
+
+// GatewayEndpoint returns the Atlassian API gateway base URL for this cloud 
ID.
+func (jc *JiraConn) GatewayEndpoint() string {
+       cloudId := sanitizedCloudID(jc.CloudId)
+       if cloudId == "" {
+               return ""
+       }
+       return fmt.Sprintf("https://api.atlassian.com/ex/jira/%s/rest/";, 
cloudId)
+}
+
+// ApplyGatewayEndpoint sets Endpoint to the OAuth 2.0 gateway URL.
+func (jc *JiraConn) ApplyGatewayEndpoint() {
+       if !jc.IsOAuth2() {
+               return
+       }
+       if endpoint := jc.GatewayEndpoint(); endpoint != "" {
+               jc.Endpoint = endpoint
+       }
+}
+
+func (jc *JiraConn) OAuthAccessToken() string {
+       return jc.oauthToken
+}
+
+func (jc *JiraConn) OAuthAccessTokenExpiresAt() *time.Time {
+       return jc.oauthTokenExpiresAt
+}
+
+func (jc *JiraConn) SetOAuthAccessToken(token string, expiresAt time.Time) {
+       jc.oauthToken = token
+       expiry := expiresAt
+       jc.oauthTokenExpiresAt = &expiry
+}
+
 // SetupAuthentication implements the `IAuthentication` interface by delegating
 // the actual logic to the `MultiAuth` struct to help us write less code
 func (jc *JiraConn) SetupAuthentication(req *http.Request) errors.Error {
+       if jc.IsOAuth2() {
+               token := jc.OAuthAccessToken()
+               if token == "" {
+                       return errors.Unauthorized.New("oauth2 access token is 
missing")
+               }
+               req.Header.Set("Authorization", "Bearer "+token)
+               return nil
+       }
        return jc.MultiAuth.SetupAuthenticationForConnection(jc, req)
 }
 
+// ValidateConnection shadows MultiAuth so authMethod=OAuth2 is not rejected by
+// the shared oneof=BasicAuth AccessToken AppKey constraint.
+func (jc *JiraConn) ValidateConnection(connection interface{}, v 
*validator.Validate) errors.Error {
+       if jc.IsOAuth2() {
+               if strings.TrimSpace(jc.ClientId) == "" || 
strings.TrimSpace(jc.ClientSecret) == "" || strings.TrimSpace(jc.CloudId) == "" 
{
+                       return errors.BadInput.New("clientId, clientSecret and 
cloudId are required for OAuth2")
+               }
+               if sanitizedCloudID(jc.CloudId) == "" {
+                       return errors.BadInput.New("cloudId must contain only 
letters, digits, and hyphens")
+               }
+               jc.ApplyGatewayEndpoint()
+               if conn, ok := connection.(*JiraConnection); ok && 
strings.TrimSpace(conn.Name) == "" {
+                       return errors.BadInput.New("name is required")
+               }
+               return nil
+       }
+       return jc.MultiAuth.ValidateConnection(connection, v)
+}
+
+var _ plugin.PrepareApiClient = (*JiraConn)(nil)
+
 // JiraConnection holds JiraConn plus ID/Name for database storage
 type JiraConnection struct {
        helper.BaseConnection `mapstructure:",squash"`
@@ -67,9 +171,14 @@ func (JiraConnection) TableName() string {
        return "_tool_jira_connections"
 }
 
+func (connection *JiraConnection) CustomValidate(entity interface{}, v 
*validator.Validate) errors.Error {
+       return connection.JiraConn.ValidateConnection(entity, v)
+}
+
 func (connection *JiraConnection) MergeFromRequest(target *JiraConnection, 
body map[string]interface{}) error {
        token := target.Token
        password := target.Password
+       clientSecret := target.ClientSecret
        authMethod := target.AuthMethod
 
        if err := helper.DecodeMapStruct(body, target, true); err != nil {
@@ -78,6 +187,7 @@ func (connection *JiraConnection) MergeFromRequest(target 
*JiraConnection, body
 
        modifiedToken := target.Token
        modifiedPassword := target.Password
+       modifiedClientSecret := target.ClientSecret
        modifiedAuthMethod := target.AuthMethod
 
        // maybe auth method has changed
@@ -88,6 +198,13 @@ func (connection *JiraConnection) MergeFromRequest(target 
*JiraConnection, body
                if modifiedPassword == "" || modifiedPassword == 
utils.SanitizeString(password) {
                        target.Password = password
                }
+               if modifiedClientSecret == "" || modifiedClientSecret == 
utils.SanitizeString(clientSecret) {
+                       target.ClientSecret = clientSecret
+               }
+       }
+
+       if target.IsOAuth2() {
+               target.ApplyGatewayEndpoint()
        }
 
        return nil
diff --git a/backend/plugins/jira/models/connection_test.go 
b/backend/plugins/jira/models/connection_test.go
new file mode 100644
index 000000000..86240caf5
--- /dev/null
+++ b/backend/plugins/jira/models/connection_test.go
@@ -0,0 +1,163 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+       "net/http"
+       "net/http/httptest"
+       "testing"
+       "time"
+
+       "github.com/go-playground/validator/v10"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+
+       "github.com/apache/devlake/core/utils"
+       helper "github.com/apache/devlake/helpers/pluginhelper/api"
+)
+
+func TestGatewayEndpoint(t *testing.T) {
+       jc := &JiraConn{CloudId: "abc-123"}
+       assert.Equal(t, "https://api.atlassian.com/ex/jira/abc-123/rest/";, 
jc.GatewayEndpoint())
+
+       jc.CloudId = "  abc-123  "
+       assert.Equal(t, "https://api.atlassian.com/ex/jira/abc-123/rest/";, 
jc.GatewayEndpoint())
+
+       jc.CloudId = ""
+       assert.Equal(t, "", jc.GatewayEndpoint())
+
+       jc.CloudId = "../evil"
+       assert.Equal(t, "", jc.GatewayEndpoint())
+
+       jc.CloudId = "foo/bar"
+       assert.Equal(t, "", jc.GatewayEndpoint())
+
+       jc.CloudId = "foo?x=1"
+       assert.Equal(t, "", jc.GatewayEndpoint())
+}
+
+func TestApplyGatewayEndpoint(t *testing.T) {
+       jc := &JiraConn{
+               CloudId: "cloud-1",
+       }
+       jc.AuthMethod = AUTH_METHOD_OAUTH2
+       jc.ApplyGatewayEndpoint()
+       assert.Equal(t, "https://api.atlassian.com/ex/jira/cloud-1/rest/";, 
jc.Endpoint)
+
+       basic := &JiraConn{CloudId: "cloud-1"}
+       basic.AuthMethod = "BasicAuth"
+       basic.Endpoint = "https://example.atlassian.net/rest/";
+       basic.ApplyGatewayEndpoint()
+       assert.Equal(t, "https://example.atlassian.net/rest/";, basic.Endpoint)
+}
+
+func TestValidateConnectionOAuth2(t *testing.T) {
+       v := validator.New()
+       jc := &JiraConn{
+               ClientId:     "id",
+               ClientSecret: "secret",
+               CloudId:      "cloud-1",
+       }
+       jc.AuthMethod = AUTH_METHOD_OAUTH2
+
+       err := jc.ValidateConnection(jc, v)
+       require.NoError(t, err)
+       assert.Equal(t, "https://api.atlassian.com/ex/jira/cloud-1/rest/";, 
jc.Endpoint)
+
+       missing := &JiraConn{CloudId: "cloud-1"}
+       missing.AuthMethod = AUTH_METHOD_OAUTH2
+       err = missing.ValidateConnection(missing, v)
+       require.Error(t, err)
+       assert.Contains(t, err.Error(), "clientId, clientSecret and cloudId are 
required")
+
+       invalid := &JiraConn{
+               ClientId:     "id",
+               ClientSecret: "secret",
+               CloudId:      "../evil",
+       }
+       invalid.AuthMethod = AUTH_METHOD_OAUTH2
+       err = invalid.ValidateConnection(invalid, v)
+       require.Error(t, err)
+       assert.Contains(t, err.Error(), "cloudId must contain only letters, 
digits, and hyphens")
+}
+
+func TestValidateConnectionOAuth2DoesNotUseMultiAuthOneOf(t *testing.T) {
+       v := validator.New()
+       conn := &JiraConnection{}
+       conn.Name = "jira-oauth"
+       conn.AuthMethod = AUTH_METHOD_OAUTH2
+       conn.ClientId = "id"
+       conn.ClientSecret = "secret"
+       conn.CloudId = "cloud-1"
+
+       err := conn.CustomValidate(conn, v)
+       require.NoError(t, err)
+}
+
+func TestSanitizeAndMergeFromRequestClientSecret(t *testing.T) {
+       conn := &JiraConnection{}
+       conn.AuthMethod = AUTH_METHOD_OAUTH2
+       conn.ClientId = "id"
+       conn.ClientSecret = "super-secret"
+       conn.CloudId = "cloud-1"
+       conn.ApplyGatewayEndpoint()
+
+       sanitized := conn.Sanitize()
+       assert.Equal(t, utils.SanitizeString("super-secret"), 
sanitized.ClientSecret)
+       assert.Empty(t, sanitized.OAuthAccessToken())
+
+       err := conn.MergeFromRequest(conn, map[string]interface{}{
+               "authMethod":   AUTH_METHOD_OAUTH2,
+               "clientId":     "id",
+               "clientSecret": utils.SanitizeString("super-secret"),
+               "cloudId":      "cloud-1",
+       })
+       require.NoError(t, err)
+       assert.Equal(t, "super-secret", conn.ClientSecret)
+       assert.Equal(t, "https://api.atlassian.com/ex/jira/cloud-1/rest/";, 
conn.Endpoint)
+}
+
+func TestSetupAuthenticationOAuth2(t *testing.T) {
+       jc := &JiraConn{}
+       jc.AuthMethod = AUTH_METHOD_OAUTH2
+       jc.SetOAuthAccessToken("access-token", time.Now().Add(time.Hour))
+
+       req := httptest.NewRequest(http.MethodGet, 
"https://api.atlassian.com/ex/jira/c/rest/api/2/serverInfo";, nil)
+       err := jc.SetupAuthentication(req)
+       require.NoError(t, err)
+       assert.Equal(t, "Bearer access-token", req.Header.Get("Authorization"))
+}
+
+func TestSetupAuthenticationOAuth2MissingToken(t *testing.T) {
+       jc := &JiraConn{}
+       jc.AuthMethod = AUTH_METHOD_OAUTH2
+       req := httptest.NewRequest(http.MethodGet, "https://example.com";, nil)
+       err := jc.SetupAuthentication(req)
+       require.Error(t, err)
+}
+
+func TestValidateConnectionBasicAuthStillWorks(t *testing.T) {
+       v := validator.New()
+       jc := &JiraConn{
+               RestConnection: helper.RestConnection{Endpoint: 
"https://example.atlassian.net/rest/"},
+               BasicAuth:      helper.BasicAuth{Username: "[email protected]", 
Password: "token"},
+       }
+       jc.AuthMethod = "BasicAuth"
+       err := jc.ValidateConnection(jc, v)
+       require.NoError(t, err)
+}
diff --git 
a/backend/plugins/jira/models/migrationscripts/20260907_add_oauth2_credentials.go
 
b/backend/plugins/jira/models/migrationscripts/20260907_add_oauth2_credentials.go
new file mode 100644
index 000000000..6444d99f7
--- /dev/null
+++ 
b/backend/plugins/jira/models/migrationscripts/20260907_add_oauth2_credentials.go
@@ -0,0 +1,48 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package migrationscripts
+
+import (
+       "github.com/apache/devlake/core/context"
+       "github.com/apache/devlake/core/errors"
+       "github.com/apache/devlake/helpers/migrationhelper"
+)
+
+type jiraOAuth20260907 struct {
+       ClientId     string `gorm:"type:varchar(255)"`
+       ClientSecret string `gorm:"type:text"`
+       CloudId      string `gorm:"type:varchar(255)"`
+}
+
+func (jiraOAuth20260907) TableName() string {
+       return "_tool_jira_connections"
+}
+
+type addJiraOAuth20260907 struct{}
+
+func (script *addJiraOAuth20260907) Up(basicRes context.BasicRes) errors.Error 
{
+       return migrationhelper.AutoMigrateTables(basicRes, &jiraOAuth20260907{})
+}
+
+func (*addJiraOAuth20260907) Version() uint64 {
+       return 20260907120000
+}
+
+func (*addJiraOAuth20260907) Name() string {
+       return "add oauth2 client credentials to _tool_jira_connections"
+}
diff --git a/backend/plugins/jira/models/migrationscripts/register.go 
b/backend/plugins/jira/models/migrationscripts/register.go
index 4cde3dd94..7716da342 100644
--- a/backend/plugins/jira/models/migrationscripts/register.go
+++ b/backend/plugins/jira/models/migrationscripts/register.go
@@ -60,5 +60,6 @@ func All() []plugin.MigrationScript {
                new(addExtraJQLToScopeConfig),
                new(addSprintReportTable),
                new(addRawDataColumnsToSprintReport),
+               new(addJiraOAuth20260907),
        }
 }
diff --git a/backend/plugins/jira/models/oauth.go 
b/backend/plugins/jira/models/oauth.go
new file mode 100644
index 000000000..664230ab5
--- /dev/null
+++ b/backend/plugins/jira/models/oauth.go
@@ -0,0 +1,165 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+       "context"
+       "encoding/json"
+       "fmt"
+       "io"
+       "net/http"
+       "net/url"
+       "strings"
+       "time"
+
+       "github.com/apache/devlake/core/errors"
+       "github.com/apache/devlake/core/plugin"
+)
+
+const (
+       // atlassianOAuthEndpoint is the public Atlassian OAuth 2.0 
client-credentials endpoint.
+       atlassianOAuthEndpoint = "https://auth.atlassian.com/oauth/token";
+       defaultTokenTimeout    = 10 * time.Second
+       defaultTokenTTL        = 3600 * time.Second
+       // Atlassian access tokens are JWTs; with many scopes the JSON easily
+       // exceeds 4KiB. Keep a cap as a DoS guard, not a typical-token size.
+       maxOAuthResponseBytes = 64 * 1024
+)
+
+// NewOAuthHTTPClient returns an HTTP client for Atlassian token requests.
+// It must not share a transport with the Jira API client (which may wrap a 
refresh round tripper).
+func NewOAuthHTTPClient(proxy string) (*http.Client, errors.Error) {
+       transport := &http.Transport{}
+       if proxy != "" {
+               pu, err := url.Parse(proxy)
+               if err != nil {
+                       return nil, errors.Convert(err)
+               }
+               transport.Proxy = http.ProxyURL(pu)
+       }
+       return &http.Client{
+               Timeout:   defaultTokenTimeout,
+               Transport: transport,
+       }, nil
+}
+
+func (jc *JiraConn) tokenURL() string {
+       if jc.OAuthTokenURL != "" {
+               return jc.OAuthTokenURL
+       }
+       return atlassianOAuthEndpoint
+}
+
+// MintOAuthAccessToken exchanges client_id/client_secret for a Bearer access 
token.
+func (jc *JiraConn) MintOAuthAccessToken(httpClient *http.Client) (string, 
time.Time, errors.Error) {
+       if httpClient == nil {
+               return "", time.Time{}, errors.Default.New("oauth http client 
is required")
+       }
+
+       form := url.Values{}
+       form.Set("grant_type", "client_credentials")
+       form.Set("client_id", jc.ClientId)
+       form.Set("client_secret", jc.ClientSecret)
+
+       ctx, cancel := context.WithTimeout(context.Background(), 
defaultTokenTimeout)
+       defer cancel()
+
+       req, err := http.NewRequestWithContext(ctx, http.MethodPost, 
jc.tokenURL(), strings.NewReader(form.Encode()))
+       if err != nil {
+               return "", time.Time{}, errors.Convert(err)
+       }
+       req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+       req.Header.Set("Accept", "application/json")
+
+       resp, err := httpClient.Do(req)
+       if err != nil {
+               return "", time.Time{}, errors.Default.Wrap(err, "failed to 
request oauth2 access token")
+       }
+       defer resp.Body.Close()
+
+       body, err := io.ReadAll(io.LimitReader(resp.Body, 
maxOAuthResponseBytes+1))
+       if err != nil {
+               return "", time.Time{}, errors.Convert(err)
+       }
+       if len(body) > maxOAuthResponseBytes {
+               return "", time.Time{}, errors.Default.New("oauth2 token 
response exceeded size limit")
+       }
+       if resp.StatusCode != http.StatusOK {
+               return "", time.Time{}, errors.Default.New(fmt.Sprintf("failed 
to mint oauth2 access token: %d: %s", resp.StatusCode, oauthErrorDetail(body)))
+       }
+       if len(strings.TrimSpace(string(body))) == 0 {
+               return "", time.Time{}, errors.Default.New("oauth2 token 
endpoint returned an empty body")
+       }
+
+       var result struct {
+               AccessToken string `json:"access_token"`
+               ExpiresIn   int    `json:"expires_in"`
+               TokenType   string `json:"token_type"`
+       }
+       if err := json.Unmarshal(body, &result); err != nil {
+               return "", time.Time{}, errors.Default.Wrap(err, 
fmt.Sprintf("decoding oauth2 token response (%d bytes)", len(body)))
+       }
+       if result.AccessToken == "" {
+               return "", time.Time{}, errors.Default.New("empty oauth2 access 
token returned")
+       }
+
+       ttl := time.Duration(result.ExpiresIn) * time.Second
+       if ttl <= 0 {
+               ttl = defaultTokenTTL
+       }
+       return result.AccessToken, time.Now().Add(ttl), nil
+}
+
+// PrepareApiClient mints an OAuth 2.0 access token so subsequent requests can 
use Bearer auth.
+func (jc *JiraConn) PrepareApiClient(_ plugin.ApiClient) errors.Error {
+       if !jc.IsOAuth2() {
+               return nil
+       }
+       jc.ApplyGatewayEndpoint()
+       if jc.OAuthAccessToken() != "" {
+               return nil
+       }
+       httpClient, err := NewOAuthHTTPClient(jc.GetProxy())
+       if err != nil {
+               return err
+       }
+       token, expiresAt, err := jc.MintOAuthAccessToken(httpClient)
+       if err != nil {
+               return err
+       }
+       jc.SetOAuthAccessToken(token, expiresAt)
+       return nil
+}
+
+func oauthErrorDetail(body []byte) string {
+       var payload struct {
+               Error            string `json:"error"`
+               ErrorDescription string `json:"error_description"`
+       }
+       if json.Unmarshal(body, &payload) == nil {
+               switch {
+               case payload.Error != "" && payload.ErrorDescription != "":
+                       return payload.Error + ": " + payload.ErrorDescription
+               case payload.Error != "":
+                       return payload.Error
+               case payload.ErrorDescription != "":
+                       return payload.ErrorDescription
+               }
+       }
+       return "unexpected token endpoint response"
+}
diff --git a/backend/plugins/jira/models/oauth_test.go 
b/backend/plugins/jira/models/oauth_test.go
new file mode 100644
index 000000000..325c5d6f4
--- /dev/null
+++ b/backend/plugins/jira/models/oauth_test.go
@@ -0,0 +1,146 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+       "encoding/json"
+       "io"
+       "net/http"
+       "net/http/httptest"
+       "strings"
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+func TestMintOAuthAccessToken(t *testing.T) {
+       var gotGrant, gotID, gotSecret, gotContentType string
+       server := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, r *http.Request) {
+               gotContentType = r.Header.Get("Content-Type")
+               require.NoError(t, r.ParseForm())
+               gotGrant = r.Form.Get("grant_type")
+               gotID = r.Form.Get("client_id")
+               gotSecret = r.Form.Get("client_secret")
+               w.Header().Set("Content-Type", "application/json")
+               require.NoError(t, 
json.NewEncoder(w).Encode(map[string]interface{}{
+                       "access_token": "minted-token",
+                       "expires_in":   3600,
+                       "token_type":   "Bearer",
+                       "scope":        "read:jira-work",
+               }))
+       }))
+       defer server.Close()
+
+       jc := &JiraConn{
+               ClientId:      "cid",
+               ClientSecret:  "csecret",
+               CloudId:       "cloud-1",
+               OAuthTokenURL: server.URL,
+       }
+       jc.AuthMethod = AUTH_METHOD_OAUTH2
+
+       token, expiresAt, err := jc.MintOAuthAccessToken(server.Client())
+       require.NoError(t, err)
+       assert.Equal(t, "application/x-www-form-urlencoded", gotContentType)
+       assert.Equal(t, "client_credentials", gotGrant)
+       assert.Equal(t, "cid", gotID)
+       assert.Equal(t, "csecret", gotSecret)
+       assert.Equal(t, "minted-token", token)
+       assert.True(t, expiresAt.After(time.Now().Add(50*time.Minute)))
+}
+
+func TestMintOAuthAccessTokenRejectsEmptyToken(t *testing.T) {
+       server := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, _ *http.Request) {
+               _, _ = io.WriteString(w, 
`{"access_token":"","expires_in":3600}`)
+       }))
+       defer server.Close()
+
+       jc := &JiraConn{OAuthTokenURL: server.URL}
+       _, _, err := jc.MintOAuthAccessToken(server.Client())
+       require.Error(t, err)
+       assert.Contains(t, err.Error(), "empty oauth2 access token")
+}
+
+func TestMintOAuthAccessTokenHTTPError(t *testing.T) {
+       server := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, _ *http.Request) {
+               w.WriteHeader(http.StatusUnauthorized)
+               _, _ = io.WriteString(w, `{"error":"invalid_client"}`)
+       }))
+       defer server.Close()
+
+       jc := &JiraConn{OAuthTokenURL: server.URL}
+       _, _, err := jc.MintOAuthAccessToken(server.Client())
+       require.Error(t, err)
+       assert.Contains(t, err.Error(), "401")
+       assert.Contains(t, err.Error(), "invalid_client")
+       assert.NotContains(t, err.Error(), `"error"`)
+}
+
+func TestMintOAuthAccessTokenAcceptsLargeJWT(t *testing.T) {
+       token := strings.Repeat("a", 8000)
+       server := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, _ *http.Request) {
+               require.NoError(t, 
json.NewEncoder(w).Encode(map[string]interface{}{
+                       "access_token": token,
+                       "expires_in":   3600,
+                       "token_type":   "Bearer",
+                       "scope":        strings.Repeat("read:jira-work ", 50),
+               }))
+       }))
+       defer server.Close()
+
+       jc := &JiraConn{OAuthTokenURL: server.URL}
+       got, _, err := jc.MintOAuthAccessToken(server.Client())
+       require.NoError(t, err)
+       assert.Equal(t, token, got)
+}
+
+func TestMintOAuthAccessTokenEmptyBody(t *testing.T) {
+       server := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, _ *http.Request) {
+               w.WriteHeader(http.StatusOK)
+       }))
+       defer server.Close()
+
+       jc := &JiraConn{OAuthTokenURL: server.URL}
+       _, _, err := jc.MintOAuthAccessToken(server.Client())
+       require.Error(t, err)
+       assert.Contains(t, err.Error(), "empty body")
+}
+
+func TestPrepareApiClientStoresTokenOnConn(t *testing.T) {
+       server := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, _ *http.Request) {
+               require.NoError(t, 
json.NewEncoder(w).Encode(map[string]interface{}{
+                       "access_token": "test-conn-token",
+                       "expires_in":   3600,
+               }))
+       }))
+       defer server.Close()
+
+       jc := &JiraConn{
+               ClientId:      "cid",
+               ClientSecret:  "csecret",
+               CloudId:       "cloud-1",
+               OAuthTokenURL: server.URL,
+       }
+       jc.AuthMethod = AUTH_METHOD_OAUTH2
+
+       require.NoError(t, jc.PrepareApiClient(nil))
+       assert.Equal(t, "test-conn-token", jc.OAuthAccessToken())
+       require.NotNil(t, jc.OAuthAccessTokenExpiresAt())
+}
diff --git a/backend/plugins/jira/tasks/api_client.go 
b/backend/plugins/jira/tasks/api_client.go
index f90f1bd01..3ea475b3f 100644
--- a/backend/plugins/jira/tasks/api_client.go
+++ b/backend/plugins/jira/tasks/api_client.go
@@ -25,13 +25,42 @@ import (
        "github.com/apache/devlake/core/plugin"
        "github.com/apache/devlake/helpers/pluginhelper/api"
        "github.com/apache/devlake/plugins/jira/models"
+       "github.com/apache/devlake/plugins/jira/token"
 )
 
 func NewJiraApiClient(taskCtx plugin.TaskContext, connection 
*models.JiraConnection) (*api.ApiAsyncClient, errors.Error) {
-       // create synchronize api client so we can calculate api rate limit 
dynamically
-       apiClient, err := api.NewApiClientFromConnection(taskCtx.GetContext(), 
taskCtx, connection)
-       if err != nil {
-               return nil, err
+       var apiClient *api.ApiClient
+       var err errors.Error
+       if connection.IsOAuth2() {
+               connection.ApplyGatewayEndpoint()
+               apiClient, err = api.NewApiClient(
+                       taskCtx.GetContext(),
+                       connection.GetEndpoint(),
+                       nil,
+                       0,
+                       connection.GetProxy(),
+                       taskCtx,
+               )
+               if err != nil {
+                       return nil, err
+               }
+               logger := taskCtx.GetLogger()
+               var tp *token.TokenProvider
+               tp, err = token.NewTokenProvider(&connection.JiraConn, logger)
+               if err != nil {
+                       return nil, err
+               }
+               baseTransport := apiClient.GetClient().Transport
+               if baseTransport == nil {
+                       baseTransport = http.DefaultTransport
+               }
+               apiClient.GetClient().Transport = 
token.NewRefreshRoundTripper(baseTransport, tp)
+               logger.Info("Installed oauth2 token refresh round tripper for 
Jira connection %d", connection.ID)
+       } else {
+               apiClient, err = 
api.NewApiClientFromConnection(taskCtx.GetContext(), taskCtx, connection)
+               if err != nil {
+                       return nil, err
+               }
        }
 
        // create rate limit calculator
diff --git a/backend/plugins/jira/token/round_tripper.go 
b/backend/plugins/jira/token/round_tripper.go
new file mode 100644
index 000000000..212fd2b4e
--- /dev/null
+++ b/backend/plugins/jira/token/round_tripper.go
@@ -0,0 +1,136 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package token
+
+import (
+       "bytes"
+       "fmt"
+       "io"
+       "net/http"
+)
+
+// maxRetryBodyBytes caps how much of a request body is buffered so a 401
+// retry can re-send it. Collection is almost entirely GET; this is a DoS 
guard.
+const maxRetryBodyBytes = int64(1 << 20)
+
+// RefreshRoundTripper automatically remints OAuth 2.0 client-credentials 
tokens.
+// On 401 the round tripper will:
+// - Force a refresh of the access token via the TokenProvider
+// - Retry the original request with the new token
+//
+// When active, the RefreshRoundTripper overwrites the Authorization header
+// on every request, superseding any header previously set by 
SetupAuthentication.
+type RefreshRoundTripper struct {
+       base          http.RoundTripper
+       tokenProvider *TokenProvider
+}
+
+// NewRefreshRoundTripper wraps base with a round tripper that automatically 
remints the OAuth 2.0 token on 401 responses.
+func NewRefreshRoundTripper(base http.RoundTripper, tp *TokenProvider) 
*RefreshRoundTripper {
+       return &RefreshRoundTripper{
+               base:          base,
+               tokenProvider: tp,
+       }
+}
+
+// RoundTrip implements http.RoundTripper and remints the access token once on 
401.
+// It may consume and close req.Body (per the RoundTripper contract) so a 
retry can
+// re-send the payload; it does not otherwise mutate req.
+func (rt *RefreshRoundTripper) RoundTrip(req *http.Request) (*http.Response, 
error) {
+       replay, err := snapshotRequestBody(req)
+       if err != nil {
+               return nil, err
+       }
+       return rt.roundTripWithRetry(req, replay, false)
+}
+
+func (rt *RefreshRoundTripper) roundTripWithRetry(req *http.Request, replay 
bodyReplay, refreshAttempted bool) (*http.Response, error) {
+       token, err := rt.tokenProvider.GetToken()
+       if err != nil {
+               return nil, err
+       }
+
+       reqClone, cloneErr := cloneRequestWithBearer(req, token, replay)
+       if cloneErr != nil {
+               return nil, cloneErr
+       }
+
+       resp, reqErr := rt.base.RoundTrip(reqClone)
+       if reqErr != nil {
+               return nil, reqErr
+       }
+
+       if resp.StatusCode == http.StatusUnauthorized && !refreshAttempted {
+               resp.Body.Close()
+
+               if err := rt.tokenProvider.ForceRefresh(token); err != nil {
+                       return nil, err
+               }
+
+               return rt.roundTripWithRetry(req, replay, true)
+       }
+
+       return resp, nil
+}
+
+type bodyReplay struct {
+       getBody func() (io.ReadCloser, error)
+       length  int64
+}
+
+func cloneRequestWithBearer(req *http.Request, token string, replay 
bodyReplay) (*http.Request, error) {
+       reqClone := req.Clone(req.Context())
+       if replay.getBody != nil {
+               body, err := replay.getBody()
+               if err != nil {
+                       return nil, err
+               }
+               reqClone.Body = body
+               reqClone.GetBody = replay.getBody
+               if replay.length >= 0 {
+                       reqClone.ContentLength = replay.length
+               }
+       }
+       reqClone.Header.Set("Authorization", "Bearer "+token)
+       return reqClone, nil
+}
+
+// snapshotRequestBody returns a replayable body for clones. When GetBody is
+// already set, the original request is left untouched. Otherwise the original
+// Body is consumed and closed (allowed by http.RoundTripper) and a GetBody is
+// synthesized for clones only.
+func snapshotRequestBody(req *http.Request) (bodyReplay, error) {
+       if req.GetBody != nil {
+               return bodyReplay{getBody: req.GetBody, length: 
req.ContentLength}, nil
+       }
+       if req.Body == nil || req.Body == http.NoBody {
+               return bodyReplay{}, nil
+       }
+       buf, err := io.ReadAll(io.LimitReader(req.Body, maxRetryBodyBytes+1))
+       _ = req.Body.Close()
+       if err != nil {
+               return bodyReplay{}, err
+       }
+       if int64(len(buf)) > maxRetryBodyBytes {
+               return bodyReplay{}, fmt.Errorf("request body exceeded %d-byte 
retry snapshot limit", maxRetryBodyBytes)
+       }
+       getBody := func() (io.ReadCloser, error) {
+               return io.NopCloser(bytes.NewReader(buf)), nil
+       }
+       return bodyReplay{getBody: getBody, length: int64(len(buf))}, nil
+}
diff --git a/backend/plugins/jira/token/token_provider.go 
b/backend/plugins/jira/token/token_provider.go
new file mode 100644
index 000000000..ee2e32be2
--- /dev/null
+++ b/backend/plugins/jira/token/token_provider.go
@@ -0,0 +1,122 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package token
+
+import (
+       "net/http"
+       "sync"
+       "time"
+
+       "github.com/apache/devlake/core/errors"
+       "github.com/apache/devlake/core/log"
+       "github.com/apache/devlake/plugins/jira/models"
+)
+
+const (
+       DefaultRefreshBuffer = 5 * time.Minute
+)
+
+// TokenProvider mints Atlassian OAuth 2.0 client-credentials tokens and 
caches them in memory.
+type TokenProvider struct {
+       conn       *models.JiraConn
+       httpClient *http.Client
+       logger     log.Logger
+       mu         sync.Mutex
+       token      string
+       expiresAt  *time.Time
+}
+
+// NewTokenProvider creates a TokenProvider that uses a dedicated HTTP client 
for token requests.
+// Call this BEFORE wrapping the Jira API client's transport with 
RefreshRoundTripper so token
+// minting never goes through the round tripper.
+func NewTokenProvider(conn *models.JiraConn, logger log.Logger) 
(*TokenProvider, errors.Error) {
+       httpClient, err := models.NewOAuthHTTPClient(conn.GetProxy())
+       if err != nil {
+               return nil, err
+       }
+       return &TokenProvider{
+               conn:       conn,
+               httpClient: httpClient,
+               logger:     logger,
+       }, nil
+}
+
+// GetToken returns a valid OAuth 2.0 access token, minting a new one if the 
cached token is absent or near expiry.
+func (tp *TokenProvider) GetToken() (string, errors.Error) {
+       tp.mu.Lock()
+       defer tp.mu.Unlock()
+
+       if tp.needsRefresh() {
+               if tp.logger != nil {
+                       expiresStr := "unknown"
+                       if tp.expiresAt != nil {
+                               expiresStr = tp.expiresAt.Format(time.RFC3339)
+                       }
+                       tp.logger.Info("Proactive oauth2 token refresh 
triggered (token expires at %s)", expiresStr)
+               }
+               if err := tp.refreshToken(); err != nil {
+                       return "", err
+               }
+       }
+       return tp.token, nil
+}
+
+func (tp *TokenProvider) needsRefresh() bool {
+       if tp.token == "" {
+               return true
+       }
+       if tp.expiresAt == nil {
+               return false
+       }
+       return time.Now().Add(DefaultRefreshBuffer).After(*tp.expiresAt)
+}
+
+func (tp *TokenProvider) refreshToken() errors.Error {
+       if tp.logger != nil {
+               tp.logger.Info("Minting Jira oauth2 access token via 
client_credentials")
+       }
+       token, expiresAt, err := tp.conn.MintOAuthAccessToken(tp.httpClient)
+       if err != nil {
+               return err
+       }
+       tp.cacheToken(token, expiresAt)
+       return nil
+}
+
+func (tp *TokenProvider) cacheToken(token string, expiresAt time.Time) {
+       tp.token = token
+       expiry := expiresAt
+       tp.expiresAt = &expiry
+}
+
+// ForceRefresh remints the access token if the current token is still equal 
to oldToken.
+func (tp *TokenProvider) ForceRefresh(oldToken string) errors.Error {
+       tp.mu.Lock()
+       defer tp.mu.Unlock()
+
+       if tp.token != oldToken {
+               if tp.logger != nil {
+                       tp.logger.Info("Skipping reactive oauth2 token refresh 
— token already changed by another goroutine")
+               }
+               return nil
+       }
+       if tp.logger != nil {
+               tp.logger.Info("Reactive oauth2 token refresh triggered 
(received 401)")
+       }
+       return tp.refreshToken()
+}
diff --git a/backend/plugins/jira/token/token_provider_test.go 
b/backend/plugins/jira/token/token_provider_test.go
new file mode 100644
index 000000000..4f4ca3719
--- /dev/null
+++ b/backend/plugins/jira/token/token_provider_test.go
@@ -0,0 +1,360 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package token
+
+import (
+       "encoding/json"
+       "io"
+       "net/http"
+       "net/http/httptest"
+       "strings"
+       "sync"
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+
+       "github.com/apache/devlake/plugins/jira/models"
+)
+
+func oauthConn(tokenURL string) *models.JiraConn {
+       jc := &models.JiraConn{
+               ClientId:      "cid",
+               ClientSecret:  "csecret",
+               CloudId:       "cloud-1",
+               OAuthTokenURL: tokenURL,
+       }
+       jc.AuthMethod = models.AUTH_METHOD_OAUTH2
+       return jc
+}
+
+func seedToken(tp *TokenProvider, token string, ttl time.Duration) {
+       tp.cacheToken(token, time.Now().Add(ttl))
+}
+
+func TestNeedsRefresh(t *testing.T) {
+       tp := &TokenProvider{conn: oauthConn("")}
+
+       assert.True(t, tp.needsRefresh(), "empty token should refresh")
+
+       seedToken(tp, "tok", 10*time.Minute)
+       assert.False(t, tp.needsRefresh())
+
+       seedToken(tp, "tok", 1*time.Minute)
+       assert.True(t, tp.needsRefresh())
+
+       seedToken(tp, "tok", -1*time.Minute)
+       assert.True(t, tp.needsRefresh())
+}
+
+func TestGetTokenMintsWhenExpired(t *testing.T) {
+       server := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, r *http.Request) {
+               require.NoError(t, r.ParseForm())
+               assert.Equal(t, "client_credentials", r.Form.Get("grant_type"))
+               require.NoError(t, 
json.NewEncoder(w).Encode(map[string]interface{}{
+                       "access_token": "fresh-token",
+                       "expires_in":   3600,
+                       "token_type":   "Bearer",
+               }))
+       }))
+       defer server.Close()
+
+       conn := oauthConn(server.URL)
+       tp, err := NewTokenProvider(conn, nil)
+       require.NoError(t, err)
+       seedToken(tp, "stale", -time.Minute)
+
+       token, err := tp.GetToken()
+       require.NoError(t, err)
+       assert.Equal(t, "fresh-token", token)
+}
+
+func TestForceRefreshSkipsIfTokenChanged(t *testing.T) {
+       calls := 0
+       server := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, _ *http.Request) {
+               calls++
+               require.NoError(t, 
json.NewEncoder(w).Encode(map[string]interface{}{
+                       "access_token": "new-token",
+                       "expires_in":   3600,
+               }))
+       }))
+       defer server.Close()
+
+       conn := oauthConn(server.URL)
+       tp, err := NewTokenProvider(conn, nil)
+       require.NoError(t, err)
+       seedToken(tp, "current", time.Hour)
+
+       require.NoError(t, tp.ForceRefresh("stale-old-token"))
+       assert.Equal(t, 0, calls)
+       assert.Equal(t, "current", tp.token)
+
+       require.NoError(t, tp.ForceRefresh("current"))
+       assert.Equal(t, 1, calls)
+       assert.Equal(t, "new-token", tp.token)
+}
+
+func TestGetTokenConcurrency(t *testing.T) {
+       var mu sync.Mutex
+       calls := 0
+       server := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, _ *http.Request) {
+               mu.Lock()
+               calls++
+               mu.Unlock()
+               require.NoError(t, 
json.NewEncoder(w).Encode(map[string]interface{}{
+                       "access_token": "shared-token",
+                       "expires_in":   3600,
+               }))
+       }))
+       defer server.Close()
+
+       conn := oauthConn(server.URL)
+       tp, err := NewTokenProvider(conn, nil)
+       require.NoError(t, err)
+
+       var wg sync.WaitGroup
+       for i := 0; i < 10; i++ {
+               wg.Add(1)
+               go func() {
+                       defer wg.Done()
+                       token, gerr := tp.GetToken()
+                       assert.NoError(t, gerr)
+                       assert.Equal(t, "shared-token", token)
+               }()
+       }
+       wg.Wait()
+
+       mu.Lock()
+       defer mu.Unlock()
+       assert.Equal(t, 1, calls)
+}
+
+func TestRoundTripper401Refresh(t *testing.T) {
+       server := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, r *http.Request) {
+               require.NoError(t, r.ParseForm())
+               assert.Equal(t, "client_credentials", r.Form.Get("grant_type"))
+               require.NoError(t, 
json.NewEncoder(w).Encode(map[string]interface{}{
+                       "access_token": "new-token",
+                       "expires_in":   3600,
+               }))
+       }))
+       defer server.Close()
+
+       conn := oauthConn(server.URL)
+       tp, err := NewTokenProvider(conn, nil)
+       require.NoError(t, err)
+       seedToken(tp, "old-token", 10*time.Minute)
+
+       apiCalls := 0
+       base := roundTripperFunc(func(req *http.Request) (*http.Response, 
error) {
+               apiCalls++
+               auth := req.Header.Get("Authorization")
+               if auth == "Bearer old-token" {
+                       return &http.Response{
+                               StatusCode: http.StatusUnauthorized,
+                               Body:       io.NopCloser(http.NoBody),
+                               Request:    req,
+                       }, nil
+               }
+               if auth == "Bearer new-token" {
+                       return &http.Response{
+                               StatusCode: http.StatusOK,
+                               Body:       io.NopCloser(http.NoBody),
+                               Request:    req,
+                       }, nil
+               }
+               return &http.Response{StatusCode: 
http.StatusInternalServerError, Body: io.NopCloser(http.NoBody), Request: req}, 
nil
+       })
+
+       rt := NewRefreshRoundTripper(base, tp)
+       req, reqErr := http.NewRequest(http.MethodGet, 
"https://api.atlassian.com/ex/jira/c/rest/api/2/serverInfo";, nil)
+       require.NoError(t, reqErr)
+
+       resp, tripErr := rt.RoundTrip(req)
+       require.NoError(t, tripErr)
+       assert.Equal(t, http.StatusOK, resp.StatusCode)
+       assert.Equal(t, 2, apiCalls)
+       assert.Equal(t, "new-token", tp.token)
+}
+
+// TestRoundTripper401RetryRestoresBody: fake Jira 401s Bearer old-token, the
+// round tripper remints via the token server, and retries with new-token.
+// Both attempts must still send the original POST body.
+func TestRoundTripper401RetryRestoresBody(t *testing.T) {
+       // Token endpoint: ForceRefresh POSTs here and gets new-token.
+       server := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, r *http.Request) {
+               require.NoError(t, 
json.NewEncoder(w).Encode(map[string]interface{}{
+                       "access_token": "new-token",
+                       "expires_in":   3600,
+               }))
+       }))
+       defer server.Close()
+
+       conn := oauthConn(server.URL)
+       tp, err := NewTokenProvider(conn, nil)
+       require.NoError(t, err)
+       seedToken(tp, "old-token", 10*time.Minute)
+
+       var bodies []string
+       base := roundTripperFunc(func(req *http.Request) (*http.Response, 
error) {
+               body, readErr := io.ReadAll(req.Body)
+               require.NoError(t, readErr)
+               bodies = append(bodies, string(body))
+               if req.Header.Get("Authorization") == "Bearer old-token" {
+                       return &http.Response{
+                               StatusCode: http.StatusUnauthorized,
+                               Body:       io.NopCloser(http.NoBody),
+                               Request:    req,
+                       }, nil
+               }
+               return &http.Response{
+                       StatusCode: http.StatusOK,
+                       Body:       io.NopCloser(http.NoBody),
+                       Request:    req,
+               }, nil
+       })
+
+       rt := NewRefreshRoundTripper(base, tp)
+       req, reqErr := http.NewRequest(http.MethodPost, 
"https://example.com/rest";, strings.NewReader(`{"ok":true}`))
+       require.NoError(t, reqErr)
+       req.Header.Set("Content-Type", "application/json")
+
+       resp, tripErr := rt.RoundTrip(req)
+       require.NoError(t, tripErr)
+       assert.Equal(t, http.StatusOK, resp.StatusCode)
+       // bodies[0] = first POST (old-token, 401); bodies[1] = retry 
(new-token, 200)
+       require.Len(t, bodies, 2)
+       assert.Equal(t, `{"ok":true}`, bodies[0])
+       assert.Equal(t, `{"ok":true}`, bodies[1])
+}
+
+// TestRoundTripper401RetryRestoresBodyWithoutGetBody covers collectors that
+// attach an io.Reader without http.NewRequest setting GetBody.
+func TestRoundTripper401RetryRestoresBodyWithoutGetBody(t *testing.T) {
+       server := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, _ *http.Request) {
+               require.NoError(t, 
json.NewEncoder(w).Encode(map[string]interface{}{
+                       "access_token": "new-token",
+                       "expires_in":   3600,
+               }))
+       }))
+       defer server.Close()
+
+       conn := oauthConn(server.URL)
+       tp, err := NewTokenProvider(conn, nil)
+       require.NoError(t, err)
+       seedToken(tp, "old-token", 10*time.Minute)
+
+       var bodies []string
+       base := roundTripperFunc(func(req *http.Request) (*http.Response, 
error) {
+               body, readErr := io.ReadAll(req.Body)
+               require.NoError(t, readErr)
+               bodies = append(bodies, string(body))
+               if req.Header.Get("Authorization") == "Bearer old-token" {
+                       return &http.Response{
+                               StatusCode: http.StatusUnauthorized,
+                               Body:       io.NopCloser(http.NoBody),
+                               Request:    req,
+                       }, nil
+               }
+               return &http.Response{
+                       StatusCode: http.StatusOK,
+                       Body:       io.NopCloser(http.NoBody),
+                       Request:    req,
+               }, nil
+       })
+
+       rt := NewRefreshRoundTripper(base, tp)
+       req, reqErr := http.NewRequest(http.MethodPost, 
"https://example.com/rest";, nil)
+       require.NoError(t, reqErr)
+       payload := `{"ok":true}`
+       req.Body = io.NopCloser(strings.NewReader(payload))
+       req.GetBody = nil
+       req.ContentLength = int64(len(payload))
+       req.Header.Set("Content-Type", "application/json")
+
+       resp, tripErr := rt.RoundTrip(req)
+       require.NoError(t, tripErr)
+       assert.Equal(t, http.StatusOK, resp.StatusCode)
+       require.Len(t, bodies, 2)
+       assert.Equal(t, payload, bodies[0])
+       assert.Equal(t, payload, bodies[1])
+       assert.Nil(t, req.GetBody, "RoundTrip must not mutate the original 
request")
+}
+
+// TestRoundTripperPersistent401: fake Jira 401s even after remint. The round
+// tripper must retry once, then return that 401 (no infinite loop).
+func TestRoundTripperPersistent401(t *testing.T) {
+       server := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, _ *http.Request) {
+               require.NoError(t, 
json.NewEncoder(w).Encode(map[string]interface{}{
+                       "access_token": "new-token",
+                       "expires_in":   3600,
+               }))
+       }))
+       defer server.Close()
+
+       conn := oauthConn(server.URL)
+       tp, err := NewTokenProvider(conn, nil)
+       require.NoError(t, err)
+       seedToken(tp, "old-token", 10*time.Minute)
+
+       apiCalls := 0
+       base := roundTripperFunc(func(req *http.Request) (*http.Response, 
error) {
+               apiCalls++
+               return &http.Response{
+                       StatusCode: http.StatusUnauthorized,
+                       Body:       io.NopCloser(http.NoBody),
+                       Request:    req,
+               }, nil
+       })
+
+       rt := NewRefreshRoundTripper(base, tp)
+       req, reqErr := http.NewRequest(http.MethodGet, 
"https://example.com/rest";, nil)
+       require.NoError(t, reqErr)
+
+       resp, tripErr := rt.RoundTrip(req)
+       require.NoError(t, tripErr)
+       assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
+       assert.Equal(t, 2, apiCalls)
+}
+
+func TestRoundTripperRejectsOversizedBody(t *testing.T) {
+       tp := &TokenProvider{conn: oauthConn("")}
+       seedToken(tp, "tok", time.Hour)
+       rt := NewRefreshRoundTripper(roundTripperFunc(func(*http.Request) 
(*http.Response, error) {
+               t.Fatal("must not send an oversized body")
+               return nil, nil
+       }), tp)
+
+       payload := strings.Repeat("a", int(maxRetryBodyBytes)+1)
+       req, err := http.NewRequest(http.MethodPost, 
"https://example.com/rest";, nil)
+       require.NoError(t, err)
+       req.Body = io.NopCloser(strings.NewReader(payload))
+       req.GetBody = nil
+       req.ContentLength = int64(len(payload))
+
+       _, tripErr := rt.RoundTrip(req)
+       require.Error(t, tripErr)
+       assert.Contains(t, tripErr.Error(), "retry snapshot limit")
+}
+
+type roundTripperFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) 
{
+       return f(req)
+}
diff --git a/config-ui/src/api/connection/index.ts 
b/config-ui/src/api/connection/index.ts
index 2728b103e..990495ce3 100644
--- a/config-ui/src/api/connection/index.ts
+++ b/config-ui/src/api/connection/index.ts
@@ -49,6 +49,9 @@ export const test = (
       | 'token'
       | 'appId'
       | 'secretKey'
+      | 'cloudId'
+      | 'clientId'
+      | 'clientSecret'
       | 'proxy'
       | 'rateLimitPerHour'
       | 'dbUrl'
@@ -73,6 +76,9 @@ export const testOld = (
     | 'token'
     | 'appId'
     | 'secretKey'
+    | 'cloudId'
+    | 'clientId'
+    | 'clientSecret'
     | 'proxy'
     | 'rateLimitPerHour'
     | 'dbUrl'
diff --git a/config-ui/src/plugins/components/connection-form/index.tsx 
b/config-ui/src/plugins/components/connection-form/index.tsx
index ce7e161e6..947897821 100644
--- a/config-ui/src/plugins/components/connection-form/index.tsx
+++ b/config-ui/src/plugins/components/connection-form/index.tsx
@@ -52,6 +52,9 @@ const buildUpdateTestPayload = (connection: any, values: any, 
customHeaders: ICu
   token: isEqual(connection?.token, values.token) ? undefined : values.token,
   appId: isEqual(connection?.appId, values.appId) ? undefined : values.appId,
   secretKey: isEqual(connection?.secretKey, values.secretKey) ? undefined : 
values.secretKey,
+  cloudId: isEqual(connection?.cloudId, values.cloudId) ? undefined : 
values.cloudId,
+  clientId: isEqual(connection?.clientId, values.clientId) ? undefined : 
values.clientId,
+  clientSecret: isEqual(connection?.clientSecret, values.clientSecret) ? 
undefined : values.clientSecret,
   proxy: isEqual(connection?.proxy, values.proxy) ? undefined : values.proxy,
   rateLimitPerHour: isEqual(connection?.rateLimitPerHour, 
values.rateLimitPerHour)
     ? undefined
@@ -74,6 +77,9 @@ const buildCreateTestPayload = (initialValues: any, values: 
any, customHeaders:
     'authMethod',
     'appId',
     'secretKey',
+    'cloudId',
+    'clientId',
+    'clientSecret',
     'accessKeyId',
     'secretAccessKey',
     'region',
diff --git a/config-ui/src/plugins/register/jira/connection-fields/auth.tsx 
b/config-ui/src/plugins/register/jira/connection-fields/auth.tsx
index d7f0d6251..fb048cee5 100644
--- a/config-ui/src/plugins/register/jira/connection-fields/auth.tsx
+++ b/config-ui/src/plugins/register/jira/connection-fields/auth.tsx
@@ -27,7 +27,8 @@ const JIRA_CLOUD_REGEX   = 
/^https:\/\/\w+\.atlassian\.net\/rest\/$/;
 const JIRA_GATEWAY_REGEX = 
/^https:\/\/api\.atlassian\.com\/ex\/jira\/[^/]+\/rest\/$/;
 
 type JiraVersion = 'cloud' | 'gateway' | 'server';
-type Method = 'BasicAuth' | 'AccessToken';
+type Method = 'BasicAuth' | 'AccessToken' | 'OAuth2';
+type GatewayMethod = 'AccessToken' | 'OAuth2';
 
 interface Props {
   type: 'create' | 'update';
@@ -43,16 +44,19 @@ export const Auth = ({ type, initialValues, values, 
setValues, setErrors }: Prop
   const [cloudId, setCloudId] = useState('');
 
   useEffect(() => {
-    if (!initialValues.endpoint) return;
-    if (JIRA_GATEWAY_REGEX.test(initialValues.endpoint)) {
+    if (initialValues.authMethod === 'OAuth2' || (initialValues.endpoint && 
JIRA_GATEWAY_REGEX.test(initialValues.endpoint))) {
       setVersion('gateway');
-      // Extract the Cloud ID from the saved endpoint so the field pre-fills 
on edit
-      const match = initialValues.endpoint.match(/\/ex\/jira\/([^/]+)\//);
-      if (match) setCloudId(match[1]);
-    } else if (!JIRA_CLOUD_REGEX.test(initialValues.endpoint)) {
+      if (initialValues.cloudId) {
+        setCloudId(initialValues.cloudId);
+      } else if (initialValues.endpoint) {
+        // Extract the Cloud ID from the saved endpoint so the field pre-fills 
on edit
+        const match = initialValues.endpoint.match(/\/ex\/jira\/([^/]+)\//);
+        if (match) setCloudId(match[1]);
+      }
+    } else if (initialValues.endpoint && 
!JIRA_CLOUD_REGEX.test(initialValues.endpoint)) {
       setVersion('server');
     }
-  }, [initialValues.endpoint]);
+  }, [initialValues.endpoint, initialValues.authMethod, 
initialValues.cloudId]);
 
   useEffect(() => {
     setValues({
@@ -61,6 +65,9 @@ export const Auth = ({ type, initialValues, values, 
setValues, setErrors }: Prop
       username: initialValues.username,
       password: initialValues.password,
       token: initialValues.token,
+      cloudId: initialValues.cloudId,
+      clientId: initialValues.clientId,
+      clientSecret: initialValues.clientSecret,
     });
   }, [
     initialValues.endpoint,
@@ -68,12 +75,16 @@ export const Auth = ({ type, initialValues, values, 
setValues, setErrors }: Prop
     initialValues.username,
     initialValues.password,
     initialValues.token,
+    initialValues.cloudId,
+    initialValues.clientId,
+    initialValues.clientSecret,
   ]);
 
   useEffect(() => {
     const required =
       (values.authMethod === 'BasicAuth' && values.username && 
values.password) ||
       (values.authMethod === 'AccessToken' && values.token) ||
+      (values.authMethod === 'OAuth2' && values.cloudId && values.clientId && 
values.clientSecret) ||
       type === 'update';
     setErrors({
       endpoint: !values.endpoint ? 'endpoint is required' : '',
@@ -86,11 +97,14 @@ export const Auth = ({ type, initialValues, values, 
setValues, setErrors }: Prop
     setCloudId('');
     setValues({
       endpoint: '',
-      // Gateway always uses AccessToken (scoped API token); others default to 
BasicAuth
+      // Gateway defaults to AccessToken (scoped API token); others default to 
BasicAuth
       authMethod: v === 'gateway' ? 'AccessToken' : 'BasicAuth',
       username: undefined,
       password: undefined,
       token: undefined,
+      cloudId: undefined,
+      clientId: undefined,
+      clientSecret: undefined,
     });
     setVersion(v);
   };
@@ -107,10 +121,21 @@ export const Auth = ({ type, initialValues, values, 
setValues, setErrors }: Prop
     // Auto-construct the gateway endpoint from the Cloud ID so the user never
     // has to type the full URL manually.
     setValues({
+      cloudId: id,
       endpoint: id ? `https://api.atlassian.com/ex/jira/${id}/rest/` : '',
     });
   };
 
+  const handleChangeGatewayMethod = (e: RadioChangeEvent) => {
+    const authMethod = (e.target as HTMLInputElement).value as GatewayMethod;
+    setValues({
+      authMethod,
+      token: undefined,
+      clientId: undefined,
+      clientSecret: undefined,
+    });
+  };
+
   const handleChangeMethod = (e: RadioChangeEvent) => {
     setValues({
       authMethod: (e.target as HTMLInputElement).value as Method,
@@ -138,6 +163,20 @@ export const Auth = ({ type, initialValues, values, 
setValues, setErrors }: Prop
     });
   };
 
+  const handleChangeClientId = (e: React.ChangeEvent<HTMLInputElement>) => {
+    setValues({
+      clientId: e.target.value,
+    });
+  };
+
+  const handleChangeClientSecret = (e: React.ChangeEvent<HTMLInputElement>) => 
{
+    setValues({
+      clientSecret: e.target.value,
+    });
+  };
+
+  const gatewayAuthMethod: GatewayMethod = values.authMethod === 'OAuth2' ? 
'OAuth2' : 'AccessToken';
+
   return (
     <>
       <Block title="Jira Version" required>
@@ -187,18 +226,50 @@ export const Auth = ({ type, initialValues, values, 
setValues, setErrors }: Prop
               onChange={handleChangeCloudId}
             />
           </Block>
-          <Block
-            title="Scoped API Token"
-            description="API token for your Atlassian Managed Service Account 
with Jira read scopes."
-            required
-          >
-            <Input.Password
-              style={{ width: 386 }}
-              placeholder={type === 'update' ? '********' : 'Your Scoped API 
Token'}
-              value={values.token}
-              onChange={handleChangeToken}
-            />
+          <Block title="Authentication Method" required>
+            <Radio.Group value={gatewayAuthMethod} 
onChange={handleChangeGatewayMethod}>
+              <Radio value="AccessToken">Scoped API Token</Radio>
+              <Radio value="OAuth2">OAuth 2.0 (Service Account)</Radio>
+            </Radio.Group>
           </Block>
+          {gatewayAuthMethod === 'AccessToken' && (
+            <Block
+              title="Scoped API Token"
+              description="API token for your Atlassian Managed Service 
Account with Jira read scopes."
+              required
+            >
+              <Input.Password
+                style={{ width: 386 }}
+                placeholder={type === 'update' ? '********' : 'Your Scoped API 
Token'}
+                value={values.token}
+                onChange={handleChangeToken}
+              />
+            </Block>
+          )}
+          {gatewayAuthMethod === 'OAuth2' && (
+            <>
+              <Block
+                title="Client ID"
+                description="OAuth 2.0 client ID from an Atlassian app 
(client-credentials / 2LO)."
+                required
+              >
+                <Input
+                  style={{ width: 386 }}
+                  placeholder="OAuth 2.0 Client ID"
+                  value={values.clientId}
+                  onChange={handleChangeClientId}
+                />
+              </Block>
+              <Block title="Client Secret" required>
+                <Input.Password
+                  style={{ width: 386 }}
+                  placeholder={type === 'update' ? '********' : 'OAuth 2.0 
Client Secret'}
+                  value={values.clientSecret}
+                  onChange={handleChangeClientSecret}
+                />
+              </Block>
+            </>
+          )}
         </>
       )}
 
diff --git a/config-ui/src/types/connection.ts 
b/config-ui/src/types/connection.ts
index 17a3b85af..3a678007a 100644
--- a/config-ui/src/types/connection.ts
+++ b/config-ui/src/types/connection.ts
@@ -32,6 +32,9 @@ export interface IConnectionAPI {
   password?: string;
   appId?: string;
   secretKey?: string;
+  cloudId?: string;
+  clientId?: string;
+  clientSecret?: string;
   dbUrl?: string;
   companyId?: number;
   proxy: string;
@@ -89,6 +92,9 @@ export interface IConnection {
   password?: string;
   appId?: string;
   secretKey?: string;
+  cloudId?: string;
+  clientId?: string;
+  clientSecret?: string;
   dbUrl?: string;
   companyId?: number;
   proxy: string;
diff --git a/config-ui/src/utils/request.ts b/config-ui/src/utils/request.ts
index e91739f72..3badcc0d4 100644
--- a/config-ui/src/utils/request.ts
+++ b/config-ui/src/utils/request.ts
@@ -41,18 +41,29 @@ export type RequestConfig = {
 
 const isLoginRoute = () => window.location.pathname.replace(/\/+$/, 
'').endsWith('/login');
 
+// Plugin connection tests often return 401 when the *remote* credential is
+// wrong. That is not a DevLake session expiry — do not wipe the form.
+const isPluginConnectionTest = (url?: string) =>
+  !!url && /\/plugins\/[^/]+(?:\/connections\/[^/]+)?\/test\/?$/.test(url);
+
 let redirectingToLogin = false;
 
 instance.interceptors.response.use(
   (response) => response,
   (error) => {
     const status = error.response?.status;
+    const requestUrl = error.config?.url as string | undefined;
 
     if (status === 428) {
       window.location.replace('/db-migrate');
     }
 
-    if (status === 401 && !isLoginRoute() && !redirectingToLogin) {
+    if (
+      status === 401 &&
+      !isLoginRoute() &&
+      !redirectingToLogin &&
+      !isPluginConnectionTest(requestUrl)
+    ) {
       redirectingToLogin = true;
       const returnUrl = encodeURIComponent(window.location.pathname + 
window.location.search);
       window.location.replace(`/login?return_url=${returnUrl}`);

Reply via email to