Skip to content

Commit 6706aa8

Browse files
authored
[FAL] Add limited-use token support (#15099)
1 parent e596222 commit 6706aa8

File tree

12 files changed

+179
-26
lines changed

12 files changed

+179
-26
lines changed

FirebaseAI/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
# 12.2.0
22
- [feature] Added support for returning thought summaries, which are synthesized
33
versions of a model's internal reasoning process. (#15096)
4+
- [feature] Added a new configuration option to use limited-use App
5+
Check tokens for attesting Firebase AI Logic requests. This enhances
6+
security against replay attacks. To use this feature, configure it
7+
explicitly via the new `useLimitedUseAppCheckTokens` parameter when
8+
initializing `FirebaseAI`. We recommend migrating to limited-use
9+
tokens now, so your app will be ready to take advantage of replay
10+
protection when it becomes available for Firebase AI Logic. (#15099)
411

512
# 12.0.0
613
- [feature] Added support for Grounding with Google Search. (#15014)

FirebaseAI/Sources/FirebaseAI.swift

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,33 @@ public final class FirebaseAI: Sendable {
3232
/// ``FirebaseApp``.
3333
/// - backend: The backend API for the Firebase AI SDK; if not specified, uses the default
3434
/// ``Backend/googleAI()`` (Gemini Developer API).
35+
/// - useLimitedUseAppCheckTokens: When sending tokens to the backend, this option enables
36+
/// the usage of App Check's limited-use tokens instead of the standard cached tokens.
37+
///
38+
/// A new limited-use tokens will be generated for each request; providing a smaller attack
39+
/// surface for malicious parties to hijack tokens. When used alongside replay protection,
40+
/// limited-use tokens are also _consumed_ after each request, ensuring they can't be used
41+
/// again.
42+
///
43+
/// _This flag is set to `false` by default._
44+
///
45+
/// > Important: Replay protection is not currently supported for the FirebaseAI backend.
46+
/// > While this feature is being developed, you can still migrate to using
47+
/// > limited-use tokens. Because limited-use tokens are backwards compatible, you can still
48+
/// > use them without replay protection. Due to their shorter TTL over standard App Check
49+
/// > tokens, they still provide a security benefit.
50+
/// >
51+
/// > Migrating to limited-use tokens sooner minimizes disruption when support for replay
52+
/// > protection is added.
3553
/// - Returns: A `FirebaseAI` instance, configured with the custom `FirebaseApp`.
3654
public static func firebaseAI(app: FirebaseApp? = nil,
37-
backend: Backend = .googleAI()) -> FirebaseAI {
55+
backend: Backend = .googleAI(),
56+
useLimitedUseAppCheckTokens: Bool = false) -> FirebaseAI {
3857
let instance = createInstance(
3958
app: app,
4059
location: backend.location,
41-
apiConfig: backend.apiConfig
60+
apiConfig: backend.apiConfig,
61+
useLimitedUseAppCheckTokens: useLimitedUseAppCheckTokens
4262
)
4363
// Verify that the `FirebaseAI` instance is always configured with the production endpoint since
4464
// this is the public API surface for creating an instance.
@@ -90,7 +110,8 @@ public final class FirebaseAI: Sendable {
90110
tools: tools,
91111
toolConfig: toolConfig,
92112
systemInstruction: systemInstruction,
93-
requestOptions: requestOptions
113+
requestOptions: requestOptions,
114+
useLimitedUseAppCheckTokens: useLimitedUseAppCheckTokens
94115
)
95116
}
96117

@@ -126,7 +147,8 @@ public final class FirebaseAI: Sendable {
126147
apiConfig: apiConfig,
127148
generationConfig: generationConfig,
128149
safetySettings: safetySettings,
129-
requestOptions: requestOptions
150+
requestOptions: requestOptions,
151+
useLimitedUseAppCheckTokens: useLimitedUseAppCheckTokens
130152
)
131153
}
132154

@@ -141,6 +163,8 @@ public final class FirebaseAI: Sendable {
141163

142164
let apiConfig: APIConfig
143165

166+
let useLimitedUseAppCheckTokens: Bool
167+
144168
/// A map of active `FirebaseAI` instances keyed by the `FirebaseApp` name and the `location`,
145169
/// in the format `appName:location`.
146170
private nonisolated(unsafe) static var instances: [InstanceKey: FirebaseAI] = [:]
@@ -156,7 +180,8 @@ public final class FirebaseAI: Sendable {
156180
)
157181

158182
static func createInstance(app: FirebaseApp?, location: String?,
159-
apiConfig: APIConfig) -> FirebaseAI {
183+
apiConfig: APIConfig,
184+
useLimitedUseAppCheckTokens: Bool) -> FirebaseAI {
160185
guard let app = app ?? FirebaseApp.app() else {
161186
fatalError("No instance of the default Firebase app was found.")
162187
}
@@ -166,16 +191,27 @@ public final class FirebaseAI: Sendable {
166191
// Unlock before the function returns.
167192
defer { os_unfair_lock_unlock(&instancesLock) }
168193

169-
let instanceKey = InstanceKey(appName: app.name, location: location, apiConfig: apiConfig)
194+
let instanceKey = InstanceKey(
195+
appName: app.name,
196+
location: location,
197+
apiConfig: apiConfig,
198+
useLimitedUseAppCheckTokens: useLimitedUseAppCheckTokens
199+
)
170200
if let instance = instances[instanceKey] {
171201
return instance
172202
}
173-
let newInstance = FirebaseAI(app: app, location: location, apiConfig: apiConfig)
203+
let newInstance = FirebaseAI(
204+
app: app,
205+
location: location,
206+
apiConfig: apiConfig,
207+
useLimitedUseAppCheckTokens: useLimitedUseAppCheckTokens
208+
)
174209
instances[instanceKey] = newInstance
175210
return newInstance
176211
}
177212

178-
init(app: FirebaseApp, location: String?, apiConfig: APIConfig) {
213+
init(app: FirebaseApp, location: String?, apiConfig: APIConfig,
214+
useLimitedUseAppCheckTokens: Bool) {
179215
guard let projectID = app.options.projectID else {
180216
fatalError("The Firebase app named \"\(app.name)\" has no project ID in its configuration.")
181217
}
@@ -195,6 +231,7 @@ public final class FirebaseAI: Sendable {
195231
)
196232
self.apiConfig = apiConfig
197233
self.location = location
234+
self.useLimitedUseAppCheckTokens = useLimitedUseAppCheckTokens
198235
}
199236

200237
func modelResourceName(modelName: String) -> String {
@@ -249,5 +286,6 @@ public final class FirebaseAI: Sendable {
249286
let appName: String
250287
let location: String?
251288
let apiConfig: APIConfig
289+
let useLimitedUseAppCheckTokens: Bool
252290
}
253291
}

FirebaseAI/Sources/GenerativeAIService.swift

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,12 @@ struct GenerativeAIService {
3030

3131
private let urlSession: URLSession
3232

33-
init(firebaseInfo: FirebaseInfo, urlSession: URLSession) {
33+
private let useLimitedUseAppCheckTokens: Bool
34+
35+
init(firebaseInfo: FirebaseInfo, urlSession: URLSession, useLimitedUseAppCheckTokens: Bool) {
3436
self.firebaseInfo = firebaseInfo
3537
self.urlSession = urlSession
38+
self.useLimitedUseAppCheckTokens = useLimitedUseAppCheckTokens
3639
}
3740

3841
func loadRequest<T: GenerativeAIRequest>(request: T) async throws -> T.Response {
@@ -177,7 +180,7 @@ struct GenerativeAIService {
177180
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
178181

179182
if let appCheck = firebaseInfo.appCheck {
180-
let tokenResult = await appCheck.getToken(forcingRefresh: false)
183+
let tokenResult = try await fetchAppCheckToken(appCheck: appCheck)
181184
urlRequest.setValue(tokenResult.token, forHTTPHeaderField: "X-Firebase-AppCheck")
182185
if let error = tokenResult.error {
183186
AILog.error(
@@ -207,6 +210,53 @@ struct GenerativeAIService {
207210
return urlRequest
208211
}
209212

213+
private func fetchAppCheckToken(appCheck: AppCheckInterop) async throws
214+
-> FIRAppCheckTokenResultInterop {
215+
if useLimitedUseAppCheckTokens {
216+
if let token = await getLimitedUseAppCheckToken(appCheck: appCheck) {
217+
return token
218+
}
219+
220+
let errorMessage =
221+
"The provided App Check token provider doesn't implement getLimitedUseToken(), but requireLimitedUseTokens was enabled."
222+
223+
#if Debug
224+
fatalError(errorMessage)
225+
#else
226+
throw NSError(
227+
domain: "\(Constants.baseErrorDomain).\(Self.self)",
228+
code: AILog.MessageCode.appCheckTokenFetchFailed.rawValue,
229+
userInfo: [NSLocalizedDescriptionKey: errorMessage]
230+
)
231+
#endif
232+
}
233+
234+
return await appCheck.getToken(forcingRefresh: false)
235+
}
236+
237+
private func getLimitedUseAppCheckToken(appCheck: AppCheckInterop) async
238+
-> FIRAppCheckTokenResultInterop? {
239+
// At the moment, `await` doesn’t get along with Objective-C’s optional protocol methods.
240+
await withCheckedContinuation { (continuation: CheckedContinuation<
241+
FIRAppCheckTokenResultInterop?,
242+
Never
243+
>) in
244+
guard
245+
useLimitedUseAppCheckTokens,
246+
// `getLimitedUseToken(completion:)` is an optional protocol method. Optional binding
247+
// is performed to make sure `continuation` is called even if the method’s not implemented.
248+
let limitedUseTokenClosure = appCheck.getLimitedUseToken
249+
else {
250+
return continuation.resume(returning: nil)
251+
}
252+
253+
limitedUseTokenClosure { tokenResult in
254+
// The placeholder token should be used in the case of App Check error.
255+
continuation.resume(returning: tokenResult)
256+
}
257+
}
258+
}
259+
210260
private func httpResponse(urlResponse: URLResponse) throws -> HTTPURLResponse {
211261
// The following condition should always be true: "Whenever you make HTTP URL load requests, any
212262
// response objects you get back from the URLSession, NSURLConnection, or NSURLDownload class

FirebaseAI/Sources/GenerativeModel.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ public final class GenerativeModel: Sendable {
7676
/// only text content is supported.
7777
/// - requestOptions: Configuration parameters for sending requests to the backend.
7878
/// - urlSession: The `URLSession` to use for requests; defaults to `URLSession.shared`.
79+
/// - useLimitedUseAppCheckTokens: Use App Check's limited-use tokens instead of the standard
80+
/// cached tokens.
7981
init(modelName: String,
8082
modelResourceName: String,
8183
firebaseInfo: FirebaseInfo,
@@ -86,13 +88,15 @@ public final class GenerativeModel: Sendable {
8688
toolConfig: ToolConfig? = nil,
8789
systemInstruction: ModelContent? = nil,
8890
requestOptions: RequestOptions,
89-
urlSession: URLSession = GenAIURLSession.default) {
91+
urlSession: URLSession = GenAIURLSession.default,
92+
useLimitedUseAppCheckTokens: Bool = false) {
9093
self.modelName = modelName
9194
self.modelResourceName = modelResourceName
9295
self.apiConfig = apiConfig
9396
generativeAIService = GenerativeAIService(
9497
firebaseInfo: firebaseInfo,
95-
urlSession: urlSession
98+
urlSession: urlSession,
99+
useLimitedUseAppCheckTokens: useLimitedUseAppCheckTokens
96100
)
97101
self.generationConfig = generationConfig
98102
self.safetySettings = safetySettings

FirebaseAI/Sources/Types/Public/Imagen/ImagenModel.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,14 @@ public final class ImagenModel {
5353
generationConfig: ImagenGenerationConfig?,
5454
safetySettings: ImagenSafetySettings?,
5555
requestOptions: RequestOptions,
56-
urlSession: URLSession = GenAIURLSession.default) {
56+
urlSession: URLSession = GenAIURLSession.default,
57+
useLimitedUseAppCheckTokens: Bool = false) {
5758
self.modelResourceName = modelResourceName
5859
self.apiConfig = apiConfig
5960
generativeAIService = GenerativeAIService(
6061
firebaseInfo: firebaseInfo,
61-
urlSession: urlSession
62+
urlSession: urlSession,
63+
useLimitedUseAppCheckTokens: useLimitedUseAppCheckTokens
6264
)
6365
self.generationConfig = generationConfig
6466
self.safetySettings = safetySettings

FirebaseAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@ extension FirebaseAI {
121121
return FirebaseAI.createInstance(
122122
app: instanceConfig.app,
123123
location: location,
124-
apiConfig: instanceConfig.apiConfig
124+
apiConfig: instanceConfig.apiConfig,
125+
useLimitedUseAppCheckTokens: false
125126
)
126127
case .googleAI:
127128
assert(
@@ -131,7 +132,8 @@ extension FirebaseAI {
131132
return FirebaseAI.createInstance(
132133
app: instanceConfig.app,
133134
location: nil,
134-
apiConfig: instanceConfig.apiConfig
135+
apiConfig: instanceConfig.apiConfig,
136+
useLimitedUseAppCheckTokens: false
135137
)
136138
}
137139
}

FirebaseAI/Tests/Unit/Fakes/AppCheckInteropFake.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ class AppCheckInteropFake: NSObject, AppCheckInterop {
4040
return AppCheckTokenResultInteropFake(token: token, error: error)
4141
}
4242

43+
func getLimitedUseToken() async -> any FIRAppCheckTokenResultInterop {
44+
return AppCheckTokenResultInteropFake(token: "limited_use_\(token)", error: error)
45+
}
46+
4347
func tokenDidChangeNotificationName() -> String {
4448
fatalError("\(#function) not implemented.")
4549
}
@@ -52,9 +56,10 @@ class AppCheckInteropFake: NSObject, AppCheckInterop {
5256
fatalError("\(#function) not implemented.")
5357
}
5458

55-
private class AppCheckTokenResultInteropFake: NSObject, FIRAppCheckTokenResultInterop {
56-
var token: String
57-
var error: Error?
59+
private class AppCheckTokenResultInteropFake: NSObject, FIRAppCheckTokenResultInterop,
60+
@unchecked Sendable {
61+
let token: String
62+
let error: Error?
5863

5964
init(token: String, error: Error?) {
6065
self.token = token

FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,31 @@ final class GenerativeModelVertexAITests: XCTestCase {
501501
_ = try await model.generateContent(testPrompt)
502502
}
503503

504+
func testGenerateContent_appCheck_validToken_limitedUse() async throws {
505+
let appCheckToken = "test-valid-token"
506+
model = GenerativeModel(
507+
modelName: testModelName,
508+
modelResourceName: testModelResourceName,
509+
firebaseInfo: GenerativeModelTestUtil.testFirebaseInfo(
510+
appCheck: AppCheckInteropFake(token: appCheckToken)
511+
),
512+
apiConfig: apiConfig,
513+
tools: nil,
514+
requestOptions: RequestOptions(),
515+
urlSession: urlSession,
516+
useLimitedUseAppCheckTokens: true
517+
)
518+
MockURLProtocol
519+
.requestHandler = try GenerativeModelTestUtil.httpRequestHandler(
520+
forResource: "unary-success-basic-reply-short",
521+
withExtension: "json",
522+
subdirectory: vertexSubdirectory,
523+
appCheckToken: "limited_use_\(appCheckToken)"
524+
)
525+
526+
_ = try await model.generateContent(testPrompt)
527+
}
528+
504529
func testGenerateContent_dataCollectionOff() async throws {
505530
let appCheckToken = "test-valid-token"
506531
model = GenerativeModel(

FirebaseAI/Tests/Unit/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
See the Firebase AI SDK
2-
[README](https://github.com/firebase/firebase-ios-sdk/tree/main/FirebaseVertexAI#unit-tests)
2+
[README](https://github.com/firebase/firebase-ios-sdk/tree/main/FirebaseAI#unit-tests)
33
for required setup instructions.

0 commit comments

Comments
 (0)