Includes provider integrations, settings, NSStatusItem UI, tests, Makefile, and README with screenshot. Co-authored-by: Cursor <cursoragent@cursor.com>
103 lines
3.4 KiB
Swift
103 lines
3.4 KiB
Swift
import Foundation
|
|
import SQLite3
|
|
|
|
enum CursorAuthError: Error, LocalizedError {
|
|
case databaseNotFound
|
|
case tokenNotFound
|
|
case invalidToken
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .databaseNotFound:
|
|
"Cursor database not found. Is Cursor installed and logged in?"
|
|
case .tokenNotFound:
|
|
"No auth token found. Please log in to Cursor."
|
|
case .invalidToken:
|
|
"Could not parse auth token."
|
|
}
|
|
}
|
|
}
|
|
|
|
struct CursorSessionCredentials: Sendable {
|
|
let cookieValue: String
|
|
}
|
|
|
|
enum CursorAuthService {
|
|
static let databasePath = FileManager.default.homeDirectoryForCurrentUser
|
|
.appendingPathComponent("Library/Application Support/Cursor/User/globalStorage/state.vscdb")
|
|
|
|
static func loadSessionCredentials() throws -> CursorSessionCredentials {
|
|
guard FileManager.default.fileExists(atPath: databasePath.path) else {
|
|
throw CursorAuthError.databaseNotFound
|
|
}
|
|
|
|
var database: OpaquePointer?
|
|
guard sqlite3_open_v2(databasePath.path, &database, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else {
|
|
throw CursorAuthError.databaseNotFound
|
|
}
|
|
defer { sqlite3_close(database) }
|
|
|
|
let query = "SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken'"
|
|
var statement: OpaquePointer?
|
|
guard sqlite3_prepare_v2(database, query, -1, &statement, nil) == SQLITE_OK else {
|
|
throw CursorAuthError.tokenNotFound
|
|
}
|
|
defer { sqlite3_finalize(statement) }
|
|
|
|
guard sqlite3_step(statement) == SQLITE_ROW,
|
|
let cString = sqlite3_column_text(statement, 0)
|
|
else {
|
|
throw CursorAuthError.tokenNotFound
|
|
}
|
|
|
|
let accessToken = String(cString: cString)
|
|
let userID = try extractUserID(from: accessToken)
|
|
let cookieValue = "\(userID)%3A%3A\(accessToken)"
|
|
return CursorSessionCredentials(cookieValue: cookieValue)
|
|
}
|
|
|
|
private static func extractUserID(from jwt: String) throws -> String {
|
|
let parts = jwt.split(separator: ".")
|
|
guard parts.count >= 2 else {
|
|
throw CursorAuthError.invalidToken
|
|
}
|
|
|
|
var payload = String(parts[1])
|
|
let padding = payload.count % 4
|
|
if padding > 0 {
|
|
payload += String(repeating: "=", count: 4 - padding)
|
|
}
|
|
payload = payload
|
|
.replacingOccurrences(of: "-", with: "+")
|
|
.replacingOccurrences(of: "_", with: "/")
|
|
|
|
guard let data = Data(base64Encoded: payload),
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let subject = json["sub"] as? String
|
|
else {
|
|
throw CursorAuthError.invalidToken
|
|
}
|
|
|
|
if let separatorIndex = subject.lastIndex(of: "|") {
|
|
return String(subject[subject.index(after: separatorIndex)...])
|
|
}
|
|
return subject
|
|
}
|
|
|
|
static func normalizeSessionCookie(_ token: String) -> String {
|
|
if token.contains("%3A%3A") {
|
|
return token
|
|
}
|
|
if token.contains("::") {
|
|
let parts = token.split(separator: ":", maxSplits: 1)
|
|
if parts.count == 2 {
|
|
return "\(parts[0])%3A%3A\(parts[1])"
|
|
}
|
|
}
|
|
if token.split(separator: ".").count >= 2,
|
|
let userID = try? extractUserID(from: token) {
|
|
return "\(userID)%3A%3A\(token)"
|
|
}
|
|
return token
|
|
}
|
|
}
|