Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
92 changes: 92 additions & 0 deletions internal/cmd/refresh/refresh.go
Original file line number Diff line number Diff line change
@@ -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)
}
8 changes: 6 additions & 2 deletions internal/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"))
}

Expand Down Expand Up @@ -140,6 +142,7 @@ func addChildCommands(cmd *cobra.Command) {
version.NewCmdVersion(),
release.NewCmdRelease(),
man.NewCmdMan(),
refresh.NewCmdRefresh(),
)
}

Expand All @@ -151,6 +154,7 @@ func cmdRequireToken(cmd string) bool {
"version",
"completion",
"man",
"refresh",
}
return !slices.Contains(allowList, cmd)
}
Expand Down
97 changes: 90 additions & 7 deletions internal/config/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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{
Expand All @@ -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{
Expand Down Expand Up @@ -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)
}

Expand Down
7 changes: 7 additions & 0 deletions pkg/jira/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
56 changes: 56 additions & 0 deletions pkg/jira/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
2 changes: 2 additions & 0 deletions pkg/jira/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down