"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"
)
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
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)
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)
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)
}
}
-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)
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 {