From ab06119431c2de5e61c27fd093459e616c47f926 Mon Sep 17 00:00:00 2001 From: Pavel Borzenkov Date: Tue, 24 Oct 2017 23:18:23 +0300 Subject: [PATCH 1/3] connector: implement LinkedIn connector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connector/linkedin implements authorization strategy via LinkedIn's OAuth2 endpoint + profile API. It doesn't implement RefreshConnector as LinkedIn doesn't provide any refresh token at all (https://developer.linkedin.com/docs/oauth2, Step 5 — Refresh your Access Tokens) and recommends ordinary AuthCode exchange flow when token refresh is required. Signed-off-by: Pavel Borzenkov --- connector/linkedin/linkedin.go | 161 +++++++++++++++++++++++++++++++ server/server.go | 2 + web/static/img/linkedin-icon.svg | 1 + web/static/main.css | 5 + 4 files changed, 169 insertions(+) create mode 100644 connector/linkedin/linkedin.go create mode 100644 web/static/img/linkedin-icon.svg diff --git a/connector/linkedin/linkedin.go b/connector/linkedin/linkedin.go new file mode 100644 index 00000000..e6176917 --- /dev/null +++ b/connector/linkedin/linkedin.go @@ -0,0 +1,161 @@ +// 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 linkedInConnector struct { + oauth2Config *oauth2.Config + logger logrus.FieldLogger +} + +// LinkedIn doesn't provide refresh tokens, so we don't implement +// RefreshConnector here. +var ( + _ connector.CallbackConnector = (*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, + } + + return identity, 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; From 3b5df52c0f40852a1cacf26c8849c709f332cf6c Mon Sep 17 00:00:00 2001 From: Pavel Borzenkov Date: Wed, 25 Oct 2017 00:19:18 +0300 Subject: [PATCH 2/3] connector/linkedin: implement RefreshConnector interface Do Refresh() by querying user's profile data. Since LinkedIn doesn't provide refresh tokens at all, and the access tokens have 60 days expiration, refresh tokens issued by Dex will fail to update after 60 days. Signed-off-by: Pavel Borzenkov --- connector/linkedin/linkedin.go | 40 ++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/connector/linkedin/linkedin.go b/connector/linkedin/linkedin.go index e6176917..ba85eefc 100644 --- a/connector/linkedin/linkedin.go +++ b/connector/linkedin/linkedin.go @@ -45,15 +45,20 @@ func (c *Config) Open(id string, logger logrus.FieldLogger) (connector.Connector }, nil } +type connectorData struct { + AccessToken string `json:"accessToken"` +} + type linkedInConnector struct { oauth2Config *oauth2.Config logger logrus.FieldLogger } -// LinkedIn doesn't provide refresh tokens, so we don't implement -// RefreshConnector here. +// 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 @@ -92,9 +97,40 @@ func (c *linkedInConnector) HandleCallback(s connector.Scopes, r *http.Request) 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"` From d5a9712aaec7cd2ad2b16cd08384efde4b69b7c4 Mon Sep 17 00:00:00 2001 From: Pavel Borzenkov Date: Wed, 25 Oct 2017 01:12:37 +0300 Subject: [PATCH 3/3] Documentation: add LinkedIn connector documentation Signed-off-by: Pavel Borzenkov --- Documentation/linkedin-connector.md | 27 +++++++++++++++++++++++++++ README.md | 1 + 2 files changed, 28 insertions(+) create mode 100644 Documentation/linkedin-connector.md 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]