Files
steipete--codexbar/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift
T
2026-07-13 12:22:33 +08:00

124 lines
4.5 KiB
Swift

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
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, Sendable {
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 let .networkError(error):
"Network error during token refresh: \(error.localizedDescription)"
case let .invalidResponse(message):
"Invalid refresh response: \(message)"
}
}
}
public static func refresh(_ credentials: CodexOAuthCredentials) async throws -> CodexOAuthCredentials {
try await self.refresh(credentials, session: CodexAuthenticatedHTTPTransport.current)
}
static func refresh(
_ credentials: CodexOAuthCredentials,
session transport: any ProviderHTTPTransport) async throws -> CodexOAuthCredentials
{
guard !credentials.refreshToken.isEmpty else {
return credentials
}
var request = URLRequest(
url: Self.refreshEndpoint,
cachePolicy: .reloadIgnoringLocalCacheData,
timeoutInterval: 30)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: String] = [
"client_id": Self.clientID,
"grant_type": "refresh_token",
"refresh_token": credentials.refreshToken,
"scope": "openid profile email",
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
do {
let response = try await transport.response(for: request)
let data = response.data
guard response.statusCode == 200 else {
throw Self.refreshFailureError(statusCode: response.statusCode, data: data)
}
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())
} catch let error as RefreshError {
throw error
} catch {
throw RefreshError.networkError(error)
}
}
private static func extractErrorCode(from data: Data) -> String? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
if let error = json["error"] as? [String: Any], let code = error["code"] as? String { return code }
if let error = json["error"] as? String { return error }
return json["code"] as? String
}
private static func refreshFailureError(statusCode: Int, data: Data) -> RefreshError {
if let errorCode = extractErrorCode(from: data) {
switch errorCode.lowercased() {
case "refresh_token_expired":
return .expired
case "refresh_token_reused":
return .reused
case "invalid_grant", "refresh_token_invalidated":
return .revoked
default:
break
}
}
if statusCode == 401 {
return .expired
}
return .invalidResponse("Status \(statusCode)")
}
}
#if DEBUG
extension CodexTokenRefresher {
static func _refreshFailureErrorForTesting(statusCode: Int, data: Data) -> RefreshError {
self.refreshFailureError(statusCode: statusCode, data: data)
}
}
#endif