Skip to content

Commit

Permalink
feat: linkedin provider (#238)
Browse files Browse the repository at this point in the history
* feat: linkedin provider

* fix: invalid linkedin emailAddress struct format

* refactor: change linkedin to lowercase

* test: update linkedin provider tests

Co-authored-by: Kang Ming <kang.ming1996@gmail.com>
  • Loading branch information
riderx and kangmingtay committed Jan 4, 2022
1 parent c383a5c commit 786efee
Show file tree
Hide file tree
Showing 9 changed files with 319 additions and 1 deletion.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,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`, `spotify`, `slack`, `twitch`, `linkedin` and `twitter` for external authentication.

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

Expand Down
2 changes: 2 additions & 0 deletions api/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,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.Linkedin, scopes)
case "facebook":
return provider.NewFacebookProvider(config.External.Facebook, scopes)
case "spotify":
Expand Down
158 changes: 158 additions & 0 deletions api/external_linkedin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package api

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

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

const (
linkedinUser string = `{"id":"linkedinTestId","firstName":{"localized":{"en_US":"Linkedin"},"preferredLocale":{"country":"US","language":"en"}},"lastName":{"localized":{"en_US":"Test"},"preferredLocale":{"country":"US","language":"en"}},"profilePicture":{"displayImage~":{"elements":[{"identifiers":[{"identifier":"http://example.com/avatar"}]}]}}}`
linkedinEmail string = `{"elements": [{"handle": "","handle~": {"emailAddress": "linkedin@example.com"}}]}`
linkedinWrongEmail string = `{"elements": [{"handle": "","handle~": {"emailAddress": "other@example.com"}}]}`
)

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

func LinkedinTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string, email string) *httptest.Server {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/oauth/v2/accessToken":
*tokenCount++
ts.Equal(code, r.FormValue("code"))
ts.Equal("authorization_code", r.FormValue("grant_type"))
ts.Equal(ts.Config.External.Linkedin.RedirectURI, r.FormValue("redirect_uri"))

w.Header().Add("Content-Type", "application/json")
fmt.Fprint(w, `{"access_token":"linkedin_token","expires_in":100000}`)
case "/v2/me":
*userCount++
w.Header().Add("Content-Type", "application/json")
fmt.Fprint(w, user)
case "/v2/emailAddress":
w.Header().Add("Content-Type", "application/json")
fmt.Fprint(w, email)
default:
w.WriteHeader(500)
ts.Fail("unknown linkedin oauth call %s", r.URL.Path)
}
}))

ts.Config.External.Linkedin.URL = server.URL

return server
}

func (ts *ExternalTestSuite) TestSignupExternalLinkedin_AuthorizationCode() {
ts.Config.DisableSignup = false
tokenCount, userCount := 0, 0
code := "authcode"
server := LinkedinTestSignupSetup(ts, &tokenCount, &userCount, code, linkedinUser, linkedinEmail)
defer server.Close()

u := performAuthorization(ts, "linkedin", code, "")

assertAuthorizationSuccess(ts, u, tokenCount, userCount, "linkedin@example.com", "Linkedin Test", "linkedinTestId", "http://example.com/avatar")
}

func (ts *ExternalTestSuite) TestSignupExternalLinkedinDisableSignupErrorWhenNoUser() {
ts.Config.DisableSignup = true

tokenCount, userCount := 0, 0
code := "authcode"
server := LinkedinTestSignupSetup(ts, &tokenCount, &userCount, code, linkedinUser, linkedinEmail)
defer server.Close()

u := performAuthorization(ts, "linkedin", code, "")

assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "linkedin@example.com")
}

func (ts *ExternalTestSuite) TestSignupExternalLinkedinDisableSignupSuccessWithPrimaryEmail() {
ts.Config.DisableSignup = true

ts.createUser("linkedinTestId", "linkedin@example.com", "Linkedin Test", "http://example.com/avatar", "")

tokenCount, userCount := 0, 0
code := "authcode"
server := LinkedinTestSignupSetup(ts, &tokenCount, &userCount, code, linkedinUser, linkedinEmail)
defer server.Close()

u := performAuthorization(ts, "linkedin", code, "")

assertAuthorizationSuccess(ts, u, tokenCount, userCount, "linkedin@example.com", "Linkedin Test", "linkedinTestId", "http://example.com/avatar")
}

func (ts *ExternalTestSuite) TestInviteTokenExternalLinkedinSuccessWhenMatchingToken() {
// name and avatar should be populated from Linkedin API
ts.createUser("linkedinTestId", "linkedin@example.com", "", "", "invite_token")

tokenCount, userCount := 0, 0
code := "authcode"
server := LinkedinTestSignupSetup(ts, &tokenCount, &userCount, code, linkedinUser, linkedinEmail)
defer server.Close()

u := performAuthorization(ts, "linkedin", code, "invite_token")

assertAuthorizationSuccess(ts, u, tokenCount, userCount, "linkedin@example.com", "Linkedin Test", "linkedinTestId", "http://example.com/avatar")
}

func (ts *ExternalTestSuite) TestInviteTokenExternalLinkedinErrorWhenNoMatchingToken() {
tokenCount, userCount := 0, 0
code := "authcode"
server := LinkedinTestSignupSetup(ts, &tokenCount, &userCount, code, linkedinUser, linkedinEmail)
defer server.Close()

w := performAuthorizationRequest(ts, "linkedin", "invite_token")
ts.Require().Equal(http.StatusNotFound, w.Code)
}

func (ts *ExternalTestSuite) TestInviteTokenExternalLinkedinErrorWhenWrongToken() {
ts.createUser("linkedinTestId", "linkedin@example.com", "", "", "invite_token")

tokenCount, userCount := 0, 0
code := "authcode"
server := LinkedinTestSignupSetup(ts, &tokenCount, &userCount, code, linkedinUser, linkedinEmail)
defer server.Close()

w := performAuthorizationRequest(ts, "linkedin", "wrong_token")
ts.Require().Equal(http.StatusNotFound, w.Code)
}

func (ts *ExternalTestSuite) TestInviteTokenExternalLinkedinErrorWhenEmailDoesntMatch() {
ts.createUser("linkedinTestId", "linkedin@example.com", "", "", "invite_token")

tokenCount, userCount := 0, 0
code := "authcode"
server := LinkedinTestSignupSetup(ts, &tokenCount, &userCount, code, linkedinUser, linkedinWrongEmail)
defer server.Close()

u := performAuthorization(ts, "linkedin", code, "invite_token")

assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
}
145 changes: 145 additions & 0 deletions api/provider/linkedin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
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
}

// See https://docs.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin?context=linkedin/consumer/context
// for retrieving a member's profile. This requires the r_liteprofile scope.
type linkedinUser struct {
ID string `json:"id"`
FirstName linkedinName `json:"firstName"`
LastName linkedinName `json:"lastName"`
AvatarURL struct {
DisplayImage struct {
Elements []struct {
Identifiers []struct {
Identifier string `json:"identifier"`
} `json:"identifiers"`
} `json:"elements"`
} `json:"displayImage~"`
} `json:"profilePicture"`
}

type linkedinName struct {
Localized interface{} `json:"localized"`
PreferredLocale linkedinLocale `json:"preferredLocale"`
}

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

// See https://docs.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin?context=linkedin/consumer/context#retrieving-member-email-address
// for retrieving a member email address. This requires the r_email_address scope.
type linkedinElements struct {
Elements []struct {
Handle string `json:"handle"`
HandleTilde struct {
EmailAddress string `json:"emailAddress"`
} `json:"handle~"`
} `json:"elements"`
}

// 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 e linkedinElements
// Note: Use primary contact api for handling phone numbers
if err := makeRequest(ctx, tok, g.Config, g.APIPath+"/v2/emailAddress?q=members&projection=(elements*(handle~))", &e); err != nil {
return nil, err
}

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

emails := []Email{}

if e.Elements[0].HandleTilde.EmailAddress != "" {
emails = append(emails, Email{
Email: e.Elements[0].HandleTilde.EmailAddress,
Primary: true,
Verified: true,
})
}

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: e.Elements[0].HandleTilde.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
}
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 @@ -45,6 +46,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 @@ -35,6 +35,7 @@ func TestSettings_DefaultProviders(t *testing.T) {
require.True(t, p.Spotify)
require.True(t, p.Slack)
require.True(t, p.Google)
require.True(t, p.Linkedin)
require.True(t, p.GitHub)
require.True(t, p.GitLab)
require.True(t, p.SAML)
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"`
Twitter OAuthProviderConfiguration `json:"twitter"`
Expand Down
5 changes: 5 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ GOTRUE_EXTERNAL_SPOTIFY_CLIENT_ID=""
GOTRUE_EXTERNAL_SPOTIFY_SECRET=""
GOTRUE_EXTERNAL_SPOTIFY_REDIRECT_URI="http://localhost:9999/callback"

# Linkedin OAuth config
GOTRUE_EXTERNAL_LINKEDIN_ENABLED="true"
GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID=""
GOTRUE_EXTERNAL_LINKEDIN_SECRET=""

# Slack OAuth config
GOTRUE_EXTERNAL_SLACK_ENABLED="false"
GOTRUE_EXTERNAL_SLACK_CLIENT_ID=""
Expand Down
4 changes: 4 additions & 0 deletions hack/test.env
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ GOTRUE_EXTERNAL_GITHUB_ENABLED=true
GOTRUE_EXTERNAL_GITHUB_CLIENT_ID=testclientid
GOTRUE_EXTERNAL_GITHUB_SECRET=testsecret
GOTRUE_EXTERNAL_GITHUB_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_GITLAB_ENABLED=true
GOTRUE_EXTERNAL_GITLAB_CLIENT_ID=testclientid
GOTRUE_EXTERNAL_GITLAB_SECRET=testsecret
Expand Down

0 comments on commit 786efee

Please sign in to comment.