145 lines
4.4 KiB
Swift
145 lines
4.4 KiB
Swift
// Sources/Core/Auth/Pairing.swift
|
|
// T-2.2: QR parsing + pairing exchange
|
|
|
|
import Foundation
|
|
|
|
// MARK: - Errors
|
|
|
|
enum PairingError: Error, Sendable {
|
|
case invalidQR
|
|
case networkError(Error)
|
|
case serverError(Int, String)
|
|
case decodingFailed
|
|
}
|
|
|
|
// MARK: - Wire types
|
|
|
|
private struct PairRequestBody: Encodable {
|
|
let pairingToken: String
|
|
let deviceName: String
|
|
// deviceToken / environment injected in T-2.4 (Push integration)
|
|
}
|
|
|
|
private struct PairResponseBody: Decodable {
|
|
let bearerToken: String
|
|
let sidecarId: String
|
|
}
|
|
|
|
// MARK: - PairingService
|
|
|
|
struct PairingService: Sendable {
|
|
|
|
// MARK: QR parsing
|
|
|
|
/// Parses a `pi-remote://` URL produced by the sidecar `pi-remote pair` command.
|
|
///
|
|
/// Expected format:
|
|
/// ```
|
|
/// pi-remote://<host>:<port>?pair=<pairingToken>&fp=<sha256-hex>&name=<sidecarName>
|
|
/// ```
|
|
static func parseQR(_ string: String) throws -> (
|
|
host: String,
|
|
port: Int,
|
|
pairingToken: String,
|
|
fingerprint: String,
|
|
name: String
|
|
) {
|
|
var components = URLComponents(string: string)
|
|
|
|
// The custom scheme confuses URLComponents on some inputs — patch it.
|
|
if components == nil || components?.host == nil {
|
|
// Try replacing custom scheme with https so standard parsing works.
|
|
let patched = string.replacingOccurrences(of: "pi-remote://", with: "https://")
|
|
components = URLComponents(string: patched)
|
|
}
|
|
|
|
guard
|
|
let comps = components,
|
|
let host = comps.host, !host.isEmpty,
|
|
let port = comps.port
|
|
else {
|
|
throw PairingError.invalidQR
|
|
}
|
|
|
|
let items = comps.queryItems ?? []
|
|
|
|
func queryValue(_ name: String) throws -> String {
|
|
guard let value = items.first(where: { $0.name == name })?.value,
|
|
!value.isEmpty
|
|
else { throw PairingError.invalidQR }
|
|
return value
|
|
}
|
|
|
|
let pairingToken = try queryValue("pair")
|
|
let fingerprint = try queryValue("fp")
|
|
let name = try queryValue("name")
|
|
|
|
return (host: host, port: port, pairingToken: pairingToken,
|
|
fingerprint: fingerprint, name: name)
|
|
}
|
|
|
|
// MARK: Exchange
|
|
|
|
/// Sends `POST /pair` to the sidecar and returns a persisted `SidecarCredential`.
|
|
///
|
|
/// - Note: TLS pinning via `PinnedTrust` is wired in T-2.5.
|
|
/// Until then the request uses plain HTTP or default TLS validation.
|
|
func exchange(
|
|
host: String,
|
|
port: Int,
|
|
pairingToken: String,
|
|
fingerprint: String,
|
|
name: String,
|
|
deviceName: String
|
|
) async throws -> SidecarCredential {
|
|
|
|
// Prefer HTTPS; the sidecar uses a self-signed cert that will be
|
|
// pinned in T-2.5. For now plain HTTP is also acceptable during dev.
|
|
guard let url = URL(string: "http://\(host):\(port)/pair") else {
|
|
throw PairingError.invalidQR
|
|
}
|
|
|
|
var request = URLRequest(url: url, timeoutInterval: 15)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
|
|
let body = PairRequestBody(pairingToken: pairingToken, deviceName: deviceName)
|
|
do {
|
|
request.httpBody = try JSONEncoder().encode(body)
|
|
} catch {
|
|
throw PairingError.decodingFailed
|
|
}
|
|
|
|
let data: Data
|
|
let response: URLResponse
|
|
do {
|
|
(data, response) = try await URLSession.shared.data(for: request)
|
|
} catch {
|
|
throw PairingError.networkError(error)
|
|
}
|
|
|
|
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
|
let body = String(data: data, encoding: .utf8) ?? "<empty>"
|
|
throw PairingError.serverError(http.statusCode, body)
|
|
}
|
|
|
|
let decoded: PairResponseBody
|
|
do {
|
|
decoded = try JSONDecoder().decode(PairResponseBody.self, from: data)
|
|
} catch {
|
|
throw PairingError.decodingFailed
|
|
}
|
|
|
|
return SidecarCredential(
|
|
sidecarId: decoded.sidecarId,
|
|
host: host,
|
|
port: port,
|
|
bearerToken: decoded.bearerToken,
|
|
tlsFingerprint: fingerprint,
|
|
sidecarName: name,
|
|
pairedAt: Date()
|
|
)
|
|
}
|
|
}
|