@@ -234,6 +234,16 @@ class AppDependencies {
234234 // 1. Create a JWT signed with the service account's private key.
235235 // 2. Exchange this JWT for an access token from Google's token endpoint.
236236 final firebaseHttpClient = HttpClient (
237+ baseUrl:
238+ 'https://fcm.googleapis.com/v1/projects/${EnvironmentConfig .firebaseProjectId }/' ,
239+ tokenProvider: _createFirebaseTokenProvider (),
240+ logger: Logger ('FirebasePushNotificationClient' ),
241+ );
242+
243+ // The OneSignal client requires the REST API key for authentication.
244+ // We use a custom interceptor to add the 'Authorization: Basic <API_KEY>'
245+ // header, as the default AuthInterceptor is hardcoded for 'Bearer' tokens.
246+ final oneSignalHttpClient = HttpClient (
237247 baseUrl:
238248 'https://fcm.googleapis.com/v1/projects/${EnvironmentConfig .firebaseProjectId }/' ,
239249 tokenProvider: () async {
@@ -294,25 +304,6 @@ class AppDependencies {
294304 },
295305 logger: Logger ('FirebasePushNotificationClient' ),
296306 );
297-
298- // The OneSignal client requires the REST API key for authentication.
299- // We use a custom interceptor to add the 'Authorization: Basic <API_KEY>'
300- // header, as the default AuthInterceptor is hardcoded for 'Bearer' tokens.
301- final oneSignalHttpClient = HttpClient (
302- baseUrl: 'https://onesignal.com/api/v1/' ,
303- // The tokenProvider is not used here; auth is handled by the interceptor.
304- tokenProvider: () async => null ,
305- interceptors: [
306- InterceptorsWrapper (
307- onRequest: (options, handler) {
308- options.headers['Authorization' ] =
309- 'Basic ${EnvironmentConfig .oneSignalRestApiKey }' ;
310- return handler.next (options);
311- },
312- ),
313- ],
314- logger: Logger ('OneSignalPushNotificationClient' ),
315- );
316307 // 4. Initialize Repositories
317308 headlineRepository = DataRepository (dataClient: headlineClient);
318309 topicRepository = DataRepository (dataClient: topicClient);
@@ -429,6 +420,75 @@ class AppDependencies {
429420 }
430421 }
431422
423+ /// Creates a token provider closure for the Firebase HTTP client.
424+ ///
425+ /// This method encapsulates the logic for obtaining a short-lived OAuth2
426+ /// access token from Google's token endpoint. It creates a single, reused
427+ /// [HttpClient] instance (`tokenClient` ) for efficiency.
428+ Future <String ?> Function () _createFirebaseTokenProvider () {
429+ // This client is created once and reused for all token exchange requests.
430+ // It is intentionally created without the standard AuthInterceptor to
431+ // avoid an infinite loop, as this specific request does not use a
432+ // Bearer token for its own authorization.
433+ final tokenClient = HttpClient (
434+ baseUrl: 'https://oauth2.googleapis.com' ,
435+ tokenProvider: () async => null ,
436+ );
437+
438+ return () async {
439+ try {
440+ // Step 1: Create and sign the JWT.
441+ final pem = EnvironmentConfig .firebasePrivateKey.replaceAll (
442+ r'\n' ,
443+ '\n ' ,
444+ );
445+ final privateKey = RSAPrivateKey (pem);
446+ final jwt = JWT (
447+ {'scope' : 'https://www.googleapis.com/auth/cloud-platform' },
448+ issuer: EnvironmentConfig .firebaseClientEmail,
449+ audience: Audience .one ('https://oauth2.googleapis.com/token' ),
450+ );
451+ final signedToken = jwt.sign (
452+ privateKey,
453+ algorithm: JWTAlgorithm .RS256 ,
454+ expiresIn: const Duration (minutes: 5 ),
455+ );
456+
457+ // Step 2: Exchange the JWT for an access token using the reused client.
458+ final response = await tokenClient.post <Map <String , dynamic >>(
459+ '/token' ,
460+ data:
461+ 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=$signedToken ' ,
462+ options: Options (
463+ headers: {
464+ 'Content-Type' : 'application/x-www-form-urlencoded' ,
465+ },
466+ ),
467+ );
468+
469+ final accessToken = response['access_token' ] as String ? ;
470+ if (accessToken == null ) {
471+ _log.severe ('Failed to get access token from Google OAuth.' );
472+ throw const OperationFailedException (
473+ 'Could not retrieve Firebase access token.' ,
474+ );
475+ }
476+ return accessToken;
477+ } catch (e, s) {
478+ _log.severe ('Error during Firebase token exchange: $e ' , e, s);
479+ // Check if the error is already an OperationFailedException to avoid
480+ // re-wrapping it.
481+ if (e is OperationFailedException ) {
482+ rethrow ;
483+ }
484+ // Wrap other exceptions for consistent error handling.
485+ throw OperationFailedException (
486+ 'Failed to authenticate with Firebase: $e ' ,
487+ );
488+ }
489+ };
490+ }
491+
432492 /// Disposes of resources, such as closing the database connection.
433493 Future <void > dispose () async {
434494 // Only attempt to dispose if initialization has been started.
0 commit comments