Skip to content

Commit 993c86e

Browse files
committed
feat: implement OAuth2 refresh token credential support
1 parent 4872123 commit 993c86e

6 files changed

Lines changed: 437 additions & 0 deletions

File tree

packages/dart_firebase_admin/lib/dart_firebase_admin.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ export 'src/app.dart'
2222
FirebaseService,
2323
FirebaseServiceType,
2424
FirebaseUserAgentClient,
25+
RefreshTokenCredential,
2526
ServiceAccountCredential,
2627
envSymbol;

packages/dart_firebase_admin/lib/src/app/credential.dart

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const envSymbol = #_envSymbol;
2323
/// - [Credential.fromServiceAccount] - For service account JSON files
2424
/// - [Credential.fromServiceAccountParams] - For individual service account parameters
2525
/// - [Credential.fromApplicationDefaultCredentials] - For Application Default Credentials (ADC)
26+
/// - [Credential.fromRefreshToken] - For OAuth2 refresh token JSON files
27+
/// - [Credential.fromRefreshTokenParams] - For individual OAuth2 refresh token parameters
2628
///
2729
/// The credential is used to authenticate all API calls made by the Admin SDK.
2830
sealed class Credential {
@@ -115,6 +117,124 @@ sealed class Credential {
115117
}
116118
}
117119

120+
/// Creates a credential from an OAuth2 refresh token JSON file.
121+
///
122+
/// The file must contain:
123+
/// - `client_id`: The OAuth2 client ID
124+
/// - `client_secret`: The OAuth2 client secret
125+
/// - `refresh_token`: The refresh token
126+
/// - `type`: The credential type (typically `"authorized_user"`)
127+
///
128+
/// You can obtain a refresh token JSON file by running:
129+
/// ```
130+
/// gcloud auth application-default login
131+
/// ```
132+
/// which writes credentials to `~/.config/gcloud/application_default_credentials.json`.
133+
///
134+
/// Example:
135+
/// ```dart
136+
/// final credential = Credential.fromRefreshToken(
137+
/// File('path/to/refresh_token.json'),
138+
/// );
139+
/// ```
140+
factory Credential.fromRefreshToken(File refreshTokenFile) {
141+
final String raw;
142+
try {
143+
raw = refreshTokenFile.readAsStringSync();
144+
} on IOException catch (e) {
145+
throw FirebaseAppException(
146+
AppErrorCode.invalidCredential,
147+
'Failed to read refresh token file: $e',
148+
);
149+
}
150+
151+
final Object? json;
152+
try {
153+
json = jsonDecode(raw);
154+
} on FormatException catch (e) {
155+
throw FirebaseAppException(
156+
AppErrorCode.invalidCredential,
157+
'Failed to parse refresh token JSON: ${e.message}',
158+
);
159+
}
160+
161+
if (json case {
162+
'client_id': final String clientId,
163+
'client_secret': final String clientSecret,
164+
'refresh_token': final String refreshToken,
165+
'type': final String type,
166+
} when clientId.isNotEmpty &&
167+
clientSecret.isNotEmpty &&
168+
refreshToken.isNotEmpty &&
169+
type.isNotEmpty) {
170+
return RefreshTokenCredential._(
171+
clientId: clientId,
172+
clientSecret: clientSecret,
173+
refreshToken: refreshToken,
174+
);
175+
}
176+
177+
throw FirebaseAppException(
178+
AppErrorCode.invalidCredential,
179+
'Refresh token file must contain non-empty string fields: '
180+
'"client_id", "client_secret", "refresh_token", and "type".',
181+
);
182+
}
183+
184+
/// Creates a credential from individual OAuth2 refresh token parameters.
185+
///
186+
/// Parameters:
187+
/// - [clientId]: The OAuth2 client ID
188+
/// - [clientSecret]: The OAuth2 client secret
189+
/// - [refreshToken]: The refresh token
190+
/// - [type]: The credential type (typically `"authorized_user"`)
191+
///
192+
/// Example:
193+
/// ```dart
194+
/// final credential = Credential.fromRefreshTokenParams(
195+
/// clientId: 'my-client-id',
196+
/// clientSecret: 'my-client-secret',
197+
/// refreshToken: 'my-refresh-token',
198+
/// type: 'authorized_user',
199+
/// );
200+
/// ```
201+
factory Credential.fromRefreshTokenParams({
202+
required String clientId,
203+
required String clientSecret,
204+
required String refreshToken,
205+
required String type,
206+
}) {
207+
if (clientId.isEmpty) {
208+
throw FirebaseAppException(
209+
AppErrorCode.invalidCredential,
210+
'Refresh token must contain a non-empty "client_id".',
211+
);
212+
}
213+
if (clientSecret.isEmpty) {
214+
throw FirebaseAppException(
215+
AppErrorCode.invalidCredential,
216+
'Refresh token must contain a non-empty "client_secret".',
217+
);
218+
}
219+
if (refreshToken.isEmpty) {
220+
throw FirebaseAppException(
221+
AppErrorCode.invalidCredential,
222+
'Refresh token must contain a non-empty "refresh_token".',
223+
);
224+
}
225+
if (type.isEmpty) {
226+
throw FirebaseAppException(
227+
AppErrorCode.invalidCredential,
228+
'Refresh token must contain a non-empty "type".',
229+
);
230+
}
231+
return RefreshTokenCredential._(
232+
clientId: clientId,
233+
clientSecret: clientSecret,
234+
refreshToken: refreshToken,
235+
);
236+
}
237+
118238
/// Private constructor for sealed class.
119239
Credential._();
120240

@@ -170,6 +290,64 @@ final class ServiceAccountCredential extends Credential {
170290
String? get serviceAccountId => _serviceAccountCredentials.email;
171291
}
172292

293+
/// OAuth2 refresh token credentials for Firebase Admin SDK.
294+
///
295+
/// Uses a refresh token to obtain and automatically refresh access tokens.
296+
/// Obtain a refresh token file by running `gcloud auth application-default login`.
297+
@internal
298+
final class RefreshTokenCredential extends Credential {
299+
RefreshTokenCredential._({
300+
required this.clientId,
301+
required this.clientSecret,
302+
required this.refreshToken,
303+
}) : super._();
304+
305+
/// The OAuth2 client ID.
306+
final String clientId;
307+
308+
/// The OAuth2 client secret.
309+
final String clientSecret;
310+
311+
/// The OAuth2 refresh token.
312+
final String refreshToken;
313+
314+
@override
315+
googleapis_auth.ServiceAccountCredentials? get serviceAccountCredentials =>
316+
null;
317+
318+
@override
319+
String? get serviceAccountId => null;
320+
321+
// TODO: move this into googleapis_auth as clientViaRefreshToken
322+
/// Creates an auto-refreshing authenticated HTTP client for [scopes].
323+
///
324+
/// An optional [baseClient] can be provided for testing. When omitted, a
325+
/// plain [Client] is used.
326+
@internal
327+
Future<googleapis_auth.AutoRefreshingAuthClient> createAuthClient(
328+
List<String> scopes, {
329+
Client? baseClient,
330+
}) async {
331+
final id = googleapis_auth.ClientId(clientId, clientSecret);
332+
// Deliberately expired — forces a token exchange on the first API call.
333+
final expiredToken = googleapis_auth.AccessToken(
334+
'Bearer',
335+
'',
336+
DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
337+
);
338+
final credentials = googleapis_auth.AccessCredentials(
339+
expiredToken,
340+
refreshToken,
341+
scopes,
342+
);
343+
return googleapis_auth.autoRefreshingClient(
344+
id,
345+
credentials,
346+
baseClient ?? Client(),
347+
);
348+
}
349+
}
350+
173351
/// Application Default Credentials for Firebase Admin SDK.
174352
///
175353
/// Uses Google Application Default Credentials (ADC) to automatically discover

packages/dart_firebase_admin/lib/src/app/firebase_app.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ class FirebaseApp {
127127
serviceAccountCredentials,
128128
scopes,
129129
),
130+
RefreshTokenCredential() => credential.createAuthClient(scopes),
130131
_ => googleapis_auth.clientViaApplicationDefaultCredentials(
131132
scopes: scopes,
132133
),

packages/dart_firebase_admin/test/integration/app/firebase_app_prod_test.dart

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,64 @@ void main() {
9999
);
100100
});
101101

102+
group('_createDefaultClient – refresh token path', () {
103+
final refreshTokenFile = Platform.environment['FIREBASE_REFRESH_TOKEN_CREDENTIALS'];
104+
105+
test(
106+
'creates an authenticated client via refresh token credential',
107+
() {
108+
return runZoned(() async {
109+
final credential = Credential.fromRefreshToken(
110+
File(refreshTokenFile!),
111+
);
112+
113+
final app = FirebaseApp.initializeApp(
114+
name: 'rt-client-${DateTime.now().microsecondsSinceEpoch}',
115+
options: AppOptions(projectId: projectId, credential: credential),
116+
);
117+
118+
try {
119+
final client = await app.client;
120+
expect(client, isNotNull);
121+
} finally {
122+
await app.close();
123+
}
124+
}, zoneValues: {envSymbol: prodEnv()});
125+
},
126+
skip: refreshTokenFile != null
127+
? false
128+
: 'Requires FIREBASE_REFRESH_TOKEN_CREDENTIALS to be set '
129+
'(path to a refresh token JSON file, e.g. '
130+
'~/.config/gcloud/application_default_credentials.json)',
131+
timeout: const Timeout(Duration(seconds: 30)),
132+
);
133+
134+
test(
135+
'SDK-created refresh token client is closed when app.close() is called',
136+
() {
137+
return runZoned(() async {
138+
final credential = Credential.fromRefreshToken(
139+
File(refreshTokenFile!),
140+
);
141+
142+
final app = FirebaseApp.initializeApp(
143+
name: 'rt-close-${DateTime.now().microsecondsSinceEpoch}',
144+
options: AppOptions(projectId: projectId, credential: credential),
145+
);
146+
147+
await app.client;
148+
await app.close();
149+
150+
expect(app.isDeleted, isTrue);
151+
}, zoneValues: {envSymbol: prodEnv()});
152+
},
153+
skip: refreshTokenFile != null
154+
? false
155+
: 'Requires FIREBASE_REFRESH_TOKEN_CREDENTIALS to be set',
156+
timeout: const Timeout(Duration(seconds: 30)),
157+
);
158+
});
159+
102160
group('getProjectId – computeProjectId fallback', () {
103161
test(
104162
'falls back to computeProjectId() when no projectId source is configured',

0 commit comments

Comments
 (0)