Skip to content

Commit

Permalink
Merge branch 'migration'
Browse files Browse the repository at this point in the history
  • Loading branch information
mikouaj committed Jul 30, 2020
2 parents fc0f254 + b385f26 commit 66cdcb0
Show file tree
Hide file tree
Showing 8 changed files with 352 additions and 0 deletions.
17 changes: 17 additions & 0 deletions GNUmakefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
GOCMD=go
TEST?=$$(go list ./... |grep -v 'vendor')

default: clean build test

all: default

test:
${GOCMD} test -v

build:
${GOCMD} build

clean:
${GOCMD} clean

.PHONY: test build clean
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
Equinix oAuth2 Go client
================
Go implementation of oAuth2 enabbled HTTP client for interactions with Equinix APIs.
Module implementes Equinix specific client credentials grant type with custom `TokenSource` from standard Go oauth2 module.

* Contact us : https://developer.equinix.com/contact-us

Requirements
----------------
* [Go](https://golang.org/doc/install) 1.14+ (to build provider plugin)

Usage
----------------
1. Import
```
import "github.com/equinix/oauth2-go"
```
2. Prepare configuration and get http client
```
authConfig := oauth2.Config{
ClientID: "myClientId",
ClientSecret: "myClientSecret"
BaseURL: "https://api.equinix.com"}
//*http.Client is returned
hc := authConfig.New(context.Background())
```
3. Use client
`*http.Client` created by oAuth2 library will deal with token acquisition, refreshment and population of Authorization headers in subsequent requests.
Below example shows how to use oAuth2 client with [Resty REST client library](https://github.com/go-resty/resty)
```
rc := resty.NewWithClient(hc)
resp, err := rc.R().Get("https://api.equinix.com/ecx/v3/port/userport")
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Status Code:", resp.StatusCode())
fmt.Println("Body:\n", resp)
}
```
10 changes: 10 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module oauth2-go

go 1.14

require (
github.com/go-resty/resty/v2 v2.3.0
github.com/jarcoal/httpmock v1.0.5
github.com/stretchr/testify v1.6.0
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
)
32 changes: 32 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-resty/resty/v2 v2.3.0 h1:JOOeAvjSlapTT92p8xiS19Zxev1neGikoHsXJeOq8So=
github.com/go-resty/resty/v2 v2.3.0/go.mod h1:UpN9CgLZNsv4e9XG50UU8xdI0F43UQ4HmxLBDwaroHU=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/jarcoal/httpmock v1.0.5 h1:cHtVEcTxRSX4J0je7mWPfc9BpDpqzXSJ5HbymZmyHck=
github.com/jarcoal/httpmock v1.0.5/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.0 h1:jlIyCplCJFULU/01vCkhKuTyc3OorI3bJFuw6obfgho=
github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120 h1:EZ3cVSzKOlJxAd8e8YAJ7no8nNypTxexh/YE/xW3ZEY=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
130 changes: 130 additions & 0 deletions oauth2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//Package oauth2 provides support for making oAuth2 authorized and authenticated HTTP requests
//for interactions with Equinix APIs, in particular Equinix specific client credencials grant type
package oauth2

import (
"context"
"fmt"
"net/http"
"strconv"
"time"

"github.com/go-resty/resty/v2"
"golang.org/x/oauth2"
)

const (
tokenPath = "/oauth2/v1/token"
defTokenTimeout = 3600
)

//Config describes oauth2 client credentials flow
type Config struct {
// ClientID is the application's ID.
ClientID string
// ClientSecret is the application's secret.
ClientSecret string
// BaseURL is the base endpoint of a server that token endpoint
BaseURL string
}

//Error describes oauth2 err
type Error struct {
Code string
Message string
}

func (e Error) Error() string {
return fmt.Sprintf("oauth2: error when acquiring token: code: %v, message %v", e.Code, e.Message)
}

//New creates *http.Client with Equinix oAuth2 tokensource.
//The returned client is not valid beyond the lifetime of the context.
func (c *Config) New(ctx context.Context) *http.Client {
return c.NewWithClient(ctx, nil)
}

//NewWithClient creates *http.Client with Equinix oAuth2 tokensource and custom *http.Client.
//The returned client is not valid beyond the lifetime of the context.
func (c *Config) NewWithClient(ctx context.Context, hc *http.Client) *http.Client {
return oauth2.NewClient(ctx, c.TokenSource(ctx, hc))
}

//TokenSource returns a TokenSource that returns t until t expires,
//automatically refreshing it as necessary using the provided context and the
//client ID and client secret.
func (c *Config) TokenSource(ctx context.Context, hc *http.Client) oauth2.TokenSource {
var restClient *resty.Client
if hc == nil {
restClient = resty.New()
} else {
restClient = resty.NewWithClient(hc)
}
source := &tokenSource{
ctx,
c,
restClient}
return oauth2.ReuseTokenSource(nil, source)
}

type tokenSource struct {
ctx context.Context
conf *Config
*resty.Client
}

type tokenRequest struct {
GrantType string `json:"grant_type"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}

type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
TokenTimeout string `json:"token_timeout"`
RefreshToken string `json:"refresh_token"`
}

type tokenError struct {
ErrorCode string `json:"errorCode"`
ErrorMessage string `json:"errorMessage"`
}

func (c *tokenSource) Token() (*oauth2.Token, error) {
req := tokenRequest{"client_credentials", c.conf.ClientID, c.conf.ClientSecret}
result := &tokenResponse{}
resp, err := c.R().
SetContext(c.ctx).
SetHeader("Accept", "application/json").
SetHeader("Content-Type", "application/json").
SetHeader("User-agent", "equinix/oauth2-go").
SetBody(&req).
SetResult(result).
SetError(&tokenError{}).
Post(c.conf.BaseURL + tokenPath)

if err != nil {
return nil, fmt.Errorf("oauth2: failed to fetch token: %s", err)
}
if resp.IsError() {
respError := resp.Error().(*tokenError)
return nil, Error{Code: respError.ErrorCode, Message: respError.ErrorMessage}
}
token := oauth2.Token{
AccessToken: result.AccessToken,
TokenType: "Bearer",
RefreshToken: result.RefreshToken}

timeout, err := strconv.Atoi(result.TokenTimeout)
if err != nil {
timeout = defTokenTimeout
}
if timeout != 0 {
token.Expiry = time.Now().Add(time.Duration(timeout) * time.Second)
}
if token.AccessToken == "" {
return nil, fmt.Errorf("oauth2: server response missing access_token")
}
return &token, nil
}
106 changes: 106 additions & 0 deletions oauth2_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package oauth2

import (
"context"
"encoding/json"
"io/ioutil"
"net/http"
"strconv"
"testing"
"time"

"github.com/jarcoal/httpmock"
"github.com/stretchr/testify/assert"
"golang.org/x/oauth2"
)

const (
baseURL = "http://localhost:8888"
)

func TestTokenFetch(t *testing.T) {
//Given
clientID := "testClientID"
clientSecret := "testClientSecret"
respFile, _ := ioutil.ReadFile("./test-fixtures/token_response.json")
resp := tokenResponse{}
req := tokenRequest{}
if err := json.Unmarshal(respFile, &resp); err != nil {
assert.Fail(t, "Can't unmarshal response file")
}
testHc := &http.Client{}
httpmock.ActivateNonDefault(testHc)
defer httpmock.DeactivateAndReset()
httpmock.RegisterResponder("POST", baseURL+tokenPath,
func(r *http.Request) (*http.Response, error) {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return httpmock.NewStringResponse(400, ""), nil
}
resp, _ := httpmock.NewJsonResponse(200, resp)
return resp, nil
},
)

//when
testConfig := Config{
ClientID: clientID,
ClientSecret: clientSecret,
BaseURL: baseURL}
token, err := testConfig.TokenSource(context.Background(), testHc).Token()

//then
assert.Nil(t, err, "TokenSource should not return an error")
assert.NotNil(t, token, "TokenSource should return a token")
verifyTokenRequest(t, req, clientID, clientSecret)
verifyToken(t, *token, resp)
}

func TestError(t *testing.T) {
respFile, _ := ioutil.ReadFile("./test-fixtures/token_response_err.json")
resp := tokenError{}
if err := json.Unmarshal(respFile, &resp); err != nil {
assert.Fail(t, "Can't unmarshal response file")
}
testHc := &http.Client{}
httpmock.ActivateNonDefault(testHc)
defer httpmock.DeactivateAndReset()
httpmock.RegisterResponder("POST", baseURL+tokenPath,
func(r *http.Request) (*http.Response, error) {
resp, _ := httpmock.NewJsonResponse(500, resp)
return resp, nil
},
)

//when
testConfig := Config{
ClientID: "clientID",
ClientSecret: "clientSecret",
BaseURL: baseURL}
_, err := testConfig.TokenSource(context.Background(), testHc).Token()

//then
assert.NotNil(t, err, "TokenSource should return an error")
assert.IsType(t, Error{}, err, "Returned error has proper type")
verifyErrorResponse(t, err.(Error), resp)
}

func verifyToken(t *testing.T, token oauth2.Token, resp tokenResponse) {
assert.Equal(t, resp.AccessToken, token.AccessToken, "AccessToken matches")
assert.Equal(t, resp.RefreshToken, token.RefreshToken, "RefreshToken matches")
assert.Equal(t, resp.TokenType, token.TokenType, "TokenType matches")

respTimeout, err := strconv.Atoi(resp.TokenTimeout)
assert.Nil(t, err, "Error when converting TokenTimeout from the response to int: %v", err)
assert.WithinDuration(t, time.Now().Add(time.Duration(respTimeout)*time.Second), token.Expiry, time.Duration(1)*time.Second, "Token expiry reflects token_timeout from the response")
}

func verifyTokenRequest(t *testing.T, req tokenRequest, clientID string, clientSecret string) {
assert.Equal(t, "client_credentials", req.GrantType, "GrantType matches")
assert.Equal(t, clientID, req.ClientID, "ClientID matches")
assert.Equal(t, clientSecret, req.ClientSecret, "ClientSecret matches")
}

func verifyErrorResponse(t *testing.T, err Error, resp tokenError) {
assert.Equal(t, resp.ErrorCode, err.Code, "Error code matches")
assert.Equal(t, resp.ErrorMessage, err.Message, "Error message matches")
}
6 changes: 6 additions & 0 deletions test-fixtures/token_response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"access_token": "qwErtY8zyW1abcdefGHI",
"refresh_token": "aBiLmo8zsMX345MSaHHI",
"token_timeout": "3600",
"token_type": "Bearer"
}
7 changes: 7 additions & 0 deletions test-fixtures/token_response_err.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"errorDomain": "apps-fqa",
"errorTitle": "Login Failure",
"errorCode": "S1006",
"developerMessage": "Login Failure, please try again",
"errorMessage": "Login Failure, please try again."
}

0 comments on commit 66cdcb0

Please sign in to comment.