-
Notifications
You must be signed in to change notification settings - Fork 169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
RCORE-2253 Redirected user authenticated app requests cause user to be logged out and location is not updated #8011
base: master
Are you sure you want to change the base?
Changes from 18 commits
aa34635
fd52bb1
3a9c663
519a71e
8a753e5
d5c2800
3cc2261
aba04c7
e0a971d
420d56a
a06e319
69c232c
3c0cd07
4e8484e
5728e1f
3c6229a
dd1d4bc
0d46d77
ebcfe93
9a9371d
307fdb7
2b2c59c
1d1edf9
ce1aeec
a4e967a
761f18b
49961ab
ee81ab4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3248,6 +3248,262 @@ TEST_CASE("app: sync integration", "[sync][pbs][app][baas]") { | |
} | ||
} | ||
|
||
TEST_CASE("app: network transport handles redirection", "[sync][app][baas]") { | ||
auto logger = util::Logger::get_default_logger(); | ||
auto app_session = get_runtime_app_session(); | ||
|
||
// Skip this test if not using the redirect server | ||
if (!get_redirector()) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why wouldn't we just explicitly create a redirecting server for this test regardless of whether it's enabled globally? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated test to define its own copy of the redirect server. |
||
return; | ||
|
||
util::ScopeExit cleanup([&]() noexcept { | ||
if (auto& director = get_redirector()) { | ||
// Reset the redirector state when the test exits | ||
director->reset_state(); | ||
} | ||
}); | ||
|
||
std::mutex counter_mutex; | ||
int error_count = 0; | ||
int location_count = 0; | ||
int redirect_count = 0; | ||
int wsredirect_count = 0; | ||
using RedirectEvent = sync::RedirectingHttpServer::Event; | ||
get_redirector()->set_event_hook([&](RedirectEvent event, std::optional<std::string> message) { | ||
std::lock_guard lk(counter_mutex); | ||
switch (event) { | ||
case RedirectEvent::location: | ||
location_count++; | ||
logger->debug("Redirector event: location - count: %1", location_count); | ||
return; | ||
case RedirectEvent::redirect: | ||
redirect_count++; | ||
logger->debug("Redirector event: redirect - count: %1", redirect_count); | ||
return; | ||
case RedirectEvent::ws_redirect: | ||
wsredirect_count++; | ||
logger->debug("Redirector event: ws_redirect - count: %1", wsredirect_count); | ||
return; | ||
case RedirectEvent::error: | ||
error_count++; | ||
logger->error("Redirect server received error: %1", message.value_or("unknown error")); | ||
return; | ||
} | ||
}); | ||
|
||
struct CounterValue { | ||
bool greater_than; | ||
int value = 0; | ||
CounterValue(bool greater_than, int value) | ||
: greater_than(greater_than) | ||
, value(value) | ||
{ | ||
} | ||
CounterValue(int value) | ||
: CounterValue(false, value) | ||
{ | ||
} | ||
}; | ||
|
||
auto check_value = [](int value, const CounterValue& check, std::string_view name) { | ||
INFO(util::format("Checking '%1' counter value", name)); | ||
if (check.greater_than) | ||
REQUIRE(value > check.value); | ||
else | ||
REQUIRE(value == check.value); | ||
}; | ||
|
||
auto reset_counters = [&] { | ||
std::lock_guard lk(counter_mutex); | ||
error_count = 0; | ||
location_count = 0; | ||
redirect_count = 0; | ||
wsredirect_count = 0; | ||
}; | ||
|
||
auto check_counters = [&](CounterValue locations, CounterValue redirects, CounterValue wsredirects, | ||
CounterValue errors) { | ||
std::lock_guard lk(counter_mutex); | ||
check_value(location_count, locations, "locations"); | ||
check_value(redirect_count, redirects, "redirects"); | ||
check_value(wsredirect_count, wsredirects, "ws redirects"); | ||
check_value(error_count, errors, "errors"); | ||
}; | ||
|
||
// Make sure the location response points to the actual server | ||
get_redirector()->force_http_redirect(false); | ||
get_redirector()->force_websocket_redirect(false); | ||
|
||
TestAppSession session{app_session, {}, DeleteApp{false}}; | ||
auto app = session.app(); | ||
|
||
// We should have already requested the location when the user was logged in | ||
// during the session constructor. | ||
auto user1 = app->current_user(); | ||
REQUIRE(user1); | ||
// Expected location requested 1 time for the original location request, | ||
// all others 0 since location request prior to login hits actual server | ||
check_counters({1}, {0}, {0}, {0}); | ||
REQUIRE(app->get_base_url() == get_redirector()->base_url()); | ||
REQUIRE(app->get_host_url() == get_redirector()->server_url()); | ||
|
||
SECTION("Appservices requests are redirected") { | ||
// Switch the location to use the redirector's address for http requests which will | ||
// return redirect responses to redirect the request to the actual server | ||
get_redirector()->force_http_redirect(true); | ||
get_redirector()->force_websocket_redirect(false); | ||
reset_counters(); | ||
// Reset the location flag and the cached location info so the app will request | ||
// the location from the original base URL again upon the next appservices request. | ||
app->reset_location_for_testing(); | ||
// Email registration should complete successfully | ||
AutoVerifiedEmailCredentials creds; | ||
{ | ||
auto pf = util::make_promise_future<void>(); | ||
app->provider_client<app::App::UsernamePasswordProviderClient>().register_email( | ||
creds.email, creds.password, | ||
[promise = util::CopyablePromiseHolder<void>(std::move(pf.promise))]( | ||
util::Optional<app::AppError> error) mutable { | ||
if (error) { | ||
promise.get_promise().set_error(error->to_status()); | ||
return; | ||
} | ||
promise.get_promise().emplace_value(); | ||
}); | ||
REQUIRE(pf.future.get_no_throw().is_ok()); | ||
} | ||
// Login should fail since the profile() command does not complete successfully due | ||
// to the authorization headers being stripped from the redirected request | ||
REQUIRE_FALSE(session.log_in_user(creds).is_ok()); | ||
// User was originally logged in, but logged out when profile request failed and | ||
// app's current user was not updated | ||
auto user2 = app->current_user(); | ||
REQUIRE(user2->is_logged_in()); | ||
REQUIRE(user1 == user2); | ||
// Expected location requested 2 times, and at least 1 redirects, all others 0 | ||
check_counters({2}, {true, 1}, {0}, {0}); | ||
REQUIRE(app->get_base_url() == get_redirector()->base_url()); | ||
REQUIRE(app->get_host_url() == get_redirector()->base_url()); | ||
|
||
// Revert the location to point to the actual server's address so the login | ||
// will complete successfully. | ||
get_redirector()->force_http_redirect(false); | ||
get_redirector()->force_websocket_redirect(false); | ||
reset_counters(); | ||
// Log in will refresh the location prior to performing the login | ||
auto result = session.log_in_user(creds); | ||
REQUIRE(result.is_ok()); | ||
// Since the log in completed successfully, app's current user was updated to | ||
// the new user. | ||
auto user3 = result.get_value(); | ||
REQUIRE(user3); | ||
REQUIRE(user3->is_logged_in()); | ||
REQUIRE(user3 == app->current_user()); | ||
REQUIRE(user3 != user2); | ||
// Expected location requested 1 time for location prior to login, all others 0 | ||
check_counters({1}, {0}, {0}, {0}); | ||
REQUIRE(app->get_base_url() == get_redirector()->base_url()); | ||
REQUIRE(app->get_host_url() == get_redirector()->server_url()); | ||
} | ||
|
||
SECTION("Websocket connection returns redirection") { | ||
auto get_dogs = [](SharedRealm r) -> Results { | ||
wait_for_upload(*r, std::chrono::seconds(10)); | ||
wait_for_download(*r, std::chrono::seconds(10)); | ||
return Results(r, r->read_group().get_table("class_Dog")); | ||
}; | ||
|
||
auto create_one_dog = [](SharedRealm r) { | ||
r->begin_transaction(); | ||
CppContext c; | ||
Object::create(c, r, "Dog", | ||
std::any(AnyDict{{"_id", std::any(ObjectId::gen())}, | ||
{"breed", std::string("bulldog")}, | ||
{"name", std::string("fido")}}), | ||
CreatePolicy::ForceCreate); | ||
r->commit_transaction(); | ||
}; | ||
|
||
const auto schema = get_default_schema(); | ||
const auto partition = random_string(100); | ||
// First websocket connection is not using redirection. Should connect | ||
// directly to the actual server | ||
{ | ||
reset_counters(); | ||
SyncTestFile config(user1, partition, schema); | ||
auto r = Realm::get_shared_realm(config); | ||
REQUIRE(get_dogs(r).size() == 0); | ||
create_one_dog(r); | ||
REQUIRE(get_dogs(r).size() == 1); | ||
// The redirect server is not expected to be used... | ||
check_counters({0}, {0}, {0}, {0}); | ||
} | ||
// Switch the location to use the redirector's address for websocket requests which will | ||
// return the 4003 redirect close code, forcing app to update the location and refresh | ||
// the access token. | ||
get_redirector()->force_websocket_redirect(true); | ||
// Since app uses the hostname value returned from the last location response to create | ||
// the server URL for requesting the location, the first location request (due to the | ||
// location_updated flag being reset) needs to return the redirect server for both | ||
// hostname and ws_hostname. When the location is requested a second time due to the | ||
// login request, the location response should include the actual server for the | ||
// hostname (so the login is successful) and the redirect server for the ws_hostname | ||
// so the websocket initially connects to the redirect server. | ||
{ | ||
auto& redirector = get_redirector(); | ||
redirector->force_http_redirect(true); | ||
redirector->set_event_hook([&](RedirectEvent event, std::optional<std::string> message) { | ||
std::lock_guard lk(counter_mutex); | ||
switch (event) { | ||
case RedirectEvent::location: | ||
location_count++; | ||
logger->debug("Redirector event: location - count: %1", location_count); | ||
if (location_count == 1) | ||
// No longer sending redirect server as location hostname value | ||
redirector->force_http_redirect(false); | ||
return; | ||
case RedirectEvent::redirect: | ||
redirect_count++; | ||
logger->debug("Redirector event: redirect - count: %1", redirect_count); | ||
return; | ||
case RedirectEvent::ws_redirect: | ||
wsredirect_count++; | ||
logger->debug("Redirector event: ws_redirect - count: %1", wsredirect_count); | ||
return; | ||
case RedirectEvent::error: | ||
error_count++; | ||
logger->error("Redirect server received error: %1", message.value_or("unknown error")); | ||
return; | ||
} | ||
}); | ||
} | ||
{ | ||
reset_counters(); | ||
// Reset the location flag and the cached location info so the app will request | ||
// the location from the original base URL again upon the next appservices request. | ||
app->reset_location_for_testing(); | ||
// Create a new user and log in to update the location info | ||
// and start with a new realm | ||
auto result = session.create_user_and_log_in(); | ||
REQUIRE(result.is_ok()); | ||
// The location should have been requested twice; once since the location_updated | ||
// flag was reset and a second time for the login request. One redirect occurred | ||
// for the register_email request, since that was sent to the redirect server. | ||
check_counters({2}, {1}, {0}, {0}); | ||
reset_counters(); | ||
SyncTestFile config(app->current_user(), partition, schema); | ||
auto r = Realm::get_shared_realm(config); | ||
Results dogs = get_dogs(r); | ||
REQUIRE(dogs.size() == 1); | ||
REQUIRE(dogs.get(0).get<String>("breed") == "bulldog"); | ||
REQUIRE(dogs.get(0).get<String>("name") == "fido"); | ||
// The location should have been requested again and the websocket should have | ||
// been hit, which sent the redirect close code. | ||
check_counters({0}, {0}, {1}, {0}); | ||
} | ||
} | ||
} | ||
|
||
TEST_CASE("app: sync logs contain baas coid", "[sync][app][baas]") { | ||
class InMemoryLogger : public util::Logger { | ||
public: | ||
|
@@ -3555,7 +3811,13 @@ TEST_CASE("app: base_url", "[sync][app][base_url]") { | |
{ | ||
if (request.url.find("/location") != std::string::npos) { | ||
CHECK(request.method == HttpMethod::get); | ||
CHECK_THAT(request.url, ContainsSubstring(expected_url)); | ||
// Location is now requested again when the user logs in - only check the exepected | ||
// url on the first location request - after that, it will be using the location_url | ||
// value when requesting the location. | ||
if (!location_requested) | ||
CHECK_THAT(request.url, ContainsSubstring(expected_url)); | ||
else | ||
CHECK_THAT(request.url, ContainsSubstring(location_url)); | ||
location_requested = true; | ||
if (location_returns_error) { | ||
completion(app::Response{static_cast<int>(sync::HTTPStatus::NotFound), 0, {}, "404 not found"}); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -22,6 +22,7 @@ | |
#ifdef REALM_ENABLE_AUTH_TESTS | ||
|
||
#include <util/sync/sync_test_utils.hpp> | ||
#include <util/sync/redirect_server.hpp> | ||
|
||
#include <realm/object-store/property.hpp> | ||
#include <realm/object-store/object_schema.hpp> | ||
|
@@ -282,6 +283,9 @@ std::string get_real_base_url(); | |
// your test is talking to. | ||
std::string get_admin_url(); | ||
|
||
// Returns a reference to the redirector if it is enabled. | ||
std::optional<sync::RedirectingHttpServer>& get_redirector(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of pulling the redirect server in as an include dependency of anyone using the baas admin API why not just instantiate one where you need it? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Removed the function in |
||
|
||
template <typename Factory> | ||
inline app::AppConfig get_config(Factory factory, const AppSession& app_session) | ||
{ | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think getting logged out is the existing behavior that's always been there, do we really want to change that now?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You do get logged out, but you will never be able to logged in again due to the authorization header being stripped from the
/profile
request, which will log you out while you are trying to log in.By updating the location when you attempt to log in, the client app will have the latest server location info and the login attempt should be successful, instead of failing when trying to query the user's profile.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So has redirection after a region migration always been totally broken? Like you cannot recover? Because I think all the SDKs HTTP implementations have been stripping the authorization header out when following a redirect this whole time...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's definitely an edge case and I doubt any customers have hit this specific use case:
they have to do a deployment change while the app is already running; and the user has to have already performed some operation that updated the location prior to the deployment change, such as logging in.
After the deployment change (and the requests start getting redirected), any app services request like updating the access token will log the user out and they won't be able to successfully log in again.
Fortunately, restarting the app will also resolve the issue, since it will require the location to be updated before sending any app services requests.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We talked about this offline - instead of making every call to
log_in_with_credentials()
pre-emptively request a location update, we're going to request a location update if the call to get the user's profile fails with a 401 unauthorized error. That way this error handling gets a bit slower in this edge case, but the behavior of all otherlog_in_with_credentials()
should stay the same.