|
@@ -55,6 +55,35 @@ final class GoogleOAuthService: NSObject {
|
|
|
private let tokenStore = KeychainTokenStore()
|
|
private let tokenStore = KeychainTokenStore()
|
|
|
@MainActor private var inAppOAuthWindowController: InAppOAuthWindowController?
|
|
@MainActor private var inAppOAuthWindowController: InAppOAuthWindowController?
|
|
|
private override init() {}
|
|
private override init() {}
|
|
|
|
|
+
|
|
|
|
|
+ private let lastSignedInEmailDefaultsKey = "google.oauth.lastSignedInEmail"
|
|
|
|
|
+
|
|
|
|
|
+ func lastSignedInEmailHint() -> String? {
|
|
|
|
|
+ let value = UserDefaults.standard
|
|
|
|
|
+ .string(forKey: lastSignedInEmailDefaultsKey)?
|
|
|
|
|
+ .trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ guard let value, value.isEmpty == false else { return nil }
|
|
|
|
|
+ return value.lowercased()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Wraps a Google URL so the default browser is nudged to use the same account as the app.
|
|
|
|
|
+ /// If the browser isn't signed into that account, Google will prompt to sign in.
|
|
|
|
|
+ func urlForPreferredGoogleAccountIfPossible(_ url: URL) -> URL {
|
|
|
|
|
+ guard let email = lastSignedInEmailHint() else { return url }
|
|
|
|
|
+ guard let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http" else { return url }
|
|
|
|
|
+ guard let host = url.host?.lowercased(), host.isEmpty == false else { return url }
|
|
|
|
|
+
|
|
|
|
|
+ // Only attempt for Google properties where account mixups are common.
|
|
|
|
|
+ let isGoogle = host == "google.com" || host.hasSuffix(".google.com")
|
|
|
|
|
+ guard isGoogle else { return url }
|
|
|
|
|
+
|
|
|
|
|
+ var components = URLComponents(string: "https://accounts.google.com/AccountChooser")!
|
|
|
|
|
+ components.queryItems = [
|
|
|
|
|
+ URLQueryItem(name: "continue", value: url.absoluteString),
|
|
|
|
|
+ URLQueryItem(name: "Email", value: email)
|
|
|
|
|
+ ]
|
|
|
|
|
+ return components.url ?? url
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
func configuredClientId() -> String? {
|
|
func configuredClientId() -> String? {
|
|
|
let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
@@ -108,7 +137,12 @@ final class GoogleOAuthService: NSObject {
|
|
|
let details = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)"
|
|
let details = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)"
|
|
|
throw GoogleOAuthError.tokenExchangeFailed(details)
|
|
throw GoogleOAuthError.tokenExchangeFailed(details)
|
|
|
}
|
|
}
|
|
|
- return try JSONDecoder().decode(GoogleUserProfile.self, from: data)
|
|
|
|
|
|
|
+ let profile = try JSONDecoder().decode(GoogleUserProfile.self, from: data)
|
|
|
|
|
+ let cleaned = profile.email?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ if let cleaned, cleaned.isEmpty == false {
|
|
|
|
|
+ UserDefaults.standard.set(cleaned.lowercased(), forKey: lastSignedInEmailDefaultsKey)
|
|
|
|
|
+ }
|
|
|
|
|
+ return profile
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
|
|
func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
|
|
@@ -140,9 +174,13 @@ final class GoogleOAuthService: NSObject {
|
|
|
let loopback = try await OAuthLoopbackServer.start()
|
|
let loopback = try await OAuthLoopbackServer.start()
|
|
|
defer { loopback.stop() }
|
|
defer { loopback.stop() }
|
|
|
let redirectURI = loopback.redirectURI
|
|
let redirectURI = loopback.redirectURI
|
|
|
|
|
+
|
|
|
|
|
+ let lastEmailHint = UserDefaults.standard
|
|
|
|
|
+ .string(forKey: lastSignedInEmailDefaultsKey)?
|
|
|
|
|
+ .trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
|
|
|
var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
|
|
var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
|
|
|
- components.queryItems = [
|
|
|
|
|
|
|
+ var queryItems: [URLQueryItem] = [
|
|
|
URLQueryItem(name: "client_id", value: clientId),
|
|
URLQueryItem(name: "client_id", value: clientId),
|
|
|
URLQueryItem(name: "redirect_uri", value: redirectURI),
|
|
URLQueryItem(name: "redirect_uri", value: redirectURI),
|
|
|
URLQueryItem(name: "response_type", value: "code"),
|
|
URLQueryItem(name: "response_type", value: "code"),
|
|
@@ -152,19 +190,21 @@ final class GoogleOAuthService: NSObject {
|
|
|
URLQueryItem(name: "state", value: state),
|
|
URLQueryItem(name: "state", value: state),
|
|
|
URLQueryItem(name: "code_challenge", value: codeChallenge),
|
|
URLQueryItem(name: "code_challenge", value: codeChallenge),
|
|
|
URLQueryItem(name: "code_challenge_method", value: "S256"),
|
|
URLQueryItem(name: "code_challenge_method", value: "S256"),
|
|
|
- URLQueryItem(name: "access_type", value: "offline")
|
|
|
|
|
|
|
+ URLQueryItem(name: "access_type", value: "offline"),
|
|
|
|
|
+ // Prefer the same Google account when multiple are present, and if none are signed in
|
|
|
|
|
+ // this forces Google to show sign-in UI.
|
|
|
|
|
+ URLQueryItem(name: "prompt", value: "select_account")
|
|
|
]
|
|
]
|
|
|
|
|
+ if let lastEmailHint, lastEmailHint.isEmpty == false {
|
|
|
|
|
+ queryItems.append(URLQueryItem(name: "login_hint", value: lastEmailHint))
|
|
|
|
|
+ }
|
|
|
|
|
+ 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
|
|
|
- openAuthURLInApp(authURL, presentingWindow: presentingWindow)
|
|
|
|
|
|
|
+ openAuthURLInDefaultBrowser(authURL)
|
|
|
}
|
|
}
|
|
|
guard opened else { throw GoogleOAuthError.unableToOpenBrowser }
|
|
guard opened else { throw GoogleOAuthError.unableToOpenBrowser }
|
|
|
- defer {
|
|
|
|
|
- Task { @MainActor [weak self] in
|
|
|
|
|
- self?.closeInAppOAuthWindow()
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
let callbackURL = try await loopback.waitForCallback()
|
|
let callbackURL = try await loopback.waitForCallback()
|
|
|
|
|
|
|
|
guard let returnedState = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
|
|
guard let returnedState = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
|
|
@@ -189,21 +229,8 @@ final class GoogleOAuthService: NSObject {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@MainActor
|
|
@MainActor
|
|
|
- private func openAuthURLInApp(_ url: URL, presentingWindow: NSWindow?) -> Bool {
|
|
|
|
|
- let controller: InAppOAuthWindowController
|
|
|
|
|
- if let existing = inAppOAuthWindowController {
|
|
|
|
|
- controller = existing
|
|
|
|
|
- } else {
|
|
|
|
|
- controller = InAppOAuthWindowController()
|
|
|
|
|
- inAppOAuthWindowController = controller
|
|
|
|
|
- }
|
|
|
|
|
- controller.alignWithPresentingWindow(presentingWindow)
|
|
|
|
|
- controller.load(url: url)
|
|
|
|
|
- controller.showWindow(nil)
|
|
|
|
|
- controller.window?.makeKeyAndOrderFront(nil)
|
|
|
|
|
- controller.window?.orderFrontRegardless()
|
|
|
|
|
- NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
- return true
|
|
|
|
|
|
|
+ private func openAuthURLInDefaultBrowser(_ url: URL) -> Bool {
|
|
|
|
|
+ NSWorkspace.shared.open(url)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@MainActor
|
|
@MainActor
|