From 000bd60479a8388a71dcf08c4c7c01dea994f137 Mon Sep 17 00:00:00 2001 From: Taylor Buchanan Date: Thu, 23 Jul 2026 16:06:42 -0500 Subject: [PATCH 1/2] Guarantee minimum token lifetime; extract internal/token Emit only access tokens with at least MinLifetime (20m) remaining, so a consumer that caches the token (e.g. mise's env_cache) can never serve it past its own exp. MSAL Go exposes no force-refresh option and refreshes proactively only when Entra sends refresh_in; when the cached token is inside the window, force a fresh mint by hiding the cached access token on cache reload, so the silent path redeems the refresh token instead. Extract the acquisition logic, cache, and lifetime policy into internal/token with tests beside the package; main.go is now CLI wiring. clientID and tenantID stay in main so the release `-ldflags -X main.*` path is unchanged. README: document the lifetime guarantee and fix a stale "device-code flow" mention (the tool uses an interactive browser sign-in). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 7 +- internal/token/token.go | 127 +++++++++++++++++++++++++++++++++++ internal/token/token_test.go | 62 +++++++++++++++++ main.go | 67 +++--------------- 4 files changed, 205 insertions(+), 58 deletions(-) create mode 100644 internal/token/token.go create mode 100644 internal/token/token_test.go diff --git a/README.md b/README.md index e1c70fd..e30a87d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ are short-lived (~1h); the durable refresh token stays in a `0600` cache. ## Usage -Sign in once per machine (interactive device-code flow): +Sign in once per machine (interactive browser sign-in): ```sh az-devops-token login @@ -26,6 +26,11 @@ Then print a token — silent, refreshing as needed: az-devops-token ``` +The printed token has a guaranteed minimum remaining lifetime (currently +~20 minutes): if the cached token is closer to expiry, the tool forces a +refresh first. A caller can therefore cache it for any shorter interval (e.g. +mise's `env_cache_ttl`) without risking an expired credential. + The default (no-argument) invocation always exits `0`. With no valid login it prints nothing to stdout and a hint to stderr, so callers (e.g. mise `[env]`) degrade to a clean `401` rather than erroring. diff --git a/internal/token/token.go b/internal/token/token.go new file mode 100644 index 0000000..4d7e5c1 --- /dev/null +++ b/internal/token/token.go @@ -0,0 +1,127 @@ +// 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" + "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 { + // A missing cache is the normal pre-login state, not an error. + return nil + } + 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) +} + +// 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 +} diff --git a/internal/token/token_test.go b/internal/token/token_test.go new file mode 100644 index 0000000..479cf41 --- /dev/null +++ b/internal/token/token_test.go @@ -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) + } + } +} diff --git a/main.go b/main.go index 6d3d546..832cc18 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,9 @@ // invocation always exits 0: a missing or expired login must not hard-error // mise's config load, so on failure it prints nothing and consumers degrade to a // clean 401. +// +// Token acquisition, the minimum-lifetime guarantee, and the on-disk cache live +// in internal/token; this file is the CLI wiring. package main import ( @@ -22,15 +25,10 @@ import ( "path/filepath" "time" - "github.com/AzureAD/microsoft-authentication-library-for-go/apps/cache" "github.com/AzureAD/microsoft-authentication-library-for-go/apps/public" + "github.com/energyworldnet/az-devops-token/internal/token" ) -// 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" - // clientID and tenantID identify the Entra public-client app registration. They // are injected at release time via `-ldflags -X`, falling back to the // AZURE_CLIENT_ID / AZURE_TENANT_ID environment variables for local builds. @@ -41,8 +39,6 @@ var ( tenantID string ) -func scopes() []string { return []string{adoResource + "/vso.packaging"} } - func firstNonEmpty(vals ...string) string { for _, v := range vals { if v != "" { @@ -67,37 +63,7 @@ func cachePath() string { return filepath.Join(dir, "az-devops-token.json") } -// 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 string } - -func (c *fileCache) Replace(_ context.Context, u cache.Unmarshaler, _ cache.ReplaceHints) error { - data, err := os.ReadFile(c.path) - if err != nil { - // A missing cache is the normal pre-login state, not an error. - return nil - } - 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) -} - -func newApp() (public.Client, error) { +func newApp(fc *token.FileCache) (public.Client, error) { cid := firstNonEmpty(clientID, os.Getenv("AZURE_CLIENT_ID")) tid := firstNonEmpty(tenantID, os.Getenv("AZURE_TENANT_ID")) if cid == "" || tid == "" { @@ -105,37 +71,24 @@ func newApp() (public.Client, error) { } return public.New(cid, public.WithAuthority("https://login.microsoftonline.com/"+tid), - public.WithCache(&fileCache{path: cachePath()}), + public.WithCache(fc), ) } -// silentToken returns a cached or silently-refreshed access token, if the user -// has a prior login that is still valid or refreshable. -func silentToken(ctx context.Context, app public.Client) (string, bool) { - accounts, err := app.Accounts(ctx) - if err != nil || len(accounts) == 0 { - return "", false - } - res, err := app.AcquireTokenSilent(ctx, scopes(), public.WithSilentAccount(accounts[0])) - if err != nil { - return "", false - } - return res.AccessToken, true -} - // interactiveLogin opens the system browser for sign-in. Unlike the device-code // flow, a browser sign-in carries the machine's Entra device identity via SSO, so // it satisfies Conditional Access policies that require a registered/compliant // device. func interactiveLogin(ctx context.Context, app public.Client) error { - _, err := app.AcquireTokenInteractive(ctx, scopes()) + _, err := app.AcquireTokenInteractive(ctx, token.Scopes()) return err } func main() { login := len(os.Args) > 1 && os.Args[1] == "login" - app, err := newApp() + fc := &token.FileCache{Path: cachePath()} + app, err := newApp(fc) if err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) if login { @@ -147,7 +100,7 @@ func main() { // A cached/refreshable token satisfies both paths: it's what `print` emits, // and it makes `login` idempotent (no prompt when already signed in). silentCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - tok, ok := silentToken(silentCtx, app) + tok, ok := token.Silent(silentCtx, app, fc) cancel() if ok { if login { From 0c326041a3d9870f0f03a61fb0cb2a217582694f Mon Sep 17 00:00:00 2001 From: Taylor Buchanan Date: Thu, 23 Jul 2026 16:25:08 -0500 Subject: [PATCH 2/2] Propagate cache-read errors other than a missing file FileCache.Replace returned nil on any os.ReadFile error, masking a permission/IO/path failure as an empty cache (i.e. "not signed in") and tripping golangci-lint nilerr. Ignore only os.ErrNotExist (the normal pre-login state) and wrap the rest. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/token/token.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/token/token.go b/internal/token/token.go index 4d7e5c1..2877f64 100644 --- a/internal/token/token.go +++ b/internal/token/token.go @@ -10,6 +10,7 @@ package token import ( "context" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -48,8 +49,11 @@ type FileCache struct { func (c *FileCache) Replace(_ context.Context, u cache.Unmarshaler, _ cache.ReplaceHints) error { data, err := os.ReadFile(c.Path) if err != nil { - // A missing cache is the normal pre-login state, not an error. - return 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)