Skip to content

Commit

Permalink
Allow supplying client id/secret for auth
Browse files Browse the repository at this point in the history
Allow a user to specify a PCBe Client ID and PCBe Client
Secret as an alternative to a token.

Either (1) a token or (2) credentials (id/secret) must be provided.
The code will use the creds to generate a token when a token is
not provided directly.

Currently the environment variables being used are
`PCBE_CLIENT_ID` and `PCBE_CLIENT_SECRET`. These are defined
as constants and may be changed later (eg when integrating with
the "umbrella" hpegl provider) if desired.
  • Loading branch information
stuart-mclaren-hpe committed Jan 27, 2025
1 parent ec116ba commit cce24ee
Show file tree
Hide file tree
Showing 4 changed files with 231 additions and 16 deletions.
87 changes: 87 additions & 0 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,99 @@
package auth

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"

"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/microsoft/kiota-abstractions-go/authentication"
)

type Credentials struct {
URL string
ID string
Secret string
}

type token struct {
AccessToken string `json:"access_token"`
}

type Config struct {
HTTPClient *http.Client
}

// WithHTTPClient allows optionally passing in a custom http client
// to the GetToken function, eg:
// c := &http.Client{}
// GetToken(ctx, "https://www.example.com", WithHTTPClient(c))
func WithHTTPClient(client *http.Client) Config {
return Config{HTTPClient: client}
}

func GetToken(
ctx context.Context,
creds Credentials,
configs ...Config,
) (string, error) {
var token token

client := &http.Client{Timeout: 10 * time.Second}

for _, config := range configs {
if config.HTTPClient != nil {
client = config.HTTPClient
}
}

grant := fmt.Sprintf(
"grant_type=client_credentials&client_id=%s&client_secret=%s",
creds.ID,
creds.Secret,
)

req, err := http.NewRequest("POST", creds.URL, bytes.NewBuffer([]byte(grant)))
if err != nil {
return "", err
}

req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := client.Do(req)
if err != nil {
return "", err
}

defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return "", errors.New("failed to get token (http code: " + resp.Status + ")")
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}

err = json.Unmarshal(body, &token)
if err != nil {
return "", err
}

if token.AccessToken == "" {
return "", errors.New("failed to get token: empty access token")
}

tflog.Debug(ctx, "token ok")

return token.AccessToken, nil
}

type PcbeAccessTokenProvider struct {
token string
}
Expand Down
74 changes: 74 additions & 0 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package auth

import (
"context"
"net/http"
"testing"

"github.com/h2non/gock"
)

func TestAuthOk(t *testing.T) {
mockURL := "http://example.com/api"
mockBody := `grant_type=client_credentials&client_id=id123&client_secret=secret123`
expectedToken := "token123"

defer gock.Off()

gock.New(mockURL).
Post("/api").
MatchHeader("Content-Type", "application/x-www-form-urlencoded").
JSON(mockBody).
Reply(200).
JSON(map[string]string{"access_token": expectedToken})

creds := Credentials{
URL: mockURL,
ID: "id123",
Secret: "secret123",
}

client := &http.Client{Transport: &http.Transport{}}
gock.InterceptClient(client)

token, err := GetToken(context.Background(), creds, WithHTTPClient(client))
if err != nil {
t.Fatalf("GetToken failed: %v", err)
}

if token != expectedToken {
t.Fatalf("GetToken returned unexpected value: %v", token)
}
}

func TestAuthBad(t *testing.T) {
mockURL := "http://example.com/api"
mockBody := `grant_type=client_credentials&client_id=id123&client_secret=secret123`

defer gock.Off()

gock.New(mockURL).
Post("/api").
MatchHeader("Content-Type", "application/x-www-form-urlencoded").
JSON(mockBody).
Reply(401).
BodyString("invalid")

creds := Credentials{
URL: mockURL,
ID: "id123",
Secret: "secret123",
}

client := &http.Client{Transport: &http.Transport{}}
gock.InterceptClient(client)

_, err := GetToken(context.Background(), creds, WithHTTPClient(client))
if err == nil {
t.Fatalf("GetToken should have failed")
}

if err.Error() != "failed to get token (http code: 401 Unauthorized)" {
t.Fatalf("GetToken returned unexpected error: %v", err)
}
}
4 changes: 4 additions & 0 deletions internal/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ const (
TaskHypervisorCluster = "hypervisor-cluster" // task's "associatedResources" string
TaskHypervisorServer = "server" // task's "associatedResources" string
TaskDatastore = "datastore" // task's "associatedResources" string

// For authentication
ClientIDEnvVar = "PCBE_CLIENT_ID"
ClientSecretEnvVar = "PCBE_CLIENT_SECRET" // nolint: gosec
)
82 changes: 66 additions & 16 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ package provider

import (
"context"
"os"

"github.com/HewlettPackard/hpegl-pcbe-terraform-resources/internal/auth"
"github.com/HewlettPackard/hpegl-pcbe-terraform-resources/internal/client"
"github.com/HewlettPackard/hpegl-pcbe-terraform-resources/internal/constants"
"github.com/HewlettPackard/hpegl-pcbe-terraform-resources/internal/defaults"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/provider"
"github.com/hashicorp/terraform-plugin-framework/provider/schema"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
)

Expand All @@ -27,6 +31,7 @@ func New(version string) func() provider.Provider {
type PCBeCfg struct {
Host types.String `tfsdk:"host"`
Token types.String `tfsdk:"token"`
AuthURL types.String `tfsdk:"auth_url"`
HTTPDump types.Bool `tfsdk:"http_dump"`
MaxPolls types.Int32 `tfsdk:"max_polls"`
PollInterval types.Float32 `tfsdk:"poll_interval"`
Expand Down Expand Up @@ -61,8 +66,26 @@ func (p *PCBeProvider) Schema(
"host": schema.StringAttribute{
Required: true,
},
"auth_url": schema.StringAttribute{
Optional: true,
// one of auth_url or token is required
Validators: []validator.String{
stringvalidator.ExactlyOneOf(path.Expressions{
path.MatchRelative().AtParent().AtName("auth_url"),
path.MatchRelative().AtParent().AtName("token"),
}...),
},
},
"token": schema.StringAttribute{
Required: true,
Optional: true,
// one of token or auth_url is required
Validators: []validator.String{
stringvalidator.ExactlyOneOf(path.Expressions{
path.MatchRelative().AtParent().AtName("token"),
path.MatchRelative().AtParent().AtName("auth_url"),
}...),
},
Sensitive: true,
},
"http_dump": schema.BoolAttribute{
Optional: true,
Expand Down Expand Up @@ -90,45 +113,72 @@ func (p *PCBeProvider) Configure(
var token string
var maxPolls int32
var pollInterval float32
var err error

diags := req.Config.Get(ctx, &config)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

if config.PCBeCfg.Host.IsUnknown() {
if config.PCBeCfg.Host.IsNull() {
resp.Diagnostics.AddAttributeError(
path.Root("host"),
"unknown 'host' value in provider configuration block",
"the provider cannot create an API client "+
"as there is an unknown configuration value for "+
"the API host.",
)
}

if config.PCBeCfg.Token.IsUnknown() {
resp.Diagnostics.AddAttributeError(
path.Root("token"),
"unknown 'token' value in provider configuration block",
"the provider cannot create an API client "+
"as there is an unknown configuration value for "+
"the API token",
)
}

if resp.Diagnostics.HasError() {
return
}

if !config.PCBeCfg.Host.IsNull() {
host = config.PCBeCfg.Host.ValueString()
if !config.PCBeCfg.AuthURL.IsNull() {
id, ok := os.LookupEnv(constants.ClientIDEnvVar)
if !ok {
resp.Diagnostics.AddError(
"token error",
"failed to get token: "+constants.ClientIDEnvVar+" not set",
)

return
}

secret, ok := os.LookupEnv(constants.ClientSecretEnvVar)
if !ok {
resp.Diagnostics.AddError(
"token error",
"failed to get token: "+constants.ClientSecretEnvVar+" not set",
)

return
}

creds := auth.Credentials{
URL: config.PCBeCfg.AuthURL.ValueString(),
ID: id,
Secret: secret,
}

token, err = auth.GetToken(ctx, creds)
if err != nil {
resp.Diagnostics.AddError(
"token error",
"token retrieval failed: "+err.Error(),
)

return
}
}

if !config.PCBeCfg.Token.IsNull() {
token = config.PCBeCfg.Token.ValueString()
}

if !config.PCBeCfg.Host.IsNull() {
host = config.PCBeCfg.Host.ValueString()
}

if !config.PCBeCfg.HTTPDump.IsNull() {
httpDump = config.PCBeCfg.HTTPDump.ValueBool()
}
Expand Down

0 comments on commit cce24ee

Please sign in to comment.