diff --git a/auth/authzserver/claims_verifier.go b/auth/authzserver/claims_verifier.go index 353c889d6..64284a637 100644 --- a/auth/authzserver/claims_verifier.go +++ b/auth/authzserver/claims_verifier.go @@ -58,6 +58,13 @@ func verifyClaims(expectedAudience sets.String, claimsRaw map[string]interface{} } } + EmailKey := "email" + // In some cases, "user_info" field doesn't exist in the raw claim, + // but we can get email from "email" field + if emailClaim, found := claimsRaw[EmailKey]; found { + email := emailClaim.(string) + userInfo.Email = email + } // If this is a user-only access token with no scopes defined then add `all` scope by default because it's equivalent // to having a user's login cookie or an ID Token as means of accessing the service. if len(clientID) == 0 && scopes.Len() == 0 { diff --git a/auth/authzserver/claims_verifier_test.go b/auth/authzserver/claims_verifier_test.go index db5b5aee7..dca3cf6e2 100644 --- a/auth/authzserver/claims_verifier_test.go +++ b/auth/authzserver/claims_verifier_test.go @@ -22,6 +22,7 @@ func Test_verifyClaims(t *testing.T) { "sub": "123", "client_id": "my-client", "scp": []interface{}{"all", "offline"}, + "email": "byhsu@linkedin.com", }) assert.NoError(t, err) @@ -29,6 +30,7 @@ func Test_verifyClaims(t *testing.T) { assert.Equal(t, "my-client", identityCtx.AppID()) assert.Equal(t, "123", identityCtx.UserID()) assert.Equal(t, "https://myserver", identityCtx.Audience()) + assert.Equal(t, "byhsu@linkedin.com", identityCtx.UserInfo().Email) }) t.Run("Multiple audience", func(t *testing.T) { diff --git a/auth/authzserver/initialize.go b/auth/authzserver/initialize.go index b61bae4c8..afd428e86 100644 --- a/auth/authzserver/initialize.go +++ b/auth/authzserver/initialize.go @@ -15,6 +15,7 @@ import ( // RegisterHandlers registers http endpoints for handling OAuth2 flow (/authorize, func RegisterHandlers(handler interfaces.HandlerRegisterer, authCtx interfaces.AuthenticationContext) { + // If using flyte self auth server, OAuth2Provider != nil if authCtx.OAuth2Provider() != nil { // Set up oauthserver endpoints. You could also use gorilla/mux or any other router. handler.HandleFunc(authorizeRelativeURL.String(), getAuthEndpoint(authCtx)) diff --git a/auth/authzserver/metadata_provider.go b/auth/authzserver/metadata_provider.go index bba6f47c4..ee9ce4d02 100644 --- a/auth/authzserver/metadata_provider.go +++ b/auth/authzserver/metadata_provider.go @@ -64,7 +64,17 @@ func (s OAuth2MetadataProvider) GetOAuth2Metadata(ctx context.Context, r *servic externalMetadataURL = baseURL.ResolveReference(oauth2MetadataEndpoint) } - response, err := http.Get(externalMetadataURL.String()) + httpClient := &http.Client{} + + if len(s.cfg.HTTPProxyURL.String()) > 0 { + // create a transport that uses the proxy + transport := &http.Transport{ + Proxy: http.ProxyURL(&s.cfg.HTTPProxyURL.URL), + } + httpClient.Transport = transport + } + + response, err := httpClient.Get(externalMetadataURL.String()) if err != nil { return nil, err } diff --git a/auth/authzserver/resource_server.go b/auth/authzserver/resource_server.go index 71e662a52..e3b36b05c 100644 --- a/auth/authzserver/resource_server.go +++ b/auth/authzserver/resource_server.go @@ -23,6 +23,8 @@ import ( "golang.org/x/oauth2" ) +// External auth server implementation + // ResourceServer authorizes access requests issued by an external Authorization Server. type ResourceServer struct { signatureVerifier oidc.KeySet @@ -68,7 +70,9 @@ func unmarshalResp(r *http.Response, body []byte, v interface{}) error { return fmt.Errorf("expected Content-Type = application/json, got %q: %v", ct, err) } -func getJwksForIssuer(ctx context.Context, issuerBaseURL url.URL, customMetadataURL url.URL) (keySet oidc.KeySet, err error) { +func getJwksForIssuer(ctx context.Context, issuerBaseURL url.URL, cfg authConfig.ExternalAuthorizationServer) (keySet oidc.KeySet, err error) { + customMetadataURL := cfg.MetadataEndpointURL.URL + issuerBaseURL.Path = strings.TrimSuffix(issuerBaseURL.Path, "/") + "/" var wellKnown *url.URL if len(customMetadataURL.String()) > 0 { @@ -77,12 +81,22 @@ func getJwksForIssuer(ctx context.Context, issuerBaseURL url.URL, customMetadata wellKnown = issuerBaseURL.ResolveReference(oauth2MetadataEndpoint) } + httpClient := &http.Client{} + + if len(cfg.HTTPProxyURL.String()) > 0 { + // create a transport that uses the proxy + transport := &http.Transport{ + Proxy: http.ProxyURL(&cfg.HTTPProxyURL.URL), + } + httpClient.Transport = transport + } + req, err := http.NewRequest(http.MethodGet, wellKnown.String(), nil) if err != nil { return nil, err } - resp, err := doRequest(ctx, req) + resp, err := httpClient.Do(req) if err != nil { return nil, err } @@ -104,7 +118,7 @@ func getJwksForIssuer(ctx context.Context, issuerBaseURL url.URL, customMetadata return nil, fmt.Errorf("failed to decode provider discovery object: %v", err) } - return oidc.NewRemoteKeySet(ctx, p.JwksUri), nil + return oidc.NewRemoteKeySet(oidc.ClientContext(ctx, httpClient), p.JwksUri), nil } // NewOAuth2ResourceServer initializes a new OAuth2ResourceServer. @@ -114,7 +128,7 @@ func NewOAuth2ResourceServer(ctx context.Context, cfg authConfig.ExternalAuthori u = fallbackBaseURL } - verifier, err := getJwksForIssuer(ctx, u.URL, cfg.MetadataEndpointURL.URL) + verifier, err := getJwksForIssuer(ctx, u.URL, cfg) if err != nil { return ResourceServer{}, err } diff --git a/auth/authzserver/resource_server_test.go b/auth/authzserver/resource_server_test.go index e300f0857..3338defeb 100644 --- a/auth/authzserver/resource_server_test.go +++ b/auth/authzserver/resource_server_test.go @@ -222,7 +222,7 @@ func Test_getJwksForIssuer(t *testing.T) { type args struct { ctx context.Context issuerBaseURL url.URL - customMetaURL url.URL + cfg authConfig.ExternalAuthorizationServer } tests := []struct { name string @@ -234,7 +234,7 @@ func Test_getJwksForIssuer(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := getJwksForIssuer(tt.args.ctx, tt.args.issuerBaseURL, tt.args.customMetaURL) + got, err := getJwksForIssuer(tt.args.ctx, tt.args.issuerBaseURL, tt.args.cfg) if (err != nil) != tt.wantErr { t.Errorf("getJwksForIssuer() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/auth/config/config.go b/auth/config/config.go index a42365205..db6665346 100644 --- a/auth/config/config.go +++ b/auth/config/config.go @@ -152,6 +152,9 @@ type Config struct { // the `secure` setting. AuthorizedURIs []config.URL `json:"authorizedUris" pflag:"-,Optional: Defines the set of URIs that clients are allowed to visit the service on. If set, the system will attempt to match the incoming host to the first authorized URIs and use that (including the scheme) when generating metadata endpoints and when validating audience and issuer claims. If not provided, the urls will be deduced based on the request url and the 'secure' setting."` + // HTTPProxyURL allows operators to access external OAuth2 servers using an external HTTP Proxy + HTTPProxyURL config.URL `json:"httpProxyURL" pflag:",OPTIONAL: HTTP Proxy to be used for OAuth requests."` + // UserAuth settings used to authenticate end users in web-browsers. UserAuth UserAuthConfig `json:"userAuth" pflag:",Defines Auth options for users."` @@ -187,6 +190,8 @@ type ExternalAuthorizationServer struct { BaseURL config.URL `json:"baseUrl" pflag:",This should be the base url of the authorization server that you are trying to hit. With Okta for instance, it will look something like https://company.okta.com/oauth2/abcdef123456789/"` AllowedAudience []string `json:"allowedAudience" pflag:",Optional: A list of allowed audiences. If not provided, the audience is expected to be the public Uri of the service."` MetadataEndpointURL config.URL `json:"metadataUrl" pflag:",Optional: If the server doesn't support /.well-known/oauth-authorization-server, you can set a custom metadata url here.'"` + // HTTPProxyURL allows operators to access external OAuth2 servers using an external HTTP Proxy + HTTPProxyURL config.URL `json:"httpProxyURL" pflag:",OPTIONAL: HTTP Proxy to be used for OAuth requests."` } // OAuth2Options defines settings for app auth. diff --git a/auth/config/config_flags.go b/auth/config/config_flags.go index b84be106f..225e8a5c9 100755 --- a/auth/config/config_flags.go +++ b/auth/config/config_flags.go @@ -54,6 +54,7 @@ func (cfg Config) GetPFlagSet(prefix string) *pflag.FlagSet { cmdFlags.String(fmt.Sprintf("%v%v", prefix, "grpcAuthorizationHeader"), DefaultConfig.GrpcAuthorizationHeader, "") cmdFlags.Bool(fmt.Sprintf("%v%v", prefix, "disableForHttp"), DefaultConfig.DisableForHTTP, "Disables auth enforcement on HTTP Endpoints.") cmdFlags.Bool(fmt.Sprintf("%v%v", prefix, "disableForGrpc"), DefaultConfig.DisableForGrpc, "Disables auth enforcement on Grpc Endpoints.") + cmdFlags.String(fmt.Sprintf("%v%v", prefix, "httpProxyURL"), DefaultConfig.HTTPProxyURL.String(), "OPTIONAL: HTTP Proxy to be used for OAuth requests.") cmdFlags.String(fmt.Sprintf("%v%v", prefix, "userAuth.redirectUrl"), DefaultConfig.UserAuth.RedirectURL.String(), "") cmdFlags.String(fmt.Sprintf("%v%v", prefix, "userAuth.openId.clientId"), DefaultConfig.UserAuth.OpenID.ClientID, "") cmdFlags.String(fmt.Sprintf("%v%v", prefix, "userAuth.openId.clientSecretName"), DefaultConfig.UserAuth.OpenID.ClientSecretName, "") @@ -75,6 +76,7 @@ func (cfg Config) GetPFlagSet(prefix string) *pflag.FlagSet { cmdFlags.String(fmt.Sprintf("%v%v", prefix, "appAuth.externalAuthServer.baseUrl"), DefaultConfig.AppAuth.ExternalAuthServer.BaseURL.String(), "This should be the base url of the authorization server that you are trying to hit. With Okta for instance, it will look something like https://company.okta.com/oauth2/abcdef123456789/") cmdFlags.StringSlice(fmt.Sprintf("%v%v", prefix, "appAuth.externalAuthServer.allowedAudience"), DefaultConfig.AppAuth.ExternalAuthServer.AllowedAudience, "Optional: A list of allowed audiences. If not provided, the audience is expected to be the public Uri of the service.") cmdFlags.String(fmt.Sprintf("%v%v", prefix, "appAuth.externalAuthServer.metadataUrl"), DefaultConfig.AppAuth.ExternalAuthServer.MetadataEndpointURL.String(), "Optional: If the server doesn't support /.well-known/oauth-authorization-server, you can set a custom metadata url here.'") + cmdFlags.String(fmt.Sprintf("%v%v", prefix, "appAuth.externalAuthServer.httpProxyURL"), DefaultConfig.AppAuth.ExternalAuthServer.HTTPProxyURL.String(), "OPTIONAL: HTTP Proxy to be used for OAuth requests.") cmdFlags.String(fmt.Sprintf("%v%v", prefix, "appAuth.thirdPartyConfig.flyteClient.clientId"), DefaultConfig.AppAuth.ThirdParty.FlyteClientConfig.ClientID, "public identifier for the app which handles authorization for a Flyte deployment") cmdFlags.String(fmt.Sprintf("%v%v", prefix, "appAuth.thirdPartyConfig.flyteClient.redirectUri"), DefaultConfig.AppAuth.ThirdParty.FlyteClientConfig.RedirectURI, "This is the callback uri registered with the app which handles authorization for a Flyte deployment") cmdFlags.StringSlice(fmt.Sprintf("%v%v", prefix, "appAuth.thirdPartyConfig.flyteClient.scopes"), DefaultConfig.AppAuth.ThirdParty.FlyteClientConfig.Scopes, "Recommended scopes for the client to request.") diff --git a/auth/config/config_flags_test.go b/auth/config/config_flags_test.go index ffcb653d8..28efafc38 100755 --- a/auth/config/config_flags_test.go +++ b/auth/config/config_flags_test.go @@ -155,6 +155,20 @@ func TestConfig_SetFlags(t *testing.T) { } }) }) + t.Run("Test_httpProxyURL", func(t *testing.T) { + + t.Run("Override", func(t *testing.T) { + testValue := DefaultConfig.HTTPProxyURL.String() + + cmdFlags.Set("httpProxyURL", testValue) + if vString, err := cmdFlags.GetString("httpProxyURL"); err == nil { + testDecodeJson_Config(t, fmt.Sprintf("%v", vString), &actual.HTTPProxyURL) + + } else { + assert.FailNow(t, err.Error()) + } + }) + }) t.Run("Test_userAuth.redirectUrl", func(t *testing.T) { t.Run("Override", func(t *testing.T) { @@ -449,6 +463,20 @@ func TestConfig_SetFlags(t *testing.T) { } }) }) + t.Run("Test_appAuth.externalAuthServer.httpProxyURL", func(t *testing.T) { + + t.Run("Override", func(t *testing.T) { + testValue := DefaultConfig.AppAuth.ExternalAuthServer.HTTPProxyURL.String() + + cmdFlags.Set("appAuth.externalAuthServer.httpProxyURL", testValue) + if vString, err := cmdFlags.GetString("appAuth.externalAuthServer.httpProxyURL"); err == nil { + testDecodeJson_Config(t, fmt.Sprintf("%v", vString), &actual.AppAuth.ExternalAuthServer.HTTPProxyURL) + + } else { + assert.FailNow(t, err.Error()) + } + }) + }) t.Run("Test_appAuth.thirdPartyConfig.flyteClient.clientId", func(t *testing.T) { t.Run("Override", func(t *testing.T) { diff --git a/auth/handlers.go b/auth/handlers.go index d3e451295..4133af4e7 100644 --- a/auth/handlers.go +++ b/auth/handlers.go @@ -309,6 +309,7 @@ func GetHTTPRequestCookieToMetadataHandler(authCtx interfaces.AuthenticationCont return nil } + // IDtoken is injected into grpc authorization metadata meta := metadata.MD{ DefaultAuthorizationHeader: []string{fmt.Sprintf("%s %s", IDTokenScheme, idToken)}, } @@ -396,6 +397,7 @@ func QueryUserInfo(ctx context.Context, identityContext interfaces.IdentityConte return QueryUserInfoUsingAccessToken(ctx, request, authCtx, accessToken) } +// Extract User info from access token for HTTP request func QueryUserInfoUsingAccessToken(ctx context.Context, originalRequest *http.Request, authCtx interfaces.AuthenticationContext, accessToken string) ( *service.UserInfoResponse, error) { diff --git a/auth/interceptor.go b/auth/interceptor.go index 696cdb5be..0e141a34b 100644 --- a/auth/interceptor.go +++ b/auth/interceptor.go @@ -3,6 +3,7 @@ package auth import ( "context" + "github.com/flyteorg/flytestdlib/logger" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -17,6 +18,7 @@ func BlanketAuthorization(ctx context.Context, req interface{}, _ *grpc.UnarySer } if !identityContext.Scopes().Has(ScopeAll) { + logger.Debugf(ctx, "authenticated user doesn't have required scope") return nil, status.Errorf(codes.Unauthenticated, "authenticated user doesn't have required scope") } diff --git a/go.mod b/go.mod index ee218205c..ddef8ccff 100644 --- a/go.mod +++ b/go.mod @@ -170,12 +170,12 @@ require ( golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect golang.org/x/net v0.7.0 // indirect - golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect + golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.5.0 // indirect golang.org/x/term v0.5.0 // indirect golang.org/x/text v0.7.0 // indirect - golang.org/x/tools v0.1.12 // indirect - golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect + golang.org/x/tools v0.4.0 // indirect + golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/appengine v1.6.7 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.66.4 // indirect diff --git a/go.sum b/go.sum index 8ac127b64..91807b3aa 100644 --- a/go.sum +++ b/go.sum @@ -1644,7 +1644,7 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= +golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180816102801-aaf60122140d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1751,8 +1751,8 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180816055513-1c9583448a9c/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1998,8 +1998,8 @@ golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.10-0.20220218145154-897bd77cd717/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= -golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.4.0 h1:7mTAgkunk3fr4GAloyyCasadO6h9zSsQZbwvcaIciV4= +golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -2007,8 +2007,8 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f h1:uF6paiQQebLeSXkrTqHqz0MXhXXS1KgF41eUdBNvxK0= -golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= gomodules.xyz/jsonpatch/v2 v2.2.0 h1:4pT439QV83L+G9FkcCriY6EkpcK6r6bK+A5FBUMI7qY= gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= gonum.org/v1/gonum v0.6.2/go.mod h1:9mxDZsDKxgMAuccQkewq682L+0eCu4dCN2yonUJTCLU=