I'm working on an app to display statistics based on user data from a third-party service's API. The authentication flow for the service's API that I wrote works, but some parts of it feel a bit awkward, so I want to make sure I'm holding Combine and SwiftUI correctly.
Some notes and specific questions:
readFromKeychain
andwriteFromKeychain
are convenience wrappers aroundSecItemAdd
and friends.- The API has two different OAuth flows. I believe only one is allowed for apps in production, but the other is more convenient during development, which is why
Constants.signInFlow
exists. Maybe I'll be allowed to use both and can offer users a choice. - That
cancellables
seems really gross to me. - The
url.removingPercentEncoding?...
bit is really gross. I get the callback url asappName:%23myauthenticationcode
; what's the best way to handle that? - Error handling is nonexistent. Would it be better to not have
signIn()
returnAnyPublisher<Bool,Never>
in favor ofAnyPublisher<URL, Error>
? I wanted the bool variant because I wantrefreshToken()
to be easy to use in a more generalfetchURL
function, but maybe it makes more sense to separate out handling of errors during initial authentication from handling refresh token errors.
This is ContentView.swift:
import Combine
import SwiftUI
var cancellables = Set<AnyCancellable>()
func tokenExists() -> Bool {
switch Constants.signInFlow {
case .implicit:
return readFromKeychain(account: Constants.implicitAccessToken) != nil
case .authorizationCode:
return readFromKeychain(account: Constants.authorizationCode) != nil
}
}
struct ContentView: View {
@State var isLoggedIn: Bool = tokenExists()
var body: some View {
if isLoggedIn {
Text("ItemsView() goes here")
} else {
// this is normally a separate file
VStack {
Text("Welcome! Please sign in.")
Button {
NetworkingController.shared.signIn()
.sink { Bool in
isLoggedIn = Bool
}
.store(in: &cancellables)
} label: {
Text("Sign In")
}
}
}
}
}
Constants.swift
:
enum SignInFlow {
case implicit, authorizationCode
}
enum Constants{
static let signInFlow: SignInFlow = .implicit
}
And then I've got NetworkingController.swift
:
import AuthenticationServices
import Combine
import Foundation
final class NetworkingController: NSObject, ObservableObject, ASWebAuthenticationPresentationContextProviding {
static let shared = NetworkingController()
func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor {
return ASPresentationAnchor()
}
func signIn(url: URL) -> AnyPublisher<URL, Error> {
return
Future<URL, Error> { promise in
let authSession = ASWebAuthenticationSession(url: url, callbackURLScheme: Constants.appName, completionHandler: { url, error in
if let error = error {
promise(.failure(error))
} else if let url = url {
promise(.success(url))
}
})
authSession.presentationContextProvider = self
authSession.prefersEphemeralWebBrowserSession = true
DispatchQueue.main.async {
authSession.start()
}
}
.eraseToAnyPublisher()
}
func implicitSignInFlow() -> AnyPublisher<Bool, Never> {
signIn(url: Endpoints.requestImplicitAccessToken())
.map { $0.absoluteString }
.replaceError(with: "")
.map { url in
if url.isEmpty {
return false
}
let token = url.removingPercentEncoding?.components(separatedBy: CharacterSet(charactersIn: "&="))[1]
saveToKeychain(info: token!, infoName: Constants.implicitAccessToken)
return true
}
.eraseToAnyPublisher()
}
func signIn() -> AnyPublisher<Bool, Never> {
if Constants.signInFlow == .implicit {
return implicitSignInFlow()
}
return Just(false).eraseToAnyPublisher()
}
func refreshToken() -> AnyPublisher<Bool, Never> {
if Constants.signInFlow == .implicit {
return implicitSignInFlow()
}
return Just(false).eraseToAnyPublisher()
}
}
Thank you in advance for your time in reading this; I look forward to your feedback!