From 892d8b8e756d80578927039ab091981f88ba0a84 Mon Sep 17 00:00:00 2001 From: Julian Pscheid Date: Tue, 3 Nov 2026 15:18:30 -0800 Subject: [PATCH] fix(auth,windows): populate ID token claims and metadata Decode the JWT payload returned by the C++ SDK and populate claims, authentication/issue/expiry times, sign-in provider and second factor when present. Keep returning the raw token if optional payload decoding fails. Use the already-linked Windows base64 decoder, exercise the parser in native tests, and verify custom claims and metadata against the Auth emulator. Enable the existing token-result integration check on Windows. Fixes #11768 --- .../firebase_auth_user_e2e_test.dart | 90 +++-- .../firebase_auth/windows/CMakeLists.txt | 8 +- .../windows/firebase_auth_plugin.cpp | 321 +++++++++++++++++- .../windows/firebase_auth_plugin.h | 1 + .../test/firebase_auth_plugin_test.cpp | 98 ++++-- 5 files changed, 455 insertions(+), 63 deletions(-) diff --git a/packages/firebase_auth/firebase_auth/example/integration_test/firebase_auth_user_e2e_test.dart b/packages/firebase_auth/firebase_auth/example/integration_test/firebase_auth_user_e2e_test.dart index 372c818ae271..2f68aa19635d 100644 --- a/packages/firebase_auth/firebase_auth/example/integration_test/firebase_auth_user_e2e_test.dart +++ b/packages/firebase_auth/firebase_auth/example/integration_test/firebase_auth_user_e2e_test.dart @@ -3,6 +3,7 @@ // BSD-style license that can be found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/foundation.dart'; @@ -113,34 +114,71 @@ void main() { ); group('getIdTokenResult()', () { - test( - 'should return a valid IdTokenResult Object', - () async { - // Setup - final userCredential = await FirebaseAuth.instance - .createUserWithEmailAndPassword( - email: email, - password: testPassword, - ); - final user = userCredential.user!; + test('should return a valid IdTokenResult Object', () async { + // Setup + final userCredential = await FirebaseAuth.instance + .createUserWithEmailAndPassword( + email: email, + password: testPassword, + ); + final user = userCredential.user!; - // Test - final idTokenResult = await user.getIdTokenResult(); + // Test + final idTokenResult = await user.getIdTokenResult(); - // Assertions - expect(idTokenResult.token.runtimeType, equals(String)); - expect(idTokenResult.authTime.runtimeType, equals(DateTime)); - expect(idTokenResult.issuedAtTime.runtimeType, equals(DateTime)); - expect(idTokenResult.expirationTime.runtimeType, equals(DateTime)); - expect(idTokenResult.token!.length, greaterThan(24)); - expect(idTokenResult.signInProvider, equals('password')); - }, - skip: - !kIsWeb && - (defaultTargetPlatform == TargetPlatform.windows || - defaultTargetPlatform == TargetPlatform.macOS), - ); - // TODO add custom claims and tenant id tests for id token result + // Assertions + expect(idTokenResult.token.runtimeType, equals(String)); + expect(idTokenResult.authTime.runtimeType, equals(DateTime)); + expect(idTokenResult.issuedAtTime.runtimeType, equals(DateTime)); + expect(idTokenResult.expirationTime.runtimeType, equals(DateTime)); + expect(idTokenResult.token!.length, greaterThan(24)); + expect(idTokenResult.signInProvider, equals('password')); + }, skip: !kIsWeb && defaultTargetPlatform == TargetPlatform.macOS); + + test('should return custom claims and token metadata', () async { + const customClaims = { + 'orgId': 'test-org', + 'admin': true, + 'roles': ['reader', 'editor'], + 'profile': {'name': 'München 😀', 'quota': 2.5}, + }; + final credential = await FirebaseAuth.instance.signInWithCustomToken( + emulatorCreateCustomToken( + 'token-claims-user', + claims: customClaims, + ), + ); + final result = await credential.user!.getIdTokenResult(true); + final payload = + jsonDecode( + utf8.decode( + base64Url.decode( + base64Url.normalize(result.token!.split('.')[1]), + ), + ), + ) + as Map; + + expect(result.claims, equals(payload)); + for (final claim in customClaims.entries) { + expect(result.claims![claim.key], equals(claim.value)); + } + expect( + result.authTime!.millisecondsSinceEpoch, + payload['auth_time'] * 1000, + ); + expect( + result.issuedAtTime!.millisecondsSinceEpoch, + payload['iat'] * 1000, + ); + expect( + result.expirationTime!.millisecondsSinceEpoch, + payload['exp'] * 1000, + ); + expect(result.signInProvider, 'custom'); + expect(result.signInSecondFactor, isNull); + }); + // TODO add tenant id tests for id token result }); group( diff --git a/packages/firebase_auth/firebase_auth/windows/CMakeLists.txt b/packages/firebase_auth/firebase_auth/windows/CMakeLists.txt index 834880ff08b5..4854f8d1387a 100644 --- a/packages/firebase_auth/firebase_auth/windows/CMakeLists.txt +++ b/packages/firebase_auth/firebase_auth/windows/CMakeLists.txt @@ -101,16 +101,14 @@ set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) set(INSTALL_GTEST OFF CACHE BOOL "Disable installation of googletest" FORCE) FetchContent_MakeAvailable(googletest) -# The plugin's C API is not very useful for unit testing, so build the sources -# directly into the test binary rather than using the DLL. +# Link the static plugin library to exercise the same parser as the app. add_executable(${TEST_RUNNER} test/firebase_auth_plugin_test.cpp - ${PLUGIN_SOURCES} ) apply_standard_settings(${TEST_RUNNER}) target_include_directories(${TEST_RUNNER} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") -target_link_libraries(${TEST_RUNNER} PRIVATE flutter_wrapper_plugin) -target_link_libraries(${TEST_RUNNER} PRIVATE gtest_main gmock) +target_link_libraries(${TEST_RUNNER} PRIVATE + ${PLUGIN_NAME} firebase_core_plugin flutter_wrapper_plugin gtest_main) # flutter_wrapper_plugin has link dependencies on the Flutter DLL. add_custom_command(TARGET ${TEST_RUNNER} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different diff --git a/packages/firebase_auth/firebase_auth/windows/firebase_auth_plugin.cpp b/packages/firebase_auth/firebase_auth/windows/firebase_auth_plugin.cpp index 0b374abea100..d3079af3a550 100644 --- a/packages/firebase_auth/firebase_auth/windows/firebase_auth_plugin.cpp +++ b/packages/firebase_auth/firebase_auth/windows/firebase_auth_plugin.cpp @@ -5,9 +5,15 @@ #include "firebase_auth_plugin.h" // This must be included before many other Windows headers. +#include #include +#include #include +#include +#include +#include +#include #include #include "firebase/app.h" @@ -966,6 +972,316 @@ void FirebaseAuthPlugin::Delete( }); } +namespace { + +class IdTokenClaimsParser { + public: + explicit IdTokenClaimsParser(std::string_view json) : json_(json) {} + + EncodableMap Parse() { + EncodableValue value = ParseValue(0); + SkipWhitespace(); + if (position_ != json_.size() || + !std::holds_alternative(value)) { + Fail(); + } + return std::get(value); + } + + private: + [[noreturn]] static void Fail() { + throw std::runtime_error("Invalid ID-token claims"); + } + + void SkipWhitespace() { + while (position_ < json_.size() && + (json_[position_] == ' ' || json_[position_] == '\t' || + json_[position_] == '\r' || json_[position_] == '\n')) { + ++position_; + } + } + + char Take() { + if (position_ == json_.size()) Fail(); + return json_[position_++]; + } + + bool Consume(char expected) { + SkipWhitespace(); + if (position_ == json_.size() || json_[position_] != expected) return false; + ++position_; + return true; + } + + uint32_t Hex4() { + uint32_t value = 0; + for (int i = 0; i < 4; ++i) { + char c = Take(); + int digit = c >= '0' && c <= '9' ? c - '0' + : c >= 'a' && c <= 'f' ? c - 'a' + 10 + : c >= 'A' && c <= 'F' ? c - 'A' + 10 + : -1; + if (digit < 0) Fail(); + value = (value << 4) | static_cast(digit); + } + return value; + } + + std::string ParseString() { + if (Take() != '"') Fail(); + std::string value; + while (true) { + unsigned char c = static_cast(Take()); + if (c == '"') return value; + if (c < 0x20) Fail(); + if (c >= 0x80) { + int count = c >= 0xc2 && c <= 0xdf ? 1 + : c >= 0xe0 && c <= 0xef ? 2 + : c >= 0xf0 && c <= 0xf4 ? 3 + : -1; + if (count < 0) Fail(); + uint32_t point = c & (0x7fu >> (count + 1)); + value.push_back(static_cast(c)); + for (int i = 0; i < count; ++i) { + unsigned char next = static_cast(Take()); + if ((next & 0xc0) != 0x80) Fail(); + point = (point << 6) | (next & 0x3fu); + value.push_back(static_cast(next)); + } + if ((count == 2 && point < 0x800u) || + (count == 3 && point < 0x10000u) || point > 0x10ffffu || + (point >= 0xd800u && point <= 0xdfffu)) + Fail(); + } else if (c != '\\') { + value.push_back(static_cast(c)); + } else { + char escape = Take(); + switch (escape) { + case '"': + case '\\': + case '/': + value.push_back(escape); + break; + case 'b': + value.push_back('\b'); + break; + case 'f': + value.push_back('\f'); + break; + case 'n': + value.push_back('\n'); + break; + case 'r': + value.push_back('\r'); + break; + case 't': + value.push_back('\t'); + break; + case 'u': { + uint32_t point = Hex4(); + if (point >= 0xd800u && point <= 0xdbffu) { + if (Take() != '\\' || Take() != 'u') Fail(); + uint32_t low = Hex4(); + if (low < 0xdc00u || low > 0xdfffu) Fail(); + point = 0x10000u + ((point - 0xd800u) << 10) + low - 0xdc00u; + } else if (point >= 0xdc00u && point <= 0xdfffu) { + Fail(); + } + if (point < 0x80u) { + value.push_back(static_cast(point)); + } else { + int count = point < 0x800u ? 1 : point < 0x10000u ? 2 : 3; + value.push_back(static_cast((count == 1 ? 0xc0u + : count == 2 ? 0xe0u + : 0xf0u) | + (point >> (6 * count)))); + for (int i = count - 1; i >= 0; --i) { + value.push_back( + static_cast(0x80u | ((point >> (6 * i)) & 0x3fu))); + } + } + break; + } + default: + Fail(); + } + } + } + } + + EncodableValue ParseValue(int depth) { + // Limit nesting in malformed tokens. + if (depth > 64) Fail(); + SkipWhitespace(); + if (position_ == json_.size()) Fail(); + char c = json_[position_]; + if (c == '"') return EncodableValue(ParseString()); + if (Consume('{')) { + EncodableMap map; + if (Consume('}')) return EncodableValue(map); + do { + SkipWhitespace(); + std::string key = ParseString(); + if (!Consume(':')) Fail(); + map[EncodableValue(key)] = ParseValue(depth + 1); + } while (Consume(',')); + if (!Consume('}')) Fail(); + return EncodableValue(map); + } + if (Consume('[')) { + flutter::EncodableList list; + if (Consume(']')) return EncodableValue(list); + do { + list.push_back(ParseValue(depth + 1)); + } while (Consume(',')); + if (!Consume(']')) Fail(); + return EncodableValue(list); + } + for (const auto& literal : {"true", "false", "null"}) { + std::string_view word(literal); + if (json_.substr(position_, word.size()) == word) { + position_ += word.size(); + return word == "null" ? EncodableValue() + : EncodableValue(word == "true"); + } + } + size_t start = position_; + if (c == '-') ++position_; + auto digits = [this]() { + size_t digit_start = position_; + while (position_ < json_.size() && json_[position_] >= '0' && + json_[position_] <= '9') + ++position_; + if (digit_start == position_) Fail(); + }; + if (position_ < json_.size() && json_[position_] == '0') { + ++position_; + } else { + digits(); + } + bool integer = true; + if (position_ < json_.size() && json_[position_] == '.') { + integer = false; + ++position_; + digits(); + } + if (position_ < json_.size() && + (json_[position_] == 'e' || json_[position_] == 'E')) { + integer = false; + ++position_; + if (position_ < json_.size() && + (json_[position_] == '+' || json_[position_] == '-')) + ++position_; + digits(); + } + std::string_view number = json_.substr(start, position_ - start); + int64_t int_value; + if (integer && + std::from_chars(number.data(), number.data() + number.size(), int_value) + .ec == std::errc()) { + return EncodableValue(int_value); + } + std::istringstream stream{std::string(number)}; + stream.imbue(std::locale::classic()); + double double_value; + if (!(stream >> double_value) || !std::isfinite(double_value)) Fail(); + return EncodableValue(double_value); + } + + std::string_view json_; + size_t position_ = 0; +}; + +std::optional DecodeIdTokenClaims(std::string_view token) { + const auto first = token.find('.'); + if (first == std::string_view::npos || first == 0) return std::nullopt; + const auto last = token.find('.', first + 1); + if (last == std::string_view::npos || + token.find('.', last + 1) != std::string_view::npos) { + return std::nullopt; + } + std::string payload(token.substr(first + 1, last - first - 1)); + if (payload.empty() || payload.size() % 4 == 1 || + payload.find_first_not_of( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") != + std::string::npos) { + return std::nullopt; + } + for (auto& c : payload) { + if (c == '-') c = '+'; + if (c == '_') c = '/'; + } + payload.append((4 - payload.size() % 4) % 4, '='); + if (payload.size() > MAXDWORD) return std::nullopt; + const auto length = static_cast(payload.size()); + DWORD decoded_length = 0; + if (!CryptStringToBinaryA(payload.data(), length, CRYPT_STRING_BASE64, + nullptr, &decoded_length, nullptr, nullptr)) { + return std::nullopt; + } + std::string json(decoded_length, '\0'); + if (!CryptStringToBinaryA(payload.data(), length, CRYPT_STRING_BASE64, + reinterpret_cast(json.data()), + &decoded_length, nullptr, nullptr)) { + return std::nullopt; + } + json.resize(decoded_length); + return IdTokenClaimsParser(json).Parse(); +} + +} // namespace + +InternalIdTokenResult FirebaseAuthPlugin::ParseIdTokenResult( + const std::string& token) { + InternalIdTokenResult result; + result.set_token(token); + try { + const auto claims = DecodeIdTokenClaims(token); + if (!claims) return result; + result.set_claims(*claims); + const auto timestamp = + [&claims](const char* key) -> std::optional { + const auto it = claims->find(EncodableValue(key)); + if (it == claims->end()) return std::nullopt; + const auto value = std::get_if(&it->second); + if (!value || *value > (std::numeric_limits::max)() / 1000 || + *value < (std::numeric_limits::min)() / 1000) { + return std::nullopt; + } + return *value * 1000; + }; + if (const auto value = timestamp("auth_time")) + result.set_auth_timestamp(*value); + if (const auto value = timestamp("iat")) + result.set_issued_at_timestamp(*value); + if (const auto value = timestamp("exp")) + result.set_expiration_timestamp(*value); + const auto firebase = claims->find(EncodableValue("firebase")); + if (firebase != claims->end()) { + const auto metadata = std::get_if(&firebase->second); + if (metadata) { + const auto provider = + metadata->find(EncodableValue("sign_in_provider")); + if (provider != metadata->end()) { + if (const auto value = std::get_if(&provider->second)) { + result.set_sign_in_provider(*value); + } + } + const auto factor = + metadata->find(EncodableValue("sign_in_second_factor")); + if (factor != metadata->end()) { + if (const auto value = std::get_if(&factor->second)) { + result.set_sign_in_second_factor(*value); + } + } + } + } + } catch (const std::exception&) { + // Decoding optional claims must not prevent returning the token. + } + return result; +} + void FirebaseAuthPlugin::GetIdToken( const AuthPigeonFirebaseApp& app, bool force_refresh, std::function reply)> result) { @@ -978,10 +1294,7 @@ void FirebaseAuthPlugin::GetIdToken( [result](const firebase::Future& completed_future) { // We are probably in a different thread right now. if (completed_future.error() == 0) { - InternalIdTokenResult token_result; - std::string_view sv(*completed_future.result()); - token_result.set_token(sv); - result(token_result); + result(ParseIdTokenResult(*completed_future.result())); } else { result(FirebaseAuthPlugin::ParseError(completed_future)); } diff --git a/packages/firebase_auth/firebase_auth/windows/firebase_auth_plugin.h b/packages/firebase_auth/firebase_auth/windows/firebase_auth_plugin.h index 040b42a4cf3b..4334cb578480 100644 --- a/packages/firebase_auth/firebase_auth/windows/firebase_auth_plugin.h +++ b/packages/firebase_auth/firebase_auth/windows/firebase_auth_plugin.h @@ -39,6 +39,7 @@ class FirebaseAuthPlugin : public flutter::Plugin, FirebaseAuthPlugin& operator=(const FirebaseAuthPlugin&) = delete; // Parser functions + static InternalIdTokenResult ParseIdTokenResult(const std::string& token); static std::string GetAuthErrorCode(AuthError authError); static FlutterError ParseError(const firebase::FutureBase& future); diff --git a/packages/firebase_auth/firebase_auth/windows/test/firebase_auth_plugin_test.cpp b/packages/firebase_auth/firebase_auth/windows/test/firebase_auth_plugin_test.cpp index 7c8110cff14f..e8d69e7dc2f3 100644 --- a/packages/firebase_auth/firebase_auth/windows/test/firebase_auth_plugin_test.cpp +++ b/packages/firebase_auth/firebase_auth/windows/test/firebase_auth_plugin_test.cpp @@ -4,44 +4,86 @@ #include "firebase_auth_plugin.h" -#include -#include -#include #include +#include #include -#include #include -#include -namespace firebase_auth { +namespace firebase_auth_windows { namespace test { - namespace { -using flutter::EncodableMap; -using flutter::EncodableValue; -using flutter::MethodCall; -using flutter::MethodResultFunctions; +std::string Token(const std::string& json) { + DWORD length = 0; + CryptBinaryToStringA(reinterpret_cast(json.data()), + static_cast(json.size()), + CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF, nullptr, + &length); + std::string payload(length, '\0'); + CryptBinaryToStringA(reinterpret_cast(json.data()), + static_cast(json.size()), + CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF, + payload.data(), &length); + payload.resize(length); + while (!payload.empty() && payload.back() == '=') payload.pop_back(); + for (auto& c : payload) { + if (c == '+') c = '-'; + if (c == '/') c = '_'; + } + return "header." + payload + "."; +} -} // namespace +TEST(FirebaseAuthPlugin, ParsesIdTokenResult) { + const auto token = Token( + R"({"auth_time":1,"iat":2,"exp":3,"firebase":{"sign_in_provider":"custom","sign_in_second_factor":"phone"},"orgId":"test-org","roles":["reader",true,null,2,2.5],"unicode":"M\u00fcnchen \ud83d\ude00"})"); + const auto result = FirebaseAuthPlugin::ParseIdTokenResult(token); + EXPECT_EQ(*result.token(), token); + ASSERT_NE(result.claims(), nullptr); + ASSERT_NE(result.auth_timestamp(), nullptr); + ASSERT_NE(result.issued_at_timestamp(), nullptr); + ASSERT_NE(result.expiration_timestamp(), nullptr); + ASSERT_NE(result.sign_in_provider(), nullptr); + ASSERT_NE(result.sign_in_second_factor(), nullptr); + EXPECT_EQ(*result.auth_timestamp(), 1000); + EXPECT_EQ(*result.issued_at_timestamp(), 2000); + EXPECT_EQ(*result.expiration_timestamp(), 3000); + EXPECT_EQ(*result.sign_in_provider(), "custom"); + EXPECT_EQ(*result.sign_in_second_factor(), "phone"); + const auto& claims = *result.claims(); + EXPECT_EQ(std::get(claims.at(flutter::EncodableValue("orgId"))), + "test-org"); + EXPECT_EQ( + std::get(claims.at(flutter::EncodableValue("unicode"))), + "M\xc3\xbcnchen \xf0\x9f\x98\x80"); + EXPECT_EQ(std::get( + claims.at(flutter::EncodableValue("roles"))) + .size(), + 5); +} -TEST(FirebaseAuthPlugin, GetPlatformVersion) { - FirebaseAuthPlugin plugin; - // Save the reply value from the success callback. - std::string result_string; - plugin.HandleMethodCall( - MethodCall("getPlatformVersion", std::make_unique()), - std::make_unique>( - [&result_string](const EncodableValue* result) { - result_string = std::get(*result); - }, - nullptr, nullptr)); - - // Since the exact string varies by host, just ensure that it's a string - // with the expected format. - EXPECT_TRUE(result_string.rfind("Windows ", 0) == 0); +TEST(FirebaseAuthPlugin, KeepsTokenWhenClaimsCannotBeDecoded) { + for (const auto& token : + {std::string("invalid"), std::string("header.!."), Token("[]"), + Token("{"), Token("{\"x\":01}"), Token("{\"x\":\"\\ud800\"}")}) { + const auto result = FirebaseAuthPlugin::ParseIdTokenResult(token); + EXPECT_EQ(*result.token(), token); + EXPECT_EQ(result.claims(), nullptr); + EXPECT_EQ(result.auth_timestamp(), nullptr); + } } +TEST(FirebaseAuthPlugin, IgnoresMissingOrInvalidMetadata) { + const auto result = FirebaseAuthPlugin::ParseIdTokenResult(Token( + R"({"auth_time":"1","iat":9223372036854775807,"exp":null,"firebase":{"sign_in_provider":false},"ok":true})")); + ASSERT_NE(result.claims(), nullptr); + EXPECT_EQ(result.auth_timestamp(), nullptr); + EXPECT_EQ(result.issued_at_timestamp(), nullptr); + EXPECT_EQ(result.expiration_timestamp(), nullptr); + EXPECT_EQ(result.sign_in_provider(), nullptr); + EXPECT_EQ(result.sign_in_second_factor(), nullptr); +} + +} // namespace } // namespace test -} // namespace firebase_auth +} // namespace firebase_auth_windows