Skip to main content

Swift 6 Compatibility

Proposal:SDK-0001
Status:Accepted
Languages:Swift
Implementation:okta-mobile-swift #214, #217, #220, #221, #222, #223, #224

Swift 6 introduces concurrency features that address runtime multithreading issues like data races and shared mutable state access. These changes significantly impact the Okta Client SDK, which has faced increased threading issues due to recent platform updates. To ensure stability and eliminate multithreading safety concerns, the SDK will adopt Swift 6 concurrency features and modernize its foundations.

Motivation​

Swift has been incrementally introducing new concurrency features to the language which protects developers from a variety of hard-to-diagnose runtime errors. Many of these features were only available if a developer opted-in to those features. With the release of Swift 6, these optional features are enabled by default, which has a huge impact on developers.

More importantly, these language features make it significantly easier for developers to fix a variety of runtime problems, such as data races, preventing concurrent access to shared mutable state, and many other tricky edge-cases.

The Okta Client SDK has been stable for a long time, but recent updates to Swift and Apple's platforms caused rare threading issues to become much more common, negatively impacting users and introducing tricky race conditions. This is most prevalent in the race conditions around the default credential property.

This SDK was originally built around completion blocks, with support for Swift Concurrency async functions added as optional wrappers around those block-based APIs. This was because Swift Concurrency was fairly new at the time, and wasn't available to all supported versions of iOS. As a result, actor isolation context and task inheritance won't work with the SDK's current architecture.

To that end, the Okta Client SDK is being updated to fully support Swift 6 concurrency features, will harden its underlying foundations to leverage modern language capabilities, and to eliminate entire classes of runtime multithreading safety issues.

Design objectives​

  1. Adopt new Swift 6 language features
  2. Maintain backwards compatibility with Swift 5.10
  3. Enable future development to easily use new language fatures
  4. Maintain an optimal Developer Experience for users of the SDK

Detailed design

Ultimately Swift 6 language compatibility is a secondary concern to the concerns around data races and thread safety. To support this, the SDK should:

  1. Update existing APIs and types to limit the surface area for mutable properties, changing common objects to value types, etc.
  2. Update classes, structs, and other data types to conform to the Sendable protocol, explicitly identifying which objects can safely transit isolation contexts.
  3. Adopt Swift Concurrency (e.g. async/await) and Task Inheritance to ensure proper actor isolation across context boundaries.
  4. Adopt Actor types for highly-asynchronous components (e.g. Token Storage, Authentication Flows, etc) to guarantee serialized access to shared state across the system.
  5. Explicitly mark commonly-used properties and functions as nonisolated, implementing other thread safety features (like locks or semaphores) to prevent all APIs requiring async access.

To support Swift 6 Complete Concurrency Checking, a lot of small changes needed to be made, such as marking types as conforming to Sendable, marking blocks as @Sendable, and updating other libraries to adapt to changes in interfaces defined in AuthFoundation.

Limit mutable API surface area​

Several sections of the API feature mutable state, or state spread across different properties. For example, additionalHttpHeaders on the OAuth2Client is defined as a var, or Authentication Flows which contains a variety of mutable properties which change throughout the authentication flow (such as authenticationUrl, device authorization flow verification data, etc). Not only do these mutable properties pose problems for data race safety, but they highlight problems in consistency across a variety of APIs.

To limit the scope of these mutable properties, Authentication Flows should all include a required Context type which can contain all developer-assigned customizations during an individual sign-in session, but can also contain any in-progress state that may change throughout the sign in operation.

public protocol AuthenticationFlow {
associatedtype Context: AuthenticationContext

var context: Context? { get }
var isAuthenticating: Bool { get }

// Other common properties / functions
}

public protocol AuthenticationContext: ProvidesOAuth2Parameters {
// Common properties / functions applicable to all context types
var acrValues: [String]? { get }
}

Not only can this restrict the mutable values to context and isAuthenticating, but it also can simplify how authentication values can pass from flows and OAuth2Client configuration to their accompanying requests.

The ProvidesOAuth2Parameters protocol can be updated to be more expressive, allowing a flow and its context to supply information to the request based on the type of request being performed.

public protocol ProvidesOAuth2Parameters {
func parameters(for category: OAuth2APIRequestCategory) -> [String: any APIRequestArgument]?
}

public enum OAuth2APIRequestCategory {
case configuration
case authorization
case token
case resource
case other
}

For example, in the context for AuthorizationCodeFlow, this can be used to ensure PKCE data, state, and other values can be supplied at the correct stage of the flow.

func parameters(for category: OAuth2APIRequestCategory) -> [String: any APIRequestArgument]? {
// Allow the developer to supply arbitrary values
// to pass through to outgoing requests.
var result = additionalParameters ?? [:]

switch category {
case .authorization:
result["state"] = state
result["response_type"] = "code"

if let nonce = nonce {
result["nonce"] = nonce
}

if let pkce = pkce {
result["code_challenge"] = pkce.codeChallenge
result["code_challenge_method"] = pkce.method
}

// Other values ...

case .token:
if let pkce = pkce {
result["code_verifier"] = pkce.codeVerifier
}

case .configuration, .resource, .other:
// No special values needed in these requests
break
}

return result
}

Adopt Sendable in classes / types​

In order to use strict concurrency checking, objects need to conform to the Sendable protocol (including blocks/closures which need to apply the @Sendable attribute) to indicate they're safe to be used or sent across isolation contexts. Adding conformances to this protocol automatically enfores a number of compilation checks which will require code changes to comply with these new checks.

Wherever possible, using immutable properties or value types to enable implicit conformance, but there will be times where more explicit handling of Sendable checks will be neccessary. In those cases, it may be necessary to utilize locks or semaphores where appropriate to ensure objects can be sendable.

warning

Overuse of locks or semaphores can introduce unneccessary overhead, or risk the introduction of deadlocks, so they should be used sparingly. If locks are used, special care must be taken to ensure deadlocks do not occur, and that their use is isolated.

Use async/await and Tasks​

Much of Swift's new concurrency features center around async operations, as well as the use of Tasks. Unlike Grand Central Dispatch (GCD) and the use of blocks within Dispatch Queues, Tasks establish an inherited relationship between asynchronous operations which not only can pass along priority information, but can enable task cancellation that can propagate across multiple operations.

This will be a prerequisite for adopting any of these other concurrency features so this context can be maintained between operations.

To better understand how this will play out in the real world, consider the following example:

  1. A user clicks a "Sign In" button in their application, which triggers an event within the @MainActor.
  2. The WebAuthentication.shared property is accessed which loads the Okta.plist configuration, implicitly creates an instance of the WebAuthentication class, and creates the appropriate OAuth2Client as well as the flows used for sign-in and -out.
  3. The start() async throws -> Token function is called, which performs a series of steps necessary to create an authorization URL.
    1. The OAuth2Client performs a request to fetch the client's /.well-known/openid-configuration endpoint.
    2. The authorize URL is fetched, PKCE data is generated, and a URL is formed.
    3. A delegate function is called to allow the developer's application to alter the URL before being opened.
    4. An instance of ASWebAuthenticationSession is initiated with the authorize URL, and is presented to the user.
  4. The browser session returns the resulting redirect URI, which the SDK consumes, validates the response data, and extracts the authorization code.
  5. This code is sent to the AuthorizationCodeFlow instance within the WebAuthentication object, and is used against the /token endpoint to request a token.
  6. In parallel to the token request, the /keys endpoint is fetched to proactively load the JWK keyset the authoriation server is using.
  7. Once both request responses are received, the tokens are validated and returned to the developer.

This workflow involves a number of asynchronous operations initiated by a user, many of which may be canceled for a variety of reasons. If completion blocks are used to trigger any of these steps, the information about task priority, the relationship between the tasks, and the cancellation of any of those operations is impossible to determine.

Use Actors where neccessary​

Actors are a relatively new capabiity within the Swift language. These are reference types, similar to classes, except there are several key differences that make them distinct from classes.

tip

Swift Actors and their nuances are too large of a topic to cover here, so it's important to review the relevant documentation.

Actors guarantee serial access to callers, ensuring that mutations are isolated and aren't susceptable to race conditions. This comes at the cost of requiring all accesses to their isolated members (from outside the actor itself, or its global context) to be asynchronous, including properties. This negatively affects the developer experience (DX), so actors should only be used within isolated areas that are seldom used directly by consumers of the SDK, and that could benefit from data race saftey.

The areas that should be focused on are:

  • Token Storage / Credential subsystem
  • Authentication Flows
  • Async conveniences / internals

Token Storage​

Given that the main concerns that precipitated these updates are related to token storage, and handling of the default token, this is the area that should be given the most attention.

Since a number of classes and protocols interact to form the token storage system, it makes sense to ensure that these classes can easily coordinate with one-another without unneccessary async operations across isolation contexts. As a result, this area should share a common custom global context to ensure they all share the same serial executor, and to simplify the way callers can coordinate with that isolation context.

Authentication Flows​

Authentication Flows are by their very nature asynchronous operations the user interacts with. Furthermore, the main access points (the start and resume functions) are already async functions. To simplify matters, the AuthenticationFlow protocol can ensure only actors can conform to that protocol.

Any other property on authentication flows beyond start and resume should do their best to remain nonisolated either by being immutable properties referencing Sendable value types, or should utilize semaphores to ensure the async interactions with the actor's isolation context can be safely serialized to the caller.

Optimize Developer Experience w/nonisolated properties & functions​

As mentioned in the previous section, ensuring an optimal developer experience (DX) is important, and requiring all interactions with SDK APIs to be async would hurt the development experience and could even introduce additional bugs through improper use of these APIs by developers.

Therefore common patterns should be adopted to consistently, and safely, enable synchronous access to objects and classes, while maintaining thread safety. Additionally, interactions with delegates should either consistently dispatch to the @MainActor, or should safely inherit the caller's isolation context, to limit the number of unneccessary synchronizations across threads.

Implications on adoption

There are some risks in adopting Swift 6 which should be carefully avoided to reduce the negative impact on the developer experience, and to ensure quick adoption.

  • Avoid leaking too many async operations to developers, preserving synchronous APIs wherever possible.
  • Provide completion wrappers for async operations for developers who haven't yet adopted Swift Concurrency, or who are using the SDK from synchronous contexts (e.g. SwiftUI action blocks).
  • Limit the use of actors to areas not commonly used directly by developers, or in areas that are already asynchronous (using nonisolated in areas that don't require complete async operations, like immutable properties or initializers).
  • Ensure task cancellation is supported, and that task priority inheritance works as expected.
  • Try to avoid crossing isolation contexts unneccessarily, inheriting actor contexts appropriately.

Future directions​

By using Actors and Swift 6 concurrency, this sets the stage to adopt new Swift features more easily in the future. This can also simplify tighter integration with other Apple frameworks like SwiftUI, to provide more developer conveniences and to simplify customer deployments.