-
Notifications
You must be signed in to change notification settings - Fork 340
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
c383a5c
commit 786efee
Showing
9 changed files
with
319 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", "") | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters