diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c7f2960..71a7906 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,3 +14,7 @@ providers/logingov_test.go @timothy-spencer # Bitbucket provider providers/bitbucket.go @aledeganopix4d providers/bitbucket_test.go @aledeganopix4d + +# Nextcloud provider +providers/nextcloud.go @Ramblurr +providers/nextcloud_test.go @Ramblurr diff --git a/CHANGELOG.md b/CHANGELOG.md index 44cd0cf..8d74584 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Changes since v4.0.0 +- [#179](https://github.com/pusher/oauth2_proxy/pull/179) Add Nextcloud provider + # v4.0.0 ## Release Highlights diff --git a/docs/2_auth.md b/docs/2_auth.md index e6c5cc6..0e7b739 100644 --- a/docs/2_auth.md +++ b/docs/2_auth.md @@ -18,6 +18,7 @@ Valid providers are : - [GitLab](#gitlab-auth-provider) - [LinkedIn](#linkedin-auth-provider) - [login.gov](#logingov-provider) +- [Nextcloud](#nextcloud-provider) The provider can be selected using the `provider` configuration value. @@ -271,6 +272,32 @@ In this case, you can set the `-skip-oidc-discovery` option, and supply those re -email-domain example.com ``` +### Nextcloud Provider + +The Nextcloud provider allows you to authenticate against users in your +Nextcloud instance. + +When you are using the Nextcloud provider, you must specify the urls via +configuration, environment variable, or command line argument. Depending +on whether your Nextcloud instance is using pretty urls your urls may be of the +form `/index.php/apps/oauth2/*` or `/apps/oauth2/*`. + +Refer to the [OAuth2 +documentation](https://docs.nextcloud.com/server/latest/admin_manual/configuration_server/oauth2.html) +to setup the client id and client secret. Your "Redirection URI" will be +`https://internalapp.yourcompany.com/oauth2/callback`. + +``` + -provider nextcloud + -client-id + -client-secret + -login-url="/index.php/apps/oauth2/authorize" + -redeem-url="/index.php/apps/oauth2/api/v1/token" + -validate-url="/ocs/v2.php/cloud/user?format=json" +``` + +Note: in *all* cases the validate-url will *not* have the `index.php`. + ## Email Authentication To authorize by email domain use `--email-domain=yourcompany.com`. To authorize individual email addresses use `--authenticated-emails-file=/path/to/file` with one email per line. To authorize all email addresses use `--email-domain=*`. diff --git a/providers/nextcloud.go b/providers/nextcloud.go new file mode 100644 index 0000000..18855c8 --- /dev/null +++ b/providers/nextcloud.go @@ -0,0 +1,45 @@ +package providers + +import ( + "fmt" + "net/http" + + "github.com/pusher/oauth2_proxy/pkg/apis/sessions" + "github.com/pusher/oauth2_proxy/pkg/logger" + "github.com/pusher/oauth2_proxy/pkg/requests" +) + +// NextcloudProvider represents an Nextcloud based Identity Provider +type NextcloudProvider struct { + *ProviderData +} + +// NewNextcloudProvider initiates a new NextcloudProvider +func NewNextcloudProvider(p *ProviderData) *NextcloudProvider { + p.ProviderName = "Nextcloud" + return &NextcloudProvider{ProviderData: p} +} + +func getNextcloudHeader(accessToken string) http.Header { + header := make(http.Header) + header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + return header +} + +// GetEmailAddress returns the Account email address +func (p *NextcloudProvider) GetEmailAddress(s *sessions.SessionState) (string, error) { + req, err := http.NewRequest("GET", + p.ValidateURL.String(), nil) + if err != nil { + logger.Printf("failed building request %s", err) + return "", err + } + req.Header = getNextcloudHeader(s.AccessToken) + json, err := requests.Request(req) + if err != nil { + logger.Printf("failed making request %s", err) + return "", err + } + email, err := json.Get("ocs").Get("data").Get("email").String() + return email, err +} diff --git a/providers/nextcloud_test.go b/providers/nextcloud_test.go new file mode 100644 index 0000000..350e584 --- /dev/null +++ b/providers/nextcloud_test.go @@ -0,0 +1,138 @@ +package providers + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/pusher/oauth2_proxy/pkg/apis/sessions" + "github.com/stretchr/testify/assert" +) + +const formatJSON = "format=json" +const userPath = "/ocs/v2.php/cloud/user" + +func testNextcloudProvider(hostname string) *NextcloudProvider { + p := NewNextcloudProvider( + &ProviderData{ + ProviderName: "", + LoginURL: &url.URL{}, + RedeemURL: &url.URL{}, + ProfileURL: &url.URL{}, + ValidateURL: &url.URL{}, + Scope: ""}) + if hostname != "" { + updateURL(p.Data().LoginURL, hostname) + updateURL(p.Data().RedeemURL, hostname) + updateURL(p.Data().ProfileURL, hostname) + updateURL(p.Data().ValidateURL, hostname) + } + return p +} + +func testNextcloudBackend(payload string) *httptest.Server { + path := userPath + query := formatJSON + + return httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != path || r.URL.RawQuery != query { + w.WriteHeader(404) + } else if r.Header.Get("Authorization") != "Bearer imaginary_access_token_nextcloud" { + w.WriteHeader(403) + } else { + w.WriteHeader(200) + w.Write([]byte(payload)) + } + })) +} + +func TestNextcloudProviderDefaults(t *testing.T) { + p := testNextcloudProvider("") + assert.NotEqual(t, nil, p) + assert.Equal(t, "Nextcloud", p.Data().ProviderName) + assert.Equal(t, "", + p.Data().LoginURL.String()) + assert.Equal(t, "", + p.Data().RedeemURL.String()) + assert.Equal(t, "", + p.Data().ValidateURL.String()) +} + +func TestNextcloudProviderOverrides(t *testing.T) { + p := NewNextcloudProvider( + &ProviderData{ + LoginURL: &url.URL{ + Scheme: "https", + Host: "example.com", + Path: "/index.php/apps/oauth2/authorize"}, + RedeemURL: &url.URL{ + Scheme: "https", + Host: "example.com", + Path: "/index.php/apps/oauth2/api/v1/token"}, + ValidateURL: &url.URL{ + Scheme: "https", + Host: "example.com", + Path: "/test/ocs/v2.php/cloud/user", + RawQuery: formatJSON}, + Scope: "profile"}) + assert.NotEqual(t, nil, p) + assert.Equal(t, "Nextcloud", p.Data().ProviderName) + assert.Equal(t, "https://example.com/index.php/apps/oauth2/authorize", + p.Data().LoginURL.String()) + assert.Equal(t, "https://example.com/index.php/apps/oauth2/api/v1/token", + p.Data().RedeemURL.String()) + assert.Equal(t, "https://example.com/test/ocs/v2.php/cloud/user?"+formatJSON, + p.Data().ValidateURL.String()) +} + +func TestNextcloudProviderGetEmailAddress(t *testing.T) { + b := testNextcloudBackend("{\"ocs\": {\"data\": { \"email\": \"michael.bland@gsa.gov\"}}}") + defer b.Close() + + bURL, _ := url.Parse(b.URL) + p := testNextcloudProvider(bURL.Host) + p.ValidateURL.Path = userPath + p.ValidateURL.RawQuery = formatJSON + + session := &sessions.SessionState{AccessToken: "imaginary_access_token_nextcloud"} + email, err := p.GetEmailAddress(session) + assert.Equal(t, nil, err) + assert.Equal(t, "michael.bland@gsa.gov", email) +} + +// Note that trying to trigger the "failed building request" case is not +// practical, since the only way it can fail is if the URL fails to parse. +func TestNextcloudProviderGetEmailAddressFailedRequest(t *testing.T) { + b := testNextcloudBackend("unused payload") + defer b.Close() + + bURL, _ := url.Parse(b.URL) + p := testNextcloudProvider(bURL.Host) + p.ValidateURL.Path = userPath + p.ValidateURL.RawQuery = formatJSON + + // 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. + session := &sessions.SessionState{AccessToken: "unexpected_access_token"} + email, err := p.GetEmailAddress(session) + assert.NotEqual(t, nil, err) + assert.Equal(t, "", email) +} + +func TestNextcloudProviderGetEmailAddressEmailNotPresentInPayload(t *testing.T) { + b := testNextcloudBackend("{\"foo\": \"bar\"}") + defer b.Close() + + bURL, _ := url.Parse(b.URL) + p := testNextcloudProvider(bURL.Host) + p.ValidateURL.Path = userPath + p.ValidateURL.RawQuery = formatJSON + + session := &sessions.SessionState{AccessToken: "imaginary_access_token_nextcloud"} + email, err := p.GetEmailAddress(session) + assert.NotEqual(t, nil, err) + assert.Equal(t, "", email) +} diff --git a/providers/providers.go b/providers/providers.go index 276fab6..09ed33d 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -38,6 +38,8 @@ func New(provider string, p *ProviderData) Provider { return NewLoginGovProvider(p) case "bitbucket": return NewBitbucketProvider(p) + case "nextcloud": + return NewNextcloudProvider(p) default: return NewGoogleProvider(p) }