commit 4fe9c1b9bddcd73193592c80e748384a46310e77
parent 33a9bae99e60665d0d977904927ffbd9990b0a14
Author: Sebastian Mancke <s.mancke@tarent.de>
Date: Tue, 14 Nov 2017 16:34:45 +0100
Merge pull request #55 from afdecastro879/master
#51 | Added Bitbucket Oauth2 support
Diffstat:
4 files changed, 272 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
@@ -49,7 +49,8 @@ _Note for Caddy users_: Not all parameters are available in Caddy. See the table
| -cookie-http-only | boolean | true | X | Set the cookie with the http only flag |
| -cookie-name | string | "jwt_token" | X | The name of the jwt cookie |
| -github | value | | X | Oauth config in the form: client_id=..,client_secret=..[,scope=..,][redirect_uri=..] |
-| -google | value | | X | Oauth config in the form: client_id=..,client_secret=..,scope=..[redirect_uri=..] |
+| -google | value | | X | Oauth config in the form: client_id=..,client_secret=..,scope=..[redirect_uri=..] |
+| -bitbucket | value | | X | Oauth config in the form: client_id=..,client_secret=..,[scope=..][redirect_uri=..] |
| -host | string | "localhost" | - | The host to listen on |
| -htpasswd | value | | X | Htpasswd login backend opts: file=/path/to/pwdfile |
| -jwt-expiry | go duration | 24h | X | The expiry duration for the jwt token, e.g. 2h or 3h30m |
@@ -241,6 +242,7 @@ Currently the following oauth Provider is supported:
* github
* google
+* bitbucket
An Oauth Provider supports the following parameters:
diff --git a/oauth2/bitbucket.go b/oauth2/bitbucket.go
@@ -0,0 +1,132 @@
+package oauth2
+
+import (
+ "encoding/json"
+ "fmt"
+ "github.com/tarent/loginsrv/model"
+ "io/ioutil"
+ "net/http"
+ "strings"
+)
+
+var bitbucketAPI = "https://api.bitbucket.org/2.0"
+
+// Using the avatar url to be able to fetch 128px image. By default BitbucketAPI return 32px image.
+var bitbucketAvatarURL = "https://bitbucket.org/account/%v/avatar/128/"
+
+func init() {
+ RegisterProvider(providerBitbucket)
+}
+
+// bitbucketUser is used for parsing the github response
+type bitbucketUser struct {
+ Username string `json:"username,omitempty"`
+ DisplayName string `json:"display_name,omitempty"`
+ Email string `json:"email,omitempty"`
+}
+
+// emails is used to parse user email information
+type emails struct {
+ Page int `json:"page,omitempty"`
+ PageLen int `json:"pagelen,omitempty"`
+ Size int `json:"size,omitempty"`
+ Values []email
+}
+
+// email used to parse one user's email
+type email struct {
+ Email string `json:"email,omitempty"`
+ IsConfirmed bool `json:"is_confirmed,omitempty"`
+ IsPrimary bool `json:"is_primary,omitempty"`
+ Links struct {
+ Self struct {
+ Href string
+ }
+ } `json:"links,omitempty"`
+ Type string `json:"type,omitempty"`
+}
+
+// getPrimaryEmailAddress retrieve the primary email address of the user
+func (e *emails) getPrimaryEmailAddress() string {
+ for _, val := range e.Values {
+ if val.IsPrimary {
+ return val.Email
+ }
+ }
+ return ""
+}
+
+// getBitbucketEmails Retrieves bitbucket user emails from the Bitbucket API emails service
+func getBitbucketEmails(token TokenInfo) (emails, error) {
+ emailUrl := fmt.Sprintf("%v/user/emails?access_token=%v", bitbucketAPI, token.AccessToken)
+ userEmails := emails{}
+ resp, err := http.Get(emailUrl)
+
+ if err != nil {
+ return emails{}, err
+ }
+
+ if !strings.Contains(resp.Header.Get("Content-Type"), "application/json") {
+ return emails{}, fmt.Errorf("wrong content-type on bitbucket get user emails: %v", resp.Header.Get("Content-Type"))
+ }
+
+ if resp.StatusCode != 200 {
+ return emails{}, fmt.Errorf("got http status %v on bitbucket get user emails", resp.StatusCode)
+ }
+
+ b, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ return emails{}, fmt.Errorf("error reading bitbucket get user emails: %v", err)
+ }
+
+ err = json.Unmarshal(b, &userEmails)
+
+ if err != nil {
+ return emails{}, fmt.Errorf("error parsing bitbucket get user emails: %v", err)
+ }
+
+ return userEmails, nil
+}
+
+var providerBitbucket = Provider{
+ Name: "bitbucket",
+ AuthURL: "https://bitbucket.org/site/oauth2/authorize",
+ TokenURL: "https://bitbucket.org/site/oauth2/access_token",
+ GetUserInfo: func(token TokenInfo) (model.UserInfo, string, error) {
+ gu := bitbucketUser{}
+ url := fmt.Sprintf("%v/user?access_token=%v", bitbucketAPI, token.AccessToken)
+ resp, err := http.Get(url)
+ if err != nil {
+ return model.UserInfo{}, "", err
+ }
+
+ if !strings.Contains(resp.Header.Get("Content-Type"), "application/json") {
+ return model.UserInfo{}, "", fmt.Errorf("wrong content-type on bitbucket get user info: %v", resp.Header.Get("Content-Type"))
+ }
+
+ if resp.StatusCode != 200 {
+ return model.UserInfo{}, "", fmt.Errorf("got http status %v on bitbucket get user info", resp.StatusCode)
+ }
+
+ b, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ return model.UserInfo{}, "", fmt.Errorf("error reading bitbucket get user info: %v", err)
+ }
+
+ err = json.Unmarshal(b, &gu)
+
+ if err != nil {
+ return model.UserInfo{}, "", fmt.Errorf("error parsing bitbucket get user info: %v", err)
+ }
+
+ userEmails, err := getBitbucketEmails(token)
+
+ return model.UserInfo{
+ Sub: gu.Username,
+ Picture: fmt.Sprintf(bitbucketAvatarURL, gu.Username),
+ Name: gu.DisplayName,
+ Email: userEmails.getPrimaryEmailAddress(),
+ Origin: "bitbucket",
+ }, string(b), nil
+ },
+}
diff --git a/oauth2/bitbucket_test.go b/oauth2/bitbucket_test.go
@@ -0,0 +1,131 @@
+package oauth2
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "github.com/gorilla/mux"
+ "github.com/stretchr/testify/suite"
+ "encoding/json"
+)
+
+var bitbucketTestUserResponse = `{
+ "created_on": "2011-12-20T16:34:07.132459+00:00",
+ "display_name": "tutorials account",
+ "is_staff": false,
+ "links": {
+ "avatar": {
+ "href": "https://bitbucket.org/account/tutorials/avatar/32/"
+ },
+ "followers": {
+ "href": "https://api.bitbucket.org/2.0/users/tutorials/followers"
+ },
+ "following": {
+ "href": "https://api.bitbucket.org/2.0/users/tutorials/following"
+ },
+ "hooks": {
+ "href": "https://api.bitbucket.org/2.0/users/tutorials/hooks"
+ },
+ "html": {
+ "href": "https://bitbucket.org/tutorials/"
+ },
+ "repositories": {
+ "href": "https://api.bitbucket.org/2.0/repositories/tutorials"
+ },
+ "self": {
+ "href": "https://api.bitbucket.org/2.0/users/tutorials"
+ },
+ "snippets": {
+ "href": "https://api.bitbucket.org/2.0/snippets/tutorials"
+ }
+ },
+ "location": null,
+ "type": "user",
+ "username": "tutorials",
+ "uuid": "{c788b2da-b7a2-404c-9e26-d3f077557007}",
+ "website": "https://tutorials.bitbucket.org/"
+}`
+
+var bitbucketTestUserEmailResponse = `{
+ "page": 1,
+ "pagelen": 10,
+ "size": 1,
+ "values": [
+ {
+ "email": "tutorials@bitbucket.com",
+ "is_confirmed": true,
+ "is_primary": true,
+ "links": {
+ "self": {
+ "href": "https://api.bitbucket.org/2.0/user/emails/tutorials@bitbucket.com"
+ }
+ },
+ "type": "email"
+ },
+ {
+ "email": "anotheremail@bitbucket.com",
+ "is_confirmed": false,
+ "is_primary": false,
+ "links": {
+ "self": {
+ "href": "https://api.bitbucket.org/2.0/user/emails/anotheremail@bitbucket.com"
+ }
+ },
+ "type": "email"
+ }
+ ]
+}`
+
+// BitbucketTestSuite Model for the bitbucket test suite
+type BitbucketTestSuite struct {
+ suite.Suite
+ Server *httptest.Server
+}
+
+// SetupTest a method that will be run before any method of this suite. It setups a mock server for bitbucket API
+func (suite *BitbucketTestSuite) SetupTest() {
+ r := mux.NewRouter()
+
+ userHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ suite.Equal("secret", r.FormValue("access_token"))
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.Write([]byte(bitbucketTestUserResponse))
+ })
+
+ emailHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ suite.Equal("secret", r.FormValue("access_token"))
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.Write([]byte(bitbucketTestUserEmailResponse))
+ })
+
+ r.HandleFunc("/user", userHandler)
+ r.HandleFunc("/user/emails", emailHandler)
+
+ suite.Server = httptest.NewServer(r)
+}
+
+// Test_Bitbucket_getUserInfo Tests Bitbucket provider returns the expected information
+func (suite *BitbucketTestSuite) Test_Bitbucket_getUserInfo() {
+
+ bitbucketAPI = suite.Server.URL
+
+ u, rawJSON, err := providerBitbucket.GetUserInfo(TokenInfo{AccessToken: "secret"})
+ suite.NoError(err)
+ suite.Equal("tutorials", u.Sub)
+ suite.Equal("tutorials@bitbucket.com", u.Email)
+ suite.Equal("tutorials account", u.Name)
+ suite.Equal(bitbucketTestUserResponse, rawJSON)
+}
+
+// Test_Bitbucket_getPrimaryEmailAddress Tests the returned primary email is the expected email
+func (suite *BitbucketTestSuite) Test_Bitbucket_getPrimaryEmailAddress() {
+ userEmails := emails{}
+ err := json.Unmarshal([]byte(bitbucketTestUserEmailResponse), &userEmails)
+ suite.NoError(err)
+ suite.Equal("tutorials@bitbucket.com", userEmails.getPrimaryEmailAddress())
+}
+
+// Test_Bitbucket_Suite Runs the entire suite for Bitbucket
+func Test_Bitbucket_Suite(t *testing.T) {
+ suite.Run(t, new(BitbucketTestSuite))
+}
diff --git a/oauth2/provider_test.go b/oauth2/provider_test.go
@@ -14,8 +14,13 @@ func Test_ProviderRegistration(t *testing.T) {
NotNil(t, google)
True(t, exist)
+ bitbucket, exist := GetProvider("bitbucket")
+ NotNil(t, bitbucket)
+ True(t, exist)
+
list := ProviderList()
- Equal(t, 2, len(list))
+ Equal(t, 3, len(list))
Contains(t, list, "github")
Contains(t, list, "google")
+ Contains(t, list, "bitbucket")
}