nginxinc / nginx-openid-connect

Reference implementation of OpenID Connect integration for NGINX Plus
https://www.nginx.com/products/nginx/
Other
196 stars 92 forks source link

Loop 302 after expire access token #84

Open dolgovas opened 9 months ago

dolgovas commented 9 months ago

Good day! I ran my test installation through this guide (https://docs.nginx.com/nginx/deployment-guides/single-sign-on/keycloak/)

And started to get infinite loop after access token expired. It seems strange.

First step go the site https://main.example.com/ step 2 -> 302 redirect to https://keycloak.example.com/ step 3 -> auth in keycloak step 4 -> 302 to https://main.example.com/ step 5 -> after 5 minutes (access token ttl) browser started return 302 from main.example.com to keycloak, keycloak send 302 to main.example and infinite loop....

server {
  listen 443 ssl;
  server_name main.example.com;
  include conf.d/locations-openid;

  location / {
    include conf.d/locations-openid_auth_jwt;
    proxy_pass https://upstream;
  }

}

# conf.d/locations-openid_auth_jwt;
auth_jwt "" token=$session_jwt;
error_page 401 = @do_oidc_flow;
auth_jwt_key_request /_jwks_uri; # Enable when using URL
proxy_set_header username $jwt_claim_sub;

#conf.d/locations-openid
# Advanced configuration START
set $internal_error_message "NGINX / OpenID Connect login failure\n";
set $pkce_id "";
subrequest_output_buffer_size 32k; # To fit a complete tokenset response
gunzip on; # Decompress IdP responses if necessary
# Advanced configuration END
location = /_jwks_uri {
    auth_jwt off;
    internal;
    proxy_cache jwk;                              # Cache the JWK Set recieved from IdP
    proxy_cache_valid 200 12h;                    # How long to consider keys "fresh"
    proxy_cache_use_stale error timeout updating; # Use old JWK Set if cannot reach IdP
    proxy_ssl_server_name on;                     # For SNI to the IdP
    proxy_method GET;                             # In case client request was non-GET
    proxy_set_header Content-Length "";           # ''
    proxy_set_header Accept-Encoding "gzip";      # fixed OIDC authorization code sent but token response is not JSON
    proxy_pass $oidc_jwt_keyfile;                 # Expecting to find a URI here
    proxy_ignore_headers Cache-Control Expires Set-Cookie; # Does not influence caching
}
location @do_oidc_flow {
    auth_jwt off;
    status_zone "OIDC start";
    js_content oidc.auth;
    default_type text/plain; # In case we throw an error
}
set $redir_location "/_codexch";
location = /_codexch {
    # This location is called by the IdP after successful authentication
    auth_jwt off;
    status_zone "OIDC code exchange";
    js_content oidc.codeExchange;
    error_page 500 502 504 @oidc_error;
}
location = /_token {
    # This location is called by oidcCodeExchange(). We use the proxy_ directives
    # to construct the OpenID Connect token request, as per:
    #  http://openid.net/specs/openid-connect-core-1_0.html#TokenRequest
    auth_jwt off;
    internal;
    proxy_ssl_server_name on; # For SNI to the IdP
    proxy_set_header      Content-Type "application/x-www-form-urlencoded";
    proxy_set_header Accept-Encoding "gzip"; # fixed OIDC authorization code sent but token response is not JSON
    proxy_set_body        "grant_type=authorization_code&client_id=$oidc_client&$args&redirect_uri=$redirect_base$redir_location";
    proxy_method          POST;
    proxy_pass            $oidc_token_endpoint;
}
location = /_refresh {
    # This location is called by oidcAuth() when performing a token refresh. We
    # use the proxy_ directives to construct the OpenID Connect token request, as per:
    #  https://openid.net/specs/openid-connect-core-1_0.html#RefreshingAccessToken
    auth_jwt off;
    internal;
    proxy_ssl_server_name on; # For SNI to the IdP
    proxy_set_header      Content-Type "application/x-www-form-urlencoded";
    proxy_set_body        "grant_type=refresh_token&refresh_token=$arg_token&client_id=$oidc_client&client_secret=$oidc_client_secret";
    proxy_method          POST;
    proxy_pass            $oidc_token_endpoint;
}
location = /_id_token_validation {
    # This location is called by oidcCodeExchange() and oidcRefreshRequest(). We use
    # the auth_jwt_module to validate the OpenID Connect token response, as per:
    #  https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
    internal;
    auth_jwt "" token=$arg_token;
    js_content oidc.validateIdToken;
    error_page 500 502 504 @oidc_error;
}
location = /logout {
    auth_jwt off;
    status_zone "OIDC logout";
    add_header Set-Cookie "auth_token=; $oidc_cookie_flags"; # Send empty cookie
    add_header Set-Cookie "auth_redir=; $oidc_cookie_flags"; # Erase original cookie
    js_content oidc.logout;
}
location = /_logout {
    # This location is the default value of $oidc_logout_redirect (in case it wasn't configured)
    auth_jwt off;
    default_type text/plain;
    return 200 "Logged out\n";
}
location @oidc_error {
    # This location is called when oidcAuth() or oidcCodeExchange() returns an error
    auth_jwt off;
    status_zone "OIDC error";
    default_type text/plain;
    return 500 $internal_error_message;
}

# openid.conf

# OpenID Connect configuration
#
# Each map block allows multiple values so that multiple IdPs can be supported,
# the $host variable is used as the default input parameter but can be changed.
#
map $host $oidc_authz_endpoint {
    default "https://keycloak/realms/main/protocol/openid-connect/auth";
    #www.example.com "https://my-idp/oauth2/v1/authorize";
}

map $host $oidc_authz_extra_args {
    # Extra arguments to include in the request to the IdP's authorization
    # endpoint.
    # Some IdPs provide extended capabilities controlled by extra arguments,
    # for example Keycloak can select an IdP to delegate to via the
    # "kc_idp_hint" argument.
    # Arguments must be expressed as query string parameters and URL-encoded
    # if required.
    default "";
    #www.example.com "kc_idp_hint=another_provider"
}

map $host $oidc_token_endpoint {
    default "https://keycloak/realms/main/protocol/openid-connect/token";
}

map $host $oidc_jwt_keyfile {
    default "https://keycloak/realms/main/protocol/openid-connect/certs";
}

map $host $oidc_client {
    default "nginx-openid";
}

map $host $oidc_pkce_enable {
    default 0;
}

map $host $oidc_client_secret {
    default "secret";
}

map $host $oidc_scopes {
    default "openid+profile+email+offline_access";
}

map $host $oidc_logout_redirect {
    # Where to send browser after requesting /logout location. This can be
    # replaced with a custom logout page, or complete URL.
    default "/_logout"; # Built-in, simple logout page
}

map $host $oidc_hmac_key {
    # This should be unique for every NGINX instance/cluster
    default "key";
}

map $host $zone_sync_leeway {
    # Specifies the maximum timeout for synchronizing ID tokens between cluster
    # nodes when you use shared memory zone content sync. This option is only
    # recommended for scenarios where cluster nodes can randomly process
    # requests from user agents and there may be a situation where node "A"
    # successfully received a token, and node "B" receives the next request in
    # less than zone_sync_interval.
    default 0; # Time in milliseconds, e.g. (zone_sync_interval * 2 * 1000)
}

map $proto $oidc_cookie_flags {
    http  "Path=/; SameSite=lax;"; # For HTTP/plaintext testing
    https "Path=/; SameSite=lax; Max-Age=86400; HttpOnly; Secure;"; # Production recommendation
}

#map $http_x_forwarded_port $redirect_base {
#    ""      $proto://$host:$server_port;
#    default $proto://$host:$http_x_forwarded_port;
#}
map $http_x_forwarded_port $redirect_base {
    default $proto://$host;
}

map $http_x_forwarded_proto $proto {
    ""      $scheme;
    default $http_x_forwarded_proto;
}

# ADVANCED CONFIGURATION BELOW THIS LINE
# Additional advanced configuration (server context) in openid_connect.server_conf

# JWK Set will be fetched from $oidc_jwks_uri and cached here - ensure writable by nginx user
proxy_cache_path /var/cache/nginx/jwk levels=1 keys_zone=jwk:100m max_size=512m;

# Change timeout values to at least the validity period of each token type
keyval_zone zone=oidc_id_tokens:100M timeout=4h;
keyval_zone zone=oidc_access_tokens:100M timeout=4h;
keyval_zone zone=refresh_tokens:10M timeout=1d;
keyval_zone zone=oidc_pkce:512K; # Temporary storage for PKCE code verifier.

keyval $cookie_auth_token $session_jwt   zone=oidc_id_tokens;     # Exchange cookie for JWT
keyval $cookie_auth_token $access_token  zone=oidc_access_tokens; # Exchange cookie for access token
keyval $cookie_auth_token $refresh_token zone=refresh_tokens;     # Exchange cookie for refresh token
keyval $request_id $new_session          zone=oidc_id_tokens;     # For initial session creation
keyval $request_id $new_access_token     zone=oidc_access_tokens;
keyval $request_id $new_refresh          zone=refresh_tokens; # ''
keyval $pkce_id $pkce_code_verifier      zone=oidc_pkce;

auth_jwt_claim_set $jwt_audience aud; # In case aud is an array
js_import oidc from js/openid_connect.js;

and unmodified js/openid_connect.js; from main branch

lcrilly commented 9 months ago

Something probably broken with the refresh process. Check the error log for OIDC refresh failure messages.

dolgovas commented 9 months ago
2023/11/29 13:27:59 [info] 2722823#2722823: *603571 expired JWT token while sending to client, client: 192.168.10.15, server: main.example.com, request: "GET /service", host: "main.example.com", referrer: "https://main.example.com/"
2023/11/29 13:27:59 [info] 2722823#2722823: *603571 expired JWT token while sending to client, client: 192.168.10.15, server: main.example.com, request: "GET /service", host: "main.example.com", referrer: "https://main.example.com/"
2023/11/29 13:27:59 [info] 2722823#2722823: *603571 expired JWT token while sending to client, client: 192.168.10.15, server: main.example.com, request: "GET /service", host: "main.example.com", referrer: "https://main.example.com/"
2023/11/29 13:27:59 [info] 2722823#2722823: *603571 expired JWT token while sending to client, client: 192.168.10.15, server: main.example.com, request: "GET /service", host: "main.example.com", referrer: "https://main.example.com/"
2023/11/29 13:28:03 [info] 2722823#2722823: *603571 expired JWT token while sending to client, client: 192.168.10.15, server: main.example.com, request: "GET /service", host: "main.example.com", referrer: "https://main.example.com/"

only this in debug error.log

It seems like nginx cannot refresh access token... but why? Where I can get additional logs? May be it's possible to run njs script with additional output?

dolgovas commented 9 months ago

One more thing. If timeout for keyval zone is less than ttl acess token - infinite loop starts before expiration access token, right after remove token from nginx_kv

dolgovas commented 9 months ago

I think I fixed this. First one I needed to enable refresh tokens in keycloak, because in latest version it disabled by default! Second one I added proxy_set_header Accept-Encoding "gzip"; only into /_jwks_uri and /_token, like said in to-do troubleshooting. BUT it seems that I need to add gzip also in /_refresh location.

After these changes everything working correctly

lcrilly commented 9 months ago

Good news. Looks like the troubleshooting guide needs an extra item!