iOS Code Sample – Coding Key Points

Background

Previously we described our iOS  Infrastructure Setup and we will now look at some key areas of our sample’s code. See also the Client Side API Journey to understand the background and the requirements being met.

Goal: Portable Coding Model

One of this blog’s coding goals is to use the same classes across multiple platforms. Our iOS App uses the same logical separation of responsibilities into classes as the earlier React SPA:

Goal: Unobtrusive OAuth Integration

The view and view model classes below call our OAuth Secured API, and need to deal with triggering login redirects and renewing tokens. This can also involve concurrency, which we will explore shortly.

Companies will want to complete the tricky plumbing code once, then focus on business value, by growing the UI and API code.

Project Creation

The project was initially created via the below App Template, after which I began developing SwiftUI Views that use Swift Classes.

App Entry Point

Our application entry point begins with the SampleApp class, which creates the main AppView along with view model objects and some environment objects that are shared among views:

import Foundation
import SwiftUI

@main
struct SampleApp: App {

    private let model: AppViewModel
    private let eventBus: EventBus
    private let orientationHandler: OrientationHandler
    private let viewRouter: ViewRouter

    init() {

        self.eventBus = EventBus()
        self.orientationHandler = OrientationHandler()

        self.model = AppViewModel(eventBus: self.eventBus)
        self.viewRouter = ViewRouter(eventBus: self.eventBus)
    }

    var body: some Scene {

        WindowGroup {
            AppView(model: self.model, viewRouter: self.viewRouter)
                .environmentObject(self.orientationHandler)
                .environmentObject(self.eventBus)
                .onOpenURL(perform: { url in

                    if self.authenticator.isOAuthResponse(responseUrl: url) {

                        self.model.resumeOAuthResponse(url: url)

                    } else {

                        self.viewRouter.handleDeepLink(url: url)
                    }
                })
                .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in

                    self.orientationHandler.isLandscape = UIDevice.current.orientation.isLandscape
                }
        }
    }
}

The AppView source file renders a tree of views in a similar manner to the SPA’s application view:

var body: some View {

    return VStack {

        TitleView(userInfoViewModel: self.model.getUserInfoViewModel())

        HeaderButtonsView(
            onHome: self.onHome,
            onReloadData: self.onReloadData,
            onExpireAccessToken: self.model.onExpireAccessToken,
            onExpireRefreshToken: self.model.onExpireRefreshToken,
            onLogout: self.onLogout)

        if self.model.error != nil {
            ErrorSummaryView(
            error: self.model.error!,
            hyperlinkText: "Application Problem Encountered",
            dialogTitle: "Application Error",
            padding: EdgeInsets(top: 0, leading: 0, bottom: 10, trailing: 0))
        }

        SessionView(sessionId: self.model.getSessionId())

        MainView(
            viewRouter: self.viewRouter,
            companiesViewModel: self.model.getCompaniesViewModel(),
            transactionsViewModel: self.model.getTransactionsViewModel(),
            isDeviceSecured: self.model.isDeviceSecured)

        Spacer()
    }
    .onAppear(perform: self.model.initialize)
    .onReceive(self.eventBus.loginRequiredTopic, perform: {_ in
        self.onLoginRequired()
    })

Views can be recreated at any time, whereas the AppViewModel is only created once. When it is constructed it reads settings from the JSON configuration file embedded in the app, then creates global objects used for OAuth and API operations:

init(eventBus: EventBus) {

    self.fetchCache = FetchCache()
    self.eventBus = eventBus

    self.configuration = try! ConfigurationLoader.load()
    self.authenticator = AuthenticatorImpl(configuration: self.configuration.oauth)
    self.fetchClient = try! FetchClient(configuration: self.configuration, fetchCache: self.fetchCache, authenticator: self.authenticator)
    self.viewModelCoordinator = ViewModelCoordinator(eventBus: eventBus, fetchCache: self.fetchCache, authenticator: self.authenticator)

    self.isLoaded = false
    self.isDeviceSecured = DeviceSecurity.isDeviceSecured()
    self.error = nil
}

View Layout and Composition

The MainView is swapped out as the user navigates, in a similar way to the main area of an SPA:

var body: some View {

    return VStack {

        if self.viewRouter.currentViewType == BlankView.Type.self {

            BlankView()
                
        } else if !self.isDeviceSecured {

            DeviceNotSecuredView()

        } else if self.viewRouter.currentViewType == TransactionsView.Type.self {

            TransactionsView(model: self.transactionsViewModel, viewRouter: self.viewRouter)

        } else if self.viewRouter.currentViewType == LoginRequiredView.Type.self {

            LoginRequiredView()

        } else {

            CompaniesView(model: self.companiesViewModel, viewRouter: self.viewRouter)
        }
    }
}

Each main view is composed of smaller views,  so for example the CompaniesView renders a collection of CompanyItemView child elements:

var body: some View {

    let deviceWidth = UIScreen.main.bounds.size.width
    return VStack {

        Text("Company List")
            .font(.headline)
            .frame(width: deviceWidth)
            .padding()
            .background(Colors.lightBlue)

        if self.model.error != nil {
            ErrorSummaryView(
                error: self.model.error!,
                hyperlinkText: "Problem Encountered in Companies View",
                dialogTitle: "Companies View Error",
                padding: EdgeInsets(top: 10, leading: 0, bottom: 0, trailing: 0))
        }

        if self.model.companies.count > 0 {
            List(self.model.companies, id: \.id) { item in
                CompanyItemView(viewRouter: self.viewRouter, company: item)
            }
            .listStyle(.plain)
        }
    }
    .onAppear(perform: self.initialLoad)
    .onReceive(self.eventBus.reloadDataTopic, perform: {data in
        self.handleReloadData(event: data)
    })

Data Binding and View Models

We use SwiftUI data binding in a limited manner, in order to reduce code. Views that perform OAuth or API operations delegate the processing to their view model class, which then ‘publishes‘ results back to the view:

class CompaniesViewModel: ObservableObject {

    private let fetchClient: FetchClient
    private let viewModelCoordinator: ViewModelCoordinator

    @Published var companies = [Company]()
    @Published var error: UIError?
}

Views and Web API Calls

The most interesting view models are those that get data from our OAuth Secured API. As is standard in UIs, this involves switching to an I/O worker thread, then switching back to the UI thread once complete:

func callApi(options: ViewLoadOptions? = nil) {

    let fetchOptions = FetchOptions(
        cacheKey: FetchCacheKeys.Companies,
        forceReload: options?.forceReload ?? false,
        causeError: options?.causeError ?? false)

    self.viewModelCoordinator.onMainViewModelLoading()
    self.error = nil

    Task {

        do {

            let companies = try await self.fetchClient.getCompanies(options: fetchOptions)
            await MainActor.run {

                if companies != nil {
                    self.companies = companies!
                }
                self.viewModelCoordinator.onMainViewModelLoaded(cacheKey: fetchOptions.cacheKey)
            }

        } catch {

            await MainActor.run {

                self.companies = [Company]()
                self.error = ErrorFactory.fromException(error: error)
                self.viewModelCoordinator.onMainViewModelLoaded(cacheKey: fetchOptions.cacheKey)
            }
        }
    }
}

Use of up to date Swift syntax  provides a readable async await coding model where we write simple promise based functions to do the work.

API Call Details

The FetchClient class does the lower level work and uses iOS Async URL Sessions. Each API call uses a shared method to deal with supplying OAuth access tokens and managing retries.

We implement the same OAuth client side behaviour that we have used in all of other UI code samples, by getting a new token and retrying the request once if the API returns a 401 status code. The basic API code, with caching omitted, looks like this:

private func getDataFromApi(url: URL, options: FetchOptions) async throws -> Data? {

    var accessToken = authenticator.getAccessToken()
    if accessToken == nil {
        throw loginRequiredError
    }

    do {
        return try await self.callApiWithToken(
            method: "GET",
            url: url,
            jsonData: nil,
            accessToken: accessToken!,
            options: options)

    } catch {

        let error = ErrorFactory.fromException(error: error)
        if error.statusCode != 401 {
            throw error
        }

        accessToken = try await authenticator.synchronizedRefreshAccessToken()
        return try await self.callApiWithToken(
            method: "GET",
            url: url,
            jsonData: nil,
            accessToken: accessToken!,
            options: options) 
    } 
}

Authenticator Interface

The ApiClient uses an Authenticator reference and calls getAccessToken in order to retrieve a message credential for API calls:

protocol Authenticator {

    func initialize() async throws

    func getAccessToken() async throws -> String

    func synchronizedRefreshAccessToken() async throws -> String

    func startLoginRedirect(viewController: UIViewController) throws

    func handleLoginResponse() async throws -> OIDAuthorizationResponse

    func finishLogin(authResponse: OIDAuthorizationResponse) async throws

    func startLogoutRedirect(viewController: UIViewController) throws

    func handleLogoutResponse() async throws -> OIDEndSessionResponse

    func isOAuthResponse(responseUrl: URL) -> Bool

    func resumeOperation(responseUrl: URL)

    func clearLoginState()

    func expireAccessToken()

    func expireRefreshToken()
}

Triggering Login Redirects

Our App has 2 fragments that load concurrently, both of which call the API, to get the main view’s data and also to get user info:

An ApiViewEvents class is used to wait for all views to load. In the event of any view receiving a permanent 401 response from the API, a single OAuth redirect is triggered by the main view:

private func handleErrorsAfterLoad() {

    if self.loadedCount == self.loadingCount {

        let errors = self.getLoadErrors()

        let loginRequired = errors.first { error in
                error.errorCode == ErrorCodes.loginRequired
        }
        if loginRequired != nil {
            self.eventBus.sendLoginRequiredEvent()
            return
        }

        let oauthConfigurationError = errors.first { error in
                (error.statusCode == 401 && error.errorCode == ErrorCodes.invalidToken) ||
                (error.statusCode == 403 && error.errorCode == ErrorCodes.insufficientScope)
        }

        if oauthConfigurationError != nil {
            self.authenticator.clearLoginState()
        }
    }
}

The ViewModelCoordinator class also deals with invalid token errors, such as incorrect scope, claims or audience configurations. For these errors, the app clears its login state to enable retries where the OAuth configuration has been fixed. The app then receives new tokens and the user can recover.

AppAuth Library – Login Requests

Our implementation uses AppAuth iOS classes to implement standards based OpenID Connect behaviour.

func startLoginRedirect(viewController: UIViewController) throws {

    do {

        let redirectUri = self.getLoginRedirectUri()
        guard let loginRedirectUri = URL(string: redirectUri) else {
            let message = "Error creating URL for : \(redirectUri)"
            throw ErrorFactory.fromMessage(message: message)
        }

        let additionalParameters = [String: String]()

        let scopesArray = self.configuration.scope.components(separatedBy: " ")
        let request = OIDAuthorizationRequest(
            configuration: self.metadata!,
            clientId: self.configuration.clientId,
            clientSecret: nil,
            scopes: scopesArray,
            redirectURL: loginRedirectUri,
            responseType: OIDResponseTypeCode,
            additionalParameters: additionalParameters)

        self.currentOAuthSession = OIDAuthorizationService.present(
            request,
            presenting: viewController,
            callback: self.loginResponseHandler.callback)

    } catch {

        self.currentOAuthSession = nil
        throw ErrorFactory.fromLoginRequestError(error: error)
    }
}

At runtime the properties of AppAuth objects are set based on our OAuth configuration settings:

{
  "app": {
    "apiBaseUrl":             "https://api.authsamples.com/investments"
  },
  "oauth": {
    "authority":              "https://cognito-idp.eu-west-2.amazonaws.com/eu-west-2_CuhLeqiE9",
    "userInfoEndpoint":       "https://login.authsamples.com/oauth2/userInfo",
    "clientId":               "2vshs4gidsbpnjmsprhh607ege",
    "webBaseUrl":             "https://authsamples.com",
    "loginRedirectPath":      "/apps/basicmobileapp/postlogin.html",
    "postLogoutRedirectPath": "/apps/basicmobileapp/postlogout.html",
    "deepLinkBaseUrl":        "https://mobile.authsamples.com",
    "loginActivatePath":      "/basicmobileapp/oauth/callback",
    "postLogoutActivatePath": "/basicmobileapp/oauth/logoutcallback",
    "scope":                  "openid profile email https://api.authsamples.com/investments",
    "customLogoutEndpoint":   "https://login.authsamples.com/logout"
  }
}

AppAuth libraries produce outgoing Authorization Code Flow (PKCE) request messages and then handle incoming response messages:

The libraries also select the type of login window, and the default option is to use an ASWebAuthenticationSession:

AppAuth Library – Login Responses

Upon return from login we present the below Web Hosted Post Login Page in the system browser window. This screen receives the authorization code in a response query parameter.

Javascript code in the web page invokes the login receiver activity when the continue button is pressed. This forwards any received query parameters, including the Authorization Code, to the app:

<script>
    window.addEventListener('DOMContentLoaded', function() {
        var redirectUri = 'https://mobile.authsamples.com/basicmobileapp/oauth/callback';

        if (window.location.search) {
            redirectUri += window.location.search;
        }
        if (window.location.hash) {
            redirectUri += window.location.hash;
        }

        document.getElementById('continueButton').onclick = () => {
            window.location.href = redirectUri;
        };
    });
</script>

For Claimed HTTPS Scheme based logins the response is then received as a deep link in the SampleApp class, then forwarded to the authenticator class, which resumes the AppAuth session:

func resumeOperation(responseUrl: URL) {

    if self.currentOAuthSession != nil {

        var resumeUrl: String = self.getResumeUrl()
        if resumeUrl != nil {
            self.currentOAuthSession!.resumeExternalUserAgentFlow(
                with: URL(string: resumeUrl!)!)
        }
    }
}

AppAuth Library – Cancelled Logins

It is possible for users to quit the login attempt if they are having trouble signing in, via the Cancel button in the top left:

AppAuth libraries are well designed and provide error codes that we can use to determine cancellation and other conditions:

private func isCancelledError(error: Error) -> Bool {

    let authError = error as NSError
    return self.matchesAppAuthError(
        error: error,
        domain: OIDGeneralErrorDomain,
        code: OIDErrorCode.userCanceledAuthorizationFlow.rawValue))
}

For cancelled logins we return the user to the Login Required view so that they can retry the operation:

AppAuth Library – Authorization Code Grant

After successfully receiving the login response, the Authorization Code Flow continues by swapping the received code for tokens:

func finishLogin(authResponse: OIDAuthorizationResponse) async throws {

    self.currentOAuthSession = nil
    let request = authResponse.tokenExchangeRequest()

    return try await withCheckedThrowingContinuation { continuation in

        OIDAuthorizationService.perform(
            request!,
            originalAuthorizationResponse: authResponse) { tokenResponse, error in

                if error != nil {

                    let uiError = ErrorFactory.fromTokenError(
                        error: error!,
                        errorCode: ErrorCodes.authorizationCodeGrantFailed)
                    continuation.resume(throwing: uiError)
                }

                self.saveTokens(tokenResponse: tokenResponse!)
                continuation.resume()
            }
    }
}

The message includes a verifier used for PKCE handling, and AppAuth libraries take care of supplying this correctly.

Secure Token Storage

After login we store OAuth tokens in a secure manner and need to ensure that no other app can access them. This is straightforward using the Swift KeyChain Wrapper helper library:

private func saveTokenData() {

    let encoder = JSONEncoder()
    let jsonText = try? encoder.encode(self.tokenData)
    if jsonText != nil {
        KeychainWrapper.standard.set(jsonText!, forKey: self.storageKey)
    }
}

Application Restarts without Login

When the app starts, it loads OpenID Connect metadata and also any tokens that have been saved to Android storage. This prevents the user needing to re-authenticate on every application restart:

override suspend fun initialize() {
    this.getMetadata()
    this.tokenStorage.loadTokens()
}

AppAuth Library – Refreshing Access Tokens

AppAuth classes are also used to send a Refresh Token Grant message, via the OIDTokenRequest class. The AppAuth error codes allow us to reliably detect the ‘Invalid Grant‘ response when the refresh token finally expires:

private func performRefreshTokenGrant() async throws {

    let tokenData = self.tokenStorage.getTokens()

    try await self.getMetadata()

    let request = OIDTokenRequest(
        configuration: self.metadata!,
        grantType: OIDGrantTypeRefreshToken,
        authorizationCode: nil,
        redirectURL: nil,
        clientID: self.configuration.clientId,
        clientSecret: nil,
        scope: nil,
        refreshToken: tokenData!.refreshToken!,
        codeVerifier: nil,
        additionalParameters: nil)

    return try await withCheckedThrowingContinuation { continuation in

        OIDAuthorizationService.perform(request) { tokenResponse, error in

            if error != nil {

                if self.matchesAppAuthError(
                    error: error!,
                    domain: OIDOAuthTokenErrorDomain,
                    code: OIDErrorCodeOAuth.invalidGrant.rawValue) {

                    self.tokenStorage.removeTokens()
                    continuation.resume()
                    return
                }

                let uiError = ErrorFactory.fromTokenError(
                    error: error!,
                    errorCode: ErrorCodes.refreshTokenGrantFailed)
                continuation.resume(throwing: uiError)
                return
            }

            if tokenResponse == nil || tokenResponse!.accessToken == nil {
                let message = "No tokens were received in the Refresh Token Grant message"
                continuation.resume(throwing: ErrorFactory.fromMessage(message: message))
                return
            }

            self.saveTokens(tokenResponse: tokenResponse!)
            continuation.resume()
        }
    }
}

Token Renewal and Concurrency

When multiple fragments call APIs and receive 401 responses, the token renewal call should be synchronised so that it only occurs once. If we view HTTP traffic we can see the correct behaviour:

  • Initially two fragments call the API and receive a 401 response
  • A single token renewal message is sent to the authorization server
  • Both fragments successfully call the API again with the new token

To ensure this, our code uses a ConcurrentActionHandler class, so that only a single UI fragment does a token refresh at a time:

func synchronizedRefreshAccessToken() async throws -> String {

    let refreshToken = self.tokenStorage.getTokens()?.refreshToken

    if refreshToken != nil {
        try await self.concurrencyHandler.execute(action: self.performRefreshTokenGrant)
    }

    let accessToken = self.tokenStorage.getTokens()?.accessToken
    if accessToken != nil {

        return accessToken!

    } else {

        throw ErrorFactory.fromLoginRequired()
    }
}

As well as being more efficient, this ensures that our code is ready to use refresh token rotation reliably, as opposed to receiving multiple refresh tokens and possibly saving one that has been invalidated.

Logout

We use AppAuth iOS support for End Session processing, and our logout code involves these two actions:

  • Removing the Refresh Token from iOS Secure Storage
  • Removing the Authorization Server’s Session Cookie

The second step requires creating an End Session Request. We then redirect on an ASWebAuthenticationSession window, since the session cookie can only be removed via the system browser:

func startLogoutRedirect(viewController: UIViewController) throws {

    let tokenData = self.tokenStorage.getTokens()
    if tokenData == nil || tokenData!.idToken == nil {
        return
    }

    do {

        let idToken = tokenData!.idToken!
        self.tokenStorage.removeTokens()

        let postLogoutUrl = self.getPostLogoutRedirectUri()
        guard let postLogoutRedirectUri = URL(string: postLogoutUrl) else {
            let message = "Error creating URL for : \(postLogoutUrl)"
            throw ErrorFactory.fromMessage(message: message)
        }

        let logoutManager = self.createLogoutManager()

        let metadataWithEndSessionEndpoint = try logoutManager.updateMetadata(
            metadata: self.metadata!)

        let request = logoutManager.createEndSessionRequest(
            metadata: metadataWithEndSessionEndpoint,
            idToken: idToken,
            postLogoutRedirectUri: postLogoutRedirectUri)

        let agent = OIDExternalUserAgentIOS(presenting: viewController)
        self.currentOAuthSession = OIDAuthorizationService.present(
            request,
            externalUserAgent: agent!,
            callback: self.logoutResponseHandler.callback)

    } catch {

        self.currentOAuthSession = nil
        throw ErrorFactory.fromLogoutRequestError(error: error)
    }
}

Logout request messages include a Post Logout Return Location that points to our Web Hosted Post Logout Page:

When continue is clicked, the web page again invokes the app’s Claimed HTTPS Scheme, which completes the operation in the same manner as for login redirects.

AppAuth Library – Error Codes

The Error Domain and Code from the AppAuth Errors Enumeration can be useful if you need to better understand any AppAuth error codes reported by the app:

/*! @brief The error codes for the @c ::OIDOAuthTokenErrorDomain error domain
    @see https://tools.ietf.org/html/rfc6749#section-5.2
 */
typedef NS_ENUM(NSInteger, OIDErrorCodeOAuthToken) {
  /*! @remarks invalid_request
      @see https://tools.ietf.org/html/rfc6749#section-5.2
   */
  OIDErrorCodeOAuthTokenInvalidRequest = OIDErrorCodeOAuthInvalidRequest,

  /*! @remarks invalid_client
      @see https://tools.ietf.org/html/rfc6749#section-5.2
   */
  OIDErrorCodeOAuthTokenInvalidClient = OIDErrorCodeOAuthInvalidClient,

  /*! @remarks invalid_grant
      @see https://tools.ietf.org/html/rfc6749#section-5.2
   */
  OIDErrorCodeOAuthTokenInvalidGrant = OIDErrorCodeOAuthInvalidGrant
};

The app’s error handling is diligent about capturing these runtime details, to help with OAuth problem resolution:

By coding in Swift, our iOS sample requires fewest technical layers to integrate AppAuth libraries, and we have first class access to error details.

Navigation

To manage navigation between views we use a simple ViewRouter class. The MainView class then renders the currently active main view whenever the router’s published properties are updated.

class ViewRouter: ObservableObject {

    @Published var currentViewType: Any.Type = CompaniesView.Type.self
    @Published var params: [Any] = [Any]()

    private let eventBus: EventBus
    var isTopMost: Bool

    init(eventBus: EventBus) {
        self.eventBus = eventBus
        self.isTopMost = true
    }

    func changeMainView(newViewType: Any.Type, newViewParams: [Any]) {

        self.currentViewType = newViewType
        self.params = newViewParams
    }
}

Deep Linking

Our iOS app also uses deep linking, as a second form of navigation. When a deep link notification matches the deep linking subpath, the View Router parses the incoming URL and updates the main view’s location:

func handleDeepLink(url: URL) {

    if self.isTopMost {

        let oldViewType = self.currentViewType
        let result = DeepLinkHelper.handleDeepLink(url: url)
        self.changeMainView(newViewType: result.0, newViewParams: result.1)

        let isSameView = oldViewType == self.currentViewType
        if isSameView {
            self.eventBus.sendReloadMainViewEvent(causeError: false)
        }
    }
}

It is possible to deep link to an unauthorised or invalid API resource, resulting in the API returning a 404 error. Our transactions view model deals with this reliably, by processing API error codes:

private func isForbiddenError() -> Bool {

    if self.error != nil {

        if self.error!.statusCode == 404 && self.error!.errorCode == ErrorCodes.companyNotFound {

            return true

        } else if self.error!.statusCode == 400 && self.error!.errorCode == ErrorCodes.invalidCompanyId {

            return true
        }
    }

    return false
}

Finally, note that deep link messages are ignored when an OAuth redirect is in progress, and the ASWebAuthenticationSession window is top most.

Debugging Swift Code

Since we are using Swift code, we can debug code by setting a breakpoint, and use step through commands from the Debug Menu. This also enables us to view the state of AppAuth classes:

Swift Code Quality Checks

At build time we use the SwiftLint static analyzer tool, to check some of the  finer details of the Swift code, to help keep the coding style maintainable:

AppAuth Libraries

This blog demonstrates mobile integration using the recommendations from RFC8252. Doing so does not mandate use of the AppAuth libraries though. If you run into any library blocking issues, the code flow  could be implemented fairly easily in the AuthenticatorImpl class.

Where Are We?

We have implemented an OpenID Connect secured iOS App with no blocking issues. By using native tech a software company would now be in a strong technical position:

  • The app supports many possible types of user login
  • The app has a modern coding model that is easy to extend
  • The app can use the latest iOS native features
  • The app has good reliability and error handling control

Next Steps