Skip to content
Open
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
44 changes: 39 additions & 5 deletions auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,12 @@ func ensureFreshToken(
full = loadFull()
// loadFull (loadConfig) builds a fresh store instance that could resolve
// to a different backend/path than the one we loaded the token from.
// Pin the store and client ID to the originally loaded config so the
// refreshed token is saved exactly where it came from.
// Pin the store, client ID, and token-file path to the originally loaded
// config so the refreshed token is saved exactly where it came from and
// the cross-process refresh lock sits next to that same file.
full.Store = cfg.Store
full.ClientID = cfg.ClientID
full.TokenFile = cfg.TokenFile
}

// Resolve endpoints lazily too. Callers that pre-populate Endpoints skip
Expand All @@ -154,19 +156,51 @@ func ensureFreshToken(
resolveEndpoints(ctx, full)
}

newTok, err := refreshAccessToken(ctx, full, tok.RefreshToken)
newTok, err := refreshAccessToken(ctx, full, tok)
if err != nil {
return reuseOrFail(err)
}
return *newTok, true, nil
}

// refreshAccessToken exchanges a refresh token for a new access token.
//
// It takes a cross-process advisory lock before the critical section so that
// concurrent CLI invocations cannot spend the same refresh token twice —
// which would cause one of them to receive invalid_grant on rotation servers.
// After acquiring the lock, it re-reads the store: if a peer process has
// already refreshed, the peer's fresh token is returned without a network
// call.
func refreshAccessToken(
ctx context.Context,
cfg *AppConfig,
refreshToken string,
stale credstore.Token,
) (*credstore.Token, error) {
unlock, err := lockTokenStore(ctx, cfg.TokenFile, cfg.ClientID)
if err != nil {
return nil, fmt.Errorf("acquire refresh lock: %w", err)
}
defer unlock.Close()

refreshToken := stale.RefreshToken

// Peer check: another process may have refreshed while we waited for the lock.
if fresh, loadErr := cfg.Store.Load(cfg.ClientID); loadErr == nil {
// A peer refreshed if the stored token is now usable (present and not
// expired — same predicate the reuse paths use) and differs from the
// stale copy we were handed.
peerRefreshed := tokenUsable(fresh, time.Now()) &&
(fresh.AccessToken != stale.AccessToken ||
fresh.RefreshToken != stale.RefreshToken)
if peerRefreshed {
return &fresh, nil
}
// Peer may have rotated the refresh token without updating our view.
if fresh.RefreshToken != "" {
refreshToken = fresh.RefreshToken
}
}

ctx, cancel := context.WithTimeout(ctx, cfg.RefreshTokenTimeout)
defer cancel()

Expand Down Expand Up @@ -246,7 +280,7 @@ func makeAPICallWithAutoRefresh(
if resp.StatusCode == http.StatusUnauthorized {
ui.ShowStatus(tui.StatusUpdate{Event: tui.EventAccessTokenRejected})

newStorage, err := refreshAccessToken(ctx, cfg, storage.RefreshToken)
newStorage, err := refreshAccessToken(ctx, cfg, *storage)
if err != nil {
if errors.Is(err, ErrRefreshTokenExpired) {
return ErrRefreshTokenExpired
Expand Down
5 changes: 3 additions & 2 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ type AppConfig struct {
Scope string
ForceDevice bool
TokenStoreMode string // "auto", "file", or "keyring"
TokenFile string // path used for the file backend and for the cross-process refresh lock
RetryClient *retry.Client
Store credstore.Store[credstore.Token]

Expand Down Expand Up @@ -191,7 +192,7 @@ func loadStoreConfig() *AppConfig {

cfg.ClientID = getConfig(flagClientID, "CLIENT_ID", "")
cfg.TokenStoreMode = getConfig(flagTokenStore, "TOKEN_STORE", "auto")
tokenFile := getConfig(flagTokenFile, "TOKEN_FILE", ".authgate-tokens.json")
cfg.TokenFile = getConfig(flagTokenFile, "TOKEN_FILE", ".authgate-tokens.json")

// Resolved here (not loadConfig) so the offline `token get` path can decide
// whether a refresh is due without building the full network config.
Expand All @@ -207,7 +208,7 @@ func loadStoreConfig() *AppConfig {
}

var storeErr error
cfg.Store, storeErr = newTokenStore(cfg.TokenStoreMode, tokenFile, defaultKeyringService)
cfg.Store, storeErr = newTokenStore(cfg.TokenStoreMode, cfg.TokenFile, defaultKeyringService)
if storeErr != nil {
fmt.Fprintln(os.Stderr, storeErr)
os.Exit(1)
Expand Down
20 changes: 17 additions & 3 deletions extra_claims_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"strings"
"testing"
"time"

"github.com/go-authgate/sdk-go/credstore"
)

// -----------------------------------------------------------------------
Expand Down Expand Up @@ -244,7 +246,11 @@ func TestRefreshAccessToken_SendsExtraClaims(t *testing.T) {
cfg.Endpoints = defaultEndpoints(gotForm.serverURL)
cfg.ExtraClaims = `{"project":"acme"}`

if _, err := refreshAccessToken(context.Background(), cfg, "old-refresh"); err != nil {
if _, err := refreshAccessToken(
context.Background(),
cfg,
credstore.Token{RefreshToken: "old-refresh"},
); err != nil {
t.Fatalf("refreshAccessToken() error: %v", err)
}

Expand All @@ -265,7 +271,11 @@ func TestRefreshAccessToken_OmitsExtraClaimsWhenUnset(t *testing.T) {
cfg.Endpoints = defaultEndpoints(gotForm.serverURL)
cfg.ExtraClaims = ""

if _, err := refreshAccessToken(context.Background(), cfg, "old-refresh"); err != nil {
if _, err := refreshAccessToken(
context.Background(),
cfg,
credstore.Token{RefreshToken: "old-refresh"},
); err != nil {
t.Fatalf("refreshAccessToken() error: %v", err)
}

Expand Down Expand Up @@ -348,7 +358,11 @@ func TestRefreshAccessToken_ExtraClaimsSurviveURLEncoding(t *testing.T) {
}
cfg.ExtraClaims = resolved

if _, err := refreshAccessToken(context.Background(), cfg, "old-refresh"); err != nil {
if _, err := refreshAccessToken(
context.Background(),
cfg,
credstore.Token{RefreshToken: "old-refresh"},
); err != nil {
t.Fatalf("refreshAccessToken() error: %v", err)
}
got := gotForm.value(t).Get("extra_claims")
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
charm.land/lipgloss/v2 v2.0.3
github.com/appleboy/go-httpretry v0.12.0
github.com/go-authgate/sdk-go v0.11.0
github.com/gofrs/flock v0.13.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/mattn/go-isatty v0.0.22
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ github.com/go-authgate/sdk-go v0.11.0 h1:ZTfJ0rzeDn4QBqAmF9VKS3CqlKhE8+0tJxg8OGN
github.com/go-authgate/sdk-go v0.11.0/go.mod h1:sa0ige5wtayj2WcnXlxa8wGuyi5z/c/chc0mXPJTl/Q=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
Expand Down
77 changes: 77 additions & 0 deletions lock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package main

import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"

"github.com/gofrs/flock"
)

const lockRetryInterval = 100 * time.Millisecond

// lockDirName is the per-user subdirectory used for refresh locks when the
// token file path is relative (the default and the keyring/auto backends).
const lockDirName = "authgate-cli"

// lockTokenStore acquires a cross-process advisory lock scoped to
// (tokenFile, clientID). It serialises the "load → refresh → save"
// critical section so concurrent CLI invocations cannot spend the same
// refresh token twice (which would yield invalid_grant on rotation servers).
//
// The returned io.Closer releases the lock on Close (flock.Close is documented
// as equivalent to Unlock).
//
// Lock placement: when tokenFile is an absolute path (an explicit file backend)
// the lock sits next to it, so distinct stores get distinct locks. When the
// path is relative — the default, and the case for keyring/auto backends where
// the token never touches disk — the lock is anchored in a stable, per-user,
// cwd-independent directory (os.UserCacheDir, falling back to os.TempDir). That
// way refresh does not require a writable working directory and concurrent runs
// launched from different directories still serialise.
func lockTokenStore(ctx context.Context, tokenFile, clientID string) (io.Closer, error) {
if tokenFile == "" {
return nil, errors.New("lock: tokenFile is empty")
}
if clientID == "" {
return nil, errors.New("lock: clientID is empty")
}

dir := filepath.Dir(tokenFile)
if !filepath.IsAbs(tokenFile) {
base, err := os.UserCacheDir()
if err != nil {
base = os.TempDir()
}
dir = filepath.Join(base, lockDirName)
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create lock directory %q: %w", dir, err)
}
Comment thread
appleboy marked this conversation as resolved.

lockPath := filepath.Join(dir, lockFileName(tokenFile, clientID))
fl := flock.New(lockPath)
locked, err := fl.TryLockContext(ctx, lockRetryInterval)
if err != nil {
return nil, fmt.Errorf("acquire lock %s: %w", lockPath, err)
}
if !locked {
return nil, fmt.Errorf("could not acquire lock %s", lockPath)
}
return fl, nil
}

// lockFileName builds a filesystem-safe lock filename for (tokenFile, clientID).
// The clientID is hashed rather than interpolated directly so that path
// separators, "..", or characters that are invalid on some platforms can never
// alter the lock location or produce an unusable name.
func lockFileName(tokenFile, clientID string) string {
sum := sha256.Sum256([]byte(clientID))
return filepath.Base(tokenFile) + "." + hex.EncodeToString(sum[:8]) + ".lock"
}
88 changes: 88 additions & 0 deletions lock_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package main

import (
"context"
"path/filepath"
"strings"
"testing"
)

// TestLockTokenStore_AcquireAndRelease verifies a basic acquire → release cycle
// and that the lock can be re-acquired after the first holder closes it.
func TestLockTokenStore_AcquireAndRelease(t *testing.T) {
tokenFile := filepath.Join(t.TempDir(), "tokens.json")

first, err := lockTokenStore(context.Background(), tokenFile, "client-a")
if err != nil {
t.Fatalf("first acquire: %v", err)
}
if err := first.Close(); err != nil {
t.Fatalf("release: %v", err)
}

second, err := lockTokenStore(context.Background(), tokenFile, "client-a")
if err != nil {
t.Fatalf("re-acquire after release: %v", err)
}
if err := second.Close(); err != nil {
t.Fatalf("second release: %v", err)
}
}

// TestLockTokenStore_RejectsEmptyInputs guards the two preconditions.
func TestLockTokenStore_RejectsEmptyInputs(t *testing.T) {
if _, err := lockTokenStore(context.Background(), "", "client"); err == nil {
t.Error("expected error for empty tokenFile")
}
if _, err := lockTokenStore(context.Background(), "tokens.json", ""); err == nil {
t.Error("expected error for empty clientID")
}
}

// TestLockFileName_SafeForHostileClientID verifies that a clientID containing
// path separators or "../" segments cannot escape the lock directory or change
// the directory component of the lock filename.
func TestLockFileName_SafeForHostileClientID(t *testing.T) {
hostile := []string{
"../../etc/passwd",
"a/b/c",
"..",
`win\path`,
"plain-client",
}
for _, id := range hostile {
name := lockFileName("tokens.json", id)
if strings.ContainsAny(name, `/\`) {
t.Errorf("clientID %q produced a name with a separator: %q", id, name)
}
// filepath.Join with the name must stay inside the directory.
got := filepath.Join("/locks", name)
if filepath.Dir(got) != "/locks" {
t.Errorf("clientID %q escaped the lock dir: %q", id, got)
}
if !strings.HasSuffix(name, ".lock") {
t.Errorf("clientID %q produced %q, want a .lock suffix", id, name)
}
}
}

// TestLockTokenStore_RelativePathUsesStableDir verifies that a relative token
// file path does not place the lock in (and therefore does not require a
// writable) current working directory — it is anchored under the user cache dir
// instead. Redirect the cache dir to a temp location so the test does not
// pollute the real one.
func TestLockTokenStore_RelativePathUsesStableDir(t *testing.T) {
cache := t.TempDir()
t.Setenv("XDG_CACHE_HOME", cache) // Linux
t.Setenv("HOME", cache) // macOS ($HOME/Library/Caches) and Linux fallback

closer, err := lockTokenStore(context.Background(), ".authgate-tokens.json", "rel-client")
if err != nil {
t.Fatalf("acquire with relative path: %v", err)
}
defer closer.Close()

if leaked, _ := filepath.Glob(".authgate-tokens.json.*.lock"); len(leaked) != 0 {
t.Errorf("lock leaked into the working directory: %v", leaked)
}
}
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ func run(ctx context.Context, ui tui.Manager, cfg *AppConfig) int {
} else {
ui.ShowStatus(tui.StatusUpdate{Event: tui.EventTokenExpired})
}
newStorage, refreshErr := refreshAccessToken(ctx, cfg, existing.RefreshToken)
newStorage, refreshErr := refreshAccessToken(ctx, cfg, existing)
if refreshErr != nil {
// The refresh genuinely failed, so mark it failed in the UI.
// Then degrade gracefully (reuse the still-valid token) or
Expand Down
Loading
Loading