diff --git a/auth/CMakeLists.txt b/auth/CMakeLists.txt index 2c1dbe3be3..93055b0edd 100644 --- a/auth/CMakeLists.txt +++ b/auth/CMakeLists.txt @@ -116,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/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..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" @@ -644,6 +645,33 @@ 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: %" 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() && + 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..68e8a5db39 100644 --- a/auth/src/android/user_android.cc +++ b/auth/src/android/user_android.cc @@ -30,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, @@ -316,6 +322,82 @@ 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); + 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(); + } +} + +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..04b170cf63 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,57 @@ 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; + if (!ParseIdToken(current_token.token(), &id_token_result)) { + LogWarning("GetIdTokenResult: Failed to parse cached ID token."); + } + 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; + if (!ParseIdToken(get_token_result.token(), &id_token_result)) { + LogWarning("GetIdTokenResult: Failed to parse refreshed ID token."); + } + 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..ef767f72ac --- /dev/null +++ b/auth/src/id_token_util.cc @@ -0,0 +1,121 @@ +/* + * 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 + +#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..173bd562ce 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,57 @@ 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; + + /// @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 && + 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; + } + + /// @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); + } +}; + /// @brief Firebase user account object. /// /// This class allows you to manipulate the profile of a user, link to and @@ -219,6 +271,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.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 new file mode 100644 index 0000000000..77b5952605 --- /dev/null +++ b/auth/src/ios/fake/FIRAuthTokenResult.mm @@ -0,0 +1,39 @@ +/* + * 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"; + _signInSecondFactor = @"totp"; + _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..76ec51be2e 100644 --- a/auth/src/ios/user_ios.mm +++ b/auth/src/ios/user_ios.mm @@ -79,6 +79,54 @@ 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) { + 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(); + } + }); + }]; + 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/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(); + } } 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..d8000cd760 100644 --- a/auth/tests/desktop/user_desktop_test.cc +++ b/auth/tests/desktop/user_desktop_test.cc @@ -378,6 +378,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.