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 } }