// Copyright 2026 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import Foundation public typealias AppCheckCoreTokenHandler = (AppCheckCoreTokenResult) -> Void @objc(GACAppCheckProtocol) public protocol AppCheckCoreProtocol: NSObjectProtocol { @objc(tokenForcingRefresh:completion:) func token(forcingRefresh: Bool, completion: @escaping AppCheckCoreTokenHandler) @objc(limitedUseTokenWithCompletion:) func limitedUseToken(completion: @escaping AppCheckCoreTokenHandler) } @objc(GACAppCheck) public class AppCheckCore: NSObject, AppCheckCoreProtocol { let serviceName: String let appCheckProvider: AppCheckCoreProvider let settings: AppCheckCoreSettingsProtocol weak var tokenDelegate: AppCheckCoreTokenDelegate? let storage: AppCheckCoreStorageProtocol let tokenRefresher: AppCheckCoreTokenRefresherProtocol @objc( initWithServiceName:resourceName:appCheckProvider:settings:tokenDelegate:keychainAccessGroup: ) public init(serviceName: String, resourceName: String, appCheckProvider: AppCheckCoreProvider, settings: AppCheckCoreSettingsProtocol, tokenDelegate: AppCheckCoreTokenDelegate?, keychainAccessGroup: String?) { self.serviceName = serviceName self.appCheckProvider = appCheckProvider self.settings = settings self.tokenDelegate = tokenDelegate let tokenKey = "app_check_token.\(serviceName).\(resourceName)" storage = AppCheckCoreStorage(tokenKey: tokenKey, accessGroup: keychainAccessGroup) let refreshResult = AppCheckCoreTokenRefreshResult( status: .never, expirationDate: nil, receivedAtDate: nil ) tokenRefresher = AppCheckCoreTokenRefresher(refreshResult: refreshResult, settings: settings) super.init() tokenRefresher.tokenRefreshHandler = { [weak self] completion in self?.periodicTokenRefresh(completion: completion) } } init(serviceName: String, appCheckProvider: AppCheckCoreProvider, storage: AppCheckCoreStorageProtocol, tokenRefresher: AppCheckCoreTokenRefresherProtocol, settings: AppCheckCoreSettingsProtocol, tokenDelegate: AppCheckCoreTokenDelegate?) { self.serviceName = serviceName self.appCheckProvider = appCheckProvider self.storage = storage self.tokenRefresher = tokenRefresher self.settings = settings self.tokenDelegate = tokenDelegate super.init() self.tokenRefresher.tokenRefreshHandler = { [weak self] completion in self?.periodicTokenRefresh(completion: completion) } } private func periodicTokenRefresh(completion: @escaping AppCheckCoreTokenRefreshCompletion) { Task { let refreshResult: AppCheckCoreTokenRefreshResult do { let token = try await self.token(forcingRefresh: false) refreshResult = AppCheckCoreTokenRefreshResult(status: .success, expirationDate: token.expirationDate, receivedAtDate: token.receivedAtDate) } catch { refreshResult = AppCheckCoreTokenRefreshResult(status: .failure, expirationDate: nil, receivedAtDate: nil) } // Parity with v11: `-[GACAppCheck periodicTokenRefreshWithCompletion:]` // used bare `.then` / `.catch`, so this ran on the main queue. // // `nonisolated(unsafe)` is an explicit acknowledgement that the result // crosses a queue boundary without compiler-checked isolation. That is // the same boundary v11 crossed via `dispatch_async`, with the same // absence of synchronization, and the value is an immutable holder. nonisolated(unsafe) let result = refreshResult nonisolated(unsafe) let completion = completion DispatchQueue.main.async { completion(result) } } } private var ongoingTask: Task? private let lock = NSLock() private let kTokenExpirationThreshold: TimeInterval = 5 * 60 // 5 minutes private enum GetTokenAction { case wait(Task) case run(Task) } public func token(forcingRefresh: Bool) async throws -> AppCheckCoreToken { let action: GetTokenAction = lock.execute { // If not forcing refresh and there is an ongoing task, return it if !forcingRefresh, let ongoing = ongoingTask { return .wait(ongoing) } // Create a new task and store it only if not forcing refresh let task = Task { () -> AppCheckCoreToken in defer { if !forcingRefresh { self.lock.execute { self.ongoingTask = nil } } } return try await self.createRetrieveOrRefreshToken(forcingRefresh: forcingRefresh) } if !forcingRefresh { self.ongoingTask = task } return .run(task) } switch action { case let .wait(ongoingTask): return try await ongoingTask.value case let .run(newTask): return try await newTask.value } } private func createRetrieveOrRefreshToken(forcingRefresh: Bool) async throws -> AppCheckCoreToken { do { let token = try await getCachedValidToken(forcingRefresh: forcingRefresh) return token } catch { return try await refreshToken() } } private func getCachedValidToken(forcingRefresh: Bool) async throws -> AppCheckCoreToken { if forcingRefresh { throw AppCheckCoreErrorUtil.cachedTokenNotFound() } guard let token = try await storage.getToken() else { throw AppCheckCoreErrorUtil.cachedTokenNotFound() } let isTokenExpiredOrExpiresSoon = token.expirationDate .timeIntervalSinceNow < kTokenExpirationThreshold if isTokenExpiredOrExpiresSoon { throw AppCheckCoreErrorUtil.cachedTokenExpired() } // A token cannot have been received in the future. If it was, the device clock was // ahead when it was fetched and its stored dates can't be trusted. if token.receivedAtDate.timeIntervalSinceNow > 0 { throw AppCheckCoreErrorUtil.cachedTokenExpired() } return token } private func refreshToken() async throws -> AppCheckCoreToken { let token = try await appCheckProvider.getToken() _ = try await storage.setToken(token) let refreshResult = AppCheckCoreTokenRefreshResult(status: .success, expirationDate: token.expirationDate, receivedAtDate: token.receivedAtDate) // Parity with v11: both of these ran inside a bare `FBLPromise.then`, which // dispatches onto `FBLPromise.defaultDispatchQueue` (the main queue). They // are awaited rather than fire-and-forget because the v11 promise only // resolved *after* this block completed, so callers were guaranteed the // delegate had already been notified by the time they received the token. await notifyTokenUpdateOnMainQueue(token, refreshResult: refreshResult) return token } @objc(tokenForcingRefresh:completion:) public func token(forcingRefresh: Bool, completion: @escaping AppCheckCoreTokenHandler) { Task { do { let token = try await self.token(forcingRefresh: forcingRefresh) Self.deliverOnMainQueue(AppCheckCoreTokenResult(token: token), to: completion) } catch { Self.deliverOnMainQueue(AppCheckCoreTokenResult(error: error), to: completion) } } } public func limitedUseToken() async throws -> AppCheckCoreToken { return try await appCheckProvider.getLimitedUseToken() } @objc(limitedUseTokenWithCompletion:) public func limitedUseToken(completion: @escaping AppCheckCoreTokenHandler) { Task { do { let token = try await self.limitedUseToken() Self.deliverOnMainQueue(AppCheckCoreTokenResult(token: token), to: completion) } catch { Self.deliverOnMainQueue(AppCheckCoreTokenResult(error: error), to: completion) } } } // MARK: - Main-queue delivery (v11 parity) /// Delivers a completion handler on the main queue. /// /// In v11 every public completion handler was invoked from a bare /// `FBLPromise` `.then` / `.catch`, which dispatches onto /// `FBLPromise.defaultDispatchQueue`. That default is `dispatch_get_main_queue()` /// (set in `+[FBLPromise initialize]`) and is never reassigned by this library /// or its known consumers, so handlers were always delivered on the main /// queue — and always asynchronously, since `FBLPromise` used an /// unconditional `dispatch_group_async` with no same-queue fast path. /// /// `DispatchQueue.main.async` is used rather than `MainActor.run` to reproduce /// that "always async" behavior exactly, including when the caller is already /// on the main thread. /// /// Note this applies only to the completion-handler API. The `async` variants /// resume on the cooperative pool as normal; `async` callers are expected to /// hop to the main actor themselves, and forcing a hop would be a new /// divergence rather than parity. static func deliverOnMainQueue(_ result: T, to completion: @escaping (T) -> Void) { // See the note in `notifyTokenUpdateOnMainQueue` — the caller's handler crosses // the same queue boundary v11 crossed, and the handler is arbitrary // caller-supplied code that cannot be declared `Sendable` on their behalf. nonisolated(unsafe) let completion = completion DispatchQueue.main.async { completion(result) } } static func deliverOnMainQueue(_ result: T, error: E?, to completion: @escaping (T, E?) -> Void) { nonisolated(unsafe) let completion = completion DispatchQueue.main.async { completion(result, error) } } /// Notifies the token refresher and token delegate on the main queue. /// /// Mirrors the v11 bare `.then` in `-[GACAppCheck refreshToken]`, which ran /// both of these on the main queue before resolving the promise. Suspends /// until they have run so that ordering relative to the returned token is /// preserved. private func notifyTokenUpdateOnMainQueue( _ token: AppCheckCoreToken, refreshResult: AppCheckCoreTokenRefreshResult ) async { // `nonisolated(unsafe)` is an explicit acknowledgement that these cross a // queue boundary without compiler-checked isolation. The refresher and // delegate are arbitrary caller-supplied objects, so neither can honestly // be declared `Sendable` here. This is the same hand-off v11 performed via // `dispatch_async` with no synchronization at all, so it is parity rather // than a new hazard. nonisolated(unsafe) let tokenRefresher = self.tokenRefresher nonisolated(unsafe) let tokenDelegate = self.tokenDelegate nonisolated(unsafe) let result = refreshResult let serviceName = self.serviceName await withCheckedContinuation { continuation in DispatchQueue.main.async { tokenRefresher.updateWithRefreshResult(result) tokenDelegate?.tokenDidUpdate(token, serviceName: serviceName) continuation.resume() } } } }