NYTAuthService.swift (11993B)
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 guard let nytsCookie = cookies.first(where: { $0.name == "NYT-S" }) else { 72 throw NYTAuthError.missingCookie 73 } 74 75 let cookieValue = nytsCookie.value 76 let email = try await fetchUserEmail(cookies: cookies) 77 78 try KeychainHelper.save( 79 key: Self.cookieKey, 80 data: Data(cookieValue.utf8) 81 ) 82 if let email { 83 try KeychainHelper.save( 84 key: Self.emailKey, 85 data: Data(email.utf8) 86 ) 87 } else { 88 KeychainHelper.delete(key: Self.emailKey) 89 } 90 sessionState = .signedIn(email: email) 91 } catch { 92 errorMessage = error.localizedDescription 93 } 94 } 95 96 func signOut() { 97 KeychainHelper.delete(key: Self.emailKey) 98 KeychainHelper.delete(key: Self.cookieKey) 99 sessionState = .signedOut 100 errorMessage = nil 101 } 102 103 var cookie: String? { 104 Self.currentCookie() 105 } 106 107 var isSignedIn: Bool { 108 if case .signedIn = sessionState { return true } 109 return false 110 } 111 112 var signedInEmail: String? { 113 if case .signedIn(let email) = sessionState { return email } 114 return nil 115 } 116 117 var canAttemptNYTFetch: Bool { 118 sessionState != .signedOut 119 } 120 121 var sessionStatusMessage: String? { 122 switch sessionState { 123 case .unknown: 124 "Crossmate could not determine whether you are signed in to NYT. Unlock the device and try again." 125 case .signedOut, .signedIn: 126 nil 127 } 128 } 129 130 // MARK: - Private 131 132 private func logCookieKeychainLoad( 133 result: (data: Data?, status: OSStatus) 134 ) { 135 log?( 136 "NYT session cookie keychain load: status=\(Self.keychainStatusDescription(result.status)) " + 137 "hasData=\(result.data != nil)" 138 ) 139 } 140 141 nonisolated private static func keychainStatusDescription(_ status: OSStatus) -> String { 142 if status == errSecSuccess { 143 return "success(0)" 144 } 145 if status == errSecItemNotFound { 146 return "itemNotFound(\(status))" 147 } 148 let message = SecCopyErrorMessageString(status, nil) as String? 149 if let message, !message.isEmpty { 150 return "\(message)(\(status))" 151 } 152 return "OSStatus(\(status))" 153 } 154 155 nonisolated static func sessionState( 156 forCookieLoadResult result: NYTCookieLoadResult, 157 email: String? = nil 158 ) -> NYTSessionState { 159 switch result { 160 case .available: 161 .signedIn(email: email) 162 case .missing: 163 .signedOut 164 case .temporarilyUnavailable: 165 .unknown 166 } 167 } 168 169 nonisolated private static func cookieLoadResult( 170 from result: (data: Data?, status: OSStatus) 171 ) -> NYTCookieLoadResult { 172 guard result.status == errSecSuccess, 173 let data = result.data, 174 let cookie = String(data: data, encoding: .utf8), 175 !cookie.isEmpty else { 176 return result.status == errSecInteractionNotAllowed 177 ? .temporarilyUnavailable 178 : .missing 179 } 180 return .available(cookie) 181 } 182 183 private func storedEmail() -> String? { 184 let emailResult = KeychainHelper.loadWithStatus(key: Self.emailKey) 185 guard let emailData = emailResult.data, 186 let email = String(data: emailData, encoding: .utf8), 187 !email.isEmpty else { 188 return nil 189 } 190 return email 191 } 192 193 private func migrateCookieAccessibility(_ cookie: String) { 194 do { 195 try KeychainHelper.save(key: Self.cookieKey, data: Data(cookie.utf8)) 196 } catch { 197 log?("NYT session cookie keychain migration failed: \(error)") 198 } 199 } 200 201 private func fetchUserEmail(cookies: [HTTPCookie]) async throws -> String? { 202 let cookieHeader = Self.cookieHeader(for: cookies) 203 guard let configuration = try await fetchAccountGraphQLConfiguration( 204 cookieHeader: cookieHeader 205 ) else { 206 return nil 207 } 208 209 var request = URLRequest(url: configuration.url) 210 request.httpMethod = "POST" 211 request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") 212 request.setValue("application/json", forHTTPHeaderField: "Accept") 213 request.setValue("application/json", forHTTPHeaderField: "Content-Type") 214 for header in configuration.headers { 215 request.setValue(header.value, forHTTPHeaderField: header.key) 216 } 217 request.httpBody = try JSONSerialization.data(withJSONObject: [ 218 "operationName": "UserQuery", 219 "variables": [:], 220 "query": """ 221 query UserQuery { 222 user { 223 profile { 224 displayName 225 email 226 givenName 227 familyName 228 } 229 userInfo { 230 regiId 231 entitlements 232 } 233 } 234 } 235 """ 236 ]) 237 238 let (data, response) = try await URLSession.shared.data(for: request) 239 guard let httpResponse = response as? HTTPURLResponse, 240 httpResponse.statusCode == 200 else { 241 return nil 242 } 243 244 return Self.extractEmail(from: data) 245 } 246 247 private func fetchAccountGraphQLConfiguration( 248 cookieHeader: String 249 ) async throws -> AccountGraphQLConfiguration? { 250 var request = URLRequest(url: URL(string: "https://myaccount.nytimes.com")!) 251 request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") 252 request.setValue("text/html", forHTTPHeaderField: "Accept") 253 254 let (data, response) = try await URLSession.shared.data(for: request) 255 guard let httpResponse = response as? HTTPURLResponse, 256 httpResponse.statusCode == 200, 257 let html = String(data: data, encoding: .utf8) else { 258 return nil 259 } 260 261 return Self.extractAccountGraphQLConfiguration(from: html) 262 } 263 264 nonisolated static func extractEmail(from data: Data) -> String? { 265 guard let response = try? JSONDecoder().decode(UserQueryResponse.self, from: data), 266 let email = response.data.user.profile?.email, 267 !email.isEmpty else { 268 return nil 269 } 270 return email 271 } 272 273 nonisolated static func extractAccountGraphQLConfiguration( 274 from html: String 275 ) -> AccountGraphQLConfiguration? { 276 guard let urlString = firstJSONStringValue(named: "gqlUrlClient", in: html), 277 let url = URL(string: urlString), 278 url.scheme?.lowercased() == "https", 279 let appType = firstJSONStringValue(named: "nyt-app-type", in: html), 280 let appVersion = firstJSONStringValue(named: "nyt-app-version", in: html), 281 let token = firstJSONStringValue(named: "nyt-token", in: html) else { 282 return nil 283 } 284 285 return AccountGraphQLConfiguration( 286 url: url, 287 headers: [ 288 "nyt-app-type": appType, 289 "nyt-app-version": appVersion, 290 "nyt-token": token 291 ] 292 ) 293 } 294 295 nonisolated private static func cookieHeader(for cookies: [HTTPCookie]) -> String { 296 cookies 297 .filter { cookie in 298 cookie.domain.contains("nytimes.com") 299 || cookie.domain.contains("nyt.com") 300 } 301 .map { "\($0.name)=\($0.value)" } 302 .joined(separator: "; ") 303 } 304 305 nonisolated private static func firstJSONStringValue( 306 named name: String, 307 in string: String 308 ) -> String? { 309 let escapedName = NSRegularExpression.escapedPattern(for: name) 310 let pattern = #"""# + escapedName + #""\s*:\s*"((?:\\.|[^"\\])*)"# 311 guard let regex = try? NSRegularExpression(pattern: pattern) else { 312 return nil 313 } 314 315 let range = NSRange(string.startIndex..<string.endIndex, in: string) 316 guard let match = regex.firstMatch(in: string, range: range), 317 let valueRange = Range(match.range(at: 1), in: string) else { 318 return nil 319 } 320 321 let rawValue = String(string[valueRange]) 322 let json = #"{"value":""# + rawValue + #""}"# 323 guard let data = json.data(using: .utf8), 324 let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { 325 return nil 326 } 327 return object["value"] 328 } 329 } 330 331 struct AccountGraphQLConfiguration: Equatable { 332 var url: URL 333 var headers: [String: String] 334 } 335 336 private struct UserQueryResponse: Decodable { 337 var data: UserQueryData 338 } 339 340 private struct UserQueryData: Decodable { 341 var user: UserQueryUser 342 } 343 344 private struct UserQueryUser: Decodable { 345 var profile: UserQueryProfile? 346 } 347 348 private struct UserQueryProfile: Decodable { 349 var email: String? 350 } 351 352 enum NYTAuthError: LocalizedError { 353 case invalidResponse 354 case httpError(statusCode: Int) 355 case missingCookie 356 case serverError(String) 357 358 var errorDescription: String? { 359 switch self { 360 case .invalidResponse: 361 "Received an invalid response from the server." 362 case .httpError(let statusCode): 363 "Sign in failed (HTTP \(statusCode))." 364 case .missingCookie: 365 "Sign in succeeded but the session cookie was not returned." 366 case .serverError(let message): 367 message 368 } 369 } 370 }