crossmate

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

commit dce12946624fd3c3e7d41785497efe032febc1a8
parent 155be03f2175684e59741415de897d959eab6e9c
Author: Michael Camilleri <[email protected]>
Date:   Thu,  7 May 2026 14:36:35 +0900

Fetch account e-mail through GraphQL profile

The previous sign-in flow for the NYT tried to read the user's email from a
previously supported URL. That endpoint now returns 404 with an NYT-S-only
session, while the crossword puzzle endpoints still accept the same cookie.

This commit switches email discovery to the account page's GraphQL path.
Signed-in sessions no longer require an email value to be stored. If NYT does
not expose the email, Settings shows a generic signed-in status instead of the
misleading "NYT User" fallback.

NYTAuthServiceTests cover GraphQL email decoding, ignoring unrelated
email-looking text, missing-email responses and account-page GraphQL config
extraction.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Services/NYTAuthService.swift | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
MCrossmate/Views/SettingsView.swift | 6+++++-
ATests/Unit/NYTAuthServiceTests.swift | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 230 insertions(+), 23 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -68,6 +68,7 @@ B94919176DEC6EC31637B037 /* ClueList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */; }; BCB9A4D5E06EE5006186465D /* ShareController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C74683332956B0D1CA37589 /* ShareController.swift */; }; BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */; }; + C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */; }; C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; }; C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */; }; C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */; }; @@ -189,6 +190,7 @@ E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClueList.swift; sourceTree = "<group>"; }; EAC61E2582D94B1E6EC67136 /* XDFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDFileType.swift; sourceTree = "<group>"; }; EBC4C0246B2BCE686A3516DB /* GamePlayerColorStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamePlayerColorStoreTests.swift; sourceTree = "<group>"; }; + ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthServiceTests.swift; sourceTree = "<group>"; }; F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGameSheet.swift; sourceTree = "<group>"; }; F7422F19AA1F1692A98E3602 /* MoveLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveLog.swift; sourceTree = "<group>"; }; F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellView.swift; sourceTree = "<group>"; }; @@ -239,6 +241,7 @@ 543481AA9FA32BF14076EB1C /* MoveLogTests.swift */, 9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */, 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */, + ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */, C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */, 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */, ACD511B95227C7D57F32A2AC /* PresencePublisherTests.swift */, @@ -501,6 +504,7 @@ 453E30B78DFB4B689D70EE2C /* GameStoreUnseenMovesTests.swift in Sources */, 3D407AF18566F6BA5261DF55 /* MoveBufferTests.swift in Sources */, 24B460FECF10A5BCC29E204E /* MoveLogTests.swift in Sources */, + C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */, AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */, 7C0AAD1DD6086E76A6DA806B /* NameBroadcasterTests.swift in Sources */, E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */, diff --git a/Crossmate/Services/NYTAuthService.swift b/Crossmate/Services/NYTAuthService.swift @@ -21,16 +21,20 @@ final class NYTAuthService { 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")! func loadStoredSession() { - guard let emailData = KeychainHelper.load(key: Self.emailKey), - let email = String(data: emailData, encoding: .utf8), - KeychainHelper.load(key: Self.cookieKey) != nil - else { + guard KeychainHelper.load(key: Self.cookieKey) != nil else { isSignedIn = false signedInEmail = nil return } + isSignedIn = true - signedInEmail = email + if let emailData = KeychainHelper.load(key: Self.emailKey), + let email = String(data: emailData, encoding: .utf8), + !email.isEmpty { + signedInEmail = email + } else { + signedInEmail = nil + } } /// Called by the web login view after the user completes sign-in and the @@ -46,7 +50,7 @@ final class NYTAuthService { print("=== COOKIES FROM WEBVIEW ===") for cookie in cookies { - print("\(cookie.name) = \(cookie.value.prefix(40))... (domain: \(cookie.domain))") + print("\(cookie.name) (domain: \(cookie.domain))") } print("=== END COOKIES ===") @@ -55,16 +59,20 @@ final class NYTAuthService { } let cookieValue = nytsCookie.value - let email = try await fetchUserEmail(cookie: cookieValue) + let email = try await fetchUserEmail(cookies: cookies) try KeychainHelper.save( - key: Self.emailKey, - data: Data(email.utf8) - ) - try KeychainHelper.save( key: Self.cookieKey, data: Data(cookieValue.utf8) ) + if let email { + try KeychainHelper.save( + key: Self.emailKey, + data: Data(email.utf8) + ) + } else { + KeychainHelper.delete(key: Self.emailKey) + } isSignedIn = true signedInEmail = email } catch { @@ -87,28 +95,156 @@ final class NYTAuthService { // MARK: - Private - private func fetchUserEmail(cookie: String) async throws -> String { - let url = URL(string: "https://myaccount.nytimes.com/svc/ios/v2/user")! - var request = URLRequest(url: url) - request.setValue("NYT-S=\(cookie)", forHTTPHeaderField: "Cookie") + private func fetchUserEmail(cookies: [HTTPCookie]) async throws -> String? { + let cookieHeader = Self.cookieHeader(for: cookies) + guard let configuration = try await fetchAccountGraphQLConfiguration( + cookieHeader: cookieHeader + ) else { + return nil + } - let (data, response) = try await URLSession.shared.data(for: request) + var request = URLRequest(url: configuration.url) + request.httpMethod = "POST" + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + for header in configuration.headers { + request.setValue(header.value, forHTTPHeaderField: header.key) + } + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "operationName": "UserQuery", + "variables": [:], + "query": """ + query UserQuery { + user { + profile { + displayName + email + givenName + familyName + } + userInfo { + regiId + entitlements + } + } + } + """ + ]) + let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { - return "NYT User" + return nil + } + + return Self.extractEmail(from: data) + } + + private func fetchAccountGraphQLConfiguration( + cookieHeader: String + ) async throws -> AccountGraphQLConfiguration? { + var request = URLRequest(url: URL(string: "https://myaccount.nytimes.com")!) + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("text/html", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200, + let html = String(data: data, encoding: .utf8) else { + return nil + } + + return Self.extractAccountGraphQLConfiguration(from: html) + } + + nonisolated static func extractEmail(from data: Data) -> String? { + guard let response = try? JSONDecoder().decode(UserQueryResponse.self, from: data), + let email = response.data.user.profile?.email, + !email.isEmpty else { + return nil + } + return email + } + + nonisolated static func extractAccountGraphQLConfiguration( + from html: String + ) -> AccountGraphQLConfiguration? { + guard let urlString = firstJSONStringValue(named: "gqlUrlClient", in: html), + let url = URL(string: urlString), + let appType = firstJSONStringValue(named: "nyt-app-type", in: html), + let appVersion = firstJSONStringValue(named: "nyt-app-version", in: html), + let token = firstJSONStringValue(named: "nyt-token", in: html) else { + return nil + } + + return AccountGraphQLConfiguration( + url: url, + headers: [ + "nyt-app-type": appType, + "nyt-app-version": appVersion, + "nyt-token": token + ] + ) + } + + nonisolated private static func cookieHeader(for cookies: [HTTPCookie]) -> String { + cookies + .filter { cookie in + cookie.domain.contains("nytimes.com") + || cookie.domain.contains("nyt.com") + } + .map { "\($0.name)=\($0.value)" } + .joined(separator: "; ") + } + + nonisolated private static func firstJSONStringValue( + named name: String, + in string: String + ) -> String? { + let escapedName = NSRegularExpression.escapedPattern(for: name) + let pattern = #"""# + escapedName + #""\s*:\s*"((?:\\.|[^"\\])*)"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return nil } - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let dataDict = json["data"] as? [String: Any], - let email = dataDict["email"] as? String { - return email + let range = NSRange(string.startIndex..<string.endIndex, in: string) + guard let match = regex.firstMatch(in: string, range: range), + let valueRange = Range(match.range(at: 1), in: string) else { + return nil } - return "NYT User" + let rawValue = String(string[valueRange]) + let json = #"{"value":""# + rawValue + #""}"# + guard let data = json.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { + return nil + } + return object["value"] } } +struct AccountGraphQLConfiguration: Equatable { + var url: URL + var headers: [String: String] +} + +private struct UserQueryResponse: Decodable { + var data: UserQueryData +} + +private struct UserQueryData: Decodable { + var user: UserQueryUser +} + +private struct UserQueryUser: Decodable { + var profile: UserQueryProfile? +} + +private struct UserQueryProfile: Decodable { + var email: String? +} + enum NYTAuthError: LocalizedError { case invalidResponse case httpError(statusCode: Int) diff --git a/Crossmate/Views/SettingsView.swift b/Crossmate/Views/SettingsView.swift @@ -78,7 +78,11 @@ struct SettingsView: View { @ViewBuilder private var signedInView: some View { - LabeledContent("Email", value: nytAuth.signedInEmail ?? "") + if let email = nytAuth.signedInEmail { + LabeledContent("E-mail", value: email) + } else { + LabeledContent("Status", value: "Signed in") + } Button("Sign Out", role: .destructive) { nytAuth.signOut() } diff --git a/Tests/Unit/NYTAuthServiceTests.swift b/Tests/Unit/NYTAuthServiceTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("NYT auth service") +struct NYTAuthServiceTests { + @Test("Extracts email from GraphQL profile") + func extractsEmailFromGraphQLProfile() throws { + let data = Data(""" + { + "data": { + "user": { + "profile": { + "email": "[email protected]" + } + } + } + } + """.utf8) + + #expect(NYTAuthService.extractEmail(from: data) == "[email protected]") + } + + @Test("Ignores emails outside GraphQL profile field") + func ignoresEmailsOutsideGraphQLProfileField() throws { + let data = Data(#"<html><body>Signed in as [email protected]</body></html>"#.utf8) + + #expect(NYTAuthService.extractEmail(from: data) == nil) + } + + @Test("Returns nil when no email is present") + func returnsNilWhenNoEmailIsPresent() throws { + let data = Data(#"{"data":{"name":"NYT User"}}"#.utf8) + + #expect(NYTAuthService.extractEmail(from: data) == nil) + } + + @Test("Extracts GraphQL account configuration from account page HTML") + func extractsGraphQLAccountConfiguration() throws { + let html = #""" + <script> + window.__preloadedData = { + "config": { + "gqlUrlClient": "https:\u002F\u002Fsamizdat-graphql.nytimes.com\u002Fgraphql\u002Fv2", + "gqlRequestHeaders": { + "nyt-app-type": "project-vi", + "nyt-app-version": "0.0.5", + "nyt-token": "abc\u002F123" + } + } + } + </script> + """# + + let configuration = NYTAuthService.extractAccountGraphQLConfiguration(from: html) + + #expect(configuration?.url.absoluteString == "https://samizdat-graphql.nytimes.com/graphql/v2") + #expect(configuration?.headers["nyt-app-type"] == "project-vi") + #expect(configuration?.headers["nyt-app-version"] == "0.0.5") + #expect(configuration?.headers["nyt-token"] == "abc/123") + } +}