sso_token_provider.go

  1package ssocreds
  2
  3import (
  4	"context"
  5	"fmt"
  6	"os"
  7	"time"
  8
  9	"github.com/aws/aws-sdk-go-v2/aws"
 10	"github.com/aws/aws-sdk-go-v2/internal/sdk"
 11	"github.com/aws/aws-sdk-go-v2/service/ssooidc"
 12	"github.com/aws/smithy-go/auth/bearer"
 13)
 14
 15// CreateTokenAPIClient provides the interface for the SSOTokenProvider's API
 16// client for calling CreateToken operation to refresh the SSO token.
 17type CreateTokenAPIClient interface {
 18	CreateToken(context.Context, *ssooidc.CreateTokenInput, ...func(*ssooidc.Options)) (
 19		*ssooidc.CreateTokenOutput, error,
 20	)
 21}
 22
 23// SSOTokenProviderOptions provides the options for configuring the
 24// SSOTokenProvider.
 25type SSOTokenProviderOptions struct {
 26	// Client that can be overridden
 27	Client CreateTokenAPIClient
 28
 29	// The set of API Client options to be applied when invoking the
 30	// CreateToken operation.
 31	ClientOptions []func(*ssooidc.Options)
 32
 33	// The path the file containing the cached SSO token will be read from.
 34	// Initialized the NewSSOTokenProvider's cachedTokenFilepath parameter.
 35	CachedTokenFilepath string
 36}
 37
 38// SSOTokenProvider provides an utility for refreshing SSO AccessTokens for
 39// Bearer Authentication. The SSOTokenProvider can only be used to refresh
 40// already cached SSO Tokens. This utility cannot perform the initial SSO
 41// create token.
 42//
 43// The SSOTokenProvider is not safe to use concurrently. It must be wrapped in
 44// a utility such as smithy-go's auth/bearer#TokenCache. The SDK's
 45// config.LoadDefaultConfig will automatically wrap the SSOTokenProvider with
 46// the smithy-go TokenCache, if the external configuration loaded configured
 47// for an SSO session.
 48//
 49// The initial SSO create token should be preformed with the AWS CLI before the
 50// Go application using the SSOTokenProvider will need to retrieve the SSO
 51// token. If the AWS CLI has not created the token cache file, this provider
 52// will return an error when attempting to retrieve the cached token.
 53//
 54// This provider will attempt to refresh the cached SSO token periodically if
 55// needed when RetrieveBearerToken is called.
 56//
 57// A utility such as the AWS CLI must be used to initially create the SSO
 58// session and cached token file.
 59// https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html
 60type SSOTokenProvider struct {
 61	options SSOTokenProviderOptions
 62}
 63
 64var _ bearer.TokenProvider = (*SSOTokenProvider)(nil)
 65
 66// NewSSOTokenProvider returns an initialized SSOTokenProvider that will
 67// periodically refresh the SSO token cached stored in the cachedTokenFilepath.
 68// The cachedTokenFilepath file's content will be rewritten by the token
 69// provider when the token is refreshed.
 70//
 71// The client must be configured for the AWS region the SSO token was created for.
 72func NewSSOTokenProvider(client CreateTokenAPIClient, cachedTokenFilepath string, optFns ...func(o *SSOTokenProviderOptions)) *SSOTokenProvider {
 73	options := SSOTokenProviderOptions{
 74		Client:              client,
 75		CachedTokenFilepath: cachedTokenFilepath,
 76	}
 77	for _, fn := range optFns {
 78		fn(&options)
 79	}
 80
 81	provider := &SSOTokenProvider{
 82		options: options,
 83	}
 84
 85	return provider
 86}
 87
 88// RetrieveBearerToken returns the SSO token stored in the cachedTokenFilepath
 89// the SSOTokenProvider was created with. If the token has expired
 90// RetrieveBearerToken will attempt to refresh it. If the token cannot be
 91// refreshed or is not present an error will be returned.
 92//
 93// A utility such as the AWS CLI must be used to initially create the SSO
 94// session and cached token file. https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html
 95func (p SSOTokenProvider) RetrieveBearerToken(ctx context.Context) (bearer.Token, error) {
 96	cachedToken, err := loadCachedToken(p.options.CachedTokenFilepath)
 97	if err != nil {
 98		return bearer.Token{}, err
 99	}
100
101	if cachedToken.ExpiresAt != nil && sdk.NowTime().After(time.Time(*cachedToken.ExpiresAt)) {
102		cachedToken, err = p.refreshToken(ctx, cachedToken)
103		if err != nil {
104			return bearer.Token{}, fmt.Errorf("refresh cached SSO token failed, %w", err)
105		}
106	}
107
108	expiresAt := aws.ToTime((*time.Time)(cachedToken.ExpiresAt))
109	return bearer.Token{
110		Value:     cachedToken.AccessToken,
111		CanExpire: !expiresAt.IsZero(),
112		Expires:   expiresAt,
113	}, nil
114}
115
116func (p SSOTokenProvider) refreshToken(ctx context.Context, cachedToken token) (token, error) {
117	if cachedToken.ClientSecret == "" || cachedToken.ClientID == "" || cachedToken.RefreshToken == "" {
118		return token{}, fmt.Errorf("cached SSO token is expired, or not present, and cannot be refreshed")
119	}
120
121	createResult, err := p.options.Client.CreateToken(ctx, &ssooidc.CreateTokenInput{
122		ClientId:     &cachedToken.ClientID,
123		ClientSecret: &cachedToken.ClientSecret,
124		RefreshToken: &cachedToken.RefreshToken,
125		GrantType:    aws.String("refresh_token"),
126	}, p.options.ClientOptions...)
127	if err != nil {
128		return token{}, fmt.Errorf("unable to refresh SSO token, %w", err)
129	}
130
131	expiresAt := sdk.NowTime().Add(time.Duration(createResult.ExpiresIn) * time.Second)
132
133	cachedToken.AccessToken = aws.ToString(createResult.AccessToken)
134	cachedToken.ExpiresAt = (*rfc3339)(&expiresAt)
135	cachedToken.RefreshToken = aws.ToString(createResult.RefreshToken)
136
137	fileInfo, err := os.Stat(p.options.CachedTokenFilepath)
138	if err != nil {
139		return token{}, fmt.Errorf("failed to stat cached SSO token file %w", err)
140	}
141
142	if err = storeCachedToken(p.options.CachedTokenFilepath, cachedToken, fileInfo.Mode()); err != nil {
143		return token{}, fmt.Errorf("unable to cache refreshed SSO token, %w", err)
144	}
145
146	return cachedToken, nil
147}