Skip to content

[Bug] managedidentity.Client token's early RefreshOn is not saved in the cache #570

@comtalyst

Description

@comtalyst

Which version of MSAL Go are you using?
Latest in main + v1.4.2

Where is the issue?

  • Public client
    • Device code flow
    • Username/Password (ROPC grant)
    • Authorization code flow
  • Confidential client
    • Authorization code flow
    • Client credentials:
      • client secret
      • client certificate
  • Token cache serialization
    • In-memory cache
  • Other (please describe)
    • Managed identity client/token caching

Is this a new or an existing app?

The app is in production and I have upgraded to a new version of Microsoft Authentication Library for Go.

What version of Go are you using (go version)?

$ go version
go version go1.24.3 linux/amd64

What operating system and processor architecture are you using (go env)?

go env Output
$ go env

Repro
Adding this unit test to managedidentity package:

package managedidentity

import (
	"context"
	"encoding/json"
	"net/http"
	"testing"
	"time"

	"github.com/AzureAD/microsoft-authentication-library-for-go/apps/internal/base/storage"
	"github.com/AzureAD/microsoft-authentication-library-for-go/apps/internal/mock"
)

func TestRefreshOnNotPersistedToCache(t *testing.T) {
	resource := "https://management.azure.com"
	miType := SystemAssigned()
	setEnvVars(t, DefaultToIMDS)
	before := cacheManager
	defer func() { cacheManager = before }()
	cacheManager = storage.New(nil)

	tokenLifetime := 24 * time.Hour
	accessToken := "[0]"

	mockClient := mock.NewClient()
	responseBody, err := json.Marshal(SuccessfulResponse{
		AccessToken: accessToken,
		ExpiresIn:   int64(tokenLifetime / time.Second),
		ExpiresOn:   time.Now().Add(tokenLifetime).Unix(),
		Resource:    resource,
		TokenType:   "Bearer",
		// Note: RefreshOn is intentionally not set in the response
	})
	if err != nil {
		t.Fatal(err)
	}
	mockClient.AppendResponse(
		mock.WithHTTPStatusCode(200),
		mock.WithBody(responseBody),
		mock.WithCallback(func(r *http.Request) {
			t.Logf("token provider server providing token: %s", accessToken)
		}),
	)
	client, err := New(miType, WithHTTPClient(mockClient))
	if err != nil {
		t.Fatal(err)
	}

	for i := 1; i <= 3; i++ {
		result, err := client.AcquireToken(context.Background(), resource)
		if err != nil {
			t.Fatal(err)
		}

		t.Logf("Call %d - token: %s, ExpiresOn: %s, RefreshOn: %s", i, result.AccessToken, result.ExpiresOn, result.Metadata.RefreshOn)
	}
}

Or, create an app that calls AcquireToken(...) multiple times on the same scope, such as from different ARM clients in azure-sdk-for-go.

Expected behavior
Correct RefreshOn is returned after the first try. This allows the caller to call AcquireToken() again to refresh as soon as the recommended time passes.

Actual behavior
Unassigned RefreshOn is returned after the first try. This voids the benefit of RefreshOn it tries to introduce.

=== RUN   TestRefreshOnNotPersistedToCache
    refresh_on_cache_test.go:41: token provider server providing token: [0]
    refresh_on_cache_test.go:55: Call 1 - token: [0], ExpiresOn: 2025-06-13 17:57:03 -0700 PDT, RefreshOn: 2025-06-13 05:57:03.480066915 -0700 PDT m=+43199.520639356
    refresh_on_cache_test.go:55: Call 2 - token: [0], ExpiresOn: 2025-06-14 00:57:03 +0000 UTC, RefreshOn: 0001-01-01 00:00:00 +0000 UTC
    refresh_on_cache_test.go:55: Call 3 - token: [0], ExpiresOn: 2025-06-14 00:57:03 +0000 UTC, RefreshOn: 0001-01-01 00:00:00 +0000 UTC

Moreover, this unassigned RefreshOn is stored in the cache, which means the logic to refresh the token in this layer will gatekeep the new token from being requested until hardcoded 5-minute before expiration. This increases the risk of token expiring before it is used by the caller.

Possible solution
Write to the cache after RefreshOn is assigned.

Additional context / logs / screenshots
ARM clients in azure-sdk-for-go has been relying on RefreshOn, thus currently suffering from this issue.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions