20 KiB
summary, read_when
| summary | read_when | |||
|---|---|---|---|---|
| Codex OAuth resolver: tokens, refresh, usage endpoint, and fetch strategy wiring. |
|
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:
- Running
codexCLI in PTY mode - Sending
/statuscommand - 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
{
"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:
{
"client_id": "app_EMoamEEZ73f0CkXaXp7hrann",
"grant_type": "refresh_token",
"refresh_token": "<refresh_token>",
"scope": "openid profile email"
}
Response:
{
"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:
{
"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
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
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
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:
// 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
- Ensure
~/.codex/auth.jsonexists (runcodexto log in first) - Run CodexBar with debug logging enabled
- Verify OAuth strategy is selected and API calls succeed
- Test token refresh by manually setting
last_refreshto old date - 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 |