Skip to content

Commit 86bdded

Browse files
authored
[feat] Add mtls authentication for client certificate auth (#615)
* Persist auth type in config file * Update `jira init` to configure `mtls` * Update README with instructions
1 parent 1783a0b commit 86bdded

File tree

6 files changed

+188
-13
lines changed

6 files changed

+188
-13
lines changed

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ Follow the [installation guide](https://github.com/ankitpokhrel/jira-cli/wiki/In
8585
more [here](https://github.com/ankitpokhrel/jira-cli/discussions/356).
8686
2. Run `jira init`, select installation type as `Local`, and provide the required details to generate a config file required
8787
for the tool.
88+
- If you want to use `mtls` (client certificates), select auth type `mtls` and provide the CA Cert, client Key, and client cert.
8889

8990
**Note:** If your on-premise Jira installation is using a language other than `English`, then the issue/epic creation
9091
may not work because the older version of Jira API doesn't return the untranslated name for `issuetypes`. In that case,
@@ -95,8 +96,11 @@ See [FAQs](https://github.com/ankitpokhrel/jira-cli/discussions/categories/faqs)
9596

9697
#### Authentication types
9798

98-
The tool supports `basic` and `bearer` (Personal Access Token) authentication types at the moment. Basic auth is used by
99-
default. If you want to use PAT, you need to set `JIRA_AUTH_TYPE` as `bearer`.
99+
The tool supports `basic`, `bearer` (Personal Access Token), and `mtls` (Client Certificates) authentication types. Basic auth is used by
100+
default.
101+
102+
* If you want to use PAT, you need to set `JIRA_AUTH_TYPE` as `bearer`.
103+
* If you want to use `mtls` run `jira init`. Select installation type `Local`, and then select authentication type as `mtls`.
100104

101105
#### Shell completion
102106
Check `jira completion --help` for more info on setting up a bash/zsh shell completion.

api/client.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ func Client(config jira.Config) *jira.Client {
4848
config.Insecure = &insecure
4949
}
5050

51+
// MTLS
52+
53+
if config.MTLSConfig.CaCert == "" {
54+
config.MTLSConfig.CaCert = viper.GetString("mtls.ca_cert")
55+
}
56+
if config.MTLSConfig.ClientCert == "" {
57+
config.MTLSConfig.ClientCert = viper.GetString("mtls.client_cert")
58+
}
59+
if config.MTLSConfig.ClientKey == "" {
60+
config.MTLSConfig.ClientKey = viper.GetString("mtls.client_key")
61+
}
62+
5163
jiraClient = jira.NewClient(
5264
config,
5365
jira.WithTimeout(clientTimeout),

internal/cmd/root/root.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/ankitpokhrel/jira-cli/internal/cmd/version"
2222
"github.com/ankitpokhrel/jira-cli/internal/cmdutil"
2323
jiraConfig "github.com/ankitpokhrel/jira-cli/internal/config"
24+
"github.com/ankitpokhrel/jira-cli/pkg/jira"
2425
"github.com/ankitpokhrel/jira-cli/pkg/netrc"
2526

2627
"github.com/zalando/go-keyring"
@@ -76,7 +77,10 @@ func NewCmdRoot() *cobra.Command {
7677
return
7778
}
7879

79-
checkForJiraToken(viper.GetString("server"), viper.GetString("login"))
80+
// mtls doesn't need Jira API Token
81+
if viper.GetString("auth_type") != string(jira.AuthTypeMTLS) {
82+
checkForJiraToken(viper.GetString("server"), viper.GetString("login"))
83+
}
8084

8185
configFile := viper.ConfigFileUsed()
8286
if !jiraConfig.Exists(configFile) {

internal/config/generator.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,24 @@ type issueTypeFieldConf struct {
5454
}
5555
}
5656

57+
// MTLS authtype specific config.
58+
type JiraCLIMTLSConfig struct {
59+
CaCert string
60+
ClientCert string
61+
ClientKey string
62+
}
63+
5764
// JiraCLIConfig is a Jira CLI config.
5865
type JiraCLIConfig struct {
5966
Installation string
6067
Server string
68+
AuthType string
6169
Login string
6270
Project string
6371
Board string
6472
Force bool
6573
Insecure bool
74+
MTLS JiraCLIMTLSConfig
6675
}
6776

6877
// JiraCLIConfigGenerator is a Jira CLI config generator.
@@ -81,6 +90,9 @@ type JiraCLIConfigGenerator struct {
8190
epic *jira.Epic
8291
issueTypes []*jira.IssueType
8392
customFields []*issueTypeFieldConf
93+
mtls struct {
94+
caCert, clientCert, clientKey string
95+
}
8496
}
8597
jiraClient *jira.Client
8698
projectSuggestions []string
@@ -139,9 +151,23 @@ func (c *JiraCLIConfigGenerator) Generate() (string, error) {
139151
if err := c.configureInstallationType(); err != nil {
140152
return "", err
141153
}
154+
155+
if c.value.installation == jira.InstallationTypeLocal {
156+
if err := c.configureLocalAuthType(); err != nil {
157+
return "", err
158+
}
159+
}
160+
161+
if c.value.authType == jira.AuthTypeMTLS {
162+
if err := c.configureMTLS(); err != nil {
163+
return "", err
164+
}
165+
}
166+
142167
if err := c.configureServerAndLoginDetails(); err != nil {
143168
return "", err
144169
}
170+
145171
if c.value.installation == jira.InstallationTypeLocal {
146172
if err := c.configureServerMeta(c.value.server, c.value.login); err != nil {
147173
return "", err
@@ -190,6 +216,80 @@ func (c *JiraCLIConfigGenerator) configureInstallationType() error {
190216
return nil
191217
}
192218

219+
func (c *JiraCLIConfigGenerator) configureLocalAuthType() error {
220+
var authType string
221+
222+
if c.usrCfg.AuthType == "" {
223+
qs := &survey.Select{
224+
Message: "Authentication type:",
225+
Help: "basic (login) or mtls (client certs)?",
226+
Options: []string{"basic", "mtls"},
227+
Default: "basic",
228+
}
229+
230+
if err := survey.AskOne(qs, &authType); err != nil {
231+
return err
232+
}
233+
}
234+
235+
if authType == strings.ToLower(jira.AuthTypeMTLS.String()) {
236+
c.value.authType = jira.AuthTypeMTLS
237+
} else {
238+
c.value.authType = jira.AuthTypeBasic
239+
}
240+
241+
return nil
242+
}
243+
244+
func (c *JiraCLIConfigGenerator) configureMTLS() error {
245+
var qs []*survey.Question
246+
247+
c.value.mtls.caCert = c.usrCfg.MTLS.CaCert
248+
c.value.mtls.clientCert = c.usrCfg.MTLS.ClientCert
249+
c.value.mtls.clientKey = c.usrCfg.MTLS.ClientKey
250+
251+
getIfEmpty := func(conf, name, msg, help string) {
252+
if conf != "" {
253+
return
254+
}
255+
qs = append(qs, &survey.Question{
256+
Name: name,
257+
Prompt: &survey.Input{
258+
Message: msg,
259+
Help: help,
260+
},
261+
})
262+
}
263+
264+
getIfEmpty(c.value.mtls.caCert, "cacert", "CA Certificate", "Local path to CA Certificate for your `server`")
265+
getIfEmpty(c.value.mtls.clientCert, "clientcert", "Client Certificate", "Local path to your client certificate")
266+
getIfEmpty(c.value.mtls.clientKey, "clientkey", "Client Key", "Local path to your client key")
267+
268+
if len(qs) > 0 {
269+
ans := struct {
270+
CaCert string
271+
ClientCert string
272+
ClientKey string
273+
}{}
274+
275+
if err := survey.Ask(qs, &ans); err != nil {
276+
return err
277+
}
278+
279+
if ans.CaCert != "" {
280+
c.value.mtls.caCert = ans.CaCert
281+
}
282+
if ans.ClientCert != "" {
283+
c.value.mtls.clientCert = ans.ClientCert
284+
}
285+
if ans.ClientKey != "" {
286+
c.value.mtls.clientKey = ans.ClientKey
287+
}
288+
}
289+
290+
return nil
291+
}
292+
193293
//nolint:gocyclo
194294
func (c *JiraCLIConfigGenerator) configureServerAndLoginDetails() error {
195295
var qs []*survey.Question
@@ -312,6 +412,11 @@ func (c *JiraCLIConfigGenerator) verifyLoginDetails(server, login string) error
312412
Insecure: &c.usrCfg.Insecure,
313413
AuthType: c.value.authType,
314414
Debug: viper.GetBool("debug"),
415+
MTLSConfig: jira.MTLSConfig{
416+
CaCert: c.value.mtls.caCert,
417+
ClientCert: c.value.mtls.clientCert,
418+
ClientKey: c.value.mtls.clientKey,
419+
},
315420
})
316421
if ret, err := c.jiraClient.Me(); err != nil {
317422
return err
@@ -337,6 +442,11 @@ func (c *JiraCLIConfigGenerator) configureServerMeta(server, login string) error
337442
Insecure: &c.usrCfg.Insecure,
338443
AuthType: c.value.authType,
339444
Debug: viper.GetBool("debug"),
445+
MTLSConfig: jira.MTLSConfig{
446+
CaCert: c.value.mtls.caCert,
447+
ClientCert: c.value.mtls.clientCert,
448+
ClientKey: c.value.mtls.clientKey,
449+
},
340450
})
341451
info, err := c.jiraClient.ServerInfo()
342452
if err != nil {
@@ -634,6 +744,12 @@ func (c *JiraCLIConfigGenerator) write(path string) (string, error) {
634744
config.Set("epic", c.value.epic)
635745
config.Set("issue.types", c.value.issueTypes)
636746
config.Set("issue.fields.custom", c.value.customFields)
747+
config.Set("auth_type", c.value.authType)
748+
749+
// MTLS
750+
config.Set("mtls.ca_cert", c.value.mtls.caCert)
751+
config.Set("mtls.client_cert", c.value.mtls.clientCert)
752+
config.Set("mtls.client_key", c.value.mtls.clientKey)
637753

638754
if c.value.version.major > 0 {
639755
config.Set("version.major", c.value.version.major)

pkg/jira/client.go

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import (
44
"bytes"
55
"context"
66
"crypto/tls"
7+
"crypto/x509"
78
"encoding/json"
89
"fmt"
10+
"log"
911
"net"
1012
"net/http"
1113
"net/http/httputil"
14+
"os"
1215
"strings"
1316
"time"
1417
)
@@ -93,14 +96,22 @@ func (e Errors) String() string {
9396
// Header is a key, value pair for request headers.
9497
type Header map[string]string
9598

99+
// MTLS authtype specific config.
100+
type MTLSConfig struct {
101+
CaCert string
102+
ClientCert string
103+
ClientKey string
104+
}
105+
96106
// Config is a jira config.
97107
type Config struct {
98-
Server string
99-
Login string
100-
APIToken string
101-
AuthType AuthType
102-
Insecure *bool
103-
Debug bool
108+
Server string
109+
Login string
110+
APIToken string
111+
AuthType AuthType
112+
Insecure *bool
113+
Debug bool
114+
MTLSConfig MTLSConfig
104115
}
105116

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

135-
client.transport = &http.Transport{
136-
Proxy: http.ProxyFromEnvironment,
137-
TLSClientConfig: &tls.Config{InsecureSkipVerify: client.insecure},
146+
transport := &http.Transport{
147+
Proxy: http.ProxyFromEnvironment,
148+
TLSClientConfig: &tls.Config{
149+
MinVersion: tls.VersionTLS12,
150+
InsecureSkipVerify: client.insecure,
151+
},
138152
DialContext: (&net.Dialer{
139153
Timeout: client.timeout,
140154
}).DialContext,
141155
}
142156

157+
if c.AuthType == AuthTypeMTLS {
158+
// Create a CA certificate pool and add cert.pem to it
159+
caCert, err := os.ReadFile(c.MTLSConfig.CaCert)
160+
if err != nil {
161+
log.Fatalf("%s, %s", err, c.MTLSConfig.CaCert)
162+
}
163+
caCertPool := x509.NewCertPool()
164+
caCertPool.AppendCertsFromPEM(caCert)
165+
166+
// Read the key pair to create the certificate.
167+
cert, err := tls.LoadX509KeyPair(c.MTLSConfig.ClientCert, c.MTLSConfig.ClientKey)
168+
if err != nil {
169+
log.Fatal(err)
170+
}
171+
172+
// Add the MTLS specific configuration.
173+
transport.TLSClientConfig.RootCAs = caCertPool
174+
transport.TLSClientConfig.Certificates = []tls.Certificate{cert}
175+
transport.TLSClientConfig.Renegotiation = tls.RenegotiateFreelyAsClient
176+
}
177+
178+
client.transport = transport
179+
143180
return &client
144181
}
145182

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

227264
if c.authType == AuthTypeBearer {
228265
req.Header.Add("Authorization", "Bearer "+c.token)
229-
} else {
266+
} else if c.authType == AuthTypeBasic {
230267
req.SetBasicAuth(c.login, c.token)
231268
}
232269

pkg/jira/types.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ const (
99
AuthTypeBasic AuthType = "basic"
1010
// AuthTypeBearer is a bearer auth.
1111
AuthTypeBearer AuthType = "bearer"
12+
// AuthTypeMTLS is a mTLS auth.
13+
AuthTypeMTLS AuthType = "mtls"
1214
)
1315

1416
// AuthType is a jira authentication type.

0 commit comments

Comments
 (0)