diff --git a/jira/jira.go b/jira/jira.go new file mode 100644 index 0000000..e8021e9 --- /dev/null +++ b/jira/jira.go @@ -0,0 +1,167 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package jira provides claims and JWT signing for OAuth2 to access JIRA/Confluence. +package jira + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/oauth2" +) + +// ClaimSet contains information about the JWT signature according +// to Atlassian's documentation +// https://developer.atlassian.com/cloud/jira/software/oauth-2-jwt-bearer-token-authorization-grant-type/ +type ClaimSet struct { + Issuer string `json:"iss"` + Subject string `json:"sub"` + InstalledURL string `json:"tnt"` // URL of installed app + AuthURL string `json:"aud"` // URL of auth server + ExpiresIn int64 `json:"exp"` // Must be no later that 60 seconds in the future + IssuedAt int64 `json:"iat"` +} + +var ( + defaultGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" + defaultHeader = map[string]string{ + "typ": "JWT", + "alg": "HS256", + } +) + +// Config is the configuration for using JWT to fetch tokens, +// commonly known as "two-legged OAuth 2.0". +type Config struct { + // BaseURL for your app + BaseURL string + + // Subject is the userkey as defined by Atlassian + // Different than username (ex: /rest/api/2/user?username=alex) + Subject string + + oauth2.Config +} + +// TokenSource returns a JWT TokenSource using the configuration +// in c and the HTTP client from the provided context. +func (c *Config) TokenSource(ctx context.Context) oauth2.TokenSource { + return oauth2.ReuseTokenSource(nil, jwtSource{ctx, c}) +} + +// Client returns an HTTP client wrapping the context's +// HTTP transport and adding Authorization headers with tokens +// obtained from c. +// +// The returned client and its Transport should not be modified. +func (c *Config) Client(ctx context.Context) *http.Client { + return oauth2.NewClient(ctx, c.TokenSource(ctx)) +} + +// jwtSource is a source that always does a signed JWT request for a token. +// It should typically be wrapped with a reuseTokenSource. +type jwtSource struct { + ctx context.Context + conf *Config +} + +func (js jwtSource) Token() (*oauth2.Token, error) { + exp := time.Duration(59) * time.Second + claimSet := &ClaimSet{ + Issuer: fmt.Sprintf("urn:atlassian:connect:clientid:%s", js.conf.ClientID), + Subject: fmt.Sprintf("urn:atlassian:connect:userkey:%s", js.conf.Subject), + InstalledURL: js.conf.BaseURL, + AuthURL: js.conf.Endpoint.AuthURL, + IssuedAt: time.Now().Unix(), + ExpiresIn: time.Now().Add(exp).Unix(), + } + + v := url.Values{} + v.Set("grant_type", defaultGrantType) + + // Add scopes if they exist; If not, it defaults to app scopes + if scopes := js.conf.Scopes; scopes != nil { + upperScopes := make([]string, len(scopes)) + for _, k := range scopes { + upperScopes = append(upperScopes, strings.ToUpper(k)) + } + v.Set("scope", strings.Join(upperScopes, "+")) + } + + // Sign claims for assertion + assertion, err := sign(js.conf.ClientSecret, claimSet) + if err != nil { + return nil, err + } + v.Set("assertion", string(assertion)) + + // Fetch access token from auth server + hc := oauth2.NewClient(js.ctx, nil) + resp, err := hc.PostForm(js.conf.Endpoint.TokenURL, v) + if err != nil { + return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err) + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err) + } + if c := resp.StatusCode; c < 200 || c > 299 { + return nil, fmt.Errorf("oauth2: cannot fetch token: %v\nResponse: %s", resp.Status, body) + } + + // tokenRes is the JSON response body. + var tokenRes struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` // relative seconds from now + } + if err := json.Unmarshal(body, &tokenRes); err != nil { + return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err) + } + token := &oauth2.Token{ + AccessToken: tokenRes.AccessToken, + TokenType: tokenRes.TokenType, + } + + if secs := tokenRes.ExpiresIn; secs > 0 { + token.Expiry = time.Now().Add(time.Duration(secs) * time.Second) + } + return token, nil +} + +// Sign the claim set with the shared secret +// Result to be sent as assertion +func sign(key string, claims *ClaimSet) (string, error) { + b, err := json.Marshal(defaultHeader) + if err != nil { + return "", err + } + header := base64.RawURLEncoding.EncodeToString(b) + + jsonClaims, err := json.Marshal(claims) + if err != nil { + return "", err + } + encodedClaims := strings.TrimRight(base64.URLEncoding.EncodeToString(jsonClaims), "=") + + ss := fmt.Sprintf("%s.%s", header, encodedClaims) + + mac := hmac.New(sha256.New, []byte(key)) + mac.Write([]byte(ss)) + signature := mac.Sum(nil) + + return fmt.Sprintf("%s.%s", ss, base64.RawURLEncoding.EncodeToString(signature)), nil +} diff --git a/jira/jira_test.go b/jira/jira_test.go new file mode 100644 index 0000000..c49940d --- /dev/null +++ b/jira/jira_test.go @@ -0,0 +1,185 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package jira + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/jws" +) + +func TestJWTFetch_JSONResponse(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "access_token": "90d64460d14870c08c81352a05dedd3465940a7c", + "token_type": "Bearer", + "expires_in": 3600 + }`)) + })) + defer ts.Close() + + conf := &Config{ + BaseURL: "https://my.app.com", + Subject: "userkey", + Config: oauth2.Config{ + ClientID: "super_secret_client_id", + ClientSecret: "super_shared_secret", + Scopes: []string{"read", "write"}, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://example.com", + TokenURL: ts.URL, + }, + }, + } + + tok, err := conf.TokenSource(context.Background()).Token() + if err != nil { + t.Fatal(err) + } + if !tok.Valid() { + t.Errorf("got invalid token: %v", tok) + } + if got, want := tok.AccessToken, "90d64460d14870c08c81352a05dedd3465940a7c"; got != want { + t.Errorf("access token = %q; want %q", got, want) + } + if got, want := tok.TokenType, "Bearer"; got != want { + t.Errorf("token type = %q; want %q", got, want) + } + if got := tok.Expiry.IsZero(); got { + t.Errorf("token expiry = %v, want none", got) + } +} + +func TestJWTFetch_BadResponse(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"token_type": "Bearer"}`)) + })) + defer ts.Close() + + conf := &Config{ + BaseURL: "https://my.app.com", + Subject: "userkey", + Config: oauth2.Config{ + ClientID: "super_secret_client_id", + ClientSecret: "super_shared_secret", + Scopes: []string{"read", "write"}, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://example.com", + TokenURL: ts.URL, + }, + }, + } + + tok, err := conf.TokenSource(context.Background()).Token() + if err != nil { + t.Fatal(err) + } + if tok == nil { + t.Fatalf("got nil token; want token") + } + if tok.Valid() { + t.Errorf("got invalid token: %v", tok) + } + if got, want := tok.AccessToken, ""; got != want { + t.Errorf("access token = %q; want %q", got, want) + } + if got, want := tok.TokenType, "Bearer"; got != want { + t.Errorf("token type = %q; want %q", got, want) + } +} + +func TestJWTFetch_BadResponseType(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"access_token":123, "token_type": "Bearer"}`)) + })) + defer ts.Close() + + conf := &Config{ + BaseURL: "https://my.app.com", + Subject: "userkey", + Config: oauth2.Config{ + ClientID: "super_secret_client_id", + ClientSecret: "super_shared_secret", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://example.com", + TokenURL: ts.URL, + }, + }, + } + + tok, err := conf.TokenSource(context.Background()).Token() + if err == nil { + t.Error("got a token; expected error") + if got, want := tok.AccessToken, ""; got != want { + t.Errorf("access token = %q; want %q", got, want) + } + } +} + +func TestJWTFetch_Assertion(t *testing.T) { + var assertion string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + assertion = r.Form.Get("assertion") + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "access_token": "90d64460d14870c08c81352a05dedd3465940a7c", + "token_type": "Bearer", + "expires_in": 3600 + }`)) + })) + defer ts.Close() + + conf := &Config{ + BaseURL: "https://my.app.com", + Subject: "userkey", + Config: oauth2.Config{ + ClientID: "super_secret_client_id", + ClientSecret: "super_shared_secret", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://example.com", + TokenURL: ts.URL, + }, + }, + } + + _, err := conf.TokenSource(context.Background()).Token() + if err != nil { + t.Fatalf("Failed to fetch token: %v", err) + } + + parts := strings.Split(assertion, ".") + if len(parts) != 3 { + t.Fatalf("assertion = %q; want 3 parts", assertion) + } + gotjson, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + t.Fatalf("invalid token header; err = %v", err) + } + + got := jws.Header{} + if err := json.Unmarshal(gotjson, &got); err != nil { + t.Errorf("failed to unmarshal json token header = %q; err = %v", gotjson, err) + } + + want := jws.Header{ + Algorithm: "HS256", + Typ: "JWT", + } + if got != want { + t.Errorf("access token header = %q; want %q", got, want) + } +}