X-Git-Url: https://git.arvados.org/arvados.git/blobdiff_plain/514fb685c9d835441e0911d9b9499952b6787095..d97c9ecca25f449ad928963f6257a01a8bbbf1e7:/lib/controller/localdb/login_test.go diff --git a/lib/controller/localdb/login_test.go b/lib/controller/localdb/login_test.go index c6aa0d31f3..c5b9ee0683 100644 --- a/lib/controller/localdb/login_test.go +++ b/lib/controller/localdb/login_test.go @@ -5,6 +5,7 @@ package localdb import ( + "bytes" "context" "crypto/rand" "crypto/rsa" @@ -13,11 +14,15 @@ 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" check "gopkg.in/check.v1" @@ -32,11 +37,14 @@ func Test(t *testing.T) { var _ = check.Suite(&LoginSuite{}) type LoginSuite struct { - cluster *arvados.Cluster - ctx context.Context - localdb *Conn - 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 @@ -46,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) @@ -110,6 +125,24 @@ 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("") @@ -119,16 +152,24 @@ func (s *LoginSuite) SetUpTest(c *check.C) { 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) +} + +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) @@ -146,52 +187,82 @@ 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}`) + 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) ctx := auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{token}}) @@ -210,6 +281,157 @@ 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", + }, + }, + } + 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"}) +} + +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"}) +} + +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 {