Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Authenticate websocket with keycloak through openresty

Currently I have a working solution with following components:

  • Webserver with custom application
  • Openresty with lua
  • Keycloak

This allows me to authenticate using keycloak.
Because my webserver also exposes a websocket host, I would like to authenticate these websockets as well. Does anyone have an example (both the nginx file as the lua file) available to authenticate websocket connections using openresty? I've had a look at https://github.com/openresty/lua-resty-websocket but can't seem to find where to plugin in the authentication part.
An example client application to test this would be great as well!

like image 940
Bob Claerhout Avatar asked Dec 24 '22 00:12

Bob Claerhout


1 Answers

I've figured it out myself. Posting my solution here to help others achieving the same.
I have following code snippets:

Openresty configuration

only for websocket, should be place inside the server section:

set $resty_user 'not_authenticated_resty_user';
location /ws {
      access_by_lua_file         /usr/local/openresty/nginx/conf/lua_access.lua;
      proxy_pass                    http://<backend-websocket-host>/ws;
      proxy_http_version            1.1;
      proxy_set_header              Host                $http_host;
      proxy_set_header              X-Real-IP           $remote_addr;
      proxy_set_header              X-Forwarded-For     $proxy_add_x_forwarded_for;

      proxy_set_header              Upgrade             $http_upgrade;
      proxy_set_header              Connection          "upgrade";
      proxy_set_header              X-Forwared-User     $resty_user;
      proxy_read_timeout            1d;
      proxy_send_timeout            1d;
    }

lua_acces.lua

local opts = {
    redirect_uri = "/*",
    discovery = "http://<keycloak-url>/auth/realms/realm/.well-known/openid-configuration",
    client_id = "<client-id>",
    client_secret = "<client-secret>",
    redirect_uri_scheme = "https",
    logout_path = "/logout",
    redirect_after_logout_uri = "http://<keycloak-url>/auth/realms/realm/protocol/openid-connect/logout?redirect_uri=http%3A%2F%2google.com",
    redirect_after_logout_with_id_token_hint = false,
    session_contents = {id_token=true},
    ssl_verify=no
  }

  -- call introspect for OAuth 2.0 Bearer Access Token validation
  local res, err = require("resty.openidc").bearer_jwt_verify(opts)
  if err or not res then
    print("Token authentication not succeeded")
    if err then
      print("jwt_verify error message:")
      print(err)
    end
    if res then
      print("jwt_verify response:")
      tprint(res)
    end
    res, err = require("resty.openidc").authenticate(opts)
    if err then
      ngx.status = 403
      ngx.say(err)
      ngx.exit(ngx.HTTP_FORBIDDEN)
    end
  end

if res.id_token and res.id_token.preferred_username then
    ngx.var.resty_user = res.id_token.preferred_username
  else
    ngx.var.resty_user = res.preferred_username
  end

This allows websocket connections only when they have a valid token retrieved from the keycloak service.
At the end, the resty user is filled in to pass on the authenticated user to the backend application.

Example Java client application

Get keycloak token

package test;

import org.keycloak.admin.client.Keycloak;
import org.keycloak.representations.AccessTokenResponse;

public class KeycloakConnection {
    private Keycloak _keycloak;

    public KeycloakConnection(final String host, String username, String password, String clientSecret, String realm, String clientId) {

        _keycloak = Keycloak.getInstance(
                "http://" + host + "/auth",
                realm,
                username,
                password,
                clientId,
                clientSecret);
    }

    public String GetAccessToken()
    {
        final AccessTokenResponse accessToken = _keycloak.tokenManager().getAccessToken();
        return accessToken.getToken();
    }
}

Websocket

This snippet only contains the function I call to setup the websocket connection. You still have to instantiate the _keycloakConnection object and in my case I have a general _session field to keep reuse the session each time I need it.

private Session GetWebsocketSession(String host)
    {
        URI uri = URI.create("wss://" + host);
        ClientUpgradeRequest request = new ClientUpgradeRequest();
        request.setHeader("Authorization", "Bearer " + _keycloakConnection.GetAccessToken());
        _client = new WebSocketClient();
        try {
                _client.start();
                // The socket that receives events
                WebsocketEventHandler socketEventHandler = new WebsocketEventHandler(this::NewLiveMessageReceivedInternal);
                // Attempt Connect
                Future<Session> fut = _client.connect(socketEventHandler, uri, request);
                // Wait for Connect
                _session = fut.get();

                return _session;
        } catch (Throwable t) {
            _logger.error("Error during websocket session creation", t);
        }
        return null;
    }

WebsocketEventHandler

A consumer is injected in this class to consume the messages in another class

package test;

import org.apache.log4j.Logger;
import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.api.WebSocketAdapter;

import java.util.function.Consumer;

public class WebsocketEventHandler extends WebSocketAdapter
{
    private final Logger _logger;
    private Consumer<String> _onMessage;

    public WebsocketEventHandler(Consumer<String> onMessage) {
        _onMessage = onMessage;
        _logger = Logger.getLogger(WebsocketEventHandler.class);
    }

    @Override
    public void onWebSocketConnect(Session sess)
    {
        super.onWebSocketConnect(sess);
        _logger.info("Socket Connected: " + sess);
    }

    @Override
    public void onWebSocketText(String message)
    {
        super.onWebSocketText(message);
        _logger.info("Received TEXT message: " + message);
        _onMessage.accept(message);
    }

    @Override
    public void onWebSocketClose(int statusCode, String reason)
    {
        super.onWebSocketClose(statusCode,reason);
        _logger.info("Socket Closed: [" + statusCode + "] " + reason);
    }

    @Override
    public void onWebSocketError(Throwable cause)
    {
        super.onWebSocketError(cause);
        _logger.error("Websocket error", cause);
    }
}

Sending messages

When the _session is created you can use following line to send data:

_session.getRemote().sendString("Hello world");

These snippets are all a small part of my whole solution. I might have missed something. If somebody has a question or this is not working in your case, please reach out and I'll provide more information.

like image 172
Bob Claerhout Avatar answered Dec 31 '22 05:12

Bob Claerhout