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

Classlink #421

Merged
merged 4 commits into from
Apr 3, 2024
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.
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 @@ -23,6 +23,7 @@ $ go get github.com/markbates/goth
* Battle.net
* Bitbucket
* Box
* ClassLink
* Cloud Foundry
* Dailymotion
* Deezer
Expand Down
156 changes: 156 additions & 0 deletions providers/classlink/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package classlink

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

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

const infoURL = "https://nodeapi.classlink.com/v2/my/info"

// Provider is an implementation of
type Provider struct {
ClientKey string
ClientSecret string
CallbackURL string
HTTPClient *http.Client
providerName string
config *oauth2.Config
}

func New(clientKey, secret, callbackURL string, scopes ...string) *Provider {
prov := &Provider{
ClientKey: clientKey,
ClientSecret: secret,
CallbackURL: callbackURL,
providerName: "classlink",
}
prov.config = newConfig(prov, scopes)
return prov
}

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

func (p Provider) Name() string {
return p.providerName
}

func (p Provider) SetName(name string) {
p.providerName = name
}

func (p Provider) BeginAuth(state string) (goth.Session, error) {
url := p.config.AuthCodeURL(state)
return &Session{
AuthURL: url,
}, nil
}

func (p Provider) UnmarshalSession(s string) (goth.Session, error) {
var sess Session
err := json.Unmarshal([]byte(s), &sess)

if err != nil {
return nil, err
}

return &sess, nil
}

// classLinkUser contains all relevant fields from the ClassLink response
// to
type classLinkUser struct {
UserID int `json:"UserId"`
Email string `json:"Email"`
DisplayName string `json:"DisplayName"`
FirstName string `json:"FirstName"`
LastName string `json:"LastName"`
}

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

req, err := http.NewRequest("GET", infoURL, nil)
if err != nil {
return user, err
}

req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", sess.AccessToken))

resp, err := p.Client().Do(req)
if err != nil {
return user, err
}

defer resp.Body.Close()

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

var u classLinkUser
if err := json.Unmarshal(bytes, &user.RawData); err != nil {
return user, err
}

if err := json.Unmarshal(bytes, &u); err != nil {
return user, err
}

user.UserID = fmt.Sprintf("%d", u.UserID)
user.FirstName = u.FirstName
user.LastName = u.LastName
user.Email = u.Email
user.Name = u.DisplayName
return user, nil
}

func (p Provider) Debug(b bool) {}

func (p Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) {
return nil, errors.New("refresh token is not provided by ClassLink")
}

func (p Provider) RefreshTokenAvailable() bool {
return false
}

func newConfig(provider *Provider, scopes []string) *oauth2.Config {
c := &oauth2.Config{
ClientID: provider.ClientKey,
ClientSecret: provider.ClientSecret,
RedirectURL: provider.CallbackURL,
Endpoint: oauth2.Endpoint{
AuthURL: "https://launchpad.classlink.com/oauth2/v2/auth",
TokenURL: "https://launchpad.classlink.com/oauth2/v2/token",
},
Scopes: []string{},
}

if len(scopes) > 0 {
c.Scopes = append(c.Scopes, scopes...)
} else {
c.Scopes = append(c.Scopes, "profile")
}

return c
}
59 changes: 59 additions & 0 deletions providers/classlink/provider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package classlink_test

import (
"fmt"
"os"
"testing"

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

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

provider := classLinkProvider()
a.Equal(provider.ClientKey, os.Getenv("CLASSLINK_KEY"))
a.Equal(provider.ClientSecret, os.Getenv("CLASSLINK_SECRET"))
a.Equal(provider.CallbackURL, "/foo")
}

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

provider := classLinkProvider()
session, err := provider.BeginAuth("test_state")
s := session.(*classlink.Session)
a.NoError(err)
a.Contains(s.AuthURL, "launchpad.classlink.com/oauth2/v2/")
a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("GOOGLE_KEY")))
a.Contains(s.AuthURL, "state=test_state")
a.Contains(s.AuthURL, "scope=profile")
}

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

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

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

provider := classLinkProvider()

s, err := provider.UnmarshalSession(`{"AuthURL":"https://launchpad.classlink.com/oauth2/v2/","AccessToken":"1234567890"}`)
a.NoError(err)
session := s.(*classlink.Session)
a.Equal(session.AuthURL, "https://launchpad.classlink.com/oauth2/v2/")
a.Equal(session.AccessToken, "1234567890")
}

func classLinkProvider() *classlink.Provider {
return classlink.New(os.Getenv("CLASSLINK_KEY"), os.Getenv("CLASSLINK_SECRET"), "/foo")
}
49 changes: 49 additions & 0 deletions providers/classlink/session.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package classlink

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

"github.com/markbates/goth"
)

type Session struct {
AuthURL string
AccessToken string
RefreshToken string
ExpiresAt time.Time
}

func (s *Session) GetAuthURL() (string, error) {
if s.AuthURL == "" {
return "", errors.New(goth.NoAuthUrlErrorMessage)
}
return s.AuthURL, nil
}

func (s *Session) Marshal() string {
bytes, _ := json.Marshal(s)
return string(bytes)
}

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
}

func (s *Session) String() string {
return s.Marshal()
}
48 changes: 48 additions & 0 deletions providers/classlink/session_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package classlink_test

import (
"testing"

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

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

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

func Test_GetAuthURL(t *testing.T) {
t.Parallel()
a := assert.New(t)
s := &classlink.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 := &classlink.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 := &classlink.Session{}

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