Skip to content

Commit

Permalink
Initial LinkedIn OAuth Implementation
Browse files Browse the repository at this point in the history
Takes code from supabase#238 and finished implementation, still needs improvements in provider impl file
  • Loading branch information
HarryET committed Nov 14, 2021
1 parent a162887 commit c7d3d75
Show file tree
Hide file tree
Showing 9 changed files with 225 additions and 2 deletions.
5 changes: 3 additions & 2 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`, `tiktok`, `twitch`, `twitter` and `workos` 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,6 +502,7 @@ Returns the publicly available settings for this gotrue instance.
"github": true,
"gitlab": true,
"google": true,
"linkedin": true,
"slack": true,
"spotify": true,
"twitch": true,
Expand Down Expand Up @@ -887,7 +888,7 @@ Get access_token from external oauth provider
query params:

```
provider=apple | azure | bitbucket | discord | facebook | github | gitlab | google | slack | spotify | tiktok | twitch | twitter | workos
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
2 changes: 2 additions & 0 deletions api/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,8 @@ 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.Google, scopes)
case "facebook":
return provider.NewFacebookProvider(config.External.Facebook, scopes)
case "spotify":
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.Apple.RedirectURI, q.Get("redirect_uri"))
ts.Equal(ts.Config.External.Apple.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)
}
176 changes: 176 additions & 0 deletions api/provider/linkedin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package provider

import (
"context"
"errors"
"strings"

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

const (
defaultLinkedinAPIBase = "api.linkedin.com"
endpointLinkedinProfile = "/v2/me?projection=(id,firstName,lastName,profilePicture(displayImage~:playableStreams))"
endpointLinkedinEmail = "/v2/emailAddress?q=members&projection=(elements*(handle~))"
)

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

// This is the json returned by api for profile
// {
// "firstName":{
// "localized":{
// "en_US":"Tina"
// },
// "preferredLocale":{
// "country":"US",
// "language":"en"
// }
// },
// "lastName":{
// "localized":{
// "en_US":"Belcher"
// },
// "preferredLocale":{
// "country":"US",
// "language":"en"
// }
// },
// }

// return format for avatarUrl
// {"displayImage~" : { elements: [{identifiers: [ {identifier: "URL"}]}]}}

// https://docs.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin?context=linkedin/consumer/context
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 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"`
}

// This is the json returned by api for email
// {
// "handle": "urn:li:emailAddress:3775708763",
// "handle~": {
// "emailAddress": "hsimpson@linkedin.com"
// }
// }

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: defaultLinkedinAPIBase + "/oauth/v2/authorization",
TokenURL: defaultLinkedinAPIBase + "/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{}) // not sure about the cast
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, defaultLinkedinAPIBase+endpointLinkedinProfile, &u); err != nil {
return nil, err
}

data := &UserProvidedData{}

var email linkedinUserEmail
if err := makeRequest(ctx, tok, g.Config, defaultLinkedinAPIBase+endpointLinkedinEmail, &email); err != nil {
return nil, err
}

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

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

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

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

return data, nil
}
2 changes: 2 additions & 0 deletions api/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type ProviderSettings struct {
GitHub bool `json:"github"`
GitLab bool `json:"gitlab"`
Google bool `json:"google"`
LinkedIn bool `json:"linkedin"`
Facebook bool `json:"facebook"`
Spotify bool `json:"spotify"`
Slack bool `json:"slack"`
Expand Down Expand Up @@ -47,6 +48,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error {
GitHub: config.External.Github.Enabled,
GitLab: config.External.Gitlab.Enabled,
Google: config.External.Google.Enabled,
LinkedIn: config.External.LinkedIn.Enabled,
Facebook: config.External.Facebook.Enabled,
Spotify: config.External.Spotify.Enabled,
Slack: config.External.Slack.Enabled,
Expand Down
1 change: 1 addition & 0 deletions api/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func TestSettings_DefaultProviders(t *testing.T) {
require.True(t, p.Facebook)
require.True(t, p.Spotify)
require.True(t, p.Slack)
require.True(t, p.LinkedIn)
require.True(t, p.TikTok)
require.True(t, p.Google)
require.True(t, p.GitHub)
Expand Down
1 change: 1 addition & 0 deletions conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ type ProviderConfiguration struct {
Github OAuthProviderConfiguration `json:"github"`
Gitlab OAuthProviderConfiguration `json:"gitlab"`
Google OAuthProviderConfiguration `json:"google"`
LinkedIn OAuthProviderConfiguration `json:"linkedin"`
Spotify OAuthProviderConfiguration `json:"spotify"`
Slack OAuthProviderConfiguration `json:"slack"`
TikTok OAuthProviderConfiguration `json:"tiktok"`
Expand Down
3 changes: 3 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ GOTRUE_EXTERNAL_SPOTIFY_SECRET=""
GOTRUE_EXTERNAL_SLACK_ENABLED="true"
GOTRUE_EXTERNAL_SLACK_CLIENT_ID=""
GOTRUE_EXTERNAL_SLACK_SECRET=""
GOTRUE_EXTERNAL_LINKEDIN_ENABLED="true"
GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID=""
GOTRUE_EXTERNAL_LINKEDIN_SECRET=""
GOTRUE_EXTERNAL_TIKTOK_ENABLED="true"
GOTRUE_EXTERNAL_TIKTOK_CLIENT_ID=""
GOTRUE_EXTERNAL_TIKTOK_SECRET=""
Expand Down
4 changes: 4 additions & 0 deletions hack/test.env
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ GOTRUE_EXTERNAL_SLACK_ENABLED=true
GOTRUE_EXTERNAL_SLACK_CLIENT_ID=testclientid
GOTRUE_EXTERNAL_SLACK_SECRET=testsecret
GOTRUE_EXTERNAL_SLACK_REDIRECT_URI=https://identity.services.netlify.com/callback
GOTRUE_EXTERNAL_LINKEDIN_ENABLED=true
GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID=testclientid
GOTRUE_EXTERNAL_LINKEDIN_SECRET=testsecret
GOTRUE_EXTERNAL_LINKEDIN_REDIRECT_URI=https://identity.services.netlify.com/callback
GOTRUE_EXTERNAL_WORKOS_ENABLED=true
GOTRUE_EXTERNAL_WORKOS_CLIENT_ID=testclientid
GOTRUE_EXTERNAL_WORKOS_SECRET=testsecret
Expand Down

0 comments on commit c7d3d75

Please sign in to comment.