From e83323248af2b4e7c45dcba6e3f32a0b8fad88d5 Mon Sep 17 00:00:00 2001 From: austinbenoit Date: Wed, 14 Oct 2026 16:38:12 +0000 Subject: [PATCH 1/3] feat(auth): Add IdTokenResult and User::GetIdTokenResult support Implements IdTokenResult struct and User::GetIdTokenResult(bool force_refresh) with token parsing, custom claims extraction, and timestamp decoding. Supports Desktop, Android, and iOS platforms. Adds comprehensive unit tests, doc samples, and release notes. --- auth/CMakeLists.txt | 2 + auth/integration_test/src/integration_test.cc | 43 ++++ auth/samples/src/doc_samples.cc | 29 +++ auth/src/android/user_android.cc | 47 ++++ auth/src/data.h | 1 + auth/src/desktop/user_desktop.cc | 48 ++++ auth/src/id_token_util.cc | 120 +++++++++ auth/src/id_token_util.h | 36 +++ auth/src/include/firebase/auth/user.h | 65 +++++ auth/src/ios/fake/FIRAuthTokenResult.mm | 38 +++ auth/src/ios/fake/FIRUser.mm | 6 +- auth/src/ios/user_ios.mm | 25 ++ auth/src/user.cc | 13 + auth/tests/CMakeLists.txt | 10 + auth/tests/desktop/user_desktop_test.cc | 47 +++- auth/tests/id_token_result_test.cc | 231 ++++++++++++++++++ auth/tests/user_test.cc | 130 ++++++++++ release_build_files/readme.md | 6 + 18 files changed, 895 insertions(+), 2 deletions(-) create mode 100644 auth/src/id_token_util.cc create mode 100644 auth/src/id_token_util.h create mode 100644 auth/src/ios/fake/FIRAuthTokenResult.mm create mode 100644 auth/tests/id_token_result_test.cc diff --git a/auth/CMakeLists.txt b/auth/CMakeLists.txt index 2c1dbe3be3..82fcb39f58 100644 --- a/auth/CMakeLists.txt +++ b/auth/CMakeLists.txt @@ -55,6 +55,8 @@ set(common_SRCS src/common.cc src/common.h src/data.h + src/id_token_util.cc + src/id_token_util.h src/user.cc) # Define the resource build needed for Android diff --git a/auth/integration_test/src/integration_test.cc b/auth/integration_test/src/integration_test.cc index 175fa297f9..0e71720735 100644 --- a/auth/integration_test/src/integration_test.cc +++ b/auth/integration_test/src/integration_test.cc @@ -108,6 +108,13 @@ class FirebaseAuthTest : public FirebaseTest { return FirebaseTest::WaitForCompletion(future, fn, expected_error); } + // Passthrough method to the base class's WaitForCompletion. + bool WaitForCompletion(firebase::Future future, + const char* fn, + int expected_error = firebase::auth::kAuthErrorNone) { + return FirebaseTest::WaitForCompletion(future, fn, expected_error); + } + // Passthrough method to the base class's WaitForCompletion. bool WaitForCompletion(firebase::Future future, const char* fn, int expected_error = firebase::auth::kAuthErrorNone) { @@ -428,6 +435,42 @@ TEST_F(FirebaseAuthTest, TestTokensAndAuthStateListeners) { ElementsAre("", Not(""), Not(""), Not(""), ""))); } +TEST_F(FirebaseAuthTest, TestGetIdTokenResult) { + SignInAnonymously(); + ASSERT_TRUE(auth_->current_user().is_valid()); + + firebase::Future result_future = + auth_->current_user().GetIdTokenResult(false); + WaitForCompletion(result_future, "GetIdTokenResult(false)"); + ASSERT_NE(result_future.result(), nullptr); + const firebase::auth::IdTokenResult first_result = *result_future.result(); + EXPECT_NE(first_result.token, ""); + EXPECT_GT(first_result.auth_timestamp, 0ULL); + EXPECT_GT(first_result.issued_at_timestamp, 0ULL); + EXPECT_GT(first_result.expiration_timestamp, + first_result.issued_at_timestamp); + EXPECT_EQ(first_result.sign_in_provider, "anonymous"); + EXPECT_FALSE(first_result.claims.empty()); + auto sub_it = first_result.claims.find(firebase::Variant("sub")); + ASSERT_TRUE(sub_it != first_result.claims.end()); + EXPECT_TRUE(sub_it->second.is_string()); + EXPECT_EQ(std::string(sub_it->second.string_value()), + auth_->current_user().uid()); + + ProcessEvents(1000); + result_future = auth_->current_user().GetIdTokenResult(true); + WaitForCompletion(result_future, "GetIdTokenResult(true)"); + ASSERT_NE(result_future.result(), nullptr); + const firebase::auth::IdTokenResult second_result = *result_future.result(); + EXPECT_NE(second_result.token, ""); + EXPECT_NE(first_result.token, second_result.token); + EXPECT_GE(second_result.issued_at_timestamp, + first_result.issued_at_timestamp); + + DeleteUser(); + SignOut(); +} + #if FIREBASE_PLATFORM_ANDROID #define INTEGRATION_TEST_PLATFORM_NAME "Android" #elif FIREBASE_PLATFORM_IOS diff --git a/auth/samples/src/doc_samples.cc b/auth/samples/src/doc_samples.cc index 5f1fbf91f9..49221ada0d 100644 --- a/auth/samples/src/doc_samples.cc +++ b/auth/samples/src/doc_samples.cc @@ -644,6 +644,35 @@ void SendIdTokenToBackend(firebase::auth::Auth* auth) { // [END send_id_token_to_backend] } +void GetIdTokenClaims(firebase::auth::Auth* auth) { + // [START get_id_token_claims] + firebase::auth::User user = auth->current_user(); + if (user.is_valid()) { + user.GetIdTokenResult(/*force_refresh=*/false) + .OnCompletion( + [](const firebase::Future& result) { + if (result.error() == firebase::auth::kAuthErrorNone) { + const firebase::auth::IdTokenResult* token_result = + result.result(); + printf("Token: %s\n", token_result->token.c_str()); + printf("Auth timestamp: %llu\n", + static_cast( + token_result->auth_timestamp)); + printf("Expiration timestamp: %llu\n", + static_cast( + token_result->expiration_timestamp)); + // Check custom claims + auto it = token_result->claims.find(firebase::Variant("admin")); + if (it != token_result->claims.end() && it->second.is_bool() && + it->second.bool_value()) { + printf("User is an admin!\n"); + } + } + }); + } + // [END get_id_token_claims] +} + firebase::auth::Auth* AuthOverview(firebase::App* app) { /// [Auth overview] // Get the Auth class for your App. diff --git a/auth/src/android/user_android.cc b/auth/src/android/user_android.cc index 893edf4ae5..54fb5842b2 100644 --- a/auth/src/android/user_android.cc +++ b/auth/src/android/user_android.cc @@ -20,6 +20,7 @@ #include "app/src/time.h" #include "auth/src/android/common_android.h" +#include "auth/src/id_token_util.h" namespace firebase { namespace auth { @@ -316,6 +317,52 @@ Future User::GetToken(bool force_refresh) { return MakeFuture(&futures, handle); } +void ReadIdTokenResult(jobject result, FutureCallbackData* d, + bool success, void* void_data) { + auto data = static_cast(void_data); + JNIEnv* env = Env(d->auth_data); + + // `result` is of type GetTokenResult when `success` is true. + if (success) { + if (d->auth_data->ShouldTriggerIdTokenListenerCallback()) { + NotifyIdTokenListeners(d->auth_data); + } + // This comes from the successfully completed Task in Java, so it should + // always be valid. + FIREBASE_ASSERT(result != nullptr); + jstring j_token = static_cast(env->CallObjectMethod( + result, tokenresult::GetMethodId(tokenresult::kGetToken))); + assert(env->ExceptionCheck() == false); + std::string token = JniStringToString(env, j_token); + ParseIdToken(token, data); + } else { + *data = IdTokenResult(); + } +} + +Future User::GetIdTokenResult(bool force_refresh) { + if (!ValidUser(auth_data_)) { + return Future(); + } + ReferenceCountedFutureImpl& futures = auth_data_->future_impl; + const auto handle = + futures.SafeAlloc(kUserFn_GetIdTokenResult); + JNIEnv* env = Env(auth_data_); + + auth_data_->SetExpectIdTokenListenerCallback(force_refresh); + jobject pending_result = env->CallObjectMethod( + UserImpl(auth_data_), user::GetMethodId(user::kToken), force_refresh); + + if (!CheckAndCompleteFutureOnError(env, &futures, handle)) { + RegisterCallback(pending_result, handle, auth_data_, ReadIdTokenResult); + env->DeleteLocalRef(pending_result); + } else { + // If the method failed for some reason, clear the expected callback. + auth_data_->SetExpectIdTokenListenerCallback(false); + } + return MakeFuture(&futures, handle); +} + std::vector User::provider_data() const { std::vector provider_data; if (ValidUser(auth_data_)) { diff --git a/auth/src/data.h b/auth/src/data.h index 9252429d3f..75cb1ae857 100644 --- a/auth/src/data.h +++ b/auth/src/data.h @@ -43,6 +43,7 @@ enum AuthApiFunction { // External functions in the User API. kUserFn_GetToken, + kUserFn_GetIdTokenResult, kUserFn_UpdateEmail, kUserFn_UpdatePassword, kUserFn_Reauthenticate, diff --git a/auth/src/desktop/user_desktop.cc b/auth/src/desktop/user_desktop.cc index 276940436a..18e5f3e42e 100644 --- a/auth/src/desktop/user_desktop.cc +++ b/auth/src/desktop/user_desktop.cc @@ -50,6 +50,7 @@ #include "auth/src/desktop/sign_in_flow.h" #include "auth/src/desktop/user_view.h" #include "auth/src/desktop/validate_credential.h" +#include "auth/src/id_token_util.h" #include "auth/src/include/firebase/auth/types.h" #include "auth/user_data_generated.h" #include "auth/user_data_resource.h" @@ -698,6 +699,53 @@ Future User::GetTokenInternal(const bool force_refresh, callback); } +Future User::GetIdTokenResult(const bool force_refresh) { + if (auth_data_ == nullptr) { + return Future(); + } + Promise promise(&auth_data_->future_impl, + kUserFn_GetIdTokenResult); + if (!ValidateCurrentUser(&promise, auth_data_)) { + return promise.LastResult(); + } + + GetTokenResult current_token(kAuthErrorFailure); + const bool is_user_logged_in = + UserView::TryRead(auth_data_, [&](const UserView::Reader& user) { + current_token = GetTokenIfFresh(user, force_refresh); + }); + + if (!is_user_logged_in) { + auto future = promise.future(); + future.Release(); + return future; + } + if (current_token.IsValid()) { + IdTokenResult id_token_result; + ParseIdToken(current_token.token(), &id_token_result); + promise.CompleteWithResult(id_token_result); + return promise.future(); + } + + const auto callback = + [](AuthDataHandle* const handle) { + const GetTokenResult get_token_result = + EnsureFreshToken(handle->auth_data, true); + if (!get_token_result.IsValid()) { + FailPromise(&handle->promise, get_token_result.error()); + return; + } + + IdTokenResult id_token_result; + ParseIdToken(get_token_result.token(), &id_token_result); + handle->promise.CompleteWithResult(id_token_result); + }; + + // Note: request is deliberately null because EnsureFreshToken will create it. + return CallAsync(auth_data_, promise, std::unique_ptr(), + callback); +} + Future User::Delete() { if (auth_data_ == nullptr) { // user is not valid return Future(); diff --git a/auth/src/id_token_util.cc b/auth/src/id_token_util.cc new file mode 100644 index 0000000000..bbd82a19cc --- /dev/null +++ b/auth/src/id_token_util.cc @@ -0,0 +1,120 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "auth/src/id_token_util.h" + +#include +#include + +#include "app/src/base64.h" +#include "app/src/variant_util.h" + +namespace firebase { +namespace auth { + +namespace { + +uint64_t ExtractTimestamp(const std::map& claims, + const char* key) { + auto it = claims.find(Variant(key)); + if (it != claims.end()) { + if (it->second.is_int64() && it->second.int64_value() > 0) { + return static_cast(it->second.int64_value()); + } else if (it->second.is_double() && it->second.double_value() > 0) { + return static_cast(it->second.double_value()); + } + } + return 0; +} + +} // namespace + +bool ParseIdToken(const std::string& token, IdTokenResult* out_result) { + if (out_result == nullptr) { + return false; + } + + out_result->token = token; + out_result->claims.clear(); + out_result->auth_timestamp = 0; + out_result->issued_at_timestamp = 0; + out_result->expiration_timestamp = 0; + out_result->sign_in_provider.clear(); + out_result->sign_in_second_factor.clear(); + + if (token.empty()) { + return false; + } + + // A JWT is composed of three dot-separated Base64URL parts: + // header.payload.signature + const size_t first_dot = token.find('.'); + if (first_dot == std::string::npos) { + return false; + } + const size_t second_dot = token.find('.', first_dot + 1); + if (second_dot == std::string::npos) { + return false; + } + if (token.find('.', second_dot + 1) != std::string::npos) { + return false; + } + + const std::string payload_b64 = + token.substr(first_dot + 1, second_dot - (first_dot + 1)); + if (payload_b64.empty()) { + return false; + } + + std::string payload_json; + if (!internal::Base64Decode(payload_b64, &payload_json)) { + return false; + } + + const Variant payload_variant = util::JsonToVariant(payload_json.c_str()); + if (!payload_variant.is_map()) { + return false; + } + + out_result->claims = payload_variant.map(); + + out_result->auth_timestamp = + ExtractTimestamp(out_result->claims, "auth_time"); + out_result->issued_at_timestamp = ExtractTimestamp(out_result->claims, "iat"); + out_result->expiration_timestamp = + ExtractTimestamp(out_result->claims, "exp"); + + const auto firebase_it = out_result->claims.find(Variant("firebase")); + if (firebase_it != out_result->claims.end() && firebase_it->second.is_map()) { + const auto& firebase_map = firebase_it->second.map(); + const auto provider_it = firebase_map.find(Variant("sign_in_provider")); + if (provider_it != firebase_map.end() && provider_it->second.is_string()) { + out_result->sign_in_provider = provider_it->second.string_value(); + } + const auto second_factor_it = + firebase_map.find(Variant("sign_in_second_factor")); + if (second_factor_it != firebase_map.end() && + second_factor_it->second.is_string()) { + out_result->sign_in_second_factor = + second_factor_it->second.string_value(); + } + } + + return true; +} + +} // namespace auth +} // namespace firebase diff --git a/auth/src/id_token_util.h b/auth/src/id_token_util.h new file mode 100644 index 0000000000..5ad1213501 --- /dev/null +++ b/auth/src/id_token_util.h @@ -0,0 +1,36 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef FIREBASE_AUTH_SRC_ID_TOKEN_UTIL_H_ +#define FIREBASE_AUTH_SRC_ID_TOKEN_UTIL_H_ + +#include + +#include "firebase/auth/user.h" + +namespace firebase { +namespace auth { + +// Parses an encoded JWT ID token string into an IdTokenResult struct. +// Populates token, claims, auth_timestamp, issued_at_timestamp, +// expiration_timestamp, sign_in_provider, and sign_in_second_factor. +// Returns true if parsing was successful, false if the token was malformed. +bool ParseIdToken(const std::string& token, IdTokenResult* out_result); + +} // namespace auth +} // namespace firebase + +#endif // FIREBASE_AUTH_SRC_ID_TOKEN_UTIL_H_ diff --git a/auth/src/include/firebase/auth/user.h b/auth/src/include/firebase/auth/user.h index 532ec9b8f9..bfcccd1955 100644 --- a/auth/src/include/firebase/auth/user.h +++ b/auth/src/include/firebase/auth/user.h @@ -19,6 +19,7 @@ #include +#include #include #include @@ -152,6 +153,48 @@ struct UserMetadata { uint64_t creation_timestamp; }; +/// @brief Result of a GetIdTokenResult call, containing the token string and +/// decoded claims. +struct IdTokenResult { + /// The JSON Web Token (JWT) string. + std::string token; + + /// Claims map decoded from the token payload, including custom claims and + /// standard claims. + std::map claims; + + /// Authentication timestamp (seconds since Unix epoch). + uint64_t auth_timestamp; + + /// Issued-at timestamp (seconds since Unix epoch). + uint64_t issued_at_timestamp; + + /// Expiration timestamp (seconds since Unix epoch). + uint64_t expiration_timestamp; + + /// The sign-in provider through which the token was obtained. + std::string sign_in_provider; + + /// The sign-in second factor through which the token was obtained. + std::string sign_in_second_factor; + + IdTokenResult() + : auth_timestamp(0), issued_at_timestamp(0), expiration_timestamp(0) {} + + bool operator==(const IdTokenResult& other) const { + return token == other.token && claims == other.claims && + auth_timestamp == other.auth_timestamp && + issued_at_timestamp == other.issued_at_timestamp && + expiration_timestamp == other.expiration_timestamp && + sign_in_provider == other.sign_in_provider && + sign_in_second_factor == other.sign_in_second_factor; + } + + bool operator!=(const IdTokenResult& other) const { + return !(*this == other); + } +}; + /// @brief Firebase user account object. /// /// This class allows you to manipulate the profile of a user, link to and @@ -219,6 +262,28 @@ class User : public UserInfoInterface { /// Get results of the most recent call to GetToken. Future GetTokenLastResult() const; + /// Retrieves the Firebase authentication token result, including claims and + /// timestamps. + /// + /// If a current ID token is still believed to be valid (i.e. it has not yet + /// expired), that token will be returned immediately. + /// A developer may set the optional force_refresh flag to get a new ID token, + /// whether or not the existing token has expired. + Future GetIdTokenResult(bool force_refresh); + +#if defined(INTERNAL_EXPERIMENTAL) || defined(SWIG) + /// A "thread safer" version of GetIdTokenResult. + /// If called by two threads simultaneously, GetIdTokenResult can return the + /// same pending Future twice. This creates problems if both threads try to + /// set the OnCompletion callback, unaware that there's another copy. + /// GetIdTokenResultThreadSafe returns a proxy to the Future if it's still + /// pending, allowing each proxy to have their own callback. + Future GetIdTokenResultThreadSafe(bool force_refresh); +#endif // defined(INTERNAL_EXPERIMENTAL) || defined(SWIG) + + /// Get results of the most recent call to GetIdTokenResult. + Future GetIdTokenResultLastResult() const; + /// Gets the third party profile data associated with this user returned by /// the authentication server, if any. /// diff --git a/auth/src/ios/fake/FIRAuthTokenResult.mm b/auth/src/ios/fake/FIRAuthTokenResult.mm new file mode 100644 index 0000000000..ba4d5d21c2 --- /dev/null +++ b/auth/src/ios/fake/FIRAuthTokenResult.mm @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import "auth/src/ios/fake/FIRAuthTokenResult.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation FIRAuthTokenResult + +- (instancetype)init { + self = [super init]; + if (self) { + _token = @"a fake token"; + _expirationDate = [NSDate dateWithTimeIntervalSince1970:1]; + _authDate = [NSDate dateWithTimeIntervalSince1970:1]; + _issuedAtDate = [NSDate dateWithTimeIntervalSince1970:1]; + _signInProvider = @"custom"; + _claims = @{}; + } + return self; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/auth/src/ios/fake/FIRUser.mm b/auth/src/ios/fake/FIRUser.mm index ab8967c879..998d0fe046 100644 --- a/auth/src/ios/fake/FIRUser.mm +++ b/auth/src/ios/fake/FIRUser.mm @@ -20,6 +20,7 @@ #import "auth/src/ios/fake/FIRUser.h" +#import "auth/src/ios/fake/FIRAuthTokenResult.h" #import "auth/src/ios/fake/FIRUserMetadata.h" NS_ASSUME_NONNULL_BEGIN @@ -94,7 +95,10 @@ - (void)reauthenticateWithCredential:(FIRAuthCredential *)credential - (void)getIDTokenResultWithCompletion:(nullable FIRAuthTokenResultCallback)completion {} - (void)getIDTokenResultForcingRefresh:(BOOL)forceRefresh - completion:(nullable FIRAuthTokenResultCallback)completion {} + completion:(nullable FIRAuthTokenResultCallback)completion { + _callbackManager.Add(@"FIRUser.getIDTokenResultForcingRefresh:completion:", completion, + [[FIRAuthTokenResult alloc] init]); +} - (void)getIDTokenForcingRefresh:(BOOL)forceRefresh completion:(nullable FIRAuthTokenCallback)completion { diff --git a/auth/src/ios/user_ios.mm b/auth/src/ios/user_ios.mm index 0702eac4d5..ddcf2fb1a9 100644 --- a/auth/src/ios/user_ios.mm +++ b/auth/src/ios/user_ios.mm @@ -16,6 +16,7 @@ #include #include "app/src/time.h" +#include "auth/src/id_token_util.h" #include "auth/src/ios/common_ios.h" namespace firebase { @@ -79,6 +80,30 @@ explicit IOSWrappedUserInfo(id user_info) : user_info_(user_info) { return MakeFuture(&futures, handle); } +Future User::GetIdTokenResult(bool force_refresh) { + if (!ValidUser(auth_data_)) { + return Future(); + } + ReferenceCountedFutureImpl &futures = auth_data_->future_impl; + const auto handle = futures.SafeAlloc(kUserFn_GetIdTokenResult); + [UserImpl(auth_data_) + getIDTokenResultForcingRefresh:force_refresh + completion:^(FIRAuthTokenResult *_Nullable token_result, + NSError *_Nullable error) { + futures.Complete( + handle, AuthErrorFromNSError(error), + [error.localizedDescription UTF8String], + [token_result](IdTokenResult *data) { + if (token_result != nullptr && token_result.token != nil) { + ParseIdToken([token_result.token UTF8String], data); + } else { + *data = IdTokenResult(); + } + }); + }]; + return MakeFuture(&futures, handle); +} + std::vector User::provider_data() const { std::vector provider_data; if (ValidUser(auth_data_)) { diff --git a/auth/src/user.cc b/auth/src/user.cc index 4f48f6d7f0..5b20dc6b6a 100644 --- a/auth/src/user.cc +++ b/auth/src/user.cc @@ -20,6 +20,7 @@ namespace firebase { namespace auth { AUTH_RESULT_FN(User, GetToken, std::string) +AUTH_RESULT_FN(User, GetIdTokenResult, IdTokenResult) AUTH_RESULT_FN(User, UpdatePassword, void) AUTH_RESULT_FN(User, LinkWithCredential, AuthResult) AUTH_RESULT_FN(User, Reauthenticate, void) @@ -49,6 +50,18 @@ Future User::GetTokenThreadSafe(bool force_refresh) { return static_cast&>(rFuture); } } + +Future User::GetIdTokenResultThreadSafe(bool force_refresh) { + Future future = GetIdTokenResult(force_refresh); + if (future.status() != kFutureStatusPending) { + return future; + } else { + FutureBase base = + auth_data_->future_impl.LastResultProxy(kUserFn_GetIdTokenResult); + const FutureBase& rFuture = base; + return static_cast&>(rFuture); + } +} #endif // defined(INTERNAL_EXPERIMENTAL) // Non-inline implementation of UserInfoInterface's virtual destructor diff --git a/auth/tests/CMakeLists.txt b/auth/tests/CMakeLists.txt index 7950353858..92ebb6957a 100644 --- a/auth/tests/CMakeLists.txt +++ b/auth/tests/CMakeLists.txt @@ -106,6 +106,16 @@ firebase_cpp_cc_test( firebase_testing ) +firebase_cpp_cc_test( + firebase_auth_id_token_result_test + SOURCES + id_token_result_test.cc + DEPENDS + firebase_app_for_testing + firebase_auth + firebase_testing +) + firebase_cpp_cc_test_on_ios( firebase_auth_credential_test HOST diff --git a/auth/tests/desktop/user_desktop_test.cc b/auth/tests/desktop/user_desktop_test.cc index 631dcd2a42..4cd1b2ebfa 100644 --- a/auth/tests/desktop/user_desktop_test.cc +++ b/auth/tests/desktop/user_desktop_test.cc @@ -298,7 +298,9 @@ class UserDesktopTest : public ::testing::Test { // This is to avoid race conditions where the next test's SignIn // doesn't cause a change in auth state or id tokens because persistent // cache has valid user logged in, preventing listeners from firing. - { SleepUponDestruction sleep_for_listeners; } + { + SleepUponDestruction sleep_for_listeners; + } firebase_auth_.reset(nullptr); firebase_app_.reset(nullptr); @@ -378,6 +380,49 @@ TEST_F(UserDesktopTest, TestGetToken) { EXPECT_EQ("new idtoken123", new_token); } +TEST_F(UserDesktopTest, TestGetIdTokenResult) { + const std::string jwt_token = + "eyJhbGciOiJub25lIn0." + "eyJhdXRoX3RpbWUiOjE2MDAwMDAwMDAsImlhdCI6MTYwMDAwMDAwMSwiZXhwIjoxNjAwMDAz" + "NjAwLCJhZG1pbiI6dHJ1ZSwiZmlyZWJhc2UiOnsic2lnbl9pbl9wcm92aWRlciI6ImN1c3Rv" + "bSJ9fQ." + "sig"; + const auto api_url = + std::string("https://securetoken.googleapis.com/v1/token?key=") + API_KEY; + InitializeConfigWithAFake( + api_url, FakeSuccessfulResponse( + std::string("\"access_token\": \"new accesstoken123\"," + "\"expires_in\": \"3600\"," + "\"token_type\": \"Bearer\"," + "\"refresh_token\": \"new refreshtoken123\"," + "\"id_token\": \"") + + jwt_token + + "\"," + "\"user_id\": \"localid123\"," + "\"project_id\": \"53101460582\"")); + + id_token_listener.ExpectChanges(1); + auth_state_listener.ExpectChanges(0); + + // Initial cached token from SetUp. + IdTokenResult initial_result = + WaitForFuture(firebase_user_.GetIdTokenResult(false)); + EXPECT_EQ("idtoken123", initial_result.token); + + // Force refresh to get the JWT and verify parsed claims & timestamps. + const IdTokenResult refreshed_result = + WaitForFuture(firebase_user_.GetIdTokenResult(true)); + EXPECT_EQ(jwt_token, refreshed_result.token); + EXPECT_EQ(1600000000ULL, refreshed_result.auth_timestamp); + EXPECT_EQ(1600000001ULL, refreshed_result.issued_at_timestamp); + EXPECT_EQ(1600003600ULL, refreshed_result.expiration_timestamp); + EXPECT_EQ("custom", refreshed_result.sign_in_provider); + auto admin_it = refreshed_result.claims.find(Variant("admin")); + ASSERT_NE(refreshed_result.claims.end(), admin_it); + EXPECT_TRUE(admin_it->second.is_bool()); + EXPECT_TRUE(admin_it->second.bool_value()); +} + TEST_F(UserDesktopTest, TestDelete) { InitializeConfigWithAFake( GetUrlForApi(API_KEY, "deleteAccount"), diff --git a/auth/tests/id_token_result_test.cc b/auth/tests/id_token_result_test.cc new file mode 100644 index 0000000000..b02b6a700c --- /dev/null +++ b/auth/tests/id_token_result_test.cc @@ -0,0 +1,231 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include "app/src/base64.h" +#include "auth/src/id_token_util.h" +#include "firebase/auth/user.h" +#include "firebase/variant.h" +#include "gtest/gtest.h" + +namespace firebase { +namespace auth { +namespace { + +std::string MakeJwt(const std::string& header_json, + const std::string& payload_json, + const std::string& signature = "sig") { + std::string header_b64; + std::string payload_b64; + internal::Base64EncodeUrlSafe(header_json, &header_b64); + internal::Base64EncodeUrlSafe(payload_json, &payload_b64); + return header_b64 + "." + payload_b64 + "." + signature; +} + +TEST(IdTokenResultTest, ParseValidTokenWithStandardClaims) { + const std::string payload = + "{" + "\"auth_time\": 1600000000," + "\"iat\": 1600000001," + "\"exp\": 1600003600," + "\"sub\": \"user_12345\"," + "\"firebase\": {" + " \"sign_in_provider\": \"google.com\"," + " \"sign_in_second_factor\": \"phone\"" + "}" + "}"; + const std::string token = MakeJwt("{\"alg\":\"none\"}", payload); + + IdTokenResult result; + EXPECT_TRUE(ParseIdToken(token, &result)); + EXPECT_EQ(token, result.token); + EXPECT_EQ(1600000000ULL, result.auth_timestamp); + EXPECT_EQ(1600000001ULL, result.issued_at_timestamp); + EXPECT_EQ(1600003600ULL, result.expiration_timestamp); + EXPECT_EQ("google.com", result.sign_in_provider); + EXPECT_EQ("phone", result.sign_in_second_factor); + + auto sub_it = result.claims.find(Variant("sub")); + ASSERT_NE(result.claims.end(), sub_it); + EXPECT_TRUE(sub_it->second.is_string()); + EXPECT_STREQ("user_12345", sub_it->second.string_value()); +} + +TEST(IdTokenResultTest, ParseValidTokenWithCustomClaims) { + const std::string payload = + "{" + "\"auth_time\": 1600000000," + "\"iat\": 1600000000," + "\"exp\": 1600003600," + "\"admin\": true," + "\"roles\": [\"editor\", \"viewer\"]," + "\"user_tier\": 3," + "\"firebase\": {" + " \"sign_in_provider\": \"password\"" + "}" + "}"; + const std::string token = MakeJwt("{\"alg\":\"none\"}", payload); + + IdTokenResult result; + EXPECT_TRUE(ParseIdToken(token, &result)); + EXPECT_EQ("password", result.sign_in_provider); + EXPECT_TRUE(result.sign_in_second_factor.empty()); + + // Check boolean custom claim + auto admin_it = result.claims.find(Variant("admin")); + ASSERT_NE(result.claims.end(), admin_it); + EXPECT_TRUE(admin_it->second.is_bool()); + EXPECT_TRUE(admin_it->second.bool_value()); + + // Check integer custom claim + auto tier_it = result.claims.find(Variant("user_tier")); + ASSERT_NE(result.claims.end(), tier_it); + EXPECT_TRUE(tier_it->second.is_int64()); + EXPECT_EQ(3, tier_it->second.int64_value()); + + // Check vector custom claim + auto roles_it = result.claims.find(Variant("roles")); + ASSERT_NE(result.claims.end(), roles_it); + EXPECT_TRUE(roles_it->second.is_vector()); + EXPECT_EQ(2, roles_it->second.vector().size()); + EXPECT_STREQ("editor", roles_it->second.vector()[0].string_value()); + EXPECT_STREQ("viewer", roles_it->second.vector()[1].string_value()); +} + +TEST(IdTokenResultTest, ParseBase64UrlSpecialCharsAndUnpaddedLengths) { + // Any 3 consecutive '>' (0x3e) guarantees one at byte index % 3 == 2 -> '-', + // and any 3 consecutive '?' (0x3f) guarantees one at byte index % 3 == 2 -> + // '_'. Input length % 3 == 1 produces unpadded Base64 length % 4 == 2. + const std::string payload_mod2 = + "{\"url\":\">>>???\",\"iat\":1700000000," + "\"exp\":1700003600,\"auth_time\":1699999999,\"pad\":\"\"}"; + ASSERT_EQ(1U, payload_mod2.size() % 3); + std::string payload_b64_mod2; + internal::Base64EncodeUrlSafe(payload_mod2, &payload_b64_mod2); + EXPECT_NE(std::string::npos, payload_b64_mod2.find('-')); + EXPECT_NE(std::string::npos, payload_b64_mod2.find('_')); + EXPECT_EQ(std::string::npos, payload_b64_mod2.find('=')); + EXPECT_EQ(2U, payload_b64_mod2.size() % 4); + + const std::string token_mod2 = + "eyJhbGciOiJub25lIn0." + payload_b64_mod2 + ".sig"; + IdTokenResult result_mod2; + EXPECT_TRUE(ParseIdToken(token_mod2, &result_mod2)); + EXPECT_EQ(1699999999ULL, result_mod2.auth_timestamp); + EXPECT_EQ(1700000000ULL, result_mod2.issued_at_timestamp); + EXPECT_EQ(1700003600ULL, result_mod2.expiration_timestamp); + EXPECT_STREQ(">>>???", result_mod2.claims[Variant("url")].string_value()); + + // Input length % 3 == 2 produces unpadded Base64 length % 4 == 3. + const std::string payload_mod3 = + "{\"url\":\">>>???\",\"iat\":1700000000," + "\"exp\":1700003600,\"auth_time\":1699999999,\"pad\":\"x\"}"; + ASSERT_EQ(2U, payload_mod3.size() % 3); + std::string payload_b64_mod3; + internal::Base64EncodeUrlSafe(payload_mod3, &payload_b64_mod3); + EXPECT_EQ(3U, payload_b64_mod3.size() % 4); + EXPECT_EQ(std::string::npos, payload_b64_mod3.find('=')); + + const std::string token_mod3 = + "eyJhbGciOiJub25lIn0." + payload_b64_mod3 + ".sig"; + IdTokenResult result_mod3; + EXPECT_TRUE(ParseIdToken(token_mod3, &result_mod3)); + EXPECT_EQ(1700000000ULL, result_mod3.issued_at_timestamp); + EXPECT_STREQ("x", result_mod3.claims[Variant("pad")].string_value()); +} + +TEST(IdTokenResultTest, NullPointerReturnsFalse) { + EXPECT_FALSE(ParseIdToken("header.payload.sig", nullptr)); +} + +TEST(IdTokenResultTest, EmptyTokenReturnsFalse) { + IdTokenResult result; + EXPECT_FALSE(ParseIdToken("", &result)); +} + +TEST(IdTokenResultTest, MalformedTokenNoDots) { + IdTokenResult result; + EXPECT_FALSE(ParseIdToken("invalidtokenwithoutdots", &result)); +} + +TEST(IdTokenResultTest, MalformedTokenOnlyOneDot) { + IdTokenResult result; + EXPECT_FALSE(ParseIdToken("header.payload", &result)); +} + +TEST(IdTokenResultTest, MalformedTokenTooManyDots) { + IdTokenResult result; + EXPECT_FALSE(ParseIdToken("header.payload.sig.extra", &result)); +} + +TEST(IdTokenResultTest, MalformedTokenInvalidBase64) { + IdTokenResult result; + EXPECT_FALSE(ParseIdToken("header.???invalidbase64???.sig", &result)); +} + +TEST(IdTokenResultTest, MalformedTokenNotJsonPayload) { + std::string b64; + internal::Base64EncodeUrlSafe("this is not json", &b64); + const std::string token = "header." + b64 + ".sig"; + + IdTokenResult result; + EXPECT_FALSE(ParseIdToken(token, &result)); +} + +TEST(IdTokenResultTest, MalformedTokenNonObjectJsonPayload) { + std::string b64; + internal::Base64EncodeUrlSafe("\"just a json string\"", &b64); + const std::string token = "header." + b64 + ".sig"; + + IdTokenResult result; + EXPECT_FALSE(ParseIdToken(token, &result)); +} + +TEST(IdTokenResultTest, EqualityOperators) { + IdTokenResult r1; + r1.token = "token1"; + r1.auth_timestamp = 100; + r1.issued_at_timestamp = 101; + r1.expiration_timestamp = 102; + r1.sign_in_provider = "google.com"; + r1.sign_in_second_factor = "phone"; + r1.claims[Variant("key")] = Variant("value"); + + IdTokenResult r2 = r1; + EXPECT_EQ(r1, r2); + EXPECT_FALSE(r1 != r2); + + r2.token = "token2"; + EXPECT_NE(r1, r2); + EXPECT_FALSE(r1 == r2); + + r2 = r1; + r2.claims[Variant("key2")] = Variant(42); + EXPECT_NE(r1, r2); + + r2 = r1; + r2.auth_timestamp = 999; + EXPECT_NE(r1, r2); + + r2 = r1; + r2.sign_in_provider = "apple.com"; + EXPECT_NE(r1, r2); +} + +} // namespace +} // namespace auth +} // namespace firebase diff --git a/auth/tests/user_test.cc b/auth/tests/user_test.cc index 79ed0d933e..abf9b357f2 100644 --- a/auth/tests/user_test.cc +++ b/auth/tests/user_test.cc @@ -21,6 +21,7 @@ #include "testing/run_all_tests.h" #endif // defined(FIREBASE_ANDROID_FOR_DESKTOP) +#include "app/src/base64.h" #include "app/src/include/firebase/app.h" #include "app/src/include/firebase/internal/platform.h" #include "app/src/time.h" @@ -250,6 +251,135 @@ TEST_F(UserTest, TestGetToken) { EXPECT_FALSE(token.result()->empty()); } +TEST_F(UserTest, TestGetIdTokenResult) { + firebase::testing::cppsdk::ConfigSet( + "{" + " config:[" + " {fake:'FirebaseUser.getIdToken', futuregeneric:{ticker:1}}," + " {fake:'FIRUser.getIDTokenResultForcingRefresh:completion:'," + " futuregeneric:{ticker:1}}," + " {" + " fake: '" + "https://securetoken.googleapis.com/v1/token?key=not_a_real_api_key'," + " httpresponse: {" + " header: ['HTTP/1.1 200 Ok','Server:mock server 101']," + " body: ['{" + " \"access_token\": \"fake_access_token\"," + " \"expires_in\": \"3600\"," + " \"token_type\": \"Bearer\"," + " \"refresh_token\": \"fake_refresh_token\"," + " \"id_token\": \"fake_id_token\"," + " \"user_id\": \"fake_user_id\"," + " \"project_id\": \"fake_project_id\"" + " }']" + " }" + " }" + " ]" + "}"); + Future token_result = + firebase_user_.GetIdTokenResult(false /* force_refresh */); + + Verify(token_result); + ASSERT_NE(nullptr, token_result.result()); + EXPECT_FALSE(token_result.result()->token.empty()); + + Future last_result = + firebase_user_.GetIdTokenResultLastResult(); + EXPECT_EQ(firebase::kFutureStatusComplete, last_result.status()); + EXPECT_EQ(0, last_result.error()); + ASSERT_NE(nullptr, last_result.result()); + EXPECT_EQ(*token_result.result(), *last_result.result()); + + User invalid_user; + Future invalid_future = invalid_user.GetIdTokenResult(false); + EXPECT_EQ(firebase::kFutureStatusInvalid, invalid_future.status()); +} + +#if !defined(FIREBASE_ANDROID_FOR_DESKTOP) && !FIREBASE_PLATFORM_IOS && \ + !FIREBASE_PLATFORM_TVOS +TEST_F(UserTest, TestGetIdTokenResultDecodesJwtClaimsAndTimestamps) { + std::string header_b64; + std::string payload_b64; + internal::Base64EncodeUrlSafe("{\"alg\":\"RS256\",\"typ\":\"JWT\"}", + &header_b64); + internal::Base64EncodeUrlSafe( + "{" + "\"auth_time\":1600000000," + "\"iat\":1600000010," + "\"exp\":1600003610," + "\"sub\":\"localid123\"," + "\"admin\":true," + "\"tier\":5," + "\"role\":\"moderator\"," + "\"firebase\":{" + " \"sign_in_provider\":\"custom\"," + " \"sign_in_second_factor\":\"totp\"" + "}" + "}", + &payload_b64); + const std::string jwt_token = header_b64 + "." + payload_b64 + ".fakesig"; + + const std::string config = + "{" + " config:[" + " {" + " fake: '" + "https://securetoken.googleapis.com/v1/token?key=not_a_real_api_key'," + " httpresponse: {" + " header: ['HTTP/1.1 200 Ok','Server:mock server 101']," + " body: ['{" + " \"access_token\": \"fake_access_token\"," + " \"expires_in\": \"3600\"," + " \"token_type\": \"Bearer\"," + " \"refresh_token\": \"fake_refresh_token\"," + " \"id_token\": \"" + + jwt_token + + "\"," + " \"user_id\": \"localid123\"," + " \"project_id\": \"fake_project_id\"" + " }']" + " }" + " }" + " ]" + "}"; + firebase::testing::cppsdk::ConfigSet(config.c_str()); + + // Force refresh to fetch the JWT from the mocked securetoken endpoint. + Future future = firebase_user_.GetIdTokenResult(true); + Verify(future); + ASSERT_NE(nullptr, future.result()); + const IdTokenResult& result = *future.result(); + EXPECT_EQ(jwt_token, result.token); + EXPECT_EQ(1600000000ULL, result.auth_timestamp); + EXPECT_EQ(1600000010ULL, result.issued_at_timestamp); + EXPECT_EQ(1600003610ULL, result.expiration_timestamp); + EXPECT_EQ("custom", result.sign_in_provider); + EXPECT_EQ("totp", result.sign_in_second_factor); + + auto admin_it = result.claims.find(Variant("admin")); + ASSERT_NE(result.claims.end(), admin_it); + EXPECT_TRUE(admin_it->second.is_bool()); + EXPECT_TRUE(admin_it->second.bool_value()); + + auto tier_it = result.claims.find(Variant("tier")); + ASSERT_NE(result.claims.end(), tier_it); + EXPECT_TRUE(tier_it->second.is_int64()); + EXPECT_EQ(5, tier_it->second.int64_value()); + + auto role_it = result.claims.find(Variant("role")); + ASSERT_NE(result.claims.end(), role_it); + EXPECT_TRUE(role_it->second.is_string()); + EXPECT_STREQ("moderator", role_it->second.string_value()); + + // Subsequent non-forced call returns the cached fresh JWT result. + Future cached_future = firebase_user_.GetIdTokenResult(false); + Verify(cached_future); + ASSERT_NE(nullptr, cached_future.result()); + EXPECT_EQ(result, *cached_future.result()); +} +#endif // !defined(FIREBASE_ANDROID_FOR_DESKTOP) && !FIREBASE_PLATFORM_IOS && + // !FIREBASE_PLATFORM_TVOS + TEST_F(UserTest, TestGetProviderData) { // Test get provider data. Right now, most of the sign-in does not have extra // data coming from providers. diff --git a/release_build_files/readme.md b/release_build_files/readme.md index 903558761e..279758ffa8 100644 --- a/release_build_files/readme.md +++ b/release_build_files/readme.md @@ -613,6 +613,12 @@ workflow use only during the development of your app, not for publicly shipping code. ## Release Notes +### Upcoming +- Changes + - Auth: Added `IdTokenResult` struct and `User::GetIdTokenResult()` to + retrieve decoded ID token claims and metadata across Desktop, Android, + and iOS. + ### 14.0.0 - Changes - General (Android): Update to Firebase Android BoM version 35.0.0. From 909adeb6e563d01d1f52f373c71f1745c8c544b9 Mon Sep 17 00:00:00 2001 From: austinbenoit Date: Wed, 14 Oct 2026 20:16:22 +0000 Subject: [PATCH 2/3] fix(auth): Address review feedback and formatting issues - Document IdTokenResult equality and inequality operators for Doxygen - Include in id_token_util.cc for IWYU compliance - Delete local JNI reference j_token in ReadIdTokenResult - Log warnings on Desktop if ParseIdToken fails for cached or refreshed tokens - Use PRIu64 with cinttypes in doc_samples.cc instead of casting to unsigned long long - Reformat single-line block in user_desktop_test.cc --- auth/samples/src/doc_samples.cc | 11 +++++------ auth/src/android/user_android.cc | 1 + auth/src/desktop/user_desktop.cc | 8 ++++++-- auth/src/id_token_util.cc | 1 + auth/src/include/firebase/auth/user.h | 9 +++++++++ auth/tests/desktop/user_desktop_test.cc | 4 +--- 6 files changed, 23 insertions(+), 11 deletions(-) diff --git a/auth/samples/src/doc_samples.cc b/auth/samples/src/doc_samples.cc index 49221ada0d..aac199d2e9 100644 --- a/auth/samples/src/doc_samples.cc +++ b/auth/samples/src/doc_samples.cc @@ -22,6 +22,7 @@ // [START auth_includes] #include +#include #include #include "firebase/app.h" @@ -655,12 +656,10 @@ void GetIdTokenClaims(firebase::auth::Auth* auth) { const firebase::auth::IdTokenResult* token_result = result.result(); printf("Token: %s\n", token_result->token.c_str()); - printf("Auth timestamp: %llu\n", - static_cast( - token_result->auth_timestamp)); - printf("Expiration timestamp: %llu\n", - static_cast( - token_result->expiration_timestamp)); + printf("Auth timestamp: %" PRIu64 "\n", + token_result->auth_timestamp); + printf("Expiration timestamp: %" PRIu64 "\n", + token_result->expiration_timestamp); // Check custom claims auto it = token_result->claims.find(firebase::Variant("admin")); if (it != token_result->claims.end() && it->second.is_bool() && diff --git a/auth/src/android/user_android.cc b/auth/src/android/user_android.cc index 54fb5842b2..8bea5dcaf0 100644 --- a/auth/src/android/user_android.cc +++ b/auth/src/android/user_android.cc @@ -334,6 +334,7 @@ void ReadIdTokenResult(jobject result, FutureCallbackData* d, result, tokenresult::GetMethodId(tokenresult::kGetToken))); assert(env->ExceptionCheck() == false); std::string token = JniStringToString(env, j_token); + env->DeleteLocalRef(j_token); ParseIdToken(token, data); } else { *data = IdTokenResult(); diff --git a/auth/src/desktop/user_desktop.cc b/auth/src/desktop/user_desktop.cc index 18e5f3e42e..04b170cf63 100644 --- a/auth/src/desktop/user_desktop.cc +++ b/auth/src/desktop/user_desktop.cc @@ -722,7 +722,9 @@ Future User::GetIdTokenResult(const bool force_refresh) { } if (current_token.IsValid()) { IdTokenResult id_token_result; - ParseIdToken(current_token.token(), &id_token_result); + if (!ParseIdToken(current_token.token(), &id_token_result)) { + LogWarning("GetIdTokenResult: Failed to parse cached ID token."); + } promise.CompleteWithResult(id_token_result); return promise.future(); } @@ -737,7 +739,9 @@ Future User::GetIdTokenResult(const bool force_refresh) { } IdTokenResult id_token_result; - ParseIdToken(get_token_result.token(), &id_token_result); + if (!ParseIdToken(get_token_result.token(), &id_token_result)) { + LogWarning("GetIdTokenResult: Failed to parse refreshed ID token."); + } handle->promise.CompleteWithResult(id_token_result); }; diff --git a/auth/src/id_token_util.cc b/auth/src/id_token_util.cc index bbd82a19cc..ef767f72ac 100644 --- a/auth/src/id_token_util.cc +++ b/auth/src/id_token_util.cc @@ -17,6 +17,7 @@ #include "auth/src/id_token_util.h" #include +#include #include #include "app/src/base64.h" diff --git a/auth/src/include/firebase/auth/user.h b/auth/src/include/firebase/auth/user.h index bfcccd1955..173bd562ce 100644 --- a/auth/src/include/firebase/auth/user.h +++ b/auth/src/include/firebase/auth/user.h @@ -178,9 +178,14 @@ struct IdTokenResult { /// The sign-in second factor through which the token was obtained. std::string sign_in_second_factor; + /// @brief Default constructor for IdTokenResult. IdTokenResult() : auth_timestamp(0), issued_at_timestamp(0), expiration_timestamp(0) {} + /// @brief Equality operator. + /// + /// @param[in] other The other IdTokenResult to compare with. + /// @return true if all fields match, false otherwise. bool operator==(const IdTokenResult& other) const { return token == other.token && claims == other.claims && auth_timestamp == other.auth_timestamp && @@ -190,6 +195,10 @@ struct IdTokenResult { sign_in_second_factor == other.sign_in_second_factor; } + /// @brief Inequality operator. + /// + /// @param[in] other The other IdTokenResult to compare with. + /// @return true if any field differs, false otherwise. bool operator!=(const IdTokenResult& other) const { return !(*this == other); } diff --git a/auth/tests/desktop/user_desktop_test.cc b/auth/tests/desktop/user_desktop_test.cc index 4cd1b2ebfa..d8000cd760 100644 --- a/auth/tests/desktop/user_desktop_test.cc +++ b/auth/tests/desktop/user_desktop_test.cc @@ -298,9 +298,7 @@ class UserDesktopTest : public ::testing::Test { // This is to avoid race conditions where the next test's SignIn // doesn't cause a change in auth state or id tokens because persistent // cache has valid user logged in, preventing listeners from firing. - { - SleepUponDestruction sleep_for_listeners; - } + { SleepUponDestruction sleep_for_listeners; } firebase_auth_.reset(nullptr); firebase_app_.reset(nullptr); From 1114b3a7c50630ba129294faafc7fc6c66d4616e Mon Sep 17 00:00:00 2001 From: austinbenoit Date: Fri, 16 Oct 2026 19:00:58 +0000 Subject: [PATCH 3/3] fix(auth): Use native GetTokenResult/FIRAuthTokenResult on Android/iOS and move id_token_util to desktop_SRCS --- auth/CMakeLists.txt | 4 +- auth/src/android/user_android.cc | 50 ++++++++++++++++--- auth/src/ios/fake/FIRAuthTokenResult.h | 5 ++ auth/src/ios/fake/FIRAuthTokenResult.mm | 1 + auth/src/ios/user_ios.mm | 29 +++++++++-- .../google/firebase/auth/GetTokenResult.java | 27 ++++++++++ 6 files changed, 103 insertions(+), 13 deletions(-) diff --git a/auth/CMakeLists.txt b/auth/CMakeLists.txt index 82fcb39f58..93055b0edd 100644 --- a/auth/CMakeLists.txt +++ b/auth/CMakeLists.txt @@ -55,8 +55,6 @@ set(common_SRCS src/common.cc src/common.h src/data.h - src/id_token_util.cc - src/id_token_util.h src/user.cc) # Define the resource build needed for Android @@ -118,6 +116,8 @@ set(desktop_SRCS src/desktop/sign_in_flow.cc src/desktop/user_desktop.cc src/desktop/user_view.cc + src/id_token_util.cc + src/id_token_util.h src/desktop/rpcs/auth_request.cc src/desktop/rpcs/auth_response.cc src/desktop/rpcs/create_auth_uri_request.cc diff --git a/auth/src/android/user_android.cc b/auth/src/android/user_android.cc index 8bea5dcaf0..68e8a5db39 100644 --- a/auth/src/android/user_android.cc +++ b/auth/src/android/user_android.cc @@ -20,7 +20,6 @@ #include "app/src/time.h" #include "auth/src/android/common_android.h" -#include "auth/src/id_token_util.h" namespace firebase { namespace auth { @@ -31,7 +30,13 @@ using util::JniUriToString; // clang-format off #define TOKEN_RESULT_METHODS(X) \ - X(GetToken, "getToken", "()Ljava/lang/String;") + X(GetToken, "getToken", "()Ljava/lang/String;"), \ + X(GetExpirationTimestamp, "getExpirationTimestamp", "()J"), \ + X(GetAuthTimestamp, "getAuthTimestamp", "()J"), \ + X(GetIssuedAtTimestamp, "getIssuedAtTimestamp", "()J"), \ + X(GetSignInProvider, "getSignInProvider", "()Ljava/lang/String;"), \ + X(GetSignInSecondFactor, "getSignInSecondFactor", "()Ljava/lang/String;"), \ + X(GetClaims, "getClaims", "()Ljava/util/Map;") // clang-format on METHOD_LOOKUP_DECLARATION(tokenresult, TOKEN_RESULT_METHODS) METHOD_LOOKUP_DEFINITION(tokenresult, @@ -330,12 +335,41 @@ void ReadIdTokenResult(jobject result, FutureCallbackData* d, // This comes from the successfully completed Task in Java, so it should // always be valid. FIREBASE_ASSERT(result != nullptr); - jstring j_token = static_cast(env->CallObjectMethod( - result, tokenresult::GetMethodId(tokenresult::kGetToken))); - assert(env->ExceptionCheck() == false); - std::string token = JniStringToString(env, j_token); - env->DeleteLocalRef(j_token); - ParseIdToken(token, data); + const jobject j_token = env->CallObjectMethod( + result, tokenresult::GetMethodId(tokenresult::kGetToken)); + util::CheckAndClearJniExceptions(env); + const jlong expiration_timestamp = env->CallLongMethod( + result, tokenresult::GetMethodId(tokenresult::kGetExpirationTimestamp)); + util::CheckAndClearJniExceptions(env); + const jlong auth_timestamp = env->CallLongMethod( + result, tokenresult::GetMethodId(tokenresult::kGetAuthTimestamp)); + util::CheckAndClearJniExceptions(env); + const jlong issued_at_timestamp = env->CallLongMethod( + result, tokenresult::GetMethodId(tokenresult::kGetIssuedAtTimestamp)); + util::CheckAndClearJniExceptions(env); + const jobject j_sign_in_provider = env->CallObjectMethod( + result, tokenresult::GetMethodId(tokenresult::kGetSignInProvider)); + util::CheckAndClearJniExceptions(env); + const jobject j_sign_in_second_factor = env->CallObjectMethod( + result, tokenresult::GetMethodId(tokenresult::kGetSignInSecondFactor)); + util::CheckAndClearJniExceptions(env); + const jobject j_claims = env->CallObjectMethod( + result, tokenresult::GetMethodId(tokenresult::kGetClaims)); + util::CheckAndClearJniExceptions(env); + + // Note that JniStringToString releases local references for us. + data->token = JniStringToString(env, j_token); + data->expiration_timestamp = static_cast(expiration_timestamp); + data->auth_timestamp = static_cast(auth_timestamp); + data->issued_at_timestamp = static_cast(issued_at_timestamp); + data->sign_in_provider = JniStringToString(env, j_sign_in_provider); + data->sign_in_second_factor = + JniStringToString(env, j_sign_in_second_factor); + data->claims.clear(); + if (j_claims != nullptr) { + util::JavaMapToVariantMap(env, &data->claims, j_claims); + env->DeleteLocalRef(j_claims); + } } else { *data = IdTokenResult(); } diff --git a/auth/src/ios/fake/FIRAuthTokenResult.h b/auth/src/ios/fake/FIRAuthTokenResult.h index 515aa60d2c..b60b793d49 100644 --- a/auth/src/ios/fake/FIRAuthTokenResult.h +++ b/auth/src/ios/fake/FIRAuthTokenResult.h @@ -53,6 +53,11 @@ NS_SWIFT_NAME(AuthTokenResult) */ @property(nonatomic, readonly) NSString *signInProvider; +/** @property signInSecondFactor + @brief Stores sign-in second factor through which the token was obtained. + */ +@property(nonatomic, readonly, nullable) NSString *signInSecondFactor; + /** @property claims @brief Stores the entire payload of claims found on the ID token. This includes the standard reserved claims as well as custom claims set by the developer via the Admin SDK. diff --git a/auth/src/ios/fake/FIRAuthTokenResult.mm b/auth/src/ios/fake/FIRAuthTokenResult.mm index ba4d5d21c2..77b5952605 100644 --- a/auth/src/ios/fake/FIRAuthTokenResult.mm +++ b/auth/src/ios/fake/FIRAuthTokenResult.mm @@ -28,6 +28,7 @@ - (instancetype)init { _authDate = [NSDate dateWithTimeIntervalSince1970:1]; _issuedAtDate = [NSDate dateWithTimeIntervalSince1970:1]; _signInProvider = @"custom"; + _signInSecondFactor = @"totp"; _claims = @{}; } return self; diff --git a/auth/src/ios/user_ios.mm b/auth/src/ios/user_ios.mm index ddcf2fb1a9..76ec51be2e 100644 --- a/auth/src/ios/user_ios.mm +++ b/auth/src/ios/user_ios.mm @@ -16,7 +16,6 @@ #include #include "app/src/time.h" -#include "auth/src/id_token_util.h" #include "auth/src/ios/common_ios.h" namespace firebase { @@ -94,8 +93,32 @@ explicit IOSWrappedUserInfo(id user_info) : user_info_(user_info) { handle, AuthErrorFromNSError(error), [error.localizedDescription UTF8String], [token_result](IdTokenResult *data) { - if (token_result != nullptr && token_result.token != nil) { - ParseIdToken([token_result.token UTF8String], data); + if (token_result != nullptr) { + data->token = StringFromNSString(token_result.token); + data->auth_timestamp = + token_result.authDate + ? static_cast( + token_result.authDate.timeIntervalSince1970) + : 0; + data->issued_at_timestamp = + token_result.issuedAtDate + ? static_cast( + token_result.issuedAtDate.timeIntervalSince1970) + : 0; + data->expiration_timestamp = + token_result.expirationDate + ? static_cast( + token_result.expirationDate.timeIntervalSince1970) + : 0; + data->sign_in_provider = + StringFromNSString(token_result.signInProvider); + data->sign_in_second_factor = + StringFromNSString(token_result.signInSecondFactor); + data->claims.clear(); + if (token_result.claims != nil) { + util::NSDictionaryToStdMap(token_result.claims, + &data->claims); + } } else { *data = IdTokenResult(); } diff --git a/auth/src_java/fake/com/google/firebase/auth/GetTokenResult.java b/auth/src_java/fake/com/google/firebase/auth/GetTokenResult.java index 2afac9255f..212cd80f1c 100644 --- a/auth/src_java/fake/com/google/firebase/auth/GetTokenResult.java +++ b/auth/src_java/fake/com/google/firebase/auth/GetTokenResult.java @@ -16,9 +16,36 @@ package com.google.firebase.auth; +import java.util.Collections; +import java.util.Map; + /** Fake GetTokenResult */ public final class GetTokenResult { public String getToken() { return "a fake token"; } + + public long getExpirationTimestamp() { + return 1L; + } + + public long getAuthTimestamp() { + return 1L; + } + + public long getIssuedAtTimestamp() { + return 1L; + } + + public String getSignInProvider() { + return "custom"; + } + + public String getSignInSecondFactor() { + return "totp"; + } + + public Map getClaims() { + return Collections.emptyMap(); + } }