Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save alemar11/ab9232695f68b5e796d0936cd458c680 to your computer and use it in GitHub Desktop.
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.
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