[ARVADOS] created: 1.3.0-1824-gdeaf1d8f2

Git user git at public.curoverse.com
Thu Oct 31 19:03:19 UTC 2019


        at  deaf1d8f2f694b09562eddac055ccebba5a98517 (commit)


commit deaf1d8f2f694b09562eddac055ccebba5a98517
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Thu Oct 31 15:00:06 2019 -0400

    15107: Add LoginCluster test.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/federation/login_test.go b/lib/controller/federation/login_test.go
new file mode 100644
index 000000000..e001014e2
--- /dev/null
+++ b/lib/controller/federation/login_test.go
@@ -0,0 +1,32 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package federation
+
+import (
+	"context"
+	"net/url"
+
+	"git.curoverse.com/arvados.git/sdk/go/arvados"
+	"git.curoverse.com/arvados.git/sdk/go/arvadostest"
+	check "gopkg.in/check.v1"
+)
+
+func (s *FederationSuite) TestDeferToLoginCluster(c *check.C) {
+	s.addHTTPRemote(c, "zhome", &arvadostest.APIStub{})
+	s.cluster.Login.LoginCluster = "zhome"
+
+	returnTo := "https://app.example.com/foo?bar"
+	for _, remote := range []string{"", "ccccc"} {
+		resp, err := s.fed.Login(context.Background(), arvados.LoginOptions{Remote: remote, ReturnTo: returnTo})
+		c.Check(err, check.IsNil)
+		c.Logf("remote %q -- RedirectLocation %q", remote, resp.RedirectLocation)
+		target, err := url.Parse(resp.RedirectLocation)
+		c.Check(err, check.IsNil)
+		c.Check(target.Host, check.Equals, s.cluster.RemoteClusters["zhome"].Host)
+		c.Check(target.Scheme, check.Equals, "http")
+		c.Check(target.Query().Get("remote"), check.Equals, remote)
+		c.Check(target.Query().Get("return_to"), check.Equals, returnTo)
+	}
+}

commit 0e077171d332434bf727a018691165a6b0621b68
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Thu Oct 31 10:07:31 2019 -0400

    15107: Add built-in Google login option, as an alternative to sso.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/config/config.default.yml b/lib/config/config.default.yml
index 9f473e361..fee8503df 100644
--- a/lib/config/config.default.yml
+++ b/lib/config/config.default.yml
@@ -493,8 +493,21 @@ Clusters:
     Login:
       # These settings are provided by your OAuth2 provider (eg
       # Google) used to perform upstream authentication.
-      ProviderAppSecret: ""
       ProviderAppID: ""
+      ProviderAppSecret: ""
+
+      # (Experimental) Authenticate with Google, bypassing the
+      # SSO-provider gateway service. Use the Google Cloud console to
+      # generate the Client ID and secret (APIs and Services >
+      # Credentials > Create credentials > OAuth client ID > Web
+      # application) and add your controller's /login URL (e.g.,
+      # "https://zzzzz.example.com/login") as an authorized redirect
+      # URL.
+      #
+      # Requires EnableBetaController14287. ProviderAppID must be
+      # blank.
+      GoogleClientID: ""
+      GoogleClientSecret: ""
 
       # The cluster ID to delegate the user database.  When set,
       # logins on this cluster will be redirected to the login cluster
diff --git a/lib/config/export.go b/lib/config/export.go
index 5f17b3459..0dd90ff65 100644
--- a/lib/config/export.go
+++ b/lib/config/export.go
@@ -130,8 +130,10 @@ var whitelist = map[string]bool{
 	"InstanceTypes.*":                              true,
 	"InstanceTypes.*.*":                            true,
 	"Login":                                        true,
-	"Login.ProviderAppSecret":                      false,
+	"Login.GoogleClientID":                         false,
+	"Login.GoogleClientSecret":                     false,
 	"Login.ProviderAppID":                          false,
+	"Login.ProviderAppSecret":                      false,
 	"Login.LoginCluster":                           true,
 	"Login.RemoteTokenRefresh":                     true,
 	"Mail":                                         false,
diff --git a/lib/config/generated_config.go b/lib/config/generated_config.go
index dbf11569f..42beb0663 100644
--- a/lib/config/generated_config.go
+++ b/lib/config/generated_config.go
@@ -499,8 +499,21 @@ Clusters:
     Login:
       # These settings are provided by your OAuth2 provider (eg
       # Google) used to perform upstream authentication.
-      ProviderAppSecret: ""
       ProviderAppID: ""
+      ProviderAppSecret: ""
+
+      # (Experimental) Authenticate with Google, bypassing the
+      # SSO-provider gateway service. Use the Google Cloud console to
+      # generate the Client ID and secret (APIs and Services >
+      # Credentials > Create credentials > OAuth client ID > Web
+      # application) and add your controller's /login URL (e.g.,
+      # "https://zzzzz.example.com/login") as an authorized redirect
+      # URL.
+      #
+      # Requires EnableBetaController14287. ProviderAppID must be
+      # blank.
+      GoogleClientID: ""
+      GoogleClientSecret: ""
 
       # The cluster ID to delegate the user database.  When set,
       # logins on this cluster will be redirected to the login cluster
diff --git a/lib/controller/federation/conn.go b/lib/controller/federation/conn.go
index 3bcafacd2..3829d0a40 100644
--- a/lib/controller/federation/conn.go
+++ b/lib/controller/federation/conn.go
@@ -17,7 +17,7 @@ import (
 	"strings"
 
 	"git.curoverse.com/arvados.git/lib/config"
-	"git.curoverse.com/arvados.git/lib/controller/railsproxy"
+	"git.curoverse.com/arvados.git/lib/controller/localdb"
 	"git.curoverse.com/arvados.git/lib/controller/rpc"
 	"git.curoverse.com/arvados.git/sdk/go/arvados"
 	"git.curoverse.com/arvados.git/sdk/go/auth"
@@ -31,7 +31,7 @@ type Conn struct {
 }
 
 func New(cluster *arvados.Cluster) *Conn {
-	local := railsproxy.NewConn(cluster)
+	local := localdb.NewConn(cluster)
 	remotes := map[string]backend{}
 	for id, remote := range cluster.RemoteClusters {
 		if !remote.Proxy {
@@ -185,6 +185,30 @@ func (conn *Conn) ConfigGet(ctx context.Context) (json.RawMessage, error) {
 	return json.RawMessage(buf.Bytes()), err
 }
 
+func (conn *Conn) Login(ctx context.Context, options arvados.LoginOptions) (arvados.LoginResponse, error) {
+	if id := conn.cluster.Login.LoginCluster; id != "" && id != conn.cluster.ClusterID {
+		// defer entire login procedure to designated cluster
+		remote, ok := conn.remotes[id]
+		if !ok {
+			return arvados.LoginResponse{}, fmt.Errorf("configuration problem: designated login cluster %q is not defined", id)
+		}
+		baseURL := remote.BaseURL()
+		target, err := baseURL.Parse(arvados.EndpointLogin.Path)
+		if err != nil {
+			return arvados.LoginResponse{}, fmt.Errorf("internal error getting redirect target: %s", err)
+		}
+		target.RawQuery = url.Values{
+			"return_to": []string{options.ReturnTo},
+			"remote":    []string{options.Remote},
+		}.Encode()
+		return arvados.LoginResponse{
+			RedirectLocation: target.String(),
+		}, nil
+	} else {
+		return conn.local.Login(ctx, options)
+	}
+}
+
 func (conn *Conn) CollectionGet(ctx context.Context, options arvados.GetOptions) (arvados.Collection, error) {
 	if len(options.UUID) == 27 {
 		// UUID is really a UUID
@@ -291,7 +315,10 @@ func (conn *Conn) APIClientAuthorizationCurrent(ctx context.Context, options arv
 	return conn.chooseBackend(options.UUID).APIClientAuthorizationCurrent(ctx, options)
 }
 
-type backend interface{ arvados.API }
+type backend interface {
+	arvados.API
+	BaseURL() url.URL
+}
 
 type notFoundError struct{}
 
diff --git a/lib/controller/federation/list_test.go b/lib/controller/federation/list_test.go
index e9e8950b9..c9b981fc1 100644
--- a/lib/controller/federation/list_test.go
+++ b/lib/controller/federation/list_test.go
@@ -59,14 +59,14 @@ func (s *FederationSuite) SetUpTest(c *check.C) {
 	s.fed = New(s.cluster)
 }
 
-func (s *FederationSuite) addDirectRemote(c *check.C, id string, backend arvados.API) {
+func (s *FederationSuite) addDirectRemote(c *check.C, id string, backend backend) {
 	s.cluster.RemoteClusters[id] = arvados.RemoteCluster{
 		Host: "in-process.local",
 	}
 	s.fed.remotes[id] = backend
 }
 
-func (s *FederationSuite) addHTTPRemote(c *check.C, id string, backend arvados.API) {
+func (s *FederationSuite) addHTTPRemote(c *check.C, id string, backend backend) {
 	srv := httpserver.Server{Addr: ":"}
 	srv.Handler = router.New(backend)
 	c.Check(srv.Start(), check.IsNil)
diff --git a/lib/controller/handler.go b/lib/controller/handler.go
index f7b2362f3..f925233ba 100644
--- a/lib/controller/handler.go
+++ b/lib/controller/handler.go
@@ -83,6 +83,7 @@ func (h *Handler) setup() {
 	if h.Cluster.EnableBetaController14287 {
 		mux.Handle("/arvados/v1/collections", rtr)
 		mux.Handle("/arvados/v1/collections/", rtr)
+		mux.Handle("/login", rtr)
 	}
 
 	hs := http.NotFoundHandler()
diff --git a/lib/controller/handler_test.go b/lib/controller/handler_test.go
index 5dc0b1e86..ebadc5d02 100644
--- a/lib/controller/handler_test.go
+++ b/lib/controller/handler_test.go
@@ -165,11 +165,18 @@ func (s *HandlerSuite) TestProxyNotFound(c *check.C) {
 }
 
 func (s *HandlerSuite) TestProxyRedirect(c *check.C) {
+	s.cluster.Login.ProviderAppID = "test"
+	s.cluster.Login.ProviderAppSecret = "test"
 	req := httptest.NewRequest("GET", "https://0.0.0.0:1/login?return_to=foo", nil)
 	resp := httptest.NewRecorder()
 	s.handler.ServeHTTP(resp, req)
-	c.Check(resp.Code, check.Equals, http.StatusFound)
-	c.Check(resp.Header().Get("Location"), check.Matches, `https://0.0.0.0:1/auth/joshid\?return_to=%2Cfoo&?`)
+	if !c.Check(resp.Code, check.Equals, http.StatusFound) {
+		c.Log(resp.Body.String())
+	}
+	// Old "proxy entire request" code path returns an absolute
+	// URL. New lib/controller/federation code path returns a
+	// relative URL.
+	c.Check(resp.Header().Get("Location"), check.Matches, `(https://0.0.0.0:1)?/auth/joshid\?return_to=%2Cfoo&?`)
 }
 
 func (s *HandlerSuite) TestValidateV1APIToken(c *check.C) {
diff --git a/lib/controller/localdb/conn.go b/lib/controller/localdb/conn.go
new file mode 100644
index 000000000..835ab4350
--- /dev/null
+++ b/lib/controller/localdb/conn.go
@@ -0,0 +1,43 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+	"context"
+	"errors"
+
+	"git.curoverse.com/arvados.git/lib/controller/railsproxy"
+	"git.curoverse.com/arvados.git/lib/controller/rpc"
+	"git.curoverse.com/arvados.git/sdk/go/arvados"
+)
+
+type railsProxy = rpc.Conn
+
+type Conn struct {
+	cluster     *arvados.Cluster
+	*railsProxy // handles API methods that aren't defined on Conn itself
+
+	googleLoginController
+}
+
+func NewConn(cluster *arvados.Cluster) *Conn {
+	return &Conn{
+		cluster:    cluster,
+		railsProxy: railsproxy.NewConn(cluster),
+	}
+}
+
+func (conn *Conn) Login(ctx context.Context, opts arvados.LoginOptions) (arvados.LoginResponse, error) {
+	wantGoogle := conn.cluster.Login.GoogleClientID != ""
+	wantSSO := conn.cluster.Login.ProviderAppID != ""
+	if wantGoogle == wantSSO {
+		return arvados.LoginResponse{}, errors.New("configuration problem: exactly one of Login.GoogleClientID and Login.ProviderAppID must be configured")
+	} else if wantGoogle {
+		return conn.googleLoginController.Login(ctx, conn.cluster, conn.railsProxy, opts)
+	} else {
+		// Proxy to RailsAPI, which hands off to sso-provider.
+		return conn.railsProxy.Login(ctx, opts)
+	}
+}
diff --git a/lib/controller/localdb/login.go b/lib/controller/localdb/login.go
new file mode 100644
index 000000000..b41ca6490
--- /dev/null
+++ b/lib/controller/localdb/login.go
@@ -0,0 +1,189 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+	"bytes"
+	"context"
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"net/url"
+	"strings"
+	"sync"
+	"text/template"
+	"time"
+
+	"git.curoverse.com/arvados.git/lib/controller/rpc"
+	"git.curoverse.com/arvados.git/sdk/go/arvados"
+	"git.curoverse.com/arvados.git/sdk/go/auth"
+	"github.com/coreos/go-oidc"
+	"golang.org/x/oauth2"
+)
+
+type googleLoginController struct {
+	issuer   string // override OIDC issuer URL (normally https://accounts.google.com) for testing
+	provider *oidc.Provider
+	mu       sync.Mutex
+}
+
+func (ctrl *googleLoginController) getProvider() (*oidc.Provider, 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
+	}
+	return ctrl.provider, nil
+}
+
+func (ctrl *googleLoginController) Login(ctx context.Context, cluster *arvados.Cluster, railsproxy *railsProxy, opts arvados.LoginOptions) (arvados.LoginResponse, error) {
+	provider, err := ctrl.getProvider()
+	if err != nil {
+		return ctrl.loginError(fmt.Errorf("error setting up OpenID Connect provider: %s", err))
+	}
+	redirURL, err := (*url.URL)(&cluster.Services.Controller.ExternalURL).Parse("/login")
+	if err != nil {
+		return ctrl.loginError(fmt.Errorf("error making redirect URL: %s", err))
+	}
+	conf := &oauth2.Config{
+		ClientID:     cluster.Login.GoogleClientID,
+		ClientSecret: cluster.Login.GoogleClientSecret,
+		Endpoint:     provider.Endpoint(),
+		Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
+		RedirectURL:  redirURL.String(),
+	}
+	verifier := provider.Verifier(&oidc.Config{
+		ClientID: conf.ClientID,
+	})
+	if opts.State == "" {
+		// Initiate Google sign-in.
+		if opts.ReturnTo == "" {
+			return ctrl.loginError(errors.New("missing return_to parameter"))
+		}
+		me := url.URL(cluster.Services.Controller.ExternalURL)
+		callback, err := me.Parse("/" + arvados.EndpointLogin.Path)
+		if err != nil {
+			return ctrl.loginError(err)
+		}
+		conf.RedirectURL = callback.String()
+		state := ctrl.newOAuth2State([]byte(cluster.SystemRootToken), opts.Remote, opts.ReturnTo)
+		return arvados.LoginResponse{
+			RedirectLocation: conf.AuthCodeURL(state.String(),
+				// prompt=select_account tells Google
+				// to show the "choose which Google
+				// account" page, even if the client
+				// is currently logged in to exactly
+				// one Google account.
+				oauth2.SetAuthURLParam("prompt", "select_account")),
+		}, nil
+	} else {
+		// Callback after Google sign-in.
+		state := ctrl.parseOAuth2State(opts.State)
+		if !state.verify([]byte(cluster.SystemRootToken)) {
+			return ctrl.loginError(errors.New("invalid OAuth2 state"))
+		}
+		oauth2Token, err := conf.Exchange(ctx, opts.Code)
+		if err != nil {
+			return ctrl.loginError(fmt.Errorf("error in OAuth2 exchange: %s", err))
+		}
+		rawIDToken, ok := oauth2Token.Extra("id_token").(string)
+		if !ok {
+			return ctrl.loginError(errors.New("error in OAuth2 exchange: no ID token in OAuth2 token"))
+		}
+		idToken, err := verifier.Verify(ctx, rawIDToken)
+		if err != nil {
+			return ctrl.loginError(fmt.Errorf("error verifying ID token: %s", err))
+		}
+		var claims struct {
+			Email    string `json:"email"`
+			Verified bool   `json:"email_verified"`
+		}
+		if err := idToken.Claims(&claims); err != nil {
+			return ctrl.loginError(fmt.Errorf("error extracting claims from ID token: %s", err))
+		}
+		if !claims.Verified {
+			return ctrl.loginError(errors.New("cannot authenticate using an unverified email address"))
+		}
+		ctxRoot := auth.NewContext(ctx, &auth.Credentials{Tokens: []string{cluster.SystemRootToken}})
+		return railsproxy.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
+			ReturnTo: state.Remote + "," + state.ReturnTo,
+			AuthInfo: map[string]interface{}{
+				"email": claims.Email,
+			},
+		})
+	}
+}
+
+func (ctrl *googleLoginController) loginError(sendError error) (resp arvados.LoginResponse, err error) {
+	tmpl, err := template.New("error").Parse(`<h2>Login error:</h2><p>{{.}}</p>`)
+	if err != nil {
+		return
+	}
+	err = tmpl.Execute(&resp.HTML, sendError.Error())
+	return
+}
+
+func (ctrl *googleLoginController) newOAuth2State(key []byte, remote, returnTo string) oauth2State {
+	s := oauth2State{
+		Time:     time.Now().Unix(),
+		Remote:   remote,
+		ReturnTo: returnTo,
+	}
+	s.HMAC = s.computeHMAC(key)
+	return s
+}
+
+type oauth2State struct {
+	HMAC     []byte // hash of other fields; see computeHMAC()
+	Time     int64  // creation time (unix timestamp)
+	Remote   string // remote cluster if requesting a salted token, otherwise blank
+	ReturnTo string // redirect target
+}
+
+func (ctrl *googleLoginController) 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)
+	f := strings.Split(string(decoded), "\n")
+	if len(f) != 4 {
+		return
+	}
+	fmt.Sscanf(f[0], "%x", &s.HMAC)
+	fmt.Sscanf(f[1], "%x", &s.Time)
+	fmt.Sscanf(f[2], "%s", &s.Remote)
+	fmt.Sscanf(f[3], "%s", &s.ReturnTo)
+	return
+}
+
+func (s oauth2State) verify(key []byte) bool {
+	if delta := time.Now().Unix() - s.Time; delta < 0 || delta > 300 {
+		return false
+	}
+	return hmac.Equal(s.computeHMAC(key), s.HMAC)
+}
+
+func (s oauth2State) String() string {
+	var buf bytes.Buffer
+	enc := base64.NewEncoder(base64.RawURLEncoding, &buf)
+	fmt.Fprintf(enc, "%x\n%x\n%s\n%s", s.HMAC, s.Time, s.Remote, s.ReturnTo)
+	enc.Close()
+	return buf.String()
+}
+
+func (s oauth2State) computeHMAC(key []byte) []byte {
+	mac := hmac.New(sha256.New, key)
+	fmt.Fprintf(mac, "%x %s %s", s.Time, s.Remote, s.ReturnTo)
+	return mac.Sum(nil)
+}
diff --git a/lib/controller/localdb/login_test.go b/lib/controller/localdb/login_test.go
new file mode 100644
index 000000000..f35a2d25f
--- /dev/null
+++ b/lib/controller/localdb/login_test.go
@@ -0,0 +1,225 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+	"time"
+
+	"git.curoverse.com/arvados.git/lib/config"
+	"git.curoverse.com/arvados.git/sdk/go/arvados"
+	"git.curoverse.com/arvados.git/sdk/go/auth"
+	"git.curoverse.com/arvados.git/sdk/go/ctxlog"
+	check "gopkg.in/check.v1"
+	jose "gopkg.in/square/go-jose.v2"
+)
+
+// Gocheck boilerplate
+func Test(t *testing.T) {
+	check.TestingT(t)
+}
+
+var _ = check.Suite(&LoginSuite{})
+
+type LoginSuite struct {
+	cluster    *arvados.Cluster
+	ctx        context.Context
+	localdb    *Conn
+	fakeIssuer *httptest.Server
+	issuerKey  *rsa.PrivateKey
+
+	// expected token request
+	validCode string
+	// desired response from token endpoint
+	authEmail         string
+	authEmailVerified bool
+}
+
+func (s *LoginSuite) SetUpTest(c *check.C) {
+	var err error
+	s.issuerKey, err = rsa.GenerateKey(rand.Reader, 2048)
+	c.Assert(err, check.IsNil)
+
+	s.authEmail = "active-user at arvados.local"
+	s.authEmailVerified = true
+	s.fakeIssuer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+		req.ParseForm()
+		c.Logf("fakeIssuer: got req: %s %s %s", req.Method, req.URL, req.Form)
+		w.Header().Set("Content-Type", "application/json")
+		switch req.URL.Path {
+		case "/.well-known/openid-configuration":
+			json.NewEncoder(w).Encode(map[string]interface{}{
+				"issuer":                 s.fakeIssuer.URL,
+				"authorization_endpoint": s.fakeIssuer.URL + "/auth",
+				"token_endpoint":         s.fakeIssuer.URL + "/token",
+				"jwks_uri":               s.fakeIssuer.URL + "/jwks",
+				"userinfo_endpoint":      s.fakeIssuer.URL + "/userinfo",
+			})
+		case "/token":
+			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"},
+				"sub":            "fake-user-id",
+				"exp":            time.Now().UTC().Add(time.Minute).UnixNano(),
+				"iat":            time.Now().UTC().UnixNano(),
+				"nonce":          "fake-nonce",
+				"email":          s.authEmail,
+				"email_verified": s.authEmailVerified,
+			})
+			json.NewEncoder(w).Encode(struct {
+				AccessToken  string `json:"access_token"`
+				TokenType    string `json:"token_type"`
+				RefreshToken string `json:"refresh_token"`
+				ExpiresIn    int32  `json:"expires_in"`
+				IDToken      string `json:"id_token"`
+			}{
+				AccessToken:  s.fakeToken(c, []byte("fake access token")),
+				TokenType:    "Bearer",
+				RefreshToken: "test-refresh-token",
+				ExpiresIn:    30,
+				IDToken:      s.fakeToken(c, idToken),
+			})
+		case "/jwks":
+			json.NewEncoder(w).Encode(jose.JSONWebKeySet{
+				Keys: []jose.JSONWebKey{
+					{Key: s.issuerKey.Public(), Algorithm: string(jose.RS256), KeyID: ""},
+				},
+			})
+		case "/auth":
+			w.WriteHeader(http.StatusInternalServerError)
+		case "/userinfo":
+			w.WriteHeader(http.StatusInternalServerError)
+		default:
+			w.WriteHeader(http.StatusNotFound)
+		}
+	}))
+
+	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"
+	c.Assert(err, check.IsNil)
+
+	s.localdb = NewConn(s.cluster)
+	s.localdb.googleLoginController.issuer = s.fakeIssuer.URL
+}
+
+func (s *LoginSuite) TestGoogleLoginStart_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) {
+	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)
+		target, err := url.Parse(resp.RedirectLocation)
+		c.Check(err, check.IsNil)
+		issuerURL, _ := url.Parse(s.fakeIssuer.URL)
+		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.googleLoginController.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)
+		c.Check(state.ReturnTo, check.Equals, "https://app.example.com/foo?bar")
+	}
+}
+
+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{
+		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{
+		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.*`)
+
+	// Callback with valid code and state.
+	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)
+	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}`)
+
+	// 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}})
+	cl, err := s.localdb.CollectionList(ctx, arvados.ListOptions{Limit: -1})
+	c.Check(cl.ItemsAvailable, check.Not(check.Equals), 0)
+	c.Check(cl.Items, check.Not(check.HasLen), 0)
+	c.Check(err, check.IsNil)
+
+	// Might as well check that bogus tokens aren't accepted.
+	badtoken := token + "plussomeboguschars"
+	c.Logf("trying an API call with mangled token %q", badtoken)
+	ctx = auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{badtoken}})
+	cl, err = s.localdb.CollectionList(ctx, arvados.ListOptions{Limit: -1})
+	c.Check(cl.Items, check.HasLen, 0)
+	c.Check(err, check.NotNil)
+	c.Check(err, check.ErrorMatches, `.*401 Unauthorized: Not logged in.*`)
+}
+
+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 {
+		c.Error(err)
+	}
+	object, err := signer.Sign(payload)
+	if err != nil {
+		c.Error(err)
+	}
+	t, err := object.CompactSerialize()
+	if err != nil {
+		c.Error(err)
+	}
+	c.Logf("fakeToken(%q) == %q", payload, t)
+	return t
+}
diff --git a/lib/controller/railsproxy/railsproxy.go b/lib/controller/railsproxy/railsproxy.go
index 576e603ee..fe070b48c 100644
--- a/lib/controller/railsproxy/railsproxy.go
+++ b/lib/controller/railsproxy/railsproxy.go
@@ -10,6 +10,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"net/http"
 	"net/url"
 	"strings"
 
@@ -40,7 +41,12 @@ func NewConn(cluster *arvados.Cluster) *rpc.Conn {
 	if err != nil {
 		panic(err)
 	}
-	return rpc.NewConn(cluster.ClusterID, url, insecure, provideIncomingToken)
+	conn := rpc.NewConn(cluster.ClusterID, url, insecure, provideIncomingToken)
+	// If Rails is running with force_ssl=true, this
+	// "X-Forwarded-Proto: https" header prevents it from
+	// redirecting our internal request to an invalid https URL.
+	conn.SendHeader = http.Header{"X-Forwarded-Proto": []string{"https"}}
+	return conn
 }
 
 func provideIncomingToken(ctx context.Context) ([]string, error) {
diff --git a/lib/controller/router/response.go b/lib/controller/router/response.go
index aa3af1f64..e3ec37a6e 100644
--- a/lib/controller/router/response.go
+++ b/lib/controller/router/response.go
@@ -52,9 +52,16 @@ func applySelectParam(selectParam []string, orig map[string]interface{}) map[str
 	return selected
 }
 
-func (rtr *router) sendResponse(w http.ResponseWriter, resp interface{}, opts responseOptions) {
+func (rtr *router) sendResponse(w http.ResponseWriter, req *http.Request, resp interface{}, opts responseOptions) {
 	var tmp map[string]interface{}
 
+	if resp, ok := resp.(http.Handler); ok {
+		// resp knows how to write its own http response
+		// header and body.
+		resp.ServeHTTP(w, req)
+		return
+	}
+
 	err := rtr.transcode(resp, &tmp)
 	if err != nil {
 		rtr.sendError(w, err)
@@ -121,7 +128,9 @@ func (rtr *router) sendResponse(w http.ResponseWriter, resp interface{}, opts re
 		}
 	}
 	w.Header().Set("Content-Type", "application/json")
-	json.NewEncoder(w).Encode(tmp)
+	enc := json.NewEncoder(w)
+	enc.SetEscapeHTML(false)
+	enc.Encode(tmp)
 }
 
 func (rtr *router) sendError(w http.ResponseWriter, err error) {
diff --git a/lib/controller/router/router.go b/lib/controller/router/router.go
index 5d5602df5..d3bdce527 100644
--- a/lib/controller/router/router.go
+++ b/lib/controller/router/router.go
@@ -48,6 +48,13 @@ func (rtr *router) addRoutes() {
 			},
 		},
 		{
+			arvados.EndpointLogin,
+			func() interface{} { return &arvados.LoginOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.Login(ctx, *opts.(*arvados.LoginOptions))
+			},
+		},
+		{
 			arvados.EndpointCollectionCreate,
 			func() interface{} { return &arvados.CreateOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
@@ -263,7 +270,7 @@ func (rtr *router) addRoute(endpoint arvados.APIEndpoint, defaultOpts func() int
 			rtr.sendError(w, err)
 			return
 		}
-		rtr.sendResponse(w, resp, respOpts)
+		rtr.sendResponse(w, req, resp, respOpts)
 	})
 }
 
diff --git a/lib/controller/rpc/conn.go b/lib/controller/rpc/conn.go
index 1028da829..cb23c7fad 100644
--- a/lib/controller/rpc/conn.go
+++ b/lib/controller/rpc/conn.go
@@ -32,6 +32,7 @@ func PassthroughTokenProvider(ctx context.Context) ([]string, error) {
 }
 
 type Conn struct {
+	SendHeader    http.Header
 	clusterID     string
 	httpClient    http.Client
 	baseURL       url.URL
@@ -61,8 +62,11 @@ func NewConn(clusterID string, url *url.URL, insecure bool, tp TokenProvider) *C
 		}
 	}
 	return &Conn{
-		clusterID:     clusterID,
-		httpClient:    http.Client{Transport: transport},
+		clusterID: clusterID,
+		httpClient: http.Client{
+			CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse },
+			Transport:     transport,
+		},
 		baseURL:       *url,
 		tokenProvider: tp,
 	}
@@ -70,9 +74,10 @@ func NewConn(clusterID string, url *url.URL, insecure bool, tp TokenProvider) *C
 
 func (conn *Conn) requestAndDecode(ctx context.Context, dst interface{}, ep arvados.APIEndpoint, body io.Reader, opts interface{}) error {
 	aClient := arvados.Client{
-		Client:  &conn.httpClient,
-		Scheme:  conn.baseURL.Scheme,
-		APIHost: conn.baseURL.Host,
+		Client:     &conn.httpClient,
+		Scheme:     conn.baseURL.Scheme,
+		APIHost:    conn.baseURL.Host,
+		SendHeader: conn.SendHeader,
 	}
 	tokens, err := conn.tokenProvider(ctx)
 	if err != nil {
@@ -121,6 +126,10 @@ func (conn *Conn) requestAndDecode(ctx context.Context, dst interface{}, ep arva
 	return aClient.RequestAndDecodeContext(ctx, dst, ep.Method, path, body, params)
 }
 
+func (conn *Conn) BaseURL() url.URL {
+	return conn.baseURL
+}
+
 func (conn *Conn) ConfigGet(ctx context.Context) (json.RawMessage, error) {
 	ep := arvados.EndpointConfigGet
 	var resp json.RawMessage
@@ -128,6 +137,30 @@ func (conn *Conn) ConfigGet(ctx context.Context) (json.RawMessage, error) {
 	return resp, err
 }
 
+func (conn *Conn) Login(ctx context.Context, options arvados.LoginOptions) (arvados.LoginResponse, error) {
+	ep := arvados.EndpointLogin
+	var resp arvados.LoginResponse
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	resp.RedirectLocation = conn.relativeToBaseURL(resp.RedirectLocation)
+	return resp, err
+}
+
+// If the given location is a valid URL and its origin is the same as
+// conn.baseURL, return it as a relative URL. Otherwise, return it
+// unmodified.
+func (conn *Conn) relativeToBaseURL(location string) string {
+	u, err := url.Parse(location)
+	if err == nil && u.Scheme == conn.baseURL.Scheme && strings.ToLower(u.Host) == strings.ToLower(conn.baseURL.Host) {
+		u.Opaque = ""
+		u.Scheme = ""
+		u.User = nil
+		u.Host = ""
+		return u.String()
+	} else {
+		return location
+	}
+}
+
 func (conn *Conn) CollectionCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Collection, error) {
 	ep := arvados.EndpointCollectionCreate
 	var resp arvados.Collection
@@ -281,3 +314,15 @@ func (conn *Conn) APIClientAuthorizationCurrent(ctx context.Context, options arv
 	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
 	return resp, err
 }
+
+type UserSessionCreateOptions struct {
+	AuthInfo map[string]interface{} `json:"auth_info"`
+	ReturnTo string                 `json:"return_to"`
+}
+
+func (conn *Conn) UserSessionCreate(ctx context.Context, options UserSessionCreateOptions) (arvados.LoginResponse, error) {
+	ep := arvados.APIEndpoint{Method: "POST", Path: "auth/controller/callback"}
+	var resp arvados.LoginResponse
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
diff --git a/sdk/go/arvados/api.go b/sdk/go/arvados/api.go
index 772f8da97..5531cf71d 100644
--- a/sdk/go/arvados/api.go
+++ b/sdk/go/arvados/api.go
@@ -18,6 +18,7 @@ type APIEndpoint struct {
 
 var (
 	EndpointConfigGet                     = APIEndpoint{"GET", "arvados/v1/config", ""}
+	EndpointLogin                         = APIEndpoint{"GET", "login", ""}
 	EndpointCollectionCreate              = APIEndpoint{"POST", "arvados/v1/collections", "collection"}
 	EndpointCollectionUpdate              = APIEndpoint{"PATCH", "arvados/v1/collections/:uuid", "collection"}
 	EndpointCollectionGet                 = APIEndpoint{"GET", "arvados/v1/collections/:uuid", ""}
@@ -83,8 +84,16 @@ type DeleteOptions struct {
 	UUID string `json:"uuid"`
 }
 
+type LoginOptions struct {
+	ReturnTo string `json:"return_to"`        // On success, redirect to this target with api_token=xxx query param
+	Remote   string `json:"remote,omitempty"` // Salt token for remote Cluster ID
+	Code     string `json:"code,omitempty"`   // OAuth2 callback code
+	State    string `json:"state,omitempty"`  // OAuth2 callback state
+}
+
 type API interface {
 	ConfigGet(ctx context.Context) (json.RawMessage, error)
+	Login(ctx context.Context, options LoginOptions) (LoginResponse, error)
 	CollectionCreate(ctx context.Context, options CreateOptions) (Collection, error)
 	CollectionUpdate(ctx context.Context, options UpdateOptions) (Collection, error)
 	CollectionGet(ctx context.Context, options GetOptions) (Collection, error)
diff --git a/sdk/go/arvados/client.go b/sdk/go/arvados/client.go
index a5815987b..8545cb969 100644
--- a/sdk/go/arvados/client.go
+++ b/sdk/go/arvados/client.go
@@ -54,6 +54,9 @@ type Client struct {
 	// arvadosclient.ArvadosClient.)
 	KeepServiceURIs []string `json:",omitempty"`
 
+	// HTTP headers to add/override in outgoing requests.
+	SendHeader http.Header
+
 	dd *DiscoveryDocument
 
 	ctx context.Context
@@ -144,9 +147,22 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
 	return c.httpClient().Do(req)
 }
 
+func isRedirectStatus(code int) bool {
+	switch code {
+	case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
+		return true
+	default:
+		return false
+	}
+}
+
 // DoAndDecode performs req and unmarshals the response (which must be
 // JSON) into dst. Use this instead of RequestAndDecode if you need
 // more control of the http.Request object.
+//
+// If the response status indicates an HTTP redirect, the Location
+// header value is unmarshalled to dst as a RedirectLocation
+// key/field.
 func (c *Client) DoAndDecode(dst interface{}, req *http.Request) error {
 	resp, err := c.Do(req)
 	if err != nil {
@@ -157,13 +173,28 @@ func (c *Client) DoAndDecode(dst interface{}, req *http.Request) error {
 	if err != nil {
 		return err
 	}
-	if resp.StatusCode != 200 {
-		return newTransactionError(req, resp, buf)
-	}
-	if dst == nil {
+	switch {
+	case resp.StatusCode == http.StatusOK && dst == nil:
 		return nil
+	case resp.StatusCode == http.StatusOK:
+		return json.Unmarshal(buf, dst)
+
+	// If the caller uses a client with a custom CheckRedirect
+	// func, Do() might return the 3xx response instead of
+	// following it.
+	case isRedirectStatus(resp.StatusCode) && dst == nil:
+		return nil
+	case isRedirectStatus(resp.StatusCode):
+		// Copy the redirect target URL to dst.RedirectLocation.
+		buf, err := json.Marshal(map[string]string{"RedirectLocation": resp.Header.Get("Location")})
+		if err != nil {
+			return err
+		}
+		return json.Unmarshal(buf, dst)
+
+	default:
+		return newTransactionError(req, resp, buf)
 	}
-	return json.Unmarshal(buf, dst)
 }
 
 // Convert an arbitrary struct to url.Values. For example,
@@ -268,6 +299,9 @@ func (c *Client) RequestAndDecodeContext(ctx context.Context, dst interface{}, m
 	}
 	req = req.WithContext(ctx)
 	req.Header.Set("Content-type", "application/x-www-form-urlencoded")
+	for k, v := range c.SendHeader {
+		req.Header[k] = v
+	}
 	return c.DoAndDecode(dst, req)
 }
 
diff --git a/sdk/go/arvados/config.go b/sdk/go/arvados/config.go
index 952fde5d1..6ec8f345d 100644
--- a/sdk/go/arvados/config.go
+++ b/sdk/go/arvados/config.go
@@ -132,8 +132,10 @@ type Cluster struct {
 		Repositories string
 	}
 	Login struct {
-		ProviderAppSecret  string
+		GoogleClientID     string
+		GoogleClientSecret string
 		ProviderAppID      string
+		ProviderAppSecret  string
 		LoginCluster       string
 		RemoteTokenRefresh Duration
 	}
diff --git a/sdk/go/arvados/login.go b/sdk/go/arvados/login.go
new file mode 100644
index 000000000..8c515468c
--- /dev/null
+++ b/sdk/go/arvados/login.go
@@ -0,0 +1,24 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import (
+	"bytes"
+	"net/http"
+)
+
+type LoginResponse struct {
+	RedirectLocation string
+	HTML             bytes.Buffer
+}
+
+func (resp LoginResponse) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+	if resp.RedirectLocation != "" {
+		w.Header().Set("Location", resp.RedirectLocation)
+		w.WriteHeader(http.StatusFound)
+	} else {
+		w.Write(resp.HTML.Bytes())
+	}
+}
diff --git a/sdk/go/arvadostest/api.go b/sdk/go/arvadostest/api.go
index 850bd0639..24e9f1908 100644
--- a/sdk/go/arvadostest/api.go
+++ b/sdk/go/arvadostest/api.go
@@ -8,6 +8,7 @@ import (
 	"context"
 	"encoding/json"
 	"errors"
+	"net/url"
 	"reflect"
 	"runtime"
 	"sync"
@@ -24,10 +25,18 @@ type APIStub struct {
 	mtx   sync.Mutex
 }
 
+// BaseURL implements federation.backend
+func (as *APIStub) BaseURL() url.URL {
+	return url.URL{Scheme: "https", Host: "apistub.example.com"}
+}
 func (as *APIStub) ConfigGet(ctx context.Context) (json.RawMessage, error) {
 	as.appendCall(as.ConfigGet, ctx, nil)
 	return nil, as.Error
 }
+func (as *APIStub) Login(ctx context.Context, options arvados.LoginOptions) (arvados.LoginResponse, error) {
+	as.appendCall(as.Login, ctx, options)
+	return arvados.LoginResponse{}, as.Error
+}
 func (as *APIStub) CollectionCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Collection, error) {
 	as.appendCall(as.CollectionCreate, ctx, options)
 	return arvados.Collection{}, as.Error
diff --git a/services/api/app/controllers/user_sessions_controller.rb b/services/api/app/controllers/user_sessions_controller.rb
index 4364229b7..0a03399d1 100644
--- a/services/api/app/controllers/user_sessions_controller.rb
+++ b/services/api/app/controllers/user_sessions_controller.rb
@@ -17,10 +17,21 @@ class UserSessionsController < ApplicationController
       raise "Local login disabled when LoginCluster is set"
     end
 
-    omniauth = request.env['omniauth.auth']
+    if params[:provider] == 'controller'
+      if request.headers['Authorization'] != 'Bearer ' + Rails.configuration.SystemRootToken
+        return send_error('Invalid authorization header', status: 401)
+      end
+      # arvados-controller verified the user and is passing auth_info
+      # in request params.
+      authinfo = SafeJSON.load(params[:auth_info])
+    else
+      # omniauth middleware verified the user and is passing auth_info
+      # in request.env.
+      authinfo = request.env['omniauth.auth']['info'].with_indifferent_access
+    end
 
     begin
-      user = User.register omniauth['info']
+      user = User.register(authinfo)
     rescue => e
       Rails.logger.warn e
       return redirect_to login_failure_url
@@ -45,8 +56,6 @@ class UserSessionsController < ApplicationController
 
     user.save or raise Exception.new(user.errors.messages)
 
-    omniauth.delete('extra')
-
     # Give the authenticated user a cookie for direct API access
     session[:user_id] = user.id
     session[:api_client_uuid] = nil
diff --git a/services/api/app/models/user.rb b/services/api/app/models/user.rb
index 50ecc6b65..7a3a854b3 100644
--- a/services/api/app/models/user.rb
+++ b/services/api/app/models/user.rb
@@ -399,8 +399,6 @@ class User < ArvadosModel
     #   alternate_emails
     #   identity_url
 
-    info = info.with_indifferent_access
-
     primary_user = nil
 
     # local database
diff --git a/services/api/test/functional/user_sessions_controller_test.rb b/services/api/test/functional/user_sessions_controller_test.rb
index d96ccb090..fc9475692 100644
--- a/services/api/test/functional/user_sessions_controller_test.rb
+++ b/services/api/test/functional/user_sessions_controller_test.rb
@@ -64,4 +64,23 @@ class UserSessionsControllerTest < ActionController::TestCase
     assert_nil assigns(:api_client)
   end
 
+  test "controller cannot create session without SystemRootToken" do
+    get :create, params: {provider: 'controller', auth_info: {email: "foo at bar.com"}, return_to: ',https://app.example'}
+    assert_response 401
+  end
+
+  test "controller cannot create session with wrong SystemRootToken" do
+    @request.headers['Authorization'] = 'Bearer blah'
+    get :create, params: {provider: 'controller', auth_info: {email: "foo at bar.com"}, return_to: ',https://app.example'}
+    assert_response 401
+  end
+
+  test "controller can create session using SystemRootToken" do
+    @request.headers['Authorization'] = 'Bearer '+Rails.configuration.SystemRootToken
+    get :create, params: {provider: 'controller', auth_info: {email: "foo at bar.com"}, return_to: ',https://app.example'}
+    assert_response :redirect
+    api_client_auth = assigns(:api_client_auth)
+    assert_not_nil api_client_auth
+    assert_includes(@response.redirect_url, 'api_token='+api_client_auth.token)
+  end
 end
diff --git a/vendor/vendor.json b/vendor/vendor.json
index c146a0003..b449e2f12 100644
--- a/vendor/vendor.json
+++ b/vendor/vendor.json
@@ -1,6 +1,6 @@
 {
 	"comment": "",
-	"ignore": "test",
+	"ignore": "test appengine",
 	"package": [
 		{
 			"checksumSHA1": "jfYWZyRWLMfG0J5K7G2K8a9AKfs=",
@@ -306,6 +306,12 @@
 			"revisionTime": "2016-08-04T10:47:26Z"
 		},
 		{
+			"checksumSHA1": "bNT5FFLDUXSamYK3jGHSwsTJqqo=",
+			"path": "github.com/coreos/go-oidc",
+			"revision": "2be1c5b8a260760503f66dc0996e102b683b3ac3",
+			"revisionTime": "2019-08-15T17:57:29Z"
+		},
+		{
 			"checksumSHA1": "+Zz+leZHHC9C0rx8DoRuffSRPso=",
 			"path": "github.com/coreos/go-systemd/daemon",
 			"revision": "cc4f39464dc797b91c8025330de585294c2a6950",
@@ -620,6 +626,18 @@
 			"revisionTime": "2017-12-16T07:03:16Z"
 		},
 		{
+			"checksumSHA1": "KxkAlLxQkuSGHH46Dxu6wpAybO4=",
+			"path": "github.com/pquerna/cachecontrol",
+			"revision": "1555304b9b35fdd2b425bccf1a5613677705e7d0",
+			"revisionTime": "2018-05-17T16:36:45Z"
+		},
+		{
+			"checksumSHA1": "wwaht1P9i8vQu6DqNvMEy24IMgY=",
+			"path": "github.com/pquerna/cachecontrol/cacheobject",
+			"revision": "1555304b9b35fdd2b425bccf1a5613677705e7d0",
+			"revisionTime": "2018-05-17T16:36:45Z"
+		},
+		{
 			"checksumSHA1": "Ajt29IHVbX99PUvzn8Gc/lMCXBY=",
 			"path": "github.com/prometheus/client_golang/prometheus",
 			"revision": "9bb6ab929dcbe1c8393cd9ef70387cb69811bd1c",
@@ -692,7 +710,7 @@
 			"revisionTime": "2017-11-10T11:01:46Z"
 		},
 		{
-			"checksumSHA1": "ySaT8G3I3y4MmnoXOYAAX0rC+p8=",
+			"checksumSHA1": "umeXHK5iK/3th4PtrTkZllezgWo=",
 			"path": "github.com/sirupsen/logrus",
 			"revision": "d682213848ed68c0a260ca37d6dd5ace8423f5ba",
 			"revisionTime": "2017-12-05T20:32:29Z"
@@ -788,6 +806,12 @@
 			"revisionTime": "2017-11-25T19:00:56Z"
 		},
 		{
+			"checksumSHA1": "1MGpGDQqnUoRpv7VEcQrXOBydXE=",
+			"path": "golang.org/x/crypto/pbkdf2",
+			"revision": "ae8bce0030810cf999bb2b9868ae5c7c58e6343b",
+			"revisionTime": "2018-04-30T17:54:52Z"
+		},
+		{
 			"checksumSHA1": "PJY7uCr3UnX4/Mf/RoWnbieSZ8o=",
 			"path": "golang.org/x/crypto/pkcs12",
 			"revision": "614d502a4dac94afa3a6ce146bd1736da82514c6",
@@ -861,6 +885,18 @@
 			"revisionTime": "2017-09-25T09:26:47Z"
 		},
 		{
+			"checksumSHA1": "+33kONpAOtjMyyw0uD4AygLvIXg=",
+			"path": "golang.org/x/oauth2",
+			"revision": "ec22f46f877b4505e0117eeaab541714644fdd28",
+			"revisionTime": "2018-05-28T20:23:04Z"
+		},
+		{
+			"checksumSHA1": "fddd1btmbXxnlMKHUZewlVlSaEQ=",
+			"path": "golang.org/x/oauth2/internal",
+			"revision": "ec22f46f877b4505e0117eeaab541714644fdd28",
+			"revisionTime": "2018-05-28T20:23:04Z"
+		},
+		{
 			"checksumSHA1": "znPq37/LZ4pJh7B4Lbu0ZuoMhNk=",
 			"origin": "github.com/docker/docker/vendor/golang.org/x/sys/unix",
 			"path": "golang.org/x/sys/unix",
@@ -893,6 +929,24 @@
 			"revisionTime": "2016-12-08T18:13:25Z"
 		},
 		{
+			"checksumSHA1": "oRfTuL23MIBG2nCwjweTJz4Eiqg=",
+			"path": "gopkg.in/square/go-jose.v2",
+			"revision": "730df5f748271903322feb182be83b43ebbbe27d",
+			"revisionTime": "2019-04-10T21:58:30Z"
+		},
+		{
+			"checksumSHA1": "Ho5sr2GbiR8S35IRni7vC54d5Js=",
+			"path": "gopkg.in/square/go-jose.v2/cipher",
+			"revision": "730df5f748271903322feb182be83b43ebbbe27d",
+			"revisionTime": "2019-04-10T21:58:30Z"
+		},
+		{
+			"checksumSHA1": "JFun0lWY9eqd80Js2iWsehu1gc4=",
+			"path": "gopkg.in/square/go-jose.v2/json",
+			"revision": "730df5f748271903322feb182be83b43ebbbe27d",
+			"revisionTime": "2019-04-10T21:58:30Z"
+		},
+		{
 			"checksumSHA1": "GdsHg+yOsZtdMvD9HJFovPsqKec=",
 			"path": "gopkg.in/src-d/go-billy.v4",
 			"revision": "053dbd006f81a230434f712314aacfb540b52cc5",

-----------------------------------------------------------------------


hooks/post-receive
-- 




More information about the arvados-commits mailing list