GoogleOAuthService.swift 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721
  1. import Foundation
  2. import CryptoKit
  3. import AppKit
  4. import Network
  5. import WebKit
  6. struct GoogleOAuthTokens: Codable, Equatable {
  7. var accessToken: String
  8. var refreshToken: String?
  9. var expiresAt: Date
  10. var scope: String?
  11. var tokenType: String?
  12. }
  13. struct GoogleUserProfile: Codable, Equatable {
  14. var name: String?
  15. var email: String?
  16. var picture: String?
  17. }
  18. enum GoogleOAuthError: Error {
  19. case missingClientId
  20. case missingClientSecret
  21. case invalidCallbackURL
  22. case missingAuthorizationCode
  23. case tokenExchangeFailed(String)
  24. case unableToOpenBrowser
  25. case authenticationTimedOut
  26. case noStoredTokens
  27. }
  28. final class GoogleOAuthService: NSObject {
  29. static let shared = GoogleOAuthService()
  30. // Stored in UserDefaults so you can configure without rebuilding.
  31. // Configure at runtime or via Info.plist keys.
  32. private let clientIdDefaultsKey = "google.oauth.clientId"
  33. private let clientSecretDefaultsKey = "google.oauth.clientSecret"
  34. private let clientIdPlistKey = "GoogleOAuthClientID"
  35. private let clientSecretPlistKey = "GoogleOAuthClientSecret"
  36. // Bundled fallback for app-distributed sign-in (no user input required).
  37. private let bundledClientId = "824412072260-m9g5g6mlemnb0o079rtuqnh0e1unmelc.apps.googleusercontent.com"
  38. private let bundledClientSecret = "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"
  39. // Calendar is needed for schedule. Profile/email make login feel complete in-app.
  40. private let scopes = [
  41. "openid",
  42. "email",
  43. "profile",
  44. "https://www.googleapis.com/auth/calendar.events",
  45. // Required for Google Meet conferenceRecords/transcripts APIs.
  46. "https://www.googleapis.com/auth/meetings.space.readonly"
  47. ]
  48. /// Use a stable, app-controlled Keychain "service" identifier so tokens remain manageable even if
  49. /// bundle identifier or signing identity changes between builds.
  50. private static let keychainService = "ai.companion.for.meet.google.oauth.tokens"
  51. private let tokenStore = KeychainTokenStore(service: GoogleOAuthService.keychainService)
  52. /// Legacy store used by older builds that keyed off the bundle identifier.
  53. private let legacyTokenStore = KeychainTokenStore()
  54. @MainActor private var inAppOAuthWindowController: InAppOAuthWindowController?
  55. private override init() {}
  56. private let lastSignedInEmailDefaultsKey = "google.oauth.lastSignedInEmail"
  57. private let ignoreLegacyTokensDefaultsKey = "google.oauth.ignoreLegacyTokens"
  58. func lastSignedInEmailHint() -> String? {
  59. let value = UserDefaults.standard
  60. .string(forKey: lastSignedInEmailDefaultsKey)?
  61. .trimmingCharacters(in: .whitespacesAndNewlines)
  62. guard let value, value.isEmpty == false else { return nil }
  63. return value.lowercased()
  64. }
  65. /// Wraps a Google URL so the default browser is nudged to use the same account as the app.
  66. /// If the browser isn't signed into that account, Google will prompt to sign in.
  67. func urlForPreferredGoogleAccountIfPossible(_ url: URL) -> URL {
  68. guard let email = lastSignedInEmailHint() else { return url }
  69. guard let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http" else { return url }
  70. guard let host = url.host?.lowercased(), host.isEmpty == false else { return url }
  71. // Only attempt for Google properties where account mixups are common.
  72. let isGoogle = host == "google.com" || host.hasSuffix(".google.com")
  73. guard isGoogle else { return url }
  74. var components = URLComponents(string: "https://accounts.google.com/AccountChooser")!
  75. components.queryItems = [
  76. URLQueryItem(name: "continue", value: url.absoluteString),
  77. URLQueryItem(name: "Email", value: email)
  78. ]
  79. return components.url ?? url
  80. }
  81. func configuredClientId() -> String? {
  82. let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
  83. if let value, value.isEmpty == false { return value }
  84. let plistValue = Bundle.main.object(forInfoDictionaryKey: clientIdPlistKey) as? String
  85. let trimmed = plistValue?.trimmingCharacters(in: .whitespacesAndNewlines)
  86. if let trimmed, trimmed.isEmpty == false { return trimmed }
  87. return bundledClientId
  88. }
  89. func setClientIdForTesting(_ clientId: String) {
  90. UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
  91. }
  92. func configuredClientSecret() -> String? {
  93. let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
  94. if let value, value.isEmpty == false { return value }
  95. let plistValue = Bundle.main.object(forInfoDictionaryKey: clientSecretPlistKey) as? String
  96. let trimmed = plistValue?.trimmingCharacters(in: .whitespacesAndNewlines)
  97. if let trimmed, trimmed.isEmpty == false { return trimmed }
  98. return bundledClientSecret
  99. }
  100. func setClientSecretForTesting(_ clientSecret: String) {
  101. UserDefaults.standard.set(clientSecret, forKey: clientSecretDefaultsKey)
  102. }
  103. func signOut() throws {
  104. try tokenStore.deleteTokens()
  105. UserDefaults.standard.set(true, forKey: ignoreLegacyTokensDefaultsKey)
  106. // Best-effort cleanup for legacy items. Some old items cannot be deleted after signing changes.
  107. try? legacyTokenStore.deleteTokens()
  108. }
  109. func signOutAndRevoke() async throws {
  110. // Ensure tokens are in the current store so sign-out is effective.
  111. let existing = try loadTokensOrMigrateIfNeeded()
  112. if let existing { try await revokeTokenIfPossible(existing) }
  113. try tokenStore.deleteTokens()
  114. UserDefaults.standard.set(true, forKey: ignoreLegacyTokensDefaultsKey)
  115. try? legacyTokenStore.deleteTokens()
  116. }
  117. func loadTokens() -> GoogleOAuthTokens? {
  118. try? loadTokensOrMigrateIfNeeded()
  119. }
  120. /// Loads from the current store; if empty, attempts to read legacy tokens and migrate them forward.
  121. /// This avoids being stuck with an undeletable legacy Keychain item after signing/bundle-id changes.
  122. private func loadTokensOrMigrateIfNeeded() throws -> GoogleOAuthTokens? {
  123. if let tokens = try tokenStore.readTokens() { return tokens }
  124. // If the user explicitly signed out, do not resurrect a stuck legacy Keychain item.
  125. if UserDefaults.standard.bool(forKey: ignoreLegacyTokensDefaultsKey) {
  126. return nil
  127. }
  128. if let legacy = try legacyTokenStore.readTokens() {
  129. // Migrate forward; legacy delete may fail with -25244 (invalid owner edit) after signing changes.
  130. try tokenStore.writeTokens(legacy)
  131. try? legacyTokenStore.deleteTokens()
  132. return legacy
  133. }
  134. return nil
  135. }
  136. func fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
  137. var request = URLRequest(url: URL(string: "https://openidconnect.googleapis.com/v1/userinfo")!)
  138. request.httpMethod = "GET"
  139. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  140. let (data, response) = try await URLSession.shared.data(for: request)
  141. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  142. let details = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)"
  143. throw GoogleOAuthError.tokenExchangeFailed(details)
  144. }
  145. let profile = try JSONDecoder().decode(GoogleUserProfile.self, from: data)
  146. let cleaned = profile.email?.trimmingCharacters(in: .whitespacesAndNewlines)
  147. if let cleaned, cleaned.isEmpty == false {
  148. UserDefaults.standard.set(cleaned.lowercased(), forKey: lastSignedInEmailDefaultsKey)
  149. }
  150. return profile
  151. }
  152. func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
  153. if var tokens = try loadTokensOrMigrateIfNeeded() {
  154. if tokens.expiresAt.timeIntervalSinceNow > 60 {
  155. return tokens.accessToken
  156. }
  157. if let refreshed = try await refreshTokens(tokens) {
  158. tokens = refreshed
  159. try tokenStore.writeTokens(tokens)
  160. return tokens.accessToken
  161. }
  162. }
  163. let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
  164. try tokenStore.writeTokens(tokens)
  165. UserDefaults.standard.set(false, forKey: ignoreLegacyTokensDefaultsKey)
  166. return tokens.accessToken
  167. }
  168. // MARK: - Interactive sign-in (Authorization Code + PKCE)
  169. private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
  170. guard let clientId = configuredClientId() else { throw GoogleOAuthError.missingClientId }
  171. guard let clientSecret = configuredClientSecret() else { throw GoogleOAuthError.missingClientSecret }
  172. let codeVerifier = Self.randomURLSafeString(length: 64)
  173. let codeChallenge = Self.pkceChallenge(for: codeVerifier)
  174. let state = Self.randomURLSafeString(length: 32)
  175. let loopback = try await OAuthLoopbackServer.start()
  176. defer { loopback.stop() }
  177. let redirectURI = loopback.redirectURI
  178. let lastEmailHint = UserDefaults.standard
  179. .string(forKey: lastSignedInEmailDefaultsKey)?
  180. .trimmingCharacters(in: .whitespacesAndNewlines)
  181. var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
  182. var queryItems: [URLQueryItem] = [
  183. URLQueryItem(name: "client_id", value: clientId),
  184. URLQueryItem(name: "redirect_uri", value: redirectURI),
  185. URLQueryItem(name: "response_type", value: "code"),
  186. URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
  187. // Reuse already granted scopes so users are not repeatedly asked.
  188. URLQueryItem(name: "include_granted_scopes", value: "true"),
  189. URLQueryItem(name: "state", value: state),
  190. URLQueryItem(name: "code_challenge", value: codeChallenge),
  191. URLQueryItem(name: "code_challenge_method", value: "S256"),
  192. URLQueryItem(name: "access_type", value: "offline"),
  193. // Prefer the same Google account when multiple are present, and if none are signed in
  194. // this forces Google to show sign-in UI.
  195. URLQueryItem(name: "prompt", value: "select_account")
  196. ]
  197. if let lastEmailHint, lastEmailHint.isEmpty == false {
  198. queryItems.append(URLQueryItem(name: "login_hint", value: lastEmailHint))
  199. }
  200. components.queryItems = queryItems
  201. guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
  202. let opened = await MainActor.run { [self] in
  203. openAuthURLInAppBrowser(authURL, presentingWindow: presentingWindow)
  204. }
  205. guard opened else { throw GoogleOAuthError.unableToOpenBrowser }
  206. let callbackURL: URL
  207. do {
  208. callbackURL = try await loopback.waitForCallback()
  209. } catch {
  210. await MainActor.run { [weak self] in
  211. self?.closeInAppOAuthWindow()
  212. }
  213. throw error
  214. }
  215. await MainActor.run { [weak self] in
  216. self?.closeInAppOAuthWindow()
  217. }
  218. guard let returnedState = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
  219. .queryItems?.first(where: { $0.name == "state" })?.value,
  220. returnedState == state else {
  221. throw GoogleOAuthError.invalidCallbackURL
  222. }
  223. guard let code = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
  224. .queryItems?.first(where: { $0.name == "code" })?.value,
  225. code.isEmpty == false else {
  226. throw GoogleOAuthError.missingAuthorizationCode
  227. }
  228. return try await exchangeCodeForTokens(
  229. code: code,
  230. codeVerifier: codeVerifier,
  231. redirectURI: redirectURI,
  232. clientId: clientId,
  233. clientSecret: clientSecret
  234. )
  235. }
  236. @MainActor
  237. private func openAuthURLInDefaultBrowser(_ url: URL) -> Bool {
  238. NSWorkspace.shared.open(url)
  239. }
  240. @MainActor
  241. private func openAuthURLInAppBrowser(_ url: URL, presentingWindow: NSWindow?) -> Bool {
  242. if inAppOAuthWindowController == nil {
  243. inAppOAuthWindowController = InAppOAuthWindowController()
  244. }
  245. guard let controller = inAppOAuthWindowController else { return false }
  246. controller.alignWithPresentingWindow(presentingWindow)
  247. controller.showWindow(nil)
  248. controller.window?.makeKeyAndOrderFront(nil)
  249. NSApp.activate(ignoringOtherApps: true)
  250. controller.load(url: url)
  251. return true
  252. }
  253. @MainActor
  254. private func closeInAppOAuthWindow() {
  255. inAppOAuthWindowController?.close()
  256. inAppOAuthWindowController = nil
  257. }
  258. private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> GoogleOAuthTokens {
  259. var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
  260. request.httpMethod = "POST"
  261. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  262. request.httpBody = Self.formURLEncoded([
  263. "client_id": clientId,
  264. "client_secret": clientSecret,
  265. "code": code,
  266. "code_verifier": codeVerifier,
  267. "redirect_uri": redirectURI,
  268. "grant_type": "authorization_code"
  269. ])
  270. let (data, response) = try await URLSession.shared.data(for: request)
  271. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  272. let details = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)"
  273. throw GoogleOAuthError.tokenExchangeFailed(details)
  274. }
  275. struct TokenResponse: Decodable {
  276. let access_token: String
  277. let expires_in: Double
  278. let refresh_token: String?
  279. let scope: String?
  280. let token_type: String?
  281. }
  282. let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
  283. return GoogleOAuthTokens(
  284. accessToken: decoded.access_token,
  285. refreshToken: decoded.refresh_token,
  286. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  287. scope: decoded.scope,
  288. tokenType: decoded.token_type
  289. )
  290. }
  291. private func refreshTokens(_ tokens: GoogleOAuthTokens) async throws -> GoogleOAuthTokens? {
  292. guard let refreshToken = tokens.refreshToken else { return nil }
  293. guard let clientId = configuredClientId() else { throw GoogleOAuthError.missingClientId }
  294. guard let clientSecret = configuredClientSecret() else { throw GoogleOAuthError.missingClientSecret }
  295. var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
  296. request.httpMethod = "POST"
  297. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  298. request.httpBody = Self.formURLEncoded([
  299. "client_id": clientId,
  300. "client_secret": clientSecret,
  301. "refresh_token": refreshToken,
  302. "grant_type": "refresh_token"
  303. ])
  304. let (data, response) = try await URLSession.shared.data(for: request)
  305. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  306. return nil
  307. }
  308. struct RefreshResponse: Decodable {
  309. let access_token: String
  310. let expires_in: Double
  311. let scope: String?
  312. let token_type: String?
  313. }
  314. let decoded = try JSONDecoder().decode(RefreshResponse.self, from: data)
  315. return GoogleOAuthTokens(
  316. accessToken: decoded.access_token,
  317. refreshToken: refreshToken,
  318. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  319. scope: decoded.scope ?? tokens.scope,
  320. tokenType: decoded.token_type ?? tokens.tokenType
  321. )
  322. }
  323. // MARK: - Helpers
  324. private static func pkceChallenge(for verifier: String) -> String {
  325. let data = Data(verifier.utf8)
  326. let digest = SHA256.hash(data: data)
  327. return Data(digest).base64URLEncodedString()
  328. }
  329. private static func randomURLSafeString(length: Int) -> String {
  330. var bytes = [UInt8](repeating: 0, count: length)
  331. _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
  332. return Data(bytes).base64URLEncodedString()
  333. }
  334. private static func formURLEncoded(_ params: [String: String]) -> Data {
  335. let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
  336. let pairs = params.map { key, value -> String in
  337. let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
  338. let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
  339. return "\(k)=\(v)"
  340. }
  341. .joined(separator: "&")
  342. return Data(pairs.utf8)
  343. }
  344. private func revokeTokenIfPossible(_ tokens: GoogleOAuthTokens) async throws {
  345. let tokenToRevoke = tokens.refreshToken ?? tokens.accessToken
  346. guard tokenToRevoke.isEmpty == false else { return }
  347. var components = URLComponents(string: "https://oauth2.googleapis.com/revoke")!
  348. components.queryItems = [URLQueryItem(name: "token", value: tokenToRevoke)]
  349. guard let url = components.url else { return }
  350. var request = URLRequest(url: url)
  351. request.httpMethod = "POST"
  352. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  353. let (_, response) = try await URLSession.shared.data(for: request)
  354. guard let http = response as? HTTPURLResponse else {
  355. throw GoogleOAuthError.tokenExchangeFailed("Invalid revoke response.")
  356. }
  357. guard (200..<300).contains(http.statusCode) else {
  358. throw GoogleOAuthError.tokenExchangeFailed("Token revoke failed with status \(http.statusCode).")
  359. }
  360. }
  361. }
  362. private extension Data {
  363. func base64URLEncodedString() -> String {
  364. let s = base64EncodedString()
  365. return s
  366. .replacingOccurrences(of: "+", with: "-")
  367. .replacingOccurrences(of: "/", with: "_")
  368. .replacingOccurrences(of: "=", with: "")
  369. }
  370. }
  371. private final class OAuthLoopbackServer {
  372. private let queue = DispatchQueue(label: "google.oauth.loopback.server")
  373. private let listener: NWListener
  374. private var readyContinuation: CheckedContinuation<Void, Error>?
  375. private var callbackContinuation: CheckedContinuation<URL, Error>?
  376. private var callbackURL: URL?
  377. private init(listener: NWListener) {
  378. self.listener = listener
  379. }
  380. static func start() async throws -> OAuthLoopbackServer {
  381. let listener = try NWListener(using: .tcp, on: .any)
  382. let server = OAuthLoopbackServer(listener: listener)
  383. try await server.startListening()
  384. return server
  385. }
  386. var redirectURI: String {
  387. let port = listener.port?.rawValue ?? 0
  388. return "http://127.0.0.1:\(port)/oauth2redirect"
  389. }
  390. func waitForCallback(timeoutSeconds: Double = 120) async throws -> URL {
  391. try await withThrowingTaskGroup(of: URL.self) { group in
  392. group.addTask { [weak self] in
  393. guard let self else { throw GoogleOAuthError.invalidCallbackURL }
  394. return try await self.awaitCallback()
  395. }
  396. group.addTask {
  397. try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
  398. throw GoogleOAuthError.authenticationTimedOut
  399. }
  400. let url = try await group.next()!
  401. group.cancelAll()
  402. return url
  403. }
  404. }
  405. func stop() {
  406. queue.async {
  407. self.listener.cancel()
  408. if let callbackContinuation = self.callbackContinuation {
  409. self.callbackContinuation = nil
  410. callbackContinuation.resume(throwing: GoogleOAuthError.authenticationTimedOut)
  411. }
  412. }
  413. }
  414. private func startListening() async throws {
  415. try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
  416. queue.async {
  417. self.readyContinuation = continuation
  418. self.listener.stateUpdateHandler = { [weak self] state in
  419. guard let self else { return }
  420. switch state {
  421. case .ready:
  422. if let readyContinuation = self.readyContinuation {
  423. self.readyContinuation = nil
  424. readyContinuation.resume()
  425. }
  426. case .failed(let error):
  427. if let readyContinuation = self.readyContinuation {
  428. self.readyContinuation = nil
  429. readyContinuation.resume(throwing: error)
  430. }
  431. case .cancelled:
  432. if let readyContinuation = self.readyContinuation {
  433. self.readyContinuation = nil
  434. readyContinuation.resume(throwing: GoogleOAuthError.invalidCallbackURL)
  435. }
  436. default:
  437. break
  438. }
  439. }
  440. self.listener.newConnectionHandler = { [weak self] connection in
  441. self?.handle(connection: connection)
  442. }
  443. self.listener.start(queue: self.queue)
  444. }
  445. }
  446. }
  447. private func awaitCallback() async throws -> URL {
  448. if let callbackURL { return callbackURL }
  449. return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
  450. queue.async {
  451. if let callbackURL = self.callbackURL {
  452. continuation.resume(returning: callbackURL)
  453. return
  454. }
  455. self.callbackContinuation = continuation
  456. }
  457. }
  458. }
  459. private func handle(connection: NWConnection) {
  460. connection.start(queue: queue)
  461. connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, _, _ in
  462. guard let self else { return }
  463. let requestLine = data
  464. .flatMap { String(data: $0, encoding: .utf8) }?
  465. .split(separator: "\r\n", omittingEmptySubsequences: false)
  466. .first
  467. .map(String.init)
  468. var parsedURL: URL?
  469. if let requestLine {
  470. let parts = requestLine.split(separator: " ")
  471. if parts.count >= 2 {
  472. let pathAndQuery = String(parts[1])
  473. parsedURL = URL(string: "http://127.0.0.1\(pathAndQuery)")
  474. }
  475. }
  476. self.sendHTTPResponse(connection: connection, success: parsedURL != nil)
  477. if let parsedURL {
  478. self.callbackURL = parsedURL
  479. DispatchQueue.main.async {
  480. // Bring the app back to foreground once OAuth redirects.
  481. NSApp.activate(ignoringOtherApps: true)
  482. }
  483. if let continuation = self.callbackContinuation {
  484. self.callbackContinuation = nil
  485. continuation.resume(returning: parsedURL)
  486. }
  487. self.listener.cancel()
  488. }
  489. connection.cancel()
  490. }
  491. }
  492. private func sendHTTPResponse(connection: NWConnection, success: Bool) {
  493. let body = success
  494. ? "<html><body><h3>Authentication complete</h3><p>You can return to the app.</p></body></html>"
  495. : "<html><body><h3>Authentication failed</h3></body></html>"
  496. let response = """
  497. HTTP/1.1 200 OK\r
  498. Content-Type: text/html; charset=utf-8\r
  499. Content-Length: \(body.utf8.count)\r
  500. Connection: close\r
  501. \r
  502. \(body)
  503. """
  504. connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in })
  505. }
  506. }
  507. extension GoogleOAuthError: LocalizedError {
  508. var errorDescription: String? {
  509. switch self {
  510. case .missingClientId:
  511. return "Missing Google OAuth Client ID."
  512. case .missingClientSecret:
  513. return "Missing Google OAuth Client Secret."
  514. case .invalidCallbackURL:
  515. return "Invalid OAuth callback URL."
  516. case .missingAuthorizationCode:
  517. return "Google did not return an authorization code."
  518. case .tokenExchangeFailed(let details):
  519. return "Token exchange failed: \(details)"
  520. case .unableToOpenBrowser:
  521. return "Could not open browser for Google sign-in."
  522. case .authenticationTimedOut:
  523. return "Google sign-in timed out."
  524. case .noStoredTokens:
  525. return "No stored Google tokens found."
  526. }
  527. }
  528. }
  529. @MainActor
  530. private final class OAuthWebViewContainerView: NSView {
  531. private let webView: WKWebView
  532. init(webView: WKWebView) {
  533. self.webView = webView
  534. super.init(frame: .zero)
  535. autoresizingMask = [.width, .height]
  536. addSubview(webView)
  537. }
  538. @available(*, unavailable)
  539. required init?(coder: NSCoder) {
  540. nil
  541. }
  542. override func layout() {
  543. super.layout()
  544. webView.frame = bounds
  545. }
  546. }
  547. @MainActor
  548. private final class InAppOAuthWindowController: NSWindowController, WKNavigationDelegate, WKUIDelegate {
  549. private let webView: WKWebView
  550. private let defaultWindowSize = NSSize(width: 980, height: 760)
  551. init() {
  552. let config = WKWebViewConfiguration()
  553. if #available(macOS 11.0, *) {
  554. config.defaultWebpagePreferences.allowsContentJavaScript = true
  555. }
  556. self.webView = WKWebView(frame: .zero, configuration: config)
  557. let container = OAuthWebViewContainerView(webView: webView)
  558. let window = NSWindow(
  559. contentRect: NSRect(origin: .zero, size: defaultWindowSize),
  560. styleMask: [.titled, .closable, .miniaturizable, .resizable],
  561. backing: .buffered,
  562. defer: false
  563. )
  564. window.title = "Google Sign-In"
  565. window.contentView = container
  566. window.center()
  567. super.init(window: window)
  568. webView.navigationDelegate = self
  569. webView.uiDelegate = self
  570. }
  571. @available(*, unavailable)
  572. required init?(coder: NSCoder) {
  573. nil
  574. }
  575. func load(url: URL) {
  576. webView.load(URLRequest(url: url))
  577. }
  578. func alignWithPresentingWindow(_ presentingWindow: NSWindow?) {
  579. guard let window else { return }
  580. if let presentingWindow {
  581. window.setFrame(presentingWindow.frame, display: false)
  582. return
  583. }
  584. if let screenFrame = NSScreen.main?.visibleFrame {
  585. let origin = NSPoint(
  586. x: screenFrame.midX - (defaultWindowSize.width / 2),
  587. y: screenFrame.midY - (defaultWindowSize.height / 2)
  588. )
  589. window.setFrame(NSRect(origin: origin, size: defaultWindowSize), display: false)
  590. } else {
  591. window.center()
  592. }
  593. }
  594. private func shouldOpenURLExternally(_ url: URL) -> Bool {
  595. let scheme = (url.scheme ?? "").lowercased()
  596. guard !scheme.isEmpty else { return false }
  597. return scheme != "about" && scheme != "javascript"
  598. }
  599. func webView(
  600. _ webView: WKWebView,
  601. decidePolicyFor navigationAction: WKNavigationAction,
  602. decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
  603. ) {
  604. guard let url = navigationAction.request.url else {
  605. decisionHandler(.allow)
  606. return
  607. }
  608. let scheme = (url.scheme ?? "").lowercased()
  609. if scheme == "http" || scheme == "https" {
  610. decisionHandler(.allow)
  611. return
  612. }
  613. if shouldOpenURLExternally(url) {
  614. NSWorkspace.shared.open(url)
  615. }
  616. decisionHandler(.cancel)
  617. }
  618. func webView(
  619. _ webView: WKWebView,
  620. createWebViewWith configuration: WKWebViewConfiguration,
  621. for navigationAction: WKNavigationAction,
  622. windowFeatures: WKWindowFeatures
  623. ) -> WKWebView? {
  624. if navigationAction.targetFrame == nil, let requestURL = navigationAction.request.url {
  625. let scheme = (requestURL.scheme ?? "").lowercased()
  626. if scheme == "http" || scheme == "https" {
  627. webView.load(URLRequest(url: requestURL))
  628. } else if shouldOpenURLExternally(requestURL) {
  629. NSWorkspace.shared.open(requestURL)
  630. }
  631. }
  632. return nil
  633. }
  634. }