173 lines · 6.1 KB · Swift
RawDownload
import Foundation#if canImport(FoundationNetworking)import FoundationNetworking#endif public struct CopilotDeviceFlow: Sendable {    public static let defaultHost = "github.com"     private let clientID = "Iv1.b507a08c87ecfe98" // VS Code Client ID    private let scopes = "read:user"    private let host: String     public struct DeviceCodeResponse: Decodable, Sendable {        public let deviceCode: String        public let userCode: String        public let verificationUri: String        public let verificationUriComplete: String?        public let expiresIn: Int        public let interval: Int         public var verificationURLToOpen: String {            self.verificationUriComplete ?? self.verificationUri        }         enum CodingKeys: String, CodingKey {            case deviceCode = "device_code"            case userCode = "user_code"            case verificationUri = "verification_uri"            case verificationUriComplete = "verification_uri_complete"            case expiresIn = "expires_in"            case interval        }    }     public struct AccessTokenResponse: Decodable, Sendable {        public let accessToken: String        public let tokenType: String        public let scope: String         enum CodingKeys: String, CodingKey {            case accessToken = "access_token"            case tokenType = "token_type"            case scope        }    }     public init(enterpriseHost: String? = nil) {        self.host = Self.normalizedHost(enterpriseHost)    }     public var deviceCodeURL: URL? {        Self.makeRequestURL(host: self.host, path: "/login/device/code")    }     public var accessTokenURL: URL? {        Self.makeRequestURL(host: self.host, path: "/login/oauth/access_token")    }     public static func normalizedHost(_ raw: String?) -> String {        guard var host = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else {            return self.defaultHost        }        let componentsValue = host.contains("://") ? host : "https://\(host)"        if let components = URLComponents(string: componentsValue),           let parsedHost = components.host,           !parsedHost.isEmpty        {            host = parsedHost            if let port = components.port {                host += ":\(port)"            }        } else {            if host.hasPrefix("https://") {                host.removeFirst("https://".count)            } else if host.hasPrefix("http://") {                host.removeFirst("http://".count)            }            host = host.split(separator: "/", maxSplits: 1).first.map(String.init) ?? host        }        let normalized = host.trimmingCharacters(in: CharacterSet(charactersIn: ".")).lowercased()        return normalized.isEmpty ? Self.defaultHost : normalized    }     public func requestDeviceCode() async throws -> DeviceCodeResponse {        guard let deviceCodeURL = self.deviceCodeURL else {            throw URLError(.badURL)        }        let request = URLRequest(url: deviceCodeURL)         var postRequest = request        postRequest.httpMethod = "POST"        postRequest.setValue("application/json", forHTTPHeaderField: "Accept")        postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")         let body = [            "client_id": self.clientID,            "scope": self.scopes,        ]        postRequest.httpBody = Self.formURLEncodedBody(body)         let response = try await ProviderHTTPClient.shared.response(for: postRequest)         guard response.statusCode == 200 else {            throw URLError(.badServerResponse)        }         return try JSONDecoder().decode(DeviceCodeResponse.self, from: response.data)    }     public func pollForToken(deviceCode: String, interval: Int) async throws -> String {        guard let accessTokenURL = self.accessTokenURL else {            throw URLError(.badURL)        }        var request = URLRequest(url: accessTokenURL)        request.httpMethod = "POST"        request.setValue("application/json", forHTTPHeaderField: "Accept")        request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")         let body = [            "client_id": self.clientID,            "device_code": deviceCode,            "grant_type": "urn:ietf:params:oauth:grant-type:device_code",        ]        request.httpBody = Self.formURLEncodedBody(body)         while true {            try await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000)            try Task.checkCancellation()             let response = try await ProviderHTTPClient.shared.response(for: request)             // Check for error in JSON            if let json = try? JSONSerialization.jsonObject(with: response.data) as? [String: Any],               let error = json["error"] as? String            {                if error == "authorization_pending" {                    continue                }                if error == "slow_down" {                    try await Task.sleep(nanoseconds: 5_000_000_000) // Add 5s                    continue                }                if error == "expired_token" {                    throw URLError(.timedOut)                }                throw URLError(.userAuthenticationRequired) // Generic failure            }             if let tokenResponse = try? JSONDecoder().decode(AccessTokenResponse.self, from: response.data) {                return tokenResponse.accessToken            }        }    }     private static func formURLEncodedBody(_ parameters: [String: String]) -> Data {        let pairs = parameters            .map { key, value in                "\(Self.formEncode(key))=\(Self.formEncode(value))"            }            .joined(separator: "&")        return Data(pairs.utf8)    }     static func makeRequestURL(host: String, path: String) -> URL? {        URL(string: "https://\(host)\(path)")    }     private static func formEncode(_ value: String) -> String {        var allowed = CharacterSet.urlQueryAllowed        allowed.remove(charactersIn: "+&=")        return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value    }}