mirror of https://github.com/dexidp/dex.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
240 lines
6.3 KiB
240 lines
6.3 KiB
// Package linkedin provides authentication strategies using LinkedIn |
|
package linkedin |
|
|
|
import ( |
|
"context" |
|
"encoding/json" |
|
"fmt" |
|
"io" |
|
"log/slog" |
|
"net/http" |
|
"strings" |
|
|
|
"golang.org/x/oauth2" |
|
|
|
"github.com/dexidp/dex/connector" |
|
) |
|
|
|
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 *slog.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.With(slog.Group("connector", "type", "linkedin", "id", id)), |
|
}, nil |
|
} |
|
|
|
type connectorData struct { |
|
AccessToken string `json:"accessToken"` |
|
} |
|
|
|
var ( |
|
_ connector.CallbackConnector = (*linkedInConnector)(nil) |
|
_ connector.RefreshConnector = (*linkedInConnector)(nil) |
|
) |
|
|
|
type linkedInConnector struct { |
|
oauth2Config *oauth2.Config |
|
logger *slog.Logger |
|
} |
|
|
|
// LoginURL returns an access token request URL |
|
func (c *linkedInConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, []byte, error) { |
|
if c.oauth2Config.RedirectURL != callbackURL { |
|
return "", nil, 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, nil |
|
} |
|
|
|
// HandleCallback handles HTTP redirect from LinkedIn |
|
func (c *linkedInConnector) HandleCallback(s connector.Scopes, connData []byte, 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 |
|
}
|
|
|