|
|
@@ -55,6 +55,35 @@ final class GoogleOAuthService: NSObject {
|
|
55
|
55
|
private let tokenStore = KeychainTokenStore()
|
|
56
|
56
|
@MainActor private var inAppOAuthWindowController: InAppOAuthWindowController?
|
|
57
|
57
|
private override init() {}
|
|
|
58
|
+
|
|
|
59
|
+ private let lastSignedInEmailDefaultsKey = "google.oauth.lastSignedInEmail"
|
|
|
60
|
+
|
|
|
61
|
+ func lastSignedInEmailHint() -> String? {
|
|
|
62
|
+ let value = UserDefaults.standard
|
|
|
63
|
+ .string(forKey: lastSignedInEmailDefaultsKey)?
|
|
|
64
|
+ .trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
65
|
+ guard let value, value.isEmpty == false else { return nil }
|
|
|
66
|
+ return value.lowercased()
|
|
|
67
|
+ }
|
|
|
68
|
+
|
|
|
69
|
+ /// Wraps a Google URL so the default browser is nudged to use the same account as the app.
|
|
|
70
|
+ /// If the browser isn't signed into that account, Google will prompt to sign in.
|
|
|
71
|
+ func urlForPreferredGoogleAccountIfPossible(_ url: URL) -> URL {
|
|
|
72
|
+ guard let email = lastSignedInEmailHint() else { return url }
|
|
|
73
|
+ guard let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http" else { return url }
|
|
|
74
|
+ guard let host = url.host?.lowercased(), host.isEmpty == false else { return url }
|
|
|
75
|
+
|
|
|
76
|
+ // Only attempt for Google properties where account mixups are common.
|
|
|
77
|
+ let isGoogle = host == "google.com" || host.hasSuffix(".google.com")
|
|
|
78
|
+ guard isGoogle else { return url }
|
|
|
79
|
+
|
|
|
80
|
+ var components = URLComponents(string: "https://accounts.google.com/AccountChooser")!
|
|
|
81
|
+ components.queryItems = [
|
|
|
82
|
+ URLQueryItem(name: "continue", value: url.absoluteString),
|
|
|
83
|
+ URLQueryItem(name: "Email", value: email)
|
|
|
84
|
+ ]
|
|
|
85
|
+ return components.url ?? url
|
|
|
86
|
+ }
|
|
58
|
87
|
|
|
59
|
88
|
func configuredClientId() -> String? {
|
|
60
|
89
|
let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
@@ -108,7 +137,12 @@ final class GoogleOAuthService: NSObject {
|
|
108
|
137
|
let details = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)"
|
|
109
|
138
|
throw GoogleOAuthError.tokenExchangeFailed(details)
|
|
110
|
139
|
}
|
|
111
|
|
- return try JSONDecoder().decode(GoogleUserProfile.self, from: data)
|
|
|
140
|
+ let profile = try JSONDecoder().decode(GoogleUserProfile.self, from: data)
|
|
|
141
|
+ let cleaned = profile.email?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
142
|
+ if let cleaned, cleaned.isEmpty == false {
|
|
|
143
|
+ UserDefaults.standard.set(cleaned.lowercased(), forKey: lastSignedInEmailDefaultsKey)
|
|
|
144
|
+ }
|
|
|
145
|
+ return profile
|
|
112
|
146
|
}
|
|
113
|
147
|
|
|
114
|
148
|
func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
|
|
|
@@ -140,9 +174,13 @@ final class GoogleOAuthService: NSObject {
|
|
140
|
174
|
let loopback = try await OAuthLoopbackServer.start()
|
|
141
|
175
|
defer { loopback.stop() }
|
|
142
|
176
|
let redirectURI = loopback.redirectURI
|
|
|
177
|
+
|
|
|
178
|
+ let lastEmailHint = UserDefaults.standard
|
|
|
179
|
+ .string(forKey: lastSignedInEmailDefaultsKey)?
|
|
|
180
|
+ .trimmingCharacters(in: .whitespacesAndNewlines)
|
|
143
|
181
|
|
|
144
|
182
|
var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
|
|
145
|
|
- components.queryItems = [
|
|
|
183
|
+ var queryItems: [URLQueryItem] = [
|
|
146
|
184
|
URLQueryItem(name: "client_id", value: clientId),
|
|
147
|
185
|
URLQueryItem(name: "redirect_uri", value: redirectURI),
|
|
148
|
186
|
URLQueryItem(name: "response_type", value: "code"),
|
|
|
@@ -152,19 +190,21 @@ final class GoogleOAuthService: NSObject {
|
|
152
|
190
|
URLQueryItem(name: "state", value: state),
|
|
153
|
191
|
URLQueryItem(name: "code_challenge", value: codeChallenge),
|
|
154
|
192
|
URLQueryItem(name: "code_challenge_method", value: "S256"),
|
|
155
|
|
- URLQueryItem(name: "access_type", value: "offline")
|
|
|
193
|
+ URLQueryItem(name: "access_type", value: "offline"),
|
|
|
194
|
+ // Prefer the same Google account when multiple are present, and if none are signed in
|
|
|
195
|
+ // this forces Google to show sign-in UI.
|
|
|
196
|
+ URLQueryItem(name: "prompt", value: "select_account")
|
|
156
|
197
|
]
|
|
|
198
|
+ if let lastEmailHint, lastEmailHint.isEmpty == false {
|
|
|
199
|
+ queryItems.append(URLQueryItem(name: "login_hint", value: lastEmailHint))
|
|
|
200
|
+ }
|
|
|
201
|
+ components.queryItems = queryItems
|
|
157
|
202
|
|
|
158
|
203
|
guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
|
|
159
|
204
|
let opened = await MainActor.run { [self] in
|
|
160
|
|
- openAuthURLInApp(authURL, presentingWindow: presentingWindow)
|
|
|
205
|
+ openAuthURLInDefaultBrowser(authURL)
|
|
161
|
206
|
}
|
|
162
|
207
|
guard opened else { throw GoogleOAuthError.unableToOpenBrowser }
|
|
163
|
|
- defer {
|
|
164
|
|
- Task { @MainActor [weak self] in
|
|
165
|
|
- self?.closeInAppOAuthWindow()
|
|
166
|
|
- }
|
|
167
|
|
- }
|
|
168
|
208
|
let callbackURL = try await loopback.waitForCallback()
|
|
169
|
209
|
|
|
170
|
210
|
guard let returnedState = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
|
|
|
@@ -189,21 +229,8 @@ final class GoogleOAuthService: NSObject {
|
|
189
|
229
|
}
|
|
190
|
230
|
|
|
191
|
231
|
@MainActor
|
|
192
|
|
- private func openAuthURLInApp(_ url: URL, presentingWindow: NSWindow?) -> Bool {
|
|
193
|
|
- let controller: InAppOAuthWindowController
|
|
194
|
|
- if let existing = inAppOAuthWindowController {
|
|
195
|
|
- controller = existing
|
|
196
|
|
- } else {
|
|
197
|
|
- controller = InAppOAuthWindowController()
|
|
198
|
|
- inAppOAuthWindowController = controller
|
|
199
|
|
- }
|
|
200
|
|
- controller.alignWithPresentingWindow(presentingWindow)
|
|
201
|
|
- controller.load(url: url)
|
|
202
|
|
- controller.showWindow(nil)
|
|
203
|
|
- controller.window?.makeKeyAndOrderFront(nil)
|
|
204
|
|
- controller.window?.orderFrontRegardless()
|
|
205
|
|
- NSApp.activate(ignoringOtherApps: true)
|
|
206
|
|
- return true
|
|
|
232
|
+ private func openAuthURLInDefaultBrowser(_ url: URL) -> Bool {
|
|
|
233
|
+ NSWorkspace.shared.open(url)
|
|
207
|
234
|
}
|
|
208
|
235
|
|
|
209
|
236
|
@MainActor
|