Update oauth scopes when enabling push notifications

Closes #467
This commit is contained in:
Shadowfacts 2024-04-09 19:05:31 -04:00
parent 9353bbb56c
commit ff11835333
9 changed files with 172 additions and 59 deletions

View File

@ -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
}
}

View File

@ -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
@ -146,6 +148,16 @@ public class UserAccountsManager: ObservableObject {
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
}
}
public extension Notification.Name {

View File

@ -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 = "<group>"; };
D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringHelperTests.swift; sourceTree = "<group>"; };
D630C3C92BC59FF500208903 /* MastodonController+Push.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonController+Push.swift"; sourceTree = "<group>"; };
D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetAuthorizationTokenService.swift; sourceTree = "<group>"; };
D6311C4F25B3765B00B27539 /* ImageDataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDataCache.swift; sourceTree = "<group>"; };
D6333B362137838300CE884A /* AttributedString+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Helpers.swift"; sourceTree = "<group>"; };
D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = "<group>"; };
@ -1668,6 +1670,7 @@
D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */,
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */,
D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */,
D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */,
);
path = API;
sourceTree = "<group>";
@ -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 */,

View File

@ -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
}
}

View File

@ -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

View File

@ -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))
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
}
} 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)
} catch {
throw Error.authenticationSessionError(error)
}
})
// 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()
})
}
}

View File

@ -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)

View File

@ -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)
@ -106,6 +109,27 @@ class PreferencesNavigationController: UINavigationController {
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)
}
}
}
}
extension PreferencesNavigationController: OnboardingViewControllerDelegate {
@ -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")
}

View File

@ -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 {