Skip to content

feat: own Claude OAuth sign-in — no Keychain prompts, multi-account#20

Merged
Balanced02 merged 1 commit into
mainfrom
feat/claude-oauth
Jul 16, 2026
Merged

feat: own Claude OAuth sign-in — no Keychain prompts, multi-account#20
Balanced02 merged 1 commit into
mainfrom
feat/claude-oauth

Conversation

@Balanced02

@Balanced02 Balanced02 commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Why

The app read the token Claude Code stored in the Keychain. Claude Code delete+re-adds that item on every token refresh, which wipes our app off the item's access list — so macOS re-prompted at random. Unfixable from the reader side.

What

The app now runs its own OAuth (PKCE loopback, Claude Code's OAuth client), mints its own token, and stores it in its own Keychain item that it refreshes in place. Because the app created the item, a signed build reads it back with zero prompts, and it persists across restarts (sign in once). Add account in Settings → Claude (or the Claude-tab banner); add more than one (Personal + Work), each with live 5H/7D limits. The opt-in Connect toggle is removed.

Implementation

  • New Core: ClaudeOAuth (PKCE S256, authorize/exchange/refresh), ClaudeOAuthLoopback (one-shot localhost server capturing ?code&state), ClaudeTokenStore (our Keychain items, SecItemUpdate in place), ClaudeTokenProvider (actor: serializes refresh, runs sign-in).
  • ClaudeReader maps our tokens to config-dir profiles by identity; a signed-in account with no local profile gets a live-only card.
  • AppModel: addClaudeAccount()/removeClaudeAccount(); deleted ClaudeKeychain (Claude Code reader).

Non-obvious gotchas (documented in CLAUDE.md)

  • UA split: the usage endpoint requires User-Agent: claude-code/<ver>; the token endpoint's edge hard-429s claude-code/* and curl/* and only accepts the CLI's real transport UA (axios/*).
  • Keychain: kSecReturnData + kSecMatchLimitAll in one query is errSecParam — list attributes, then read each item.
  • Refresh tokens rotate; invalid_grant on refresh is terminal.

Review + tests

  • Adversarial multi-agent review (loopback concurrency, keychain, OAuth protocol, refresh/reader, security). 8 confirmed findings, all fixed: bounded loopback recv timeout, require+match state (CSRF), per-account refresh coalescing, org-UUID last-resort in identity matching, single-profile fallback count guard, delete stale-keyed item on identity firm-up.
  • 26 Core tests (PKCE, token parsing, loopback request parsing, identity matching incl. same-org regression). Verified live end-to-end on a real account — no Keychain prompt at any point.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added browser-based Claude sign-in with automatic token refresh.
    • Added support for multiple Claude accounts, each with separate live usage limits.
    • Added account management in Claude settings, including account removal.
    • Claude usage can fall back to local data before sign-in or when live services are unavailable.
  • Documentation

    • Updated setup and usage guidance to reflect the new “Add account” sign-in flow and dedicated credential storage.

Replace reading Claude Code's Keychain token (whose ACL is wiped on its every
refresh → recurring macOS prompt) with our OWN OAuth: PKCE loopback sign-in mints a
token we store in our own Keychain item and refresh in place, so a signed build reads
it back with zero prompts and it persists across restarts. Add multiple accounts;
each shows live 5H/7D limits. Removes the opt-in Connect toggle.

New Core (ClaudeOAuth, ClaudeOAuthLoopback, ClaudeTokenStore, ClaudeTokenProvider);
ClaudeReader now maps our tokens to profiles by identity and emits live-only cards for
accounts with no local profile. Uses Claude Code's OAuth client; token endpoint needs
the CLI's axios UA (it 429s claude-code/*), the usage endpoint needs claude-code/<ver>.

Hardening from adversarial review: bounded loopback recv timeout, require+match OAuth
state (CSRF), coalesce concurrent refreshes per account, org-UUID last-resort in
identity matching, single-profile fallback requires exactly one token, delete
stale-keyed item on identity firm-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Claude live-limit authentication now uses app-managed OAuth tokens stored per account in Keychain. The reader supports multiple token-backed accounts and local-profile matching, while the UI replaces the single Connect toggle with account sign-in and removal flows.

Changes

Claude OAuth account redesign

Layer / File(s) Summary
OAuth and loopback flow
Sources/AIUsageBarCore/Claude/ClaudeOAuth.swift, Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift, Tests/AIUsageBarCoreTests/ClaudeOAuthTests.swift
PKCE authorization, localhost callback handling, token exchange and refresh, token parsing, expiry handling, and OAuth identity tests are added.
Token persistence and refresh
Sources/AIUsageBarCore/Claude/ClaudeTokenStore.swift, Sources/AIUsageBarCore/Claude/ClaudeTokenProvider.swift
Tokens are stored per account in Keychain, updated in place, enumerated, removed, cached, and refreshed with concurrent refresh coalescing.
Token-based usage reading
Sources/AIUsageBarCore/Claude/ClaudeProfile.swift, Sources/AIUsageBarCore/Claude/ClaudeReader.swift
ClaudeReader accepts tokens, matches them to profiles, creates live-only account cards, and maps live API failures to fallback messages.
Account management UI and configuration
Sources/AIUsageBarUI/AppModel.swift, Sources/AIUsageBarUI/MenuContentView.swift, Sources/AIUsageBarUI/SettingsView.swift, Tests/AIUsageBarUITests/ProviderSettingsTests.swift, README.md, CLAUDE.md, CHANGELOG.md
The single Claude connection toggle is replaced with multi-account sign-in, progress, error, and removal state; documentation and release notes describe the new OAuth flow and storage behavior.

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

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant AppModel
  participant ClaudeTokenProvider
  participant Browser
  participant ClaudeOAuthLoopback
  participant ClaudeOAuth
  participant ClaudeTokenStore
  participant ClaudeReader

  User->>AppModel: Add account
  AppModel->>ClaudeTokenProvider: signIn(openURL:)
  ClaudeTokenProvider->>ClaudeOAuth: Create PKCE authorization URL
  ClaudeTokenProvider->>Browser: Open authorization URL
  Browser->>ClaudeOAuthLoopback: Send OAuth callback
  ClaudeOAuthLoopback->>ClaudeTokenProvider: Return code and state
  ClaudeTokenProvider->>ClaudeOAuth: Exchange code for token
  ClaudeOAuth-->>ClaudeTokenProvider: Return ClaudeOAuthToken
  ClaudeTokenProvider->>ClaudeTokenStore: Save account token
  AppModel->>ClaudeReader: Refresh usage
  ClaudeReader->>ClaudeTokenProvider: Request valid tokens
  ClaudeTokenProvider->>ClaudeTokenStore: Load or refresh stored tokens
  ClaudeReader-->>AppModel: Return account usage cards
Loading

Possibly related PRs

  • Balanced02/ai-usage-bar#15: Earlier Claude connection gating in the same AppModel and settings flows, replaced here by OAuth-based multi-account management.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 56.67% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: app-owned Claude OAuth sign-in with no Keychain prompts and multi-account support.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/claude-oauth

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.

@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: 13

🧹 Nitpick comments (1)
Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift (1)

140-171: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Move the success-page presentation out of Core.

Inject the response body or renderer from the UI layer so this listener only handles callback transport and parsing.

As per coding guidelines, AIUsageBarCore must remain pure logic with no UI code.

🤖 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 `@Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift` around lines 140 -
171, Remove the hardcoded successHTML presentation from ClaudeOAuthLoopback and
make respond(_ client:ok:) obtain the success response body or renderer through
dependency injection from the UI layer. Keep ClaudeOAuthLoopback responsible
only for callback transport, status, headers, and parsing, while preserving the
existing success and failure response behavior.

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 `@CHANGELOG.md`:
- Line 14: Update the changelog reference definitions to add links for releases
0.2.0 and 0.1.6, and change the Unreleased comparison baseline from v0.1.5 to
v0.2.0 while preserving the existing link format.

In `@Sources/AIUsageBarCore/Claude/ClaudeOAuth.swift`:
- Around line 178-181: Update ClaudeOAuth.randomBytes to check the return status
from SecRandomCopyBytes and stop PKCE/sign-in processing unless it equals
errSecSuccess; propagate the failure through the caller rather than returning
the zero-filled buffer, adjusting the method’s error handling as needed.

In `@Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift`:
- Around line 116-120: Update readRequest(_:) to accumulate data across multiple
recv calls until the HTTP request line terminator \r\n is received or the
8192-byte buffer limit is reached, while preserving the empty-string result for
failed or closed reads. Return the accumulated request so the caller’s parse()
receives complete request-line data.
- Around line 59-67: Update waitForCallback to use withTaskCancellationHandler
so task cancellation resumes the pending continuation with the cancellation
error immediately. In the timeout closure, resume the continuation with the
timeout error before calling stop(), while preserving single-resumption behavior
when acceptLoop races with cancellation or timeout.

In `@Sources/AIUsageBarCore/Claude/ClaudeReader.swift`:
- Around line 47-87: Restrict Claude profile binding in ClaudeReader’s
identityMatches to precise account UUID or email matches, rejecting tokens when
both precise identifiers exist but conflict and removing organization-only
matching. Also update Tests/AIUsageBarCoreTests/ClaudeOAuthTests.swift lines
110-136 to verify organization-only identities are rejected and conflicting
singleton identities do not bind.

In `@Sources/AIUsageBarCore/Claude/ClaudeTokenProvider.swift`:
- Around line 62-66: Update the refresh error handling around refreshShared in
ClaudeTokenProvider so ClaudeOAuthError.invalidGrant is caught separately from
transient failures. Persist the required re-authentication state for
invalidGrant and avoid returning the unchanged token, while preserving the
existing stored-token fallback for other errors.
- Around line 89-96: Handle a false result from ClaudeTokenStore.save in the
refresh flow around the task.value result: check the persistence outcome before
deleting the old account entry or updating cache, and propagate an appropriate
failure when saving is unsuccessful. Apply the same handling to the initial
sign-in path around lines 103-105 so authentication is not reported as
successful unless the token is durably stored.
- Around line 108-111: The signOut(account:) flow must invalidate any in-flight
refresh before deleting credentials so a resumed refresh cannot restore the
account. Update refreshShared and signOut(account:) to track or cancel
per-account refresh generations, and require the refresh generation to remain
current before ClaudeTokenStore.save(fresh); preserve cache invalidation on
sign-out.

In `@Sources/AIUsageBarCore/Claude/ClaudeTokenStore.swift`:
- Around line 23-25: Require stable identity before persisting Claude tokens:
update ClaudeTokenStore.accountKey(for:) to remove the shared "default" fallback
and return an error when both account UUID and normalized email are absent. In
Sources/AIUsageBarCore/Claude/ClaudeTokenProvider.swift lines 38-44, ensure the
provider does not call store until identity resolution succeeds; preserve
association by email or UUID only.

In `@Sources/AIUsageBarUI/AppModel.swift`:
- Around line 421-423: Update the account-change flows around
reloadClaudeAccounts() and refresh() to invalidate UsageService’s cached Claude
usage data, or force the next Claude usage read, before calling refresh(). Apply
the same change to both the sign-in completion path and the corresponding
account-removal path.
- Line 142: Update the account initialization around AppModel’s claudeAccounts
assignment and the related account-loading path around lines 504-509 to honor
the AIUSAGEBAR_NO_KEYCHAIN environment flag. When the flag is enabled, skip
Self.loadClaudeAccounts() entirely and use the existing empty-account/default
value; otherwise preserve normal account loading behavior and avoid any
ClaudeTokenStore access.

In `@Sources/AIUsageBarUI/MenuContentView.swift`:
- Around line 23-24: Update the Claude empty-account menu flow around
ClaudeConnectBanner to pass model.claudeSignInError into the banner, then render
that error near the banner’s CTA. Ensure failed OAuth attempts visibly display
the stored sign-in error while preserving the existing busy and
account-selection behavior.

In `@Sources/AIUsageBarUI/SettingsView.swift`:
- Around line 43-51: Update the Claude account rows in SettingsView’s ForEach
over model.claudeAccounts to apply the existing maskAccounts-aware account-label
masking used by provider cards before passing the value to Label. Keep the
unmasked account.label behavior when masking is disabled.

---

Nitpick comments:
In `@Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift`:
- Around line 140-171: Remove the hardcoded successHTML presentation from
ClaudeOAuthLoopback and make respond(_ client:ok:) obtain the success response
body or renderer through dependency injection from the UI layer. Keep
ClaudeOAuthLoopback responsible only for callback transport, status, headers,
and parsing, while preserving the existing success and failure response
behavior.
🪄 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

Run ID: bd7b9391-5235-45f4-ae05-1c62f844cf0a

📥 Commits

Reviewing files that changed from the base of the PR and between ae0a051 and cbf3a72.

📒 Files selected for processing (15)
  • CHANGELOG.md
  • CLAUDE.md
  • README.md
  • Sources/AIUsageBarCore/Claude/ClaudeKeychain.swift
  • Sources/AIUsageBarCore/Claude/ClaudeOAuth.swift
  • Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift
  • Sources/AIUsageBarCore/Claude/ClaudeProfile.swift
  • Sources/AIUsageBarCore/Claude/ClaudeReader.swift
  • Sources/AIUsageBarCore/Claude/ClaudeTokenProvider.swift
  • Sources/AIUsageBarCore/Claude/ClaudeTokenStore.swift
  • Sources/AIUsageBarUI/AppModel.swift
  • Sources/AIUsageBarUI/MenuContentView.swift
  • Sources/AIUsageBarUI/SettingsView.swift
  • Tests/AIUsageBarCoreTests/ClaudeOAuthTests.swift
  • Tests/AIUsageBarUITests/ProviderSettingsTests.swift
💤 Files with no reviewable changes (2)
  • Tests/AIUsageBarUITests/ProviderSettingsTests.swift
  • Sources/AIUsageBarCore/Claude/ClaudeKeychain.swift

Comment thread CHANGELOG.md

## [Unreleased]

## [0.2.0] - 2026-07-16

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

Add the 0.2.0 release link definitions.

[0.2.0] has no matching reference, and [Unreleased] still starts at v0.1.5. Add links for 0.2.0 and 0.1.6, then move the Unreleased baseline to v0.2.0.

🤖 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 `@CHANGELOG.md` at line 14, Update the changelog reference definitions to add
links for releases 0.2.0 and 0.1.6, and change the Unreleased comparison
baseline from v0.1.5 to v0.2.0 while preserving the existing link format.

Comment on lines +178 to +181
static func randomBytes(_ n: Int) -> Data {
var bytes = [UInt8](repeating: 0, count: n)
_ = SecRandomCopyBytes(kSecRandomDefault, n, &bytes)
return Data(bytes)

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

file='Sources/AIUsageBarCore/Claude/ClaudeOAuth.swift'
echo '--- outline ---'
ast-grep outline "$file" --view expanded || true
echo
echo '--- relevant lines ---'
sed -n '1,260p' "$file" | cat -n
echo
echo '--- call sites for randomBytes ---'
rg -n "randomBytes\(" Sources/AIUsageBarCore -S

Repository: Balanced02/ai-usage-bar

Length of output: 14418


Abort PKCE generation if SecRandomCopyBytes fails
randomBytes ignores the entropy status and still returns a zero-filled buffer, which would make the PKCE verifier/state predictable if the call fails. Make this throw or otherwise stop the sign-in flow unless the status is errSecSuccess.

🤖 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 `@Sources/AIUsageBarCore/Claude/ClaudeOAuth.swift` around lines 178 - 181,
Update ClaudeOAuth.randomBytes to check the return status from
SecRandomCopyBytes and stop PKCE/sign-in processing unless it equals
errSecSuccess; propagate the failure through the caller rather than returning
the zero-filled buffer, adjusting the method’s error handling as needed.

Comment on lines +59 to +67
public func waitForCallback(timeout: TimeInterval = 300) async throws -> (code: String, state: String?) {
try await withCheckedThrowingContinuation { c in
lock.lock(); cont = c; lock.unlock()
DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.acceptLoop() }
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { [weak self] in
guard let self else { return }
self.stop()
self.resume(.failure(ClaudeOAuthError.transport("timed out waiting for sign-in")))
}

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
set -euo pipefail

# Map the file structure first, then inspect the relevant slice.
ast-grep outline Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift --view expanded || true
echo '--- FILE (1-220) ---'
cat -n Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift | sed -n '1,220p'

Repository: Balanced02/ai-usage-bar

Length of output: 9905


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Probe the claim about cancellation/timeout races with a small read-only simulator.
python3 - <<'PY'
import asyncio, threading, time

class Cont:
    def __init__(self):
        self.done = False
        self.result = None
        self.lock = threading.Lock()
    def resume(self, value):
        with self.lock:
            if self.done:
                return False
            self.done = True
            self.result = value
            return True

class Sim:
    def __init__(self):
        self.cont = None
        self.lock = threading.Lock()
        self.fd = 1
        self.stop_called = False
    def stop(self):
        self.stop_called = True
    def resume(self, value):
        c = self.cont
        if c:
            return c.resume(value)
        return False

def timeout_race(order='stop-first'):
    s = Sim()
    c = Cont()
    s.cont = c
    def accept_loop():
        time.sleep(0.01)
        s.resume(('accept', 'cancelled'))
    t = threading.Thread(target=accept_loop)
    t.start()

    if order == 'stop-first':
        s.stop()
        s.resume(('timeout', 'timed out'))
    else:
        s.resume(('timeout', 'timed out'))
        s.stop()
    t.join()
    return c.result, s.stop_called

for order in ['stop-first', 'resume-first']:
    print(order, timeout_race(order))
PY

Repository: Balanced02/ai-usage-bar

Length of output: 251


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for related code paths and any helper methods that affect cancellation/timeout behavior.
rg -n "waitForCallback|acceptLoop|resume\(|stop\(|ClaudeOAuthError.transport|cancel" Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift Sources/AIUsageBarCore/Claude -S

Repository: Balanced02/ai-usage-bar

Length of output: 5501


Make callback waiting cancellation-aware and deterministic
Task cancellation still leaves this pending until the 5-minute timeout, and the timeout path can race acceptLoop() into reporting .cancelled instead of the timeout error. Wrap this in withTaskCancellationHandler, and resume the continuation before calling stop() so timeout wins deterministically.

🤖 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 `@Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift` around lines 59 -
67, Update waitForCallback to use withTaskCancellationHandler so task
cancellation resumes the pending continuation with the cancellation error
immediately. In the timeout closure, resume the continuation with the timeout
error before calling stop(), while preserving single-resumption behavior when
acceptLoop races with cancellation or timeout.

Comment on lines +116 to +120
private static func readRequest(_ client: Int32) -> String {
var buf = [UInt8](repeating: 0, count: 8192)
let n = recv(client, &buf, buf.count, 0)
guard n > 0 else { return "" }
return String(decoding: buf[0..<n], as: UTF8.self)

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:

sed -n '1,220p' Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift | cat -n

Repository: Balanced02/ai-usage-bar

Length of output: 9281


Read until the HTTP request line is complete. recv() can return a fragmented request, so a valid OAuth callback may be misparsed as noise and answered with 404. Accumulate until \r\n (or the buffer limit) before calling parse().

🧰 Tools
🪛 SwiftLint (0.65.0)

[Warning] 120-120: Prefer failable String(bytes:encoding:) initializer when converting Data to String

(optional_data_string_conversion)

🤖 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 `@Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift` around lines 116 -
120, Update readRequest(_:) to accumulate data across multiple recv calls until
the HTTP request line terminator \r\n is received or the 8192-byte buffer limit
is reached, while preserving the empty-string result for failed or closed reads.
Return the accumulated request so the caller’s parse() receives complete
request-line data.

Comment on lines +47 to +87
// Bind each token to the best-matching profile by the identity it carries.
var tokenForProfile: [String: ClaudeOAuthToken] = [:]
var usedTokens = Set<Int>()
for profile in profiles {
let account = ClaudeAccountLoader.load(profile)
if let hit = live.enumerated().first(where: { entry in
!usedTokens.contains(entry.offset) && Self.identityMatches(entry.element, account)
}) {
tokenForProfile[profile.id] = hit.element
usedTokens.insert(hit.offset)
}
}
// Exactly one profile and one token, unmatched: no ambiguity, pair them.
// (Requires live.count == 1 — with 2+ tokens we'd bind an arbitrary one and
// drop the others' cards.)
if profiles.count == 1, live.count == 1, tokenForProfile.isEmpty, let only = live.first {
tokenForProfile[profiles[0].id] = only
usedTokens.insert(0)
}

var results: [ProviderUsage] = []
for profile in profiles {
results.append(await readProfile(profile, credential: mapping[profile.id]))
results.append(await readProfile(profile, token: tokenForProfile[profile.id]))
}
// Signed-in accounts with no local profile → live-only cards.
for entry in live.enumerated() where !usedTokens.contains(entry.offset) {
results.append(await readAccountOnly(entry.element))
}
return results
}

private func readProfile(_ profile: ClaudeProfile, credential: ClaudeCredential?) async -> ProviderUsage {
static func identityMatches(_ token: ClaudeOAuthToken, _ account: ClaudeAccount?) -> Bool {
guard let account else { return false }
// Precise per-account identifiers are definitive: when both sides carry one,
// it settles the match (== true / != false). A shared organization UUID is
// only a last resort — two accounts in one org share it, so matching on org
// alone would bind a token to the wrong same-org profile.
if let a = token.accountUUID, let b = account.accountUuid { return a == b }
if let a = token.accountEmail?.lowercased(), let b = account.emailAddress?.lowercased() { return a == b }
if let a = token.orgUUID, let b = account.organizationUuid { return a == b }
return false

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Restrict profile binding to precise account identity. Organization-only and contradictory singleton fallback matches can display one account’s limits under another profile.

  • Sources/AIUsageBarCore/Claude/ClaudeReader.swift#L47-L87: require matching email/account UUID and reject conflicting precise identifiers.
  • Tests/AIUsageBarCoreTests/ClaudeOAuthTests.swift#L110-L136: reject organization-only matching and cover conflicting singleton identities.

As per coding guidelines, live tokens must match profiles by account email or UUID.

📍 Affects 2 files
  • Sources/AIUsageBarCore/Claude/ClaudeReader.swift#L47-L87 (this comment)
  • Tests/AIUsageBarCoreTests/ClaudeOAuthTests.swift#L110-L136
🤖 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 `@Sources/AIUsageBarCore/Claude/ClaudeReader.swift` around lines 47 - 87,
Restrict Claude profile binding in ClaudeReader’s identityMatches to precise
account UUID or email matches, rejecting tokens when both precise identifiers
exist but conflict and removing organization-only matching. Also update
Tests/AIUsageBarCoreTests/ClaudeOAuthTests.swift lines 110-136 to verify
organization-only identities are rejected and conflicting singleton identities
do not bind.

Source: Coding guidelines

Comment on lines +23 to +25
public static func accountKey(for token: ClaudeOAuthToken) -> String {
token.accountUUID ?? token.accountEmail ?? "default"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Require stable identity before persisting an account. Suppressed profile lookup failures allow multiple identity-less tokens to share and overwrite the "default" Keychain item.

  • Sources/AIUsageBarCore/Claude/ClaudeTokenStore.swift#L23-L25: remove the shared fallback and return an error when neither account UUID nor normalized email exists.
  • Sources/AIUsageBarCore/Claude/ClaudeTokenProvider.swift#L38-L44: do not call store until identity resolution succeeds.

As per coding guidelines, live tokens must be associated by account email or UUID.

📍 Affects 2 files
  • Sources/AIUsageBarCore/Claude/ClaudeTokenStore.swift#L23-L25 (this comment)
  • Sources/AIUsageBarCore/Claude/ClaudeTokenProvider.swift#L38-L44
🤖 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 `@Sources/AIUsageBarCore/Claude/ClaudeTokenStore.swift` around lines 23 - 25,
Require stable identity before persisting Claude tokens: update
ClaudeTokenStore.accountKey(for:) to remove the shared "default" fallback and
return an error when both account UUID and normalized email are absent. In
Sources/AIUsageBarCore/Claude/ClaudeTokenProvider.swift lines 38-44, ensure the
provider does not call store until identity resolution succeeds; preserve
association by email or UUID only.

Source: Coding guidelines

self.monthlyBudgetUSD = monthlyBudgetUSD
self.maskAccounts = maskAccounts
self.claudeConnected = claudeConnected
self.claudeAccounts = Self.loadClaudeAccounts()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make AIUSAGEBAR_NO_KEYCHAIN skip account loading too.

The escape hatch disables token reads in ClaudeReader, but Line 142 still queries ClaudeTokenStore. Gate loadClaudeAccounts() with the same environment flag so tests/dev runs truly avoid Keychain access.

Also applies to: 504-509

🤖 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 `@Sources/AIUsageBarUI/AppModel.swift` at line 142, Update the account
initialization around AppModel’s claudeAccounts assignment and the related
account-loading path around lines 504-509 to honor the AIUSAGEBAR_NO_KEYCHAIN
environment flag. When the flag is enabled, skip Self.loadClaudeAccounts()
entirely and use the existing empty-account/default value; otherwise preserve
normal account loading behavior and avoid any ClaudeTokenStore access.

Comment on lines +421 to +423
signingInClaude = false
reloadClaudeAccounts()
await refresh()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Invalidate Claude usage caching after account changes.

UsageService can return its cached Claude cards for at least 180 seconds, so adding an account may not show it and removing one may leave its live card visible. Invalidate the Claude cache or force the next read before calling refresh().

Also applies to: 436-438

🤖 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 `@Sources/AIUsageBarUI/AppModel.swift` around lines 421 - 423, Update the
account-change flows around reloadClaudeAccounts() and refresh() to invalidate
UsageService’s cached Claude usage data, or force the next Claude usage read,
before calling refresh(). Apply the same change to both the sign-in completion
path and the corresponding account-removal path.

Comment on lines +23 to +24
if model.selectedKind == .claude && model.claudeAccounts.isEmpty {
ClaudeConnectBanner(busy: model.signingInClaude) { model.addClaudeAccount() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Show OAuth failures in the Claude banner.

Sign-in errors are stored in model.claudeSignInError, but this menu entry point never renders them. A failed browser flow therefore appears to do nothing. Pass the error into ClaudeConnectBanner and display it near the CTA.

Also applies to: 103-122

🤖 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 `@Sources/AIUsageBarUI/MenuContentView.swift` around lines 23 - 24, Update the
Claude empty-account menu flow around ClaudeConnectBanner to pass
model.claudeSignInError into the banner, then render that error near the
banner’s CTA. Ensure failed OAuth attempts visibly display the stored sign-in
error while preserving the existing busy and account-selection behavior.

Comment on lines +43 to +51
ForEach(model.claudeAccounts) { account in
LabeledContent {
Button("Remove") { model.removeClaudeAccount(account.key) }
.foregroundStyle(.red)
} label: {
Label(account.label, systemImage: "checkmark.circle.fill")
.foregroundStyle(.primary)
.labelStyle(.titleAndIcon)
}

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

Honor account masking for signed-in account labels.

These rows expose the full email or UUID even when maskAccounts is enabled. Apply the same masking used by provider cards before rendering account.label.

🤖 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 `@Sources/AIUsageBarUI/SettingsView.swift` around lines 43 - 51, Update the
Claude account rows in SettingsView’s ForEach over model.claudeAccounts to apply
the existing maskAccounts-aware account-label masking used by provider cards
before passing the value to Label. Keep the unmasked account.label behavior when
masking is disabled.

@Balanced02
Balanced02 merged commit eff364c into main Jul 16, 2026
2 checks passed
@Balanced02
Balanced02 deleted the feat/claude-oauth branch July 16, 2026 10:45
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.

1 participant