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

Add Gitea as a Provider #276

Merged
merged 2 commits into from
May 16, 2019
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ $ go get github.com/markbates/goth
* Eve Online
* Facebook
* Fitbit
* Gitea
* GitHub
* Gitlab
* Google
Expand Down
3 changes: 3 additions & 0 deletions examples/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/markbates/goth/providers/eveonline"
"github.com/markbates/goth/providers/facebook"
"github.com/markbates/goth/providers/fitbit"
"github.com/markbates/goth/providers/gitea"
"github.com/markbates/goth/providers/github"
"github.com/markbates/goth/providers/gitlab"
"github.com/markbates/goth/providers/google"
Expand Down Expand Up @@ -120,6 +121,7 @@ func main() {
naver.New(os.Getenv("NAVER_KEY"), os.Getenv("NAVER_SECRET"), "http://localhost:3000/auth/naver/callback"),
yandex.New(os.Getenv("YANDEX_KEY"), os.Getenv("YANDEX_SECRET"), "http://localhost:3000/auth/yandex/callback"),
nextcloud.NewCustomisedDNS(os.Getenv("NEXTCLOUD_KEY"), os.Getenv("NEXTCLOUD_SECRET"), "http://localhost:3000/auth/nextcloud/callback", os.Getenv("NEXTCLOUD_URL")),
gitea.New(os.Getenv("GITEA_KEY"), os.Getenv("GITEA_SECRET"), "http://localhost:3000/auth/gitea/callback"),
)

// OpenID Connect is based on OpenID Connect Auto Discovery URL (https://openid.net/specs/openid-connect-discovery-1_0-17.html)
Expand All @@ -142,6 +144,7 @@ func main() {
m["eveonline"] = "Eve Online"
m["facebook"] = "Facebook"
m["fitbit"] = "Fitbit"
m["gitea"] = "Gitea"
m["github"] = "Github"
m["gitlab"] = "Gitlab"
m["google"] = "Google"
Expand Down
186 changes: 186 additions & 0 deletions providers/gitea/gitea.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// Package gitea implements the OAuth2 protocol for authenticating users through gitea.
// This package can be used as a reference implementation of an OAuth2 provider for Goth.
package gitea

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

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

// These vars define the default Authentication, Token, and Profile URLS for Gitea.
//
// Examples:
// gitea.AuthURL = "https://gitea.acme.com/oauth/authorize
// gitea.TokenURL = "https://gitea.acme.com/oauth/token
// gitea.ProfileURL = "https://gitea.acme.com/api/v3/user
var (
AuthURL = "https://gitea.com/login/oauth/authorize"
TokenURL = "https://gitea.com/login/oauth/access_token"
ProfileURL = "https://gitea.com/api/v1/user"
)

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

// New creates a new Gitea provider and sets up important connection details.
// You should always call `gitea.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, 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, profileURL string, scopes ...string) *Provider {
p := &Provider{
ClientKey: clientKey,
Secret: secret,
CallbackURL: callbackURL,
providerName: "gitea",
profileURL: profileURL,
}
p.config = newConfig(p, authURL, tokenURL, scopes)
return p
}

// 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 gitea package.
func (p *Provider) Debug(debug bool) {}

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

// FetchUser will go to Gitea 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,
Provider: p.Name(),
RefreshToken: sess.RefreshToken,
ExpiresAt: sess.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)
}

response, err := p.Client().Get(p.profileURL + "?access_token=" + url.QueryEscape(sess.AccessToken))
if err != nil {
if response != nil {
response.Body.Close()
}
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, 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{},
}

if len(scopes) > 0 {
for _, scope := range scopes {
c.Scopes = append(c.Scopes, scope)
}
}
return c
}

func userFromReader(r io.Reader, user *goth.User) error {
u := struct {
Name string `json:"full_name"`
Email string `json:"email"`
NickName string `json:"login"`
ID int `json:"id"`
AvatarURL string `json:"avatar_url"`
}{}
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 = strconv.Itoa(u.ID)
user.AvatarURL = u.AvatarURL
return nil
}

//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
}
67 changes: 67 additions & 0 deletions providers/gitea/gitea_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package gitea_test

import (
"os"
"testing"

"github.com/markbates/goth"
"github.com/markbates/goth/providers/gitea"
"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("GITEA_KEY"))
a.Equal(p.Secret, os.Getenv("GITEA_SECRET"))
a.Equal(p.CallbackURL, "/foo")
}

func Test_NewCustomisedURL(t *testing.T) {
t.Parallel()
a := assert.New(t)
p := urlCustomisedURLProvider()
session, err := p.BeginAuth("test_state")
s := session.(*gitea.Session)
a.NoError(err)
a.Contains(s.AuthURL, "http://authURL")
}

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.(*gitea.Session)
a.NoError(err)
a.Contains(s.AuthURL, "gitea.com/login/oauth/authorize")
}

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

p := provider()
session, err := p.UnmarshalSession(`{"AuthURL":"https://gitea.com/login/oauth/authorize","AccessToken":"1234567890"}`)
a.NoError(err)

s := session.(*gitea.Session)
a.Equal(s.AuthURL, "https://gitea.com/login/oauth/authorize")
a.Equal(s.AccessToken, "1234567890")
}

func provider() *gitea.Provider {
return gitea.New(os.Getenv("GITEA_KEY"), os.Getenv("GITEA_SECRET"), "/foo")
}

func urlCustomisedURLProvider() *gitea.Provider {
return gitea.NewCustomisedURL(os.Getenv("GITEA_KEY"), os.Getenv("GITEA_SECRET"), "/foo", "http://authURL", "http://tokenURL", "http://profileURL")
}
63 changes: 63 additions & 0 deletions providers/gitea/session.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package gitea

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

"github.com/markbates/goth"
)

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

var _ goth.Session = &Session{}

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

// Authorize the session with Gitea 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 wil 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
}
47 changes: 47 additions & 0 deletions providers/gitea/session_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package gitea_test

import (
"github.com/markbates/goth"
"github.com/markbates/goth/providers/gitea"
"github.com/stretchr/testify/assert"
"testing"
)

func Test_Implements_Session(t *testing.T) {
t.Parallel()
a := assert.New(t)
s := &gitea.Session{}

a.Implements((*goth.Session)(nil), s)
}

func Test_GetAuthURL(t *testing.T) {
t.Parallel()
a := assert.New(t)
s := &gitea.Session{}

_, err := s.GetAuthURL()
a.Error(err)

s.AuthURL = "/foo"

url, _ := s.GetAuthURL()
a.Equal(url, "/foo")
}

func Test_ToJSON(t *testing.T) {
t.Parallel()
a := assert.New(t)
s := &gitea.Session{}

data := s.Marshal()
a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`)
}

func Test_String(t *testing.T) {
t.Parallel()
a := assert.New(t)
s := &gitea.Session{}

a.Equal(s.String(), s.Marshal())
}