From a2685dccaec7ccfb284b7ae4a32d4a7516f8b65a Mon Sep 17 00:00:00 2001 From: Ahmed Bally Date: Sat, 22 Aug 2026 14:49:09 +0000 Subject: [PATCH] feat(jira): add OAuth 2.1 with PKCE via Atlassian MCP server (#2805) Co-authored-by: Carlos Florencio --- apps/backend/internal/jira/cloud_client.go | 74 ++- apps/backend/internal/jira/copy.go | 7 + apps/backend/internal/jira/copy_test.go | 22 + apps/backend/internal/jira/handlers.go | 107 +++- apps/backend/internal/jira/mcp_client.go | 546 ++++++++++++++++++ apps/backend/internal/jira/mcp_client_test.go | 230 ++++++++ apps/backend/internal/jira/models.go | 33 ++ apps/backend/internal/jira/oauth.go | 525 +++++++++++++++++ apps/backend/internal/jira/oauth_test.go | 207 +++++++ .../backend/internal/jira/scope_watch_test.go | 34 ++ apps/backend/internal/jira/service.go | 468 +++------------ .../internal/jira/service_issue_watch_test.go | 31 + apps/backend/internal/jira/service_oauth.go | 116 ++++ apps/backend/internal/jira/service_watch.go | 311 ++++++++++ apps/backend/internal/jira/store.go | 56 +- .../components/jira/jira-form-state.test.ts | 69 +++ apps/web/components/jira/jira-form-state.ts | 136 +++++ .../jira/jira-oauth-fields.test.tsx | 79 +++ .../web/components/jira/jira-oauth-fields.tsx | 118 ++++ apps/web/components/jira/jira-secret-help.tsx | 3 + apps/web/components/jira/jira-settings.tsx | 117 ++-- .../components/jira/use-jira-oauth-connect.ts | 170 ++++++ apps/web/e2e/pages/jira-settings-page.ts | 6 +- .../integrations/mobile-jira-oauth.spec.ts | 18 + apps/web/lib/api/domains/jira-api.ts | 23 + apps/web/lib/types/jira.ts | 10 +- apps/web/src/locales/en/jira.json | 15 + apps/web/src/locales/pseudo/jira.json | 19 +- apps/web/src/locales/pt-pt/jira.json | 15 +- apps/web/src/locales/zh-cn/jira.json | 15 +- apps/web/src/locales/zh-hk/jira.json | 15 +- apps/web/src/locales/zh-tw/jira.json | 15 +- docs/public/integrations.md | 3 + 33 files changed, 3143 insertions(+), 470 deletions(-) create mode 100644 apps/backend/internal/jira/mcp_client.go create mode 100644 apps/backend/internal/jira/mcp_client_test.go create mode 100644 apps/backend/internal/jira/oauth.go create mode 100644 apps/backend/internal/jira/oauth_test.go create mode 100644 apps/backend/internal/jira/service_oauth.go create mode 100644 apps/backend/internal/jira/service_watch.go create mode 100644 apps/web/components/jira/jira-form-state.test.ts create mode 100644 apps/web/components/jira/jira-form-state.ts create mode 100644 apps/web/components/jira/jira-oauth-fields.test.tsx create mode 100644 apps/web/components/jira/jira-oauth-fields.tsx create mode 100644 apps/web/components/jira/use-jira-oauth-connect.ts create mode 100644 apps/web/e2e/tests/integrations/mobile-jira-oauth.spec.ts diff --git a/apps/backend/internal/jira/cloud_client.go b/apps/backend/internal/jira/cloud_client.go index bddea11629..62329d66af 100644 --- a/apps/backend/internal/jira/cloud_client.go +++ b/apps/backend/internal/jira/cloud_client.go @@ -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) diff --git a/apps/backend/internal/jira/copy.go b/apps/backend/internal/jira/copy.go index 2189c7cf97..56ac8709d0 100644 --- a/apps/backend/internal/jira/copy.go +++ b/apps/backend/internal/jira/copy.go @@ -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) diff --git a/apps/backend/internal/jira/copy_test.go b/apps/backend/internal/jira/copy_test.go index a37b0077d8..157272c5f6 100644 --- a/apps/backend/internal/jira/copy_test.go +++ b/apps/backend/internal/jira/copy_test.go @@ -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) + } +} diff --git a/apps/backend/internal/jira/handlers.go b/apps/backend/internal/jira/handlers.go index 20ae50e31f..437391a699 100644 --- a/apps/backend/internal/jira/handlers.go +++ b/apps/backend/internal/jira/handlers.go @@ -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, ` + +

Authorization complete. You can close this window.

+ +`) +} + +// 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}) +} diff --git a/apps/backend/internal/jira/mcp_client.go b/apps/backend/internal/jira/mcp_client.go new file mode 100644 index 0000000000..431cf9f009 --- /dev/null +++ b/apps/backend/internal/jira/mcp_client.go @@ -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 +} diff --git a/apps/backend/internal/jira/mcp_client_test.go b/apps/backend/internal/jira/mcp_client_test.go new file mode 100644 index 0000000000..6851f86144 --- /dev/null +++ b/apps/backend/internal/jira/mcp_client_test.go @@ -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") + } +} diff --git a/apps/backend/internal/jira/models.go b/apps/backend/internal/jira/models.go index dd79b73cb7..b3c77d61e2 100644 --- a/apps/backend/internal/jira/models.go +++ b/apps/backend/internal/jira/models.go @@ -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) diff --git a/apps/backend/internal/jira/oauth.go b/apps/backend/internal/jira/oauth.go new file mode 100644 index 0000000000..9954cc1366 --- /dev/null +++ b/apps/backend/internal/jira/oauth.go @@ -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, ®); 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() +} diff --git a/apps/backend/internal/jira/oauth_test.go b/apps/backend/internal/jira/oauth_test.go new file mode 100644 index 0000000000..1b5c873efc --- /dev/null +++ b/apps/backend/internal/jira/oauth_test.go @@ -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)) + } +} diff --git a/apps/backend/internal/jira/scope_watch_test.go b/apps/backend/internal/jira/scope_watch_test.go index 4c023a29fc..e831a5daa3 100644 --- a/apps/backend/internal/jira/scope_watch_test.go +++ b/apps/backend/internal/jira/scope_watch_test.go @@ -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) { diff --git a/apps/backend/internal/jira/service.go b/apps/backend/internal/jira/service.go index 914cad78f3..f13c38c601 100644 --- a/apps/backend/internal/jira/service.go +++ b/apps/backend/internal/jira/service.go @@ -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 diff --git a/apps/backend/internal/jira/service_issue_watch_test.go b/apps/backend/internal/jira/service_issue_watch_test.go index f0166f4dfb..e6800e0fdb 100644 --- a/apps/backend/internal/jira/service_issue_watch_test.go +++ b/apps/backend/internal/jira/service_issue_watch_test.go @@ -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") + } +} diff --git a/apps/backend/internal/jira/service_oauth.go b/apps/backend/internal/jira/service_oauth.go new file mode 100644 index 0000000000..2bcdb3dd40 --- /dev/null +++ b/apps/backend/internal/jira/service_oauth.go @@ -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 +} diff --git a/apps/backend/internal/jira/service_watch.go b/apps/backend/internal/jira/service_watch.go new file mode 100644 index 0000000000..c4acb6de9e --- /dev/null +++ b/apps/backend/internal/jira/service_watch.go @@ -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 diff --git a/apps/backend/internal/jira/store.go b/apps/backend/internal/jira/store.go index 40737b185c..d316863e90 100644 --- a/apps/backend/internal/jira/store.go +++ b/apps/backend/internal/jira/store.go @@ -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) } diff --git a/apps/web/components/jira/jira-form-state.test.ts b/apps/web/components/jira/jira-form-state.test.ts new file mode 100644 index 0000000000..8a4f58ff25 --- /dev/null +++ b/apps/web/components/jira/jira-form-state.test.ts @@ -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 { + 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 { + 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); + }); +}); diff --git a/apps/web/components/jira/jira-form-state.ts b/apps/web/components/jira/jira-form-state.ts new file mode 100644 index 0000000000..901c8dc454 --- /dev/null +++ b/apps/web/components/jira/jira-form-state.ts @@ -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, + }; +} diff --git a/apps/web/components/jira/jira-oauth-fields.test.tsx b/apps/web/components/jira/jira-oauth-fields.test.tsx new file mode 100644 index 0000000000..a34522b064 --- /dev/null +++ b/apps/web/components/jira/jira-oauth-fields.test.tsx @@ -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( + , + ); + 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(); + }); +}); diff --git a/apps/web/components/jira/jira-oauth-fields.tsx b/apps/web/components/jira/jira-oauth-fields.tsx new file mode 100644 index 0000000000..fe02dbc4ee --- /dev/null +++ b/apps/web/components/jira/jira-oauth-fields.tsx @@ -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 ( + + +

{t("jira:oauthPasteInstructions")}

+

{t("jira:oauthPasteHelp")}

+ setPasteUrl(e.target.value)} + disabled={completing} + data-testid="jira-oauth-paste-url" + /> +
+ + +
+
+
+ ); +} + +export function OAuthFields({ + form, + loading, + workspaceId, + connected, + tokenExpiresAt, + onConnected, +}: OAuthFieldsProps) { + const { t } = useTranslation(); + const oauth = useJiraOAuthConnect(workspaceId, form.siteUrl, onConnected); + return ( +
+ {connected && ( + + + {t("jira:oauthConnected")} + {tokenExpiresAt && ( + + {t("jira:oauthTokenExpires", { date: new Date(tokenExpiresAt).toLocaleString() })} + + )} + + + )} + +

{t("jira:oauthDescription")}

+ {oauth.showPasteDialog && ( + + )} +
+ ); +} diff --git a/apps/web/components/jira/jira-secret-help.tsx b/apps/web/components/jira/jira-secret-help.tsx index 0d4b66527b..153ae9055f 100644 --- a/apps/web/components/jira/jira-secret-help.tsx +++ b/apps/web/components/jira/jira-secret-help.tsx @@ -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 type. + oauth: { label: t("jira:oauth2"), placeholder: "" }, }; } diff --git a/apps/web/components/jira/jira-settings.tsx b/apps/web/components/jira/jira-settings.tsx index 5b929bbbba..fb0f5cd209 100644 --- a/apps/web/components/jira/jira-settings.tsx +++ b/apps/web/components/jira/jira-settings.tsx @@ -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" ? ( <> {t("jira:apiTokenRecommended")} + {t("jira:oauth2")} {t("jira:browserSessionCookie")} ) : ( @@ -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 }) /> - + {isOAuth ? ( + void s.load()} + /> + ) : ( + + )} 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; + 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, + pendingStateRef: RefObject, + completeOAuth: (code: string, state: string) => Promise, +) { + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + 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(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(null); + const pendingStateRef = useRef(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, + }; +} diff --git a/apps/web/e2e/pages/jira-settings-page.ts b/apps/web/e2e/pages/jira-settings-page.ts index 4a0749ea39..1a4fc99a19 100644 --- a/apps/web/e2e/pages/jira-settings-page.ts +++ b/apps/web/e2e/pages/jira-settings-page.ts @@ -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(); diff --git a/apps/web/e2e/tests/integrations/mobile-jira-oauth.spec.ts b/apps/web/e2e/tests/integrations/mobile-jira-oauth.spec.ts new file mode 100644 index 0000000000..27d14e94db --- /dev/null +++ b/apps/web/e2e/tests/integrations/mobile-jira-oauth.spec.ts @@ -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); +}); diff --git a/apps/web/lib/api/domains/jira-api.ts b/apps/web/lib/api/domains/jira-api.ts index 8df055ebf2..405b78786d 100644 --- a/apps/web/lib/api/domains/jira-api.ts +++ b/apps/web/lib/api/domains/jira-api.ts @@ -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, diff --git a/apps/web/lib/types/jira.ts b/apps/web/lib/types/jira.ts index 05375ee8c8..d09f7802d4 100644 --- a/apps/web/lib/types/jira.ts +++ b/apps/web/lib/types/jira.ts @@ -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 { diff --git a/apps/web/src/locales/en/jira.json b/apps/web/src/locales/en/jira.json index 09c3079bee..6ebbb1e97d 100644 --- a/apps/web/src/locales/en/jira.json +++ b/apps/web/src/locales/en/jira.json @@ -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}}", diff --git a/apps/web/src/locales/pseudo/jira.json b/apps/web/src/locales/pseudo/jira.json index d3e6930858..fc9d7acdd2 100644 --- a/apps/web/src/locales/pseudo/jira.json +++ b/apps/web/src/locales/pseudo/jira.json @@ -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ĩēŵ ţĥĩś ţĩćķēţ." } diff --git a/apps/web/src/locales/pt-pt/jira.json b/apps/web/src/locales/pt-pt/jira.json index c633edefbe..683764beb3 100644 --- a/apps/web/src/locales/pt-pt/jira.json +++ b/apps/web/src/locales/pt-pt/jira.json @@ -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." } diff --git a/apps/web/src/locales/zh-cn/jira.json b/apps/web/src/locales/zh-cn/jira.json index e354c69447..6c9304b8f7 100644 --- a/apps/web/src/locales/zh-cn/jira.json +++ b/apps/web/src/locales/zh-cn/jira.json @@ -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 参数的完整重定向网址。" } diff --git a/apps/web/src/locales/zh-hk/jira.json b/apps/web/src/locales/zh-hk/jira.json index 33d4a71378..1b7f14bea3 100644 --- a/apps/web/src/locales/zh-hk/jira.json +++ b/apps/web/src/locales/zh-hk/jira.json @@ -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 參數的完整重定向網址。" } diff --git a/apps/web/src/locales/zh-tw/jira.json b/apps/web/src/locales/zh-tw/jira.json index 6e65365d45..4a03bc97e0 100644 --- a/apps/web/src/locales/zh-tw/jira.json +++ b/apps/web/src/locales/zh-tw/jira.json @@ -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 引數的完整重定向網址。" } diff --git a/docs/public/integrations.md b/docs/public/integrations.md index 20cb2ce917..c7bbfe03c9 100644 --- a/docs/public/integrations.md +++ b/docs/public/integrations.md @@ -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