diff --git a/README.md b/README.md
index 28a16856..7332f6ff 100644
--- a/README.md
+++ b/README.md
@@ -60,15 +60,17 @@ features like issue creation, cloning, linking, ticket transition, and much more
> This tool is heavily inspired by the [GitHub CLI](https://github.com/cli/cli)
## Supported platforms
+
> [!NOTE]
> Some features might work slightly differently in cloud installation versus on-premise installation due to the
-nature of the data. Yet, we've attempted to make the experience as similar as possible.
+> nature of the data. Yet, we've attempted to make the experience as similar as possible.
| Platform | |
-| :------------- | :----------: |
-| **Jira** |
|
+| :------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
+| **Jira** |
|
## Installation
+
`jira-cli` is available as a downloadable packaged binary for Linux, macOS, and Windows from the [releases page](https://github.com/ankitpokhrel/jira-cli/releases).
You can use Docker to quickly try out `jira-cli`.
@@ -93,6 +95,7 @@ Follow the [installation guide](https://github.com/ankitpokhrel/jira-cli/wiki/In
more [here](https://github.com/ankitpokhrel/jira-cli/discussions/356).
2. Run `jira init`, select installation type as `Cloud`, and provide required details to generate a config file required
for the tool.
+3. Run the `jira init`, Select the `Cloud` installation type and then select the `OAuth` authentication type. This will prompt for your Jira App Client ID and Client Secret. You can learn more about how to create a Jira App [here](https://github.com/ankitpokhrel/jira-cli/discussions/879#discussion-8604411)
#### On-premise installation
@@ -111,22 +114,24 @@ Follow the [installation guide](https://github.com/ankitpokhrel/jira-cli/wiki/In
> [!IMPORTANT]
> If your on-premise Jira installation is using a language other than `English`, then the issue/epic creation
- may not work because the older version of Jira API doesn't return the untranslated name for `issuetypes`. In that case,
- you will have to fill in `epic.name`, `epic.link` and `issue.types.*.handle` fields manually in the generated config
- to get the expected behavior.
+> may not work because the older version of Jira API doesn't return the untranslated name for `issuetypes`. In that case,
+> you will have to fill in `epic.name`, `epic.link` and `issue.types.*.handle` fields manually in the generated config
+> to get the expected behavior.
See [FAQs](https://github.com/ankitpokhrel/jira-cli/discussions/categories/faqs) for frequently asked questions.
#### Authentication types
-The tool supports `basic`, `bearer` (Personal Access Token), and `mtls` (Client Certificates) authentication types. Basic auth is used by
+The tool supports `basic`, `bearer` (Personal Access Token), `mtls` (Client Certificates), and `oauth` (OAuth 3LO) authentication types. Basic auth is used by
default.
-* If you want to use PAT, you need to set `JIRA_AUTH_TYPE` as `bearer`.
-* If you want to use `mtls` run `jira init`. Select installation type `Local`, and then select authentication type as `mtls`.
- * In case `JIRA_API_TOKEN` variable is set it will be used together with `mtls`.
+- If you want to use PAT, you need to set `JIRA_AUTH_TYPE` as `bearer`.
+- If you want to use `mtls` run `jira init`. Select installation type `Local`, and then select authentication type as `mtls`.
+ - In case `JIRA_API_TOKEN` variable is set it will be used together with `mtls`.
+- If you want to use `oauth` run `jira init`. Select installation type `Cloud`, and then select authentication type as `oauth`.
#### Shell completion
+
Check `jira completion --help` for more info on setting up a bash/zsh shell completion.
#### Multiple projects
@@ -141,15 +146,19 @@ $ jira issue list -c ./local_jira_config.yaml
```
## Usage
+
The tool currently comes with an issue, epic, and sprint explorer. The flags are [POSIX-compliant](https://www.gnu.org/software/libc/manual/html_node/Argument-Syntax.html).
You can combine available flags in any order to create a unique query. For example, the command below will give you high priority issues created this month
with status `To Do` that are assigned to you and has the label `backend`.
+
```sh
jira issue list -yHigh -s"To Do" --created month -lbackend -a$(jira me)
```
### Navigation
+
The lists are displayed in an interactive UI by default.
+
- Use arrow keys or `j, k, h, l` characters to navigate through the list.
- Use `g` and `G` to quickly navigate to the top and bottom respectively.
- Use `CTRL + f` to scroll through a page downwards direction.
@@ -165,6 +174,7 @@ The lists are displayed in an interactive UI by default.
- Press `?` to open the help window.
### Resources
+
- [FAQs](https://github.com/ankitpokhrel/jira-cli/discussions/categories/faqs)
- [Introduction and Motivation](https://medium.com/@ankitpokhrel/introducing-jira-cli-the-missing-command-line-tool-for-atlassian-jira-fe44982cc1de)
- [Getting Started with JiraCLI](https://www.mslinn.com/blog/2022/08/12/jiracli.html)
@@ -173,10 +183,13 @@ The lists are displayed in an interactive UI by default.
> Like this tool? Checkout [similar tool for Shopify!](https://github.com/ankitpokhrel/shopctl)
## Commands
+
### Issue
+
Issues are displayed in an interactive table view by default. You can output the results in a plain view using the `--plain` flag.
#### List
+
The `list` command lets you search and navigate the issues. The issues are sorted by `created` field in descending order by default.
```sh
@@ -214,6 +227,7 @@ Check some more examples/use-cases below.
```sh
jira issue list -w
```
+
List issues assigned to me
@@ -221,6 +235,7 @@ jira issue list -w
```sh
jira issue list -a$(jira me)
```
+
List issues assigned to a user and are reported by another user
@@ -228,6 +243,7 @@ jira issue list -a$(jira me)
```sh
jira issue list -a"User A" -r"User B"
```
+
List issues assigned to me, is of high priority and is open
@@ -235,6 +251,7 @@ jira issue list -a"User A" -r"User B"
```sh
jira issue list -a$(jira me) -yHigh -sopen
```
+
List issues assigned to no one and are created this week
@@ -242,6 +259,7 @@ jira issue list -a$(jira me) -yHigh -sopen
```sh
jira issue list -ax --created week
```
+
List issues with resolution won't do
@@ -249,6 +267,7 @@ jira issue list -ax --created week
```sh
jira issue list -R"Won't do"
```
+
List issues whose status is not done and is created before 6 months and is assigned to someone
@@ -257,6 +276,7 @@ jira issue list -R"Won't do"
# Tilde (~) acts as a not operator
jira issue list -s~Done --created-before -24w -a~x
```
+
List issues created within an hour and updated in the last 30 minutes :stopwatch:
@@ -264,6 +284,7 @@ jira issue list -s~Done --created-before -24w -a~x
```sh
jira issue list --created -1h --updated -30m
```
+
Give me issues that are of high priority, are in progress, were created this month, and have given labels :fire:
@@ -271,13 +292,15 @@ jira issue list --created -1h --updated -30m
```sh
jira issue list -yHigh -s"In Progress" --created month -lbackend -l"high-prio"
```
+
Wait, what was that ticket I opened earlier today? :tired_face:
- ```sh
- jira issue list --history
- ```
+```sh
+jira issue list --history
+```
+
What was the first issue I ever reported on the current board? :thinking:
@@ -285,6 +308,7 @@ jira issue list -yHigh -s"In Progress" --created month -lbackend -l"high-prio"
```sh
jira issue list -r$(jira me) --reverse
```
+
What was the first bug I ever fixed in the current board? :beetle:
@@ -292,6 +316,7 @@ jira issue list -r$(jira me) --reverse
```sh
jira issue list -a$(jira me) -tBug sDone -rFixed --reverse
```
+
What issues did I report this week? :man_shrugging:
@@ -299,6 +324,7 @@ jira issue list -a$(jira me) -tBug sDone -rFixed --reverse
```sh
jira issue list -r$(jira me) --created week
```
+
Am I watching any tickets in project XYZ? :monocle_face:
@@ -306,9 +332,11 @@ jira issue list -r$(jira me) --created week
```sh
jira issue list -w -pXYZ
```
+
Navigate to the issue
@@ -704,6 +756,7 @@ jira open
```sh
jira open KEY-1
```
+
List all projects you have access to
@@ -711,6 +764,7 @@ jira open KEY-1
```sh
jira project list
```
+
List all boards in a project
@@ -718,9 +772,11 @@ jira project list
```sh
jira board list
```
+
Number of tickets per sprint
@@ -767,6 +824,7 @@ Sprint 2: 40
Sprint 1: 30
...
```
+
Number of unique assignee per sprint
@@ -787,12 +845,18 @@ Sprint 3: 5
Sprint 2: 4
Sprint 1: 3
```
+
You can close this window and return to the terminal.
+ + + + `)); err != nil { + errChan <- fmt.Errorf("failed to write response: %w", err) + return + } + + codeChan <- code + } else { + http.NotFound(w, r) + } + }), + } + + // Start server in goroutine + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errChan <- err + } + }() + + if openBrowser { + // Open browser for authorization + fmt.Printf("Opening browser for authorization...\n") + fmt.Printf("If the browser doesn't open automatically, please visit: %s\n", authURL) + + // Try to open browser + if err := browser.OpenURL(authURL); err != nil { + fmt.Printf("Could not open browser automatically: %v\n", err) + fmt.Printf("Please manually visit: %s\n", authURL) + } + + } + + // Wait for authorization code + select { + case code := <-codeChan: + // Shutdown server + ctx, cancel := context.WithTimeout(context.Background(), serverShutdownTimeout) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + fmt.Printf("Warning: failed to shutdown server: %v\n", err) + } + + // Exchange code for token + s.Stop() + s = cmdutil.Info("Exchanging authorization code for access token...") + defer s.Stop() + + token, err := oauthConfig.Exchange(context.Background(), code) + if err != nil { + return nil, fmt.Errorf("failed to exchange code for token: %w", err) + } + + return token, nil + + case err := <-errChan: + // Shutdown server + ctx, cancel := context.WithTimeout(context.Background(), serverShutdownTimeout) + defer cancel() + if shutdownErr := server.Shutdown(ctx); shutdownErr != nil { + fmt.Printf("Warning: failed to shutdown server: %v\n", shutdownErr) + } + return nil, fmt.Errorf("OAuth flow failed: %w", err) + + case <-time.After(httpTimeout): + // Shutdown server + ctx, cancel := context.WithTimeout(context.Background(), serverShutdownTimeout) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + fmt.Printf("Warning: failed to shutdown server: %v\n", err) + } + return nil, fmt.Errorf("OAuth flow timed out after %v", oauthTimeout) + } +} + +// getCloudID retrieves the Cloud ID for the authenticated user. +func getCloudID(url string, accessToken string) (string, error) { + s := cmdutil.Info("Fetching cloud ID...") + defer s.Stop() + + // Create HTTP client with bearer token + client := &http.Client{Timeout: httpClientTimeout} + + req, err := http.NewRequest("GET", url, http.NoBody) + if err != nil { + return "", err + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + fmt.Printf("Warning: failed to close response body: %v\n", closeErr) + } + }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to get accessible resources: status %d", resp.StatusCode) + } + + // Parse response to get cloud ID + var resourceResponse []struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Scopes []string `json:"scopes"` + AvatarURL string `json:"avatarUrl"` + } + + if err := json.NewDecoder(resp.Body).Decode(&resourceResponse); err != nil { + return "", fmt.Errorf("failed to decode accessible resources response: %w", err) + } + + if len(resourceResponse) == 0 { + return "", fmt.Errorf("no accessible resources found or cloud ID not found") + } + + return resourceResponse[0].ID, nil +} + +func getJiraConfigDir() (string, error) { + home, err := cmdutil.GetConfigHome() + if err != nil { + return "", err + } + return filepath.Join(home, ".jira"), nil +} diff --git a/pkg/oauth/oauth_test.go b/pkg/oauth/oauth_test.go new file mode 100644 index 00000000..7b3874b1 --- /dev/null +++ b/pkg/oauth/oauth_test.go @@ -0,0 +1,558 @@ +package oauth + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "golang.org/x/oauth2" + + "github.com/ankitpokhrel/jira-cli/pkg/utils" +) + +func TestGetJiraConfigDir(t *testing.T) { + // Save original environment + originalHome := os.Getenv("HOME") + originalXDG := os.Getenv("XDG_CONFIG_HOME") + defer func() { + t.Setenv("HOME", originalHome) + t.Setenv("XDG_CONFIG_HOME", originalXDG) + }() + + t.Run("uses XDG_CONFIG_HOME when set", func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/tmp/test-config") + t.Setenv("HOME", "/tmp/test-home") + + dir, err := getJiraConfigDir() + assert.NoError(t, err) + assert.Equal(t, "/tmp/test-config/.jira", dir) + }) + + t.Run("falls back to HOME/.config when XDG_CONFIG_HOME not set", func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("HOME", "/tmp/test-home") + + dir, err := getJiraConfigDir() + assert.NoError(t, err) + assert.Equal(t, "/tmp/test-home/.config/.jira", dir) + }) +} + +func TestOAuthSecrets(t *testing.T) { + t.Parallel() + + t.Run("IsExpired returns true for expired tokens", func(t *testing.T) { + t.Parallel() + secrets := &OAuthSecrets{ + AccessToken: "test-token", + Expiry: time.Now().Add(-time.Hour), // Expired 1 hour ago + } + assert.True(t, secrets.IsExpired()) + }) + + t.Run("IsExpired returns false for valid tokens", func(t *testing.T) { + t.Parallel() + secrets := &OAuthSecrets{ + AccessToken: "test-token", + Expiry: time.Now().Add(time.Hour), // Expires in 1 hour + } + assert.False(t, secrets.IsExpired()) + }) + + t.Run("IsValid returns true for valid tokens", func(t *testing.T) { + t.Parallel() + secrets := &OAuthSecrets{ + AccessToken: "test-token", + Expiry: time.Now().Add(time.Hour), // Expires in 1 hour + } + assert.True(t, secrets.IsValid()) + }) + + t.Run("IsValid returns false for expired tokens", func(t *testing.T) { + t.Parallel() + secrets := &OAuthSecrets{ + AccessToken: "test-token", + Expiry: time.Now().Add(-time.Hour), // Expired 1 hour ago + } + assert.False(t, secrets.IsValid()) + }) + + t.Run("IsValid returns false for empty tokens", func(t *testing.T) { + t.Parallel() + secrets := &OAuthSecrets{ + AccessToken: "", + Expiry: time.Now().Add(time.Hour), // Expires in 1 hour + } + assert.False(t, secrets.IsValid()) + }) +} + +func TestLoadOAuthSecrets(t *testing.T) { + t.Parallel() + + t.Run("loads OAuth secrets successfully", func(t *testing.T) { + t.Parallel() + // Create a temporary directory for testing + tempDir, err := os.MkdirTemp("", "oauth-test-*") + assert.NoError(t, err) + defer func() { + _ = os.RemoveAll(tempDir) + }() + + // Create test secrets + testSecrets := &OAuthSecrets{ + ClientSecret: "test-client-secret", + AccessToken: "test-access-token", + RefreshToken: "test-refresh-token", + TokenType: "Bearer", + Expiry: time.Now().Add(time.Hour), + } + + // Save secrets to temp directory + storage := utils.FileSystemStorage{BaseDir: tempDir} + err = utils.SaveJSON(storage, oauthSecretsFile, testSecrets) + assert.NoError(t, err) + + // Load secrets directly from the test directory + loadedSecrets, err := utils.LoadJSON[OAuthSecrets](storage, oauthSecretsFile) + assert.NoError(t, err) + assert.Equal(t, testSecrets.ClientSecret, loadedSecrets.ClientSecret) + assert.Equal(t, testSecrets.AccessToken, loadedSecrets.AccessToken) + assert.Equal(t, testSecrets.RefreshToken, loadedSecrets.RefreshToken) + assert.Equal(t, testSecrets.TokenType, loadedSecrets.TokenType) + assert.True(t, testSecrets.Expiry.Equal(loadedSecrets.Expiry)) + }) + + t.Run("returns error when secrets file doesn't exist", func(t *testing.T) { + t.Parallel() + // Create a temporary directory without any secrets file + tempDir, err := os.MkdirTemp("", "oauth-test-*") + assert.NoError(t, err) + defer func() { + _ = os.RemoveAll(tempDir) + }() + + storage := utils.FileSystemStorage{BaseDir: tempDir} + _, err = utils.LoadJSON[OAuthSecrets](storage, oauthSecretsFile) + assert.Error(t, err) + }) +} + +func TestGetCloudID(t *testing.T) { + t.Parallel() + + t.Run("successfully retrieves cloud ID", func(t *testing.T) { + t.Parallel() + expectedCloudID := "test-cloud-id-123" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request + assert.Equal(t, "GET", r.Method) + assert.Equal(t, "/oauth/token/accessible-resources", r.URL.Path) + assert.Equal(t, "Bearer test-access-token", r.Header.Get("Authorization")) + assert.Equal(t, "application/json", r.Header.Get("Accept")) + + // Return mock response + response := []map[string]interface{}{ + { + "id": expectedCloudID, + "name": "Test Site", + "url": "https://test.atlassian.net", + "scopes": []string{"read:jira-user", "read:jira-work"}, + "avatarUrl": "https://test.atlassian.net/avatar.png", + }, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + })) + defer server.Close() + + // Test with mock server - this requires refactoring the function to accept a custom URL + // For now, we'll test the error cases and create a separate testable function + cloudID, err := getCloudIDFromURL(server.URL+"/oauth/token/accessible-resources", "test-access-token") + assert.NoError(t, err) + assert.Equal(t, expectedCloudID, cloudID) + }) + + t.Run("handles HTTP error", func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + cloudID, err := getCloudIDFromURL(server.URL+"/oauth/token/accessible-resources", "invalid-token") + assert.Error(t, err) + assert.Empty(t, cloudID) + assert.Contains(t, err.Error(), "failed to get accessible resources: status 401") + }) + + t.Run("handles invalid JSON response", func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write([]byte("invalid json")); err != nil { + t.Errorf("Failed to write response: %v", err) + } + })) + defer server.Close() + + cloudID, err := getCloudIDFromURL(server.URL+"/oauth/token/accessible-resources", "test-token") + assert.Error(t, err) + assert.Empty(t, cloudID) + assert.Contains(t, err.Error(), "failed to decode accessible resources response") + }) + + t.Run("handles empty response", func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode([]map[string]interface{}{}); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + })) + defer server.Close() + + cloudID, err := getCloudIDFromURL(server.URL+"/oauth/token/accessible-resources", "test-token") + assert.Error(t, err) + assert.Empty(t, cloudID) + assert.Contains(t, err.Error(), "no accessible resources found") + }) +} + +// getCloudIDFromURL is a helper function to make getCloudID testable. +func getCloudIDFromURL(url, accessToken string) (string, error) { + client := &http.Client{Timeout: 30 * time.Second} + + req, err := http.NewRequest("GET", url, http.NoBody) + if err != nil { + return "", err + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to get accessible resources: status %d", resp.StatusCode) + } + + var resourceResponse []struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Scopes []string `json:"scopes"` + AvatarURL string `json:"avatarUrl"` + } + + if err := json.NewDecoder(resp.Body).Decode(&resourceResponse); err != nil { + return "", fmt.Errorf("failed to decode accessible resources response: %w", err) + } + + if len(resourceResponse) == 0 { + return "", fmt.Errorf("no accessible resources found or cloud ID not found") + } + + return resourceResponse[0].ID, nil +} + +func TestConfig(t *testing.T) { + t.Parallel() + + t.Run("creates config with all required fields", func(t *testing.T) { + t.Parallel() + config := &OAuthConfig{ + ClientID: "test-client-id", + ClientSecret: "test-secret", + RedirectURI: "http://localhost:9876/callback", + Scopes: []string{"read:jira-user", "read:jira-work"}, + } + + assert.Equal(t, "test-client-id", config.ClientID) + assert.Equal(t, "test-secret", config.ClientSecret) + assert.Equal(t, "http://localhost:9876/callback", config.RedirectURI) + assert.Contains(t, config.Scopes, "read:jira-user") + assert.Contains(t, config.Scopes, "read:jira-work") + }) +} + +func TestConfigureTokenResponse(t *testing.T) { + t.Parallel() + + t.Run("creates token response with all required fields", func(t *testing.T) { + t.Parallel() + response := &ConfigureTokenResponse{ + AccessToken: "test-access-token", + RefreshToken: "test-refresh-token", + CloudID: "test-cloud-id", + } + + assert.Equal(t, "test-access-token", response.AccessToken) + assert.Equal(t, "test-refresh-token", response.RefreshToken) + assert.Equal(t, "test-cloud-id", response.CloudID) + }) +} + +func TestPerformOAuthFlow_ErrorCases(t *testing.T) { + t.Run("handles timeout", func(t *testing.T) { + config := &OAuthConfig{ + ClientID: "test-client-id", + ClientSecret: "test-secret", + RedirectURI: "http://localhost:9876/callback", + Scopes: []string{"read:jira-user"}, + } + + // Create a version of performOAuthFlow with a shorter timeout for testing + token, err := performOAuthFlow(config, 100*time.Millisecond, false) + assert.Error(t, err) + assert.Nil(t, token) + assert.Contains(t, err.Error(), "OAuth flow timed out") + }) + + t.Run("handles server startup error", func(t *testing.T) { + config := &OAuthConfig{ + ClientID: "test-client-id", + ClientSecret: "test-secret", + RedirectURI: "http://localhost:9876/callback", + Scopes: []string{"read:jira-user"}, + } + + // Start a server on the same port to cause a conflict + conflictServer := &http.Server{ + Addr: defaultPort, + ReadHeaderTimeout: readHeaderTimeout, + } + go func() { + _ = conflictServer.ListenAndServe() + }() + defer func() { + _ = conflictServer.Close() + }() + + // Wait a bit for the server to start + time.Sleep(100 * time.Millisecond) + + // This should fail due to port conflict + token, err := performOAuthFlow(config, 1*time.Second, false) + // The error might be about port conflict or timeout, both are acceptable + assert.Error(t, err) + assert.Nil(t, token) + }) +} + +func TestConstants(t *testing.T) { + t.Parallel() + + t.Run("verifies file permission constants", func(t *testing.T) { + t.Parallel() + assert.Equal(t, 0o700, int(utils.OWNER_ONLY)) + assert.Equal(t, 0o600, int(utils.OWNER_READ_WRITE)) + }) +} + +func TestOAuthFlowIntegration(t *testing.T) { + t.Parallel() + + t.Run("handles callback with authorization code", func(t *testing.T) { + t.Parallel() + // Create a mock OAuth server + mockOAuthServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/oauth/token" { + // Mock token exchange + token := map[string]interface{}{ + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "token_type": "Bearer", + "expires_in": 3600, + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(token); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + } + })) + defer mockOAuthServer.Close() + + // Create config with mock server + config := &OAuthConfig{ + ClientID: "test-client-id", + ClientSecret: "test-secret", + RedirectURI: "http://localhost:9876/callback", + Scopes: []string{"read:jira-user"}, + } + + // Test the OAuth configuration creation + oauthConfig := &oauth2.Config{ + ClientID: config.ClientID, + ClientSecret: config.ClientSecret, + RedirectURL: config.RedirectURI, + Scopes: config.Scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: jiraAuthURL, + TokenURL: mockOAuthServer.URL + "/oauth/token", + }, + } + + // Test authorization URL generation + verifier := oauth2.GenerateVerifier() + authURL := oauthConfig.AuthCodeURL(verifier, oauth2.AccessTypeOffline) + + assert.Contains(t, authURL, jiraAuthURL) + assert.Contains(t, authURL, "client_id=test-client-id") + assert.Contains(t, authURL, "redirect_uri=http%3A%2F%2Flocalhost%3A9876%2Fcallback") + assert.Contains(t, authURL, "scope=read%3Ajira-user") + }) + + t.Run("handles callback without authorization code", func(t *testing.T) { + t.Parallel() + // Test callback handler + codeChan := make(chan string, 1) + errChan := make(chan error, 1) + + handler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + if r.URL.Path == callbackPath { + code := r.URL.Query().Get("code") + if code == "" { + errChan <- fmt.Errorf("no authorization code received") + return + } + codeChan <- code + } + }) + + // Create test request without code + req := httptest.NewRequest("GET", "http://localhost:9876/callback", http.NoBody) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + select { + case err := <-errChan: + assert.Error(t, err) + assert.Contains(t, err.Error(), "no authorization code received") + case <-time.After(100 * time.Millisecond): + t.Error("Expected error but got timeout") + } + }) + + t.Run("handles callback with authorization code", func(t *testing.T) { + t.Parallel() + codeChan := make(chan string, 1) + errChan := make(chan error, 1) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == callbackPath { + code := r.URL.Query().Get("code") + if code == "" { + errChan <- fmt.Errorf("no authorization code received") + return + } + + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(`You can close this window and return to the terminal.
+ + + + `)) + } + } + }) + + req := httptest.NewRequest("GET", "http://localhost:9876/callback?code=test-code", http.NoBody) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "text/html", w.Header().Get("Content-Type")) + assert.Contains(t, w.Body.String(), "Authorization successful!") + assert.Contains(t, w.Body.String(), "window.close()") + }) +} + +func TestGetOAuth2Config(t *testing.T) { + t.Parallel() + + t.Run("creates OAuth2 config with all parameters", func(t *testing.T) { + t.Parallel() + clientID := "test-client-id" + clientSecret := "test-client-secret" + redirectURI := "http://localhost:9876/callback" + scopes := []string{"read:jira-user", "read:jira-work"} + + config := GetOAuth2Config(clientID, clientSecret, redirectURI, scopes) + + assert.Equal(t, clientID, config.ClientID) + assert.Equal(t, clientSecret, config.ClientSecret) + assert.Equal(t, redirectURI, config.RedirectURL) + assert.Equal(t, scopes, config.Scopes) + assert.Equal(t, jiraAuthURL, config.Endpoint.AuthURL) + assert.Equal(t, jiraTokenURL, config.Endpoint.TokenURL) + }) + + t.Run("uses default scopes when nil", func(t *testing.T) { + t.Parallel() + config := GetOAuth2Config("test-client-id", "test-client-secret", "http://localhost:9876/callback", nil) + + assert.Equal(t, defaultScopes, config.Scopes) + }) + + t.Run("uses default redirect URI when empty", func(t *testing.T) { + t.Parallel() + config := GetOAuth2Config("test-client-id", "test-client-secret", "", []string{"read:jira-user"}) + + assert.Equal(t, defaultRedirectURI, config.RedirectURL) + }) +} diff --git a/pkg/oauth/tokens.go b/pkg/oauth/tokens.go new file mode 100644 index 00000000..bc64a686 --- /dev/null +++ b/pkg/oauth/tokens.go @@ -0,0 +1,128 @@ +package oauth + +import ( + "context" + "fmt" + "time" + + "github.com/ankitpokhrel/jira-cli/pkg/utils" + "golang.org/x/oauth2" +) + +// OAuthSecrets holds all OAuth secrets in a single structure. +type OAuthSecrets struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + Expiry time.Time `json:"expiry"` +} + +// PersistentTokenSource implements oauth2.TokenSource with automatic token persistence. +type PersistentTokenSource struct { + clientID string + clientSecret string + storage utils.Storage +} + +// IsExpired checks if the access token is expired. +func (o *OAuthSecrets) IsExpired() bool { + return time.Now().After(o.Expiry) +} + +// IsValid checks if the OAuth secrets are valid and not expired. +func (o *OAuthSecrets) IsValid() bool { + return o.AccessToken != "" && !o.IsExpired() +} + +// ToOAuth2Token converts OAuthSecrets to oauth2.Token. +func (o *OAuthSecrets) ToOAuth2Token() *oauth2.Token { + return &oauth2.Token{ + AccessToken: o.AccessToken, + RefreshToken: o.RefreshToken, + TokenType: o.TokenType, + Expiry: o.Expiry, + } +} + +// FromOAuth2Token updates OAuthSecrets from oauth2.Token. +func (o *OAuthSecrets) FromOAuth2Token(token *oauth2.Token) { + o.AccessToken = token.AccessToken + o.RefreshToken = token.RefreshToken + o.TokenType = token.TokenType + o.Expiry = token.Expiry +} + +// NewPersistentTokenSource creates a new TokenSource that persists tokens. +func NewPersistentTokenSource(clientID, clientSecret string) (*PersistentTokenSource, error) { + jiraDir, err := getJiraConfigDir() + if err != nil { + return nil, fmt.Errorf("failed to get Jira config directory: %w", err) + } + + storage := utils.FileSystemStorage{BaseDir: jiraDir} + return &PersistentTokenSource{ + clientID: clientID, + clientSecret: clientSecret, + storage: storage, + }, nil +} + +// Token implements oauth2.TokenSource interface. +func (pts *PersistentTokenSource) Token() (*oauth2.Token, error) { + // Load current token from storage + secrets, err := utils.LoadJSON[OAuthSecrets](pts.storage, oauthSecretsFile) + if err != nil { + return nil, fmt.Errorf("failed to load OAuth secrets: %w", err) + } + + token := secrets.ToOAuth2Token() + + // If token is still valid, return it + if token.Valid() { + return token, nil + } + + // Token needs refresh - create OAuth2 config for refresh + oauthConfig := &oauth2.Config{ + ClientID: pts.clientID, + ClientSecret: pts.clientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: jiraAuthURL, + TokenURL: jiraTokenURL, + }, + } + + // Refresh the token + refreshedToken, err := oauthConfig.TokenSource(context.Background(), token).Token() + if err != nil { + return nil, fmt.Errorf("failed to refresh OAuth token: %w", err) + } + + // Save the refreshed token + secrets.FromOAuth2Token(refreshedToken) + if err := utils.SaveJSON(pts.storage, oauthSecretsFile, &secrets); err != nil { + // Log error but don't fail the request - we still have a valid token + fmt.Printf("Warning: failed to save refreshed OAuth token: %v\n", err) + } + + return refreshedToken, nil +} + +// LoadOAuth2TokenSource creates a TokenSource from stored OAuth secrets. +func LoadOAuth2TokenSource() (oauth2.TokenSource, error) { + // Load OAuth secrets to get client credentials + secrets, err := LoadOAuthSecrets() + if err != nil { + return nil, fmt.Errorf("failed to load OAuth secrets: %w", err) + } + + // Create persistent token source + tokenSource, err := NewPersistentTokenSource(secrets.ClientID, secrets.ClientSecret) + if err != nil { + return nil, fmt.Errorf("failed to create token source: %w", err) + } + + return tokenSource, nil +} diff --git a/pkg/utils/storage.go b/pkg/utils/storage.go new file mode 100644 index 00000000..a44e753d --- /dev/null +++ b/pkg/utils/storage.go @@ -0,0 +1,58 @@ +package utils + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type Storage interface { + Save(key string, value []byte) error + Load(key string) ([]byte, error) +} + +const ( + OWNER_ONLY = 0o700 + OWNER_READ_WRITE = 0o600 +) + +// FileSystemStorage implements Storage interface for filesystem operations. +type FileSystemStorage struct { + // BaseDir is the directory where the storage will be saved + BaseDir string +} + +func (fs FileSystemStorage) Save(key string, value []byte) error { + if err := os.MkdirAll(fs.BaseDir, OWNER_ONLY); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + filePath := filepath.Join(fs.BaseDir, key) + return os.WriteFile(filePath, value, OWNER_READ_WRITE) +} + +func (fs FileSystemStorage) Load(key string) ([]byte, error) { + filePath := filepath.Join(fs.BaseDir, key) + return os.ReadFile(filePath) +} + +// SaveJSON saves a typed value as JSON using the provided storage. +func SaveJSON[T any](storage Storage, key string, value T) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + return storage.Save(key, data) +} + +// LoadJSON loads a typed value from JSON using the provided storage. +func LoadJSON[T any](storage Storage, key string) (T, error) { + var result T + data, err := storage.Load(key) + if err != nil { + return result, err + } + err = json.Unmarshal(data, &result) + return result, err +} diff --git a/pkg/utils/storage_test.go b/pkg/utils/storage_test.go new file mode 100644 index 00000000..5996e018 --- /dev/null +++ b/pkg/utils/storage_test.go @@ -0,0 +1,134 @@ +package utils + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFileSystemStorage(t *testing.T) { + t.Run("creates directory and saves file", func(t *testing.T) { + // Create temporary directory + tempDir := t.TempDir() + storage := FileSystemStorage{BaseDir: tempDir} + + // Test saving + err := storage.Save("test-key", []byte("test-value")) + assert.NoError(t, err) + + // Verify file exists and has correct content + filePath := filepath.Join(tempDir, "test-key") + content, err := os.ReadFile(filePath) + assert.NoError(t, err) + assert.Equal(t, "test-value", string(content)) + + // Verify file permissions + info, err := os.Stat(filePath) + assert.NoError(t, err) + // File permissions on Unix systems can vary, so we just check that it's restrictive + assert.True(t, info.Mode().Perm() <= 0o600) + }) + + t.Run("loads file content", func(t *testing.T) { + // Create temporary directory + tempDir := t.TempDir() + storage := FileSystemStorage{BaseDir: tempDir} + + // Create test file + testContent := "test-content" + filePath := filepath.Join(tempDir, "test-key") + err := os.WriteFile(filePath, []byte(testContent), OWNER_READ_WRITE) + assert.NoError(t, err) + + // Test loading + content, err := storage.Load("test-key") + assert.NoError(t, err) + assert.Equal(t, testContent, string(content)) + }) + + t.Run("handles non-existent file", func(t *testing.T) { + tempDir := t.TempDir() + storage := FileSystemStorage{BaseDir: tempDir} + + // Test loading non-existent file + content, err := storage.Load("non-existent-key") + assert.Error(t, err) + assert.Nil(t, content) + }) + + t.Run("handles directory creation failure", func(t *testing.T) { + // Use a path that cannot be created (e.g., under a file instead of directory) + tempDir := t.TempDir() + + // Create a file where we want to create a directory + filePath := filepath.Join(tempDir, "blocking-file") + err := os.WriteFile(filePath, []byte("content"), 0o644) + assert.NoError(t, err) + + // Try to create storage with the file as base directory + storage := FileSystemStorage{BaseDir: filePath} + + err = storage.Save("test-key", []byte("test-value")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to create directory") + }) +} + +func TestStorageOperations(t *testing.T) { + t.Run("storage save and load operations", func(t *testing.T) { + storage := &mockStorage{} + + err := storage.Save("test-key", []byte("test-value")) + assert.NoError(t, err) + assert.Equal(t, "test-key", storage.savedKey) + assert.Equal(t, []byte("test-value"), storage.savedValue) + }) + + t.Run("storage load with error", func(t *testing.T) { + storage := &mockStorage{ + loadError: fmt.Errorf("storage error"), + } + + _, err := storage.Load("test-key") + assert.Error(t, err) + assert.Contains(t, err.Error(), "storage error") + }) + + t.Run("storage load success", func(t *testing.T) { + storage := &mockStorage{ + loadReturn: []byte("loaded-value"), + } + + value, err := storage.Load("test-key") + assert.NoError(t, err) + assert.Equal(t, []byte("loaded-value"), value) + }) +} + +// mockStorage is a mock storage for testing. +type mockStorage struct { + savedKey string + savedValue []byte + loadReturn []byte + loadError error + saveError error +} + +func (m *mockStorage) Save(key string, value []byte) error { + if m.saveError != nil { + return m.saveError + } + m.savedKey = key + m.savedValue = value + return nil +} + +func (m *mockStorage) Load(_ string) ([]byte, error) { + if m.loadError != nil { + return nil, m.loadError + } + return m.loadReturn, nil +}