Knowing who you are is a difficult thing at the best of times. Allowing an app to authenticate against a web service is likewise as difficult due to the level of trust that needs to be created between you and the system handling the authentication. Thankfully Apple has a solution to the authentication problem known as ASWebAuthenticationSession that exists as part of the AuthenticationServices framework.

So what makes authentication a difficult? Can’t we just send username and password in the clear? The answer to that is simple. No, we can’t. We need to make sure that who we are authenticating against is who we expect. We need to make sure that we create the trust between our app and the web service. Let’s look at how this is done.

The first component we want is ASWebAuthenticationSession, though in order to make use of that we need to build a few things first. By doing this, we are able to expose parts of AppKit or UIKit to SwiftUI so that ASWebAuthenticationSession does it thing. So what does it require? It requires a ASPresentationAnchor. This typically is a window being either an NSWindow or an UIWindow. In the land of SwiftUI, this is done through an environment variable. Let’s build this out.

struct WindowKey: EnvironmentKey {
    static let defaultValue: NSWindow? = nil
}

extension EnvironmentValues {
    var window: NSWindow? {
        get {
            self[WindowKey.self]
        }
        set {
            self[WindowKey.self] = newValue
        }
    }
}

So what exactly does that do? It provides our SwiftUI views with the knowledge of the window and we’ll use that later on. We’ll move on and start building out the app parts. First up is the view that will allow us to present everything. As you guessed, it’s a button that we tap.

struct AuthView: View {
  @Environment(\.window) var window: NSWindow?
  let store: AuthStore

  var body: some View {
    VStack(alignment: .center, spacing: 16.0) {
      Button(action: {
        self.store.auth()
      }) {
        Text("Login with GitHub")
          .font(.headline)
      }
    }
  }
}

There are a few bits we need to fill out here, the first of which is our AuthStore. Lets look at this and how it’s pieced together.

final class AuthStore {
  private var helper: AuthHelper?
  private var authCancellable: AnyCancellable? {
    willSet {
      authCancellable?.cancel()
    }
  }

  init(window: NSWindow?) {
    helper = AuthHelper(window: window)
  }

  func auth() {
    authCancellable = helper?.authenticate()
      .sink(receiveCompletion: { completion in

      }, receiveValue: { apiKey in
        if AuthKeychainHelper.tokenExists {
          try? AuthKeychainHelper.update(token: apiKey)
        } else {
          try? AuthKeychainHelper.store(token: apiKey)
        }
      })
  }
}

This involves one piece that we wont touch on being the Keychain helper. Suffice to say, it’s for storing the resulting api token. The piece of interest though is the AuthHelper which handles our interaction with ASWebAuthenticationSession. The full implementation will be placed at the end, but for now we’ll break apart the interesting pieces.

To begin, we have the type that conforms to ASWebAuthenticationPresentationContextProviding and tells us how the web auth window should be presented. When dealing with ASWebAuthenticationSession we want to be providing it with an instance of this type.

class AuthServiceContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding {
  let window: NSWindow

  init(window: NSWindow) {
    self.window = window
  }

  func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
    return window
  }
}

The second part is our call to ASWebAuthenticationSession. We are wrapping this in a Future so that it’s exposed to SwiftUI using Combine.

  func authenticate() -> AnyPublisher<String, Errors> {
    let scopes = [
      "user",
      "public_repo",
      "repo",
      "repo:status",
      "read:org"
    ]

    var urlComponents = URLComponents(string: "")
    urlComponents?.queryItems = [
      URLQueryItem(name: "client_id", value: "abc123"),
      URLQueryItem(name: "scope", value: scopes.joined(separator: " "))
    ]

    guard let url = urlComponents?.url else {
      return Fail(error: Errors.invalidURL).eraseToAnyPublisher()
    }

    return Future<String, Errors> { promise in
      let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "appscheme") { (url, error) in
        if let error = error as? ASWebAuthenticationSessionError {
          switch error.code {
          case .canceledLogin:
            promise(.failure(.userCancelled))
          default:
            promise(.failure(.unknown))
          }
        }

        if let url = url, let oauthToken = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.filter({ $0.name == "code" }).first?.value {
          promise(.success(oauthToken))
        } else {
          promise(.failure(.noToken))
        }
      }

      session.presentationContextProvider = self.contextProvider
      session.start()
    }
    .flatMap(fetchToken(with:))
    .receive(on: RunLoop.main)
    .eraseToAnyPublisher()
  }

Here we have asked ASWebAuthenticationSession to authenticate against the given URL (in my projects case, GitHub). The second interesting part is what we do with the token we get back. Unfortunately what GitHub returns us isn’t the final api token we use for requests. We need to make another call to get that. This happens in the fetchToken(with:) function. Let’s unpack that now.

There’s a curiosity here in the code. What exactly is the callbackURLScheme? This is how the service knows to call back into your app once you have been authenticated. You’ll need to set this up in your info.plist. The scheme you define there is the one you provide here.

  private func fetchToken(with token: String) -> AnyPublisher<String, Errors> {
    var components = URLComponents(string: "")
    let queryItems: [URLQueryItem] = [
      URLQueryItem(name: "client_id", value: "123abc"),
      URLQueryItem(name: "client_secret", value: "abc123"),
      URLQueryItem(name: "code", value: token)
    ]

    components?.queryItems = queryItems

    guard let url = components?.url else {
      return Fail(error: .invalidURL).eraseToAnyPublisher()
    }

    var request = URLRequest(url: url)
    request.addValue("application/json", forHTTPHeaderField: "Accept")
    request.httpMethod = "POST"

    return URLSession.shared
      .dataTaskPublisher(for: request)
      .map { $0.data }
      .decode(type: Response.self, decoder: decoder)
      .mapError { error in
        if let error = error as? Errors {
          return error
        }

        return Errors.jsonDecoding
      }
      .map { $0.accessToken }
      .eraseToAnyPublisher()
  }

The result of this is our api token after having been authenticated with the web service.

As mentioned, the entire implementation of the auth helper is included.

class AuthHelper {
  struct Response: Decodable {
    let accessToken: String
    let scope: String
    let tokenType: String

    enum CodingKeys: String, CodingKey {
      case accessToken = "access_token"
      case scope
      case tokenType = "token_type"
    }
  }

  enum Errors: Error {
    case invalidURL
    case userCancelled
    case noToken
    case unknown
    case jsonDecoding
  }

  private var contextProvider: AuthServiceContextProvider
  private var decoder: JSONDecoder

  init?(window: NSWindow?, environment: AppEnvironment) {
    guard let window = window else { return nil }

    contextProvider = AuthServiceContextProvider(
      window: window
    )
    self.decoder = JSONDecoder()
  }

  func authenticate() -> AnyPublisher<String, Errors> {
    let scopes = [
      "user",
      "public_repo",
      "repo",
      "repo:status",
      "read:org"
    ]

    var urlComponents = URLComponents(string: "")
    urlComponents?.queryItems = [
      URLQueryItem(name: "client_id", value: ""),
      URLQueryItem(name: "scope", value: scopes.joined(separator: " "))
    ]

    guard let url = urlComponents?.url else {
      return Fail(error: Errors.invalidURL).eraseToAnyPublisher()
    }

    return Future<String, Errors> { promise in
      let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "") { (url, error) in
        if let error = error as? ASWebAuthenticationSessionError {
          switch error.code {
          case .canceledLogin:
            promise(.failure(.userCancelled))
          default:
            promise(.failure(.unknown))
          }
        }

        if let url = url, let oauthToken = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.filter({ $0.name == "code" }).first?.value {
          promise(.success(oauthToken))
        } else {
          promise(.failure(.noToken))
        }
      }

      session.presentationContextProvider = self.contextProvider
      session.start()
    }
    .flatMap(fetchToken(with:))
    .receive(on: RunLoop.main)
    .eraseToAnyPublisher()
  }

  private func fetchToken(with token: String) -> AnyPublisher<String, Errors> {
    var components = URLComponents(string: "")
    let queryItems: [URLQueryItem] = [
      URLQueryItem(name: "client_id", value: ""),
      URLQueryItem(name: "client_secret", value: ""),
      URLQueryItem(name: "code", value: token)
    ]

    components?.queryItems = queryItems

    guard let url = components?.url else {
      return Fail(error: .invalidURL).eraseToAnyPublisher()
    }

    var request = URLRequest(url: url)
    request.addValue("application/json", forHTTPHeaderField: "Accept")
    request.httpMethod = "POST"

    return URLSession.shared
      .dataTaskPublisher(for: request)
      .map { $0.data }
      .decode(type: Response.self, decoder: decoder)
      .mapError { error in
        if let error = error as? Errors {
          return error
        }

        return Errors.jsonDecoding
      }
      .map { $0.accessToken }
      .eraseToAnyPublisher()
  }
}

I hope this has been an insightful article. One that allows you to build some amazing products. If you are after help in such amazing applications, please do get in touch using the links below.