Background:
I have a Scala (with akka-http
) webserver running behind NGINX. I also have a NextJS
application running behind the same NGINX.
My goal here is for the NextJS
application to establish a (secure) ws
connection with the webserver.
I have followed official documentation and guidelines, and this is my current implementation:
The webserver side:
val responseQueue: mutable.Queue[ApiResponse] = mutable.Queue()
private def webSocketFlow: Flow[Message, Message, _] = {
Flow[Message].mapAsync(1) { _ =>
if (responseQueue.nonEmpty) {
system.log.info("Flushing responseQueue")
val response = responseQueue.dequeue()
val protobufMessage = ByteString(response.toByteArray)
Future.successful(BinaryMessage(protobufMessage))
} else {
system.log.warn("Response queue empty")
Future.successful(BinaryMessage(ByteString.empty))
}
}
}
private def websocketRoute: Route = {
pathPrefix("ws") {
pathEndOrSingleSlash {
extractRequest { req =>
// extract auth token from header
val tokenOpt = req.headers.collectFirst {
case header if header.name() == "Sec-WebSocket-Protocol" =>
OAuth2BearerToken(header.value()) // Extract the value of the Sec-WebSocket-Protocol header
}
system.log.info(s"Handling ws auth:${tokenOpt.toString}")
// run it through token verification
extractUri { uri =>
val callingURI = uri.toRelative.path.dropChars(1).toString
system.log.info(s"===== $callingURI")
oAuthAuthenticator(Credentials(tokenOpt), handleWebSocketMessages(webSocketFlow), callingURI).get
}
}
}
}
}
private def oAuthAuthenticator(credentials: Credentials, protectedRoutes: Route, callingURI: String): Option[Route] =
credentials match {
case [email protected](_) =>
system.log.info("Credentials provided")
val user = loggedInUsers.find(user => p.verify(user.oAuthToken.access_token))
if (user.isDefined) {
system.log.info(s"User found:${user.head.toString}")
val userPermissions = Permissions.valueOf(user.head.user.username.toUpperCase) // <- get permissions for this user
if (userPermissions.getAllowedRoutes.contains(callingURI)) {
system.log.info(s"User has permission for route: $callingURI")
if (user.head.oneTime) loggedInUsers -= user.head // remove token if it's a one-time use token
Option(protectedRoutes)
} else {
system.log.error(s"User does not have permission for route: $callingURI")
Option(complete(ApiResponse().withStatusResponse(HydraStatusCodes.UNAUTHORISED_ROUTE.getStatusResponse)))
}
} else {
system.log.error("We did not distribute this token or its expired")
Option(complete(ApiResponse().withStatusResponse(HydraStatusCodes.INVALID_AUTH_TOKEN.getStatusResponse)))
}
case _ =>
system.log.error(s"No credentials provided: ${credentials.toString}")
Option(complete(ApiResponse().withStatusResponse(HydraStatusCodes.MISSING_CREDENTIALS.getStatusResponse)))
}
The goal is for the server to notify the web page when a new ApiResponse
has been placed in the queue.
I can confirm the Authorization
section is working from the webserver logs:
INFO[typed-system-actor-akka.actor.default-dispatcher-11] ActorSystem - Handling ws auth:Some(Bearer 61704059-2e51-4d0f-b574-bdcebf3aeae3)
INFO[typed-system-actor-akka.actor.default-dispatcher-11] ActorSystem - ===== ws
INFO[typed-system-actor-akka.actor.default-dispatcher-11] ActorSystem - Credentials provided
INFO[typed-system-actor-akka.actor.default-dispatcher-11] ActorSystem - User found:LoggedInUser(database.objects.User@bb81dcf5, username: admin, password: 517ffce87ad701f071040b32ddaa7f4b7b0bb6774b02ff45bf2eef3f2fc1a549,AuthToken(61704059-2e51-4d0f-b574-bdcebf3aeae3,bearer,3600),2025-02-21T16:59:06.343277,false)
INFO[typed-system-actor-akka.actor.default-dispatcher-11] ActorSystem - User has permission for route: ws
On the NextJS
side:
const [pageToken, setPageToken] = useState("")
const router = useRouter()
useEffect(() => {
if (pageToken) {
const ws = new WebSocket("/ws", pageToken);
ws.onopen = () => {
console.log("Connected to WebSocket server");
};
ws.onmessage = (event) => {
try {
if (event.data.byteLength === 0) {
console.log("No message to decode, queue was empty.");
return; // Ignore empty messages
}
// Deserialize the Protobuf message
const buffer = new Uint8Array(event.data);
const decodedMessage = ApiResponse.deserializeBinary(buffer)
console.log(decodedMessage)
} catch (err) {
console.error("Failed to decode Protobuf message:", err);
}
};
ws.onerror = (err) => {
console.error("WebSocket error:", err);
};
return () => {
if (ws) {
ws.close();
}
};
}
}, [pageToken]); // <-- listen to token changes
const fetcher = (url) => fetchWithErrors(url, {}, (error) => {
if (error.status === 401) {
setToken(null) //<-- for anything that still might be using token
setPageToken(null)
router.push("/login");
} else {
errorToast("Unknown Internal Error:" + "[" + error.status + "]" + error.message);
}
})
.then(data => {
console.log("Fetched token data:", data)
if (data.access_token) {
if (pageToken !== data.access_token) {
setPageToken(data.access_token);
setToken(data.access_token);
}
}
return data
});
// read the token every second to ensure it has not expired
const {data, mutate} = useSWR(
"/api/gettoken",
fetcher,
{
refreshInterval: 100
}
);
useEffect(() => {
if (data) return;
// mark as stale
mutate()
}, [data]);
This code:
- fetches the token from its own server route (extracts it from a cookie) and sets it globally
- When the token changes, establishes a connection with the websocket
The final part of the process is NGINX
:
location /ws {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
}
The Problem:
On my NextJS
side, I get the error: Page.jsx:19 WebSocket connection to 'wss://192.168.0.11/ws' failed:
I have captured the output as a HAR
which can be found here.
What am I missing/done wrong?