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

Added Atlassian Provider #341

Open
wants to merge 3 commits 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ $ go get github.com/markbates/goth

* Amazon
* Apple
* Atlassian
* Auth0
* Azure AD
* Battle.net
Expand Down
5 changes: 4 additions & 1 deletion examples/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/markbates/goth/gothic"
"github.com/markbates/goth/providers/amazon"
"github.com/markbates/goth/providers/apple"
"github.com/markbates/goth/providers/atlassian"
"github.com/markbates/goth/providers/auth0"
"github.com/markbates/goth/providers/azuread"
"github.com/markbates/goth/providers/battlenet"
Expand Down Expand Up @@ -44,6 +45,7 @@ import (
"github.com/markbates/goth/providers/microsoftonline"
"github.com/markbates/goth/providers/naver"
"github.com/markbates/goth/providers/nextcloud"
"github.com/markbates/goth/providers/okta"
"github.com/markbates/goth/providers/onedrive"
"github.com/markbates/goth/providers/openidConnect"
"github.com/markbates/goth/providers/paypal"
Expand All @@ -66,7 +68,6 @@ import (
"github.com/markbates/goth/providers/yahoo"
"github.com/markbates/goth/providers/yammer"
"github.com/markbates/goth/providers/yandex"
"github.com/markbates/goth/providers/okta"
)

func main() {
Expand Down Expand Up @@ -134,6 +135,7 @@ func main() {
apple.New(os.Getenv("APPLE_KEY"), os.Getenv("APPLE_SECRET"), "http://localhost:3000/auth/apple/callback", nil, apple.ScopeName, apple.ScopeEmail),
strava.New(os.Getenv("STRAVA_KEY"), os.Getenv("STRAVA_SECRET"), "http://localhost:3000/auth/strava/callback"),
okta.New(os.Getenv("OKTA_ID"), os.Getenv("OKTA_SECRET"), os.Getenv("OKTA_ORG_URL"), "http://localhost:3000/auth/okta/callback", "openid", "profile", "email"),
atlassian.New(os.Getenv("ATLASSIAN_KEY"), os.Getenv("ATLASSIAN_SECRET"), "http://localhost:3000/auth/atlassian/callback"),
)

// OpenID Connect is based on OpenID Connect Auto Discovery URL (https://openid.net/specs/openid-connect-discovery-1_0-17.html)
Expand Down Expand Up @@ -199,6 +201,7 @@ func main() {
m["apple"] = "Apple"
m["strava"] = "Strava"
m["okta"] = "Okta"
m["atlassian"] = "Atlassian"

var keys []string
for k := range m {
Expand Down
2 changes: 0 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,4 @@ require (
github.com/lestrrat-go/jwx v0.9.0
github.com/markbates/going v1.0.0
github.com/mrjones/oauth v0.0.0-20180629183705-f4e24b6d100c
github.com/stretchr/testify v1.2.2
golang.org/x/oauth2 v0.0.0-20180620175406-ef147856a6dd
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These removals don't seem correct – we depend on testify for tests, and multiple providers depend on oauth2.

)
274 changes: 274 additions & 0 deletions go.sum

Large diffs are not rendered by default.

183 changes: 183 additions & 0 deletions providers/atlassian/atlassian.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Package atlassian implements the OAuth2 protocol for authenticating users through atlassian.
// This package can be used as a reference implementation of an OAuth2 provider for Goth.
package atlassian

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

"fmt"

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

const (
authURL string = "https://auth.atlassian.com/authorize"
tokenURL string = "https://auth.atlassian.com/oauth/token"
endpointProfile string = "https://api.atlassian.com/me"
)

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

// New creates a new Atlassian provider and sets up important connection details.
// You should always call `atlassian.New` to get a new provider. Never try to
// create one manually.
func New(clientKey, secret, callbackURL string, scopes ...string) *Provider {
p := &Provider{
ClientKey: clientKey,
Secret: secret,
CallbackURL: callbackURL,
providerName: "atlassian",
}
p.config = newConfig(p, scopes)
return p
}

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

// 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
}

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

// BeginAuth asks Atlassian for an authentication end-point.
func (p *Provider) BeginAuth(state string) (goth.Session, error) {
authUrl := p.config.AuthCodeURL(state)
// audience and prompt are required static fields as described by
// https://developer.atlassian.com/cloud/atlassian/platform/oauth-2-authorization-code-grants-3lo-for-apps/#authcode
authUrl += "&audience=api.atlassian.com&prompt=consent"
return &Session{
AuthURL: authUrl,
}, nil
}

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

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)
}

c := p.Client()
req, err := http.NewRequest("GET", endpointProfile, nil)
if err != nil {
return user, err
}
req.Header.Set("Authorization", "Bearer "+s.AccessToken)
response, err := c.Do(req)

if err != nil {
return user, err
}
defer response.Body.Close()

if response.StatusCode != http.StatusOK {
return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, 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)
return user, err
}

func newConfig(provider *Provider, 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{},
}
if len(scopes) > 0 {
for _, scope := range scopes {
c.Scopes = append(c.Scopes, scope)
}
} else {
c.Scopes = append(c.Scopes, "read:me")
}
return c
}

func userFromReader(r io.Reader, user *goth.User) error {

u := struct {
Name string `json:"name"`
NickName string `json:"nickname"`
ExtendedProfile struct {
Location string `json:"location"`
} `json:"extended_profile"`
Email string `json:"email"`
ID string `json:"account_id"`
AvatarURL string `json:"picture"`
}{}
err := json.NewDecoder(r).Decode(&u)
if err != nil {
return err
}
user.Email = u.Email
user.Name = u.Name
user.NickName = u.NickName
user.UserID = u.ID
user.Location = u.ExtendedProfile.Location
user.AvatarURL = u.AvatarURL

return err
}

//RefreshTokenAvailable refresh token is provided by auth provider or not
func (p *Provider) RefreshTokenAvailable() bool {
return true
}

//RefreshToken get new access token based on the refresh token
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
}
57 changes: 57 additions & 0 deletions providers/atlassian/atlassian_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package atlassian_test

import (
"fmt"
"os"
"testing"

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

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

a.Equal(p.ClientKey, os.Getenv("JIRA_KEY"))
a.Equal(p.Secret, os.Getenv("JIRA_SECRET"))
a.Equal(p.CallbackURL, "/foo")
}

func Test_Implements_Provider(t *testing.T) {
t.Parallel()
a := assert.New(t)
a.Implements((*goth.Provider)(nil), provider())
}

func Test_BeginAuth(t *testing.T) {
t.Parallel()
a := assert.New(t)
p := provider()
session, err := p.BeginAuth("test_state")
s := session.(*atlassian.Session)
a.NoError(err)
a.Contains(s.AuthURL, "https://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=read%3Ame")
}

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

p := provider()
session, err := p.UnmarshalSession(`{"AuthURL":"https://auth.atlassian.com/auth_url","AccessToken":"1234567890"}`)
a.NoError(err)

s := session.(*atlassian.Session)
a.Equal(s.AuthURL, "https://auth.atlassian.com/auth_url")
a.Equal(s.AccessToken, "1234567890")
}

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

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

"github.com/markbates/goth"
)

// Session stores data during the auth process with Atlassian.
type Session struct {
AuthURL string
AccessToken string
RefreshToken string
ExpiresAt time.Time
}

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

// Authorize the session with Atlassian 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 {
b, _ := json.Marshal(s)
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) {
s := &Session{}
err := json.NewDecoder(strings.NewReader(data)).Decode(s)
return s, err
}