174 lines
6.1 KiB
Swift
174 lines
6.1 KiB
Swift
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
|
|
}
|
|
}
|