1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
//
// LoopbackServer.swift
// AT-IRC
//
// Tiny one-shot HTTP listener for OAuth loopback redirects
// (http://127.0.0.1:<port>/) — the native-app pattern from the
// AT Protocol OAuth spec.
//
import Foundation
import Network
import OSLog
@MainActor
final class LoopbackServer {
private var listener: NWListener?
private var startContinuation: CheckedContinuation<UInt16, Error>?
private var callbackContinuation: CheckedContinuation<[String: String], Error>?
private var received: [String: String]?
private(set) var port: UInt16 = 0
/// Start listening on an ephemeral loopback port and return it. Never
/// binds a wildcard interface: the OAuth redirect listener must stay
/// reachable from this device only. Two loopback-only spellings are tried
/// because some devices reject one of them.
func start() async throws -> UInt16 {
guard listener == nil else { return port }
var lastError: Error = NSError(
domain: "ATIRC.LoopbackServer", code: 1,
userInfo: [NSLocalizedDescriptionKey: "no loopback listener variant succeeded"]
)
for host: NWEndpoint.Host in [.ipv4(.loopback), "127.0.0.1"] {
do {
return try await startListener(host: host)
} catch {
self.listener = nil
lastError = error
Logger(subsystem: "com.fedfork.irc.AT-IRC", category: "market-oauth")
.error("loopback listener \(host.debugDescription, privacy: .public) failed: \(error.localizedDescription, privacy: .public)")
}
}
throw lastError
}
private func startListener(host: NWEndpoint.Host) async throws -> UInt16 {
try await withCheckedThrowingContinuation { continuation in
startContinuation = continuation
do {
let parameters = NWParameters.tcp
parameters.allowLocalEndpointReuse = true
// Loopback only, so the redirect can only be delivered
// locally; never 0.0.0.0 / any interface.
parameters.requiredLocalEndpoint = .hostPort(host: host, port: .any)
let listener = try NWListener(using: parameters)
self.listener = listener
listener.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
guard let self else { return }
switch state {
case .ready:
guard let assigned = listener.port?.rawValue else { return }
self.port = assigned
if let continuation = self.startContinuation {
self.startContinuation = nil
continuation.resume(returning: assigned)
}
case .failed(let error):
listener.cancel()
self.listener = nil
if let continuation = self.startContinuation {
self.startContinuation = nil
continuation.resume(throwing: error)
}
default:
break
}
}
}
listener.newConnectionHandler = { [weak self] connection in
Task { @MainActor in
self?.handle(connection)
}
}
listener.start(queue: .global(qos: .userInitiated))
} catch {
startContinuation = nil
continuation.resume(throwing: error)
}
}
}
/// Wait for the authorization server to redirect back with query items.
func waitForCallback() async throws -> [String: String] {
if let received { return received }
return try await withCheckedThrowingContinuation { continuation in
callbackContinuation = continuation
}
}
/// Keep the listener for the next redirect (the requester's OAuth flow
/// reuses one loopback port for every sign-in attempt).
func resetCallback() {
received = nil
}
func stop() {
listener?.cancel()
listener = nil
}
// MARK: - Connection handling
private func handle(_ connection: NWConnection) {
// Defense in depth: even though the listener is pinned to loopback,
// refuse anything whose remote endpoint is not local.
guard Self.isLoopback(connection.endpoint) else {
connection.cancel()
return
}
connection.start(queue: .global(qos: .userInitiated))
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] data, _, _, error in
let requestData = data
Task { @MainActor in
guard let self else { return }
if let error {
self.finish(with: .failure(error))
connection.cancel()
return
}
guard let requestData, let text = String(data: requestData, encoding: .utf8) else {
connection.cancel()
return
}
let path = Self.requestTarget(from: text)
let query = Self.queryItems(path)
self.respond(to: connection, query: query)
if !query.isEmpty {
self.finish(with: .success(query))
}
}
}
}
private func respond(to connection: NWConnection, query: [String: String]) {
let message: String
if query["error"] != nil {
message = "Sign-in failed: \(query["error"] ?? "unknown error"). You can close this window."
} else if query["code"] != nil {
message = "Signed in. You can return to AT-IRC."
} else {
message = "Waiting for sign-in…"
}
let html = """
<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1">
<title>AT-IRC</title></head>
<body style="font:17px/1.5 -apple-system,system-ui;margin:3rem 1.5rem;text-align:center">
<p>\(message)</p></body></html>
"""
let body = Data(html.utf8)
var response = Data("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: \(body.count)\r\nConnection: close\r\n\r\n".utf8)
response.append(body)
connection.send(content: response, completion: .contentProcessed { _ in
connection.cancel()
})
}
private func finish(with result: Result<[String: String], Error>) {
guard let continuation = callbackContinuation else { return }
callbackContinuation = nil
switch result {
case .success(let query):
received = query
continuation.resume(returning: query)
case .failure(let error):
continuation.resume(throwing: error)
}
}
/// True for IPv4 127.0.0.1, IPv6 ::1, or localhost.
private static func isLoopback(_ endpoint: NWEndpoint) -> Bool {
guard case let .hostPort(host, _) = endpoint else { return false }
switch host {
case .ipv4(let address):
return address == .loopback
case .ipv6(let address):
return address == .loopback
case .name(let name, _):
return name.lowercased() == "localhost"
@unknown default:
return false
}
}
/// Extract the request target from the HTTP request line.
private static func requestTarget(from request: String) -> String {
guard let firstLine = request.split(separator: "\r\n", maxSplits: 1).first else {
return ""
}
let parts = firstLine.split(separator: " ")
guard parts.count >= 2 else { return "" }
return String(parts[1])
}
private static func queryItems(_ target: String) -> [String: String] {
guard let components = URLComponents(string: "http://127.0.0.1\(target)"),
let items = components.queryItems else {
return [:]
}
var result: [String: String] = [:]
for item in items {
result[item.name] = item.value ?? ""
}
return result
}
}