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

feat: extra auth providers & tests for previously added providers #269

Closed
wants to merge 13 commits into from
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ The default group to assign all new users to.

### External Authentication Providers

We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `github`, `gitlab`, `google`, `spotify`, `slack`, `twitch` and `twitter` for external authentication.
We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `github`, `gitlab`, `google`, `linkedin`,`spotify`, `slack`, `tiktok`, `twitch`, `twitter` and `workos` for external authentication.

Use the names as the keys underneath `external` to configure each separately.

Expand Down Expand Up @@ -502,10 +502,13 @@ Returns the publicly available settings for this gotrue instance.
"github": true,
"gitlab": true,
"google": true,
"linkedin": true,
"slack": true,
"spotify": true,
"twitch": true,
"twitter": true
"twitter": true,
"tiktok": true,
"workos": true,
},
"disable_signup": false,
"autoconfirm": false
Expand Down Expand Up @@ -885,7 +888,7 @@ Get access_token from external oauth provider
query params:

```
provider=apple | azure | bitbucket | discord | facebook | github | gitlab | google | slack | spotify | twitch | twitter
provider=apple | azure | bitbucket | discord | facebook | github | gitlab | google | linkedin | slack | spotify | tiktok | twitch | twitter | workos
scopes=<optional additional scopes depending on the provider (email and name are requested by default)>
```

Expand Down
6 changes: 6 additions & 0 deletions api/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,16 +381,22 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide
return provider.NewGitlabProvider(config.External.Gitlab, scopes)
case "google":
return provider.NewGoogleProvider(config.External.Google, scopes)
case "linkedin":
return provider.NewLinkedInProvider(config.External.LinkedIn, scopes)
case "facebook":
return provider.NewFacebookProvider(config.External.Facebook, scopes)
case "spotify":
return provider.NewSpotifyProvider(config.External.Spotify, scopes)
case "slack":
return provider.NewSlackProvider(config.External.Slack, scopes)
case "tiktok":
return provider.NewTikTokProvider(config.External.TikTok, scopes)
case "twitch":
return provider.NewTwitchProvider(config.External.Twitch, scopes)
case "twitter":
return provider.NewTwitterProvider(config.External.Twitter, scopes)
case "workos":
return provider.NewWorkOSProvider(config.External.WorkOS, scopes)
case "saml":
return provider.NewSamlProvider(config.External.Saml, a.db, getInstanceID(ctx))
default:
Expand Down
33 changes: 33 additions & 0 deletions api/external_linkedin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package api

import (
"net/http"
"net/http/httptest"
"net/url"

jwt "github.com/golang-jwt/jwt"
)

func (ts *ExternalTestSuite) TestSignupExternalLinkedIn() {
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=linkedin", nil)
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)
ts.Require().Equal(http.StatusFound, w.Code)
u, err := url.Parse(w.Header().Get("Location"))
ts.Require().NoError(err, "redirect url parse failed")
q := u.Query()
ts.Equal(ts.Config.External.LinkedIn.RedirectURI, q.Get("redirect_uri"))
ts.Equal(ts.Config.External.LinkedIn.ClientID, q.Get("client_id"))
ts.Equal("code", q.Get("response_type"))
ts.Equal("r_emailaddress r_liteprofile", q.Get("scope"))

claims := ExternalProviderClaims{}
p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
return []byte(ts.Config.JWT.Secret), nil
})
ts.Require().NoError(err)

ts.Equal("linkedin", claims.Provider)
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
}
33 changes: 33 additions & 0 deletions api/external_slack_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package api

import (
"net/http"
"net/http/httptest"
"net/url"

jwt "github.com/golang-jwt/jwt"
)

func (ts *ExternalTestSuite) TestSignupExternalSlack() {
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=slack", nil)
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)
ts.Require().Equal(http.StatusFound, w.Code)
u, err := url.Parse(w.Header().Get("Location"))
ts.Require().NoError(err, "redirect url parse failed")
q := u.Query()
ts.Equal(ts.Config.External.Slack.RedirectURI, q.Get("redirect_uri"))
ts.Equal(ts.Config.External.Slack.ClientID, q.Get("client_id"))
ts.Equal("code", q.Get("response_type"))
ts.Equal("profile email openid", q.Get("scope"))

claims := ExternalProviderClaims{}
p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
return []byte(ts.Config.JWT.Secret), nil
})
ts.Require().NoError(err)

ts.Equal("slack", claims.Provider)
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
}
33 changes: 33 additions & 0 deletions api/external_spotify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package api

import (
"net/http"
"net/http/httptest"
"net/url"

jwt "github.com/golang-jwt/jwt"
)

func (ts *ExternalTestSuite) TestSignupExternalSpotify() {
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=spotify", nil)
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)
ts.Require().Equal(http.StatusFound, w.Code)
u, err := url.Parse(w.Header().Get("Location"))
ts.Require().NoError(err, "redirect url parse failed")
q := u.Query()
ts.Equal(ts.Config.External.Spotify.RedirectURI, q.Get("redirect_uri"))
ts.Equal(ts.Config.External.Spotify.ClientID, q.Get("client_id"))
ts.Equal("code", q.Get("response_type"))
ts.Equal("user-read-email", q.Get("scope"))

claims := ExternalProviderClaims{}
p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
return []byte(ts.Config.JWT.Secret), nil
})
ts.Require().NoError(err)

ts.Equal("spotify", claims.Provider)
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
}
33 changes: 33 additions & 0 deletions api/external_tiktok_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package api

import (
"net/http"
"net/http/httptest"
"net/url"

jwt "github.com/golang-jwt/jwt"
)

func (ts *ExternalTestSuite) TestSignupExternalTikTok() {
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=tiktok", nil)
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)
ts.Require().Equal(http.StatusFound, w.Code)
u, err := url.Parse(w.Header().Get("Location"))
ts.Require().NoError(err, "redirect url parse failed")
q := u.Query()
ts.Equal(ts.Config.External.TikTok.RedirectURI, q.Get("redirect_uri"))
ts.Equal(ts.Config.External.TikTok.ClientID, q.Get("client_id"))
ts.Equal("code", q.Get("response_type"))
ts.Equal("user.info.basic", q.Get("scope"))

claims := ExternalProviderClaims{}
p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
return []byte(ts.Config.JWT.Secret), nil
})
ts.Require().NoError(err)

ts.Equal("tiktok", claims.Provider)
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
}
33 changes: 33 additions & 0 deletions api/external_workos_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package api

import (
"net/http"
"net/http/httptest"
"net/url"

jwt "github.com/golang-jwt/jwt"
)

func (ts *ExternalTestSuite) TestSignupExternalWorkOS() {
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=workos", nil)
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)
ts.Require().Equal(http.StatusFound, w.Code)
u, err := url.Parse(w.Header().Get("Location"))
ts.Require().NoError(err, "redirect url parse failed")
q := u.Query()
ts.Equal(ts.Config.External.WorkOS.RedirectURI, q.Get("redirect_uri"))
ts.Equal(ts.Config.External.WorkOS.ClientID, q.Get("client_id"))
ts.Equal("code", q.Get("response_type"))
ts.Equal("", q.Get("scope"))

claims := ExternalProviderClaims{}
p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
return []byte(ts.Config.JWT.Secret), nil
})
ts.Require().NoError(err)

ts.Equal("workos", claims.Provider)
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
}
140 changes: 140 additions & 0 deletions api/provider/linkedin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package provider

import (
"context"
"errors"
"strings"

"github.com/netlify/gotrue/conf"
"golang.org/x/oauth2"
)

const (
defaultLinkedInAPIBase = "api.linkedin.com"
)

type linkedinProvider struct {
*oauth2.Config
APIPath string
UserInfoURL string
UserEmailUrl string
}

// https://docs.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin?context=linkedin/consumer/context
type linkedinUser struct {
ID string `json:"id"`
FirstName linkedinName `json:"firstName"` // i tried to parse data but not sure
LastName linkedinName `json:"lastName"` // i tried to parse data but not sure
AvatarURL struct { // I don't know if we can do better than that
DisplayImage struct {
Elements []struct {
Identifiers []struct {
Identifier string `json:"identifier"`
} `json:"identifiers"`
} `json:"elements"`
} `json:"displayImage~"`
} `json:"profilePicture"`
}

type linkedinLocale struct {
Country string `json:"country"`
Language string `json:"language"`
}

type linkedinName struct {
Localized interface{} `json:"localized"` // try to catch all possible value
PreferredLocale linkedinLocale `json:"preferredLocale"`
}

type linkedinEmail struct {
EmailAddress string `json:"emailAddress"`
}

type linkedinUserEmail struct {
Handle string `json:"handle"`
Handle_email linkedinEmail `json:"handle~"`
}

// NewLinkedinProvider creates a Linkedin account provider.
func NewLinkedInProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) {
if err := ext.Validate(); err != nil {
return nil, err
}

// authHost := chooseHost(ext.URL, defaultLinkedinAuthBase)
apiPath := chooseHost(ext.URL, defaultLinkedInAPIBase)

oauthScopes := []string{
"r_emailaddress",
"r_liteprofile",
}

if scopes != "" {
oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...)
}

return &linkedinProvider{
Config: &oauth2.Config{
ClientID: ext.ClientID,
ClientSecret: ext.Secret,
Endpoint: oauth2.Endpoint{
AuthURL: apiPath + "/oauth/v2/authorization",
TokenURL: apiPath + "/oauth/v2/accessToken",
},
Scopes: oauthScopes,
RedirectURL: ext.RedirectURI,
},
APIPath: apiPath,
}, nil
}

func (g linkedinProvider) GetOAuthToken(code string) (*oauth2.Token, error) {
return g.Exchange(oauth2.NoContext, code)
}

func GetName(name linkedinName) string {
key := name.PreferredLocale.Language + "_" + name.PreferredLocale.Country
myMap := name.Localized.(map[string]interface{})
return myMap[key].(string)
}

func (g linkedinProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) {
var u linkedinUser
if err := makeRequest(ctx, tok, g.Config, g.APIPath+"/v2/me?projection=(id,firstName,lastName,profilePicture(displayImage~:playableStreams))", &u); err != nil {
return nil, err
}

var email linkedinUserEmail
if err := makeRequest(ctx, tok, g.Config, g.APIPath+"/v2/emailAddress?q=members&projection=(elements*(handle~))", &email); err != nil {
return nil, err
}

emails := []Email{}

if email.Handle_email.EmailAddress != "" {
emails = append(emails, Email{
Email: email.Handle_email.EmailAddress,
Primary: true,
})
}

if len(emails) <= 0 {
return nil, errors.New("Unable to find email with Linkedin provider")
}

return &UserProvidedData{
Metadata: &Claims{
Issuer: g.APIPath,
Subject: u.ID,
Name: strings.TrimSpace(GetName(u.FirstName) + " " + GetName(u.LastName)),
Picture: u.AvatarURL.DisplayImage.Elements[0].Identifiers[0].Identifier,
Email: email.Handle_email.EmailAddress,

// To be deprecated
AvatarURL: u.AvatarURL.DisplayImage.Elements[0].Identifiers[0].Identifier,
FullName: strings.TrimSpace(GetName(u.FirstName) + " " + GetName(u.LastName)),
ProviderId: u.ID,
},
Emails: emails,
}, nil
}
Loading