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 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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;