129 lines
4.9 KiB
Swift
129 lines
4.9 KiB
Swift
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
|
|
}
|
|
}
|