crossmate

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

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 }