1
\$\begingroup\$

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 and writeFromKeychain are convenience wrappers around SecItemAdd 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 as appName:%23myauthenticationcode; what's the best way to handle that?
  • Error handling is nonexistent. Would it be better to not have signIn() return AnyPublisher<Bool,Never> in favor of AnyPublisher<URL, Error>? I wanted the bool variant because I want refreshToken() to be easy to use in a more general fetchURL 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!

\$\endgroup\$

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.