Skip to content

Commit

Permalink
Merge pull request #79 from ovh/oauth2
Browse files Browse the repository at this point in the history
feat: add OAuth2 support
  • Loading branch information
deathiop authored Apr 16, 2024
2 parents 3d121d0 + fa48fc7 commit 18be085
Show file tree
Hide file tree
Showing 11 changed files with 422 additions and 49 deletions.
58 changes: 53 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
23 changes: 23 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand All @@ -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=
Expand Down
44 changes: 38 additions & 6 deletions ovh/configuration.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package ovh

import (
"context"
"errors"
"fmt"
"os"
"os/user"
"strings"

"golang.org/x/oauth2/clientcredentials"
"gopkg.in/ini.v1"
)

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
64 changes: 52 additions & 12 deletions ovh/configuration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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"`)
}
Loading

0 comments on commit 18be085

Please sign in to comment.