16306: Merge branch 'master'
[arvados.git] / lib / controller / localdb / login_oidc_test.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package localdb
6
7 import (
8         "bytes"
9         "context"
10         "encoding/json"
11         "fmt"
12         "net/http"
13         "net/http/httptest"
14         "net/url"
15         "sort"
16         "strings"
17         "testing"
18         "time"
19
20         "git.arvados.org/arvados.git/lib/config"
21         "git.arvados.org/arvados.git/lib/controller/rpc"
22         "git.arvados.org/arvados.git/sdk/go/arvados"
23         "git.arvados.org/arvados.git/sdk/go/arvadostest"
24         "git.arvados.org/arvados.git/sdk/go/auth"
25         "git.arvados.org/arvados.git/sdk/go/ctxlog"
26         check "gopkg.in/check.v1"
27 )
28
29 // Gocheck boilerplate
30 func Test(t *testing.T) {
31         check.TestingT(t)
32 }
33
34 var _ = check.Suite(&OIDCLoginSuite{})
35
36 type OIDCLoginSuite struct {
37         cluster      *arvados.Cluster
38         localdb      *Conn
39         railsSpy     *arvadostest.Proxy
40         fakeProvider *arvadostest.OIDCProvider
41 }
42
43 func (s *OIDCLoginSuite) TearDownSuite(c *check.C) {
44         // Undo any changes/additions to the user database so they
45         // don't affect subsequent tests.
46         arvadostest.ResetEnv()
47         c.Check(arvados.NewClientFromEnv().RequestAndDecode(nil, "POST", "database/reset", nil, nil), check.IsNil)
48 }
49
50 func (s *OIDCLoginSuite) SetUpTest(c *check.C) {
51         s.fakeProvider = arvadostest.NewOIDCProvider(c)
52         s.fakeProvider.AuthEmail = "active-user@arvados.local"
53         s.fakeProvider.AuthEmailVerified = true
54         s.fakeProvider.AuthName = "Fake User Name"
55         s.fakeProvider.ValidCode = fmt.Sprintf("abcdefgh-%d", time.Now().Unix())
56         s.fakeProvider.PeopleAPIResponse = map[string]interface{}{}
57
58         cfg, err := config.NewLoader(nil, ctxlog.TestLogger(c)).Load()
59         c.Assert(err, check.IsNil)
60         s.cluster, err = cfg.GetCluster("")
61         c.Assert(err, check.IsNil)
62         s.cluster.Login.SSO.Enable = false
63         s.cluster.Login.Google.Enable = true
64         s.cluster.Login.Google.ClientID = "test%client$id"
65         s.cluster.Login.Google.ClientSecret = "test#client/secret"
66         s.cluster.Users.PreferDomainForUsername = "PreferDomainForUsername.example.com"
67         s.fakeProvider.ValidClientID = "test%client$id"
68         s.fakeProvider.ValidClientSecret = "test#client/secret"
69
70         s.localdb = NewConn(s.cluster)
71         c.Assert(s.localdb.loginController, check.FitsTypeOf, (*oidcLoginController)(nil))
72         s.localdb.loginController.(*oidcLoginController).Issuer = s.fakeProvider.Issuer.URL
73         s.localdb.loginController.(*oidcLoginController).peopleAPIBasePath = s.fakeProvider.PeopleAPI.URL
74
75         s.railsSpy = arvadostest.NewProxy(c, s.cluster.Services.RailsAPI)
76         *s.localdb.railsProxy = *rpc.NewConn(s.cluster.ClusterID, s.railsSpy.URL, true, rpc.PassthroughTokenProvider)
77 }
78
79 func (s *OIDCLoginSuite) TearDownTest(c *check.C) {
80         s.railsSpy.Close()
81 }
82
83 func (s *OIDCLoginSuite) TestGoogleLogout(c *check.C) {
84         resp, err := s.localdb.Logout(context.Background(), arvados.LogoutOptions{ReturnTo: "https://foo.example.com/bar"})
85         c.Check(err, check.IsNil)
86         c.Check(resp.RedirectLocation, check.Equals, "https://foo.example.com/bar")
87 }
88
89 func (s *OIDCLoginSuite) TestGoogleLogin_Start_Bogus(c *check.C) {
90         resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{})
91         c.Check(err, check.IsNil)
92         c.Check(resp.RedirectLocation, check.Equals, "")
93         c.Check(resp.HTML.String(), check.Matches, `.*missing return_to parameter.*`)
94 }
95
96 func (s *OIDCLoginSuite) TestGoogleLogin_Start(c *check.C) {
97         for _, remote := range []string{"", "zzzzz"} {
98                 resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{Remote: remote, ReturnTo: "https://app.example.com/foo?bar"})
99                 c.Check(err, check.IsNil)
100                 target, err := url.Parse(resp.RedirectLocation)
101                 c.Check(err, check.IsNil)
102                 issuerURL, _ := url.Parse(s.fakeProvider.Issuer.URL)
103                 c.Check(target.Host, check.Equals, issuerURL.Host)
104                 q := target.Query()
105                 c.Check(q.Get("client_id"), check.Equals, "test%client$id")
106                 state := s.localdb.loginController.(*oidcLoginController).parseOAuth2State(q.Get("state"))
107                 c.Check(state.verify([]byte(s.cluster.SystemRootToken)), check.Equals, true)
108                 c.Check(state.Time, check.Not(check.Equals), 0)
109                 c.Check(state.Remote, check.Equals, remote)
110                 c.Check(state.ReturnTo, check.Equals, "https://app.example.com/foo?bar")
111         }
112 }
113
114 func (s *OIDCLoginSuite) TestGoogleLogin_InvalidCode(c *check.C) {
115         state := s.startLogin(c)
116         resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
117                 Code:  "first-try-a-bogus-code",
118                 State: state,
119         })
120         c.Check(err, check.IsNil)
121         c.Check(resp.RedirectLocation, check.Equals, "")
122         c.Check(resp.HTML.String(), check.Matches, `(?ms).*error in OAuth2 exchange.*cannot fetch token.*`)
123 }
124
125 func (s *OIDCLoginSuite) TestGoogleLogin_InvalidState(c *check.C) {
126         s.startLogin(c)
127         resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
128                 Code:  s.fakeProvider.ValidCode,
129                 State: "bogus-state",
130         })
131         c.Check(err, check.IsNil)
132         c.Check(resp.RedirectLocation, check.Equals, "")
133         c.Check(resp.HTML.String(), check.Matches, `(?ms).*invalid OAuth2 state.*`)
134 }
135
136 func (s *OIDCLoginSuite) setupPeopleAPIError(c *check.C) {
137         s.fakeProvider.PeopleAPI = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
138                 w.WriteHeader(http.StatusForbidden)
139                 fmt.Fprintln(w, `Error 403: accessNotConfigured`)
140         }))
141         s.localdb.loginController.(*oidcLoginController).peopleAPIBasePath = s.fakeProvider.PeopleAPI.URL
142 }
143
144 func (s *OIDCLoginSuite) TestGoogleLogin_PeopleAPIDisabled(c *check.C) {
145         s.localdb.loginController.(*oidcLoginController).UseGooglePeopleAPI = false
146         s.fakeProvider.AuthEmail = "joe.smith@primary.example.com"
147         s.setupPeopleAPIError(c)
148         state := s.startLogin(c)
149         _, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
150                 Code:  s.fakeProvider.ValidCode,
151                 State: state,
152         })
153         c.Check(err, check.IsNil)
154         authinfo := getCallbackAuthInfo(c, s.railsSpy)
155         c.Check(authinfo.Email, check.Equals, "joe.smith@primary.example.com")
156 }
157
158 func (s *OIDCLoginSuite) TestConfig(c *check.C) {
159         s.cluster.Login.Google.Enable = false
160         s.cluster.Login.OpenIDConnect.Enable = true
161         s.cluster.Login.OpenIDConnect.Issuer = "https://accounts.example.com/"
162         s.cluster.Login.OpenIDConnect.ClientID = "oidc-client-id"
163         s.cluster.Login.OpenIDConnect.ClientSecret = "oidc-client-secret"
164         localdb := NewConn(s.cluster)
165         ctrl := localdb.loginController.(*oidcLoginController)
166         c.Check(ctrl.Issuer, check.Equals, "https://accounts.example.com/")
167         c.Check(ctrl.ClientID, check.Equals, "oidc-client-id")
168         c.Check(ctrl.ClientSecret, check.Equals, "oidc-client-secret")
169         c.Check(ctrl.UseGooglePeopleAPI, check.Equals, false)
170
171         for _, enableAltEmails := range []bool{false, true} {
172                 s.cluster.Login.OpenIDConnect.Enable = false
173                 s.cluster.Login.Google.Enable = true
174                 s.cluster.Login.Google.ClientID = "google-client-id"
175                 s.cluster.Login.Google.ClientSecret = "google-client-secret"
176                 s.cluster.Login.Google.AlternateEmailAddresses = enableAltEmails
177                 localdb = NewConn(s.cluster)
178                 ctrl = localdb.loginController.(*oidcLoginController)
179                 c.Check(ctrl.Issuer, check.Equals, "https://accounts.google.com")
180                 c.Check(ctrl.ClientID, check.Equals, "google-client-id")
181                 c.Check(ctrl.ClientSecret, check.Equals, "google-client-secret")
182                 c.Check(ctrl.UseGooglePeopleAPI, check.Equals, enableAltEmails)
183         }
184 }
185
186 func (s *OIDCLoginSuite) TestGoogleLogin_PeopleAPIError(c *check.C) {
187         s.setupPeopleAPIError(c)
188         state := s.startLogin(c)
189         resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
190                 Code:  s.fakeProvider.ValidCode,
191                 State: state,
192         })
193         c.Check(err, check.IsNil)
194         c.Check(resp.RedirectLocation, check.Equals, "")
195 }
196
197 func (s *OIDCLoginSuite) TestGenericOIDCLogin(c *check.C) {
198         s.cluster.Login.Google.Enable = false
199         s.cluster.Login.OpenIDConnect.Enable = true
200         json.Unmarshal([]byte(fmt.Sprintf("%q", s.fakeProvider.Issuer.URL)), &s.cluster.Login.OpenIDConnect.Issuer)
201         s.cluster.Login.OpenIDConnect.ClientID = "oidc#client#id"
202         s.cluster.Login.OpenIDConnect.ClientSecret = "oidc#client#secret"
203         s.fakeProvider.ValidClientID = "oidc#client#id"
204         s.fakeProvider.ValidClientSecret = "oidc#client#secret"
205         for _, trial := range []struct {
206                 expectEmail string // "" if failure expected
207                 setup       func()
208         }{
209                 {
210                         expectEmail: "user@oidc.example.com",
211                         setup: func() {
212                                 c.Log("=== succeed because email_verified is false but not required")
213                                 s.fakeProvider.AuthEmail = "user@oidc.example.com"
214                                 s.fakeProvider.AuthEmailVerified = false
215                                 s.cluster.Login.OpenIDConnect.EmailClaim = "email"
216                                 s.cluster.Login.OpenIDConnect.EmailVerifiedClaim = ""
217                                 s.cluster.Login.OpenIDConnect.UsernameClaim = ""
218                         },
219                 },
220                 {
221                         expectEmail: "",
222                         setup: func() {
223                                 c.Log("=== fail because email_verified is false and required")
224                                 s.fakeProvider.AuthEmail = "user@oidc.example.com"
225                                 s.fakeProvider.AuthEmailVerified = false
226                                 s.cluster.Login.OpenIDConnect.EmailClaim = "email"
227                                 s.cluster.Login.OpenIDConnect.EmailVerifiedClaim = "email_verified"
228                                 s.cluster.Login.OpenIDConnect.UsernameClaim = ""
229                         },
230                 },
231                 {
232                         expectEmail: "user@oidc.example.com",
233                         setup: func() {
234                                 c.Log("=== succeed because email_verified is false but config uses custom 'verified' claim")
235                                 s.fakeProvider.AuthEmail = "user@oidc.example.com"
236                                 s.fakeProvider.AuthEmailVerified = false
237                                 s.cluster.Login.OpenIDConnect.EmailClaim = "email"
238                                 s.cluster.Login.OpenIDConnect.EmailVerifiedClaim = "alt_verified"
239                                 s.cluster.Login.OpenIDConnect.UsernameClaim = ""
240                         },
241                 },
242                 {
243                         expectEmail: "alt_email@example.com",
244                         setup: func() {
245                                 c.Log("=== succeed with custom 'email' and 'email_verified' claims")
246                                 s.fakeProvider.AuthEmail = "bad@wrong.example.com"
247                                 s.fakeProvider.AuthEmailVerified = false
248                                 s.cluster.Login.OpenIDConnect.EmailClaim = "alt_email"
249                                 s.cluster.Login.OpenIDConnect.EmailVerifiedClaim = "alt_verified"
250                                 s.cluster.Login.OpenIDConnect.UsernameClaim = "alt_username"
251                         },
252                 },
253         } {
254                 trial.setup()
255                 if s.railsSpy != nil {
256                         s.railsSpy.Close()
257                 }
258                 s.railsSpy = arvadostest.NewProxy(c, s.cluster.Services.RailsAPI)
259                 s.localdb = NewConn(s.cluster)
260                 *s.localdb.railsProxy = *rpc.NewConn(s.cluster.ClusterID, s.railsSpy.URL, true, rpc.PassthroughTokenProvider)
261
262                 state := s.startLogin(c)
263                 resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
264                         Code:  s.fakeProvider.ValidCode,
265                         State: state,
266                 })
267                 c.Assert(err, check.IsNil)
268                 if trial.expectEmail == "" {
269                         c.Check(resp.HTML.String(), check.Matches, `(?ms).*Login error.*`)
270                         c.Check(resp.RedirectLocation, check.Equals, "")
271                         continue
272                 }
273                 c.Check(resp.HTML.String(), check.Equals, "")
274                 target, err := url.Parse(resp.RedirectLocation)
275                 c.Assert(err, check.IsNil)
276                 token := target.Query().Get("api_token")
277                 c.Check(token, check.Matches, `v2/zzzzz-gj3su-.{15}/.{32,50}`)
278                 authinfo := getCallbackAuthInfo(c, s.railsSpy)
279                 c.Check(authinfo.Email, check.Equals, trial.expectEmail)
280
281                 switch s.cluster.Login.OpenIDConnect.UsernameClaim {
282                 case "alt_username":
283                         c.Check(authinfo.Username, check.Equals, "desired-username")
284                 case "":
285                         c.Check(authinfo.Username, check.Equals, "")
286                 default:
287                         c.Fail() // bad test case
288                 }
289         }
290 }
291
292 func (s *OIDCLoginSuite) TestGoogleLogin_Success(c *check.C) {
293         state := s.startLogin(c)
294         resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
295                 Code:  s.fakeProvider.ValidCode,
296                 State: state,
297         })
298         c.Check(err, check.IsNil)
299         c.Check(resp.HTML.String(), check.Equals, "")
300         target, err := url.Parse(resp.RedirectLocation)
301         c.Check(err, check.IsNil)
302         c.Check(target.Host, check.Equals, "app.example.com")
303         c.Check(target.Path, check.Equals, "/foo")
304         token := target.Query().Get("api_token")
305         c.Check(token, check.Matches, `v2/zzzzz-gj3su-.{15}/.{32,50}`)
306
307         authinfo := getCallbackAuthInfo(c, s.railsSpy)
308         c.Check(authinfo.FirstName, check.Equals, "Fake User")
309         c.Check(authinfo.LastName, check.Equals, "Name")
310         c.Check(authinfo.Email, check.Equals, "active-user@arvados.local")
311         c.Check(authinfo.AlternateEmails, check.HasLen, 0)
312
313         // Try using the returned Arvados token.
314         c.Logf("trying an API call with new token %q", token)
315         ctx := auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{token}})
316         cl, err := s.localdb.CollectionList(ctx, arvados.ListOptions{Limit: -1})
317         c.Check(cl.ItemsAvailable, check.Not(check.Equals), 0)
318         c.Check(cl.Items, check.Not(check.HasLen), 0)
319         c.Check(err, check.IsNil)
320
321         // Might as well check that bogus tokens aren't accepted.
322         badtoken := token + "plussomeboguschars"
323         c.Logf("trying an API call with mangled token %q", badtoken)
324         ctx = auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{badtoken}})
325         cl, err = s.localdb.CollectionList(ctx, arvados.ListOptions{Limit: -1})
326         c.Check(cl.Items, check.HasLen, 0)
327         c.Check(err, check.NotNil)
328         c.Check(err, check.ErrorMatches, `.*401 Unauthorized: Not logged in.*`)
329 }
330
331 func (s *OIDCLoginSuite) TestGoogleLogin_RealName(c *check.C) {
332         s.fakeProvider.AuthEmail = "joe.smith@primary.example.com"
333         s.fakeProvider.PeopleAPIResponse = map[string]interface{}{
334                 "names": []map[string]interface{}{
335                         {
336                                 "metadata":   map[string]interface{}{"primary": false},
337                                 "givenName":  "Joe",
338                                 "familyName": "Smith",
339                         },
340                         {
341                                 "metadata":   map[string]interface{}{"primary": true},
342                                 "givenName":  "Joseph",
343                                 "familyName": "Psmith",
344                         },
345                 },
346         }
347         state := s.startLogin(c)
348         s.localdb.Login(context.Background(), arvados.LoginOptions{
349                 Code:  s.fakeProvider.ValidCode,
350                 State: state,
351         })
352
353         authinfo := getCallbackAuthInfo(c, s.railsSpy)
354         c.Check(authinfo.FirstName, check.Equals, "Joseph")
355         c.Check(authinfo.LastName, check.Equals, "Psmith")
356 }
357
358 func (s *OIDCLoginSuite) TestGoogleLogin_OIDCRealName(c *check.C) {
359         s.fakeProvider.AuthName = "Joe P. Smith"
360         s.fakeProvider.AuthEmail = "joe.smith@primary.example.com"
361         state := s.startLogin(c)
362         s.localdb.Login(context.Background(), arvados.LoginOptions{
363                 Code:  s.fakeProvider.ValidCode,
364                 State: state,
365         })
366
367         authinfo := getCallbackAuthInfo(c, s.railsSpy)
368         c.Check(authinfo.FirstName, check.Equals, "Joe P.")
369         c.Check(authinfo.LastName, check.Equals, "Smith")
370 }
371
372 // People API returns some additional email addresses.
373 func (s *OIDCLoginSuite) TestGoogleLogin_AlternateEmailAddresses(c *check.C) {
374         s.fakeProvider.AuthEmail = "joe.smith@primary.example.com"
375         s.fakeProvider.PeopleAPIResponse = map[string]interface{}{
376                 "emailAddresses": []map[string]interface{}{
377                         {
378                                 "metadata": map[string]interface{}{"verified": true},
379                                 "value":    "joe.smith@work.example.com",
380                         },
381                         {
382                                 "value": "joe.smith@unverified.example.com", // unverified, so this one will be ignored
383                         },
384                         {
385                                 "metadata": map[string]interface{}{"verified": true},
386                                 "value":    "joe.smith@home.example.com",
387                         },
388                 },
389         }
390         state := s.startLogin(c)
391         s.localdb.Login(context.Background(), arvados.LoginOptions{
392                 Code:  s.fakeProvider.ValidCode,
393                 State: state,
394         })
395
396         authinfo := getCallbackAuthInfo(c, s.railsSpy)
397         c.Check(authinfo.Email, check.Equals, "joe.smith@primary.example.com")
398         c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith@home.example.com", "joe.smith@work.example.com"})
399 }
400
401 // Primary address is not the one initially returned by oidc.
402 func (s *OIDCLoginSuite) TestGoogleLogin_AlternateEmailAddresses_Primary(c *check.C) {
403         s.fakeProvider.AuthEmail = "joe.smith@alternate.example.com"
404         s.fakeProvider.PeopleAPIResponse = map[string]interface{}{
405                 "emailAddresses": []map[string]interface{}{
406                         {
407                                 "metadata": map[string]interface{}{"verified": true, "primary": true},
408                                 "value":    "joe.smith@primary.example.com",
409                         },
410                         {
411                                 "metadata": map[string]interface{}{"verified": true},
412                                 "value":    "joe.smith@alternate.example.com",
413                         },
414                         {
415                                 "metadata": map[string]interface{}{"verified": true},
416                                 "value":    "jsmith+123@preferdomainforusername.example.com",
417                         },
418                 },
419         }
420         state := s.startLogin(c)
421         s.localdb.Login(context.Background(), arvados.LoginOptions{
422                 Code:  s.fakeProvider.ValidCode,
423                 State: state,
424         })
425         authinfo := getCallbackAuthInfo(c, s.railsSpy)
426         c.Check(authinfo.Email, check.Equals, "joe.smith@primary.example.com")
427         c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith@alternate.example.com", "jsmith+123@preferdomainforusername.example.com"})
428         c.Check(authinfo.Username, check.Equals, "jsmith")
429 }
430
431 func (s *OIDCLoginSuite) TestGoogleLogin_NoPrimaryEmailAddress(c *check.C) {
432         s.fakeProvider.AuthEmail = "joe.smith@unverified.example.com"
433         s.fakeProvider.AuthEmailVerified = false
434         s.fakeProvider.PeopleAPIResponse = map[string]interface{}{
435                 "emailAddresses": []map[string]interface{}{
436                         {
437                                 "metadata": map[string]interface{}{"verified": true},
438                                 "value":    "joe.smith@work.example.com",
439                         },
440                         {
441                                 "metadata": map[string]interface{}{"verified": true},
442                                 "value":    "joe.smith@home.example.com",
443                         },
444                 },
445         }
446         state := s.startLogin(c)
447         s.localdb.Login(context.Background(), arvados.LoginOptions{
448                 Code:  s.fakeProvider.ValidCode,
449                 State: state,
450         })
451
452         authinfo := getCallbackAuthInfo(c, s.railsSpy)
453         c.Check(authinfo.Email, check.Equals, "joe.smith@work.example.com") // first verified email in People response
454         c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith@home.example.com"})
455         c.Check(authinfo.Username, check.Equals, "")
456 }
457
458 func (s *OIDCLoginSuite) startLogin(c *check.C) (state string) {
459         // Initiate login, but instead of following the redirect to
460         // the provider, just grab state from the redirect URL.
461         resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{ReturnTo: "https://app.example.com/foo?bar"})
462         c.Check(err, check.IsNil)
463         target, err := url.Parse(resp.RedirectLocation)
464         c.Check(err, check.IsNil)
465         state = target.Query().Get("state")
466         c.Check(state, check.Not(check.Equals), "")
467         return
468 }
469
470 func getCallbackAuthInfo(c *check.C, railsSpy *arvadostest.Proxy) (authinfo rpc.UserSessionAuthInfo) {
471         for _, dump := range railsSpy.RequestDumps {
472                 c.Logf("spied request: %q", dump)
473                 split := bytes.Split(dump, []byte("\r\n\r\n"))
474                 c.Assert(split, check.HasLen, 2)
475                 hdr, body := string(split[0]), string(split[1])
476                 if strings.Contains(hdr, "POST /auth/controller/callback") {
477                         vs, err := url.ParseQuery(body)
478                         c.Check(json.Unmarshal([]byte(vs.Get("auth_info")), &authinfo), check.IsNil)
479                         c.Check(err, check.IsNil)
480                         sort.Strings(authinfo.AlternateEmails)
481                         return
482                 }
483         }
484         c.Error("callback not found")
485         return
486 }