From d0047459a4c3e7ea36f90657648e8ecaaf11ddba Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Tue, 18 Aug 2026 15:31:49 +0000 Subject: [PATCH 1/3] feat(app_check,windows): add custom App Check provider support Allow Flutter Windows apps to mint App Check tokens through a Dart WindowsCustomProvider callback, with app-scoped native wiring. --- docs/app-check/default-providers.md | 5 + .../appcheck/FirebaseAppCheckPlugin.kt | 1 + .../GeneratedAndroidFirebaseAppCheck.g.kt | 118 ++++++- .../firebase_app_check/example/lib/main.dart | 9 +- .../FirebaseAppCheckMessages.g.swift | 228 +++++++++++--- .../FirebaseAppCheckPlugin.swift | 2 +- .../lib/firebase_app_check.dart | 4 +- .../lib/src/firebase_app_check.dart | 12 +- .../windows/firebase_app_check_plugin.cpp | 98 +++++- .../windows/firebase_app_check_plugin.h | 45 +++ .../firebase_app_check/windows/messages.g.cpp | 125 +++++++- .../firebase_app_check/windows/messages.g.h | 67 ++++ .../method_channel_firebase_app_check.dart | 67 +++- .../lib/src/pigeon/messages.pigeon.dart | 124 +++++++- ...platform_interface_firebase_app_check.dart | 12 +- .../lib/src/windows_providers.dart | 60 +++- .../pigeons/messages.dart | 35 +++ ...ethod_channel_firebase_app_check_test.dart | 288 +++++++++++++++++- 18 files changed, 1194 insertions(+), 106 deletions(-) diff --git a/docs/app-check/default-providers.md b/docs/app-check/default-providers.md index c6b05afafb2b..092375ae8e91 100644 --- a/docs/app-check/default-providers.md +++ b/docs/app-check/default-providers.md @@ -93,6 +93,11 @@ Future main() async { // 3. AppleAppAttestProvider // 4. AppleAppAttestProviderWithDeviceCheckFallback (App Attest provider is only available on iOS 14.0+, macOS 14.0+) providerApple: AppleAppAttestProvider(), + // Default provider for Windows is the debug provider. Use `providerWindows` + // to choose: + // 1. WindowsDebugProvider for development (pass a console-registered token) + // 2. WindowsCustomProvider for production token minting via your backend + providerWindows: WindowsDebugProvider(debugToken: 'your-debug-token'), ); runApp(App()); } diff --git a/packages/firebase_app_check/firebase_app_check/android/src/main/kotlin/io/flutter/plugins/firebase/appcheck/FirebaseAppCheckPlugin.kt b/packages/firebase_app_check/firebase_app_check/android/src/main/kotlin/io/flutter/plugins/firebase/appcheck/FirebaseAppCheckPlugin.kt index 3e13ad2c0f8a..4eddebf8b747 100644 --- a/packages/firebase_app_check/firebase_app_check/android/src/main/kotlin/io/flutter/plugins/firebase/appcheck/FirebaseAppCheckPlugin.kt +++ b/packages/firebase_app_check/firebase_app_check/android/src/main/kotlin/io/flutter/plugins/firebase/appcheck/FirebaseAppCheckPlugin.kt @@ -54,6 +54,7 @@ class FirebaseAppCheckPlugin : FlutterFirebasePlugin, FlutterPlugin, FirebaseApp appleProvider: String?, debugToken: String?, recaptchaSiteKey: String?, + windowsProvider: String?, callback: (Result) -> Unit ) { try { diff --git a/packages/firebase_app_check/firebase_app_check/android/src/main/kotlin/io/flutter/plugins/firebase/appcheck/GeneratedAndroidFirebaseAppCheck.g.kt b/packages/firebase_app_check/firebase_app_check/android/src/main/kotlin/io/flutter/plugins/firebase/appcheck/GeneratedAndroidFirebaseAppCheck.g.kt index fe6c1510a9d3..00a72bd0b81c 100644 --- a/packages/firebase_app_check/firebase_app_check/android/src/main/kotlin/io/flutter/plugins/firebase/appcheck/GeneratedAndroidFirebaseAppCheck.g.kt +++ b/packages/firebase_app_check/firebase_app_check/android/src/main/kotlin/io/flutter/plugins/firebase/appcheck/GeneratedAndroidFirebaseAppCheck.g.kt @@ -17,6 +17,11 @@ import java.nio.ByteBuffer private object GeneratedAndroidFirebaseAppCheckPigeonUtils { + fun createConnectionError(channelName: String): FlutterError { + return FlutterError( + "channel-error", "Unable to establish connection on channel: '$channelName'.", "") + } + fun wrapResult(result: Any?): List { return listOf(result) } @@ -229,12 +234,69 @@ data class InternalAppCheckTokenResult(val token: String, val expirationTimestam } } +/** + * Carries a minted App Check token plus the wall-clock expiry the Firebase SDK should associate + * with it. Returning the expiry alongside the token lets backends mint tokens with arbitrary + * lifetimes (short TTLs for a stricter security posture, longer TTLs for fewer round-trips) without + * the plugin hardcoding a refresh window. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class CustomAppCheckToken( + /** The App Check token string to send with Firebase requests. */ + val token: String, + /** + * Absolute expiry as Unix epoch milliseconds (UTC). The Firebase SDK uses this to decide when + * to refresh; a token returned with an expiry in the past is treated as immediately expired. + */ + val expireTimeMillis: Long +) { + companion object { + fun fromList(pigeonVar_list: List): CustomAppCheckToken { + val token = pigeonVar_list[0] as String + val expireTimeMillis = pigeonVar_list[1] as Long + return CustomAppCheckToken(token, expireTimeMillis) + } + } + + fun toList(): List { + return listOf( + token, + expireTimeMillis, + ) + } + + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as CustomAppCheckToken + return GeneratedAndroidFirebaseAppCheckPigeonUtils.deepEquals(this.token, other.token) && + GeneratedAndroidFirebaseAppCheckPigeonUtils.deepEquals( + this.expireTimeMillis, other.expireTimeMillis) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + GeneratedAndroidFirebaseAppCheckPigeonUtils.deepHash(this.token) + result = + 31 * result + GeneratedAndroidFirebaseAppCheckPigeonUtils.deepHash(this.expireTimeMillis) + return result + } +} + private open class GeneratedAndroidFirebaseAppCheckPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return when (type) { 129.toByte() -> { return (readValue(buffer) as? List)?.let { InternalAppCheckTokenResult.fromList(it) } } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { CustomAppCheckToken.fromList(it) } + } else -> super.readValueOfType(type, buffer) } } @@ -245,6 +307,10 @@ private open class GeneratedAndroidFirebaseAppCheckPigeonCodec : StandardMessage stream.write(129) writeValue(stream, value.toList()) } + is CustomAppCheckToken -> { + stream.write(130) + writeValue(stream, value.toList()) + } else -> super.writeValue(stream, value) } } @@ -258,6 +324,7 @@ interface FirebaseAppCheckHostApi { appleProvider: String?, debugToken: String?, recaptchaSiteKey: String?, + windowsProvider: String?, callback: (Result) -> Unit ) @@ -308,12 +375,14 @@ interface FirebaseAppCheckHostApi { val appleProviderArg = args[2] as String? val debugTokenArg = args[3] as String? val recaptchaSiteKeyArg = args[4] as String? + val windowsProviderArg = args[5] as String? api.activate( appNameArg, androidProviderArg, appleProviderArg, debugTokenArg, - recaptchaSiteKeyArg) { result: Result -> + recaptchaSiteKeyArg, + windowsProviderArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(GeneratedAndroidFirebaseAppCheckPigeonUtils.wrapError(error)) @@ -453,3 +522,50 @@ interface FirebaseAppCheckHostApi { } } } +/** + * Dart-side handler invoked by the native plugin when the Firebase SDK needs a fresh App Check + * token. Implementations typically call a backend service (for example a Cloud Function with + * `enforceAppCheck: false`) that mints a token using the Firebase Admin SDK. The native side awaits + * the future, then hands the token to the Firebase SDK, which attaches it to subsequent Firebase + * backend requests (Firestore, Functions, Storage, Auth, RTDB). + * + * Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. + */ +class FirebaseAppCheckFlutterApi( + private val binaryMessenger: BinaryMessenger, + private val messageChannelSuffix: String = "" +) { + companion object { + /** The codec used by FirebaseAppCheckFlutterApi. */ + val codec: MessageCodec by lazy { GeneratedAndroidFirebaseAppCheckPigeonCodec() } + } + + fun getCustomToken(callback: (Result) -> Unit) { + val separatedMessageChannelSuffix = + if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = + "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckFlutterApi.getCustomToken$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else if (it[0] == null) { + callback( + Result.failure( + FlutterError( + "null-error", + "Flutter api returned null value for non-null return value.", + ""))) + } else { + val output = it[0] as CustomAppCheckToken + callback(Result.success(output)) + } + } else { + callback( + Result.failure( + GeneratedAndroidFirebaseAppCheckPigeonUtils.createConnectionError(channelName))) + } + } + } +} diff --git a/packages/firebase_app_check/firebase_app_check/example/lib/main.dart b/packages/firebase_app_check/firebase_app_check/example/lib/main.dart index 6e9c8225546c..b9a872c02a92 100644 --- a/packages/firebase_app_check/firebase_app_check/example/lib/main.dart +++ b/packages/firebase_app_check/firebase_app_check/example/lib/main.dart @@ -39,10 +39,11 @@ Future main() async { : ReCaptchaV3Provider(kWebRecaptchaSiteKey), providerAndroid: const AndroidDebugProvider(), providerApple: const AppleDebugProvider(), - // On Windows, only the debug provider is available. - // You must supply a debug token — the desktop C++ SDK does not - // auto-generate one. Create one in the Firebase Console under - // App Check > Apps > Manage debug tokens, then either: + // On Windows, use WindowsDebugProvider for development or + // WindowsCustomProvider to mint production tokens from your backend. + // The desktop C++ SDK does not auto-generate debug tokens. Create one in + // the Firebase Console under App Check > Apps > Manage debug tokens, + // then either: // - pass it via --dart-define=APP_CHECK_DEBUG_TOKEN= // - or set the APP_CHECK_DEBUG_TOKEN environment variable providerWindows: WindowsDebugProvider( diff --git a/packages/firebase_app_check/firebase_app_check/ios/firebase_app_check/Sources/firebase_app_check/FirebaseAppCheckMessages.g.swift b/packages/firebase_app_check/firebase_app_check/ios/firebase_app_check/Sources/firebase_app_check/FirebaseAppCheckMessages.g.swift index daaa0f7796d6..26d4fe428c0e 100644 --- a/packages/firebase_app_check/firebase_app_check/ios/firebase_app_check/Sources/firebase_app_check/FirebaseAppCheckMessages.g.swift +++ b/packages/firebase_app_check/firebase_app_check/ios/firebase_app_check/Sources/firebase_app_check/FirebaseAppCheckMessages.g.swift @@ -27,13 +27,12 @@ final class PigeonError: Error { } var localizedDescription: String { - return - "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" } } private func wrapResult(_ result: Any?) -> [Any?] { - return [result] + [result] } private func wrapError(_ error: Any) -> [Any?] { @@ -58,8 +57,15 @@ private func wrapError(_ error: Any) -> [Any?] { ] } +private func createConnectionError(withChannelName channelName: String) -> PigeonError { + PigeonError( + code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", + details: "" + ) +} + private func isNullish(_ value: Any?) -> Bool { - return value is NSNull || value == nil + value is NSNull || value == nil } private func nilOrValue(_ value: Any?) -> T? { @@ -68,7 +74,7 @@ private func nilOrValue(_ value: Any?) -> T? { } private func doubleEqualsFirebaseAppCheckMessages(_ lhs: Double, _ rhs: Double) -> Bool { - return (lhs.isNaN && rhs.isNaN) || lhs == rhs + (lhs.isNaN && rhs.isNaN) || lhs == rhs } private func doubleHashFirebaseAppCheckMessages(_ value: Double, _ hasher: inout Hasher) { @@ -145,7 +151,7 @@ func deepEqualsFirebaseAppCheckMessages(_ lhs: Any?, _ rhs: Any?) -> Bool { func deepHashFirebaseAppCheckMessages(value: Any?, hasher: inout Hasher) { let cleanValue = nilOrValue(value) as Any? - if let cleanValue = cleanValue { + if let cleanValue { if let doubleValue = cleanValue as? Double { doubleHashFirebaseAppCheckMessages(doubleValue, &hasher) } else if let valueList = cleanValue as? [Any?] { @@ -179,7 +185,7 @@ func deepHashFirebaseAppCheckMessages(value: Any?, hasher: inout Hasher) { /// Generated class from Pigeon that represents data sent in messages. struct InternalAppCheckTokenResult: Hashable { var token: String - var expirationTimestamp: Int64? = nil + var expirationTimestamp: Int64? // swift-format-ignore: AlwaysUseLowerCamelCase static func fromList(_ pigeonVar_list: [Any?]) -> InternalAppCheckTokenResult? { @@ -191,12 +197,14 @@ struct InternalAppCheckTokenResult: Hashable { expirationTimestamp: expirationTimestamp ) } + func toList() -> [Any?] { - return [ + [ token, expirationTimestamp, ] } + static func == (lhs: InternalAppCheckTokenResult, rhs: InternalAppCheckTokenResult) -> Bool { if Swift.type(of: lhs) != Swift.type(of: rhs) { return false @@ -212,11 +220,61 @@ struct InternalAppCheckTokenResult: Hashable { } } +/// Carries a minted App Check token plus the wall-clock expiry the Firebase +/// SDK should associate with it. Returning the expiry alongside the token lets +/// backends mint tokens with arbitrary lifetimes (short TTLs for a stricter +/// security posture, longer TTLs for fewer round-trips) without the plugin +/// hardcoding a refresh window. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct CustomAppCheckToken: Hashable { + /// The App Check token string to send with Firebase requests. + var token: String + /// Absolute expiry as Unix epoch milliseconds (UTC). The Firebase SDK uses + /// this to decide when to refresh; a token returned with an expiry in the + /// past is treated as immediately expired. + var expireTimeMillis: Int64 + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> CustomAppCheckToken? { + let token = pigeonVar_list[0] as! String + let expireTimeMillis = pigeonVar_list[1] as! Int64 + + return CustomAppCheckToken( + token: token, + expireTimeMillis: expireTimeMillis + ) + } + + func toList() -> [Any?] { + [ + token, + expireTimeMillis, + ] + } + + static func == (lhs: CustomAppCheckToken, rhs: CustomAppCheckToken) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return deepEqualsFirebaseAppCheckMessages(lhs.token, rhs.token) + && deepEqualsFirebaseAppCheckMessages(lhs.expireTimeMillis, rhs.expireTimeMillis) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("CustomAppCheckToken") + deepHashFirebaseAppCheckMessages(value: token, hasher: &hasher) + deepHashFirebaseAppCheckMessages(value: expireTimeMillis, hasher: &hasher) + } +} + private class FirebaseAppCheckMessagesPigeonCodecReader: FlutterStandardReader { override func readValue(ofType type: UInt8) -> Any? { switch type { case 129: - return InternalAppCheckTokenResult.fromList(self.readValue() as! [Any?]) + return InternalAppCheckTokenResult.fromList(readValue() as! [Any?]) + case 130: + return CustomAppCheckToken.fromList(readValue() as! [Any?]) default: return super.readValue(ofType: type) } @@ -228,6 +286,9 @@ private class FirebaseAppCheckMessagesPigeonCodecWriter: FlutterStandardWriter { if let value = value as? InternalAppCheckTokenResult { super.writeByte(129) super.writeValue(value.toList()) + } else if let value = value as? CustomAppCheckToken { + super.writeByte(130) + super.writeValue(value.toList()) } else { super.writeValue(value) } @@ -236,51 +297,54 @@ private class FirebaseAppCheckMessagesPigeonCodecWriter: FlutterStandardWriter { private class FirebaseAppCheckMessagesPigeonCodecReaderWriter: FlutterStandardReaderWriter { override func reader(with data: Data) -> FlutterStandardReader { - return FirebaseAppCheckMessagesPigeonCodecReader(data: data) + FirebaseAppCheckMessagesPigeonCodecReader(data: data) } override func writer(with data: NSMutableData) -> FlutterStandardWriter { - return FirebaseAppCheckMessagesPigeonCodecWriter(data: data) + FirebaseAppCheckMessagesPigeonCodecWriter(data: data) } } class FirebaseAppCheckMessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { static let shared = FirebaseAppCheckMessagesPigeonCodec( - readerWriter: FirebaseAppCheckMessagesPigeonCodecReaderWriter()) + readerWriter: FirebaseAppCheckMessagesPigeonCodecReaderWriter() + ) } /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol FirebaseAppCheckHostApi { - func activate( - appName: String, androidProvider: String?, appleProvider: String?, debugToken: String?, - recaptchaSiteKey: String?, completion: @escaping (Result) -> Void) - func getToken( - appName: String, forceRefresh: Bool, completion: @escaping (Result) -> Void) - func getTokenResult( - appName: String, forceRefresh: Bool, - completion: @escaping (Result) -> Void) - func setTokenAutoRefreshEnabled( - appName: String, isTokenAutoRefreshEnabled: Bool, - completion: @escaping (Result) -> Void) + func activate(appName: String, androidProvider: String?, appleProvider: String?, + debugToken: String?, + recaptchaSiteKey: String?, windowsProvider: String?, + completion: @escaping (Result) -> Void) + func getToken(appName: String, forceRefresh: Bool, + completion: @escaping (Result) -> Void) + func getTokenResult(appName: String, forceRefresh: Bool, + completion: @escaping (Result) -> Void) + func setTokenAutoRefreshEnabled(appName: String, isTokenAutoRefreshEnabled: Bool, + completion: @escaping (Result) -> Void) func registerTokenListener(appName: String, completion: @escaping (Result) -> Void) - func getLimitedUseAppCheckToken( - appName: String, completion: @escaping (Result) -> Void) + func getLimitedUseAppCheckToken(appName: String, + completion: @escaping (Result) -> Void) } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. class FirebaseAppCheckHostApiSetup { - static var codec: FlutterStandardMessageCodec { FirebaseAppCheckMessagesPigeonCodec.shared } - /// Sets up an instance of `FirebaseAppCheckHostApi` to handle messages through the `binaryMessenger`. - static func setUp( - binaryMessenger: FlutterBinaryMessenger, api: FirebaseAppCheckHostApi?, - messageChannelSuffix: String = "" - ) { + static var codec: FlutterStandardMessageCodec { + FirebaseAppCheckMessagesPigeonCodec.shared + } + + /// Sets up an instance of `FirebaseAppCheckHostApi` to handle messages through the + /// `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: FirebaseAppCheckHostApi?, + messageChannelSuffix: String = "") { let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" let activateChannel = FlutterBasicMessageChannel( name: - "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.activate\(channelSuffix)", - binaryMessenger: binaryMessenger, codec: codec) - if let api = api { + "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.activate\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec + ) + if let api { activateChannel.setMessageHandler { message, reply in let args = message as! [Any?] let appNameArg = args[0] as! String @@ -288,9 +352,11 @@ class FirebaseAppCheckHostApiSetup { let appleProviderArg: String? = nilOrValue(args[2]) let debugTokenArg: String? = nilOrValue(args[3]) let recaptchaSiteKeyArg: String? = nilOrValue(args[4]) + let windowsProviderArg: String? = nilOrValue(args[5]) api.activate( appName: appNameArg, androidProvider: androidProviderArg, appleProvider: appleProviderArg, - debugToken: debugTokenArg, recaptchaSiteKey: recaptchaSiteKeyArg + debugToken: debugTokenArg, recaptchaSiteKey: recaptchaSiteKeyArg, + windowsProvider: windowsProviderArg ) { result in switch result { case .success: @@ -305,9 +371,10 @@ class FirebaseAppCheckHostApiSetup { } let getTokenChannel = FlutterBasicMessageChannel( name: - "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.getToken\(channelSuffix)", - binaryMessenger: binaryMessenger, codec: codec) - if let api = api { + "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.getToken\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec + ) + if let api { getTokenChannel.setMessageHandler { message, reply in let args = message as! [Any?] let appNameArg = args[0] as! String @@ -326,9 +393,10 @@ class FirebaseAppCheckHostApiSetup { } let getTokenResultChannel = FlutterBasicMessageChannel( name: - "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.getTokenResult\(channelSuffix)", - binaryMessenger: binaryMessenger, codec: codec) - if let api = api { + "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.getTokenResult\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec + ) + if let api { getTokenResultChannel.setMessageHandler { message, reply in let args = message as! [Any?] let appNameArg = args[0] as! String @@ -347,9 +415,10 @@ class FirebaseAppCheckHostApiSetup { } let setTokenAutoRefreshEnabledChannel = FlutterBasicMessageChannel( name: - "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.setTokenAutoRefreshEnabled\(channelSuffix)", - binaryMessenger: binaryMessenger, codec: codec) - if let api = api { + "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.setTokenAutoRefreshEnabled\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec + ) + if let api { setTokenAutoRefreshEnabledChannel.setMessageHandler { message, reply in let args = message as! [Any?] let appNameArg = args[0] as! String @@ -370,9 +439,10 @@ class FirebaseAppCheckHostApiSetup { } let registerTokenListenerChannel = FlutterBasicMessageChannel( name: - "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.registerTokenListener\(channelSuffix)", - binaryMessenger: binaryMessenger, codec: codec) - if let api = api { + "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.registerTokenListener\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec + ) + if let api { registerTokenListenerChannel.setMessageHandler { message, reply in let args = message as! [Any?] let appNameArg = args[0] as! String @@ -390,9 +460,10 @@ class FirebaseAppCheckHostApiSetup { } let getLimitedUseAppCheckTokenChannel = FlutterBasicMessageChannel( name: - "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.getLimitedUseAppCheckToken\(channelSuffix)", - binaryMessenger: binaryMessenger, codec: codec) - if let api = api { + "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.getLimitedUseAppCheckToken\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec + ) + if let api { getLimitedUseAppCheckTokenChannel.setMessageHandler { message, reply in let args = message as! [Any?] let appNameArg = args[0] as! String @@ -410,3 +481,60 @@ class FirebaseAppCheckHostApiSetup { } } } + +/// Dart-side handler invoked by the native plugin when the Firebase SDK needs +/// a fresh App Check token. Implementations typically call a backend service +/// (for example a Cloud Function with `enforceAppCheck: false`) that mints a +/// token using the Firebase Admin SDK. The native side awaits the future, +/// then hands the token to the Firebase SDK, which attaches it to subsequent +/// Firebase backend requests (Firestore, Functions, Storage, Auth, RTDB). +/// +/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. +protocol FirebaseAppCheckFlutterApiProtocol { + func getCustomToken(completion: @escaping (Result) -> Void) +} + +class FirebaseAppCheckFlutterApi: FirebaseAppCheckFlutterApiProtocol { + private let binaryMessenger: FlutterBinaryMessenger + private let messageChannelSuffix: String + init(binaryMessenger: FlutterBinaryMessenger, messageChannelSuffix: String = "") { + self.binaryMessenger = binaryMessenger + self.messageChannelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + } + + var codec: FirebaseAppCheckMessagesPigeonCodec { + FirebaseAppCheckMessagesPigeonCodec.shared + } + + func getCustomToken(completion: @escaping (Result) -> Void) { + let channelName = + "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckFlutterApi.getCustomToken\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel( + name: channelName, binaryMessenger: binaryMessenger, codec: codec + ) + channel.sendMessage(nil) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else if listResponse[0] == nil { + completion( + .failure( + PigeonError( + code: "null-error", + message: "Flutter api returned null value for non-null return value.", details: "" + ) + ) + ) + } else { + let result = listResponse[0] as! CustomAppCheckToken + completion(.success(result)) + } + } + } +} diff --git a/packages/firebase_app_check/firebase_app_check/ios/firebase_app_check/Sources/firebase_app_check/FirebaseAppCheckPlugin.swift b/packages/firebase_app_check/firebase_app_check/ios/firebase_app_check/Sources/firebase_app_check/FirebaseAppCheckPlugin.swift index 3fe364ec1ce7..3e01394bd520 100644 --- a/packages/firebase_app_check/firebase_app_check/ios/firebase_app_check/Sources/firebase_app_check/FirebaseAppCheckPlugin.swift +++ b/packages/firebase_app_check/firebase_app_check/ios/firebase_app_check/Sources/firebase_app_check/FirebaseAppCheckPlugin.swift @@ -62,7 +62,7 @@ public class FirebaseAppCheckPlugin: NSObject, FlutterPlugin, func activate( appName: String, androidProvider: String?, appleProvider: String?, - debugToken: String?, recaptchaSiteKey: String?, + debugToken: String?, recaptchaSiteKey: String?, windowsProvider: String?, completion: @escaping (Result) -> Void ) { guard let app = FLTFirebasePlugin.firebaseAppNamed(appName) else { diff --git a/packages/firebase_app_check/firebase_app_check/lib/firebase_app_check.dart b/packages/firebase_app_check/firebase_app_check/lib/firebase_app_check.dart index ef74a9b2e136..d912e881aba7 100644 --- a/packages/firebase_app_check/firebase_app_check/lib/firebase_app_check.dart +++ b/packages/firebase_app_check/firebase_app_check/lib/firebase_app_check.dart @@ -27,7 +27,9 @@ export 'package:firebase_app_check_platform_interface/firebase_app_check_platfor WebDebugProvider, WebProvider, WindowsAppCheckProvider, - WindowsDebugProvider; + WindowsDebugProvider, + WindowsCustomProvider, + CustomAppCheckToken; export 'package:firebase_core_platform_interface/firebase_core_platform_interface.dart' show FirebaseException; diff --git a/packages/firebase_app_check/firebase_app_check/lib/src/firebase_app_check.dart b/packages/firebase_app_check/firebase_app_check/lib/src/firebase_app_check.dart index 2cc14f2e9d33..d8a7c0f19a90 100644 --- a/packages/firebase_app_check/firebase_app_check/lib/src/firebase_app_check.dart +++ b/packages/firebase_app_check/firebase_app_check/lib/src/firebase_app_check.dart @@ -73,12 +73,12 @@ class FirebaseAppCheck extends FirebasePlugin implements FirebaseService { /// "app attest with fallback to device check" via `AppleAppCheckProvider`. /// Note: App Attest is only available on iOS 14.0+ and macOS 14.0+. /// - /// **Windows**: Only the debug provider is supported. You **must** supply a - /// debug token — the desktop C++ SDK does not auto-generate one. Either pass - /// it via `providerWindows: WindowsDebugProvider(debugToken: 'your-token')` - /// or set the `APP_CHECK_DEBUG_TOKEN` environment variable. The token must - /// first be registered in the Firebase Console under - /// *App Check → Apps → Manage debug tokens*. + /// **Windows**: Use `providerWindows` to configure either + /// [WindowsCustomProvider] for production token minting or + /// [WindowsDebugProvider] for development. The desktop C++ SDK does not + /// auto-generate debug tokens. Either pass a registered token via + /// `providerWindows: WindowsDebugProvider(debugToken: 'your-token')` or set + /// the `APP_CHECK_DEBUG_TOKEN` environment variable. /// /// ## Migration Notice /// diff --git a/packages/firebase_app_check/firebase_app_check/windows/firebase_app_check_plugin.cpp b/packages/firebase_app_check/firebase_app_check/windows/firebase_app_check_plugin.cpp index fa156faa63eb..83ff970864aa 100644 --- a/packages/firebase_app_check/firebase_app_check/windows/firebase_app_check_plugin.cpp +++ b/packages/firebase_app_check/firebase_app_check/windows/firebase_app_check_plugin.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -98,6 +99,71 @@ class TokenStreamHandler std::unique_ptr listener_; }; +FlutterAppCheckProvider::FlutterAppCheckProvider( + flutter::BinaryMessenger* binary_messenger, + FlutterAppCheckProviderFactory* factory, const std::string& app_name) + : flutter_api_(std::make_unique( + binary_messenger, app_name)), + factory_(factory), + app_name_(app_name) {} + +void FlutterAppCheckProvider::GetToken( + std::function + completion_callback) { + if (factory_ == nullptr || !factory_->UsesCustomProvider(app_name_)) { + App* app = App::GetInstance(app_name_.c_str()); + firebase::app_check::AppCheckProvider* debug_provider = + DebugAppCheckProviderFactory::GetInstance()->CreateProvider(app); + debug_provider->GetToken(std::move(completion_callback)); + return; + } + + auto completion = std::make_shared>( + std::move(completion_callback)); + + flutter_api_->GetCustomToken( + [completion](const CustomAppCheckToken& dart_token) { + firebase::app_check::AppCheckToken result_token; + result_token.token = dart_token.token(); + result_token.expire_time_millis = dart_token.expire_time_millis(); + (*completion)(result_token, firebase::app_check::kAppCheckErrorNone, + ""); + }, + [completion](const FlutterError& error) { + (*completion)(firebase::app_check::AppCheckToken(), + firebase::app_check::kAppCheckErrorUnknown, + error.message().empty() ? "unknown" : error.message()); + }); +} + +FlutterAppCheckProviderFactory::FlutterAppCheckProviderFactory( + flutter::BinaryMessenger* binary_messenger) + : binary_messenger_(binary_messenger) {} + +firebase::app_check::AppCheckProvider* +FlutterAppCheckProviderFactory::CreateProvider(firebase::App* app) { + const std::string app_name = app == nullptr ? "" : app->name(); + auto& provider = providers_[app_name]; + if (!provider) { + provider = std::make_unique(binary_messenger_, + this, app_name); + } + return provider.get(); +} + +void FlutterAppCheckProviderFactory::SetAppUsesCustomProvider( + const std::string& app_name, bool uses_custom) { + custom_apps_[app_name] = uses_custom; +} + +bool FlutterAppCheckProviderFactory::UsesCustomProvider( + const std::string& app_name) const { + auto it = custom_apps_.find(app_name); + return it != custom_apps_.end() && it->second; +} + static AppCheck* GetAppCheckFromPigeon(const std::string& app_name) { App* app = App::GetInstance(app_name.c_str()); return AppCheck::GetInstance(app); @@ -136,12 +202,13 @@ void FirebaseAppCheckPlugin::RegisterWithRegistrar( flutter::PluginRegistrarWindows* registrar) { auto plugin = std::make_unique(); + binaryMessenger = registrar->messenger(); + plugin->EnsureProviderFactory(); + FirebaseAppCheckHostApi::SetUp(registrar->messenger(), plugin.get()); registrar->AddPlugin(std::move(plugin)); - binaryMessenger = registrar->messenger(); - // Register for platform logging App::RegisterLibrary(kLibraryName.c_str(), getPluginVersion().c_str(), nullptr); @@ -149,6 +216,15 @@ void FirebaseAppCheckPlugin::RegisterWithRegistrar( FirebaseAppCheckPlugin::FirebaseAppCheckPlugin() {} +void FirebaseAppCheckPlugin::EnsureProviderFactory() { + if (provider_factory_) { + return; + } + provider_factory_ = + std::make_unique(binaryMessenger); + AppCheck::SetAppCheckProviderFactory(provider_factory_.get()); +} + FirebaseAppCheckPlugin::~FirebaseAppCheckPlugin() { for (auto& [app_name, listener] : listeners_map_) { App* app = App::GetInstance(app_name.c_str()); @@ -166,20 +242,22 @@ FirebaseAppCheckPlugin::~FirebaseAppCheckPlugin() { void FirebaseAppCheckPlugin::Activate( const std::string& app_name, const std::string* android_provider, const std::string* apple_provider, const std::string* debug_token, - const std::string* recaptcha_site_key, + const std::string* recaptcha_site_key, const std::string* windows_provider, std::function reply)> result) { // reCAPTCHA is a mobile-only provider, so the site key is unused here. (void)recaptcha_site_key; + (void)android_provider; + (void)apple_provider; - // On Windows/desktop, only the Debug provider is available. - DebugAppCheckProviderFactory* factory = - DebugAppCheckProviderFactory::GetInstance(); + EnsureProviderFactory(); - if (debug_token != nullptr && !debug_token->empty()) { - factory->SetDebugToken(*debug_token); - } + const bool uses_custom = + windows_provider != nullptr && *windows_provider == "custom"; + provider_factory_->SetAppUsesCustomProvider(app_name, uses_custom); - AppCheck::SetAppCheckProviderFactory(factory); + if (!uses_custom && debug_token != nullptr && !debug_token->empty()) { + DebugAppCheckProviderFactory::GetInstance()->SetDebugToken(*debug_token); + } result(std::nullopt); } diff --git a/packages/firebase_app_check/firebase_app_check/windows/firebase_app_check_plugin.h b/packages/firebase_app_check/firebase_app_check/windows/firebase_app_check_plugin.h index c5450a2bf369..33809091060a 100644 --- a/packages/firebase_app_check/firebase_app_check/windows/firebase_app_check_plugin.h +++ b/packages/firebase_app_check/firebase_app_check/windows/firebase_app_check_plugin.h @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -23,6 +24,45 @@ namespace firebase_app_check_windows { class TokenStreamHandler; +class FlutterAppCheckProviderFactory; + +// Custom App Check provider for Windows. When the Firebase C++ SDK calls +// GetToken(), this provider either calls into Dart via +// FirebaseAppCheckFlutterApi for a server-minted token, or forwards to the +// debug provider for the same app. +class FlutterAppCheckProvider : public firebase::app_check::AppCheckProvider { + public: + FlutterAppCheckProvider(flutter::BinaryMessenger* binary_messenger, + FlutterAppCheckProviderFactory* factory, + const std::string& app_name); + void GetToken(std::function + completion_callback) override; + + private: + std::unique_ptr flutter_api_; + FlutterAppCheckProviderFactory* factory_; + std::string app_name_; +}; + +// Long-lived factory that creates one FlutterAppCheckProvider per firebase +// App. CreateProvider is process-global in the C++ SDK, so this factory must +// outlive every AppCheck instance it is registered with. +class FlutterAppCheckProviderFactory + : public firebase::app_check::AppCheckProviderFactory { + public: + explicit FlutterAppCheckProviderFactory( + flutter::BinaryMessenger* binary_messenger); + firebase::app_check::AppCheckProvider* CreateProvider( + firebase::App* app) override; + void SetAppUsesCustomProvider(const std::string& app_name, bool uses_custom); + bool UsesCustomProvider(const std::string& app_name) const; + + private: + flutter::BinaryMessenger* binary_messenger_; + std::map custom_apps_; + std::map> providers_; +}; class FirebaseAppCheckPlugin : public flutter::Plugin, public FirebaseAppCheckHostApi { @@ -44,6 +84,7 @@ class FirebaseAppCheckPlugin : public flutter::Plugin, const std::string& app_name, const std::string* android_provider, const std::string* apple_provider, const std::string* debug_token, const std::string* recaptcha_site_key, + const std::string* windows_provider, std::function reply)> result) override; void GetToken(const std::string& app_name, bool force_refresh, std::function> reply)> @@ -64,6 +105,10 @@ class FirebaseAppCheckPlugin : public flutter::Plugin, std::function reply)> result) override; private: + void EnsureProviderFactory(); + + std::unique_ptr provider_factory_; + static flutter::BinaryMessenger* binaryMessenger; static std::map< std::string, diff --git a/packages/firebase_app_check/firebase_app_check/windows/messages.g.cpp b/packages/firebase_app_check/firebase_app_check/windows/messages.g.cpp index d239741562cc..ea760bed18b4 100644 --- a/packages/firebase_app_check/firebase_app_check/windows/messages.g.cpp +++ b/packages/firebase_app_check/firebase_app_check/windows/messages.g.cpp @@ -315,6 +315,60 @@ size_t PigeonInternalDeepHash(const InternalAppCheckTokenResult& v) { return v.Hash(); } +// CustomAppCheckToken + +CustomAppCheckToken::CustomAppCheckToken(const std::string& token, + int64_t expire_time_millis) + : token_(token), expire_time_millis_(expire_time_millis) {} + +const std::string& CustomAppCheckToken::token() const { return token_; } + +void CustomAppCheckToken::set_token(std::string_view value_arg) { + token_ = value_arg; +} + +int64_t CustomAppCheckToken::expire_time_millis() const { + return expire_time_millis_; +} + +void CustomAppCheckToken::set_expire_time_millis(int64_t value_arg) { + expire_time_millis_ = value_arg; +} + +EncodableList CustomAppCheckToken::ToEncodableList() const { + EncodableList list; + list.reserve(2); + list.push_back(EncodableValue(token_)); + list.push_back(EncodableValue(expire_time_millis_)); + return list; +} + +CustomAppCheckToken CustomAppCheckToken::FromEncodableList( + const EncodableList& list) { + CustomAppCheckToken decoded(std::get(list[0]), + std::get(list[1])); + return decoded; +} + +bool CustomAppCheckToken::operator==(const CustomAppCheckToken& other) const { + return PigeonInternalDeepEquals(token_, other.token_) && + PigeonInternalDeepEquals(expire_time_millis_, + other.expire_time_millis_); +} + +bool CustomAppCheckToken::operator!=(const CustomAppCheckToken& other) const { + return !(*this == other); +} + +size_t CustomAppCheckToken::Hash() const { + size_t result = 1; + result = result * 31 + PigeonInternalDeepHash(token_); + result = result * 31 + PigeonInternalDeepHash(expire_time_millis_); + return result; +} + +size_t PigeonInternalDeepHash(const CustomAppCheckToken& v) { return v.Hash(); } + PigeonInternalCodecSerializer::PigeonInternalCodecSerializer() {} EncodableValue PigeonInternalCodecSerializer::ReadValueOfType( @@ -325,6 +379,10 @@ EncodableValue PigeonInternalCodecSerializer::ReadValueOfType( InternalAppCheckTokenResult::FromEncodableList( std::get(ReadValue(stream)))); } + case 130: { + return CustomEncodableValue(CustomAppCheckToken::FromEncodableList( + std::get(ReadValue(stream)))); + } default: return ::flutter::StandardCodecSerializer::ReadValueOfType(type, stream); } @@ -342,6 +400,14 @@ void PigeonInternalCodecSerializer::WriteValue( stream); return; } + if (custom_value->type() == typeid(CustomAppCheckToken)) { + stream->WriteByte(130); + WriteValue( + EncodableValue(std::any_cast(*custom_value) + .ToEncodableList()), + stream); + return; + } } ::flutter::StandardCodecSerializer::WriteValue(value, stream); } @@ -399,9 +465,12 @@ void FirebaseAppCheckHostApi::SetUp( const auto& encodable_recaptcha_site_key_arg = args.at(4); const auto* recaptcha_site_key_arg = std::get_if(&encodable_recaptcha_site_key_arg); + const auto& encodable_windows_provider_arg = args.at(5); + const auto* windows_provider_arg = + std::get_if(&encodable_windows_provider_arg); api->Activate(app_name_arg, android_provider_arg, apple_provider_arg, debug_token_arg, - recaptcha_site_key_arg, + recaptcha_site_key_arg, windows_provider_arg, [reply](std::optional&& output) { if (output.has_value()) { reply(WrapError(output.value())); @@ -665,4 +734,58 @@ EncodableValue FirebaseAppCheckHostApi::WrapError(const FlutterError& error) { error.details()}); } +// Generated class from Pigeon that represents Flutter messages that can be +// called from C++. +FirebaseAppCheckFlutterApi::FirebaseAppCheckFlutterApi( + ::flutter::BinaryMessenger* binary_messenger) + : binary_messenger_(binary_messenger), message_channel_suffix_("") {} + +FirebaseAppCheckFlutterApi::FirebaseAppCheckFlutterApi( + ::flutter::BinaryMessenger* binary_messenger, + const std::string& message_channel_suffix) + : binary_messenger_(binary_messenger), + message_channel_suffix_(message_channel_suffix.length() > 0 + ? std::string(".") + message_channel_suffix + : "") {} + +const ::flutter::StandardMessageCodec& FirebaseAppCheckFlutterApi::GetCodec() { + return ::flutter::StandardMessageCodec::GetInstance( + &PigeonInternalCodecSerializer::GetInstance()); +} + +void FirebaseAppCheckFlutterApi::GetCustomToken( + std::function&& on_success, + std::function&& on_error) { + const std::string channel_name = + "dev.flutter.pigeon.firebase_app_check_platform_interface." + "FirebaseAppCheckFlutterApi.getCustomToken" + + message_channel_suffix_; + BasicMessageChannel<> channel(binary_messenger_, channel_name, &GetCodec()); + EncodableValue encoded_api_arguments = EncodableValue(); + channel.Send(encoded_api_arguments, [channel_name, + on_success = std::move(on_success), + on_error = std::move(on_error)]( + const uint8_t* reply, + size_t reply_size) { + std::unique_ptr response = + GetCodec().DecodeMessage(reply, reply_size); + const auto& encodable_return_value = *response; + const auto* list_return_value = + std::get_if(&encodable_return_value); + if (list_return_value) { + if (list_return_value->size() > 1) { + on_error(FlutterError(std::get(list_return_value->at(0)), + std::get(list_return_value->at(1)), + list_return_value->at(2))); + } else { + const auto& return_value = std::any_cast( + std::get(list_return_value->at(0))); + on_success(return_value); + } + } else { + on_error(CreateConnectionError(channel_name)); + } + }); +} + } // namespace firebase_app_check_windows diff --git a/packages/firebase_app_check/firebase_app_check/windows/messages.g.h b/packages/firebase_app_check/firebase_app_check/windows/messages.g.h index ab7fde9147b5..ba5134ba761f 100644 --- a/packages/firebase_app_check/firebase_app_check/windows/messages.g.h +++ b/packages/firebase_app_check/firebase_app_check/windows/messages.g.h @@ -52,6 +52,7 @@ class ErrorOr { private: friend class FirebaseAppCheckHostApi; + friend class FirebaseAppCheckFlutterApi; ErrorOr() = default; T TakeValue() && { return std::get(std::move(v_)); } @@ -86,11 +87,52 @@ class InternalAppCheckTokenResult { const ::flutter::EncodableList& list); ::flutter::EncodableList ToEncodableList() const; friend class FirebaseAppCheckHostApi; + friend class FirebaseAppCheckFlutterApi; friend class PigeonInternalCodecSerializer; std::string token_; std::optional expiration_timestamp_; }; +// Carries a minted App Check token plus the wall-clock expiry the Firebase +// SDK should associate with it. Returning the expiry alongside the token lets +// backends mint tokens with arbitrary lifetimes (short TTLs for a stricter +// security posture, longer TTLs for fewer round-trips) without the plugin +// hardcoding a refresh window. +// +// Generated class from Pigeon that represents data sent in messages. +class CustomAppCheckToken { + public: + // Constructs an object setting all fields. + explicit CustomAppCheckToken(const std::string& token, + int64_t expire_time_millis); + + // The App Check token string to send with Firebase requests. + const std::string& token() const; + void set_token(std::string_view value_arg); + + // Absolute expiry as Unix epoch milliseconds (UTC). The Firebase SDK uses + // this to decide when to refresh; a token returned with an expiry in the + // past is treated as immediately expired. + int64_t expire_time_millis() const; + void set_expire_time_millis(int64_t value_arg); + + bool operator==(const CustomAppCheckToken& other) const; + bool operator!=(const CustomAppCheckToken& other) const; + /// Returns a hash code value for the object. This method is supported for the + /// benefit of hash tables. + size_t Hash() const; + + private: + static CustomAppCheckToken FromEncodableList( + const ::flutter::EncodableList& list); + ::flutter::EncodableList ToEncodableList() const; + friend class FirebaseAppCheckHostApi; + friend class FirebaseAppCheckFlutterApi; + friend class PigeonInternalCodecSerializer; + std::string token_; + int64_t expire_time_millis_; +}; + class PigeonInternalCodecSerializer : public ::flutter::StandardCodecSerializer { public: @@ -119,6 +161,7 @@ class FirebaseAppCheckHostApi { const std::string& app_name, const std::string* android_provider, const std::string* apple_provider, const std::string* debug_token, const std::string* recaptcha_site_key, + const std::string* windows_provider, std::function reply)> result) = 0; virtual void GetToken( const std::string& app_name, bool force_refresh, @@ -154,5 +197,29 @@ class FirebaseAppCheckHostApi { protected: FirebaseAppCheckHostApi() = default; }; +// Dart-side handler invoked by the native plugin when the Firebase SDK needs +// a fresh App Check token. Implementations typically call a backend service +// (for example a Cloud Function with `enforceAppCheck: false`) that mints a +// token using the Firebase Admin SDK. The native side awaits the future, +// then hands the token to the Firebase SDK, which attaches it to subsequent +// Firebase backend requests (Firestore, Functions, Storage, Auth, RTDB). +// +// Generated class from Pigeon that represents Flutter messages that can be +// called from C++. +class FirebaseAppCheckFlutterApi { + public: + FirebaseAppCheckFlutterApi(::flutter::BinaryMessenger* binary_messenger); + FirebaseAppCheckFlutterApi(::flutter::BinaryMessenger* binary_messenger, + const std::string& message_channel_suffix); + static const ::flutter::StandardMessageCodec& GetCodec(); + void GetCustomToken( + std::function&& on_success, + std::function&& on_error); + + private: + ::flutter::BinaryMessenger* binary_messenger_; + std::string message_channel_suffix_; +}; + } // namespace firebase_app_check_windows #endif // PIGEON_MESSAGES_G_H_ diff --git a/packages/firebase_app_check/firebase_app_check_platform_interface/lib/src/method_channel/method_channel_firebase_app_check.dart b/packages/firebase_app_check/firebase_app_check_platform_interface/lib/src/method_channel/method_channel_firebase_app_check.dart index be033a61b73f..a568a909d9f1 100644 --- a/packages/firebase_app_check/firebase_app_check_platform_interface/lib/src/method_channel/method_channel_firebase_app_check.dart +++ b/packages/firebase_app_check/firebase_app_check_platform_interface/lib/src/method_channel/method_channel_firebase_app_check.dart @@ -10,14 +10,46 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import '../../firebase_app_check_platform_interface.dart'; -import '../pigeon/messages.pigeon.dart'; +import '../pigeon/messages.pigeon.dart' as pigeon; import 'utils/exception.dart'; import 'utils/provider_to_string.dart'; +class _WindowsCustomProviderFlutterApi + extends pigeon.FirebaseAppCheckFlutterApi { + _WindowsCustomProviderFlutterApi(this.appName); + + final String appName; + + @override + Future getCustomToken() async { + final provider = + MethodChannelFirebaseAppCheck._windowsCustomProviders[appName]; + if (provider == null) { + throw StateError( + 'No WindowsCustomProvider has been activated for app $appName.', + ); + } + + final token = await provider.fetchToken(); + return pigeon.CustomAppCheckToken( + token: token.token, + expireTimeMillis: token.expireTimeMillis, + ); + } +} + class MethodChannelFirebaseAppCheck extends FirebaseAppCheckPlatform { /// Create an instance of [MethodChannelFirebaseAppCheck]. MethodChannelFirebaseAppCheck({required FirebaseApp app}) : super(appInstance: app) { + final flutterApi = _windowsCustomProviderFlutterApis.putIfAbsent( + app.name, + () => _WindowsCustomProviderFlutterApi(app.name), + ); + pigeon.FirebaseAppCheckFlutterApi.setUp( + flutterApi, + messageChannelSuffix: app.name, + ); _tokenChangesListeners[app.name] = StreamController.broadcast(); _listenerRegistration = _registerTokenListener(app); } @@ -55,7 +87,11 @@ class MethodChannelFirebaseAppCheck extends FirebaseAppCheckPlatform { {}; /// The Pigeon API used for platform communication. - final FirebaseAppCheckHostApi _pigeonApi = FirebaseAppCheckHostApi(); + final pigeon.FirebaseAppCheckHostApi _pigeonApi = + pigeon.FirebaseAppCheckHostApi(); + static final Map + _windowsCustomProviderFlutterApis = {}; + static final Map _windowsCustomProviders = {}; late final Future _listenerRegistration; StreamSubscription? _subscription; bool _isDisposed = false; @@ -86,6 +122,13 @@ class MethodChannelFirebaseAppCheck extends FirebaseAppCheckPlatform { await _subscription?.cancel(); _subscription = null; await _tokenChangesListeners.remove(app.name)?.close(); + _windowsCustomProviders.remove(app.name); + if (_windowsCustomProviderFlutterApis.remove(app.name) != null) { + pigeon.FirebaseAppCheckFlutterApi.setUp( + null, + messageChannelSuffix: app.name, + ); + } _methodChannelFirebaseAppCheckInstances.remove(app.name); } @@ -112,6 +155,7 @@ class MethodChannelFirebaseAppCheck extends FirebaseAppCheckPlatform { WindowsAppCheckProvider? providerWindows, }) async { try { + _setWindowsCustomProvider(providerWindows); await _pigeonApi.activate( app.name, defaultTargetPlatform == TargetPlatform.android || kDebugMode @@ -137,12 +181,23 @@ class MethodChannelFirebaseAppCheck extends FirebaseAppCheckPlatform { providerAndroid: providerAndroid, providerApple: providerApple, ), + _getWindowsProvider(providerWindows), ); } on PlatformException catch (e, s) { convertPlatformException(e, s); } } + void _setWindowsCustomProvider( + WindowsAppCheckProvider? providerWindows, + ) { + if (providerWindows is WindowsCustomProvider) { + _windowsCustomProviders[app.name] = providerWindows; + } else { + _windowsCustomProviders.remove(app.name); + } + } + @override Future getToken(bool forceRefresh) async { try { @@ -245,3 +300,11 @@ String? _getRecaptchaSiteKey({ return null; } } + +String? _getWindowsProvider(WindowsAppCheckProvider? providerWindows) { + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows) { + return providerWindows?.type; + } + + return null; +} diff --git a/packages/firebase_app_check/firebase_app_check_platform_interface/lib/src/pigeon/messages.pigeon.dart b/packages/firebase_app_check/firebase_app_check_platform_interface/lib/src/pigeon/messages.pigeon.dart index 6033bf98471a..1dee713a9dac 100644 --- a/packages/firebase_app_check/firebase_app_check_platform_interface/lib/src/pigeon/messages.pigeon.dart +++ b/packages/firebase_app_check/firebase_app_check_platform_interface/lib/src/pigeon/messages.pigeon.dart @@ -37,6 +37,17 @@ Object? _extractReplyValueOrThrow( return replyList.firstOrNull; } +List wrapResponse( + {Object? result, PlatformException? error, bool empty = false}) { + if (empty) { + return []; + } + if (error == null) { + return [result]; + } + return [error.code, error.message, error.details]; +} + bool _deepEquals(Object? a, Object? b) { if (identical(a, b)) { return true; @@ -147,6 +158,62 @@ class InternalAppCheckTokenResult { int get hashCode => _deepHash([runtimeType, ..._toList()]); } +/// Carries a minted App Check token plus the wall-clock expiry the Firebase +/// SDK should associate with it. Returning the expiry alongside the token lets +/// backends mint tokens with arbitrary lifetimes (short TTLs for a stricter +/// security posture, longer TTLs for fewer round-trips) without the plugin +/// hardcoding a refresh window. +class CustomAppCheckToken { + CustomAppCheckToken({ + required this.token, + required this.expireTimeMillis, + }); + + /// The App Check token string to send with Firebase requests. + String token; + + /// Absolute expiry as Unix epoch milliseconds (UTC). The Firebase SDK uses + /// this to decide when to refresh; a token returned with an expiry in the + /// past is treated as immediately expired. + int expireTimeMillis; + + List _toList() { + return [ + token, + expireTimeMillis, + ]; + } + + Object encode() { + return _toList(); + } + + static CustomAppCheckToken decode(Object result) { + result as List; + return CustomAppCheckToken( + token: result[0]! as String, + expireTimeMillis: result[1]! as int, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! CustomAppCheckToken || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(token, other.token) && + _deepEquals(expireTimeMillis, other.expireTimeMillis); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); +} + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -157,6 +224,9 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is InternalAppCheckTokenResult) { buffer.putUint8(129); writeValue(buffer, value.encode()); + } else if (value is CustomAppCheckToken) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -167,6 +237,8 @@ class _PigeonCodec extends StandardMessageCodec { switch (type) { case 129: return InternalAppCheckTokenResult.decode(readValue(buffer)!); + case 130: + return CustomAppCheckToken.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -193,7 +265,8 @@ class FirebaseAppCheckHostApi { String? androidProvider, String? appleProvider, String? debugToken, - String? recaptchaSiteKey) async { + String? recaptchaSiteKey, + String? windowsProvider) async { final pigeonVar_channelName = 'dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.activate$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( @@ -201,13 +274,14 @@ class FirebaseAppCheckHostApi { pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = pigeonVar_channel - .send([ + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([ appName, androidProvider, appleProvider, debugToken, - recaptchaSiteKey + recaptchaSiteKey, + windowsProvider ]); final pigeonVar_replyList = await pigeonVar_sendFuture as List?; @@ -319,3 +393,45 @@ class FirebaseAppCheckHostApi { return pigeonVar_replyValue! as String; } } + +/// Dart-side handler invoked by the native plugin when the Firebase SDK needs +/// a fresh App Check token. Implementations typically call a backend service +/// (for example a Cloud Function with `enforceAppCheck: false`) that mints a +/// token using the Firebase Admin SDK. The native side awaits the future, +/// then hands the token to the Firebase SDK, which attaches it to subsequent +/// Firebase backend requests (Firestore, Functions, Storage, Auth, RTDB). +abstract class FirebaseAppCheckFlutterApi { + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + Future getCustomToken(); + + static void setUp( + FirebaseAppCheckFlutterApi? api, { + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) { + messageChannelSuffix = + messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckFlutterApi.getCustomToken$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + final CustomAppCheckToken output = await api.getCustomToken(); + return wrapResponse(result: output); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + } +} diff --git a/packages/firebase_app_check/firebase_app_check_platform_interface/lib/src/platform_interface/platform_interface_firebase_app_check.dart b/packages/firebase_app_check/firebase_app_check_platform_interface/lib/src/platform_interface/platform_interface_firebase_app_check.dart index 597929b7c9af..e45453104866 100644 --- a/packages/firebase_app_check/firebase_app_check_platform_interface/lib/src/platform_interface/platform_interface_firebase_app_check.dart +++ b/packages/firebase_app_check/firebase_app_check_platform_interface/lib/src/platform_interface/platform_interface_firebase_app_check.dart @@ -67,12 +67,12 @@ abstract class FirebaseAppCheckPlatform extends PlatformInterface { /// "app attest with fallback to device check" via `AppleAppCheckProvider`. /// Note: App Attest is only available on iOS 14.0+ and macOS 14.0+. /// - /// **Windows**: Only the debug provider is supported. You **must** supply a - /// debug token — the desktop C++ SDK does not auto-generate one. Either pass - /// it via `providerWindows: WindowsDebugProvider(debugToken: 'your-token')` - /// or set the `APP_CHECK_DEBUG_TOKEN` environment variable. The token must - /// first be registered in the Firebase Console under - /// *App Check → Apps → Manage debug tokens*. + /// **Windows**: Use `providerWindows` to configure either + /// [WindowsCustomProvider] for production token minting or + /// [WindowsDebugProvider] for development. The desktop C++ SDK does not + /// auto-generate debug tokens. Either pass a registered token via + /// `providerWindows: WindowsDebugProvider(debugToken: 'your-token')` or set + /// the `APP_CHECK_DEBUG_TOKEN` environment variable. /// /// ## Migration Notice /// diff --git a/packages/firebase_app_check/firebase_app_check_platform_interface/lib/src/windows_providers.dart b/packages/firebase_app_check/firebase_app_check_platform_interface/lib/src/windows_providers.dart index b6b09e55b20b..903cc5e2c5e1 100644 --- a/packages/firebase_app_check/firebase_app_check_platform_interface/lib/src/windows_providers.dart +++ b/packages/firebase_app_check/firebase_app_check_platform_interface/lib/src/windows_providers.dart @@ -4,17 +4,69 @@ /// Base class for Windows App Check providers. /// -/// On Windows, only the [WindowsDebugProvider] is supported. The Firebase C++ -/// SDK does not support platform attestation providers (such as Play Integrity -/// or DeviceCheck) on desktop platforms. +/// The Firebase C++ SDK does not ship native platform attestation providers +/// (such as Play Integrity or DeviceCheck) on desktop, so Windows supports +/// [WindowsDebugProvider] for development and [WindowsCustomProvider] for +/// production builds that mint tokens via a backend. abstract class WindowsAppCheckProvider { final String type; const WindowsAppCheckProvider(this.type); } +/// Carries a minted App Check token and its expiry. +class CustomAppCheckToken { + /// Creates a custom App Check token result. + const CustomAppCheckToken({ + required this.token, + required this.expireTimeMillis, + }); + + /// The App Check token string to send with Firebase requests. + final String token; + + /// Absolute expiry as Unix epoch milliseconds (UTC). + final int expireTimeMillis; +} + +/// Custom provider for Windows production builds. +/// +/// When activated, the Windows C++ plugin registers a custom +/// `AppCheckProvider` that calls [fetchToken] each time the Firebase SDK needs +/// a fresh App Check token. The callback is expected to call a backend service +/// (typically a Cloud Function with `enforceAppCheck: false`) that mints a +/// valid App Check token using the Firebase Admin SDK, then return both the +/// token and its expiry. +/// +/// Register the callback before any Firebase operations that require App Check: +/// +/// ```dart +/// await FirebaseAppCheck.instance.activate( +/// providerWindows: WindowsCustomProvider( +/// fetchToken: () async { +/// // Call your backend, e.g. a callable Cloud Function that uses +/// // admin.appCheck().createToken(windowsAppId). +/// final response = await myBackend.mintAppCheckToken(); +/// return CustomAppCheckToken( +/// token: response.token, +/// expireTimeMillis: response.expireTimeMillis, +/// ); +/// }, +/// ), +/// ); +/// ``` +class WindowsCustomProvider extends WindowsAppCheckProvider { + /// Creates a Windows custom provider. + const WindowsCustomProvider({ + required this.fetchToken, + }) : super('custom'); + + /// Callback invoked when the native Firebase SDK needs a fresh token. + final Future Function() fetchToken; +} + /// Debug provider for Windows. /// -/// This is the **only** provider available on Windows. Unlike mobile platforms, +/// Intended for development and local testing only. Unlike mobile platforms, /// the desktop C++ SDK does **not** auto-generate a debug token. You must /// supply one explicitly. /// diff --git a/packages/firebase_app_check/firebase_app_check_platform_interface/pigeons/messages.dart b/packages/firebase_app_check/firebase_app_check_platform_interface/pigeons/messages.dart index bdd4d0673002..fc2eccf8563a 100644 --- a/packages/firebase_app_check/firebase_app_check_platform_interface/pigeons/messages.dart +++ b/packages/firebase_app_check/firebase_app_check_platform_interface/pigeons/messages.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +// ignore_for_file: one_member_abstracts + import 'package:pigeon/pigeon.dart'; class InternalAppCheckTokenResult { @@ -41,6 +43,7 @@ abstract class FirebaseAppCheckHostApi { String? appleProvider, String? debugToken, String? recaptchaSiteKey, + String? windowsProvider, ); @async @@ -64,3 +67,35 @@ abstract class FirebaseAppCheckHostApi { @async String getLimitedUseAppCheckToken(String appName); } + +/// Carries a minted App Check token plus the wall-clock expiry the Firebase +/// SDK should associate with it. Returning the expiry alongside the token lets +/// backends mint tokens with arbitrary lifetimes (short TTLs for a stricter +/// security posture, longer TTLs for fewer round-trips) without the plugin +/// hardcoding a refresh window. +class CustomAppCheckToken { + CustomAppCheckToken({ + required this.token, + required this.expireTimeMillis, + }); + + /// The App Check token string to send with Firebase requests. + final String token; + + /// Absolute expiry as Unix epoch milliseconds (UTC). The Firebase SDK uses + /// this to decide when to refresh; a token returned with an expiry in the + /// past is treated as immediately expired. + final int expireTimeMillis; +} + +/// Dart-side handler invoked by the native plugin when the Firebase SDK needs +/// a fresh App Check token. Implementations typically call a backend service +/// (for example a Cloud Function with `enforceAppCheck: false`) that mints a +/// token using the Firebase Admin SDK. The native side awaits the future, +/// then hands the token to the Firebase SDK, which attaches it to subsequent +/// Firebase backend requests (Firestore, Functions, Storage, Auth, RTDB). +@FlutterApi() +abstract class FirebaseAppCheckFlutterApi { + @async + CustomAppCheckToken getCustomToken(); +} diff --git a/packages/firebase_app_check/firebase_app_check_platform_interface/test/method_channel_tests/method_channel_firebase_app_check_test.dart b/packages/firebase_app_check/firebase_app_check_platform_interface/test/method_channel_tests/method_channel_firebase_app_check_test.dart index e64bff5f1f9c..df68eb364b13 100644 --- a/packages/firebase_app_check/firebase_app_check_platform_interface/test/method_channel_tests/method_channel_firebase_app_check_test.dart +++ b/packages/firebase_app_check/firebase_app_check_platform_interface/test/method_channel_tests/method_channel_firebase_app_check_test.dart @@ -3,11 +3,12 @@ // found in the LICENSE file. import 'package:firebase_app_check_platform_interface/firebase_app_check_platform_interface.dart'; -import 'package:firebase_app_check_platform_interface/src/pigeon/messages.pigeon.dart'; +import 'package:firebase_app_check_platform_interface/src/pigeon/messages.pigeon.dart' + as pigeon; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; import '../mock.dart'; @@ -15,6 +16,9 @@ void main() { setupFirebaseAppCheckMocks(); late FirebaseApp secondaryApp; + const activateChannelName = + 'dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.activate'; + group('$MethodChannelFirebaseAppCheck', () { setUpAll(() async { await Firebase.initializeApp(); @@ -33,7 +37,7 @@ void main() { debugDefaultTargetPlatformOverride = null; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMessageHandler( - 'dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.activate', + activateChannelName, null, ); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -75,7 +79,8 @@ void main() { .setMockMessageHandler( 'dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.getToken', (ByteData? message) async { - return FirebaseAppCheckHostApi.pigeonChannelCodec.encodeMessage( + return pigeon.FirebaseAppCheckHostApi.pigeonChannelCodec + .encodeMessage( ['test-token'], ); }, @@ -84,9 +89,10 @@ void main() { .setMockMessageHandler( 'dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.getTokenResult', (ByteData? message) async { - return FirebaseAppCheckHostApi.pigeonChannelCodec.encodeMessage( + return pigeon.FirebaseAppCheckHostApi.pigeonChannelCodec + .encodeMessage( [ - InternalAppCheckTokenResult( + pigeon.InternalAppCheckTokenResult( token: 'test-token', expirationTimestamp: expirationTimestamp, ), @@ -122,13 +128,14 @@ void main() { final calls = >[]; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMessageHandler( - 'dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.activate', + activateChannelName, (ByteData? message) async { calls.add( - FirebaseAppCheckHostApi.pigeonChannelCodec.decodeMessage(message)! - as List, + pigeon.FirebaseAppCheckHostApi.pigeonChannelCodec + .decodeMessage(message)! as List, ); - return FirebaseAppCheckHostApi.pigeonChannelCodec.encodeMessage( + return pigeon.FirebaseAppCheckHostApi.pigeonChannelCodec + .encodeMessage( [], ); }, @@ -154,13 +161,14 @@ void main() { final calls = >[]; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMessageHandler( - 'dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.activate', + activateChannelName, (ByteData? message) async { calls.add( - FirebaseAppCheckHostApi.pigeonChannelCodec.decodeMessage(message)! - as List, + pigeon.FirebaseAppCheckHostApi.pigeonChannelCodec + .decodeMessage(message)! as List, ); - return FirebaseAppCheckHostApi.pigeonChannelCodec.encodeMessage( + return pigeon.FirebaseAppCheckHostApi.pigeonChannelCodec + .encodeMessage( [], ); }, @@ -180,6 +188,96 @@ void main() { expect(calls, hasLength(1)); expect(calls.single[3], 'android-debug-token'); }); + + group('on Windows', () { + late BasicMessageChannel activateChannel; + late List activateMessages; + + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + activateChannel = const BasicMessageChannel( + activateChannelName, + pigeon.FirebaseAppCheckHostApi.pigeonChannelCodec, + ); + activateMessages = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockDecodedMessageHandler(activateChannel, + (Object? message) async { + activateMessages.add(message); + return []; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockDecodedMessageHandler(activateChannel, null); + }); + + test('forwards WindowsCustomProvider over Pigeon', () async { + final appCheck = MethodChannelFirebaseAppCheck(app: secondaryApp); + + await appCheck.activate( + providerWindows: WindowsCustomProvider( + fetchToken: () async => const CustomAppCheckToken( + token: 'app-check-token', + expireTimeMillis: 1735689600000, + ), + ), + ); + + expect(activateMessages, hasLength(1)); + expect(activateMessages.single, [ + 'secondaryApp', + 'playIntegrity', + 'deviceCheck', + null, + null, + 'custom', + ]); + }); + + test( + 'forwards WindowsDebugProvider with an explicit debug token over Pigeon', + () async { + final appCheck = MethodChannelFirebaseAppCheck(app: secondaryApp); + + await appCheck.activate( + providerWindows: const WindowsDebugProvider( + debugToken: 'debug-token', + ), + ); + + expect(activateMessages, hasLength(1)); + expect(activateMessages.single, [ + 'secondaryApp', + 'playIntegrity', + 'deviceCheck', + 'debug-token', + null, + 'debug', + ]); + }); + + test( + 'forwards WindowsDebugProvider with no explicit token as null ' + '(env-var fallback path)', () async { + final appCheck = MethodChannelFirebaseAppCheck(app: secondaryApp); + + await appCheck.activate( + providerWindows: const WindowsDebugProvider(), + ); + + expect(activateMessages, hasLength(1)); + expect(activateMessages.single, [ + 'secondaryApp', + 'playIntegrity', + 'deviceCheck', + null, + null, + 'debug', + ]); + }); + }); }); group('activate() with Recaptcha', () { @@ -190,7 +288,7 @@ void main() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMessageHandler( - 'dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.activate', + activateChannelName, (message) async { final list = const StandardMessageCodec().decodeMessage(message) as List; @@ -218,7 +316,7 @@ void main() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMessageHandler( - 'dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.activate', + activateChannelName, (message) async { final list = const StandardMessageCodec().decodeMessage(message) as List; @@ -239,4 +337,162 @@ void main() { }); }); }); + + group('Windows custom token callback', () { + BasicMessageChannel flutterApiChannelFor(String appName) { + return BasicMessageChannel( + 'dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckFlutterApi.getCustomToken.$appName', + pigeon.FirebaseAppCheckFlutterApi.pigeonChannelCodec, + ); + } + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler( + activateChannelName, + (ByteData? message) async { + return pigeon.FirebaseAppCheckHostApi.pigeonChannelCodec + .encodeMessage( + [], + ); + }, + ); + }); + + tearDown(() { + pigeon.FirebaseAppCheckFlutterApi.setUp( + null, + messageChannelSuffix: Firebase.app().name, + ); + pigeon.FirebaseAppCheckFlutterApi.setUp( + null, + messageChannelSuffix: secondaryApp.name, + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler( + activateChannelName, + null, + ); + }); + + test('returns tokens from the active WindowsCustomProvider fetchToken', + () async { + const token = CustomAppCheckToken( + token: 'app-check-token', + expireTimeMillis: 1735689600000, + ); + final appCheck = MethodChannelFirebaseAppCheck(app: secondaryApp); + + await appCheck.activate( + providerWindows: WindowsCustomProvider( + fetchToken: () async => token, + ), + ); + + final flutterApiChannel = flutterApiChannelFor(secondaryApp.name); + final replyData = await TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .handlePlatformMessage( + flutterApiChannel.name, + flutterApiChannel.codec.encodeMessage(null), + null, + ); + final reply = + flutterApiChannel.codec.decodeMessage(replyData) as List?; + + final customToken = reply!.single! as pigeon.CustomAppCheckToken; + expect(customToken.token, 'app-check-token'); + expect(customToken.expireTimeMillis, 1735689600000); + }); + + test('uses the provider registered for the requested app', () async { + final defaultAppCheck = + MethodChannelFirebaseAppCheck(app: Firebase.app()); + final secondaryAppCheck = + MethodChannelFirebaseAppCheck(app: secondaryApp); + + await defaultAppCheck.activate( + providerWindows: WindowsCustomProvider( + fetchToken: () async => const CustomAppCheckToken( + token: 'default-app-token', + expireTimeMillis: 1735689600000, + ), + ), + ); + await secondaryAppCheck.activate( + providerWindows: WindowsCustomProvider( + fetchToken: () async => const CustomAppCheckToken( + token: 'secondary-app-token', + expireTimeMillis: 1735689700000, + ), + ), + ); + + final defaultChannel = flutterApiChannelFor(Firebase.app().name); + final secondaryChannel = flutterApiChannelFor(secondaryApp.name); + + final defaultReplyData = await TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .handlePlatformMessage( + defaultChannel.name, + defaultChannel.codec.encodeMessage(null), + null, + ); + final secondaryReplyData = await TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .handlePlatformMessage( + secondaryChannel.name, + secondaryChannel.codec.encodeMessage(null), + null, + ); + + final defaultReply = defaultChannel.codec.decodeMessage(defaultReplyData) + as List?; + final secondaryReply = secondaryChannel.codec + .decodeMessage(secondaryReplyData) as List?; + + final defaultToken = defaultReply!.single! as pigeon.CustomAppCheckToken; + final secondaryToken = + secondaryReply!.single! as pigeon.CustomAppCheckToken; + + expect(defaultToken.token, 'default-app-token'); + expect(defaultToken.expireTimeMillis, 1735689600000); + expect(secondaryToken.token, 'secondary-app-token'); + expect(secondaryToken.expireTimeMillis, 1735689700000); + }); + + test('returns a PlatformException envelope when fetchToken throws', + () async { + final appCheck = MethodChannelFirebaseAppCheck(app: secondaryApp); + + await appCheck.activate( + providerWindows: WindowsCustomProvider( + fetchToken: () async { + throw PlatformException( + code: 'token-error', + message: 'Failed to mint App Check token', + details: {'source': 'test'}, + ); + }, + ), + ); + + final flutterApiChannel = flutterApiChannelFor(secondaryApp.name); + final replyData = await TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .handlePlatformMessage( + flutterApiChannel.name, + flutterApiChannel.codec.encodeMessage(null), + null, + ); + final reply = + flutterApiChannel.codec.decodeMessage(replyData) as List?; + + expect(reply, [ + 'token-error', + 'Failed to mint App Check token', + {'source': 'test'}, + ]); + }); + }); } From 2d81d432f2067e36cd7d2df42601722ab8f5a5ec Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Tue, 18 Aug 2026 15:53:30 +0000 Subject: [PATCH 2/3] style(app_check,apple): format generated Pigeon Swift bindings --- .../FirebaseAppCheckMessages.g.swift | 47 +++++++++++-------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/packages/firebase_app_check/firebase_app_check/ios/firebase_app_check/Sources/firebase_app_check/FirebaseAppCheckMessages.g.swift b/packages/firebase_app_check/firebase_app_check/ios/firebase_app_check/Sources/firebase_app_check/FirebaseAppCheckMessages.g.swift index 26d4fe428c0e..d3982d71b065 100644 --- a/packages/firebase_app_check/firebase_app_check/ios/firebase_app_check/Sources/firebase_app_check/FirebaseAppCheckMessages.g.swift +++ b/packages/firebase_app_check/firebase_app_check/ios/firebase_app_check/Sources/firebase_app_check/FirebaseAppCheckMessages.g.swift @@ -313,19 +313,24 @@ class FirebaseAppCheckMessagesPigeonCodec: FlutterStandardMessageCodec, @uncheck /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol FirebaseAppCheckHostApi { - func activate(appName: String, androidProvider: String?, appleProvider: String?, - debugToken: String?, - recaptchaSiteKey: String?, windowsProvider: String?, - completion: @escaping (Result) -> Void) - func getToken(appName: String, forceRefresh: Bool, - completion: @escaping (Result) -> Void) - func getTokenResult(appName: String, forceRefresh: Bool, - completion: @escaping (Result) -> Void) - func setTokenAutoRefreshEnabled(appName: String, isTokenAutoRefreshEnabled: Bool, - completion: @escaping (Result) -> Void) + func activate( + appName: String, androidProvider: String?, appleProvider: String?, + debugToken: String?, + recaptchaSiteKey: String?, windowsProvider: String?, + completion: @escaping (Result) -> Void) + func getToken( + appName: String, forceRefresh: Bool, + completion: @escaping (Result) -> Void) + func getTokenResult( + appName: String, forceRefresh: Bool, + completion: @escaping (Result) -> Void) + func setTokenAutoRefreshEnabled( + appName: String, isTokenAutoRefreshEnabled: Bool, + completion: @escaping (Result) -> Void) func registerTokenListener(appName: String, completion: @escaping (Result) -> Void) - func getLimitedUseAppCheckToken(appName: String, - completion: @escaping (Result) -> Void) + func getLimitedUseAppCheckToken( + appName: String, + completion: @escaping (Result) -> Void) } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -336,12 +341,14 @@ class FirebaseAppCheckHostApiSetup { /// Sets up an instance of `FirebaseAppCheckHostApi` to handle messages through the /// `binaryMessenger`. - static func setUp(binaryMessenger: FlutterBinaryMessenger, api: FirebaseAppCheckHostApi?, - messageChannelSuffix: String = "") { + static func setUp( + binaryMessenger: FlutterBinaryMessenger, api: FirebaseAppCheckHostApi?, + messageChannelSuffix: String = "" + ) { let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" let activateChannel = FlutterBasicMessageChannel( name: - "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.activate\(channelSuffix)", + "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.activate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec ) if let api { @@ -371,7 +378,7 @@ class FirebaseAppCheckHostApiSetup { } let getTokenChannel = FlutterBasicMessageChannel( name: - "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.getToken\(channelSuffix)", + "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.getToken\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec ) if let api { @@ -393,7 +400,7 @@ class FirebaseAppCheckHostApiSetup { } let getTokenResultChannel = FlutterBasicMessageChannel( name: - "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.getTokenResult\(channelSuffix)", + "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.getTokenResult\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec ) if let api { @@ -415,7 +422,7 @@ class FirebaseAppCheckHostApiSetup { } let setTokenAutoRefreshEnabledChannel = FlutterBasicMessageChannel( name: - "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.setTokenAutoRefreshEnabled\(channelSuffix)", + "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.setTokenAutoRefreshEnabled\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec ) if let api { @@ -439,7 +446,7 @@ class FirebaseAppCheckHostApiSetup { } let registerTokenListenerChannel = FlutterBasicMessageChannel( name: - "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.registerTokenListener\(channelSuffix)", + "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.registerTokenListener\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec ) if let api { @@ -460,7 +467,7 @@ class FirebaseAppCheckHostApiSetup { } let getLimitedUseAppCheckTokenChannel = FlutterBasicMessageChannel( name: - "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.getLimitedUseAppCheckToken\(channelSuffix)", + "dev.flutter.pigeon.firebase_app_check_platform_interface.FirebaseAppCheckHostApi.getLimitedUseAppCheckToken\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec ) if let api { From 31361e69a4785fc8e11c7dcf27d1d4aec1d6f4fc Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Wed, 19 Aug 2026 07:50:08 +0000 Subject: [PATCH 3/3] test(app_check,windows): add custom provider e2e coverage --- .../example/integration_test/e2e_test.dart | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/packages/firebase_app_check/firebase_app_check/example/integration_test/e2e_test.dart b/packages/firebase_app_check/firebase_app_check/example/integration_test/e2e_test.dart index db9f90b0508f..3a199fb85cf6 100644 --- a/packages/firebase_app_check/firebase_app_check/example/integration_test/e2e_test.dart +++ b/packages/firebase_app_check/firebase_app_check/example/integration_test/e2e_test.dart @@ -221,6 +221,128 @@ void main() { 'APP_CHECK_APPLE_DEBUG_TOKEN dart-defines.' : null, ); + + group( + 'WindowsCustomProvider', + () { + int farFutureExpireTimeMillis() { + return DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch; + } + + test( + 'getToken invokes fetchToken and returns the synthetic token', + () async { + var fetchCount = 0; + const token = 'windows-custom-e2e-token'; + final expireTimeMillis = farFutureExpireTimeMillis(); + + await FirebaseAppCheck.instance.activate( + providerWindows: WindowsCustomProvider( + fetchToken: () async { + fetchCount++; + return CustomAppCheckToken( + token: token, + expireTimeMillis: expireTimeMillis, + ); + }, + ), + ); + + expect( + await FirebaseAppCheck.instance.getToken(true), + token, + ); + expect( + fetchCount, + greaterThan(0), + reason: 'native must call GetCustomToken so fetchToken runs; ' + 'a zero count means the debug provider path was used', + ); + }, + ); + + test( + 'isolates fetchToken by Firebase app name', + () async { + var defaultFetchCount = 0; + var secondaryFetchCount = 0; + const defaultToken = 'windows-custom-default-app-token'; + const secondaryToken = 'windows-custom-secondary-app-token'; + final expireTimeMillis = farFutureExpireTimeMillis(); + + final secondaryApp = await Firebase.initializeApp( + name: 'app-check-secondary', + options: DefaultFirebaseOptions.currentPlatform, + ); + addTearDown(secondaryApp.delete); + + final defaultAppCheck = FirebaseAppCheck.instance; + final secondaryAppCheck = FirebaseAppCheck.instanceFor( + app: secondaryApp, + ); + + await defaultAppCheck.activate( + providerWindows: WindowsCustomProvider( + fetchToken: () async { + defaultFetchCount++; + return CustomAppCheckToken( + token: defaultToken, + expireTimeMillis: expireTimeMillis, + ); + }, + ), + ); + await secondaryAppCheck.activate( + providerWindows: WindowsCustomProvider( + fetchToken: () async { + secondaryFetchCount++; + return CustomAppCheckToken( + token: secondaryToken, + expireTimeMillis: expireTimeMillis, + ); + }, + ), + ); + + expect(await defaultAppCheck.getToken(true), defaultToken); + expect(defaultFetchCount, greaterThan(0)); + expect( + secondaryFetchCount, + 0, + reason: 'fetching the default app must not invoke the ' + 'secondary app fetchToken', + ); + + final defaultCountAfterDefaultFetch = defaultFetchCount; + expect( + await secondaryAppCheck.getToken(true), + secondaryToken, + ); + expect(secondaryFetchCount, greaterThan(0)); + expect( + defaultFetchCount, + defaultCountAfterDefaultFetch, + reason: 'fetching the secondary app must not invoke the ' + 'default app fetchToken', + ); + + final secondaryCountAfterSecondaryFetch = secondaryFetchCount; + expect(await defaultAppCheck.getToken(true), defaultToken); + expect( + defaultFetchCount, + greaterThan(defaultCountAfterDefaultFetch), + ); + expect( + secondaryFetchCount, + secondaryCountAfterSecondaryFetch, + ); + }, + ); + }, + skip: kIsWeb || defaultTargetPlatform != TargetPlatform.windows, + ); }, ); }