diff --git a/Documentation/linkedin-connector.md b/Documentation/linkedin-connector.md new file mode 100644 index 00000000..646ee243 --- /dev/null +++ b/Documentation/linkedin-connector.md @@ -0,0 +1,27 @@ +# Authentication through LinkedIn + +## Overview + +One of the login options for dex uses the LinkedIn OAuth2 flow to identify the end user through their LinkedIn account. + +When a client redeems a refresh token through dex, dex will re-query LinkedIn to update user information in the ID Token. To do this, __dex stores a readonly LinkedIn access token in its backing datastore.__ Users that reject dex's access through LinkedIn will also revoke all dex clients which authenticated them through LinkedIn. + +## Configuration + +Register a new application via `My Apps -> Create Application` ensuring the callback URL is `(dex issuer)/callback`. For example if dex is listening at the non-root path `https://auth.example.com/dex` the callback would be `https://auth.example.com/dex/callback`. + +The following is an example of a configuration for `examples/config-dev.yaml`: + +```yaml +connectors: + - type: linkedin + # Required field for connector id. + id: linkedin + # Required field for connector name. + name: LinkedIn + config: + # Credentials can be string literals or pulled from the environment. + clientID: $LINKEDIN_APPLICATION_ID + clientSecret: $LINKEDIN_CLIENT_SECRET + redirectURI: http://127.0.0.1:5556/dex/callback +``` diff --git a/README.md b/README.md index 84b39725..61358c53 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ More docs for running dex as a Kubernetes authenticator can be found [here](Docu * [SAML 2.0](Documentation/saml-connector.md) * [OpenID Connect](Documentation/oidc-connector.md) (includes Google, Salesforce, Azure, etc.) * [authproxy](Documentation/authproxy.md) (Apache2 mod_auth, etc.) + * [LinkedIn](Documentation/linkedin-connector.md) * Client libraries * [Go][go-oidc] diff --git a/connector/linkedin/linkedin.go b/connector/linkedin/linkedin.go new file mode 100644 index 00000000..ba85eefc --- /dev/null +++ b/connector/linkedin/linkedin.go @@ -0,0 +1,197 @@ +// Package linkedin provides authentication strategies using LinkedIn +package linkedin + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" + + "golang.org/x/oauth2" + + "github.com/coreos/dex/connector" + "github.com/sirupsen/logrus" +) + +const ( + apiURL = "https://api.linkedin.com/v1" + 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 logrus.FieldLogger) (connector.Connector, error) { + return &linkedInConnector{ + oauth2Config: &oauth2.Config{ + ClientID: c.ClientID, + ClientSecret: c.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{"r_basicprofile", "r_emailaddress"}, + RedirectURL: c.RedirectURI, + }, + logger: logger, + }, nil +} + +type connectorData struct { + AccessToken string `json:"accessToken"` +} + +type linkedInConnector struct { + oauth2Config *oauth2.Config + logger logrus.FieldLogger +} + +// 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:"firstName"` + LastName string `json:"lastName"` + Email string `json:"emailAddress"` +} + +// 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) profile(ctx context.Context, client *http.Client) (p profile, err error) { + // https://developer.linkedin.com/docs/fields/basic-profile + req, err := http.NewRequest("GET", apiURL+"/people/~:(id,first-name,last-name,email-address)", nil) + if err != nil { + return p, fmt.Errorf("new req: %v", err) + } + q := req.URL.Query() + q.Add("format", "json") + req.URL.RawQuery = q.Encode() + + 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 := ioutil.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) + } + + if p.Email == "" { + return p, fmt.Errorf("email is not set") + } + + 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 +} diff --git a/server/server.go b/server/server.go index 65de3b83..e0b7d359 100644 --- a/server/server.go +++ b/server/server.go @@ -24,6 +24,7 @@ import ( "github.com/coreos/dex/connector/github" "github.com/coreos/dex/connector/gitlab" "github.com/coreos/dex/connector/ldap" + "github.com/coreos/dex/connector/linkedin" "github.com/coreos/dex/connector/mock" "github.com/coreos/dex/connector/oidc" "github.com/coreos/dex/connector/saml" @@ -409,6 +410,7 @@ var ConnectorsConfig = map[string]func() ConnectorConfig{ "oidc": func() ConnectorConfig { return new(oidc.Config) }, "saml": func() ConnectorConfig { return new(saml.Config) }, "authproxy": func() ConnectorConfig { return new(authproxy.Config) }, + "linkedin": func() ConnectorConfig { return new(linkedin.Config) }, // Keep around for backwards compatibility. "samlExperimental": func() ConnectorConfig { return new(saml.Config) }, } diff --git a/web/static/img/linkedin-icon.svg b/web/static/img/linkedin-icon.svg new file mode 100644 index 00000000..409bad5e --- /dev/null +++ b/web/static/img/linkedin-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/static/main.css b/web/static/main.css index 9df2052f..a73b591f 100644 --- a/web/static/main.css +++ b/web/static/main.css @@ -83,6 +83,11 @@ body { background-image: url(../static/img/saml-icon.svg); } +.dex-btn-icon--linkedin { + background-image: url(../static/img/linkedin-icon.svg); + background-size: contain; +} + .dex-btn-text { font-weight: 600; line-height: 36px;