Skip to content

Commit

Permalink
fix: reconnect web components after session expiration (#20407)
Browse files Browse the repository at this point in the history
* fix: reconnect web components after session expiration

After session expiration, Flow client in webcomponent mode send a GET request
to the server to re-initialize itself with a valid session cookie.
However, the XHR call is done with the withCredentials flag set to false,
making the browser ignore the Set-Cookie header in the response.
This change forces the withCredential flag to true for resync request
so that the new cookie can be handled by the browser and reused in the
subsequent request that re-intitializes the embedded component.
If PUSH is enabled, it also restores the connection after resynchornization
request to make sure pending invocation queue, and especially the
webcomponent connected events, can be flushed correctly and sent to the
server.
Also temporarily suspends hearbeat during resynchronization request to prevent
issue with concurrent requests, potentially causing duplicated session
expiration handling on the client.

Fixes #19620

* add tests
  • Loading branch information
mcollovati authored Nov 11, 2024
1 parent 9ff0957 commit 6f2ab8d
Show file tree
Hide file tree
Showing 39 changed files with 1,364 additions and 23 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ public DefaultRegistry(ApplicationConnection connection,
set(InitialPropertiesHandler.class, new InitialPropertiesHandler(this));

// Classes with dependencies, in correct order
set(Heartbeat.class, new Heartbeat(this));
Supplier<Heartbeat> heartbeatSupplier = () -> new Heartbeat(this);
set(Heartbeat.class, heartbeatSupplier);
set(ConnectionStateHandler.class,
new DefaultConnectionStateHandler(this));
set(XhrConnection.class, new XhrConnection(this));
Expand Down
70 changes: 60 additions & 10 deletions flow-client/src/main/java/com/vaadin/client/SystemErrorHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

import com.google.gwt.core.client.Scheduler;
import com.google.gwt.xhr.client.XMLHttpRequest;

import com.vaadin.client.bootstrap.ErrorMessage;
import com.vaadin.client.communication.MessageHandler;
import com.vaadin.client.gwt.elemental.js.util.Xhr;
Expand Down Expand Up @@ -144,47 +145,96 @@ public void handleUnrecoverableError(String caption, String message,
}
}

private boolean resyncInProgress = false;

/**
* Send GET async request to acquire new JSESSIONID, browser will set cookie
* automatically based on Set-Cookie response header.
*/
private void resynchronizeSession() {
if (resyncInProgress) {
Console.debug(
"Web components resynchronization already in progress");
return;
}
resyncInProgress = true;
String serviceUrl = registry.getApplicationConfiguration()
.getServiceUrl() + "web-component/web-component-bootstrap.js";

// Stop heart beat to prevent requests during resynchronization
registry.getHeartbeat().setInterval(-1);
if (registry.getPushConfiguration().isPushEnabled()) {
registry.getMessageSender().setPushEnabled(false, false);
}

String sessionResyncUri = SharedUtil.addGetParameter(serviceUrl,
ApplicationConstants.REQUEST_TYPE_PARAMETER,
ApplicationConstants.REQUEST_TYPE_WEBCOMPONENT_RESYNC);

Xhr.get(sessionResyncUri, new Xhr.Callback() {
Xhr.getWithCredentials(sessionResyncUri, new Xhr.Callback() {
@Override
public void onFail(XMLHttpRequest xhr, Exception exception) {
registry.getHeartbeat().setInterval(registry
.getApplicationConfiguration().getHeartbeatInterval());
handleError(exception);
}

@Override
public void onSuccess(XMLHttpRequest xhr) {

Console.log(
"Received xhr HTTP session resynchronization message: "
+ xhr.getResponseText());

registry.reset();
registry.getUILifecycle().setState(UILifecycle.UIState.RUNNING);
// Make sure heartbeat has not been restarted. This is
// especially important if the uiId gets reset after session
// expiration, to prevent multiple heartbeats requests for
// different ui
registry.getHeartbeat().setInterval(-1);

int uiId = registry.getApplicationConfiguration().getUIId();
ValueMap json = MessageHandler
.parseWrappedJson(xhr.getResponseText());
int newUiId = json.getInt(ApplicationConstants.UI_ID);
if (newUiId != uiId) {
Console.debug("UI ID switched from " + uiId + " to "
+ newUiId + " after resynchronization");
registry.getApplicationConfiguration().setUIId(newUiId);
}
registry.reset();

registry.getUILifecycle().setState(UILifecycle.UIState.RUNNING);
registry.getMessageHandler().handleMessage(json);
registry.getApplicationConfiguration()
.setUIId(json.getInt(ApplicationConstants.UI_ID));

Scheduler.get().scheduleDeferred(() -> Arrays
.stream(registry.getApplicationConfiguration()
.getExportedWebComponents())
.forEach(SystemErrorHandler.this::recreateNodes));
boolean pushEnabled = registry.getPushConfiguration()
.isPushEnabled();
if (pushEnabled) {
// PUSH connection might have been closed in response to
// sever session expiration. If PUSH is required, reconnect
// before recreating web components to make sure the
// connected events can be propagated to the server.
// PUSH reconnection is deferred to allow current request
// to complete and process the Set-Cookie header.
Scheduler.get().scheduleDeferred(() -> {
Console.debug("Re-establish PUSH connection");
registry.getMessageSender().setPushEnabled(true);
Scheduler.get().scheduleDeferred(
() -> recreateWebComponents());
});
} else {
Scheduler.get()
.scheduleDeferred(() -> recreateWebComponents());
}
}
});
}

private void recreateWebComponents() {
Arrays.stream(registry.getApplicationConfiguration()
.getExportedWebComponents())
.forEach(SystemErrorHandler.this::recreateNodes);
resyncInProgress = false;
}

private native void recreateNodes(String elementName)
/*-{
var elements = document.getElementsByTagName(elementName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,6 @@ public AtmospherePushConnection(Registry registry) {
} else {
config.setStringValue(key, value);
}

});

String pushServletMapping = getPushConfiguration()
Expand Down Expand Up @@ -686,6 +685,7 @@ protected final native AtmosphereConfiguration createConfig()
fallbackTransport: 'long-polling',
contentType: 'application/json; charset=UTF-8',
reconnectInterval: 5000,
withCredentials: true,
maxWebsocketErrorRetries: 12,
timeout: -1,
maxReconnectOnClose: 10000000,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -534,8 +534,11 @@ private void pauseHeartbeats() {
}

private void resumeHeartbeats() {
registry.getHeartbeat().setInterval(
registry.getApplicationConfiguration().getHeartbeatInterval());
// Resume heart beat only if it was not terminated (interval == -1)
if (registry.getHeartbeat().getInterval() >= 0) {
registry.getHeartbeat().setInterval(registry
.getApplicationConfiguration().getHeartbeatInterval());
}
}

private boolean redirectIfRefreshToken(String message) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import com.google.gwt.user.client.Timer;
import com.google.gwt.xhr.client.XMLHttpRequest;

import com.vaadin.client.Console;
import com.vaadin.client.Registry;
import com.vaadin.client.gwt.elemental.js.util.Xhr;
Expand Down Expand Up @@ -74,8 +75,13 @@ public Heartbeat(Registry registry) {
*/
public void send() {
timer.cancel();
if (interval < 0) {
Console.debug("Heartbeat terminated, skipping request");
return;
}

Console.debug("Sending heartbeat request...");

Xhr.post(uri, null, "text/plain; charset=utf-8", new Xhr.Callback() {

@Override
Expand All @@ -86,12 +92,19 @@ public void onSuccess(XMLHttpRequest xhr) {

@Override
public void onFail(XMLHttpRequest xhr, Exception e) {

// Handler should stop the application if heartbeat should no
// longer be sent
if (e == null) {
registry.getConnectionStateHandler()
.heartbeatInvalidStatusCode(xhr);
// Heartbeat has been terminated before response processing.
// Most likely a session expiration happened, and it has
// already been handled by another component.
if (interval < 0) {
Console.debug(
"Heartbeat terminated, ignoring failure.");
} else {
registry.getConnectionStateHandler()
.heartbeatInvalidStatusCode(xhr);
}
} else {
registry.getConnectionStateHandler().heartbeatException(xhr,
e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@
package com.vaadin.client.communication;

import com.google.gwt.core.client.GWT;
import com.vaadin.client.Console;

import com.vaadin.client.ConnectionIndicator;
import com.vaadin.client.Console;
import com.vaadin.client.Registry;
import com.vaadin.flow.shared.ApplicationConstants;

Expand Down Expand Up @@ -91,10 +92,14 @@ public void sendInvocationsToServer() {
return;
}

if (registry.getRequestResponseTracker().hasActiveRequest()
|| (push != null && !push.isActive())) {
boolean hasActiveRequest = registry.getRequestResponseTracker()
.hasActiveRequest();
if (hasActiveRequest || (push != null && !push.isActive())) {
// There is an active request or push is enabled but not active
// -> send when current request completes or push becomes active
Console.debug("Postpone sending invocations to server because of "
+ (hasActiveRequest ? "active request"
: "PUSH not active"));
} else {
doSendInvocationsToServer();
}
Expand Down Expand Up @@ -200,9 +205,11 @@ public void send(final JsonObject payload) {
// server after a reconnection.
// Reference will be cleaned up once the server confirms it has
// seen this message
Console.debug("send PUSH");
pushPendingMessage = payload;
push.push(payload);
} else {
Console.log("send XHR");
registry.getXhrConnection().send(payload);
}
}
Expand All @@ -215,7 +222,22 @@ public void send(final JsonObject payload) {
* <code>false</code> to disable the push connection.
*/
public void setPushEnabled(boolean enabled) {
if (enabled && push == null) {
setPushEnabled(enabled, true);
}

/**
* Sets the status for the push connection.
*
* @param enabled
* <code>true</code> to enable the push connection;
* <code>false</code> to disable the push connection.
* @param reEnableIfNeeded
* <code>true</code> if push should be re-enabled after
* disconnection if configuration changed; <code>false</code> to
* prevent reconnection.
*/
public void setPushEnabled(boolean enabled, boolean reEnableIfNeeded) {
if (enabled && (push == null || !push.isActive())) {
push = pushConnectionFactory.create(registry);
} else if (!enabled && push != null && push.isActive()) {
push.disconnect(() -> {
Expand All @@ -225,7 +247,8 @@ public void setPushEnabled(boolean enabled) {
* old connection to disconnect, now is the right time to open a
* new connection
*/
if (registry.getPushConfiguration().isPushEnabled()) {
if (reEnableIfNeeded
&& registry.getPushConfiguration().isPushEnabled()) {
setPushEnabled(true);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package com.vaadin.client.communication;

import com.google.gwt.core.client.Scheduler;

import com.vaadin.client.Console;
import com.vaadin.client.Registry;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import com.google.gwt.core.client.JavaScriptException;
import com.google.gwt.xhr.client.ReadyStateChangeHandler;
import com.google.gwt.xhr.client.XMLHttpRequest;

import com.vaadin.client.Console;

import elemental.client.Browser;
Expand Down Expand Up @@ -90,6 +91,23 @@ public static XMLHttpRequest get(String url, Callback callback) {
return request(create(), "GET", url, callback);
}

/**
* Send a GET request to the <code>url</code> including credentials in XHR,
* and dispatch updates to the <code>callback</code>.
*
* @param url
* the URL
* @param callback
* the callback to be notified
* @return a reference to the sent XmlHttpRequest
*/
public static XMLHttpRequest getWithCredentials(String url,
Callback callback) {
XMLHttpRequest request = create();
request.setWithCredentials(true);
return request(request, "GET", url, callback);
}

/**
* Send a GET request to the <code>url</code> and dispatch updates to the
* <code>callback</code>.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,8 @@ protected boolean handleWebComponentResyncRequest(BootstrapContext context,
json.put(ApplicationConstants.UI_ID, context.getUI().getUIId());
json.put(ApplicationConstants.UIDL_SECURITY_TOKEN_ID,
context.getUI().getCsrfToken());
json.put(ApplicationConstants.UIDL_PUSH_ID,
context.getUI().getSession().getPushId());
String responseString = "for(;;);[" + JsonUtil.stringify(json) + "]";

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ protected String bootstrapNpm(boolean productionMode) {
const delay = 200;
const poll = async () => {
try {
const response = await fetch(bootstrapSrc, { method: 'HEAD', headers: { 'X-DevModePoll': 'true' } });
const response = await fetch(bootstrapSrc, { method: 'HEAD', credentials: 'include', headers: { 'X-DevModePoll': 'true' } });
if (response.headers.has('X-DevModePending')) {
setTimeout(poll, delay);
} else {
Expand Down
3 changes: 3 additions & 0 deletions flow-tests/test-frontend/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@
<!-- npm and pnpm dev mode and prod mode -->
<!-- run production build before dev build as dev build has npm i in thread -->
<module>vite-embedded-webcomponent-resync</module>
<module>vite-embedded-webcomponent-resync-ws</module>
<module>vite-embedded-webcomponent-resync-wsxhr</module>
<module>vite-embedded-webcomponent-resync-longpolling</module>
<module>test-npm/pom-production.xml</module>
<module>test-npm</module>
<module>test-pnpm/pom-production.xml</module>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
!**/index.html
Loading

0 comments on commit 6f2ab8d

Please sign in to comment.