Repository: supabase/gotrue
Pull request #238 (2021-10-17 10:25:04)

feat: linkedin provider
## What kind of change does this PR introduce?

feature, it add Linkedin Provider

## What is the current behavior?

No provider for linkedin before

## What is the new behavior?

WIP: Linkedin provider. 
Ps: i know nothing of golang and don't like it much, but i really need this provider for socialselling.ai ,so i did try to start the work 

So i did try to do it.

Few things are missing :
- tests, i didn't write or run the code i just made the biding from my js implementation
- i did try to handle catch all in type for name localization but not sure if it's handle well

Help is more than welcome :)

## Additional context

Add any other context or screenshots.


HarryET: I added a working implementation to this in #269
dthyresson: Hi @riderx.

Thanks for adding these envars. 

In order for them to be recognized, could you also add the provider and config for LinkedIn as well? 

Not sure if they need to be: LINKEDIN or LINKED_IN to match a LinkedIn or LinkedIn case.

Thanks!

See: https://github.com/supabase/gotrue/blob/1dddc248fb820e638f0d54b9d51c2803295b5c1d/api/external.go#L365

and

See: https://github.com/supabase/gotrue/blob/1dddc248fb820e638f0d54b9d51c2803295b5c1d/conf/configuration.go#L82


HarryET: > Hi @riderx.
> 
> Thanks for adding these envars.
> 
> In order for them to be recognized, could you also add the provider and config for LinkedIn as well?
> 
> Not sure if they need to be: LINKEDIN or LINKED_IN to match a LinkedIn or LinkedIn case.
> 
> Thanks!
> 
> See:
> 
> https://github.com/supabase/gotrue/blob/1dddc248fb820e638f0d54b9d51c2803295b5c1d/api/external.go#L365
> 
> and
> 
> See:
> 
> https://github.com/supabase/gotrue/blob/1dddc248fb820e638f0d54b9d51c2803295b5c1d/conf/configuration.go#L82

Hi this isn't being implemented in this PR. It was added to my other PR #269 
HarryET: @riderx might be worth closing this now that its been implemented in #269 
dthyresson: Thanks @HarryET - I hadn't seen your PR #269.

I'll run a few tests later and check with @kangmingtay but agree that your PR means that this can be closed.


HarryET: > Thanks @HarryET - I hadn't seen your PR #269.
> 
> I'll run a few tests later and check with @kangmingtay but agree that your PR means that this can be closed.

Awesome. I still need to get my GoTrue Local Environment sorted, but from the looks of things should be working. Thanks @dthyresson 
dthyresson: > I still need to get my GoTrue Local Environment sorted,

I setup a local GoTrue last week. Was a little bumpy, but I intend to update the docs and contributing readme to make it smoother for new contributors.

I'll be sure to cc you on the PR so you can test it out and try it out. 
HarryET: > > I still need to get my GoTrue Local Environment sorted,
> 
> I setup a local GoTrue last week. Was a little bumpy, but I intend to update the docs and contributing readme to make it smoother for new contributors.
> 
> I'll be sure to cc you on the PR so you can test it out and try it out.

Awesome. I've really enjoyed contributing to GoTrue so far just need a better local setup to tackle issues other than providers. I don't know if you saw but I have an RFC for 2FA just need to pad it out a bit more but though about trying to get a PR rolling if that was something you were interested in?
dthyresson: > I have an RFC for 2FA

I did not, but a fan of 2FA and I have seen requests for this feature. I'll be sure to watch it!
riderx: @dthyresson thanks for the doc improvement as a newbe in golang, i wasn't able to run gotrue locally, all my code was done without been able to test anything, it was very furstrating.
Thanks a lot for the help guys 
riderx: as @dthyresson say in https://github.com/supabase/gotrue/pull/269 it's better to have specific Pr, so I reopen this one with the fix of @HarryET 
riderx: @HarryET took 1 hours with me to make gotrue work.
Thanks for that. @dthyresson please take this in consideration we can help the project if the install process is smooth, now 
the install process is broken, script fail we had to fix so many things.

An now I have grotrue work, does the redirect to Linkedin, and the redirect back to my test app, but I'm stuck again since I run only gotrue supabase refuse:
```
{error: "invalid_grant", error_description: "Invalid Refresh Token"}
error: "invalid_grant"
error_description: "Invalid Refresh Token"
```
Can we have a docker-compose who run everything in one time to help better ?
riderx: @dthyresson do you need more things to review the PR ?

Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 94779521..467abaa8 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -151,7 +151,7 @@ Version          Name                         Status
 That lists each migration that was applied. Note: there may be more migrations than those listed.
 
 4. Create a `.env` file in the root of the project and copy the following config in [example.env](example.env)
-5. In order to have GoTrue connect to your PostgreSQL database running in Docket, it is important to set a connection string like:
+5. In order to have GoTrue connect to your PostgreSQL database running in Docker, it is important to set a connection string like:
 
 ```
 DATABASE_URL="postgres://supabase_auth_admin:root@localhost:5432/postgres"
@@ -395,7 +395,7 @@ $ make migrate_test
 $ make test
 ```
 
-### Customizing the POostgreSQL Port
+### Customizing the PostgreSQL Port
 
 if you already run PostgreSQL and need to run your database on a different, custom port,
 you will need to make several configuration changes to the following files:
@@ -469,7 +469,7 @@ We actively welcome your pull requests.
 
 ## Guidelines for Implementing Additional oAuth Providers
 
-Please ensure that an end-to-end test is done for the oAuth provider implemented.
+Please ensure that an end-to-end test is done for the OAuth provider implemented.
 
 An end-to-end test includes:
 
@@ -481,7 +481,7 @@ An end-to-end test includes:
 
 ### Writing tests for the new oAuth provider implemented
 
-Since implementing an additional oauth provider consists of making api calls to an external api, we set up a mock server to attempt to mock the responses expected from the oauth provider.
+Since implementing an additional OAuth provider consists of making api calls to an external api, we set up a mock server to attempt to mock the responses expected from the OAuth provider.
 
 ## License
 
diff --git a/README.md b/README.md
index d49f4b7e..d30b6f66 100644
--- a/README.md
+++ b/README.md
@@ -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.
 
diff --git a/api/admin.go b/api/admin.go
index bb1d3c7b..c583ac72 100644
--- a/api/admin.go
+++ b/api/admin.go
@@ -10,6 +10,7 @@ import (
 	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/models"
 	"github.com/netlify/gotrue/storage"
+	"github.com/sethvargo/go-password/password"
 )
 
 type adminUserParams struct {
@@ -219,6 +220,14 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error {
 		}
 	}
 
+	if params.Password == nil || *params.Password == "" {
+		password, err := password.Generate(64, 10, 0, false, true)
+		if err != nil {
+			return internalServerError("Error generating password").WithInternalError(err)
+		}
+		params.Password = &password
+	}
+
 	user, err := models.NewUser(instanceID, params.Email, *params.Password, aud, params.UserMetaData)
 	if err != nil {
 		return internalServerError("Error creating user").WithInternalError(err)
diff --git a/api/external.go b/api/external.go
index 80e8af77..63db5f0b 100644
--- a/api/external.go
+++ b/api/external.go
@@ -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":
diff --git a/api/provider/linkedin.go b/api/provider/linkedin.go
new file mode 100644
index 00000000..6cbb5d54
--- /dev/null
+++ b/api/provider/linkedin.go
@@ -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
+}
diff --git a/api/settings.go b/api/settings.go
index 56b02a63..ab988eb0 100644
--- a/api/settings.go
+++ b/api/settings.go
@@ -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"`
@@ -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,
diff --git a/conf/configuration.go b/conf/configuration.go
index d6bcb394..b74e1ccb 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -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"`
diff --git a/example.env b/example.env
index 35b7ffea..437f92a4 100644
--- a/example.env
+++ b/example.env
@@ -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=""


Test patch:
diff --git a/api/admin_test.go b/api/admin_test.go
index b46bc9c3..ca1d61dc 100644
--- a/api/admin_test.go
+++ b/api/admin_test.go
@@ -258,29 +258,84 @@ func (ts *AdminTestSuite) TestAdminUsers_FilterName() {
 
 // TestAdminUserCreate tests API /admin/user route (POST)
 func (ts *AdminTestSuite) TestAdminUserCreate() {
-	var buffer bytes.Buffer
-	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
-		"email":    "test1@example.com",
-		"phone":    "123456789",
-		"password": "test1",
-	}))
+	cases := []struct {
+		desc     string
+		params   map[string]interface{}
+		expected map[string]interface{}
+	}{
+		{
+			desc: "With password",
+			params: map[string]interface{}{
+				"email":    "test1@example.com",
+				"phone":    "123456789",
+				"password": "test1",
+			},
+			expected: map[string]interface{}{
+				"email":           "test1@example.com",
+				"phone":           "123456789",
+				"isAuthenticated": true,
+			},
+		},
+		{
+			desc: "Without password",
+			params: map[string]interface{}{
+				"email": "test2@example.com",
+				"phone": "",
+			},
+			expected: map[string]interface{}{
+				"email":           "test2@example.com",
+				"phone":           "",
+				"isAuthenticated": false,
+			},
+		},
+		{
+			desc: "With empty string password",
+			params: map[string]interface{}{
+				"email":    "test3@example.com",
+				"phone":    "",
+				"password": "",
+			},
+			expected: map[string]interface{}{
+				"email":           "test3@example.com",
+				"phone":           "",
+				"isAuthenticated": false,
+			},
+		},
+	}
 
-	// Setup request
-	w := httptest.NewRecorder()
-	req := httptest.NewRequest(http.MethodPost, "/admin/users", &buffer)
+	for _, c := range cases {
+		ts.Run(c.desc, func() {
+			var buffer bytes.Buffer
+			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.params))
 
-	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
-	ts.Config.External.Phone.Enabled = true
+			// Setup request
+			w := httptest.NewRecorder()
+			req := httptest.NewRequest(http.MethodPost, "/admin/users", &buffer)
 
-	ts.API.handler.ServeHTTP(w, req)
-	require.Equal(ts.T(), http.StatusOK, w.Code)
+			req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
+			ts.Config.External.Phone.Enabled = true
 
-	data := models.User{}
-	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
-	assert.Equal(ts.T(), "test1@example.com", data.GetEmail())
-	assert.Equal(ts.T(), "123456789", data.GetPhone())
-	assert.Equal(ts.T(), "email", data.AppMetaData["provider"])
-	assert.Equal(ts.T(), []interface{}{"email"}, data.AppMetaData["providers"])
+			ts.API.handler.ServeHTTP(w, req)
+			require.Equal(ts.T(), http.StatusOK, w.Code)
+
+			data := models.User{}
+			require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
+			assert.Equal(ts.T(), c.expected["email"], data.GetEmail())
+			assert.Equal(ts.T(), c.expected["phone"], data.GetPhone())
+			assert.Equal(ts.T(), "email", data.AppMetaData["provider"])
+			assert.Equal(ts.T(), []interface{}{"email"}, data.AppMetaData["providers"])
+
+			u, err := models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, data.GetEmail(), ts.Config.JWT.Aud)
+			require.NoError(ts.T(), err)
+
+			var expectedPassword string
+			if _, ok := c.params["password"]; ok {
+				expectedPassword = fmt.Sprintf("%v", c.params["password"])
+			}
+
+			assert.Equal(ts.T(), c.expected["isAuthenticated"], u.Authenticate(expectedPassword))
+		})
+	}
 }
 
 // TestAdminUserGet tests API /admin/user route (GET)
diff --git a/api/external_linkedin_test.go b/api/external_linkedin_test.go
new file mode 100644
index 00000000..cb539248
--- /dev/null
+++ b/api/external_linkedin_test.go
@@ -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", "")
+}
diff --git a/api/settings_test.go b/api/settings_test.go
index f8698749..683232ac 100644
--- a/api/settings_test.go
+++ b/api/settings_test.go
@@ -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)
diff --git a/hack/test.env b/hack/test.env
index 6b5b6c25..dbd2ca92 100644
--- a/hack/test.env
+++ b/hack/test.env
@@ -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