15107: Add built-in Google login option, as an alternative to sso.
authorTom Clegg <tclegg@veritasgenetics.com>
Thu, 31 Oct 2019 14:07:31 +0000 (10:07 -0400)
committerTom Clegg <tclegg@veritasgenetics.com>
Thu, 31 Oct 2019 19:03:00 +0000 (15:03 -0400)
Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg@veritasgenetics.com>

23 files changed:
lib/config/config.default.yml
lib/config/export.go
lib/config/generated_config.go
lib/controller/federation/conn.go
lib/controller/federation/list_test.go
lib/controller/handler.go
lib/controller/handler_test.go
lib/controller/localdb/conn.go [new file with mode: 0644]
lib/controller/localdb/login.go [new file with mode: 0644]
lib/controller/localdb/login_test.go [new file with mode: 0644]
lib/controller/railsproxy/railsproxy.go
lib/controller/router/response.go
lib/controller/router/router.go
lib/controller/rpc/conn.go
sdk/go/arvados/api.go
sdk/go/arvados/client.go
sdk/go/arvados/config.go
sdk/go/arvados/login.go [new file with mode: 0644]
sdk/go/arvadostest/api.go
services/api/app/controllers/user_sessions_controller.rb
services/api/app/models/user.rb
services/api/test/functional/user_sessions_controller_test.rb
vendor/vendor.json

index 9f473e361931aff39bc7a5f8a276a6e626c97bc5..fee8503df812492357ebf813240fc8c8abda4596 100644 (file)
@@ -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
index 5f17b345944e612af899b7b15e370e4df8f3e5ca..0dd90ff65c7d0bf2ab86bacafed780d349bc8498 100644 (file)
@@ -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,
index dbf11569f65fac975b8fdfd3c54f4d4e48a7413d..42beb066344545f6faa2e9a1c30c6dc8b744578e 100644 (file)
@@ -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
index 3bcafacd2c8bc47bdce87bb6908169c710662c6d..3829d0a40adab273f7ea126ac8ff40d92527fc46 100644 (file)
@@ -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{}
 
index e9e8950b992e498d072d87be18cfd8737c312f39..c9b981fc15fed27d5fb75131894a2cff6cd77a41 100644 (file)
@@ -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)
index f7b2362f371e71b99a196d4b795e54a927e81919..f925233ba36ddfdddab0b26b3ea16db772e2877a 100644 (file)
@@ -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()
index 5dc0b1e86f8f1ff66d689f19ad6ab7d7b699a3de..ebadc5d0213a25f1fd21123a48b4456b042d4b52 100644 (file)
@@ -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 (file)
index 0000000..835ab43
--- /dev/null
@@ -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 (file)
index 0000000..b41ca64
--- /dev/null
@@ -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 (file)
index 0000000..f35a2d2
--- /dev/null
@@ -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@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
+}
index 576e603eedd758f8ff53f2556e1161b6957b0691..fe070b48cc7054015f1481163e51611181075b10 100644 (file)
@@ -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) {
index aa3af1f64c45194c5d5b3cc2e1996ed212941e5e..e3ec37a6ea842ac36e6b8d766cf91cf0210f05f9 100644 (file)
@@ -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) {
index 5d5602df523b672d6e8f6d84346ed5255a20ae76..d3bdce527211e1b26245a820dcbc9cd174f3dc62 100644 (file)
@@ -47,6 +47,13 @@ func (rtr *router) addRoutes() {
                                return rtr.fed.ConfigGet(ctx)
                        },
                },
+               {
+                       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{} },
@@ -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)
        })
 }
 
index 1028da829fbdb0361fc5b041e98fba5e30c81c6c..cb23c7fad1ea99dce585f3626b66bf9130b82069 100644 (file)
@@ -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
+}
index 772f8da9719ae874d3f392782fb6388d3e74a488..5531cf71d344cbb795caaa2cac670d7a3ff88ca1 100644 (file)
@@ -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)
index a5815987b192a86c9ee646205bcc9ea0f7986dcc..8545cb969d92f8fbc716175d5c9820f47ed6680a 100644 (file)
@@ -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)
 }
 
index 952fde5d1cb9d01c9a398b76fc86c5cd3ef02b1d..6ec8f345d75f430ecd4fe8ffab4bfa665f569770 100644 (file)
@@ -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 (file)
index 0000000..8c51546
--- /dev/null
@@ -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())
+       }
+}
index 850bd0639dcaa856c5b6dfa69b09309a1ead1de5..24e9f190865b0456a8be431449239e2ec3aba6db 100644 (file)
@@ -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
index 4364229b77284e9dbaab975c626ec9e5e52c3e33..0a03399d1f0607b5412ef6b45a47a4ac230b7e8e 100644 (file)
@@ -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
index 50ecc6b65df04adbf36b9b00e5f34cf985ad26b5..7a3a854b3a17826117a6ff913ff5f20743f86483 100644 (file)
@@ -399,8 +399,6 @@ class User < ArvadosModel
     #   alternate_emails
     #   identity_url
 
-    info = info.with_indifferent_access
-
     primary_user = nil
 
     # local database
index d96ccb0903fc72026297a412ac474a8ac895af04..fc9475692a5933c2ed01f77e7871f4fd3942d7ec 100644 (file)
@@ -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@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@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@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
index c146a000325cb25b27cbe30e5e37c80b9144b0bf..b449e2f12bc8394e0c7e92d00c84100cf8c18ec5 100644 (file)
@@ -1,6 +1,6 @@
 {
        "comment": "",
-       "ignore": "test",
+       "ignore": "test appengine",
        "package": [
                {
                        "checksumSHA1": "jfYWZyRWLMfG0J5K7G2K8a9AKfs=",
                        "revision": "4c0e84591b9aa9e6dcfdf3e020114cd81f89d5f9",
                        "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": "e881fd58d78e04cf6d0de1217f8707c8cc2249bc",
                        "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",
                        "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"
                        "revision": "0fcca4842a8d74bfddc2c96a073bd2a4d2a7a2e8",
                        "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": "434ec0c7fe3742c984919a691b2018a6e9694425",
                        "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",
                        "revision": "20d25e2804050c1cd24a7eea1e7a6447dd0e74ec",
                        "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",