diff --git a/README.md b/README.md index 4c70429a..fb032ae0 100644 --- a/README.md +++ b/README.md @@ -338,3 +338,54 @@ These are included as submodules so can be retrieved using However should not be updated or pulled to another version since the initialized version is known to build and run stable. JsonCpp has recently been made a submodule (as of brainCloud 4.15) so if pulling or changing branches the existing folder lib/jsoncpp-1.0.0/ should be deleted or there will be git errors. + +### Asynchronous App Check token provider + +Register `setAppCheckTokenProvider` on the authentication service to obtain a current +opaque token for every authentication, including retries and automatic reconnects. +The provider takes precedence over `setAppCheckToken`; removing it restores the +stored-token behavior. Neither SDK depends on Firebase. + +The provider is invoked on the authentication thread and may complete on any thread. +Keep calling `runCallbacks` (REST or ALL): it processes results and a 30-second timeout. +An error or empty token fails authentication locally with status 400 and reason 90300 +(`CLIENT_APP_CHECK_TOKEN_ERROR`), without sending the request or using the stored token. +Only the first completion is accepted. Resetting communication or destroying the client +silently discards pending requests; late completions are ignored. Configure providers +on the SDK thread and avoid strong ownership cycles. Provider replacement affects new +requests; requests already waiting retain their original provider. + +```cpp +client.getAuthenticationService()->setAppCheckTokenProvider( + [](BrainCloud::BrainCloudAuthentication::AppCheckTokenCompletion completion) { + // Fetch asynchronously through your app's token service, then: + // completion(token, ""); on success, or completion("", errorMessage). + }); +// Restore the manually supplied token: +client.getAuthenticationService()->setAppCheckTokenProvider(nullptr); +``` + +//// +Use `setAppCheckTokenProvider` to fetch a fresh token for each authentication attempt, +including retries and reconnects. It overrides `setAppCheckToken` until removed. +Firebase is not required. +The provider runs on the authentication thread and can complete on any thread. +Keep calling runCallbacks (`REST` or `ALL`) to process results and the 30-second timeout. +Errors, empty tokens, and timeouts fail locally with +(`ReasonCodes.CLIENT_APP_CHECK_TOKEN_ERROR`); the stored token is never used as a fallback. + +```cpp +client.getAuthenticationService()->setAppCheckTokenProvider( + [](BrainCloud::BrainCloudAuthentication::AppCheckTokenCompletion completion) { + // Fetch a token asynchronously, then call: + // completion(token, ""); // success + // completion("", errorMessage); // failure + }); + +// Switch back to the manually supplied token: +client.getAuthenticationService()->setAppCheckTokenProvider(nullptr); +``` + +Configure providers on the SDK thread and avoid strong ownership cycles. +Changes apply to new requests. Only the first completion counts; completions +after a reset or client destruction are ignored. \ No newline at end of file diff --git a/include/braincloud/BrainCloudAuthentication.h b/include/braincloud/BrainCloudAuthentication.h index e4a951be..1d42e67e 100644 --- a/include/braincloud/BrainCloudAuthentication.h +++ b/include/braincloud/BrainCloudAuthentication.h @@ -9,6 +9,9 @@ #include #include +#include +#include +#include #include "json/json.h" #include "braincloud/BrainCloudTypes.h" #include "braincloud/AuthenticationType.h" @@ -29,6 +32,35 @@ namespace BrainCloud // internal use only void setClientLib(const char* lib); + /** + * Sets the caller-supplied Firebase App Check token for subsequent authenticate requests. + * The token is copied and retained in memory until replaced or cleared with an empty string. + * The host app is responsible for obtaining and refreshing it, including before reconnects. + * Call on the same thread as authentication; already queued requests are not updated. + * No token validation or Firebase integration is performed by this SDK. + * @param token Opaque App Check token, or an empty string to omit it from requests. + */ + void setAppCheckToken(const std::string& token); + + typedef std::function AppCheckTokenCompletion; + typedef std::function AppCheckTokenProvider; + + /** + * Resolves a token for every authentication, including retries and automatic reconnects. + * Takes precedence over the setter; an empty provider restores setter behavior. + * Called on the authentication thread. Completion may run on any thread; only its + * first result is used. An error or empty token fails authentication without sending. + * Results and a 30-second timeout are processed by REST/ALL runCallbacks(). + * Configure on the SDK thread. Reset/destruction discards pending results without callbacks. + * The provider is retained until replaced or the client is destroyed; avoid ownership cycles. + * No Firebase dependency is required. + */ + void setAppCheckTokenProvider(AppCheckTokenProvider provider); + + // Internal: called by BrainCloudClient on its callback thread. + void runAppCheckCallbacks(); + void cancelPendingAppCheckRequests(); + /** * Initialize - initializes the identity service with a saved * anonymous installation id and most recently used profile id @@ -475,6 +507,11 @@ namespace BrainCloud void authenticate(const char * externalId, const char * authenticationToken, AuthenticationType authenticationType, const char * externalAuthName, bool forceCreate, const std::string &extraJson, IServerCallback * callback); private: + std::string _appCheckToken; + struct PendingAppCheck; + AppCheckTokenProvider _appCheckTokenProvider; + std::vector > _pendingAppCheck; + struct PreviousAuthParams { std::string externalId; diff --git a/include/braincloud/OperationParam.h b/include/braincloud/OperationParam.h index 6a7fece7..ae7aa46f 100644 --- a/include/braincloud/OperationParam.h +++ b/include/braincloud/OperationParam.h @@ -44,6 +44,7 @@ namespace BrainCloud { // Authenticate Service - Authenticate Params static const OperationParam AuthenticateServiceAuthenticateAuthenticationType; static const OperationParam AuthenticateServiceAuthenticateAuthenticationToken; + static const OperationParam AuthenticateServiceAuthenticateAppCheckToken; static const OperationParam AuthenticateServiceAuthenticateExternalId; static const OperationParam AuthenticateServiceAuthenticateUniversalId; static const OperationParam AuthenticateServiceAuthenticateEmailAddress; diff --git a/include/braincloud/reason_codes.h b/include/braincloud/reason_codes.h index e5dcc69f..828aa942 100644 --- a/include/braincloud/reason_codes.h +++ b/include/braincloud/reason_codes.h @@ -733,6 +733,7 @@ static const int CLIENT_UPLOAD_FILE_CANCELLED = 90100; static const int CLIENT_UPLOAD_FILE_TIMED_OUT = 90101; static const int CLIENT_UPLOAD_FILE_UNKNOWN = 90102; static const int CLIENT_DISABLED = 90200; +static const int CLIENT_APP_CHECK_TOKEN_ERROR = 90300; static const int ITUNES_PURCHASE_ALREADY_CLAIMED = APPLE_TRANS_ID_ALREADY_CLAIMED; static const int CHILD_USER_MISSING = CHILD_PLAYER_MISSING; static const int DISABLED_APP = DISABLED_GAME; diff --git a/src/BrainCloudAuthentication.cpp b/src/BrainCloudAuthentication.cpp index 513f196b..84ff0939 100644 --- a/src/BrainCloudAuthentication.cpp +++ b/src/BrainCloudAuthentication.cpp @@ -1,6 +1,9 @@ // Copyright 2026 bitHeads, Inc. All Rights Reserved. #include "braincloud/BrainCloudAuthentication.h" +#include +#include +#include "braincloud/reason_codes.h" #include "braincloud/BrainCloudClient.h" #include "braincloud/IServerCallback.h" @@ -29,6 +32,76 @@ namespace BrainCloud { _clientLib = lib; } + void BrainCloudAuthentication::setAppCheckToken(const std::string& token) + { + _appCheckToken = token; + } + + struct BrainCloudAuthentication::PendingAppCheck + { + std::mutex mutex; + bool completed = false; + std::string token, error; + Json::Value message; + IServerCallback* callback = nullptr; + std::chrono::steady_clock::time_point started = std::chrono::steady_clock::now(); + }; + + void BrainCloudAuthentication::setAppCheckTokenProvider(AppCheckTokenProvider provider) + { + _appCheckTokenProvider = provider; + } + + void BrainCloudAuthentication::cancelPendingAppCheckRequests() + { + _pendingAppCheck.clear(); + } + + void BrainCloudAuthentication::runAppCheckCallbacks() + { + // Remove each result before invoking application code, which can reset the client. + for (size_t i = 0; i < _pendingAppCheck.size();) + { + auto pending = _pendingAppCheck[i]; + std::string token, error; + { + std::lock_guard lock(pending->mutex); + if (!pending->completed) + { + if (std::chrono::steady_clock::now() - pending->started < std::chrono::seconds(30)) + { + ++i; + continue; + } + pending->completed = true; + pending->error = "App Check token provider timed out"; + } + token = pending->token; + error = pending->error; + } + _pendingAppCheck.erase(_pendingAppCheck.begin() + i); + if (!error.empty() || token.empty()) + { + if (pending->callback) + { + Json::Value response; + response["status"] = 400; + response["reason_code"] = CLIENT_APP_CHECK_TOKEN_ERROR; + response["status_message"] = error.empty() ? "App Check token provider returned an empty token" : error; + pending->callback->serverError(ServiceName::AuthenticateV2, ServiceOperation::Authenticate, + 400, CLIENT_APP_CHECK_TOKEN_ERROR, Json::FastWriter().write(response)); + } + } + else + { + pending->message[OperationParam::AuthenticateServiceAuthenticateAppCheckToken.getValue()] = token; + m_client->sendRequest(new ServerCall(ServiceName::AuthenticateV2, + ServiceOperation::Authenticate, pending->message, pending->callback)); + } + i = 0; + } + } + void BrainCloudAuthentication::initialize(const char * profileId, const char * anonymousId) { _profileId = profileId; @@ -339,6 +412,32 @@ namespace BrainCloud { message[OperationParam::AuthenticateServiceAuthenticateExtraJson.getValue()] = JsonUtil::jsonStringToValue(extraJson); } + if (_appCheckTokenProvider) + { + auto pending = std::make_shared(); + pending->message = message; + pending->callback = callback; + _pendingAppCheck.push_back(pending); + std::weak_ptr weakPending = pending; + // Capture no client pointer: late completions after reset/destruction are harmless. + auto provider = _appCheckTokenProvider; + provider([weakPending](const std::string& token, const std::string& error) { + auto result = weakPending.lock(); + if (!result) return; + std::lock_guard lock(result->mutex); + if (result->completed) return; + result->token = token; + result->error = error; + result->completed = true; + }); + return; + } + + if (!_appCheckToken.empty()) + { + message[OperationParam::AuthenticateServiceAuthenticateAppCheckToken.getValue()] = _appCheckToken; + } + ServerCall * sc = new ServerCall(ServiceName::AuthenticateV2, ServiceOperation::Authenticate, message, callback); m_client->sendRequest(sc); } diff --git a/src/BrainCloudClient.cpp b/src/BrainCloudClient.cpp index 446ef828..d5b5fd1d 100644 --- a/src/BrainCloudClient.cpp +++ b/src/BrainCloudClient.cpp @@ -271,6 +271,7 @@ namespace BrainCloud switch (updateType) { case eBrainCloudUpdateType::REST: + _authenticationService->runAppCheckCallbacks(); _brainCloudComms->runCallbacks(); break; case eBrainCloudUpdateType::PING: @@ -283,6 +284,7 @@ namespace BrainCloud _relayComms->runCallbacks(); break; case eBrainCloudUpdateType::ALL: + _authenticationService->runAppCheckCallbacks(); _brainCloudComms->runCallbacks(); _lobbyService->runPingCallbacks(); _rttComms->runCallbacks(); @@ -380,6 +382,7 @@ namespace BrainCloud void BrainCloudClient::resetCommunication() { + _authenticationService->cancelPendingAppCheckRequests(); _relayComms->resetCommunication(); _rttComms->resetCommunication(); _brainCloudComms->resetCommunication(); diff --git a/src/OperationParam.cpp b/src/OperationParam.cpp index 259f7234..092bc174 100644 --- a/src/OperationParam.cpp +++ b/src/OperationParam.cpp @@ -38,6 +38,7 @@ namespace BrainCloud // Authenticate Service - Authenticate Params const OperationParam OperationParam::AuthenticateServiceAuthenticateAuthenticationType = OperationParam("authenticationType"); const OperationParam OperationParam::AuthenticateServiceAuthenticateAuthenticationToken = OperationParam("authenticationToken"); + const OperationParam OperationParam::AuthenticateServiceAuthenticateAppCheckToken = OperationParam("appCheckToken"); const OperationParam OperationParam::AuthenticateServiceAuthenticateExternalId = OperationParam("externalId"); const OperationParam OperationParam::AuthenticateServiceAuthenticateUniversalId = OperationParam("universalId"); const OperationParam OperationParam::AuthenticateServiceAuthenticateEmailAddress = OperationParam("emailAddress"); diff --git a/tests/src/TestBCAppCheck.cpp b/tests/src/TestBCAppCheck.cpp new file mode 100644 index 00000000..8d091cfc --- /dev/null +++ b/tests/src/TestBCAppCheck.cpp @@ -0,0 +1,249 @@ +// Copyright 2026 bitHeads, Inc. All Rights Reserved. + +#include "gtest/gtest.h" +#include +#include "braincloud/reason_codes.h" +#include "braincloud/BrainCloudClient.h" +#include "braincloud/AuthenticationIds.h" +#include "braincloud/internal/DefaultBrainCloudComms.h" + +namespace +{ + using namespace BrainCloud; + + // Capture the actual ServerCall at the queue boundary. No network or test account is needed. + class AppCheckCaptureComms : public DefaultBrainCloudComms + { + public: + explicit AppCheckCaptureComms(BrainCloudClient* client) : DefaultBrainCloudComms(client) + { + _appId = "test-app"; + } + + void addToQueue(ServerCall* call) override + { + payload = *call->getPayload(); + delete call; + } + + Json::Value payload; + }; + + class AppCheckClient : public BrainCloudClient + { + public: + AppCheckClient() + { + delete _brainCloudComms; + _brainCloudComms = new AppCheckCaptureComms(this); + _releasePlatform = "IOS"; + _appVersion = "1.2.3"; + _countryCode = "CA"; + _languageCode = "en"; + _timezoneOffset = 0.0; + getAuthenticationService()->initialize("profile", "anonymous"); + } + + const Json::Value& payload() const + { + return static_cast(_brainCloudComms)->payload; + } + }; + + class TestBCAppCheck : public testing::Test + { + protected: + AppCheckClient client; + + std::string serializedPayload() + { + Json::FastWriter writer; + return writer.write(client.payload()); + } + + // Legacy authenticate ServerCall bytes for fixed inputs, including field order and newline. + std::string legacyPayload() + { + return std::string("{\"data\":{\"anonymousId\":\"anonymous\",\"authenticationToken\":\"password\"," + "\"authenticationType\":\"Universal\",\"clientLib\":\"cpp\",\"clientLibVersion\":\"") + + client.getBrainCloudClientVersion() + + "\",\"compressResponses\":true,\"countryCode\":\"CA\",\"externalId\":\"user\"," + "\"forceCreate\":true,\"gameId\":\"test-app\",\"gameVersion\":\"1.2.3\"," + "\"languageCode\":\"en\",\"profileId\":\"profile\",\"releasePlatform\":\"IOS\"," + "\"timeZoneOffset\":0.0},\"operation\":\"AUTHENTICATE\",\"service\":\"authenticationV2\"}\n"; + } + }; + + TEST_F(TestBCAppCheck, OmittedTokenPreservesLegacyBytes) + { + client.getAuthenticationService()->authenticateUniversal("user", "password", true); + EXPECT_FALSE(client.payload()["data"].isMember("appCheckToken")); + EXPECT_EQ(legacyPayload(), serializedPayload()); + } + + TEST_F(TestBCAppCheck, SuppliedTokenIsCopiedAndExistingFieldsAreUnchanged) + { + std::string token = "opaque.token.with-\"quotes\"\\and\nnewline"; + const std::string original = token; + client.getAuthenticationService()->setAppCheckToken(token); + token = "changed-by-caller"; + client.getAuthenticationService()->authenticateUniversal("user", "password", true); + EXPECT_EQ(original, client.payload()["data"]["appCheckToken"].asString()); + + Json::Value withoutToken = client.payload(); + withoutToken["data"].removeMember("appCheckToken"); + Json::FastWriter writer; + EXPECT_EQ(legacyPayload(), writer.write(withoutToken)); + } + + TEST_F(TestBCAppCheck, ClearingTokenRestoresLegacyBytes) + { + auto auth = client.getAuthenticationService(); + auth->setAppCheckToken("token"); + auth->authenticateUniversal("user", "password", true); + auth->setAppCheckToken(""); + auth->authenticateUniversal("user", "password", true); + EXPECT_EQ(legacyPayload(), serializedPayload()); + } + + TEST_F(TestBCAppCheck, RetryUsesLatestTokenAndClearingIsRespected) + { + auto auth = client.getAuthenticationService(); + auth->setAppCheckToken("first"); + auth->authenticateUniversal("user", "password", true); + auth->setAppCheckToken("refreshed"); + EXPECT_EQ("first", client.payload()["data"]["appCheckToken"].asString()); + auth->retryPreviousAuthenticate(NULL); + EXPECT_EQ("refreshed", client.payload()["data"]["appCheckToken"].asString()); + auth->setAppCheckToken(""); + auth->retryPreviousAuthenticate(NULL); + EXPECT_EQ(legacyPayload(), serializedPayload()); + } + + TEST_F(TestBCAppCheck, TokenPersistsAcrossAuthenticationMethods) + { + auto auth = client.getAuthenticationService(); + auth->setAppCheckToken("token"); + auth->authenticateAnonymous(true); + EXPECT_EQ("token", client.payload()["data"]["appCheckToken"].asString()); + AuthenticationIds ids = { "user", "password", "provider" }; + auth->authenticateAdvanced(AuthenticationType::External, ids, false, "{\"custom\":42}"); + EXPECT_EQ("token", client.payload()["data"]["appCheckToken"].asString()); + EXPECT_EQ("provider", client.payload()["data"]["externalAuthName"].asString()); + EXPECT_EQ(42, client.payload()["data"]["extraJson"]["custom"].asInt()); + } + + TEST_F(TestBCAppCheck, TokenIsNotSentWithOtherOperationsOrSharedBetweenClients) + { + client.getAuthenticationService()->setAppCheckToken("token"); + client.getAuthenticationService()->resetEmailPassword("user@example.com"); + EXPECT_FALSE(client.payload()["data"].isMember("appCheckToken")); + client.getAuthenticationService()->getServerVersion(); + EXPECT_FALSE(client.payload()["data"].isMember("appCheckToken")); + + AppCheckClient other; + other.getAuthenticationService()->authenticateAnonymous(true); + EXPECT_FALSE(other.payload()["data"].isMember("appCheckToken")); + } + + TEST_F(TestBCAppCheck, ProviderRefreshesEachAuthenticationAndCanBeRemoved) + { + auto auth = client.getAuthenticationService(); + auth->setAppCheckToken("manual"); + int calls = 0; + auth->setAppCheckTokenProvider([&](BrainCloudAuthentication::AppCheckTokenCompletion done) { + done(++calls == 1 ? "first" : "fresh", ""); + }); + auth->authenticateAnonymous(false); + EXPECT_TRUE(client.payload().isNull()); + client.runCallbacks(eBrainCloudUpdateType::REST); + EXPECT_EQ("first", client.payload()["data"]["appCheckToken"].asString()); + auth->retryPreviousAuthenticate(nullptr); + client.runCallbacks(eBrainCloudUpdateType::REST); + EXPECT_EQ("fresh", client.payload()["data"]["appCheckToken"].asString()); + EXPECT_EQ(2, calls); + auth->setAppCheckTokenProvider(nullptr); + auth->authenticateAnonymous(false); + EXPECT_EQ("manual", client.payload()["data"]["appCheckToken"].asString()); + } + + TEST_F(TestBCAppCheck, DelayedWorkerCompletionIsMarshalledAndOnlyUsedOnce) + { + auto auth = client.getAuthenticationService(); + BrainCloudAuthentication::AppCheckTokenCompletion done; + auth->setAppCheckTokenProvider([&](BrainCloudAuthentication::AppCheckTokenCompletion completion) { done = completion; }); + auth->authenticateUniversal("user", "password", true); + client.runCallbacks(eBrainCloudUpdateType::REST); + EXPECT_TRUE(client.payload().isNull()); + std::thread worker([&] { done("fresh", ""); done("duplicate", ""); }); + worker.join(); + EXPECT_TRUE(client.payload().isNull()); + client.runCallbacks(eBrainCloudUpdateType::REST); + EXPECT_EQ("fresh", client.payload()["data"]["appCheckToken"].asString()); + EXPECT_EQ("user", client.payload()["data"]["externalId"].asString()); + } + + class ProviderErrorCallback : public IServerCallback + { + public: + int errors = 0; + void serverCallback(ServiceName, ServiceOperation, const std::string&) override { ADD_FAILURE(); } + void serverError(ServiceName, ServiceOperation, int status, int reason, const std::string&) override + { + ++errors; + EXPECT_EQ(400, status); + EXPECT_EQ(CLIENT_APP_CHECK_TOKEN_ERROR, reason); + } + }; + + TEST_F(TestBCAppCheck, ProviderFailureAndEmptyTokenNeverFallBackToStoredToken) + { + auto auth = client.getAuthenticationService(); + ProviderErrorCallback callback; + auth->setAppCheckToken("stale"); + auth->setAppCheckTokenProvider([](BrainCloudAuthentication::AppCheckTokenCompletion done) { done("ignored", "failed"); }); + auth->authenticateAnonymous(false, &callback); + client.runCallbacks(eBrainCloudUpdateType::REST); + auth->setAppCheckTokenProvider([](BrainCloudAuthentication::AppCheckTokenCompletion done) { done("", ""); }); + auth->authenticateAnonymous(false, &callback); + client.runCallbacks(eBrainCloudUpdateType::REST); + EXPECT_EQ(2, callback.errors); + EXPECT_TRUE(client.payload().isNull()); + } + + TEST_F(TestBCAppCheck, ResetAndDestructionIgnoreLateCompletions) + { + BrainCloudAuthentication::AppCheckTokenCompletion done; + auto auth = client.getAuthenticationService(); + auth->setAppCheckTokenProvider([&](BrainCloudAuthentication::AppCheckTokenCompletion completion) { done = completion; }); + auth->authenticateAnonymous(false); + client.resetCommunication(); + done("late", ""); + client.runCallbacks(eBrainCloudUpdateType::REST); + EXPECT_TRUE(client.payload().isNull()); + { + AppCheckClient temporary; + temporary.getAuthenticationService()->setAppCheckTokenProvider([&](BrainCloudAuthentication::AppCheckTokenCompletion completion) { done = completion; }); + temporary.getAuthenticationService()->authenticateAnonymous(false); + } + done("after destruction", ""); + } + + TEST_F(TestBCAppCheck, EpicGamesAuthenticationUsesProviderWithoutReplacingEpicCredentials) + { + auto auth = client.getAuthenticationService(); + auth->setAppCheckTokenProvider([](BrainCloudAuthentication::AppCheckTokenCompletion done) { + done("app-check-token", ""); + }); + auth->authenticateEpicGames("epic-account", "epic-id-token", false); + EXPECT_TRUE(client.payload().isNull()); + client.runCallbacks(eBrainCloudUpdateType::REST); + const Json::Value& data = client.payload()["data"]; + EXPECT_EQ("EpicGames", data["authenticationType"].asString()); + EXPECT_EQ("epic-account", data["externalId"].asString()); + EXPECT_EQ("epic-id-token", data["authenticationToken"].asString()); + EXPECT_EQ("app-check-token", data["appCheckToken"].asString()); + EXPECT_FALSE(data["forceCreate"].asBool()); + } + +} diff --git a/tests/src/TestBCAppCheck.h b/tests/src/TestBCAppCheck.h new file mode 100644 index 00000000..86ef63bb --- /dev/null +++ b/tests/src/TestBCAppCheck.h @@ -0,0 +1,29 @@ +// Copyright 2026 bitHeads, Inc. All Rights Reserved. + +#ifndef TESTBCAPPCHECK_H +#define TESTBCAPPCHECK_H + +#include "gtest/gtest.h" +#include "braincloud/BrainCloudClient.h" + +namespace AppCheckTests +{ + class AppCheckClient : public BrainCloud::BrainCloudClient + { + public: + AppCheckClient(); + const Json::Value& payload() const; + }; + + // Offline fixture: captures requests without authenticating against a server. + class TestBCAppCheck : public testing::Test + { + protected: + AppCheckClient client; + + std::string serializedPayload(); + std::string legacyPayload(); + }; +} + +#endif