-
Notifications
You must be signed in to change notification settings - Fork 0
Guarantee minimum remaining token lifetime #1
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
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| // Package token acquires short-lived Microsoft Entra access tokens for the | ||
| // internal Azure DevOps Artifacts feeds, scoped to vso.packaging, and persists | ||
| // MSAL's token cache to disk. | ||
| // | ||
| // Tokens returned by Silent are guaranteed to have at least MinLifetime | ||
| // remaining, so a consumer may cache one for any TTL below that bound (e.g. | ||
| // mise's env_cache_ttl) without ever serving it past its own expiry. | ||
| package token | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "errors" | ||
| "fmt" | ||
| "os" | ||
| "path/filepath" | ||
| "time" | ||
|
|
||
| "github.com/AzureAD/microsoft-authentication-library-for-go/apps/cache" | ||
| "github.com/AzureAD/microsoft-authentication-library-for-go/apps/public" | ||
| ) | ||
|
|
||
| // adoResource is the public, global Azure DevOps resource ID (identical for | ||
| // every tenant — not a secret). The requested scope narrows the token to feed | ||
| // reads. | ||
| const adoResource = "499b84ac-1321-427f-aa17-267ca6975798" | ||
|
|
||
| // MinLifetime is the smallest remaining lifetime a token from Silent may have. | ||
| // When the cached token has less, Silent forces a refresh, so the returned | ||
| // token is always good for at least this long. That lets a consumer cache it | ||
| // for any TTL strictly below this bound and never serve an expired token. Entra | ||
| // feed tokens live ~1h, so this leaves ample refresh headroom. | ||
| const MinLifetime = 20 * time.Minute | ||
|
|
||
| // Scopes returns the delegated scopes requested for a feed token. | ||
| func Scopes() []string { return []string{adoResource + "/vso.packaging"} } | ||
|
|
||
| // FileCache persists MSAL's token cache (which holds the refresh token and any | ||
| // live access token) to disk so a single login survives across invocations. | ||
| type FileCache struct { | ||
| // Path is the on-disk location of the serialized MSAL cache. | ||
| Path string | ||
| // DropAccessTokens hides the cached access tokens on the next Replace so a | ||
| // silent acquisition redeems the refresh token instead of returning a | ||
| // near-expiry cached token. See Silent. | ||
| DropAccessTokens bool | ||
| } | ||
|
|
||
| func (c *FileCache) Replace(_ context.Context, u cache.Unmarshaler, _ cache.ReplaceHints) error { | ||
| data, err := os.ReadFile(c.Path) | ||
| if err != nil { | ||
| if errors.Is(err, os.ErrNotExist) { | ||
| // A missing cache is the normal pre-login state, not an error. | ||
| return nil | ||
| } | ||
| return fmt.Errorf("read MSAL cache: %w", err) | ||
| } | ||
| if c.DropAccessTokens { | ||
| data, err = StripAccessTokens(data) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| } | ||
| return u.Unmarshal(data) | ||
| } | ||
|
|
||
| // Export writes atomically (temp + rename, 0600) so concurrent shim invocations | ||
| // never read a half-written cache. | ||
| func (c *FileCache) Export(_ context.Context, m cache.Marshaler, _ cache.ExportHints) error { | ||
| data, err := m.Marshal() | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if err := os.MkdirAll(filepath.Dir(c.Path), 0o700); err != nil { | ||
| return err | ||
| } | ||
| tmp := fmt.Sprintf("%s.%d.tmp", c.Path, os.Getpid()) | ||
| if err := os.WriteFile(tmp, data, 0o600); err != nil { | ||
| return err | ||
| } | ||
| return os.Rename(tmp, c.Path) | ||
|
gtbuchanan marked this conversation as resolved.
|
||
| } | ||
|
|
||
| // StripAccessTokens removes the access-token entries from a serialized MSAL | ||
| // cache, leaving the refresh token and everything else intact. MSAL's silent | ||
| // path then finds no cached access token and redeems the refresh token for a | ||
| // freshly-minted one (apps/internal/base: "redeem a cached refresh token"). | ||
| func StripAccessTokens(data []byte) ([]byte, error) { | ||
| var contents map[string]json.RawMessage | ||
| if err := json.Unmarshal(data, &contents); err != nil { | ||
| return nil, err | ||
| } | ||
| delete(contents, "AccessToken") | ||
| return json.Marshal(contents) | ||
| } | ||
|
|
||
| // NeedsForcedRefresh reports whether a token expiring at expiresOn has too | ||
| // little life left to return under the MinLifetime guarantee. | ||
| func NeedsForcedRefresh(expiresOn, now time.Time) bool { | ||
| return expiresOn.Sub(now) < MinLifetime | ||
| } | ||
|
|
||
| // Silent returns a cached or silently-refreshed access token, if the account | ||
| // has a prior login that is still valid or refreshable. The returned token is | ||
| // guaranteed to have at least MinLifetime remaining: MSAL refreshes proactively | ||
| // only when Entra sends refresh_in, so when it doesn't, the cached token is | ||
| // returned until it actually expires. If the token is inside that window, Silent | ||
| // forces a refresh-token grant by hiding the cached access token (see FileCache). | ||
| func Silent(ctx context.Context, app public.Client, fc *FileCache) (string, bool) { | ||
| accounts, err := app.Accounts(ctx) | ||
| if err != nil || len(accounts) == 0 { | ||
| return "", false | ||
| } | ||
| acct := accounts[0] | ||
|
|
||
| res, err := app.AcquireTokenSilent(ctx, Scopes(), public.WithSilentAccount(acct)) | ||
| if err != nil { | ||
| return "", false | ||
| } | ||
| if !NeedsForcedRefresh(res.ExpiresOn, time.Now()) { | ||
| return res.AccessToken, true | ||
| } | ||
|
|
||
| fc.DropAccessTokens = true | ||
| defer func() { fc.DropAccessTokens = false }() | ||
| res, err = app.AcquireTokenSilent(ctx, Scopes(), public.WithSilentAccount(acct)) | ||
| if err != nil { | ||
| return "", false | ||
| } | ||
| return res.AccessToken, true | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| package token_test | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/energyworldnet/az-devops-token/internal/token" | ||
| ) | ||
|
|
||
| func TestStripAccessTokensRemovesOnlyAccessTokens(t *testing.T) { | ||
| in := []byte(`{` + | ||
| `"AccessToken":{"k":{"secret":"at"}},` + | ||
| `"RefreshToken":{"r":{"secret":"rt"}},` + | ||
| `"IdToken":{"i":{}},` + | ||
| `"Account":{"a":{}},` + | ||
| `"AppMetadata":{"m":{}}` + | ||
| `}`) | ||
|
|
||
| out, err := token.StripAccessTokens(in) | ||
| if err != nil { | ||
| t.Fatalf("StripAccessTokens: %v", err) | ||
| } | ||
|
|
||
| var got map[string]json.RawMessage | ||
| if err := json.Unmarshal(out, &got); err != nil { | ||
| t.Fatalf("unmarshal result: %v", err) | ||
| } | ||
| if _, ok := got["AccessToken"]; ok { | ||
| t.Error("AccessToken section should be removed") | ||
| } | ||
| for _, key := range []string{"RefreshToken", "IdToken", "Account", "AppMetadata"} { | ||
| if _, ok := got[key]; !ok { | ||
| t.Errorf("%s section should be preserved", key) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestStripAccessTokensRejectsInvalidJSON(t *testing.T) { | ||
| if _, err := token.StripAccessTokens([]byte("not json")); err == nil { | ||
| t.Error("expected an error for invalid JSON") | ||
| } | ||
| } | ||
|
|
||
| func TestNeedsForcedRefresh(t *testing.T) { | ||
| now := time.Unix(1_700_000_000, 0) | ||
| cases := []struct { | ||
| name string | ||
| expires time.Time | ||
| want bool | ||
| }{ | ||
| {"ample life", now.Add(token.MinLifetime + time.Minute), false}, | ||
| {"exactly at bound", now.Add(token.MinLifetime), false}, | ||
| {"inside window", now.Add(token.MinLifetime - time.Minute), true}, | ||
| {"already expired", now.Add(-time.Minute), true}, | ||
| } | ||
| for _, c := range cases { | ||
| if got := token.NeedsForcedRefresh(c.expires, now); got != c.want { | ||
| t.Errorf("%s: NeedsForcedRefresh = %v, want %v", c.name, got, c.want) | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.