Forked from shaps80/AuthenticatingSceneDelegate.swift
Created
January 3, 2021 17:49
-
-
Save alemar11/ab9232695f68b5e796d0936cd458c680 to your computer and use it in GitHub Desktop.
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment