Sin descripción

GoogleOAuthService.swift 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. import Foundation
  2. import CryptoKit
  3. import AppKit
  4. import Network
  5. struct GoogleOAuthTokens: Codable, Equatable {
  6. var accessToken: String
  7. var refreshToken: String?
  8. var expiresAt: Date
  9. var scope: String?
  10. var tokenType: String?
  11. }
  12. struct GoogleUserProfile: Codable, Equatable {
  13. var name: String?
  14. var email: String?
  15. var picture: String?
  16. }
  17. enum GoogleOAuthError: Error {
  18. case missingClientId
  19. case missingClientSecret
  20. case invalidCallbackURL
  21. case missingAuthorizationCode
  22. case tokenExchangeFailed(String)
  23. case unableToOpenBrowser
  24. case authenticationTimedOut
  25. case noStoredTokens
  26. }
  27. final class GoogleOAuthService: NSObject {
  28. static let shared = GoogleOAuthService()
  29. // Stored in UserDefaults so you can configure without rebuilding.
  30. // Put your OAuth Desktop client ID here (from Google Cloud Console).
  31. private let clientIdDefaultsKey = "google.oauth.clientId"
  32. private let clientSecretDefaultsKey = "google.oauth.clientSecret"
  33. private let bundledClientId = "1027920783127-tu96fn69edr6fimn32nqh9rfj711fm2i.apps.googleusercontent.com"
  34. private let bundledClientSecret = "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"
  35. private let legacyMeetingClientId = "824412072260-m9g5g6mlemnb0o079rtuqnh0e1unmelc.apps.googleusercontent.com"
  36. // Calendar is needed for schedule. Profile/email make login feel complete in-app.
  37. private let scopes = [
  38. "openid",
  39. "email",
  40. "profile",
  41. "https://www.googleapis.com/auth/calendar.events",
  42. // Classroom To-do (assignments/quizzes)
  43. "https://www.googleapis.com/auth/classroom.courses.readonly",
  44. "https://www.googleapis.com/auth/classroom.coursework.me.readonly",
  45. "https://www.googleapis.com/auth/classroom.rosters.readonly"
  46. ]
  47. private let tokenStore = KeychainTokenStore()
  48. private override init() {
  49. super.init()
  50. migrateLegacyOAuthOverridesIfNeeded()
  51. }
  52. func configuredClientId() -> String? {
  53. let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
  54. if let value, value.isEmpty == false { return value }
  55. return bundledClientId
  56. }
  57. func setClientIdForTesting(_ clientId: String) {
  58. UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
  59. }
  60. func configuredClientSecret() -> String? {
  61. let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
  62. if let value, value.isEmpty == false { return value }
  63. return bundledClientSecret
  64. }
  65. func setClientSecretForTesting(_ clientSecret: String) {
  66. UserDefaults.standard.set(clientSecret, forKey: clientSecretDefaultsKey)
  67. }
  68. private func migrateLegacyOAuthOverridesIfNeeded() {
  69. let configuredId = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
  70. guard configuredId == legacyMeetingClientId else { return }
  71. // Remove old project override so this app uses the Classroom OAuth client.
  72. UserDefaults.standard.removeObject(forKey: clientIdDefaultsKey)
  73. UserDefaults.standard.removeObject(forKey: clientSecretDefaultsKey)
  74. }
  75. func signOut() throws {
  76. try tokenStore.deleteTokens()
  77. }
  78. func loadTokens() -> GoogleOAuthTokens? {
  79. try? tokenStore.readTokens()
  80. }
  81. func fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
  82. var request = URLRequest(url: URL(string: "https://openidconnect.googleapis.com/v1/userinfo")!)
  83. request.httpMethod = "GET"
  84. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  85. let (data, response) = try await URLSession.shared.data(for: request)
  86. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  87. let details = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)"
  88. throw GoogleOAuthError.tokenExchangeFailed(details)
  89. }
  90. return try JSONDecoder().decode(GoogleUserProfile.self, from: data)
  91. }
  92. func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
  93. if var tokens = try tokenStore.readTokens() {
  94. if tokens.expiresAt.timeIntervalSinceNow > 60 {
  95. return tokens.accessToken
  96. }
  97. if let refreshed = try await refreshTokens(tokens) {
  98. tokens = refreshed
  99. try tokenStore.writeTokens(tokens)
  100. return tokens.accessToken
  101. }
  102. }
  103. let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
  104. try tokenStore.writeTokens(tokens)
  105. return tokens.accessToken
  106. }
  107. // MARK: - Interactive sign-in (Authorization Code + PKCE)
  108. private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
  109. _ = presentingWindow
  110. guard let clientId = configuredClientId() else { throw GoogleOAuthError.missingClientId }
  111. guard let clientSecret = configuredClientSecret() else { throw GoogleOAuthError.missingClientSecret }
  112. let codeVerifier = Self.randomURLSafeString(length: 64)
  113. let codeChallenge = Self.pkceChallenge(for: codeVerifier)
  114. let state = Self.randomURLSafeString(length: 32)
  115. let loopback = try await OAuthLoopbackServer.start()
  116. defer { loopback.stop() }
  117. let redirectURI = loopback.redirectURI
  118. var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
  119. components.queryItems = [
  120. URLQueryItem(name: "client_id", value: clientId),
  121. URLQueryItem(name: "redirect_uri", value: redirectURI),
  122. URLQueryItem(name: "response_type", value: "code"),
  123. URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
  124. URLQueryItem(name: "state", value: state),
  125. URLQueryItem(name: "code_challenge", value: codeChallenge),
  126. URLQueryItem(name: "code_challenge_method", value: "S256"),
  127. URLQueryItem(name: "access_type", value: "offline"),
  128. URLQueryItem(name: "prompt", value: "consent")
  129. ]
  130. guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
  131. guard NSWorkspace.shared.open(authURL) else { throw GoogleOAuthError.unableToOpenBrowser }
  132. let callbackURL = try await loopback.waitForCallback()
  133. guard let returnedState = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
  134. .queryItems?.first(where: { $0.name == "state" })?.value,
  135. returnedState == state else {
  136. throw GoogleOAuthError.invalidCallbackURL
  137. }
  138. guard let code = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
  139. .queryItems?.first(where: { $0.name == "code" })?.value,
  140. code.isEmpty == false else {
  141. throw GoogleOAuthError.missingAuthorizationCode
  142. }
  143. return try await exchangeCodeForTokens(
  144. code: code,
  145. codeVerifier: codeVerifier,
  146. redirectURI: redirectURI,
  147. clientId: clientId,
  148. clientSecret: clientSecret
  149. )
  150. }
  151. private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> GoogleOAuthTokens {
  152. var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
  153. request.httpMethod = "POST"
  154. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  155. request.httpBody = Self.formURLEncoded([
  156. "client_id": clientId,
  157. "client_secret": clientSecret,
  158. "code": code,
  159. "code_verifier": codeVerifier,
  160. "redirect_uri": redirectURI,
  161. "grant_type": "authorization_code"
  162. ])
  163. let (data, response) = try await URLSession.shared.data(for: request)
  164. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  165. let details = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)"
  166. throw GoogleOAuthError.tokenExchangeFailed(details)
  167. }
  168. struct TokenResponse: Decodable {
  169. let access_token: String
  170. let expires_in: Double
  171. let refresh_token: String?
  172. let scope: String?
  173. let token_type: String?
  174. }
  175. let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
  176. return GoogleOAuthTokens(
  177. accessToken: decoded.access_token,
  178. refreshToken: decoded.refresh_token,
  179. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  180. scope: decoded.scope,
  181. tokenType: decoded.token_type
  182. )
  183. }
  184. private func refreshTokens(_ tokens: GoogleOAuthTokens) async throws -> GoogleOAuthTokens? {
  185. guard let refreshToken = tokens.refreshToken else { return nil }
  186. guard let clientId = configuredClientId() else { throw GoogleOAuthError.missingClientId }
  187. guard let clientSecret = configuredClientSecret() else { throw GoogleOAuthError.missingClientSecret }
  188. var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
  189. request.httpMethod = "POST"
  190. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  191. request.httpBody = Self.formURLEncoded([
  192. "client_id": clientId,
  193. "client_secret": clientSecret,
  194. "refresh_token": refreshToken,
  195. "grant_type": "refresh_token"
  196. ])
  197. let (data, response) = try await URLSession.shared.data(for: request)
  198. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  199. return nil
  200. }
  201. struct RefreshResponse: Decodable {
  202. let access_token: String
  203. let expires_in: Double
  204. let scope: String?
  205. let token_type: String?
  206. }
  207. let decoded = try JSONDecoder().decode(RefreshResponse.self, from: data)
  208. return GoogleOAuthTokens(
  209. accessToken: decoded.access_token,
  210. refreshToken: refreshToken,
  211. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  212. scope: decoded.scope ?? tokens.scope,
  213. tokenType: decoded.token_type ?? tokens.tokenType
  214. )
  215. }
  216. // MARK: - Helpers
  217. private static func pkceChallenge(for verifier: String) -> String {
  218. let data = Data(verifier.utf8)
  219. let digest = SHA256.hash(data: data)
  220. return Data(digest).base64URLEncodedString()
  221. }
  222. private static func randomURLSafeString(length: Int) -> String {
  223. var bytes = [UInt8](repeating: 0, count: length)
  224. _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
  225. return Data(bytes).base64URLEncodedString()
  226. }
  227. private static func formURLEncoded(_ params: [String: String]) -> Data {
  228. let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
  229. let pairs = params.map { key, value -> String in
  230. let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
  231. let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
  232. return "\(k)=\(v)"
  233. }
  234. .joined(separator: "&")
  235. return Data(pairs.utf8)
  236. }
  237. }
  238. private extension Data {
  239. func base64URLEncodedString() -> String {
  240. let s = base64EncodedString()
  241. return s
  242. .replacingOccurrences(of: "+", with: "-")
  243. .replacingOccurrences(of: "/", with: "_")
  244. .replacingOccurrences(of: "=", with: "")
  245. }
  246. }
  247. private final class OAuthLoopbackServer {
  248. private let queue = DispatchQueue(label: "google.oauth.loopback.server")
  249. private let listener: NWListener
  250. private var readyContinuation: CheckedContinuation<Void, Error>?
  251. private var callbackContinuation: CheckedContinuation<URL, Error>?
  252. private var callbackURL: URL?
  253. private init(listener: NWListener) {
  254. self.listener = listener
  255. }
  256. static func start() async throws -> OAuthLoopbackServer {
  257. let listener = try NWListener(using: .tcp, on: .any)
  258. let server = OAuthLoopbackServer(listener: listener)
  259. try await server.startListening()
  260. return server
  261. }
  262. var redirectURI: String {
  263. let port = listener.port?.rawValue ?? 0
  264. return "http://127.0.0.1:\(port)/oauth2redirect"
  265. }
  266. func waitForCallback(timeoutSeconds: Double = 120) async throws -> URL {
  267. try await withThrowingTaskGroup(of: URL.self) { group in
  268. group.addTask { [weak self] in
  269. guard let self else { throw GoogleOAuthError.invalidCallbackURL }
  270. return try await self.awaitCallback()
  271. }
  272. group.addTask {
  273. try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
  274. throw GoogleOAuthError.authenticationTimedOut
  275. }
  276. let url = try await group.next()!
  277. group.cancelAll()
  278. return url
  279. }
  280. }
  281. func stop() {
  282. queue.async {
  283. self.listener.cancel()
  284. if let callbackContinuation = self.callbackContinuation {
  285. self.callbackContinuation = nil
  286. callbackContinuation.resume(throwing: GoogleOAuthError.authenticationTimedOut)
  287. }
  288. }
  289. }
  290. private func startListening() async throws {
  291. try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
  292. queue.async {
  293. self.readyContinuation = continuation
  294. self.listener.stateUpdateHandler = { [weak self] state in
  295. guard let self else { return }
  296. switch state {
  297. case .ready:
  298. if let readyContinuation = self.readyContinuation {
  299. self.readyContinuation = nil
  300. readyContinuation.resume()
  301. }
  302. case .failed(let error):
  303. if let readyContinuation = self.readyContinuation {
  304. self.readyContinuation = nil
  305. readyContinuation.resume(throwing: error)
  306. }
  307. case .cancelled:
  308. if let readyContinuation = self.readyContinuation {
  309. self.readyContinuation = nil
  310. readyContinuation.resume(throwing: GoogleOAuthError.invalidCallbackURL)
  311. }
  312. default:
  313. break
  314. }
  315. }
  316. self.listener.newConnectionHandler = { [weak self] connection in
  317. self?.handle(connection: connection)
  318. }
  319. self.listener.start(queue: self.queue)
  320. }
  321. }
  322. }
  323. private func awaitCallback() async throws -> URL {
  324. if let callbackURL { return callbackURL }
  325. return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
  326. queue.async {
  327. if let callbackURL = self.callbackURL {
  328. continuation.resume(returning: callbackURL)
  329. return
  330. }
  331. self.callbackContinuation = continuation
  332. }
  333. }
  334. }
  335. private func handle(connection: NWConnection) {
  336. connection.start(queue: queue)
  337. connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, _, _ in
  338. guard let self else { return }
  339. let requestLine = data
  340. .flatMap { String(data: $0, encoding: .utf8) }?
  341. .split(separator: "\r\n", omittingEmptySubsequences: false)
  342. .first
  343. .map(String.init)
  344. var parsedURL: URL?
  345. if let requestLine {
  346. let parts = requestLine.split(separator: " ")
  347. if parts.count >= 2 {
  348. let pathAndQuery = String(parts[1])
  349. parsedURL = URL(string: "http://127.0.0.1\(pathAndQuery)")
  350. }
  351. }
  352. self.sendHTTPResponse(connection: connection, success: parsedURL != nil)
  353. if let parsedURL {
  354. self.callbackURL = parsedURL
  355. DispatchQueue.main.async {
  356. // Bring the app back to foreground once OAuth redirects.
  357. NSApp.activate(ignoringOtherApps: true)
  358. }
  359. if let continuation = self.callbackContinuation {
  360. self.callbackContinuation = nil
  361. continuation.resume(returning: parsedURL)
  362. }
  363. self.listener.cancel()
  364. }
  365. connection.cancel()
  366. }
  367. }
  368. private func sendHTTPResponse(connection: NWConnection, success: Bool) {
  369. let body = success
  370. ? "<html><body><h3>Authentication complete</h3><p>You can return to the app.</p></body></html>"
  371. : "<html><body><h3>Authentication failed</h3></body></html>"
  372. let response = """
  373. HTTP/1.1 200 OK\r
  374. Content-Type: text/html; charset=utf-8\r
  375. Content-Length: \(body.utf8.count)\r
  376. Connection: close\r
  377. \r
  378. \(body)
  379. """
  380. connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in })
  381. }
  382. }
  383. extension GoogleOAuthError: LocalizedError {
  384. var errorDescription: String? {
  385. switch self {
  386. case .missingClientId:
  387. return "Missing Google OAuth Client ID."
  388. case .missingClientSecret:
  389. return "Missing Google OAuth Client Secret."
  390. case .invalidCallbackURL:
  391. return "Invalid OAuth callback URL."
  392. case .missingAuthorizationCode:
  393. return "Google did not return an authorization code."
  394. case .tokenExchangeFailed(let details):
  395. return "Token exchange failed: \(details)"
  396. case .unableToOpenBrowser:
  397. return "Could not open browser for Google sign-in."
  398. case .authenticationTimedOut:
  399. return "Google sign-in timed out."
  400. case .noStoredTokens:
  401. return "No stored Google tokens found."
  402. }
  403. }
  404. }