mirror of
https://github.com/interviewstreet/go-jira.git
synced 2025-01-20 02:59:58 +02:00
feat: add support for JWT auth with qsh needed by add-ons
This commit is contained in:
parent
913be01848
commit
a8bdfed27f
1
go.mod
1
go.mod
@ -3,6 +3,7 @@ module github.com/andygrunwald/go-jira
|
||||
go 1.12
|
||||
|
||||
require (
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||
github.com/fatih/structs v1.0.0
|
||||
github.com/google/go-cmp v0.3.0
|
||||
github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135
|
||||
|
18
go.sum
Normal file
18
go.sum
Normal file
@ -0,0 +1,18 @@
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/fatih/structs v1.0.0 h1:BrX964Rv5uQ3wwS+KRUAJCBBw5PQmgJfJ6v4yly5QwU=
|
||||
github.com/fatih/structs v1.0.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135 h1:zLTLjkaOFEFIOxY5BWLFLwh+cL8vOBW4XJ2aqLE/Tf0=
|
||||
github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/trivago/tgo v1.0.1 h1:bxatjJIXNIpV18bucU4Uk/LaoxvxuOlp/oowRHyncLQ=
|
||||
github.com/trivago/tgo v1.0.1/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkFnhc=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
78
jira.go
78
jira.go
@ -2,15 +2,19 @@ package jira
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/google/go-querystring/query"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@ -360,7 +364,7 @@ func (t *BasicAuthTransport) transport() http.RoundTripper {
|
||||
// CookieAuthTransport is an http.RoundTripper that authenticates all requests
|
||||
// using Jira's cookie-based authentication.
|
||||
//
|
||||
// Note that it is generally preferrable to use HTTP BASIC authentication with the REST API.
|
||||
// Note that it is generally preferable to use HTTP BASIC authentication with the REST API.
|
||||
// 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).
|
||||
//
|
||||
// JIRA API docs: https://docs.atlassian.com/jira/REST/latest/#auth/1/session
|
||||
@ -453,6 +457,78 @@ func (t *CookieAuthTransport) transport() http.RoundTripper {
|
||||
return http.DefaultTransport
|
||||
}
|
||||
|
||||
// JWTAuthTransport is an http.RoundTripper that authenticates all requests
|
||||
// using Jira's JWT based authentication.
|
||||
//
|
||||
// NOTE: this form of auth should be used by add-ons installed from the Atlassian marketplace.
|
||||
//
|
||||
// JIRA docs: https://developer.atlassian.com/cloud/jira/platform/understanding-jwt
|
||||
// Examples in other languages:
|
||||
// https://bitbucket.org/atlassian/atlassian-jwt-ruby/src/d44a8e7a4649e4f23edaa784402655fda7c816ea/lib/atlassian/jwt.rb
|
||||
// https://bitbucket.org/atlassian/atlassian-jwt-py/src/master/atlassian_jwt/url_utils.py
|
||||
type JWTAuthTransport struct {
|
||||
Secret []byte
|
||||
Issuer string
|
||||
|
||||
// Transport is the underlying HTTP transport to use when making requests.
|
||||
// It will default to http.DefaultTransport if nil.
|
||||
Transport http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *JWTAuthTransport) Client() *http.Client {
|
||||
return &http.Client{Transport: t}
|
||||
}
|
||||
|
||||
func (t *JWTAuthTransport) transport() http.RoundTripper {
|
||||
if t.Transport != nil {
|
||||
return t.Transport
|
||||
}
|
||||
return http.DefaultTransport
|
||||
}
|
||||
|
||||
// RoundTrip adds the session object to the request.
|
||||
func (t *JWTAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req2 := cloneRequest(req) // per RoundTripper contract
|
||||
exp := time.Duration(59) * time.Second
|
||||
qsh := t.createQueryStringHash(req.Method, req2.URL)
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"iss": t.Issuer,
|
||||
"iat": time.Now().Unix(),
|
||||
"exp": time.Now().Add(exp).Unix(),
|
||||
"qsh": qsh,
|
||||
})
|
||||
|
||||
jwtStr, err := token.SignedString(t.Secret)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "jwtAuth: error signing JWT")
|
||||
}
|
||||
|
||||
req2.Header.Set("Authorization", fmt.Sprintf("JWT %s", jwtStr))
|
||||
return t.transport().RoundTrip(req2)
|
||||
}
|
||||
|
||||
func (t *JWTAuthTransport) createQueryStringHash(httpMethod string, jiraURL *url.URL) string {
|
||||
canonicalRequest := t.canonicalizeRequest(httpMethod, jiraURL)
|
||||
h := sha256.Sum256([]byte(canonicalRequest))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func (t *JWTAuthTransport) canonicalizeRequest(httpMethod string, jiraURL *url.URL) string {
|
||||
path := "/" + strings.Replace(strings.Trim(jiraURL.Path, "/"), "&", "%26", -1)
|
||||
|
||||
var canonicalQueryString []string
|
||||
for k, v := range jiraURL.Query() {
|
||||
if k == "jwt" {
|
||||
continue
|
||||
}
|
||||
param := url.QueryEscape(k)
|
||||
value := url.QueryEscape(strings.Join(v, ""))
|
||||
canonicalQueryString = append(canonicalQueryString, strings.Replace(strings.Join([]string{param, value}, "="), "+", "%20", -1))
|
||||
}
|
||||
sort.Strings(canonicalQueryString)
|
||||
return fmt.Sprintf("%s&%s&%s", strings.ToUpper(httpMethod), path, strings.Join(canonicalQueryString, "&"))
|
||||
}
|
||||
|
||||
// cloneRequest returns a clone of the provided *http.Request.
|
||||
// The clone is a shallow copy of the struct and its Header map.
|
||||
func cloneRequest(r *http.Request) *http.Request {
|
||||
|
24
jira_test.go
24
jira_test.go
@ -612,3 +612,27 @@ func TestCookieAuthTransport_SessionObject_DoesNotExist(t *testing.T) {
|
||||
req, _ := basicAuthClient.NewRequest("GET", ".", nil)
|
||||
basicAuthClient.Do(req, nil)
|
||||
}
|
||||
|
||||
func TestJWTAuthTransport_HeaderContainsJWT(t *testing.T) {
|
||||
setup()
|
||||
defer teardown()
|
||||
|
||||
sharedSecret := []byte("ssshh,it's a secret")
|
||||
issuer := "add-on.key"
|
||||
|
||||
jwtTransport := &JWTAuthTransport{
|
||||
Secret: sharedSecret,
|
||||
Issuer: issuer,
|
||||
}
|
||||
|
||||
testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// look for the presence of the JWT in the header
|
||||
val := r.Header.Get("Authorization")
|
||||
if !strings.Contains(val, "JWT ") {
|
||||
t.Errorf("request does not contain JWT in the Auth header")
|
||||
}
|
||||
})
|
||||
|
||||
jwtClient, _ := NewClient(jwtTransport.Client(), testServer.URL)
|
||||
jwtClient.Issue.Get("TEST-1", nil)
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user