crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }