--- summary: "Codex OAuth resolver: tokens, refresh, usage endpoint, and fetch strategy wiring." read_when: - Adding or modifying Codex OAuth usage fetching - Debugging auth.json parsing or token refresh behavior - Adjusting Codex provider source selection --- # Codex OAuth Resolver Implementation Plan > Replicate Codex's direct OAuth token usage in CodexBar instead of calling the CLI. ## Background Currently, CodexBar fetches Codex usage by: 1. Running `codex` CLI in PTY mode 2. Sending `/status` command 3. Parsing the text output This is slow and unreliable. The goal is to directly read Codex's OAuth tokens and call the same API endpoints that Codex uses internally. --- ## Codex OAuth Architecture (from source analysis) ### Token Storage **Location:** `~/.codex/auth.json` ```json { "OPENAI_API_KEY": null, "tokens": { "id_token": "eyJ...", "access_token": "eyJ...", "refresh_token": "...", "account_id": "account-..." }, "last_refresh": "2025-12-28T12:34:56Z" } ``` **Source:** `codex-rs/core/src/auth/storage.rs` ### Token Refresh **Endpoint:** `POST https://auth.openai.com/oauth/token` **Request:** ```json { "client_id": "app_EMoamEEZ73f0CkXaXp7hrann", "grant_type": "refresh_token", "refresh_token": "", "scope": "openid profile email" } ``` **Response:** ```json { "id_token": "eyJ...", "access_token": "eyJ...", "refresh_token": "..." } ``` **Refresh Interval:** 8 days (from `TOKEN_REFRESH_INTERVAL` constant) **Source:** `codex-rs/core/src/auth.rs:504-545` ### Usage API **Endpoint:** `GET {chatgpt_base_url}/wham/usage` (default: `https://chatgpt.com/backend-api/wham/usage`) If `chatgpt_base_url` does not include `/backend-api`, Codex falls back to `{base_url}/api/codex/usage` (see `PathStyle` in `backend-client/src/client.rs`). **Headers:** ``` Authorization: Bearer ChatGPT-Account-Id: User-Agent: codex-cli ``` **Quick checks** - Command: `cat ~/.codex/auth.json` - Command: `curl -H "Authorization: Bearer " -H "ChatGPT-Account-Id: " -H "User-Agent: codex-cli" https://chatgpt.com/backend-api/wham/usage` - Command: `CodexBarCLI usage --provider codex --source oauth --json --pretty` **Response:** ```json { "plan_type": "pro", "rate_limit": { "primary_window": { "used_percent": 15, "reset_at": 1735401600, "limit_window_seconds": 18000 }, "secondary_window": { "used_percent": 5, "reset_at": 1735920000, "limit_window_seconds": 604800 } }, "credits": { "has_credits": true, "unlimited": false, "balance": 150.0 } } ``` **Source:** `codex-rs/backend-client/src/client.rs:161-170` --- ## Implementation ### Files to Create | File | Location | Purpose | |------|----------|---------| | `CodexOAuthCredentials.swift` | `Sources/CodexBarCore/Providers/Codex/CodexOAuth/` | Token storage model + loader | | `CodexOAuthUsageFetcher.swift` | `Sources/CodexBarCore/Providers/Codex/CodexOAuth/` | API client for usage endpoint | | `CodexTokenRefresher.swift` | `Sources/CodexBarCore/Providers/Codex/CodexOAuth/` | Token refresh logic | ### Files to Modify | File | Changes | |------|---------| | `CodexProviderDescriptor.swift` | Add `CodexOAuthFetchStrategy`, update `resolveStrategies()` | --- ### Step 1: CodexOAuthCredentials.swift ```swift import Foundation public struct CodexOAuthCredentials: Sendable { public let accessToken: String public let refreshToken: String public let idToken: String? public let accountId: String? public let lastRefresh: Date? public var needsRefresh: Bool { guard let last = lastRefresh else { return true } let eightDays: TimeInterval = 8 * 24 * 3600 return Date().timeIntervalSince(last) > eightDays } } public enum CodexOAuthCredentialsError: LocalizedError { case notFound case decodeFailed(String) case missingTokens public var errorDescription: String? { switch self { case .notFound: "Codex auth.json not found. Run `codex` to log in." case .decodeFailed(let msg): "Failed to decode Codex credentials: \(msg)" case .missingTokens: "Codex auth.json exists but contains no tokens." } } } public enum CodexOAuthCredentialsStore { private static var authFilePath: URL { let home = FileManager.default.homeDirectoryForCurrentUser // Respect CODEX_HOME if set if let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"], !codexHome.isEmpty { return URL(fileURLWithPath: codexHome).appendingPathComponent("auth.json") } return home.appendingPathComponent(".codex/auth.json") } public static func load() throws -> CodexOAuthCredentials { let url = authFilePath guard FileManager.default.fileExists(atPath: url.path) else { throw CodexOAuthCredentialsError.notFound } let data = try Data(contentsOf: url) guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw CodexOAuthCredentialsError.decodeFailed("Invalid JSON") } // Check for API key auth (no tokens needed for refresh) if let apiKey = json["OPENAI_API_KEY"] as? String, !apiKey.isEmpty { return CodexOAuthCredentials( accessToken: apiKey, refreshToken: "", idToken: nil, accountId: nil, lastRefresh: nil) } guard let tokens = json["tokens"] as? [String: Any], let accessToken = tokens["access_token"] as? String, let refreshToken = tokens["refresh_token"] as? String else { throw CodexOAuthCredentialsError.missingTokens } let idToken = tokens["id_token"] as? String let accountId = tokens["account_id"] as? String let lastRefresh: Date? = { guard let str = json["last_refresh"] as? String else { return nil } let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return formatter.date(from: str) ?? ISO8601DateFormatter().date(from: str) }() return CodexOAuthCredentials( accessToken: accessToken, refreshToken: refreshToken, idToken: idToken, accountId: accountId, lastRefresh: lastRefresh) } public static func save(_ credentials: CodexOAuthCredentials) throws { let url = authFilePath // Read existing file to preserve structure var json: [String: Any] = [:] if let data = try? Data(contentsOf: url), let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { json = existing } var tokens: [String: Any] = [ "access_token": credentials.accessToken, "refresh_token": credentials.refreshToken ] if let idToken = credentials.idToken { tokens["id_token"] = idToken } if let accountId = credentials.accountId { tokens["account_id"] = accountId } json["tokens"] = tokens json["last_refresh"] = ISO8601DateFormatter().string(from: Date()) let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) try data.write(to: url, options: .atomic) } } ``` --- ### Step 2: CodexOAuthUsageFetcher.swift ```swift import Foundation public struct CodexUsageResponse: Decodable, Sendable { public let planType: PlanType public let rateLimit: RateLimitDetails? public let credits: CreditDetails? enum CodingKeys: String, CodingKey { case planType = "plan_type" case rateLimit = "rate_limit" case credits } public enum PlanType: String, Decodable, Sendable { case guest, free, go, plus, pro case freeWorkspace = "free_workspace" case team, business, education, quorum, k12, enterprise, edu } public struct RateLimitDetails: Decodable, Sendable { public let primaryWindow: WindowSnapshot? public let secondaryWindow: WindowSnapshot? enum CodingKeys: String, CodingKey { case primaryWindow = "primary_window" case secondaryWindow = "secondary_window" } } public struct WindowSnapshot: Decodable, Sendable { public let usedPercent: Int public let resetAt: Int public let limitWindowSeconds: Int enum CodingKeys: String, CodingKey { case usedPercent = "used_percent" case resetAt = "reset_at" case limitWindowSeconds = "limit_window_seconds" } } public struct CreditDetails: Decodable, Sendable { public let hasCredits: Bool public let unlimited: Bool public let balance: Double? enum CodingKeys: String, CodingKey { case hasCredits = "has_credits" case unlimited case balance } } } public enum CodexOAuthFetchError: LocalizedError, Sendable { case unauthorized case invalidResponse case serverError(Int, String?) case networkError(Error) public var errorDescription: String? { switch self { case .unauthorized: "Codex OAuth token expired or invalid. Run `codex` to re-authenticate." case .invalidResponse: "Invalid response from Codex usage API." case .serverError(let code, let msg): "Codex API error \(code): \(msg ?? "unknown")" case .networkError(let error): "Network error: \(error.localizedDescription)" } } } public enum CodexOAuthUsageFetcher { private static let defaultChatGPTBaseURL = "https://chatgpt.com/backend-api/" private static let chatGPTUsagePath = "/wham/usage" private static let codexUsagePath = "/api/codex/usage" public static func fetchUsage( accessToken: String, accountId: String? ) async throws -> CodexUsageResponse { var request = URLRequest(url: resolveUsageURL()) request.httpMethod = "GET" request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue("CodexBar", forHTTPHeaderField: "User-Agent") request.setValue("application/json", forHTTPHeaderField: "Accept") if let accountId { request.setValue(accountId, forHTTPHeaderField: "ChatGPT-Account-Id") } let (data, response): (Data, URLResponse) do { (data, response) = try await URLSession.shared.data(for: request) } catch { throw CodexOAuthFetchError.networkError(error) } guard let http = response as? HTTPURLResponse else { throw CodexOAuthFetchError.invalidResponse } switch http.statusCode { case 200...299: do { return try JSONDecoder().decode(CodexUsageResponse.self, from: data) } catch { throw CodexOAuthFetchError.invalidResponse } case 401, 403: throw CodexOAuthFetchError.unauthorized default: let body = String(data: data, encoding: .utf8) throw CodexOAuthFetchError.serverError(http.statusCode, body) } } } ``` --- ### Step 3: CodexTokenRefresher.swift ```swift import Foundation public enum CodexTokenRefresher { private static let refreshEndpoint = URL(string: "https://auth.openai.com/oauth/token")! private static let clientID = "app_EMoamEEZ73f0CkXaXp7hrann" public enum RefreshError: LocalizedError { case expired case revoked case reused case networkError(Error) case invalidResponse(String) public var errorDescription: String? { switch self { case .expired: "Refresh token expired. Please run `codex` to log in again." case .revoked: "Refresh token was revoked. Please run `codex` to log in again." case .reused: "Refresh token was already used. Please run `codex` to log in again." case .networkError(let error): "Network error during token refresh: \(error.localizedDescription)" case .invalidResponse(let msg): "Invalid refresh response: \(msg)" } } } public static func refresh(_ credentials: CodexOAuthCredentials) async throws -> CodexOAuthCredentials { guard !credentials.refreshToken.isEmpty else { // API key auth - no refresh needed return credentials } var request = URLRequest(url: refreshEndpoint) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body: [String: String] = [ "client_id": clientID, "grant_type": "refresh_token", "refresh_token": credentials.refreshToken, "scope": "openid profile email" ] request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response): (Data, URLResponse) do { (data, response) = try await URLSession.shared.data(for: request) } catch { throw RefreshError.networkError(error) } guard let http = response as? HTTPURLResponse else { throw RefreshError.invalidResponse("No HTTP response") } if http.statusCode == 401 { // Parse error code to classify failure if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let errorCode = (json["error"] as? [String: Any])?["code"] as? String ?? json["error"] as? String ?? json["code"] as? String { switch errorCode.lowercased() { case "refresh_token_expired": throw RefreshError.expired case "refresh_token_reused": throw RefreshError.reused case "refresh_token_invalidated": throw RefreshError.revoked default: throw RefreshError.expired } } throw RefreshError.expired } guard http.statusCode == 200 else { throw RefreshError.invalidResponse("Status \(http.statusCode)") } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw RefreshError.invalidResponse("Invalid JSON") } let newAccessToken = json["access_token"] as? String ?? credentials.accessToken let newRefreshToken = json["refresh_token"] as? String ?? credentials.refreshToken let newIdToken = json["id_token"] as? String ?? credentials.idToken return CodexOAuthCredentials( accessToken: newAccessToken, refreshToken: newRefreshToken, idToken: newIdToken, accountId: credentials.accountId, lastRefresh: Date()) } } ``` --- ### Step 4: Update CodexProviderDescriptor.swift Add OAuth to `sourceModes` and create new strategy: ```swift // In makeDescriptor(), update fetchPlan: fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .oauth, .web, .cli], // Add .oauth pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), // Update resolveStrategies: private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { let oauth = CodexOAuthFetchStrategy() let cli = CodexCLIUsageStrategy() let web = CodexWebDashboardStrategy() switch context.sourceMode { case .oauth: return [oauth] case .web: return [web] case .cli: return [cli] case .auto: // OAuth first (fast), CLI fallback if context.runtime == .cli { return [web, cli] } return [oauth, cli] } } // Add new strategy: struct CodexOAuthFetchStrategy: ProviderFetchStrategy { let id: String = "codex.oauth" let kind: ProviderFetchKind = .oauth func isAvailable(_ context: ProviderFetchContext) async -> Bool { (try? CodexOAuthCredentialsStore.load()) != nil } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { var creds = try CodexOAuthCredentialsStore.load() // Refresh if needed (8+ days old) if creds.needsRefresh && !creds.refreshToken.isEmpty { creds = try await CodexTokenRefresher.refresh(creds) try CodexOAuthCredentialsStore.save(creds) } let usage = try await CodexOAuthUsageFetcher.fetchUsage( accessToken: creds.accessToken, accountId: creds.accountId) return makeResult( usage: Self.mapUsage(usage), credits: Self.mapCredits(usage.credits), sourceLabel: "oauth") } func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { // Fallback to CLI on auth errors if let fetchError = error as? CodexOAuthFetchError { switch fetchError { case .unauthorized: return true default: return false } } if error is CodexOAuthCredentialsError { return true } if error is CodexTokenRefresher.RefreshError { return true } return false } private static func mapUsage(_ response: CodexUsageResponse) -> UsageSnapshot { let primary: RateWindow? = response.rateLimit?.primaryWindow.map { window in RateWindow( usedPercent: Double(window.usedPercent), windowMinutes: window.limitWindowSeconds / 60, resetsAt: Date(timeIntervalSince1970: TimeInterval(window.resetAt)), resetDescription: nil) } let secondary: RateWindow? = response.rateLimit?.secondaryWindow.map { window in RateWindow( usedPercent: Double(window.usedPercent), windowMinutes: window.limitWindowSeconds / 60, resetsAt: Date(timeIntervalSince1970: TimeInterval(window.resetAt)), resetDescription: nil) } let identity = ProviderIdentitySnapshot( providerID: .codex, accountEmail: nil, accountOrganization: nil, loginMethod: response.planType.rawValue) return UsageSnapshot( primary: primary ?? RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), secondary: secondary, tertiary: nil, providerCost: nil, updatedAt: Date(), identity: identity) } private static func mapCredits(_ credits: CodexUsageResponse.CreditDetails?) -> CreditsSnapshot? { guard let credits else { return nil } return CreditsSnapshot( hasCredits: credits.hasCredits, unlimited: credits.unlimited, balance: credits.balance) } } ``` --- ## Constants Reference | Constant | Value | Source | |----------|-------|--------| | Client ID | `app_EMoamEEZ73f0CkXaXp7hrann` | `auth.rs:618` | | Refresh URL | `https://auth.openai.com/oauth/token` | `auth.rs:66` | | Usage URL | `https://chatgpt.com/backend-api/wham/usage` (default) | `client.rs:163` | | Token refresh interval | 8 days | `auth.rs:59` | | Auth file | `~/.codex/auth.json` | `storage.rs` | --- ## Testing 1. Ensure `~/.codex/auth.json` exists (run `codex` to log in first) 2. Run CodexBar with debug logging enabled 3. Verify OAuth strategy is selected and API calls succeed 4. Test token refresh by manually setting `last_refresh` to old date 5. Test fallback by temporarily renaming auth.json --- ## Error Handling | Error | Behavior | |-------|----------| | No auth.json | Fall back to CLI strategy | | Token expired | Attempt refresh, fall back to CLI on failure | | Refresh failed | Log error, fall back to CLI | | API error | Fall back to CLI | | Network error | Retry with backoff, then fall back |