Skip to content

Commit fa48fc7

Browse files
committed
feat: add OAuth2 support
Resolves #76 Signed-off-by: Romain Beuque <556072+rbeuque74@users.noreply.github.com>
1 parent 3d121d0 commit fa48fc7

11 files changed

+422
-49
lines changed

README.md

+53-5
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,51 @@ Alternatively it is suggested to use configuration files or environment
6161
variables so that the same code may run seamlessly in multiple environments.
6262
Production and development for instance.
6363

64+
`go-ovh` supports two forms of authentication:
65+
- OAuth2, using scopped service accounts, and compatible with OVHcloud IAM
66+
- application key & application secret & consumer key
67+
68+
### OAuth2
69+
70+
First, you need to generate a pair of valid `client_id` and `client_secret`: you
71+
can proceed by [following this documentation](https://help.ovhcloud.com/csm/en-manage-service-account?id=kb_article_view&sysparm_article=KB0059343)
72+
73+
Once you have retrieved your `client_id` and `client_secret`, you can create and edit
74+
a configuration file that will be used by `go-ovh`.
75+
76+
```ini
77+
[default]
78+
; general configuration: default endpoint
79+
endpoint=ovh-eu
80+
81+
[ovh-eu]
82+
; configuration specific to 'ovh-eu' endpoint
83+
client_id=my_client_id
84+
client_secret=my_client_secret
85+
```
86+
87+
The client will successively attempt to locate this configuration file in
88+
89+
1. Current working directory: ``./ovh.conf``
90+
2. Current user's home directory: ``~/.ovh.conf``
91+
3. System wide configuration: ``/etc/ovh.conf``
92+
93+
Depending on the API you want to use, you may set the ``endpoint`` to:
94+
95+
* ``ovh-eu`` for OVHcloud Europe API
96+
* ``ovh-us`` for OVHcloud US API
97+
* ``ovh-ca`` for OVHcloud Canada API
98+
99+
This lookup mechanism makes it easy to overload credentials for a specific
100+
project or user.
101+
102+
### Application Key/Application Secret
103+
104+
If you have completed successfully the __OAuth2__ part, you can continue to
105+
[the Use the Lib part](https://github.com/ovh/go-ovh?tab=readme-ov-file#use-the-lib).
106+
107+
This section will cover the legacy authentication method using application key and
108+
application secret.
64109
This wrapper will first look for direct instanciation parameters then
65110
``OVH_ENDPOINT``, ``OVH_APPLICATION_KEY``, ``OVH_APPLICATION_SECRET`` and
66111
``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
98143
This lookup mechanism makes it easy to overload credentials for a specific
99144
project or user.
100145

101-
## Register your app
146+
#### Register your app
102147

103148
OVHcloud's API, like most modern APIs is designed to authenticate both an application and
104149
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
116161
to build an application for a single user. In this case you may generate all
117162
credentials at once. See below.
118163

119-
### Use the API on behalf of a user
164+
##### Use the API on behalf of a user
120165

121166
Visit [https://eu.api.ovh.com/createApp](https://eu.api.ovh.com/createApp) and create your app
122167
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() {
178223
}
179224
```
180225

181-
### Use the API for a single user
226+
##### Use the API for a single user
182227

183228
Alternatively, you may generate all creadentials at once, including the consumer key. You will
184229
typically want to do this when writing automation scripts for a single projects.
@@ -309,9 +354,10 @@ client.Get("/xdsl/xdsl-yourservice", nil)
309354

310355
### Create a client
311356

312-
- Use ``ovh.NewClient()`` to have full controll over ther authentication
313-
- Use ``ovh.NewEndpointClient()`` to create a client for a specific API and use credentials from config files or environment
314357
- Use ``ovh.NewDefaultClient()`` to create a client unsing endpoint and credentials from config files or environment
358+
- Use ``ovh.NewEndpointClient()`` to create a client for a specific API and use credentials from config files or environment
359+
- Use ``ovh.NewOAuth2Client()`` to have full control over their authentication, using OAuth2 authentication method
360+
- Use ``ovh.NewClient()`` to have full control over their authentication, using legacy authentication method
315361

316362
### Query
317363

@@ -342,6 +388,8 @@ Or, for unauthenticated requests:
342388

343389
### Request consumer keys
344390

391+
__[Only valid for legacy authentication method]__
392+
345393
Consumer keys may be restricted to a subset of the API. This allows to delegate the API to manage
346394
only a specific server or domain name for example. This is called "scoping" a consumer key.
347395

go.mod

+5
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@ go 1.18
55
require (
66
github.com/jarcoal/httpmock v1.3.0
77
github.com/maxatome/go-testdeep v1.12.0
8+
golang.org/x/oauth2 v0.18.0
89
gopkg.in/ini.v1 v1.67.0
910
)
1011

1112
require (
1213
github.com/davecgh/go-spew v1.1.1 // indirect
14+
github.com/golang/protobuf v1.5.3 // indirect
1315
github.com/stretchr/testify v1.8.2 // indirect
16+
golang.org/x/net v0.22.0 // indirect
17+
google.golang.org/appengine v1.6.7 // indirect
18+
google.golang.org/protobuf v1.31.0 // indirect
1419
)
1520

1621
retract (

go.sum

+23
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
22
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
33
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4+
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
5+
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
6+
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
7+
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
8+
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
9+
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
410
github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
511
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
612
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/
1420
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
1521
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
1622
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
23+
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
24+
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
25+
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
26+
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
27+
golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
28+
golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
29+
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
30+
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
31+
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
32+
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
33+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
34+
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
35+
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
36+
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
37+
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
38+
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
39+
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
1740
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
1841
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
1942
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=

ovh/configuration.go

+38-6
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package ovh
22

33
import (
4+
"context"
5+
"errors"
46
"fmt"
57
"os"
68
"os/user"
79
"strings"
810

11+
"golang.org/x/oauth2/clientcredentials"
912
"gopkg.in/ini.v1"
1013
)
1114

@@ -114,6 +117,27 @@ func (c *Client) loadConfig(endpointName string) error {
114117
c.ConsumerKey = getConfigValue(cfg, endpointName, "consumer_key", "")
115118
}
116119

120+
if c.ClientID == "" {
121+
c.ClientID = getConfigValue(cfg, endpointName, "client_id", "")
122+
}
123+
124+
if c.ClientSecret == "" {
125+
c.ClientSecret = getConfigValue(cfg, endpointName, "client_secret", "")
126+
}
127+
128+
if (c.ClientID != "") != (c.ClientSecret != "") {
129+
return errors.New("invalid oauth2 config, both client_id and client_secret must be given")
130+
}
131+
if (c.AppKey != "") != (c.AppSecret != "") {
132+
return errors.New("invalid authentication config, both application_key and application_secret must be given")
133+
}
134+
135+
if c.ClientID != "" && c.AppKey != "" {
136+
return errors.New("can't use both application_key/application_secret and OAuth2 client_id/client_secret")
137+
} else if c.ClientID == "" && c.AppKey == "" {
138+
return errors.New("missing authentication information, you need to provide at least an application_key/application_secret or a client_id/client_secret")
139+
}
140+
117141
// Load real endpoint URL by name. If endpoint contains a '/', consider it as a URL
118142
if strings.Contains(endpointName, "/") {
119143
c.endpoint = endpointName
@@ -123,13 +147,21 @@ func (c *Client) loadConfig(endpointName string) error {
123147

124148
// If we still have no valid endpoint, AppKey or AppSecret, return an error
125149
if c.endpoint == "" {
126-
return fmt.Errorf("unknown endpoint '%s', consider checking 'Endpoints' list of using an URL", endpointName)
150+
return fmt.Errorf("unknown endpoint '%s', consider checking 'Endpoints' list or using an URL", endpointName)
127151
}
128-
if c.AppKey == "" {
129-
return fmt.Errorf("missing application key, please check your configuration or consult the documentation to create one")
130-
}
131-
if c.AppSecret == "" {
132-
return fmt.Errorf("missing application secret, please check your configuration or consult the documentation to create one")
152+
153+
if c.ClientID != "" {
154+
if _, ok := tokensURLs[c.endpoint]; !ok {
155+
return fmt.Errorf("oauth2 authentication is not compatible with endpoint %q", c.endpoint)
156+
}
157+
158+
conf := &clientcredentials.Config{
159+
ClientID: c.ClientID,
160+
ClientSecret: c.ClientSecret,
161+
TokenURL: tokensURLs[c.endpoint],
162+
}
163+
164+
c.oauth2TokenSource = conf.TokenSource(context.Background())
133165
}
134166

135167
return nil

ovh/configuration_test.go

+52-12
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,18 @@ import (
77
)
88

99
const (
10-
systemConf = "testdata/system.ini"
11-
userPartialConf = "testdata/userPartial.ini"
12-
userConf = "testdata/user.ini"
13-
localPartialConf = "testdata/localPartial.ini"
14-
localWithURLConf = "testdata/localWithURL.ini"
15-
doesNotExistConf = "testdata/doesNotExist.ini"
16-
invalidINIConf = "testdata/invalid.ini"
17-
errorConf = "testdata"
10+
systemConf = "testdata/system.ini"
11+
userPartialConf = "testdata/userPartial.ini"
12+
userConf = "testdata/user.ini"
13+
userOAuth2Conf = "testdata/user_oauth2.ini"
14+
userOAuth2InvalidConf = "testdata/user_oauth2_invalid.ini"
15+
userOAuth2IncompatibleConfig = "testdata/user_oauth2_incompatible.ini"
16+
userBothConf = "testdata/user_both.ini"
17+
localPartialConf = "testdata/localPartial.ini"
18+
localWithURLConf = "testdata/localWithURL.ini"
19+
doesNotExistConf = "testdata/doesNotExist.ini"
20+
invalidINIConf = "testdata/invalid.ini"
21+
errorConf = "testdata"
1822
)
1923

2024
func setConfigPaths(t testing.TB, paths ...string) {
@@ -60,7 +64,7 @@ func TestConfigFromNonExistingFile(t *testing.T) {
6064

6165
client := Client{}
6266
err := client.loadConfig("ovh-eu")
63-
td.CmpString(t, err, `missing application key, please check your configuration or consult the documentation to create one`)
67+
td.CmpString(t, err, `missing authentication information, you need to provide at least an application_key/application_secret or a client_id/client_secret`)
6468
}
6569

6670
func TestConfigFromInvalidINIFile(t *testing.T) {
@@ -139,16 +143,16 @@ func TestMissingParam(t *testing.T) {
139143

140144
client.endpoint = ""
141145
err := client.loadConfig("")
142-
td.CmpString(t, err, `unknown endpoint '', consider checking 'Endpoints' list of using an URL`)
146+
td.CmpString(t, err, `unknown endpoint '', consider checking 'Endpoints' list or using an URL`)
143147

144148
client.AppKey = ""
145149
err = client.loadConfig("ovh-eu")
146-
td.CmpString(t, err, `missing application key, please check your configuration or consult the documentation to create one`)
150+
td.CmpString(t, err, `invalid authentication config, both application_key and application_secret must be given`)
147151
client.AppKey = "param"
148152

149153
client.AppSecret = ""
150154
err = client.loadConfig("ovh-eu")
151-
td.CmpString(t, err, `missing application secret, please check your configuration or consult the documentation to create one`)
155+
td.CmpString(t, err, `invalid authentication config, both application_key and application_secret must be given`)
152156
}
153157

154158
func TestConfigPaths(t *testing.T) {
@@ -163,3 +167,39 @@ func TestConfigPaths(t *testing.T) {
163167
[]interface{}{"", "file", "file.ini", "dir/file.ini", home + "/file.ini", "~typo.ini"},
164168
)
165169
}
170+
171+
func TestConfigOAuth2(t *testing.T) {
172+
setConfigPaths(t, userOAuth2Conf)
173+
174+
client := Client{}
175+
err := client.loadConfig("ovh-eu")
176+
td.Require(t).CmpNoError(err)
177+
td.Cmp(t, client, td.Struct(Client{
178+
ClientID: "foo",
179+
ClientSecret: "bar",
180+
}))
181+
}
182+
183+
func TestConfigInvalidBoth(t *testing.T) {
184+
setConfigPaths(t, userBothConf)
185+
186+
client := Client{}
187+
err := client.loadConfig("ovh-eu")
188+
td.CmpString(t, err, "can't use both application_key/application_secret and OAuth2 client_id/client_secret")
189+
}
190+
191+
func TestConfigOAuth2Invalid(t *testing.T) {
192+
setConfigPaths(t, userOAuth2InvalidConf)
193+
194+
client := Client{}
195+
err := client.loadConfig("ovh-eu")
196+
td.CmpString(t, err, "invalid oauth2 config, both client_id and client_secret must be given")
197+
}
198+
199+
func TestConfigOAuth2Incompatible(t *testing.T) {
200+
setConfigPaths(t, userOAuth2IncompatibleConfig)
201+
202+
client := Client{}
203+
err := client.loadConfig("kimsufi-eu")
204+
td.CmpString(t, err, `oauth2 authentication is not compatible with endpoint "https://eu.api.kimsufi.com/1.0"`)
205+
}

0 commit comments

Comments
 (0)