X-Git-Url: https://git.arvados.org/arvados.git/blobdiff_plain/9ab021c2c2720b563f012b99dfbf3e7034a3c245..ffe94bef9cd17abb522d7fabb32326405d466a94:/lib/controller/localdb/login_oidc_test.go diff --git a/lib/controller/localdb/login_oidc_test.go b/lib/controller/localdb/login_oidc_test.go index 59fb8ce05a..2ccb1fce2a 100644 --- a/lib/controller/localdb/login_oidc_test.go +++ b/lib/controller/localdb/login_oidc_test.go @@ -9,6 +9,7 @@ import ( "context" "crypto/rand" "crypto/rsa" + "encoding/base64" "encoding/json" "fmt" "net/http" @@ -34,11 +35,10 @@ func Test(t *testing.T) { check.TestingT(t) } -var _ = check.Suite(&LoginSuite{}) +var _ = check.Suite(&OIDCLoginSuite{}) -type LoginSuite struct { +type OIDCLoginSuite struct { cluster *arvados.Cluster - ctx context.Context localdb *Conn railsSpy *arvadostest.Proxy fakeIssuer *httptest.Server @@ -47,21 +47,23 @@ type LoginSuite struct { issuerKey *rsa.PrivateKey // expected token request - validCode string + validCode string + validClientID string + validClientSecret string // desired response from token endpoint authEmail string authEmailVerified bool authName string } -func (s *LoginSuite) TearDownSuite(c *check.C) { +func (s *OIDCLoginSuite) 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) { +func (s *OIDCLoginSuite) SetUpTest(c *check.C) { var err error s.issuerKey, err = rsa.GenerateKey(rand.Reader, 2048) c.Assert(err, check.IsNil) @@ -83,20 +85,36 @@ func (s *LoginSuite) SetUpTest(c *check.C) { "userinfo_endpoint": s.fakeIssuer.URL + "/userinfo", }) case "/token": + var clientID, clientSecret string + auth, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(req.Header.Get("Authorization"), "Basic ")) + authsplit := strings.Split(string(auth), ":") + if len(authsplit) == 2 { + clientID, _ = url.QueryUnescape(authsplit[0]) + clientSecret, _ = url.QueryUnescape(authsplit[1]) + } + if clientID != s.validClientID || clientSecret != s.validClientSecret { + c.Logf("fakeIssuer: expected (%q, %q) got (%q, %q)", s.validClientID, s.validClientSecret, clientID, clientSecret) + w.WriteHeader(http.StatusUnauthorized) + return + } + if req.Form.Get("code") != s.validCode || s.validCode == "" { w.WriteHeader(http.StatusUnauthorized) return } idToken, _ := json.Marshal(map[string]interface{}{ "iss": s.fakeIssuer.URL, - "aud": []string{"test%client$id"}, + "aud": []string{clientID}, "sub": "fake-user-id", - "exp": time.Now().UTC().Add(time.Minute).UnixNano(), - "iat": time.Now().UTC().UnixNano(), + "exp": time.Now().UTC().Add(time.Minute).Unix(), + "iat": time.Now().UTC().Unix(), "nonce": "fake-nonce", "email": s.authEmail, "email_verified": s.authEmailVerified, "name": s.authName, + "alt_verified": true, // for custom claim tests + "alt_email": "alt_email@example.com", // for custom claim tests + "alt_username": "desired-username", // for custom claim tests }) json.NewEncoder(w).Encode(struct { AccessToken string `json:"access_token"` @@ -145,17 +163,19 @@ func (s *LoginSuite) SetUpTest(c *check.C) { s.fakePeopleAPIResponse = map[string]interface{}{} cfg, err := config.NewLoader(nil, ctxlog.TestLogger(c)).Load() + c.Assert(err, check.IsNil) s.cluster, err = cfg.GetCluster("") + c.Assert(err, check.IsNil) s.cluster.Login.SSO.Enable = false s.cluster.Login.Google.Enable = true s.cluster.Login.Google.ClientID = "test%client$id" s.cluster.Login.Google.ClientSecret = "test#client/secret" s.cluster.Users.PreferDomainForUsername = "PreferDomainForUsername.example.com" - c.Assert(err, check.IsNil) + s.validClientID = "test%client$id" + s.validClientSecret = "test#client/secret" s.localdb = NewConn(s.cluster) c.Assert(s.localdb.loginController, check.FitsTypeOf, (*oidcLoginController)(nil)) - c.Check(s.localdb.loginController.(*oidcLoginController).Issuer, check.Equals, "https://accounts.google.com") s.localdb.loginController.(*oidcLoginController).Issuer = s.fakeIssuer.URL s.localdb.loginController.(*oidcLoginController).peopleAPIBasePath = s.fakePeopleAPI.URL @@ -163,24 +183,24 @@ func (s *LoginSuite) SetUpTest(c *check.C) { *s.localdb.railsProxy = *rpc.NewConn(s.cluster.ClusterID, s.railsSpy.URL, true, rpc.PassthroughTokenProvider) } -func (s *LoginSuite) TearDownTest(c *check.C) { +func (s *OIDCLoginSuite) TearDownTest(c *check.C) { s.railsSpy.Close() } -func (s *LoginSuite) TestGoogleLogout(c *check.C) { +func (s *OIDCLoginSuite) TestGoogleLogout(c *check.C) { resp, err := s.localdb.Logout(context.Background(), arvados.LogoutOptions{ReturnTo: "https://foo.example.com/bar"}) c.Check(err, check.IsNil) c.Check(resp.RedirectLocation, check.Equals, "https://foo.example.com/bar") } -func (s *LoginSuite) TestGoogleLogin_Start_Bogus(c *check.C) { +func (s *OIDCLoginSuite) 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) TestGoogleLogin_Start(c *check.C) { +func (s *OIDCLoginSuite) 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) @@ -198,7 +218,7 @@ func (s *LoginSuite) TestGoogleLogin_Start(c *check.C) { } } -func (s *LoginSuite) TestGoogleLogin_InvalidCode(c *check.C) { +func (s *OIDCLoginSuite) TestGoogleLogin_InvalidCode(c *check.C) { state := s.startLogin(c) resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{ Code: "first-try-a-bogus-code", @@ -209,7 +229,7 @@ func (s *LoginSuite) TestGoogleLogin_InvalidCode(c *check.C) { c.Check(resp.HTML.String(), check.Matches, `(?ms).*error in OAuth2 exchange.*cannot fetch token.*`) } -func (s *LoginSuite) TestGoogleLogin_InvalidState(c *check.C) { +func (s *OIDCLoginSuite) TestGoogleLogin_InvalidState(c *check.C) { s.startLogin(c) resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{ Code: s.validCode, @@ -220,7 +240,7 @@ func (s *LoginSuite) TestGoogleLogin_InvalidState(c *check.C) { c.Check(resp.HTML.String(), check.Matches, `(?ms).*invalid OAuth2 state.*`) } -func (s *LoginSuite) setupPeopleAPIError(c *check.C) { +func (s *OIDCLoginSuite) 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`) @@ -228,8 +248,8 @@ func (s *LoginSuite) setupPeopleAPIError(c *check.C) { s.localdb.loginController.(*oidcLoginController).peopleAPIBasePath = s.fakePeopleAPI.URL } -func (s *LoginSuite) TestGoogleLogin_PeopleAPIDisabled(c *check.C) { - s.cluster.Login.Google.AlternateEmailAddresses = false +func (s *OIDCLoginSuite) TestGoogleLogin_PeopleAPIDisabled(c *check.C) { + s.localdb.loginController.(*oidcLoginController).UseGooglePeopleAPI = false s.authEmail = "joe.smith@primary.example.com" s.setupPeopleAPIError(c) state := s.startLogin(c) @@ -242,7 +262,35 @@ func (s *LoginSuite) TestGoogleLogin_PeopleAPIDisabled(c *check.C) { c.Check(authinfo.Email, check.Equals, "joe.smith@primary.example.com") } -func (s *LoginSuite) TestGoogleLogin_PeopleAPIError(c *check.C) { +func (s *OIDCLoginSuite) TestConfig(c *check.C) { + s.cluster.Login.Google.Enable = false + s.cluster.Login.OpenIDConnect.Enable = true + s.cluster.Login.OpenIDConnect.Issuer = "https://accounts.example.com/" + s.cluster.Login.OpenIDConnect.ClientID = "oidc-client-id" + s.cluster.Login.OpenIDConnect.ClientSecret = "oidc-client-secret" + localdb := NewConn(s.cluster) + ctrl := localdb.loginController.(*oidcLoginController) + c.Check(ctrl.Issuer, check.Equals, "https://accounts.example.com/") + c.Check(ctrl.ClientID, check.Equals, "oidc-client-id") + c.Check(ctrl.ClientSecret, check.Equals, "oidc-client-secret") + c.Check(ctrl.UseGooglePeopleAPI, check.Equals, false) + + for _, enableAltEmails := range []bool{false, true} { + s.cluster.Login.OpenIDConnect.Enable = false + s.cluster.Login.Google.Enable = true + s.cluster.Login.Google.ClientID = "google-client-id" + s.cluster.Login.Google.ClientSecret = "google-client-secret" + s.cluster.Login.Google.AlternateEmailAddresses = enableAltEmails + localdb = NewConn(s.cluster) + ctrl = localdb.loginController.(*oidcLoginController) + c.Check(ctrl.Issuer, check.Equals, "https://accounts.google.com") + c.Check(ctrl.ClientID, check.Equals, "google-client-id") + c.Check(ctrl.ClientSecret, check.Equals, "google-client-secret") + c.Check(ctrl.UseGooglePeopleAPI, check.Equals, enableAltEmails) + } +} + +func (s *OIDCLoginSuite) TestGoogleLogin_PeopleAPIError(c *check.C) { s.setupPeopleAPIError(c) state := s.startLogin(c) resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{ @@ -253,7 +301,102 @@ func (s *LoginSuite) TestGoogleLogin_PeopleAPIError(c *check.C) { c.Check(resp.RedirectLocation, check.Equals, "") } -func (s *LoginSuite) TestGoogleLogin_Success(c *check.C) { +func (s *OIDCLoginSuite) TestGenericOIDCLogin(c *check.C) { + s.cluster.Login.Google.Enable = false + s.cluster.Login.OpenIDConnect.Enable = true + json.Unmarshal([]byte(fmt.Sprintf("%q", s.fakeIssuer.URL)), &s.cluster.Login.OpenIDConnect.Issuer) + s.cluster.Login.OpenIDConnect.ClientID = "oidc#client#id" + s.cluster.Login.OpenIDConnect.ClientSecret = "oidc#client#secret" + s.validClientID = "oidc#client#id" + s.validClientSecret = "oidc#client#secret" + for _, trial := range []struct { + expectEmail string // "" if failure expected + setup func() + }{ + { + expectEmail: "user@oidc.example.com", + setup: func() { + c.Log("=== succeed because email_verified is false but not required") + s.authEmail = "user@oidc.example.com" + s.authEmailVerified = false + s.cluster.Login.OpenIDConnect.EmailClaim = "email" + s.cluster.Login.OpenIDConnect.EmailVerifiedClaim = "" + s.cluster.Login.OpenIDConnect.UsernameClaim = "" + }, + }, + { + expectEmail: "", + setup: func() { + c.Log("=== fail because email_verified is false and required") + s.authEmail = "user@oidc.example.com" + s.authEmailVerified = false + s.cluster.Login.OpenIDConnect.EmailClaim = "email" + s.cluster.Login.OpenIDConnect.EmailVerifiedClaim = "email_verified" + s.cluster.Login.OpenIDConnect.UsernameClaim = "" + }, + }, + { + expectEmail: "user@oidc.example.com", + setup: func() { + c.Log("=== succeed because email_verified is false but config uses custom 'verified' claim") + s.authEmail = "user@oidc.example.com" + s.authEmailVerified = false + s.cluster.Login.OpenIDConnect.EmailClaim = "email" + s.cluster.Login.OpenIDConnect.EmailVerifiedClaim = "alt_verified" + s.cluster.Login.OpenIDConnect.UsernameClaim = "" + }, + }, + { + expectEmail: "alt_email@example.com", + setup: func() { + c.Log("=== succeed with custom 'email' and 'email_verified' claims") + s.authEmail = "bad@wrong.example.com" + s.authEmailVerified = false + s.cluster.Login.OpenIDConnect.EmailClaim = "alt_email" + s.cluster.Login.OpenIDConnect.EmailVerifiedClaim = "alt_verified" + s.cluster.Login.OpenIDConnect.UsernameClaim = "alt_username" + }, + }, + } { + trial.setup() + if s.railsSpy != nil { + s.railsSpy.Close() + } + s.railsSpy = arvadostest.NewProxy(c, s.cluster.Services.RailsAPI) + s.localdb = NewConn(s.cluster) + *s.localdb.railsProxy = *rpc.NewConn(s.cluster.ClusterID, s.railsSpy.URL, true, rpc.PassthroughTokenProvider) + + state := s.startLogin(c) + resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{ + Code: s.validCode, + State: state, + }) + c.Assert(err, check.IsNil) + if trial.expectEmail == "" { + c.Check(resp.HTML.String(), check.Matches, `(?ms).*Login error.*`) + c.Check(resp.RedirectLocation, check.Equals, "") + continue + } + c.Check(resp.HTML.String(), check.Equals, "") + target, err := url.Parse(resp.RedirectLocation) + c.Assert(err, check.IsNil) + token := target.Query().Get("api_token") + c.Check(token, check.Matches, `v2/zzzzz-gj3su-.{15}/.{32,50}`) + authinfo := getCallbackAuthInfo(c, s.railsSpy) + c.Check(authinfo.Email, check.Equals, trial.expectEmail) + + switch s.cluster.Login.OpenIDConnect.UsernameClaim { + case "alt_username": + c.Check(authinfo.Username, check.Equals, "desired-username") + case "": + c.Check(authinfo.Username, check.Equals, "") + default: + c.Fail() // bad test case + } + } +} + +func (s *OIDCLoginSuite) TestGoogleLogin_Success(c *check.C) { state := s.startLogin(c) resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{ Code: s.validCode, @@ -292,7 +435,7 @@ func (s *LoginSuite) TestGoogleLogin_Success(c *check.C) { c.Check(err, check.ErrorMatches, `.*401 Unauthorized: Not logged in.*`) } -func (s *LoginSuite) TestGoogleLogin_RealName(c *check.C) { +func (s *OIDCLoginSuite) TestGoogleLogin_RealName(c *check.C) { s.authEmail = "joe.smith@primary.example.com" s.fakePeopleAPIResponse = map[string]interface{}{ "names": []map[string]interface{}{ @@ -319,7 +462,7 @@ func (s *LoginSuite) TestGoogleLogin_RealName(c *check.C) { c.Check(authinfo.LastName, check.Equals, "Psmith") } -func (s *LoginSuite) TestGoogleLogin_OIDCRealName(c *check.C) { +func (s *OIDCLoginSuite) TestGoogleLogin_OIDCRealName(c *check.C) { s.authName = "Joe P. Smith" s.authEmail = "joe.smith@primary.example.com" state := s.startLogin(c) @@ -334,7 +477,7 @@ func (s *LoginSuite) TestGoogleLogin_OIDCRealName(c *check.C) { } // People API returns some additional email addresses. -func (s *LoginSuite) TestGoogleLogin_AlternateEmailAddresses(c *check.C) { +func (s *OIDCLoginSuite) TestGoogleLogin_AlternateEmailAddresses(c *check.C) { s.authEmail = "joe.smith@primary.example.com" s.fakePeopleAPIResponse = map[string]interface{}{ "emailAddresses": []map[string]interface{}{ @@ -363,7 +506,7 @@ func (s *LoginSuite) TestGoogleLogin_AlternateEmailAddresses(c *check.C) { } // Primary address is not the one initially returned by oidc. -func (s *LoginSuite) TestGoogleLogin_AlternateEmailAddresses_Primary(c *check.C) { +func (s *OIDCLoginSuite) TestGoogleLogin_AlternateEmailAddresses_Primary(c *check.C) { s.authEmail = "joe.smith@alternate.example.com" s.fakePeopleAPIResponse = map[string]interface{}{ "emailAddresses": []map[string]interface{}{ @@ -392,7 +535,7 @@ func (s *LoginSuite) TestGoogleLogin_AlternateEmailAddresses_Primary(c *check.C) c.Check(authinfo.Username, check.Equals, "jsmith") } -func (s *LoginSuite) TestGoogleLogin_NoPrimaryEmailAddress(c *check.C) { +func (s *OIDCLoginSuite) TestGoogleLogin_NoPrimaryEmailAddress(c *check.C) { s.authEmail = "joe.smith@unverified.example.com" s.authEmailVerified = false s.fakePeopleAPIResponse = map[string]interface{}{ @@ -419,7 +562,7 @@ func (s *LoginSuite) TestGoogleLogin_NoPrimaryEmailAddress(c *check.C) { c.Check(authinfo.Username, check.Equals, "") } -func (s *LoginSuite) startLogin(c *check.C) (state string) { +func (s *OIDCLoginSuite) 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"}) @@ -431,7 +574,7 @@ func (s *LoginSuite) startLogin(c *check.C) (state string) { return } -func (s *LoginSuite) fakeToken(c *check.C, payload []byte) string { +func (s *OIDCLoginSuite) fakeToken(c *check.C, payload []byte) string { signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: s.issuerKey}, nil) if err != nil { c.Error(err)