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