Skip to content

systems auth

Nik edited this page May 30, 2026 · 1 revision

Auth management

DroidProxy keeps each provider's OAuth credentials as JSON files in ~/.cli-proxy-api/. The auth subsystem models those files as typed accounts, scans the directory on demand, watches it for external changes, and lets the UI enable, disable, or delete individual accounts.

Active contributors

Ran, Nik

Purpose

The auth subsystem answers three questions for the rest of the app:

  • Which providers are authenticated, and with which accounts? (AuthManager.checkAuthStatus)
  • Where do credential files live? (AuthPaths.authDirectory)
  • When does the on-disk credential set change, so the UI can refresh? (AuthDirectoryMonitor)

Credential files are written by the backend's OAuth login flows (driven by Backend supervisor). The auth subsystem only reads, toggles, and deletes them; it never performs the OAuth handshake itself.

Directory layout

Path Role
src/Sources/AuthStatus.swift ServiceType, AuthAccount, ServiceAccounts, and the AuthManager observable object.
src/Sources/AuthPaths.swift Single source of truth for the auth directory (~/.cli-proxy-api).
src/Sources/AuthDirectoryMonitor.swift Debounced DispatchSource file watcher on the auth directory.
~/.cli-proxy-api/*.json One credential file per authenticated account.

Key abstractions

Abstraction Description
ServiceType String-backed CaseIterable enum: claude, codex, antigravity, kimi, cursor. init?(authFileType:) maps a file's type field to a case; antigravity, gemini, and gemini-cli all map to .antigravity. displayName gives the UI label (Claude Code, Codex, Antigravity, Kimi, Cursor).
AuthAccount One account parsed from a credential file. Fields: id (filename), email?, login?, type, expired: Date?, filePath, isDisabled. isExpired is computed (expired < Date()). displayName falls back email → login → id. Equality is by id.
ServiceAccounts Groups all AuthAccounts for one ServiceType. hasAccounts is !accounts.isEmpty.
AuthManager ObservableObject exposing @Published serviceAccounts: [ServiceType: ServiceAccounts]. Owns scanning, toggling, and deletion.
AuthPaths Namespace whose authDirectory resolves to ~/.cli-proxy-api.
AuthDirectoryMonitor Debounced DispatchSourceFileSystemObject watcher that calls an onChange closure after the directory changes.

Credential file shape

Each *.json file in the auth directory carries the fields the parser reads:

  • type — provider type string (e.g. claude, codex, antigravity, gemini, kimi, cursor).
  • email — account email, when present.
  • login — login handle (used by Copilot-style accounts).
  • expired — ISO 8601 expiry timestamp.
  • disabled — bool; when true the backend skips the account.

How it works

Scanning

checkAuthStatus() lists ~/.cli-proxy-api via FileManager.contentsOfDirectory. On error it publishes an empty account set and returns. Otherwise it iterates over files with the json extension, calls parseAccount on each, and groups results by ServiceType into a fresh dictionary, which it assigns to serviceAccounts on the main thread.

parseAccount(from:) reads the file, decodes JSON, requires a type string that maps to a known ServiceType, and builds an AuthAccount. The expired field is parsed by parseExpiry, which tries two ISO8601DateFormatters in order: one with fractional seconds (.withInternetDateTime, .withFractionalSeconds) and one without (.withInternetDateTime).

Reacting to changes

AuthDirectoryMonitor.start() opens the auth directory with O_EVTONLY and creates a DispatchSource.makeFileSystemObjectSource with an event mask of [.write, .delete, .rename] on the main queue. Each event schedules a refresh; scheduleRefresh() cancels any pending work item and posts a new one after debounceInterval, which logs and invokes onChange. stop() cancels the pending refresh and the source (the cancel handler closes the file descriptor); deinit calls stop().

sequenceDiagram
    participant FS as ~/.cli-proxy-api (filesystem)
    participant M as AuthDirectoryMonitor
    participant AM as AuthManager
    participant UI as SettingsView

    FS->>M: write / delete / rename event
    M->>M: scheduleRefresh() (cancel + debounce)
    Note over M: wait debounceInterval
    M->>AM: onChange() -> checkAuthStatus()
    AM->>FS: list *.json, parseAccount each
    FS-->>AM: credential files
    AM->>AM: publish serviceAccounts (main thread)
    AM-->>UI: @Published update re-renders ServiceRow
Loading

Enabling and disabling accounts

toggleAccountDisabled(_:) reads the account's file, flips the disabled bool, and writes it back atomically with .sortedKeys. Before disabling a currently-enabled account it counts the enabled accounts for that ServiceType and refuses (enabledCount > 1 guard) so the last enabled account for a provider cannot be disabled. After a successful write it calls checkAuthStatus().

Deleting accounts

deleteAccount(_:) removes the credential file via FileManager.removeItem and then calls checkAuthStatus(). Both the toggle and delete paths re-scan so serviceAccounts reflects disk state immediately, in addition to the monitor firing from the file change.

Integration points

  • SettingsView (src/Sources/SettingsView.swift) observes AuthManager and renders a ServiceRow per provider, with the expanded account list driving toggleAccountDisabled and deleteAccount. It also runs its own AuthDirectoryMonitor to refresh while the window is open.
  • AppDelegate (src/Sources/AppDelegate.swift) runs an AuthDirectoryMonitor for the lifetime of the app so the menu reflects credential changes.
  • OAuthUsageTracker (src/Sources/OAuthUsageTracker.swift) consumes the parsed accounts to fetch quota windows. See OAuth usage tracker.
  • ServerManager (src/Sources/ServerManager.swift) login flows write the credential files that the monitor detects and checkAuthStatus then parses. See Backend supervisor.

Entry points for modification

  • Support a new provider: add a ServiceType case, map its auth type strings in init?(authFileType:), and give it a displayName.
  • Read a new credential field: add it to AuthAccount and populate it in parseAccount.
  • Accept another expiry format: add an ISO8601DateFormatter to the dateFormatters array used by parseExpiry.
  • Change watch sensitivity: adjust the eventMask or debounceInterval passed to AuthDirectoryMonitor.
  • Move the auth directory: change AuthPaths.authDirectory (the single source of truth).

Key source files

File Role
src/Sources/AuthStatus.swift ServiceType, AuthAccount, ServiceAccounts, AuthManager.
src/Sources/AuthPaths.swift Auth directory location.
src/Sources/AuthDirectoryMonitor.swift Debounced directory watcher.
src/Sources/NotificationNames.swift authDirectoryChanged constant posted after login.

Related pages

Clone this wiki locally