|
@@ -52,11 +52,17 @@ final class GoogleOAuthService: NSObject {
|
|
|
"https://www.googleapis.com/auth/meetings.space.readonly"
|
|
"https://www.googleapis.com/auth/meetings.space.readonly"
|
|
|
]
|
|
]
|
|
|
|
|
|
|
|
- private let tokenStore = KeychainTokenStore()
|
|
|
|
|
|
|
+ /// Use a stable, app-controlled Keychain "service" identifier so tokens remain manageable even if
|
|
|
|
|
+ /// bundle identifier or signing identity changes between builds.
|
|
|
|
|
+ private static let keychainService = "ai.companion.for.meet.google.oauth.tokens"
|
|
|
|
|
+ private let tokenStore = KeychainTokenStore(service: GoogleOAuthService.keychainService)
|
|
|
|
|
+ /// Legacy store used by older builds that keyed off the bundle identifier.
|
|
|
|
|
+ private let legacyTokenStore = KeychainTokenStore()
|
|
|
@MainActor private var inAppOAuthWindowController: InAppOAuthWindowController?
|
|
@MainActor private var inAppOAuthWindowController: InAppOAuthWindowController?
|
|
|
private override init() {}
|
|
private override init() {}
|
|
|
|
|
|
|
|
private let lastSignedInEmailDefaultsKey = "google.oauth.lastSignedInEmail"
|
|
private let lastSignedInEmailDefaultsKey = "google.oauth.lastSignedInEmail"
|
|
|
|
|
+ private let ignoreLegacyTokensDefaultsKey = "google.oauth.ignoreLegacyTokens"
|
|
|
|
|
|
|
|
func lastSignedInEmailHint() -> String? {
|
|
func lastSignedInEmailHint() -> String? {
|
|
|
let value = UserDefaults.standard
|
|
let value = UserDefaults.standard
|
|
@@ -113,18 +119,40 @@ final class GoogleOAuthService: NSObject {
|
|
|
|
|
|
|
|
func signOut() throws {
|
|
func signOut() throws {
|
|
|
try tokenStore.deleteTokens()
|
|
try tokenStore.deleteTokens()
|
|
|
|
|
+ UserDefaults.standard.set(true, forKey: ignoreLegacyTokensDefaultsKey)
|
|
|
|
|
+ // Best-effort cleanup for legacy items. Some old items cannot be deleted after signing changes.
|
|
|
|
|
+ try? legacyTokenStore.deleteTokens()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func signOutAndRevoke() async throws {
|
|
func signOutAndRevoke() async throws {
|
|
|
- let existing = try tokenStore.readTokens()
|
|
|
|
|
- if let existing {
|
|
|
|
|
- try await revokeTokenIfPossible(existing)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // Ensure tokens are in the current store so sign-out is effective.
|
|
|
|
|
+ let existing = try loadTokensOrMigrateIfNeeded()
|
|
|
|
|
+ if let existing { try await revokeTokenIfPossible(existing) }
|
|
|
try tokenStore.deleteTokens()
|
|
try tokenStore.deleteTokens()
|
|
|
|
|
+ UserDefaults.standard.set(true, forKey: ignoreLegacyTokensDefaultsKey)
|
|
|
|
|
+ try? legacyTokenStore.deleteTokens()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func loadTokens() -> GoogleOAuthTokens? {
|
|
func loadTokens() -> GoogleOAuthTokens? {
|
|
|
- try? tokenStore.readTokens()
|
|
|
|
|
|
|
+ try? loadTokensOrMigrateIfNeeded()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Loads from the current store; if empty, attempts to read legacy tokens and migrate them forward.
|
|
|
|
|
+ /// This avoids being stuck with an undeletable legacy Keychain item after signing/bundle-id changes.
|
|
|
|
|
+ private func loadTokensOrMigrateIfNeeded() throws -> GoogleOAuthTokens? {
|
|
|
|
|
+ if let tokens = try tokenStore.readTokens() { return tokens }
|
|
|
|
|
+
|
|
|
|
|
+ // If the user explicitly signed out, do not resurrect a stuck legacy Keychain item.
|
|
|
|
|
+ if UserDefaults.standard.bool(forKey: ignoreLegacyTokensDefaultsKey) {
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }
|
|
|
|
|
+ if let legacy = try legacyTokenStore.readTokens() {
|
|
|
|
|
+ // Migrate forward; legacy delete may fail with -25244 (invalid owner edit) after signing changes.
|
|
|
|
|
+ try tokenStore.writeTokens(legacy)
|
|
|
|
|
+ try? legacyTokenStore.deleteTokens()
|
|
|
|
|
+ return legacy
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
|
|
func fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
|
|
@@ -146,7 +174,7 @@ final class GoogleOAuthService: NSObject {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
|
|
func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
|
|
|
- if var tokens = try tokenStore.readTokens() {
|
|
|
|
|
|
|
+ if var tokens = try loadTokensOrMigrateIfNeeded() {
|
|
|
if tokens.expiresAt.timeIntervalSinceNow > 60 {
|
|
if tokens.expiresAt.timeIntervalSinceNow > 60 {
|
|
|
return tokens.accessToken
|
|
return tokens.accessToken
|
|
|
}
|
|
}
|
|
@@ -159,6 +187,7 @@ final class GoogleOAuthService: NSObject {
|
|
|
|
|
|
|
|
let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
|
|
let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
|
|
|
try tokenStore.writeTokens(tokens)
|
|
try tokenStore.writeTokens(tokens)
|
|
|
|
|
+ UserDefaults.standard.set(false, forKey: ignoreLegacyTokensDefaultsKey)
|
|
|
return tokens.accessToken
|
|
return tokens.accessToken
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -201,11 +230,25 @@ final class GoogleOAuthService: NSObject {
|
|
|
components.queryItems = queryItems
|
|
components.queryItems = queryItems
|
|
|
|
|
|
|
|
guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
|
|
guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
|
|
|
|
|
+
|
|
|
let opened = await MainActor.run { [self] in
|
|
let opened = await MainActor.run { [self] in
|
|
|
- openAuthURLInDefaultBrowser(authURL)
|
|
|
|
|
|
|
+ openAuthURLInAppBrowser(authURL, presentingWindow: presentingWindow)
|
|
|
}
|
|
}
|
|
|
guard opened else { throw GoogleOAuthError.unableToOpenBrowser }
|
|
guard opened else { throw GoogleOAuthError.unableToOpenBrowser }
|
|
|
- let callbackURL = try await loopback.waitForCallback()
|
|
|
|
|
|
|
+
|
|
|
|
|
+ let callbackURL: URL
|
|
|
|
|
+ do {
|
|
|
|
|
+ callbackURL = try await loopback.waitForCallback()
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ await MainActor.run { [weak self] in
|
|
|
|
|
+ self?.closeInAppOAuthWindow()
|
|
|
|
|
+ }
|
|
|
|
|
+ throw error
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ await MainActor.run { [weak self] in
|
|
|
|
|
+ self?.closeInAppOAuthWindow()
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
guard let returnedState = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
|
|
guard let returnedState = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
|
|
|
.queryItems?.first(where: { $0.name == "state" })?.value,
|
|
.queryItems?.first(where: { $0.name == "state" })?.value,
|
|
@@ -232,6 +275,20 @@ final class GoogleOAuthService: NSObject {
|
|
|
private func openAuthURLInDefaultBrowser(_ url: URL) -> Bool {
|
|
private func openAuthURLInDefaultBrowser(_ url: URL) -> Bool {
|
|
|
NSWorkspace.shared.open(url)
|
|
NSWorkspace.shared.open(url)
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func openAuthURLInAppBrowser(_ url: URL, presentingWindow: NSWindow?) -> Bool {
|
|
|
|
|
+ if inAppOAuthWindowController == nil {
|
|
|
|
|
+ inAppOAuthWindowController = InAppOAuthWindowController()
|
|
|
|
|
+ }
|
|
|
|
|
+ guard let controller = inAppOAuthWindowController else { return false }
|
|
|
|
|
+ controller.alignWithPresentingWindow(presentingWindow)
|
|
|
|
|
+ controller.showWindow(nil)
|
|
|
|
|
+ controller.window?.makeKeyAndOrderFront(nil)
|
|
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
+ controller.load(url: url)
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
@MainActor
|
|
@MainActor
|
|
|
private func closeInAppOAuthWindow() {
|
|
private func closeInAppOAuthWindow() {
|