[ARVADOS] created: 1.3.0-1706-g7d0e4f13e

Git user git at public.curoverse.com
Mon Oct 7 18:44:51 UTC 2019


        at  7d0e4f13ef9ce9cd17869d908a4b44bef9c136cd (commit)


commit 7d0e4f13ef9ce9cd17869d908a4b44bef9c136cd
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Mon Oct 7 14:44:37 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 4e3bf6d6c..824fc57e1 100644
--- a/lib/config/config.default.yml
+++ b/lib/config/config.default.yml
@@ -496,6 +496,14 @@ Clusters:
       ProviderAppSecret: ""
       ProviderAppID: ""
 
+      # (Experimental) Authenticate with Google, bypassing the
+      # SSO-provider gateway service. To use this, configure
+      # ProviderAppID and ProviderAppSecret with the Client ID and
+      # Client Secret issued by Google.
+      #
+      # Requires EnableBetaController14287 (see below).
+      Google: false
+
       # The cluster ID to delegate the user database.  When set,
       # logins on this cluster will be redirected to the login cluster
       # (login cluster must appear in RemoteHosts with Proxy: true)
diff --git a/lib/config/export.go b/lib/config/export.go
index 5437836f6..e1cec1f13 100644
--- a/lib/config/export.go
+++ b/lib/config/export.go
@@ -130,6 +130,7 @@ var whitelist = map[string]bool{
 	"InstanceTypes.*":                              true,
 	"InstanceTypes.*.*":                            true,
 	"Login":                                        true,
+	"Login.Google":                                 false,
 	"Login.ProviderAppSecret":                      false,
 	"Login.ProviderAppID":                          false,
 	"Login.LoginCluster":                           true,
diff --git a/lib/config/generated_config.go b/lib/config/generated_config.go
index d21bb2d28..bcef910a3 100644
--- a/lib/config/generated_config.go
+++ b/lib/config/generated_config.go
@@ -502,6 +502,14 @@ Clusters:
       ProviderAppSecret: ""
       ProviderAppID: ""
 
+      # (Experimental) Authenticate with Google, bypassing the
+      # SSO-provider gateway service. To use this, configure
+      # ProviderAppID and ProviderAppSecret with the Client ID and
+      # Client Secret issued by Google.
+      #
+      # Requires EnableBetaController14287 (see below).
+      Google: false
+
       # The cluster ID to delegate the user database.  When set,
       # logins on this cluster will be redirected to the login cluster
       # (login cluster must appear in RemoteHosts with Proxy: true)
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 b28609c2d..d08c03b2e 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 d34df7f2c..e7df0d53c 100644
--- a/lib/controller/handler_test.go
+++ b/lib/controller/handler_test.go
@@ -162,8 +162,13 @@ func (s *HandlerSuite) TestProxyRedirect(c *check.C) {
 	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..f93779f25
--- /dev/null
+++ b/lib/controller/localdb/conn.go
@@ -0,0 +1,38 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+	"context"
+
+	"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) {
+	if conn.cluster.Login.Google {
+		return conn.googleLoginController.Login(ctx, conn.cluster, 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..3193624c6
--- /dev/null
+++ b/lib/controller/localdb/login.go
@@ -0,0 +1,173 @@
+// 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/sdk/go/arvados"
+	"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(ctx context.Context) (*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(ctx, issuer)
+		if err != nil {
+			return nil, err
+		}
+		ctrl.provider = provider
+	}
+	return ctrl.provider, nil
+}
+
+func (ctrl *googleLoginController) Login(ctx context.Context, cluster *arvados.Cluster, opts arvados.LoginOptions) (arvados.LoginResponse, error) {
+	provider, err := ctrl.getProvider(ctx)
+	if err != nil {
+		return ctrl.loginError(fmt.Errorf("error setting up OpenID Connect provider: %s", err))
+	}
+	conf := &oauth2.Config{
+		ClientID:     cluster.Login.ProviderAppID,
+		ClientSecret: cluster.Login.ProviderAppSecret,
+		Endpoint:     provider.Endpoint(),
+		Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
+	}
+	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 := newOAuth2State([]byte(cluster.SystemRootToken), opts.Remote, opts.ReturnTo)
+		return arvados.LoginResponse{
+			RedirectLocation: conf.AuthCodeURL(state.String()),
+		}, nil
+	} else {
+		// Callback after Google sign-in.
+		state := 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"))
+		}
+		return arvados.LoginResponse{
+			RedirectLocation: state.ReturnTo,
+		}, nil
+	}
+}
+
+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 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
+	Time     int64
+	Remote   string
+	ReturnTo string
+}
+
+func parseOAuth2State(encoded string) (s oauth2State) {
+	decoded, err := base64.RawURLEncoding.DecodeString(encoded)
+	if err != nil {
+		return
+	}
+	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.Time, 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..f35d7dabd
--- /dev/null
+++ b/lib/controller/localdb/login_test.go
@@ -0,0 +1,169 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"encoding/json"
+	"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/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
+}
+
+func (s *LoginSuite) SetUpTest(c *check.C) {
+	var err error
+	s.issuerKey, err = rsa.GenerateKey(rand.Reader, 2048)
+	c.Assert(err, check.IsNil)
+
+	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":
+			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":          "fake+user at example.com",
+				"email_verified": true,
+			})
+			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.Google = true
+	s.cluster.Login.ProviderAppID = "test%client$id"
+	s.cluster.Login.ProviderAppSecret = "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 := 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) {
+	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), "")
+	resp, err = s.localdb.Login(context.Background(), arvados.LoginOptions{
+		Code:  "abcdefgh",
+		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")
+	c.Check(target.Query().Get("api_token"), check.Equals, "xxx-tbd-xxx")
+}
+
+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/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..3ed6dda8c 100644
--- a/lib/controller/rpc/conn.go
+++ b/lib/controller/rpc/conn.go
@@ -61,8 +61,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,
 	}
@@ -121,6 +124,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 +135,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
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..4fdd2d3bb 100644
--- a/sdk/go/arvados/client.go
+++ b/sdk/go/arvados/client.go
@@ -144,9 +144,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 +170,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,
diff --git a/sdk/go/arvados/config.go b/sdk/go/arvados/config.go
index 7c1c35380..0f7b3bbc0 100644
--- a/sdk/go/arvados/config.go
+++ b/sdk/go/arvados/config.go
@@ -132,6 +132,7 @@ type Cluster struct {
 		Repositories string
 	}
 	Login struct {
+		Google             bool
 		ProviderAppSecret  string
 		ProviderAppID      string
 		LoginCluster       string
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/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