Skip to content

Commit

Permalink
feat: add OAuth2 support
Browse files Browse the repository at this point in the history
Resolves #76

Signed-off-by: Romain Beuque <556072+rbeuque74@users.noreply.github.com>
  • Loading branch information
rbeuque74 committed Dec 22, 2023
1 parent 48ddc3f commit 133b8b8
Show file tree
Hide file tree
Showing 8 changed files with 158 additions and 9 deletions.
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.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 (
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.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=
Expand Down
60 changes: 57 additions & 3 deletions ovh/configuration.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package ovh

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

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

Expand Down Expand Up @@ -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).
//
Expand Down Expand Up @@ -114,24 +121,71 @@ 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
} else {
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
}

Expand Down
26 changes: 24 additions & 2 deletions ovh/configuration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
Expand All @@ -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")
}
43 changes: 40 additions & 3 deletions ovh/ovh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ovh/ovh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions ovh/testdata/user_both.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[ovh-eu]
application_key=user
application_secret=user
client_id=foo
client_secret=bar
3 changes: 3 additions & 0 deletions ovh/testdata/user_oauth2.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[ovh-eu]
client_id=foo
client_secret=bar

0 comments on commit 133b8b8

Please sign in to comment.