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}`);