diff --git a/README.md b/README.md index 4a5656f..8420d7c 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,9 @@ providers to validate individual accounts, or a whole google apps domain. You will need to register an OAuth application with Google (or [another provider](#providers)), and configure it with Redirect URI(s) for the domain -you intend to run `google_auth_proxy` on. For Google, the registration steps -are: +you intend to run `google_auth_proxy` on. + +For Google, the registration steps are: 1. Create a new project: https://console.developers.google.com/project 2. Under "APIs & Auth", choose "Credentials" @@ -47,6 +48,14 @@ are: * Fill in the necessary fields and Save (this is _required_) 5. Take note of the **Client ID** and **Client Secret** +For LinkedIn, the registration steps are: + +1. Create a new project: https://www.linkedin.com/secure/developer +2. In the OAuth User Agreement section: + * In default scope, select r_basicprofile and r_emailaddress. + * In "OAuth 2.0 Redirect URLs", enter `https://internal.yourcompany.com/oauth2/callback` +3. Fill in the remaining required fields and Save. +4. Take note of the **Consumer Key / API Key** and **Consumer Secret / Secret Key** ## Configuration @@ -160,6 +169,7 @@ directive. Right now this includes: * `myusa` - The [MyUSA](https://alpha.my.usa.gov) authentication service ([GitHub](https://github.com/18F/myusa)) +* `linkedin` - The [LinkedIn](https://developer.linkedin.com/docs/signin-with-linkedin) Sign In service. ## Adding a new Provider diff --git a/providers/linkedin.go b/providers/linkedin.go new file mode 100644 index 0000000..539cd97 --- /dev/null +++ b/providers/linkedin.go @@ -0,0 +1,68 @@ +package providers + +import ( + "bytes" + "errors" + "fmt" + "log" + "net/http" + "net/url" + + "github.com/bitly/go-simplejson" + "github.com/bitly/google_auth_proxy/api" +) + +type LinkedInProvider struct { + *ProviderData +} + +func NewLinkedInProvider(p *ProviderData) *LinkedInProvider { + p.ProviderName = "LinkedIn" + if p.LoginUrl.String() == "" { + p.LoginUrl = &url.URL{Scheme: "https", + Host: "www.linkedin.com", + Path: "/uas/oauth2/authorization"} + } + if p.RedeemUrl.String() == "" { + p.RedeemUrl = &url.URL{Scheme: "https", + Host: "www.linkedin.com", + Path: "/uas/oauth2/accessToken"} + } + if p.ProfileUrl.String() == "" { + p.ProfileUrl = &url.URL{Scheme: "https", + Host: "www.linkedin.com", + Path: "/v1/people/~/email-address"} + } + if p.Scope == "" { + p.Scope = "r_emailaddress r_basicprofile" + } + return &LinkedInProvider{ProviderData: p} +} + +func (p *LinkedInProvider) GetEmailAddress(unused_auth_response *simplejson.Json, + access_token string) (string, error) { + if access_token == "" { + return "", errors.New("missing access token") + } + params := url.Values{} + req, err := http.NewRequest("GET", p.ProfileUrl.String()+"?format=json", bytes.NewBufferString(params.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("x-li-format", "json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", access_token)) + + json, err := api.Request(req) + if err != nil { + log.Printf("failed making request %s", err) + return "", err + } + + email, err := json.String() + if err != nil { + log.Printf("failed making request %s", err) + return "", err + } + return email, nil +} diff --git a/providers/linkedin_test.go b/providers/linkedin_test.go new file mode 100644 index 0000000..5aa4353 --- /dev/null +++ b/providers/linkedin_test.go @@ -0,0 +1,128 @@ +package providers + +import ( + "github.com/bitly/go-simplejson" + "github.com/bmizerany/assert" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func testLinkedInProvider(hostname string) *LinkedInProvider { + p := NewLinkedInProvider( + &ProviderData{ + ProviderName: "", + LoginUrl: &url.URL{}, + RedeemUrl: &url.URL{}, + ProfileUrl: &url.URL{}, + Scope: ""}) + if hostname != "" { + updateUrl(p.Data().LoginUrl, hostname) + updateUrl(p.Data().RedeemUrl, hostname) + updateUrl(p.Data().ProfileUrl, hostname) + } + return p +} + +func testLinkedInBackend(payload string) *httptest.Server { + path := "/v1/people/~/email-address" + + return httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + url := r.URL + if url.Path != path { + w.WriteHeader(404) + } else if r.Header.Get("Authorization") != "Bearer imaginary_access_token" { + w.WriteHeader(403) + } else { + w.WriteHeader(200) + w.Write([]byte(payload)) + } + })) +} + +func TestLinkedInProviderDefaults(t *testing.T) { + p := testLinkedInProvider("") + assert.NotEqual(t, nil, p) + assert.Equal(t, "LinkedIn", p.Data().ProviderName) + assert.Equal(t, "https://www.linkedin.com/uas/oauth2/authorization", + p.Data().LoginUrl.String()) + assert.Equal(t, "https://www.linkedin.com/uas/oauth2/accessToken", + p.Data().RedeemUrl.String()) + assert.Equal(t, "https://www.linkedin.com/v1/people/~/email-address", + p.Data().ProfileUrl.String()) + assert.Equal(t, "r_emailaddress r_basicprofile", p.Data().Scope) +} + +func TestLinkedInProviderOverrides(t *testing.T) { + p := NewLinkedInProvider( + &ProviderData{ + LoginUrl: &url.URL{ + Scheme: "https", + Host: "example.com", + Path: "/oauth/auth"}, + RedeemUrl: &url.URL{ + Scheme: "https", + Host: "example.com", + Path: "/oauth/token"}, + ProfileUrl: &url.URL{ + Scheme: "https", + Host: "example.com", + Path: "/oauth/profile"}, + Scope: "profile"}) + assert.NotEqual(t, nil, p) + assert.Equal(t, "LinkedIn", p.Data().ProviderName) + assert.Equal(t, "https://example.com/oauth/auth", + p.Data().LoginUrl.String()) + assert.Equal(t, "https://example.com/oauth/token", + p.Data().RedeemUrl.String()) + assert.Equal(t, "https://example.com/oauth/profile", + p.Data().ProfileUrl.String()) + assert.Equal(t, "profile", p.Data().Scope) +} + +func TestLinkedInProviderGetEmailAddress(t *testing.T) { + b := testLinkedInBackend(`"user@linkedin.com"`) + defer b.Close() + + b_url, _ := url.Parse(b.URL) + p := testLinkedInProvider(b_url.Host) + unused_auth_response := simplejson.New() + + email, err := p.GetEmailAddress(unused_auth_response, + "imaginary_access_token") + assert.Equal(t, nil, err) + assert.Equal(t, "user@linkedin.com", email) +} + +func TestLinkedInProviderGetEmailAddressFailedRequest(t *testing.T) { + b := testLinkedInBackend("unused payload") + defer b.Close() + + b_url, _ := url.Parse(b.URL) + p := testLinkedInProvider(b_url.Host) + unused_auth_response := simplejson.New() + + // We'll trigger a request failure by using an unexpected access + // token. Alternatively, we could allow the parsing of the payload as + // JSON to fail. + email, err := p.GetEmailAddress(unused_auth_response, + "unexpected_access_token") + assert.NotEqual(t, nil, err) + assert.Equal(t, "", email) +} + +func TestLinkedInProviderGetEmailAddressEmailNotPresentInPayload(t *testing.T) { + b := testLinkedInBackend("{\"foo\": \"bar\"}") + defer b.Close() + + b_url, _ := url.Parse(b.URL) + p := testLinkedInProvider(b_url.Host) + unused_auth_response := simplejson.New() + + email, err := p.GetEmailAddress(unused_auth_response, + "imaginary_access_token") + assert.NotEqual(t, nil, err) + assert.Equal(t, "", email) +} diff --git a/providers/providers.go b/providers/providers.go index eb43b26..486668b 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -14,6 +14,8 @@ func New(provider string, p *ProviderData) Provider { switch provider { case "myusa": return NewMyUsaProvider(p) + case "linkedin": + return NewLinkedInProvider(p) default: return NewGoogleProvider(p) }