[unifi] Fix session reauthentication for websockets (#21063)

* [unifi] Fixes session reauthentication for websockets
When a protect or access websocket session expired, the client was not
reauthorizing correctly with the new shared auth model.

Signed-off-by: Dan Cunningham <dan@digitaldan.com>
This commit is contained in:
Dan Cunningham
2026-06-26 23:00:29 +02:00
committed by GitHub
parent 82f0c988e6
commit 43c8de7a65
8 changed files with 195 additions and 31 deletions
@@ -42,9 +42,12 @@ import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.api.UpgradeRequest;
import org.eclipse.jetty.websocket.api.UpgradeResponse;
import org.eclipse.jetty.websocket.api.WebSocketAdapter;
import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
import org.eclipse.jetty.websocket.client.WebSocketClient;
import org.eclipse.jetty.websocket.client.io.UpgradeListener;
import org.openhab.binding.unifi.internal.access.dto.Device;
import org.openhab.binding.unifi.internal.access.dto.DeviceAccessMethodSettings;
import org.openhab.binding.unifi.internal.access.dto.Door;
@@ -887,6 +890,12 @@ public final class UnifiAccessApiClient implements Closeable {
// Seed the heartbeat timestamp so the monitor doesn't see the default 0 and immediately escalate
// if its first tick races with onWebSocketConnect.
this.lastHeartbeatEpochMs = System.currentTimeMillis();
connectNotifications(onOpen, onMessage, onError, true);
startWsMonitor();
}
private void connectNotifications(Runnable onOpen, Consumer<Notification> onMessage, Consumer<Throwable> onError,
boolean allowReauth) throws UnifiAccessApiException {
try {
URI wsUri = URI.create("wss://" + host + V2_BASE + "/ws/notification");
logger.debug("Notifications WebSocket URI: {}", wsUri);
@@ -960,13 +969,50 @@ public final class UnifiAccessApiClient implements Closeable {
}
};
wsClient.connect(socket, wsUri, req);
startWsMonitor();
// The upgrade HTTP status is delivered here directly; a 401 means an expired session cookie.
UpgradeListener upgradeListener = new UpgradeListener() {
@Override
@NonNullByDefault({})
public void onHandshakeRequest(UpgradeRequest request) {
}
@Override
@NonNullByDefault({})
public void onHandshakeResponse(UpgradeResponse response) {
int status = response.getStatusCode();
logger.debug("Notifications WebSocket upgrade response: {}", status);
if (allowReauth && status == HttpStatus.UNAUTHORIZED_401) {
executorService.execute(() -> reauthenticateAndReconnect(onOpen, onMessage, onError));
}
}
};
wsClient.connect(socket, wsUri, req, upgradeListener);
} catch (IOException e) {
throw new UnifiAccessApiException("WebSocket connect failed: " + e.getMessage(), e);
}
}
private void reauthenticateAndReconnect(Runnable onOpen, Consumer<Notification> onMessage,
Consumer<Throwable> onError) {
logger.debug("Notifications WebSocket upgrade rejected (401); re-authenticating and retrying");
unifiSession.reauthenticate().whenComplete((v, ex) -> {
if (ex != null) {
logger.debug("Re-authentication after WebSocket 401 failed: {}", ex.getMessage());
return;
}
synchronized (this) {
if (closed) {
return;
}
try {
connectNotifications(onOpen, onMessage, onError, false);
} catch (UnifiAccessApiException e) {
logger.debug("WebSocket reconnect after re-auth failed: {}", e.getMessage());
}
}
});
}
// ---- HTTP Helpers ----
private ContentResponse execGet(String path) throws UnifiAccessApiException {
@@ -32,6 +32,8 @@ public class UniFiSessionImpl implements UniFiSession {
private final String baseUrl;
private final UniFiAuthenticator authenticator;
private final UniFiRequestThrottler throttler;
private final Object reauthLock = new Object();
private @Nullable CompletableFuture<Void> reauthInFlight;
public UniFiSessionImpl(String baseUrl, UniFiAuthenticator authenticator, UniFiRequestThrottler throttler) {
this.baseUrl = baseUrl;
@@ -66,8 +68,17 @@ public class UniFiSessionImpl implements UniFiSession {
@Override
public CompletableFuture<Void> reauthenticate() {
authenticator.clearAuth();
return authenticator.authenticate();
// Don't clear credentials first: a failed/throttled login must leave the still-valid cookie in place.
// Coalesce concurrent callers (Network/Protect/Access share this session).
synchronized (reauthLock) {
CompletableFuture<Void> existing = reauthInFlight;
if (existing != null && !existing.isDone()) {
return existing;
}
CompletableFuture<Void> future = authenticator.authenticate();
reauthInFlight = future;
return future;
}
}
@Override
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.unifi.internal.api;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.websocket.api.UpgradeException;
/**
* Helpers shared by the family WebSocket clients (UniFi Protect private API, UniFi Access notifications).
*
* @author Dan Cunningham - Initial contribution
*/
@NonNullByDefault
public final class UniFiWebSocketUtil {
private UniFiWebSocketUtil() {
}
/**
* @return {@code true} if the throwable chain represents a WebSocket upgrade rejected with HTTP 401.
*/
public static boolean isUnauthorizedUpgrade(@Nullable Throwable ex) {
Throwable cause = ex;
while (cause != null) {
if (cause instanceof UpgradeException ue && ue.getResponseStatusCode() == HttpStatus.UNAUTHORIZED_401) {
return true;
}
cause = cause.getCause();
}
return false;
}
}
@@ -146,4 +146,10 @@ public class UniFiProtectAuthenticator {
this.userId = null;
logger.debug("Cleared cached user id");
}
public CompletableFuture<Void> reauthenticate() {
this.userId = null;
logger.debug("Re-authenticating shared UniFi session");
return session.reauthenticate();
}
}
@@ -35,6 +35,7 @@ import org.eclipse.jetty.client.util.StringContentProvider;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpStatus;
import org.openhab.binding.unifi.internal.api.UniFiSession;
import org.openhab.binding.unifi.internal.api.UniFiWebSocketUtil;
import org.openhab.binding.unifi.internal.protect.api.priv.dto.devices.Camera;
import org.openhab.binding.unifi.internal.protect.api.priv.dto.devices.Chime;
import org.openhab.binding.unifi.internal.protect.api.priv.dto.devices.Doorlock;
@@ -77,6 +78,7 @@ public class UniFiProtectPrivateClient {
private volatile @Nullable UniFiProtectPrivateWebSocket webSocket;
private volatile @Nullable ScheduledFuture<?> bootstrapRefreshTask;
private volatile @Nullable CompletableFuture<Bootstrap> inFlightBootstrapRefresh;
private @Nullable CompletableFuture<Void> reauthInFlight;
/**
* Create a new UniFi Protect private-API client using a session supplied by the parent
@@ -196,23 +198,46 @@ public class UniFiProtectPrivateClient {
*/
public CompletableFuture<Void> enableWebSocket(
Consumer<UniFiProtectPrivateWebSocket.WebSocketUpdate> updateHandler) {
return ensureAuthenticated().thenCompose(v -> {
return ensureAuthenticated().thenCompose(v -> connectWebSocket(updateHandler, true));
}
// A null cookie is fine: the shared HttpClient's CookieStore supplies it on the upgrade (as for REST), and a
// genuine 401 is re-authenticated and retried below.
private CompletableFuture<Void> connectWebSocket(
Consumer<UniFiProtectPrivateWebSocket.WebSocketUpdate> updateHandler, boolean allowReauth) {
if (webSocket != null) {
logger.debug("WebSocket already enabled");
return CompletableFuture.completedFuture(null);
}
String wsUrl = baseUrl.replace("https://", "wss://").replace("http://", "ws://")
+ "/proxy/protect/ws/updates";
String wsUrl = baseUrl.replace("https://", "wss://").replace("http://", "ws://") + "/proxy/protect/ws/updates";
String cookie = authenticator.getAuthCookie();
if (cookie == null) {
return CompletableFuture.failedFuture(new IllegalStateException("Not authenticated"));
UniFiProtectPrivateWebSocket ws = new UniFiProtectPrivateWebSocket(wsUrl, cookie, updateHandler, this,
httpClient);
return ws.connect().handle((connected, ex) -> ex).thenCompose(ex -> {
if (ex == null) {
webSocket = ws;
return CompletableFuture.<Void> completedFuture(null);
}
webSocket = new UniFiProtectPrivateWebSocket(wsUrl, cookie, updateHandler, this, httpClient);
return webSocket.connect();
ws.disconnect();
if (allowReauth && UniFiWebSocketUtil.isUnauthorizedUpgrade(ex)) {
logger.debug("WebSocket upgrade rejected (401); re-authenticating and retrying");
return reauthenticateSession().thenCompose(v -> connectWebSocket(updateHandler, false));
}
return CompletableFuture.<Void> failedFuture(ex);
});
}
private synchronized CompletableFuture<Void> reauthenticateSession() {
CompletableFuture<Void> existing = reauthInFlight;
if (existing != null && !existing.isDone()) {
return existing;
}
CompletableFuture<Void> future = authenticator.reauthenticate();
reauthInFlight = future;
return future;
}
/**
* Generic API request
*/
@@ -238,6 +263,12 @@ public class UniFiProtectPrivateClient {
*/
private <T> CompletableFuture<T> makeApiRequest(HttpMethod method, String path, @Nullable Object body,
Class<T> responseType) {
return makeApiRequest(method, path, body, responseType, true);
}
// allowReauth=true: a 401 re-authenticates and retries once (the retry passes false to avoid looping).
private <T> CompletableFuture<T> makeApiRequest(HttpMethod method, String path, @Nullable Object body,
Class<T> responseType, boolean allowReauth) {
return ensureAuthenticated().thenCompose(v -> {
CompletableFuture<T> future = new CompletableFuture<>();
@@ -276,10 +307,30 @@ public class UniFiProtectPrivateClient {
int status = response.getStatus();
if (status == HttpStatus.UNAUTHORIZED_401) {
if (allowReauth) {
logger.debug("Authentication rejected: {}, re-authenticating and retrying", status);
reauthenticateSession().whenComplete((rv, rex) -> {
if (rex != null) {
authenticator.clearAuth();
future.completeExceptionally(new AuthenticationException(
"Re-authentication failed after " + status, rex));
} else {
makeApiRequest(method, path, body, responseType, false)
.whenComplete((retryResult, retryEx) -> {
if (retryEx != null) {
future.completeExceptionally(retryEx);
} else {
future.complete(retryResult);
}
});
}
});
} else {
logger.debug("Authentication rejected: {}", status);
authenticator.clearAuth();
future.completeExceptionally(
new AuthenticationException("Authentication rejected: " + status));
}
return;
}
if (status == HttpStatus.FORBIDDEN_403 || status == HttpStatus.TOO_MANY_REQUESTS_429) {
@@ -58,7 +58,7 @@ public class UniFiProtectPrivateWebSocket {
private final Logger logger = LoggerFactory.getLogger(UniFiProtectPrivateWebSocket.class);
private final String wsUrl;
private final String authCookie;
private final @Nullable String authCookie;
private final Consumer<WebSocketUpdate> updateHandler;
private final UniFiProtectPrivateClient client;
private final WebSocketClient wsClient;
@@ -81,8 +81,8 @@ public class UniFiProtectPrivateWebSocket {
* @param client Reference to the main client
* @param httpClient HTTP client to use (shared from main client)
*/
public UniFiProtectPrivateWebSocket(String wsUrl, String authCookie, Consumer<WebSocketUpdate> updateHandler,
UniFiProtectPrivateClient client, HttpClient httpClient) {
public UniFiProtectPrivateWebSocket(String wsUrl, @Nullable String authCookie,
Consumer<WebSocketUpdate> updateHandler, UniFiProtectPrivateClient client, HttpClient httpClient) {
this.wsUrl = wsUrl;
this.authCookie = authCookie;
this.updateHandler = updateHandler;
@@ -298,10 +298,11 @@ public class UnifiProtectNVRHandler extends BaseBridgeHandler {
inner = cause.getCause();
}
if (cause instanceof AuthenticationException) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/offline.conf-error-auth-rejected");
// Already survived an in-client re-auth; treat as a transient expired session and reconnect with a
// status detail so the failure is visible rather than a bare OFFLINE.
setOfflineAndReconnect(false, "@text/offline.comm-error-retrying");
} else if (cause instanceof ThrottledException) {
setOfflineAndReconnect(true);
setOfflineAndReconnect(true, null);
} else {
// Public API 401 (bad token) arrives as IOException("HTTP 401: ...").
if (cause instanceof IOException ioe) {
@@ -310,7 +311,7 @@ public class UnifiProtectNVRHandler extends BaseBridgeHandler {
attemptTokenRecovery();
}
}
setOfflineAndReconnect(false);
setOfflineAndReconnect(false, null);
}
}
@@ -527,7 +528,7 @@ public class UnifiProtectNVRHandler extends BaseBridgeHandler {
}
}
private synchronized void setOfflineAndReconnect(boolean throttled) {
private synchronized void setOfflineAndReconnect(boolean throttled, @Nullable String message) {
ScheduledFuture<?> existing = this.reconnectTask;
if (shuttingDown) {
return;
@@ -555,8 +556,12 @@ public class UnifiProtectNVRHandler extends BaseBridgeHandler {
delay = Math.min((int) Math.pow(2, reconnectAttempt) * 5, MAX_RECONNECT_DELAY_SECONDS);
reconnectAttempt++;
logger.debug("Scheduling reconnect in {} seconds (attempt {})", delay, reconnectAttempt);
if (message != null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);
} else {
updateStatus(ThingStatus.OFFLINE);
}
}
this.reconnectTask = scheduler.schedule(this::initialize, delay, TimeUnit.SECONDS);
}
@@ -574,7 +579,7 @@ public class UnifiProtectNVRHandler extends BaseBridgeHandler {
scheduler.execute(() -> syncDevices());
}, (code, reason) -> {
logger.debug("Event WS closed: {} {}", code, reason);
setOfflineAndReconnect(false);
setOfflineAndReconnect(false, null);
}, err -> logger.debug("Event WS error", err)).get();
return;
} catch (ExecutionException e) {
@@ -636,7 +641,7 @@ public class UnifiProtectNVRHandler extends BaseBridgeHandler {
// ignore on-open
}, (code, reason) -> {
logger.debug("Device WS closed: {} {}", code, reason);
setOfflineAndReconnect(false);
setOfflineAndReconnect(false, null);
}, err -> logger.debug("Device WS error", err)).get();
return;
} catch (ExecutionException e) {
@@ -421,6 +421,7 @@ offline.conf-error-no-user-id = Failed to retrieve user ID from UniFi Protect
offline.conf-error-api-key-empty = API key creation returned empty result
offline.conf-error-api-key-creation = Failed to create API key
offline.comm-error-refresh-failed = Failed to refresh device from API
offline.comm-error-retrying = Authentication failed, retrying
offline.conf-error-auth-rejected = Authentication failed - check username and password
offline.gone = Device no longer found on NVR
offline.login-throttled = Login throttled by UniFi Protect controller