NYTAuthService.swift (8860B)
1 import Foundation 2 import UIKit 3 import WebKit 4 5 @MainActor @Observable 6 final class NYTAuthService { 7 private(set) var isSignedIn = false 8 private(set) var signedInEmail: String? 9 private(set) var isLoading = false 10 var errorMessage: String? 11 12 nonisolated private static let emailKey = "nyt-email" 13 nonisolated private static let cookieKey = "nyt-cookie" 14 15 /// Thread-safe read of the stored NYT cookie for use from non-MainActor contexts. 16 nonisolated static func currentCookie() -> String? { 17 guard let data = KeychainHelper.load(key: cookieKey) else { return nil } 18 return String(data: data, encoding: .utf8) 19 } 20 21 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")! 22 23 func loadStoredSession() { 24 guard KeychainHelper.load(key: Self.cookieKey) != nil else { 25 isSignedIn = false 26 signedInEmail = nil 27 return 28 } 29 30 isSignedIn = true 31 if let emailData = KeychainHelper.load(key: Self.emailKey), 32 let email = String(data: emailData, encoding: .utf8), 33 !email.isEmpty { 34 signedInEmail = email 35 } else { 36 signedInEmail = nil 37 } 38 } 39 40 /// Called by the web login view after the user completes sign-in and the 41 /// WKWebView navigates to the redirect URL. Extracts the NYT-S cookie 42 /// from the provided cookie store. 43 func completeSignIn(cookieStore: WKHTTPCookieStore) async { 44 isLoading = true 45 errorMessage = nil 46 defer { isLoading = false } 47 48 do { 49 let cookies = await cookieStore.allCookies() 50 51 print("=== COOKIES FROM WEBVIEW ===") 52 for cookie in cookies { 53 print("\(cookie.name) (domain: \(cookie.domain))") 54 } 55 print("=== END COOKIES ===") 56 57 guard let nytsCookie = cookies.first(where: { $0.name == "NYT-S" }) else { 58 throw NYTAuthError.missingCookie 59 } 60 61 let cookieValue = nytsCookie.value 62 let email = try await fetchUserEmail(cookies: cookies) 63 64 try KeychainHelper.save( 65 key: Self.cookieKey, 66 data: Data(cookieValue.utf8) 67 ) 68 if let email { 69 try KeychainHelper.save( 70 key: Self.emailKey, 71 data: Data(email.utf8) 72 ) 73 } else { 74 KeychainHelper.delete(key: Self.emailKey) 75 } 76 isSignedIn = true 77 signedInEmail = email 78 } catch { 79 errorMessage = error.localizedDescription 80 } 81 } 82 83 func signOut() { 84 KeychainHelper.delete(key: Self.emailKey) 85 KeychainHelper.delete(key: Self.cookieKey) 86 isSignedIn = false 87 signedInEmail = nil 88 errorMessage = nil 89 } 90 91 var cookie: String? { 92 guard let data = KeychainHelper.load(key: Self.cookieKey) else { return nil } 93 return String(data: data, encoding: .utf8) 94 } 95 96 // MARK: - Private 97 98 private func fetchUserEmail(cookies: [HTTPCookie]) async throws -> String? { 99 let cookieHeader = Self.cookieHeader(for: cookies) 100 guard let configuration = try await fetchAccountGraphQLConfiguration( 101 cookieHeader: cookieHeader 102 ) else { 103 return nil 104 } 105 106 var request = URLRequest(url: configuration.url) 107 request.httpMethod = "POST" 108 request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") 109 request.setValue("application/json", forHTTPHeaderField: "Accept") 110 request.setValue("application/json", forHTTPHeaderField: "Content-Type") 111 for header in configuration.headers { 112 request.setValue(header.value, forHTTPHeaderField: header.key) 113 } 114 request.httpBody = try JSONSerialization.data(withJSONObject: [ 115 "operationName": "UserQuery", 116 "variables": [:], 117 "query": """ 118 query UserQuery { 119 user { 120 profile { 121 displayName 122 email 123 givenName 124 familyName 125 } 126 userInfo { 127 regiId 128 entitlements 129 } 130 } 131 } 132 """ 133 ]) 134 135 let (data, response) = try await URLSession.shared.data(for: request) 136 guard let httpResponse = response as? HTTPURLResponse, 137 httpResponse.statusCode == 200 else { 138 return nil 139 } 140 141 return Self.extractEmail(from: data) 142 } 143 144 private func fetchAccountGraphQLConfiguration( 145 cookieHeader: String 146 ) async throws -> AccountGraphQLConfiguration? { 147 var request = URLRequest(url: URL(string: "https://myaccount.nytimes.com")!) 148 request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") 149 request.setValue("text/html", forHTTPHeaderField: "Accept") 150 151 let (data, response) = try await URLSession.shared.data(for: request) 152 guard let httpResponse = response as? HTTPURLResponse, 153 httpResponse.statusCode == 200, 154 let html = String(data: data, encoding: .utf8) else { 155 return nil 156 } 157 158 return Self.extractAccountGraphQLConfiguration(from: html) 159 } 160 161 nonisolated static func extractEmail(from data: Data) -> String? { 162 guard let response = try? JSONDecoder().decode(UserQueryResponse.self, from: data), 163 let email = response.data.user.profile?.email, 164 !email.isEmpty else { 165 return nil 166 } 167 return email 168 } 169 170 nonisolated static func extractAccountGraphQLConfiguration( 171 from html: String 172 ) -> AccountGraphQLConfiguration? { 173 guard let urlString = firstJSONStringValue(named: "gqlUrlClient", in: html), 174 let url = URL(string: urlString), 175 let appType = firstJSONStringValue(named: "nyt-app-type", in: html), 176 let appVersion = firstJSONStringValue(named: "nyt-app-version", in: html), 177 let token = firstJSONStringValue(named: "nyt-token", in: html) else { 178 return nil 179 } 180 181 return AccountGraphQLConfiguration( 182 url: url, 183 headers: [ 184 "nyt-app-type": appType, 185 "nyt-app-version": appVersion, 186 "nyt-token": token 187 ] 188 ) 189 } 190 191 nonisolated private static func cookieHeader(for cookies: [HTTPCookie]) -> String { 192 cookies 193 .filter { cookie in 194 cookie.domain.contains("nytimes.com") 195 || cookie.domain.contains("nyt.com") 196 } 197 .map { "\($0.name)=\($0.value)" } 198 .joined(separator: "; ") 199 } 200 201 nonisolated private static func firstJSONStringValue( 202 named name: String, 203 in string: String 204 ) -> String? { 205 let escapedName = NSRegularExpression.escapedPattern(for: name) 206 let pattern = #"""# + escapedName + #""\s*:\s*"((?:\\.|[^"\\])*)"# 207 guard let regex = try? NSRegularExpression(pattern: pattern) else { 208 return nil 209 } 210 211 let range = NSRange(string.startIndex..<string.endIndex, in: string) 212 guard let match = regex.firstMatch(in: string, range: range), 213 let valueRange = Range(match.range(at: 1), in: string) else { 214 return nil 215 } 216 217 let rawValue = String(string[valueRange]) 218 let json = #"{"value":""# + rawValue + #""}"# 219 guard let data = json.data(using: .utf8), 220 let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { 221 return nil 222 } 223 return object["value"] 224 } 225 } 226 227 struct AccountGraphQLConfiguration: Equatable { 228 var url: URL 229 var headers: [String: String] 230 } 231 232 private struct UserQueryResponse: Decodable { 233 var data: UserQueryData 234 } 235 236 private struct UserQueryData: Decodable { 237 var user: UserQueryUser 238 } 239 240 private struct UserQueryUser: Decodable { 241 var profile: UserQueryProfile? 242 } 243 244 private struct UserQueryProfile: Decodable { 245 var email: String? 246 } 247 248 enum NYTAuthError: LocalizedError { 249 case invalidResponse 250 case httpError(statusCode: Int) 251 case missingCookie 252 case serverError(String) 253 254 var errorDescription: String? { 255 switch self { 256 case .invalidResponse: 257 "Received an invalid response from the server." 258 case .httpError(let statusCode): 259 "Sign in failed (HTTP \(statusCode))." 260 case .missingCookie: 261 "Sign in succeeded but the session cookie was not returned." 262 case .serverError(let message): 263 message 264 } 265 } 266 }