This repository has been archived on 2022-08-17. You can view files and clone it, but cannot push or open issues or pull requests.
dex/connector/linkedin/linkedin.go

243 lines
6.4 KiB
Go

// Package linkedin provides authentication strategies using LinkedIn
package linkedin
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"golang.org/x/oauth2"
"github.com/dexidp/dex/connector"
"github.com/dexidp/dex/pkg/log"
)
const (
apiURL = "https://api.linkedin.com/v2"
authURL = "https://www.linkedin.com/oauth/v2/authorization"
tokenURL = "https://www.linkedin.com/oauth/v2/accessToken"
)
// Config holds configuration options for LinkedIn logins.
type Config struct {
ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"`
RedirectURI string `json:"redirectURI"`
}
// Open returns a strategy for logging in through LinkedIn
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
return &linkedInConnector{
oauth2Config: &oauth2.Config{
ClientID: c.ClientID,
ClientSecret: c.ClientSecret,
Endpoint: oauth2.Endpoint{
AuthURL: authURL,
TokenURL: tokenURL,
},
Scopes: []string{"r_liteprofile", "r_emailaddress"},
RedirectURL: c.RedirectURI,
},
logger: logger,
}, nil
}
type connectorData struct {
AccessToken string `json:"accessToken"`
}
type linkedInConnector struct {
oauth2Config *oauth2.Config
logger log.Logger
}
// LinkedIn doesn't provide refresh tokens, so refresh tokens issued by Dex
// will expire in 60 days (default LinkedIn token lifetime).
var (
_ connector.CallbackConnector = (*linkedInConnector)(nil)
_ connector.RefreshConnector = (*linkedInConnector)(nil)
)
// LoginURL returns an access token request URL
func (c *linkedInConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) {
if c.oauth2Config.RedirectURL != callbackURL {
return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q",
callbackURL, c.oauth2Config.RedirectURL)
}
return c.oauth2Config.AuthCodeURL(state), nil
}
// HandleCallback handles HTTP redirect from LinkedIn
func (c *linkedInConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) {
q := r.URL.Query()
if errType := q.Get("error"); errType != "" {
return identity, &oauth2Error{errType, q.Get("error_description")}
}
ctx := r.Context()
token, err := c.oauth2Config.Exchange(ctx, q.Get("code"))
if err != nil {
return identity, fmt.Errorf("linkedin: get token: %v", err)
}
client := c.oauth2Config.Client(ctx, token)
profile, err := c.profile(ctx, client)
if err != nil {
return identity, fmt.Errorf("linkedin: get profile: %v", err)
}
identity = connector.Identity{
UserID: profile.ID,
Username: profile.fullname(),
Email: profile.Email,
EmailVerified: true,
}
if s.OfflineAccess {
data := connectorData{AccessToken: token.AccessToken}
connData, err := json.Marshal(data)
if err != nil {
return identity, fmt.Errorf("linkedin: marshal connector data: %v", err)
}
identity.ConnectorData = connData
}
return identity, nil
}
func (c *linkedInConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) {
if len(ident.ConnectorData) == 0 {
return ident, fmt.Errorf("linkedin: no upstream access token found")
}
var data connectorData
if err := json.Unmarshal(ident.ConnectorData, &data); err != nil {
return ident, fmt.Errorf("linkedin: unmarshal access token: %v", err)
}
client := c.oauth2Config.Client(ctx, &oauth2.Token{AccessToken: data.AccessToken})
profile, err := c.profile(ctx, client)
if err != nil {
return ident, fmt.Errorf("linkedin: get profile: %v", err)
}
ident.Username = profile.fullname()
ident.Email = profile.Email
return ident, nil
}
type profile struct {
ID string `json:"id"`
FirstName string `json:"localizedFirstName"`
LastName string `json:"localizedLastName"`
Email string `json:"emailAddress"`
}
type emailresp struct {
Elements []struct {
Handle struct {
EmailAddress string `json:"emailAddress"`
} `json:"handle~"`
} `json:"elements"`
}
// fullname returns a full name of a person, or email if the resulting name is
// empty
func (p profile) fullname() string {
fname := strings.TrimSpace(p.FirstName + " " + p.LastName)
if fname == "" {
return p.Email
}
return fname
}
func (c *linkedInConnector) primaryEmail(ctx context.Context, client *http.Client) (email string, err error) {
req, err := http.NewRequest("GET", apiURL+"/emailAddress?q=members&projection=(elements*(handle~))", nil)
if err != nil {
return email, fmt.Errorf("new req: %v", err)
}
resp, err := client.Do(req.WithContext(ctx))
if err != nil {
return email, fmt.Errorf("get URL %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return email, fmt.Errorf("read body: %v", err)
}
if resp.StatusCode != http.StatusOK {
return email, fmt.Errorf("%s: %s", resp.Status, body)
}
var parsedResp emailresp
err = json.Unmarshal(body, &parsedResp)
if err == nil {
for _, elem := range parsedResp.Elements {
email = elem.Handle.EmailAddress
}
}
if email == "" {
err = fmt.Errorf("email is not set")
}
return email, err
}
func (c *linkedInConnector) profile(ctx context.Context, client *http.Client) (p profile, err error) {
// https://docs.microsoft.com/en-us/linkedin/shared/integrations/people/profile-api
// https://docs.microsoft.com/en-us/linkedin/shared/integrations/people/primary-contact-api
// https://docs.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/migration-faq#how-do-i-retrieve-the-members-email-address
req, err := http.NewRequest("GET", apiURL+"/me", nil)
if err != nil {
return p, fmt.Errorf("new req: %v", err)
}
resp, err := client.Do(req.WithContext(ctx))
if err != nil {
return p, fmt.Errorf("get URL %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return p, fmt.Errorf("read body: %v", err)
}
return p, fmt.Errorf("%s: %s", resp.Status, body)
}
if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
return p, fmt.Errorf("JSON decode: %v", err)
}
email, err := c.primaryEmail(ctx, client)
if err != nil {
return p, fmt.Errorf("fetching email: %v", err)
}
p.Email = email
return p, err
}
type oauth2Error struct {
error string
errorDescription string
}
func (e *oauth2Error) Error() string {
if e.errorDescription == "" {
return e.error
}
return e.error + ": " + e.errorDescription
}