From bfb22506ff5f2c3db37eb7db7aa4089ac7ebbd7b Mon Sep 17 00:00:00 2001 From: Kamal Nasser Date: Fri, 11 Oct 2019 15:39:57 +0300 Subject: [PATCH 1/7] allow redirects to whitelisted hosts with ports --- oauthproxy.go | 2 +- oauthproxy_test.go | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/oauthproxy.go b/oauthproxy.go index 01c18c39..dbcb42b7 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -505,7 +505,7 @@ func (p *OAuthProxy) IsValidRedirect(redirect string) bool { return false } for _, domain := range p.whitelistDomains { - if (redirectURL.Host == domain) || (strings.HasPrefix(domain, ".") && strings.HasSuffix(redirectURL.Host, domain)) { + if (redirectURL.Hostname() == domain) || (strings.HasPrefix(domain, ".") && strings.HasSuffix(redirectURL.Hostname(), domain)) { return true } } diff --git a/oauthproxy_test.go b/oauthproxy_test.go index 8dd3adfb..d7774cc1 100644 --- a/oauthproxy_test.go +++ b/oauthproxy_test.go @@ -225,6 +225,12 @@ func TestIsValidRedirect(t *testing.T) { invalidHTTPS2 := proxy.IsValidRedirect("https://evil.corp/redirect?rd=foo.bar") assert.Equal(t, false, invalidHTTPS2) + + validPort := proxy.IsValidRedirect("http://foo.bar:3838/redirect") + assert.Equal(t, true, validPort) + + validPortSubdomain := proxy.IsValidRedirect("http://baz.bar.foo:3838/redirect") + assert.Equal(t, true, validPortSubdomain) } type TestProvider struct { From ae4e9155d27a4079670b909484e50696455e6925 Mon Sep 17 00:00:00 2001 From: Kamal Nasser Date: Sat, 12 Oct 2019 23:47:23 +0300 Subject: [PATCH 2/7] implicit/explicit redirect port matching --- oauthproxy.go | 20 ++++++++++++++++++-- oauthproxy_test.go | 25 ++++++++++++++++++++----- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/oauthproxy.go b/oauthproxy.go index dbcb42b7..5278445c 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -504,11 +504,27 @@ func (p *OAuthProxy) IsValidRedirect(redirect string) bool { if err != nil { return false } + redirectHostname := redirectURL.Hostname() + for _, domain := range p.whitelistDomains { - if (redirectURL.Hostname() == domain) || (strings.HasPrefix(domain, ".") && strings.HasSuffix(redirectURL.Hostname(), domain)) { - return true + domainURL := url.URL{ + Host: strings.TrimLeft(domain, "."), + } + domainHostname := domainURL.Hostname() + if domainHostname == "" { + continue + } + + if (redirectHostname == domainHostname) || (strings.HasPrefix(domain, ".") && strings.HasSuffix(redirectHostname, domainHostname)) { + // if the domain has a port, only allow that port + // otherwise allow any port + domainPort := domainURL.Port() + if (domainPort == "") || (domainPort == redirectURL.Port()) { + return true + } } } + return false default: return false diff --git a/oauthproxy_test.go b/oauthproxy_test.go index d7774cc1..2745c1b7 100644 --- a/oauthproxy_test.go +++ b/oauthproxy_test.go @@ -182,7 +182,7 @@ func TestIsValidRedirect(t *testing.T) { opts.ClientSecret = "fgkdsgj" opts.CookieSecret = "ljgiogbj" // Should match domains that are exactly foo.bar and any subdomain of bar.foo - opts.WhitelistDomains = []string{"foo.bar", ".bar.foo"} + opts.WhitelistDomains = []string{"foo.bar", ".bar.foo", "port.bar:8080", ".sub.port.bar:8080"} opts.Validate() proxy := NewOAuthProxy(opts, func(string) bool { return true }) @@ -226,11 +226,26 @@ func TestIsValidRedirect(t *testing.T) { invalidHTTPS2 := proxy.IsValidRedirect("https://evil.corp/redirect?rd=foo.bar") assert.Equal(t, false, invalidHTTPS2) - validPort := proxy.IsValidRedirect("http://foo.bar:3838/redirect") - assert.Equal(t, true, validPort) + invalidPort := proxy.IsValidRedirect("https://evil.corp:3838/redirect") + assert.Equal(t, false, invalidPort) - validPortSubdomain := proxy.IsValidRedirect("http://baz.bar.foo:3838/redirect") - assert.Equal(t, true, validPortSubdomain) + validAnyPort := proxy.IsValidRedirect("http://foo.bar:3838/redirect") + assert.Equal(t, true, validAnyPort) + + validAnyPortSubdomain := proxy.IsValidRedirect("http://baz.bar.foo:3838/redirect") + assert.Equal(t, true, validAnyPortSubdomain) + + validSpecificPort := proxy.IsValidRedirect("http://port.bar:8080/redirect") + assert.Equal(t, true, validSpecificPort) + + invalidSpecificPort := proxy.IsValidRedirect("http://port.bar:3838/redirect") + assert.Equal(t, false, invalidSpecificPort) + + validSpecificPortSubdomain := proxy.IsValidRedirect("http://foo.sub.port.bar:8080/redirect") + assert.Equal(t, true, validSpecificPortSubdomain) + + invalidSpecificPortSubdomain := proxy.IsValidRedirect("http://foo.sub.port.bar:3838/redirect") + assert.Equal(t, false, invalidSpecificPortSubdomain) } type TestProvider struct { From a12bae35ca0a19e6571ca4ca4ea5497d6f1c31d5 Mon Sep 17 00:00:00 2001 From: Kamal Nasser Date: Wed, 23 Oct 2019 16:38:44 +0300 Subject: [PATCH 3/7] update port whitelisting rules, refactor IsValidRedirect tests --- oauthproxy.go | 56 +++++++++++-- oauthproxy_test.go | 204 ++++++++++++++++++++++++++++++++------------- 2 files changed, 193 insertions(+), 67 deletions(-) diff --git a/oauthproxy.go b/oauthproxy.go index 5278445c..8d781770 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -494,6 +494,43 @@ func (p *OAuthProxy) GetRedirect(req *http.Request) (redirect string, err error) return } +// splitHostPort separates host and port. If the port is not valid, it returns +// the entire input as host, and it doesn't check the validity of the host. +// Unlike net.SplitHostPort, but per RFC 3986, it requires ports to be numeric. +// *** taken from net/url, modified validOptionalPort() to accept ":*" +func splitHostPort(hostport string) (host, port string) { + host = hostport + + colon := strings.LastIndexByte(host, ':') + if colon != -1 && validOptionalPort(host[colon:]) { + host, port = host[:colon], host[colon+1:] + } + + if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + host = host[1 : len(host)-1] + } + + return +} + +// validOptionalPort reports whether port is either an empty string +// or matches /^:\d*$/ +// *** taken from net/url, modified to accept ":*" +func validOptionalPort(port string) bool { + if port == "" || port == ":*" { + return true + } + if port[0] != ':' { + return false + } + for _, b := range port[1:] { + if b < '0' || b > '9' { + return false + } + } + return true +} + // IsValidRedirect checks whether the redirect URL is whitelisted func (p *OAuthProxy) IsValidRedirect(redirect string) bool { switch { @@ -507,19 +544,20 @@ func (p *OAuthProxy) IsValidRedirect(redirect string) bool { redirectHostname := redirectURL.Hostname() for _, domain := range p.whitelistDomains { - domainURL := url.URL{ - Host: strings.TrimLeft(domain, "."), - } - domainHostname := domainURL.Hostname() - if domainHostname == "" { + domainHostname, domainPort := splitHostPort(strings.TrimLeft(domain, ".")) + if err != nil || domainHostname == "" { continue } if (redirectHostname == domainHostname) || (strings.HasPrefix(domain, ".") && strings.HasSuffix(redirectHostname, domainHostname)) { - // if the domain has a port, only allow that port - // otherwise allow any port - domainPort := domainURL.Port() - if (domainPort == "") || (domainPort == redirectURL.Port()) { + // the domain names match, now validate the ports + // if the whitelisted domain's port is '*', allow all ports + // if the whitelisted domain contains a specific port, only allow that port + // if the whitelisted domain doesn't contain a port at all, only allow empty redirect ports ie http and https + redirectPort := redirectURL.Port() + if (domainPort == "*") || + (domainPort == redirectPort) || + (domainPort == "" && redirectPort == "") { return true } } diff --git a/oauthproxy_test.go b/oauthproxy_test.go index 2745c1b7..5bd6523c 100644 --- a/oauthproxy_test.go +++ b/oauthproxy_test.go @@ -182,70 +182,158 @@ func TestIsValidRedirect(t *testing.T) { opts.ClientSecret = "fgkdsgj" opts.CookieSecret = "ljgiogbj" // Should match domains that are exactly foo.bar and any subdomain of bar.foo - opts.WhitelistDomains = []string{"foo.bar", ".bar.foo", "port.bar:8080", ".sub.port.bar:8080"} + opts.WhitelistDomains = []string{ + "foo.bar", + ".bar.foo", + "port.bar:8080", + ".sub.port.bar:8080", + "anyport.bar:*", + ".sub.anyport.bar:*", + } opts.Validate() proxy := NewOAuthProxy(opts, func(string) bool { return true }) - noRD := proxy.IsValidRedirect("") - assert.Equal(t, false, noRD) + testCases := []struct { + Desc, Redirect string + ExpectedResult bool + }{ + { + Desc: "noRD", + Redirect: "", + ExpectedResult: false, + }, + { + Desc: "singleSlash", + Redirect: "/redirect", + ExpectedResult: true, + }, + { + Desc: "doubleSlash", + Redirect: "//redirect", + ExpectedResult: false, + }, + { + Desc: "validHTTP", + Redirect: "http://foo.bar/redirect", + ExpectedResult: true, + }, + { + Desc: "validHTTPS", + Redirect: "https://foo.bar/redirect", + ExpectedResult: true, + }, + { + Desc: "invalidHTTPSubdomain", + Redirect: "http://baz.foo.bar/redirect", + ExpectedResult: false, + }, + { + Desc: "invalidHTTPSSubdomain", + Redirect: "https://baz.foo.bar/redirect", + ExpectedResult: false, + }, + { + Desc: "validHTTPSubdomain", + Redirect: "http://baz.bar.foo/redirect", + ExpectedResult: true, + }, + { + Desc: "validHTTPSSubdomain", + Redirect: "https://baz.bar.foo/redirect", + ExpectedResult: true, + }, + { + Desc: "validHTTPDomain", + Redirect: "http://bar.foo/redirect", + ExpectedResult: true, + }, + { + Desc: "invalidHTTP1", + Redirect: "http://foo.bar.evil.corp/redirect", + ExpectedResult: false, + }, + { + Desc: "invalidHTTPS1", + Redirect: "https://foo.bar.evil.corp/redirect", + ExpectedResult: false, + }, + { + Desc: "invalidHTTP2", + Redirect: "http://evil.corp/redirect?rd=foo.bar", + ExpectedResult: false, + }, + { + Desc: "invalidHTTPS2", + Redirect: "https://evil.corp/redirect?rd=foo.bar", + ExpectedResult: false, + }, + { + Desc: "invalidPort", + Redirect: "https://evil.corp:3838/redirect", + ExpectedResult: false, + }, + { + Desc: "invalidEmptyPort", + Redirect: "http://foo.bar:3838/redirect", + ExpectedResult: false, + }, + { + Desc: "invalidEmptyPortSubdomain", + Redirect: "http://baz.bar.foo:3838/redirect", + ExpectedResult: false, + }, + { + Desc: "validSpecificPort", + Redirect: "http://port.bar:8080/redirect", + ExpectedResult: true, + }, + { + Desc: "invalidSpecificPort", + Redirect: "http://port.bar:3838/redirect", + ExpectedResult: false, + }, + { + Desc: "validSpecificPortSubdomain", + Redirect: "http://foo.sub.port.bar:8080/redirect", + ExpectedResult: true, + }, + { + Desc: "invalidSpecificPortSubdomain", + Redirect: "http://foo.sub.port.bar:3838/redirect", + ExpectedResult: false, + }, + { + Desc: "validAnyPort1", + Redirect: "http://anyport.bar:8080/redirect", + ExpectedResult: true, + }, + { + Desc: "validAnyPort2", + Redirect: "http://anyport.bar:8081/redirect", + ExpectedResult: true, + }, + { + Desc: "validAnyPortSubdomain1", + Redirect: "http://a.sub.anyport.bar:8080/redirect", + ExpectedResult: true, + }, + { + Desc: "validAnyPortSubdomain2", + Redirect: "http://a.sub.anyport.bar:8081/redirect", + ExpectedResult: true, + }, + } - singleSlash := proxy.IsValidRedirect("/redirect") - assert.Equal(t, true, singleSlash) + for _, tc := range testCases { + t.Run(tc.Desc, func(t *testing.T) { + result := proxy.IsValidRedirect(tc.Redirect) - doubleSlash := proxy.IsValidRedirect("//redirect") - assert.Equal(t, false, doubleSlash) - - validHTTP := proxy.IsValidRedirect("http://foo.bar/redirect") - assert.Equal(t, true, validHTTP) - - validHTTPS := proxy.IsValidRedirect("https://foo.bar/redirect") - assert.Equal(t, true, validHTTPS) - - invalidHTTPSubdomain := proxy.IsValidRedirect("http://baz.foo.bar/redirect") - assert.Equal(t, false, invalidHTTPSubdomain) - - invalidHTTPSSubdomain := proxy.IsValidRedirect("https://baz.foo.bar/redirect") - assert.Equal(t, false, invalidHTTPSSubdomain) - - validHTTPSubdomain := proxy.IsValidRedirect("http://baz.bar.foo/redirect") - assert.Equal(t, true, validHTTPSubdomain) - - validHTTPSSubdomain := proxy.IsValidRedirect("https://baz.bar.foo/redirect") - assert.Equal(t, true, validHTTPSSubdomain) - - invalidHTTP1 := proxy.IsValidRedirect("http://foo.bar.evil.corp/redirect") - assert.Equal(t, false, invalidHTTP1) - - invalidHTTPS1 := proxy.IsValidRedirect("https://foo.bar.evil.corp/redirect") - assert.Equal(t, false, invalidHTTPS1) - - invalidHTTP2 := proxy.IsValidRedirect("http://evil.corp/redirect?rd=foo.bar") - assert.Equal(t, false, invalidHTTP2) - - invalidHTTPS2 := proxy.IsValidRedirect("https://evil.corp/redirect?rd=foo.bar") - assert.Equal(t, false, invalidHTTPS2) - - invalidPort := proxy.IsValidRedirect("https://evil.corp:3838/redirect") - assert.Equal(t, false, invalidPort) - - validAnyPort := proxy.IsValidRedirect("http://foo.bar:3838/redirect") - assert.Equal(t, true, validAnyPort) - - validAnyPortSubdomain := proxy.IsValidRedirect("http://baz.bar.foo:3838/redirect") - assert.Equal(t, true, validAnyPortSubdomain) - - validSpecificPort := proxy.IsValidRedirect("http://port.bar:8080/redirect") - assert.Equal(t, true, validSpecificPort) - - invalidSpecificPort := proxy.IsValidRedirect("http://port.bar:3838/redirect") - assert.Equal(t, false, invalidSpecificPort) - - validSpecificPortSubdomain := proxy.IsValidRedirect("http://foo.sub.port.bar:8080/redirect") - assert.Equal(t, true, validSpecificPortSubdomain) - - invalidSpecificPortSubdomain := proxy.IsValidRedirect("http://foo.sub.port.bar:3838/redirect") - assert.Equal(t, false, invalidSpecificPortSubdomain) + if result != tc.ExpectedResult { + t.Errorf("expected %t got %t", tc.ExpectedResult, result) + } + }) + } } type TestProvider struct { From 1af7c208eeab793f648dca2ca42c92cb104b069b Mon Sep 17 00:00:00 2001 From: Kamal Nasser Date: Wed, 23 Oct 2019 16:48:16 +0300 Subject: [PATCH 4/7] Update documentation and changelog --- CHANGELOG.md | 1 + docs/configuration/configuration.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55548857..e659781e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - [#275](https://github.com/pusher/oauth2_proxy/pull/275) docker: build from debian buster (@syscll) - [#258](https://github.com/pusher/oauth2_proxy/pull/258) Add IDToken for Azure provider - This PR adds the IDToken into the session for the Azure provider allowing requests to a backend to be identified as a specific user. As a consequence, if you are using a cookie to store the session the cookie will now exceed the 4kb size limit and be split into multiple cookies. This can cause problems when using nginx as a proxy, resulting in no cookie being passed at all. Either increase the proxy_buffer_size in nginx or implement the redis session storage (see https://pusher.github.io/oauth2_proxy/configuration#redis-storage) + - [#280](https://github.com/pusher/oauth2_proxy/pull/280) Add support for whitelisting specific ports or allowing wildcard ports in whitelisted redirect domains # v4.0.0 diff --git a/docs/configuration/configuration.md b/docs/configuration/configuration.md index 332c2238..0caf942a 100644 --- a/docs/configuration/configuration.md +++ b/docs/configuration/configuration.md @@ -111,7 +111,7 @@ An example [oauth2_proxy.cfg]({{ site.gitweb }}/contrib/oauth2_proxy.cfg.example | `-version` | n/a | print version string | | | `-whitelist-domain` | string \| list | allowed domains for redirection after authentication. Prefix domain with a `.` to allow subdomains (eg `.example.com`) | | -Note, when using the `whitelist-domain` option, any domain prefixed with a `.` will allow any subdomain of the specified domain as a valid redirect URL. +Note: when using the `whitelist-domain` option, any domain prefixed with a `.` will allow any subdomain of the specified domain as a valid redirect URL. By default, only empty ports are allowed. This translates to allowing the default port of the URL's protocol (80 for HTTP, 443 for HTTPS, etc.) since browsers omit them. To allow only a specific port, add it to the whitelisted domain: `example.com:8080`. To allow any port, use `*`: `example.com:*`. See below for provider specific options From 898b6b81c9b7812ca4c197b19420fa07eb7ac323 Mon Sep 17 00:00:00 2001 From: Kamal Nasser Date: Thu, 14 Nov 2019 17:17:12 +0200 Subject: [PATCH 5/7] remove unnecessary if conditional --- oauthproxy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauthproxy.go b/oauthproxy.go index 8d781770..11860a95 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -545,7 +545,7 @@ func (p *OAuthProxy) IsValidRedirect(redirect string) bool { for _, domain := range p.whitelistDomains { domainHostname, domainPort := splitHostPort(strings.TrimLeft(domain, ".")) - if err != nil || domainHostname == "" { + if domainHostname == "" { continue } From f94dee6f0dd1cf74d3da5fa35687a18dc37c7f7b Mon Sep 17 00:00:00 2001 From: sushiMix <53741704+sushiMix@users.noreply.github.com> Date: Fri, 10 Jan 2020 10:41:08 +0100 Subject: [PATCH 6/7] Update keycloak provider configuration doc (#347) * update keycloak provider configuration doc * Add changelog entry --- CHANGELOG.md | 1 + docs/2_auth.md | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90b7ccf5..783335dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## Breaking Changes ## Changes since v4.1.0 +- [#347](https://github.com/pusher/oauth2_proxy/pull/347) Update keycloak provider configuration documentation - [#325](https://github.com/pusher/oauth2_proxy/pull/325) dist.sh: use sha256sum (@syscll) - [#179](https://github.com/pusher/oauth2_proxy/pull/179) Add Nextcloud provider (@Ramblurr) diff --git a/docs/2_auth.md b/docs/2_auth.md index b3d96559..e1440075 100644 --- a/docs/2_auth.md +++ b/docs/2_auth.md @@ -107,8 +107,9 @@ If you are using GitHub enterprise, make sure you set the following to the appro ### Keycloak Auth Provider -1. Create new client in your Keycloak with **Access Type** 'confidental'. -2. Create a mapper with **Mapper Type** 'Group Membership'. +1. Create new client in your Keycloak with **Access Type** 'confidental' and **Valid Redirect URIs** 'https://internal.yourcompany.com/oauth2/callback' +2. Take note of the Secret in the credential tab of the client +3. Create a mapper with **Mapper Type** 'Group Membership' and **Token Claim Name** 'groups'. Make sure you set the following to the appropriate url: @@ -116,8 +117,11 @@ Make sure you set the following to the appropriate url: -client-id= -client-secret= -login-url="http(s):///realms//protocol/openid-connect/auth" - -redeem-url="http(s):///realms/master//openid-connect/auth/token" - -validate-url="http(s):///realms/master//openid-connect/userinfo" + -redeem-url="http(s):///realms//protocol/openid-connect/token" + -validate-url="http(s):///realms//protocol/openid-connect/userinfo" + -keycloak-group= + +The group management in keycloak is using a tree. If you create a group named admin in keycloak you should define the 'keycloak-group' value to /admin. ### GitLab Auth Provider From eee4b55e0f254e1fea19e34d18c0bd48a574bc8f Mon Sep 17 00:00:00 2001 From: Kamal Nasser Date: Wed, 15 Jan 2020 13:09:34 +0200 Subject: [PATCH 7/7] DigitalOcean Auth Provider (#351) * DigitalOcean provider * documentation: digitalocean provider * changelog: digitalocean provider * codeowners: digitalocean provider --- .github/CODEOWNERS | 4 + CHANGELOG.md | 1 + contrib/oauth2_proxy_autocomplete.sh | 2 +- docs/2_auth.md | 18 ++++ providers/digitalocean.go | 81 ++++++++++++++++ providers/digitalocean_test.go | 134 +++++++++++++++++++++++++++ providers/providers.go | 2 + 7 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 providers/digitalocean.go create mode 100644 providers/digitalocean_test.go diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 71a79063..fa1f8104 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -18,3 +18,7 @@ providers/bitbucket_test.go @aledeganopix4d # Nextcloud provider providers/nextcloud.go @Ramblurr providers/nextcloud_test.go @Ramblurr + +# DigitalOcean provider +providers/digitalocean.go @kamaln7 +providers/digitalocean_test.go @kamaln7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 783335dd..14d60f09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - [#347](https://github.com/pusher/oauth2_proxy/pull/347) Update keycloak provider configuration documentation - [#325](https://github.com/pusher/oauth2_proxy/pull/325) dist.sh: use sha256sum (@syscll) - [#179](https://github.com/pusher/oauth2_proxy/pull/179) Add Nextcloud provider (@Ramblurr) +- [#351](https://github.com/pusher/oauth2_proxy/pull/351) Add DigitalOcean Auth provider (@kamaln7) # v4.1.0 diff --git a/contrib/oauth2_proxy_autocomplete.sh b/contrib/oauth2_proxy_autocomplete.sh index fd9d87a4..219a0ec5 100644 --- a/contrib/oauth2_proxy_autocomplete.sh +++ b/contrib/oauth2_proxy_autocomplete.sh @@ -17,7 +17,7 @@ _oauth2_proxy() { return 0 ;; -provider) - COMPREPLY=( $(compgen -W "google azure facebook github keycloak gitlab linkedin login.gov" -- ${cur}) ) + COMPREPLY=( $(compgen -W "google azure facebook github keycloak gitlab linkedin login.gov digitalocean" -- ${cur}) ) return 0 ;; -@(http-address|https-address|redirect-url|upstream|basic-auth-password|skip-auth-regex|flush-interval|extra-jwt-issuers|email-domain|whitelist-domain|keycloak-group|azure-tenant|bitbucket-team|bitbucket-repository|github-org|github-team|gitlab-group|google-group|google-admin-email|google-service-account-json|client-id|client_secret|banner|footer|proxy-prefix|ping-path|cookie-name|cookie-secret|cookie-domain|cookie-path|cookie-expire|cookie-refresh|redist-sentinel-master-name|redist-sentinel-connection-urls|logging-max-size|logging-max-age|logging-max-backups|standard-logging-format|request-logging-format|exclude-logging-paths|auth-logging-format|oidc-issuer-url|oidc-jwks-url|login-url|redeem-url|profile-url|resource|validate-url|scope|approval-prompt|signature-key|acr-values|jwt-key|pubjwk-url)) diff --git a/docs/2_auth.md b/docs/2_auth.md index e1440075..d715c6cc 100644 --- a/docs/2_auth.md +++ b/docs/2_auth.md @@ -20,6 +20,7 @@ Valid providers are : - [LinkedIn](#linkedin-auth-provider) - [login.gov](#logingov-provider) - [Nextcloud](#nextcloud-provider) +- [DigitalOcean](#digitalocean-auth-provider) The provider can be selected using the `provider` configuration value. @@ -320,6 +321,23 @@ to setup the client id and client secret. Your "Redirection URI" will be Note: in *all* cases the validate-url will *not* have the `index.php`. +### DigitalOcean Auth Provider + +1. [Create a new OAuth application](https://cloud.digitalocean.com/account/api/applications) + * You can fill in the name, homepage, and description however you wish. + * In the "Application callback URL" field, enter: `https://oauth-proxy/oauth2/callback`, substituting `oauth2-proxy` with the actual hostname that oauth2_proxy is running on. The URL must match oauth2_proxy's configured redirect URL. +2. Note the Client ID and Client Secret. + +To use the provider, pass the following options: + +``` + --provider=digitalocean + --client-id= + --client-secret= +``` + + Alternatively, set the equivalent options in the config file. The redirect URL defaults to `https:///oauth2/callback`. If you need to change it, you can use the `--redirect-url` command-line option. + ## 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/digitalocean.go b/providers/digitalocean.go new file mode 100644 index 00000000..f4d9ce57 --- /dev/null +++ b/providers/digitalocean.go @@ -0,0 +1,81 @@ +package providers + +import ( + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/pusher/oauth2_proxy/pkg/apis/sessions" + "github.com/pusher/oauth2_proxy/pkg/requests" +) + +// DigitalOceanProvider represents a DigitalOcean based Identity Provider +type DigitalOceanProvider struct { + *ProviderData +} + +// NewDigitalOceanProvider initiates a new DigitalOceanProvider +func NewDigitalOceanProvider(p *ProviderData) *DigitalOceanProvider { + p.ProviderName = "DigitalOcean" + if p.LoginURL.String() == "" { + p.LoginURL = &url.URL{Scheme: "https", + Host: "cloud.digitalocean.com", + Path: "/v1/oauth/authorize", + } + } + if p.RedeemURL.String() == "" { + p.RedeemURL = &url.URL{Scheme: "https", + Host: "cloud.digitalocean.com", + Path: "/v1/oauth/token", + } + } + if p.ProfileURL.String() == "" { + p.ProfileURL = &url.URL{Scheme: "https", + Host: "api.digitalocean.com", + Path: "/v2/account", + } + } + if p.ValidateURL.String() == "" { + p.ValidateURL = p.ProfileURL + } + if p.Scope == "" { + p.Scope = "read" + } + return &DigitalOceanProvider{ProviderData: p} +} + +func getDigitalOceanHeader(accessToken string) http.Header { + header := make(http.Header) + header.Set("Content-Type", "application/json") + header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + return header +} + +// GetEmailAddress returns the Account email address +func (p *DigitalOceanProvider) GetEmailAddress(s *sessions.SessionState) (string, error) { + if s.AccessToken == "" { + return "", errors.New("missing access token") + } + req, err := http.NewRequest("GET", p.ProfileURL.String(), nil) + if err != nil { + return "", err + } + req.Header = getDigitalOceanHeader(s.AccessToken) + + json, err := requests.Request(req) + if err != nil { + return "", err + } + + email, err := json.GetPath("account", "email").String() + if err != nil { + return "", err + } + return email, nil +} + +// ValidateSessionState validates the AccessToken +func (p *DigitalOceanProvider) ValidateSessionState(s *sessions.SessionState) bool { + return validateToken(p, s.AccessToken, getDigitalOceanHeader(s.AccessToken)) +} diff --git a/providers/digitalocean_test.go b/providers/digitalocean_test.go new file mode 100644 index 00000000..08b52957 --- /dev/null +++ b/providers/digitalocean_test.go @@ -0,0 +1,134 @@ +package providers + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/pusher/oauth2_proxy/pkg/apis/sessions" + "github.com/stretchr/testify/assert" +) + +func testDigitalOceanProvider(hostname string) *DigitalOceanProvider { + p := NewDigitalOceanProvider( + &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) + } + return p +} + +func testDigitalOceanBackend(payload string) *httptest.Server { + path := "/v2/account" + + return httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if r.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 TestDigitalOceanProviderDefaults(t *testing.T) { + p := testDigitalOceanProvider("") + assert.NotEqual(t, nil, p) + assert.Equal(t, "DigitalOcean", p.Data().ProviderName) + assert.Equal(t, "https://cloud.digitalocean.com/v1/oauth/authorize", + p.Data().LoginURL.String()) + assert.Equal(t, "https://cloud.digitalocean.com/v1/oauth/token", + p.Data().RedeemURL.String()) + assert.Equal(t, "https://api.digitalocean.com/v2/account", + p.Data().ProfileURL.String()) + assert.Equal(t, "https://api.digitalocean.com/v2/account", + p.Data().ValidateURL.String()) + assert.Equal(t, "read", p.Data().Scope) +} + +func TestDigitalOceanProviderOverrides(t *testing.T) { + p := NewDigitalOceanProvider( + &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"}, + ValidateURL: &url.URL{ + Scheme: "https", + Host: "example.com", + Path: "/oauth/tokeninfo"}, + Scope: "profile"}) + assert.NotEqual(t, nil, p) + assert.Equal(t, "DigitalOcean", 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, "https://example.com/oauth/tokeninfo", + p.Data().ValidateURL.String()) + assert.Equal(t, "profile", p.Data().Scope) +} + +func TestDigitalOceanProviderGetEmailAddress(t *testing.T) { + b := testDigitalOceanBackend(`{"account": {"email": "user@example.com"}}`) + defer b.Close() + + bURL, _ := url.Parse(b.URL) + p := testDigitalOceanProvider(bURL.Host) + + session := &sessions.SessionState{AccessToken: "imaginary_access_token"} + email, err := p.GetEmailAddress(session) + assert.Equal(t, nil, err) + assert.Equal(t, "user@example.com", email) +} + +func TestDigitalOceanProviderGetEmailAddressFailedRequest(t *testing.T) { + b := testDigitalOceanBackend("unused payload") + defer b.Close() + + bURL, _ := url.Parse(b.URL) + p := testDigitalOceanProvider(bURL.Host) + + // 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 TestDigitalOceanProviderGetEmailAddressEmailNotPresentInPayload(t *testing.T) { + b := testDigitalOceanBackend("{\"foo\": \"bar\"}") + defer b.Close() + + bURL, _ := url.Parse(b.URL) + p := testDigitalOceanProvider(bURL.Host) + + session := &sessions.SessionState{AccessToken: "imaginary_access_token"} + 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 0e264105..fb8cccad 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -42,6 +42,8 @@ func New(provider string, p *ProviderData) Provider { return NewBitbucketProvider(p) case "nextcloud": return NewNextcloudProvider(p) + case "digitalocean": + return NewDigitalOceanProvider(p) default: return NewGoogleProvider(p) }