[ARVADOS] created: 1.3.0-1821-gdf491facf
Git user
git at public.curoverse.com
Thu Oct 31 13:56:23 UTC 2019
at df491facf34a6e1ab399f1f5eca84b48e8834ffc (commit)
commit df491facf34a6e1ab399f1f5eca84b48e8834ffc
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date: Thu Oct 31 09:54:53 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..5ba7f57e5 100644
--- a/lib/config/config.default.yml
+++ b/lib/config/config.default.yml
@@ -496,6 +496,19 @@ Clusters:
ProviderAppSecret: ""
ProviderAppID: ""
+ # (Experimental) Authenticate with Google, bypassing the
+ # SSO-provider gateway service. To use this, create
+ # a client ID (Google Cloud console > APIs and Services >
+ # Credentials > Create credentials > OAuth client ID > Web
+ # application), add your controller's /login URL (e.g.,
+ # "https://zzzzz.example.com/login") as an authorized redirect
+ # URL, and save the provided credentials (client ID and client
+ # secret) as ProviderAppID and ProviderAppSecret in your Arvados
+ # configuration.
+ #
+ # 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 5f17b3459..725bf893e 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 dbf11569f..5231deeaa 100644
--- a/lib/config/generated_config.go
+++ b/lib/config/generated_config.go
@@ -502,6 +502,19 @@ Clusters:
ProviderAppSecret: ""
ProviderAppID: ""
+ # (Experimental) Authenticate with Google, bypassing the
+ # SSO-provider gateway service. To use this, create
+ # a client ID (Google Cloud console > APIs and Services >
+ # Credentials > Create credentials > OAuth client ID > Web
+ # application), add your controller's /login URL (e.g.,
+ # "https://zzzzz.example.com/login") as an authorized redirect
+ # URL, and save the provided credentials (client ID and client
+ # secret) as ProviderAppID and ProviderAppSecret in your Arvados
+ # configuration.
+ #
+ # 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 5dc0b1e86..b1c587ecb 100644
--- a/lib/controller/handler_test.go
+++ b/lib/controller/handler_test.go
@@ -168,8 +168,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..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