diff --git a/Packages/UserAccounts/Sources/UserAccounts/UserAccountInfo.swift b/Packages/UserAccounts/Sources/UserAccounts/UserAccountInfo.swift index 6d0f5c8b..d35c43df 100644 --- a/Packages/UserAccounts/Sources/UserAccounts/UserAccountInfo.swift +++ b/Packages/UserAccounts/Sources/UserAccounts/UserAccountInfo.swift @@ -13,8 +13,9 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable { public let instanceURL: URL public let clientID: String public let clientSecret: String - public private(set) var username: String! - public let accessToken: String + public let username: String! + public internal(set) var accessToken: String + public internal(set) var scopes: [String]? // Sort of hack to be able to access these from the share extension. public internal(set) var serverDefaultLanguage: String? @@ -40,16 +41,19 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable { self.instanceURL = instanceURL self.clientID = clientID self.clientSecret = clientSecret + self.username = nil self.accessToken = accessToken + self.scopes = nil } - init(instanceURL: URL, clientID: String, clientSecret: String, username: String? = nil, accessToken: String) { + init(instanceURL: URL, clientID: String, clientSecret: String, username: String? = nil, accessToken: String, scopes: [String]) { self.id = UserAccountInfo.id(instanceURL: instanceURL, username: username) self.instanceURL = instanceURL self.clientID = clientID self.clientSecret = clientSecret self.username = username self.accessToken = accessToken + self.scopes = scopes } init?(userDefaultsDict dict: [String: Any]) { @@ -67,6 +71,7 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable { self.clientSecret = secret self.username = dict["username"] as? String self.accessToken = accessToken + self.scopes = dict["scopes"] as? [String] self.serverDefaultLanguage = dict["serverDefaultLanguage"] as? String self.serverDefaultVisibility = dict["serverDefaultVisibility"] as? String self.serverDefaultFederation = dict["serverDefaultFederation"] as? Bool @@ -83,6 +88,9 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable { if let username { dict["username"] = username } + if let scopes { + dict["scopes"] = scopes + } if let serverDefaultLanguage { dict["serverDefaultLanguage"] = serverDefaultLanguage } @@ -100,12 +108,4 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable { // slashes are not allowed in the persistent store coordinator name id.replacingOccurrences(of: "/", with: "_") } - - public func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - - public static func ==(lhs: UserAccountInfo, rhs: UserAccountInfo) -> Bool { - return lhs.id == rhs.id - } } diff --git a/Packages/UserAccounts/Sources/UserAccounts/UserAccountsManager.swift b/Packages/UserAccounts/Sources/UserAccounts/UserAccountsManager.swift index d5752409..63309dd6 100644 --- a/Packages/UserAccounts/Sources/UserAccounts/UserAccountsManager.swift +++ b/Packages/UserAccounts/Sources/UserAccounts/UserAccountsManager.swift @@ -25,7 +25,9 @@ public class UserAccountsManager: ObservableObject { clientID: "client_id", clientSecret: "client_secret", username: "admin", - accessToken: "access_token") + accessToken: "access_token", + scopes: [] + ) ] } } else { @@ -38,7 +40,7 @@ public class UserAccountsManager: ObservableObject { private let accountsKey = "accounts" public private(set) var accounts: [UserAccountInfo] { get { - if let array = defaults.array(forKey: accountsKey) as? [[String: String]] { + if let array = defaults.array(forKey: accountsKey) as? [[String: Any]] { return array.compactMap(UserAccountInfo.init(userDefaultsDict:)) } else { return [] @@ -101,12 +103,12 @@ public class UserAccountsManager: ObservableObject { return !accounts.isEmpty } - public func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String?, accessToken: String) -> UserAccountInfo { + public func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String?, accessToken: String, scopes: [String]) -> UserAccountInfo { var accounts = self.accounts if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) { accounts.remove(at: index) } - let info = UserAccountInfo(instanceURL: url, clientID: clientID, clientSecret: secret, username: username, accessToken: accessToken) + let info = UserAccountInfo(instanceURL: url, clientID: clientID, clientSecret: secret, username: username, accessToken: accessToken, scopes: scopes) accounts.append(info) self.accounts = accounts return info @@ -145,6 +147,16 @@ public class UserAccountsManager: ObservableObject { account.serverDefaultFederation = defaultFederation accounts[index] = account } + + public func updateAccessToken(_ account: UserAccountInfo, token: String, scopes: [String]) { + guard let index = accounts.firstIndex(where: { $0.id == account.id }) else { + return + } + var account = account + account.accessToken = token + account.scopes = scopes + accounts[index] = account + } } diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 36a4e5be..d260e3ec 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -94,6 +94,7 @@ D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */; }; D630C3C82BC43AFD00208903 /* PushNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3C72BC43AFD00208903 /* PushNotifications */; }; D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */ = {isa = PBXBuildFile; fileRef = D630C3C92BC59FF500208903 /* MastodonController+Push.swift */; }; + D630C3CC2BC5FD4600208903 /* GetAuthorizationTokenService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */; }; D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6311C4F25B3765B00B27539 /* ImageDataCache.swift */; }; D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Helpers.swift */; }; D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; }; @@ -499,6 +500,7 @@ D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMetaIndicatorsView.swift; sourceTree = ""; }; D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringHelperTests.swift; sourceTree = ""; }; D630C3C92BC59FF500208903 /* MastodonController+Push.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonController+Push.swift"; sourceTree = ""; }; + D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetAuthorizationTokenService.swift; sourceTree = ""; }; D6311C4F25B3765B00B27539 /* ImageDataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDataCache.swift; sourceTree = ""; }; D6333B362137838300CE884A /* AttributedString+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Helpers.swift"; sourceTree = ""; }; D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = ""; }; @@ -1668,6 +1670,7 @@ D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */, D6DD8FFC298495A8002AD3FD /* LogoutService.swift */, D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */, + D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */, ); path = API; sourceTree = ""; @@ -2064,6 +2067,7 @@ D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */, D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameView.swift in Sources */, D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */, + D630C3CC2BC5FD4600208903 /* GetAuthorizationTokenService.swift in Sources */, D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */, D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */, D6934F3A2BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift in Sources */, diff --git a/Tusker/API/GetAuthorizationTokenService.swift b/Tusker/API/GetAuthorizationTokenService.swift new file mode 100644 index 00000000..1fad23b9 --- /dev/null +++ b/Tusker/API/GetAuthorizationTokenService.swift @@ -0,0 +1,63 @@ +// +// GetAuthorizationTokenService.swift +// Tusker +// +// Created by Shadowfacts on 4/9/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import Foundation +import AuthenticationServices + +class GetAuthorizationTokenService { + let instanceURL: URL + let clientID: String + let presentationContextProvider: ASWebAuthenticationPresentationContextProviding + + init(instanceURL: URL, clientID: String, presentationContextProvider: ASWebAuthenticationPresentationContextProviding) { + self.instanceURL = instanceURL + self.clientID = clientID + self.presentationContextProvider = presentationContextProvider + } + + @MainActor + func run() async throws -> String { + var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)! + components.path = "/oauth/authorize" + components.queryItems = [ + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "scope", value: MastodonController.oauthScopes.map(\.rawValue).joined(separator: " ")), + URLQueryItem(name: "redirect_uri", value: "tusker://oauth") + ] + let authorizeURL = components.url! + + return try await withCheckedThrowingContinuation({ continuation in + let authenticationSession = ASWebAuthenticationSession(url: authorizeURL, callbackURLScheme: "tusker", completionHandler: { url, error in + if let error = error { + if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin { + continuation.resume(throwing: Error.cancelled) + } else { + continuation.resume(throwing: error) + } + } else if let url = url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: true), + let item = components.queryItems?.first(where: { $0.name == "code" }), + let code = item.value { + continuation.resume(returning: code) + } else { + continuation.resume(throwing: Error.noAuthorizationCode) + } + }) + // Prefer ephemeral sessions to make it easier to sign into multiple accounts on the same instance. + authenticationSession.prefersEphemeralWebBrowserSession = true + authenticationSession.presentationContextProvider = presentationContextProvider + authenticationSession.start() + }) + } + + enum Error: Swift.Error { + case cancelled + case noAuthorizationCode + } +} diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index d59bb9aa..5911a421 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -24,22 +24,22 @@ final class MastodonController: ObservableObject, Sendable { static let oauthScopes = [Scope.read, .write, .follow, .push] @MainActor - static private(set) var all = [UserAccountInfo: MastodonController]() + static private(set) var all = [String: MastodonController]() @MainActor static func getForAccount(_ account: UserAccountInfo) -> MastodonController { - if let controller = all[account] { + if let controller = all[account.id] { return controller } else { let controller = MastodonController(instanceURL: account.instanceURL, accountInfo: account) - all[account] = controller + all[account.id] = controller return controller } } @MainActor static func removeForAccount(_ account: UserAccountInfo) { - all.removeValue(forKey: account) + all.removeValue(forKey: account.id) } @MainActor diff --git a/Tusker/Screens/Onboarding/OnboardingViewController.swift b/Tusker/Screens/Onboarding/OnboardingViewController.swift index 0f287273..579e6cf5 100644 --- a/Tusker/Screens/Onboarding/OnboardingViewController.swift +++ b/Tusker/Screens/Onboarding/OnboardingViewController.swift @@ -158,7 +158,14 @@ class OnboardingViewController: UINavigationController { throw Error.gettingOwnAccount(error) } - let accountInfo = UserAccountsManager.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: ownAccount.username, accessToken: accessToken) + let accountInfo = UserAccountsManager.shared.addAccount( + instanceURL: instanceURL, + clientID: clientID, + clientSecret: clientSecret, + username: ownAccount.username, + accessToken: accessToken, + scopes: MastodonController.oauthScopes.map(\.rawValue) + ) self.onboardingDelegate?.didFinishOnboarding(account: accountInfo) } @@ -177,38 +184,19 @@ class OnboardingViewController: UINavigationController { @MainActor private func getAuthorizationCode(instanceURL: URL, clientID: String) async throws -> String { - var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)! - components.path = "/oauth/authorize" - components.queryItems = [ - URLQueryItem(name: "client_id", value: clientID), - URLQueryItem(name: "response_type", value: "code"), - URLQueryItem(name: "scope", value: MastodonController.oauthScopes.map(\.rawValue).joined(separator: " ")), - URLQueryItem(name: "redirect_uri", value: "tusker://oauth") - ] - let authorizeURL = components.url! - - return try await withCheckedThrowingContinuation({ continuation in - self.authenticationSession = ASWebAuthenticationSession(url: authorizeURL, callbackURLScheme: "tusker", completionHandler: { url, error in - if let error = error { - if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin { - continuation.resume(throwing: Error.cancelled) - } else { - continuation.resume(throwing: Error.authenticationSessionError(error)) - } - } else if let url = url, - let components = URLComponents(url: url, resolvingAgainstBaseURL: true), - let item = components.queryItems?.first(where: { $0.name == "code" }), - let code = item.value { - continuation.resume(returning: code) - } else { - continuation.resume(throwing: Error.noAuthorizationCode) - } - }) - // Prefer ephemeral sessions to make it easier to sign into multiple accounts on the same instance. - self.authenticationSession!.prefersEphemeralWebBrowserSession = true - self.authenticationSession!.presentationContextProvider = self - self.authenticationSession!.start() - }) + do { + let service = GetAuthorizationTokenService(instanceURL: instanceURL, clientID: clientID, presentationContextProvider: self) + return try await service.run() + } catch let error as GetAuthorizationTokenService.Error { + switch error { + case .cancelled: + throw Error.cancelled + case .noAuthorizationCode: + throw Error.noAuthorizationCode + } + } catch { + throw Error.authenticationSessionError(error) + } } } diff --git a/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift b/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift index b48126b8..ce6968b6 100644 --- a/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift +++ b/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift @@ -18,6 +18,7 @@ struct PushInstanceSettingsView: View { @State private var mode: AsyncToggle.Mode @State private var error: Error? @State private var subscription: PushNotifications.PushSubscription? + @State private var showReLoginRequiredAlert = false @MainActor init(account: UserAccountInfo, pushProxyRegistration: PushProxyRegistration) { @@ -43,12 +44,20 @@ struct PushInstanceSettingsView: View { } message: { data in Text(data.localizedDescription) } + .alert("Re-Login Required", isPresented: $showReLoginRequiredAlert) { + Button("Cancel", role: .cancel) {} + Button("Login") { + NotificationCenter.default.post(name: .reLogInRequired, object: account) + } + } message: { + Text("You must grant permission on \(account.instanceURL.host!) to turn on push notifications.") + } } private func updateNotificationsEnabled(enabled: Bool) async -> Bool { if enabled { do { - try await enableNotifications() + return try await enableNotifications() } catch { PushManager.logger.error("Error creating instance subscription: \(String(describing: error))") self.error = .enabling(error) @@ -66,13 +75,19 @@ struct PushInstanceSettingsView: View { return true } - private func enableNotifications() async throws { + private func enableNotifications() async throws -> Bool { + guard account.scopes?.contains(Scope.push.rawValue) == true else { + showReLoginRequiredAlert = true + return false + } + let subscription = try await PushManager.shared.createSubscription(account: account) let mastodonController = await MastodonController.getForAccount(account) do { let result = try await mastodonController.createPushSubscription(subscription: subscription) PushManager.logger.debug("Push subscription \(result.id) created on \(account.instanceURL)") self.subscription = subscription + return true } catch { // if creation failed, remove the subscription locally as well await PushManager.shared.removeSubscription(account: account) diff --git a/Tusker/Screens/Preferences/PreferencesNavigationController.swift b/Tusker/Screens/Preferences/PreferencesNavigationController.swift index 165a2841..ac1c3634 100644 --- a/Tusker/Screens/Preferences/PreferencesNavigationController.swift +++ b/Tusker/Screens/Preferences/PreferencesNavigationController.swift @@ -10,6 +10,8 @@ import UIKit import SwiftUI import UserAccounts import SafariServices +import AuthenticationServices +import Pachyderm class PreferencesNavigationController: UINavigationController { @@ -37,6 +39,7 @@ class PreferencesNavigationController: UINavigationController { NotificationCenter.default.addObserver(self, selector: #selector(activateAccount(_:)), name: .activateAccount, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(userLoggedOut), name: .userLoggedOut, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(showMastodonSettings), name: .showMastodonSettings, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(reLogInToAccount), name: .reLogInRequired, object: nil) } override func viewWillDisappear(_ animated: Bool) { @@ -63,7 +66,7 @@ class PreferencesNavigationController: UINavigationController { dismiss(animated: true) // dismisses instance selector } - @objc func activateAccount(_ notification: Notification) { + @objc func activateAccount(_ notification: Foundation.Notification) { // TODO: this is a temporary measure // when switching accounts shortly after adding a new one, there is an old instance of PreferncesNavigationController still around // which tries to handle the notification but is unable to because it no longer is in a window (and therefore doesn't have a scene delegate) @@ -105,6 +108,27 @@ class PreferencesNavigationController: UINavigationController { let vc = SFSafariViewController(url: components.url!) present(vc, animated: true) } + + @objc private func reLogInToAccount(_ notification: Foundation.Notification) { + guard let account = notification.object as? UserAccountInfo else { + return + } + Task { + do { + let service = GetAuthorizationTokenService(instanceURL: account.instanceURL, clientID: account.clientID, presentationContextProvider: self) + let code = try await service.run() + let mastodonController = MastodonController.getForAccount(account) + let token = try await mastodonController.authorize(authorizationCode: code) + UserAccountsManager.shared.updateAccessToken(account, token: token, scopes: MastodonController.oauthScopes.map(\.rawValue)) + // try to revoke the old token + try? await Client(baseURL: account.instanceURL, accessToken: account.accessToken, clientID: account.clientID, clientSecret: account.clientSecret).revokeAccessToken() + } catch { + let alert = UIAlertController(title: "Error Updating Permissions", message: error.localizedDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + self.present(alert, animated: true) + } + } + } } @@ -124,3 +148,14 @@ extension PreferencesNavigationController: OnboardingViewControllerDelegate { } } } + +extension PreferencesNavigationController: ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + view.window! + } +} + +extension Foundation.Notification.Name { + static let showMastodonSettings = Notification.Name("Tusker.showMastodonSettings") + static let reLogInRequired = Notification.Name("Tusker.reLogInRequired") +} diff --git a/Tusker/Screens/Preferences/PreferencesView.swift b/Tusker/Screens/Preferences/PreferencesView.swift index 4ec176e9..b32c87a3 100644 --- a/Tusker/Screens/Preferences/PreferencesView.swift +++ b/Tusker/Screens/Preferences/PreferencesView.swift @@ -143,10 +143,6 @@ struct PreferencesView: View { } } -extension Notification.Name { - static let showMastodonSettings = Notification.Name("Tusker.showMastodonSettings") -} - //#if DEBUG //struct PreferencesView_Previews : PreviewProvider { // static var previews: some View {