docs/codex-oauth.md
Replicate Codex's direct OAuth token usage in CodexBar instead of calling the CLI.
Currently, CodexBar fetches Codex usage by:
codex CLI in PTY mode/status commandThis is slow and unreliable. The goal is to directly read Codex's OAuth tokens and call the same API endpoints that Codex uses internally.
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
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
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
cat ~/.codex/auth.jsoncurl -H "Authorization: Bearer <access_token>" -H "ChatGPT-Account-Id: <account_id>" -H "User-Agent: codex-cli" https://chatgpt.com/backend-api/wham/usageCodexBarCLI usage --provider codex --source oauth --json --prettyResponse:
{
"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
| 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 |
| File | Changes |
|---|---|
CodexProviderDescriptor.swift | Add CodexOAuthFetchStrategy, update resolveStrategies() |
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)
}
}
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)
}
}
}
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())
}
}
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)
}
}
| 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 |
~/.codex/auth.json exists (run codex to log in first)last_refresh to old date| 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 |