import Foundation#if canImport(FoundationNetworking)import FoundationNetworking#endif public enum VertexAITokenRefresher { private static let tokenEndpoint = URL(string: "https://oauth2.googleapis.com/token")! public enum RefreshError: LocalizedError, Sendable { case expired case revoked case networkError(Error) case invalidResponse(String) public var errorDescription: String? { switch self { case .expired: "Refresh token expired. Run `gcloud auth application-default login` again." case .revoked: "Refresh token was revoked. Run `gcloud auth application-default login` 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: VertexAIOAuthCredentials) async throws -> VertexAIOAuthCredentials { guard !credentials.refreshToken.isEmpty else { throw RefreshError.invalidResponse("No refresh token available") } var request = URLRequest(url: Self.tokenEndpoint) request.httpMethod = "POST" request.timeoutInterval = 30 request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") let bodyParams = [ "client_id": credentials.clientId, "client_secret": credentials.clientSecret, "refresh_token": credentials.refreshToken, "grant_type": "refresh_token", ] let bodyString = bodyParams .map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0.value)" } .joined(separator: "&") request.httpBody = bodyString.data(using: .utf8) do { let response = try await ProviderHTTPClient.shared.response(for: request) let data = response.data if response.statusCode == 400 || response.statusCode == 401 { if let errorCode = Self.extractErrorCode(from: data) { switch errorCode.lowercased() { case "invalid_grant": throw RefreshError.expired case "unauthorized_client": throw RefreshError.revoked default: throw RefreshError.invalidResponse("Error: \(errorCode)") } } throw RefreshError.expired } guard response.statusCode == 200 else { throw RefreshError.invalidResponse("Status \(response.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 expiresIn = json["expires_in"] as? Double ?? 3600 let newExpiryDate = Date().addingTimeInterval(expiresIn) // Extract email from new ID token if present let idToken = json["id_token"] as? String let email = Self.extractEmailFromIdToken(idToken) ?? credentials.email return VertexAIOAuthCredentials( accessToken: newAccessToken, refreshToken: credentials.refreshToken, clientId: credentials.clientId, clientSecret: credentials.clientSecret, projectId: credentials.projectId, email: email, expiryDate: newExpiryDate) } 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 } return json["error"] as? String } private static func extractEmailFromIdToken(_ token: String?) -> String? { guard let token, !token.isEmpty else { return nil } let parts = token.components(separatedBy: ".") guard parts.count >= 2 else { return nil } var payload = parts[1] .replacingOccurrences(of: "-", with: "+") .replacingOccurrences(of: "_", with: "/") let remainder = payload.count % 4 if remainder > 0 { payload += String(repeating: "=", count: 4 - remainder) } guard let data = Data(base64Encoded: payload, options: .ignoreUnknownCharacters), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } return json["email"] as? String }}
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
public enum VertexAITokenRefresher {
private static let tokenEndpoint = URL(string: "https://oauth2.googleapis.com/token")!
public enum RefreshError: LocalizedError, Sendable {
case expired
case revoked
case networkError(Error)
case invalidResponse(String)
public var errorDescription: String? {
switch self {
case .expired:
"Refresh token expired. Run `gcloud auth application-default login` again."
case .revoked:
"Refresh token was revoked. Run `gcloud auth application-default login` 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: VertexAIOAuthCredentials) async throws -> VertexAIOAuthCredentials {
guard !credentials.refreshToken.isEmpty else {
throw RefreshError.invalidResponse("No refresh token available")
}
var request = URLRequest(url: Self.tokenEndpoint)
request.httpMethod = "POST"
request.timeoutInterval = 30
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
let bodyParams = [
"client_id": credentials.clientId,
"client_secret": credentials.clientSecret,
"refresh_token": credentials.refreshToken,
"grant_type": "refresh_token",
]
let bodyString = bodyParams
.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0.value)" }
.joined(separator: "&")
request.httpBody = bodyString.data(using: .utf8)
do {
let response = try await ProviderHTTPClient.shared.response(for: request)
let data = response.data
if response.statusCode == 400 || response.statusCode == 401 {
if let errorCode = Self.extractErrorCode(from: data) {
switch errorCode.lowercased() {
case "invalid_grant":
throw RefreshError.expired
case "unauthorized_client":
throw RefreshError.revoked
default:
throw RefreshError.invalidResponse("Error: \(errorCode)")
}
}
throw RefreshError.expired
}
guard response.statusCode == 200 else {
throw RefreshError.invalidResponse("Status \(response.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 expiresIn = json["expires_in"] as? Double ?? 3600
let newExpiryDate = Date().addingTimeInterval(expiresIn)
// Extract email from new ID token if present
let idToken = json["id_token"] as? String
let email = Self.extractEmailFromIdToken(idToken) ?? credentials.email
return VertexAIOAuthCredentials(
accessToken: newAccessToken,
refreshToken: credentials.refreshToken,
clientId: credentials.clientId,
clientSecret: credentials.clientSecret,
projectId: credentials.projectId,
email: email,
expiryDate: newExpiryDate)
} 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 }
return json["error"] as? String
}
private static func extractEmailFromIdToken(_ token: String?) -> String? {
guard let token, !token.isEmpty else { return nil }
let parts = token.components(separatedBy: ".")
guard parts.count >= 2 else { return nil }
var payload = parts[1]
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let remainder = payload.count % 4
if remainder > 0 {
payload += String(repeating: "=", count: 4 - remainder)
}
guard let data = Data(base64Encoded: payload, options: .ignoreUnknownCharacters),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else {
return nil
}
return json["email"] as? String
}
}