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: linkedin provider #238

Merged
merged 13 commits into from
Jan 4, 2022
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