zmartzone / lua-resty-openidc

OpenID Connect Relying Party and OAuth 2.0 Resource Server implementation in Lua for NGINX / OpenResty
Apache License 2.0
976 stars 248 forks source link

state from argument does not match state restored from session error #482

Open MelnykVL opened 1 year ago

MelnykVL commented 1 year ago

Hi! I have a problem with "state from argument does not match state restored from session" error.

Environment

Keycloak image

Openresty image

Rocks installed for Lua 5.1

Keycloak settings
image image image
nginx.conf
env KEYCLOAK_HOST;
env KEYCLOAK_PORT;
env KEYCLOAK_REALM;
env ENABLE_AUTHENTICATION;
env CLIENT_ID;
env CLIENT_SECRET;
env ACCESS_ROLE;

pcre_jit on;

events {
  worker_connections 1024;
}

http {
  include mime.types;
  default_type application/octet-stream;

  # See Move default writable paths to a dedicated directory (#119)
  # https://github.com/openresty/docker-openresty/issues/119
  client_body_temp_path /var/run/openresty/nginx-client-body;
  proxy_temp_path /var/run/openresty/nginx-proxy;
  fastcgi_temp_path /var/run/openresty/nginx-fastcgi;
  uwsgi_temp_path /var/run/openresty/nginx-uwsgi;
  scgi_temp_path /var/run/openresty/nginx-scgi;

  sendfile on;

  keepalive_timeout 65;

  log_format req_resp_logs '$remote_addr - $username [$time_local] '
  '"$request" $status $body_bytes_sent '
  '"$http_referer" "$http_user_agent" "$gzip_ratio"';

  access_log /usr/local/openresty/nginx/logs/access.log req_resp_logs;
  error_log /usr/local/openresty/nginx/logs/error.log debug;

  lua_package_path "/usr/local/openresty/nginx/?.lua;;";

  # Cache for discovery metadata documents
  lua_shared_dict discovery 1m;

  server {
    include server.conf;

    resolver $RESOLVER_IP_ADDRESS valid=30s;

    # $SESSION_COOKIE_LIFETIME = 60
    set $session_cookie_lifetime $SESSION_COOKIE_LIFETIME;

    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 X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Scheme $scheme;
    proxy_set_header X-Scheme $scheme;

    set $username '';

    set_by_lua_block $username {
      return ngx.var.username
    }

    access_by_lua_block {
      if os.getenv("ENABLE_AUTHENTICATION") == 'true' then
        local keycloak_host = os.getenv("KEYCLOAK_HOST");
        local keycloak_port = os.getenv("KEYCLOAK_PORT");
        local keycloak_realm = os.getenv("KEYCLOAK_REALM");
        local client_id = os.getenv("CLIENT_ID");
        local client_secret = os.getenv("CLIENT_SECRET");
        local access_role = os.getenv("ACCESS_ROLE");
        require("authentication").auth(keycloak_host, keycloak_port, keycloak_realm, client_id, client_secret, access_role)
      end
    }

    header_filter_by_lua_block {
      if not ngx.header.cache_control then
        ngx.header.cache_control = 'no-cache';
      end
      if ngx.var.scheme == 'https' then
        ngx.header['Content-Security-Policy'] = 'upgrade-insecure-requests'
      end
    }
    location / {
      proxy_pass http://ui-1;
    }

    location /fe {
      client_max_body_size $CLIENT_MAX_BODY_SIZE;
      proxy_pass http://ui-1;
    }

    location /mn {
      proxy_pass http://ui-2;
    }

    location /en {
      proxy_pass http://ui-3;
    }

    location /fe/api/ws {
      proxy_pass http://ui-1;
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection "upgrade";
    }
  }
}
authentication.lua
local authentication = {}

local function has_role(roles, role)
    if roles == nil then
        return false
    end
    for k,v in pairs(roles) do
        if v == role then
            return true
        end
    end
    return false
end

function authentication.auth(keycloak_host, keycloak_port, keyalock_realm, client_id, client_secret, access_role)
    local keycloak_base_url = keycloak_host .. ':' .. keycloak_port;

    local opts = {
        redirect_uri = "/redirect_uri",
        -- The discovery endpoint of the OP. Enable to get the URI of all endpoints (Token, introspection, logout...)
        discovery = keycloak_base_url .. "/auth/realms/" .. keyalock_realm .. "/.well-known/openid-configuration",
        client_id = client_id,
        client_secret = client_secret,
        scope = "openid profile roles",
        ssl_verify = "no",
        redirect_uri_scheme = "http",
        logout_path = "/logout",
        revoke_tokens_on_logout = true,
        post_logout_redirect_uri = ngx.var.scheme .. "://" .. ngx.var.http_host,
        -- Whitelist of session content to enable. This can be used to reduce the session size.
        -- When not set everything will be included in the session.
        -- Available are: id_token, enc_id_token, user, access_token (includes refresh token)
        session_contents = {
            id_token = true,
            access_token = true
        }
    }

    local oidc = require("resty.openidc")
    local res, err, _, session = oidc.authenticate(opts)

    if err then
--         local args = ngx.req.get_uri_args()
--         if args.state ~= session.data.state then
--             ngx.redirect(ngx.var.scheme .. "://" .. ngx.var.http_host)
--         end
        ngx.header.content_type = "text/html"
        ngx.status = ngx.HTTP_FORBIDDEN
        ngx.say(err)
        ngx.exit(ngx.HTTP_FORBIDDEN)
    end

    if not has_role(res.id_token.roles, access_role) then
        ngx.header.content_type = "text/html"
        ngx.status = ngx.HTTP_FORBIDDEN
        ngx.say('Access denied. <a href="/logout">Log in</a>')
        ngx.exit(ngx.HTTP_FORBIDDEN)
    end

    ngx.var.username = res.id_token.preferred_username;
end

return authentication
Expected behaviour

When the session is expired (4 min idle), click on the logo to go to the main page, redirect to log in page, and after successful login redirect to the main page without problem

Actual behaviour

When the session is expired, click on the logo to go to the main page, redirect to log in page, and after successful login I see the following:

image
Openresty logs from docker
172.18.0.1 - test_user [08/Jun/2023:10:55:10 +0000] "GET /fe/api/ws/781/5x24moji/websocket HTTP/1.1" 101 555 "-"  "-"
2023/06/08 10:55:11 [debug] 9#9: *70 [lua] openidc.lua:1520: authenticate(): session.present=nil, session.data.id_token=false, session.data.authenticated=nil, opts.force_reauthorize=nil, opts.renew_access_token_on_expiry=nil, try_to_renew=true, token_expired=false
2023/06/08 10:55:11 [debug] 9#9: *70 [lua] openidc.lua:556: openidc_discover(): openidc_discover: URL is: http://host.docker.internal:8888/auth/realms/HLXREALM/.well-known/openid-configuration
2023/06/08 10:55:11 [debug] 9#9: *70 [lua] openidc.lua:107: openidc_cache_get(): cache hit: type=discovery key=http://host.docker.internal:8888/auth/realms/HLXREALM/.well-known/openid-configuration
2023/06/08 10:55:11 [debug] 9#9: *70 [lua] openidc.lua:674: openidc_get_token_auth_method(): 1 => private_key_jwt
2023/06/08 10:55:11 [debug] 9#9: *70 [lua] openidc.lua:64: supported(): Can't use private_key_jwt without opts.client_rsa_private_key
2023/06/08 10:55:11 [debug] 9#9: *70 [lua] openidc.lua:674: openidc_get_token_auth_method(): 2 => client_secret_basic
2023/06/08 10:55:11 [debug] 9#9: *70 [lua] openidc.lua:677: openidc_get_token_auth_method(): no configuration setting for option so select the first supported method specified by the OP: client_secret_basic
2023/06/08 10:55:11 [debug] 9#9: *70 [lua] openidc.lua:691: openidc_get_token_auth_method(): token_endpoint_auth_method result set to client_secret_basic
2023/06/08 10:55:11 [debug] 9#9: *70 [lua] openidc.lua:1551: authenticate(): Authentication is required - Redirecting to OP Authorization endpoint
172.18.0.1 -  [08/Jun/2023:10:55:11 +0000] "GET /fe/api/flows HTTP/1.1" 302 151 "http://localhost/fe/home" "-"
2023/06/08 10:55:11 [debug] 9#9: *69 [lua] openidc.lua:1520: authenticate(): session.present=nil, session.data.id_token=false, session.data.authenticated=nil, opts.force_reauthorize=nil, opts.renew_access_token_on_expiry=nil, try_to_renew=true, token_expired=false
2023/06/08 10:55:11 [debug] 9#9: *69 [lua] openidc.lua:556: openidc_discover(): openidc_discover: URL is: http://host.docker.internal:8888/auth/realms/HLXREALM/.well-known/openid-configuration
2023/06/08 10:55:11 [debug] 9#9: *69 [lua] openidc.lua:107: openidc_cache_get(): cache hit: type=discovery key=http://host.docker.internal:8888/auth/realms/HLXREALM/.well-known/openid-configuration
2023/06/08 10:55:11 [debug] 9#9: *69 [lua] openidc.lua:674: openidc_get_token_auth_method(): 1 => private_key_jwt
2023/06/08 10:55:11 [debug] 9#9: *69 [lua] openidc.lua:64: supported(): Can't use private_key_jwt without opts.client_rsa_private_key
2023/06/08 10:55:11 [debug] 9#9: *69 [lua] openidc.lua:674: openidc_get_token_auth_method(): 2 => client_secret_basic
2023/06/08 10:55:11 [debug] 9#9: *69 [lua] openidc.lua:677: openidc_get_token_auth_method(): no configuration setting for option so select the first supported method specified by the OP: client_secret_basic
2023/06/08 10:55:11 [debug] 9#9: *69 [lua] openidc.lua:691: openidc_get_token_auth_method(): token_endpoint_auth_method result set to client_secret_basic
2023/06/08 10:55:11 [debug] 9#9: *69 [lua] openidc.lua:1551: authenticate(): Authentication is required - Redirecting to OP Authorization endpoint
172.18.0.1 -  [08/Jun/2023:10:55:11 +0000] "GET /fe/home HTTP/1.1" 302 151 "http://localhost/fe/home" "-"
2023/06/08 10:55:11 [debug] 9#9: *68 [lua] openidc.lua:1520: authenticate(): session.present=nil, session.data.id_token=false, session.data.authenticated=nil, opts.force_reauthorize=nil, opts.renew_access_token_on_expiry=nil, try_to_renew=true, token_expired=false
2023/06/08 10:55:11 [debug] 9#9: *68 [lua] openidc.lua:556: openidc_discover(): openidc_discover: URL is: http://host.docker.internal:8888/auth/realms/HLXREALM/.well-known/openid-configuration
2023/06/08 10:55:11 [debug] 9#9: *68 [lua] openidc.lua:107: openidc_cache_get(): cache hit: type=discovery key=http://host.docker.internal:8888/auth/realms/HLXREALM/.well-known/openid-configuration
2023/06/08 10:55:11 [debug] 9#9: *68 [lua] openidc.lua:674: openidc_get_token_auth_method(): 1 => private_key_jwt
2023/06/08 10:55:11 [debug] 9#9: *68 [lua] openidc.lua:64: supported(): Can't use private_key_jwt without opts.client_rsa_private_key
2023/06/08 10:55:11 [debug] 9#9: *68 [lua] openidc.lua:674: openidc_get_token_auth_method(): 2 => client_secret_basic
2023/06/08 10:55:11 [debug] 9#9: *68 [lua] openidc.lua:677: openidc_get_token_auth_method(): no configuration setting for option so select the first supported method specified by the OP: client_secret_basic
2023/06/08 10:55:11 [debug] 9#9: *68 [lua] openidc.lua:691: openidc_get_token_auth_method(): token_endpoint_auth_method result set to client_secret_basic
2023/06/08 10:55:11 [debug] 9#9: *68 [lua] openidc.lua:1551: authenticate(): Authentication is required - Redirecting to OP Authorization endpoint
2023/06/08 10:55:11 [debug] 9#9: *71 [lua] openidc.lua:1520: authenticate(): session.present=nil, session.data.id_token=false, session.data.authenticated=nil, opts.force_reauthorize=nil, opts.renew_access_token_on_expiry=nil, try_to_renew=true, token_expired=false
2023/06/08 10:55:11 [debug] 9#9: *71 [lua] openidc.lua:556: openidc_discover(): openidc_discover: URL is: http://host.docker.internal:8888/auth/realms/HLXREALM/.well-known/openid-configuration
2023/06/08 10:55:11 [debug] 9#9: *71 [lua] openidc.lua:107: openidc_cache_get(): cache hit: type=discovery key=http://host.docker.internal:8888/auth/realms/HLXREALM/.well-known/openid-configuration
2023/06/08 10:55:11 [debug] 9#9: *71 [lua] openidc.lua:674: openidc_get_token_auth_method(): 1 => private_key_jwt
2023/06/08 10:55:11 [debug] 9#9: *71 [lua] openidc.lua:64: supported(): Can't use private_key_jwt without opts.client_rsa_private_key
2023/06/08 10:55:11 [debug] 9#9: *71 [lua] openidc.lua:674: openidc_get_token_auth_method(): 2 => client_secret_basic
2023/06/08 10:55:11 [debug] 9#9: *71 [lua] openidc.lua:677: openidc_get_token_auth_method(): no configuration setting for option so select the first supported method specified by the OP: client_secret_basic
2023/06/08 10:55:11 [debug] 9#9: *71 [lua] openidc.lua:691: openidc_get_token_auth_method(): token_endpoint_auth_method result set to client_secret_basic
2023/06/08 10:55:11 [debug] 9#9: *71 [lua] openidc.lua:1551: authenticate(): Authentication is required - Redirecting to OP Authorization endpoint
172.18.0.1 -  [08/Jun/2023:10:55:11 +0000] "GET /fe/favicon.ico HTTP/1.1" 302 151 "http://localhost/fe/home?sort=modified" "-"
172.18.0.1 -  [08/Jun/2023:10:55:11 +0000] "GET /fe/asset/logo.png HTTP/1.1" 302 151 "http://localhost/fe/home" "-"
2023/06/08 10:55:11 [debug] 9#9: *71 [lua] openidc.lua:1520: authenticate(): session.present=true, session.data.id_token=false, session.data.authenticated=nil, opts.force_reauthorize=nil, opts.renew_access_token_on_expiry=nil, try_to_renew=true, token_expired=false
2023/06/08 10:55:11 [debug] 9#9: *71 [lua] openidc.lua:556: openidc_discover(): openidc_discover: URL is: http://host.docker.internal:8888/auth/realms/HLXREALM/.well-known/openid-configuration
2023/06/08 10:55:11 [debug] 9#9: *71 [lua] openidc.lua:107: openidc_cache_get(): cache hit: type=discovery key=http://host.docker.internal:8888/auth/realms/HLXREALM/.well-known/openid-configuration
2023/06/08 10:55:11 [debug] 9#9: *71 [lua] openidc.lua:674: openidc_get_token_auth_method(): 1 => private_key_jwt
2023/06/08 10:55:11 [debug] 9#9: *71 [lua] openidc.lua:64: supported(): Can't use private_key_jwt without opts.client_rsa_private_key
2023/06/08 10:55:11 [debug] 9#9: *71 [lua] openidc.lua:674: openidc_get_token_auth_method(): 2 => client_secret_basic
2023/06/08 10:55:11 [debug] 9#9: *71 [lua] openidc.lua:677: openidc_get_token_auth_method(): no configuration setting for option so select the first supported method specified by the OP: client_secret_basic
2023/06/08 10:55:11 [debug] 9#9: *71 [lua] openidc.lua:691: openidc_get_token_auth_method(): token_endpoint_auth_method result set to client_secret_basic
2023/06/08 10:55:11 [debug] 9#9: *71 [lua] openidc.lua:1551: authenticate(): Authentication is required - Redirecting to OP Authorization endpoint
172.18.0.1 -  [08/Jun/2023:10:55:11 +0000] "GET /fe/favicon.ico HTTP/1.1" 302 151 "http://localhost/fe/home?sort=modified" "-"
2023/06/08 10:55:14 [debug] 9#9: *71 [lua] openidc.lua:1480: authenticate(): Redirect URI path (/redirect_uri) is currently navigated -> Processing authorization response coming from OP
2023/06/08 10:55:14 [error] 9#9: *71 [lua] openidc.lua:1106: authenticate(): state from argument: 555770ea1f450225ddb26d22e28bfbd8 does not match state restored from session: 9bd0307473f7f6dc5bf4478d39cbfc54, client: 172.18.0.1, server: localhost, request: "GET /redirect_uri?state=555770ea1f450225ddb26d22e28bfbd8&session_state=197bbfd0-20fd-47a2-b44c-a8d12d93699e&code=3b0c4676-0b8e-4b18-9690-c87f73a5dd7e.197bbfd0-20fd-47a2-b44c-a8d12d93699e.188ff305-f0be-4bca-b2a1-c810d938ecc4 HTTP/1.1", host: "localhost"
172.18.0.1 -  [08/Jun/2023:10:55:14 +0000] "GET /redirect_uri?state=555770ea1f450225ddb26d22e28bfbd8&session_state=197bbfd0-20fd-47a2-b44c-a8d12d93699e&code=3b0c4676-0b8e-4b18-9690-c87f73a5dd7e.197bbfd0-20fd-47a2-b44c-a8d12d93699e.188ff305-f0be-4bca-b2a1-c810d938ecc4 HTTP/1.1" 403 74 "-" "-"
2023/06/08 10:55:14 [debug] 9#9: *71 [lua] openidc.lua:1520: authenticate(): session.present=true, session.data.id_token=false, session.data.authenticated=nil, opts.force_reauthorize=nil, opts.renew_access_token_on_expiry=nil, try_to_renew=true, token_expired=false
2023/06/08 10:55:14 [debug] 9#9: *71 [lua] openidc.lua:556: openidc_discover(): openidc_discover: URL is: http://host.docker.internal:8888/auth/realms/HLXREALM/.well-known/openid-configuration
2023/06/08 10:55:14 [debug] 9#9: *71 [lua] openidc.lua:107: openidc_cache_get(): cache hit: type=discovery key=http://host.docker.internal:8888/auth/realms/HLXREALM/.well-known/openid-configuration
2023/06/08 10:55:14 [debug] 9#9: *71 [lua] openidc.lua:674: openidc_get_token_auth_method(): 1 => private_key_jwt
2023/06/08 10:55:14 [debug] 9#9: *71 [lua] openidc.lua:64: supported(): Can't use private_key_jwt without opts.client_rsa_private_key
2023/06/08 10:55:14 [debug] 9#9: *71 [lua] openidc.lua:674: openidc_get_token_auth_method(): 2 => client_secret_basic
2023/06/08 10:55:14 [debug] 9#9: *71 [lua] openidc.lua:677: openidc_get_token_auth_method(): no configuration setting for option so select the first supported method specified by the OP: client_secret_basic
2023/06/08 10:55:14 [debug] 9#9: *71 [lua] openidc.lua:691: openidc_get_token_auth_method(): token_endpoint_auth_method result set to client_secret_basic
2023/06/08 10:55:14 [debug] 9#9: *71 [lua] openidc.lua:1551: authenticate(): Authentication is required - Redirecting to OP Authorization endpoint
172.18.0.1 -  [08/Jun/2023:10:55:14 +0000] "GET /favicon.ico HTTP/1.1" 302 151 "http://localhost/redirect_uri?state=555770ea1f450225ddb26d22e28bfbd8&session_state=197bbfd0-20fd-47a2-b44c-a8d12d93699e&code=3b0c4676-0b8e-4b18-9690-c87f73a5dd7e.197bbfd0-20fd-47a2-b44c-a8d12d93699e.188ff305-f0be-4bca-b2a1-c810d938ecc4"  "-"

The following code can fix it but I'm not sure that it is a good idea

local args = ngx.req.get_uri_args()
        if args.state ~= session.data.state then
            ngx.redirect(ngx.var.scheme .. "://" .. ngx.var.http_host)
        end
bodewig commented 1 year ago

This most likely means that for some reason two authentication flows are happening concurrently in your browser. The first flow has state s1, stored it in the session cookie and redirected your browser to keycloak. Before the flow completes another request comes in and creates a second flow with state s2 getting stored in the session cookie. When the first flow completes, Keycloak sends back s1 but your session cookie contains s2 and so the states do not match.

Redirect based token refreshs do not work properly if multiple flows can happen at the same time. Server side token refreshs with a refresh token are far less prone to errors here.

alexdowad commented 1 year ago

@bodewig Would it cause any security vulnerabilities if rather than generating state s2 the second time the same browser comes back to authenticate, state s1 was reused (from the encrypted session cookie)?

bodewig commented 1 year ago

I believe the OAuth2 best current practices recommend against re-using state parameters (can't seem to reach the ietf.org websites right now). You may want to read the threat model as well as the latest draft of https://datatracker.ietf.org/doc/id/draft-ietf-oauth-security-topics-15.html .

Independent of security issues if there are two login flows happening in parallel you might get away with using the same state but you will also get the target URL of whichever login flow has been started last (or rather whichever flow has successfully set the cookie last). This may or may not be the target the browser tab you are looking at wanted to go as well. In the general case it will not be the URL your user wanted to navigate to.

alexdowad commented 1 year ago

@bodewig Thank you very much for the response! I am reading through the document which you kindly linked to but haven't yet found the part which recommends not re-using the state parameter.

Another option would be to store not just one, but a small number of state parameters in the encrypted session cookie, so that parallel login flows can still succeed.

You are right that if parallel login flows are allowed, the one which sets the cookie last will 'win' in terms of what URL the user is redirected to after one of the flows succeeds... but this is much better than stopping the user cold with an error page.

One application which uses lua-resty-openidc to handle authentication is suffering OAuth failures for about 2% of logins. In many cases, the failures are not caused by the user initiating multiple login flows; even where the user just tries to load a protected page once, the browser inexplicably seems to send two requests in some cases.

bodewig commented 1 year ago

Sorry for the late reply. The document I linked sees state mainly in the context of CSRF prevention, so it doesn't necessarily need to be different with each call as long as attackers cannot guess it. Of course as part of an URI it is not protected very well (shows up in referrer headers, is visible to JavaScript running inside the browser and so on). PKCE might be an alternative with more modern OPs.

I don't really see how accepting multiple parallel login flows by accepting different state values (or reusing existing ones) is going to help, though. Users ending up in a different place than they wanted to go are probably not happy either.

Even if lua-resty-openidc somehow allowed multiple login flows originating from the same session you may still run into the problem of parallel flows starting without any session being present at all. I.e. multiple requests come in and neither contains a valid session token. In that case all of them start new flows and all of them end up with different Set-Cookie responses. This probably only is an edge case, though. I've seen this when a page referred to multiple images loaded from a protected server shortly after the session cookie expired, for example.

WRT your browser sending multiple requests. Can you identify what is going on there? CORS preflight requests or browsers trying to load "/favicon.ico" spring to mind.

vershnik commented 1 year ago

I did a small workaround to improve UX a bit when this happens - instead of empty page with the error, users re-directed to the root path (and since keycloak has session cookie, user comes back as logged in instantly):

           if err then
                if string.find(err, "state restored", 1, true) or string.find(err, "no session state", 1, true) then
                    ngx.log(ngx.WARN, err .. ", redirecting to " .. root_path)
                    ngx.redirect(root_path)
                else
                    ngx.status = 403
                    ngx.header["Content-Type"] = "text/html"
                    ngx.say("<html><head></head><body>"..err.."<br/><br/></body></html>")
                end
                ngx.exit(ngx.HTTP_FORBIDDEN)
            end
markbanierink commented 9 months ago

We experience the same when sessions are timed out. We have Keycloak configured as our IDP. It can be reproduced when you try to login and meanwhile have the Developer Tools window opened. Probably some parallel request is executed, eventually causing not matching session states.