Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow supplying client id/secret for auth #89

Merged
merged 1 commit into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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