ソースを参照

Harden Google OAuth session handling and remove manual credential prompts.

Move token persistence to Keychain, add revoke-on-logout, and make login fully app-config-driven so users sign in without entering OAuth secrets manually.

Made-with: Cursor
huzaifahayat12 1 ヶ月 前
コミット
505417aa47
共有3 個のファイルを変更した172 個の追加77 個の削除を含む
  1. 39 1
      meetings_app/Auth/GoogleOAuthService.swift
  2. 88 10
      meetings_app/Auth/KeychainTokenStore.swift
  3. 45 66
      meetings_app/ViewController.swift

+ 39 - 1
meetings_app/Auth/GoogleOAuthService.swift

@@ -32,9 +32,12 @@ final class GoogleOAuthService: NSObject {
32 32
     static let shared = GoogleOAuthService()
33 33
 
34 34
     // Stored in UserDefaults so you can configure without rebuilding.
35
-    // Put your OAuth Desktop client ID here (from Google Cloud Console).
35
+    // Configure at runtime or via Info.plist keys.
36 36
     private let clientIdDefaultsKey = "google.oauth.clientId"
37 37
     private let clientSecretDefaultsKey = "google.oauth.clientSecret"
38
+    private let clientIdPlistKey = "GoogleOAuthClientID"
39
+    private let clientSecretPlistKey = "GoogleOAuthClientSecret"
40
+    // Bundled fallback for app-distributed sign-in (no user input required).
38 41
     private let bundledClientId = "824412072260-m9g5g6mlemnb0o079rtuqnh0e1unmelc.apps.googleusercontent.com"
39 42
     private let bundledClientSecret = "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"
40 43
 
@@ -52,6 +55,9 @@ final class GoogleOAuthService: NSObject {
52 55
     func configuredClientId() -> String? {
53 56
         let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
54 57
         if let value, value.isEmpty == false { return value }
58
+        let plistValue = Bundle.main.object(forInfoDictionaryKey: clientIdPlistKey) as? String
59
+        let trimmed = plistValue?.trimmingCharacters(in: .whitespacesAndNewlines)
60
+        if let trimmed, trimmed.isEmpty == false { return trimmed }
55 61
         return bundledClientId
56 62
     }
57 63
 
@@ -62,6 +68,9 @@ final class GoogleOAuthService: NSObject {
62 68
     func configuredClientSecret() -> String? {
63 69
         let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
64 70
         if let value, value.isEmpty == false { return value }
71
+        let plistValue = Bundle.main.object(forInfoDictionaryKey: clientSecretPlistKey) as? String
72
+        let trimmed = plistValue?.trimmingCharacters(in: .whitespacesAndNewlines)
73
+        if let trimmed, trimmed.isEmpty == false { return trimmed }
65 74
         return bundledClientSecret
66 75
     }
67 76
 
@@ -73,6 +82,14 @@ final class GoogleOAuthService: NSObject {
73 82
         try tokenStore.deleteTokens()
74 83
     }
75 84
 
85
+    func signOutAndRevoke() async throws {
86
+        let existing = try tokenStore.readTokens()
87
+        if let existing {
88
+            try await revokeTokenIfPossible(existing)
89
+        }
90
+        try tokenStore.deleteTokens()
91
+    }
92
+
76 93
     func loadTokens() -> GoogleOAuthTokens? {
77 94
         try? tokenStore.readTokens()
78 95
     }
@@ -257,6 +274,27 @@ final class GoogleOAuthService: NSObject {
257 274
         .joined(separator: "&")
258 275
         return Data(pairs.utf8)
259 276
     }
277
+
278
+    private func revokeTokenIfPossible(_ tokens: GoogleOAuthTokens) async throws {
279
+        let tokenToRevoke = tokens.refreshToken ?? tokens.accessToken
280
+        guard tokenToRevoke.isEmpty == false else { return }
281
+
282
+        var components = URLComponents(string: "https://oauth2.googleapis.com/revoke")!
283
+        components.queryItems = [URLQueryItem(name: "token", value: tokenToRevoke)]
284
+
285
+        guard let url = components.url else { return }
286
+        var request = URLRequest(url: url)
287
+        request.httpMethod = "POST"
288
+        request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
289
+
290
+        let (_, response) = try await URLSession.shared.data(for: request)
291
+        guard let http = response as? HTTPURLResponse else {
292
+            throw GoogleOAuthError.tokenExchangeFailed("Invalid revoke response.")
293
+        }
294
+        guard (200..<300).contains(http.statusCode) else {
295
+            throw GoogleOAuthError.tokenExchangeFailed("Token revoke failed with status \(http.statusCode).")
296
+        }
297
+    }
260 298
 }
261 299
 
262 300
 private extension Data {

+ 88 - 10
meetings_app/Auth/KeychainTokenStore.swift

@@ -1,30 +1,108 @@
1 1
 import Foundation
2
+import Security
2 3
 
3
-/// Keeps the existing API surface while storing OAuth tokens in UserDefaults.
4
-/// This avoids macOS keychain unlock prompts during development/test runs.
4
+/// Stores OAuth tokens in macOS Keychain.
5 5
 final class KeychainTokenStore {
6
-    private let defaultsKey: String
7
-    private let defaults: UserDefaults
6
+    private let service: String
7
+    private let account: String
8
+    private let accessGroup: String?
8 9
 
9 10
     init(service: String = Bundle.main.bundleIdentifier ?? "meetings_app",
10 11
          account: String = "googleOAuthTokens",
11
-         defaults: UserDefaults = .standard) {
12
-        self.defaultsKey = "\(service).\(account)"
13
-        self.defaults = defaults
12
+         accessGroup: String? = nil) {
13
+        self.service = service
14
+        self.account = account
15
+        self.accessGroup = accessGroup
14 16
     }
15 17
 
16 18
     func readTokens() throws -> GoogleOAuthTokens? {
17
-        guard let data = defaults.data(forKey: defaultsKey) else { return nil }
19
+        var query: [String: Any] = [
20
+            kSecClass as String: kSecClassGenericPassword,
21
+            kSecAttrService as String: service,
22
+            kSecAttrAccount as String: account,
23
+            kSecReturnData as String: true,
24
+            kSecMatchLimit as String: kSecMatchLimitOne
25
+        ]
26
+        if let accessGroup {
27
+            query[kSecAttrAccessGroup as String] = accessGroup
28
+        }
29
+
30
+        var result: CFTypeRef?
31
+        let status = SecItemCopyMatching(query as CFDictionary, &result)
32
+        if status == errSecItemNotFound { return nil }
33
+        guard status == errSecSuccess else {
34
+            throw NSError(
35
+                domain: NSOSStatusErrorDomain,
36
+                code: Int(status),
37
+                userInfo: [NSLocalizedDescriptionKey: "Failed to read OAuth tokens from Keychain."]
38
+            )
39
+        }
40
+        guard let data = result as? Data else {
41
+            throw NSError(
42
+                domain: NSOSStatusErrorDomain,
43
+                code: Int(errSecDecode),
44
+                userInfo: [NSLocalizedDescriptionKey: "Keychain returned an invalid token payload."]
45
+            )
46
+        }
18 47
         return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
19 48
     }
20 49
 
21 50
     func writeTokens(_ tokens: GoogleOAuthTokens) throws {
22 51
         let data = try JSONEncoder().encode(tokens)
23
-        defaults.set(data, forKey: defaultsKey)
52
+        var baseQuery: [String: Any] = [
53
+            kSecClass as String: kSecClassGenericPassword,
54
+            kSecAttrService as String: service,
55
+            kSecAttrAccount as String: account
56
+        ]
57
+        if let accessGroup {
58
+            baseQuery[kSecAttrAccessGroup as String] = accessGroup
59
+        }
60
+
61
+        let attributesToUpdate: [String: Any] = [
62
+            kSecValueData as String: data
63
+        ]
64
+
65
+        let updateStatus = SecItemUpdate(baseQuery as CFDictionary, attributesToUpdate as CFDictionary)
66
+        if updateStatus == errSecSuccess { return }
67
+        if updateStatus != errSecItemNotFound {
68
+            throw NSError(
69
+                domain: NSOSStatusErrorDomain,
70
+                code: Int(updateStatus),
71
+                userInfo: [NSLocalizedDescriptionKey: "Failed to update OAuth tokens in Keychain."]
72
+            )
73
+        }
74
+
75
+        var addQuery = baseQuery
76
+        addQuery[kSecValueData as String] = data
77
+        addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
78
+        let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
79
+        guard addStatus == errSecSuccess else {
80
+            throw NSError(
81
+                domain: NSOSStatusErrorDomain,
82
+                code: Int(addStatus),
83
+                userInfo: [NSLocalizedDescriptionKey: "Failed to save OAuth tokens to Keychain."]
84
+            )
85
+        }
24 86
     }
25 87
 
26 88
     func deleteTokens() throws {
27
-        defaults.removeObject(forKey: defaultsKey)
89
+        var query: [String: Any] = [
90
+            kSecClass as String: kSecClassGenericPassword,
91
+            kSecAttrService as String: service,
92
+            kSecAttrAccount as String: account
93
+        ]
94
+        if let accessGroup {
95
+            query[kSecAttrAccessGroup as String] = accessGroup
96
+        }
97
+
98
+        let status = SecItemDelete(query as CFDictionary)
99
+        guard status == errSecSuccess || status == errSecItemNotFound else {
100
+            throw NSError(
101
+                domain: NSOSStatusErrorDomain,
102
+                code: Int(status),
103
+                userInfo: [NSLocalizedDescriptionKey: "Failed to delete OAuth tokens from Keychain."]
104
+            )
105
+        }
28 106
     }
29 107
 }
30 108
 

+ 45 - 66
meetings_app/ViewController.swift

@@ -1906,7 +1906,7 @@ private extension ViewController {
1906 1906
         row.translatesAutoresizingMaskIntoConstraints = false
1907 1907
         styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
1908 1908
 
1909
-        let signedIn = googleOAuth.loadTokens() != nil
1909
+        let signedIn = hasGoogleSessionAvailable()
1910 1910
         let titleText = signedIn ? (scheduleCurrentProfile?.name ?? "Google account connected") : "Google account not connected"
1911 1911
         let subtitleText = signedIn ? (scheduleCurrentProfile?.email ?? "Signed in") : "Sign in to sync your meetings and calendar."
1912 1912
 
@@ -1978,7 +1978,7 @@ private extension ViewController {
1978 1978
     }
1979 1979
 
1980 1980
     @objc private func settingsGoogleActionButtonClicked(_ sender: NSButton) {
1981
-        if googleOAuth.loadTokens() == nil {
1981
+        if hasGoogleSessionAvailable() == false {
1982 1982
             scheduleConnectClicked()
1983 1983
         } else {
1984 1984
             performGoogleSignOut()
@@ -2297,7 +2297,7 @@ private extension ViewController {
2297 2297
         ])
2298 2298
         mainContentHost = host
2299 2299
 
2300
-        if googleOAuth.loadTokens() != nil, let profile = scheduleCurrentProfile {
2300
+        if hasGoogleSessionAvailable(), let profile = scheduleCurrentProfile {
2301 2301
             applyGoogleProfile(profile)
2302 2302
         }
2303 2303
 
@@ -3612,7 +3612,7 @@ private extension ViewController {
3612 3612
         titleRowSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
3613 3613
 
3614 3614
         titleRow.addArrangedSubview(titleLabel)
3615
-        if googleOAuth.loadTokens() != nil && storeKitCoordinator.hasPremiumAccess {
3615
+        if hasGoogleSessionAvailable() && storeKitCoordinator.hasPremiumAccess {
3616 3616
             titleRow.addArrangedSubview(makeSchedulePageAddButton())
3617 3617
             titleRow.setCustomSpacing(12, after: titleLabel)
3618 3618
         }
@@ -5031,8 +5031,16 @@ private final class SettingsMenuViewController: NSViewController {
5031 5031
 }
5032 5032
 
5033 5033
 private extension ViewController {
5034
+    private func hasGoogleSessionAvailable() -> Bool {
5035
+        guard let tokens = googleOAuth.loadTokens() else { return false }
5036
+        if tokens.expiresAt.timeIntervalSinceNow > 60 {
5037
+            return true
5038
+        }
5039
+        return (tokens.refreshToken?.isEmpty == false)
5040
+    }
5041
+
5034 5042
     private func requireGoogleLoginForCalendarScheduling() -> Bool {
5035
-        guard googleOAuth.loadTokens() != nil else {
5043
+        guard hasGoogleSessionAvailable() else {
5036 5044
             showSimpleAlert(
5037 5045
                 title: "Connect Google",
5038 5046
                 message: "Sign in with Google first to schedule a meeting from Calendar."
@@ -5492,7 +5500,7 @@ private extension ViewController {
5492 5500
         f.dateFormat = "EEE, d MMM"
5493 5501
 
5494 5502
         if meetings.isEmpty {
5495
-            calendarPageDaySummaryLabel?.stringValue = googleOAuth.loadTokens() == nil
5503
+            calendarPageDaySummaryLabel?.stringValue = hasGoogleSessionAvailable() == false
5496 5504
                 ? "Connect Google to see meetings"
5497 5505
                 : "No meetings on \(f.string(from: selectedDay))"
5498 5506
         } else if meetings.count == 1 {
@@ -6128,11 +6136,11 @@ private extension ViewController {
6128 6136
     }
6129 6137
 
6130 6138
     private func scheduleInitialHeadingText() -> String {
6131
-        googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "Loading…"
6139
+        hasGoogleSessionAvailable() ? "Loading…" : "Connect Google to see meetings"
6132 6140
     }
6133 6141
 
6134 6142
     private func schedulePageInitialHeadingText() -> String {
6135
-        googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "Loading schedule…"
6143
+        hasGoogleSessionAvailable() ? "Loading schedule…" : "Connect Google to see meetings"
6136 6144
     }
6137 6145
 
6138 6146
     @objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) {
@@ -6239,7 +6247,7 @@ private extension ViewController {
6239 6247
 
6240 6248
     private func scheduleHeadingText(for meetings: [ScheduledMeeting]) -> String {
6241 6249
         guard let first = meetings.first else {
6242
-            return googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "No upcoming meetings"
6250
+            return hasGoogleSessionAvailable() ? "No upcoming meetings" : "Connect Google to see meetings"
6243 6251
         }
6244 6252
 
6245 6253
         let day = Calendar.current.startOfDay(for: first.startDate)
@@ -6276,7 +6284,7 @@ private extension ViewController {
6276 6284
             empty.heightAnchor.constraint(equalToConstant: 150).isActive = true
6277 6285
             styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
6278 6286
 
6279
-            let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load schedule" : "No meetings", font: typography.cardSubtitle, color: palette.textSecondary)
6287
+            let label = textLabel(hasGoogleSessionAvailable() ? "No meetings" : "Connect to load schedule", font: typography.cardSubtitle, color: palette.textSecondary)
6280 6288
             label.translatesAutoresizingMaskIntoConstraints = false
6281 6289
             empty.addSubview(label)
6282 6290
             NSLayoutConstraint.activate([
@@ -6413,7 +6421,7 @@ private extension ViewController {
6413 6421
             empty.translatesAutoresizingMaskIntoConstraints = false
6414 6422
             empty.heightAnchor.constraint(equalToConstant: 140).isActive = true
6415 6423
             styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
6416
-            let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load schedule" : "No meetings for selected filters", font: typography.cardSubtitle, color: palette.textSecondary)
6424
+            let label = textLabel(hasGoogleSessionAvailable() ? "No meetings for selected filters" : "Connect to load schedule", font: typography.cardSubtitle, color: palette.textSecondary)
6417 6425
             label.translatesAutoresizingMaskIntoConstraints = false
6418 6426
             empty.addSubview(label)
6419 6427
             NSLayoutConstraint.activate([
@@ -6480,7 +6488,7 @@ private extension ViewController {
6480 6488
 
6481 6489
     private func loadSchedule() async {
6482 6490
         do {
6483
-            if googleOAuth.loadTokens() == nil {
6491
+            if hasGoogleSessionAvailable() == false {
6484 6492
                 await MainActor.run {
6485 6493
                     updateGoogleAuthButtonTitle()
6486 6494
                     applyGoogleProfile(nil)
@@ -6523,7 +6531,7 @@ private extension ViewController {
6523 6531
         } catch {
6524 6532
             await MainActor.run {
6525 6533
                 updateGoogleAuthButtonTitle()
6526
-                if googleOAuth.loadTokens() == nil {
6534
+                if hasGoogleSessionAvailable() == false {
6527 6535
                     applyGoogleProfile(nil)
6528 6536
                 }
6529 6537
                 scheduleDateHeadingLabel?.stringValue = "Couldn’t load schedule"
@@ -6576,7 +6584,7 @@ private extension ViewController {
6576 6584
         Task { [weak self] in
6577 6585
             guard let self else { return }
6578 6586
             do {
6579
-                if self.googleOAuth.loadTokens() != nil {
6587
+                if self.hasGoogleSessionAvailable() {
6580 6588
                     await MainActor.run { self.showGoogleAccountMenu() }
6581 6589
                     return
6582 6590
                 }
@@ -6634,25 +6642,30 @@ private extension ViewController {
6634 6642
     }
6635 6643
 
6636 6644
     private func performGoogleSignOut() {
6637
-        do {
6638
-            try googleOAuth.signOut()
6639
-            applyGoogleProfile(nil)
6640
-            updateGoogleAuthButtonTitle()
6641
-            pageCache[.joinMeetings] = nil
6642
-            pageCache[.photo] = nil
6643
-            pageCache[.video] = nil
6644
-            pageCache[.settings] = nil
6645
-            showSidebarPage(selectedSidebarPage)
6646
-            Task { [weak self] in
6647
-                await self?.loadSchedule()
6645
+        Task { [weak self] in
6646
+            guard let self else { return }
6647
+            do {
6648
+                try await self.googleOAuth.signOutAndRevoke()
6649
+                await MainActor.run {
6650
+                    self.applyGoogleProfile(nil)
6651
+                    self.updateGoogleAuthButtonTitle()
6652
+                    self.pageCache[.joinMeetings] = nil
6653
+                    self.pageCache[.photo] = nil
6654
+                    self.pageCache[.video] = nil
6655
+                    self.pageCache[.settings] = nil
6656
+                    self.showSidebarPage(self.selectedSidebarPage)
6657
+                }
6658
+                await self.loadSchedule()
6659
+            } catch {
6660
+                await MainActor.run {
6661
+                    self.showSimpleError("Couldn’t logout Google account.", error: error)
6662
+                }
6648 6663
             }
6649
-        } catch {
6650
-            showSimpleError("Couldn’t logout Google account.", error: error)
6651 6664
         }
6652 6665
     }
6653 6666
 
6654 6667
     private func updateGoogleAuthButtonTitle() {
6655
-        let signedIn = (googleOAuth.loadTokens() != nil)
6668
+        let signedIn = hasGoogleSessionAvailable()
6656 6669
         guard let button = scheduleGoogleAuthButton else { return }
6657 6670
 
6658 6671
         let profileName = scheduleCurrentProfile?.name ?? "Google account"
@@ -6786,7 +6799,7 @@ private extension ViewController {
6786 6799
 
6787 6800
     private func applyGoogleAuthButtonSurface() {
6788 6801
         guard let button = scheduleGoogleAuthButton else { return }
6789
-        let signedIn = (googleOAuth.loadTokens() != nil)
6802
+        let signedIn = hasGoogleSessionAvailable()
6790 6803
         let isDark = darkModeEnabled
6791 6804
         if signedIn {
6792 6805
             button.layer?.backgroundColor = NSColor.clear.cgColor
@@ -6812,43 +6825,9 @@ private extension ViewController {
6812 6825
 
6813 6826
     @MainActor
6814 6827
     func ensureGoogleClientIdConfigured(presentingWindow: NSWindow?) async throws {
6815
-        if googleOAuth.configuredClientId() != nil, googleOAuth.configuredClientSecret() != nil { return }
6816
-
6817
-        let alert = NSAlert()
6818
-        alert.messageText = "Enter Google OAuth credentials"
6819
-        alert.informativeText = "Paste the OAuth Client ID and Client Secret from your downloaded Desktop OAuth JSON."
6820
-
6821
-        let accessory = NSStackView()
6822
-        accessory.orientation = .vertical
6823
-        accessory.spacing = 8
6824
-        accessory.alignment = .leading
6825
-
6826
-        let idField = NSTextField(string: googleOAuth.configuredClientId() ?? "")
6827
-        idField.placeholderString = "Client ID (....apps.googleusercontent.com)"
6828
-        idField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
6829
-
6830
-        let secretField = NSSecureTextField(string: googleOAuth.configuredClientSecret() ?? "")
6831
-        secretField.placeholderString = "Client Secret (GOCSPX-...)"
6832
-        secretField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
6833
-
6834
-        accessory.addArrangedSubview(idField)
6835
-        accessory.addArrangedSubview(secretField)
6836
-        alert.accessoryView = accessory
6837
-
6838
-        alert.addButton(withTitle: "Save")
6839
-        alert.addButton(withTitle: "Cancel")
6840
-
6841
-        // Keep this synchronous to avoid additional sheet state handling.
6842
-        let response = alert.runModal()
6843
-        if response != .alertFirstButtonReturn { throw GoogleOAuthError.missingClientId }
6844
-
6845
-        let idValue = idField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
6846
-        let secretValue = secretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
6847
-        if idValue.isEmpty { throw GoogleOAuthError.missingClientId }
6848
-        if secretValue.isEmpty { throw GoogleOAuthError.missingClientSecret }
6849
-
6850
-        googleOAuth.setClientIdForTesting(idValue)
6851
-        googleOAuth.setClientSecretForTesting(secretValue)
6828
+        _ = presentingWindow
6829
+        guard googleOAuth.configuredClientId() != nil else { throw GoogleOAuthError.missingClientId }
6830
+        guard googleOAuth.configuredClientSecret() != nil else { throw GoogleOAuthError.missingClientSecret }
6852 6831
     }
6853 6832
 
6854 6833
     func showSimpleError(_ title: String, error: Error) {