git.iko.soy/typeable/oauth2-proxy
on master help sign in
Merge branch 'master' into pgroudas/add-samesite-cookie-options
commit17d9961cb970e2f9309c4c43499c2d10719a97c5 authorJoel Speed <Joel.speed@hotmail.co.uk> dateMon 20 Jan 2020, 19:21 (7 years ago) parentafb7247038ee16
10 files changed+455−45
.github/CODEOWNERS+4 CHANGELOG.md+4 contrib/oauth2_proxy_autocomplete.sh+1−1 docs/2_auth.md+26−4 docs/configuration/configuration.md+1−1 oauthproxy.go+56−2 oauthproxy_test.go+146−37 providers/digitalocean.go+81 providers/digitalocean_test.go+134 providers/providers.go+2
.github/CODEOWNERS+4
@@ -18,3 +18,7 @@ providers/bitbucket_test.go @aledeganopix4d
1818# Nextcloud provider
1919providers/nextcloud.go @Ramblurr
2020providers/nextcloud_test.go @Ramblurr
21+
22+# DigitalOcean provider
23+providers/digitalocean.go @kamaln7
24+providers/digitalocean_test.go @kamaln7
CHANGELOG.md+4
@@ -5,9 +5,13 @@
55## Breaking Changes
66
77## Changes since v4.1.0
8+
89- [#339](https://github.com/pusher/oauth2_proxy/pull/339) Add configuration for cookie 'SameSite' value. (@pgroudas)
10+- [#347](https://github.com/pusher/oauth2_proxy/pull/347) Update keycloak provider configuration documentation
911- [#325](https://github.com/pusher/oauth2_proxy/pull/325) dist.sh: use sha256sum (@syscll)
1012- [#179](https://github.com/pusher/oauth2_proxy/pull/179) Add Nextcloud provider (@Ramblurr)
13+- [#280](https://github.com/pusher/oauth2_proxy/pull/280) whitelisted redirect domains: add support for whitelisting specific ports or allowing wildcard ports (@kamaln7)
14+- [#351](https://github.com/pusher/oauth2_proxy/pull/351) Add DigitalOcean Auth provider (@kamaln7)
1115
1216# v4.1.0
1317
contrib/oauth2_proxy_autocomplete.sh+1−1
@@ -17,7 +17,7 @@ _oauth2_proxy() {
1717 return 0
1818 ;;
1919 -provider)
20− COMPREPLY=( $(compgen -W "google azure facebook github keycloak gitlab linkedin login.gov" -- ${cur}) )
20+ COMPREPLY=( $(compgen -W "google azure facebook github keycloak gitlab linkedin login.gov digitalocean" -- ${cur}) )
2121 return 0
2222 ;;
2323 -@(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|cookie-samesite|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))
docs/2_auth.md+26−4
@@ -20,6 +20,7 @@ Valid providers are :
2020- [LinkedIn](#linkedin-auth-provider)
2121- [login.gov](#logingov-provider)
2222- [Nextcloud](#nextcloud-provider)
23+- [DigitalOcean](#digitalocean-auth-provider)
2324
2425The provider can be selected using the `provider` configuration value.
2526
@@ -107,8 +108,9 @@ If you are using GitHub enterprise, make sure you set the following to the appro
107108
108109### Keycloak Auth Provider
109110
110−1. Create new client in your Keycloak with **Access Type** 'confidental'.
111−2. Create a mapper with **Mapper Type** 'Group Membership'.
111+1. Create new client in your Keycloak with **Access Type** 'confidental' and **Valid Redirect URIs** 'https://internal.yourcompany.com/oauth2/callback'
112+2. Take note of the Secret in the credential tab of the client
113+3. Create a mapper with **Mapper Type** 'Group Membership' and **Token Claim Name** 'groups'.
112114
113115Make sure you set the following to the appropriate url:
114116
@@ -116,8 +118,11 @@ Make sure you set the following to the appropriate url:
116118 -client-id=<client you have created>
117119 -client-secret=<your client's secret>
118120 -login-url="http(s)://<keycloak host>/realms/<your realm>/protocol/openid-connect/auth"
119− -redeem-url="http(s)://<keycloak host>/realms/master/<your realm>/openid-connect/auth/token"
120− -validate-url="http(s)://<keycloak host>/realms/master/<your realm>/openid-connect/userinfo"
121+ -redeem-url="http(s)://<keycloak host>/realms/<your realm>/protocol/openid-connect/token"
122+ -validate-url="http(s)://<keycloak host>/realms/<your realm>/protocol/openid-connect/userinfo"
123+ -keycloak-group=<user_group>
124+
125+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.
121126
122127### GitLab Auth Provider
123128
@@ -316,6 +321,23 @@ to setup the client id and client secret. Your "Redirection URI" will be
316321
317322Note: in *all* cases the validate-url will *not* have the `index.php`.
318323
324+### DigitalOcean Auth Provider
325+
326+1. [Create a new OAuth application](https://cloud.digitalocean.com/account/api/applications)
327+ * You can fill in the name, homepage, and description however you wish.
328+ * 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.
329+2. Note the Client ID and Client Secret.
330+
331+To use the provider, pass the following options:
332+
333+```
334+ --provider=digitalocean
335+ --client-id=<Client ID>
336+ --client-secret=<Client Secret>
337+```
338+
339+ Alternatively, set the equivalent options in the config file. The redirect URL defaults to `https://<requested host header>/oauth2/callback`. If you need to change it, you can use the `--redirect-url` command-line option.
340+
319341## Email Authentication
320342
321343To 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=*`.
docs/configuration/configuration.md+1−1
@@ -113,7 +113,7 @@ An example [oauth2_proxy.cfg]({{ site.gitweb }}/contrib/oauth2_proxy.cfg.example
113113| `-version` | n/a | print version string | |
114114| `-whitelist-domain` | string \| list | allowed domains for redirection after authentication. Prefix domain with a `.` to allow subdomains (eg `.example.com`) | |
115115
116−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.
116+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:*`.
117117
118118See below for provider specific options
119119
oauthproxy.go+56−2
@@ -518,6 +518,43 @@ func (p *OAuthProxy) GetRedirect(req *http.Request) (redirect string, err error)
518518 return
519519}
520520
521+// splitHostPort separates host and port. If the port is not valid, it returns
522+// the entire input as host, and it doesn't check the validity of the host.
523+// Unlike net.SplitHostPort, but per RFC 3986, it requires ports to be numeric.
524+// *** taken from net/url, modified validOptionalPort() to accept ":*"
525+func splitHostPort(hostport string) (host, port string) {
526+ host = hostport
527+
528+ colon := strings.LastIndexByte(host, ':')
529+ if colon != -1 && validOptionalPort(host[colon:]) {
530+ host, port = host[:colon], host[colon+1:]
531+ }
532+
533+ if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
534+ host = host[1 : len(host)-1]
535+ }
536+
537+ return
538+}
539+
540+// validOptionalPort reports whether port is either an empty string
541+// or matches /^:\d*$/
542+// *** taken from net/url, modified to accept ":*"
543+func validOptionalPort(port string) bool {
544+ if port == "" || port == ":*" {
545+ return true
546+ }
547+ if port[0] != ':' {
548+ return false
549+ }
550+ for _, b := range port[1:] {
551+ if b < '0' || b > '9' {
552+ return false
553+ }
554+ }
555+ return true
556+}
557+
521558// IsValidRedirect checks whether the redirect URL is whitelisted
522559func (p *OAuthProxy) IsValidRedirect(redirect string) bool {
523560 switch {
@@ -528,11 +565,28 @@ func (p *OAuthProxy) IsValidRedirect(redirect string) bool {
528565 if err != nil {
529566 return false
530567 }
568+ redirectHostname := redirectURL.Hostname()
569+
531570 for _, domain := range p.whitelistDomains {
532− if (redirectURL.Host == domain) || (strings.HasPrefix(domain, ".") && strings.HasSuffix(redirectURL.Host, domain)) {
533− return true
571+ domainHostname, domainPort := splitHostPort(strings.TrimLeft(domain, "."))
572+ if domainHostname == "" {
573+ continue
574+ }
575+
576+ if (redirectHostname == domainHostname) || (strings.HasPrefix(domain, ".") && strings.HasSuffix(redirectHostname, domainHostname)) {
577+ // the domain names match, now validate the ports
578+ // if the whitelisted domain's port is '*', allow all ports
579+ // if the whitelisted domain contains a specific port, only allow that port
580+ // if the whitelisted domain doesn't contain a port at all, only allow empty redirect ports ie http and https
581+ redirectPort := redirectURL.Port()
582+ if (domainPort == "*") ||
583+ (domainPort == redirectPort) ||
584+ (domainPort == "" && redirectPort == "") {
585+ return true
586+ }
534587 }
535588 }
589+
536590 return false
537591 default:
538592 return false
oauthproxy_test.go+146−37
@@ -182,49 +182,158 @@ func TestIsValidRedirect(t *testing.T) {
182182 opts.ClientSecret = "fgkdsgj"
183183 opts.CookieSecret = "ljgiogbj"
184184 // Should match domains that are exactly foo.bar and any subdomain of bar.foo
185− opts.WhitelistDomains = []string{"foo.bar", ".bar.foo"}
185+ opts.WhitelistDomains = []string{
186+ "foo.bar",
187+ ".bar.foo",
188+ "port.bar:8080",
189+ ".sub.port.bar:8080",
190+ "anyport.bar:*",
191+ ".sub.anyport.bar:*",
192+ }
186193 opts.Validate()
187194
188195 proxy := NewOAuthProxy(opts, func(string) bool { return true })
189196
190− noRD := proxy.IsValidRedirect("")
191− assert.Equal(t, false, noRD)
192−
193− singleSlash := proxy.IsValidRedirect("/redirect")
194− assert.Equal(t, true, singleSlash)
195−
196− doubleSlash := proxy.IsValidRedirect("//redirect")
197− assert.Equal(t, false, doubleSlash)
198−
199− validHTTP := proxy.IsValidRedirect("http://foo.bar/redirect")
200− assert.Equal(t, true, validHTTP)
201−
202− validHTTPS := proxy.IsValidRedirect("https://foo.bar/redirect")
203− assert.Equal(t, true, validHTTPS)
204−
205− invalidHTTPSubdomain := proxy.IsValidRedirect("http://baz.foo.bar/redirect")
206− assert.Equal(t, false, invalidHTTPSubdomain)
207−
208− invalidHTTPSSubdomain := proxy.IsValidRedirect("https://baz.foo.bar/redirect")
209− assert.Equal(t, false, invalidHTTPSSubdomain)
210−
211− validHTTPSubdomain := proxy.IsValidRedirect("http://baz.bar.foo/redirect")
212− assert.Equal(t, true, validHTTPSubdomain)
213−
214− validHTTPSSubdomain := proxy.IsValidRedirect("https://baz.bar.foo/redirect")
215− assert.Equal(t, true, validHTTPSSubdomain)
216−
217− invalidHTTP1 := proxy.IsValidRedirect("http://foo.bar.evil.corp/redirect")
218− assert.Equal(t, false, invalidHTTP1)
219−
220− invalidHTTPS1 := proxy.IsValidRedirect("https://foo.bar.evil.corp/redirect")
221− assert.Equal(t, false, invalidHTTPS1)
197+ testCases := []struct {
198+ Desc, Redirect string
199+ ExpectedResult bool
200+ }{
201+ {
202+ Desc: "noRD",
203+ Redirect: "",
204+ ExpectedResult: false,
205+ },
206+ {
207+ Desc: "singleSlash",
208+ Redirect: "/redirect",
209+ ExpectedResult: true,
210+ },
211+ {
212+ Desc: "doubleSlash",
213+ Redirect: "//redirect",
214+ ExpectedResult: false,
215+ },
216+ {
217+ Desc: "validHTTP",
218+ Redirect: "http://foo.bar/redirect",
219+ ExpectedResult: true,
220+ },
221+ {
222+ Desc: "validHTTPS",
223+ Redirect: "https://foo.bar/redirect",
224+ ExpectedResult: true,
225+ },
226+ {
227+ Desc: "invalidHTTPSubdomain",
228+ Redirect: "http://baz.foo.bar/redirect",
229+ ExpectedResult: false,
230+ },
231+ {
232+ Desc: "invalidHTTPSSubdomain",
233+ Redirect: "https://baz.foo.bar/redirect",
234+ ExpectedResult: false,
235+ },
236+ {
237+ Desc: "validHTTPSubdomain",
238+ Redirect: "http://baz.bar.foo/redirect",
239+ ExpectedResult: true,
240+ },
241+ {
242+ Desc: "validHTTPSSubdomain",
243+ Redirect: "https://baz.bar.foo/redirect",
244+ ExpectedResult: true,
245+ },
246+ {
247+ Desc: "validHTTPDomain",
248+ Redirect: "http://bar.foo/redirect",
249+ ExpectedResult: true,
250+ },
251+ {
252+ Desc: "invalidHTTP1",
253+ Redirect: "http://foo.bar.evil.corp/redirect",
254+ ExpectedResult: false,
255+ },
256+ {
257+ Desc: "invalidHTTPS1",
258+ Redirect: "https://foo.bar.evil.corp/redirect",
259+ ExpectedResult: false,
260+ },
261+ {
262+ Desc: "invalidHTTP2",
263+ Redirect: "http://evil.corp/redirect?rd=foo.bar",
264+ ExpectedResult: false,
265+ },
266+ {
267+ Desc: "invalidHTTPS2",
268+ Redirect: "https://evil.corp/redirect?rd=foo.bar",
269+ ExpectedResult: false,
270+ },
271+ {
272+ Desc: "invalidPort",
273+ Redirect: "https://evil.corp:3838/redirect",
274+ ExpectedResult: false,
275+ },
276+ {
277+ Desc: "invalidEmptyPort",
278+ Redirect: "http://foo.bar:3838/redirect",
279+ ExpectedResult: false,
280+ },
281+ {
282+ Desc: "invalidEmptyPortSubdomain",
283+ Redirect: "http://baz.bar.foo:3838/redirect",
284+ ExpectedResult: false,
285+ },
286+ {
287+ Desc: "validSpecificPort",
288+ Redirect: "http://port.bar:8080/redirect",
289+ ExpectedResult: true,
290+ },
291+ {
292+ Desc: "invalidSpecificPort",
293+ Redirect: "http://port.bar:3838/redirect",
294+ ExpectedResult: false,
295+ },
296+ {
297+ Desc: "validSpecificPortSubdomain",
298+ Redirect: "http://foo.sub.port.bar:8080/redirect",
299+ ExpectedResult: true,
300+ },
301+ {
302+ Desc: "invalidSpecificPortSubdomain",
303+ Redirect: "http://foo.sub.port.bar:3838/redirect",
304+ ExpectedResult: false,
305+ },
306+ {
307+ Desc: "validAnyPort1",
308+ Redirect: "http://anyport.bar:8080/redirect",
309+ ExpectedResult: true,
310+ },
311+ {
312+ Desc: "validAnyPort2",
313+ Redirect: "http://anyport.bar:8081/redirect",
314+ ExpectedResult: true,
315+ },
316+ {
317+ Desc: "validAnyPortSubdomain1",
318+ Redirect: "http://a.sub.anyport.bar:8080/redirect",
319+ ExpectedResult: true,
320+ },
321+ {
322+ Desc: "validAnyPortSubdomain2",
323+ Redirect: "http://a.sub.anyport.bar:8081/redirect",
324+ ExpectedResult: true,
325+ },
326+ }
222327
223− invalidHTTP2 := proxy.IsValidRedirect("http://evil.corp/redirect?rd=foo.bar")
224− assert.Equal(t, false, invalidHTTP2)
328+ for _, tc := range testCases {
329+ t.Run(tc.Desc, func(t *testing.T) {
330+ result := proxy.IsValidRedirect(tc.Redirect)
225331
226− invalidHTTPS2 := proxy.IsValidRedirect("https://evil.corp/redirect?rd=foo.bar")
227− assert.Equal(t, false, invalidHTTPS2)
332+ if result != tc.ExpectedResult {
333+ t.Errorf("expected %t got %t", tc.ExpectedResult, result)
334+ }
335+ })
336+ }
228337}
229338
230339type TestProvider struct {
providers/digitalocean.gonew file+81
@@ -0,0 +1,81 @@
1+package providers
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "net/http"
7+ "net/url"
8+
9+ "github.com/pusher/oauth2_proxy/pkg/apis/sessions"
10+ "github.com/pusher/oauth2_proxy/pkg/requests"
11+)
12+
13+// DigitalOceanProvider represents a DigitalOcean based Identity Provider
14+type DigitalOceanProvider struct {
15+ *ProviderData
16+}
17+
18+// NewDigitalOceanProvider initiates a new DigitalOceanProvider
19+func NewDigitalOceanProvider(p *ProviderData) *DigitalOceanProvider {
20+ p.ProviderName = "DigitalOcean"
21+ if p.LoginURL.String() == "" {
22+ p.LoginURL = &url.URL{Scheme: "https",
23+ Host: "cloud.digitalocean.com",
24+ Path: "/v1/oauth/authorize",
25+ }
26+ }
27+ if p.RedeemURL.String() == "" {
28+ p.RedeemURL = &url.URL{Scheme: "https",
29+ Host: "cloud.digitalocean.com",
30+ Path: "/v1/oauth/token",
31+ }
32+ }
33+ if p.ProfileURL.String() == "" {
34+ p.ProfileURL = &url.URL{Scheme: "https",
35+ Host: "api.digitalocean.com",
36+ Path: "/v2/account",
37+ }
38+ }
39+ if p.ValidateURL.String() == "" {
40+ p.ValidateURL = p.ProfileURL
41+ }
42+ if p.Scope == "" {
43+ p.Scope = "read"
44+ }
45+ return &DigitalOceanProvider{ProviderData: p}
46+}
47+
48+func getDigitalOceanHeader(accessToken string) http.Header {
49+ header := make(http.Header)
50+ header.Set("Content-Type", "application/json")
51+ header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
52+ return header
53+}
54+
55+// GetEmailAddress returns the Account email address
56+func (p *DigitalOceanProvider) GetEmailAddress(s *sessions.SessionState) (string, error) {
57+ if s.AccessToken == "" {
58+ return "", errors.New("missing access token")
59+ }
60+ req, err := http.NewRequest("GET", p.ProfileURL.String(), nil)
61+ if err != nil {
62+ return "", err
63+ }
64+ req.Header = getDigitalOceanHeader(s.AccessToken)
65+
66+ json, err := requests.Request(req)
67+ if err != nil {
68+ return "", err
69+ }
70+
71+ email, err := json.GetPath("account", "email").String()
72+ if err != nil {
73+ return "", err
74+ }
75+ return email, nil
76+}
77+
78+// ValidateSessionState validates the AccessToken
79+func (p *DigitalOceanProvider) ValidateSessionState(s *sessions.SessionState) bool {
80+ return validateToken(p, s.AccessToken, getDigitalOceanHeader(s.AccessToken))
81+}
providers/digitalocean_test.gonew file+134
@@ -0,0 +1,134 @@
1+package providers
2+
3+import (
4+ "net/http"
5+ "net/http/httptest"
6+ "net/url"
7+ "testing"
8+
9+ "github.com/pusher/oauth2_proxy/pkg/apis/sessions"
10+ "github.com/stretchr/testify/assert"
11+)
12+
13+func testDigitalOceanProvider(hostname string) *DigitalOceanProvider {
14+ p := NewDigitalOceanProvider(
15+ &ProviderData{
16+ ProviderName: "",
17+ LoginURL: &url.URL{},
18+ RedeemURL: &url.URL{},
19+ ProfileURL: &url.URL{},
20+ ValidateURL: &url.URL{},
21+ Scope: ""})
22+ if hostname != "" {
23+ updateURL(p.Data().LoginURL, hostname)
24+ updateURL(p.Data().RedeemURL, hostname)
25+ updateURL(p.Data().ProfileURL, hostname)
26+ }
27+ return p
28+}
29+
30+func testDigitalOceanBackend(payload string) *httptest.Server {
31+ path := "/v2/account"
32+
33+ return httptest.NewServer(http.HandlerFunc(
34+ func(w http.ResponseWriter, r *http.Request) {
35+ if r.URL.Path != path {
36+ w.WriteHeader(404)
37+ } else if r.Header.Get("Authorization") != "Bearer imaginary_access_token" {
38+ w.WriteHeader(403)
39+ } else {
40+ w.WriteHeader(200)
41+ w.Write([]byte(payload))
42+ }
43+ }))
44+}
45+
46+func TestDigitalOceanProviderDefaults(t *testing.T) {
47+ p := testDigitalOceanProvider("")
48+ assert.NotEqual(t, nil, p)
49+ assert.Equal(t, "DigitalOcean", p.Data().ProviderName)
50+ assert.Equal(t, "https://cloud.digitalocean.com/v1/oauth/authorize",
51+ p.Data().LoginURL.String())
52+ assert.Equal(t, "https://cloud.digitalocean.com/v1/oauth/token",
53+ p.Data().RedeemURL.String())
54+ assert.Equal(t, "https://api.digitalocean.com/v2/account",
55+ p.Data().ProfileURL.String())
56+ assert.Equal(t, "https://api.digitalocean.com/v2/account",
57+ p.Data().ValidateURL.String())
58+ assert.Equal(t, "read", p.Data().Scope)
59+}
60+
61+func TestDigitalOceanProviderOverrides(t *testing.T) {
62+ p := NewDigitalOceanProvider(
63+ &ProviderData{
64+ LoginURL: &url.URL{
65+ Scheme: "https",
66+ Host: "example.com",
67+ Path: "/oauth/auth"},
68+ RedeemURL: &url.URL{
69+ Scheme: "https",
70+ Host: "example.com",
71+ Path: "/oauth/token"},
72+ ProfileURL: &url.URL{
73+ Scheme: "https",
74+ Host: "example.com",
75+ Path: "/oauth/profile"},
76+ ValidateURL: &url.URL{
77+ Scheme: "https",
78+ Host: "example.com",
79+ Path: "/oauth/tokeninfo"},
80+ Scope: "profile"})
81+ assert.NotEqual(t, nil, p)
82+ assert.Equal(t, "DigitalOcean", p.Data().ProviderName)
83+ assert.Equal(t, "https://example.com/oauth/auth",
84+ p.Data().LoginURL.String())
85+ assert.Equal(t, "https://example.com/oauth/token",
86+ p.Data().RedeemURL.String())
87+ assert.Equal(t, "https://example.com/oauth/profile",
88+ p.Data().ProfileURL.String())
89+ assert.Equal(t, "https://example.com/oauth/tokeninfo",
90+ p.Data().ValidateURL.String())
91+ assert.Equal(t, "profile", p.Data().Scope)
92+}
93+
94+func TestDigitalOceanProviderGetEmailAddress(t *testing.T) {
95+ b := testDigitalOceanBackend(`{"account": {"email": "user@example.com"}}`)
96+ defer b.Close()
97+
98+ bURL, _ := url.Parse(b.URL)
99+ p := testDigitalOceanProvider(bURL.Host)
100+
101+ session := &sessions.SessionState{AccessToken: "imaginary_access_token"}
102+ email, err := p.GetEmailAddress(session)
103+ assert.Equal(t, nil, err)
104+ assert.Equal(t, "user@example.com", email)
105+}
106+
107+func TestDigitalOceanProviderGetEmailAddressFailedRequest(t *testing.T) {
108+ b := testDigitalOceanBackend("unused payload")
109+ defer b.Close()
110+
111+ bURL, _ := url.Parse(b.URL)
112+ p := testDigitalOceanProvider(bURL.Host)
113+
114+ // We'll trigger a request failure by using an unexpected access
115+ // token. Alternatively, we could allow the parsing of the payload as
116+ // JSON to fail.
117+ session := &sessions.SessionState{AccessToken: "unexpected_access_token"}
118+ email, err := p.GetEmailAddress(session)
119+ assert.NotEqual(t, nil, err)
120+ assert.Equal(t, "", email)
121+}
122+
123+func TestDigitalOceanProviderGetEmailAddressEmailNotPresentInPayload(t *testing.T) {
124+ b := testDigitalOceanBackend("{\"foo\": \"bar\"}")
125+ defer b.Close()
126+
127+ bURL, _ := url.Parse(b.URL)
128+ p := testDigitalOceanProvider(bURL.Host)
129+
130+ session := &sessions.SessionState{AccessToken: "imaginary_access_token"}
131+ email, err := p.GetEmailAddress(session)
132+ assert.NotEqual(t, nil, err)
133+ assert.Equal(t, "", email)
134+}
providers/providers.go+2
@@ -42,6 +42,8 @@ func New(provider string, p *ProviderData) Provider {
4242 return NewBitbucketProvider(p)
4343 case "nextcloud":
4444 return NewNextcloudProvider(p)
45+ case "digitalocean":
46+ return NewDigitalOceanProvider(p)
4547 default:
4648 return NewGoogleProvider(p)
4749 }