Merge branch '16171-oidc'
authorTom Clegg <tom@tomclegg.ca>
Mon, 8 Jun 2020 14:17:55 +0000 (10:17 -0400)
committerTom Clegg <tom@tomclegg.ca>
Mon, 8 Jun 2020 14:17:55 +0000 (10:17 -0400)
refs #16171

Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom@tomclegg.ca>

doc/install/setup-login.html.textile.liquid
lib/config/config.default.yml
lib/config/export.go
lib/config/generated_config.go
lib/controller/handler_test.go
lib/controller/localdb/login.go
lib/controller/localdb/login_oidc.go [moved from lib/controller/localdb/login_google.go with 74% similarity]
lib/controller/localdb/login_oidc_test.go [moved from lib/controller/localdb/login_google_test.go with 74% similarity]
sdk/go/arvados/config.go

index 3fe442c75b2e48e67cc9de126abeabb011fb65c4..572a83f7060ab925b8c4e39b45186bf898f893dc 100644 (file)
@@ -12,6 +12,7 @@ SPDX-License-Identifier: CC-BY-SA-3.0
 Select one of the following login mechanisms for your cluster.
 
 # If all users will authenticate with Google, "configure Google login":#google.
+# If all users will authenticate with an OpenID Connect provider (other than Google), "configure OpenID Connect":#oidc.
 # If all users will authenticate with an existing LDAP service, "configure LDAP":#ldap.
 # If all users will authenticate using PAM as configured on your controller node, "configure PAM":#pam.
 
@@ -42,6 +43,19 @@ Use the <a href="https://console.developers.google.com" target="_blank">Google D
         ClientSecret: "zzzzzzzzzzzzzzzzzzzzzzzz"
 </pre>
 
+h2(#oidc). OpenID Connect
+
+With this configuration, users will sign in with a third-party OpenID Connect provider. The provider will supply appropriate values for the issuer URL, client ID, and client secret config entries.
+
+<pre>
+    Login:
+      OpenIDConnect:
+        Enable: true
+        Issuer: https://accounts.example.com/
+        ClientID: "0123456789abcdef"
+        ClientSecret: "zzzzzzzzzzzzzzzzzzzzzzzz"
+</pre>
+
 h2(#ldap). LDAP
 
 With this configuration, authentication uses an external LDAP service like OpenLDAP or Active Directory.
index 29418baa670666208fc9c64404ef6b8048bc0b73..219f6ef0ba91a1afb2e3311ca66b94f5a989020f 100644 (file)
@@ -551,6 +551,24 @@ Clusters:
         # work. If false, only the primary email address will be used.
         AlternateEmailAddresses: true
 
+      OpenIDConnect:
+        # Authenticate with an OpenID Connect provider.
+        Enable: false
+
+        # Issuer URL, e.g., "https://login.example.com".
+        #
+        # This must be exactly equal to the URL returned by the issuer
+        # itself in its config response ("isser" key). If the
+        # configured value is "https://example" and the provider
+        # returns "https://example:443" or "https://example/" then
+        # login will fail, even though those URLs are equivalent
+        # (RFC3986).
+        Issuer: ""
+
+        # Your client ID and client secret (supplied by the provider).
+        ClientID: ""
+        ClientSecret: ""
+
       PAM:
         # (Experimental) Use PAM to authenticate users.
         Enable: false
index 26782c8ba61dca3e4b657b911b6361439522ebb6..fc4908c15929d7807d4c63033d61cbcef3b4bcab 100644 (file)
@@ -151,6 +151,11 @@ var whitelist = map[string]bool{
        "Login.LDAP.URL":                               false,
        "Login.LDAP.UsernameAttribute":                 false,
        "Login.LoginCluster":                           true,
+       "Login.OpenIDConnect":                          true,
+       "Login.OpenIDConnect.ClientID":                 false,
+       "Login.OpenIDConnect.ClientSecret":             false,
+       "Login.OpenIDConnect.Enable":                   true,
+       "Login.OpenIDConnect.Issuer":                   false,
        "Login.PAM":                                    true,
        "Login.PAM.DefaultEmailDomain":                 false,
        "Login.PAM.Enable":                             true,
index 6a4ced7c6ea9776e0cb3f6fd92b052eb3637ddbd..6f8cab462bce2dc15118f31454b40bb35d06e3ff 100644 (file)
@@ -557,6 +557,24 @@ Clusters:
         # work. If false, only the primary email address will be used.
         AlternateEmailAddresses: true
 
+      OpenIDConnect:
+        # Authenticate with an OpenID Connect provider.
+        Enable: false
+
+        # Issuer URL, e.g., "https://login.example.com".
+        #
+        # This must be exactly equal to the URL returned by the issuer
+        # itself in its config response ("isser" key). If the
+        # configured value is "https://example" and the provider
+        # returns "https://example:443" or "https://example/" then
+        # login will fail, even though those URLs are equivalent
+        # (RFC3986).
+        Issuer: ""
+
+        # Your client ID and client secret (supplied by the provider).
+        ClientID: ""
+        ClientSecret: ""
+
       PAM:
         # (Experimental) Use PAM to authenticate users.
         Enable: false
index 3c7ae3a2d9e5abf2773af742e0bcbc1b2213aea5..c7bce97130bfb0e4b327d3d2233a41d9c9c3b73d 100644 (file)
@@ -71,7 +71,10 @@ func (s *HandlerSuite) TestConfigExport(c *check.C) {
                req := httptest.NewRequest(method, "/arvados/v1/config", nil)
                resp := httptest.NewRecorder()
                s.handler.ServeHTTP(resp, req)
-               c.Check(resp.Code, check.Equals, http.StatusOK)
+               c.Log(resp.Body.String())
+               if !c.Check(resp.Code, check.Equals, http.StatusOK) {
+                       continue
+               }
                c.Check(resp.Header().Get("Access-Control-Allow-Origin"), check.Equals, `*`)
                c.Check(resp.Header().Get("Access-Control-Allow-Methods"), check.Matches, `.*\bGET\b.*`)
                c.Check(resp.Header().Get("Access-Control-Allow-Headers"), check.Matches, `.+`)
@@ -80,12 +83,11 @@ func (s *HandlerSuite) TestConfigExport(c *check.C) {
                        continue
                }
                var cluster arvados.Cluster
-               c.Log(resp.Body.String())
                err := json.Unmarshal(resp.Body.Bytes(), &cluster)
                c.Check(err, check.IsNil)
                c.Check(cluster.ManagementToken, check.Equals, "")
                c.Check(cluster.SystemRootToken, check.Equals, "")
-               c.Check(cluster.Collections.BlobSigning, check.DeepEquals, true)
+               c.Check(cluster.Collections.BlobSigning, check.Equals, true)
                c.Check(cluster.Collections.BlobSigningTTL, check.Equals, arvados.Duration(23*time.Second))
        }
 }
index 0fd0a9ad2348045c1abd85b3f47a54a5d25dc202..9a0ee746e64006d08ab2d87981f6f47bf8fbcfa6 100644 (file)
@@ -24,21 +24,37 @@ type loginController interface {
 
 func chooseLoginController(cluster *arvados.Cluster, railsProxy *railsProxy) loginController {
        wantGoogle := cluster.Login.Google.Enable
+       wantOpenIDConnect := cluster.Login.OpenIDConnect.Enable
        wantSSO := cluster.Login.SSO.Enable
        wantPAM := cluster.Login.PAM.Enable
        wantLDAP := cluster.Login.LDAP.Enable
        switch {
-       case wantGoogle && !wantSSO && !wantPAM && !wantLDAP:
-               return &googleLoginController{Cluster: cluster, RailsProxy: railsProxy}
-       case !wantGoogle && wantSSO && !wantPAM && !wantLDAP:
+       case wantGoogle && !wantOpenIDConnect && !wantSSO && !wantPAM && !wantLDAP:
+               return &oidcLoginController{
+                       Cluster:            cluster,
+                       RailsProxy:         railsProxy,
+                       Issuer:             "https://accounts.google.com",
+                       ClientID:           cluster.Login.Google.ClientID,
+                       ClientSecret:       cluster.Login.Google.ClientSecret,
+                       UseGooglePeopleAPI: cluster.Login.Google.AlternateEmailAddresses,
+               }
+       case !wantGoogle && wantOpenIDConnect && !wantSSO && !wantPAM && !wantLDAP:
+               return &oidcLoginController{
+                       Cluster:      cluster,
+                       RailsProxy:   railsProxy,
+                       Issuer:       cluster.Login.OpenIDConnect.Issuer,
+                       ClientID:     cluster.Login.OpenIDConnect.ClientID,
+                       ClientSecret: cluster.Login.OpenIDConnect.ClientSecret,
+               }
+       case !wantGoogle && !wantOpenIDConnect && wantSSO && !wantPAM && !wantLDAP:
                return &ssoLoginController{railsProxy}
-       case !wantGoogle && !wantSSO && wantPAM && !wantLDAP:
+       case !wantGoogle && !wantOpenIDConnect && !wantSSO && wantPAM && !wantLDAP:
                return &pamLoginController{Cluster: cluster, RailsProxy: railsProxy}
-       case !wantGoogle && !wantSSO && !wantPAM && wantLDAP:
+       case !wantGoogle && !wantOpenIDConnect && !wantSSO && !wantPAM && wantLDAP:
                return &ldapLoginController{Cluster: cluster, RailsProxy: railsProxy}
        default:
                return errorLoginController{
-                       error: errors.New("configuration problem: exactly one of Login.Google, Login.SSO, Login.PAM, and Login.LDAP must be enabled"),
+                       error: errors.New("configuration problem: exactly one of Login.Google, Login.OpenIDConnect, Login.SSO, Login.PAM, and Login.LDAP must be enabled"),
                }
        }
 }
similarity index 74%
rename from lib/controller/localdb/login_google.go
rename to lib/controller/localdb/login_oidc.go
index 144b04c46d7ee6ab865eff24a7acc06db08f5dd9..f42b8f8beaf1d2721a78c9883c20353eabd0e43b 100644 (file)
@@ -30,70 +30,72 @@ import (
        "google.golang.org/api/people/v1"
 )
 
-type googleLoginController struct {
-       Cluster    *arvados.Cluster
-       RailsProxy *railsProxy
+type oidcLoginController struct {
+       Cluster            *arvados.Cluster
+       RailsProxy         *railsProxy
+       Issuer             string // OIDC issuer URL, e.g., "https://accounts.google.com"
+       ClientID           string
+       ClientSecret       string
+       UseGooglePeopleAPI bool // Use Google People API to look up alternate email addresses
 
-       issuer            string // override OIDC issuer URL (normally https://accounts.google.com) for testing
-       peopleAPIBasePath string // override Google People API base URL (normally set by google pkg to https://people.googleapis.com/)
-       provider          *oidc.Provider
-       mu                sync.Mutex
+       // override Google People API base URL for testing purposes
+       // (normally empty, set by google pkg to
+       // https://people.googleapis.com/)
+       peopleAPIBasePath string
+
+       provider   *oidc.Provider        // initialized by setup()
+       oauth2conf *oauth2.Config        // initialized by setup()
+       verifier   *oidc.IDTokenVerifier // initialized by setup()
+       mu         sync.Mutex            // protects setup()
 }
 
-func (ctrl *googleLoginController) getProvider() (*oidc.Provider, error) {
+// Initialize ctrl.provider and ctrl.oauth2conf.
+func (ctrl *oidcLoginController) setup() error {
        ctrl.mu.Lock()
        defer ctrl.mu.Unlock()
-       if ctrl.provider == nil {
-               issuer := ctrl.issuer
-               if issuer == "" {
-                       issuer = "https://accounts.google.com"
-               }
-               provider, err := oidc.NewProvider(context.Background(), issuer)
-               if err != nil {
-                       return nil, err
-               }
-               ctrl.provider = provider
+       if ctrl.provider != nil {
+               // already set up
+               return nil
        }
-       return ctrl.provider, nil
-}
-
-func (ctrl *googleLoginController) Logout(ctx context.Context, opts arvados.LogoutOptions) (arvados.LogoutResponse, error) {
-       return noopLogout(ctrl.Cluster, opts)
-}
-
-func (ctrl *googleLoginController) Login(ctx context.Context, opts arvados.LoginOptions) (arvados.LoginResponse, error) {
-       provider, err := ctrl.getProvider()
+       redirURL, err := (*url.URL)(&ctrl.Cluster.Services.Controller.ExternalURL).Parse("/" + arvados.EndpointLogin.Path)
        if err != nil {
-               return loginError(fmt.Errorf("error setting up OpenID Connect provider: %s", err))
+               return fmt.Errorf("error making redirect URL: %s", err)
        }
-       redirURL, err := (*url.URL)(&ctrl.Cluster.Services.Controller.ExternalURL).Parse("/login")
+       provider, err := oidc.NewProvider(context.Background(), ctrl.Issuer)
        if err != nil {
-               return loginError(fmt.Errorf("error making redirect URL: %s", err))
+               return err
        }
-       conf := &oauth2.Config{
-               ClientID:     ctrl.Cluster.Login.Google.ClientID,
-               ClientSecret: ctrl.Cluster.Login.Google.ClientSecret,
+       ctrl.oauth2conf = &oauth2.Config{
+               ClientID:     ctrl.ClientID,
+               ClientSecret: ctrl.ClientSecret,
                Endpoint:     provider.Endpoint(),
                Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
                RedirectURL:  redirURL.String(),
        }
-       verifier := provider.Verifier(&oidc.Config{
-               ClientID: conf.ClientID,
+       ctrl.verifier = provider.Verifier(&oidc.Config{
+               ClientID: ctrl.ClientID,
        })
+       ctrl.provider = provider
+       return nil
+}
+
+func (ctrl *oidcLoginController) Logout(ctx context.Context, opts arvados.LogoutOptions) (arvados.LogoutResponse, error) {
+       return noopLogout(ctrl.Cluster, opts)
+}
+
+func (ctrl *oidcLoginController) Login(ctx context.Context, opts arvados.LoginOptions) (arvados.LoginResponse, error) {
+       err := ctrl.setup()
+       if err != nil {
+               return loginError(fmt.Errorf("error setting up OpenID Connect provider: %s", err))
+       }
        if opts.State == "" {
-               // Initiate Google sign-in.
+               // Initiate OIDC sign-in.
                if opts.ReturnTo == "" {
                        return loginError(errors.New("missing return_to parameter"))
                }
-               me := url.URL(ctrl.Cluster.Services.Controller.ExternalURL)
-               callback, err := me.Parse("/" + arvados.EndpointLogin.Path)
-               if err != nil {
-                       return loginError(err)
-               }
-               conf.RedirectURL = callback.String()
                state := ctrl.newOAuth2State([]byte(ctrl.Cluster.SystemRootToken), opts.Remote, opts.ReturnTo)
                return arvados.LoginResponse{
-                       RedirectLocation: conf.AuthCodeURL(state.String(),
+                       RedirectLocation: ctrl.oauth2conf.AuthCodeURL(state.String(),
                                // prompt=select_account tells Google
                                // to show the "choose which Google
                                // account" page, even if the client
@@ -102,12 +104,12 @@ func (ctrl *googleLoginController) Login(ctx context.Context, opts arvados.Login
                                oauth2.SetAuthURLParam("prompt", "select_account")),
                }, nil
        } else {
-               // Callback after Google sign-in.
+               // Callback after OIDC sign-in.
                state := ctrl.parseOAuth2State(opts.State)
                if !state.verify([]byte(ctrl.Cluster.SystemRootToken)) {
                        return loginError(errors.New("invalid OAuth2 state"))
                }
-               oauth2Token, err := conf.Exchange(ctx, opts.Code)
+               oauth2Token, err := ctrl.oauth2conf.Exchange(ctx, opts.Code)
                if err != nil {
                        return loginError(fmt.Errorf("error in OAuth2 exchange: %s", err))
                }
@@ -115,11 +117,11 @@ func (ctrl *googleLoginController) Login(ctx context.Context, opts arvados.Login
                if !ok {
                        return loginError(errors.New("error in OAuth2 exchange: no ID token in OAuth2 token"))
                }
-               idToken, err := verifier.Verify(ctx, rawIDToken)
+               idToken, err := ctrl.verifier.Verify(ctx, rawIDToken)
                if err != nil {
                        return loginError(fmt.Errorf("error verifying ID token: %s", err))
                }
-               authinfo, err := ctrl.getAuthInfo(ctx, ctrl.Cluster, conf, oauth2Token, idToken)
+               authinfo, err := ctrl.getAuthInfo(ctx, oauth2Token, idToken)
                if err != nil {
                        return loginError(err)
                }
@@ -131,7 +133,7 @@ func (ctrl *googleLoginController) Login(ctx context.Context, opts arvados.Login
        }
 }
 
-func (ctrl *googleLoginController) UserAuthenticate(ctx context.Context, opts arvados.UserAuthenticateOptions) (arvados.APIClientAuthorization, error) {
+func (ctrl *oidcLoginController) UserAuthenticate(ctx context.Context, opts arvados.UserAuthenticateOptions) (arvados.APIClientAuthorization, error) {
        return arvados.APIClientAuthorization{}, httpserver.ErrorWithStatus(errors.New("username/password authentication is not available"), http.StatusBadRequest)
 }
 
@@ -139,7 +141,7 @@ func (ctrl *googleLoginController) UserAuthenticate(ctx context.Context, opts ar
 // primary address at index 0. The provided defaultAddr is always
 // included in the returned slice, and is used as the primary if the
 // Google API does not indicate one.
-func (ctrl *googleLoginController) getAuthInfo(ctx context.Context, cluster *arvados.Cluster, conf *oauth2.Config, token *oauth2.Token, idToken *oidc.IDToken) (*rpc.UserSessionAuthInfo, error) {
+func (ctrl *oidcLoginController) getAuthInfo(ctx context.Context, token *oauth2.Token, idToken *oidc.IDToken) (*rpc.UserSessionAuthInfo, error) {
        var ret rpc.UserSessionAuthInfo
        defer ctxlog.FromContext(ctx).WithField("ret", &ret).Debug("getAuthInfo returned")
 
@@ -162,14 +164,14 @@ func (ctrl *googleLoginController) getAuthInfo(ctx context.Context, cluster *arv
                ret.Email = claims.Email
        }
 
-       if !ctrl.Cluster.Login.Google.AlternateEmailAddresses {
+       if !ctrl.UseGooglePeopleAPI {
                if ret.Email == "" {
                        return nil, fmt.Errorf("cannot log in with unverified email address %q", claims.Email)
                }
                return &ret, nil
        }
 
-       svc, err := people.NewService(ctx, option.WithTokenSource(conf.TokenSource(ctx, token)), option.WithScopes(people.UserEmailsReadScope))
+       svc, err := people.NewService(ctx, option.WithTokenSource(ctrl.oauth2conf.TokenSource(ctx, token)), option.WithScopes(people.UserEmailsReadScope))
        if err != nil {
                return nil, fmt.Errorf("error setting up People API: %s", err)
        }
@@ -237,7 +239,7 @@ func loginError(sendError error) (resp arvados.LoginResponse, err error) {
        return
 }
 
-func (ctrl *googleLoginController) newOAuth2State(key []byte, remote, returnTo string) oauth2State {
+func (ctrl *oidcLoginController) newOAuth2State(key []byte, remote, returnTo string) oauth2State {
        s := oauth2State{
                Time:     time.Now().Unix(),
                Remote:   remote,
@@ -254,7 +256,7 @@ type oauth2State struct {
        ReturnTo string // redirect target
 }
 
-func (ctrl *googleLoginController) parseOAuth2State(encoded string) (s oauth2State) {
+func (ctrl *oidcLoginController) parseOAuth2State(encoded string) (s oauth2State) {
        // Errors are not checked. If decoding/parsing fails, the
        // token will be rejected by verify().
        decoded, _ := base64.RawURLEncoding.DecodeString(encoded)
similarity index 74%
rename from lib/controller/localdb/login_google_test.go
rename to lib/controller/localdb/login_oidc_test.go
index 495fbb69b31e5c659b4476c772303658e46592c1..aa437218ff79eaae26ee93d8450af72561387919 100644 (file)
@@ -9,6 +9,7 @@ import (
        "context"
        "crypto/rand"
        "crypto/rsa"
+       "encoding/base64"
        "encoding/json"
        "fmt"
        "net/http"
@@ -34,9 +35,9 @@ 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
@@ -47,21 +48,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,16 +86,29 @@ 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,
@@ -145,40 +161,44 @@ 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)
-       s.localdb.loginController.(*googleLoginController).issuer = s.fakeIssuer.URL
-       s.localdb.loginController.(*googleLoginController).peopleAPIBasePath = s.fakePeopleAPI.URL
+       c.Assert(s.localdb.loginController, check.FitsTypeOf, (*oidcLoginController)(nil))
+       s.localdb.loginController.(*oidcLoginController).Issuer = s.fakeIssuer.URL
+       s.localdb.loginController.(*oidcLoginController).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) {
+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)
@@ -188,7 +208,7 @@ func (s *LoginSuite) TestGoogleLogin_Start(c *check.C) {
                c.Check(target.Host, check.Equals, issuerURL.Host)
                q := target.Query()
                c.Check(q.Get("client_id"), check.Equals, "test%client$id")
-               state := s.localdb.loginController.(*googleLoginController).parseOAuth2State(q.Get("state"))
+               state := s.localdb.loginController.(*oidcLoginController).parseOAuth2State(q.Get("state"))
                c.Check(state.verify([]byte(s.cluster.SystemRootToken)), check.Equals, true)
                c.Check(state.Time, check.Not(check.Equals), 0)
                c.Check(state.Remote, check.Equals, remote)
@@ -196,7 +216,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",
@@ -207,7 +227,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,
@@ -218,16 +238,16 @@ 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`)
        }))
-       s.localdb.loginController.(*googleLoginController).peopleAPIBasePath = s.fakePeopleAPI.URL
+       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)
@@ -240,7 +260,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{
@@ -251,7 +299,29 @@ 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) TestOIDCLogin_Success(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"
+       s.localdb = NewConn(s.cluster)
+       state := s.startLogin(c)
+       resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
+               Code:  s.validCode,
+               State: state,
+       })
+       c.Assert(err, check.IsNil)
+       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}`)
+}
+
+func (s *OIDCLoginSuite) TestGoogleLogin_Success(c *check.C) {
        state := s.startLogin(c)
        resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
                Code:  s.validCode,
@@ -290,7 +360,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{}{
@@ -317,7 +387,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)
@@ -332,7 +402,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{}{
@@ -361,7 +431,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{}{
@@ -390,7 +460,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{}{
@@ -417,7 +487,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"})
@@ -429,7 +499,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)
index 1efc87ea72ac6f67496e0b4df931905092f2c6fa..dbd9f71099619203bb38f4dd1118b865f5c2f662 100644 (file)
@@ -156,6 +156,12 @@ type Cluster struct {
                        ClientSecret            string
                        AlternateEmailAddresses bool
                }
+               OpenIDConnect struct {
+                       Enable       bool
+                       Issuer       string
+                       ClientID     string
+                       ClientSecret string
+               }
                PAM struct {
                        Enable             bool
                        Service            string