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:
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")
+ }
+}