From ebf0ce0176d9fa847d48f4c72120c11800769ca0 Mon Sep 17 00:00:00 2001 From: Ismar Iljazovic Date: Fri, 28 Nov 2025 11:55:27 +0100 Subject: [PATCH] Add cookie-based authentication support This adds a new 'cookie' auth type for on-premise Jira installations that use SSO, reverse proxy, or client certificate authentication. Changes: - Add AuthTypeCookie constant to pkg/jira/types.go - Add JSESSIONID cookie handling in pkg/jira/client.go - Add 'cookie' option to auth type selection in config generator - Add configureCookie() function that validates and stores session cookie - Add 'jira refresh' command for easy session cookie renewal - Skip token check for cookie auth in root command - Update README with cookie auth documentation - Add tests for cookie auth in client_test.go --- README.md | 9 ++- internal/cmd/refresh/refresh.go | 92 +++++++++++++++++++++++++++++++ internal/cmd/root/root.go | 8 ++- internal/config/generator.go | 97 ++++++++++++++++++++++++++++++--- pkg/jira/client.go | 7 +++ pkg/jira/client_test.go | 56 +++++++++++++++++++ pkg/jira/types.go | 2 + 7 files changed, 260 insertions(+), 11 deletions(-) create mode 100644 internal/cmd/refresh/refresh.go diff --git a/README.md b/README.md index 28a16856..8c16879c 100644 --- a/README.md +++ b/README.md @@ -119,12 +119,17 @@ See [FAQs](https://github.com/ankitpokhrel/jira-cli/discussions/categories/faqs) #### Authentication types -The tool supports `basic`, `bearer` (Personal Access Token), and `mtls` (Client Certificates) authentication types. Basic auth is used by -default. +The tool supports `basic`, `bearer` (Personal Access Token), `mtls` (Client Certificates), and `cookie` (Session Cookie) 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 your Jira is behind SSO, a reverse proxy, or requires client certificate authentication, use `cookie` auth: + 1. Run `jira init`, select `Local` installation, then select `cookie` as authentication type. + 2. Sign in to Jira in your browser (authenticate via SSO/certificate as needed). + 3. Copy the `JSESSIONID` cookie value from your browser's DevTools. + 4. Paste it when prompted. The cookie is validated and stored in your system keychain. + * When your session expires, run `jira refresh` to update the cookie without re-running full setup. #### Shell completion Check `jira completion --help` for more info on setting up a bash/zsh shell completion. diff --git a/internal/cmd/refresh/refresh.go b/internal/cmd/refresh/refresh.go new file mode 100644 index 00000000..98140bc6 --- /dev/null +++ b/internal/cmd/refresh/refresh.go @@ -0,0 +1,92 @@ +package refresh + +import ( + "fmt" + + "github.com/AlecAivazis/survey/v2" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/zalando/go-keyring" + + "github.com/ankitpokhrel/jira-cli/internal/cmdutil" + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +// NewCmdRefresh is a refresh command. +func NewCmdRefresh() *cobra.Command { + return &cobra.Command{ + Use: "refresh", + Short: "Refresh session cookie for cookie-based authentication", + Long: `Refresh session cookie for cookie-based authentication. + +This command is only applicable when using 'cookie' auth type. +It allows you to update your JSESSIONID without re-running the full 'jira init' setup.`, + Run: refresh, + } +} + +func refresh(cmd *cobra.Command, _ []string) { + authType := viper.GetString("auth_type") + if authType != string(jira.AuthTypeCookie) { + cmdutil.Failed("This command is only for cookie-based authentication (current auth_type: %s)", authType) + return + } + + server := viper.GetString("server") + login := viper.GetString("login") + + if server == "" || login == "" { + cmdutil.Failed("Missing server or login in config. Please run 'jira init' first.") + return + } + + fmt.Println("Refresh session cookie for", server) + fmt.Println() + fmt.Println("1. Open", server, "in a browser") + fmt.Println("2. Sign in (authenticate via SSO/certificate as needed)") + fmt.Println("3. Open browser DevTools (F12) → Application/Storage → Cookies") + fmt.Println("4. Find the cookie named 'JSESSIONID' and copy its value") + fmt.Println() + + var sessionCookie string + prompt := &survey.Password{ + Message: "Paste JSESSIONID value:", + Help: "The session cookie will be validated and stored securely in your system keychain", + } + + if err := survey.AskOne(prompt, &sessionCookie, survey.WithValidator(survey.Required)); err != nil { + cmdutil.Failed("Failed to read input: %s", err.Error()) + return + } + + // Validate cookie + s := cmdutil.Info("Validating session cookie...") + + client := jira.NewClient(jira.Config{ + Server: server, + APIToken: sessionCookie, + AuthType: &[]jira.AuthType{jira.AuthTypeCookie}[0], + }) + + me, err := client.Me() + if err != nil { + s.Stop() + cmdutil.Failed("Failed to validate cookie: %s", err.Error()) + return + } + s.Stop() + + // Verify it's the same user + if me.Login != login { + cmdutil.Failed("Cookie belongs to user '%s' but config expects '%s'", me.Login, login) + return + } + + // Store in keychain + if err := keyring.Set("jira-cli", login, sessionCookie); err != nil { + cmdutil.Failed("Failed to store session cookie in keychain: %s", err.Error()) + return + } + + cmdutil.Success("Session refreshed for %s (%s)", me.Name, me.Login) +} diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index 85cb9ab1..67b1f0ac 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -17,6 +17,7 @@ import ( "github.com/ankitpokhrel/jira-cli/internal/cmd/me" "github.com/ankitpokhrel/jira-cli/internal/cmd/open" "github.com/ankitpokhrel/jira-cli/internal/cmd/project" + "github.com/ankitpokhrel/jira-cli/internal/cmd/refresh" "github.com/ankitpokhrel/jira-cli/internal/cmd/release" "github.com/ankitpokhrel/jira-cli/internal/cmd/serverinfo" "github.com/ankitpokhrel/jira-cli/internal/cmd/sprint" @@ -84,8 +85,9 @@ func NewCmdRoot() *cobra.Command { return } - // mTLS doesn't need Jira API Token. - if viper.GetString("auth_type") != string(jira.AuthTypeMTLS) { + // mTLS and cookie auth don't need token check here (retrieved from env/keychain). + authType := viper.GetString("auth_type") + if authType != string(jira.AuthTypeMTLS) && authType != string(jira.AuthTypeCookie) { checkForJiraToken(viper.GetString("server"), viper.GetString("login")) } @@ -140,6 +142,7 @@ func addChildCommands(cmd *cobra.Command) { version.NewCmdVersion(), release.NewCmdRelease(), man.NewCmdMan(), + refresh.NewCmdRefresh(), ) } @@ -151,6 +154,7 @@ func cmdRequireToken(cmd string) bool { "version", "completion", "man", + "refresh", } return !slices.Contains(allowList, cmd) } diff --git a/internal/config/generator.go b/internal/config/generator.go index 6688097f..9ebba7a0 100644 --- a/internal/config/generator.go +++ b/internal/config/generator.go @@ -11,6 +11,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/core" "github.com/spf13/viper" + "github.com/zalando/go-keyring" "github.com/ankitpokhrel/jira-cli/api" "github.com/ankitpokhrel/jira-cli/internal/cmdutil" @@ -171,6 +172,12 @@ func (c *JiraCLIConfigGenerator) Generate() (string, error) { } } + if c.value.authType == jira.AuthTypeCookie { + if err := c.configureCookie(); err != nil { + return "", err + } + } + if err := c.configureServerAndLoginDetails(); err != nil { return "", err } @@ -229,10 +236,11 @@ func (c *JiraCLIConfigGenerator) configureLocalAuthType() error { if c.usrCfg.AuthType == "" { qs := &survey.Select{ Message: "Authentication type:", - Help: `Authentication type coud be: basic (login), bearer (PAT) or mtls (client certs) + Help: `Authentication type could be: basic (login), bearer (PAT), mtls (client certs), or cookie (session) ? If you are using your login credentials, the auth type is probably 'basic' (most common for local installation) -? If you are using a personal access token, the auth type is probably 'bearer'`, - Options: []string{"basic", "bearer", "mtls"}, +? If you are using a personal access token, the auth type is probably 'bearer' +? If your Jira uses SSO, reverse proxy, or client certificates, you may need 'cookie' auth`, + Options: []string{"basic", "bearer", "mtls", "cookie"}, Default: "basic", } if err := survey.AskOne(qs, &authType); err != nil { @@ -245,6 +253,8 @@ func (c *JiraCLIConfigGenerator) configureLocalAuthType() error { c.value.authType = jira.AuthTypeBearer case jira.AuthTypeMTLS.String(): c.value.authType = jira.AuthTypeMTLS + case jira.AuthTypeCookie.String(): + c.value.authType = jira.AuthTypeCookie default: c.value.authType = jira.AuthTypeBasic } @@ -301,14 +311,81 @@ func (c *JiraCLIConfigGenerator) configureMTLS() error { return nil } +func (c *JiraCLIConfigGenerator) configureCookie() error { + // Need server URL first to validate cookie and fetch username + if c.value.server == "" { + var server string + prompt := &survey.Input{ + Message: "Link to Jira server:", + Help: "This is a link to your jira server, eg: https://company.atlassian.net", + } + if err := survey.AskOne(prompt, &server, survey.WithValidator(survey.Required)); err != nil { + return err + } + c.value.server = strings.TrimSpace(server) + } + + fmt.Println("\nCookie-based authentication setup:") + fmt.Println("1. Open", c.value.server, "in a browser") + fmt.Println("2. Sign in (you may need to authenticate with SSO/certificate)") + fmt.Println("3. Open browser DevTools (F12) → Application/Storage → Cookies") + fmt.Println("4. Find the cookie named 'JSESSIONID' and copy its value") + fmt.Println() + + var sessionCookie string + prompt := &survey.Password{ + Message: "Paste JSESSIONID value:", + Help: "The session cookie will be validated and stored securely in your system keychain", + } + + if err := survey.AskOne(prompt, &sessionCookie, survey.WithValidator(survey.Required)); err != nil { + return err + } + + // Validate cookie and fetch username + s := cmdutil.Info("Validating session cookie...") + defer s.Stop() + + client := jira.NewClient(jira.Config{ + Server: c.value.server, + APIToken: sessionCookie, + AuthType: &[]jira.AuthType{jira.AuthTypeCookie}[0], + }) + + me, err := client.Me() + if err != nil { + s.Stop() + return fmt.Errorf("failed to validate cookie: %w", err) + } + + c.value.login = me.Login + s.Stop() + + // Store in keychain + if err := keyring.Set("jira-cli", c.value.login, sessionCookie); err != nil { + return fmt.Errorf("failed to store session cookie in keychain: %w", err) + } + + cmdutil.Success(fmt.Sprintf("Authenticated as %s (%s)", me.Name, me.Login)) + cmdutil.Warn("Note: Session cookies expire. Run 'jira refresh' when needed.") + + return nil +} + //nolint:gocyclo func (c *JiraCLIConfigGenerator) configureServerAndLoginDetails() error { var qs []*survey.Question - c.value.server = c.usrCfg.Server - c.value.login = c.usrCfg.Login + // Only set from user config if not already set (e.g., by cookie config) + if c.value.server == "" { + c.value.server = c.usrCfg.Server + } + if c.value.login == "" { + c.value.login = c.usrCfg.Login + } - if c.usrCfg.Server == "" { + // Skip server prompt if already set + if c.value.server == "" { qs = append(qs, &survey.Question{ Name: "server", Prompt: &survey.Input{ @@ -335,7 +412,8 @@ func (c *JiraCLIConfigGenerator) configureServerAndLoginDetails() error { }) } - if c.usrCfg.Login == "" { + // Skip login prompt for cookie auth (username already fetched from API) + if c.usrCfg.Login == "" && c.value.authType != jira.AuthTypeCookie { switch c.value.installation { case jira.InstallationTypeCloud: qs = append(qs, &survey.Question{ @@ -408,6 +486,11 @@ func (c *JiraCLIConfigGenerator) configureServerAndLoginDetails() error { } } + // Skip verification for cookie auth (already validated during cookie config) + if c.value.authType == jira.AuthTypeCookie { + return nil + } + return c.verifyLoginDetails(c.value.server, c.value.login) } diff --git a/pkg/jira/client.go b/pkg/jira/client.go index c6435dbc..e513b4f1 100644 --- a/pkg/jira/client.go +++ b/pkg/jira/client.go @@ -281,6 +281,13 @@ func (c *Client) request(ctx context.Context, method, endpoint string, body []by } case string(AuthTypeBearer): req.Header.Add("Authorization", "Bearer "+c.token) + case string(AuthTypeCookie): + if c.token != "" { + req.AddCookie(&http.Cookie{ + Name: "JSESSIONID", + Value: c.token, + }) + } case string(AuthTypeBasic): req.SetBasicAuth(c.login, c.token) } diff --git a/pkg/jira/client_test.go b/pkg/jira/client_test.go index cc5abcde..691063eb 100644 --- a/pkg/jira/client_test.go +++ b/pkg/jira/client_test.go @@ -207,3 +207,59 @@ func TestDeleteV2(t *testing.T) { _ = resp.Body.Close() } + +func TestGetWithCookieAuth(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/myself", r.URL.Path) + + // Verify JSESSIONID cookie is set + cookie, err := r.Cookie("JSESSIONID") + assert.NoError(t, err) + assert.Equal(t, "test-session-id", cookie.Value) + + // Verify no Authorization header is set + assert.Empty(t, r.Header.Get("Authorization")) + + w.WriteHeader(200) + })) + defer server.Close() + + authType := AuthTypeCookie + client := NewClient(Config{ + Server: server.URL, + APIToken: "test-session-id", + AuthType: &authType, + }, WithTimeout(3*time.Second)) + + resp, err := client.Get(context.Background(), "/myself", nil) + + assert.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + + _ = resp.Body.Close() +} + +func TestGetWithCookieAuthEmptyToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify no JSESSIONID cookie is set when token is empty + _, err := r.Cookie("JSESSIONID") + assert.Error(t, err) + + w.WriteHeader(200) + })) + defer server.Close() + + authType := AuthTypeCookie + client := NewClient(Config{ + Server: server.URL, + APIToken: "", + AuthType: &authType, + }, WithTimeout(3*time.Second)) + + resp, err := client.Get(context.Background(), "/myself", nil) + + assert.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + + _ = resp.Body.Close() +} diff --git a/pkg/jira/types.go b/pkg/jira/types.go index e1c17719..82edbc61 100644 --- a/pkg/jira/types.go +++ b/pkg/jira/types.go @@ -11,6 +11,8 @@ const ( AuthTypeBearer AuthType = "bearer" // AuthTypeMTLS is a mTLS auth. AuthTypeMTLS AuthType = "mtls" + // AuthTypeCookie is a session cookie auth. + AuthTypeCookie AuthType = "cookie" ) // AuthType is a jira authentication type.