diff --git a/go.mod b/go.mod index 95cffcb..a64e081 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,17 @@ go 1.18 require ( github.com/jarcoal/httpmock v1.3.0 github.com/maxatome/go-testdeep v1.12.0 + golang.org/x/oauth2 v0.15.0 gopkg.in/ini.v1 v1.67.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/golang/protobuf v1.5.3 // indirect github.com/stretchr/testify v1.8.2 // indirect + golang.org/x/net v0.19.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.31.0 // indirect ) retract ( diff --git a/go.sum b/go.sum index 751b155..6fd34d7 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,12 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g= @@ -14,6 +20,23 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ= +golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= diff --git a/ovh/configuration.go b/ovh/configuration.go index 7dbea7a..313aa72 100644 --- a/ovh/configuration.go +++ b/ovh/configuration.go @@ -1,11 +1,13 @@ package ovh import ( + "context" "fmt" "os" "os/user" "strings" + "golang.org/x/oauth2/clientcredentials" "gopkg.in/ini.v1" ) @@ -71,6 +73,11 @@ func loadINI() (*ini.File, error) { return ini.LooseLoad(paths[0], paths[1:]...) } +const ( + authenticationModeOAuth2 = "oauth2" + authenticationModeOVH = "ak-as-ck" +) + // loadConfig loads client configuration from params, environments or configuration // files (by order of decreasing precedence). // @@ -114,6 +121,18 @@ func (c *Client) loadConfig(endpointName string) error { c.ConsumerKey = getConfigValue(cfg, endpointName, "consumer_key", "") } + if c.ClientID == "" { + c.ClientID = getConfigValue(cfg, endpointName, "client_id", "") + } + + if c.ClientSecret == "" { + c.ClientSecret = getConfigValue(cfg, endpointName, "client_secret", "") + } + + if c.ClientID != "" && c.ClientSecret != "" && c.AppKey != "" && c.AppSecret != "" { + return fmt.Errorf("can't use application_key/application_secret at the same time than OAuth2 client_id/client_secret") + } + // Load real endpoint URL by name. If endpoint contains a '/', consider it as a URL if strings.Contains(endpointName, "/") { c.endpoint = endpointName @@ -121,17 +140,52 @@ func (c *Client) loadConfig(endpointName string) error { c.endpoint = Endpoints[endpointName] } + if c.ClientID != "" && c.ClientSecret != "" { + c.AppKey, c.AppSecret, c.ConsumerKey = "", "", "" + c.authenticationMode = authenticationModeOAuth2 + if _, ok := tokensURLs[c.endpoint]; !ok { + return fmt.Errorf("oauth2 authentication is not compatible with endpoint %q", c.endpoint) + } + } + + if c.AppKey != "" || c.AppSecret != "" { + c.ClientID, c.ClientSecret = "", "" + c.authenticationMode = authenticationModeOVH + } + + if c.authenticationMode == "" { + return fmt.Errorf("missing authentication information, you need to provide at least an application_key/application_secret or a client_id/client_secret") + } + // If we still have no valid endpoint, AppKey or AppSecret, return an error if c.endpoint == "" { - return fmt.Errorf("unknown endpoint '%s', consider checking 'Endpoints' list of using an URL", endpointName) + return fmt.Errorf("unknown endpoint '%s', consider checking 'Endpoints' list or using an URL", endpointName) } - if c.AppKey == "" { + if c.AppKey == "" && c.authenticationMode == authenticationModeOVH { return fmt.Errorf("missing application key, please check your configuration or consult the documentation to create one") } - if c.AppSecret == "" { + if c.AppSecret == "" && c.authenticationMode == authenticationModeOVH { return fmt.Errorf("missing application secret, please check your configuration or consult the documentation to create one") } + if c.ClientID == "" && c.authenticationMode == authenticationModeOAuth2 { + return fmt.Errorf("missing client_id, please check your configuration or consult the documentation to create one") + } + if c.ClientSecret == "" && c.authenticationMode == authenticationModeOAuth2 { + return fmt.Errorf("missing client_secret, please check your configuration or consult the documentation to create one") + } + + if c.authenticationMode == authenticationModeOAuth2 { + conf := &clientcredentials.Config{ + ClientID: c.ClientID, + ClientSecret: c.ClientSecret, + TokenURL: tokensURLs[c.endpoint], + Scopes: []string{"all"}, + } + + c.oauth2TokenSource = conf.TokenSource(context.Background()) + } + return nil } diff --git a/ovh/configuration_test.go b/ovh/configuration_test.go index a08b0e5..92ce1a1 100644 --- a/ovh/configuration_test.go +++ b/ovh/configuration_test.go @@ -10,6 +10,8 @@ const ( systemConf = "testdata/system.ini" userPartialConf = "testdata/userPartial.ini" userConf = "testdata/user.ini" + userOAuth2Conf = "testdata/user_oauth2.ini" + userBothConf = "testdata/user_both.ini" localPartialConf = "testdata/localPartial.ini" localWithURLConf = "testdata/localWithURL.ini" doesNotExistConf = "testdata/doesNotExist.ini" @@ -60,7 +62,7 @@ func TestConfigFromNonExistingFile(t *testing.T) { client := Client{} err := client.loadConfig("ovh-eu") - td.CmpString(t, err, `missing application key, please check your configuration or consult the documentation to create one`) + td.CmpString(t, err, `missing authentication information, you need to provide at least an application_key/application_secret or a client_id/client_secret`) } func TestConfigFromInvalidINIFile(t *testing.T) { @@ -139,7 +141,7 @@ func TestMissingParam(t *testing.T) { client.endpoint = "" err := client.loadConfig("") - td.CmpString(t, err, `unknown endpoint '', consider checking 'Endpoints' list of using an URL`) + td.CmpString(t, err, `unknown endpoint '', consider checking 'Endpoints' list or using an URL`) client.AppKey = "" err = client.loadConfig("ovh-eu") @@ -163,3 +165,23 @@ func TestConfigPaths(t *testing.T) { []interface{}{"", "file", "file.ini", "dir/file.ini", home + "/file.ini", "~typo.ini"}, ) } + +func TestConfigOAuth2(t *testing.T) { + setConfigPaths(t, userOAuth2Conf) + + client := Client{} + err := client.loadConfig("ovh-eu") + td.Require(t).CmpNoError(err) + td.Cmp(t, client, td.Struct(Client{ + ClientID: "foo", + ClientSecret: "bar", + })) +} + +func TestConfigInvalidBoth(t *testing.T) { + setConfigPaths(t, userBothConf) + + client := Client{} + err := client.loadConfig("ovh-eu") + td.Require(t).CmpError(err, "can't use application_key/application_secret at the same time than OAuth2 client_id/client_secret") +} diff --git a/ovh/ovh.go b/ovh/ovh.go index 950fd37..4965510 100644 --- a/ovh/ovh.go +++ b/ovh/ovh.go @@ -14,6 +14,8 @@ import ( "strings" "sync/atomic" "time" + + "golang.org/x/oauth2" ) // getLocalTime is a function to be overwritten during the tests, it returns the time @@ -48,6 +50,12 @@ var Endpoints = map[string]string{ // Errors var ( ErrAPIDown = errors.New("go-ovh: the OVH API is not reachable: failed to get /auth/time response") + + tokensURLs = map[string]string{ + OvhEU: "https://www.ovh.com/auth/oauth2/token", + OvhCA: "https://ca.ovh.com/auth/oauth2/token", + OvhUS: "https://us.ovhcloud.com/auth/oauth2/token", + } ) // Client represents a client to call the OVH API @@ -63,8 +71,13 @@ type Client struct { // ConsumerKey holds the user/app specific token. It must have been validated before use. ConsumerKey string + ClientID string + ClientSecret string + // API endpoint - endpoint string + endpoint string + authenticationMode string + oauth2TokenSource oauth2.TokenSource // Client is the underlying HTTP client used to run the requests. It may be overloaded but a default one is instanciated in ``NewClient`` by default. Client *http.Client @@ -114,6 +127,21 @@ func NewDefaultClient() (*Client, error) { return NewClient("", "", "", "") } +func NewOAuth2Client(endpoint, clientID, clientSecret string) (*Client, error) { + client := Client{ + ClientID: clientID, + ClientSecret: clientSecret, + Client: &http.Client{}, + Timeout: DefaultTimeout, + } + + // Get and check the configuration + if err := client.loadConfig(endpoint); err != nil { + return nil, err + } + return &client, nil +} + func (c *Client) Endpoint() string { return c.endpoint } @@ -288,12 +316,14 @@ func (c *Client) NewRequest(method, path string, reqBody interface{}, needAuth b if body != nil { req.Header.Add("Content-Type", "application/json;charset=utf-8") } - req.Header.Add("X-Ovh-Application", c.AppKey) + if c.authenticationMode == authenticationModeOVH { + req.Header.Add("X-Ovh-Application", c.AppKey) + } req.Header.Add("Accept", "application/json") // Inject signature. Some methods do not need authentication, especially /time, // /auth and some /order methods are actually broken if authenticated. - if needAuth { + if needAuth && c.authenticationMode == authenticationModeOVH { timeDelta, err := c.TimeDelta() if err != nil { return nil, err @@ -314,6 +344,13 @@ func (c *Client) NewRequest(method, path string, reqBody interface{}, needAuth b timestamp, ))) req.Header.Add("X-Ovh-Signature", fmt.Sprintf("$1$%x", h.Sum(nil))) + } else if needAuth && c.authenticationMode == authenticationModeOAuth2 { + token, err := c.oauth2TokenSource.Token() + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+token.AccessToken) } // Send the request with requested timeout diff --git a/ovh/ovh_test.go b/ovh/ovh_test.go index 381d4d2..b8717b6 100644 --- a/ovh/ovh_test.go +++ b/ovh/ovh_test.go @@ -404,7 +404,7 @@ func TestConstructors(t *testing.T) { // Error: missing Endpoint client, err := NewClient("", MockApplicationKey, MockApplicationSecret, MockConsumerKey) assert.Nil(client) - assert.String(err, `unknown endpoint '', consider checking 'Endpoints' list of using an URL`) + assert.String(err, `unknown endpoint '', consider checking 'Endpoints' list or using an URL`) // Error: missing ApplicationKey client, err = NewClient("ovh-eu", "", MockApplicationSecret, MockConsumerKey) diff --git a/ovh/testdata/user_both.ini b/ovh/testdata/user_both.ini new file mode 100644 index 0000000..b0fa43a --- /dev/null +++ b/ovh/testdata/user_both.ini @@ -0,0 +1,5 @@ +[ovh-eu] +application_key=user +application_secret=user +client_id=foo +client_secret=bar \ No newline at end of file diff --git a/ovh/testdata/user_oauth2.ini b/ovh/testdata/user_oauth2.ini new file mode 100644 index 0000000..0501976 --- /dev/null +++ b/ovh/testdata/user_oauth2.ini @@ -0,0 +1,3 @@ +[ovh-eu] +client_id=foo +client_secret=bar \ No newline at end of file