설명 없음

GoogleOAuthService.swift 23KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602
  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. private var inAppOAuthWindowController: InAppOAuthWindowController?
  31. // Stored in UserDefaults so you can configure without rebuilding.
  32. // Put your OAuth Desktop client ID here (from Google Cloud Console).
  33. private let clientIdDefaultsKey = "google.oauth.clientId"
  34. private let clientSecretDefaultsKey = "google.oauth.clientSecret"
  35. private let bundledClientId = "1027920783127-tu96fn69edr6fimn32nqh9rfj711fm2i.apps.googleusercontent.com"
  36. private let bundledClientSecret = "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"
  37. private let legacyMeetingClientId = "824412072260-m9g5g6mlemnb0o079rtuqnh0e1unmelc.apps.googleusercontent.com"
  38. // Profile/email make login feel complete in-app.
  39. private let scopes = [
  40. "openid",
  41. "email",
  42. "profile",
  43. // Classroom To-do (assignments/quizzes)
  44. "https://www.googleapis.com/auth/classroom.courses.readonly",
  45. "https://www.googleapis.com/auth/classroom.courses",
  46. "https://www.googleapis.com/auth/classroom.coursework.me.readonly",
  47. "https://www.googleapis.com/auth/classroom.announcements.readonly",
  48. "https://www.googleapis.com/auth/classroom.rosters.readonly",
  49. "https://www.googleapis.com/auth/classroom.rosters"
  50. ]
  51. private let tokenStore = KeychainTokenStore()
  52. private override init() {
  53. super.init()
  54. migrateLegacyOAuthOverridesIfNeeded()
  55. }
  56. func configuredClientId() -> String? {
  57. let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
  58. if let value, value.isEmpty == false { return value }
  59. return bundledClientId
  60. }
  61. func setClientIdForTesting(_ clientId: String) {
  62. UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
  63. }
  64. func configuredClientSecret() -> String? {
  65. let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
  66. if let value, value.isEmpty == false { return value }
  67. return bundledClientSecret
  68. }
  69. func setClientSecretForTesting(_ clientSecret: String) {
  70. UserDefaults.standard.set(clientSecret, forKey: clientSecretDefaultsKey)
  71. }
  72. private func migrateLegacyOAuthOverridesIfNeeded() {
  73. let configuredId = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
  74. guard configuredId == legacyMeetingClientId else { return }
  75. // Remove old project override so this app uses the Classroom OAuth client.
  76. UserDefaults.standard.removeObject(forKey: clientIdDefaultsKey)
  77. UserDefaults.standard.removeObject(forKey: clientSecretDefaultsKey)
  78. }
  79. func signOut() throws {
  80. try tokenStore.deleteTokens()
  81. }
  82. func loadTokens() -> GoogleOAuthTokens? {
  83. try? tokenStore.readTokens()
  84. }
  85. func fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
  86. var request = URLRequest(url: URL(string: "https://openidconnect.googleapis.com/v1/userinfo")!)
  87. request.httpMethod = "GET"
  88. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  89. let (data, response) = try await URLSession.shared.data(for: request)
  90. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  91. let details = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)"
  92. throw GoogleOAuthError.tokenExchangeFailed(details)
  93. }
  94. return try JSONDecoder().decode(GoogleUserProfile.self, from: data)
  95. }
  96. func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
  97. if var tokens = try tokenStore.readTokens() {
  98. if tokens.expiresAt.timeIntervalSinceNow > 60 {
  99. return tokens.accessToken
  100. }
  101. if let refreshed = try await refreshTokens(tokens) {
  102. tokens = refreshed
  103. try tokenStore.writeTokens(tokens)
  104. return tokens.accessToken
  105. }
  106. }
  107. let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
  108. try tokenStore.writeTokens(tokens)
  109. return tokens.accessToken
  110. }
  111. // MARK: - Interactive sign-in (Authorization Code + PKCE)
  112. private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
  113. _ = presentingWindow
  114. guard let clientId = configuredClientId() else { throw GoogleOAuthError.missingClientId }
  115. guard let clientSecret = configuredClientSecret() else { throw GoogleOAuthError.missingClientSecret }
  116. let codeVerifier = Self.randomURLSafeString(length: 64)
  117. let codeChallenge = Self.pkceChallenge(for: codeVerifier)
  118. let state = Self.randomURLSafeString(length: 32)
  119. let loopback = try await OAuthLoopbackServer.start()
  120. defer { loopback.stop() }
  121. let redirectURI = loopback.redirectURI
  122. var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
  123. components.queryItems = [
  124. URLQueryItem(name: "client_id", value: clientId),
  125. URLQueryItem(name: "redirect_uri", value: redirectURI),
  126. URLQueryItem(name: "response_type", value: "code"),
  127. URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
  128. URLQueryItem(name: "state", value: state),
  129. URLQueryItem(name: "code_challenge", value: codeChallenge),
  130. URLQueryItem(name: "code_challenge_method", value: "S256"),
  131. URLQueryItem(name: "access_type", value: "offline"),
  132. URLQueryItem(name: "prompt", value: "consent")
  133. ]
  134. guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
  135. let opened = await MainActor.run { [self] in
  136. openAuthURLInApp(authURL)
  137. }
  138. guard opened else { throw GoogleOAuthError.unableToOpenBrowser }
  139. defer {
  140. Task { @MainActor [weak self] in
  141. self?.closeInAppOAuthWindow()
  142. }
  143. }
  144. let callbackURL = try await loopback.waitForCallback()
  145. guard let returnedState = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
  146. .queryItems?.first(where: { $0.name == "state" })?.value,
  147. returnedState == state else {
  148. throw GoogleOAuthError.invalidCallbackURL
  149. }
  150. guard let code = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
  151. .queryItems?.first(where: { $0.name == "code" })?.value,
  152. code.isEmpty == false else {
  153. throw GoogleOAuthError.missingAuthorizationCode
  154. }
  155. return try await exchangeCodeForTokens(
  156. code: code,
  157. codeVerifier: codeVerifier,
  158. redirectURI: redirectURI,
  159. clientId: clientId,
  160. clientSecret: clientSecret
  161. )
  162. }
  163. @MainActor
  164. private func openAuthURLInApp(_ url: URL) -> Bool {
  165. let controller: InAppOAuthWindowController
  166. if let existing = inAppOAuthWindowController {
  167. controller = existing
  168. } else {
  169. controller = InAppOAuthWindowController()
  170. inAppOAuthWindowController = controller
  171. }
  172. controller.load(url: url)
  173. controller.showWindow(nil)
  174. controller.window?.makeKeyAndOrderFront(nil)
  175. controller.window?.orderFrontRegardless()
  176. NSApp.activate(ignoringOtherApps: true)
  177. return true
  178. }
  179. @MainActor
  180. private func closeInAppOAuthWindow() {
  181. inAppOAuthWindowController?.close()
  182. inAppOAuthWindowController = nil
  183. }
  184. private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> GoogleOAuthTokens {
  185. var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
  186. request.httpMethod = "POST"
  187. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  188. request.httpBody = Self.formURLEncoded([
  189. "client_id": clientId,
  190. "client_secret": clientSecret,
  191. "code": code,
  192. "code_verifier": codeVerifier,
  193. "redirect_uri": redirectURI,
  194. "grant_type": "authorization_code"
  195. ])
  196. let (data, response) = try await URLSession.shared.data(for: request)
  197. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  198. let details = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)"
  199. throw GoogleOAuthError.tokenExchangeFailed(details)
  200. }
  201. struct TokenResponse: Decodable {
  202. let access_token: String
  203. let expires_in: Double
  204. let refresh_token: String?
  205. let scope: String?
  206. let token_type: String?
  207. }
  208. let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
  209. return GoogleOAuthTokens(
  210. accessToken: decoded.access_token,
  211. refreshToken: decoded.refresh_token,
  212. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  213. scope: decoded.scope,
  214. tokenType: decoded.token_type
  215. )
  216. }
  217. private func refreshTokens(_ tokens: GoogleOAuthTokens) async throws -> GoogleOAuthTokens? {
  218. guard let refreshToken = tokens.refreshToken else { return nil }
  219. guard let clientId = configuredClientId() else { throw GoogleOAuthError.missingClientId }
  220. guard let clientSecret = configuredClientSecret() else { throw GoogleOAuthError.missingClientSecret }
  221. var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
  222. request.httpMethod = "POST"
  223. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  224. request.httpBody = Self.formURLEncoded([
  225. "client_id": clientId,
  226. "client_secret": clientSecret,
  227. "refresh_token": refreshToken,
  228. "grant_type": "refresh_token"
  229. ])
  230. let (data, response) = try await URLSession.shared.data(for: request)
  231. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  232. return nil
  233. }
  234. struct RefreshResponse: Decodable {
  235. let access_token: String
  236. let expires_in: Double
  237. let scope: String?
  238. let token_type: String?
  239. }
  240. let decoded = try JSONDecoder().decode(RefreshResponse.self, from: data)
  241. return GoogleOAuthTokens(
  242. accessToken: decoded.access_token,
  243. refreshToken: refreshToken,
  244. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  245. scope: decoded.scope ?? tokens.scope,
  246. tokenType: decoded.token_type ?? tokens.tokenType
  247. )
  248. }
  249. // MARK: - Helpers
  250. private static func pkceChallenge(for verifier: String) -> String {
  251. let data = Data(verifier.utf8)
  252. let digest = SHA256.hash(data: data)
  253. return Data(digest).base64URLEncodedString()
  254. }
  255. private static func randomURLSafeString(length: Int) -> String {
  256. var bytes = [UInt8](repeating: 0, count: length)
  257. _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
  258. return Data(bytes).base64URLEncodedString()
  259. }
  260. private static func formURLEncoded(_ params: [String: String]) -> Data {
  261. let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
  262. let pairs = params.map { key, value -> String in
  263. let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
  264. let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
  265. return "\(k)=\(v)"
  266. }
  267. .joined(separator: "&")
  268. return Data(pairs.utf8)
  269. }
  270. }
  271. private extension Data {
  272. func base64URLEncodedString() -> String {
  273. let s = base64EncodedString()
  274. return s
  275. .replacingOccurrences(of: "+", with: "-")
  276. .replacingOccurrences(of: "/", with: "_")
  277. .replacingOccurrences(of: "=", with: "")
  278. }
  279. }
  280. private final class OAuthLoopbackServer {
  281. private let queue = DispatchQueue(label: "google.oauth.loopback.server")
  282. private let listener: NWListener
  283. private var readyContinuation: CheckedContinuation<Void, Error>?
  284. private var callbackContinuation: CheckedContinuation<URL, Error>?
  285. private var callbackURL: URL?
  286. private init(listener: NWListener) {
  287. self.listener = listener
  288. }
  289. static func start() async throws -> OAuthLoopbackServer {
  290. let listener = try NWListener(using: .tcp, on: .any)
  291. let server = OAuthLoopbackServer(listener: listener)
  292. try await server.startListening()
  293. return server
  294. }
  295. var redirectURI: String {
  296. let port = listener.port?.rawValue ?? 0
  297. return "http://127.0.0.1:\(port)/oauth2redirect"
  298. }
  299. func waitForCallback(timeoutSeconds: Double = 120) async throws -> URL {
  300. try await withThrowingTaskGroup(of: URL.self) { group in
  301. group.addTask { [weak self] in
  302. guard let self else { throw GoogleOAuthError.invalidCallbackURL }
  303. return try await self.awaitCallback()
  304. }
  305. group.addTask {
  306. try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
  307. throw GoogleOAuthError.authenticationTimedOut
  308. }
  309. let url = try await group.next()!
  310. group.cancelAll()
  311. return url
  312. }
  313. }
  314. func stop() {
  315. queue.async {
  316. self.listener.cancel()
  317. if let callbackContinuation = self.callbackContinuation {
  318. self.callbackContinuation = nil
  319. callbackContinuation.resume(throwing: GoogleOAuthError.authenticationTimedOut)
  320. }
  321. }
  322. }
  323. private func startListening() async throws {
  324. try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
  325. queue.async {
  326. self.readyContinuation = continuation
  327. self.listener.stateUpdateHandler = { [weak self] state in
  328. guard let self else { return }
  329. switch state {
  330. case .ready:
  331. if let readyContinuation = self.readyContinuation {
  332. self.readyContinuation = nil
  333. readyContinuation.resume()
  334. }
  335. case .failed(let error):
  336. if let readyContinuation = self.readyContinuation {
  337. self.readyContinuation = nil
  338. readyContinuation.resume(throwing: error)
  339. }
  340. case .cancelled:
  341. if let readyContinuation = self.readyContinuation {
  342. self.readyContinuation = nil
  343. readyContinuation.resume(throwing: GoogleOAuthError.invalidCallbackURL)
  344. }
  345. default:
  346. break
  347. }
  348. }
  349. self.listener.newConnectionHandler = { [weak self] connection in
  350. self?.handle(connection: connection)
  351. }
  352. self.listener.start(queue: self.queue)
  353. }
  354. }
  355. }
  356. private func awaitCallback() async throws -> URL {
  357. if let callbackURL { return callbackURL }
  358. return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
  359. queue.async {
  360. if let callbackURL = self.callbackURL {
  361. continuation.resume(returning: callbackURL)
  362. return
  363. }
  364. self.callbackContinuation = continuation
  365. }
  366. }
  367. }
  368. private func handle(connection: NWConnection) {
  369. connection.start(queue: queue)
  370. connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, _, _ in
  371. guard let self else { return }
  372. let requestLine = data
  373. .flatMap { String(data: $0, encoding: .utf8) }?
  374. .split(separator: "\r\n", omittingEmptySubsequences: false)
  375. .first
  376. .map(String.init)
  377. var parsedURL: URL?
  378. if let requestLine {
  379. let parts = requestLine.split(separator: " ")
  380. if parts.count >= 2 {
  381. let pathAndQuery = String(parts[1])
  382. parsedURL = URL(string: "http://127.0.0.1\(pathAndQuery)")
  383. }
  384. }
  385. self.sendHTTPResponse(connection: connection, success: parsedURL != nil)
  386. if let parsedURL {
  387. self.callbackURL = parsedURL
  388. DispatchQueue.main.async {
  389. // Bring the app back to foreground once OAuth redirects.
  390. NSApp.activate(ignoringOtherApps: true)
  391. }
  392. if let continuation = self.callbackContinuation {
  393. self.callbackContinuation = nil
  394. continuation.resume(returning: parsedURL)
  395. }
  396. self.listener.cancel()
  397. }
  398. connection.cancel()
  399. }
  400. }
  401. private func sendHTTPResponse(connection: NWConnection, success: Bool) {
  402. let body = success
  403. ? "<html><body><h3>Authentication complete</h3><p>You can return to the app.</p></body></html>"
  404. : "<html><body><h3>Authentication failed</h3></body></html>"
  405. let response = """
  406. HTTP/1.1 200 OK\r
  407. Content-Type: text/html; charset=utf-8\r
  408. Content-Length: \(body.utf8.count)\r
  409. Connection: close\r
  410. \r
  411. \(body)
  412. """
  413. connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in })
  414. }
  415. }
  416. extension GoogleOAuthError: LocalizedError {
  417. var errorDescription: String? {
  418. switch self {
  419. case .missingClientId:
  420. return "Missing Google OAuth Client ID."
  421. case .missingClientSecret:
  422. return "Missing Google OAuth Client Secret."
  423. case .invalidCallbackURL:
  424. return "Invalid OAuth callback URL."
  425. case .missingAuthorizationCode:
  426. return "Google did not return an authorization code."
  427. case .tokenExchangeFailed(let details):
  428. return "Token exchange failed: \(details)"
  429. case .unableToOpenBrowser:
  430. return "Could not open browser for Google sign-in."
  431. case .authenticationTimedOut:
  432. return "Google sign-in timed out."
  433. case .noStoredTokens:
  434. return "No stored Google tokens found."
  435. }
  436. }
  437. }
  438. @MainActor
  439. private final class OAuthWebViewContainerView: NSView {
  440. private let webView: WKWebView
  441. init(webView: WKWebView) {
  442. self.webView = webView
  443. super.init(frame: .zero)
  444. autoresizingMask = [.width, .height]
  445. addSubview(webView)
  446. }
  447. @available(*, unavailable)
  448. required init?(coder: NSCoder) {
  449. nil
  450. }
  451. override func layout() {
  452. super.layout()
  453. webView.frame = bounds
  454. }
  455. }
  456. @MainActor
  457. private final class InAppOAuthWindowController: NSWindowController, WKNavigationDelegate, WKUIDelegate {
  458. private let webView: WKWebView
  459. init() {
  460. let config = WKWebViewConfiguration()
  461. if #available(macOS 11.0, *) {
  462. config.defaultWebpagePreferences.allowsContentJavaScript = true
  463. }
  464. self.webView = WKWebView(frame: .zero, configuration: config)
  465. // Frame-based layout avoids Auto Layout conflicts with WKWebView’s internal constraints.
  466. let container = OAuthWebViewContainerView(webView: webView)
  467. let window = NSWindow(
  468. contentRect: NSRect(x: 0, y: 0, width: 980, height: 760),
  469. styleMask: [.titled, .closable, .miniaturizable, .resizable],
  470. backing: .buffered,
  471. defer: false
  472. )
  473. window.title = "Google Sign-In"
  474. window.contentView = container
  475. window.center()
  476. super.init(window: window)
  477. webView.navigationDelegate = self
  478. webView.uiDelegate = self
  479. }
  480. @available(*, unavailable)
  481. required init?(coder: NSCoder) {
  482. nil
  483. }
  484. func load(url: URL) {
  485. webView.load(URLRequest(url: url))
  486. }
  487. private func shouldOpenURLExternally(_ url: URL) -> Bool {
  488. let scheme = (url.scheme ?? "").lowercased()
  489. guard !scheme.isEmpty else { return false }
  490. // Google auth popups can navigate through about:blank/javascript:
  491. // as an intermediate step. Don't hand these to NSWorkspace.
  492. return scheme != "about" && scheme != "javascript"
  493. }
  494. func webView(
  495. _ webView: WKWebView,
  496. decidePolicyFor navigationAction: WKNavigationAction,
  497. decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
  498. ) {
  499. guard let url = navigationAction.request.url else {
  500. decisionHandler(.allow)
  501. return
  502. }
  503. let scheme = (url.scheme ?? "").lowercased()
  504. if scheme == "http" || scheme == "https" {
  505. decisionHandler(.allow)
  506. return
  507. }
  508. if shouldOpenURLExternally(url) {
  509. NSWorkspace.shared.open(url)
  510. }
  511. decisionHandler(.cancel)
  512. }
  513. func webView(
  514. _ webView: WKWebView,
  515. createWebViewWith configuration: WKWebViewConfiguration,
  516. for navigationAction: WKNavigationAction,
  517. windowFeatures: WKWindowFeatures
  518. ) -> WKWebView? {
  519. if navigationAction.targetFrame == nil, let requestURL = navigationAction.request.url {
  520. let scheme = (requestURL.scheme ?? "").lowercased()
  521. if scheme == "http" || scheme == "https" {
  522. webView.load(URLRequest(url: requestURL))
  523. } else {
  524. if shouldOpenURLExternally(requestURL) {
  525. NSWorkspace.shared.open(requestURL)
  526. }
  527. }
  528. }
  529. return nil
  530. }
  531. }