Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class AppCheckTokenGenerator {
'aud': firebaseAppCheckAudience,
'exp': iat + (oneMinuteInSeconds * 5),
'iat': iat,
if (options?.ttlMillis case final ttl?) 'ttl': '${ttl.inSeconds}s',
};

final token = '${_encodeSegment(header)}.${_encodeSegment(body)}';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ abstract class _AbstractAuthRequestHandler {
options,
ignoreMissingFields: true,
);
final updateMask = generateUpdateMask(request);
final updateMask = generateUpdateMask(request?.toJson());

final response = await _httpClient.updateOAuthIdpConfig(
request ?? auth2.GoogleCloudIdentitytoolkitAdminV2OAuthIdpConfig(),
Expand Down Expand Up @@ -252,7 +252,7 @@ abstract class _AbstractAuthRequestHandler {
options,
ignoreMissingFields: true,
);
final updateMask = generateUpdateMask(request);
final updateMask = generateUpdateMask(request?.toJson());
final response = await _httpClient.updateInboundSamlConfig(
request ?? auth2.GoogleCloudIdentitytoolkitAdminV2InboundSamlConfig(),
providerId,
Expand Down
4 changes: 4 additions & 0 deletions packages/dart_firebase_admin/lib/src/storage/storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ class Storage implements FirebaseService {
);
}
setNativeEnvironmentVariable('STORAGE_EMULATOR_HOST', emulatorHost);
} else {
// Ensure no emulator host leaks into production GCS client from a
// previous emulator-mode Storage instance in the same process.
unsetNativeEnvironmentVariable('STORAGE_EMULATOR_HOST');
}
_delegate = gcs.Storage();
}
Expand Down
5 changes: 4 additions & 1 deletion packages/dart_firebase_admin/lib/src/utils/jwt.dart
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,10 @@ class PublicKeySignatureVerifier implements SignatureVerifier {
}

try {
JWT.verify(token, key);
// Firebase session cookies omit the `typ` header claim, which
// dart_jsonwebtoken 3.x rejects by default. Disable the check here;
// signature, issuer, audience, and expiry are validated separately.
JWT.verify(token, key, checkHeaderType: false);
} catch (e, stackTrace) {
Error.throwWithStackTrace(
JwtException(
Expand Down
21 changes: 21 additions & 0 deletions packages/dart_firebase_admin/lib/src/utils/native_environment.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ final int Function(Pointer<Utf8>, Pointer<Utf8>, int) _setenv =
int Function(Pointer<Utf8>, Pointer<Utf8>, int)
>('setenv');

final int Function(Pointer<Utf8>) _unsetenv = DynamicLibrary.process()
.lookupFunction<Int32 Function(Pointer<Utf8>), int Function(Pointer<Utf8>)>(
'unsetenv',
);

final int Function(Pointer<Utf16>, Pointer<Utf16>) _setEnvironmentVariableW =
DynamicLibrary.open('kernel32.dll').lookupFunction<
Int32 Function(Pointer<Utf16>, Pointer<Utf16>),
Expand Down Expand Up @@ -44,3 +49,19 @@ void setNativeEnvironmentVariable(String name, String value) {
});
}
}

@internal
void unsetNativeEnvironmentVariable(String name) {
if (Platform.isWindows) {
using((arena) {
final namePtr = name.toNativeUtf16(allocator: arena);
// Passing NULL as the second argument deletes the variable on Windows
_setEnvironmentVariableW(namePtr, nullptr);
});
} else {
using((arena) {
final namePtr = name.toNativeUtf8(allocator: arena);
_unsetenv(namePtr);
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import 'dart:async';

import 'package:dart_firebase_admin/app_check.dart';
import 'package:dart_firebase_admin/src/app.dart';
import 'package:test/test.dart';

import '../../fixtures/helpers.dart';

const _testAppId = '1:559949546715:android:13025aec6cc3243d0ab8fe';

void main() {
group(
'AppCheck (Production)',
() {
group('createToken()', () {
test('returns a token with a non-empty JWT string', () {
return runZoned(() async {
final appName =
'prod-test-${DateTime.now().microsecondsSinceEpoch}';
final app = FirebaseApp.initializeApp(name: appName);
final appCheck = AppCheck.internal(app);

try {
final token = await appCheck.createToken(_testAppId);

expect(token.token, isNotEmpty);
// A JWT has exactly two dots
expect(token.token.split('.').length, equals(3));
} finally {
await app.close();
}
}, zoneValues: {envSymbol: prodEnv()});
});

test('returns ttlMillis within the valid range (30min–7days)', () {
return runZoned(() async {
final appName =
'prod-test-${DateTime.now().microsecondsSinceEpoch}';
final app = FirebaseApp.initializeApp(name: appName);
final appCheck = AppCheck.internal(app);

try {
final token = await appCheck.createToken(_testAppId);

expect(
token.ttlMillis,
greaterThanOrEqualTo(
const Duration(minutes: 30).inMilliseconds,
),
);
expect(
token.ttlMillis,
lessThanOrEqualTo(const Duration(days: 7).inMilliseconds),
);
} finally {
await app.close();
}
}, zoneValues: {envSymbol: prodEnv()});
});

test('honours a custom ttlMillis option', () {
return runZoned(() async {
final appName =
'prod-test-${DateTime.now().microsecondsSinceEpoch}';
final app = FirebaseApp.initializeApp(name: appName);
final appCheck = AppCheck.internal(app);

try {
const customTtl = Duration(hours: 2);
final token = await appCheck.createToken(
_testAppId,
AppCheckTokenOptions(ttlMillis: customTtl),
);

expect(token.token, isNotEmpty);
// Server rounds to the nearest second; allow ±1 second tolerance.
expect(token.ttlMillis, closeTo(customTtl.inMilliseconds, 1000));
} finally {
await app.close();
}
}, zoneValues: {envSymbol: prodEnv()});
});
});

group('verifyToken()', () {
test('returns decoded token with correct claims structure', () {
return runZoned(() async {
final appName =
'prod-test-${DateTime.now().microsecondsSinceEpoch}';
final app = FirebaseApp.initializeApp(name: appName);
final appCheck = AppCheck.internal(app);

try {
final token = await appCheck.createToken(_testAppId);
final result = await appCheck.verifyToken(token.token);

final decoded = result.token;
expect(result.appId, equals(_testAppId));
expect(decoded.sub, equals(_testAppId));
expect(
decoded.iss,
startsWith('https://firebaseappcheck.googleapis.com/'),
);
expect(decoded.aud, isNotEmpty);
expect(decoded.exp, greaterThan(decoded.iat));
} finally {
await app.close();
}
}, zoneValues: {envSymbol: prodEnv()});
});

test('sets alreadyConsumed to null when consume option is not set', () {
return runZoned(() async {
final appName =
'prod-test-${DateTime.now().microsecondsSinceEpoch}';
final app = FirebaseApp.initializeApp(name: appName);
final appCheck = AppCheck.internal(app);

try {
final token = await appCheck.createToken(_testAppId);
final result = await appCheck.verifyToken(token.token);

expect(result.alreadyConsumed, isNull);
} finally {
await app.close();
}
}, zoneValues: {envSymbol: prodEnv()});
});

test(
'sets alreadyConsumed to false on first consume, true on second',
() {
return runZoned(() async {
final appName =
'prod-test-${DateTime.now().microsecondsSinceEpoch}';
final app = FirebaseApp.initializeApp(name: appName);
final appCheck = AppCheck.internal(app);

try {
final token = await appCheck.createToken(_testAppId);

final first = await appCheck.verifyToken(
token.token,
VerifyAppCheckTokenOptions()..consume = true,
);
expect(first.alreadyConsumed, isFalse);

final second = await appCheck.verifyToken(
token.token,
VerifyAppCheckTokenOptions()..consume = true,
);
expect(second.alreadyConsumed, isTrue);
} finally {
await app.close();
}
}, zoneValues: {envSymbol: prodEnv()});
},
);

test('throws FirebaseAppCheckException for an invalid token', () {
return runZoned(() async {
final appName =
'prod-test-${DateTime.now().microsecondsSinceEpoch}';
final app = FirebaseApp.initializeApp(name: appName);
final appCheck = AppCheck.internal(app);

try {
await expectLater(
() => appCheck.verifyToken('invalid.token.value'),
throwsA(isA<FirebaseAppCheckException>()),
);
} finally {
await app.close();
}
}, zoneValues: {envSymbol: prodEnv()});
});

test('throws FirebaseAppCheckException for a tampered token', () {
return runZoned(() async {
final appName =
'prod-test-${DateTime.now().microsecondsSinceEpoch}';
final app = FirebaseApp.initializeApp(name: appName);
final appCheck = AppCheck.internal(app);

try {
final token = await appCheck.createToken(_testAppId);
// Corrupt the signature portion of the JWT.
final parts = token.token.split('.');
final tampered = '${parts[0]}.${parts[1]}.invalidsignature';

await expectLater(
() => appCheck.verifyToken(tampered),
throwsA(isA<FirebaseAppCheckException>()),
);
} finally {
await app.close();
}
}, zoneValues: {envSymbol: prodEnv()});
});
});
},
skip: hasProdEnv
? false
: 'Requires GOOGLE_APPLICATION_CREDENTIALS and RUN_PROD_TESTS=true',
);
}
Loading
Loading