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

189 lines
6.9 KiB
Swift

import Foundation
public struct GrokCredentials: Sendable {
public let accessToken: String
public let refreshToken: String?
public let scope: String
public let authMode: String?
public let userId: String?
public let email: String?
public let firstName: String?
public let lastName: String?
public let teamId: String?
public let oidcIssuer: String?
public let oidcClientId: String?
public let expiresAt: Date?
public let createTime: Date?
public init(
accessToken: String,
refreshToken: String?,
scope: String,
authMode: String?,
userId: String?,
email: String?,
firstName: String?,
lastName: String?,
teamId: String?,
oidcIssuer: String?,
oidcClientId: String?,
expiresAt: Date?,
createTime: Date?)
{
self.accessToken = accessToken
self.refreshToken = refreshToken
self.scope = scope
self.authMode = authMode
self.userId = userId
self.email = email
self.firstName = firstName
self.lastName = lastName
self.teamId = teamId
self.oidcIssuer = oidcIssuer
self.oidcClientId = oidcClientId
self.expiresAt = expiresAt
self.createTime = createTime
}
public var displayName: String? {
let parts = [self.firstName, self.lastName].compactMap { $0?.nilIfEmpty }
guard !parts.isEmpty else { return nil }
return parts.joined(separator: " ")
}
public var isExpired: Bool {
guard let expiresAt else { return false }
return Date() >= expiresAt
}
public var loginMethod: String? {
switch self.authMode?.lowercased() {
case "oidc": "SuperGrok"
case "session": "session"
case nil: nil
default: self.authMode
}
}
}
public enum GrokCredentialsError: LocalizedError, Sendable {
case notFound
case decodeFailed(String)
case missingTokens
public var errorDescription: String? {
switch self {
case .notFound:
"Grok auth.json not found. Run `grok login` to authenticate."
case let .decodeFailed(message):
"Failed to decode Grok credentials: \(message)"
case .missingTokens:
"Grok auth.json exists but contains no access tokens."
}
}
}
public enum GrokCredentialsStore {
/// Top-level OIDC scope used by `grok login` for SuperGrok subscribers.
public static let oidcScopePrefix = "https://auth.x.ai::"
/// Legacy/session scope used by older `grok login` flows.
public static let legacySessionScope = "https://accounts.x.ai/sign-in"
public static func grokHomeURL(
env: [String: String] = ProcessInfo.processInfo.environment,
fileManager: FileManager = .default) -> URL
{
if let custom = env["GROK_HOME"]?.nilIfEmpty {
return URL(fileURLWithPath: (custom as NSString).expandingTildeInPath)
}
let home = fileManager.homeDirectoryForCurrentUser
return home.appendingPathComponent(".grok", isDirectory: true)
}
public static func authFileURL(
env: [String: String] = ProcessInfo.processInfo.environment,
fileManager: FileManager = .default) -> URL
{
self.grokHomeURL(env: env, fileManager: fileManager).appendingPathComponent("auth.json")
}
public static func load(env: [String: String] = ProcessInfo.processInfo.environment) throws -> GrokCredentials {
let url = self.authFileURL(env: env)
guard FileManager.default.fileExists(atPath: url.path) else {
throw GrokCredentialsError.notFound
}
let data = try Data(contentsOf: url)
return try self.parse(data: data)
}
public static func parse(data: Data) throws -> GrokCredentials {
let raw: Any
do {
raw = try JSONSerialization.jsonObject(with: data)
} catch {
throw GrokCredentialsError.decodeFailed(error.localizedDescription)
}
guard let root = raw as? [String: Any] else {
throw GrokCredentialsError.decodeFailed("Invalid JSON (expected object at root)")
}
// `auth.json` is a map keyed by scope URL. Prefer the OIDC scope (SuperGrok),
// fall back to the legacy session scope.
let preferredEntry = Self.selectPreferredEntry(in: root)
guard let (scope, entry) = preferredEntry else {
throw GrokCredentialsError.missingTokens
}
guard let key = entry["key"] as? String, !key.isEmpty else {
throw GrokCredentialsError.missingTokens
}
return GrokCredentials(
accessToken: key,
refreshToken: (entry["refresh_token"] as? String)?.nilIfEmpty,
scope: scope,
authMode: (entry["auth_mode"] as? String)?.nilIfEmpty,
userId: (entry["user_id"] as? String)?.nilIfEmpty,
email: (entry["email"] as? String)?.nilIfEmpty,
firstName: (entry["first_name"] as? String)?.nilIfEmpty,
lastName: (entry["last_name"] as? String)?.nilIfEmpty,
teamId: (entry["team_id"] as? String)?.nilIfEmpty,
oidcIssuer: (entry["oidc_issuer"] as? String)?.nilIfEmpty,
oidcClientId: (entry["oidc_client_id"] as? String)?.nilIfEmpty,
expiresAt: Self.parseDate(entry["expires_at"]),
createTime: Self.parseDate(entry["create_time"]))
}
private static func selectPreferredEntry(in root: [String: Any]) -> (scope: String, entry: [String: Any])? {
var oidcCandidate: (String, [String: Any])?
var legacyCandidate: (String, [String: Any])?
for (scope, value) in root {
guard let entry = value as? [String: Any] else { continue }
// Only accept entries that actually carry a usable bearer token. A
// stale/partial OIDC record (key missing or empty) must not shadow a
// healthy legacy session entry.
guard let key = entry["key"] as? String, !key.isEmpty else { continue }
if scope.hasPrefix(self.oidcScopePrefix) {
oidcCandidate = (scope, entry)
} else if scope == self.legacySessionScope || scope.contains("/sign-in") {
legacyCandidate = (scope, entry)
}
}
return oidcCandidate ?? legacyCandidate
}
private static func parseDate(_ raw: Any?) -> Date? {
guard let value = raw as? String, !value.isEmpty else { return nil }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: value) { return date }
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: value)
}
}
extension String {
fileprivate var nilIfEmpty: String? {
self.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self
}
}