nginxwebsocketkeycloakopenrestylua-resty-openidc

Authenticate websocket with keycloak through openresty


Currently I have a working solution with following components:

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!


Solution

  • 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.