Selaa lähdekoodia

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 3 kuukautta sitten
vanhempi
sitoutus
505417aa47

+ 39 - 1
meetings_app/Auth/GoogleOAuthService.swift

@@ -32,9 +32,12 @@ final class GoogleOAuthService: NSObject {
     static let shared = GoogleOAuthService()
 
     // Stored in UserDefaults so you can configure without rebuilding.
-    // Put your OAuth Desktop client ID here (from Google Cloud Console).
+    // Configure at runtime or via Info.plist keys.
     private let clientIdDefaultsKey = "google.oauth.clientId"
     private let clientSecretDefaultsKey = "google.oauth.clientSecret"
+    private let clientIdPlistKey = "GoogleOAuthClientID"
+    private let clientSecretPlistKey = "GoogleOAuthClientSecret"
+    // Bundled fallback for app-distributed sign-in (no user input required).
     private let bundledClientId = "824412072260-m9g5g6mlemnb0o079rtuqnh0e1unmelc.apps.googleusercontent.com"
     private let bundledClientSecret = "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"
 
@@ -52,6 +55,9 @@ final class GoogleOAuthService: NSObject {
     func configuredClientId() -> String? {
         let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
         if let value, value.isEmpty == false { return value }
+        let plistValue = Bundle.main.object(forInfoDictionaryKey: clientIdPlistKey) as? String
+        let trimmed = plistValue?.trimmingCharacters(in: .whitespacesAndNewlines)
+        if let trimmed, trimmed.isEmpty == false { return trimmed }
         return bundledClientId
     }
 
@@ -62,6 +68,9 @@ final class GoogleOAuthService: NSObject {
     func configuredClientSecret() -> String? {
         let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
         if let value, value.isEmpty == false { return value }
+        let plistValue = Bundle.main.object(forInfoDictionaryKey: clientSecretPlistKey) as? String
+        let trimmed = plistValue?.trimmingCharacters(in: .whitespacesAndNewlines)
+        if let trimmed, trimmed.isEmpty == false { return trimmed }
         return bundledClientSecret
     }
 
@@ -73,6 +82,14 @@ final class GoogleOAuthService: NSObject {
         try tokenStore.deleteTokens()
     }
 
+    func signOutAndRevoke() async throws {
+        let existing = try tokenStore.readTokens()
+        if let existing {
+            try await revokeTokenIfPossible(existing)
+        }
+        try tokenStore.deleteTokens()
+    }
+
     func loadTokens() -> GoogleOAuthTokens? {
         try? tokenStore.readTokens()
     }
@@ -257,6 +274,27 @@ final class GoogleOAuthService: NSObject {
         .joined(separator: "&")
         return Data(pairs.utf8)
     }
+
+    private func revokeTokenIfPossible(_ tokens: GoogleOAuthTokens) async throws {
+        let tokenToRevoke = tokens.refreshToken ?? tokens.accessToken
+        guard tokenToRevoke.isEmpty == false else { return }
+
+        var components = URLComponents(string: "https://oauth2.googleapis.com/revoke")!
+        components.queryItems = [URLQueryItem(name: "token", value: tokenToRevoke)]
+
+        guard let url = components.url else { return }
+        var request = URLRequest(url: url)
+        request.httpMethod = "POST"
+        request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
+
+        let (_, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse else {
+            throw GoogleOAuthError.tokenExchangeFailed("Invalid revoke response.")
+        }
+        guard (200..<300).contains(http.statusCode) else {
+            throw GoogleOAuthError.tokenExchangeFailed("Token revoke failed with status \(http.statusCode).")
+        }
+    }
 }
 
 private extension Data {

+ 88 - 10
meetings_app/Auth/KeychainTokenStore.swift

@@ -1,30 +1,108 @@
 import Foundation
+import Security
 
-/// Keeps the existing API surface while storing OAuth tokens in UserDefaults.
-/// This avoids macOS keychain unlock prompts during development/test runs.
+/// Stores OAuth tokens in macOS Keychain.
 final class KeychainTokenStore {
-    private let defaultsKey: String
-    private let defaults: UserDefaults
+    private let service: String
+    private let account: String
+    private let accessGroup: String?
 
     init(service: String = Bundle.main.bundleIdentifier ?? "meetings_app",
          account: String = "googleOAuthTokens",
-         defaults: UserDefaults = .standard) {
-        self.defaultsKey = "\(service).\(account)"
-        self.defaults = defaults
+         accessGroup: String? = nil) {
+        self.service = service
+        self.account = account
+        self.accessGroup = accessGroup
     }
 
     func readTokens() throws -> GoogleOAuthTokens? {
-        guard let data = defaults.data(forKey: defaultsKey) else { return nil }
+        var query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: account,
+            kSecReturnData as String: true,
+            kSecMatchLimit as String: kSecMatchLimitOne
+        ]
+        if let accessGroup {
+            query[kSecAttrAccessGroup as String] = accessGroup
+        }
+
+        var result: CFTypeRef?
+        let status = SecItemCopyMatching(query as CFDictionary, &result)
+        if status == errSecItemNotFound { return nil }
+        guard status == errSecSuccess else {
+            throw NSError(
+                domain: NSOSStatusErrorDomain,
+                code: Int(status),
+                userInfo: [NSLocalizedDescriptionKey: "Failed to read OAuth tokens from Keychain."]
+            )
+        }
+        guard let data = result as? Data else {
+            throw NSError(
+                domain: NSOSStatusErrorDomain,
+                code: Int(errSecDecode),
+                userInfo: [NSLocalizedDescriptionKey: "Keychain returned an invalid token payload."]
+            )
+        }
         return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
     }
 
     func writeTokens(_ tokens: GoogleOAuthTokens) throws {
         let data = try JSONEncoder().encode(tokens)
-        defaults.set(data, forKey: defaultsKey)
+        var baseQuery: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: account
+        ]
+        if let accessGroup {
+            baseQuery[kSecAttrAccessGroup as String] = accessGroup
+        }
+
+        let attributesToUpdate: [String: Any] = [
+            kSecValueData as String: data
+        ]
+
+        let updateStatus = SecItemUpdate(baseQuery as CFDictionary, attributesToUpdate as CFDictionary)
+        if updateStatus == errSecSuccess { return }
+        if updateStatus != errSecItemNotFound {
+            throw NSError(
+                domain: NSOSStatusErrorDomain,
+                code: Int(updateStatus),
+                userInfo: [NSLocalizedDescriptionKey: "Failed to update OAuth tokens in Keychain."]
+            )
+        }
+
+        var addQuery = baseQuery
+        addQuery[kSecValueData as String] = data
+        addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
+        let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
+        guard addStatus == errSecSuccess else {
+            throw NSError(
+                domain: NSOSStatusErrorDomain,
+                code: Int(addStatus),
+                userInfo: [NSLocalizedDescriptionKey: "Failed to save OAuth tokens to Keychain."]
+            )
+        }
     }
 
     func deleteTokens() throws {
-        defaults.removeObject(forKey: defaultsKey)
+        var query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: account
+        ]
+        if let accessGroup {
+            query[kSecAttrAccessGroup as String] = accessGroup
+        }
+
+        let status = SecItemDelete(query as CFDictionary)
+        guard status == errSecSuccess || status == errSecItemNotFound else {
+            throw NSError(
+                domain: NSOSStatusErrorDomain,
+                code: Int(status),
+                userInfo: [NSLocalizedDescriptionKey: "Failed to delete OAuth tokens from Keychain."]
+            )
+        }
     }
 }
 

+ 45 - 66
meetings_app/ViewController.swift

@@ -1906,7 +1906,7 @@ private extension ViewController {
         row.translatesAutoresizingMaskIntoConstraints = false
         styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
 
-        let signedIn = googleOAuth.loadTokens() != nil
+        let signedIn = hasGoogleSessionAvailable()
         let titleText = signedIn ? (scheduleCurrentProfile?.name ?? "Google account connected") : "Google account not connected"
         let subtitleText = signedIn ? (scheduleCurrentProfile?.email ?? "Signed in") : "Sign in to sync your meetings and calendar."
 
@@ -1978,7 +1978,7 @@ private extension ViewController {
     }
 
     @objc private func settingsGoogleActionButtonClicked(_ sender: NSButton) {
-        if googleOAuth.loadTokens() == nil {
+        if hasGoogleSessionAvailable() == false {
             scheduleConnectClicked()
         } else {
             performGoogleSignOut()
@@ -2297,7 +2297,7 @@ private extension ViewController {
         ])
         mainContentHost = host
 
-        if googleOAuth.loadTokens() != nil, let profile = scheduleCurrentProfile {
+        if hasGoogleSessionAvailable(), let profile = scheduleCurrentProfile {
             applyGoogleProfile(profile)
         }
 
@@ -3612,7 +3612,7 @@ private extension ViewController {
         titleRowSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
 
         titleRow.addArrangedSubview(titleLabel)
-        if googleOAuth.loadTokens() != nil && storeKitCoordinator.hasPremiumAccess {
+        if hasGoogleSessionAvailable() && storeKitCoordinator.hasPremiumAccess {
             titleRow.addArrangedSubview(makeSchedulePageAddButton())
             titleRow.setCustomSpacing(12, after: titleLabel)
         }
@@ -5031,8 +5031,16 @@ private final class SettingsMenuViewController: NSViewController {
 }
 
 private extension ViewController {
+    private func hasGoogleSessionAvailable() -> Bool {
+        guard let tokens = googleOAuth.loadTokens() else { return false }
+        if tokens.expiresAt.timeIntervalSinceNow > 60 {
+            return true
+        }
+        return (tokens.refreshToken?.isEmpty == false)
+    }
+
     private func requireGoogleLoginForCalendarScheduling() -> Bool {
-        guard googleOAuth.loadTokens() != nil else {
+        guard hasGoogleSessionAvailable() else {
             showSimpleAlert(
                 title: "Connect Google",
                 message: "Sign in with Google first to schedule a meeting from Calendar."
@@ -5492,7 +5500,7 @@ private extension ViewController {
         f.dateFormat = "EEE, d MMM"
 
         if meetings.isEmpty {
-            calendarPageDaySummaryLabel?.stringValue = googleOAuth.loadTokens() == nil
+            calendarPageDaySummaryLabel?.stringValue = hasGoogleSessionAvailable() == false
                 ? "Connect Google to see meetings"
                 : "No meetings on \(f.string(from: selectedDay))"
         } else if meetings.count == 1 {
@@ -6128,11 +6136,11 @@ private extension ViewController {
     }
 
     private func scheduleInitialHeadingText() -> String {
-        googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "Loading…"
+        hasGoogleSessionAvailable() ? "Loading…" : "Connect Google to see meetings"
     }
 
     private func schedulePageInitialHeadingText() -> String {
-        googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "Loading schedule…"
+        hasGoogleSessionAvailable() ? "Loading schedule…" : "Connect Google to see meetings"
     }
 
     @objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) {
@@ -6239,7 +6247,7 @@ private extension ViewController {
 
     private func scheduleHeadingText(for meetings: [ScheduledMeeting]) -> String {
         guard let first = meetings.first else {
-            return googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "No upcoming meetings"
+            return hasGoogleSessionAvailable() ? "No upcoming meetings" : "Connect Google to see meetings"
         }
 
         let day = Calendar.current.startOfDay(for: first.startDate)
@@ -6276,7 +6284,7 @@ private extension ViewController {
             empty.heightAnchor.constraint(equalToConstant: 150).isActive = true
             styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
 
-            let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load schedule" : "No meetings", font: typography.cardSubtitle, color: palette.textSecondary)
+            let label = textLabel(hasGoogleSessionAvailable() ? "No meetings" : "Connect to load schedule", font: typography.cardSubtitle, color: palette.textSecondary)
             label.translatesAutoresizingMaskIntoConstraints = false
             empty.addSubview(label)
             NSLayoutConstraint.activate([
@@ -6413,7 +6421,7 @@ private extension ViewController {
             empty.translatesAutoresizingMaskIntoConstraints = false
             empty.heightAnchor.constraint(equalToConstant: 140).isActive = true
             styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
-            let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load schedule" : "No meetings for selected filters", font: typography.cardSubtitle, color: palette.textSecondary)
+            let label = textLabel(hasGoogleSessionAvailable() ? "No meetings for selected filters" : "Connect to load schedule", font: typography.cardSubtitle, color: palette.textSecondary)
             label.translatesAutoresizingMaskIntoConstraints = false
             empty.addSubview(label)
             NSLayoutConstraint.activate([
@@ -6480,7 +6488,7 @@ private extension ViewController {
 
     private func loadSchedule() async {
         do {
-            if googleOAuth.loadTokens() == nil {
+            if hasGoogleSessionAvailable() == false {
                 await MainActor.run {
                     updateGoogleAuthButtonTitle()
                     applyGoogleProfile(nil)
@@ -6523,7 +6531,7 @@ private extension ViewController {
         } catch {
             await MainActor.run {
                 updateGoogleAuthButtonTitle()
-                if googleOAuth.loadTokens() == nil {
+                if hasGoogleSessionAvailable() == false {
                     applyGoogleProfile(nil)
                 }
                 scheduleDateHeadingLabel?.stringValue = "Couldn’t load schedule"
@@ -6576,7 +6584,7 @@ private extension ViewController {
         Task { [weak self] in
             guard let self else { return }
             do {
-                if self.googleOAuth.loadTokens() != nil {
+                if self.hasGoogleSessionAvailable() {
                     await MainActor.run { self.showGoogleAccountMenu() }
                     return
                 }
@@ -6634,25 +6642,30 @@ private extension ViewController {
     }
 
     private func performGoogleSignOut() {
-        do {
-            try googleOAuth.signOut()
-            applyGoogleProfile(nil)
-            updateGoogleAuthButtonTitle()
-            pageCache[.joinMeetings] = nil
-            pageCache[.photo] = nil
-            pageCache[.video] = nil
-            pageCache[.settings] = nil
-            showSidebarPage(selectedSidebarPage)
-            Task { [weak self] in
-                await self?.loadSchedule()
+        Task { [weak self] in
+            guard let self else { return }
+            do {
+                try await self.googleOAuth.signOutAndRevoke()
+                await MainActor.run {
+                    self.applyGoogleProfile(nil)
+                    self.updateGoogleAuthButtonTitle()
+                    self.pageCache[.joinMeetings] = nil
+                    self.pageCache[.photo] = nil
+                    self.pageCache[.video] = nil
+                    self.pageCache[.settings] = nil
+                    self.showSidebarPage(self.selectedSidebarPage)
+                }
+                await self.loadSchedule()
+            } catch {
+                await MainActor.run {
+                    self.showSimpleError("Couldn’t logout Google account.", error: error)
+                }
             }
-        } catch {
-            showSimpleError("Couldn’t logout Google account.", error: error)
         }
     }
 
     private func updateGoogleAuthButtonTitle() {
-        let signedIn = (googleOAuth.loadTokens() != nil)
+        let signedIn = hasGoogleSessionAvailable()
         guard let button = scheduleGoogleAuthButton else { return }
 
         let profileName = scheduleCurrentProfile?.name ?? "Google account"
@@ -6786,7 +6799,7 @@ private extension ViewController {
 
     private func applyGoogleAuthButtonSurface() {
         guard let button = scheduleGoogleAuthButton else { return }
-        let signedIn = (googleOAuth.loadTokens() != nil)
+        let signedIn = hasGoogleSessionAvailable()
         let isDark = darkModeEnabled
         if signedIn {
             button.layer?.backgroundColor = NSColor.clear.cgColor
@@ -6812,43 +6825,9 @@ private extension ViewController {
 
     @MainActor
     func ensureGoogleClientIdConfigured(presentingWindow: NSWindow?) async throws {
-        if googleOAuth.configuredClientId() != nil, googleOAuth.configuredClientSecret() != nil { return }
-
-        let alert = NSAlert()
-        alert.messageText = "Enter Google OAuth credentials"
-        alert.informativeText = "Paste the OAuth Client ID and Client Secret from your downloaded Desktop OAuth JSON."
-
-        let accessory = NSStackView()
-        accessory.orientation = .vertical
-        accessory.spacing = 8
-        accessory.alignment = .leading
-
-        let idField = NSTextField(string: googleOAuth.configuredClientId() ?? "")
-        idField.placeholderString = "Client ID (....apps.googleusercontent.com)"
-        idField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
-
-        let secretField = NSSecureTextField(string: googleOAuth.configuredClientSecret() ?? "")
-        secretField.placeholderString = "Client Secret (GOCSPX-...)"
-        secretField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
-
-        accessory.addArrangedSubview(idField)
-        accessory.addArrangedSubview(secretField)
-        alert.accessoryView = accessory
-
-        alert.addButton(withTitle: "Save")
-        alert.addButton(withTitle: "Cancel")
-
-        // Keep this synchronous to avoid additional sheet state handling.
-        let response = alert.runModal()
-        if response != .alertFirstButtonReturn { throw GoogleOAuthError.missingClientId }
-
-        let idValue = idField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
-        let secretValue = secretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
-        if idValue.isEmpty { throw GoogleOAuthError.missingClientId }
-        if secretValue.isEmpty { throw GoogleOAuthError.missingClientSecret }
-
-        googleOAuth.setClientIdForTesting(idValue)
-        googleOAuth.setClientSecretForTesting(secretValue)
+        _ = presentingWindow
+        guard googleOAuth.configuredClientId() != nil else { throw GoogleOAuthError.missingClientId }
+        guard googleOAuth.configuredClientSecret() != nil else { throw GoogleOAuthError.missingClientSecret }
     }
 
     func showSimpleError(_ title: String, error: Error) {