Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for refresh_in for ConfidentialClient #542

Open
wants to merge 30 commits into
base: main
Choose a base branch
from

Conversation

4gust
Copy link
Collaborator

@4gust 4gust commented Jan 9, 2025

PR Description:

This update introduces support for a refreshIn field, which is calculated as half of the expiresIn value, but only if expiresIn is greater than 2 hours and if it was not received in the response.

Key changes:

  • When storing data: If expiresIn is more than 2 hours and not provided in the response, refreshIn will be set to half of expiresIn.
  • When retrieving from cache: We now check the refreshIn value. If it has expired, a server call will be triggered on the main thread to refresh the data.

This ensures that cache data is refreshed proactively, reducing the need for repeated server calls while improving overall performance.

@4gust 4gust requested review from bgavrilMS and rayluo as code owners January 9, 2025 13:44
Copy link
Member

@bgavrilMS bgavrilMS left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only takes care (partially) of adding RefreshOn in the cache. However, the feature is also meant to provide proactive token refresh capabilities, meaning:

  • when app dev requests a token
  • if RefreshOn exists AND it has elasped AND expiresON has not elapsed
  • make a request to AAD for a new token
  • and if that request fails AAD is unavailable (HTTP 5xx error or HTTP 429 error), return the old token

Shifted RefreshOn in Metadata
Copy link
Collaborator

@AndyOHart AndyOHart left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@@ -444,3 +443,40 @@ func TestAuthResultFromStorage(t *testing.T) {
}
}
}

// TestShouldRefresh tests the shouldRefresh function
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if we need this comment

}
if pr.RefreshInSeconds > 0 {
tr.RefreshOn = internalTime.DurationTime{
T: now.Add(time.Duration(pr.RefreshInSeconds) * time.Second),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you set this field unconditionally i.e., what happens when pr.RefreshInSeconds == 0? Do you not just get a zero Time?

@@ -311,10 +327,20 @@ func (c Client) AcquireToken(ctx context.Context, resource string, options ...Ac
}
ar, err := base.AuthResultFromStorage(storageTokenResponse)
if err == nil {
if c.shouldRefresh(storageTokenResponse.AccessToken.RefreshOn.T) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd inline this. All else being equal, it's best to have a critical section in a single function because it's easier to understand and maintain that way


// Conditionally add the "refresh_in" field if refreshIn is provided
if refreshIn > 0 {
body += fmt.Sprintf(`, "refresh_in": %d`, refreshIn)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is refresh_in always a number, or could it be a string?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Always a number, same as expires_in

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expires_in can be a string though. I'm referring to JSON value types here i.e., is refresh_in always a number like 42 or can it be a string like "42"

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated, tested it and it works fine both ways :)

Comment on lines 919 to 924
// Wait for all goroutines in a separate goroutine
go func() {
wg.Wait()
close(done)
close(ch)
}()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do this in a separate goroutine, and with a done channel? You want the goroutine executing the test to wait for the others--it can just call wg.Wait() as before. Maybe this will help explain the mechanics: https://play.golang.com/p/hWAE3Cenyd8

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created the go routine, so that we can safely close the channels and create a wait()
I understand what i missed in there
I updated it

@@ -866,7 +866,7 @@ func TestRefreshInMultipleRequests(t *testing.T) {
return fixedTime
}
var wg sync.WaitGroup
done := make(chan struct{})
// done := make(chan struct{})
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀

return
}
if ar.AccessToken == secondToken && ar.Metadata.TokenSource == base.IdentityProvider {
if requestChecker {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this has no effect--the next line will never execute--because mockClient has only one response and would panic upon receiving a second request

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This checks two things

  1. Have it token updated.
  2. The updated token we got was not from cache.

In these scenarios should only happen once. If we don't check this then how do, we know that the request to the mockClient has been made or not. As, this is concurrent we can get the updated token from cache before the token from Identity. This check is useful.
The request can only be made once, as there is only one request in queue.

}
return
}
if ar.AccessToken == secondToken+"firstTenant" && ar.Metadata.TokenSource == base.IdentityProvider {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 this test is unclear and maybe incorrect. How to clarify or fix it depends on what behaviors you want to verify. I suppose it's a subset of these?

  • proactive refreshes in general don't race
  • client refreshes different tenants concurrently
  • concurrent refreshes in particular don't race

if err != nil {
t.Error("Unexpected error", err)
}
wg.Wait()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You want to observe the first and second goroutines refreshing concurrently. That means observing them sending token requests via AcquireTokenSilent at the same time. As written, this test can't do that because it synchronizes the goroutines after they call AcquireTokenSilent, making this possible:

  1. first completes its refresh
  2. first waits for second
  3. second completes its refresh
  4. second signals first to continue
  5. test passes

You need to synchronize during the refresh requests, while the goroutines are executing AcquireTokenSilent. You can do that with the response callback (adjust the mock client so it doesn't lock around this callback) and a channel the goroutines use to announce when they're refreshing, like this:

// goroutines use present to announce they've reached the (fake) server and are waiting to proceed
present := make(chan string)
// the test goroutine uses this channel to signal the goroutines to complete their refreshes
proceed := make(chan struct{})
wg := sync.WaitGroup{}
// need a timeout because a bug could cause deadlock
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
for _, tenant := range []string{"A", "B"} {
    wg.Add(1)
    mockClient.AppendResponse(
        mock.WithBody(mock.GetAccessTokenBody(tenant, "", "", "", expiresIn, refreshIn)),
        mock.WithCallback(func(r *http.Request) {
            if s := strings.SplitN(r.URL.Path, "/", 3); len(s) == 3 {
                // send the tenant from the URL, which uniquely identifies the goroutine
                present <- s[1]
                for range proceed {
                    // wait for the test goroutine to close this channel
                }
            } else {
                t.Error("unexpected token request URL: " + r.URL.String())
            }
        }),
    )
    go func(id string) {
        defer wg.Done()
        if _, err := client.AcquireTokenSilent(ctx, tokenScope, WithTenantID(id)); err != nil {
            t.Error("Unexpected error", err)
        }
    }(tenant)
}
for a, b := false, false; !(a && b); {
    select {
    case <-ctx.Done():
        t.Fatal("timed out waiting for both goroutines to refresh")
    case g := <-present:
        switch g {
        case "A":
            a = true
        case "B":
            b = true
        default:
            t.Fatal("unexpected goroutine: " + g)
        }
    }
}
// both goroutines are refreshing concurrently. Signal
// them to proceed and wait for them to finish
close(proceed)
wg.Wait()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Feature Request] refresh_in
4 participants