nginxinc / nginx-openid-connect

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

Loop 302 after expire access token #84

Open dolgovas opened 11 months ago

dolgovas commented 11 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 11 months ago

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

dolgovas commented 11 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 11 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 11 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 11 months ago

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