crossmate

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

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 }