diff --git a/README.md b/README.md index a440156..3adf3b2 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,51 @@ Alternatively it is suggested to use configuration files or environment variables so that the same code may run seamlessly in multiple environments. Production and development for instance. +`go-ovh` supports two forms of authentication: +- OAuth2, using scopped service accounts, and compatible with OVHcloud IAM +- application key & application secret & consumer key + +### OAuth2 + +First, you need to generate a pair of valid `client_id` and `client_secret`: you +can proceed by [following this documentation](https://help.ovhcloud.com/csm/en-manage-service-account?id=kb_article_view&sysparm_article=KB0059343) + +Once you have retrieved your `client_id` and `client_secret`, you can create and edit +a configuration file that will be used by `go-ovh`. + +```ini +[default] +; general configuration: default endpoint +endpoint=ovh-eu + +[ovh-eu] +; configuration specific to 'ovh-eu' endpoint +client_id=my_client_id +client_secret=my_client_secret +``` + +The client will successively attempt to locate this configuration file in + +1. Current working directory: ``./ovh.conf`` +2. Current user's home directory: ``~/.ovh.conf`` +3. System wide configuration: ``/etc/ovh.conf`` + +Depending on the API you want to use, you may set the ``endpoint`` to: + +* ``ovh-eu`` for OVHcloud Europe API +* ``ovh-us`` for OVHcloud US API +* ``ovh-ca`` for OVHcloud Canada API + +This lookup mechanism makes it easy to overload credentials for a specific +project or user. + +### Application Key/Application Secret + +If you have completed successfully the __OAuth2__ part, you can continue to +[the Use the Lib part](https://github.com/ovh/go-ovh?tab=readme-ov-file#use-the-lib). + +This section will cover the legacy authentication method using application key and +application secret. This wrapper will first look for direct instanciation parameters then ``OVH_ENDPOINT``, ``OVH_APPLICATION_KEY``, ``OVH_APPLICATION_SECRET`` and ``OVH_CONSUMER_KEY`` environment variables. If either of these parameter is not @@ -98,7 +143,7 @@ The client will successively attempt to locate this configuration file in This lookup mechanism makes it easy to overload credentials for a specific project or user. -## Register your app +#### Register your app OVHcloud's API, like most modern APIs is designed to authenticate both an application and a user, without requiring the user to provide a password. Your application will be @@ -116,7 +161,7 @@ This process is detailed in the following section. Alternatively, you may only n to build an application for a single user. In this case you may generate all credentials at once. See below. -### Use the API on behalf of a user +##### Use the API on behalf of a user Visit [https://eu.api.ovh.com/createApp](https://eu.api.ovh.com/createApp) and create your app You'll get an application key and an application secret. To use the API you'll need a consumer key. @@ -178,7 +223,7 @@ func main() { } ``` -### Use the API for a single user +##### Use the API for a single user Alternatively, you may generate all creadentials at once, including the consumer key. You will typically want to do this when writing automation scripts for a single projects. @@ -309,9 +354,10 @@ client.Get("/xdsl/xdsl-yourservice", nil) ### Create a client -- Use ``ovh.NewClient()`` to have full controll over ther authentication -- Use ``ovh.NewEndpointClient()`` to create a client for a specific API and use credentials from config files or environment - Use ``ovh.NewDefaultClient()`` to create a client unsing endpoint and credentials from config files or environment +- Use ``ovh.NewEndpointClient()`` to create a client for a specific API and use credentials from config files or environment +- Use ``ovh.NewOAuth2Client()`` to have full control over their authentication, using OAuth2 authentication method +- Use ``ovh.NewClient()`` to have full control over their authentication, using legacy authentication method ### Query @@ -342,6 +388,8 @@ Or, for unauthenticated requests: ### Request consumer keys +__[Only valid for legacy authentication method]__ + Consumer keys may be restricted to a subset of the API. This allows to delegate the API to manage only a specific server or domain name for example. This is called "scoping" a consumer key. diff --git a/go.mod b/go.mod index 95cffcb..1789ecf 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.18.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.22.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..8637b6c 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.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= +golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= +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..2be8096 100644 --- a/ovh/configuration.go +++ b/ovh/configuration.go @@ -1,11 +1,14 @@ package ovh import ( + "context" + "errors" "fmt" "os" "os/user" "strings" + "golang.org/x/oauth2/clientcredentials" "gopkg.in/ini.v1" ) @@ -114,6 +117,27 @@ 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 != "") { + return errors.New("invalid oauth2 config, both client_id and client_secret must be given") + } + if (c.AppKey != "") != (c.AppSecret != "") { + return errors.New("invalid authentication config, both application_key and application_secret must be given") + } + + if c.ClientID != "" && c.AppKey != "" { + return errors.New("can't use both application_key/application_secret and OAuth2 client_id/client_secret") + } else if c.ClientID == "" && c.AppKey == "" { + return errors.New("missing authentication information, you need to provide at least an application_key/application_secret or a 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 @@ -123,13 +147,21 @@ func (c *Client) loadConfig(endpointName string) error { // 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 == "" { - return fmt.Errorf("missing application key, please check your configuration or consult the documentation to create one") - } - if c.AppSecret == "" { - return fmt.Errorf("missing application secret, please check your configuration or consult the documentation to create one") + + if c.ClientID != "" { + if _, ok := tokensURLs[c.endpoint]; !ok { + return fmt.Errorf("oauth2 authentication is not compatible with endpoint %q", c.endpoint) + } + + conf := &clientcredentials.Config{ + ClientID: c.ClientID, + ClientSecret: c.ClientSecret, + TokenURL: tokensURLs[c.endpoint], + } + + c.oauth2TokenSource = conf.TokenSource(context.Background()) } return nil diff --git a/ovh/configuration_test.go b/ovh/configuration_test.go index a08b0e5..c524cf7 100644 --- a/ovh/configuration_test.go +++ b/ovh/configuration_test.go @@ -7,14 +7,18 @@ import ( ) const ( - systemConf = "testdata/system.ini" - userPartialConf = "testdata/userPartial.ini" - userConf = "testdata/user.ini" - localPartialConf = "testdata/localPartial.ini" - localWithURLConf = "testdata/localWithURL.ini" - doesNotExistConf = "testdata/doesNotExist.ini" - invalidINIConf = "testdata/invalid.ini" - errorConf = "testdata" + systemConf = "testdata/system.ini" + userPartialConf = "testdata/userPartial.ini" + userConf = "testdata/user.ini" + userOAuth2Conf = "testdata/user_oauth2.ini" + userOAuth2InvalidConf = "testdata/user_oauth2_invalid.ini" + userOAuth2IncompatibleConfig = "testdata/user_oauth2_incompatible.ini" + userBothConf = "testdata/user_both.ini" + localPartialConf = "testdata/localPartial.ini" + localWithURLConf = "testdata/localWithURL.ini" + doesNotExistConf = "testdata/doesNotExist.ini" + invalidINIConf = "testdata/invalid.ini" + errorConf = "testdata" ) func setConfigPaths(t testing.TB, paths ...string) { @@ -60,7 +64,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,16 +143,16 @@ 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") - td.CmpString(t, err, `missing application key, please check your configuration or consult the documentation to create one`) + td.CmpString(t, err, `invalid authentication config, both application_key and application_secret must be given`) client.AppKey = "param" client.AppSecret = "" err = client.loadConfig("ovh-eu") - td.CmpString(t, err, `missing application secret, please check your configuration or consult the documentation to create one`) + td.CmpString(t, err, `invalid authentication config, both application_key and application_secret must be given`) } func TestConfigPaths(t *testing.T) { @@ -163,3 +167,39 @@ 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.CmpString(t, err, "can't use both application_key/application_secret and OAuth2 client_id/client_secret") +} + +func TestConfigOAuth2Invalid(t *testing.T) { + setConfigPaths(t, userOAuth2InvalidConf) + + client := Client{} + err := client.loadConfig("ovh-eu") + td.CmpString(t, err, "invalid oauth2 config, both client_id and client_secret must be given") +} + +func TestConfigOAuth2Incompatible(t *testing.T) { + setConfigPaths(t, userOAuth2IncompatibleConfig) + + client := Client{} + err := client.loadConfig("kimsufi-eu") + td.CmpString(t, err, `oauth2 authentication is not compatible with endpoint "https://eu.api.kimsufi.com/1.0"`) +} diff --git a/ovh/ovh.go b/ovh/ovh.go index 950fd37..0c47c32 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,12 @@ 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 + 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 +126,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,32 +315,43 @@ 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.AppKey != "" { + 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 { - timeDelta, err := c.TimeDelta() - if err != nil { - return nil, err + if c.AppKey != "" { + timeDelta, err := c.TimeDelta() + if err != nil { + return nil, err + } + + timestamp := getLocalTime().Add(-timeDelta).Unix() + + req.Header.Add("X-Ovh-Timestamp", strconv.FormatInt(timestamp, 10)) + req.Header.Add("X-Ovh-Consumer", c.ConsumerKey) + + h := sha1.New() + h.Write([]byte(fmt.Sprintf("%s+%s+%s+%s+%s+%d", + c.AppSecret, + c.ConsumerKey, + method, + target, + body, + timestamp, + ))) + req.Header.Add("X-Ovh-Signature", fmt.Sprintf("$1$%x", h.Sum(nil))) + } else if c.ClientID != "" { + token, err := c.oauth2TokenSource.Token() + if err != nil { + return nil, fmt.Errorf("failed to retrieve OAuth2 Access Token: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token.AccessToken) } - - timestamp := getLocalTime().Add(-timeDelta).Unix() - - req.Header.Add("X-Ovh-Timestamp", strconv.FormatInt(timestamp, 10)) - req.Header.Add("X-Ovh-Consumer", c.ConsumerKey) - - h := sha1.New() - h.Write([]byte(fmt.Sprintf("%s+%s+%s+%s+%s+%d", - c.AppSecret, - c.ConsumerKey, - method, - target, - body, - timestamp, - ))) - req.Header.Add("X-Ovh-Signature", fmt.Sprintf("$1$%x", h.Sum(nil))) } // Send the request with requested timeout diff --git a/ovh/ovh_test.go b/ovh/ovh_test.go index 381d4d2..48e7fc6 100644 --- a/ovh/ovh_test.go +++ b/ovh/ovh_test.go @@ -5,8 +5,8 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "net/http" + "os" "strconv" "strings" "testing" @@ -31,7 +31,7 @@ const ( // func sbody(s string) io.ReadCloser { - return ioutil.NopCloser(strings.NewReader(s)) + return io.NopCloser(strings.NewReader(s)) } // @@ -404,17 +404,17 @@ 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) assert.Nil(client) - assert.String(err, `missing application key, please check your configuration or consult the documentation to create one`) + assert.String(err, `invalid authentication config, both application_key and application_secret must be given`) // Error: missing ApplicationSecret client, err = NewClient("ovh-eu", MockConsumerKey, "", MockConsumerKey) assert.Nil(client) - assert.String(err, `missing application secret, please check your configuration or consult the documentation to create one`) + assert.String(err, `invalid authentication config, both application_key and application_secret must be given`) // Next: success cases expected := td.Struct(&Client{ @@ -446,6 +446,46 @@ func TestConstructors(t *testing.T) { assert.Cmp(client, expected) } +func TestConstructorsOAuth2(t *testing.T) { + assert, require := td.AssertRequire(t) + + // Error: missing Endpoint + client, err := NewOAuth2Client("", "aaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbb") + assert.Nil(client) + assert.String(err, `unknown endpoint '', consider checking 'Endpoints' list or using an URL`) + + // Error: missing Client ID + client, err = NewOAuth2Client("ovh-eu", "", "MockApplicationSecret") + assert.Nil(client) + assert.String(err, `invalid oauth2 config, both client_id and client_secret must be given`) + + // Error: missing Client Secret + client, err = NewOAuth2Client("ovh-eu", "aaaaaaaaaaaaaaa", "") + assert.Nil(client) + assert.String(err, `invalid oauth2 config, both client_id and client_secret must be given`) + + // Next: success cases + expected := td.Struct(&Client{ + ClientID: "aaaaaaaa", + ClientSecret: "bbbbbbbbbbbbbbbbbbbbbbbbbbbb", + endpoint: "https://eu.api.ovh.com/1.0", + }) + + // Nominal: full constructor + client, err = NewOAuth2Client("ovh-eu", "aaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbb") + require.CmpNoError(err) + assert.Cmp(client, expected) + + // With NewEndpointClient + setConfigPaths(t, userOAuth2Conf) + client, err = NewEndpointClient("ovh-eu") + require.CmpNoError(err) + assert.Cmp(client, td.Struct(&Client{ + ClientID: "foo", + ClientSecret: "bar", + })) +} + func (ms *MockSuite) TestVersionInURL(assert, require *td.T) { // Signature checking mocks httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/call", func(req *http.Request) (*http.Response, error) { @@ -492,3 +532,136 @@ func (ms *MockSuite) TestVersionInURL(assert, require *td.T) { require.CmpNoError(ms.client.Get("/v2/call", nil)) assertCallCount(assert, 1, 1, 1) } + +func TestOAuth2_503(t *testing.T) { + assert, require := td.AssertRequire(t) + + httpmock.Activate() + defer httpmock.DeactivateAndReset() + errHTML := `
test
` + httpmock.RegisterResponder("POST", "https://www.ovh.com/auth/oauth2/token", + httpmock.NewStringResponder(http.StatusServiceUnavailable, errHTML)) + + // Nominal: full constructor + client, err := NewOAuth2Client("ovh-eu", "aaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbb") + require.CmpNoError(err) + + err = client.Get("/v1/auth/time", nil) + assert.String(err, "failed to retrieve OAuth2 Access Token: oauth2: cannot fetch token: 503\nResponse:test
") +} + +func TestOAuth2_BadJSON(t *testing.T) { + assert, require := td.AssertRequire(t) + + httpmock.Activate() + defer httpmock.DeactivateAndReset() + errHTML := `test
` + httpmock.RegisterResponder("POST", "https://www.ovh.com/auth/oauth2/token", + httpmock.NewStringResponder(http.StatusOK, errHTML)) + + // Nominal: full constructor + client, err := NewOAuth2Client("ovh-eu", "aaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbb") + require.CmpNoError(err) + + err = client.Get("/v1/auth/time", nil) + assert.String(err, "failed to retrieve OAuth2 Access Token: oauth2: cannot parse json: invalid character '<' looking for beginning of value") +} + +func TestOAuth2_UnknownClient(t *testing.T) { + assert, require := td.AssertRequire(t) + + httpmock.Activate() + defer httpmock.DeactivateAndReset() + output := `{"error":"invalid_client", "error_description":"ovhcloud oauth2 client does not exists"}` + httpmock.RegisterResponder("POST", "https://www.ovh.com/auth/oauth2/token", + httpmock.NewStringResponder(http.StatusBadRequest, output)) + + // Nominal: full constructor + client, err := NewOAuth2Client("ovh-eu", "aaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbb") + require.CmpNoError(err) + + err = client.Get("/v1/auth/time", nil) + assert.String(err, `failed to retrieve OAuth2 Access Token: oauth2: "invalid_client" "ovhcloud oauth2 client does not exists"`) +} + +func TestOAuth2_OK(t *testing.T) { + assert, require := td.AssertRequire(t) + + httpmock.Activate() + defer httpmock.DeactivateAndReset() + // expires_in set to 11 seconds. Will test that token are well renewed. + // golang.org/x/oauth2 has internal 10 seconds period that it will use to renew the token before actual expiration + output := `{"access_token":"cccccccccccccccc", "token_type":"Bearer", "expires_in":11,"scope":"all"}` + httpmock.RegisterResponder("POST", "https://www.ovh.com/auth/oauth2/token", + httpmock.NewStringResponder(http.StatusOK, output)) + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/v1/auth/time", + func(req *http.Request) (*http.Response, error) { + assert.Cmp(req.Header.Get("Authorization"), "Bearer cccccccccccccccc") + resp, err := httpmock.NewJsonResponse(http.StatusOK, map[string]string{ + "hello": "world", + }) + require.CmpNoError(err) + return resp, nil + }) + + // Nominal: full constructor + client, err := NewOAuth2Client("ovh-eu", "aaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbb") + require.CmpNoError(err) + + out := map[string]string{} + err = client.Get("/v1/auth/time", &out) + require.CmpNoError(err) + assert.Cmp(out, map[string]string{ + "hello": "world", + }) + + assert.Cmp(httpmock.GetCallCountInfo(), map[string]int{ + "POST https://www.ovh.com/auth/oauth2/token": 1, + "GET https://eu.api.ovh.com/v1/auth/time": 1, + }, "no token at this time, retrieving the token") + + httpmock.ZeroCallCounters() + + err = client.Get("/v1/auth/time", &out) + require.CmpNoError(err) + assert.Cmp(httpmock.GetCallCountInfo(), map[string]int{ + "GET https://eu.api.ovh.com/v1/auth/time": 1, + "POST https://www.ovh.com/auth/oauth2/token": 0, + }, "token is still valid, no call to retrieve new token") + + // waiting 3 seconds, to get below the 10 seconds period + time.Sleep(time.Second + 100*time.Millisecond) + + httpmock.ZeroCallCounters() + + err = client.Get("/v1/auth/time", &out) + require.CmpNoError(err) + assert.Cmp(httpmock.GetCallCountInfo(), map[string]int{ + "GET https://eu.api.ovh.com/v1/auth/time": 1, + "POST https://www.ovh.com/auth/oauth2/token": 1, + }, "token is considered as expired, renewing token") +} + +func TestOAuth2_ForReal(t *testing.T) { + assert, require := td.AssertRequire(t) + + if os.Getenv("OAUTH2_CLIENT_ID") == "" && os.Getenv("OAUTH2_CLIENT_SECRET") == "" { + t.SkipNow() + } + + clientID, clientSecret := os.Getenv("OAUTH2_CLIENT_ID"), os.Getenv("OAUTH2_CLIENT_SECRET") + + // Nominal: full constructor + client, err := NewOAuth2Client("ovh-eu", clientID, clientSecret) + require.CmpNoError(err) + + type outType struct { + Identities []string + } + out := outType{} + err = client.Get("/v1/auth/details", &out) + require.CmpNoError(err) + require.Gte(len(out.Identities), 1) + assert.Contains(out.Identities[0], "/oauth2-") + assert.Contains(out.Identities[0], ":identity:credential:") +} 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 diff --git a/ovh/testdata/user_oauth2_incompatible.ini b/ovh/testdata/user_oauth2_incompatible.ini new file mode 100644 index 0000000..2bfcebb --- /dev/null +++ b/ovh/testdata/user_oauth2_incompatible.ini @@ -0,0 +1,3 @@ +[kimsufi-eu] +client_id=foo +client_secret=bar \ No newline at end of file diff --git a/ovh/testdata/user_oauth2_invalid.ini b/ovh/testdata/user_oauth2_invalid.ini new file mode 100644 index 0000000..9d7b564 --- /dev/null +++ b/ovh/testdata/user_oauth2_invalid.ini @@ -0,0 +1,3 @@ +[ovh-eu] +client_id=foo +client_secret= \ No newline at end of file