From 8da37d2ef6761c2f16a7c6788eb387785841e93a Mon Sep 17 00:00:00 2001 From: 65397 Date: Fri, 5 May 2023 17:14:21 +0000 Subject: [PATCH 01/15] Updated deprecated responseBody (#81) --- openid_connect.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/openid_connect.js b/openid_connect.js index 49909c9..faaeee6 100644 --- a/openid_connect.js +++ b/openid_connect.js @@ -63,10 +63,10 @@ function auth(r, afterSyncCheck) { error_log += ", timeout waiting for IdP"; } else if (reply.status == 400) { try { - var errorset = JSON.parse(reply.responseBody); + var errorset = JSON.parse(reply.responseText); error_log += ": " + errorset.error + " " + errorset.error_description; } catch (e) { - error_log += ": " + reply.responseBody; + error_log += ": " + reply.responseText; } } else { error_log += " " + reply.status; @@ -81,7 +81,7 @@ function auth(r, afterSyncCheck) { // Refresh request returned 200, check response try { - var tokenset = JSON.parse(reply.responseBody); + var tokenset = JSON.parse(reply.responseText); if (!tokenset.id_token) { r.error("OIDC refresh response did not include id_token"); if (tokenset.error) { @@ -151,14 +151,14 @@ function codeExchange(r) { if (reply.status != 200) { try { - var errorset = JSON.parse(reply.responseBody); + var errorset = JSON.parse(reply.responseText); if (errorset.error) { r.error("OIDC error from IdP when sending authorization code: " + errorset.error + ", " + errorset.error_description); } else { - r.error("OIDC unexpected response from IdP when sending authorization code (HTTP " + reply.status + "). " + reply.responseBody); + r.error("OIDC unexpected response from IdP when sending authorization code (HTTP " + reply.status + "). " + reply.responseText); } } catch (e) { - r.error("OIDC unexpected response from IdP when sending authorization code (HTTP " + reply.status + "). " + reply.responseBody); + r.error("OIDC unexpected response from IdP when sending authorization code (HTTP " + reply.status + "). " + reply.responseText); } r.return(502); return; @@ -166,7 +166,7 @@ function codeExchange(r) { // Code exchange returned 200, check for errors try { - var tokenset = JSON.parse(reply.responseBody); + var tokenset = JSON.parse(reply.responseText); if (tokenset.error) { r.error("OIDC " + tokenset.error + " " + tokenset.error_description); r.return(500); @@ -202,7 +202,7 @@ function codeExchange(r) { } ); } catch (e) { - r.error("OIDC authorization code sent but token response is not JSON. " + reply.responseBody); + r.error("OIDC authorization code sent but token response is not JSON. " + reply.responseText); r.return(502); } } From 39334b6616690b652bad9fa5f0d3c72df8759ece Mon Sep 17 00:00:00 2001 From: Ivan Ovchinnikov <33402471+route443@users.noreply.github.com> Date: Fri, 29 Dec 2023 11:04:08 -0800 Subject: [PATCH 02/15] Add URL encoding to auth_redir cookie value (#86) Storing a URI directly in the auth_redir cookie without encoding has led to issues where browsers misinterpret special characters, like semicolons, as part of the cookie delimiter. This behavior results in the truncation of the URI at the special character, causing incomplete or incorrect redirection URLs after user authentication. --- openid_connect.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openid_connect.js b/openid_connect.js index faaeee6..5ef1a80 100644 --- a/openid_connect.js +++ b/openid_connect.js @@ -197,8 +197,9 @@ function codeExchange(r) { } else { r.variables.new_access_token = ""; } + r.headersOut["Set-Cookie"] = "auth_token=" + r.variables.request_id + "; " + r.variables.oidc_cookie_flags; - r.return(302, r.variables.redirect_base + r.variables.cookie_auth_redir); + r.return(302, r.variables.redirect_base + decodeURIComponent(r.variables.cookie_auth_redir)); } ); } catch (e) { @@ -283,8 +284,10 @@ function getAuthZArgs(r) { authZArgs += "&" + r.variables.oidc_authz_extra_args; } + var encodedRequestUri = encodeURIComponent(r.variables.request_uri); + r.headersOut['Set-Cookie'] = [ - "auth_redir=" + r.variables.request_uri + "; " + r.variables.oidc_cookie_flags, + "auth_redir=" + encodedRequestUri + "; " + r.variables.oidc_cookie_flags, "auth_nonce=" + noncePlain + "; " + r.variables.oidc_cookie_flags ]; From bce5c2246ce083ba1e7ce213e0725e2ffe1ce1df Mon Sep 17 00:00:00 2001 From: Ivan Ovchinnikov <33402471+route443@users.noreply.github.com> Date: Wed, 17 Apr 2024 11:15:26 -0700 Subject: [PATCH 03/15] Change default keyval state file location. (#90) Previously, the keyval state file was configured to be stored in the "conf.d" directory. By default, the NGINX process does not have write access to this directory, necessitating users to either specify a different path or alter the directory permissions. The default path for the state file has been changed to "/var/lib/nginx/state". This new location is more suitable for most Linux users and aligns with security best practices, as only the NGINX user has read and write permissions by default. --- README.md | 6 +++--- openid_connect_configuration.conf | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 969dcd7..c2f6ffc 100644 --- a/README.md +++ b/README.md @@ -136,9 +136,9 @@ Manual configuration involves reviewing the following files so that they match y The key-value store is used to maintain persistent storage for ID tokens and refresh tokens. The default configuration should be reviewed so that it suits the environment. This is part of the advanced configuration in **openid_connect_configuration.conf**. ```nginx -keyval_zone zone=oidc_id_tokens:1M state=conf.d/oidc_id_tokens.json timeout=1h; -keyval_zone zone=oidc_access_tokens:1M state=conf.d/oidc_access_tokens.json timeout=1h; -keyval_zone zone=refresh_tokens:1M state=conf.d/refresh_tokens.json timeout=8h; +keyval_zone zone=oidc_id_tokens:1M state=/var/lib/nginx/state/oidc_id_tokens.json timeout=1h; +keyval_zone zone=oidc_access_tokens:1M state=/var/lib/nginx/state/oidc_access_tokens.json timeout=1h; +keyval_zone zone=refresh_tokens:1M state=/var/lib/nginx/state/refresh_tokens.json timeout=8h; keyval_zone zone=oidc_pkce:128K timeout=90s; ``` diff --git a/openid_connect_configuration.conf b/openid_connect_configuration.conf index 0aa69a4..c2b0a52 100644 --- a/openid_connect_configuration.conf +++ b/openid_connect_configuration.conf @@ -87,9 +87,9 @@ map $http_x_forwarded_proto $proto { proxy_cache_path /var/cache/nginx/jwk levels=1 keys_zone=jwk:64k max_size=1m; # Change timeout values to at least the validity period of each token type -keyval_zone zone=oidc_id_tokens:1M state=conf.d/oidc_id_tokens.json timeout=1h; -keyval_zone zone=oidc_access_tokens:1M state=conf.d/oidc_access_tokens.json timeout=1h; -keyval_zone zone=refresh_tokens:1M state=conf.d/refresh_tokens.json timeout=8h; +keyval_zone zone=oidc_id_tokens:1M state=/var/lib/nginx/state/oidc_id_tokens.json timeout=1h; +keyval_zone zone=oidc_access_tokens:1M state=/var/lib/nginx/state/oidc_access_tokens.json timeout=1h; +keyval_zone zone=refresh_tokens:1M state=/var/lib/nginx/state/refresh_tokens.json timeout=8h; keyval_zone zone=oidc_pkce:128K timeout=90s; # Temporary storage for PKCE code verifier. keyval $cookie_auth_token $session_jwt zone=oidc_id_tokens; # Exchange cookie for JWT From 8dad5806714830576b07968558a0896adfbb8036 Mon Sep 17 00:00:00 2001 From: Liam Crilly Date: Sun, 2 Jun 2024 22:34:53 +0100 Subject: [PATCH 04/15] Replace diagram images with Mermaid --- README.md | 46 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c2f6ffc..22e5266 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,24 @@ Reference implementation of NGINX Plus as relying party for OpenID Connect authe This repository describes how to enable OpenID Connect integration for [NGINX Plus](https://www.nginx.com/products/nginx/). The solution depends on NGINX Plus components ([auth_jwt module](http://nginx.org/en/docs/http/ngx_http_auth_jwt_module.html) and [key-value store](http://nginx.org/en/docs/http/ngx_http_keyval_module.html)) and as such is not suitable for [open source NGINX](http://www.nginx.org/en). -OpenID Connect components - +```mermaid +flowchart BT + subgraph " " + direction LR + id1(User)==>|Request for app|id2 + id2-. Unauthenticated .->id1 + id2(NGINX+)-->|Authenticated|id3(Backend app) + end + subgraph IDP + id4(Authorization Server) + end + id1<-. User authenticates directly with IdP .->IDP + IDP<-. NGINX exchanges authorization code for ID token .->id2 + style id1 fill:#fff,stroke:#444,stroke-width:3px,color:#222 + style id3 fill:#fff,stroke:#444,stroke-width:3px,color:#222 + style id2 fill:#009639,stroke:#215732,stroke-width:2px,color:#fff + style id4 fill:#666,stroke:#222,stroke-width:1px,color:#fff +``` `Figure 1. High level components of an OpenID Connect environment` This implementation assumes the following environment: @@ -19,7 +35,31 @@ This implementation assumes the following environment: With this environment, both the client and NGINX Plus communicate directly with the IdP at different stages during the initial authentication event. -![OpenID Connect protocol diagram](https://www.nginx.com/wp-content/uploads/2018/04/dia-LC-2018-03-30-OpenID-Connect-authentication-code-flow-detailed-800x840-03.svg) +```mermaid +sequenceDiagram + autonumber + actor User + participant Browser + participant IdP + participant NGINX Plus + participant Web App + User->>NGINX Plus: Requests protected resource + NGINX Plus->>Browser: Sends redirect to IdP for authentication + Browser->>IdP: Requests login page + User->>IdP: Provides authentication and consent + IdP->>Browser: Sends redirect w/ authZ code + Browser->>NGINX Plus: Redirected for code exchange + NGINX Plus->>IdP: Sends authZ code + IdP->>NGINX Plus: Sends ID(+refresh) token + NGINX Plus-->>NGINX Plus: Validates ID token, stores in keyval, creates session cookie + Note right of NGINX Plus: keyvals zone for ID token (JWT) + Note right of NGINX Plus: keyval zone for refresh token + NGINX Plus->>Browser: Sends redirect to original URI with session cookie + Browser->>NGINX Plus: Requests original URI, supplies session cookie + NGINX Plus-->>NGINX Plus: Obtains ID token from keyval, validates JWT + NGINX Plus->>Web App: Proxies request + Web App->>Browser: Sends resource +``` `Figure 2. OpenID Connect authorization code flow protocol` NGINX Plus is configured to perform OpenID Connect authentication. Upon a first visit to a protected resource, NGINX Plus initiates the OpenID Connect authorization code flow and redirects the client to the OpenID Connect provider (IdP). When the client returns to NGINX Plus with an authorization code, NGINX Plus exchanges that code for a set of tokens by communicating directly with the IdP. From 6ea7364b47b54ba4e4684106ca24944817f7f17b Mon Sep 17 00:00:00 2001 From: Ivan Ovchinnikov <33402471+route443@users.noreply.github.com> Date: Tue, 2 Jul 2024 09:36:03 -0700 Subject: [PATCH 05/15] Add support for RP-initiated OIDC logout (#96) Implement support for RP-initiated logout in accordance with OpenID Connect RP-Initiated Logout 1.0. Introduce "oidc_end_session_endpoint" variable to specify the "end_session_endpoint" URL. If "oidc_end_session_endpoint" is not set or is empty, the default behavior of logging out only on the NGINX side is maintained. When set, the endpoint triggers the RP-initiated logout as specified in the specification. --- README.md | 8 +++++- configure.sh | 11 ++++++-- openid_connect.js | 47 ++++++++++++++++++++++++++----- openid_connect.server_conf | 11 ++++---- openid_connect_configuration.conf | 7 +++++ 5 files changed, 68 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 22e5266..c41a978 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,10 @@ If a [refresh token](https://openid.net/specs/openid-connect-core-1_0.html#Refre Requests made to the `/logout` location invalidate both the ID token, access token and refresh token by erasing them from the key-value store. Therefore, subsequent requests to protected resources will be treated as a first-time request and send the client to the IdP for authentication. Note that the IdP may issue cookies such that an authenticated session still exists at the IdP. +#### RP-Initiated OIDC Logout + +RP-initiated logout is supported according to [OpenID Connect RP-Initiated Logout 1.0](https://openid.net/specs/openid-connect-rpinitiated-1_0.html). This behavior is controlled by the `$oidc_end_session_endpoint` variable. + ### Multiple IdPs Where NGINX Plus is configured to proxy requests for multiple websites or applications, or user groups, these may require authentication by different IdPs. Separate IdPs can be configured, with each one matching on an attribute of the HTTP request, e.g. hostname or part of the URI path. @@ -137,11 +141,13 @@ When NGINX Plus is deployed behind another proxy, the original protocol and port * Set the **redirect URI** to the address of your NGINX Plus instance (including the port number), with `/_codexch` as the path, e.g. `https://my-nginx.example.com:443/_codexch` * Ensure NGINX Plus is configured as a confidential client (with a client secret) or a public client (with PKCE S256 enabled) * Make a note of the `client ID` and `client secret` if set + * Set the **post logout redirect URI** to the address of your NGINX Plus instance (including the port number), with `/_logout` as the path, e.g. `https://my-nginx.example.com:443/_logout` * If your IdP supports OpenID Connect Discovery (usually at the URI `/.well-known/openid-configuration`) then use the `configure.sh` script to complete configuration. In this case you can skip the next section. Otherwise: * Obtain the URL for `jwks_uri` or download the JWK file to your NGINX Plus instance * Obtain the URL for the **authorization endpoint** * Obtain the URL for the **token endpoint** + * Obtain the URL for the **end session endpoint** ## Configuring NGINX Plus @@ -165,7 +171,7 @@ Manual configuration involves reviewing the following files so that they match y * **openid_connect.server_conf** - this is the NGINX configuration for handling the various stages of OpenID Connect authorization code flow * No changes are usually required here - * Modify the `resolver` directive to match a DNS server that is capable of resolving the IdP defined in `$oidc_token_endpoint` + * Modify the `resolver` directive to match a DNS server that is capable of resolving the IdP defined in `$oidc_token_endpoint` and `$oidc_end_session_endpoint` * If using [`auth_jwt_key_request`](http://nginx.org/en/docs/http/ngx_http_auth_jwt_module.html#auth_jwt_key_request) to automatically fetch the JWK file from the IdP then modify the validity period and other caching options to suit your IdP * **openid_connect.js** - this is the JavaScript code for performing the authorization code exchange and nonce hashing diff --git a/configure.sh b/configure.sh index 17e8920..e48b0cd 100755 --- a/configure.sh +++ b/configure.sh @@ -120,7 +120,7 @@ fi # Build an intermediate configuration file # File format is: # -jq -r '. | "$oidc_authz_endpoint \(.authorization_endpoint)\n$oidc_token_endpoint \(.token_endpoint)\n$oidc_jwks_uri \(.jwks_uri)"' < /tmp/${COMMAND}_$$_json > /tmp/${COMMAND}_$$_conf +jq -r '. | "$oidc_authz_endpoint \(.authorization_endpoint)\n$oidc_token_endpoint \(.token_endpoint)\n$oidc_end_session_endpoint \(.end_session_endpoint // "")\n$oidc_jwks_uri \(.jwks_uri)"' < /tmp/${COMMAND}_$$_json > /tmp/${COMMAND}_$$_conf # Create a random value for HMAC key, adding to the intermediate configuration file echo "\$oidc_hmac_key `openssl rand -base64 18`" >> /tmp/${COMMAND}_$$_conf @@ -178,13 +178,18 @@ fi # Loop through each configuration variable echo "$COMMAND: NOTICE: Configuring $CONFDIR/openid_connect_configuration.conf" -for OIDC_VAR in \$oidc_authz_endpoint \$oidc_token_endpoint \$oidc_jwt_keyfile \$oidc_hmac_key $CLIENT_ID_VAR $CLIENT_SECRET_VAR $PKCE_ENABLE_VAR; do +for OIDC_VAR in \$oidc_authz_endpoint \$oidc_token_endpoint \$oidc_end_session_endpoint \$oidc_jwt_keyfile \$oidc_hmac_key $CLIENT_ID_VAR $CLIENT_SECRET_VAR $PKCE_ENABLE_VAR; do # Pull the configuration value from the intermediate file VALUE=`grep "^$OIDC_VAR " /tmp/${COMMAND}_$$_conf | cut -f2 -d' '` echo -n "$COMMAND: NOTICE: - $OIDC_VAR ..." + # If the value is empty, assign a default value + if [ -z "$VALUE" ]; then + VALUE="\"\"" + fi + # Find where this variable is configured - LINE=`grep -nA10 $OIDC_VAR $CONFDIR/openid_connect_configuration.conf | grep $HOSTNAME | head -1 | cut -f1 -d-` + LINE=`grep -nA10 $OIDC_VAR $CONFDIR/openid_connect_configuration.conf | grep -vE '^[0-9]+-?[[:space:]]*($|#)' | grep $HOSTNAME | head -1 | cut -f1 -d-` if [ "$LINE" == "" ]; then # Add new value LINE=`grep -n $OIDC_VAR $CONFDIR/openid_connect_configuration.conf | head -1 | cut -f1 -d:` diff --git a/openid_connect.js b/openid_connect.js index 5ef1a80..e4572eb 100644 --- a/openid_connect.js +++ b/openid_connect.js @@ -1,6 +1,6 @@ /* * JavaScript functions for providing OpenID Connect with NGINX Plus - * + * * Copyright (C) 2020 Nginx, Inc. */ var newSession = false; // Used by oidcAuth() and validateIdToken() @@ -51,7 +51,7 @@ function auth(r, afterSyncCheck) { r.return(302, r.variables.oidc_authz_endpoint + getAuthZArgs(r)); return; } - + // Pass the refresh token to the /_refresh location so that it can be // proxied to the IdP in exchange for a new id_token r.subrequest("/_refresh", "token=" + r.variables.refresh_token, @@ -266,10 +266,43 @@ function validateIdToken(r) { function logout(r) { r.log("OIDC logout for " + r.variables.cookie_auth_token); - r.variables.session_jwt = "-"; - r.variables.access_token = "-"; - r.variables.refresh_token = "-"; - r.return(302, r.variables.oidc_logout_redirect); + + // Determine if oidc_logout_redirect is a full URL or a relative path + function getLogoutRedirectUrl(base, redirect) { + return redirect.match(/^(http|https):\/\//) ? redirect : base + redirect; + } + + var logoutRedirectUrl = getLogoutRedirectUrl(r.variables.redirect_base, r.variables.oidc_logout_redirect); + + // Helper function to perform the final logout steps + function performLogout(redirectUrl) { + r.variables.session_jwt = '-'; + r.variables.access_token = '-'; + r.variables.refresh_token = '-'; + r.return(302, redirectUrl); + } + + // Check if OIDC end session endpoint is available + if (r.variables.oidc_end_session_endpoint) { + + if (!r.variables.session_jwt || r.variables.session_jwt === '-') { + if (r.variables.refresh_token && r.variables.refresh_token !== '-') { + // Renew ID token if only refresh token is available + auth(r, 0); + } else { + performLogout(logoutRedirectUrl); + return; + } + } + + // Construct logout arguments for RP-initiated logout + var logoutArgs = "?post_logout_redirect_uri=" + encodeURIComponent(logoutRedirectUrl) + + "&id_token_hint=" + encodeURIComponent(r.variables.session_jwt); + performLogout(r.variables.oidc_end_session_endpoint + logoutArgs); + } else { + // Fallback to traditional logout approach + performLogout(logoutRedirectUrl); + } } function getAuthZArgs(r) { @@ -311,5 +344,5 @@ function idpClientAuth(r) { return "code=" + r.variables.arg_code + "&code_verifier=" + r.variables.pkce_code_verifier; } else { return "code=" + r.variables.arg_code + "&client_secret=" + r.variables.oidc_client_secret; - } + } } diff --git a/openid_connect.server_conf b/openid_connect.server_conf index 13456d2..55d95a5 100644 --- a/openid_connect.server_conf +++ b/openid_connect.server_conf @@ -8,7 +8,7 @@ location = /_jwks_uri { internal; - proxy_cache jwk; # Cache the JWK Set recieved from IdP + proxy_cache jwk; # Cache the JWK Set received 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 @@ -29,9 +29,9 @@ # This location is called by the IdP after successful authentication status_zone "OIDC code exchange"; js_content oidc.codeExchange; - error_page 500 502 504 @oidc_error; + 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: @@ -68,8 +68,9 @@ location = /logout { 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 + add_header Set-Cookie "auth_token=; $oidc_cookie_flags"; + add_header Set-Cookie "auth_nonce=; $oidc_cookie_flags"; + add_header Set-Cookie "auth_redir=; $oidc_cookie_flags"; js_content oidc.logout; } diff --git a/openid_connect_configuration.conf b/openid_connect_configuration.conf index c2b0a52..16d25e7 100644 --- a/openid_connect_configuration.conf +++ b/openid_connect_configuration.conf @@ -28,6 +28,13 @@ map $host $oidc_jwt_keyfile { default "http://127.0.0.1:8080/auth/realms/master/protocol/openid-connect/certs"; } +map $host $oidc_end_session_endpoint { + # Specifies the end_session_endpoint URL for RP-initiated logout. + # If this variable is empty or not set, the default behavior is maintained, + # which logs out only on the NGINX side. + default ""; +} + map $host $oidc_client { default "my-client-id"; } From 4f9da386488ff2a5111d40cd1c5954e318383e3b Mon Sep 17 00:00:00 2001 From: Ivan Ovchinnikov <33402471+route443@users.noreply.github.com> Date: Wed, 24 Jul 2024 10:35:13 -0700 Subject: [PATCH 06/15] Added support for client_secret_basic as a client authentication method (#97) - Updated token exchange to use the Authorization header for client_secret_basic. - Refactored logic for generating POST request parameters for token retrieval and refresh. - Added "oidc_client_auth_method" variable to select client authentication method. --- README.md | 14 ++++++++++ openid_connect.js | 44 ++++++++++++++++++++++++------- openid_connect.server_conf | 6 ++--- openid_connect_configuration.conf | 7 +++++ 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c41a978..c3c38d1 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,18 @@ Subsequent requests to protected resources are authenticated by exchanging the s For more information on OpenID Connect and JWT validation with NGINX Plus, see [Authenticating Users to Existing Applications with OpenID Connect and NGINX Plus](https://www.nginx.com/blog/authenticating-users-existing-applications-openid-connect-nginx-plus/). +### Client Authentication Methods + +When configuring NGINX Plus as an OpenID Connect client, it supports multiple client authentication methods: + +* **client_secret_basic**: + * The `client_id` and `client_secret` are sent in the Authorization header as a Base64-encoded string. +* **client_secret_post**: + * The `client_id` and `client_secret` are sent in the body of the POST request. +* **none** (PKCE): + * For public clients that cannot protect a client secret, the `code_verifier` is used instead of a `client_secret`. + * PKCE is particularly useful for mobile and single-page applications. + ### Access Tokens [Access tokens](https://openid.net/specs/openid-connect-core-1_0.html#AccessTokenDisclosure) are used in token-based authentication to allow OIDC client to access a protected resource on behalf of the user. NGINX Plus receives an access token after a user successfully authenticates and authorizes access, and then stores it in the key-value store. NGINX Plus can pass that token on the HTTP Authorization header as a [Bearer token](https://oauth.net/2/bearer-tokens/) for every request that is sent to the downstream application. @@ -140,6 +152,7 @@ When NGINX Plus is deployed behind another proxy, the original protocol and port * Choose the **authorization code flow** * Set the **redirect URI** to the address of your NGINX Plus instance (including the port number), with `/_codexch` as the path, e.g. `https://my-nginx.example.com:443/_codexch` * Ensure NGINX Plus is configured as a confidential client (with a client secret) or a public client (with PKCE S256 enabled) + * If NGINX Plus is configured as a confidential client, choose the appropriate authentication method: **client_secret_basic** or **client_secret_post**. * Make a note of the `client ID` and `client secret` if set * Set the **post logout redirect URI** to the address of your NGINX Plus instance (including the port number), with `/_logout` as the path, e.g. `https://my-nginx.example.com:443/_logout` @@ -300,3 +313,4 @@ This reference implementation for OpenID Connect is supported for NGINX Plus sub * **R22** Separate configuration file, supports multiple IdPs. Configurable scopes and cookie flags. JavaScript is imported as an indepedent module with `js_import`. Container-friendly logging. Additional metrics for OIDC activity. * **R23** PKCE support. Added support for deployments behind another proxy or load balancer. * **R28** Access token support. Added support for access token to authorize NGINX to access protected backend. + * **R32** Added support for `client_secret_basic` client authentication method. diff --git a/openid_connect.js b/openid_connect.js index e4572eb..e219e46 100644 --- a/openid_connect.js +++ b/openid_connect.js @@ -54,7 +54,7 @@ function auth(r, afterSyncCheck) { // Pass the refresh token to the /_refresh location so that it can be // proxied to the IdP in exchange for a new id_token - r.subrequest("/_refresh", "token=" + r.variables.refresh_token, + r.subrequest("/_refresh", generateTokenRequestParams(r, "refresh_token"), function(reply) { if (reply.status != 200) { // Refresh request failed, log the reason @@ -142,7 +142,7 @@ function codeExchange(r) { // Pass the authorization code to the /_token location so that it can be // proxied to the IdP in exchange for a JWT - r.subrequest("/_token",idpClientAuth(r), function(reply) { + r.subrequest("/_token", generateTokenRequestParams(r, "authorization_code"), function(reply) { if (reply.status == 504) { r.error("OIDC timeout connecting to IdP when sending authorization code"); r.return(504); @@ -337,12 +337,38 @@ function getAuthZArgs(r) { return authZArgs; } -function idpClientAuth(r) { - // If PKCE is enabled we have to use the code_verifier - if ( r.variables.oidc_pkce_enable == 1 ) { - r.variables.pkce_id = r.variables.arg_state; - return "code=" + r.variables.arg_code + "&code_verifier=" + r.variables.pkce_code_verifier; - } else { - return "code=" + r.variables.arg_code + "&client_secret=" + r.variables.oidc_client_secret; +function generateTokenRequestParams(r, grant_type) { + var body = "grant_type=" + grant_type + "&client_id=" + r.variables.oidc_client; + + switch(grant_type) { + case "authorization_code": + body += "&code=" + r.variables.arg_code + "&redirect_uri=" + r.variables.redirect_base + r.variables.redir_location; + if (r.variables.oidc_pkce_enable == 1) { + r.variables.pkce_id = r.variables.arg_state; + body += "&code_verifier=" + r.variables.pkce_code_verifier; + } + break; + case "refresh_token": + body += "&refresh_token=" + r.variables.refresh_token; + break; + default: + r.error("Unsupported grant type: " + grant_type); + return; } + + var options = { + body: body, + method: "POST" + }; + + if (r.variables.oidc_pkce_enable != 1) { + if (r.variables.oidc_client_auth_method === "client_secret_basic") { + let auth_basic = "Basic " + Buffer.from(r.variables.oidc_client + ":" + r.variables.oidc_client_secret).toString('base64'); + options.args = "secret_basic=" + auth_basic; + } else { + options.body += "&client_secret=" + r.variables.oidc_client_secret; + } + } + + return options; } diff --git a/openid_connect.server_conf b/openid_connect.server_conf index 55d95a5..d65d77b 100644 --- a/openid_connect.server_conf +++ b/openid_connect.server_conf @@ -39,8 +39,7 @@ 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=authorization_code&client_id=$oidc_client&$args&redirect_uri=$redirect_base$redir_location"; - proxy_method POST; + proxy_set_header Authorization $arg_secret_basic; proxy_pass $oidc_token_endpoint; } @@ -51,8 +50,7 @@ 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_set_header Authorization $arg_secret_basic; proxy_pass $oidc_token_endpoint; } diff --git a/openid_connect_configuration.conf b/openid_connect_configuration.conf index 16d25e7..8c33067 100644 --- a/openid_connect_configuration.conf +++ b/openid_connect_configuration.conf @@ -47,6 +47,13 @@ map $host $oidc_client_secret { default "my-client-secret"; } +map $host $oidc_client_auth_method { + # Choose either "client_secret_basic" for sending client credentials in the + # Authorization header, or "client_secret_post" for sending them in the + # body of the POST request. This setting is used for confidential clients. + default "client_secret_post"; +} + map $host $oidc_scopes { default "openid+profile+email+offline_access"; } From afa8f4c01cee5da509e74cdfcf837df088a70b13 Mon Sep 17 00:00:00 2001 From: henggai <51138480+happyhd@users.noreply.github.com> Date: Tue, 13 Aug 2024 20:48:21 -0700 Subject: [PATCH 07/15] Clarify mandatory sync configuration for NGINXaaS OIDC integration (#99) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c3c38d1..539b0ab 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ Each of the `keyval_zone` parameters are described below. * **timeout** - Expired tokens are removed from the key-value store after the `timeout` value. This should be set to value slightly longer than the JWT validity period. JWT validation occurs on each request, and will fail when the expiry date (`exp` claim) has elapsed. If JWTs are issued without an `exp` claim then set `timeout` to the desired session duration. If JWTs are issued with a range of validity periods then set `timeout` to exceed the longest period. - * **sync** (optional) - If deployed in a cluster, the key-value store may be synchronized across all instances in the cluster, so that all instances are able to create and validate authenticated sessions. Each instance must be configured to participate in state sharing with the [zone_sync module](http://nginx.org/en/docs/stream/ngx_stream_zone_sync_module.html) and by adding the `sync` parameter to the `keyval_zone` directives above. + * **sync** (optional) - If deployed in a cluster, the key-value store may be synchronized across all instances in the cluster, so that all instances are able to create and validate authenticated sessions. Each instance must be configured to participate in state sharing with the [zone_sync module](http://nginx.org/en/docs/stream/ngx_stream_zone_sync_module.html) and by adding the `sync` parameter to the `keyval_zone` directives above. If deployed in [NGINXaaS for Azure](https://docs.nginx.com/nginxaas/azure/overview/overview/), **sync** is a must configuration. Check [NGINXaaS OIDC](https://docs.nginx.com/nginxaas/azure/quickstart/security-controls/oidc/) for more details. ## Session Management From 133504f4fd9f72f3e36668f9f2f3d32a86fcb269 Mon Sep 17 00:00:00 2001 From: jo-carter <104033676+jo-carter@users.noreply.github.com> Date: Thu, 24 Oct 2024 23:15:54 +0100 Subject: [PATCH 08/15] Fixed ID token nonce claim validation (#104) The validateIdToken function previously did not correctly validate the nonce claim in the ID Token due to improper handling of session state. The newSession variable, intended to indicate a new authentication session, was not reliably set, causing nonce validation to be skipped in all cases. --------- Co-authored-by: Tom Noonan II Co-authored-by: Ivan Ovchinnikov <33402471+route443@users.noreply.github.com> --- openid_connect.js | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/openid_connect.js b/openid_connect.js index e219e46..eccabca 100644 --- a/openid_connect.js +++ b/openid_connect.js @@ -3,8 +3,6 @@ * * Copyright (C) 2020 Nginx, Inc. */ -var newSession = false; // Used by oidcAuth() and validateIdToken() - export default {auth, codeExchange, validateIdToken, logout}; function retryOriginalRequest(r) { @@ -32,8 +30,6 @@ function auth(r, afterSyncCheck) { } if (!r.variables.refresh_token || r.variables.refresh_token == "-") { - newSession = true; - // Check we have all necessary configuration variables (referenced only by njs) var oidcConfigurables = ["authz_endpoint", "scopes", "hmac_key", "cookie_flags"]; var missingConfig = []; @@ -241,10 +237,9 @@ function validateIdToken(r) { validToken = false; } - // If we receive a nonce in the ID Token then we will use the auth_nonce cookies - // to check that the JWT can be validated as being directly related to the - // original request by this client. This mitigates against token replay attacks. - if (newSession) { + // According to OIDC Core 1.0 Section 2: + // "If present in the ID Token, Clients MUST verify that the nonce Claim Value is equal to the value of the nonce parameter sent in the Authentication Request." + if (r.variables.jwt_claim_nonce) { var client_nonce_hash = ""; if (r.variables.cookie_auth_nonce) { var c = require('crypto'); @@ -255,6 +250,9 @@ function validateIdToken(r) { r.error("OIDC ID Token validation error: nonce from token (" + r.variables.jwt_claim_nonce + ") does not match client (" + client_nonce_hash + ")"); validToken = false; } + } else if (!r.variables.refresh_token || r.variables.refresh_token == "-") { + r.error("OIDC ID Token validation error: missing nonce claim in ID Token during initial authentication."); + validToken = false; } if (validToken) { From f7e8726ab2d625d342b3a181c10b78443b53775f Mon Sep 17 00:00:00 2001 From: ag-TJNII <69820911+ag-TJNII@users.noreply.github.com> Date: Wed, 4 Dec 2024 18:09:50 -0500 Subject: [PATCH 09/15] Do not pass client headers or body to the IdP token endpoint --- openid_connect.server_conf | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/openid_connect.server_conf b/openid_connect.server_conf index d65d77b..f662f23 100644 --- a/openid_connect.server_conf +++ b/openid_connect.server_conf @@ -37,6 +37,12 @@ # to construct the OpenID Connect token request, as per: # http://openid.net/specs/openid-connect-core-1_0.html#TokenRequest internal; + + # Do not pass through body or headers from the client, this should be a net-new connection. + # Some IdPs, like Microsoft Entra, will throw CORS errors if client headers are passed through. + proxy_pass_request_headers off; + proxy_pass_request_body off; + proxy_ssl_server_name on; # For SNI to the IdP proxy_set_header Content-Type "application/x-www-form-urlencoded"; proxy_set_header Authorization $arg_secret_basic; @@ -48,6 +54,12 @@ # use the proxy_ directives to construct the OpenID Connect token request, as per: # https://openid.net/specs/openid-connect-core-1_0.html#RefreshingAccessToken internal; + + # Do not pass through body or headers from the client, this should be a net-new connection. + # Some IdPs, like Microsoft Entra, will throw CORS errors if client headers are passed through. + proxy_pass_request_headers off; + proxy_pass_request_body off; + proxy_ssl_server_name on; # For SNI to the IdP proxy_set_header Content-Type "application/x-www-form-urlencoded"; proxy_set_header Authorization $arg_secret_basic; From 1da0cc154894ea32e0a6994c33670ecbe3de1457 Mon Sep 17 00:00:00 2001 From: Ivan Ovchinnikov Date: Wed, 11 Dec 2024 07:51:36 +0000 Subject: [PATCH 10/15] Fix OIDC client authentication for POST method after f7e8726 Remove the `proxy_pass_request_body off` directive, which unintentionally broke OIDC client authentication using the POST body method (`client_secret_post`). Previously, when `$oidc_client_auth_method` was set to "client_secret_post" the `generateTokenRequestParams()` function correctly formatted the POST request and sent it via `r.subrequest` to the internal `/_token` location. However, the `proxy_pass_request_body off` directive caused the POST request to reach `$oidc_token_endpoint` with a valid Content-Length header but an empty body. This led to a timeout as the OP token endpoint closed the connection. Users encountered the error: "NGINX / OpenID Connect login failure." This commit restores functionality by ensuring the request body is passed to the token endpoint while retaining header exclusion to prevent CORS issues. --- openid_connect.server_conf | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/openid_connect.server_conf b/openid_connect.server_conf index f662f23..c6e4630 100644 --- a/openid_connect.server_conf +++ b/openid_connect.server_conf @@ -38,10 +38,8 @@ # http://openid.net/specs/openid-connect-core-1_0.html#TokenRequest internal; - # Do not pass through body or headers from the client, this should be a net-new connection. - # Some IdPs, like Microsoft Entra, will throw CORS errors if client headers are passed through. + # Exclude client headers to avoid CORS errors with certain IdPs (e.g., Microsoft Entra ID) proxy_pass_request_headers off; - proxy_pass_request_body off; proxy_ssl_server_name on; # For SNI to the IdP proxy_set_header Content-Type "application/x-www-form-urlencoded"; @@ -55,10 +53,8 @@ # https://openid.net/specs/openid-connect-core-1_0.html#RefreshingAccessToken internal; - # Do not pass through body or headers from the client, this should be a net-new connection. - # Some IdPs, like Microsoft Entra, will throw CORS errors if client headers are passed through. + # Exclude client headers to avoid CORS errors with certain IdPs (e.g., Microsoft Entra ID) proxy_pass_request_headers off; - proxy_pass_request_body off; proxy_ssl_server_name on; # For SNI to the IdP proxy_set_header Content-Type "application/x-www-form-urlencoded"; From 66c4eaa27be8039126e6ccbb6e070606a90f0abd Mon Sep 17 00:00:00 2001 From: Ivan Ovchinnikov Date: Wed, 18 Dec 2024 20:35:21 +0000 Subject: [PATCH 11/15] Refactor code to use async/await, modular functions, and improve token handling. - Switched from callbacks to async/await for clearer, more maintainable code. - Broke up the monolithic code into smaller and modular functions. - Refined id token validation logic. - Changed the internal /_id_token_validation location to /_token_validation. - Minimum required njs version is 0.7.0 now. --- openid_connect.js | 573 +++++++++++++++++++++---------------- openid_connect.server_conf | 11 +- 2 files changed, 336 insertions(+), 248 deletions(-) diff --git a/openid_connect.js b/openid_connect.js index eccabca..d79ca9b 100644 --- a/openid_connect.js +++ b/openid_connect.js @@ -1,278 +1,291 @@ /* * JavaScript functions for providing OpenID Connect with NGINX Plus * - * Copyright (C) 2020 Nginx, Inc. + * Copyright (C) 2024 Nginx, Inc. */ -export default {auth, codeExchange, validateIdToken, logout}; -function retryOriginalRequest(r) { - delete r.headersOut["WWW-Authenticate"]; // Remove evidence of original failed auth_jwt - r.internalRedirect(r.variables.uri + r.variables.is_args + (r.variables.args || '')); -} - -// If the ID token has not been synced yet, poll the variable every 100ms until -// get a value or after a timeout. -function waitForSessionSync(r, timeLeft) { - if (r.variables.session_jwt) { - retryOriginalRequest(r); - } else if (timeLeft > 0) { - setTimeout(waitForSessionSync, 100, r, timeLeft - 100); - } else { - auth(r, true); +export default { + auth, + codeExchange, + extractTokenClaims, + logout +}; + +// The main authentication flow, called before serving a protected resource. +async function auth(r, afterSyncCheck) { + // If there's a session cookie but session not synced, wait for sync + if (r.variables.cookie_auth_token && !r.variables.session_jwt && + !afterSyncCheck && r.variables.zone_sync_leeway > 0) { + waitForSessionSync(r, r.variables.zone_sync_leeway); + return; } -} -function auth(r, afterSyncCheck) { - // If a cookie was sent but the ID token is not in the key-value database, wait for the token to be in sync. - if (r.variables.cookie_auth_token && !r.variables.session_jwt && !afterSyncCheck && r.variables.zone_sync_leeway > 0) { - waitForSessionSync(r, r.variables.zone_sync_leeway); + if (isNewSession(r)) { + initiateNewAuth(r); return; } - if (!r.variables.refresh_token || r.variables.refresh_token == "-") { - // Check we have all necessary configuration variables (referenced only by njs) - var oidcConfigurables = ["authz_endpoint", "scopes", "hmac_key", "cookie_flags"]; - var missingConfig = []; - for (var i in oidcConfigurables) { - if (!r.variables["oidc_" + oidcConfigurables[i]] || r.variables["oidc_" + oidcConfigurables[i]] == "") { - missingConfig.push(oidcConfigurables[i]); - } - } - if (missingConfig.length) { - r.error("OIDC missing configuration variables: $oidc_" + missingConfig.join(" $oidc_")); - r.return(500, r.variables.internal_error_message); - return; - } - // Redirect the client to the IdP login page with the cookies we need for state - r.return(302, r.variables.oidc_authz_endpoint + getAuthZArgs(r)); + // No or expired ID token, but refresh token present, attempt to refresh + const tokenset = await refreshTokens(r); + if (!tokenset) { return; } - // Pass the refresh token to the /_refresh location so that it can be - // proxied to the IdP in exchange for a new id_token - r.subrequest("/_refresh", generateTokenRequestParams(r, "refresh_token"), - function(reply) { - if (reply.status != 200) { - // Refresh request failed, log the reason - var error_log = "OIDC refresh failure"; - if (reply.status == 504) { - error_log += ", timeout waiting for IdP"; - } else if (reply.status == 400) { - try { - var errorset = JSON.parse(reply.responseText); - error_log += ": " + errorset.error + " " + errorset.error_description; - } catch (e) { - error_log += ": " + reply.responseText; - } - } else { - error_log += " " + reply.status; - } - r.error(error_log); + // Validate refreshed ID token + const claims = await validateIdToken(r, tokenset.id_token); + if (!claims) { + // If validation failed, reset and reinitiate auth + r.variables.refresh_token = "-"; + r.return(302, r.variables.request_uri); + return; + } - // Clear the refresh token, try again - r.variables.refresh_token = "-"; - r.return(302, r.variables.request_uri); - return; - } + // Determine session ID and store session data + const sessionId = getSessionId(r, false); + storeSessionData(r, tokenset, false); - // Refresh request returned 200, check response - try { - var tokenset = JSON.parse(reply.responseText); - if (!tokenset.id_token) { - r.error("OIDC refresh response did not include id_token"); - if (tokenset.error) { - r.error("OIDC " + tokenset.error + " " + tokenset.error_description); - } - r.variables.refresh_token = "-"; - r.return(302, r.variables.request_uri); - return; - } + r.log("OIDC success, refreshing session " + sessionId); - // Send the new ID Token to auth_jwt location for validation - r.subrequest("/_id_token_validation", "token=" + tokenset.id_token, - function(reply) { - if (reply.status != 204) { - r.variables.refresh_token = "-"; - r.return(302, r.variables.request_uri); - return; - } - - // ID Token is valid, update keyval - r.log("OIDC refresh success, updating id_token for " + r.variables.cookie_auth_token); - r.variables.session_jwt = tokenset.id_token; // Update key-value store - if (tokenset.access_token) { - r.variables.access_token = tokenset.access_token; - } else { - r.variables.access_token = ""; - } - - // Update refresh token (if we got a new one) - if (r.variables.refresh_token != tokenset.refresh_token) { - r.log("OIDC replacing previous refresh token (" + r.variables.refresh_token + ") with new value: " + tokenset.refresh_token); - r.variables.refresh_token = tokenset.refresh_token; // Update key-value store - } - - retryOriginalRequest(r); // Continue processing original request - } - ); - } catch (e) { - r.variables.refresh_token = "-"; - r.return(302, r.variables.request_uri); - return; - } - } - ); + // Continue processing original request + retryOriginalRequest(r); } -function codeExchange(r) { - // First check that we received an authorization code from the IdP - if (r.variables.arg_code == undefined || r.variables.arg_code.length == 0) { +// The code exchange handler, called after IdP redirects back with a authorization code. +async function codeExchange(r) { + // Check authorization code presence + if (!r.variables.arg_code || r.variables.arg_code.length == 0) { if (r.variables.arg_error) { - r.error("OIDC error receiving authorization code from IdP: " + r.variables.arg_error_description); + r.error("OIDC error receiving authorization code: " + + r.variables.arg_error_description); } else { - r.error("OIDC expected authorization code from IdP but received: " + r.uri); + r.error("OIDC expected authorization code but received: " + r.uri); } r.return(502); return; } - // Pass the authorization code to the /_token location so that it can be - // proxied to the IdP in exchange for a JWT - r.subrequest("/_token", generateTokenRequestParams(r, "authorization_code"), function(reply) { - if (reply.status == 504) { - r.error("OIDC timeout connecting to IdP when sending authorization code"); - r.return(504); - return; - } + // Exchange authorization code for tokens + const tokenset = await exchangeCodeForTokens(r); + if (!tokenset) { + return; + } - if (reply.status != 200) { + // Validate ID token + const claims = await validateIdToken(r, tokenset.id_token); + if (!claims) { + r.return(500); + return; + } + + // Determine session ID and store session data for a new session + const sessionId = getSessionId(r, true); + storeSessionData(r, tokenset, true); + + r.log("OIDC success, creating session " + sessionId); + + // Set cookie and redirect to the originally requested URI + r.headersOut["Set-Cookie"] = "auth_token=" + sessionId + "; " + r.variables.oidc_cookie_flags; + r.return(302, r.variables.redirect_base + decodeURIComponent(r.variables.cookie_auth_redir)); +} + +// Extracts claims from token by calling the internal endpoint. +function getTokenClaims(r, token) { + return new Promise((resolve) => { + r.subrequest('/_token_validation', 'token=' + token, + function(reply) { + if (reply.status !== 200) { + r.error("Failed to retrieve claims: HTTP " + reply.status); + resolve(null); + return; + } try { - var errorset = JSON.parse(reply.responseText); - if (errorset.error) { - r.error("OIDC error from IdP when sending authorization code: " + errorset.error + ", " + errorset.error_description); - } else { - r.error("OIDC unexpected response from IdP when sending authorization code (HTTP " + reply.status + "). " + reply.responseText); - } + const claims = JSON.parse(reply.responseText); + resolve(claims); } catch (e) { - r.error("OIDC unexpected response from IdP when sending authorization code (HTTP " + reply.status + "). " + reply.responseText); + r.error("Failed to parse claims: " + e); + resolve(null); } - r.return(502); - return; } + ); + }); +} - // Code exchange returned 200, check for errors - try { - var tokenset = JSON.parse(reply.responseText); - if (tokenset.error) { - r.error("OIDC " + tokenset.error + " " + tokenset.error_description); - r.return(500); - return; - } +// Extracts and validates claims from the ID Token. +async function validateIdToken(r, idToken) { + const claims = await getTokenClaims(r, idToken); + if (!claims) { + return null; + } - // Send the ID Token to auth_jwt location for validation - r.subrequest("/_id_token_validation", "token=" + tokenset.id_token, - function(reply) { - if (reply.status != 204) { - r.return(500); // validateIdToken() will log errors - return; - } - - // If the response includes a refresh token then store it - if (tokenset.refresh_token) { - r.variables.new_refresh = tokenset.refresh_token; // Create key-value store entry - r.log("OIDC refresh token stored"); - } else { - r.warn("OIDC no refresh token"); - } - - // Add opaque token to keyval session store - r.log("OIDC success, creating session " + r.variables.request_id); - r.variables.new_session = tokenset.id_token; // Create key-value store entry - if (tokenset.access_token) { - r.variables.new_access_token = tokenset.access_token; - } else { - r.variables.new_access_token = ""; - } - - r.headersOut["Set-Cookie"] = "auth_token=" + r.variables.request_id + "; " + r.variables.oidc_cookie_flags; - r.return(302, r.variables.redirect_base + decodeURIComponent(r.variables.cookie_auth_redir)); - } - ); - } catch (e) { - r.error("OIDC authorization code sent but token response is not JSON. " + reply.responseText); - r.return(502); - } - } - ); + if (!validateIdTokenClaims(r, claims)) { + return null; + } + + return claims; } -function validateIdToken(r) { - // Check mandatory claims - var required_claims = ["iat", "iss", "sub"]; // aud is checked separately - var missing_claims = []; - for (var i in required_claims) { - if (r.variables["jwt_claim_" + required_claims[i]].length == 0 ) { - missing_claims.push(required_claims[i]); - } - } - if (r.variables.jwt_audience.length == 0) missing_claims.push("aud"); - if (missing_claims.length) { - r.error("OIDC ID Token validation error: missing claim(s) " + missing_claims.join(" ")); - r.return(403); - return; +// Validates the claims in the ID Token as per the OpenID Connect spec. +function validateIdTokenClaims(r, claims) { + const requiredClaims = ["iat", "iss", "sub", "aud"]; + const missingClaims = requiredClaims.filter((claim) => !claims[claim]); + + if (missingClaims.length > 0) { + r.error(`OIDC ID Token validation error: missing claim(s) ${missingClaims.join(' ')}`); + return false; } - var validToken = true; - // Check iat is a positive integer - var iat = Math.floor(Number(r.variables.jwt_claim_iat)); - if (String(iat) != r.variables.jwt_claim_iat || iat < 1) { + // Check 'iat' validity + const iat = Math.floor(Number(claims.iat)); + if (String(iat) !== claims.iat || iat < 1) { r.error("OIDC ID Token validation error: iat claim is not a valid number"); - validToken = false; + return false; } - // Audience matching - var aud = r.variables.jwt_audience.split(","); + // Audience must include the configured client + const aud = Array.isArray(claims.aud) ? claims.aud : claims.aud.split(','); if (!aud.includes(r.variables.oidc_client)) { - r.error("OIDC ID Token validation error: aud claim (" + r.variables.jwt_audience + ") does not include configured $oidc_client (" + r.variables.oidc_client + ")"); - validToken = false; + r.error(`OIDC ID Token validation error: aud claim (${claims.aud}) ` + + `does not include $oidc_client (${r.variables.oidc_client})`); + return false; } - // According to OIDC Core 1.0 Section 2: - // "If present in the ID Token, Clients MUST verify that the nonce Claim Value is equal to the value of the nonce parameter sent in the Authentication Request." - if (r.variables.jwt_claim_nonce) { - var client_nonce_hash = ""; - if (r.variables.cookie_auth_nonce) { - var c = require('crypto'); - var h = c.createHmac('sha256', r.variables.oidc_hmac_key).update(r.variables.cookie_auth_nonce); - client_nonce_hash = h.digest('base64url'); - } - if (r.variables.jwt_claim_nonce != client_nonce_hash) { - r.error("OIDC ID Token validation error: nonce from token (" + r.variables.jwt_claim_nonce + ") does not match client (" + client_nonce_hash + ")"); - validToken = false; + // Nonce validation for initial authentication + if (claims.nonce) { + const clientNonceHash = r.variables.cookie_auth_nonce + ? require('crypto') + .createHmac('sha256', r.variables.oidc_hmac_key) + .update(r.variables.cookie_auth_nonce) + .digest('base64url') + : ''; + + if (claims.nonce !== clientNonceHash) { + r.error(`OIDC ID Token validation error: nonce from token (${claims.nonce}) ` + + `does not match client (${clientNonceHash})`); + return false; } - } else if (!r.variables.refresh_token || r.variables.refresh_token == "-") { - r.error("OIDC ID Token validation error: missing nonce claim in ID Token during initial authentication."); - validToken = false; + } else if (isNewSession(r)) { + r.error("OIDC ID Token validation error: " + + "missing nonce claim during initial authentication."); + return false; } - if (validToken) { - r.return(204); + return true; +} + +// Store session data in the key-val store +function storeSessionData(r, tokenset, isNewSession) { + if (isNewSession) { + r.variables.new_session = tokenset.id_token; + r.variables.new_access_token = tokenset.access_token || ""; + r.variables.new_refresh = tokenset.refresh_token || ""; } else { - r.return(403); + r.variables.session_jwt = tokenset.id_token; + r.variables.access_token = tokenset.access_token || ""; + if (tokenset.refresh_token && r.variables.refresh_token != tokenset.refresh_token) { + r.variables.refresh_token = tokenset.refresh_token; + } } } +// Extracts claims from the validated ID Token (used by /_token_validation) +function extractTokenClaims(r) { + const claims = {}; + const claimNames = ["sub", "iss", "iat", "nonce"]; + + claimNames.forEach((name) => { + const value = r.variables["jwt_claim_" + name]; + value && (claims[name] = value); + }); + + // Handle aud via 'jwt_audience' variable + const audience = r.variables.jwt_audience; + audience && (claims.aud = audience.split(",")); + + r.return(200, JSON.stringify(claims)); +} + +// Determine the session ID depending on whether it's a new auth or a refresh +function getSessionId(r, isNewSession) { + return isNewSession ? r.variables.request_id : r.variables.cookie_auth_token; +} + +// Check for existing session using refresh token +function isNewSession(r) { + return !r.variables.refresh_token || r.variables.refresh_token === '-'; +} + +// Exchange authorization code for tokens using the internal /_token endpoint +async function exchangeCodeForTokens(r) { + const reply = await new Promise((resolve) => { + r.subrequest("/_token", generateTokenRequestParams(r, "authorization_code"), resolve); + }); + + if (reply.status === 504) { + r.error("OIDC timeout connecting to IdP during code exchange"); + r.return(504); + return null; + } + + if (reply.status !== 200) { + handleTokenError(r, reply); + r.return(502); + return null; + } + + try { + const tokenset = JSON.parse(reply.responseText); + if (tokenset.error) { + r.error("OIDC " + tokenset.error + " " + tokenset.error_description); + r.return(500); + return null; + } + return tokenset; + } catch (e) { + r.error("OIDC token response not JSON: " + reply.responseText); + r.return(502); + return null; + } +} + +// Refresh tokens using the internal /_refresh endpoint +async function refreshTokens(r) { + const reply = await new Promise((resolve) => { + r.subrequest("/_refresh", generateTokenRequestParams(r, "refresh_token"), resolve); + }); + + if (reply.status !== 200) { + handleRefreshError(r, reply); + return null; + } + + try { + const tokenset = JSON.parse(reply.responseText); + if (!tokenset.id_token) { + r.error("OIDC refresh response did not include id_token"); + if (tokenset.error) { + r.error("OIDC " + tokenset.error + " " + tokenset.error_description); + } + return null; + } + return tokenset; + } catch (e) { + r.variables.refresh_token = "-"; + r.return(302, r.variables.request_uri); + return null; + } +} + +// Logout handler function logout(r) { - r.log("OIDC logout for " + r.variables.cookie_auth_token); + r.log("RP-Initiated Logout for " + (r.variables.cookie_auth_token || "unknown")); - // Determine if oidc_logout_redirect is a full URL or a relative path function getLogoutRedirectUrl(base, redirect) { return redirect.match(/^(http|https):\/\//) ? redirect : base + redirect; } - var logoutRedirectUrl = getLogoutRedirectUrl(r.variables.redirect_base, r.variables.oidc_logout_redirect); + var logoutRedirectUrl = getLogoutRedirectUrl(r.variables.redirect_base, + r.variables.oidc_logout_redirect); - // Helper function to perform the final logout steps function performLogout(redirectUrl) { r.variables.session_jwt = '-'; r.variables.access_token = '-'; @@ -280,67 +293,89 @@ function logout(r) { r.return(302, redirectUrl); } - // Check if OIDC end session endpoint is available if (r.variables.oidc_end_session_endpoint) { - - if (!r.variables.session_jwt || r.variables.session_jwt === '-') { - if (r.variables.refresh_token && r.variables.refresh_token !== '-') { - // Renew ID token if only refresh token is available - auth(r, 0); - } else { - performLogout(logoutRedirectUrl); - return; - } + // If no ID token but refresh token present, attempt to re-auth to get ID token + if ((!r.variables.session_jwt || r.variables.session_jwt === '-') + && r.variables.refresh_token && r.variables.refresh_token !== '-') { + auth(r, 0); + } else if (!r.variables.session_jwt || r.variables.session_jwt === '-') { + performLogout(logoutRedirectUrl); + return; } - // Construct logout arguments for RP-initiated logout var logoutArgs = "?post_logout_redirect_uri=" + encodeURIComponent(logoutRedirectUrl) + "&id_token_hint=" + encodeURIComponent(r.variables.session_jwt); performLogout(r.variables.oidc_end_session_endpoint + logoutArgs); } else { - // Fallback to traditional logout approach performLogout(logoutRedirectUrl); } } +// Initiate a new authentication flow by redirecting to the IdP's authorization endpoint +function initiateNewAuth(r) { + const oidcConfigurables = ["authz_endpoint", "scopes", "hmac_key", "cookie_flags"]; + const missingConfig = oidcConfigurables.filter(key => + !r.variables["oidc_" + key] || r.variables["oidc_" + key] == "" + ); + + if (missingConfig.length) { + r.error("OIDC missing configuration variables: $oidc_" + missingConfig.join(" $oidc_")); + r.return(500, r.variables.internal_error_message); + return; + } + + // Redirect to IdP authorization endpoint with the cookie set for state and nonce + r.return(302, r.variables.oidc_authz_endpoint + getAuthZArgs(r)); +} + +// Generate the authorization request arguments function getAuthZArgs(r) { - // Choose a nonce for this flow for the client, and hash it for the IdP - var noncePlain = r.variables.request_id; var c = require('crypto'); + var noncePlain = r.variables.request_id; var h = c.createHmac('sha256', r.variables.oidc_hmac_key).update(noncePlain); var nonceHash = h.digest('base64url'); - var authZArgs = "?response_type=code&scope=" + r.variables.oidc_scopes + "&client_id=" + r.variables.oidc_client + "&redirect_uri="+ r.variables.redirect_base + r.variables.redir_location + "&nonce=" + nonceHash; + + var authZArgs = "?response_type=code&scope=" + r.variables.oidc_scopes + + "&client_id=" + r.variables.oidc_client + + "&redirect_uri=" + r.variables.redirect_base + r.variables.redir_location + + "&nonce=" + nonceHash; if (r.variables.oidc_authz_extra_args) { authZArgs += "&" + r.variables.oidc_authz_extra_args; } var encodedRequestUri = encodeURIComponent(r.variables.request_uri); - r.headersOut['Set-Cookie'] = [ "auth_redir=" + encodedRequestUri + "; " + r.variables.oidc_cookie_flags, "auth_nonce=" + noncePlain + "; " + r.variables.oidc_cookie_flags ]; - if ( r.variables.oidc_pkce_enable == 1 ) { - var pkce_code_verifier = c.createHmac('sha256', r.variables.oidc_hmac_key).update(String(Math.random())).digest('hex'); - r.variables.pkce_id = c.createHash('sha256').update(String(Math.random())).digest('base64url'); - var pkce_code_challenge = c.createHash('sha256').update(pkce_code_verifier).digest('base64url'); + if (r.variables.oidc_pkce_enable == 1) { + var pkce_code_verifier = c.createHmac('sha256', r.variables.oidc_hmac_key) + .update(String(Math.random())).digest('hex'); + r.variables.pkce_id = c.createHash('sha256') + .update(String(Math.random())).digest('base64url'); + var pkce_code_challenge = c.createHash('sha256') + .update(pkce_code_verifier).digest('base64url'); r.variables.pkce_code_verifier = pkce_code_verifier; - authZArgs += "&code_challenge_method=S256&code_challenge=" + pkce_code_challenge + "&state=" + r.variables.pkce_id; + authZArgs += "&code_challenge_method=S256&code_challenge=" + + pkce_code_challenge + "&state=" + r.variables.pkce_id; } else { authZArgs += "&state=0"; } + return authZArgs; } +// Generate the token request parameters function generateTokenRequestParams(r, grant_type) { var body = "grant_type=" + grant_type + "&client_id=" + r.variables.oidc_client; switch(grant_type) { case "authorization_code": - body += "&code=" + r.variables.arg_code + "&redirect_uri=" + r.variables.redirect_base + r.variables.redir_location; + body += "&code=" + r.variables.arg_code + + "&redirect_uri=" + r.variables.redirect_base + r.variables.redir_location; if (r.variables.oidc_pkce_enable == 1) { r.variables.pkce_id = r.variables.arg_state; body += "&code_verifier=" + r.variables.pkce_code_verifier; @@ -361,7 +396,8 @@ function generateTokenRequestParams(r, grant_type) { if (r.variables.oidc_pkce_enable != 1) { if (r.variables.oidc_client_auth_method === "client_secret_basic") { - let auth_basic = "Basic " + Buffer.from(r.variables.oidc_client + ":" + r.variables.oidc_client_secret).toString('base64'); + let auth_basic = "Basic " + Buffer.from(r.variables.oidc_client + ":" + + r.variables.oidc_client_secret).toString('base64'); options.args = "secret_basic=" + auth_basic; } else { options.body += "&client_secret=" + r.variables.oidc_client_secret; @@ -370,3 +406,56 @@ function generateTokenRequestParams(r, grant_type) { return options; } + +function handleTokenError(r, reply) { + try { + const errorset = JSON.parse(reply.responseText); + if (errorset.error) { + r.error("OIDC error from IdP during token exchange: " + + errorset.error + ", " + errorset.error_description); + } else { + r.error("OIDC unexpected response from IdP (HTTP " + + reply.status + "). " + reply.responseText); + } + } catch (e) { + r.error("OIDC unexpected response from IdP (HTTP " + reply.status + "). " + + reply.responseText); + } +} + + +function handleRefreshError(r, reply) { + let errorLog = "OIDC refresh failure"; + if (reply.status === 504) { + errorLog += ", timeout waiting for IdP"; + } else if (reply.status === 400) { + try { + const errorset = JSON.parse(reply.responseText); + errorLog += ": " + errorset.error + " " + errorset.error_description; + } catch (e) { + errorLog += ": " + reply.responseText; + } + } else { + errorLog += " " + reply.status; + } + r.error(errorLog); + r.variables.refresh_token = "-"; + r.return(302, r.variables.request_uri); +} + +/* If the ID token has not been synced yet, poll the variable every 100ms until + get a value or after a timeout. */ +function waitForSessionSync(r, timeLeft) { + if (r.variables.session_jwt) { + retryOriginalRequest(r); + } else if (timeLeft > 0) { + setTimeout(waitForSessionSync, 100, r, timeLeft - 100); + } else { + auth(r, true); + } +} + +function retryOriginalRequest(r) { + delete r.headersOut["WWW-Authenticate"]; + r.internalRedirect(r.variables.uri + r.variables.is_args + (r.variables.args || '')); +} diff --git a/openid_connect.server_conf b/openid_connect.server_conf index c6e4630..f75b965 100644 --- a/openid_connect.server_conf +++ b/openid_connect.server_conf @@ -45,7 +45,7 @@ proxy_set_header Content-Type "application/x-www-form-urlencoded"; proxy_set_header Authorization $arg_secret_basic; proxy_pass $oidc_token_endpoint; - } + } location = /_refresh { # This location is called by oidcAuth() when performing a token refresh. We @@ -62,13 +62,12 @@ 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 + location = /_token_validation { + # Internal location to verify any JWT (e.g., id_token, logout_token) + # using the auth_jwt module. Extracts the claims and returns them as JSON. internal; auth_jwt "" token=$arg_token; - js_content oidc.validateIdToken; + js_content oidc.extractTokenClaims; error_page 500 502 504 @oidc_error; } From 1f5053b387b288506985ea5c09427142d49b8686 Mon Sep 17 00:00:00 2001 From: Ivan Ovchinnikov Date: Thu, 19 Dec 2024 04:16:58 +0000 Subject: [PATCH 12/15] Implement Front-Channel Logout endpoint Implement OpenID Connect Front-Channel Logout 1.0 specification: - Add default /front_channel_logout location that handles logout requests - Both sid and iss parameters must be present - Issuer verification against iss claim in ID token Reference: https://openid.net/specs/openid-connect-frontchannel-1_0.html --- README.md | 6 ++ openid_connect.js | 100 +++++++++++++++++++++++++++--- openid_connect.server_conf | 8 +++ openid_connect_configuration.conf | 2 + 4 files changed, 107 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 539b0ab..d90970a 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,10 @@ Requests made to the `/logout` location invalidate both the ID token, access tok RP-initiated logout is supported according to [OpenID Connect RP-Initiated Logout 1.0](https://openid.net/specs/openid-connect-rpinitiated-1_0.html). This behavior is controlled by the `$oidc_end_session_endpoint` variable. +#### Front-Channel OIDC Logout + +Front-Channel Logout is supported according to [OpenID Connect Front-Channel Logout 1.0](https://openid.net/specs/openid-connect-frontchannel-1_0.html). The `/front_channel_logout endpoint` location handles logout requests from the IdP. Both arguments, `sid` (session identifier) and `iss` (issuer identifier), must be present. + ### Multiple IdPs Where NGINX Plus is configured to proxy requests for multiple websites or applications, or user groups, these may require authentication by different IdPs. Separate IdPs can be configured, with each one matching on an attribute of the HTTP request, e.g. hostname or part of the URI path. @@ -198,6 +202,7 @@ The key-value store is used to maintain persistent storage for ID tokens and ref keyval_zone zone=oidc_id_tokens:1M state=/var/lib/nginx/state/oidc_id_tokens.json timeout=1h; keyval_zone zone=oidc_access_tokens:1M state=/var/lib/nginx/state/oidc_access_tokens.json timeout=1h; keyval_zone zone=refresh_tokens:1M state=/var/lib/nginx/state/refresh_tokens.json timeout=8h; +keyval_zone zone=oidc_sids:1M state=/var/lib/nginx/state/oidc_sids.json timeout=8h; keyval_zone zone=oidc_pkce:128K timeout=90s; ``` @@ -314,3 +319,4 @@ This reference implementation for OpenID Connect is supported for NGINX Plus sub * **R23** PKCE support. Added support for deployments behind another proxy or load balancer. * **R28** Access token support. Added support for access token to authorize NGINX to access protected backend. * **R32** Added support for `client_secret_basic` client authentication method. + * **R33** Refactor code to use async/await. Implement Front-Channel Logout endpoint. diff --git a/openid_connect.js b/openid_connect.js index d79ca9b..e39f1a6 100644 --- a/openid_connect.js +++ b/openid_connect.js @@ -8,7 +8,8 @@ export default { auth, codeExchange, extractTokenClaims, - logout + logout, + handleFrontChannelLogout }; // The main authentication flow, called before serving a protected resource. @@ -42,7 +43,7 @@ async function auth(r, afterSyncCheck) { // Determine session ID and store session data const sessionId = getSessionId(r, false); - storeSessionData(r, tokenset, false); + storeSessionData(r, sessionId, claims, tokenset, true); r.log("OIDC success, refreshing session " + sessionId); @@ -79,7 +80,7 @@ async function codeExchange(r) { // Determine session ID and store session data for a new session const sessionId = getSessionId(r, true); - storeSessionData(r, tokenset, true); + storeSessionData(r, sessionId, claims, tokenset, true); r.log("OIDC success, creating session " + sessionId); @@ -173,7 +174,12 @@ function validateIdTokenClaims(r, claims) { } // Store session data in the key-val store -function storeSessionData(r, tokenset, isNewSession) { +function storeSessionData(r, sessionId, claims, tokenset, isNewSession) { + if (claims.sid) { + r.variables.idp_sid = claims.sid; + r.variables.client_sid = sessionId; + } + if (isNewSession) { r.variables.new_session = tokenset.id_token; r.variables.new_access_token = tokenset.access_token || ""; @@ -190,7 +196,7 @@ function storeSessionData(r, tokenset, isNewSession) { // Extracts claims from the validated ID Token (used by /_token_validation) function extractTokenClaims(r) { const claims = {}; - const claimNames = ["sub", "iss", "iat", "nonce"]; + const claimNames = ["sub", "iss", "iat", "nonce", "sid"]; claimNames.forEach((name) => { const value = r.variables["jwt_claim_" + name]; @@ -277,7 +283,7 @@ async function refreshTokens(r) { // Logout handler function logout(r) { - r.log("RP-Initiated Logout for " + (r.variables.cookie_auth_token || "unknown")); + r.log("OIDC RP-Initiated Logout for " + (r.variables.cookie_auth_token || "unknown")); function getLogoutRedirectUrl(base, redirect) { return redirect.match(/^(http|https):\/\//) ? redirect : base + redirect; @@ -286,7 +292,16 @@ function logout(r) { var logoutRedirectUrl = getLogoutRedirectUrl(r.variables.redirect_base, r.variables.oidc_logout_redirect); - function performLogout(redirectUrl) { + async function performLogout(redirectUrl, idToken) { + // Clean up $idp_sid -> $client_sid mapping + if (idToken && idToken !== '-') { + const claims = await getTokenClaims(r, idToken); + if (claims.sid) { + r.variables.idp_sid = claims.sid; + r.variables.client_sid = '-'; + } + } + r.variables.session_jwt = '-'; r.variables.access_token = '-'; r.variables.refresh_token = '-'; @@ -305,12 +320,79 @@ function logout(r) { var logoutArgs = "?post_logout_redirect_uri=" + encodeURIComponent(logoutRedirectUrl) + "&id_token_hint=" + encodeURIComponent(r.variables.session_jwt); - performLogout(r.variables.oidc_end_session_endpoint + logoutArgs); + performLogout(r.variables.oidc_end_session_endpoint + logoutArgs, r.variables.session_jwt); } else { - performLogout(logoutRedirectUrl); + performLogout(logoutRedirectUrl, r.variables.session_jwt); } } +/** + * Handles Front-Channel Logout as per OpenID Connect Front-Channel Logout 1.0 spec. + * @see https://openid.net/specs/openid-connect-frontchannel-1_0.html + */ +async function handleFrontChannelLogout(r) { + const sid = r.args.sid; + const requestIss = r.args.iss; + + // Validate input parameters + if (!sid) { + r.error("Missing sid parameter in front-channel logout request"); + r.return(400, "Missing sid"); + return; + } + + if (!requestIss) { + r.error("Missing iss parameter in front-channel logout request"); + r.return(400, "Missing iss"); + return; + } + + r.log("OIDC Front-Channel Logout initiated for sid: " + sid); + + // Define idp_sid as a key to get the client_sid from the key-value store + r.variables.idp_sid = sid; + + const clientSid = r.variables.client_sid; + if (!clientSid || clientSid === '-') { + r.log("No client session found for sid: " + sid); + r.return(200, "Logout successful"); + return; + } + + /* TODO: Since we cannot use the cookie_auth_token var as a key (it does not exist if cookies + are absent), we use the request_id as a workaround. */ + r.variables.request_id = clientSid; + var sessionJwt = r.variables.new_session; + + if (!sessionJwt || sessionJwt === '-') { + r.log("No associated ID token found for client session: " + clientSid); + cleanSessionData(r); + r.return(200, "Logout successful"); + return; + } + + const claims = await getTokenClaims(r, sessionJwt); + if (claims.iss !== requestIss) { + r.error("Issuer mismatch during logout. Received iss: " + + requestIss + ", expected: " + claims.iss); + r.return(400, "Issuer mismatch"); + return; + } + + // idp_sid needs to be updated after subrequest + r.variables.idp_sid = sid; + cleanSessionData(r); + + r.return(200, "Logout successful"); +} + +function cleanSessionData(r) { + r.variables.new_session = '-'; + r.variables.new_access_token = '-'; + r.variables.new_refresh = '-'; + r.variables.client_sid = '-'; +} + // Initiate a new authentication flow by redirecting to the IdP's authorization endpoint function initiateNewAuth(r) { const oidcConfigurables = ["authz_endpoint", "scopes", "hmac_key", "cookie_flags"]; diff --git a/openid_connect.server_conf b/openid_connect.server_conf index f75b965..bd748aa 100644 --- a/openid_connect.server_conf +++ b/openid_connect.server_conf @@ -1,6 +1,7 @@ # Advanced configuration START set $internal_error_message "NGINX / OpenID Connect login failure\n"; set $pkce_id ""; + set $idp_sid ""; resolver 8.8.8.8; # For DNS lookup of IdP endpoints; subrequest_output_buffer_size 32k; # To fit a complete tokenset response gunzip on; # Decompress IdP responses if necessary @@ -79,6 +80,13 @@ js_content oidc.logout; } + location = /front_channel_logout { + status_zone "OIDC logout"; + add_header Cache-Control "no-store"; + default_type text/plain; + js_content oidc.handleFrontChannelLogout; + } + location = /_logout { # This location is the default value of $oidc_logout_redirect (in case it wasn't configured) default_type text/plain; diff --git a/openid_connect_configuration.conf b/openid_connect_configuration.conf index 8c33067..7477933 100644 --- a/openid_connect_configuration.conf +++ b/openid_connect_configuration.conf @@ -104,6 +104,7 @@ proxy_cache_path /var/cache/nginx/jwk levels=1 keys_zone=jwk:64k max_size=1m; keyval_zone zone=oidc_id_tokens:1M state=/var/lib/nginx/state/oidc_id_tokens.json timeout=1h; keyval_zone zone=oidc_access_tokens:1M state=/var/lib/nginx/state/oidc_access_tokens.json timeout=1h; keyval_zone zone=refresh_tokens:1M state=/var/lib/nginx/state/refresh_tokens.json timeout=8h; +keyval_zone zone=oidc_sids:1M state=/var/lib/nginx/state/oidc_sids.json timeout=8h; keyval_zone zone=oidc_pkce:128K timeout=90s; # Temporary storage for PKCE code verifier. keyval $cookie_auth_token $session_jwt zone=oidc_id_tokens; # Exchange cookie for JWT @@ -113,6 +114,7 @@ keyval $request_id $new_session zone=oidc_id_tokens; # For initial 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; +keyval $idp_sid $client_sid zone=oidc_sids; auth_jwt_claim_set $jwt_audience aud; # In case aud is an array js_import oidc from conf.d/openid_connect.js; From 6066b8c5d5f19a241abf8c9f971b780510634ca7 Mon Sep 17 00:00:00 2001 From: Ivan Ovchinnikov Date: Thu, 14 Aug 2025 21:32:20 +0000 Subject: [PATCH 13/15] Add F5 CLA workflow. --- .github/workflows/f5_cla.yml | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/f5_cla.yml diff --git a/.github/workflows/f5_cla.yml b/.github/workflows/f5_cla.yml new file mode 100644 index 0000000..43e473e --- /dev/null +++ b/.github/workflows/f5_cla.yml @@ -0,0 +1,41 @@ +--- +name: F5 CLA +on: + issue_comment: + types: [created] + pull_request_target: + types: [opened, closed, synchronize] +permissions: read-all +jobs: + f5-cla: + name: F5 CLA + runs-on: ubuntu-24.04 + permissions: + actions: write + pull-requests: write + statuses: write + steps: + - name: Run F5 Contributor License Agreement (CLA) assistant + if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have hereby read the F5 CLA and agree to its terms') || github.event_name == 'pull_request_target' + uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1 + with: + # Path to the CLA document. + path-to-document: https://github.com/f5/f5-cla/blob/main/docs/f5_cla.md + # Custom CLA messages. + custom-notsigned-prcomment: '🎉 Thank you for your contribution! It appears you have not yet signed the [F5 Contributor License Agreement (CLA)](https://github.com/f5/f5-cla/blob/main/docs/f5_cla.md), which is required for your changes to be incorporated into an F5 Open Source Software (OSS) project. Please kindly read the [F5 CLA](https://github.com/f5/f5-cla/blob/main/docs/f5_cla.md) and reply on a new comment with the following text to agree:' + custom-pr-sign-comment: 'I have hereby read the F5 CLA and agree to its terms' + custom-allsigned-prcomment: '✅ All required contributors have signed the F5 CLA for this PR. Thank you!' + # Remote repository storing CLA signatures. + remote-organization-name: f5 + remote-repository-name: f5-cla-data + # Branch where CLA signatures are stored. + branch: main + path-to-signatures: signatures/signatures.json + # Comma separated list of usernames for maintainers or any other individuals who should not be prompted for a CLA. + # NOTE: You will want to edit the usernames to suit your project needs. + allowlist: bot* + # Do not lock PRs after a merge. + lock-pullrequest-aftermerge: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PERSONAL_ACCESS_TOKEN: ${{ secrets.F5_CLA_TOKEN }} From 24d53f992c81cad1646d9b2f60dd4ff397545a40 Mon Sep 17 00:00:00 2001 From: Ivan Ovchinnikov Date: Tue, 19 Aug 2025 07:41:35 +0000 Subject: [PATCH 14/15] Error logging improvement. - Implemented unified function for error handling. - Each error is assigned its own identifier, generated by using the first 8 chars of the $request_id variable. - Added support for JSON log output. This is controlled by the $oidc_log_format variable, which must be set to 'json'. - Added support for stack trace output. This is enabled by the $oidc_debug variable, which must have any non-empty value. If this variable is defined, the $internal_error_message variable is overwritten with the text of the last error and returned to the User Agent - so use this only for debugging! --- openid_connect.js | 259 +++++++++++++++++++++++-------------- openid_connect.server_conf | 1 + 2 files changed, 166 insertions(+), 94 deletions(-) diff --git a/openid_connect.js b/openid_connect.js index e39f1a6..49d5731 100644 --- a/openid_connect.js +++ b/openid_connect.js @@ -1,7 +1,7 @@ /* * JavaScript functions for providing OpenID Connect with NGINX Plus * - * Copyright (C) 2024 Nginx, Inc. + * Copyright (C) 2025 Nginx, Inc. */ export default { @@ -33,11 +33,14 @@ async function auth(r, afterSyncCheck) { } // Validate refreshed ID token - const claims = await validateIdToken(r, tokenset.id_token); - if (!claims) { + let claims; + try { + claims = await validateIdToken(r, tokenset.id_token); + } catch (e) { // If validation failed, reset and reinitiate auth r.variables.refresh_token = "-"; - r.return(302, r.variables.request_uri); + r.headersOut["Location"] = r.variables.request_uri; + oidcError(r, 302, getRefId(r, "auth.validate"), e); return; } @@ -51,17 +54,20 @@ async function auth(r, afterSyncCheck) { retryOriginalRequest(r); } -// The code exchange handler, called after IdP redirects back with a authorization code. +// The code exchange handler, called after IdP redirects back with an authorization code. async function codeExchange(r) { // Check authorization code presence - if (!r.variables.arg_code || r.variables.arg_code.length == 0) { + if (!r.variables.arg_code || r.variables.arg_code.length === 0) { + const ref = getRefId(r, "codeExchange.code"); if (r.variables.arg_error) { - r.error("OIDC error receiving authorization code: " + - r.variables.arg_error_description); + oidcError(r, 502, ref, + new Error(`OIDC error receiving authorization code: ` + + `${r.variables.arg_error_description || r.variables.arg_error}`)); } else { - r.error("OIDC expected authorization code but received: " + r.uri); + oidcError(r, 502, ref, + new Error(`OIDC expected authorization code but received: ` + + `${r.variables.request_uri}`)); } - r.return(502); return; } @@ -72,9 +78,11 @@ async function codeExchange(r) { } // Validate ID token - const claims = await validateIdToken(r, tokenset.id_token); - if (!claims) { - r.return(500); + let claims; + try { + claims = await validateIdToken(r, tokenset.id_token); + } catch (e) { + oidcError(r, 500, getRefId(r, "codeExchange.validate"), e); return; } @@ -91,20 +99,18 @@ async function codeExchange(r) { // Extracts claims from token by calling the internal endpoint. function getTokenClaims(r, token) { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { r.subrequest('/_token_validation', 'token=' + token, function(reply) { if (reply.status !== 200) { - r.error("Failed to retrieve claims: HTTP " + reply.status); - resolve(null); + reject(new Error(`Failed to retrieve claims: HTTP ${reply.status}`)); return; } try { const claims = JSON.parse(reply.responseText); resolve(claims); } catch (e) { - r.error("Failed to parse claims: " + e); - resolve(null); + reject(new Error(`Failed to parse claims: ${e}`)); } } ); @@ -114,14 +120,7 @@ function getTokenClaims(r, token) { // Extracts and validates claims from the ID Token. async function validateIdToken(r, idToken) { const claims = await getTokenClaims(r, idToken); - if (!claims) { - return null; - } - - if (!validateIdTokenClaims(r, claims)) { - return null; - } - + validateIdTokenClaims(r, claims); return claims; } @@ -131,23 +130,24 @@ function validateIdTokenClaims(r, claims) { const missingClaims = requiredClaims.filter((claim) => !claims[claim]); if (missingClaims.length > 0) { - r.error(`OIDC ID Token validation error: missing claim(s) ${missingClaims.join(' ')}`); - return false; + throw new Error( + `OIDC ID Token validation error: missing claim(s) ${missingClaims.join(' ')}` + ); } // Check 'iat' validity const iat = Math.floor(Number(claims.iat)); if (String(iat) !== claims.iat || iat < 1) { - r.error("OIDC ID Token validation error: iat claim is not a valid number"); - return false; + throw new Error("OIDC ID Token validation error: iat claim is not a valid number"); } // Audience must include the configured client const aud = Array.isArray(claims.aud) ? claims.aud : claims.aud.split(','); if (!aud.includes(r.variables.oidc_client)) { - r.error(`OIDC ID Token validation error: aud claim (${claims.aud}) ` + - `does not include $oidc_client (${r.variables.oidc_client})`); - return false; + throw new Error( + `OIDC ID Token validation error: aud claim (${claims.aud}) ` + + `does not include $oidc_client (${r.variables.oidc_client})` + ); } // Nonce validation for initial authentication @@ -160,17 +160,16 @@ function validateIdTokenClaims(r, claims) { : ''; if (claims.nonce !== clientNonceHash) { - r.error(`OIDC ID Token validation error: nonce from token (${claims.nonce}) ` + - `does not match client (${clientNonceHash})`); - return false; + throw new Error( + `OIDC ID Token validation error: nonce from token (${claims.nonce}) ` + + `does not match client (${clientNonceHash})` + ); } } else if (isNewSession(r)) { - r.error("OIDC ID Token validation error: " + - "missing nonce claim during initial authentication."); - return false; + throw new Error( + "OIDC ID Token validation error: missing nonce claim during initial authentication." + ); } - - return true; } // Store session data in the key-val store @@ -222,41 +221,70 @@ function isNewSession(r) { // Exchange authorization code for tokens using the internal /_token endpoint async function exchangeCodeForTokens(r) { + let params; + try { + params = generateTokenRequestParams(r, "authorization_code"); + } catch (e) { + oidcError(r, 500, getRefId(r, "token.params"), e); + return null; + } const reply = await new Promise((resolve) => { - r.subrequest("/_token", generateTokenRequestParams(r, "authorization_code"), resolve); + r.subrequest("/_token", params, resolve); }); + const ref = getRefId(r, "token.exchange"); + if (reply.status === 504) { - r.error("OIDC timeout connecting to IdP during code exchange"); - r.return(504); + oidcError(r, 504, ref, new Error("OIDC timeout connecting to IdP during code exchange")); return null; } if (reply.status !== 200) { - handleTokenError(r, reply); - r.return(502); + let message; + try { + const errorset = JSON.parse(reply.responseText); + if (errorset.error) { + message = `OIDC error from IdP during token exchange: ${errorset.error}, ` + + `${errorset.error_description || ""}`; + } else { + message = `OIDC unexpected response from IdP (HTTP ${reply.status}). ` + + `${reply.responseText}`; + } + } catch (_e) { + message = `OIDC unexpected response from IdP (HTTP ${reply.status}). ` + + `${reply.responseText}`; + } + oidcError(r, 502, ref, new Error(message)); return null; } try { const tokenset = JSON.parse(reply.responseText); if (tokenset.error) { - r.error("OIDC " + tokenset.error + " " + tokenset.error_description); - r.return(500); + oidcError(r, 500, ref, + new Error(`OIDC token response error: ${tokenset.error}` + + ` ${tokenset.error_description}`) + ); return null; } return tokenset; - } catch (e) { - r.error("OIDC token response not JSON: " + reply.responseText); - r.return(502); + } catch (_e) { + oidcError(r, 502, ref, new Error(`OIDC token response not JSON: ${reply.responseText}`)); return null; } } // Refresh tokens using the internal /_refresh endpoint async function refreshTokens(r) { + let params; + try { + params = generateTokenRequestParams(r, "refresh_token"); + } catch (e) { + oidcError(r, 500, getRefId(r, "refresh.params"), e); + return null; + } const reply = await new Promise((resolve) => { - r.subrequest("/_refresh", generateTokenRequestParams(r, "refresh_token"), resolve); + r.subrequest("/_refresh", params, resolve); }); if (reply.status !== 200) { @@ -267,16 +295,15 @@ async function refreshTokens(r) { try { const tokenset = JSON.parse(reply.responseText); if (!tokenset.id_token) { - r.error("OIDC refresh response did not include id_token"); - if (tokenset.error) { - r.error("OIDC " + tokenset.error + " " + tokenset.error_description); - } + r.log("OIDC refresh response did not include id_token" + + (tokenset.error ? ("; " + tokenset.error + " " + tokenset.error_description) : "")); return null; } return tokenset; - } catch (e) { + } catch (_e) { r.variables.refresh_token = "-"; - r.return(302, r.variables.request_uri); + r.headersOut["Location"] = r.variables.request_uri; + oidcError(r, 302, getRefId(r, "refresh.parse"), new Error("OIDC refresh response not JSON")); return null; } } @@ -295,10 +322,13 @@ function logout(r) { async function performLogout(redirectUrl, idToken) { // Clean up $idp_sid -> $client_sid mapping if (idToken && idToken !== '-') { - const claims = await getTokenClaims(r, idToken); - if (claims.sid) { - r.variables.idp_sid = claims.sid; - r.variables.client_sid = '-'; + try { + const claims = await getTokenClaims(r, idToken); + if (claims.sid) { + r.variables.idp_sid = claims.sid; + r.variables.client_sid = '-'; + } + } catch (_e) { } } @@ -336,14 +366,14 @@ async function handleFrontChannelLogout(r) { // Validate input parameters if (!sid) { - r.error("Missing sid parameter in front-channel logout request"); - r.return(400, "Missing sid"); + oidcError(r, 400, getRefId(r, "frontchannel.missingSid"), + new Error("Missing sid parameter in front-channel logout request")); return; } if (!requestIss) { - r.error("Missing iss parameter in front-channel logout request"); - r.return(400, "Missing iss"); + oidcError(r, 400, getRefId(r, "frontchannel.missingIss"), + new Error("Missing iss parameter in front-channel logout request")); return; } @@ -371,11 +401,18 @@ async function handleFrontChannelLogout(r) { return; } - const claims = await getTokenClaims(r, sessionJwt); + let claims; + try { + claims = await getTokenClaims(r, sessionJwt); + } catch (e) { + oidcError(r, 400, getRefId(r, "frontchannel.claims"), e); + return; + } + if (claims.iss !== requestIss) { - r.error("Issuer mismatch during logout. Received iss: " + - requestIss + ", expected: " + claims.iss); - r.return(400, "Issuer mismatch"); + oidcError(r, 400, getRefId(r, "frontchannel.issMismatch"), + new Error(`Issuer mismatch during logout. ` + + `Received iss: ${requestIss}, expected: ${claims.iss}`)); return; } @@ -401,8 +438,10 @@ function initiateNewAuth(r) { ); if (missingConfig.length) { - r.error("OIDC missing configuration variables: $oidc_" + missingConfig.join(" $oidc_")); - r.return(500, r.variables.internal_error_message); + oidcError(r, 500, getRefId(r, "init.missingConfig"), + new Error(`OIDC missing configuration variables: $oidc_` + + `${missingConfig.join(" $oidc_")}`) + ); return; } @@ -467,8 +506,7 @@ function generateTokenRequestParams(r, grant_type) { body += "&refresh_token=" + r.variables.refresh_token; break; default: - r.error("Unsupported grant type: " + grant_type); - return; + throw new Error("Unsupported grant type: " + grant_type); } var options = { @@ -489,40 +527,27 @@ function generateTokenRequestParams(r, grant_type) { return options; } -function handleTokenError(r, reply) { - try { - const errorset = JSON.parse(reply.responseText); - if (errorset.error) { - r.error("OIDC error from IdP during token exchange: " + - errorset.error + ", " + errorset.error_description); - } else { - r.error("OIDC unexpected response from IdP (HTTP " + - reply.status + "). " + reply.responseText); - } - } catch (e) { - r.error("OIDC unexpected response from IdP (HTTP " + reply.status + "). " + - reply.responseText); - } -} - - +// Handle refresh error: log + reset refresh + redirect 302 to original request function handleRefreshError(r, reply) { + const ref = getRefId(r, "refresh.error"); let errorLog = "OIDC refresh failure"; + if (reply.status === 504) { errorLog += ", timeout waiting for IdP"; } else if (reply.status === 400) { try { const errorset = JSON.parse(reply.responseText); errorLog += ": " + errorset.error + " " + errorset.error_description; - } catch (e) { + } catch (_e) { errorLog += ": " + reply.responseText; } } else { errorLog += " " + reply.status; } - r.error(errorLog); + r.variables.refresh_token = "-"; - r.return(302, r.variables.request_uri); + r.headersOut["Location"] = r.variables.request_uri; + oidcError(r, 302, ref, new Error(errorLog)); } /* If the ID token has not been synced yet, poll the variable every 100ms until @@ -541,3 +566,49 @@ function retryOriginalRequest(r) { delete r.headersOut["WWW-Authenticate"]; r.internalRedirect(r.variables.uri + r.variables.is_args + (r.variables.args || '')); } + +function oidcError(r, http_code, refId, e) { + const hasDebug = !!r.variables.oidc_debug; + const msg = (e && e.message) ? String(e.message) : (e ? String(e) : "Unexpected Error"); + const stack = (hasDebug && e && e.stack) ? String(e.stack) : ""; + + const clientIp = r.remoteAddress || "-"; + const host = r.headersIn.host || r.variables.host || "-"; + const requestLine = `${r.method} ${r.uri} HTTP/${r.httpVersion}`; + + if (r.variables.oidc_log_format === "json") { + const errorObj = { + refId: refId, + message: msg, + clientIp: clientIp, + host: host, + method: r.method, + uri: r.uri, + httpVersion: r.httpVersion + }; + if (stack) { + errorObj.stack = stack; + } + r.error(JSON.stringify(errorObj)); + } else { + let logEntry = `OIDC Error: ReferenceID: ${refId} ${msg}; ` + + `client: ${clientIp}, host: ${host}, request: "${requestLine}"`; + if (stack) { + logEntry += `\n${stack}`; + } + r.error(logEntry); + } + + if (hasDebug) { + r.variables.internal_error_message = stack + ? `ReferenceID: ${refId} ${msg}\n${stack}` + : `ReferenceID: ${refId} ${msg}`; + } + + r.return(http_code); +} + +function getRefId(r, context) { + const base = (r.variables.request_id).substring(0, 8); + return context ? `${base}:${context}` : base; +} diff --git a/openid_connect.server_conf b/openid_connect.server_conf index bd748aa..311dcd0 100644 --- a/openid_connect.server_conf +++ b/openid_connect.server_conf @@ -2,6 +2,7 @@ set $internal_error_message "NGINX / OpenID Connect login failure\n"; set $pkce_id ""; set $idp_sid ""; + #set $oidc_debug ""; resolver 8.8.8.8; # For DNS lookup of IdP endpoints; subrequest_output_buffer_size 32k; # To fit a complete tokenset response gunzip on; # Decompress IdP responses if necessary From c866e23e730495d0ceb8419052e130650065ac88 Mon Sep 17 00:00:00 2001 From: Ivan Ovchinnikov Date: Tue, 11 Nov 2025 18:59:15 +0000 Subject: [PATCH 15/15] Enable TLS certificate verification for OIDC IdP upstream connections. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates `openid_connect.server_conf` to enforce secure TLS settings on all IdP-bound requests (`/_token`, `/_refresh`, `/_jwks_uri`). This adds: - `proxy_ssl_verify on` to enforce verification of the OP’s TLS certificate. - `proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt` to use the system (Debian/Ubuntu/Alpine) CA bundle for trust. - `proxy_ssl_verify_depth 2` to allow certificate chains up to one intermediate CA. --- README.md | 3 +++ openid_connect.server_conf | 19 ++++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d90970a..fd800f9 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,8 @@ Manual configuration involves reviewing the following files so that they match y * No changes are usually required here * Modify the `resolver` directive to match a DNS server that is capable of resolving the IdP defined in `$oidc_token_endpoint` and `$oidc_end_session_endpoint` * If using [`auth_jwt_key_request`](http://nginx.org/en/docs/http/ngx_http_auth_jwt_module.html#auth_jwt_key_request) to automatically fetch the JWK file from the IdP then modify the validity period and other caching options to suit your IdP + * TLS certificate verification for all IdP-bound requests (token, refresh, JWKS) is enabled by default. NGINX Plus uses the system CA bundle at `/etc/ssl/certs/ca-certificates.crt` (via [`proxy_ssl_trusted_certificate`](http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ssl_trusted_certificate)) to validate the IdP’s TLS certificate. If the IdP’s certificate is signed by a private or custom CA, append that CA to this bundle or update the `proxy_ssl_trusted_certificate` path accordingly. + * The [`proxy_ssl_verify_depth`](http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ssl_verify_depth) directive is set to **2** by default, allowing one intermediate CA in the chain. This is sufficient for most public IdPs. * **openid_connect.js** - this is the JavaScript code for performing the authorization code exchange and nonce hashing * No changes are required unless modifying the code exchange or validation process @@ -320,3 +322,4 @@ This reference implementation for OpenID Connect is supported for NGINX Plus sub * **R28** Access token support. Added support for access token to authorize NGINX to access protected backend. * **R32** Added support for `client_secret_basic` client authentication method. * **R33** Refactor code to use async/await. Implement Front-Channel Logout endpoint. + * **R36** Enable TLS certificate verification for all IdP-bound requests by default. diff --git a/openid_connect.server_conf b/openid_connect.server_conf index 311dcd0..bda5911 100644 --- a/openid_connect.server_conf +++ b/openid_connect.server_conf @@ -13,7 +13,12 @@ proxy_cache jwk; # Cache the JWK Set received 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_ssl_verify on; # Enforce TLS certificate verification + proxy_ssl_verify_depth 2; # Allow intermediate CA chains of depth 2 + proxy_ssl_server_name on; # Send SNI to IdP host + proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; # Use system CA bundle + proxy_method GET; # In case client request was non-GET proxy_set_header Content-Length ""; # '' proxy_pass $oidc_jwt_keyfile; # Expecting to find a URI here @@ -43,7 +48,11 @@ # Exclude client headers to avoid CORS errors with certain IdPs (e.g., Microsoft Entra ID) proxy_pass_request_headers off; - proxy_ssl_server_name on; # For SNI to the IdP + proxy_ssl_verify on; # Enforce TLS certificate verification + proxy_ssl_verify_depth 2; # Allow intermediate CA chains of depth 2 + proxy_ssl_server_name on; # Send SNI to IdP host + proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; # Use system CA bundle + proxy_set_header Content-Type "application/x-www-form-urlencoded"; proxy_set_header Authorization $arg_secret_basic; proxy_pass $oidc_token_endpoint; @@ -58,7 +67,11 @@ # Exclude client headers to avoid CORS errors with certain IdPs (e.g., Microsoft Entra ID) proxy_pass_request_headers off; - proxy_ssl_server_name on; # For SNI to the IdP + proxy_ssl_verify on; # Enforce TLS certificate verification + proxy_ssl_verify_depth 2; # Allow intermediate CA chains of depth 2 + proxy_ssl_server_name on; # Send SNI to IdP host + proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; # Use system CA bundle + proxy_set_header Content-Type "application/x-www-form-urlencoded"; proxy_set_header Authorization $arg_secret_basic; proxy_pass $oidc_token_endpoint;