Skip to content

Commit

Permalink
[feat] Add mtls authentication for client certificate auth (#615)
Browse files Browse the repository at this point in the history
* Persist auth type in config file
* Update `jira init` to configure `mtls`
* Update README with instructions
  • Loading branch information
markhatch committed Sep 23, 2023
1 parent 1783a0b commit 86bdded
Show file tree
Hide file tree
Showing 6 changed files with 188 additions and 13 deletions.
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,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 `Local`, and provide the required details to generate a config file required
for the tool.
- If you want to use `mtls` (client certificates), select auth type `mtls` and provide the CA Cert, client Key, and client cert.

**Note:** 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,
Expand All @@ -95,8 +96,11 @@ See [FAQs](https://github.com/ankitpokhrel/jira-cli/discussions/categories/faqs)

#### Authentication types

The tool supports `basic` and `bearer` (Personal Access Token) authentication types at the moment. Basic auth is used by
default. If you want to use PAT, you need to set `JIRA_AUTH_TYPE` as `bearer`.
The tool supports `basic`, `bearer` (Personal Access Token), and `mtls` (Client Certificates) 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`.

#### Shell completion
Check `jira completion --help` for more info on setting up a bash/zsh shell completion.
Expand Down
12 changes: 12 additions & 0 deletions api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ func Client(config jira.Config) *jira.Client {
config.Insecure = &insecure
}

// MTLS

if config.MTLSConfig.CaCert == "" {
config.MTLSConfig.CaCert = viper.GetString("mtls.ca_cert")
}
if config.MTLSConfig.ClientCert == "" {
config.MTLSConfig.ClientCert = viper.GetString("mtls.client_cert")
}
if config.MTLSConfig.ClientKey == "" {
config.MTLSConfig.ClientKey = viper.GetString("mtls.client_key")
}

jiraClient = jira.NewClient(
config,
jira.WithTimeout(clientTimeout),
Expand Down
6 changes: 5 additions & 1 deletion internal/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/ankitpokhrel/jira-cli/internal/cmd/version"
"github.com/ankitpokhrel/jira-cli/internal/cmdutil"
jiraConfig "github.com/ankitpokhrel/jira-cli/internal/config"
"github.com/ankitpokhrel/jira-cli/pkg/jira"
"github.com/ankitpokhrel/jira-cli/pkg/netrc"

"github.com/zalando/go-keyring"
Expand Down Expand Up @@ -76,7 +77,10 @@ func NewCmdRoot() *cobra.Command {
return
}

checkForJiraToken(viper.GetString("server"), viper.GetString("login"))
// mtls doesn't need Jira API Token
if viper.GetString("auth_type") != string(jira.AuthTypeMTLS) {
checkForJiraToken(viper.GetString("server"), viper.GetString("login"))
}

configFile := viper.ConfigFileUsed()
if !jiraConfig.Exists(configFile) {
Expand Down
116 changes: 116 additions & 0 deletions internal/config/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,24 @@ type issueTypeFieldConf struct {
}
}

// MTLS authtype specific config.
type JiraCLIMTLSConfig struct {
CaCert string
ClientCert string
ClientKey string
}

// JiraCLIConfig is a Jira CLI config.
type JiraCLIConfig struct {
Installation string
Server string
AuthType string
Login string
Project string
Board string
Force bool
Insecure bool
MTLS JiraCLIMTLSConfig
}

// JiraCLIConfigGenerator is a Jira CLI config generator.
Expand All @@ -81,6 +90,9 @@ type JiraCLIConfigGenerator struct {
epic *jira.Epic
issueTypes []*jira.IssueType
customFields []*issueTypeFieldConf
mtls struct {
caCert, clientCert, clientKey string
}
}
jiraClient *jira.Client
projectSuggestions []string
Expand Down Expand Up @@ -139,9 +151,23 @@ func (c *JiraCLIConfigGenerator) Generate() (string, error) {
if err := c.configureInstallationType(); err != nil {
return "", err
}

if c.value.installation == jira.InstallationTypeLocal {
if err := c.configureLocalAuthType(); err != nil {
return "", err
}
}

if c.value.authType == jira.AuthTypeMTLS {
if err := c.configureMTLS(); err != nil {
return "", err
}
}

if err := c.configureServerAndLoginDetails(); err != nil {
return "", err
}

if c.value.installation == jira.InstallationTypeLocal {
if err := c.configureServerMeta(c.value.server, c.value.login); err != nil {
return "", err
Expand Down Expand Up @@ -190,6 +216,80 @@ func (c *JiraCLIConfigGenerator) configureInstallationType() error {
return nil
}

func (c *JiraCLIConfigGenerator) configureLocalAuthType() error {
var authType string

if c.usrCfg.AuthType == "" {
qs := &survey.Select{
Message: "Authentication type:",
Help: "basic (login) or mtls (client certs)?",
Options: []string{"basic", "mtls"},
Default: "basic",
}

if err := survey.AskOne(qs, &authType); err != nil {
return err
}
}

if authType == strings.ToLower(jira.AuthTypeMTLS.String()) {
c.value.authType = jira.AuthTypeMTLS
} else {
c.value.authType = jira.AuthTypeBasic
}

return nil
}

func (c *JiraCLIConfigGenerator) configureMTLS() error {
var qs []*survey.Question

c.value.mtls.caCert = c.usrCfg.MTLS.CaCert
c.value.mtls.clientCert = c.usrCfg.MTLS.ClientCert
c.value.mtls.clientKey = c.usrCfg.MTLS.ClientKey

getIfEmpty := func(conf, name, msg, help string) {
if conf != "" {
return
}
qs = append(qs, &survey.Question{
Name: name,
Prompt: &survey.Input{
Message: msg,
Help: help,
},
})
}

getIfEmpty(c.value.mtls.caCert, "cacert", "CA Certificate", "Local path to CA Certificate for your `server`")
getIfEmpty(c.value.mtls.clientCert, "clientcert", "Client Certificate", "Local path to your client certificate")
getIfEmpty(c.value.mtls.clientKey, "clientkey", "Client Key", "Local path to your client key")

if len(qs) > 0 {
ans := struct {
CaCert string
ClientCert string
ClientKey string
}{}

if err := survey.Ask(qs, &ans); err != nil {
return err
}

if ans.CaCert != "" {
c.value.mtls.caCert = ans.CaCert
}
if ans.ClientCert != "" {
c.value.mtls.clientCert = ans.ClientCert
}
if ans.ClientKey != "" {
c.value.mtls.clientKey = ans.ClientKey
}
}

return nil
}

//nolint:gocyclo
func (c *JiraCLIConfigGenerator) configureServerAndLoginDetails() error {
var qs []*survey.Question
Expand Down Expand Up @@ -312,6 +412,11 @@ func (c *JiraCLIConfigGenerator) verifyLoginDetails(server, login string) error
Insecure: &c.usrCfg.Insecure,
AuthType: c.value.authType,
Debug: viper.GetBool("debug"),
MTLSConfig: jira.MTLSConfig{
CaCert: c.value.mtls.caCert,
ClientCert: c.value.mtls.clientCert,
ClientKey: c.value.mtls.clientKey,
},
})
if ret, err := c.jiraClient.Me(); err != nil {
return err
Expand All @@ -337,6 +442,11 @@ func (c *JiraCLIConfigGenerator) configureServerMeta(server, login string) error
Insecure: &c.usrCfg.Insecure,
AuthType: c.value.authType,
Debug: viper.GetBool("debug"),
MTLSConfig: jira.MTLSConfig{
CaCert: c.value.mtls.caCert,
ClientCert: c.value.mtls.clientCert,
ClientKey: c.value.mtls.clientKey,
},
})
info, err := c.jiraClient.ServerInfo()
if err != nil {
Expand Down Expand Up @@ -634,6 +744,12 @@ func (c *JiraCLIConfigGenerator) write(path string) (string, error) {
config.Set("epic", c.value.epic)
config.Set("issue.types", c.value.issueTypes)
config.Set("issue.fields.custom", c.value.customFields)
config.Set("auth_type", c.value.authType)

// MTLS
config.Set("mtls.ca_cert", c.value.mtls.caCert)
config.Set("mtls.client_cert", c.value.mtls.clientCert)
config.Set("mtls.client_key", c.value.mtls.clientKey)

if c.value.version.major > 0 {
config.Set("version.major", c.value.version.major)
Expand Down
57 changes: 47 additions & 10 deletions pkg/jira/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"net/http/httputil"
"os"
"strings"
"time"
)
Expand Down Expand Up @@ -93,14 +96,22 @@ func (e Errors) String() string {
// Header is a key, value pair for request headers.
type Header map[string]string

// MTLS authtype specific config.
type MTLSConfig struct {
CaCert string
ClientCert string
ClientKey string
}

// Config is a jira config.
type Config struct {
Server string
Login string
APIToken string
AuthType AuthType
Insecure *bool
Debug bool
Server string
Login string
APIToken string
AuthType AuthType
Insecure *bool
Debug bool
MTLSConfig MTLSConfig
}

// Client is a jira client.
Expand Down Expand Up @@ -132,14 +143,40 @@ func NewClient(c Config, opts ...ClientFunc) *Client {
opt(&client)
}

client.transport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{InsecureSkipVerify: client.insecure},
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: client.insecure,
},
DialContext: (&net.Dialer{
Timeout: client.timeout,
}).DialContext,
}

if c.AuthType == AuthTypeMTLS {
// Create a CA certificate pool and add cert.pem to it
caCert, err := os.ReadFile(c.MTLSConfig.CaCert)
if err != nil {
log.Fatalf("%s, %s", err, c.MTLSConfig.CaCert)
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)

// Read the key pair to create the certificate.
cert, err := tls.LoadX509KeyPair(c.MTLSConfig.ClientCert, c.MTLSConfig.ClientKey)
if err != nil {
log.Fatal(err)
}

// Add the MTLS specific configuration.
transport.TLSClientConfig.RootCAs = caCertPool
transport.TLSClientConfig.Certificates = []tls.Certificate{cert}
transport.TLSClientConfig.Renegotiation = tls.RenegotiateFreelyAsClient
}

client.transport = transport

return &client
}

Expand Down Expand Up @@ -226,7 +263,7 @@ func (c *Client) request(ctx context.Context, method, endpoint string, body []by

if c.authType == AuthTypeBearer {
req.Header.Add("Authorization", "Bearer "+c.token)
} else {
} else if c.authType == AuthTypeBasic {
req.SetBasicAuth(c.login, c.token)
}

Expand Down
2 changes: 2 additions & 0 deletions pkg/jira/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const (
AuthTypeBasic AuthType = "basic"
// AuthTypeBearer is a bearer auth.
AuthTypeBearer AuthType = "bearer"
// AuthTypeMTLS is a mTLS auth.
AuthTypeMTLS AuthType = "mtls"
)

// AuthType is a jira authentication type.
Expand Down

0 comments on commit 86bdded

Please sign in to comment.