Merge branch '20300-rails7'
[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         "crypto/hmac"
11         "crypto/sha256"
12         "encoding/json"
13         "fmt"
14         "io"
15         "net/http"
16         "net/http/httptest"
17         "net/url"
18         "sort"
19         "strings"
20         "sync"
21         "testing"
22         "time"
23
24         "git.arvados.org/arvados.git/lib/controller/rpc"
25         "git.arvados.org/arvados.git/lib/ctrlctx"
26         "git.arvados.org/arvados.git/sdk/go/arvados"
27         "git.arvados.org/arvados.git/sdk/go/arvadostest"
28         "git.arvados.org/arvados.git/sdk/go/auth"
29         "github.com/jmoiron/sqlx"
30         check "gopkg.in/check.v1"
31 )
32
33 // Gocheck boilerplate
34 func Test(t *testing.T) {
35         check.TestingT(t)
36 }
37
38 var _ = check.Suite(&OIDCLoginSuite{})
39
40 type OIDCLoginSuite struct {
41         localdbSuite
42         trustedURL   *arvados.URL
43         fakeProvider *arvadostest.OIDCProvider
44 }
45
46 func (s *OIDCLoginSuite) SetUpTest(c *check.C) {
47         s.trustedURL = &arvados.URL{Scheme: "https", Host: "app.example.com:443", Path: "/"}
48
49         s.fakeProvider = arvadostest.NewOIDCProvider(c)
50         s.fakeProvider.AuthEmail = "active-user@arvados.local"
51         s.fakeProvider.AuthEmailVerified = true
52         s.fakeProvider.AuthName = "Fake User Name"
53         s.fakeProvider.AuthGivenName = "Fake"
54         s.fakeProvider.AuthFamilyName = "User Name"
55         s.fakeProvider.ValidCode = fmt.Sprintf("abcdefgh-%d", time.Now().Unix())
56         s.fakeProvider.PeopleAPIResponse = map[string]interface{}{}
57
58         s.localdbSuite.SetUpTest(c)
59
60         s.cluster.Login.Test.Enable = false
61         s.cluster.Login.Google.Enable = true
62         s.cluster.Login.Google.ClientID = "test%client$id"
63         s.cluster.Login.Google.ClientSecret = "test#client/secret"
64         s.cluster.Login.TrustedClients = map[arvados.URL]struct{}{*s.trustedURL: {}}
65         s.cluster.Users.PreferDomainForUsername = "PreferDomainForUsername.example.com"
66         s.fakeProvider.ValidClientID = "test%client$id"
67         s.fakeProvider.ValidClientSecret = "test#client/secret"
68
69         s.localdb = NewConn(s.ctx, s.cluster, (&ctrlctx.DBConnector{PostgreSQL: s.cluster.PostgreSQL}).GetDB)
70         c.Assert(s.localdb.loginController, check.FitsTypeOf, (*oidcLoginController)(nil))
71         s.localdb.loginController.(*oidcLoginController).Issuer = s.fakeProvider.Issuer.URL
72         s.localdb.loginController.(*oidcLoginController).peopleAPIBasePath = s.fakeProvider.PeopleAPI.URL
73
74         *s.localdb.railsProxy = *rpc.NewConn(s.cluster.ClusterID, s.railsSpy.URL, true, rpc.PassthroughTokenProvider)
75 }
76
77 func (s *OIDCLoginSuite) TestGoogleLogout(c *check.C) {
78         s.cluster.Login.TrustedClients[arvados.URL{Scheme: "https", Host: "foo.example", Path: "/"}] = struct{}{}
79         s.cluster.Login.TrustPrivateNetworks = false
80
81         resp, err := s.localdb.Logout(context.Background(), arvados.LogoutOptions{ReturnTo: "https://foo.example.com/bar"})
82         c.Check(err, check.NotNil)
83         c.Check(resp.RedirectLocation, check.Equals, "")
84
85         resp, err = s.localdb.Logout(context.Background(), arvados.LogoutOptions{ReturnTo: "https://127.0.0.1/bar"})
86         c.Check(err, check.NotNil)
87         c.Check(resp.RedirectLocation, check.Equals, "")
88
89         resp, err = s.localdb.Logout(context.Background(), arvados.LogoutOptions{ReturnTo: "https://foo.example/bar"})
90         c.Check(err, check.IsNil)
91         c.Check(resp.RedirectLocation, check.Equals, "https://foo.example/bar")
92
93         s.cluster.Login.TrustPrivateNetworks = true
94
95         resp, err = s.localdb.Logout(context.Background(), arvados.LogoutOptions{ReturnTo: "https://192.168.1.1/bar"})
96         c.Check(err, check.IsNil)
97         c.Check(resp.RedirectLocation, check.Equals, "https://192.168.1.1/bar")
98 }
99
100 func (s *OIDCLoginSuite) TestGoogleLogin_Start_Bogus(c *check.C) {
101         resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{})
102         c.Check(err, check.IsNil)
103         c.Check(resp.RedirectLocation, check.Equals, "")
104         c.Check(resp.HTML.String(), check.Matches, `.*missing return_to parameter.*`)
105 }
106
107 func (s *OIDCLoginSuite) TestGoogleLogin_Start(c *check.C) {
108         for _, remote := range []string{"", "zzzzz"} {
109                 resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{Remote: remote, ReturnTo: "https://app.example.com/foo?bar"})
110                 c.Check(err, check.IsNil)
111                 target, err := url.Parse(resp.RedirectLocation)
112                 c.Check(err, check.IsNil)
113                 issuerURL, _ := url.Parse(s.fakeProvider.Issuer.URL)
114                 c.Check(target.Host, check.Equals, issuerURL.Host)
115                 q := target.Query()
116                 c.Check(q.Get("client_id"), check.Equals, "test%client$id")
117                 state := s.localdb.loginController.(*oidcLoginController).parseOAuth2State(q.Get("state"))
118                 c.Check(state.verify([]byte(s.cluster.SystemRootToken)), check.Equals, true)
119                 c.Check(state.Time, check.Not(check.Equals), 0)
120                 c.Check(state.Remote, check.Equals, remote)
121                 c.Check(state.ReturnTo, check.Equals, "https://app.example.com/foo?bar")
122         }
123 }
124
125 func (s *OIDCLoginSuite) TestGoogleLogin_UnknownClient(c *check.C) {
126         resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{ReturnTo: "https://bad-app.example.com/foo?bar"})
127         c.Check(err, check.IsNil)
128         c.Check(resp.RedirectLocation, check.Equals, "")
129         c.Check(resp.HTML.String(), check.Matches, `(?ms).*requesting site is not listed in TrustedClients.*`)
130 }
131
132 func (s *OIDCLoginSuite) TestGoogleLogin_InvalidCode(c *check.C) {
133         state := s.startLogin(c)
134         resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
135                 Code:  "first-try-a-bogus-code",
136                 State: state,
137         })
138         c.Check(err, check.IsNil)
139         c.Check(resp.RedirectLocation, check.Equals, "")
140         c.Check(resp.HTML.String(), check.Matches, `(?ms).*error in OAuth2 exchange.*cannot fetch token.*`)
141 }
142
143 func (s *OIDCLoginSuite) TestGoogleLogin_InvalidState(c *check.C) {
144         s.startLogin(c)
145         resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
146                 Code:  s.fakeProvider.ValidCode,
147                 State: "bogus-state",
148         })
149         c.Check(err, check.IsNil)
150         c.Check(resp.RedirectLocation, check.Equals, "")
151         c.Check(resp.HTML.String(), check.Matches, `(?ms).*invalid OAuth2 state.*`)
152 }
153
154 func (s *OIDCLoginSuite) setupPeopleAPIError(c *check.C) {
155         s.fakeProvider.PeopleAPI = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
156                 w.WriteHeader(http.StatusForbidden)
157                 fmt.Fprintln(w, `Error 403: accessNotConfigured`)
158         }))
159         s.localdb.loginController.(*oidcLoginController).peopleAPIBasePath = s.fakeProvider.PeopleAPI.URL
160 }
161
162 func (s *OIDCLoginSuite) TestGoogleLogin_PeopleAPIDisabled(c *check.C) {
163         s.localdb.loginController.(*oidcLoginController).UseGooglePeopleAPI = false
164         s.fakeProvider.AuthEmail = "joe.smith@primary.example.com"
165         s.setupPeopleAPIError(c)
166         state := s.startLogin(c)
167         _, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
168                 Code:  s.fakeProvider.ValidCode,
169                 State: state,
170         })
171         c.Check(err, check.IsNil)
172         authinfo := getCallbackAuthInfo(c, s.railsSpy)
173         c.Check(authinfo.Email, check.Equals, "joe.smith@primary.example.com")
174 }
175
176 func (s *OIDCLoginSuite) TestConfig(c *check.C) {
177         s.cluster.Login.Google.Enable = false
178         s.cluster.Login.OpenIDConnect.Enable = true
179         s.cluster.Login.OpenIDConnect.Issuer = "https://accounts.example.com/"
180         s.cluster.Login.OpenIDConnect.ClientID = "oidc-client-id"
181         s.cluster.Login.OpenIDConnect.ClientSecret = "oidc-client-secret"
182         s.cluster.Login.OpenIDConnect.AuthenticationRequestParameters = map[string]string{"testkey": "testvalue"}
183         localdb := NewConn(context.Background(), s.cluster, (&ctrlctx.DBConnector{PostgreSQL: s.cluster.PostgreSQL}).GetDB)
184         ctrl := localdb.loginController.(*oidcLoginController)
185         c.Check(ctrl.Issuer, check.Equals, "https://accounts.example.com/")
186         c.Check(ctrl.ClientID, check.Equals, "oidc-client-id")
187         c.Check(ctrl.ClientSecret, check.Equals, "oidc-client-secret")
188         c.Check(ctrl.UseGooglePeopleAPI, check.Equals, false)
189         c.Check(ctrl.AuthParams["testkey"], check.Equals, "testvalue")
190
191         for _, enableAltEmails := range []bool{false, true} {
192                 s.cluster.Login.OpenIDConnect.Enable = false
193                 s.cluster.Login.Google.Enable = true
194                 s.cluster.Login.Google.ClientID = "google-client-id"
195                 s.cluster.Login.Google.ClientSecret = "google-client-secret"
196                 s.cluster.Login.Google.AlternateEmailAddresses = enableAltEmails
197                 s.cluster.Login.Google.AuthenticationRequestParameters = map[string]string{"testkey": "testvalue"}
198                 localdb = NewConn(context.Background(), s.cluster, (&ctrlctx.DBConnector{PostgreSQL: s.cluster.PostgreSQL}).GetDB)
199                 ctrl = localdb.loginController.(*oidcLoginController)
200                 c.Check(ctrl.Issuer, check.Equals, "https://accounts.google.com")
201                 c.Check(ctrl.ClientID, check.Equals, "google-client-id")
202                 c.Check(ctrl.ClientSecret, check.Equals, "google-client-secret")
203                 c.Check(ctrl.UseGooglePeopleAPI, check.Equals, enableAltEmails)
204                 c.Check(ctrl.AuthParams["testkey"], check.Equals, "testvalue")
205         }
206 }
207
208 func (s *OIDCLoginSuite) TestGoogleLogin_PeopleAPIError(c *check.C) {
209         s.setupPeopleAPIError(c)
210         state := s.startLogin(c)
211         resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
212                 Code:  s.fakeProvider.ValidCode,
213                 State: state,
214         })
215         c.Check(err, check.IsNil)
216         c.Check(resp.RedirectLocation, check.Equals, "")
217 }
218
219 func (s *OIDCLoginSuite) TestOIDCAuthorizer(c *check.C) {
220         s.cluster.Login.Google.Enable = false
221         s.cluster.Login.OpenIDConnect.Enable = true
222         json.Unmarshal([]byte(fmt.Sprintf("%q", s.fakeProvider.Issuer.URL)), &s.cluster.Login.OpenIDConnect.Issuer)
223         s.cluster.Login.OpenIDConnect.ClientID = "oidc#client#id"
224         s.cluster.Login.OpenIDConnect.ClientSecret = "oidc#client#secret"
225         s.cluster.Login.OpenIDConnect.AcceptAccessToken = true
226         s.cluster.Login.OpenIDConnect.AcceptAccessTokenScope = ""
227         s.fakeProvider.ValidClientID = "oidc#client#id"
228         s.fakeProvider.ValidClientSecret = "oidc#client#secret"
229         db := arvadostest.DB(c, s.cluster)
230
231         tokenCacheTTL = time.Millisecond
232         tokenCacheRaceWindow = time.Millisecond
233         tokenCacheNegativeTTL = time.Millisecond
234
235         oidcAuthorizer := OIDCAccessTokenAuthorizer(s.cluster, func(context.Context) (*sqlx.DB, error) { return db, nil })
236         accessToken := s.fakeProvider.ValidAccessToken()
237
238         mac := hmac.New(sha256.New, []byte(s.cluster.SystemRootToken))
239         io.WriteString(mac, accessToken)
240         apiToken := fmt.Sprintf("%x", mac.Sum(nil))
241
242         checkTokenInDB := func() time.Time {
243                 var exp time.Time
244                 err := db.QueryRow(`select expires_at at time zone 'UTC' from api_client_authorizations where api_token=$1`, apiToken).Scan(&exp)
245                 c.Check(err, check.IsNil)
246                 c.Check(exp.Sub(time.Now()) > -time.Second, check.Equals, true)
247                 c.Check(exp.Sub(time.Now()) < time.Second, check.Equals, true)
248                 return exp
249         }
250         cleanup := func() {
251                 oidcAuthorizer.cache.Purge()
252                 _, err := db.Exec(`delete from api_client_authorizations where api_token=$1`, apiToken)
253                 c.Check(err, check.IsNil)
254         }
255         cleanup()
256         defer cleanup()
257
258         ctx := ctrlctx.NewWithToken(s.ctx, s.cluster, accessToken)
259
260         // Check behavior on 5xx/network errors (don't cache) vs 4xx
261         // (do cache)
262         {
263                 call := oidcAuthorizer.WrapCalls(func(ctx context.Context, opts interface{}) (interface{}, error) {
264                         return nil, nil
265                 })
266
267                 // If fakeProvider UserInfo endpoint returns 502, we
268                 // should fail, return an error, and *not* cache the
269                 // negative result.
270                 tokenCacheNegativeTTL = time.Minute
271                 s.fakeProvider.UserInfoErrorStatus = 502
272                 _, err := call(ctx, nil)
273                 c.Check(err, check.NotNil)
274
275                 // The negative result was not cached, so retrying
276                 // immediately (with UserInfo working now) should
277                 // succeed.
278                 s.fakeProvider.UserInfoErrorStatus = 0
279                 _, err = call(ctx, nil)
280                 c.Check(err, check.IsNil)
281                 checkTokenInDB()
282
283                 cleanup()
284
285                 // UserInfo 401 => cache the negative result, but
286                 // don't return an error (just pass the token through
287                 // as a v1 token)
288                 s.fakeProvider.UserInfoErrorStatus = 401
289                 _, err = call(ctx, nil)
290                 c.Check(err, check.IsNil)
291                 ent, ok := oidcAuthorizer.cache.Get(accessToken)
292                 c.Check(ok, check.Equals, true)
293                 c.Check(ent, check.FitsTypeOf, time.Time{})
294
295                 // UserInfo succeeds now, but we still have a cached
296                 // negative result.
297                 s.fakeProvider.UserInfoErrorStatus = 0
298                 _, err = call(ctx, nil)
299                 c.Check(err, check.IsNil)
300                 ent, ok = oidcAuthorizer.cache.Get(accessToken)
301                 c.Check(ok, check.Equals, true)
302                 c.Check(ent, check.FitsTypeOf, time.Time{})
303
304                 tokenCacheNegativeTTL = time.Millisecond
305                 cleanup()
306         }
307
308         var exp1 time.Time
309         concurrent := 4
310         s.fakeProvider.HoldUserInfo = make(chan *http.Request)
311         s.fakeProvider.ReleaseUserInfo = make(chan struct{})
312         go func() {
313                 for i := 0; ; i++ {
314                         if i == concurrent {
315                                 close(s.fakeProvider.ReleaseUserInfo)
316                         }
317                         <-s.fakeProvider.HoldUserInfo
318                 }
319         }()
320         var wg sync.WaitGroup
321         for i := 0; i < concurrent; i++ {
322                 i := i
323                 wg.Add(1)
324                 go func() {
325                         defer wg.Done()
326                         _, err := oidcAuthorizer.WrapCalls(func(ctx context.Context, opts interface{}) (interface{}, error) {
327                                 c.Logf("concurrent req %d/%d", i, concurrent)
328
329                                 creds, ok := auth.FromContext(ctx)
330                                 c.Assert(ok, check.Equals, true)
331                                 c.Assert(creds.Tokens, check.HasLen, 1)
332                                 c.Check(creds.Tokens[0], check.Equals, accessToken)
333                                 exp := checkTokenInDB()
334                                 if i == 0 {
335                                         exp1 = exp
336                                 }
337                                 return nil, nil
338                         })(ctx, nil)
339                         c.Check(err, check.IsNil)
340                 }()
341         }
342         wg.Wait()
343         if c.Failed() {
344                 c.Fatal("giving up")
345         }
346
347         // If the token is used again after the in-memory cache
348         // expires, oidcAuthorizer must re-check the token and update
349         // the expires_at value in the database.
350         time.Sleep(3 * time.Millisecond)
351         oidcAuthorizer.WrapCalls(func(ctx context.Context, opts interface{}) (interface{}, error) {
352                 exp := checkTokenInDB()
353                 c.Check(exp.Sub(exp1) > 0, check.Equals, true, check.Commentf("expect %v > 0", exp.Sub(exp1)))
354                 c.Check(exp.Sub(exp1) < time.Second, check.Equals, true, check.Commentf("expect %v < 1s", exp.Sub(exp1)))
355                 return nil, nil
356         })(ctx, nil)
357
358         s.fakeProvider.AccessTokenPayload = map[string]interface{}{"scope": "openid profile foobar"}
359         accessToken = s.fakeProvider.ValidAccessToken()
360         ctx = ctrlctx.NewWithToken(s.ctx, s.cluster, accessToken)
361
362         mac = hmac.New(sha256.New, []byte(s.cluster.SystemRootToken))
363         io.WriteString(mac, accessToken)
364         apiToken = fmt.Sprintf("%x", mac.Sum(nil))
365
366         for _, trial := range []struct {
367                 configEnable bool
368                 configScope  string
369                 acceptable   bool
370                 shouldRun    bool
371         }{
372                 {true, "foobar", true, true},
373                 {true, "foo", false, false},
374                 {true, "", true, true},
375                 {false, "", false, true},
376                 {false, "foobar", false, true},
377         } {
378                 c.Logf("trial = %+v", trial)
379                 cleanup()
380                 s.cluster.Login.OpenIDConnect.AcceptAccessToken = trial.configEnable
381                 s.cluster.Login.OpenIDConnect.AcceptAccessTokenScope = trial.configScope
382                 oidcAuthorizer = OIDCAccessTokenAuthorizer(s.cluster, func(context.Context) (*sqlx.DB, error) { return db, nil })
383                 checked := false
384                 oidcAuthorizer.WrapCalls(func(ctx context.Context, opts interface{}) (interface{}, error) {
385                         var n int
386                         err := db.QueryRowContext(ctx, `select count(*) from api_client_authorizations where api_token=$1`, apiToken).Scan(&n)
387                         c.Check(err, check.IsNil)
388                         if trial.acceptable {
389                                 c.Check(n, check.Equals, 1)
390                         } else {
391                                 c.Check(n, check.Equals, 0)
392                         }
393                         checked = true
394                         return nil, nil
395                 })(ctx, nil)
396                 c.Check(checked, check.Equals, trial.shouldRun)
397         }
398 }
399
400 func (s *OIDCLoginSuite) TestGenericOIDCLogin(c *check.C) {
401         s.cluster.Login.Google.Enable = false
402         s.cluster.Login.OpenIDConnect.Enable = true
403         json.Unmarshal([]byte(fmt.Sprintf("%q", s.fakeProvider.Issuer.URL)), &s.cluster.Login.OpenIDConnect.Issuer)
404         s.cluster.Login.OpenIDConnect.ClientID = "oidc#client#id"
405         s.cluster.Login.OpenIDConnect.ClientSecret = "oidc#client#secret"
406         s.cluster.Login.OpenIDConnect.AuthenticationRequestParameters = map[string]string{"testkey": "testvalue"}
407         s.fakeProvider.ValidClientID = "oidc#client#id"
408         s.fakeProvider.ValidClientSecret = "oidc#client#secret"
409         for _, trial := range []struct {
410                 expectEmail string // "" if failure expected
411                 setup       func()
412         }{
413                 {
414                         expectEmail: "user@oidc.example.com",
415                         setup: func() {
416                                 c.Log("=== succeed because email_verified is false but not required")
417                                 s.fakeProvider.AuthEmail = "user@oidc.example.com"
418                                 s.fakeProvider.AuthEmailVerified = false
419                                 s.cluster.Login.OpenIDConnect.EmailClaim = "email"
420                                 s.cluster.Login.OpenIDConnect.EmailVerifiedClaim = ""
421                                 s.cluster.Login.OpenIDConnect.UsernameClaim = ""
422                         },
423                 },
424                 {
425                         expectEmail: "",
426                         setup: func() {
427                                 c.Log("=== fail because email_verified is false and required")
428                                 s.fakeProvider.AuthEmail = "user@oidc.example.com"
429                                 s.fakeProvider.AuthEmailVerified = false
430                                 s.cluster.Login.OpenIDConnect.EmailClaim = "email"
431                                 s.cluster.Login.OpenIDConnect.EmailVerifiedClaim = "email_verified"
432                                 s.cluster.Login.OpenIDConnect.UsernameClaim = ""
433                         },
434                 },
435                 {
436                         expectEmail: "user@oidc.example.com",
437                         setup: func() {
438                                 c.Log("=== succeed because email_verified is false but config uses custom 'verified' claim")
439                                 s.fakeProvider.AuthEmail = "user@oidc.example.com"
440                                 s.fakeProvider.AuthEmailVerified = false
441                                 s.cluster.Login.OpenIDConnect.EmailClaim = "email"
442                                 s.cluster.Login.OpenIDConnect.EmailVerifiedClaim = "alt_verified"
443                                 s.cluster.Login.OpenIDConnect.UsernameClaim = ""
444                         },
445                 },
446                 {
447                         expectEmail: "alt_email@example.com",
448                         setup: func() {
449                                 c.Log("=== succeed with custom 'email' and 'email_verified' claims")
450                                 s.fakeProvider.AuthEmail = "bad@wrong.example.com"
451                                 s.fakeProvider.AuthEmailVerified = false
452                                 s.cluster.Login.OpenIDConnect.EmailClaim = "alt_email"
453                                 s.cluster.Login.OpenIDConnect.EmailVerifiedClaim = "alt_verified"
454                                 s.cluster.Login.OpenIDConnect.UsernameClaim = "alt_username"
455                         },
456                 },
457         } {
458                 trial.setup()
459                 if s.railsSpy != nil {
460                         s.railsSpy.Close()
461                 }
462                 s.railsSpy = arvadostest.NewProxy(c, s.cluster.Services.RailsAPI)
463                 s.localdb = NewConn(context.Background(), s.cluster, (&ctrlctx.DBConnector{PostgreSQL: s.cluster.PostgreSQL}).GetDB)
464                 *s.localdb.railsProxy = *rpc.NewConn(s.cluster.ClusterID, s.railsSpy.URL, true, rpc.PassthroughTokenProvider)
465
466                 state := s.startLogin(c, func(form url.Values) {
467                         c.Check(form.Get("testkey"), check.Equals, "testvalue")
468                 })
469                 resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
470                         Code:  s.fakeProvider.ValidCode,
471                         State: state,
472                 })
473                 c.Assert(err, check.IsNil)
474                 if trial.expectEmail == "" {
475                         c.Check(resp.HTML.String(), check.Matches, `(?ms).*Login error.*`)
476                         c.Check(resp.RedirectLocation, check.Equals, "")
477                         continue
478                 }
479                 c.Check(resp.HTML.String(), check.Equals, "")
480                 target, err := url.Parse(resp.RedirectLocation)
481                 c.Assert(err, check.IsNil)
482                 token := target.Query().Get("api_token")
483                 c.Check(token, check.Matches, `v2/zzzzz-gj3su-.{15}/.{32,50}`)
484                 authinfo := getCallbackAuthInfo(c, s.railsSpy)
485                 c.Check(authinfo.Email, check.Equals, trial.expectEmail)
486
487                 switch s.cluster.Login.OpenIDConnect.UsernameClaim {
488                 case "alt_username":
489                         c.Check(authinfo.Username, check.Equals, "desired-username")
490                 case "":
491                         c.Check(authinfo.Username, check.Equals, "")
492                 default:
493                         c.Fail() // bad test case
494                 }
495         }
496 }
497
498 func (s *OIDCLoginSuite) TestGoogleLogin_Success(c *check.C) {
499         s.cluster.Login.Google.AuthenticationRequestParameters["prompt"] = "consent"
500         s.cluster.Login.Google.AuthenticationRequestParameters["foo"] = "bar"
501         state := s.startLogin(c, func(form url.Values) {
502                 c.Check(form.Get("foo"), check.Equals, "bar")
503                 c.Check(form.Get("prompt"), check.Equals, "consent")
504         })
505         resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
506                 Code:  s.fakeProvider.ValidCode,
507                 State: state,
508         })
509         c.Check(err, check.IsNil)
510         c.Check(resp.HTML.String(), check.Equals, "")
511         target, err := url.Parse(resp.RedirectLocation)
512         c.Check(err, check.IsNil)
513         c.Check(target.Host, check.Equals, "app.example.com")
514         c.Check(target.Path, check.Equals, "/foo")
515         token := target.Query().Get("api_token")
516         c.Check(token, check.Matches, `v2/zzzzz-gj3su-.{15}/.{32,50}`)
517
518         authinfo := getCallbackAuthInfo(c, s.railsSpy)
519         c.Check(authinfo.FirstName, check.Equals, "Fake")
520         c.Check(authinfo.LastName, check.Equals, "User Name")
521         c.Check(authinfo.Email, check.Equals, "active-user@arvados.local")
522         c.Check(authinfo.AlternateEmails, check.HasLen, 0)
523
524         // Try using the returned Arvados token.
525         c.Logf("trying an API call with new token %q", token)
526         ctx := ctrlctx.NewWithToken(s.ctx, s.cluster, token)
527         cl, err := s.localdb.CollectionList(ctx, arvados.ListOptions{Limit: -1})
528         c.Check(cl.ItemsAvailable, check.Not(check.Equals), 0)
529         c.Check(cl.Items, check.Not(check.HasLen), 0)
530         c.Check(err, check.IsNil)
531
532         // Might as well check that bogus tokens aren't accepted.
533         badtoken := token + "plussomeboguschars"
534         c.Logf("trying an API call with mangled token %q", badtoken)
535         ctx = ctrlctx.NewWithToken(s.ctx, s.cluster, badtoken)
536         cl, err = s.localdb.CollectionList(ctx, arvados.ListOptions{Limit: -1})
537         c.Check(cl.Items, check.HasLen, 0)
538         c.Check(err, check.NotNil)
539         c.Check(err, check.ErrorMatches, `.*401 Unauthorized: Not logged in.*`)
540 }
541
542 func (s *OIDCLoginSuite) TestGoogleLogin_RealName(c *check.C) {
543         s.fakeProvider.AuthEmail = "joe.smith@primary.example.com"
544         s.fakeProvider.AuthEmailVerified = true
545         s.fakeProvider.PeopleAPIResponse = map[string]interface{}{
546                 "names": []map[string]interface{}{
547                         {
548                                 "metadata":   map[string]interface{}{"primary": false},
549                                 "givenName":  "Joe",
550                                 "familyName": "Smith",
551                         },
552                         {
553                                 "metadata":   map[string]interface{}{"primary": true},
554                                 "givenName":  "Joseph",
555                                 "familyName": "Psmith",
556                         },
557                 },
558         }
559         state := s.startLogin(c)
560         s.localdb.Login(context.Background(), arvados.LoginOptions{
561                 Code:  s.fakeProvider.ValidCode,
562                 State: state,
563         })
564
565         authinfo := getCallbackAuthInfo(c, s.railsSpy)
566         c.Check(authinfo.FirstName, check.Equals, "Joseph")
567         c.Check(authinfo.LastName, check.Equals, "Psmith")
568 }
569
570 func (s *OIDCLoginSuite) TestGoogleLogin_OIDCNameWithoutGivenAndFamilyNames(c *check.C) {
571         s.fakeProvider.AuthName = "Joe P. Smith"
572         s.fakeProvider.AuthGivenName = ""
573         s.fakeProvider.AuthFamilyName = ""
574         s.fakeProvider.AuthEmail = "joe.smith@primary.example.com"
575         state := s.startLogin(c)
576         s.localdb.Login(context.Background(), arvados.LoginOptions{
577                 Code:  s.fakeProvider.ValidCode,
578                 State: state,
579         })
580
581         authinfo := getCallbackAuthInfo(c, s.railsSpy)
582         c.Check(authinfo.FirstName, check.Equals, "Joe P.")
583         c.Check(authinfo.LastName, check.Equals, "Smith")
584 }
585
586 // People API returns some additional email addresses.
587 func (s *OIDCLoginSuite) TestGoogleLogin_AlternateEmailAddresses(c *check.C) {
588         s.fakeProvider.AuthEmail = "joe.smith@primary.example.com"
589         s.fakeProvider.PeopleAPIResponse = map[string]interface{}{
590                 "emailAddresses": []map[string]interface{}{
591                         {
592                                 "metadata": map[string]interface{}{"verified": true},
593                                 "value":    "joe.smith@work.example.com",
594                         },
595                         {
596                                 "value": "joe.smith@unverified.example.com", // unverified, so this one will be ignored
597                         },
598                         {
599                                 "metadata": map[string]interface{}{"verified": true},
600                                 "value":    "joe.smith@home.example.com",
601                         },
602                 },
603         }
604         state := s.startLogin(c)
605         s.localdb.Login(context.Background(), arvados.LoginOptions{
606                 Code:  s.fakeProvider.ValidCode,
607                 State: state,
608         })
609
610         authinfo := getCallbackAuthInfo(c, s.railsSpy)
611         c.Check(authinfo.Email, check.Equals, "joe.smith@primary.example.com")
612         c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith@home.example.com", "joe.smith@work.example.com"})
613 }
614
615 // Primary address is not the one initially returned by oidc.
616 func (s *OIDCLoginSuite) TestGoogleLogin_AlternateEmailAddresses_Primary(c *check.C) {
617         s.fakeProvider.AuthEmail = "joe.smith@alternate.example.com"
618         s.fakeProvider.PeopleAPIResponse = map[string]interface{}{
619                 "emailAddresses": []map[string]interface{}{
620                         {
621                                 "metadata": map[string]interface{}{"verified": true, "primary": true},
622                                 "value":    "joe.smith@primary.example.com",
623                         },
624                         {
625                                 "metadata": map[string]interface{}{"verified": true},
626                                 "value":    "joe.smith@alternate.example.com",
627                         },
628                         {
629                                 "metadata": map[string]interface{}{"verified": true},
630                                 "value":    "jsmith+123@preferdomainforusername.example.com",
631                         },
632                 },
633         }
634         state := s.startLogin(c)
635         s.localdb.Login(context.Background(), arvados.LoginOptions{
636                 Code:  s.fakeProvider.ValidCode,
637                 State: state,
638         })
639         authinfo := getCallbackAuthInfo(c, s.railsSpy)
640         c.Check(authinfo.Email, check.Equals, "joe.smith@primary.example.com")
641         c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith@alternate.example.com", "jsmith+123@preferdomainforusername.example.com"})
642         c.Check(authinfo.Username, check.Equals, "jsmith")
643 }
644
645 func (s *OIDCLoginSuite) TestGoogleLogin_NoPrimaryEmailAddress(c *check.C) {
646         s.fakeProvider.AuthEmail = "joe.smith@unverified.example.com"
647         s.fakeProvider.AuthEmailVerified = false
648         s.fakeProvider.PeopleAPIResponse = map[string]interface{}{
649                 "emailAddresses": []map[string]interface{}{
650                         {
651                                 "metadata": map[string]interface{}{"verified": true},
652                                 "value":    "joe.smith@work.example.com",
653                         },
654                         {
655                                 "metadata": map[string]interface{}{"verified": true},
656                                 "value":    "joe.smith@home.example.com",
657                         },
658                 },
659         }
660         state := s.startLogin(c)
661         s.localdb.Login(context.Background(), arvados.LoginOptions{
662                 Code:  s.fakeProvider.ValidCode,
663                 State: state,
664         })
665
666         authinfo := getCallbackAuthInfo(c, s.railsSpy)
667         c.Check(authinfo.Email, check.Equals, "joe.smith@work.example.com") // first verified email in People response
668         c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith@home.example.com"})
669         c.Check(authinfo.Username, check.Equals, "")
670 }
671
672 func (s *OIDCLoginSuite) startLogin(c *check.C, checks ...func(url.Values)) (state string) {
673         // Initiate login, but instead of following the redirect to
674         // the provider, just grab state from the redirect URL.
675         resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{ReturnTo: "https://app.example.com/foo?bar"})
676         c.Check(err, check.IsNil)
677         c.Check(resp.HTML.String(), check.Not(check.Matches), `(?ms).*error:.*`)
678         target, err := url.Parse(resp.RedirectLocation)
679         c.Check(err, check.IsNil)
680         state = target.Query().Get("state")
681         if !c.Check(state, check.Not(check.Equals), "") {
682                 c.Logf("Redirect target: %q", target)
683                 c.Logf("HTML: %q", resp.HTML)
684         }
685         for _, fn := range checks {
686                 fn(target.Query())
687         }
688         s.cluster.Login.OpenIDConnect.AuthenticationRequestParameters = map[string]string{"testkey": "testvalue"}
689         return
690 }
691
692 func (s *OIDCLoginSuite) TestValidateLoginRedirectTarget(c *check.C) {
693         for _, trial := range []struct {
694                 permit       bool
695                 trustPrivate bool
696                 url          string
697         }{
698                 // wb1, wb2 => accept
699                 {true, false, s.cluster.Services.Workbench1.ExternalURL.String()},
700                 {true, false, s.cluster.Services.Workbench2.ExternalURL.String()},
701                 // explicitly listed host => accept
702                 {true, false, "https://app.example.com/"},
703                 {true, false, "https://app.example.com:443/foo?bar=baz"},
704                 // non-listed hostname => deny (regardless of TrustPrivateNetworks)
705                 {false, false, "https://bad.example/"},
706                 {false, true, "https://bad.example/"},
707                 // non-listed non-private IP addr => deny (regardless of TrustPrivateNetworks)
708                 {false, true, "https://1.2.3.4/"},
709                 {false, true, "https://1.2.3.4/"},
710                 {false, true, "https://[ab::cd]:1234/"},
711                 // localhost or non-listed private IP addr => accept only if TrustPrivateNetworks is set
712                 {false, false, "https://localhost/"},
713                 {true, true, "https://localhost/"},
714                 {false, false, "https://[10.9.8.7]:80/foo"},
715                 {true, true, "https://[10.9.8.7]:80/foo"},
716                 {false, false, "https://[::1]:80/foo"},
717                 {true, true, "https://[::1]:80/foo"},
718                 {true, true, "http://192.168.1.1/"},
719                 {true, true, "http://172.17.2.0/"},
720                 // bad url => deny
721                 {false, true, "https://10.1.1.1:blorp/foo"},        // non-numeric port
722                 {false, true, "https://app.example.com:blorp/foo"}, // non-numeric port
723                 {false, true, "https://]:443"},
724                 {false, true, "https://"},
725                 {false, true, "https:"},
726                 {false, true, ""},
727                 // explicitly listed host but different port, protocol, or user/pass => deny
728                 {false, true, "http://app.example.com/"},
729                 {false, true, "http://app.example.com:443/"},
730                 {false, true, "https://app.example.com:80/"},
731                 {false, true, "https://app.example.com:4433/"},
732                 {false, true, "https://u:p@app.example.com:443/foo?bar=baz"},
733         } {
734                 c.Logf("trial %+v", trial)
735                 s.cluster.Login.TrustPrivateNetworks = trial.trustPrivate
736                 err := validateLoginRedirectTarget(s.cluster, trial.url)
737                 c.Check(err == nil, check.Equals, trial.permit)
738         }
739
740 }
741
742 func getCallbackAuthInfo(c *check.C, railsSpy *arvadostest.Proxy) (authinfo rpc.UserSessionAuthInfo) {
743         for _, dump := range railsSpy.RequestDumps {
744                 c.Logf("spied request: %q", dump)
745                 split := bytes.Split(dump, []byte("\r\n\r\n"))
746                 c.Assert(split, check.HasLen, 2)
747                 hdr, body := string(split[0]), string(split[1])
748                 if strings.Contains(hdr, "POST /auth/controller/callback") {
749                         vs, err := url.ParseQuery(body)
750                         c.Check(json.Unmarshal([]byte(vs.Get("auth_info")), &authinfo), check.IsNil)
751                         c.Check(err, check.IsNil)
752                         sort.Strings(authinfo.AlternateEmails)
753                         return
754                 }
755         }
756         c.Error("callback not found")
757         return
758 }