diff --git a/.secrets.baseline b/.secrets.baseline index 639a05c..3b28e8c 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "go.sum|^.secrets.baseline$", "lines": null }, - "generated_at": "2024-09-27T22:05:21Z", + "generated_at": "2024-10-01T21:07:46Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -242,7 +242,7 @@ "hashed_secret": "6f667d3e9627f5549ffeb1055ff294c34430b837", "is_secret": false, "is_verified": false, - "line_number": 197, + "line_number": 201, "type": "Secret Keyword", "verified_result": null } diff --git a/README.md b/README.md index a4099d2..50500af 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,60 @@ func main() { } ``` +### IAM authentication + +This library supports [IBM's IAM Authentication](https://cloud.ibm.com/docs/account?topic=account-iamoverview) (used by the `ibmcloud` cli for example). You will want to set the `IAMToken` and `IAMRefreshToken` properties on the session to make use of it. + + +```go +token := "Bearer aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa..." +refreshToken := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb..." +sess := &session.Session{ + Endpoint: "https://api.softlayer.com/rest/v3.1", + Debug: true, + Timeout: 90, + IAMToken: token, + IAMRefreshToken: refreshToken +} +``` + +You can bypass automatic IAM refresh by either not setting the `IAMRefreshToken` property, or by manually configuring the `TransportHandler` + +```go +token := "Bearer aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa..." +handler := &session.RestTransport{} +sess := &session.Session{ + Endpoint: "https://api.softlayer.com/rest/v3.1", + Debug: true, + Timeout: 90, + IAMToken: token, + TransportHandler handler, +} +``` + +If you want to be able to record the new tokens in a config file (or elsewhere), you can configure an `IAMUpdaters` Observer which will take in as arguments the new tokens, allowing you to save them somewhere for reuse. + +```go +type MyIamUpdater struct { + debug bool +} +// This function is where you can configure the logic to save these new tokens somewhere +func (iamupdater *MyIamUpdater) Update(token string, refresh string) { + fmt.Printf("[DEBUG] New Token: %s\n", token) + fmt.Printf("[DEBUG] New Refresh Token: %s\n", refresh) +} +updater := &MyIamUpdater{debug: false} +token := "Bearer aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa..." +refreshToken := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb..." +sess := &session.Session{ + Endpoint: "https://api.softlayer.com/rest/v3.1", + IAMToken: token, + IAMRefreshToken refreshToken, +} +sess.AddIAMUpdater(updater) +``` +You can add multiple Updaters as well, they will be called in the order they are added. `MyIamUpdater.Update(token, refresh)` in this example will only be called when the token is actually refreshed. + ## Running Examples The [Examples](https://github.com/softlayer/softlayer-go/tree/master/examples) directory has a few rough examples scripts that can help you get started developing with this library. diff --git a/session/iamupdater.go b/session/iamupdater.go new file mode 100644 index 0000000..8870aa3 --- /dev/null +++ b/session/iamupdater.go @@ -0,0 +1,23 @@ +package session + +//counterfeiter:generate . IAMUpdater +type IAMUpdater interface { + Update(token string, refresh string) +} + +type LogIamUpdater struct { + debug bool +} + +func NewLogIamUpdater(debug bool) *LogIamUpdater { + return &LogIamUpdater{ + debug: debug, + } +} + +func (iamupdater *LogIamUpdater) Update(token string, refresh string) { + if iamupdater.debug { + Logger.Printf("[DEBUG] New Token: %s\n", token) + Logger.Printf("[DEBUG] New Refresh Token: %s\n", refresh) + } +} diff --git a/session/rest.go b/session/rest.go index cc4e388..17d189d 100644 --- a/session/rest.go +++ b/session/rest.go @@ -54,12 +54,6 @@ func (r *RestTransport) DoRequest(sess *Session, service string, method string, resp, code, err := sendHTTPRequest(sess, path, restMethod, parameters, options) - //Check if this is a refreshable exception - if err != nil && sess.IAMRefreshToken != "" && NeedsRefresh(err) { - sess.RefreshToken() - resp, code, err = sendHTTPRequest(sess, path, restMethod, parameters, options) - } - if err != nil { //Preserve the original sl error if _, ok := err.(sl.Error); ok { diff --git a/session/session.go b/session/session.go index 7f7bb0e..ac9404e 100644 --- a/session/session.go +++ b/session/session.go @@ -127,6 +127,10 @@ type Session struct { //IAMRefreshToken is the IAM refresh token secret that required to refresh IAM Token IAMRefreshToken string + // A list objects that implement the IAMUpdater interface. + // When a IAMToken is refreshed, these are notified with the new token and new refresh token. + IAMUpdaters []IAMUpdater + // AuthToken is the token secret for token-based authentication AuthToken string @@ -289,6 +293,11 @@ func (r *Session) DoRequest(service string, method string, args []interface{}, o } err := r.TransportHandler.DoRequest(r, service, method, args, options, pResult) + //Check if this is a refreshable exception and try 1 more time + if err != nil && r.IAMRefreshToken != "" && NeedsRefresh(err) { + r.RefreshToken() + err = r.TransportHandler.DoRequest(r, service, method, args, options, pResult) + } r.LastCall = CallToString(service, method, args, options) if err != nil { return err @@ -345,8 +354,6 @@ func (r *Session) ResetUserAgent() { // Refreshes an IAM authenticated session func (r *Session) RefreshToken() error { - - Logger.Println("[DEBUG] Refreshing IAM Token") client := http.DefaultClient reqPayload := url.Values{} reqPayload.Add("grant_type", "refresh_token") @@ -393,6 +400,10 @@ func (r *Session) RefreshToken() error { r.IAMToken = fmt.Sprintf("%s %s", token.TokenType, token.AccessToken) r.IAMRefreshToken = token.RefreshToken + // Mostly these are needed if we want to save these new tokens to a config file. + for _, updater := range r.IAMUpdaters { + updater.Update(r.IAMToken, r.IAMRefreshToken) + } return nil } @@ -401,6 +412,12 @@ func (r *Session) String() string { return r.LastCall } +// Adds a new IAMUpdater instance to the session +// Useful if you want to update a config file with the new Tokens +func (r *Session) AddIAMUpdater(updater IAMUpdater) { + r.IAMUpdaters = append(r.IAMUpdaters, updater) +} + func envFallback(keyName string, value *string) { if *value == "" { *value = os.Getenv(keyName) @@ -457,6 +474,7 @@ func isRetryable(err error) bool { return isTimeout(err) || hasRetryableCode(err) } +// Detects if the SL API returned a specific exception indicating the IAMToken is expired. func NeedsRefresh(err error) bool { if slError, ok := err.(sl.Error); ok { if slError.StatusCode == 500 && slError.Exception == "SoftLayer_Exception_Account_Authentication_AccessTokenValidation" { @@ -475,6 +493,7 @@ func getDefaultUserAgent() string { return fmt.Sprintf("softlayer-go/%s %s ", sl.Version.String(), envAgent) } +// Formats an API call into a readable string func CallToString(service string, method string, args []interface{}, options *sl.Options) string { if options == nil { options = new(sl.Options) diff --git a/session/session_test.go b/session/session_test.go index 1512f82..ba8cce0 100644 --- a/session/session_test.go +++ b/session/session_test.go @@ -1,10 +1,12 @@ package session import ( + "bytes" "fmt" "github.com/jarcoal/httpmock" "github.com/softlayer/softlayer-go/datatypes" "github.com/softlayer/softlayer-go/sl" + "log" "os" "strings" "testing" @@ -178,6 +180,50 @@ func TestRefreshToken(t *testing.T) { httpmock.Reset() } +// Tests refreshing a IAM token logging output and calling IAMUpdaters +func TestRefreshTokenWithLog(t *testing.T) { + // setup session and mock environment + logBuf := bytes.Buffer{} + Logger = log.New(&logBuf, "", log.LstdFlags) + s = New() + s.Endpoint = restEndpoint + s.IAMToken = "Bearer TestToken" + s.IAMRefreshToken = "TestTokenRefresh" + //s.Debug = true + updater := NewLogIamUpdater(true) + s.AddIAMUpdater(updater) + httpmock.Activate() + defer httpmock.DeactivateAndReset() + fmt.Printf("TestRefreshTokenWithLog [Happy Path]: ") + + // Happy Path + httpmock.RegisterResponder("POST", IBMCLOUDIAMENDPOINT, + httpmock.NewStringResponder(200, `{"access_token": "NewToken123", "refresh_token":"NewRefreshToken123", "token_type":"Bearer"}`), + ) + err := s.RefreshToken() + if err != nil { + t.Errorf("Testing Error: %v\n", err.Error()) + } + + if s.IAMToken != "Bearer NewToken123" { + t.Errorf("(IAMToken) %s != 'Bearer NewToken123', Refresh Failed.", s.IAMToken) + } + if s.IAMRefreshToken != "NewRefreshToken123" { + t.Errorf("(IAMRefreshToken) %s != 'NewRefreshToken123', Refresh Failed.", s.IAMRefreshToken) + } + logOutput := strings.Split(logBuf.String(), "\n") + if len(logOutput) < 2 { + t.Errorf("Not enough log output detected.") + } + if !strings.HasSuffix(logOutput[0], "[DEBUG] New Token: Bearer NewToken123") { + t.Errorf("%s is incorrect log output", logOutput[0]) + } + if !strings.HasSuffix(logOutput[1], "[DEBUG] New Refresh Token: NewRefreshToken123") { + t.Errorf("%s is incorrect log output", logOutput[1]) + } + httpmock.Reset() +} + func TestString(t *testing.T) { // setup session and mock environment s = New()