diff --git a/internal/configs/oidc/oidc.conf b/internal/configs/oidc/oidc.conf index 9d63e7d201..a04ff52927 100644 --- a/internal/configs/oidc/oidc.conf +++ b/internal/configs/oidc/oidc.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 @@ -37,33 +38,48 @@ # to construct the OpenID Connect token request, as per: # http://openid.net/specs/openid-connect-core-1_0.html#TokenRequest internal; + + # 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_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 # 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; + + # 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_set_header Content-Type "application/x-www-form-urlencoded"; proxy_set_header Authorization $arg_secret_basic; proxy_pass $oidc_token_endpoint; } - location = /_id_token_validation { + location = /_token_validation { # This location is called by oidcCodeExchange() and oidcRefreshRequest(). We use # the auth_jwt_module to validate the OpenID Connect token response, as per: # https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation internal; auth_jwt "" token=$arg_token; - js_content oidc.validateIdToken; + js_content oidc.extractTokenClaims; error_page 500 502 504 @oidc_error; } + location = /front_channel_logout { + status_zone "OIDC logout"; + add_header Cache-Control "no-store"; + default_type text/plain; + js_content oidc.handleFrontChannelLogout; + } + location = /logout { status_zone "OIDC logout"; add_header Set-Cookie "auth_token=; $oidc_cookie_flags"; diff --git a/internal/configs/oidc/oidc_common.conf b/internal/configs/oidc/oidc_common.conf index 26ef21b05b..fa70160b08 100644 --- a/internal/configs/oidc/oidc_common.conf +++ b/internal/configs/oidc/oidc_common.conf @@ -20,6 +20,7 @@ proxy_cache_path /var/cache/nginx/jwk levels=1 keys_zone=jwk:64k max_size=1m; keyval_zone zone=oidc_id_tokens:1M timeout=1h sync; keyval_zone zone=oidc_access_tokens:1M timeout=1h sync; keyval_zone zone=refresh_tokens:1M timeout=8h sync; +keyval_zone zone=oidc_sids:1M timeout=8h; #keyval_zone zone=oidc_pkce:128K timeout=90s sync; # Temporary storage for PKCE code verifier. keyval $cookie_auth_token $session_jwt zone=oidc_id_tokens; # Exchange cookie for ID token(JWT) @@ -28,6 +29,7 @@ keyval $cookie_auth_token $refresh_token zone=refresh_tokens; # Exchange coo keyval $request_id $new_session zone=oidc_id_tokens; # For initial session creation keyval $request_id $new_access_token zone=oidc_access_tokens; keyval $request_id $new_refresh zone=refresh_tokens; # '' +keyval $idp_sid $client_sid zone=oidc_sids #keyval $pkce_id $pkce_code_verifier zone=oidc_pkce; auth_jwt_claim_set $jwt_audience aud; # In case aud is an array diff --git a/internal/configs/oidc/openid_connect.js b/internal/configs/oidc/openid_connect.js index eccabca2ba..e0799ffa0b 100644 --- a/internal/configs/oidc/openid_connect.js +++ b/internal/configs/oidc/openid_connect.js @@ -1,346 +1,463 @@ /* * 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, + handleFrontChannelLogout +}; + +// 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, sessionId, claims, tokenset, true); - // 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; + } + + // 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, sessionId, claims, tokenset, true); - if (reply.status != 200) { + 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; - } - - // 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; + r.error(`OIDC ID Token validation error: aud claim (${claims.aud}) ` + + `does not include $oidc_client (${r.variables.oidc_client})`); + return 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, 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 || ""; + 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", "sid"]; + + 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("OIDC-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); + + 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 = '-'; + } + } - // 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; - } + // 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, r.variables.session_jwt); + 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); + performLogout(r.variables.oidc_end_session_endpoint + logoutArgs, r.variables.session_jwt); } else { - // Fallback to traditional logout approach performLogout(logoutRedirectUrl); } } +/** + * 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"]; + 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 +478,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 +488,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 || '')); +}