Some simplification to login

Creating async await alternatives to Combine publishers in cases where that makes sense.

Near term goal is to centralize responsibility for caching of user info.

contributes to iOS-319
This commit is contained in:
shannon 2024-11-18 15:27:47 -05:00
parent 8ee189e3cf
commit 1e472c8232
11 changed files with 171 additions and 63 deletions

View File

@ -169,12 +169,14 @@ class MastodonLoginViewController: UIViewController, NeedsDependency {
self.mastodonAuthenticationController = authenticationController
authenticationController.authenticationSession?.presentationContextProvider = self
authenticationController.authenticationSession?.start()
self.authenticationViewModel.authenticate(
info: info,
pinCodePublisher: authenticationController.pinCodePublisher
pinCodePublisher: authenticationController.resultStream
)
authenticationController.authenticationSession?.start()
}
.store(in: &disposeBag)
}

View File

@ -24,12 +24,12 @@ extension MastodonRegisterViewController {
let instanceResponse = try await APIService.shared.instance(domain: domain, authenticationBox: nil).singleOutput()
let applicationResponse = try await APIService.shared.createApplication(domain: domain).singleOutput()
let accessTokenResponse = try await APIService.shared.applicationAccessToken(
let accessToken = try await APIService.shared.applicationAccessToken(
domain: domain,
clientID: applicationResponse.value.clientID!,
clientSecret: applicationResponse.value.clientSecret!,
redirectURI: applicationResponse.value.redirectURI!
).singleOutput()
)
viewController.viewModel = MastodonRegisterViewModel(
context: context,
@ -39,7 +39,7 @@ extension MastodonRegisterViewController {
application: applicationResponse.value
)!,
instance: instanceResponse.value,
applicationToken: accessTokenResponse.value
applicationToken: accessToken
)
return viewController

View File

@ -11,6 +11,7 @@ import CoreDataStack
import Combine
import MastodonSDK
import MastodonCore
import AuthenticationServices
@MainActor
final class AuthenticationViewModel {
@ -136,50 +137,38 @@ extension AuthenticationViewModel {
}
}
func authenticate(info: AuthenticateInfo, pinCodePublisher: PassthroughSubject<String, Never>) {
pinCodePublisher
.handleEvents(receiveOutput: { [weak self] _ in
guard let self = self else { return }
self.isAuthenticating.value = true
})
.compactMap { [weak self] code -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error>? in
guard let self = self else { return nil }
return APIService.shared
.userAccessToken(
domain: info.domain,
clientID: info.clientID,
clientSecret: info.clientSecret,
redirectURI: info.redirectURI,
code: code
)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
let token = response.value
return AuthenticationViewModel.verifyAndSaveAuthentication(
context: self.context,
info: info,
userToken: token
func authenticate(info: AuthenticateInfo, pinCodePublisher: AsyncThrowingStream<String, Error>) {
Task {
do {
for try await code in pinCodePublisher {
self.isAuthenticating.value = true
let token = try await APIService.shared
.userAccessToken(
domain: info.domain,
clientID: info.clientID,
clientSecret: info.clientSecret,
redirectURI: info.redirectURI,
code: code
)
}
.eraseToAnyPublisher()
}
.switchToLatest()
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
guard let self = self else { return }
switch completion {
case .failure(let error):
self.isAuthenticating.value = false
self.error.value = error
case .finished:
break
let account = try await AuthenticationViewModel.verifyAndSaveAuthentication(
context: self.context,
info: info,
userToken: token
)
self.authenticated.send((domain: info.domain, account: account))
}
} catch let error {
self.isAuthenticating.value = false
if let error = error as? ASWebAuthenticationSessionError {
if error.errorCode == ASWebAuthenticationSessionError.canceledLogin.rawValue {
//cancelled
return
}
} else {
// error
}
} receiveValue: { [weak self] response in
guard let self = self else { return }
let account = response.value
self.authenticated.send((domain: info.domain, account: account))
}
.store(in: &self.disposeBag)
}
}
static func verifyAndSaveAuthentication(
@ -214,4 +203,33 @@ extension AuthenticationViewModel {
}
.eraseToAnyPublisher()
}
static func verifyAndSaveAuthentication(
context: AppContext,
info: AuthenticateInfo,
userToken: Mastodon.Entity.Token
) async throws -> Mastodon.Entity.Account {
let authorization = Mastodon.API.OAuth.Authorization(accessToken: userToken.accessToken)
let account = try await APIService.shared.accountVerifyCredentials(
domain: info.domain,
authorization: authorization
)
let authentication = MastodonAuthentication
.createFrom(domain: info.domain,
userID: account.id,
username: account.username,
appAccessToken: userToken.accessToken, // TODO: swap app token
userAccessToken: userToken.accessToken,
clientID: info.clientID,
clientSecret: info.clientSecret,
accountCreatedAt: account.createdAt)
AuthenticationServiceProvider.shared
.authentications
.insert(authentication, at: 0) // TODO: this should not be happening. authentications should be readonly.
return account
}
}

View File

@ -10,6 +10,7 @@ import Combine
import AuthenticationServices
import MastodonCore
@MainActor
final class MastodonAuthenticationController {
var disposeBag = Set<AnyCancellable>()
@ -20,9 +21,8 @@ final class MastodonAuthenticationController {
var authenticationSession: ASWebAuthenticationSession?
// output
let isAuthenticating = CurrentValueSubject<Bool, Never>(false)
let error = CurrentValueSubject<Error?, Never>(nil)
let pinCodePublisher = PassthroughSubject<String, Never>()
public let resultStream: AsyncThrowingStream<String, Error>
private let resultStreamContinuation: AsyncThrowingStream<String, Error>.Continuation
init(
context: AppContext,
@ -31,9 +31,10 @@ final class MastodonAuthenticationController {
self.context = context
self.authenticateURL = authenticateURL
(resultStream, resultStreamContinuation) = AsyncThrowingStream<String, Error>.makeStream()
authentication()
}
}
extension MastodonAuthenticationController {
@ -45,15 +46,7 @@ extension MastodonAuthenticationController {
guard let self = self else { return }
if let error = error {
if let error = error as? ASWebAuthenticationSessionError {
if error.errorCode == ASWebAuthenticationSessionError.canceledLogin.rawValue {
self.isAuthenticating.value = false
return
}
}
self.isAuthenticating.value = false
self.error.value = error
self.resultStreamContinuation.finish(throwing: error)
return
}
@ -61,10 +54,12 @@ extension MastodonAuthenticationController {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let codeQueryItem = components.queryItems?.first(where: { $0.name == "code" }),
let code = codeQueryItem.value else {
self.resultStreamContinuation.finish()
return
}
self.pinCodePublisher.send(code)
self.resultStreamContinuation.yield(code)
self.resultStreamContinuation.finish()
}
authenticationSession?.prefersEphemeralWebBrowserSession = true
}

View File

@ -55,7 +55,11 @@ public class PersistenceManager {
}
public func cacheAccount(_ account: Mastodon.Entity.Account, for authenticationBox: MastodonAuthenticationBox) {
FileManager.default.store(account: account, forUserID: authenticationBox.authentication.userIdentifier())
cacheAccount(account, forUserID: authenticationBox.authentication.userIdentifier())
}
public func cacheAccount(_ account: Mastodon.Entity.Account, forUserID userID: MastodonUserIdentifier) {
FileManager.default.store(account: account, forUserID: userID)
}
}

View File

@ -45,11 +45,13 @@ extension APIService {
domain: String,
authorization: Mastodon.API.OAuth.Authorization
) async throws -> Mastodon.Entity.Account {
return try await Mastodon.API.Account.verifyCredentials(
let account = try await Mastodon.API.Account.verifyCredentials(
session: session,
domain: domain,
authorization: authorization
)
PersistenceManager.shared.cacheAccount(account, forUserID: MastodonUserIdentifier(domain: domain, userID: account.id))
return account
}
public func accountUpdateCredentials(

View File

@ -54,4 +54,45 @@ extension APIService {
)
}
public func userAccessToken(
domain: String,
clientID: String,
clientSecret: String,
redirectURI: String,
code: String
) async throws -> Mastodon.Entity.Token {
let query = Mastodon.API.OAuth.AccessTokenQuery(
clientID: clientID,
clientSecret: clientSecret,
redirectURI: redirectURI,
code: code,
grantType: "authorization_code"
)
return try await Mastodon.API.OAuth.accessToken(
session: session,
domain: domain,
query: query
)
}
public func applicationAccessToken(
domain: String,
clientID: String,
clientSecret: String,
redirectURI: String
) async throws -> Mastodon.Entity.Token {
let query = Mastodon.API.OAuth.AccessTokenQuery(
clientID: clientID,
clientSecret: clientSecret,
redirectURI: redirectURI,
code: nil,
grantType: "client_credentials"
)
return try await Mastodon.API.OAuth.accessToken(
session: session,
domain: domain,
query: query
)
}
}

View File

@ -20,6 +20,13 @@ extension APIService {
return Mastodon.API.Instance.instance(session: session, authorization: authenticationBox?.userAuthorization, domain: domain)
}
public func instance(
domain: String,
authenticationBox: MastodonAuthenticationBox?
) async throws -> Mastodon.Entity.Instance {
return try await Mastodon.API.Instance.instance(session: session, authorization: authenticationBox?.userAuthorization, domain: domain)
}
public func instanceV2(
domain: String,
authenticationBox: MastodonAuthenticationBox?

View File

@ -20,7 +20,7 @@ public final class APIService {
public static let shared = { APIService(backgroundContext: PersistenceManager.shared.backgroundManagedObjectContext) }()
public static let callbackURLScheme = "mastodon"
public static let oauthCallbackURL = "mastodon://joinmastodon.org/oauth"
nonisolated public static let oauthCallbackURL = "mastodon://joinmastodon.org/oauth"
var disposeBag = Set<AnyCancellable>()

View File

@ -52,6 +52,29 @@ extension Mastodon.API.Instance {
}
.eraseToAnyPublisher()
}
public static func instance(
session: URLSession,
authorization: Mastodon.API.OAuth.Authorization?,
domain: String
) async throws -> Mastodon.Entity.Instance {
let request = Mastodon.API.get(url: instanceEndpointURL(domain: domain), authorization: authorization)
let (data, response) = try await session.data(for: request)
let value: Mastodon.Entity.Instance
do {
value = try Mastodon.API.decode(type: Mastodon.Entity.Instance.self, from: data, response: response)
} catch {
if let response = response as? HTTPURLResponse, 400 ..< 500 ~= response.statusCode {
// For example, AUTHORIZED_FETCH may result in authentication errors
value = Mastodon.Entity.Instance(domain: domain)
} else {
throw error
}
}
return value
}
static func extendedDescriptionEndpointURL(domain: String) -> URL {
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("instance").appendingPathComponent("extended_description")

View File

@ -95,6 +95,22 @@ extension Mastodon.API.OAuth {
}
.eraseToAnyPublisher()
}
public static func accessToken(
session: URLSession,
domain: String,
query: AccessTokenQuery
) async throws -> Mastodon.Entity.Token {
let request = Mastodon.API.post(
url: accessTokenEndpointURL(domain: domain),
query: query,
authorization: nil
)
let (data, response) = try await session.data(for: request)
let value = try Mastodon.API.decode(type: Mastodon.Entity.Token.self, from: data, response: response)
return value
}
/// Revoke User Access Token
///