Skip to content

Commit

Permalink
Credential Caching for the process-creds command (#158)
Browse files Browse the repository at this point in the history
* Initial thoughts on changed file structure

* First working (returns valid looking result) version

* add beeline to credProcessRun for timing check

* remove some extraneous comments

* Move creds process cache logic to pkg

* change package name to match dir structure
  • Loading branch information
jacoblerner-czi authored Jul 27, 2021
1 parent aca8b01 commit 9d9503a
Show file tree
Hide file tree
Showing 3 changed files with 273 additions and 22 deletions.
110 changes: 88 additions & 22 deletions cmd/creds-process.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,19 @@ import (

"github.com/aws/aws-sdk-go/service/sts"
"github.com/chanzuckerberg/aws-oidc/pkg/aws_config_client"
"github.com/chanzuckerberg/aws-oidc/pkg/creds_process"
"github.com/chanzuckerberg/aws-oidc/pkg/getter"
oidc "github.com/chanzuckerberg/go-misc/oidc_cli"
oidc_client "github.com/chanzuckerberg/go-misc/oidc_cli/client"
"github.com/chanzuckerberg/go-misc/oidc_cli/storage"
"github.com/chanzuckerberg/go-misc/osutil"
"github.com/chanzuckerberg/go-misc/pidlock"
"github.com/honeycombio/beeline-go"
"github.com/mitchellh/go-homedir" // for storage, refactor out.
"github.com/pkg/errors"
"github.com/spf13/cobra"
)

const credProcessVersion = 1

func init() {
credProcessCmd.Flags().StringVar(&clientID, "client-id", "", "CLIENT_ID generated from the OIDC application")
credProcessCmd.Flags().StringVar(&issuerURL, "issuer-url", "", "The URL that hosts the OIDC identity provider")
Expand All @@ -29,14 +32,6 @@ func init() {
rootCmd.AddCommand(credProcessCmd)
}

type credProcess struct {
Version int `json:"Version"`
AccessKeyID string `json:"AccessKeyId"`
SecretAccessKey string `json:"SecretAccessKey"`
SessionToken string `json:"SessionToken"`
Expiration string `json:"Expiration"`
}

// credProcessCmd represents the cred-process command
var credProcessCmd = &cobra.Command{
Use: "creds-process",
Expand All @@ -46,36 +41,78 @@ var credProcessCmd = &cobra.Command{
RunE: credProcessRun,
}

func credProcessRun(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
const (
lockFilePath = "/tmp/aws-oidc-creds_process.lock"
defaultFileStorageDir = "~/.oidc-cli"
assumeRoleTime = time.Hour // default to 1 hour

)

func updateCred(ctx context.Context,
awsOIDCConfig *aws_config_client.AWSOIDCConfiguration) (*creds_process.ProcessedCred, error) {
assumeRoleOutput, err := assumeRole(
ctx,
&aws_config_client.AWSOIDCConfiguration{
ClientID: clientID,
IssuerURL: issuerURL,
RoleARN: roleARN,
},
time.Hour, // default to 1 hour
awsOIDCConfig,
assumeRoleTime,
)
if err != nil {
return err
return nil, err
}

creds := credProcess{
Version: credProcessVersion,
creds := creds_process.ProcessedCred{
Version: creds_process.ProcessedCredVersion,
AccessKeyID: string(*assumeRoleOutput.Credentials.AccessKeyId),
SecretAccessKey: string(*assumeRoleOutput.Credentials.SecretAccessKey),
SessionToken: string(*assumeRoleOutput.Credentials.SessionToken),
Expiration: assumeRoleOutput.Credentials.Expiration.Format(time.RFC3339),
CacheExpiry: *assumeRoleOutput.Credentials.Expiration,
}
return &creds, nil

output, err := json.MarshalIndent(creds, "", " ")
if err != nil {
return errors.Wrap(err, "Unable to convert current credentials to json output")
return nil, errors.Wrap(err, "Unable to convert current credentials to json output") //error handling? as above
}
fmt.Println(string(output))

return nil, nil
}

func credProcessRun(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
ctx, span := beeline.StartSpan(ctx, "get_cred_process_run")
defer span.Send()

fileLock, err := pidlock.NewLock(lockFilePath)
if err != nil {
return errors.Wrap(err, "unable to create lock")
}

config := &aws_config_client.AWSOIDCConfiguration{
ClientID: clientID,
IssuerURL: issuerURL,
RoleARN: roleARN,
}

storage, err := getStorage(clientID, issuerURL)
if err != nil {
return err
}

cache := creds_process.NewCache(storage, updateCred, fileLock)

creds, err := cache.Read(ctx, config)
if err != nil {
return errors.Wrap(err, "Unable to process credentials.")
}
if creds == nil {
return errors.New("nil token from OIDC-IDP")
}
output, err := json.MarshalIndent(creds, "", " ")
if err != nil {
return errors.Wrap(err, "Unable to convert current credentials to json output")
}
fmt.Println(string(output))
return nil
}

Expand Down Expand Up @@ -114,3 +151,32 @@ func getOIDCToken(
oidc_client.SetSuccessMessage(successMessage),
)
}

// TODO - Refactor out
func getStorage(clientID string, issuerURL string) (storage.Storage, error) {
isWSL, err := osutil.IsWSL()
if err != nil {
return nil, err
}

// If WSL we use a file storage which does not cache refreshTokens
// we do this because WSL doesn't have a graphical interface
// and therefore limits how we can interact with a keyring (such as gnome-keyring).
// To limit the risks of having a long-lived refresh token around,
// we disable this part of the flow for WSL. This could change in the future
// when we find a better way to work with a WSL secure storage.
if isWSL {
return getFileStorage(clientID, issuerURL)
}

return storage.NewKeyring(clientID, issuerURL), nil
}

func getFileStorage(clientID string, issuerURL string) (storage.Storage, error) {
dir, err := homedir.Expand(defaultFileStorageDir)
if err != nil {
return nil, errors.Wrap(err, "could not expand path")
}

return storage.NewFile(dir, clientID, issuerURL), nil
}
108 changes: 108 additions & 0 deletions pkg/creds_process/creds_cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package creds_process

import (
"context"

"github.com/chanzuckerberg/aws-oidc/pkg/aws_config_client"
"github.com/chanzuckerberg/go-misc/oidc_cli/storage"
"github.com/chanzuckerberg/go-misc/pidlock"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)

type Cache struct {
storage storage.Storage
lock *pidlock.Lock

updateCred func(context.Context, *aws_config_client.AWSOIDCConfiguration) (*ProcessedCred, error)
}

func NewCache(
storage storage.Storage,
credGetter func(context.Context, *aws_config_client.AWSOIDCConfiguration) (*ProcessedCred, error),
lock *pidlock.Lock,
) *Cache {
return &Cache{
storage: storage,
updateCred: credGetter,
lock: lock,
}
}

// Read will attempt to read a cred from the cache
// if not present or expired, will refresh
func (c *Cache) Read(ctx context.Context, config *aws_config_client.AWSOIDCConfiguration) (*ProcessedCred, error) {
cachedCred, err := c.readFromStorage(ctx)
if err != nil {
return nil, err
}
// if we have a valid cred, use it
if cachedCred.IsFresh() {
return cachedCred, nil
}

// otherwise, try refreshing
return c.refresh(ctx, config)
}

func (c *Cache) refresh(ctx context.Context, config *aws_config_client.AWSOIDCConfiguration) (*ProcessedCred, error) {
err := c.lock.Lock()
if err != nil {
return nil, err
}
defer c.lock.Unlock() //nolint:errcheck

// acquire lock, try reading from cache again just in case
// someone else got here first
cachedCred, err := c.readFromStorage(ctx)
if err != nil {
return nil, err
}
// if we have a valid cred, use it
if cachedCred.IsFresh() {
return cachedCred, nil
}

// ok, at this point we have the lock and there are no good creds around
// fetch a new one and save it
cred, err := c.updateCred(ctx, config)
if err != nil {
return nil, err
}

// check the new cred is good to use
if !cred.IsFresh() {
return nil, errors.New("invalid cred fetched")
}

strCred, err := cred.Marshal()

if err != nil {
return nil, errors.Wrap(err, "unable to marshall cred")
}
// save cred to storage
err = c.storage.Set(ctx, strCred)
if err != nil {
return nil, errors.Wrap(err, "Unable to cache the strCred")
}

return cred, nil
}

// reads cred from storage, potentially returning a nil/expired cred
// users must call IsFresh to check cred validty
func (c *Cache) readFromStorage(ctx context.Context) (*ProcessedCred, error) {
cached, err := c.storage.Read(ctx)
if err != nil {
return nil, err
}
cachedCred, err := CredFromString(cached)
if err != nil {
logrus.WithError(err).Debug("error fetching stored cred")
err = c.storage.Delete(ctx) // can't read it, so attempt to purge it
if err != nil {
logrus.WithError(err).Debug("error clearing cred from storage")
}
}
return cachedCred, nil
}
77 changes: 77 additions & 0 deletions pkg/creds_process/processed-cred.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package creds_process

import (
"encoding/base64"
"encoding/json"
"time"

"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)

// Used to store the parsed results from running assumeRole
type ProcessedCred struct {
Version int `json:"Version"`
AccessKeyID string `json:"AccessKeyId"`
SecretAccessKey string `json:"SecretAccessKey"`
SessionToken string `json:"SessionToken"`
Expiration string `json:"Expiration"`
CacheExpiry time.Time `json:"CacheExpiration"`
}

func (pc *ProcessedCred) IsFresh() bool {
if pc == nil {
return false
}
return pc.CacheExpiry.After(time.Now().Add(timeSkew))
}

const (
timeSkew = 5 * time.Minute
ProcessedCredVersion = 1
)

func CredFromString(credString *string, opts ...MarshalOpts) (*ProcessedCred, error) {
if credString == nil {
logrus.Debug("nil cred string")
return nil, nil
}
credBytes, err := base64.StdEncoding.DecodeString(*credString)
if err != nil {
return nil, errors.Wrap(err, "error b64 decoding token")
}
pc := &ProcessedCred{
Version: ProcessedCredVersion,
}
err = json.Unmarshal(credBytes, pc)
if err != nil {
return nil, errors.Wrap(err, "could not json unmarshal cred")
}

for _, opt := range opts {
opt(pc)
}
return pc, nil
}

func (pc *ProcessedCred) Marshal(opts ...MarshalOpts) (string, error) {
if pc == nil {
return "", errors.New("error Marshalling nil token")
}

// apply any processing to the token
for _, opt := range opts {
opt(pc)
}

credBytes, err := json.Marshal(pc)
if err != nil {
return "", errors.Wrap(err, "could not marshal token")
}

b64 := base64.StdEncoding.EncodeToString(credBytes)
return b64, nil
}

// MarshalOpts changes a token for marshaling
type MarshalOpts func(*ProcessedCred)

0 comments on commit 9d9503a

Please sign in to comment.