1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
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"
34 // Gocheck boilerplate
35 func Test(t *testing.T) {
39 var _ = check.Suite(&OIDCLoginSuite{})
41 type OIDCLoginSuite struct {
43 trustedURL *arvados.URL
44 fakeProvider *arvadostest.OIDCProvider
47 func (s *OIDCLoginSuite) SetUpTest(c *check.C) {
48 s.trustedURL = &arvados.URL{Scheme: "https", Host: "app.example.com:443", Path: "/"}
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{}{}
59 s.localdbSuite.SetUpTest(c)
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"
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
75 *s.localdb.railsProxy = *rpc.NewConn(s.cluster.ClusterID, s.railsSpy.URL, true, rpc.PassthroughTokenProvider)
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
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, "")
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, "")
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")
94 s.cluster.Login.TrustPrivateNetworks = true
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")
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")) {
106 expURL, err := url.Parse(s.fakeProvider.Issuer.URL)
107 if !c.Check(err, check.IsNil, check.Commentf("error parsing expected URL")) {
110 expURL.Path = expURL.Path + s.fakeProvider.EndSessionEndpoint.Path
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) {
118 loc, err := url.Parse(resp.RedirectLocation)
119 if !c.Check(err, check.IsNil, check.Commentf("error parsing response URL")) {
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)
130 expReturn = s.cluster.Services.Workbench2.ExternalURL.String()
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)
139 func (s *OIDCLoginSuite) TestRPInitiatedLogoutWithoutReturnTo(c *check.C) {
140 s.fakeProvider.EndSessionEndpoint = &url.URL{Path: "/logout/fromRP"}
141 s.checkRPInitiatedLogout(c, "")
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())
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()))
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"))
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.*`)
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)
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")
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.*`)
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",
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.*`)
213 func (s *OIDCLoginSuite) TestGoogleLogin_InvalidState(c *check.C) {
215 resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
216 Code: s.fakeProvider.ValidCode,
217 State: "bogus-state",
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.*`)
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`)
229 s.localdb.loginController.(*oidcLoginController).peopleAPIBasePath = s.fakeProvider.PeopleAPI.URL
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,
241 c.Check(err, check.IsNil)
242 authinfo := getCallbackAuthInfo(c, s.railsSpy)
243 c.Check(authinfo.Email, check.Equals, "joe.smith@primary.example.com")
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")
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")
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,
285 c.Check(err, check.IsNil)
286 c.Check(resp.RedirectLocation, check.Equals, "")
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)
301 tokenCacheTTL = time.Millisecond
302 tokenCacheRaceWindow = time.Millisecond
303 tokenCacheNegativeTTL = time.Millisecond
305 oidcAuthorizer := OIDCAccessTokenAuthorizer(s.cluster, func(context.Context) (*sqlx.DB, error) { return db, nil })
306 accessToken := s.fakeProvider.ValidAccessToken()
308 mac := hmac.New(sha256.New, []byte(s.cluster.SystemRootToken))
309 io.WriteString(mac, accessToken)
310 apiToken := fmt.Sprintf("%x", mac.Sum(nil))
312 checkTokenInDB := func() 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)
321 oidcAuthorizer.cache.Purge()
322 _, err := db.Exec(`delete from api_client_authorizations where api_token=$1`, apiToken)
323 c.Check(err, check.IsNil)
328 ctx := ctrlctx.NewWithToken(s.ctx, s.cluster, accessToken)
330 // Check behavior on 5xx/network errors (don't cache) vs 4xx
333 call := oidcAuthorizer.WrapCalls(func(ctx context.Context, opts interface{}) (interface{}, error) {
337 // If fakeProvider UserInfo endpoint returns 502, we
338 // should fail, return an error, and *not* cache the
340 tokenCacheNegativeTTL = time.Minute
341 s.fakeProvider.UserInfoErrorStatus = 502
342 _, err := call(ctx, nil)
343 c.Check(err, check.NotNil)
345 // The negative result was not cached, so retrying
346 // immediately (with UserInfo working now) should
348 s.fakeProvider.UserInfoErrorStatus = 0
349 _, err = call(ctx, nil)
350 c.Check(err, check.IsNil)
355 // UserInfo 401 => cache the negative result, but
356 // don't return an error (just pass the token through
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{})
365 // UserInfo succeeds now, but we still have a cached
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{})
374 tokenCacheNegativeTTL = time.Millisecond
380 s.fakeProvider.HoldUserInfo = make(chan *http.Request)
381 s.fakeProvider.ReleaseUserInfo = make(chan struct{})
385 close(s.fakeProvider.ReleaseUserInfo)
387 <-s.fakeProvider.HoldUserInfo
390 var wg sync.WaitGroup
391 for i := 0; i < concurrent; i++ {
396 _, err := oidcAuthorizer.WrapCalls(func(ctx context.Context, opts interface{}) (interface{}, error) {
397 c.Logf("concurrent req %d/%d", i, concurrent)
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()
409 c.Check(err, check.IsNil)
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)))
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)
432 mac = hmac.New(sha256.New, []byte(s.cluster.SystemRootToken))
433 io.WriteString(mac, accessToken)
434 apiToken = fmt.Sprintf("%x", mac.Sum(nil))
436 for _, trial := range []struct {
442 {true, "foobar", true, true},
443 {true, "foo", false, false},
444 {true, "", true, true},
445 {false, "", false, true},
446 {false, "foobar", false, true},
448 c.Logf("trial = %+v", trial)
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 })
454 oidcAuthorizer.WrapCalls(func(ctx context.Context, opts interface{}) (interface{}, error) {
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)
461 c.Check(n, check.Equals, 0)
466 c.Check(checked, check.Equals, trial.shouldRun)
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
484 expectEmail: "user@oidc.example.com",
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 = ""
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 = ""
506 expectEmail: "user@oidc.example.com",
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 = ""
517 expectEmail: "alt_email@example.com",
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"
529 if s.railsSpy != nil {
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)
536 state := s.startLogin(c, func(form url.Values) {
537 c.Check(form.Get("testkey"), check.Equals, "testvalue")
539 resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
540 Code: s.fakeProvider.ValidCode,
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, "")
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)
557 switch s.cluster.Login.OpenIDConnect.UsernameClaim {
559 c.Check(authinfo.Username, check.Equals, "desired-username")
561 c.Check(authinfo.Username, check.Equals, "")
563 c.Fail() // bad test case
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")
575 resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
576 Code: s.fakeProvider.ValidCode,
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}`)
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)
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)
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.*`)
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{}{
618 "metadata": map[string]interface{}{"primary": false},
620 "familyName": "Smith",
623 "metadata": map[string]interface{}{"primary": true},
624 "givenName": "Joseph",
625 "familyName": "Psmith",
629 state := s.startLogin(c)
630 s.localdb.Login(context.Background(), arvados.LoginOptions{
631 Code: s.fakeProvider.ValidCode,
635 authinfo := getCallbackAuthInfo(c, s.railsSpy)
636 c.Check(authinfo.FirstName, check.Equals, "Joseph")
637 c.Check(authinfo.LastName, check.Equals, "Psmith")
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,
651 authinfo := getCallbackAuthInfo(c, s.railsSpy)
652 c.Check(authinfo.FirstName, check.Equals, "Joe P.")
653 c.Check(authinfo.LastName, check.Equals, "Smith")
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{}{
662 "metadata": map[string]interface{}{"verified": true},
663 "value": "joe.smith@work.example.com",
666 "value": "joe.smith@unverified.example.com", // unverified, so this one will be ignored
669 "metadata": map[string]interface{}{"verified": true},
670 "value": "joe.smith@home.example.com",
674 state := s.startLogin(c)
675 s.localdb.Login(context.Background(), arvados.LoginOptions{
676 Code: s.fakeProvider.ValidCode,
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"})
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{}{
691 "metadata": map[string]interface{}{"verified": true, "primary": true},
692 "value": "joe.smith@primary.example.com",
695 "metadata": map[string]interface{}{"verified": true},
696 "value": "joe.smith@alternate.example.com",
699 "metadata": map[string]interface{}{"verified": true},
700 "value": "jsmith+123@preferdomainforusername.example.com",
704 state := s.startLogin(c)
705 s.localdb.Login(context.Background(), arvados.LoginOptions{
706 Code: s.fakeProvider.ValidCode,
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")
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{}{
721 "metadata": map[string]interface{}{"verified": true},
722 "value": "joe.smith@work.example.com",
725 "metadata": map[string]interface{}{"verified": true},
726 "value": "joe.smith@home.example.com",
730 state := s.startLogin(c)
731 s.localdb.Login(context.Background(), arvados.LoginOptions{
732 Code: s.fakeProvider.ValidCode,
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, "")
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)
755 for _, fn := range checks {
758 s.cluster.Login.OpenIDConnect.AuthenticationRequestParameters = map[string]string{"testkey": "testvalue"}
762 func (s *OIDCLoginSuite) TestValidateLoginRedirectTarget(c *check.C) {
763 for _, trial := range []struct {
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/"},
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:"},
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"},
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)
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)
826 c.Error("callback not found")