Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
131 changes: 131 additions & 0 deletions internal/token/token.go
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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// 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)
Comment thread
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
}
62 changes: 62 additions & 0 deletions internal/token/token_test.go
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)
}
}
}
67 changes: 10 additions & 57 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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.
Expand All @@ -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 != "" {
Expand All @@ -67,75 +63,32 @@ 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 == "" {
return public.Client{}, fmt.Errorf("client/tenant ID not configured (set AZURE_CLIENT_ID and AZURE_TENANT_ID)")
}
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 {
Expand All @@ -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 {
Expand Down
Loading