Skip to content

fix(auth): prevent destructive refresh token races - #2147

Closed
buxibuxi wants to merge 3 commits into
larksuite:mainfrom
buxibuxi:fix/auth-refresh-rotation-race
Closed

fix(auth): prevent destructive refresh token races#2147
buxibuxi wants to merge 3 commits into
larksuite:mainfrom
buxibuxi:fix/auth-refresh-rotation-race

Conversation

@buxibuxi

@buxibuxi buxibuxi commented Aug 3, 2026

Copy link
Copy Markdown

Summary

Prevent concurrent lark-cli processes from invalidating rotated Feishu refresh tokens, deleting newly stored credentials, or misreporting credential-store failures as missing authorization. All login, refresh, revoke, and logout transitions now serialize on one user-scoped credential lock.

Changes

  • use one credential-scoped lock across config directories and credential writers; filesystem locks on Darwin/Linux and a user-bound named mutex on Windows
  • reload a concurrent refresh winner and preserve stored state on revoked, expired, transport, and retry failures
  • distinguish credential-store read failures from missing authorization in token resolution and identity diagnostics
  • preflight storage before remote rotation and retry atomic persistence with backoff
  • serialize remote revoke plus local removal so logout cannot race with refresh
  • add unit, secret-redaction, login/logout race, and real cross-process single-rotation coverage

Test Plan

  • make unit-test
  • go test -race ./internal/auth ./cmd/auth ./internal/identitydiag -count=1
  • go test ./internal/auth -run TestRefreshRotatesOnceAcrossConfigIsolatedProcesses -count=5
  • go vet ./internal/auth ./cmd/auth ./internal/identitydiag
  • GOOS=windows GOARCH=amd64 go test -c ./internal/auth
  • patched binary installed and four concurrent lark-cli auth status --json --verify calls returned user/ready/valid

Related Issues

Compatibility

  • No command or configuration contract changes.
  • Expired/revoked refresh state is retained for safe replacement by the next login instead of being destructively deleted.

@buxibuxi
buxibuxi requested a review from liangshuo-1 as a code owner August 3, 2026 03:10
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

UAT credential storage now uses injectable operations and platform-specific shared locks. Refresh failures preserve stored credentials, reuse concurrent refresh results, retry persistence, and return structured errors. Logout and identity diagnosis now handle storage failures explicitly.

Changes

UAT refresh handling

Layer / File(s) Summary
Credential storage and shared locking
internal/auth/credential_lock_windows.go, internal/auth/uat_lock_path_filesystem.go, internal/auth/token_store.go
Credential loading, persistence, and removal use injectable operations and credential-scoped locks. Platform implementations support Windows mutexes and Darwin/Linux filesystem locks.
Refresh state and error handling
internal/auth/uat_client.go
Refresh reloads stored state under lock, reuses concurrent winners, preserves credentials after failures, retries persistence three times, and returns classified errors.
Revocation and diagnostic error handling
internal/auth/revoke.go, cmd/auth/logout.go, internal/identitydiag/...
Logout uses serialized revoke-and-remove behavior. Identity diagnosis reports credential-store failures as verification failures.
Refresh failure and concurrency coverage
internal/auth/uat_client_test.go, internal/auth/uat_client_process_test.go, internal/identitydiag/diagnostics_test.go
Tests cover typed errors, state preservation, secret redaction, persistence retries, login/logout serialization, diagnostic failures, and cross-process locking.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

  • #1925 — Covers propagation of stored-token credential or decryption errors and their handling in diagnostics.

Suggested reviewers: liangshuo-1

Sequence Diagram(s)

sequenceDiagram
  participant UATClient
  participant TokenStore
  participant CredentialLock
  participant RefreshEndpoint
  UATClient->>TokenStore: load stored credentials
  UATClient->>CredentialLock: acquire credential lock
  CredentialLock->>TokenStore: reload stored credentials
  UATClient->>RefreshEndpoint: send refresh request
  RefreshEndpoint-->>UATClient: return refreshed credentials or error
  UATClient->>TokenStore: preserve or persist credentials
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address issue #1404 by preserving credentials after refresh failures and returning structured errors for diagnosis and recovery.
Out of Scope Changes check ✅ Passed The locking, storage, logout, diagnostics, and regression-test changes support the stated authentication-preservation objectives.
Title check ✅ Passed The title clearly and concisely describes the primary fix for destructive refresh-token races.
Description check ✅ Passed The description covers the required summary, changes, test plan, and related issues, with clear verification details.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the size/L Large or sensitive change across domains or core paths label Aug 3, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (2)
internal/auth/uat_client.go (1)

250-260: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Add a short delay before the single retry.

The retry runs immediately after a retryable authentication failure. A transient server error is usually not resolved within microseconds, so the immediate retry mostly wastes one round trip. Add a small fixed delay before the second callEndpoint() call.

♻️ Proposed refactor
 			fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh transient error (code=%d) for %s, retrying once\n", code, opts.UserOpenId)
+			time.Sleep(500 * time.Millisecond)
 			data, err = callEndpoint()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/auth/uat_client.go` around lines 250 - 260, In the retry branch of
the refresh flow around concurrentRefreshWinner, add a small fixed delay
immediately before the second callEndpoint() invocation. Keep the existing
single-retry behavior and error handling unchanged.
internal/auth/uat_client_test.go (1)

244-249: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Do not pass http.DefaultClient to a test that must not perform I/O.

The test relies on the local expiry check returning before any request. If that guard regresses, the test performs a real network call to the Feishu token endpoint. Pass a transport that fails the test when it is called. The assertion then also proves that no request was sent.

♻️ Proposed test change
-	_, err := GetValidAccessToken(http.DefaultClient, UATCallOptions{
+	client := &http.Client{Transport: uatRoundTripFunc(func(*http.Request) (*http.Response, error) {
+		t.Error("AC4: locally expired refresh token must not reach the token endpoint")
+		return nil, errors.New("unexpected request")
+	})}
+
+	_, err := GetValidAccessToken(client, UATCallOptions{
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/auth/uat_client_test.go` around lines 244 - 249, Update the
GetValidAccessToken test to use an HTTP client with a transport that immediately
fails if invoked instead of http.DefaultClient. Keep the existing expired-token
setup and assertions so the test verifies the local expiry guard returns without
performing any network request.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/auth/uat_client_process_test.go`:
- Around line 102-113: Update the “hold” branch in refreshLockHelperCommand to
remove the fixed 500 ms sleep and wait for an explicit probe-completion signal
instead. Pass the probe result-file path through an additional environment
variable, then have the holder poll or otherwise wait until that file exists
after writing the marker before allowing defer lock.Unlock() to run.
- Around line 91-95: Update the process-test setup around refreshLockPath and
its lock usage to register test cleanup that removes the lock path and any
test-created filesystem state. Replace direct os.MkdirAll, os.Stat, os.ReadFile,
and os.WriteFile calls in this test flow with the corresponding internal/vfs
operations, preserving the existing test behavior across Darwin, Linux, and
Windows.

In `@internal/auth/uat_client_test.go`:
- Around line 220-223: Update the error-path test around errs.ProblemOf to
assert the problem’s category, subtype, and param metadata, and verify the
original transport error remains reachable through the preserved cause. Declare
the connection-reset transport error once at the test’s start and have the round
tripper return that same error, then assert cause preservation alongside the
existing network subtype check.

In `@internal/auth/uat_client.go`:
- Around line 319-328: Update concurrentRefreshWinner to detect a concurrent
refresh when either the refresh token or access token differs from attempted,
while retaining the existing nil and valid-status checks. This must recognize
successful writes where the server reuses the refresh token but produces a new
access token.

In `@internal/auth/uat_lock_path_windows.go`:
- Around line 13-22: Update refreshLockDir to obtain cache, home, and temporary
directories exclusively through internal/vfs, adding the missing UserCacheDir
and TempDir accessors there. Preserve the cache-then-home lookup order, and when
both fail use an absolute temporary-directory fallback before appending the
lark-cli credential-locks path; remove the relative ".lark-cli" fallback.

---

Nitpick comments:
In `@internal/auth/uat_client_test.go`:
- Around line 244-249: Update the GetValidAccessToken test to use an HTTP client
with a transport that immediately fails if invoked instead of
http.DefaultClient. Keep the existing expired-token setup and assertions so the
test verifies the local expiry guard returns without performing any network
request.

In `@internal/auth/uat_client.go`:
- Around line 250-260: In the retry branch of the refresh flow around
concurrentRefreshWinner, add a small fixed delay immediately before the second
callEndpoint() invocation. Keep the existing single-retry behavior and error
handling unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 955c4785-85e7-4e95-a451-4ff8d1900cf3

📥 Commits

Reviewing files that changed from the base of the PR and between 40a0a9d and b58df59.

📒 Files selected for processing (5)
  • internal/auth/uat_client.go
  • internal/auth/uat_client_process_test.go
  • internal/auth/uat_client_test.go
  • internal/auth/uat_lock_path_filesystem.go
  • internal/auth/uat_lock_path_windows.go

Comment on lines +91 to +95
lockPath := refreshLockPath("cli_process_test", "ou_process_test")
lock := flock.New(lockPath)
if err := os.MkdirAll(filepath.Dir(lockPath), 0700); err != nil {
t.Fatal(err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Determine whether keychain.StorageDir follows HOME or another env override.
ast-grep run --pattern 'func StorageDir($$$) $$$' --lang go internal/keychain
rg -n 'HOME|UserHomeDir|os.Getenv' internal/keychain -C2

Repository: larksuite/cli

Length of output: 7402


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- test file outline ---'
ast-grep outline internal/auth/uat_client_process_test.go

printf '%s\n' '--- focused test ranges ---'
cat -n internal/auth/uat_client_process_test.go | sed -n '1,150p'

printf '%s\n' '--- refreshLockPath definitions and usages ---'
rg -n -C4 'refreshLockPath|flock\.New|RemoveAll|os\.Remove|vfs\.' internal/auth internal/keychain

printf '%s\n' '--- UserHomeDir implementation ---'
rg -n -C8 'func UserHomeDir|UserHomeDir\(' internal/vfs internal | head -160

printf '%s\n' '--- platform-specific cache directory references ---'
rg -n -C4 'UserCacheDir|CacheDir|LARKSUITE_CLI_DATA_DIR|LARKSUITE_CLI_CONFIG_DIR' internal/auth internal/keychain internal/vfs

Repository: larksuite/cli

Length of output: 46936


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- all refresh lock path implementations ---'
for f in $(rg -l 'func refreshLockDir' internal/auth); do
  echo "--- $f"
  cat -n "$f"
done

printf '%s\n' '--- lock dependency and cleanup references ---'
rg -n -C3 'gofrs/flock|TryLock|Unlock|refresh_.*\.lock' go.mod go.sum internal/auth

printf '%s\n' '--- auth test main ---'
cat -n internal/auth/testmain_test.go

Repository: larksuite/cli

Length of output: 8940


Keep the process-test lock and filesystem operations test-scoped.

On Darwin and Linux, HOME points to t.TempDir() and LARKSUITE_CLI_DATA_DIR is removed, so keychain.StorageDir resolves under the temporary directory. On Windows, refreshLockDir uses os.UserCacheDir, so the lock can remain in the user's cache. Register cleanup for the lock path, and use internal/vfs instead of os.MkdirAll, os.Stat, os.ReadFile, and os.WriteFile.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/auth/uat_client_process_test.go` around lines 91 - 95, Update the
process-test setup around refreshLockPath and its lock usage to register test
cleanup that removes the lock path and any test-created filesystem state.
Replace direct os.MkdirAll, os.Stat, os.ReadFile, and os.WriteFile calls in this
test flow with the corresponding internal/vfs operations, preserving the
existing test behavior across Darwin, Linux, and Windows.

Source: Learnings

Comment thread internal/auth/uat_client_process_test.go Outdated
Comment on lines +220 to +223
problem, ok := errs.ProblemOf(err)
if !ok || problem.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("AC3: problem = %#v, want network_transport", problem)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert the error category and cause preservation.

The test checks only problem.Subtype. The coding guidelines require error-path tests to assert typed metadata including category, and to verify cause preservation. wrapRefreshTransportError attaches the transport error with WithCause, so the test can assert that the original connection reset error is still reachable.

💚 Proposed test change
+	transportErr := errors.New("connection reset")
 	problem, ok := errs.ProblemOf(err)
 	if !ok || problem.Subtype != errs.SubtypeNetworkTransport {
 		t.Fatalf("AC3: problem = %#v, want network_transport", problem)
 	}
+	if problem.Category != errs.CategoryNetwork {
+		t.Fatalf("AC3: category = %q, want %q", problem.Category, errs.CategoryNetwork)
+	}
+	if !errors.Is(err, transportErr) {
+		t.Fatalf("AC3: cause was not preserved: %v", err)
+	}

Declare transportErr once at the top of the test and return it from the round tripper.

As per coding guidelines: "Error-path tests must assert typed metadata through errs.ProblemOf (category, subtype, and param) and verify cause preservation rather than relying only on message substrings."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/auth/uat_client_test.go` around lines 220 - 223, Update the
error-path test around errs.ProblemOf to assert the problem’s category, subtype,
and param metadata, and verify the original transport error remains reachable
through the preserved cause. Declare the connection-reset transport error once
at the test’s start and have the round tripper return that same error, then
assert cause preservation alongside the existing network subtype check.

Source: Coding guidelines

Comment thread internal/auth/uat_client.go
Comment thread internal/auth/uat_lock_path_windows.go Outdated
Comment on lines +13 to +22
func refreshLockDir() string {
baseDir, err := os.UserCacheDir()
if err != nil || baseDir == "" {
baseDir, err = os.UserHomeDir()
if err != nil || baseDir == "" {
baseDir = ".lark-cli"
}
}
return filepath.Join(baseDir, "lark-cli", "credential-locks")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check which user-directory accessors internal/vfs exposes.
rg -n 'UserHomeDir|UserCacheDir|TempDir' internal/vfs

Repository: larksuite/cli

Length of output: 2483


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- target file ---'
cat -n internal/auth/uat_lock_path_windows.go

printf '%s\n' '--- vfs API and implementations ---'
cat -n internal/vfs/fs.go
cat -n internal/vfs/default.go
cat -n internal/vfs/osfs.go

printf '%s\n' '--- lock helper and callers ---'
rg -n -C 3 'refreshLockDir|credential-locks|uat_lock_path|refresh.*lock|lock.*refresh' --glob '*.go' .

printf '%s\n' '--- filesystem accessor conventions ---'
rg -n -C 2 'os\.(UserHomeDir|UserCacheDir|TempDir)|vfs\.(UserHomeDir|UserCacheDir|TempDir)' --glob '*.go' .

printf '%s\n' '--- relevant diff summary ---'
git diff --stat -- internal/auth/uat_lock_path_windows.go internal/vfs

Repository: larksuite/cli

Length of output: 21063


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- target file ---'
cat -n internal/auth/uat_lock_path_windows.go

printf '%s\n' '--- vfs API and implementations ---'
cat -n internal/vfs/fs.go
cat -n internal/vfs/default.go
cat -n internal/vfs/osfs.go

printf '%s\n' '--- lock helper and callers ---'
rg -n -C 3 'refreshLockDir|credential-locks|uat_lock_path|refresh.*lock|lock.*refresh' --glob '*.go' .

printf '%s\n' '--- filesystem accessor conventions ---'
rg -n -C 2 'os\.(UserHomeDir|UserCacheDir|TempDir)|vfs\.(UserHomeDir|UserCacheDir|UserTempDir)' --glob '*.go' .

printf '%s\n' '--- relevant diff summary ---'
git diff --stat -- internal/auth/uat_lock_path_windows.go internal/vfs

Repository: larksuite/cli

Length of output: 21063


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- FS implementations and test doubles ---'
rg -n -C 3 'type [A-Za-z0-9_]+ struct|type [A-Za-z0-9_]+ FS|DefaultFS\s*=|implements.*FS' internal/vfs internal --glob '*.go' \
  | rg -n 'FS|DefaultFS|type .*struct|type .*FS' || true

printf '%s\n' '--- direct vfs.DefaultFS assignments ---'
rg -n -C 2 'vfs\.DefaultFS\s*=' --glob '*.go' .

printf '%s\n' '--- direct FS assignments and constructors ---'
rg -n -C 2 '(DefaultFS\s*=|FS\s*\{|FS:|New.*FS|mock.*FS|fake.*FS)' --glob '*.go' internal

printf '%s\n' '--- standalone Windows path probe ---'
node - <<'JS'
const path = require('path');

const suffix = ['lark-cli', 'credential-locks'];
const relative = path.win32.join('.lark-cli', ...suffix);
const fromOne = path.win32.resolve('C:\\work\\one', relative);
const fromTwo = path.win32.resolve('C:\\work\\two', relative);

console.log(JSON.stringify({
  relative,
  fromOne,
  fromTwo,
  samePath: fromOne === fromTwo,
  isAbsoluteRelative: path.win32.isAbsolute(relative),
}));
JS

Repository: larksuite/cli

Length of output: 41693


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- FS implementations and test doubles ---'
rg -n -C 3 'type [A-Za-z0-9_]+ struct|type [A-Za-z0-9_]+ FS|DefaultFS\s*=|implements.*FS' internal/vfs internal --glob '*.go' \
  | rg -n 'FS|DefaultFS|type .*struct|type .*FS' || true

printf '%s\n' '--- direct vfs.DefaultFS assignments ---'
rg -n -C 2 'vfs\.DefaultFS\s*=' --glob '*.go' .

printf '%s\n' '--- direct FS assignments and constructors ---'
rg -n -C 2 '(DefaultFS\s*=|FS\s*\{|FS:|New.*FS|mock.*FS|fake.*FS)' --glob '*.go' internal

printf '%s\n' '--- standalone Windows path probe ---'
node - <<'JS'
const path = require('path');

const relative = path.win32.join('.lark-cli', 'lark-cli', 'credential-locks');
const fromOne = path.win32.resolve('C:\\work\\one', relative);
const fromTwo = path.win32.resolve('C:\\work\\two', relative);

console.log(JSON.stringify({
  relative,
  fromOne,
  fromTwo,
  samePath: fromOne === fromTwo,
  isAbsoluteRelative: path.win32.isAbsolute(relative),
}));
JS

Repository: larksuite/cli

Length of output: 41686


Use internal/vfs for all directory lookups and keep the fallback absolute.

internal/vfs exposes UserHomeDir() but not UserCacheDir() or TempDir(). Add the required accessors and use them here. If both cache and home lookups fail, use an absolute temporary-directory fallback. The current ".lark-cli" fallback creates different lock files for processes started from different working directories.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/auth/uat_lock_path_windows.go` around lines 13 - 22, Update
refreshLockDir to obtain cache, home, and temporary directories exclusively
through internal/vfs, adding the missing UserCacheDir and TempDir accessors
there. Preserve the cache-then-home lookup order, and when both fail use an
absolute temporary-directory fallback before appending the lark-cli
credential-locks path; remove the relative ".lark-cli" fallback.

Source: Coding guidelines

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/auth/uat_client_test.go (2)

124-161: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Give winner a distinct refresh token so the assertion detects a real replacement.

winner at Line 127 reuses "old-refresh", the same refresh-token string as attempted at Line 126. The check at Lines 157-160 only fails if stored.RefreshToken differs from winner.RefreshToken. Since both tokens share the same string, this check would still pass even if store.replace(winner) never actually applied and the original attempted token remained stored. Only the access-token check at Lines 154-156 currently proves the winner's data reached the caller; the refresh-token invariant is not verified.

Use a distinct value for the winner's refresh token, for example "winner-refresh", so Line 158 genuinely verifies that the concurrently-stored winner (not the original attempted token) survived the reuse-detection path.

🐛 Proposed fix
-	winner := refreshTestToken("winner-access", "old-refresh", now.Add(time.Hour), now.Add(7*24*time.Hour))
+	winner := refreshTestToken("winner-access", "winner-refresh", now.Add(time.Hour), now.Add(7*24*time.Hour))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/auth/uat_client_test.go` around lines 124 - 161, Update the winner
fixture in TestRefreshReusedReturnsWinnerStoredByConcurrentProcess to use a
refresh-token value distinct from attempted, such as “winner-refresh”, so the
stored.RefreshToken assertion verifies that store.replace(winner) actually
persisted the concurrent winner.

189-192: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert Category (and cause, where applicable) alongside Subtype in these error-path tests. Each of these tests validates a typed error only through problem.Subtype from errs.ProblemOf. The coding guideline for *_test.go files requires asserting typed metadata including category, and verifying cause preservation, rather than relying on a single field.

  • internal/auth/uat_client_test.go#L189-L192: In TestRefreshRevokedPreservesUnexpiredStoredState, add a problem.Category check next to the existing Subtype check for errs.SubtypeRefreshTokenRevoked.
  • internal/auth/uat_client_test.go#L248-L251: In TestLocallyExpiredRefreshTokenIsPreserved, add a problem.Category check next to the existing Subtype check for errs.SubtypeRefreshTokenExpired.
  • internal/auth/uat_client_test.go#L272-L275: In TestCredentialStoreReadFailureIsNotReportedAsMissing, add a problem.Category check next to the existing Subtype check for errs.SubtypeStorage, and verify the injected loadErr cause remains reachable via errors.Is/errors.As since it is returned unwrapped.
  • internal/auth/uat_client_test.go#L299-L302: In TestRefreshPreflightPreventsRotationWhenStorageIsUnwritable, add a problem.Category check next to the existing Subtype check for errs.SubtypeStorage, and verify the underlying "credential store unavailable" cause is preserved.

As per coding guidelines: "Error-path tests must assert typed metadata through errs.ProblemOf (category, subtype, and param) and verify cause preservation rather than relying only on message substrings."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/auth/uat_client_test.go` around lines 189 - 192, Update
internal/auth/uat_client_test.go at lines 189-192, 248-251, 272-275, and
299-302: in TestRefreshRevokedPreservesUnexpiredStoredState and
TestLocallyExpiredRefreshTokenIsPreserved, assert the expected problem.Category
alongside Subtype; in TestCredentialStoreReadFailureIsNotReportedAsMissing, also
verify loadErr remains reachable via errors.Is/errors.As; in
TestRefreshPreflightPreventsRotationWhenStorageIsUnwritable, verify the
underlying credential-store-unavailable cause is preserved. Keep each test’s
existing errs.ProblemOf validation and assert the typed error metadata required
by the guideline.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/auth/uat_client_test.go`:
- Around line 342-422: Isolate the credential lock storage from the host
filesystem in TestLoginWaitsForInFlightRefreshAndWins and
TestLogoutWaitsForInFlightRefreshAndWins by setting LARKSUITE_CLI_DATA_DIR to a
unique t.TempDir() before invoking SetStoredToken or RemoveStoredToken. Ensure
the environment override is cleaned up with the test lifecycle, while preserving
the existing refresh-lock assertions.

---

Outside diff comments:
In `@internal/auth/uat_client_test.go`:
- Around line 124-161: Update the winner fixture in
TestRefreshReusedReturnsWinnerStoredByConcurrentProcess to use a refresh-token
value distinct from attempted, such as “winner-refresh”, so the
stored.RefreshToken assertion verifies that store.replace(winner) actually
persisted the concurrent winner.
- Around line 189-192: Update internal/auth/uat_client_test.go at lines 189-192,
248-251, 272-275, and 299-302: in
TestRefreshRevokedPreservesUnexpiredStoredState and
TestLocallyExpiredRefreshTokenIsPreserved, assert the expected problem.Category
alongside Subtype; in TestCredentialStoreReadFailureIsNotReportedAsMissing, also
verify loadErr remains reachable via errors.Is/errors.As; in
TestRefreshPreflightPreventsRotationWhenStorageIsUnwritable, verify the
underlying credential-store-unavailable cause is preserved. Keep each test’s
existing errs.ProblemOf validation and assert the typed error metadata required
by the guideline.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a5b02be6-b8de-4e68-ae1b-d78041378ee2

📥 Commits

Reviewing files that changed from the base of the PR and between b58df59 and 2454398.

📒 Files selected for processing (6)
  • internal/auth/credential_lock_windows.go
  • internal/auth/token_store.go
  • internal/auth/uat_client.go
  • internal/auth/uat_client_process_test.go
  • internal/auth/uat_client_test.go
  • internal/auth/uat_lock_path_filesystem.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/auth/uat_client_process_test.go
  • internal/auth/uat_client.go

Comment on lines +342 to +422
func TestLoginWaitsForInFlightRefreshAndWins(t *testing.T) {
now := time.Now()
attempted := refreshTestToken("expired-access", "old-refresh", now.Add(-time.Minute), now.Add(24*time.Hour))
store := installUATStoreStub(t, attempted)
requestStarted := make(chan struct{})
releaseRefresh := make(chan struct{})
registry := successfulRefreshRegistry("rotated-access", "rotated-refresh", func(*http.Request) {
close(requestStarted)
<-releaseRefresh
})
t.Cleanup(func() { registry.Verify(t) })
refreshDone := make(chan error, 1)
go func() {
_, err := GetValidAccessToken(httpmock.NewClient(registry), UATCallOptions{
UserOpenId: "ou_user", AppId: "cli_test", AppSecret: "secret", Domain: core.BrandFeishu, ErrOut: &bytes.Buffer{},
})
refreshDone <- err
}()
<-requestStarted

login := refreshTestToken("login-access", "login-refresh", now.Add(time.Hour), now.Add(7*24*time.Hour))
login.GrantedAt = now.UnixMilli()
loginDone := make(chan error, 1)
go func() { loginDone <- SetStoredToken(login) }()
select {
case err := <-loginDone:
t.Fatalf("AC2: login bypassed in-flight refresh lock: %v", err)
case <-time.After(50 * time.Millisecond):
}
close(releaseRefresh)
if err := <-refreshDone; err != nil {
t.Fatalf("AC2: refresh failed: %v", err)
}
if err := <-loginDone; err != nil {
t.Fatalf("AC2: login persistence failed: %v", err)
}
stored, _ := store.snapshot()
if stored == nil || stored.RefreshToken != login.RefreshToken || stored.GrantedAt != login.GrantedAt {
t.Fatalf("AC2: stale refresh overwrote newer login: %#v", stored)
}
}

func TestLogoutWaitsForInFlightRefreshAndWins(t *testing.T) {
now := time.Now()
attempted := refreshTestToken("expired-access", "old-refresh", now.Add(-time.Minute), now.Add(24*time.Hour))
store := installUATStoreStub(t, attempted)
requestStarted := make(chan struct{})
releaseRefresh := make(chan struct{})
registry := successfulRefreshRegistry("rotated-access", "rotated-refresh", func(*http.Request) {
close(requestStarted)
<-releaseRefresh
})
t.Cleanup(func() { registry.Verify(t) })
refreshDone := make(chan error, 1)
go func() {
_, err := GetValidAccessToken(httpmock.NewClient(registry), UATCallOptions{
UserOpenId: "ou_user", AppId: "cli_test", AppSecret: "secret", Domain: core.BrandFeishu, ErrOut: &bytes.Buffer{},
})
refreshDone <- err
}()
<-requestStarted

logoutDone := make(chan error, 1)
go func() { logoutDone <- RemoveStoredToken("cli_test", "ou_user") }()
select {
case err := <-logoutDone:
t.Fatalf("AC2: logout bypassed in-flight refresh lock: %v", err)
case <-time.After(50 * time.Millisecond):
}
close(releaseRefresh)
if err := <-refreshDone; err != nil {
t.Fatalf("AC2: refresh failed: %v", err)
}
if err := <-logoutDone; err != nil {
t.Fatalf("AC2: logout failed: %v", err)
}
stored, _ := store.snapshot()
if stored != nil {
t.Fatalf("AC2: refresh recreated token after logout: %#v", stored)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether uat_client_test.go isolates LARKSUITE_CLI_CONFIG_DIR,
# and whether the credential lock path depends on it.
set -euo pipefail

echo "== t.Setenv / LARKSUITE_CLI_CONFIG_DIR usage in the test file =="
rg -n 'LARKSUITE_CLI_CONFIG_DIR|t\.Setenv|TestMain' internal/auth/uat_client_test.go

echo "== Lock path resolution source =="
fd -a 'uat_lock_path_filesystem.go|credential_lock_windows.go' internal/auth | while IFS= read -r f; do
  echo "--- $f ---"
  rg -n 'LARKSUITE_CLI_CONFIG_DIR|ConfigDir|appdir\.' "$f"
done

echo "== withCredentialLock definition =="
ast-grep run --pattern 'func withCredentialLock($$$) $$$ { $$$ }' --lang go internal/auth

Repository: larksuite/cli

Length of output: 216


🏁 Script executed:

#!/bin/bash
# Description: Inspect internal/auth test setup and credential lock path definitions across the package.
set -euo pipefail

echo "== internal/auth Go test files =="
git ls-files 'internal/auth/*_test.go'

echo "== uat_client_test.go outline and relevant setenv/lck lines =="
wc -l internal/auth/uat_client_test.go || true
rg -n 'LARKSUITE_CLI_CONFIG_DIR|t\.Setenv|TestMain|withCredentialLock|SetStoredToken|RemoveStoredToken|GetValidAccessToken|installUATStoreStub' internal/auth/uat_client_test.go || true

echo "== token_store lock-related code =="
rg -n 'func (withCredentialLock|loadStoredUAToken|persistStoredUAToken|removeStoredUAToken|SetStoredToken|RemoveStoredToken|GetValidAccessToken)|credential|appdir|ConfigDir|LARKSUITE_CLI_CONFIG_DIR' internal/auth -g '*.go' | head -n 300

echo "== candidate credential lock files =="
git ls-files internal/auth | rg '(lock|credential|uat|token|appdir)' || true

echo "== read credential lock candidates in chunks =="
for f in $(git ls-files internal/auth | rg '(lock|credential|uat|token|appdir)'); do
  echo "--- $f ($(wc -l < "$f" | tr -d ' ') lines) ---"
  sed -n '1,220p' "$f"
done

Repository: larksuite/cli

Length of output: 30891


🏁 Script executed:

#!/bin/bash
# Description: Inspect internal/auth test setup and credential lock path definitions across the package.
set -euo pipefail

echo "== internal/auth Go test files =="
git ls-files 'internal/auth/*_test.go'

echo "== uat_client_test.go outline and relevant setenv/lck lines =="
wc -l internal/auth/uat_client_test.go || true
rg -n 'LARKSUITE_CLI_CONFIG_DIR|t\.Setenv|TestMain|withCredentialLock|SetStoredToken|RemoveStoredUAToken|GetValidAccessToken|installUATStoreStub' internal/auth/uat_client_test.go || true

echo "== token_store lock-related code =="
rg -n 'func (withCredentialLock|loadStoredUAToken|persistStoredUAToken|removeStoredUAToken|SetStoredToken|RemoveStoredToken|GetValidAccessToken)|credential|appdir|ConfigDir|LARKSUITE_CLI_CONFIG_DIR' internal/auth -g '*.go' | head -n 300

echo "== candidate credential lock files =="
git ls-files internal/auth | rg '(lock|credential|uat|token|appdir)' || true

echo "== read credential lock candidates in chunks =="
for f in $(git ls-files internal/auth | rg '(lock|credential|uat|token|appdir)'); do
  echo "--- $f ($(wc -l < "$f" | tr -d ' ') lines) ---"
  sed -n '1,220p' "$f"
done

Repository: larksuite/cli

Length of output: 30815


🏁 Script executed:

#!/bin/bash
# Description: Check whether an auth package TestMain sets LARKSUITE_CLI_CONFIG_DIR before these tests run.
set -euo pipefail

echo "== internal/auth TestMain definitions/usages =="
rg -n 'func TestMain|LARKSUITE_CLI_CONFIG_DIR' internal/auth -g '*_test.go'

echo "== internal/auth/testmain_test.go =="
sed -n '1,160p' internal/auth/testmain_test.go || true

echo "== keychain StorageDir implementation =="
git ls-files | rg '(^|/)keychain/.*\.go$|keychain'
rg -n 'func StorageDir|LarkCliService|RundirDir|StorageDir' -g '*.go' .

Repository: larksuite/cli

Length of output: 5139


🏁 Script executed:

#!/bin/bash
# Description: Inspect exact keychain StorageDir source for all builds to determine whether LARKSUITE_CLI_CONFIG_DIR is used.
set -euo pipefail

for f in internal/keychain/default.go internal/keychain/keychain_darwin.go internal/keychain/keychain_windows.go internal/keychain/keychain_other.go; do
  echo "--- $f ($(wc -l < "$f" | tr -d ' ') lines) ---"
  sed -n '/func StorageDir/,/^}/p' "$f"
done

echo "== Read-only model of Linux/default StorageDir behavior for absence/presence of LARKSUITE_CLI_CONFIG_DIR =="
python3 - <<'PY'
from pathlib import Path
text = Path('internal/keychain/keychain_other.go').read_text()
start = text.index('func StorageDir(service string) string')
end = text.index('\n}', start) + 2
print(text[start:end])
PY

Repository: larksuite/cli

Length of output: 1939


Isolate the credential lock path from the host storage location.

Linux builds route the credential lock through keychain.StorageDir(keychain.LarkCliService), which resolves to ~/.local/share/lark-cli/locks unless LARKSUITE_CLI_DATA_DIR is set. internal/auth/testmain_test.go only sets LARKSUITE_CLI_LOG_DIR, not LARKSUITE_CLI_DATA_DIR, so these tests still create/temp lock real host credential files. Set LARKSUITE_CLI_DATA_DIR to t.TempDir() for SetStoredToken/RemoveStoredToken tests, or stub the storage directory under test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/auth/uat_client_test.go` around lines 342 - 422, Isolate the
credential lock storage from the host filesystem in
TestLoginWaitsForInFlightRefreshAndWins and
TestLogoutWaitsForInFlightRefreshAndWins by setting LARKSUITE_CLI_DATA_DIR to a
unique t.TempDir() before invoking SetStoredToken or RemoveStoredToken. Ensure
the environment override is cleaned up with the test lifecycle, while preserving
the existing refresh-lock assertions.

Source: Path instructions

@github-actions github-actions Bot added size/XL Architecture-level or global-impact change and removed size/L Large or sensitive change across domains or core paths labels Aug 3, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/auth/revoke.go`:
- Around line 86-104: Update RevokeAndRemoveStoredToken to capture the error
returned by RevokeToken instead of discarding it, while still always attempting
removeStoredUAToken. Return the revoke error when local removal succeeds; if
removal fails, preserve and return the removal error as the required
local-cleanup outcome.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 17096654-8edc-4871-bbb8-ee8a1d3dbf1c

📥 Commits

Reviewing files that changed from the base of the PR and between 2454398 and 2a53026.

📒 Files selected for processing (8)
  • cmd/auth/logout.go
  • internal/auth/credential_lock_windows.go
  • internal/auth/revoke.go
  • internal/auth/uat_client.go
  • internal/auth/uat_client_process_test.go
  • internal/auth/uat_client_test.go
  • internal/identitydiag/diagnostics.go
  • internal/identitydiag/diagnostics_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • internal/auth/credential_lock_windows.go
  • internal/auth/uat_client.go
  • internal/auth/uat_client_test.go

Comment thread internal/auth/revoke.go
Comment on lines +86 to +104
// RevokeAndRemoveStoredToken serializes remote revocation with local credential removal.
func RevokeAndRemoveStoredToken(httpClient *http.Client, appID, appSecret string, brand core.LarkBrand, userOpenID string) error {
return withCredentialLock(appID, userOpenID, func() error {
stored, err := loadStoredUAToken(appID, userOpenID)
if err == nil && stored != nil {
revokeToken := stored.RefreshToken
tokenTypeHint := "refresh_token"
if revokeToken == "" {
revokeToken = stored.AccessToken
tokenTypeHint = "access_token"
}
if revokeToken != "" {
_ = RevokeToken(httpClient, appID, appSecret, brand, revokeToken, tokenTypeHint)
}
}
return removeStoredUAToken(appID, userOpenID)
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Surface the discarded RevokeToken error instead of dropping it.

Line 98 discards the result of RevokeToken with _ =. If remote revocation fails (network error, server rejects the token, revoked already), RevokeAndRemoveStoredToken still returns nil as long as removeStoredUAToken succeeds. The caller in cmd/auth/logout.go only prints its warning when this function returns a non-nil error, so a failed remote revocation produces no diagnostic output. The user believes the token is revoked, but the token can remain valid on the server with no local trace of the failure.

Preserve local removal as the required outcome, but do not discard the revoke error. Return it when removal succeeds so the existing warning path in cmd/auth/logout.go reports it.

🛠️ Proposed fix to surface the revoke error
 func RevokeAndRemoveStoredToken(httpClient *http.Client, appID, appSecret string, brand core.LarkBrand, userOpenID string) error {
 	return withCredentialLock(appID, userOpenID, func() error {
+		var revokeErr error
 		stored, err := loadStoredUAToken(appID, userOpenID)
 		if err == nil && stored != nil {
 			revokeToken := stored.RefreshToken
 			tokenTypeHint := "refresh_token"
 			if revokeToken == "" {
 				revokeToken = stored.AccessToken
 				tokenTypeHint = "access_token"
 			}
 			if revokeToken != "" {
-				_ = RevokeToken(httpClient, appID, appSecret, brand, revokeToken, tokenTypeHint)
+				revokeErr = RevokeToken(httpClient, appID, appSecret, brand, revokeToken, tokenTypeHint)
 			}
 		}
-		return removeStoredUAToken(appID, userOpenID)
+		if removeErr := removeStoredUAToken(appID, userOpenID); removeErr != nil {
+			return removeErr
+		}
+		return revokeErr
 	})
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// RevokeAndRemoveStoredToken serializes remote revocation with local credential removal.
func RevokeAndRemoveStoredToken(httpClient *http.Client, appID, appSecret string, brand core.LarkBrand, userOpenID string) error {
return withCredentialLock(appID, userOpenID, func() error {
stored, err := loadStoredUAToken(appID, userOpenID)
if err == nil && stored != nil {
revokeToken := stored.RefreshToken
tokenTypeHint := "refresh_token"
if revokeToken == "" {
revokeToken = stored.AccessToken
tokenTypeHint = "access_token"
}
if revokeToken != "" {
_ = RevokeToken(httpClient, appID, appSecret, brand, revokeToken, tokenTypeHint)
}
}
return removeStoredUAToken(appID, userOpenID)
})
}
// RevokeAndRemoveStoredToken serializes remote revocation with local credential removal.
func RevokeAndRemoveStoredToken(httpClient *http.Client, appID, appSecret string, brand core.LarkBrand, userOpenID string) error {
return withCredentialLock(appID, userOpenID, func() error {
var revokeErr error
stored, err := loadStoredUAToken(appID, userOpenID)
if err == nil && stored != nil {
revokeToken := stored.RefreshToken
tokenTypeHint := "refresh_token"
if revokeToken == "" {
revokeToken = stored.AccessToken
tokenTypeHint = "access_token"
}
if revokeToken != "" {
revokeErr = RevokeToken(httpClient, appID, appSecret, brand, revokeToken, tokenTypeHint)
}
}
if removeErr := removeStoredUAToken(appID, userOpenID); removeErr != nil {
return removeErr
}
return revokeErr
})
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/auth/revoke.go` around lines 86 - 104, Update
RevokeAndRemoveStoredToken to capture the error returned by RevokeToken instead
of discarding it, while still always attempting removeStoredUAToken. Return the
revoke error when local removal succeeds; if removal fails, preserve and return
the removal error as the required local-cleanup outcome.

@buxibuxi

buxibuxi commented Aug 3, 2026

Copy link
Copy Markdown
Author

Closing this upstream contribution because the requested remediation is being kept local; no contributor agreement action is needed.

@buxibuxi buxibuxi closed this Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Architecture-level or global-impact change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refresh HTTP 400 can clear user auth despite valid refreshExpiresAt

2 participants