Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
A UIWindowSceneDelegate that provides lifecycle events and an API for deferring specific tasks automatically for you. Simplifies the implementation of LocalAuthentication or some other authentication implementation.
import UIKit
/// Defines a token for determining the validity of a session
public protocol AuthenticationToken: Codable {
/// The token is currently valid
var isValid: Bool { get }
/// An encoded representation for storage
var encoded: Data? { get }
}
public extension AuthenticationToken {
/// The default implementation simply encodes the token as JSON
var encoded: Data? { try? JSONEncoder().encode(self) }
}
/// A permanently authenticated token that never expires
public struct PermanentToken: AuthenticationToken {
public var isValid: Bool { return true }
}
/// A timed authentication token that expires after `timeout`
public struct ExpiryingToken: AuthenticationToken {
/// The time when this token was created
private let initialTime: TimeInterval = CACurrentMediaTime()
/// The expiry (in seconds) until this token expires
private let expiryInSeconds: TimeInterval
/// Returns true if the time hasn't expired, false otherwise
public var isValid: Bool {
let elapsed = CACurrentMediaTime() - initialTime
return elapsed < expiryInSeconds && elapsed > 0
}
/// Makes a new token with the specified expiry (in seconds). Specify a time of 0 to create an invalid token
public init(expiryInSeconds: TimeInterval) {
self.expiryInSeconds = expiryInSeconds
}
}
/// Additional methods that you use to manage authentication during your Scene lifecycle
open class AuthenticatingSceneDelegate: UIResponder, UIWindowSceneDelegate {
public enum AuthenticationError: LocalizedError {
case badToken(AuthenticationToken)
public var errorDescription: String? {
switch self {
case .badToken:
return NSLocalizedString("The authentication completed but the token was invalid.", comment: "Error description")
}
}
}
/// Represents the supported lifecycle events of a UIScene
public enum SceneEvent {
case resignActive
case enterBackground
}
/// Represents the various authentication states for a Scene
private enum SceneAuthenticationState {
case unauthenticated
case beganAuthentication
case authenticated
}
public struct GlobalSettings {
/// Represents a token timeout to use when a scene enters the background or becomes inactive
public var authenticationTimeoutInterval: TimeInterval {
get { AuthenticatingSceneDelegate.authenticationTimeoutInterval }
set { AuthenticatingSceneDelegate.authenticationTimeoutInterval = newValue }
}
/// Returns true if Authentication is enabled. False otherwise
public var isAuthenticationEnabled: Bool {
get { AuthenticatingSceneDelegate.isAuthenticationEnabled }
set { AuthenticatingSceneDelegate.isAuthenticationEnabled = newValue }
}
/// Specifies the event that triggers an invalidation of all deferred operations
public var invalidatesDeferredOperationsOnEvent: SceneEvent {
get { AuthenticatingSceneDelegate.invalidatesDeferredOperationsOnEvent }
set { AuthenticatingSceneDelegate.invalidatesDeferredOperationsOnEvent = newValue }
}
/// Specifies the event that triggers authentication invalidation
public var invalidatesAuthenticationOnEvent: SceneEvent {
get { AuthenticatingSceneDelegate.invalidatesAuthenticationOnEvent }
set { AuthenticatingSceneDelegate.invalidatesAuthenticationOnEvent = newValue }
}
}
private static var isAuthenticationEnabled: Bool = true
private static var authenticationTimeoutInterval: TimeInterval = 15
private static var invalidatesDeferredOperationsOnEvent: SceneEvent = .enterBackground
private static var invalidatesAuthenticationOnEvent: SceneEvent = .enterBackground
/// The primary scene that will be responsible for presenting the authentication
private static var authenticatingScene: UIScene?
/// The current authentication token. This is valid for all scenes
private static var authenticationToken: AuthenticationToken?
/// A list of pending operations to peform after authentication has succeeded
private lazy var deferredOperations: [Operation] = []
/// The key window associated with this delegate
public var window: UIWindow?
public var globalSettings = GlobalSettings()
/// Returns true if authentication is currently required
public var requiresAuthentication: Bool {
return type(of: self).isAuthenticationEnabled &&
(type(of: self).authenticationToken == nil
|| type(of: self).authenticationToken?.isValid == false)
}
/// The current authentication state of this specific delegate. Used to ensure duplicate method calls doesn't occur
private var sceneState: SceneAuthenticationState = .unauthenticated
/// The current global value for all Scene's. Returns true if the app requires authentication regardless of which Scene delegate this is called from
public var isAuthenticating: Bool {
return type(of: self).authenticatingScene != nil
}
// Called when a scene is first connected
open func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard requiresAuthentication else { return }
sceneWillBeginAuthentication(scene)
}
// Called after a scene is first connected and before it becomes active
open func sceneWillEnterForeground(_ scene: UIScene) {
guard requiresAuthentication else {
sceneDidCompleteAuthentication(scene)
return
}
guard type(of: self).invalidatesAuthenticationOnEvent == .enterBackground else { return }
// first scene
if activeScenes == 0, inactiveScenes == 0 {
requestAuthentication(scene)
}
}
// Called after a scene has become active, i.e. multi-tasking or a LocalAuthentication prompt
open func sceneDidBecomeActive(_ scene: UIScene) {
guard requiresAuthentication else {
// We don't require authentication either because we have a permanently valid token or
// a timed token that hasn't expired. Either way we can safely upgrade to a permanent one.
type(of: self).authenticationToken = PermanentToken()
return
}
guard type(of: self).invalidatesAuthenticationOnEvent == .resignActive else { return }
if activeScenes == 1 {
requestAuthentication(scene)
}
}
// Called before a scene will become inactive, i.e. multi-tasking or a LocalAuthentication prompt
open func sceneWillResignActive(_ scene: UIScene) {
guard type(of: self).invalidatesAuthenticationOnEvent == .resignActive else { return }
defer {
clearDeferredOperations(scene)
}
if activeScenes == 1 {
invalidateToken()
} else {
clearDeferredOperations(scene)
}
}
// Called after a scene has become inand has entered the background
open func sceneDidEnterBackground(_ scene: UIScene) {
guard type(of: self).invalidatesAuthenticationOnEvent == .enterBackground else { return }
if activeScenes == 0 && inactiveScenes == 0 {
invalidateToken()
} else {
clearDeferredOperations(scene)
}
}
/// Called when authentication begins. Use this method to obscure any sensitive content
/// This method is guaranteed to be called for each of your UIScene instances.
///
/// - Note: By default this method does nothing so its not necessary to call `super`
///
/// - Parameter scene: The scene to obscure
open func sceneWillBeginAuthentication(_ scene: UIScene) { }
/// Called when authentication is requested. Use this method to present any UI prompts, LocalAuthentication or other method.
/// This method is guaranteed to be called exactly once for only one of your UIScene's instances.
/// If you need to obscure content, use `sceneWillBeginAuthentication(_:)` instead.
///
/// - Note: You must call the completion handler when you're done either with an error or a valid AuthenticationToken to complete the authentication
/// - Note: By default this method does nothing so its not necessary to call `super`
///
/// - Parameters:
/// - scene: The scene where the authentication should occur
/// - completion: The completion handler with a successful token or an error
open func sceneDidRequestAuthentication(_ scene: UIScene, completion: @escaping (Result<AuthenticationToken, Error>) -> Void) {
completion(.success(PermanentToken()))
}
/// Authentication did complete. Use this method to restore your application to its normal state
/// This method is guaranteed to be called for each of your UIScene instances.
///
/// - Note: By default this method does nothing so its not necessary to call `super`
///
/// - Parameter scene: The scene where the authentication occurred
open func sceneDidCompleteAuthentication(_ scene: UIScene) { }
/// Authentication did fail. Use this method to check the error and potentially present it to the user.
/// - Parameters:
/// - scene: The scene where the authentication occurred
/// - error: The error that occurred
open func sceneDidFailAuthentication(_ scene: UIScene, error: Error) { }
public func invalidateAuthentication(_ scene: UIScene) {
invalidateToken()
}
/// Retries the authentication attempt. Use this when you needed to present a previous error and want to force a retry from a new (or the same) UIScene
/// - Parameter scene: The scene where the authentication should occur
public func authenticate(_ scene: UIScene) {
guard !isAuthenticating else { return }
if authenticateWithScene(scene) {
sceneWillBeginAuthentication(scene)
requestAuthentication(scene)
}
}
/// Defer's the specified operation's execution until authentication has succeeded
/// - Parameter operation: The operation to defer
public func deferUntilAuthenticated(_ operation: @escaping () -> Void) {
guard requiresAuthentication else {
OperationQueue.main.addOperation(operation)
return
}
deferredOperations.append(BlockOperation(block: operation))
}
/// Executes all deferred operations immediately
private func processDeferredOperations(_ scene: UIScene) {
guard !deferredOperations.isEmpty else { return }
OperationQueue.main.addOperations(deferredOperations, waitUntilFinished: false)
clearDeferredOperations(scene)
}
/// Clears all deferred operations from the queue, effectively cancelling them
public func clearDeferredOperations(_ scene: UIScene) {
guard !deferredOperations.isEmpty else { return }
deferredOperations.removeAll()
}
}
private extension AuthenticatingSceneDelegate {
/// The current number of scenes that are in the foreground and active
var activeScenes: Int {
return UIApplication.shared.connectedScenes
.filter { $0.activationState == .foregroundActive }.count
}
/// The current number of scenes that are in the foreground and inactive
var inactiveScenes: Int {
return UIApplication.shared.connectedScenes
.filter { $0.activationState == .foregroundInactive }.count
}
/// All connected scenes that are backed by a AuthenticatingSceneDelegate
var authenticatingScenes: [UIScene] {
return UIApplication.shared.connectedScenes
.filter { $0.delegate is AuthenticatingSceneDelegate }
}
/// Asks the delegate to authenticate and return the result
func requestAuthentication(_ scene: UIScene) {
sceneDidRequestAuthentication(scene) { result in
func finishWithResult() {
switch result {
case let .success(token):
guard token.isValid else {
self.failAuthentication(AuthenticationError.badToken(token))
return
}
type(of: self).authenticationToken = token
self.completeAuthentication()
case let .failure(error):
self.failAuthentication(error)
}
}
if Thread.isMainThread {
finishWithResult()
} else {
DispatchQueue.main.async {
finishWithResult()
}
}
}
}
/// Peforms all tasks necessary to complete the authentication and clean up state
private func completeAuthentication() {
assert(Thread.isMainThread)
authenticatingScenes.forEach {
let delegate = $0.delegate as? AuthenticatingSceneDelegate
delegate?.sceneState = .authenticated
delegate?.sceneDidCompleteAuthentication($0)
delegate?.processDeferredOperations($0)
}
type(of: self).authenticatingScene = nil
}
/// Performs all tasks necessary to fail the authentication and clean up state
private func failAuthentication(_ error: Error) {
assert(Thread.isMainThread)
authenticatingScenes.forEach {
let delegate = $0.delegate as? AuthenticatingSceneDelegate
delegate?.sceneState = .unauthenticated
delegate?.sceneDidFailAuthentication($0, error: error)
}
type(of: self).authenticatingScene = nil
type(of: self).authenticationToken = nil
}
/// Swaps out the current token for a TimedAuthenticationToken. If the time expires, the original token will be invalidated. If not, it will be reinstated
private func invalidateToken() {
type(of: self).authenticationToken = ExpiryingToken(expiryInSeconds: type(of: self).authenticationTimeoutInterval)
authenticatingScenes.forEach {
let delegate = $0.delegate as? AuthenticatingSceneDelegate
delegate?.sceneWillBeginAuthentication($0)
delegate?.clearDeferredOperations($0)
}
}
}
private extension AuthenticatingSceneDelegate {
/// Attempts to authenticate with the specified scene. If no other scene is currently authenticating, this scene will be assigned and return.
/// O.therwise the authenticatingScene will be returned
func authenticateWithScene(_ scene: UIScene) -> Bool {
guard type(of: self).authenticatingScene == nil else { return false }
type(of: self).authenticatingScene = scene
return true
}
}
@shaps80

This comment has been minimized.

Copy link
Owner Author

@shaps80 shaps80 commented Jan 4, 2020

Here's a rough example of how you'd use this the framework above:

import UIKit
import LocalAuthentication

final class SceneDelegate: AuthenticatingSceneDelegate {

    override func sceneWillBeginAuthentication(_ scene: UIScene) {
        // AuthenticationController is just an empty controller with the app's logo in it, can be anything you want
        let controller = AuthenticationController(nibName: nil, bundle: nil)
        controller.retryHandler = { [unowned self] in
            self.authenticate(scene)
        }

        if window?.rootViewController != nil {
            // we only want to animate transitions between controllers
            window?.layer.add(CATransition(), forKey: nil)
        }

        window?.rootViewController = controller
    }

    override func sceneDidRequestAuthentication(_ scene: UIScene, completion: @escaping (Result<AuthenticationToken, Error>) -> Void) {
        // I'm using LA as an example here but you can use any method you prefer

        LAContext().evaluatePolicy(.deviceOwnerAuthentication, localizedReason: NSLocalizedString("Keep your device secure.", comment: "Authentication reason")) { _, error in
            if let error = error {
                completion(.failure(error))
            } else {
                // We return a never expiring token, this will automatically be invalidated by the framework as required
                completion(.success(PermanentToken()))
            }
        }
    }

    override func sceneDidCompleteAuthentication(_ scene: UIScene) {
        guard let scene = scene as? Scene else { return }
        window?.layer.add(CATransition(), forKey: nil)
        window?.rootViewController = appViewController
    }

    override func sceneDidFailAuthentication(_ scene: UIScene, error: Error) {
        let error = error as NSError
        guard error.code != LAError.userCancel.rawValue else { return }

        let alert = UIAlertController(title: NSLocalizedString("Authentication Failed", comment: "Alert title"), message: error.localizedDescription, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Alert action"), style: .cancel, handler: nil))
        window?.rootViewController?.present(alert, animated: true, completion: nil)
    }
}
@shaps80

This comment has been minimized.

Copy link
Owner Author

@shaps80 shaps80 commented Jan 4, 2020

A few notes, the framework takes care of multiple UIScene's on iPad as well. It keeps track of when the first or last scene is involved and propagates updates appropriately. There is also a globalSettings property on the delegate which you can use to configure various options, like timeouts and when it should invalidate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment