Skip to content

Commit

Permalink
initial ephemeral api token work
Browse files Browse the repository at this point in the history
  • Loading branch information
chrismarget-j committed Dec 15, 2024
1 parent 4513764 commit 88b3c8a
Show file tree
Hide file tree
Showing 9 changed files with 453 additions and 39 deletions.
99 changes: 99 additions & 0 deletions apstra/authentication/token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package authentication

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"

"github.com/Juniper/terraform-provider-apstra/apstra/private"
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
"github.com/hashicorp/terraform-plugin-framework/diag"
ephemeralSchema "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
)

const apiTokenDefaultWarning = 60

type ApiToken struct {
Value types.String `tfsdk:"value"`
SessionId types.String `tfsdk:"session_id"`
UserName types.String `tfsdk:"user_name"`
WarnSeconds types.Int64 `tfsdk:"warn_seconds"`
ExpiresAt time.Time `tfsdk:"-"`
}

func (o ApiToken) EphemeralAttributes() map[string]ephemeralSchema.Attribute {
return map[string]ephemeralSchema.Attribute{
"value": ephemeralSchema.StringAttribute{
Computed: true,
MarkdownDescription: "The API token value.",
},
"session_id": ephemeralSchema.StringAttribute{
Computed: true,
MarkdownDescription: "The API session ID associated with the token.",
},
"user_name": ephemeralSchema.StringAttribute{
Computed: true,
MarkdownDescription: "The user name associated with the session ID.",
},
"warn_seconds": ephemeralSchema.Int64Attribute{
Optional: true,
Computed: true,
MarkdownDescription: fmt.Sprintf("Terraform will produce a warning when the token value is "+
"referenced with less than this amount of time remaining before expiration. Note that "+
"determination of remaining token lifetime depends on clock sync between the Apstra server and "+
"the Terraform host. Value `0` disables warnings. Default value is `%d`.", apiTokenDefaultWarning),
Validators: []validator.Int64{int64validator.AtLeast(0)},
},
}
}

func (o *ApiToken) LoadApiData(_ context.Context, in string, diags *diag.Diagnostics) {
parts := strings.Split(in, ".")
if len(parts) != 3 {
diags.AddError("unexpected API response", fmt.Sprintf("JWT should have 3 parts, got %d", len(parts)))
return
}

claimsB64 := parts[1] + strings.Repeat("=", (4-len(parts[1])%4)%4) // pad the b64 part as necessary
claimsBytes, err := base64.StdEncoding.DecodeString(claimsB64)
if err != nil {
diags.AddError("failed decoding token claims", err.Error())
return
}

var claims struct {
Username string `json:"username"`
UserSession string `json:"user_session"`
Expiration int64 `json:"exp"`
}
err = json.Unmarshal(claimsBytes, &claims)
if err != nil {
diags.AddError("failed unpacking token claims", err.Error())
return
}

o.Value = types.StringValue(in)
o.UserName = types.StringValue(claims.Username)
o.SessionId = types.StringValue(claims.UserSession)
o.ExpiresAt = time.Unix(claims.Expiration, 0)
}

func (o *ApiToken) SetDefaults() {
if o.WarnSeconds.IsNull() {
o.WarnSeconds = types.Int64Value(apiTokenDefaultWarning)
}
}

func (o *ApiToken) SetPrivateState(ctx context.Context, ps private.State, diags *diag.Diagnostics) {
var privateEphemeralApiToken = private.EphemeralApiToken{
Token: o.Value.ValueString(),
ExpiresAt: o.ExpiresAt,
WarnThreshold: time.Duration(o.WarnSeconds.ValueInt64()) * time.Second,
}
privateEphemeralApiToken.SetPrivateState(ctx, ps, diags)
}
1 change: 0 additions & 1 deletion apstra/configure_data_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,4 @@ func configureDataSource(_ context.Context, ds datasource.DataSourceWithConfigur
if ds, ok := ds.(datasourceWithSetFfBpClientFunc); ok {
ds.setBpClientFunc(pd.getFreeformClient)
}

}
51 changes: 51 additions & 0 deletions apstra/configure_ephemeral.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package tfapstra

import (
"context"
"fmt"
"github.com/Juniper/apstra-go-sdk/apstra"
"github.com/hashicorp/terraform-plugin-framework/ephemeral"
)

type ephemeralWithSetClient interface {
ephemeral.EphemeralResourceWithConfigure
setClient(*apstra.Client)
}

type ephemeralWithSetDcBpClientFunc interface {
ephemeral.EphemeralResourceWithConfigure
setBpClientFunc(func(context.Context, string) (*apstra.TwoStageL3ClosClient, error))
}

type ephemeralWithSetFfBpClientFunc interface {
ephemeral.EphemeralResourceWithConfigure
setBpClientFunc(func(context.Context, string) (*apstra.FreeformClient, error))
}

func configureEphemeral(_ context.Context, ep ephemeral.EphemeralResourceWithConfigure, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) {
if req.ProviderData == nil {
return // cannot continue
}

var pd *providerData
var ok bool

if pd, ok = req.ProviderData.(*providerData); !ok {
resp.Diagnostics.AddError(
errDataSourceConfigureProviderDataSummary,
fmt.Sprintf(errDataSourceConfigureProviderDataDetail, *pd, req.ProviderData),
)
}

if ep, ok := ep.(ephemeralWithSetClient); ok {
ep.setClient(pd.client)
}

if ep, ok := ep.(ephemeralWithSetDcBpClientFunc); ok {
ep.setBpClientFunc(pd.getTwoStageL3ClosClient)
}

if ep, ok := ep.(ephemeralWithSetFfBpClientFunc); ok {
ep.setBpClientFunc(pd.getFreeformClient)
}
}
174 changes: 174 additions & 0 deletions apstra/ephemeral_api_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package tfapstra

import (
"context"
"errors"
"fmt"
"time"

"github.com/Juniper/apstra-go-sdk/apstra"
"github.com/Juniper/terraform-provider-apstra/apstra/authentication"
"github.com/Juniper/terraform-provider-apstra/apstra/private"
"github.com/hashicorp/terraform-plugin-framework/ephemeral"
"github.com/hashicorp/terraform-plugin-framework/ephemeral/schema"
)

var (
_ ephemeral.EphemeralResource = (*ephemeralToken)(nil)
_ ephemeral.EphemeralResourceWithClose = (*ephemeralToken)(nil)
_ ephemeral.EphemeralResourceWithRenew = (*ephemeralToken)(nil)
_ ephemeral.EphemeralResourceWithConfigure = (*ephemeralToken)(nil)
_ ephemeralWithSetClient = (*ephemeralToken)(nil)
)

type ephemeralToken struct {
client *apstra.Client
}

func (o *ephemeralToken) Metadata(_ context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_api_token"
}

func (o *ephemeralToken) Schema(_ context.Context, _ ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "This Ephemeral Resource retrieves a unique API token and invalidates it on exit.",
Attributes: authentication.ApiToken{}.EphemeralAttributes(),
}
}

func (o *ephemeralToken) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) {
configureEphemeral(ctx, o, req, resp)
}

func (o *ephemeralToken) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) {
var config authentication.ApiToken
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}

// set default values
config.SetDefaults()

// create a new client using the credentials in the embedded client's config
client, err := o.client.Config().NewClient(ctx)
if err != nil {
resp.Diagnostics.AddError("error creating new client", err.Error())
return
}

// log in so that the new client fetches an API token
err = client.Login(ctx)
if err != nil {
resp.Diagnostics.AddError("error logging in new client", err.Error())
return
}

// extract the token
token := client.GetApiToken()
if token == "" {
resp.Diagnostics.AddError("requested API token is empty", "requested API token is empty")
return
}

// destroy the new client without invalidating the API token we just collected
client.SetApiToken("")
err = client.Logout(ctx)
if err != nil {
resp.Diagnostics.AddError("error logging out client", err.Error())
return
}

config.LoadApiData(ctx, token, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}

config.SetPrivateState(ctx, resp.Private, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}

// sanity check the token lifetime
now := time.Now()
if now.After(config.ExpiresAt) {
resp.Diagnostics.AddError(
"Just-fetched API token is expired",
fmt.Sprintf("Token expired at: %s. Current time is: %s", config.ExpiresAt, now),
)
return
}

// warn the user about imminent expiration
warn := time.Duration(config.WarnSeconds.ValueInt64()) * time.Second
if now.Add(warn).After(config.ExpiresAt) {
resp.Diagnostics.AddWarning(
fmt.Sprintf("API token expires within %d second warning threshold", config.WarnSeconds),
fmt.Sprintf("API token expires at %s. Current time: %s", config.ExpiresAt, now),
)
}

// set the refresh timestamp
resp.RenewAt = config.ExpiresAt.Add(-1 * warn)

// set the result
resp.Diagnostics.Append(resp.Result.Set(ctx, &config)...)
}

func (o *ephemeralToken) Renew(ctx context.Context, req ephemeral.RenewRequest, resp *ephemeral.RenewResponse) {
var privateEphemeralApiToken private.EphemeralApiToken
privateEphemeralApiToken.LoadPrivateState(ctx, req.Private, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}

now := time.Now()
if now.After(privateEphemeralApiToken.ExpiresAt) {
resp.Diagnostics.AddError(
"API token has expired",
fmt.Sprintf("Token expired at: %s. Current time is: %s", privateEphemeralApiToken.ExpiresAt, now),
)
return
}

if now.Add(privateEphemeralApiToken.WarnThreshold).After(privateEphemeralApiToken.ExpiresAt) {
resp.Diagnostics.AddWarning(
fmt.Sprintf("API token expires within %d second warning threshold", privateEphemeralApiToken.WarnThreshold),
fmt.Sprintf("API token expires at %s. Current time: %s", privateEphemeralApiToken.ExpiresAt, now),
)
}

resp.RenewAt = privateEphemeralApiToken.ExpiresAt
}

func (o *ephemeralToken) Close(ctx context.Context, req ephemeral.CloseRequest, resp *ephemeral.CloseResponse) {
// create a new client based on the embedded client's config
client, err := o.client.Config().NewClient(ctx)
if err != nil {
resp.Diagnostics.AddError("error creating new client", err.Error())
return
}

// extract the private state data
var privateEphemeralApiToken private.EphemeralApiToken
privateEphemeralApiToken.LoadPrivateState(ctx, req.Private, &resp.Diagnostics)

// swap the API token from private state into the new client
client.SetApiToken(privateEphemeralApiToken.Token)

// log out the client
err = client.Logout(ctx)
if err != nil {
var ace apstra.ClientErr
if errors.As(err, &ace) && ace.Type() == apstra.ErrAuthFail {
return // 401 is okay
}

resp.Diagnostics.AddError("Error while logging out the API key", err.Error())
return
}
}

func (o *ephemeralToken) setClient(client *apstra.Client) {
o.client = client
}
45 changes: 45 additions & 0 deletions apstra/private/ephemeral_api_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package private

import (
"context"
"encoding/json"
"time"

"github.com/hashicorp/terraform-plugin-framework/diag"
)

type EphemeralApiToken struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
WarnThreshold time.Duration `json:"warn_threshold"`
}

func (o *EphemeralApiToken) LoadApiData(_ context.Context, token string, expiresAt time.Time, warnThreshold time.Duration, diags *diag.Diagnostics) {
o.Token = token
o.ExpiresAt = expiresAt
o.WarnThreshold = warnThreshold
}

func (o *EphemeralApiToken) LoadPrivateState(ctx context.Context, ps State, diags *diag.Diagnostics) {
b, d := ps.GetKey(ctx, "EphemeralApiToken")
diags.Append(d...)
if diags.HasError() {
return
}

err := json.Unmarshal(b, &o)
if err != nil {
diags.AddError("failed to unmarshal private state", err.Error())
return
}
}

func (o *EphemeralApiToken) SetPrivateState(ctx context.Context, ps State, diags *diag.Diagnostics) {
b, err := json.Marshal(o)
if err != nil {
diags.AddError("failed to marshal private state", err.Error())
return
}

diags.Append(ps.SetKey(ctx, "EphemeralApiToken", b)...)
}
Loading

0 comments on commit 88b3c8a

Please sign in to comment.