Skip to content

Commit a8bdfed

Browse files
Shaun Dunningghostsquad
Shaun Dunning
authored andcommitted
feat: add support for JWT auth with qsh needed by add-ons
1 parent 913be01 commit a8bdfed

File tree

4 files changed

+120
-1
lines changed

4 files changed

+120
-1
lines changed

go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/andygrunwald/go-jira
33
go 1.12
44

55
require (
6+
github.com/dgrijalva/jwt-go v3.2.0+incompatible
67
github.com/fatih/structs v1.0.0
78
github.com/google/go-cmp v0.3.0
89
github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135

go.sum

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
2+
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
3+
github.com/fatih/structs v1.0.0 h1:BrX964Rv5uQ3wwS+KRUAJCBBw5PQmgJfJ6v4yly5QwU=
4+
github.com/fatih/structs v1.0.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
5+
github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=
6+
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
7+
github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135 h1:zLTLjkaOFEFIOxY5BWLFLwh+cL8vOBW4XJ2aqLE/Tf0=
8+
github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
9+
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
10+
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
11+
github.com/trivago/tgo v1.0.1 h1:bxatjJIXNIpV18bucU4Uk/LaoxvxuOlp/oowRHyncLQ=
12+
github.com/trivago/tgo v1.0.1/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkFnhc=
13+
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
14+
golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
15+
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
16+
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
17+
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
18+
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

jira.go

+77-1
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,19 @@ package jira
22

33
import (
44
"bytes"
5+
"crypto/sha256"
6+
"encoding/hex"
57
"encoding/json"
68
"fmt"
79
"io"
810
"net/http"
911
"net/url"
1012
"reflect"
13+
"sort"
1114
"strings"
1215
"time"
1316

17+
"github.com/dgrijalva/jwt-go"
1418
"github.com/google/go-querystring/query"
1519
"github.com/pkg/errors"
1620
)
@@ -360,7 +364,7 @@ func (t *BasicAuthTransport) transport() http.RoundTripper {
360364
// CookieAuthTransport is an http.RoundTripper that authenticates all requests
361365
// using Jira's cookie-based authentication.
362366
//
363-
// Note that it is generally preferrable to use HTTP BASIC authentication with the REST API.
367+
// Note that it is generally preferable to use HTTP BASIC authentication with the REST API.
364368
// However, this resource may be used to mimic the behaviour of JIRA's log-in page (e.g. to display log-in errors to a user).
365369
//
366370
// JIRA API docs: https://docs.atlassian.com/jira/REST/latest/#auth/1/session
@@ -453,6 +457,78 @@ func (t *CookieAuthTransport) transport() http.RoundTripper {
453457
return http.DefaultTransport
454458
}
455459

460+
// JWTAuthTransport is an http.RoundTripper that authenticates all requests
461+
// using Jira's JWT based authentication.
462+
//
463+
// NOTE: this form of auth should be used by add-ons installed from the Atlassian marketplace.
464+
//
465+
// JIRA docs: https://developer.atlassian.com/cloud/jira/platform/understanding-jwt
466+
// Examples in other languages:
467+
// https://bitbucket.org/atlassian/atlassian-jwt-ruby/src/d44a8e7a4649e4f23edaa784402655fda7c816ea/lib/atlassian/jwt.rb
468+
// https://bitbucket.org/atlassian/atlassian-jwt-py/src/master/atlassian_jwt/url_utils.py
469+
type JWTAuthTransport struct {
470+
Secret []byte
471+
Issuer string
472+
473+
// Transport is the underlying HTTP transport to use when making requests.
474+
// It will default to http.DefaultTransport if nil.
475+
Transport http.RoundTripper
476+
}
477+
478+
func (t *JWTAuthTransport) Client() *http.Client {
479+
return &http.Client{Transport: t}
480+
}
481+
482+
func (t *JWTAuthTransport) transport() http.RoundTripper {
483+
if t.Transport != nil {
484+
return t.Transport
485+
}
486+
return http.DefaultTransport
487+
}
488+
489+
// RoundTrip adds the session object to the request.
490+
func (t *JWTAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
491+
req2 := cloneRequest(req) // per RoundTripper contract
492+
exp := time.Duration(59) * time.Second
493+
qsh := t.createQueryStringHash(req.Method, req2.URL)
494+
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
495+
"iss": t.Issuer,
496+
"iat": time.Now().Unix(),
497+
"exp": time.Now().Add(exp).Unix(),
498+
"qsh": qsh,
499+
})
500+
501+
jwtStr, err := token.SignedString(t.Secret)
502+
if err != nil {
503+
return nil, errors.Wrap(err, "jwtAuth: error signing JWT")
504+
}
505+
506+
req2.Header.Set("Authorization", fmt.Sprintf("JWT %s", jwtStr))
507+
return t.transport().RoundTrip(req2)
508+
}
509+
510+
func (t *JWTAuthTransport) createQueryStringHash(httpMethod string, jiraURL *url.URL) string {
511+
canonicalRequest := t.canonicalizeRequest(httpMethod, jiraURL)
512+
h := sha256.Sum256([]byte(canonicalRequest))
513+
return hex.EncodeToString(h[:])
514+
}
515+
516+
func (t *JWTAuthTransport) canonicalizeRequest(httpMethod string, jiraURL *url.URL) string {
517+
path := "/" + strings.Replace(strings.Trim(jiraURL.Path, "/"), "&", "%26", -1)
518+
519+
var canonicalQueryString []string
520+
for k, v := range jiraURL.Query() {
521+
if k == "jwt" {
522+
continue
523+
}
524+
param := url.QueryEscape(k)
525+
value := url.QueryEscape(strings.Join(v, ""))
526+
canonicalQueryString = append(canonicalQueryString, strings.Replace(strings.Join([]string{param, value}, "="), "+", "%20", -1))
527+
}
528+
sort.Strings(canonicalQueryString)
529+
return fmt.Sprintf("%s&%s&%s", strings.ToUpper(httpMethod), path, strings.Join(canonicalQueryString, "&"))
530+
}
531+
456532
// cloneRequest returns a clone of the provided *http.Request.
457533
// The clone is a shallow copy of the struct and its Header map.
458534
func cloneRequest(r *http.Request) *http.Request {

jira_test.go

+24
Original file line numberDiff line numberDiff line change
@@ -612,3 +612,27 @@ func TestCookieAuthTransport_SessionObject_DoesNotExist(t *testing.T) {
612612
req, _ := basicAuthClient.NewRequest("GET", ".", nil)
613613
basicAuthClient.Do(req, nil)
614614
}
615+
616+
func TestJWTAuthTransport_HeaderContainsJWT(t *testing.T) {
617+
setup()
618+
defer teardown()
619+
620+
sharedSecret := []byte("ssshh,it's a secret")
621+
issuer := "add-on.key"
622+
623+
jwtTransport := &JWTAuthTransport{
624+
Secret: sharedSecret,
625+
Issuer: issuer,
626+
}
627+
628+
testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
629+
// look for the presence of the JWT in the header
630+
val := r.Header.Get("Authorization")
631+
if !strings.Contains(val, "JWT ") {
632+
t.Errorf("request does not contain JWT in the Auth header")
633+
}
634+
})
635+
636+
jwtClient, _ := NewClient(jwtTransport.Client(), testServer.URL)
637+
jwtClient.Issue.Get("TEST-1", nil)
638+
}

0 commit comments

Comments
 (0)