Back to Codexbar

Codex OAuth Resolver Implementation Plan

docs/codex-oauth.md

0.2419.6 KB
Original Source

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": "<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 <access_token>
ChatGPT-Account-Id: <account_id>
User-Agent: codex-cli

Quick checks

  • Command: cat ~/.codex/auth.json
  • Command: curl -H "Authorization: Bearer <access_token>" -H "ChatGPT-Account-Id: <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

FileLocationPurpose
CodexOAuthCredentials.swiftSources/CodexBarCore/Providers/Codex/CodexOAuth/Token storage model + loader
CodexOAuthUsageFetcher.swiftSources/CodexBarCore/Providers/Codex/CodexOAuth/API client for usage endpoint
CodexTokenRefresher.swiftSources/CodexBarCore/Providers/Codex/CodexOAuth/Token refresh logic

Files to Modify

FileChanges
CodexProviderDescriptor.swiftAdd 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

ConstantValueSource
Client IDapp_EMoamEEZ73f0CkXaXp7hrannauth.rs:618
Refresh URLhttps://auth.openai.com/oauth/tokenauth.rs:66
Usage URLhttps://chatgpt.com/backend-api/wham/usage (default)client.rs:163
Token refresh interval8 daysauth.rs:59
Auth file~/.codex/auth.jsonstorage.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

ErrorBehavior
No auth.jsonFall back to CLI strategy
Token expiredAttempt refresh, fall back to CLI on failure
Refresh failedLog error, fall back to CLI
API errorFall back to CLI
Network errorRetry with backoff, then fall back