feat(jira): add OAuth 2.1 with PKCE via Atlassian MCP server (#2805)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Ahmed Bally
2026-08-22 14:49:09 +00:00
committed by GitHub
parent e74a01ff5e
commit a2685dccae
33 changed files with 3143 additions and 470 deletions
+71 -3
View File
@@ -12,6 +12,7 @@ import (
"net/url"
"strconv"
"strings"
"sync"
"time"
)
@@ -43,10 +44,33 @@ type CloudClient struct {
siteURL string
email string
secret string
secretMu sync.RWMutex // guards secret for concurrent read/write
authMethod string
instanceType string
apiBase string // "/rest/api/3" for cloud, "/rest/api/2" for server.
maxBodySize int64
// refresher enables on-demand token refresh for OAuth. When non-nil and a
// request gets 401, the client refreshes the access token and retries once.
refresher TokenRefresher
// workspaceID identifies the workspace for token refresh.
workspaceID string
}
// TokenRefresher is implemented by the Service to allow CloudClient to refresh
// OAuth access tokens on-demand when a request hits a 401.
type TokenRefresher interface {
// staleToken is the access token that just hit a 401; the refresher skips
// rotation if the stored token already changed (another goroutine won the
// race). Pass "" to force a refresh.
RefreshOAuthToken(ctx context.Context, workspaceID, staleToken string) error
RevealAccessToken(ctx context.Context, workspaceID string) (string, error)
}
// SetRefresher wires the OAuth token refresher. Called by the service after
// constructing the client so the client can trigger a refresh on 401.
func (c *CloudClient) SetRefresher(workspaceID string, r TokenRefresher) {
c.workspaceID = workspaceID
c.refresher = r
}
// NewCloudClient builds a client from a JiraConfig + secret. siteURL is
@@ -97,14 +121,19 @@ const userAgent = "kandev/1.0 (+https://github.com/kdlbs/kandev)"
func (c *CloudClient) authorize(req *http.Request) {
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", userAgent)
c.secretMu.RLock()
secret := c.secret
c.secretMu.RUnlock()
switch c.authMethod {
case AuthMethodAPIToken:
basic := base64.StdEncoding.EncodeToString([]byte(c.email + ":" + c.secret))
basic := base64.StdEncoding.EncodeToString([]byte(c.email + ":" + secret))
req.Header.Set("Authorization", "Basic "+basic)
case AuthMethodPAT:
req.Header.Set("Authorization", "Bearer "+c.secret)
req.Header.Set("Authorization", "Bearer "+secret)
case AuthMethodSessionCookie:
req.Header.Set("Cookie", buildSessionCookieHeader(c.secret))
req.Header.Set("Cookie", buildSessionCookieHeader(secret))
case AuthMethodOAuth:
req.Header.Set("Authorization", "Bearer "+secret)
}
}
@@ -118,7 +147,46 @@ func buildSessionCookieHeader(secret string) string {
// do executes a request and decodes a 2xx JSON body into out (may be nil).
// Non-2xx responses are returned as *APIError so callers can switch on status.
// For OAuth auth, a 401 triggers an automatic token refresh and single retry.
func (c *CloudClient) do(ctx context.Context, method, path string, body interface{}, out interface{}) error {
err := c.doOnce(ctx, method, path, body, out)
if err == nil {
return nil
}
if c.tryRefreshOn401(ctx, err) {
return c.doOnce(ctx, method, path, body, out)
}
return err
}
// tryRefreshOn401 checks if the error is a 401 and refreshes the OAuth token.
// Returns true if the token was refreshed and the caller should retry.
func (c *CloudClient) tryRefreshOn401(ctx context.Context, err error) bool {
if c.authMethod != AuthMethodOAuth || c.refresher == nil {
return false
}
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusUnauthorized {
return false
}
c.secretMu.RLock()
stale := c.secret
c.secretMu.RUnlock()
if refreshErr := c.refresher.RefreshOAuthToken(ctx, c.workspaceID, stale); refreshErr != nil {
return false
}
newToken, revealErr := c.refresher.RevealAccessToken(ctx, c.workspaceID)
if revealErr != nil {
return false
}
c.secretMu.Lock()
c.secret = newToken
c.secretMu.Unlock()
return true
}
// doOnce executes a single request attempt without retry logic.
func (c *CloudClient) doOnce(ctx context.Context, method, path string, body interface{}, out interface{}) error {
var reqBody io.Reader
if body != nil {
b, err := json.Marshal(body)
+7
View File
@@ -14,6 +14,10 @@ var ErrSameWorkspace = errors.New("jira: source and target workspaces are the sa
// ErrNothingToCopy is returned when the source workspace has no Jira config.
var ErrNothingToCopy = errors.New("jira: source workspace has no configuration to copy")
// ErrOAuthConfigCopyUnsupported prevents two workspaces from sharing one
// rotating refresh-token lineage. The target must complete its own OAuth flow.
var ErrOAuthConfigCopyUnsupported = errors.New("jira: OAuth connections cannot be copied; reconnect the target workspace")
// CopyConfigToWorkspace copies the Jira provider config and credential (token)
// from sourceWorkspaceID to targetWorkspaceID. Watchers are intentionally out
// of scope — only the connection settings and secret are duplicated.
@@ -41,6 +45,9 @@ func (s *Service) CopyConfigToWorkspace(ctx context.Context, sourceWorkspaceID,
if cfg == nil {
return nil, ErrNothingToCopy
}
if cfg.AuthMethod == AuthMethodOAuth {
return nil, ErrOAuthConfigCopyUnsupported
}
secret, err := s.revealSecret(ctx, sourceWorkspaceID)
if err != nil {
return nil, fmt.Errorf("read source jira secret: %w", err)
+22
View File
@@ -3,6 +3,7 @@ package jira
import (
"context"
"errors"
"strings"
"testing"
"time"
)
@@ -67,3 +68,24 @@ func TestCopyConfigToWorkspace_NothingToCopy(t *testing.T) {
t.Fatalf("expected ErrNothingToCopy, got %v", err)
}
}
func TestCopyConfigToWorkspace_RejectsOAuthConnection(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
const src = "ws-oauth"
if err := f.store.UpsertConfigForWorkspace(ctx, src, &JiraConfig{
WorkspaceID: src,
SiteURL: "https://acme.atlassian.net", AuthMethod: AuthMethodOAuth,
InstanceType: InstanceTypeCloud, ClientID: "client-1", CloudID: "cloud-1",
}); err != nil {
t.Fatalf("seed OAuth config: %v", err)
}
if err := f.secrets.Set(ctx, OAuthAccessTokenKeyForWorkspace(src), "access", "token-a"); err != nil {
t.Fatalf("seed OAuth token: %v", err)
}
_, err := f.svc.CopyConfigToWorkspace(ctx, src, "ws-target")
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "oauth") {
t.Fatalf("expected an explicit OAuth copy rejection, got %v", err)
}
}
+106 -1
View File
@@ -56,6 +56,11 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
api.POST("/watches/issue/:id/trigger", c.httpTriggerIssueWatch)
api.GET("/watches/issue/:id/reset/preview", c.httpPreviewResetIssueWatch)
api.POST("/watches/issue/:id/reset", c.httpResetIssueWatch)
// OAuth 2.0 (3LO) flow — start, callback, manual refresh.
api.GET("/oauth/start", c.httpOAuthStart)
api.GET("/oauth/callback", c.httpOAuthCallback)
api.POST("/oauth/refresh", c.httpOAuthRefresh)
}
// --- HTTP handlers ---
@@ -235,7 +240,8 @@ func (c *Controller) httpCopyConfig(ctx *gin.Context) {
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, ErrSameWorkspace), errors.Is(err, ErrNothingToCopy), errors.Is(err, ErrInvalidConfig):
case errors.Is(err, ErrSameWorkspace), errors.Is(err, ErrNothingToCopy),
errors.Is(err, ErrOAuthConfigCopyUnsupported), errors.Is(err, ErrInvalidConfig):
status = http.StatusBadRequest
case workspaceDenied(err):
status = http.StatusNotFound
@@ -586,3 +592,102 @@ func wsListProjects(svc *Service) func(ctx context.Context, msg *ws.Message) (*w
return wsReply(msg, gin.H{"projects": projects})
}
}
// --- OAuth handlers ---
// httpOAuthStart initiates the OAuth 2.0 (3LO) flow. Returns the Atlassian
// authorization URL for the frontend to open in a popup/redirect.
func (c *Controller) httpOAuthStart(ctx *gin.Context) {
workspaceID := c.workspaceID(ctx)
if workspaceID == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id required"})
return
}
if err := c.service.authorizeWorkspaceAccess(ctx.Request.Context(), workspaceID); err != nil {
if workspaceDenied(err) {
ctx.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
} else {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
siteURL := ctx.Query("siteUrl")
if siteURL == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "siteUrl required"})
return
}
redirectURI := c.service.oauthRedirectURI
if redirectURI == "" {
redirectURI = "http://localhost:38429/api/v1/jira/oauth/callback"
}
authURL, err := c.service.StartOAuthFlow(ctx.Request.Context(), workspaceID, siteURL, redirectURI)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"authUrl": authURL})
}
// httpOAuthCallback handles the redirect from Atlassian after user consent.
// When called via browser redirect (popup), returns an HTML page that uses
// postMessage to send the code back to the opener window, then auto-closes.
// When called via fetch (paste-URL flow), returns JSON.
func (c *Controller) httpOAuthCallback(ctx *gin.Context) {
code := ctx.Query("code")
state := ctx.Query("state")
if code == "" || state == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "code and state required"})
return
}
// If this is a fetch/API request (paste-URL flow), return JSON.
if ctx.Query("api") == "1" {
_, err := c.service.CompleteOAuthFlow(ctx.Request.Context(), code, state)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
return
}
// Browser redirect (popup flow): return an HTML page that postMessages
// the code+state back to the opener window, then closes itself.
// The opener (Kandev settings page) listens for the message and completes
// the OAuth flow via the API endpoint.
ctx.Header("Content-Type", "text/html; charset=utf-8")
ctx.String(http.StatusOK, `<!doctype html>
<html><body>
<p>Authorization complete. You can close this window.</p>
<script>
(function() {
var params = new URLSearchParams(window.location.search);
var data = { type: "jira-oauth-callback", code: params.get("code"), state: params.get("state") };
if (window.opener) {
window.opener.postMessage(data, window.location.origin);
setTimeout(function() { window.close(); }, 100);
}
})();
</script>
</body></html>`)
}
// httpOAuthRefresh manually triggers a token refresh for testing/debugging.
func (c *Controller) httpOAuthRefresh(ctx *gin.Context) {
workspaceID := c.workspaceID(ctx)
if workspaceID == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id required"})
return
}
if err := c.service.authorizeWorkspaceAccess(ctx.Request.Context(), workspaceID); err != nil {
if workspaceDenied(err) {
ctx.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
} else {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
if err := c.service.RefreshOAuthToken(ctx.Request.Context(), workspaceID, ""); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
+546
View File
@@ -0,0 +1,546 @@
package jira
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
mcpclient "github.com/mark3labs/mcp-go/client"
mcptransport "github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
)
// MCPClient implements the Client interface by calling Jira APIs through
// the Atlassian MCP server (mcp.atlassian.com/v1/mcp/authv2) using JSON-RPC
// over the MCP Streamable HTTP transport. The OAuth token from the MCP
// OAuth 2.1 flow authenticates the session — it does NOT work for direct
// Jira REST API calls, so we route through the MCP server.
type MCPClient struct {
http *http.Client
accessToken string
tokenMu sync.RWMutex // guards accessToken
workspaceID string
siteURL string
sessionMu sync.Mutex // guards SDK client initialization and replacement
session *mcpclient.Client
cloudID string
refresher TokenRefresher
}
const (
mcpEndpoint = "https://mcp.atlassian.com/v1/mcp/authv2"
mcpProtocol = "2025-06-18"
)
// NewMCPClient builds an MCP-backed Jira client.
func NewMCPClient(accessToken, cloudID, workspaceID, siteURL string) *MCPClient {
site := strings.TrimRight(siteURL, "/")
return &MCPClient{
http: &http.Client{
Timeout: 30 * time.Second,
Transport: &mcpStatusRoundTripper{base: http.DefaultTransport},
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
accessToken: accessToken,
workspaceID: workspaceID,
siteURL: site,
cloudID: cloudID,
}
}
func (c *MCPClient) getToken() string {
c.tokenMu.RLock()
defer c.tokenMu.RUnlock()
return c.accessToken
}
func (c *MCPClient) setToken(t string) {
c.tokenMu.Lock()
defer c.tokenMu.Unlock()
c.accessToken = t
}
// SetRefresher wires the OAuth token refresher for 401 auto-refresh.
func (c *MCPClient) SetRefresher(workspaceID string, r TokenRefresher) {
c.workspaceID = workspaceID
c.refresher = r
}
type mcpAccessTokenContextKey struct{}
type mcpStatusRoundTripper struct {
base http.RoundTripper
}
func (t *mcpStatusRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
base := t.base
if base == nil {
base = http.DefaultTransport
}
resp, err := base.RoundTrip(req)
if err != nil || resp == nil || resp.StatusCode != http.StatusUnauthorized {
return resp, err
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return nil, &APIError{StatusCode: http.StatusUnauthorized, Message: string(body)}
}
func withMCPAccessToken(ctx context.Context, token string) context.Context {
return context.WithValue(ctx, mcpAccessTokenContextKey{}, token)
}
// call sends a JSON-RPC request. On 401, refreshes token and retries once.
func (c *MCPClient) call(ctx context.Context, method string, params interface{}) (string, error) {
staleToken := c.getToken()
requestCtx := withMCPAccessToken(ctx, staleToken)
result, err := c.callOnce(requestCtx, method, params)
if err == nil {
return result, nil
}
if errors.Is(err, mcptransport.ErrSessionTerminated) {
c.resetSession()
return c.callOnce(requestCtx, method, params)
}
if c.tryRefreshOn401(ctx, err, staleToken) {
return c.callOnce(withMCPAccessToken(ctx, c.getToken()), method, params)
}
return "", err
}
// tryRefreshOn401 refreshes the OAuth token on 401 and resets the session.
// Returns true if the caller should retry.
func (c *MCPClient) tryRefreshOn401(ctx context.Context, err error, staleToken string) bool {
if c.refresher == nil {
return false
}
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != 401 {
return false
}
if refreshErr := c.refresher.RefreshOAuthToken(ctx, c.workspaceID, staleToken); refreshErr != nil {
return false
}
newToken, revealErr := c.refresher.RevealAccessToken(ctx, c.workspaceID)
if revealErr != nil {
return false
}
c.setToken(newToken)
c.resetSession()
return true
}
func (c *MCPClient) callOnce(ctx context.Context, method string, params interface{}) (string, error) {
client, err := c.ensureSession(ctx)
if err != nil {
return "", err
}
if method != "tools/call" {
return "", fmt.Errorf("unsupported MCP method %q", method)
}
requestParams, ok := params.(map[string]interface{})
if !ok {
return "", errors.New("MCP tools/call params must be an object")
}
toolName, _ := requestParams["name"].(string)
if toolName == "" {
return "", errors.New("MCP tool name is required")
}
result, err := client.CallTool(ctx, mcp.CallToolRequest{Params: mcp.CallToolParams{
Name: toolName, Arguments: requestParams["arguments"],
}})
if err != nil {
return "", err
}
text := firstMCPText(result)
if result.IsError {
if text != "" {
return "", fmt.Errorf("MCP tool error: %s", text)
}
return "", errors.New("MCP tool error (no details)")
}
return text, nil
}
func firstMCPText(result *mcp.CallToolResult) string {
for _, content := range result.Content {
switch text := content.(type) {
case mcp.TextContent:
return text.Text
case *mcp.TextContent:
return text.Text
}
}
return ""
}
// ensureSession creates and initializes the SDK client once. The SDK owns
// protocol negotiation, initialized notification, response demultiplexing,
// and the Streamable HTTP session identifier.
func (c *MCPClient) ensureSession(ctx context.Context) (*mcpclient.Client, error) {
c.sessionMu.Lock()
defer c.sessionMu.Unlock()
if c.session != nil {
return c.session, nil
}
client, err := mcpclient.NewStreamableHttpClient(
mcpEndpoint,
mcptransport.WithHTTPBasicClient(c.http),
mcptransport.WithHTTPHeaderFunc(func(requestCtx context.Context) map[string]string {
token, _ := requestCtx.Value(mcpAccessTokenContextKey{}).(string)
if token == "" {
token = c.getToken()
}
return map[string]string{"Authorization": "Bearer " + token}
}),
)
if err != nil {
return nil, err
}
if err := client.Start(ctx); err != nil {
return nil, fmt.Errorf("MCP start: %w", err)
}
if _, err := client.Initialize(ctx, mcp.InitializeRequest{Params: mcp.InitializeParams{
ProtocolVersion: mcpProtocol,
Capabilities: mcp.ClientCapabilities{},
ClientInfo: mcp.Implementation{Name: "kandev", Version: "1.0"},
}}); err != nil {
return nil, fmt.Errorf("MCP initialize: %w", err)
}
c.session = client
return client, nil
}
func (c *MCPClient) resetSession() {
c.sessionMu.Lock()
previous := c.session
c.session = nil
c.sessionMu.Unlock()
if previous != nil {
_ = previous.Close()
}
}
// --- Client interface ---
func (c *MCPClient) TestAuth(ctx context.Context) (*TestConnectionResult, error) {
result, err := c.call(ctx, "tools/call", map[string]interface{}{
"name": "atlassianUserInfo",
"arguments": map[string]interface{}{},
})
if err != nil {
return &TestConnectionResult{OK: false, Error: err.Error()}, nil
}
var user struct {
Name string `json:"name"`
Email string `json:"email"`
AccountID string `json:"accountId"`
}
if err := json.Unmarshal([]byte(result), &user); err != nil {
return &TestConnectionResult{OK: true}, nil
}
return &TestConnectionResult{OK: true, DisplayName: user.Name, Email: user.Email, AccountID: user.AccountID}, nil
}
func (c *MCPClient) GetTicket(ctx context.Context, ticketKey string) (*JiraTicket, error) {
type issueResult struct {
ticket *JiraTicket
err error
}
type transResult struct {
transitions []JiraTransition
err error
}
issueCh := make(chan issueResult, 1)
transCh := make(chan transResult, 1)
go func() {
result, err := c.call(ctx, "tools/call", map[string]interface{}{
"name": "getJiraIssue",
"arguments": map[string]interface{}{
"cloudId": c.cloudID,
"issueIdOrKey": ticketKey,
},
})
if err != nil {
issueCh <- issueResult{nil, err}
return
}
t, err := parseMCPIssue(result, c.siteURL)
issueCh <- issueResult{t, err}
}()
go func() {
trans, err := c.listTransitions(ctx, ticketKey)
transCh <- transResult{trans, err}
}()
ir := <-issueCh
if ir.err != nil {
return nil, ir.err
}
tr := <-transCh
if tr.err == nil {
ir.ticket.Transitions = tr.transitions
}
return ir.ticket, nil
}
func (c *MCPClient) listTransitions(ctx context.Context, ticketKey string) ([]JiraTransition, error) {
result, err := c.call(ctx, "tools/call", map[string]interface{}{
"name": "getTransitionsForJiraIssue",
"arguments": map[string]interface{}{
"cloudId": c.cloudID,
"issueIdOrKey": ticketKey,
},
})
if err != nil {
return nil, err
}
var resp struct {
Transitions []struct {
ID string `json:"id"`
Name string `json:"name"`
To struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"to"`
} `json:"transitions"`
}
if err := json.Unmarshal([]byte(result), &resp); err != nil {
return nil, fmt.Errorf("decode transitions: %w", err)
}
out := make([]JiraTransition, 0, len(resp.Transitions))
for _, t := range resp.Transitions {
out = append(out, JiraTransition{
ID: t.ID,
Name: t.Name,
ToStatusID: t.To.ID,
ToStatusName: t.To.Name,
})
}
return out, nil
}
func (c *MCPClient) ListTransitions(ctx context.Context, ticketKey string) ([]JiraTransition, error) {
return c.listTransitions(ctx, ticketKey)
}
func (c *MCPClient) DoTransition(ctx context.Context, ticketKey, transitionID string) error {
_, err := c.call(ctx, "tools/call", map[string]interface{}{
"name": "transitionJiraIssue",
"arguments": map[string]interface{}{
"cloudId": c.cloudID,
"issueIdOrKey": ticketKey,
"transition": map[string]string{"id": transitionID},
},
})
return err
}
func (c *MCPClient) ListProjects(ctx context.Context) ([]JiraProject, error) {
result, err := c.call(ctx, "tools/call", map[string]interface{}{
"name": "getVisibleJiraProjects",
"arguments": map[string]interface{}{
"cloudId": c.cloudID,
},
})
if err != nil {
return nil, err
}
var resp struct {
Values []struct {
Key string `json:"key"`
Name string `json:"name"`
ID string `json:"id"`
} `json:"values"`
}
if err := json.Unmarshal([]byte(result), &resp); err != nil {
return nil, fmt.Errorf("decode projects: %w", err)
}
out := make([]JiraProject, 0, len(resp.Values))
for _, p := range resp.Values {
out = append(out, JiraProject{Key: p.Key, Name: p.Name, ID: p.ID})
}
return out, nil
}
// ListProjectStatuses fetches project workflow statuses. The MCP server
// doesn't expose a direct /project/{key}/statuses endpoint, so we use
// getJiraProjectIssueTypesMetadata which returns issue types with their
// available statuses. We flatten and dedupe like CloudClient does.
func (c *MCPClient) ListProjectStatuses(ctx context.Context, projectKey string) ([]JiraStatus, error) {
result, err := c.call(ctx, "tools/call", map[string]interface{}{
"name": "getJiraProjectIssueTypesMetadata",
"arguments": map[string]interface{}{
"cloudId": c.cloudID,
"projectIdOrKey": projectKey,
},
})
if err != nil {
return nil, err
}
// The response is an array of issue types, each with a "statuses" array.
var body []struct {
Statuses []struct {
ID string `json:"id"`
Name string `json:"name"`
StatusCategory struct {
Key string `json:"key"`
} `json:"statusCategory"`
} `json:"statuses"`
}
if err := json.Unmarshal([]byte(result), &body); err != nil {
return nil, fmt.Errorf("decode project statuses: %w", err)
}
out := make([]JiraStatus, 0)
seen := make(map[string]struct{})
for _, it := range body {
for _, s := range it.Statuses {
if _, dup := seen[s.ID]; dup {
continue
}
seen[s.ID] = struct{}{}
out = append(out, JiraStatus{
ID: s.ID,
Name: s.Name,
StatusCategory: s.StatusCategory.Key,
})
}
}
return out, nil
}
func (c *MCPClient) SearchTickets(ctx context.Context, jql, pageToken string, maxResults int) (*SearchResult, error) {
args := map[string]interface{}{
"cloudId": c.cloudID,
"jql": jql,
"maxResults": maxResults,
}
if pageToken != "" {
args["nextPageToken"] = pageToken
}
result, err := c.call(ctx, "tools/call", map[string]interface{}{
"name": "searchJiraIssuesUsingJql",
"arguments": args,
})
if err != nil {
return nil, err
}
return parseMCPSearchResult(result, c.siteURL)
}
// mcpIssue mirrors the subset of the Jira issue payload from the MCP tool.
type mcpIssue struct {
ID string `json:"id"`
Key string `json:"key"`
Fields struct {
Summary string `json:"summary"`
Description interface{} `json:"description"` // ADF or string
Updated string `json:"updated"`
Status struct {
ID string `json:"id"`
Name string `json:"name"`
StatusCategory struct {
Key string `json:"key"`
} `json:"statusCategory"`
} `json:"status"`
Project struct {
Key string `json:"key"`
} `json:"project"`
Issuetype struct {
Name string `json:"name"`
IconURL string `json:"iconUrl"`
} `json:"issuetype"`
Priority struct {
Name string `json:"name"`
IconURL string `json:"iconUrl"`
} `json:"priority"`
Assignee *mcpUser `json:"assignee"`
Reporter *mcpUser `json:"reporter"`
} `json:"fields"`
}
type mcpUser struct {
DisplayName string `json:"displayName"`
AvatarURLs struct {
Size24 string `json:"24x24"`
Size32 string `json:"32x32"`
} `json:"avatarUrls"`
}
func (u *mcpUser) avatar() string {
if u == nil {
return ""
}
if u.AvatarURLs.Size24 != "" {
return u.AvatarURLs.Size24
}
return u.AvatarURLs.Size32
}
func (u *mcpUser) name() string {
if u == nil {
return ""
}
return u.DisplayName
}
// parseMCPIssue converts the MCP tool response into a JiraTicket.
func parseMCPIssue(raw, siteURL string) (*JiraTicket, error) {
var issue mcpIssue
if err := json.Unmarshal([]byte(raw), &issue); err != nil {
return nil, fmt.Errorf("decode issue: %w", err)
}
return &JiraTicket{
ID: issue.ID,
Key: issue.Key,
Summary: issue.Fields.Summary,
Description: extractDescription(issue.Fields.Description),
StatusID: issue.Fields.Status.ID,
StatusName: issue.Fields.Status.Name,
StatusCategory: issue.Fields.Status.StatusCategory.Key,
ProjectKey: issue.Fields.Project.Key,
IssueType: issue.Fields.Issuetype.Name,
IssueTypeIcon: issue.Fields.Issuetype.IconURL,
Priority: issue.Fields.Priority.Name,
PriorityIcon: issue.Fields.Priority.IconURL,
AssigneeName: issue.Fields.Assignee.name(),
AssigneeAvatar: issue.Fields.Assignee.avatar(),
ReporterName: issue.Fields.Reporter.name(),
ReporterAvatar: issue.Fields.Reporter.avatar(),
Updated: issue.Fields.Updated,
URL: siteURL + "/browse/" + issue.Key,
}, nil
}
func parseMCPSearchResult(raw, siteURL string) (*SearchResult, error) {
var resp struct {
Issues []json.RawMessage `json:"issues"`
IsLast bool `json:"isLast"`
NextPageToken string `json:"nextPageToken"`
MaxResults int `json:"maxResults"`
}
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
return nil, fmt.Errorf("decode search result: %w", err)
}
tickets := make([]JiraTicket, 0, len(resp.Issues))
for _, raw := range resp.Issues {
ticket, err := parseMCPIssue(string(raw), siteURL)
if err != nil {
continue
}
tickets = append(tickets, *ticket)
}
return &SearchResult{
Tickets: tickets,
MaxResults: resp.MaxResults,
IsLast: resp.IsLast,
NextPageToken: resp.NextPageToken,
}, nil
}
@@ -0,0 +1,230 @@
package jira
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"sync"
"testing"
mcpclient "github.com/mark3labs/mcp-go/client"
mcptransport "github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
)
type mcpRoundTripFunc func(*http.Request) (*http.Response, error)
func (f mcpRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func mcpHTTPResponse(status int, contentType, body string) *http.Response {
return &http.Response{
StatusCode: status,
Header: http.Header{"Content-Type": []string{contentType}},
Body: io.NopCloser(strings.NewReader(body)),
}
}
func setMCPTestTransport(c *MCPClient, transport http.RoundTripper) {
c.http.Transport = &mcpStatusRoundTripper{base: transport}
}
func mcpMethod(t *testing.T, req *http.Request) string {
t.Helper()
if req.Body == nil {
return req.Method
}
var body struct {
Method string `json:"method"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
t.Fatalf("decode MCP request: %v", err)
}
return body.Method
}
const (
initializeResult = `{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"fake","version":"1"}}}`
toolResult = `{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"ok"}]}}`
)
func TestMCPClientSendsInitializedNotification(t *testing.T) {
c := NewMCPClient("token-a", "cloud", "ws", "https://x.atlassian.net")
initialized := false
setMCPTestTransport(c, mcpRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch mcpMethod(t, req) {
case "initialize":
resp := mcpHTTPResponse(http.StatusOK, "application/json", initializeResult)
resp.Header.Set("Mcp-Session-Id", "session-1")
return resp, nil
case "notifications/initialized":
initialized = true
return mcpHTTPResponse(http.StatusAccepted, "application/json", ""), nil
case "tools/call":
return mcpHTTPResponse(http.StatusOK, "text/event-stream", "data: "+toolResult+"\n\n"), nil
default:
return mcpHTTPResponse(http.StatusBadRequest, "text/plain", "unexpected method"), nil
}
}))
if _, err := c.call(context.Background(), "tools/call", map[string]interface{}{"name": "test", "arguments": map[string]interface{}{}}); err != nil {
t.Fatalf("call: %v", err)
}
if !initialized {
t.Fatal("client did not send notifications/initialized")
}
}
func TestMCPClientAcceptsJSONToolResponse(t *testing.T) {
c := NewMCPClient("token-a", "cloud", "ws", "https://x.atlassian.net")
setMCPTestTransport(c, mcpRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch mcpMethod(t, req) {
case "initialize":
resp := mcpHTTPResponse(http.StatusOK, "application/json", initializeResult)
resp.Header.Set("Mcp-Session-Id", "session-1")
return resp, nil
case "notifications/initialized":
return mcpHTTPResponse(http.StatusAccepted, "application/json", ""), nil
default:
return mcpHTTPResponse(http.StatusOK, "application/json", toolResult), nil
}
}))
got, err := c.call(context.Background(), "tools/call", map[string]interface{}{"name": "test", "arguments": map[string]interface{}{}})
if err != nil {
t.Fatalf("call: %v", err)
}
if got != "ok" {
t.Fatalf("result = %q, want ok", got)
}
}
type recordingRefresher struct {
mu sync.Mutex
token string
stale []string
rotated chan struct{}
once sync.Once
}
func (r *recordingRefresher) RefreshOAuthToken(_ context.Context, _ string, stale string) error {
r.mu.Lock()
r.stale = append(r.stale, stale)
if stale == "token-a" {
r.token = "token-b"
} else {
r.token = "token-c"
}
r.mu.Unlock()
r.once.Do(func() { close(r.rotated) })
return nil
}
func (r *recordingRefresher) RevealAccessToken(context.Context, string) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
return r.token, nil
}
func TestMCPClientRefreshUsesTokenSentByFailedRequest(t *testing.T) {
refresher := &recordingRefresher{token: "token-a", rotated: make(chan struct{})}
c := NewMCPClient("token-a", "cloud", "ws", "https://x.atlassian.net")
c.SetRefresher("ws", refresher)
bothInitial := make(chan struct{})
var initialMu sync.Mutex
initialCount := 0
setMCPTestTransport(c, mcpRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch mcpMethod(t, req) {
case "initialize":
resp := mcpHTTPResponse(http.StatusOK, "application/json", initializeResult)
resp.Header.Set("Mcp-Session-Id", "session-1")
return resp, nil
case "notifications/initialized":
return mcpHTTPResponse(http.StatusAccepted, "application/json", ""), nil
}
if req.Header.Get("Authorization") != "Bearer token-a" {
return mcpHTTPResponse(http.StatusOK, "application/json", toolResult), nil
}
initialMu.Lock()
initialCount++
current := initialCount
if current == 2 {
close(bothInitial)
}
initialMu.Unlock()
<-bothInitial
if current == 2 {
<-refresher.rotated
}
return mcpHTTPResponse(http.StatusUnauthorized, "text/plain", "expired"), nil
}))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var wg sync.WaitGroup
errs := make(chan error, 2)
for range 2 {
wg.Add(1)
go func() {
defer wg.Done()
_, err := c.call(ctx, "tools/call", map[string]interface{}{"name": "test", "arguments": map[string]interface{}{}})
errs <- err
}()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("call failed: %v", err)
}
}
refresher.mu.Lock()
defer refresher.mu.Unlock()
if len(refresher.stale) != 2 || refresher.stale[0] != "token-a" || refresher.stale[1] != "token-a" {
t.Fatalf("stale tokens = %v, want [token-a token-a]", refresher.stale)
}
}
type resetSessionTransport struct {
closed chan struct{}
once sync.Once
}
func (t *resetSessionTransport) Start(context.Context) error { return nil }
func (t *resetSessionTransport) SendRequest(context.Context, mcptransport.JSONRPCRequest) (*mcptransport.JSONRPCResponse, error) {
return nil, nil
}
func (t *resetSessionTransport) SendNotification(context.Context, mcp.JSONRPCNotification) error {
return nil
}
func (t *resetSessionTransport) SetNotificationHandler(func(mcp.JSONRPCNotification)) {}
func (t *resetSessionTransport) Close() error {
t.once.Do(func() { close(t.closed) })
return nil
}
func (t *resetSessionTransport) GetSessionId() string { return "test-session" }
func TestMCPClientResetSessionClosesPreviousClient(t *testing.T) {
transport := &resetSessionTransport{closed: make(chan struct{})}
c := NewMCPClient("token", "cloud", "ws", "https://x.atlassian.net")
c.session = mcpclient.NewClient(transport)
c.resetSession()
if c.session != nil {
t.Fatal("session was not cleared")
}
select {
case <-transport.closed:
default:
t.Fatal("previous session was not closed")
}
}
+33
View File
@@ -22,6 +22,11 @@ const (
// Created from the user's profile on the Jira instance. Server/DC-only —
// Cloud doesn't accept Bearer for the Jira REST API.
AuthMethodPAT = "pat"
// AuthMethodOAuth is Atlassian's OAuth 2.0 (3LO) authorization code flow.
// Cloud-only. A rotating refresh token keeps the session alive. API calls
// use the Atlassian MCP server because its OAuth token does not work with
// the direct Jira REST API.
AuthMethodOAuth = "oauth"
)
// Instance type identifiers persisted in JiraConfig.InstanceType. Cloud sites
@@ -49,6 +54,17 @@ type JiraConfig struct {
// a JWT (cloud.session.token / tenant.session.token). Nil for api_token or
// opaque session cookies.
SecretExpiresAt *time.Time `json:"secretExpiresAt,omitempty" db:"-"`
// ClientID is the client_id from OAuth Dynamic Client Registration.
// Stored in the config row, not the secret store; it is not sensitive.
// Only set when AuthMethod == AuthMethodOAuth.
ClientID string `json:"clientId,omitempty" db:"client_id"`
// CloudID is the Atlassian Cloud site ID resolved through the MCP server.
// MCP tool requests use it to select the Jira site. Only set when
// AuthMethod == AuthMethodOAuth.
CloudID string `json:"cloudId,omitempty" db:"cloud_id"`
// TokenExpiresAt is the OAuth access token expiry. The client refreshes
// on-demand when a request hits this deadline. Only for AuthMethodOAuth.
TokenExpiresAt *time.Time `json:"tokenExpiresAt,omitempty" db:"token_expires_at"`
// LastCheckedAt / LastOk / LastError are written by the background auth
// poller. They let the UI render a "connected/disconnected + checked Xs ago"
// indicator without doing its own probing.
@@ -69,6 +85,10 @@ type SetConfigRequest struct {
InstanceType string `json:"instanceType"`
DefaultProjectKey string `json:"defaultProjectKey"`
Secret string `json:"secret"`
// ClientID is the OAuth app client_id (dynamically registered via MCP).
// Only used when authMethod == "oauth". Stored in the config row, not
// the secret store — it is not sensitive.
ClientID string `json:"clientId"`
}
// TestConnectionResult reports what the backend learned when pinging Jira with
@@ -157,6 +177,19 @@ func SecretKeyForWorkspace(workspaceID string) string {
return "jira:" + workspaceID + ":token"
}
// OAuthAccessTokenKeyForWorkspace returns the secret-store key for the OAuth
// access token. The access token has a 15-minute TTL and is refreshed on-demand.
func OAuthAccessTokenKeyForWorkspace(workspaceID string) string {
return "jira:" + workspaceID + ":oauth_access_token"
}
// OAuthRefreshTokenKeyForWorkspace returns the secret-store key for the OAuth
// refresh token. The refresh token is rotating — every refresh invalidates the
// old one and returns a new one.
func OAuthRefreshTokenKeyForWorkspace(workspaceID string) string {
return "jira:" + workspaceID + ":oauth_refresh_token"
}
// LegacySecretKeyForWorkspace is kept for older tests/callers.
func LegacySecretKeyForWorkspace(workspaceID string) string {
return SecretKeyForWorkspace(workspaceID)
+525
View File
@@ -0,0 +1,525 @@
package jira
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
mcptransport "github.com/mark3labs/mcp-go/client/transport"
)
// Atlassian MCP OAuth 2.1 endpoints. The MCP server acts as both
// authorization server and resource server, proxying to auth.atlassian.com.
// Dynamic Client Registration (RFC 7591) + PKCE (S256) replaces the need for
// a pre-created app — no client_id/client_secret from the developer console.
const (
mcpResourceURL = "https://mcp.atlassian.com"
mcpScopes = "offline_access read:me read:account read:jira-user read:jira-work write:jira-work search:jira-work"
mcpStateTTL = 10 * time.Minute
oauthPersistenceTimeout = 10 * time.Second
)
// mcpTokenResponse is the response from the token endpoint.
type mcpTokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"` // seconds
Scope string `json:"scope"`
}
// mcpRegisterResponse is the response from dynamic client registration.
type mcpRegisterResponse struct {
ClientID string `json:"client_id"`
ClientIDIssuedAt int64 `json:"client_id_issued_at"`
RedirectURIs []string `json:"redirect_uris"`
GrantTypes []string `json:"grant_types"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
}
// mcpPendingState holds the CSRF state + PKCE verifier during the OAuth flow.
type mcpPendingState struct {
State string
WorkspaceID string
SiteURL string
ClientID string // from dynamic registration
CodeVerifier string // PKCE verifier (kept server-side)
RedirectURI string
TokenEndpoint string
CreatedAt time.Time
}
type mcpOAuthEndpoints struct {
Authorization string
Token string
Registration string
}
// mcpStateManager holds pending OAuth states in memory.
type mcpStateManager struct {
mu sync.Mutex
states map[string]mcpPendingState
}
func newMCPStateManager() *mcpStateManager {
return &mcpStateManager{states: make(map[string]mcpPendingState)}
}
func (m *mcpStateManager) store(state mcpPendingState) {
m.mu.Lock()
defer m.mu.Unlock()
m.states[state.State] = state
m.cleanupLocked()
}
func (m *mcpStateManager) loadAndDelete(state string) (mcpPendingState, bool) {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.states[state]
if ok {
delete(m.states, state)
}
return s, ok
}
func (m *mcpStateManager) cleanupLocked() {
cutoff := time.Now().Add(-mcpStateTTL)
for k, s := range m.states {
if s.CreatedAt.Before(cutoff) {
delete(m.states, k)
}
}
}
func generateRandomString(nBytes int) (string, error) {
b := make([]byte, nBytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// generatePKCEPair creates a code_verifier and code_challenge (S256).
func generatePKCEPair() (verifier, challenge string, err error) {
verifier, err = generateRandomString(32)
if err != nil {
return "", "", err
}
h := sha256.Sum256([]byte(verifier))
challenge = base64.RawURLEncoding.EncodeToString(h[:])
return verifier, challenge, nil
}
// registerMCPClient performs Dynamic Client Registration (RFC 7591) against
// the MCP server. Returns a client_id that is used for the authorization flow.
// No client_secret is returned — the flow uses PKCE with
// token_endpoint_auth_method="none".
func registerMCPClient(ctx context.Context, client *http.Client, registrationEndpoint, redirectURI string) (string, error) {
body, _ := json.Marshal(map[string]interface{}{
"client_name": "Kandev",
"redirect_uris": []string{redirectURI},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"token_endpoint_auth_method": "none",
"scope": mcpScopes,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, registrationEndpoint, strings.NewReader(string(body)))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("dynamic registration failed (%d): %s", resp.StatusCode, string(raw))
}
var reg mcpRegisterResponse
if err := json.Unmarshal(raw, &reg); err != nil {
return "", fmt.Errorf("decode registration response: %w", err)
}
if reg.ClientID == "" {
return "", errors.New("registration returned empty client_id")
}
return reg.ClientID, nil
}
func discoverMCPOAuthEndpoints(ctx context.Context, client *http.Client) (*mcpOAuthEndpoints, error) {
handler := mcptransport.NewOAuthHandler(mcptransport.OAuthConfig{HTTPClient: client})
handler.SetBaseURL(mcpResourceURL)
metadata, err := handler.GetServerMetadata(ctx)
if err != nil {
return nil, fmt.Errorf("discover OAuth server: %w", err)
}
if metadata.AuthorizationEndpoint == "" || metadata.TokenEndpoint == "" || metadata.RegistrationEndpoint == "" {
return nil, errors.New("OAuth server metadata is missing required endpoints")
}
return &mcpOAuthEndpoints{
Authorization: metadata.AuthorizationEndpoint,
Token: metadata.TokenEndpoint,
Registration: metadata.RegistrationEndpoint,
}, nil
}
func newMCPOAuthHTTPClient() *http.Client {
return &http.Client{Timeout: 15 * time.Second}
}
// oauthPersistenceContext keeps credential writes alive after the request
// ends. The token exchange invalidates the previous refresh token, so losing
// the new token between the exchange and persistence would force re-auth.
func oauthPersistenceContext(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(ctx), oauthPersistenceTimeout)
}
// StartOAuthFlow registers a client dynamically, generates PKCE, and builds the
// authorization URL. The user never needs to create an Atlassian app.
func (s *Service) StartOAuthFlow(ctx context.Context, workspaceID, siteURL, redirectURI string) (string, error) {
if redirectURI == "" {
return "", errors.New("redirect URI is required")
}
resolvedWorkspaceID, err := s.resolveWorkspaceID(ctx, workspaceID)
if err != nil {
return "", err
}
client := newMCPOAuthHTTPClient()
endpoints, err := discoverMCPOAuthEndpoints(ctx, client)
if err != nil {
return "", err
}
clientID, err := registerMCPClient(ctx, client, endpoints.Registration, redirectURI)
if err != nil {
return "", fmt.Errorf("dynamic client registration: %w", err)
}
verifier, challenge, err := generatePKCEPair()
if err != nil {
return "", fmt.Errorf("generate PKCE: %w", err)
}
state, err := generateRandomString(16)
if err != nil {
return "", err
}
s.oauthStates.store(mcpPendingState{
State: state,
WorkspaceID: resolvedWorkspaceID,
SiteURL: siteURL,
ClientID: clientID,
CodeVerifier: verifier,
RedirectURI: redirectURI,
TokenEndpoint: endpoints.Token,
CreatedAt: time.Now(),
})
params := url.Values{
"response_type": {"code"},
"client_id": {clientID},
"redirect_uri": {redirectURI},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
"state": {state},
"resource": {mcpResourceURL},
"scope": {mcpScopes},
}
return endpoints.Authorization + "?" + params.Encode(), nil
}
// CompleteOAuthFlow exchanges the authorization code for tokens (using PKCE,
// no client_secret), resolves the cloudId, and persists config + secrets.
func (s *Service) CompleteOAuthFlow(ctx context.Context, code, state string) (*JiraConfig, error) {
st, ok := s.oauthStates.loadAndDelete(state)
if !ok || time.Since(st.CreatedAt) > mcpStateTTL {
return nil, errors.New("invalid or expired OAuth state")
}
// Bind the completing user to the workspace the flow was started for, so a
// leaked state cannot persist a victim's tokens into someone else's workspace.
if err := s.authorizeWorkspaceAccess(ctx, st.WorkspaceID); err != nil {
return nil, fmt.Errorf("authorize workspace: %w", err)
}
tok, err := exchangeMCPCode(ctx, newMCPOAuthHTTPClient(), st.TokenEndpoint, st.ClientID, code, st.RedirectURI, st.CodeVerifier)
if err != nil {
return nil, fmt.Errorf("token exchange: %w", err)
}
// cloudId is required for every MCP tool call; a workspace saved without it
// looks connected but fails on every request, so fail the flow loudly here.
cloudID, err := resolveCloudIDViaMCP(ctx, tok.AccessToken, st.SiteURL)
if err != nil {
return nil, fmt.Errorf("resolve Atlassian cloud ID for %q: %w", st.SiteURL, err)
}
expiresAt := time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second)
cfg := &JiraConfig{
WorkspaceID: st.WorkspaceID,
SiteURL: st.SiteURL,
AuthMethod: AuthMethodOAuth,
InstanceType: InstanceTypeCloud,
ClientID: st.ClientID,
CloudID: cloudID,
TokenExpiresAt: &expiresAt,
}
// UpsertConfigForWorkspace is a full-row write; carry over user-owned fields
// so connecting OAuth doesn't wipe an existing email / default project key.
if existing, err := s.store.GetConfigForWorkspace(ctx, st.WorkspaceID); err == nil && existing != nil {
cfg.Email = existing.Email
cfg.DefaultProjectKey = existing.DefaultProjectKey
}
if tok.AccessToken == "" || tok.RefreshToken == "" {
return nil, errors.New("token endpoint did not return the required access and refresh tokens")
}
persistCtx, cancel := oauthPersistenceContext(ctx)
defer cancel()
// Persist the rotating refresh token first. A later failure then leaves a
// credential that can recover, instead of retaining an invalidated token.
if err := s.secrets.Set(persistCtx, OAuthRefreshTokenKeyForWorkspace(st.WorkspaceID), "oauth_refresh_token", tok.RefreshToken); err != nil {
return nil, fmt.Errorf("store refresh token: %w", err)
}
if err := s.secrets.Set(persistCtx, OAuthAccessTokenKeyForWorkspace(st.WorkspaceID), "oauth_access_token", tok.AccessToken); err != nil {
return nil, fmt.Errorf("store access token: %w", err)
}
if err := s.store.UpsertConfigForWorkspace(persistCtx, st.WorkspaceID, cfg); err != nil {
return nil, fmt.Errorf("persist config: %w", err)
}
s.invalidateClient(st.WorkspaceID)
return cfg, nil
}
// RefreshOAuthToken uses the stored refresh token to get a new access token.
// PKCE-based flow: only client_id is sent (no client_secret). Refresh tokens
// are rotating — the old one is invalidated and a new one is returned.
func (s *Service) RefreshOAuthToken(ctx context.Context, workspaceID, staleToken string) error {
resolvedWorkspaceID, err := s.resolveWorkspaceID(ctx, workspaceID)
if err != nil {
return err
}
workspaceID = resolvedWorkspaceID
s.oauthMu.Lock(workspaceID)
defer s.oauthMu.Unlock(workspaceID)
return s.refreshOAuthTokenLocked(ctx, workspaceID, staleToken)
}
func (s *Service) refreshOAuthTokenLocked(ctx context.Context, workspaceID, staleToken string) error {
// If another goroutine already rotated the token while we waited for the
// lock, skip: rotating again with the now-invalid refresh token would fail.
if s.oauthRefreshAlreadyCompleted(ctx, workspaceID, staleToken) {
return nil
}
refreshToken, err := s.secrets.Reveal(ctx, OAuthRefreshTokenKeyForWorkspace(workspaceID))
if err != nil {
return fmt.Errorf("read refresh token: %w", err)
}
if refreshToken == "" {
return errors.New("no refresh token — re-authentication required")
}
cfg, err := s.store.GetConfigForWorkspace(ctx, workspaceID)
if err != nil {
return fmt.Errorf("read config: %w", err)
}
if cfg == nil {
return errors.New("no config for workspace")
}
endpoints, err := discoverMCPOAuthEndpoints(ctx, newMCPOAuthHTTPClient())
if err != nil {
return err
}
tok, err := refreshMCPToken(ctx, newMCPOAuthHTTPClient(), endpoints.Token, cfg.ClientID, refreshToken)
if err != nil {
return fmt.Errorf("refresh: %w", err)
}
if tok.AccessToken == "" {
return errors.New("token endpoint returned an empty access token")
}
return s.persistRefreshedOAuthToken(ctx, workspaceID, refreshToken, tok)
}
func (s *Service) oauthRefreshAlreadyCompleted(ctx context.Context, workspaceID, staleToken string) bool {
if staleToken == "" {
return false
}
current, err := s.secrets.Reveal(ctx, OAuthAccessTokenKeyForWorkspace(workspaceID))
return err == nil && current != "" && current != staleToken
}
func (s *Service) persistRefreshedOAuthToken(
ctx context.Context,
workspaceID, previousRefreshToken string,
tok *mcpTokenResponse,
) error {
persistCtx, cancel := oauthPersistenceContext(ctx)
defer cancel()
newRefreshToken := tok.RefreshToken
if newRefreshToken == "" {
newRefreshToken = previousRefreshToken
}
if err := s.secrets.Set(persistCtx, OAuthRefreshTokenKeyForWorkspace(workspaceID), "oauth_refresh_token", newRefreshToken); err != nil {
return fmt.Errorf("store refresh token: %w", err)
}
if err := s.secrets.Set(persistCtx, OAuthAccessTokenKeyForWorkspace(workspaceID), "oauth_access_token", tok.AccessToken); err != nil {
return fmt.Errorf("store access token: %w", err)
}
expiresAt := time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second)
if err := s.store.UpdateTokenExpiryForWorkspace(persistCtx, workspaceID, expiresAt); err != nil {
return fmt.Errorf("update token expiry: %w", err)
}
s.invalidateClient(workspaceID)
return nil
}
// exchangeMCPCode swaps the authorization code for tokens using PKCE.
// No client_secret is sent — token_endpoint_auth_method="none".
func exchangeMCPCode(ctx context.Context, client *http.Client, tokenEndpoint, clientID, code, redirectURI, codeVerifier string) (*mcpTokenResponse, error) {
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {redirectURI},
"client_id": {clientID},
"code_verifier": {codeVerifier},
"resource": {mcpResourceURL},
}
return postMCPTokenRequest(ctx, client, tokenEndpoint, form.Encode())
}
// refreshMCPToken uses a refresh token to get a new access token. Only
// client_id is sent (no client_secret — PKCE public client).
func refreshMCPToken(ctx context.Context, client *http.Client, tokenEndpoint, clientID, refreshToken string) (*mcpTokenResponse, error) {
form := url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {refreshToken},
"client_id": {clientID},
"resource": {mcpResourceURL},
}
return postMCPTokenRequest(ctx, client, tokenEndpoint, form.Encode())
}
func postMCPTokenRequest(ctx context.Context, client *http.Client, tokenEndpoint, body string) (*mcpTokenResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var errResp struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
_ = json.Unmarshal(raw, &errResp)
if errResp.Error != "" {
return nil, fmt.Errorf("%s: %s", errResp.Error, errResp.ErrorDescription)
}
return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(raw))
}
var tok mcpTokenResponse
if err := json.Unmarshal(raw, &tok); err != nil {
return nil, fmt.Errorf("decode token response: %w", err)
}
return &tok, nil
}
// resolveCloudIDViaMCP uses the MCP getAccessibleAtlassianResources tool to
// resolve the cloudId. The MCP OAuth token doesn't work for direct
// api.atlassian.com calls, but it works through the MCP server.
func resolveCloudIDViaMCP(ctx context.Context, accessToken, siteURL string) (string, error) {
client := NewMCPClient(accessToken, "", "", siteURL)
result, err := client.call(ctx, "tools/call", map[string]interface{}{
"name": "getAccessibleAtlassianResources", "arguments": map[string]interface{}{},
})
if err != nil {
return "", err
}
return matchAccessibleResource(result, siteURL)
}
// matchAccessibleResource parses accessible-resources JSON and matches by URL.
func matchAccessibleResource(raw, siteURL string) (string, error) {
var resources []accessibleResource
if err := json.Unmarshal([]byte(raw), &resources); err != nil {
return "", fmt.Errorf("decode accessible-resources: %w", err)
}
normalized := strings.TrimRight(siteURL, "/")
if normalized != "" && !strings.Contains(normalized, "://") {
normalized = "https://" + normalized
}
for _, r := range resources {
if strings.TrimRight(r.URL, "/") == normalized {
return r.ID, nil
}
}
return "", fmt.Errorf("site URL %q not found in accessible resources", siteURL)
}
// (resolveCloudID removed — the MCP OAuth token doesn't work for direct
// api.atlassian.com calls, so resolveCloudIDViaMCP is used instead.)
// accessibleResource is one entry from the accessible-resources response.
type accessibleResource struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Scopes []string `json:"scopes"`
}
// perWorkspaceMutex provides a lock per workspace ID for OAuth token refresh.
type perWorkspaceMutex struct {
mu sync.Mutex
locks map[string]*workspaceLock
}
type workspaceLock struct {
mu sync.Mutex
refs int
}
func newPerWorkspaceMutex() *perWorkspaceMutex {
return &perWorkspaceMutex{locks: make(map[string]*workspaceLock)}
}
func (p *perWorkspaceMutex) Lock(workspaceID string) {
p.mu.Lock()
entry, ok := p.locks[workspaceID]
if !ok {
entry = &workspaceLock{}
p.locks[workspaceID] = entry
}
entry.refs++
p.mu.Unlock()
entry.mu.Lock()
}
func (p *perWorkspaceMutex) Unlock(workspaceID string) {
p.mu.Lock()
entry, ok := p.locks[workspaceID]
p.mu.Unlock()
if !ok {
return
}
entry.mu.Unlock()
p.mu.Lock()
entry.refs--
if entry.refs == 0 {
delete(p.locks, workspaceID)
}
p.mu.Unlock()
}
+207
View File
@@ -0,0 +1,207 @@
package jira
import (
"context"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/kandev/kandev/internal/task/repository/repoerrors"
)
type oauthRoundTripFunc func(*http.Request) (*http.Response, error)
func (f oauthRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func oauthResponse(status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}
}
func withDefaultOAuthTransport(t *testing.T, transport http.RoundTripper) {
t.Helper()
previous := http.DefaultTransport
http.DefaultTransport = transport
t.Cleanup(func() { http.DefaultTransport = previous })
}
type contextAwareSecretStore struct {
*fakeSecretStore
}
func (s *contextAwareSecretStore) Set(ctx context.Context, id, name, value string) error {
if err := ctx.Err(); err != nil {
return err
}
return s.fakeSecretStore.Set(ctx, id, name, value)
}
func TestGeneratePKCEPairUsesS256(t *testing.T) {
verifier, challenge, err := generatePKCEPair()
if err != nil {
t.Fatalf("generate pair: %v", err)
}
wantHash := sha256.Sum256([]byte(verifier))
want := base64.RawURLEncoding.EncodeToString(wantHash[:])
if challenge != want {
t.Fatalf("challenge = %q, want %q", challenge, want)
}
}
func TestMCPStateIsSingleUseAndExpires(t *testing.T) {
states := newMCPStateManager()
states.store(mcpPendingState{State: "fresh", CreatedAt: time.Now()})
if _, ok := states.loadAndDelete("fresh"); !ok {
t.Fatal("fresh state was not found")
}
if _, ok := states.loadAndDelete("fresh"); ok {
t.Fatal("state was reusable")
}
states.store(mcpPendingState{State: "expired", CreatedAt: time.Now().Add(-mcpStateTTL - time.Second)})
if _, ok := states.loadAndDelete("expired"); ok {
t.Fatal("expired state remained available")
}
}
func TestMCPScopesCoverEveryUsedTool(t *testing.T) {
requested := make(map[string]bool)
for _, scope := range strings.Fields(mcpScopes) {
requested[scope] = true
}
for _, required := range []string{"offline_access", "read:me", "read:account", "read:jira-work", "write:jira-work", "search:jira-work"} {
if !requested[required] {
t.Errorf("required scope %q is not requested", required)
}
}
}
func TestStartOAuthFlowAuthorizesBeforeNetwork(t *testing.T) {
f := newSvcFixture(t)
f.svc.SetWorkspaceAuthorizer(denyOnly("ws-foreign"))
called := false
withDefaultOAuthTransport(t, oauthRoundTripFunc(func(*http.Request) (*http.Response, error) {
called = true
return nil, errors.New("network must not be called")
}))
_, err := f.svc.StartOAuthFlow(context.Background(), "ws-foreign", "https://x.atlassian.net", "http://localhost/callback")
if !errors.Is(err, repoerrors.ErrWorkspaceNotFound) {
t.Fatalf("expected workspace denial, got %v", err)
}
if called {
t.Fatal("OAuth registration ran before authorization")
}
}
func TestStartOAuthFlowPropagatesCancellation(t *testing.T) {
f := newSvcFixture(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
withDefaultOAuthTransport(t, oauthRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if err := req.Context().Err(); err != nil {
return nil, err
}
return oauthResponse(http.StatusCreated, `{"client_id":"client-1"}`), nil
}))
_, err := f.svc.StartOAuthFlow(ctx, "ws-1", "https://x.atlassian.net", "http://localhost/callback")
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context cancellation, got %v", err)
}
}
func TestStartOAuthFlowDiscoversAuthorizationServer(t *testing.T) {
f := newSvcFixture(t)
var paths []string
withDefaultOAuthTransport(t, oauthRoundTripFunc(func(req *http.Request) (*http.Response, error) {
paths = append(paths, req.URL.Path)
switch req.URL.Path {
case "/.well-known/oauth-protected-resource":
return oauthResponse(http.StatusOK, `{"authorization_servers":["https://auth.example"]}`), nil
case "/.well-known/openid-configuration":
return oauthResponse(http.StatusOK, `{"issuer":"https://auth.example","authorization_endpoint":"https://auth.example/authorize","token_endpoint":"https://auth.example/token","registration_endpoint":"https://auth.example/register","response_types_supported":["code"]}`), nil
case "/register":
return oauthResponse(http.StatusCreated, `{"client_id":"client-1"}`), nil
default:
return oauthResponse(http.StatusNotFound, "not found"), nil
}
}))
authURL, err := f.svc.StartOAuthFlow(context.Background(), "ws-1", "https://x.atlassian.net", "http://localhost/callback")
if err != nil {
t.Fatalf("start OAuth: %v (paths=%v)", err, paths)
}
if !strings.HasPrefix(authURL, "https://auth.example/authorize?") {
t.Fatalf("authorization URL = %q", authURL)
}
}
func TestRefreshOAuthTokenAuthorizesBeforeReadingSecrets(t *testing.T) {
f := newSvcFixture(t)
f.svc.SetWorkspaceAuthorizer(denyOnly("ws-foreign"))
err := f.svc.RefreshOAuthToken(context.Background(), "ws-foreign", "stale")
if !errors.Is(err, repoerrors.ErrWorkspaceNotFound) {
t.Fatalf("expected workspace denial, got %v", err)
}
}
func TestPersistRefreshedOAuthTokenOutlivesCanceledRequest(t *testing.T) {
f := newSvcFixture(t)
f.svc.secrets = &contextAwareSecretStore{fakeSecretStore: f.secrets}
if err := f.store.UpsertConfigForWorkspace(context.Background(), "ws-1", &JiraConfig{
WorkspaceID: "ws-1",
SiteURL: "https://x.atlassian.net",
AuthMethod: AuthMethodOAuth,
ClientID: "client-1",
CloudID: "cloud-1",
}); err != nil {
t.Fatalf("seed config: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := f.svc.persistRefreshedOAuthToken(ctx, "ws-1", "old-refresh", &mcpTokenResponse{
AccessToken: "new-access",
RefreshToken: "new-refresh",
ExpiresIn: 300,
})
if err != nil {
t.Fatalf("persist refreshed token: %v", err)
}
if got, _ := f.secrets.Reveal(context.Background(), OAuthRefreshTokenKeyForWorkspace("ws-1")); got != "new-refresh" {
t.Fatalf("refresh token = %q, want new-refresh", got)
}
if got, _ := f.secrets.Reveal(context.Background(), OAuthAccessTokenKeyForWorkspace("ws-1")); got != "new-access" {
t.Fatalf("access token = %q, want new-access", got)
}
cfg, err := f.store.GetConfigForWorkspace(context.Background(), "ws-1")
if err != nil {
t.Fatalf("read persisted config: %v", err)
}
if cfg.TokenExpiresAt == nil {
t.Fatal("token expiry was not persisted")
}
}
func TestPerWorkspaceMutexReleasesUnusedEntry(t *testing.T) {
locks := newPerWorkspaceMutex()
locks.Lock("ws-1")
locks.Unlock("ws-1")
locks.mu.Lock()
defer locks.mu.Unlock()
if len(locks.locks) != 0 {
t.Fatalf("unused lock count = %d, want 0", len(locks.locks))
}
}
@@ -47,6 +47,40 @@ func TestUpdateIssueWatch_DeniesForeignWorkspace(t *testing.T) {
}
}
func TestIssueWatchIDOperationsDenyForeignWorkspace(t *testing.T) {
operations := []struct {
name string
run func(*Service, string) error
}{
{name: "get", run: func(s *Service, id string) error { _, err := s.GetIssueWatch(context.Background(), id); return err }},
{name: "delete", run: func(s *Service, id string) error { return s.DeleteIssueWatch(context.Background(), id) }},
{name: "preview reset", run: func(s *Service, id string) error {
_, err := s.PreviewResetIssueWatch(context.Background(), id)
return err
}},
{name: "reset", run: func(s *Service, id string) error { _, err := s.ResetIssueWatch(context.Background(), id); return err }},
}
for _, operation := range operations {
t.Run(operation.name, func(t *testing.T) {
f := newSvcFixture(t)
w := &IssueWatch{
WorkspaceID: "ws-victim", WorkflowID: "wf", WorkflowStepID: "step",
JQL: "project = X", AgentProfileID: "ap", Enabled: true,
}
if err := f.store.CreateIssueWatch(context.Background(), w); err != nil {
t.Fatalf("seed watch: %v", err)
}
f.svc.SetWorkspaceAuthorizer(denyOnly("ws-victim"))
if err := operation.run(f.svc, w.ID); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) {
t.Fatalf("expected ErrWorkspaceNotFound, got %v", err)
}
if got, err := f.store.GetIssueWatch(context.Background(), w.ID); err != nil || got == nil {
t.Fatalf("denied operation removed watch: watch=%+v err=%v", got, err)
}
})
}
}
// TestListIssueWatches_DeniesForeignWorkspace covers the single-workspace list
// path (the scoped form the settings page uses with an explicit workspace_id).
func TestListIssueWatches_DeniesForeignWorkspace(t *testing.T) {
+93 -375
View File
@@ -12,9 +12,7 @@ import (
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/common/securityutil"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/task/repository/repoerrors"
"github.com/kandev/kandev/internal/watchreset"
)
@@ -64,6 +62,13 @@ type Service struct {
// post-construction via SetWorkspaceAuthorizer. Nil (unit tests, auth
// disabled) means unscoped — every workspace is visible, as before auth.
workspaceAuthorizer func(context.Context, string) error
// oauthStates holds pending OAuth CSRF states in memory.
oauthStates *mcpStateManager
// oauthMu provides per-workspace locks for token refresh to prevent
// concurrent refreshes from racing on the rotating refresh token.
oauthMu *perWorkspaceMutex
// oauthRedirectURI is the fully-qualified callback URL. Set at Provide time.
oauthRedirectURI string
}
// SetWorkspaceAuthorizer installs the per-user workspace access boundary applied
@@ -133,8 +138,12 @@ func (s *Service) MockClient() *MockClient {
// tests can inject fakes without touching HTTP.
type ClientFactory func(cfg *JiraConfig, secret string) Client
// DefaultClientFactory returns a real CloudClient.
// DefaultClientFactory returns a real CloudClient for token-based auth, or
// MCPClient for OAuth auth (which routes through the MCP server).
func DefaultClientFactory(cfg *JiraConfig, secret string) Client {
if cfg.AuthMethod == AuthMethodOAuth {
return NewMCPClient(secret, cfg.CloudID, cfg.WorkspaceID, cfg.SiteURL)
}
return NewCloudClient(cfg, secret)
}
@@ -145,11 +154,13 @@ func NewService(store *Store, secrets SecretStore, clientFn ClientFactory, log *
clientFn = DefaultClientFactory
}
return &Service{
store: store,
secrets: secrets,
log: log,
clientFn: clientFn,
clients: make(map[string]Client),
store: store,
secrets: secrets,
log: log,
clientFn: clientFn,
clients: make(map[string]Client),
oauthStates: newMCPStateManager(),
oauthMu: newPerWorkspaceMutex(),
}
}
@@ -193,6 +204,13 @@ func (s *Service) GetConfigForWorkspace(ctx context.Context, workspaceID string)
cfg.SecretExpiresAt = parseSessionCookieExpiry(secret)
}
}
// For OAuth, HasSecret should reflect whether the OAuth access token exists.
if cfg.AuthMethod == AuthMethodOAuth && s.secrets != nil {
oauthExists, oauthErr := s.secrets.Exists(ctx, OAuthAccessTokenKeyForWorkspace(workspaceID))
if oauthErr == nil {
cfg.HasSecret = oauthExists
}
}
return cfg, nil
}
@@ -227,6 +245,19 @@ func (s *Service) SetConfigForWorkspace(ctx context.Context, workspaceID string,
AuthMethod: req.AuthMethod,
InstanceType: req.InstanceType,
DefaultProjectKey: req.DefaultProjectKey,
ClientID: req.ClientID,
}
// Preserve cloudId for OAuth — it's resolved during the OAuth flow and
// shouldn't be overwritten by a regular config save.
if req.AuthMethod == AuthMethodOAuth {
existing, err := s.store.GetConfigForWorkspace(ctx, workspaceID)
if err != nil {
return nil, fmt.Errorf("read existing config: %w", err)
}
if existing != nil {
cfg.CloudID = existing.CloudID
cfg.TokenExpiresAt = existing.TokenExpiresAt
}
}
if err := s.store.UpsertConfigForWorkspace(ctx, workspaceID, cfg); err != nil {
return nil, fmt.Errorf("upsert jira config: %w", err)
@@ -236,6 +267,9 @@ func (s *Service) SetConfigForWorkspace(ctx context.Context, workspaceID string,
return nil, fmt.Errorf("store jira secret: %w", err)
}
}
// OAuth: client_id is stored in the config row (UpsertConfigForWorkspace
// above). No client_secret — the flow uses PKCE with
// token_endpoint_auth_method="none".
s.invalidateClient(workspaceID)
// Probe asynchronously so a slow Atlassian doesn't stall the save response.
// RecordAuthHealth manages its own probe timeout, so a fresh
@@ -272,6 +306,14 @@ func (s *Service) DeleteConfigForWorkspace(ctx context.Context, workspaceID stri
if err := s.secrets.Delete(ctx, SecretKeyForWorkspace(workspaceID)); err != nil {
s.log.Warn("jira: secret delete failed", zap.Error(err))
}
// Clean up OAuth secrets if they exist. Log failures so a stale
// refresh token left in the store after config removal is visible.
if err := s.secrets.Delete(ctx, OAuthAccessTokenKeyForWorkspace(workspaceID)); err != nil {
s.log.Warn("jira: oauth access token delete failed", zap.Error(err))
}
if err := s.secrets.Delete(ctx, OAuthRefreshTokenKeyForWorkspace(workspaceID)); err != nil {
s.log.Warn("jira: oauth refresh token delete failed", zap.Error(err))
}
}
s.invalidateClient(workspaceID)
return nil
@@ -307,7 +349,16 @@ func (s *Service) TestConnectionForWorkspace(ctx context.Context, workspaceID st
if err != nil {
return &TestConnectionResult{OK: false, Error: err.Error()}, nil
}
// Set WorkspaceID on the config so MCPClient/SetRefresher get the right ID.
cfg.WorkspaceID = workspaceID
client := s.clientFn(cfg, secret)
// Wire the OAuth token refresher for test-connection clients too,
// so an expired access token is auto-refreshed during testing.
if cfg.AuthMethod == AuthMethodOAuth {
if mc, ok := client.(*MCPClient); ok {
mc.SetRefresher(workspaceID, s)
}
}
return client.TestAuth(ctx)
}
@@ -538,7 +589,11 @@ func (s *Service) clientFor(ctx context.Context, workspaceID string) (Client, er
}
secret := ""
if s.secrets != nil {
secret, err = s.revealSecret(ctx, workspaceID)
if cfg.AuthMethod == AuthMethodOAuth {
secret, err = s.secrets.Reveal(ctx, OAuthAccessTokenKeyForWorkspace(workspaceID))
} else {
secret, err = s.revealSecret(ctx, workspaceID)
}
if err != nil {
// Don't conflate a transient secret-store failure with "user never
// configured Jira". The UI gates on ErrNotConfigured to render a
@@ -551,6 +606,18 @@ func (s *Service) clientFor(ctx context.Context, workspaceID string) (Client, er
}
}
client := s.clientFn(cfg, secret)
// Wire the OAuth token refresher so clients can auto-refresh on 401.
// This covers both the cached path (clientFor) and the test-connection
// path (resolveCredentials → clientFn) — both create a fresh client that
// needs the refresher wired.
if cfg.AuthMethod == AuthMethodOAuth {
if cc, ok := client.(*CloudClient); ok {
cc.SetRefresher(workspaceID, s)
}
if mc, ok := client.(*MCPClient); ok {
mc.SetRefresher(workspaceID, s)
}
}
s.mu.Lock()
defer s.mu.Unlock()
// Re-check: another caller may have populated the cache while we were
@@ -576,91 +643,6 @@ func (s *Service) invalidateClient(workspaceID string) {
s.mu.Unlock()
}
// resolveCredentials picks the credentials to test: if the request carries a
// secret, use it inline (pre-save); otherwise fall back to the stored secret
// (post-save re-test).
func (s *Service) resolveCredentials(ctx context.Context, workspaceID string, req *SetConfigRequest) (*JiraConfig, string, error) {
cfg := &JiraConfig{
SiteURL: normalizeSiteURL(req.SiteURL),
Email: req.Email,
AuthMethod: req.AuthMethod,
InstanceType: req.InstanceType,
}
var secret string
if req.Secret != "" {
secret = req.Secret
} else {
if s.secrets == nil {
return nil, "", errors.New("no secret store configured")
}
var err error
secret, err = s.revealSecret(ctx, workspaceID)
if err != nil {
// Don't conflate a transient secret-store failure with "no token
// stored". Surfacing the real error gives the user a path to retry
// instead of telling them to paste a token they already have.
s.log.Warn("jira: secret reveal failed", zap.Error(err))
return nil, "", fmt.Errorf("read jira secret: %w", err)
}
if secret == "" {
return nil, "", errors.New("no token stored — paste one to test")
}
}
// Merge with persisted config only when the caller didn't pass enough on
// its own. A fully-specified pre-save TestConnection request needs no DB
// read — keeping the call gated avoids a transient DB error blocking auth
// testing for a request that doesn't depend on persisted state. When a
// read does happen, surface its error so a real DB failure isn't masked
// as an opaque "missing field" validation message later on.
needsStoredConfig := cfg.SiteURL == "" ||
cfg.AuthMethod == "" ||
cfg.InstanceType == "" ||
(cfg.AuthMethod == AuthMethodAPIToken && cfg.Email == "")
if needsStoredConfig {
if err := s.fillConfigFromStored(ctx, workspaceID, cfg); err != nil {
return nil, "", err
}
}
if cfg.InstanceType == "" {
cfg.InstanceType = InstanceTypeCloud
}
// Run the same auth/instance check the save path runs, so TestConnection
// reports invalid combos (e.g. pat+cloud, api_token+server) with a clear
// validation error instead of letting Jira return an opaque 401.
if err := validateAuthInstance(cfg.AuthMethod, cfg.InstanceType, cfg.Email); err != nil {
return nil, "", err
}
return cfg, secret, nil
}
// fillConfigFromStored reads the persisted singleton config and copies any
// fields the caller left blank onto cfg. A real read failure is returned so
// callers don't mask DB errors as opaque "missing field" validation errors;
// a missing row (stored == nil) is treated as "nothing to fill" rather than
// an error so first-time TestConnection requests still go through.
func (s *Service) fillConfigFromStored(ctx context.Context, workspaceID string, cfg *JiraConfig) error {
stored, err := s.store.GetConfigForWorkspace(ctx, workspaceID)
if err != nil {
return fmt.Errorf("read jira config: %w", err)
}
if stored == nil {
return nil
}
if cfg.SiteURL == "" {
cfg.SiteURL = stored.SiteURL
}
if cfg.Email == "" {
cfg.Email = stored.Email
}
if cfg.AuthMethod == "" {
cfg.AuthMethod = stored.AuthMethod
}
if cfg.InstanceType == "" {
cfg.InstanceType = stored.InstanceType
}
return nil
}
func (s *Service) defaultWorkspaceID() (string, error) {
return s.store.defaultWorkspaceID()
}
@@ -726,7 +708,17 @@ func validateConfigRequest(req *SetConfigRequest) error {
default:
return fmt.Errorf("unknown instance type: %q", req.InstanceType)
}
return validateAuthInstance(req.AuthMethod, req.InstanceType, req.Email)
if err := validateAuthInstance(req.AuthMethod, req.InstanceType, req.Email); err != nil {
return err
}
// OAuth requires a client_id at config-save time. The client_secret is
// stored during the OAuth connect flow, not here.
if req.AuthMethod == AuthMethodOAuth && req.ClientID == "" {
return errors.New("clientId required for oauth auth")
}
// OAuth client_id is populated by the dynamic registration flow, not
// by the user — so we don't validate it here for non-oauth methods.
return nil
}
// validateAuthInstance enforces the supported (auth method, instance type)
@@ -759,6 +751,12 @@ func validateAuthInstance(authMethod, instanceType, email string) error {
if instanceType != InstanceTypeCloud {
return errors.New("session_cookie auth is supported only on Atlassian Cloud — use pat for Server/DC")
}
case AuthMethodOAuth:
// OAuth 2.0 (3LO) is an Atlassian Cloud feature. The authorization,
// token, and accessible-resources endpoints are Cloud-only.
if instanceType != InstanceTypeCloud {
return errors.New("oauth auth is supported only on Atlassian Cloud — use pat for Server/DC")
}
default:
return fmt.Errorf("unknown auth method: %q", authMethod)
}
@@ -793,286 +791,6 @@ func (s *Service) SetEventBus(eb bus.EventBus) {
// ErrIssueWatchNotFound is returned when GetIssueWatch's caller looks up an ID
// that doesn't exist. Callers map this to HTTP 404.
var ErrIssueWatchNotFound = errors.New("jira: issue watch not found")
// CreateIssueWatch validates the request and persists a new watch row.
func (s *Service) CreateIssueWatch(ctx context.Context, req *CreateIssueWatchRequest) (*IssueWatch, error) {
if err := validateIssueWatchCreate(req); err != nil {
return nil, err
}
// WorkspaceID comes from the request body, which the query-only integration
// middleware never authorizes. Without this a caller could plant a watch in
// another user's workspace that repeatedly spawns tasks with that user's
// credentials and an attacker-supplied JQL/prompt.
if err := s.authorizeWorkspaceAccess(ctx, req.WorkspaceID); err != nil {
return nil, err
}
repositoryID, baseBranch, err := s.resolveRepositoryBinding(ctx, req.WorkspaceID, req.RepositoryID, req.BaseBranch)
if err != nil {
return nil, err
}
w := &IssueWatch{
WorkspaceID: req.WorkspaceID,
WorkflowID: req.WorkflowID,
WorkflowStepID: req.WorkflowStepID,
RepositoryID: repositoryID,
BaseBranch: baseBranch,
JQL: strings.TrimSpace(req.JQL),
AgentProfileID: req.AgentProfileID,
ExecutorProfileID: req.ExecutorProfileID,
Prompt: req.Prompt,
PollIntervalSeconds: req.PollIntervalSeconds,
MaxInflightTasks: req.MaxInflightTasks,
Enabled: true,
}
if req.Enabled != nil {
w.Enabled = *req.Enabled
}
if err := s.store.CreateIssueWatch(ctx, w); err != nil {
return nil, err
}
return w, nil
}
// ListIssueWatches returns the watches configured for a workspace.
func (s *Service) ListIssueWatches(ctx context.Context, workspaceID string) ([]*IssueWatch, error) {
if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil {
return nil, err
}
return s.store.ListIssueWatches(ctx, workspaceID)
}
// ListAllIssueWatches returns every watch the caller may see. For a scoped
// caller that is only their own workspaces' watches; for an identity-less
// internal caller (unscoped) it is every watch, as before auth. The unscoped
// list form is only reachable when workspace_id is omitted, which the query-only
// integration middleware does not authorize — without this filter it leaked
// every workspace's watch config (JQL, repo/agent/profile IDs, spawn prompt).
func (s *Service) ListAllIssueWatches(ctx context.Context) ([]*IssueWatch, error) {
watches, err := s.store.ListAllIssueWatches(ctx)
if err != nil {
return nil, err
}
return s.filterIssueWatchesByAccess(ctx, watches)
}
// filterIssueWatchesByAccess keeps only watches whose workspace the caller may
// access. Access decisions are memoized per workspace so a long list costs one
// authorize call per distinct workspace, not one per watch. Only an
// ErrWorkspaceNotFound denial drops a watch; any other authorizer error (a
// transient DB failure, say) is propagated so the caller sees the failure
// rather than a silently truncated 200.
func (s *Service) filterIssueWatchesByAccess(ctx context.Context, watches []*IssueWatch) ([]*IssueWatch, error) {
decision := make(map[string]bool)
visible := make([]*IssueWatch, 0, len(watches))
for _, w := range watches {
allowed, seen := decision[w.WorkspaceID]
if !seen {
switch err := s.authorizeWorkspaceAccess(ctx, w.WorkspaceID); {
case err == nil:
allowed = true
case errors.Is(err, repoerrors.ErrWorkspaceNotFound):
allowed = false
default:
return nil, err
}
decision[w.WorkspaceID] = allowed
}
if allowed {
visible = append(visible, w)
}
}
return visible, nil
}
// GetIssueWatch returns a single watch by ID or ErrIssueWatchNotFound.
func (s *Service) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, error) {
w, err := s.store.GetIssueWatch(ctx, id)
if err != nil {
return nil, err
}
if w == nil {
return nil, ErrIssueWatchNotFound
}
return w, nil
}
// UpdateIssueWatch applies a partial update by patching only the fields the
// caller explicitly set, then persists the result.
func (s *Service) UpdateIssueWatch(ctx context.Context, id string, req *UpdateIssueWatchRequest) (*IssueWatch, error) {
w, err := s.GetIssueWatch(ctx, id)
if err != nil {
return nil, err
}
// Authorize off the loaded row's workspace — the watch ID alone reveals
// nothing about ownership, so a caller must not mutate another user's watch.
if err := s.authorizeWorkspaceAccess(ctx, w.WorkspaceID); err != nil {
return nil, err
}
prevRepositoryID, prevBaseBranch := w.RepositoryID, w.BaseBranch
applyIssueWatchPatch(w, req)
// Empty-string PATCH writes (`{"workflowId": ""}` etc.) bypass the nil
// guard in applyIssueWatchPatch — Go's JSON decoder returns a non-nil
// pointer to "". Guard the post-patch state so a PATCH can't strip a row
// of the fields the orchestrator needs to create tasks.
if w.JQL == "" {
return nil, fmt.Errorf("%w: jql cannot be empty", ErrInvalidConfig)
}
if w.WorkflowID == "" || w.WorkflowStepID == "" {
return nil, fmt.Errorf("%w: workflowId and workflowStepId cannot be empty", ErrInvalidConfig)
}
if err := validatePollInterval(w.PollIntervalSeconds); err != nil {
return nil, err
}
if err := validateMaxInflightTasks(w.MaxInflightTasks); err != nil {
return nil, err
}
// Only validate/resolve the binding when its value actually changed. The
// dialog re-sends repositoryId/baseBranch on every PATCH, and an unchanged
// binding whose repo was since soft-deleted must not block edits to other
// fields (prompt, JQL, …).
if w.RepositoryID != prevRepositoryID || w.BaseBranch != prevBaseBranch {
repositoryID, baseBranch, err := s.resolveRepositoryBinding(ctx, w.WorkspaceID, w.RepositoryID, w.BaseBranch)
if err != nil {
return nil, err
}
w.RepositoryID = repositoryID
w.BaseBranch = baseBranch
}
if err := s.store.UpdateIssueWatch(ctx, w); err != nil {
return nil, err
}
return w, nil
}
// DeleteIssueWatch removes the watch and its dedup rows. Idempotent: deleting
// a missing ID is a silent success.
func (s *Service) DeleteIssueWatch(ctx context.Context, id string) error {
return s.store.DeleteIssueWatch(ctx, id)
}
// jiraIssueWatchResetter is the watchreset.Resetter adapter for a single
// Jira issue watch. Closes over the store and watch ID so the shared
// watchreset.Run helper stays integration-agnostic.
type jiraIssueWatchResetter struct {
store *Store
watchID string
}
func (r *jiraIssueWatchResetter) ListTaskIDs(ctx context.Context) ([]string, error) {
return r.store.ListIssueWatchTaskIDs(ctx, r.watchID)
}
func (r *jiraIssueWatchResetter) Clear(ctx context.Context) error {
return r.store.ResetIssueWatchState(ctx, r.watchID)
}
// PreviewResetIssueWatch returns how many tasks ResetIssueWatch would
// cascade-delete. Used by the frontend to populate the confirmation dialog.
func (s *Service) PreviewResetIssueWatch(ctx context.Context, watchID string) (int, error) {
return watchreset.Preview(ctx, &jiraIssueWatchResetter{store: s.store, watchID: watchID})
}
// ResetIssueWatch is destructive: cascade-deletes every task previously
// created by the watch (including archived), wipes the per-watch dedup
// rows, and nulls last_polled_at so the next poll re-imports every
// currently-matching ticket. Returns the count of tasks deleted.
func (s *Service) ResetIssueWatch(ctx context.Context, watchID string) (int, error) {
s.mu.Lock()
td := s.taskDeleter
s.mu.Unlock()
if td == nil {
return 0, errors.New("jira: task deleter not wired; reset unavailable")
}
res, err := watchreset.Run(ctx,
&jiraIssueWatchResetter{store: s.store, watchID: watchID},
td, s.log)
return res.TasksDeleted, err
}
// CheckIssueWatch runs the watch's JQL once and returns the tickets that
// haven't been turned into tasks yet. last_polled_at is stamped regardless of
// whether the search succeeded — a failing search still counts as "we tried"
// so the UI can show liveness.
func (s *Service) CheckIssueWatch(ctx context.Context, w *IssueWatch) ([]*JiraTicket, error) {
defer s.stampLastPolled(w.ID)
client, err := s.clientFor(ctx, w.WorkspaceID)
if err != nil {
return nil, err
}
// Single page is enough per tick: the next poll picks up anything that
// overflowed maxResults. Pagination would let one chatty workspace starve
// the others without any real freshness benefit.
res, err := client.SearchTickets(ctx, w.JQL, "", issueWatchSearchPageSize)
if err != nil {
return nil, err
}
// Bulk-fetch the dedup set once per call instead of one query per ticket —
// a broad JQL can return up to issueWatchSearchPageSize tickets, so a
// per-ticket round-trip would multiply the per-tick cost.
seen, err := s.store.ListSeenIssueKeys(ctx, w.ID)
if err != nil {
s.log.Warn("jira: dedup set fetch failed",
zap.String("watch_id", w.ID), zap.Error(err))
seen = nil // fall through: a missing dedup set is safer than dropping all tickets
}
out := make([]*JiraTicket, 0, len(res.Tickets))
for i := range res.Tickets {
t := res.Tickets[i]
if _, ok := seen[t.Key]; ok {
continue
}
out = append(out, &t)
}
return out, nil
}
// stampLastPolled writes the current timestamp onto the watch row using a
// fresh background context with a short write deadline. The caller's ctx may
// already be cancelled (typical at poller shutdown), which would cause the
// DB write to fail silently and the watch would look "never polled" on the
// next backend restart. Mirrors the pattern in RecordAuthHealth.
func (s *Service) stampLastPolled(watchID string) {
ctx, cancel := context.WithTimeout(context.Background(), authHealthWriteTimeout)
defer cancel()
if err := s.store.UpdateIssueWatchLastPolled(ctx, watchID, time.Now().UTC()); err != nil {
s.log.Warn("jira: update last_polled_at failed",
zap.String("watch_id", watchID), zap.Error(err))
}
}
// publishNewJiraIssueEvent emits the orchestrator-facing event for one freshly
// observed ticket. No-op when the event bus is not wired (tests, early boot).
func (s *Service) publishNewJiraIssueEvent(ctx context.Context, w *IssueWatch, ticket *JiraTicket) {
s.mu.Lock()
eb := s.eventBus
s.mu.Unlock()
if eb == nil {
return
}
evt := bus.NewEvent(events.JiraNewIssue, "jira", &NewJiraIssueEvent{
IssueWatchID: w.ID,
WorkspaceID: w.WorkspaceID,
WorkflowID: w.WorkflowID,
WorkflowStepID: w.WorkflowStepID,
RepositoryID: w.RepositoryID,
BaseBranch: w.BaseBranch,
AgentProfileID: w.AgentProfileID,
ExecutorProfileID: w.ExecutorProfileID,
Prompt: w.Prompt,
MaxInflightTasks: w.MaxInflightTasks,
Issue: ticket,
})
if err := eb.Publish(ctx, events.JiraNewIssue, evt); err != nil {
s.log.Debug("jira: publish new issue event failed",
zap.String("watch_id", w.ID), zap.String("issue_key", ticket.Key), zap.Error(err))
}
}
// issueWatchSearchPageSize caps how many tickets a single CheckIssueWatch call
// pulls from JIRA. 50 is well under the API's 100-result limit and keeps the
// per-tick cost bounded even for very broad JQL queries.
const issueWatchSearchPageSize = 50
// MinIssueWatchPollInterval / MaxIssueWatchPollInterval bound the per-watch
// JQL re-run cadence. The lower limit protects Atlassian from rapid-fire
@@ -313,3 +313,34 @@ func TestService_CheckIssueWatch_StampsLastPolledOnError(t *testing.T) {
t.Error("expected last_polled_at stamped even on search failure (liveness signal)")
}
}
func TestService_CheckIssueWatchReturnsDedupLookupError(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
if err := f.store.UpsertConfigForWorkspace(ctx, "ws-1", &JiraConfig{
WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e",
AuthMethod: AuthMethodAPIToken, InstanceType: InstanceTypeCloud,
}); err != nil {
t.Fatalf("seed config: %v", err)
}
if err := f.secrets.Set(ctx, SecretKeyForWorkspace("ws-1"), "token", "tok"); err != nil {
t.Fatalf("seed secret: %v", err)
}
if _, err := f.svc.clientFor(ctx, "ws-1"); err != nil {
t.Fatalf("prime client: %v", err)
}
f.client.withSearchResults([]JiraTicket{{Key: "PROJ-1"}})
w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step", JQL: "project = PROJ",
})
if err != nil {
t.Fatalf("create watch: %v", err)
}
if _, err := f.store.db.ExecContext(ctx, "DROP TABLE jira_issue_watch_tasks"); err != nil {
t.Fatalf("break dedup lookup: %v", err)
}
if _, err := f.svc.CheckIssueWatch(ctx, w); err == nil {
t.Fatal("expected dedup lookup failure to stop ticket delivery")
}
}
+116
View File
@@ -0,0 +1,116 @@
package jira
import (
"context"
"errors"
"fmt"
"go.uber.org/zap"
)
// RevealAccessToken reads the current OAuth access token from the secret store.
// Implements the TokenRefresher interface for CloudClient/MCPClient auto-refresh.
func (s *Service) RevealAccessToken(ctx context.Context, workspaceID string) (string, error) {
if s.secrets == nil {
return "", nil
}
return s.secrets.Reveal(ctx, OAuthAccessTokenKeyForWorkspace(workspaceID))
}
// SetOAuthRedirectURI sets the OAuth callback URL. Called at Provide time.
func (s *Service) SetOAuthRedirectURI(uri string) {
if s != nil {
s.oauthRedirectURI = uri
}
}
// resolveSecret picks the secret to test: inline from the request if present,
// or from the stored secret/OAuth access token otherwise.
func (s *Service) resolveSecret(ctx context.Context, workspaceID string, req *SetConfigRequest) (string, error) {
if req.Secret != "" {
return req.Secret, nil
}
if s.secrets == nil {
return "", errors.New("no secret store configured")
}
var secret string
var err error
if req.AuthMethod == AuthMethodOAuth {
secret, err = s.secrets.Reveal(ctx, OAuthAccessTokenKeyForWorkspace(workspaceID))
} else {
secret, err = s.revealSecret(ctx, workspaceID)
}
if err != nil {
s.log.Warn("jira: secret reveal failed", zap.Error(err))
return "", fmt.Errorf("read jira secret: %w", err)
}
if secret == "" {
return "", errors.New("no token stored - paste one to test")
}
return secret, nil
}
// resolveCredentials picks the credentials to test: if the request carries a
// secret, use it inline (pre-save); otherwise fall back to the stored secret
// (post-save re-test). For OAuth, reads the OAuth access token instead of
// the legacy secret key.
func (s *Service) resolveCredentials(ctx context.Context, workspaceID string, req *SetConfigRequest) (*JiraConfig, string, error) {
cfg := &JiraConfig{
SiteURL: normalizeSiteURL(req.SiteURL),
Email: req.Email,
AuthMethod: req.AuthMethod,
InstanceType: req.InstanceType,
}
secret, err := s.resolveSecret(ctx, workspaceID, req)
if err != nil {
return nil, "", err
}
needsStoredConfig := cfg.SiteURL == "" ||
cfg.AuthMethod == "" ||
cfg.InstanceType == "" ||
cfg.AuthMethod == AuthMethodOAuth ||
(cfg.AuthMethod == AuthMethodAPIToken && cfg.Email == "")
if needsStoredConfig {
if err := s.fillConfigFromStored(ctx, workspaceID, cfg); err != nil {
return nil, "", err
}
}
if cfg.InstanceType == "" {
cfg.InstanceType = InstanceTypeCloud
}
if err := validateAuthInstance(cfg.AuthMethod, cfg.InstanceType, cfg.Email); err != nil {
return nil, "", err
}
return cfg, secret, nil
}
// fillConfigFromStored reads the persisted config and copies any fields the
// caller left blank onto cfg. Includes OAuth fields (ClientID, CloudID).
func (s *Service) fillConfigFromStored(ctx context.Context, workspaceID string, cfg *JiraConfig) error {
stored, err := s.store.GetConfigForWorkspace(ctx, workspaceID)
if err != nil {
return fmt.Errorf("read jira config: %w", err)
}
if stored == nil {
return nil
}
if cfg.SiteURL == "" {
cfg.SiteURL = stored.SiteURL
}
if cfg.Email == "" {
cfg.Email = stored.Email
}
if cfg.AuthMethod == "" {
cfg.AuthMethod = stored.AuthMethod
}
if cfg.InstanceType == "" {
cfg.InstanceType = stored.InstanceType
}
if cfg.ClientID == "" {
cfg.ClientID = stored.ClientID
}
if cfg.CloudID == "" {
cfg.CloudID = stored.CloudID
}
return nil
}
+311
View File
@@ -0,0 +1,311 @@
package jira
import (
"context"
"errors"
"fmt"
"strings"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/task/repository/repoerrors"
"github.com/kandev/kandev/internal/watchreset"
)
var ErrIssueWatchNotFound = errors.New("jira: issue watch not found")
// CreateIssueWatch validates the request and persists a new watch row.
func (s *Service) CreateIssueWatch(ctx context.Context, req *CreateIssueWatchRequest) (*IssueWatch, error) {
if err := validateIssueWatchCreate(req); err != nil {
return nil, err
}
// WorkspaceID comes from the request body, which the query-only integration
// middleware never authorizes. Without this a caller could plant a watch in
// another user's workspace that repeatedly spawns tasks with that user's
// credentials and an attacker-supplied JQL/prompt.
if err := s.authorizeWorkspaceAccess(ctx, req.WorkspaceID); err != nil {
return nil, err
}
repositoryID, baseBranch, err := s.resolveRepositoryBinding(ctx, req.WorkspaceID, req.RepositoryID, req.BaseBranch)
if err != nil {
return nil, err
}
w := &IssueWatch{
WorkspaceID: req.WorkspaceID,
WorkflowID: req.WorkflowID,
WorkflowStepID: req.WorkflowStepID,
RepositoryID: repositoryID,
BaseBranch: baseBranch,
JQL: strings.TrimSpace(req.JQL),
AgentProfileID: req.AgentProfileID,
ExecutorProfileID: req.ExecutorProfileID,
Prompt: req.Prompt,
PollIntervalSeconds: req.PollIntervalSeconds,
MaxInflightTasks: req.MaxInflightTasks,
Enabled: true,
}
if req.Enabled != nil {
w.Enabled = *req.Enabled
}
if err := s.store.CreateIssueWatch(ctx, w); err != nil {
return nil, err
}
return w, nil
}
// ListIssueWatches returns the watches configured for a workspace.
func (s *Service) ListIssueWatches(ctx context.Context, workspaceID string) ([]*IssueWatch, error) {
if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil {
return nil, err
}
return s.store.ListIssueWatches(ctx, workspaceID)
}
// ListAllIssueWatches returns every watch the caller may see. For a scoped
// caller that is only their own workspaces' watches; for an identity-less
// internal caller (unscoped) it is every watch, as before auth. The unscoped
// list form is only reachable when workspace_id is omitted, which the query-only
// integration middleware does not authorize — without this filter it leaked
// every workspace's watch config (JQL, repo/agent/profile IDs, spawn prompt).
func (s *Service) ListAllIssueWatches(ctx context.Context) ([]*IssueWatch, error) {
watches, err := s.store.ListAllIssueWatches(ctx)
if err != nil {
return nil, err
}
return s.filterIssueWatchesByAccess(ctx, watches)
}
// filterIssueWatchesByAccess keeps only watches whose workspace the caller may
// access. Access decisions are memoized per workspace so a long list costs one
// authorize call per distinct workspace, not one per watch. Only an
// ErrWorkspaceNotFound denial drops a watch; any other authorizer error (a
// transient DB failure, say) is propagated so the caller sees the failure
// rather than a silently truncated 200.
func (s *Service) filterIssueWatchesByAccess(ctx context.Context, watches []*IssueWatch) ([]*IssueWatch, error) {
decision := make(map[string]bool)
visible := make([]*IssueWatch, 0, len(watches))
for _, w := range watches {
allowed, seen := decision[w.WorkspaceID]
if !seen {
switch err := s.authorizeWorkspaceAccess(ctx, w.WorkspaceID); {
case err == nil:
allowed = true
case errors.Is(err, repoerrors.ErrWorkspaceNotFound):
allowed = false
default:
return nil, err
}
decision[w.WorkspaceID] = allowed
}
if allowed {
visible = append(visible, w)
}
}
return visible, nil
}
// GetIssueWatch returns a single watch by ID or ErrIssueWatchNotFound.
func (s *Service) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, error) {
w, err := s.store.GetIssueWatch(ctx, id)
if err != nil {
return nil, err
}
if w == nil {
return nil, ErrIssueWatchNotFound
}
if err := s.authorizeWorkspaceAccess(ctx, w.WorkspaceID); err != nil {
return nil, err
}
return w, nil
}
// UpdateIssueWatch applies a partial update by patching only the fields the
// caller explicitly set, then persists the result.
func (s *Service) UpdateIssueWatch(ctx context.Context, id string, req *UpdateIssueWatchRequest) (*IssueWatch, error) {
w, err := s.GetIssueWatch(ctx, id)
if err != nil {
return nil, err
}
prevRepositoryID, prevBaseBranch := w.RepositoryID, w.BaseBranch
applyIssueWatchPatch(w, req)
// Empty-string PATCH writes (`{"workflowId": ""}` etc.) bypass the nil
// guard in applyIssueWatchPatch — Go's JSON decoder returns a non-nil
// pointer to "". Guard the post-patch state so a PATCH can't strip a row
// of the fields the orchestrator needs to create tasks.
if w.JQL == "" {
return nil, fmt.Errorf("%w: jql cannot be empty", ErrInvalidConfig)
}
if w.WorkflowID == "" || w.WorkflowStepID == "" {
return nil, fmt.Errorf("%w: workflowId and workflowStepId cannot be empty", ErrInvalidConfig)
}
if err := validatePollInterval(w.PollIntervalSeconds); err != nil {
return nil, err
}
if err := validateMaxInflightTasks(w.MaxInflightTasks); err != nil {
return nil, err
}
// Only validate/resolve the binding when its value actually changed. The
// dialog re-sends repositoryId/baseBranch on every PATCH, and an unchanged
// binding whose repo was since soft-deleted must not block edits to other
// fields (prompt, JQL, …).
if w.RepositoryID != prevRepositoryID || w.BaseBranch != prevBaseBranch {
repositoryID, baseBranch, err := s.resolveRepositoryBinding(ctx, w.WorkspaceID, w.RepositoryID, w.BaseBranch)
if err != nil {
return nil, err
}
w.RepositoryID = repositoryID
w.BaseBranch = baseBranch
}
if err := s.store.UpdateIssueWatch(ctx, w); err != nil {
return nil, err
}
return w, nil
}
// DeleteIssueWatch removes the watch and its dedup rows. Idempotent: deleting
// a missing ID is a silent success.
func (s *Service) DeleteIssueWatch(ctx context.Context, id string) error {
w, err := s.store.GetIssueWatch(ctx, id)
if err != nil {
return err
}
if w == nil {
return nil
}
if err := s.authorizeWorkspaceAccess(ctx, w.WorkspaceID); err != nil {
return err
}
return s.store.DeleteIssueWatch(ctx, id)
}
// jiraIssueWatchResetter is the watchreset.Resetter adapter for a single
// Jira issue watch. Closes over the store and watch ID so the shared
// watchreset.Run helper stays integration-agnostic.
type jiraIssueWatchResetter struct {
store *Store
watchID string
}
func (r *jiraIssueWatchResetter) ListTaskIDs(ctx context.Context) ([]string, error) {
return r.store.ListIssueWatchTaskIDs(ctx, r.watchID)
}
func (r *jiraIssueWatchResetter) Clear(ctx context.Context) error {
return r.store.ResetIssueWatchState(ctx, r.watchID)
}
// PreviewResetIssueWatch returns how many tasks ResetIssueWatch would
// cascade-delete. Used by the frontend to populate the confirmation dialog.
func (s *Service) PreviewResetIssueWatch(ctx context.Context, watchID string) (int, error) {
if _, err := s.GetIssueWatch(ctx, watchID); err != nil {
return 0, err
}
return watchreset.Preview(ctx, &jiraIssueWatchResetter{store: s.store, watchID: watchID})
}
// ResetIssueWatch is destructive: cascade-deletes every task previously
// created by the watch (including archived), wipes the per-watch dedup
// rows, and nulls last_polled_at so the next poll re-imports every
// currently-matching ticket. Returns the count of tasks deleted.
func (s *Service) ResetIssueWatch(ctx context.Context, watchID string) (int, error) {
if _, err := s.GetIssueWatch(ctx, watchID); err != nil {
return 0, err
}
s.mu.Lock()
td := s.taskDeleter
s.mu.Unlock()
if td == nil {
return 0, errors.New("jira: task deleter not wired; reset unavailable")
}
res, err := watchreset.Run(ctx,
&jiraIssueWatchResetter{store: s.store, watchID: watchID},
td, s.log)
return res.TasksDeleted, err
}
// CheckIssueWatch runs the watch's JQL once and returns the tickets that
// haven't been turned into tasks yet. last_polled_at is stamped regardless of
// whether the search succeeded — a failing search still counts as "we tried"
// so the UI can show liveness.
func (s *Service) CheckIssueWatch(ctx context.Context, w *IssueWatch) ([]*JiraTicket, error) {
defer s.stampLastPolled(w.ID)
client, err := s.clientFor(ctx, w.WorkspaceID)
if err != nil {
return nil, err
}
// Single page is enough per tick: the next poll picks up anything that
// overflowed maxResults. Pagination would let one chatty workspace starve
// the others without any real freshness benefit.
res, err := client.SearchTickets(ctx, w.JQL, "", issueWatchSearchPageSize)
if err != nil {
return nil, err
}
// Bulk-fetch the dedup set once per call instead of one query per ticket —
// a broad JQL can return up to issueWatchSearchPageSize tickets, so a
// per-ticket round-trip would multiply the per-tick cost.
seen, err := s.store.ListSeenIssueKeys(ctx, w.ID)
if err != nil {
s.log.Warn("jira: dedup set fetch failed",
zap.String("watch_id", w.ID), zap.Error(err))
return nil, fmt.Errorf("load Jira issue-watch dedup set: %w", err)
}
out := make([]*JiraTicket, 0, len(res.Tickets))
for i := range res.Tickets {
t := res.Tickets[i]
if _, ok := seen[t.Key]; ok {
continue
}
out = append(out, &t)
}
return out, nil
}
// stampLastPolled writes the current timestamp onto the watch row using a
// fresh background context with a short write deadline. The caller's ctx may
// already be cancelled (typical at poller shutdown), which would cause the
// DB write to fail silently and the watch would look "never polled" on the
// next backend restart. Mirrors the pattern in RecordAuthHealth.
func (s *Service) stampLastPolled(watchID string) {
ctx, cancel := context.WithTimeout(context.Background(), authHealthWriteTimeout)
defer cancel()
if err := s.store.UpdateIssueWatchLastPolled(ctx, watchID, time.Now().UTC()); err != nil {
s.log.Warn("jira: update last_polled_at failed",
zap.String("watch_id", watchID), zap.Error(err))
}
}
// publishNewJiraIssueEvent emits the orchestrator-facing event for one freshly
// observed ticket. No-op when the event bus is not wired (tests, early boot).
func (s *Service) publishNewJiraIssueEvent(ctx context.Context, w *IssueWatch, ticket *JiraTicket) {
s.mu.Lock()
eb := s.eventBus
s.mu.Unlock()
if eb == nil {
return
}
evt := bus.NewEvent(events.JiraNewIssue, "jira", &NewJiraIssueEvent{
IssueWatchID: w.ID,
WorkspaceID: w.WorkspaceID,
WorkflowID: w.WorkflowID,
WorkflowStepID: w.WorkflowStepID,
RepositoryID: w.RepositoryID,
BaseBranch: w.BaseBranch,
AgentProfileID: w.AgentProfileID,
ExecutorProfileID: w.ExecutorProfileID,
Prompt: w.Prompt,
MaxInflightTasks: w.MaxInflightTasks,
Issue: ticket,
})
if err := eb.Publish(ctx, events.JiraNewIssue, evt); err != nil {
s.log.Debug("jira: publish new issue event failed",
zap.String("watch_id", w.ID), zap.String("issue_key", ticket.Key), zap.Error(err))
}
}
// issueWatchSearchPageSize caps how many tickets a single CheckIssueWatch call
// pulls from JIRA. 50 is well under the API's 100-result limit and keeps the
// per-tick cost bounded even for very broad JQL queries.
const issueWatchSearchPageSize = 50
+52 -4
View File
@@ -124,6 +124,9 @@ func (s *Store) initSchema() error {
if err := s.addIssueWatchRepositoryColumns(); err != nil {
return err
}
if err := s.addOAuthColumns(); err != nil {
return err
}
return nil
}
@@ -241,6 +244,32 @@ func (s *Store) addInstanceTypeColumn() error {
return nil
}
// addOAuthColumns brings older databases up to the current schema by adding
// client_id, cloud_id, and token_expires_at to jira_configs when missing.
// All three backfill to empty/NULL and are only populated for OAuth configs.
func (s *Store) addOAuthColumns() error {
cols, err := s.tableColumns("jira_configs")
if err != nil {
return err
}
if _, ok := cols["client_id"]; !ok {
if _, err := s.db.Exec(`ALTER TABLE jira_configs ADD COLUMN client_id TEXT NOT NULL DEFAULT ''`); err != nil {
return fmt.Errorf("add client_id column: %w", err)
}
}
if _, ok := cols["cloud_id"]; !ok {
if _, err := s.db.Exec(`ALTER TABLE jira_configs ADD COLUMN cloud_id TEXT NOT NULL DEFAULT ''`); err != nil {
return fmt.Errorf("add cloud_id column: %w", err)
}
}
if _, ok := cols["token_expires_at"]; !ok {
if _, err := s.db.Exec(`ALTER TABLE jira_configs ADD COLUMN token_expires_at DATETIME`); err != nil {
return fmt.Errorf("add token_expires_at column: %w", err)
}
}
return nil
}
// migrateLegacySingletonTable detects the install-wide singleton schema and
// rewrites it into the workspace-scoped shape. Picks the active/default
// workspace as the target so startup is deterministic.
@@ -372,7 +401,8 @@ func (s *Store) tableColumns(table string) (map[string]struct{}, error) {
}
const selectConfigColumns = `workspace_id, site_url, email, auth_method, instance_type, default_project_key,
last_checked_at, last_ok, last_error, created_at, updated_at`
last_checked_at, last_ok, last_error, created_at, updated_at,
client_id, cloud_id, token_expires_at`
// GetConfig returns the default workspace Jira config, or nil when no row
// exists. New code should call GetConfigForWorkspace.
@@ -433,16 +463,21 @@ func (s *Store) UpsertConfigForWorkspace(ctx context.Context, workspaceID string
cfg.InstanceType = InstanceTypeCloud
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO jira_configs (workspace_id, site_url, email, auth_method, instance_type, default_project_key, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO jira_configs (workspace_id, site_url, email, auth_method, instance_type, default_project_key,
client_id, cloud_id, token_expires_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(workspace_id) DO UPDATE SET
site_url = excluded.site_url,
email = excluded.email,
auth_method = excluded.auth_method,
instance_type = excluded.instance_type,
default_project_key = excluded.default_project_key,
client_id = excluded.client_id,
cloud_id = excluded.cloud_id,
token_expires_at = excluded.token_expires_at,
updated_at = excluded.updated_at`,
workspaceID, cfg.SiteURL, cfg.Email, cfg.AuthMethod, cfg.InstanceType, cfg.DefaultProjectKey, cfg.CreatedAt, cfg.UpdatedAt)
workspaceID, cfg.SiteURL, cfg.Email, cfg.AuthMethod, cfg.InstanceType, cfg.DefaultProjectKey,
cfg.ClientID, cfg.CloudID, cfg.TokenExpiresAt, cfg.CreatedAt, cfg.UpdatedAt)
return err
}
@@ -514,6 +549,19 @@ func (s *Store) UpdateAuthHealthForWorkspace(ctx context.Context, workspaceID st
return err
}
// UpdateTokenExpiryForWorkspace updates the OAuth access token expiry for a
// workspace config row. Called after a successful token refresh.
func (s *Store) UpdateTokenExpiryForWorkspace(ctx context.Context, workspaceID string, expiresAt time.Time) error {
workspaceID, err := s.resolveWorkspaceID(workspaceID)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, `
UPDATE jira_configs SET token_expires_at = ?, updated_at = ? WHERE workspace_id = ?`,
expiresAt, time.Now().UTC(), workspaceID)
return err
}
func (s *Store) defaultWorkspaceID() (string, error) {
return s.defaultWorkspace.Resolve(s.db)
}
@@ -0,0 +1,69 @@
import { beforeAll, describe, expect, it } from "vitest";
import { activateLocale, i18n } from "@/lib/i18n";
import type { JiraConfig } from "@/lib/types/jira";
import { configToForm, deriveFormState, type FormState } from "./jira-form-state";
const t = i18n.t;
beforeAll(async () => {
await activateLocale("en");
});
function config(overrides: Partial<JiraConfig> = {}): JiraConfig {
return {
siteUrl: "https://acme.atlassian.net",
email: "user@example.com",
authMethod: "oauth",
instanceType: "cloud",
defaultProjectKey: "ENG",
hasSecret: true,
clientId: "client-1",
lastOk: true,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
};
}
function oauthForm(overrides: Partial<FormState> = {}): FormState {
return {
...configToForm(config()),
...overrides,
};
}
function derive(saved: JiraConfig | null, form: FormState) {
return deriveFormState({ config: saved, form, saving: false, loading: false }, t);
}
describe("deriveFormState OAuth credential identity", () => {
it("requires a connection before a fresh OAuth form can be saved or tested", () => {
const state = derive(null, oauthForm());
expect(state.disableSave).toBe(true);
expect(state.disableTest).toBe(true);
});
it("does not reuse an API-token secret after switching to OAuth", () => {
const saved = config({ authMethod: "api_token", clientId: undefined });
const state = derive(saved, oauthForm());
expect(state.disableSave).toBe(true);
expect(state.disableTest).toBe(true);
});
it("requires reconnecting after the OAuth site changes", () => {
const saved = config();
const state = derive(saved, oauthForm({ siteUrl: "https://other.atlassian.net" }));
expect(state.savedSecretMatchesMode).toBe(false);
expect(state.disableSave).toBe(true);
expect(state.disableTest).toBe(true);
});
it("allows a saved OAuth connection whose full identity still matches", () => {
const saved = config();
const state = derive(saved, oauthForm());
expect(state.savedSecretMatchesMode).toBe(true);
expect(state.disableSave).toBe(false);
expect(state.disableTest).toBe(false);
});
});
+136
View File
@@ -0,0 +1,136 @@
import type { TFunction } from "i18next";
import type { JiraAuthMethod, JiraConfig, JiraInstanceType } from "@/lib/types/jira";
export type FormState = {
siteUrl: string;
email: string;
authMethod: JiraAuthMethod;
instanceType: JiraInstanceType;
defaultProjectKey: string;
secret: string;
clientId: string;
};
export const emptyForm: FormState = {
siteUrl: "",
email: "",
authMethod: "api_token",
instanceType: "cloud",
defaultProjectKey: "",
secret: "",
clientId: "",
};
export function configToForm(cfg: JiraConfig | null): FormState {
if (!cfg) return emptyForm;
return {
siteUrl: cfg.siteUrl,
email: cfg.email,
authMethod: cfg.authMethod,
// Legacy rows written before Server/DC support carry an empty instanceType;
// default to cloud so the dropdown has a valid selection.
instanceType: cfg.instanceType || "cloud",
defaultProjectKey: cfg.defaultProjectKey,
secret: "",
clientId: cfg.clientId || "",
};
}
function normalizeComparableSiteUrl(value: string): string {
const trimmed = value.trim().replace(/\/+$/, "");
if (!trimmed) return "";
return trimmed.includes("://") ? trimmed : `https://${trimmed}`;
}
// savedSecretMatches reports whether the saved secret can be reused against
// the current form values. Reuse is only safe when every identity component
// of the saved credential still matches: same auth method, same instance
// type, same Jira host, and — for Cloud api_token where the basic pair is
// email:token — the same email (case-insensitive). Otherwise the user could
// change the site URL or Cloud account and silently submit the previous
// token to a different host/account.
export function savedSecretMatches(config: JiraConfig | null, form: FormState): boolean {
if (!config?.hasSecret) return false;
if (config.authMethod !== form.authMethod) return false;
if ((config.instanceType || "cloud") !== form.instanceType) return false;
if (normalizeComparableSiteUrl(config.siteUrl) !== normalizeComparableSiteUrl(form.siteUrl)) {
return false;
}
if (form.authMethod !== "api_token") return true;
return (config.email ?? "").toLowerCase() === form.email.toLowerCase();
}
export type DerivedFormState = {
isOAuth: boolean;
savedSecretMatchesMode: boolean;
disableSave: boolean;
disableTest: boolean;
revision: string;
dirty: boolean;
invalidReason: string | undefined;
};
// The subset of the settings hook's state deriveFormState reads. Structural, so
// this module stays independent of the component's hook return type.
type FormStateInputs = {
config: JiraConfig | null;
form: FormState;
saving: boolean;
loading: boolean;
};
function jiraDisableSave(
s: FormStateInputs,
emailRequired: boolean,
missingSecret: boolean,
oauthNeedsConnect: boolean,
): boolean {
return (
s.saving ||
!s.form.siteUrl ||
(emailRequired && !s.form.email) ||
missingSecret ||
oauthNeedsConnect
);
}
function jiraInvalidReason(
form: FormState,
emailRequired: boolean,
missingSecret: boolean,
t: TFunction,
): string | undefined {
if (!form.siteUrl) return t("jira:aJiraSiteUrlIsRequired");
if (emailRequired && !form.email) return t("jira:anEmailAddressIsRequired");
if (missingSecret) return t("jira:aCredentialIsRequired");
return undefined;
}
// Derives save/test gating and the validation message from the form + stored
// config. Extracted so JiraConnectionSection stays under the complexity limit.
export function deriveFormState(s: FormStateInputs, t: TFunction): DerivedFormState {
const savedSecretMatchesMode = savedSecretMatches(s.config, s.form);
const isOAuth = s.form.authMethod === "oauth";
const missingSecret = !isOAuth && !savedSecretMatchesMode && !s.form.secret;
const oauthNeedsConnect = isOAuth && !savedSecretMatchesMode;
const emailRequired = s.form.instanceType === "cloud" && s.form.authMethod === "api_token";
const disableSave = jiraDisableSave(s, emailRequired, missingSecret, oauthNeedsConnect);
const disableTest = missingSecret || (isOAuth && oauthNeedsConnect);
const revision = JSON.stringify(s.form);
const dirty = !s.loading && revision !== JSON.stringify(configToForm(s.config));
const invalidReason = jiraInvalidReason(
s.form,
emailRequired,
missingSecret || oauthNeedsConnect,
t,
);
return {
isOAuth,
savedSecretMatchesMode,
disableSave,
disableTest,
revision,
dirty,
invalidReason,
};
}
@@ -0,0 +1,79 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { activateLocale } from "@/lib/i18n";
import { OAuthFields } from "./jira-oauth-fields";
const api = vi.hoisted(() => ({
start: vi.fn(),
complete: vi.fn(),
}));
const toast = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/domains/jira-api", () => ({
startJiraOAuth: api.start,
completeJiraOAuth: api.complete,
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast }),
}));
beforeAll(async () => {
await activateLocale("en");
});
afterEach(() => {
cleanup();
api.start.mockReset();
api.complete.mockReset();
toast.mockReset();
vi.restoreAllMocks();
});
function renderOAuth(onConnected = vi.fn()) {
render(
<OAuthFields
form={{ siteUrl: "https://acme.atlassian.net" }}
loading={false}
workspaceId="ws-1"
connected={false}
onConnected={onConnected}
/>,
);
return onConnected;
}
describe("OAuthFields callback handoff", () => {
it("completes an origin-checked message from the authorization popup", async () => {
api.start.mockResolvedValue({
authUrl: "https://auth.example/authorize?client_id=client-1&state=state-1",
});
api.complete.mockResolvedValue({ ok: true });
const popup = { location: { assign: vi.fn() }, close: vi.fn() };
vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window);
const onConnected = renderOAuth();
fireEvent.click(screen.getByTestId("jira-oauth-connect"));
await waitFor(() => expect(api.start).toHaveBeenCalled());
window.dispatchEvent(
new MessageEvent("message", {
origin: window.location.origin,
source: popup as unknown as WindowProxy,
data: { type: "jira-oauth-callback", code: "code-1", state: "state-1" },
}),
);
await waitFor(() => expect(api.complete).toHaveBeenCalledWith("code-1", "state-1"));
expect(onConnected).toHaveBeenCalledOnce();
});
it("does not start OAuth when the browser blocks the popup", async () => {
vi.spyOn(window, "open").mockReturnValue(null);
renderOAuth();
fireEvent.click(screen.getByTestId("jira-oauth-connect"));
await waitFor(() => expect(toast).toHaveBeenCalled());
expect(api.start).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,118 @@
"use client";
import { Alert, AlertDescription } from "@kandev/ui/alert";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { useTranslation } from "react-i18next";
import { useJiraOAuthConnect } from "./use-jira-oauth-connect";
type OAuthFieldsProps = {
form: { siteUrl: string };
loading: boolean;
workspaceId: string;
connected: boolean;
tokenExpiresAt?: string | null;
onConnected: () => void;
};
// Fallback for remote servers: the OAuth redirect targets localhost, so the user
// pastes the full callback URL back for the code+state exchange.
function OAuthPasteDialog({
pasteUrl,
setPasteUrl,
completing,
onComplete,
onCancel,
}: {
pasteUrl: string;
setPasteUrl: (value: string) => void;
completing: boolean;
onComplete: () => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
return (
<Alert>
<AlertDescription className="space-y-3">
<p className="font-medium">{t("jira:oauthPasteInstructions")}</p>
<p className="text-xs">{t("jira:oauthPasteHelp")}</p>
<Input
type="text"
placeholder="http://localhost:38429/api/v1/jira/oauth/callback?code=...&state=..."
value={pasteUrl}
onChange={(e) => setPasteUrl(e.target.value)}
disabled={completing}
data-testid="jira-oauth-paste-url"
/>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="button"
size="sm"
onClick={onComplete}
disabled={completing || !pasteUrl}
className="h-11 w-full cursor-pointer sm:w-auto"
>
{completing ? t("jira:connecting") : t("jira:oauthComplete")}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={onCancel}
className="h-11 w-full cursor-pointer sm:w-auto"
>
{t("jira:oauthCancel")}
</Button>
</div>
</AlertDescription>
</Alert>
);
}
export function OAuthFields({
form,
loading,
workspaceId,
connected,
tokenExpiresAt,
onConnected,
}: OAuthFieldsProps) {
const { t } = useTranslation();
const oauth = useJiraOAuthConnect(workspaceId, form.siteUrl, onConnected);
return (
<div className="space-y-4">
{connected && (
<Alert>
<AlertDescription>
{t("jira:oauthConnected")}
{tokenExpiresAt && (
<span className="ml-2 text-xs text-muted-foreground">
{t("jira:oauthTokenExpires", { date: new Date(tokenExpiresAt).toLocaleString() })}
</span>
)}
</AlertDescription>
</Alert>
)}
<Button
type="button"
onClick={oauth.handleConnect}
disabled={oauth.connecting || loading || !form.siteUrl}
className="h-11 w-full cursor-pointer sm:w-auto"
data-testid="jira-oauth-connect"
>
{oauth.connecting ? t("jira:connecting") : t("jira:connectWithAtlassian")}
</Button>
<p className="text-xs text-muted-foreground">{t("jira:oauthDescription")}</p>
{oauth.showPasteDialog && (
<OAuthPasteDialog
pasteUrl={oauth.pasteUrl}
setPasteUrl={oauth.setPasteUrl}
completing={oauth.completing}
onComplete={oauth.handlePasteComplete}
onCancel={oauth.cancelPaste}
/>
)}
</div>
);
}
@@ -52,6 +52,9 @@ export function secretCopy(
label: t("jira:sessionTokenValue"),
placeholder: t("jira:pasteSessionTokenValue", { cookieName: SESSION_COOKIE_NAME }),
},
// OAuth has no paste field (OAuthFields renders the connect button instead);
// this entry only satisfies the exhaustive Record<JiraAuthMethod, ...> type.
oauth: { label: t("jira:oauth2"), placeholder: "" },
};
}
+39 -78
View File
@@ -36,50 +36,25 @@ import type {
TestJiraConnectionResult,
} from "@/lib/types/jira";
import { useTranslation } from "react-i18next";
import {
configToForm,
deriveFormState,
emptyForm,
type FormState,
} from "@/components/jira/jira-form-state";
import {
CookieExpiry,
SecretHelp,
secretCopy,
secretPlaceholder,
} from "@/components/jira/jira-secret-help";
import { OAuthFields } from "@/components/jira/jira-oauth-fields";
import { INTEGRATION_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/integrations";
// Cloud sites live under this domain; shown as an example, not as prose, so it
// is interpolated rather than written into the catalog.
const ATLASSIAN_CLOUD_DOMAIN = "*.atlassian.net";
type FormState = {
siteUrl: string;
email: string;
authMethod: JiraAuthMethod;
instanceType: JiraInstanceType;
defaultProjectKey: string;
secret: string;
};
const emptyForm: FormState = {
siteUrl: "",
email: "",
authMethod: "api_token",
instanceType: "cloud",
defaultProjectKey: "",
secret: "",
};
function configToForm(cfg: JiraConfig | null): FormState {
if (!cfg) return emptyForm;
return {
siteUrl: cfg.siteUrl,
email: cfg.email,
authMethod: cfg.authMethod,
// Legacy rows written before Server/DC support carry an empty instanceType;
// default to cloud so the dropdown has a valid selection.
instanceType: cfg.instanceType || "cloud",
defaultProjectKey: cfg.defaultProjectKey,
secret: "",
};
}
// defaultAuthForInstance returns the canonical auth method for an instance
// type. Used when the user switches Instance type and the current auth method
// is no longer valid for the new type (e.g. PAT picked for Cloud).
@@ -97,6 +72,7 @@ function authAllowedForInstance(auth: JiraAuthMethod, instance: JiraInstanceType
if (auth === "api_token") return instance === "cloud";
if (auth === "pat") return instance === "server";
if (auth === "session_cookie") return instance === "cloud";
if (auth === "oauth") return instance === "cloud";
return false;
}
@@ -212,6 +188,7 @@ function AuthFields({ form, baseline, loading, update }: FieldsRowProps) {
{form.instanceType === "cloud" ? (
<>
<SelectItem value="api_token">{t("jira:apiTokenRecommended")}</SelectItem>
<SelectItem value="oauth">{t("jira:oauth2")}</SelectItem>
<SelectItem value="session_cookie">{t("jira:browserSessionCookie")}</SelectItem>
</>
) : (
@@ -402,6 +379,7 @@ function useJiraConfigMutations({
instanceType: form.instanceType,
defaultProjectKey: form.defaultProjectKey,
secret: form.secret || undefined,
clientId: form.clientId || undefined,
},
{ workspaceId },
);
@@ -513,51 +491,23 @@ function useJiraSettings(workspaceId: string) {
handleSave,
handleDelete,
discard,
load,
};
}
function normalizeComparableSiteUrl(value: string): string {
const trimmed = value.trim().replace(/\/+$/, "");
if (!trimmed) return "";
return trimmed.includes("://") ? trimmed : `https://${trimmed}`;
}
// savedSecretMatches reports whether the saved secret can be reused against
// the current form values. Reuse is only safe when every identity component
// of the saved credential still matches: same auth method, same instance
// type, same Jira host, and — for Cloud api_token where the basic pair is
// email:token — the same email (case-insensitive). Otherwise the user could
// change the site URL or Cloud account and silently submit the previous
// token to a different host/account.
function savedSecretMatches(config: JiraConfig | null, form: FormState): boolean {
if (!config?.hasSecret) return false;
if (config.authMethod !== form.authMethod) return false;
if ((config.instanceType || "cloud") !== form.instanceType) return false;
if (normalizeComparableSiteUrl(config.siteUrl) !== normalizeComparableSiteUrl(form.siteUrl)) {
return false;
}
if (form.authMethod !== "api_token") return true;
return (config.email ?? "").toLowerCase() === form.email.toLowerCase();
}
export function JiraConnectionSection({ workspaceId }: { workspaceId: string }) {
const { t } = useTranslation();
const s = useJiraSettings(workspaceId);
const baseline = configToForm(s.config);
const savedSecretMatchesMode = savedSecretMatches(s.config, s.form);
const missingSecret = !savedSecretMatchesMode && !s.form.secret;
const emailRequired = s.form.instanceType === "cloud" && s.form.authMethod === "api_token";
const disableSave =
s.saving || !s.form.siteUrl || (emailRequired && !s.form.email) || missingSecret;
const disableTest = missingSecret;
const revision = JSON.stringify(s.form);
const dirty = !s.loading && revision !== JSON.stringify(configToForm(s.config));
// Assigned rather than returned from a helper, but the guard would not see it
// either way — `invalidReason` is a plain string, never a JSX literal.
let invalidReason: string | undefined;
if (!s.form.siteUrl) invalidReason = t("jira:aJiraSiteUrlIsRequired");
else if (emailRequired && !s.form.email) invalidReason = t("jira:anEmailAddressIsRequired");
else if (missingSecret) invalidReason = t("jira:aCredentialIsRequired");
const {
isOAuth,
savedSecretMatchesMode,
disableSave,
disableTest,
revision,
dirty,
invalidReason,
} = deriveFormState(s, t);
useSettingsSaveContributor({
id: `jira-config:${workspaceId}`,
@@ -589,14 +539,25 @@ export function JiraConnectionSection({ workspaceId }: { workspaceId: string })
/>
<SiteFields form={s.form} baseline={baseline} loading={s.loading} update={s.update} />
<AuthFields form={s.form} baseline={baseline} loading={s.loading} update={s.update} />
<SecretField
form={s.form}
baseline={baseline}
loading={s.loading}
update={s.update}
hasSavedSecret={savedSecretMatchesMode}
secretExpiresAt={s.config?.secretExpiresAt ?? null}
/>
{isOAuth ? (
<OAuthFields
form={s.form}
loading={s.loading}
workspaceId={workspaceId}
connected={savedSecretMatchesMode}
tokenExpiresAt={s.config?.tokenExpiresAt ?? null}
onConnected={() => void s.load()}
/>
) : (
<SecretField
form={s.form}
baseline={baseline}
loading={s.loading}
update={s.update}
hasSavedSecret={savedSecretMatchesMode}
secretExpiresAt={s.config?.secretExpiresAt ?? null}
/>
)}
<TestResultAlert result={s.testResult} />
<Separator />
<ActionBar
@@ -0,0 +1,170 @@
"use client";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import { useTranslation } from "react-i18next";
import { useToast } from "@/components/toast-provider";
import { completeJiraOAuth, startJiraOAuth } from "@/lib/api/domains/jira-api";
export type OAuthConnectState = {
connecting: boolean;
completing: boolean;
showPasteDialog: boolean;
pasteUrl: string;
setPasteUrl: (value: string) => void;
handleConnect: () => void;
handlePasteComplete: () => void;
cancelPaste: () => void;
};
type JiraOAuthCallbackMessage = {
type: "jira-oauth-callback";
code: string;
state: string;
};
function isJiraOAuthCallbackMessage(value: unknown): value is JiraOAuthCallbackMessage {
if (!value || typeof value !== "object") return false;
const message = value as Partial<JiraOAuthCallbackMessage>;
return (
message.type === "jira-oauth-callback" &&
typeof message.code === "string" &&
message.code.length > 0 &&
typeof message.state === "string" &&
message.state.length > 0
);
}
function useOAuthPopupMessage(
popupRef: RefObject<Window | null>,
pendingStateRef: RefObject<string | null>,
completeOAuth: (code: string, state: string) => Promise<void>,
) {
useEffect(() => {
const handleMessage = (event: MessageEvent<unknown>) => {
if (event.origin !== window.location.origin || event.source !== popupRef.current) return;
if (!isJiraOAuthCallbackMessage(event.data)) return;
if (event.data.state !== pendingStateRef.current) return;
void completeOAuth(event.data.code, event.data.state);
};
window.addEventListener("message", handleMessage);
return () => {
window.removeEventListener("message", handleMessage);
popupRef.current?.close();
};
}, [completeOAuth, pendingStateRef, popupRef]);
}
function useLatestRef<T>(value: T) {
const ref = useRef(value);
useEffect(() => {
ref.current = value;
}, [value]);
return ref;
}
export function useJiraOAuthConnect(
workspaceId: string,
siteUrl: string,
onConnected: () => void,
): OAuthConnectState {
const { t } = useTranslation();
const { toast } = useToast();
const [connecting, setConnecting] = useState(false);
const [completing, setCompleting] = useState(false);
const [showPasteDialog, setShowPasteDialog] = useState(false);
const [pasteUrl, setPasteUrl] = useState("");
const popupRef = useRef<Window | null>(null);
const pendingStateRef = useRef<string | null>(null);
const onConnectedRef = useLatestRef(onConnected);
const showError = useCallback(
(error: string) => {
toast({ description: t("jira:oauthStartFailed", { error }), variant: "error" });
},
[t, toast],
);
const completeOAuth = useCallback(
async (code: string, state: string) => {
setCompleting(true);
try {
await completeJiraOAuth(code, state);
toast({ description: t("jira:oauthConnected"), variant: "success" });
setShowPasteDialog(false);
setPasteUrl("");
pendingStateRef.current = null;
popupRef.current?.close();
popupRef.current = null;
onConnectedRef.current();
} catch (err) {
showError(err instanceof Error ? err.message : String(err));
setShowPasteDialog(true);
} finally {
setCompleting(false);
}
},
[t, toast, showError],
);
useOAuthPopupMessage(popupRef, pendingStateRef, completeOAuth);
const handleConnect = useCallback(async () => {
const popup = window.open("", "_blank");
if (!popup) {
showError(t("jira:oauthPopupBlocked"));
return;
}
popupRef.current = popup;
setConnecting(true);
try {
const { authUrl } = await startJiraOAuth(siteUrl, { workspaceId });
const state = new URL(authUrl).searchParams.get("state");
if (!state) throw new Error(t("jira:oauthPasteInvalid"));
pendingStateRef.current = state;
popup.location.assign(authUrl);
setShowPasteDialog(true);
} catch (err) {
popup.close();
popupRef.current = null;
pendingStateRef.current = null;
showError(err instanceof Error ? err.message : String(err));
} finally {
setConnecting(false);
}
}, [workspaceId, siteUrl, showError, t]);
const handlePasteComplete = useCallback(async () => {
try {
const url = new URL(pasteUrl);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code || !state) {
toast({ description: t("jira:oauthPasteInvalid"), variant: "error" });
return;
}
await completeOAuth(code, state);
} catch {
toast({ description: t("jira:oauthPasteInvalid"), variant: "error" });
}
}, [pasteUrl, t, toast, completeOAuth]);
const cancelPaste = useCallback(() => {
popupRef.current?.close();
popupRef.current = null;
pendingStateRef.current = null;
setShowPasteDialog(false);
setPasteUrl("");
}, []);
return {
connecting,
completing,
showPasteDialog,
pasteUrl,
setPasteUrl,
handleConnect: () => void handleConnect(),
handlePasteComplete: () => void handlePasteComplete(),
cancelPaste,
};
}
+5 -1
View File
@@ -53,7 +53,11 @@ export class JiraSettingsPage {
/** Open the Auth-method dropdown and pick by visible label. */
async selectAuth(
label: "API token (recommended)" | "Browser session cookie" | "Personal Access Token",
label:
| "API token (recommended)"
| "Browser session cookie"
| "Personal Access Token"
| "OAuth 2.0",
) {
await this.authSelect.click();
await this.page.getByRole("option", { name: label, exact: true }).click();
@@ -0,0 +1,18 @@
import { expect, test } from "../../fixtures/test-base";
import { JiraSettingsPage } from "../../pages/jira-settings-page";
test("Jira OAuth connect is reachable with a touch-sized action", async ({
testPage,
seedData,
}) => {
const settings = new JiraSettingsPage(testPage);
await settings.gotoWorkspace(seedData.workspaceId);
await settings.siteInput.fill("https://acme.atlassian.net");
await settings.selectAuth("OAuth 2.0");
const connect = testPage.getByTestId("jira-oauth-connect");
await expect(connect).toBeEnabled();
const box = await connect.boundingBox();
expect(box?.height).toBeGreaterThanOrEqual(44);
});
+23
View File
@@ -55,6 +55,29 @@ export async function deleteJiraConfig(options?: WorkspaceApiOptions) {
);
}
// startJiraOAuth registers a client + PKCE and returns the Atlassian
// authorization URL. Uses withWorkspace so the backend gets `workspace_id`.
export async function startJiraOAuth(
siteUrl: string,
options?: WorkspaceApiOptions,
): Promise<{ authUrl: string }> {
const path = withWorkspace(
`/api/v1/jira/oauth/start?siteUrl=${encodeURIComponent(siteUrl)}`,
options,
);
return fetchJson<{ authUrl: string }>(path, requestOptions(options));
}
// completeJiraOAuth exchanges the pasted redirect (code + state) for tokens.
export async function completeJiraOAuth(
code: string,
state: string,
options?: WorkspaceApiOptions,
): Promise<{ ok: boolean }> {
const path = `/api/v1/jira/oauth/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}&api=1`;
return fetchJson<{ ok: boolean }>(path, requestOptions(options));
}
export async function testJiraConnection(
payload: SetJiraConfigRequest,
options?: WorkspaceApiOptions,
+9 -1
View File
@@ -4,7 +4,7 @@
* - `pat` — Jira Server / Data Center only (Personal Access Token, sent as Bearer).
* - `session_cookie` — works on both Cloud and Server (wraps the session JWT cookie).
*/
export type JiraAuthMethod = "api_token" | "pat" | "session_cookie";
export type JiraAuthMethod = "api_token" | "pat" | "session_cookie" | "oauth";
/**
* Jira deployment kind. Cloud uses REST v3 and the token-paginated search
@@ -21,6 +21,12 @@ export interface JiraConfig {
instanceType: JiraInstanceType;
defaultProjectKey: string;
hasSecret: boolean;
/** OAuth client_id (only for authMethod === "oauth"). */
clientId?: string;
/** Atlassian cloudId (only for authMethod === "oauth"). */
cloudId?: string;
/** OAuth access token expiry ISO timestamp (only for authMethod === "oauth"). */
tokenExpiresAt?: string | null;
/** ISO timestamp when the session cookie's JWT expires, or null for api_token / opaque cookies. */
secretExpiresAt?: string | null;
/** Last time the backend probed credentials, or null if never probed. */
@@ -40,6 +46,8 @@ export interface SetJiraConfigRequest {
instanceType: JiraInstanceType;
defaultProjectKey?: string;
secret?: string;
/** OAuth client_id (only for authMethod === "oauth", dynamically registered). */
clientId?: string;
}
export interface TestJiraConnectionResult {
+15
View File
@@ -18,6 +18,21 @@
"authenticationMethod": "Authentication method",
"availablePlaceholders": "Available placeholders:",
"browserSessionCookie": "Browser session cookie",
"builtinViewAssignedToMe": "Assigned to me",
"builtinViewUnassigned": "Unassigned",
"connectWithAtlassian": "Connect with Atlassian",
"connecting": "Connecting...",
"oauth2": "OAuth 2.0",
"oauthConnected": "Connected via OAuth. Tokens refresh automatically.",
"oauthDescription": "Authenticate via Atlassian OAuth 2.1 with PKCE. No app creation or credentials needed. Click to authorize through your browser. Tokens auto-refresh on expiry.",
"oauthStartFailed": "Failed to start OAuth: {{error}}",
"oauthTokenExpires": "Token expires at: {{date}}",
"oauthPasteInstructions": "Complete authorization in the new tab",
"oauthPasteHelp": "After approving on Atlassian, you'll be redirected to a localhost URL that won't load. Copy the full URL from your browser's address bar and paste it below.",
"oauthPopupBlocked": "Your browser blocked the authorization window. Allow pop-ups and try again.",
"oauthComplete": "Complete",
"oauthCancel": "Cancel",
"oauthPasteInvalid": "Invalid URL. Make sure you copied the full redirect URL including code and state parameters.",
"builtIn": "Built-in",
"capOnOpenTasksCreatedBy": "Cap on open tasks created by this watcher. Leave blank for no cap. New matches are deferred to the next poll when the cap is reached.",
"checkFailed": "Check failed: {{error}}",
+16 -3
View File
@@ -18,6 +18,21 @@
"authenticationMethod": "Àũţĥēńţĩćàţĩōń ḿēţĥōď",
"availablePlaceholders": "Àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś:",
"browserSessionCookie": "Ɓŕōŵśēŕ śēśśĩōń ćōōķĩē",
"builtinViewAssignedToMe": "Àśśĩĝńēď ţō ḿē",
"builtinViewUnassigned": "Ũńàśśĩĝńēď",
"connectWithAtlassian": "Ćōńńēćţ ŵĩţĥ Àţĺàśśĩàń",
"connecting": "Ćōńńēćţĩńĝ...",
"oauth2": "ŌÀũţĥ 2.0",
"oauthConnected": "Ćōńńēćţēď vĩà ŌÀũţĥ. Ţōķēńś ŕēƒŕēśĥ àũţōḿàţĩćàĺĺŷ.",
"oauthDescription": "Àũţĥēńţĩćàţē vĩà Àţĺàśśĩàń ŌÀũţĥ 2.1 ŵĩţĥ ƤĶĆĒ. Ńō àƥƥ ćŕēàţĩōń ōŕ ćŕēďēńţĩàĺś ńēēďēď. Ćĺĩćķ ţō àũţĥōŕĩźē ţĥŕōũĝĥ ŷōũŕ ƀŕōŵśēŕ. Ţōķēńś àũţō-ŕēƒŕēśĥ ōń ēxƥĩŕŷ.",
"oauthStartFailed": "Ƒàĩĺēď ţō śţàŕţ ŌÀũţĥ: {{error}}",
"oauthTokenExpires": "Ţōķēń ēxƥĩŕēś àţ: {{date}}",
"oauthPasteInstructions": "Ćōḿƥĺēţē àũţĥōŕĩźàţĩōń ĩń ţĥē ńēŵ ţàƀ",
"oauthPasteHelp": "Àƒţēŕ àƥƥŕōvĩńĝ ōń Àţĺàśśĩàń, ŷōũ'ĺĺ ƀē ŕēďĩŕēćţēď ţō à ĺōćàĺĥōśţ ŨŔĹ ţĥàţ ŵōń'ţ ĺōàď. Ćōƥŷ ţĥē ƒũĺĺ ŨŔĹ ƒŕōḿ ŷōũŕ ƀŕōŵśēŕ'ś àďďŕēśś ƀàŕ àńď ƥàśţē ĩţ ƀēĺōŵ.",
"oauthPopupBlocked": "Ŷōũŕ ƀŕōŵśēŕ ƀĺōćķēď ţĥē àũţĥōŕĩźàţĩōń ŵĩńďōŵ. Àĺĺōŵ ƥōƥ-ũƥś àńď ţŕŷ àĝàĩń.",
"oauthComplete": "Ćōḿƥĺēţē",
"oauthCancel": "Ćàńćēĺ",
"oauthPasteInvalid": "Ĩńvàĺĩď ŨŔĹ. Ḿàķē śũŕē ŷōũ ćōƥĩēď ţĥē ƒũĺĺ ŕēďĩŕēćţ ŨŔĹ ĩńćĺũďĩńĝ ćōďē àńď śţàţē ƥàŕàḿēţēŕś.",
"builtIn": "Ɓũĩĺţ-ĩń",
"capOnOpenTasksCreatedBy": "Ćàƥ ōń ōƥēń ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ. Ĺēàvē ƀĺàńķ ƒōŕ ńō ćàƥ. Ńēŵ ḿàţćĥēś àŕē ďēƒēŕŕēď ţō ţĥē ńēxţ ƥōĺĺ ŵĥēń ţĥē ćàƥ ĩś ŕēàćĥēď.",
"checkFailed": "Ćĥēćķ ƒàĩĺēď: {{error}}",
@@ -208,7 +223,5 @@
"watcherUpdated": "Ŵàţćĥēŕ ũƥďàţēď",
"workflow": "Ŵōŕķƒĺōŵ",
"workflowStep": "Ŵōŕķƒĺōŵ Śţēƥ",
"yourJiraSessionExpiredOrNeeds": "Ŷōũŕ Ĵĩŕà śēśśĩōń ēxƥĩŕēď ōŕ ńēēďś śţēƥ-ũƥ àũţĥēńţĩćàţĩōń. Ŕēćōńńēćţ ţō vĩēŵ ţĥĩś ţĩćķēţ.",
"builtinViewAssignedToMe": "Àśśĩĝńēď ţō ḿē",
"builtinViewUnassigned": "Ũńàśśĩĝńēď"
"yourJiraSessionExpiredOrNeeds": "Ŷōũŕ Ĵĩŕà śēśśĩōń ēxƥĩŕēď ōŕ ńēēďś śţēƥ-ũƥ àũţĥēńţĩćàţĩōń. Ŕēćōńńēćţ ţō vĩēŵ ţĥĩś ţĩćķēţ."
}
+14 -1
View File
@@ -210,5 +210,18 @@
"workflowStep": "Passo do fluxo de trabalho",
"yourJiraSessionExpiredOrNeeds": "A sua sessão do Jira expirou ou precisa de autenticação reforçada. Volte a ligar para ver este ticket.",
"builtinViewAssignedToMe": "Atribuídos a mim",
"builtinViewUnassigned": "Sem responsável"
"builtinViewUnassigned": "Sem responsável",
"connectWithAtlassian": "Ligar à Atlassian",
"connecting": "A ligar...",
"oauth2": "OAuth 2.0",
"oauthConnected": "Ligado através de OAuth. Os tokens são renovados automaticamente.",
"oauthDescription": "Autenticação através do OAuth 2.1 da Atlassian com PKCE. Não é necessário criar uma aplicação nem fornecer credenciais. Clique para autorizar através do seu navegador. Os tokens são renovados automaticamente ao expirar.",
"oauthStartFailed": "Não foi possível iniciar o OAuth: {{error}}",
"oauthTokenExpires": "O token expira a: {{date}}",
"oauthPasteInstructions": "Conclua a autorização no novo separador",
"oauthPasteHelp": "Depois de aprovar na Atlassian, será redirecionado para um URL de localhost que não irá carregar. Copie o URL completo da barra de endereço do navegador e cole-o abaixo.",
"oauthPopupBlocked": "O navegador bloqueou a janela de autorização. Permita pop-ups e tente novamente.",
"oauthComplete": "Concluir",
"oauthCancel": "Cancelar",
"oauthPasteInvalid": "URL inválido. Certifique-se de que copiou o URL de redirecionamento completo, incluindo os parâmetros code e state."
}
+14 -1
View File
@@ -210,5 +210,18 @@
"workflowStep": "工作流步骤",
"yourJiraSessionExpiredOrNeeds": "你的 Jira 会话已过期或需要二次验证。请重新连接以查看此工单。",
"builtinViewAssignedToMe": "指派给我",
"builtinViewUnassigned": "未指派"
"builtinViewUnassigned": "未指派",
"connectWithAtlassian": "连接 Atlassian",
"connecting": "正在连接...",
"oauth2": "OAuth 2.0",
"oauthConnected": "已通过 OAuth 连接。令牌会自动刷新。",
"oauthDescription": "通过 Atlassian OAuth 2.1(含 PKCE)进行身份验证。无需创建应用或提供凭据。点击即可在浏览器中授权。令牌过期后会自动刷新。",
"oauthStartFailed": "无法启动 OAuth:{{error}}",
"oauthTokenExpires": "令牌过期时间:{{date}}",
"oauthPasteInstructions": "在新标签页中完成授权",
"oauthPasteHelp": "在 Atlassian 上批准后,你将被重定向到一个无法加载的 localhost 网址。请从浏览器地址栏复制完整网址并粘贴到下方。",
"oauthPopupBlocked": "浏览器已阻止授权窗口。请允许弹出窗口,然后重试。",
"oauthComplete": "完成",
"oauthCancel": "取消",
"oauthPasteInvalid": "网址无效。请确保复制了包含 code 和 state 参数的完整重定向网址。"
}
+14 -1
View File
@@ -210,5 +210,18 @@
"workflowStep": "工作流程步驟",
"yourJiraSessionExpiredOrNeeds": "你的 Jira 工作階段已過期或需要二次驗證。請重新連接以查看此工單。",
"builtinViewAssignedToMe": "指派給我",
"builtinViewUnassigned": "未指派"
"builtinViewUnassigned": "未指派",
"connectWithAtlassian": "連接 Atlassian",
"connecting": "正在連線...",
"oauth2": "OAuth 2.0",
"oauthConnected": "已通過 OAuth 連接。權杖會自動刷新。",
"oauthDescription": "通過 Atlassian OAuth 2.1(含 PKCE)進行身份驗證。無需建立應用或提供認證資料。點擊即可在瀏覽器中授權。權杖過期後會自動刷新。",
"oauthStartFailed": "無法啓動 OAuth:{{error}}",
"oauthTokenExpires": "權杖過期時間:{{date}}",
"oauthPasteInstructions": "在新標籤頁中完成授權",
"oauthPasteHelp": "在 Atlassian 上批准後,你將被重定向到一個無法載入的 localhost 網址。請從瀏覽器地址欄複製完整網址並貼上到下方。",
"oauthPopupBlocked": "瀏覽器已封鎖授權視窗。請允許彈出式視窗,然後再試。",
"oauthComplete": "完成",
"oauthCancel": "取消",
"oauthPasteInvalid": "網址無效。請確保複製了包含 code 和 state 參數的完整重定向網址。"
}
+14 -1
View File
@@ -210,5 +210,18 @@
"workflowStep": "工作流程步驟",
"yourJiraSessionExpiredOrNeeds": "你的 Jira 工作階段已過期或需要二次驗證。請重新連線以檢視此工單。",
"builtinViewAssignedToMe": "指派給我",
"builtinViewUnassigned": "未指派"
"builtinViewUnassigned": "未指派",
"connectWithAtlassian": "連線 Atlassian",
"connecting": "正在連線...",
"oauth2": "OAuth 2.0",
"oauthConnected": "已通過 OAuth 連線。權杖會自動重新整理。",
"oauthDescription": "通過 Atlassian OAuth 2.1(含 PKCE)進行身分驗證。無需建立應用或提供認證資訊。點選即可在瀏覽器中授權。權杖過期後會自動重新整理。",
"oauthStartFailed": "無法啟動 OAuth:{{error}}",
"oauthTokenExpires": "權杖過期時間:{{date}}",
"oauthPasteInstructions": "在新標籤頁中完成授權",
"oauthPasteHelp": "在 Atlassian 上批准後,你將被重定向到一個無法載入的 localhost 網址。請從瀏覽器位址列複製完整網址並貼上到下方。",
"oauthPopupBlocked": "瀏覽器已封鎖授權視窗。請允許彈出式視窗,然後再試。",
"oauthComplete": "完成",
"oauthCancel": "取消",
"oauthPasteInvalid": "網址無效。請確保複製了包含 code 和 state 引數的完整重定向網址。"
}
+3
View File
@@ -549,11 +549,14 @@ Enter the site URL (a missing scheme is normalized to HTTPS), choose **Cloud** o
| Deployment | Method | Required values |
|---|---|---|
| Jira Cloud | API token (recommended) | Atlassian account email and API token. |
| Jira Cloud | OAuth 2.0 | Approval from the Atlassian account in a browser window. |
| Jira Cloud | Browser session | Only the value of the `cloud.session.token` or `tenant.session.token` cookie. Do not include the cookie name or `=`. |
| Server/Data Center | Personal access token | Bearer personal access token with the required read/write access. |
Cloud API tokens are not accepted for Server/Data Center, and Server/Data Center PATs are not the Cloud token flow. Browser-session JWTs expire and are less reliable than an API token; Kandev surfaces the decoded expiry and warns as it approaches.
For OAuth, select **Connect with Atlassian**. Then approve the connection in the browser window. Kandev completes the connection when Atlassian returns the result. If the automatic return fails, paste the full callback URL into Kandev.
When editing, a blank secret preserves the saved credential only if the URL, account identity, and authentication method still match. Supply a new secret when changing those identity fields. Save, select **Test connection**, and check the background health result.
### Jira issue watches