Skip to content

fix: token deduplication and stale token accumulation#25

Open
JoshSalway wants to merge 5 commits intolaravel:mainfrom
JoshSalway:fix/token-management-issue-23
Open

fix: token deduplication and stale token accumulation#25
JoshSalway wants to merge 5 commits intolaravel:mainfrom
JoshSalway:fix/token-management-issue-23

Conversation

@JoshSalway
Copy link

@JoshSalway JoshSalway commented Mar 16, 2026

Summary

Fixes two related token management bugs that share a root cause: tokens accumulating in config.json without deduplication or cleanup.

Closes #22
Closes #23


Issue #22: Duplicate organizations in selection prompt

Root Cause

ConfigRepository::addApiToken() used ->push($token) without deduplication. Running cloud auth multiple times appended the same token repeatedly:

// BEFORE (bug): just appends, no dedup
public function addApiToken(string $token): void
{
    $this->config["api_tokens"] = $this->apiTokens()->push($token);
    $this->save();
}

Then in HasAClient::resolveApiToken(), each duplicate token triggers a separate API call to $client->meta()->organization(). With 5 duplicate tokens, the same org appears 5 times in the picker.

Proof

Simulating 5 auth sessions with the same token against the original code:

// BEFORE fix: 5 duplicates → org appears 5 times in picker
{ "api_tokens": ["token-abc", "token-abc", "token-abc", "token-abc", "token-abc"] }

// AFTER fix: deduplicated → org appears once
{ "api_tokens": ["token-abc"] }

Fix

  • apiTokens() returns ->unique()->values() — deduplicates on read (fixes existing bloated configs)
  • addApiToken() chains ->unique()->values() after push() — prevents future duplicates on write

Issue #23: command:run returns {"message":"Required."} despite valid auth

Root Cause

Two interrelated bugs:

1. Token accumulation (Auth.php) — Each cloud auth session appended new tokens without removing old ones:

// BEFORE (bug): appends every time, old expired tokens stay forever
foreach ($tokens as $tokenData) {
    $this->config->addApiToken($tokenData["token"]);
}

After 3 auth sessions: ["token-A-expired", "token-B-expired", "token-C-valid"]

2. Unhandled exception on expired tokens (HasAClient.php) — With multiple tokens, resolveApiToken() iterates all of them calling $client->meta()->organization(). The Connector uses Saloon's AlwaysThrowOnErrors trait, so any expired token throws a RequestExceptionbut this was never caught:

// BEFORE (bug): no try/catch, crashes on first expired token
$orgs = $apiTokens->mapWithKeys(function ($token) {
    $client = new Connector($token);
    return [$token => $client->meta()->organization()]; // THROWS if expired!
});

The unhandled exception bubbles up and the user sees {"message":"Required."}.

Proof

Simulating 3 auth sessions against the original code:

After 1st auth: ["token-A-valid"]
After 2nd auth: ["token-A-valid", "token-B-valid"]       ← token A now expired
After 3rd auth: ["token-A-valid", "token-B-valid", "token-C-valid"]  ← A, B expired

BUG: 3 tokens in config → hasSole() false → iterates all → hits expired token-A → unhandled RequestException → crash.

After the fix:

After 1st auth: ["token-A-valid"]
After 2nd auth: ["token-B-valid"]     ← replaced, not appended
After 3rd auth: ["token-C-valid"]     ← replaced, not appended

FIXED: Config always has only the latest tokens. Even if stale tokens somehow remain, the try/catch skips them gracefully.

Fix

  • Auth.php uses new setApiTokens() to atomically replace all tokens on re-auth
  • HasAClient.php wraps each token's API call in try/catch RequestException — expired tokens are skipped and cleaned up
  • ConfigRepository.php adds setApiTokens(Collection $tokens) for bulk replacement

Regression tests

6 new tests in tests/Unit/ConfigRepositoryTest.php that fail on main and pass on this branch:

Test main (unfixed) This branch
addApiToken does not accumulate duplicates FAIL (expected 1, got 5) PASS
apiTokens deduplicates existing config on read FAIL (expected 2, got 5) PASS
setApiTokens replaces all tokens atomically FAIL (method undefined) PASS
setApiTokens deduplicates input FAIL (method undefined) PASS
removeApiToken removes specific tokens FAIL (method undefined) PASS
Empty config returns empty collection PASS PASS

Full suite: 37 passed, 0 failed (45 assertions).

Test plan

  • Run cloud auth multiple times → verify config.json does not accumulate duplicate tokens
  • Run cloud environment:variables → verify each organization appears only once in the picker
  • Manually add duplicate tokens to config.json → verify they are deduplicated on read
  • Run cloud auth twice → verify config.json only contains tokens from the latest session
  • Manually add an invalid/expired token alongside a valid one → run any command → verify it skips the bad token
  • With only invalid tokens in config → verify the CLI warns instead of crashing
  • ./vendor/bin/pest → 37 passed, 0 failed

🤖 Generated with Claude Code

JoshSalway and others added 4 commits March 16, 2026 21:56
When authenticating multiple times, the same API tokens were appended
to config.json without deduplication. Each duplicate token triggered a
separate API call returning the same organization, causing the org
selection prompt to show duplicate entries.

Add unique() to both apiTokens() (to handle existing duplicated configs)
and addApiToken() (to prevent future duplicates).

Fixes laravel#22

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When running `cloud auth` multiple times, old tokens accumulated in
config.json because new tokens were appended without removing previous
ones. Expired/revoked tokens were never cleaned up, causing
`{"message":"Required."}` errors when the CLI tried to use them.

Three fixes:
- Auth command now replaces all tokens with fresh ones from the auth
  session instead of appending, preventing stale token buildup
- Token resolution now validates tokens via API before use and
  automatically removes expired/invalid ones from config
- Added ConfigRepository::setApiTokens() for bulk token replacement

Closes laravel#23

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The isValidToken() check made an unmocked API call to /api/meta/organization
on every command, causing 5 test failures. For a single token, just use it
directly — if expired, the actual command will fail with a clear auth error.
Token validation is only needed when choosing among multiple tokens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Proves both Issue laravel#22 and laravel#23 fixes with 6 targeted tests:
- addApiToken() no longer accumulates duplicates (was 5, now 1)
- apiTokens() deduplicates existing bloated configs on read
- setApiTokens() atomically replaces all tokens
- setApiTokens() deduplicates input
- removeApiToken() removes specific tokens
- Empty config returns empty collection

All 6 tests FAIL on main branch (confirming the bugs exist) and
PASS on this branch (confirming the fixes work).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Ensures setApiTokens correctly handles users with multiple orgs:
- Preserves multiple tokens (one per org)
- Atomically replaces all org tokens on re-auth
- Handles org count changing between auth sessions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@JoshSalway JoshSalway changed the title Fix expired token accumulation causing auth failures fix: token deduplication and stale token accumulation Mar 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: command:run returns {\"message\":\"Required.\"} despite valid auth token Bug: environment:variables shows duplicate organizations

1 participant