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

Support Jira #562

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions examples/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
"github.com/markbates/goth/providers/heroku"
"github.com/markbates/goth/providers/instagram"
"github.com/markbates/goth/providers/intercom"
"github.com/markbates/goth/providers/jira"
"github.com/markbates/goth/providers/kakao"
"github.com/markbates/goth/providers/lastfm"
"github.com/markbates/goth/providers/line"
Expand Down Expand Up @@ -111,6 +112,7 @@ func main() {
microsoftonline.New(os.Getenv("MICROSOFTONLINE_KEY"), os.Getenv("MICROSOFTONLINE_SECRET"), "http://localhost:3000/auth/microsoftonline/callback"),
battlenet.New(os.Getenv("BATTLENET_KEY"), os.Getenv("BATTLENET_SECRET"), "http://localhost:3000/auth/battlenet/callback"),
eveonline.New(os.Getenv("EVEONLINE_KEY"), os.Getenv("EVEONLINE_SECRET"), "http://localhost:3000/auth/eveonline/callback"),
jira.New(os.Getenv("JIRA_KEY"), os.Getenv("JIRA_SECRET"), "http://localhost:3000/auth/jira/callback"),
kakao.New(os.Getenv("KAKAO_KEY"), os.Getenv("KAKAO_SECRET"), "http://localhost:3000/auth/kakao/callback"),

// Pointed localhost.com to http://localhost:3000/auth/yahoo/callback through proxy as yahoo
Expand Down
231 changes: 231 additions & 0 deletions providers/jira/jira.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
// Package jira implements the OAuth2 protocol for authenticating users through Jira.
// This package can be used as a reference implementation of an OAuth2 provider for Goth.
package jira

import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"

"github.com/markbates/goth"
"golang.org/x/oauth2"
)

// These vars define the Authentication, Token, and API URLS for Jira. If
// using Jira enterprise you should change these values before calling New.
var (
authURL = "https://auth.atlassian.com/authorize"
tokenURL = "https://auth.atlassian.com/oauth/token"
resourceURL = "https://api.atlassian.com/oauth/token/accessible-resources"
profileURL = "https://api.atlassian.com/ex/jira/%s/rest/api/2/myself"
)

// New creates a new Jira provider, and sets up important connection details.
// You should always call `jira.New` to get a new Provider. Never try to create
// one manually.
func New(clientKey, secret, callbackURL string, scopes ...string) *Provider {
return NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, resourceURL, profileURL, scopes...)
}

// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to
func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, resourceURL, profileURL string, scopes ...string) *Provider {
p := &Provider{
ClientKey: clientKey,
Secret: secret,
CallbackURL: callbackURL,
providerName: "jira",
resourceURL: resourceURL,
profileURL: profileURL,
}
p.config = newConfig(p, authURL, tokenURL, scopes)
return p
}

// Provider is the implementation of `goth.Provider` for accessing Jira.
type Provider struct {
ClientKey string
Secret string
CallbackURL string
HTTPClient *http.Client
config *oauth2.Config
providerName string
resourceURL string
profileURL string
}

type getAccessibleResourcesResponse struct {
ID string `json:"id"`
URL string `json:"url"`
Scopes []string `json:"scopes"`
AvatarURL string `json:"avatarUrl"`
}

// Name is the name used to retrieve this provider later.
func (p *Provider) Name() string {
return p.providerName
}

// SetName is to update the name of the provider (needed in case of multiple providers of 1 type)
func (p *Provider) SetName(name string) {
p.providerName = name
}

func (p *Provider) Client() *http.Client {
return goth.HTTPClientWithFallBack(p.HTTPClient)
}

// Debug is a no-op for the goth package.
func (p *Provider) Debug(_ bool) {}

// BeginAuth asks Goth for an authentication end-point.
func (p *Provider) BeginAuth(state string) (goth.Session, error) {
url := p.config.AuthCodeURL(state)
session := &Session{
AuthURL: url,
}
return session, nil
}

func (p *Provider) getAccessibleResources(accessToken string) ([]getAccessibleResourcesResponse, error) {
req, err := http.NewRequest("GET", p.resourceURL, nil)
if err != nil {
return []getAccessibleResourcesResponse{}, err
}

req.Header.Add("Authorization", "Bearer "+accessToken)
r, err := p.Client().Do(req)
if err != nil {
return []getAccessibleResourcesResponse{}, err
}
defer r.Body.Close()

if r.StatusCode != http.StatusOK {
return []getAccessibleResourcesResponse{}, fmt.Errorf("jira responded with a %d trying to fetch accessible resources", r.StatusCode)
}

body, err := ioutil.ReadAll(r.Body)
if err != nil {
return []getAccessibleResourcesResponse{}, err
}

var response []getAccessibleResourcesResponse

err = json.Unmarshal(body, &response)

if err != nil {
return []getAccessibleResourcesResponse{}, err
}
return response, nil
}

// FetchUser will go to Jira and access basic information about the user.
func (p *Provider) FetchUser(session goth.Session) (goth.User, error) {
sess := session.(*Session)
user := goth.User{
AccessToken: sess.AccessToken,
RefreshToken: sess.RefreshToken,
ExpiresAt: sess.ExpiresAt,
Provider: p.Name(),
}

if user.AccessToken == "" {
// data is not yet retrieved since accessToken is still empty
return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName)
}

accessibleResources, err := p.getAccessibleResources(user.AccessToken)
if err != nil {
return user, err
}
if len(accessibleResources) == 0 {
return user, fmt.Errorf("%s cannot get user information without any accessible resources", p.providerName)
}

req, err := http.NewRequest("GET", fmt.Sprintf(p.profileURL, accessibleResources[0].ID), nil)
if err != nil {
return user, err
}

req.Header.Add("Authorization", "Bearer "+sess.AccessToken)
response, err := p.Client().Do(req)
if err != nil {
return user, err
}
defer response.Body.Close()

if response.StatusCode != http.StatusOK {
return user, fmt.Errorf("Jira responded with a %d trying to fetch user information", response.StatusCode)
}

bits, err := ioutil.ReadAll(response.Body)
if err != nil {
return user, err
}

err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData)
if err != nil {
return user, err
}

err = userFromReader(bytes.NewReader(bits), &user)
if err != nil {
return user, err
}

return user, err
}

func userFromReader(reader io.Reader, user *goth.User) error {
u := struct {
ID string `json:"accountId"`
Email string `json:"emailAddress"`
Name string `json:"displayName"`
}{}

err := json.NewDecoder(reader).Decode(&u)
if err != nil {
return err
}

user.Name = u.Name
user.Email = u.Email
user.UserID = u.ID

return err
}

func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config {
c := &oauth2.Config{
ClientID: provider.ClientKey,
ClientSecret: provider.Secret,
RedirectURL: provider.CallbackURL,
Endpoint: oauth2.Endpoint{
AuthURL: authURL,
TokenURL: tokenURL,
},
Scopes: []string{},
}

for _, scope := range scopes {
c.Scopes = append(c.Scopes, scope)
}

return c
}

func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) {
token := &oauth2.Token{RefreshToken: refreshToken}
ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token)
newToken, err := ts.Token()
if err != nil {
return nil, err
}
return newToken, err
}

func (p *Provider) RefreshTokenAvailable() bool {
return true
}
59 changes: 59 additions & 0 deletions providers/jira/jira_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package jira_test

import (
"fmt"
"os"
"testing"

"github.com/markbates/goth"
"github.com/markbates/goth/providers/jira"
"github.com/stretchr/testify/assert"
)

func Test_New(t *testing.T) {
t.Parallel()
a := assert.New(t)

provider := jiraProvider()
a.Equal(provider.ClientKey, os.Getenv("JIRA_KEY"))
a.Equal(provider.Secret, os.Getenv("JIRA_SECRET"))
a.Equal(provider.CallbackURL, "/foo")
}

func Test_Implements_Provider(t *testing.T) {
t.Parallel()
a := assert.New(t)

a.Implements((*goth.Provider)(nil), jiraProvider())
}

func Test_BeginAuth(t *testing.T) {
t.Parallel()
a := assert.New(t)

provider := jiraProvider()
session, err := provider.BeginAuth("test_state")
s := session.(*jira.Session)
a.NoError(err)
a.Contains(s.AuthURL, "auth.atlassian.com/authorize")
a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("JIRA_KEY")))
a.Contains(s.AuthURL, "state=test_state")
a.Contains(s.AuthURL, "scope=user")
}

func Test_SessionFromJSON(t *testing.T) {
t.Parallel()
a := assert.New(t)

provider := jiraProvider()

s, err := provider.UnmarshalSession(`{"AuthURL":"https://auth.atlassian.com/authorize","AccessToken":"1234567890"}`)
a.NoError(err)
session := s.(*jira.Session)
a.Equal(session.AuthURL, "https://auth.atlassian.com/authorize")
a.Equal(session.AccessToken, "1234567890")
}

func jiraProvider() *jira.Provider {
return jira.New(os.Getenv("JIRA_KEY"), os.Getenv("JIRA_SECRET"), "/foo", "user")
}
75 changes: 75 additions & 0 deletions providers/jira/session.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package jira

import (
"encoding/json"
"errors"
"strings"
"time"

"github.com/markbates/goth"
)

// Session stores data during the auth process with Jira.
type Session struct {
AuthURL string
AccessToken string
RefreshToken string
ExpiresAt time.Time
// AccessToken and RefreshToken are too large to store
// in goths default cookie store, so only set this to true
// if you are using your own store that can handle it
ShouldStoreTokens bool
}

// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Jira provider.
func (s Session) GetAuthURL() (string, error) {
if s.AuthURL == "" {
return "", errors.New(goth.NoAuthUrlErrorMessage)
}
return s.AuthURL, nil
}

// Authorize the session with Jira and return the access token to be stored for future use.
func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) {
p := provider.(*Provider)
token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code"))
if err != nil {
return "", err
}

if !token.Valid() {
return "", errors.New("invalid token received from provider")
}

s.AccessToken = token.AccessToken
s.RefreshToken = token.RefreshToken
s.ExpiresAt = token.Expiry
return token.AccessToken, err
}

// Marshal the session into a string
func (s Session) Marshal() string {
session := Session{
AuthURL: s.AuthURL,
AccessToken: "",
RefreshToken: "",
ExpiresAt: s.ExpiresAt,
}
if s.ShouldStoreTokens {
session.AccessToken = s.AccessToken
session.RefreshToken = s.RefreshToken
}
b, _ := json.Marshal(session)
return string(b)
}

func (s Session) String() string {
return s.Marshal()
}

// UnmarshalSession will unmarshal a JSON string into a session.
func (p *Provider) UnmarshalSession(data string) (goth.Session, error) {
sess := &Session{}
err := json.NewDecoder(strings.NewReader(data)).Decode(sess)
return sess, err
}
Loading
Loading