Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add mtls authentication for client certificate auth #615

Merged
merged 1 commit into from
Sep 23, 2023
Merged
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
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 {
markhatch marked this conversation as resolved.
Show resolved Hide resolved
var authType string

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should here be bearer as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that bearer is only used in for InstallationType cloud

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 {
markhatch marked this conversation as resolved.
Show resolved Hide resolved
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
markhatch marked this conversation as resolved.
Show resolved Hide resolved
}

// 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
}
markhatch marked this conversation as resolved.
Show resolved Hide resolved

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 {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current default auth type is basic (if nothing is set in config/env). Looks like this might break that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry been a while - but I believe this change was made so that the login/token are provided only if authType is AuthTypeBasic.

If authType is Bearer you add the authorization header.
If authType is Basic you add the login/token
If authType is mtls, you don't need to modify the request.

I don't believe the default auth type has changed, and will depend on how the user configured authType.

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