15924: Change import paths to git.arvados.org.
[arvados.git] / lib / controller / localdb / login_test.go
index 362e258409aa4221ecbabe10d504671f55f8b9a7..9f3267cef0e5c0435a027f73dfa0367afd6d403c 100644 (file)
@@ -14,16 +14,17 @@ import (
        "net/http"
        "net/http/httptest"
        "net/url"
+       "sort"
        "strings"
        "testing"
        "time"
 
-       "git.curoverse.com/arvados.git/lib/config"
-       "git.curoverse.com/arvados.git/lib/controller/rpc"
-       "git.curoverse.com/arvados.git/sdk/go/arvados"
-       "git.curoverse.com/arvados.git/sdk/go/arvadostest"
-       "git.curoverse.com/arvados.git/sdk/go/auth"
-       "git.curoverse.com/arvados.git/sdk/go/ctxlog"
+       "git.arvados.org/arvados.git/lib/config"
+       "git.arvados.org/arvados.git/lib/controller/rpc"
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadostest"
+       "git.arvados.org/arvados.git/sdk/go/auth"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
        check "gopkg.in/check.v1"
        jose "gopkg.in/square/go-jose.v2"
 )
@@ -36,12 +37,14 @@ func Test(t *testing.T) {
 var _ = check.Suite(&LoginSuite{})
 
 type LoginSuite struct {
-       cluster    *arvados.Cluster
-       ctx        context.Context
-       localdb    *Conn
-       railsSpy   *arvadostest.Proxy
-       fakeIssuer *httptest.Server
-       issuerKey  *rsa.PrivateKey
+       cluster               *arvados.Cluster
+       ctx                   context.Context
+       localdb               *Conn
+       railsSpy              *arvadostest.Proxy
+       fakeIssuer            *httptest.Server
+       fakePeopleAPI         *httptest.Server
+       fakePeopleAPIResponse map[string]interface{}
+       issuerKey             *rsa.PrivateKey
 
        // expected token request
        validCode string
@@ -51,6 +54,13 @@ type LoginSuite struct {
        authName          string
 }
 
+func (s *LoginSuite) TearDownSuite(c *check.C) {
+       // Undo any changes/additions to the user database so they
+       // don't affect subsequent tests.
+       arvadostest.ResetEnv()
+       c.Check(arvados.NewClientFromEnv().RequestAndDecode(nil, "POST", "database/reset", nil, nil), check.IsNil)
+}
+
 func (s *LoginSuite) SetUpTest(c *check.C) {
        var err error
        s.issuerKey, err = rsa.GenerateKey(rand.Reader, 2048)
@@ -115,15 +125,35 @@ func (s *LoginSuite) SetUpTest(c *check.C) {
                        w.WriteHeader(http.StatusNotFound)
                }
        }))
+       s.validCode = fmt.Sprintf("abcdefgh-%d", time.Now().Unix())
+
+       s.fakePeopleAPI = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+               req.ParseForm()
+               c.Logf("fakePeopleAPI: got req: %s %s %s", req.Method, req.URL, req.Form)
+               w.Header().Set("Content-Type", "application/json")
+               switch req.URL.Path {
+               case "/v1/people/me":
+                       if f := req.Form.Get("personFields"); f != "emailAddresses,names" {
+                               w.WriteHeader(http.StatusBadRequest)
+                               break
+                       }
+                       json.NewEncoder(w).Encode(s.fakePeopleAPIResponse)
+               default:
+                       w.WriteHeader(http.StatusNotFound)
+               }
+       }))
+       s.fakePeopleAPIResponse = map[string]interface{}{}
 
        cfg, err := config.NewLoader(nil, ctxlog.TestLogger(c)).Load()
        s.cluster, err = cfg.GetCluster("")
        s.cluster.Login.GoogleClientID = "test%client$id"
        s.cluster.Login.GoogleClientSecret = "test#client/secret"
+       s.cluster.Users.PreferDomainForUsername = "PreferDomainForUsername.example.com"
        c.Assert(err, check.IsNil)
 
        s.localdb = NewConn(s.cluster)
        s.localdb.googleLoginController.issuer = s.fakeIssuer.URL
+       s.localdb.googleLoginController.peopleAPIBasePath = s.fakePeopleAPI.URL
 
        s.railsSpy = arvadostest.NewProxy(c, s.cluster.Services.RailsAPI)
        s.localdb.railsProxy = rpc.NewConn(s.cluster.ClusterID, s.railsSpy.URL, true, rpc.PassthroughTokenProvider)
@@ -133,14 +163,14 @@ func (s *LoginSuite) TearDownTest(c *check.C) {
        s.railsSpy.Close()
 }
 
-func (s *LoginSuite) TestGoogleLoginStart_Bogus(c *check.C) {
+func (s *LoginSuite) TestGoogleLogin_Start_Bogus(c *check.C) {
        resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{})
        c.Check(err, check.IsNil)
        c.Check(resp.RedirectLocation, check.Equals, "")
        c.Check(resp.HTML.String(), check.Matches, `.*missing return_to parameter.*`)
 }
 
-func (s *LoginSuite) TestGoogleLoginStart(c *check.C) {
+func (s *LoginSuite) TestGoogleLogin_Start(c *check.C) {
        for _, remote := range []string{"", "zzzzz"} {
                resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{Remote: remote, ReturnTo: "https://app.example.com/foo?bar"})
                c.Check(err, check.IsNil)
@@ -158,70 +188,81 @@ func (s *LoginSuite) TestGoogleLoginStart(c *check.C) {
        }
 }
 
-func (s *LoginSuite) TestGoogleLoginSuccess(c *check.C) {
-       // Initiate login, but instead of following the redirect to
-       // the provider, just grab state from the redirect URL.
-       resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{ReturnTo: "https://app.example.com/foo?bar"})
-       c.Check(err, check.IsNil)
-       target, err := url.Parse(resp.RedirectLocation)
-       c.Check(err, check.IsNil)
-       state := target.Query().Get("state")
-       c.Check(state, check.Not(check.Equals), "")
-
-       // Prime the fake issuer with a valid code.
-       s.validCode = fmt.Sprintf("abcdefgh-%d", time.Now().Unix())
-
-       // Callback with invalid code.
-       resp, err = s.localdb.Login(context.Background(), arvados.LoginOptions{
+func (s *LoginSuite) TestGoogleLogin_InvalidCode(c *check.C) {
+       state := s.startLogin(c)
+       resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
                Code:  "first-try-a-bogus-code",
                State: state,
        })
        c.Check(err, check.IsNil)
        c.Check(resp.RedirectLocation, check.Equals, "")
        c.Check(resp.HTML.String(), check.Matches, `(?ms).*error in OAuth2 exchange.*cannot fetch token.*`)
+}
 
-       // Callback with invalid state.
-       resp, err = s.localdb.Login(context.Background(), arvados.LoginOptions{
+func (s *LoginSuite) TestGoogleLogin_InvalidState(c *check.C) {
+       s.startLogin(c)
+       resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
                Code:  s.validCode,
                State: "bogus-state",
        })
        c.Check(err, check.IsNil)
        c.Check(resp.RedirectLocation, check.Equals, "")
        c.Check(resp.HTML.String(), check.Matches, `(?ms).*invalid OAuth2 state.*`)
+}
+
+func (s *LoginSuite) setupPeopleAPIError(c *check.C) {
+       s.fakePeopleAPI = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+               w.WriteHeader(http.StatusForbidden)
+               fmt.Fprintln(w, `Error 403: accessNotConfigured`)
+       }))
+       s.localdb.googleLoginController.peopleAPIBasePath = s.fakePeopleAPI.URL
+}
 
-       // Callback with valid code and state.
-       resp, err = s.localdb.Login(context.Background(), arvados.LoginOptions{
+func (s *LoginSuite) TestGoogleLogin_PeopleAPIDisabled(c *check.C) {
+       s.cluster.Login.GoogleAlternateEmailAddresses = false
+       s.authEmail = "joe.smith@primary.example.com"
+       s.setupPeopleAPIError(c)
+       state := s.startLogin(c)
+       _, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
+               Code:  s.validCode,
+               State: state,
+       })
+       c.Check(err, check.IsNil)
+       authinfo := s.getCallbackAuthInfo(c)
+       c.Check(authinfo.Email, check.Equals, "joe.smith@primary.example.com")
+}
+
+func (s *LoginSuite) TestGoogleLogin_PeopleAPIError(c *check.C) {
+       s.setupPeopleAPIError(c)
+       state := s.startLogin(c)
+       resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
+               Code:  s.validCode,
+               State: state,
+       })
+       c.Check(err, check.IsNil)
+       c.Check(resp.RedirectLocation, check.Equals, "")
+}
+
+func (s *LoginSuite) TestGoogleLogin_Success(c *check.C) {
+       state := s.startLogin(c)
+       resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
                Code:  s.validCode,
                State: state,
        })
        c.Check(err, check.IsNil)
        c.Check(resp.HTML.String(), check.Equals, "")
-       c.Check(resp.RedirectLocation, check.Not(check.Equals), "")
-       target, err = url.Parse(resp.RedirectLocation)
+       target, err := url.Parse(resp.RedirectLocation)
        c.Check(err, check.IsNil)
        c.Check(target.Host, check.Equals, "app.example.com")
        c.Check(target.Path, check.Equals, "/foo")
        token := target.Query().Get("api_token")
        c.Check(token, check.Matches, `v2/zzzzz-gj3su-.{15}/.{32,50}`)
 
-       foundCallback := false
-       for _, dump := range s.railsSpy.RequestDumps {
-               c.Logf("spied request: %q", dump)
-               split := bytes.Split(dump, []byte("\r\n\r\n"))
-               c.Assert(split, check.HasLen, 2)
-               hdr, body := string(split[0]), string(split[1])
-               if strings.Contains(hdr, "POST /auth/controller/callback") {
-                       vs, err := url.ParseQuery(body)
-                       var authinfo map[string]interface{}
-                       c.Check(json.Unmarshal([]byte(vs.Get("auth_info")), &authinfo), check.IsNil)
-                       c.Check(err, check.IsNil)
-                       c.Check(authinfo["first_name"], check.Equals, "Fake User")
-                       c.Check(authinfo["last_name"], check.Equals, "Name")
-                       c.Check(authinfo["email"], check.Equals, "active-user@arvados.local")
-                       foundCallback = true
-               }
-       }
-       c.Check(foundCallback, check.Equals, true)
+       authinfo := s.getCallbackAuthInfo(c)
+       c.Check(authinfo.FirstName, check.Equals, "Fake User")
+       c.Check(authinfo.LastName, check.Equals, "Name")
+       c.Check(authinfo.Email, check.Equals, "active-user@arvados.local")
+       c.Check(authinfo.AlternateEmails, check.HasLen, 0)
 
        // Try using the returned Arvados token.
        c.Logf("trying an API call with new token %q", token)
@@ -241,6 +282,163 @@ func (s *LoginSuite) TestGoogleLoginSuccess(c *check.C) {
        c.Check(err, check.ErrorMatches, `.*401 Unauthorized: Not logged in.*`)
 }
 
+func (s *LoginSuite) TestGoogleLogin_RealName(c *check.C) {
+       s.authEmail = "joe.smith@primary.example.com"
+       s.fakePeopleAPIResponse = map[string]interface{}{
+               "names": []map[string]interface{}{
+                       {
+                               "metadata":   map[string]interface{}{"primary": false},
+                               "givenName":  "Joe",
+                               "familyName": "Smith",
+                       },
+                       {
+                               "metadata":   map[string]interface{}{"primary": true},
+                               "givenName":  "Joseph",
+                               "familyName": "Psmith",
+                       },
+               },
+       }
+       state := s.startLogin(c)
+       s.localdb.Login(context.Background(), arvados.LoginOptions{
+               Code:  s.validCode,
+               State: state,
+       })
+
+       authinfo := s.getCallbackAuthInfo(c)
+       c.Check(authinfo.FirstName, check.Equals, "Joseph")
+       c.Check(authinfo.LastName, check.Equals, "Psmith")
+}
+
+func (s *LoginSuite) TestGoogleLogin_OIDCRealName(c *check.C) {
+       s.authName = "Joe P. Smith"
+       s.authEmail = "joe.smith@primary.example.com"
+       state := s.startLogin(c)
+       s.localdb.Login(context.Background(), arvados.LoginOptions{
+               Code:  s.validCode,
+               State: state,
+       })
+
+       authinfo := s.getCallbackAuthInfo(c)
+       c.Check(authinfo.FirstName, check.Equals, "Joe P.")
+       c.Check(authinfo.LastName, check.Equals, "Smith")
+}
+
+// People API returns some additional email addresses.
+func (s *LoginSuite) TestGoogleLogin_AlternateEmailAddresses(c *check.C) {
+       s.authEmail = "joe.smith@primary.example.com"
+       s.fakePeopleAPIResponse = map[string]interface{}{
+               "emailAddresses": []map[string]interface{}{
+                       {
+                               "metadata": map[string]interface{}{"verified": true},
+                               "value":    "joe.smith@work.example.com",
+                       },
+                       {
+                               "value": "joe.smith@unverified.example.com", // unverified, so this one will be ignored
+                       },
+                       {
+                               "metadata": map[string]interface{}{"verified": true},
+                               "value":    "joe.smith@home.example.com",
+                       },
+               },
+       }
+       state := s.startLogin(c)
+       s.localdb.Login(context.Background(), arvados.LoginOptions{
+               Code:  s.validCode,
+               State: state,
+       })
+
+       authinfo := s.getCallbackAuthInfo(c)
+       c.Check(authinfo.Email, check.Equals, "joe.smith@primary.example.com")
+       c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith@home.example.com", "joe.smith@work.example.com"})
+}
+
+// Primary address is not the one initially returned by oidc.
+func (s *LoginSuite) TestGoogleLogin_AlternateEmailAddresses_Primary(c *check.C) {
+       s.authEmail = "joe.smith@alternate.example.com"
+       s.fakePeopleAPIResponse = map[string]interface{}{
+               "emailAddresses": []map[string]interface{}{
+                       {
+                               "metadata": map[string]interface{}{"verified": true, "primary": true},
+                               "value":    "joe.smith@primary.example.com",
+                       },
+                       {
+                               "metadata": map[string]interface{}{"verified": true},
+                               "value":    "joe.smith@alternate.example.com",
+                       },
+                       {
+                               "metadata": map[string]interface{}{"verified": true},
+                               "value":    "jsmith+123@preferdomainforusername.example.com",
+                       },
+               },
+       }
+       state := s.startLogin(c)
+       s.localdb.Login(context.Background(), arvados.LoginOptions{
+               Code:  s.validCode,
+               State: state,
+       })
+       authinfo := s.getCallbackAuthInfo(c)
+       c.Check(authinfo.Email, check.Equals, "joe.smith@primary.example.com")
+       c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith@alternate.example.com", "jsmith+123@preferdomainforusername.example.com"})
+       c.Check(authinfo.Username, check.Equals, "jsmith")
+}
+
+func (s *LoginSuite) TestGoogleLogin_NoPrimaryEmailAddress(c *check.C) {
+       s.authEmail = "joe.smith@unverified.example.com"
+       s.authEmailVerified = false
+       s.fakePeopleAPIResponse = map[string]interface{}{
+               "emailAddresses": []map[string]interface{}{
+                       {
+                               "metadata": map[string]interface{}{"verified": true},
+                               "value":    "joe.smith@work.example.com",
+                       },
+                       {
+                               "metadata": map[string]interface{}{"verified": true},
+                               "value":    "joe.smith@home.example.com",
+                       },
+               },
+       }
+       state := s.startLogin(c)
+       s.localdb.Login(context.Background(), arvados.LoginOptions{
+               Code:  s.validCode,
+               State: state,
+       })
+
+       authinfo := s.getCallbackAuthInfo(c)
+       c.Check(authinfo.Email, check.Equals, "joe.smith@work.example.com") // first verified email in People response
+       c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith@home.example.com"})
+       c.Check(authinfo.Username, check.Equals, "")
+}
+
+func (s *LoginSuite) getCallbackAuthInfo(c *check.C) (authinfo rpc.UserSessionAuthInfo) {
+       for _, dump := range s.railsSpy.RequestDumps {
+               c.Logf("spied request: %q", dump)
+               split := bytes.Split(dump, []byte("\r\n\r\n"))
+               c.Assert(split, check.HasLen, 2)
+               hdr, body := string(split[0]), string(split[1])
+               if strings.Contains(hdr, "POST /auth/controller/callback") {
+                       vs, err := url.ParseQuery(body)
+                       c.Check(json.Unmarshal([]byte(vs.Get("auth_info")), &authinfo), check.IsNil)
+                       c.Check(err, check.IsNil)
+                       sort.Strings(authinfo.AlternateEmails)
+                       return
+               }
+       }
+       c.Error("callback not found")
+       return
+}
+
+func (s *LoginSuite) startLogin(c *check.C) (state string) {
+       // Initiate login, but instead of following the redirect to
+       // the provider, just grab state from the redirect URL.
+       resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{ReturnTo: "https://app.example.com/foo?bar"})
+       c.Check(err, check.IsNil)
+       target, err := url.Parse(resp.RedirectLocation)
+       c.Check(err, check.IsNil)
+       state = target.Query().Get("state")
+       c.Check(state, check.Not(check.Equals), "")
+       return
+}
+
 func (s *LoginSuite) fakeToken(c *check.C, payload []byte) string {
        signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: s.issuerKey}, nil)
        if err != nil {