Skip to content

Commit 71d8b79

Browse files
committed
refactor(notifications): implement Firebase token provider for HTTP client
- Add Firebase token provider to create and exchange JWT for access token - Introduce reusable HttpClient instance for token exchange - Update OneSignal client initialization
1 parent 80e87f9 commit 71d8b79

File tree

1 file changed

+79
-19
lines changed

1 file changed

+79
-19
lines changed

lib/src/config/app_dependencies.dart

Lines changed: 79 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)