NYTAuthService.swift (12151B)
1 import Foundation 2 import Security 3 import UIKit 4 import WebKit 5 6 enum NYTSessionState: Equatable { 7 case unknown 8 case signedOut 9 case signedIn(email: String?) 10 } 11 12 enum NYTCookieLoadResult: Equatable, Sendable { 13 case available(String) 14 case missing 15 case temporarilyUnavailable 16 } 17 18 @MainActor @Observable 19 final class NYTAuthService { 20 private(set) var sessionState: NYTSessionState = .unknown 21 private(set) var isLoading = false 22 var errorMessage: String? 23 private let log: ((String) -> Void)? 24 25 nonisolated private static let emailKey = "nyt-email" 26 nonisolated private static let cookieKey = "nyt-cookie" 27 28 init(log: ((String) -> Void)? = nil) { 29 self.log = log 30 } 31 32 /// Thread-safe read of the stored NYT cookie for use from non-MainActor contexts. 33 nonisolated static func currentCookie() -> String? { 34 guard case .available(let cookie) = currentCookieResult() else { return nil } 35 return cookie 36 } 37 38 /// Thread-safe read of the stored NYT cookie that preserves transient keychain failures. 39 nonisolated static func currentCookieResult() -> NYTCookieLoadResult { 40 cookieLoadResult(from: KeychainHelper.loadWithStatus(key: cookieKey)) 41 } 42 43 static let loginURL = URL(string: "https://myaccount.nytimes.com/auth/login?response_type=cookie&client_id=games&redirect_uri=https%3A%2F%2Fwww.nytimes.com%2Fcrosswords")! 44 45 func loadStoredSession() { 46 let cookieResult = KeychainHelper.loadWithStatus(key: Self.cookieKey) 47 logCookieKeychainLoad(result: cookieResult) 48 49 switch Self.cookieLoadResult(from: cookieResult) { 50 case .available(let cookie): 51 migrateCookieAccessibility(cookie) 52 sessionState = .signedIn(email: storedEmail()) 53 case .missing: 54 sessionState = .signedOut 55 case .temporarilyUnavailable: 56 sessionState = .unknown 57 } 58 } 59 60 /// Called by the web login view after the user completes sign-in and the 61 /// WKWebView navigates to the redirect URL. Extracts the NYT-S cookie 62 /// from the provided cookie store. 63 func completeSignIn(cookieStore: WKHTTPCookieStore) async { 64 isLoading = true 65 errorMessage = nil 66 defer { isLoading = false } 67 68 do { 69 let cookies = await cookieStore.allCookies() 70 71 print("=== COOKIES FROM WEBVIEW ===") 72 for cookie in cookies { 73 print("\(cookie.name) (domain: \(cookie.domain))") 74 } 75 print("=== END COOKIES ===") 76 77 guard let nytsCookie = cookies.first(where: { $0.name == "NYT-S" }) else { 78 throw NYTAuthError.missingCookie 79 } 80 81 let cookieValue = nytsCookie.value 82 let email = try await fetchUserEmail(cookies: cookies) 83 84 try KeychainHelper.save( 85 key: Self.cookieKey, 86 data: Data(cookieValue.utf8) 87 ) 88 if let email { 89 try KeychainHelper.save( 90 key: Self.emailKey, 91 data: Data(email.utf8) 92 ) 93 } else { 94 KeychainHelper.delete(key: Self.emailKey) 95 } 96 sessionState = .signedIn(email: email) 97 } catch { 98 errorMessage = error.localizedDescription 99 } 100 } 101 102 func signOut() { 103 KeychainHelper.delete(key: Self.emailKey) 104 KeychainHelper.delete(key: Self.cookieKey) 105 sessionState = .signedOut 106 errorMessage = nil 107 } 108 109 var cookie: String? { 110 Self.currentCookie() 111 } 112 113 var isSignedIn: Bool { 114 if case .signedIn = sessionState { return true } 115 return false 116 } 117 118 var signedInEmail: String? { 119 if case .signedIn(let email) = sessionState { return email } 120 return nil 121 } 122 123 var canAttemptNYTFetch: Bool { 124 sessionState != .signedOut 125 } 126 127 var sessionStatusMessage: String? { 128 switch sessionState { 129 case .unknown: 130 "Crossmate could not determine whether you are signed in to NYT. Unlock the device and try again." 131 case .signedOut, .signedIn: 132 nil 133 } 134 } 135 136 // MARK: - Private 137 138 private func logCookieKeychainLoad( 139 result: (data: Data?, status: OSStatus) 140 ) { 141 log?( 142 "NYT session cookie keychain load: status=\(Self.keychainStatusDescription(result.status)) " + 143 "hasData=\(result.data != nil)" 144 ) 145 } 146 147 nonisolated private static func keychainStatusDescription(_ status: OSStatus) -> String { 148 if status == errSecSuccess { 149 return "success(0)" 150 } 151 if status == errSecItemNotFound { 152 return "itemNotFound(\(status))" 153 } 154 let message = SecCopyErrorMessageString(status, nil) as String? 155 if let message, !message.isEmpty { 156 return "\(message)(\(status))" 157 } 158 return "OSStatus(\(status))" 159 } 160 161 nonisolated static func sessionState( 162 forCookieLoadResult result: NYTCookieLoadResult, 163 email: String? = nil 164 ) -> NYTSessionState { 165 switch result { 166 case .available: 167 .signedIn(email: email) 168 case .missing: 169 .signedOut 170 case .temporarilyUnavailable: 171 .unknown 172 } 173 } 174 175 nonisolated private static func cookieLoadResult( 176 from result: (data: Data?, status: OSStatus) 177 ) -> NYTCookieLoadResult { 178 guard result.status == errSecSuccess, 179 let data = result.data, 180 let cookie = String(data: data, encoding: .utf8), 181 !cookie.isEmpty else { 182 return result.status == errSecInteractionNotAllowed 183 ? .temporarilyUnavailable 184 : .missing 185 } 186 return .available(cookie) 187 } 188 189 private func storedEmail() -> String? { 190 let emailResult = KeychainHelper.loadWithStatus(key: Self.emailKey) 191 guard let emailData = emailResult.data, 192 let email = String(data: emailData, encoding: .utf8), 193 !email.isEmpty else { 194 return nil 195 } 196 return email 197 } 198 199 private func migrateCookieAccessibility(_ cookie: String) { 200 do { 201 try KeychainHelper.save(key: Self.cookieKey, data: Data(cookie.utf8)) 202 } catch { 203 log?("NYT session cookie keychain migration failed: \(error)") 204 } 205 } 206 207 private func fetchUserEmail(cookies: [HTTPCookie]) async throws -> String? { 208 let cookieHeader = Self.cookieHeader(for: cookies) 209 guard let configuration = try await fetchAccountGraphQLConfiguration( 210 cookieHeader: cookieHeader 211 ) else { 212 return nil 213 } 214 215 var request = URLRequest(url: configuration.url) 216 request.httpMethod = "POST" 217 request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") 218 request.setValue("application/json", forHTTPHeaderField: "Accept") 219 request.setValue("application/json", forHTTPHeaderField: "Content-Type") 220 for header in configuration.headers { 221 request.setValue(header.value, forHTTPHeaderField: header.key) 222 } 223 request.httpBody = try JSONSerialization.data(withJSONObject: [ 224 "operationName": "UserQuery", 225 "variables": [:], 226 "query": """ 227 query UserQuery { 228 user { 229 profile { 230 displayName 231 email 232 givenName 233 familyName 234 } 235 userInfo { 236 regiId 237 entitlements 238 } 239 } 240 } 241 """ 242 ]) 243 244 let (data, response) = try await URLSession.shared.data(for: request) 245 guard let httpResponse = response as? HTTPURLResponse, 246 httpResponse.statusCode == 200 else { 247 return nil 248 } 249 250 return Self.extractEmail(from: data) 251 } 252 253 private func fetchAccountGraphQLConfiguration( 254 cookieHeader: String 255 ) async throws -> AccountGraphQLConfiguration? { 256 var request = URLRequest(url: URL(string: "https://myaccount.nytimes.com")!) 257 request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") 258 request.setValue("text/html", forHTTPHeaderField: "Accept") 259 260 let (data, response) = try await URLSession.shared.data(for: request) 261 guard let httpResponse = response as? HTTPURLResponse, 262 httpResponse.statusCode == 200, 263 let html = String(data: data, encoding: .utf8) else { 264 return nil 265 } 266 267 return Self.extractAccountGraphQLConfiguration(from: html) 268 } 269 270 nonisolated static func extractEmail(from data: Data) -> String? { 271 guard let response = try? JSONDecoder().decode(UserQueryResponse.self, from: data), 272 let email = response.data.user.profile?.email, 273 !email.isEmpty else { 274 return nil 275 } 276 return email 277 } 278 279 nonisolated static func extractAccountGraphQLConfiguration( 280 from html: String 281 ) -> AccountGraphQLConfiguration? { 282 guard let urlString = firstJSONStringValue(named: "gqlUrlClient", in: html), 283 let url = URL(string: urlString), 284 let appType = firstJSONStringValue(named: "nyt-app-type", in: html), 285 let appVersion = firstJSONStringValue(named: "nyt-app-version", in: html), 286 let token = firstJSONStringValue(named: "nyt-token", in: html) else { 287 return nil 288 } 289 290 return AccountGraphQLConfiguration( 291 url: url, 292 headers: [ 293 "nyt-app-type": appType, 294 "nyt-app-version": appVersion, 295 "nyt-token": token 296 ] 297 ) 298 } 299 300 nonisolated private static func cookieHeader(for cookies: [HTTPCookie]) -> String { 301 cookies 302 .filter { cookie in 303 cookie.domain.contains("nytimes.com") 304 || cookie.domain.contains("nyt.com") 305 } 306 .map { "\($0.name)=\($0.value)" } 307 .joined(separator: "; ") 308 } 309 310 nonisolated private static func firstJSONStringValue( 311 named name: String, 312 in string: String 313 ) -> String? { 314 let escapedName = NSRegularExpression.escapedPattern(for: name) 315 let pattern = #"""# + escapedName + #""\s*:\s*"((?:\\.|[^"\\])*)"# 316 guard let regex = try? NSRegularExpression(pattern: pattern) else { 317 return nil 318 } 319 320 let range = NSRange(string.startIndex..<string.endIndex, in: string) 321 guard let match = regex.firstMatch(in: string, range: range), 322 let valueRange = Range(match.range(at: 1), in: string) else { 323 return nil 324 } 325 326 let rawValue = String(string[valueRange]) 327 let json = #"{"value":""# + rawValue + #""}"# 328 guard let data = json.data(using: .utf8), 329 let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { 330 return nil 331 } 332 return object["value"] 333 } 334 } 335 336 struct AccountGraphQLConfiguration: Equatable { 337 var url: URL 338 var headers: [String: String] 339 } 340 341 private struct UserQueryResponse: Decodable { 342 var data: UserQueryData 343 } 344 345 private struct UserQueryData: Decodable { 346 var user: UserQueryUser 347 } 348 349 private struct UserQueryUser: Decodable { 350 var profile: UserQueryProfile? 351 } 352 353 private struct UserQueryProfile: Decodable { 354 var email: String? 355 } 356 357 enum NYTAuthError: LocalizedError { 358 case invalidResponse 359 case httpError(statusCode: Int) 360 case missingCookie 361 case serverError(String) 362 363 var errorDescription: String? { 364 switch self { 365 case .invalidResponse: 366 "Received an invalid response from the server." 367 case .httpError(let statusCode): 368 "Sign in failed (HTTP \(statusCode))." 369 case .missingCookie: 370 "Sign in succeeded but the session cookie was not returned." 371 case .serverError(let message): 372 message 373 } 374 } 375 }