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 }} diff --git a/README.md b/README.md index 969dcd7..fd800f9 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,24 @@ Reference implementation of NGINX Plus as relying party for OpenID Connect authe This repository describes how to enable OpenID Connect integration for [NGINX Plus](https://www.nginx.com/products/nginx/). The solution depends on NGINX Plus components ([auth_jwt module](http://nginx.org/en/docs/http/ngx_http_auth_jwt_module.html) and [key-value store](http://nginx.org/en/docs/http/ngx_http_keyval_module.html)) and as such is not suitable for [open source NGINX](http://www.nginx.org/en). -OpenID Connect components - +```mermaid +flowchart BT + subgraph " " + direction LR + id1(User)==>|Request for app|id2 + id2-. Unauthenticated .->id1 + id2(NGINX+)-->|Authenticated|id3(Backend app) + end + subgraph IDP + id4(Authorization Server) + end + id1<-. User authenticates directly with IdP .->IDP + IDP<-. NGINX exchanges authorization code for ID token .->id2 + style id1 fill:#fff,stroke:#444,stroke-width:3px,color:#222 + style id3 fill:#fff,stroke:#444,stroke-width:3px,color:#222 + style id2 fill:#009639,stroke:#215732,stroke-width:2px,color:#fff + style id4 fill:#666,stroke:#222,stroke-width:1px,color:#fff +``` `Figure 1. High level components of an OpenID Connect environment` This implementation assumes the following environment: @@ -19,7 +35,31 @@ This implementation assumes the following environment: With this environment, both the client and NGINX Plus communicate directly with the IdP at different stages during the initial authentication event. -![OpenID Connect protocol diagram](https://www.nginx.com/wp-content/uploads/2018/04/dia-LC-2018-03-30-OpenID-Connect-authentication-code-flow-detailed-800x840-03.svg) +```mermaid +sequenceDiagram + autonumber + actor User + participant Browser + participant IdP + participant NGINX Plus + participant Web App + User->>NGINX Plus: Requests protected resource + NGINX Plus->>Browser: Sends redirect to IdP for authentication + Browser->>IdP: Requests login page + User->>IdP: Provides authentication and consent + IdP->>Browser: Sends redirect w/ authZ code + Browser->>NGINX Plus: Redirected for code exchange + NGINX Plus->>IdP: Sends authZ code + IdP->>NGINX Plus: Sends ID(+refresh) token + NGINX Plus-->>NGINX Plus: Validates ID token, stores in keyval, creates session cookie + Note right of NGINX Plus: keyvals zone for ID token (JWT) + Note right of NGINX Plus: keyval zone for refresh token + NGINX Plus->>Browser: Sends redirect to original URI with session cookie + Browser->>NGINX Plus: Requests original URI, supplies session cookie + NGINX Plus-->>NGINX Plus: Obtains ID token from keyval, validates JWT + NGINX Plus->>Web App: Proxies request + Web App->>Browser: Sends resource +``` `Figure 2. OpenID Connect authorization code flow protocol` NGINX Plus is configured to perform OpenID Connect authentication. Upon a first visit to a protected resource, NGINX Plus initiates the OpenID Connect authorization code flow and redirects the client to the OpenID Connect provider (IdP). When the client returns to NGINX Plus with an authorization code, NGINX Plus exchanges that code for a set of tokens by communicating directly with the IdP. @@ -30,6 +70,18 @@ Subsequent requests to protected resources are authenticated by exchanging the s For more information on OpenID Connect and JWT validation with NGINX Plus, see [Authenticating Users to Existing Applications with OpenID Connect and NGINX Plus](https://www.nginx.com/blog/authenticating-users-existing-applications-openid-connect-nginx-plus/). +### Client Authentication Methods + +When configuring NGINX Plus as an OpenID Connect client, it supports multiple client authentication methods: + +* **client_secret_basic**: + * The `client_id` and `client_secret` are sent in the Authorization header as a Base64-encoded string. +* **client_secret_post**: + * The `client_id` and `client_secret` are sent in the body of the POST request. +* **none** (PKCE): + * For public clients that cannot protect a client secret, the `code_verifier` is used instead of a `client_secret`. + * PKCE is particularly useful for mobile and single-page applications. + ### Access Tokens [Access tokens](https://openid.net/specs/openid-connect-core-1_0.html#AccessTokenDisclosure) are used in token-based authentication to allow OIDC client to access a protected resource on behalf of the user. NGINX Plus receives an access token after a user successfully authenticates and authorizes access, and then stores it in the key-value store. NGINX Plus can pass that token on the HTTP Authorization header as a [Bearer token](https://oauth.net/2/bearer-tokens/) for every request that is sent to the downstream application. @@ -44,6 +96,14 @@ If a [refresh token](https://openid.net/specs/openid-connect-core-1_0.html#Refre Requests made to the `/logout` location invalidate both the ID token, access token and refresh token by erasing them from the key-value store. Therefore, subsequent requests to protected resources will be treated as a first-time request and send the client to the IdP for authentication. Note that the IdP may issue cookies such that an authenticated session still exists at the IdP. +#### RP-Initiated OIDC Logout + +RP-initiated logout is supported according to [OpenID Connect RP-Initiated Logout 1.0](https://openid.net/specs/openid-connect-rpinitiated-1_0.html). This behavior is controlled by the `$oidc_end_session_endpoint` variable. + +#### 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. @@ -96,12 +156,15 @@ When NGINX Plus is deployed behind another proxy, the original protocol and port * Choose the **authorization code flow** * Set the **redirect URI** to the address of your NGINX Plus instance (including the port number), with `/_codexch` as the path, e.g. `https://my-nginx.example.com:443/_codexch` * Ensure NGINX Plus is configured as a confidential client (with a client secret) or a public client (with PKCE S256 enabled) + * If NGINX Plus is configured as a confidential client, choose the appropriate authentication method: **client_secret_basic** or **client_secret_post**. * Make a note of the `client ID` and `client secret` if set + * Set the **post logout redirect URI** to the address of your NGINX Plus instance (including the port number), with `/_logout` as the path, e.g. `https://my-nginx.example.com:443/_logout` * If your IdP supports OpenID Connect Discovery (usually at the URI `/.well-known/openid-configuration`) then use the `configure.sh` script to complete configuration. In this case you can skip the next section. Otherwise: * Obtain the URL for `jwks_uri` or download the JWK file to your NGINX Plus instance * Obtain the URL for the **authorization endpoint** * Obtain the URL for the **token endpoint** + * Obtain the URL for the **end session endpoint** ## Configuring NGINX Plus @@ -125,8 +188,10 @@ Manual configuration involves reviewing the following files so that they match y * **openid_connect.server_conf** - this is the NGINX configuration for handling the various stages of OpenID Connect authorization code flow * No changes are usually required here - * Modify the `resolver` directive to match a DNS server that is capable of resolving the IdP defined in `$oidc_token_endpoint` + * Modify the `resolver` directive to match a DNS server that is capable of resolving the IdP defined in `$oidc_token_endpoint` and `$oidc_end_session_endpoint` * If using [`auth_jwt_key_request`](http://nginx.org/en/docs/http/ngx_http_auth_jwt_module.html#auth_jwt_key_request) to automatically fetch the JWK file from the IdP then modify the validity period and other caching options to suit your IdP + * 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 @@ -136,9 +201,10 @@ Manual configuration involves reviewing the following files so that they match y The key-value store is used to maintain persistent storage for ID tokens and refresh tokens. The default configuration should be reviewed so that it suits the environment. This is part of the advanced configuration in **openid_connect_configuration.conf**. ```nginx -keyval_zone zone=oidc_id_tokens:1M state=conf.d/oidc_id_tokens.json timeout=1h; -keyval_zone zone=oidc_access_tokens:1M state=conf.d/oidc_access_tokens.json timeout=1h; -keyval_zone zone=refresh_tokens:1M state=conf.d/refresh_tokens.json timeout=8h; +keyval_zone zone=oidc_id_tokens:1M state=/var/lib/nginx/state/oidc_id_tokens.json timeout=1h; +keyval_zone zone=oidc_access_tokens:1M state=/var/lib/nginx/state/oidc_access_tokens.json timeout=1h; +keyval_zone zone=refresh_tokens:1M state=/var/lib/nginx/state/refresh_tokens.json timeout=8h; +keyval_zone zone=oidc_sids:1M state=/var/lib/nginx/state/oidc_sids.json timeout=8h; keyval_zone zone=oidc_pkce:128K timeout=90s; ``` @@ -150,7 +216,7 @@ Each of the `keyval_zone` parameters are described below. * **timeout** - Expired tokens are removed from the key-value store after the `timeout` value. This should be set to value slightly longer than the JWT validity period. JWT validation occurs on each request, and will fail when the expiry date (`exp` claim) has elapsed. If JWTs are issued without an `exp` claim then set `timeout` to the desired session duration. If JWTs are issued with a range of validity periods then set `timeout` to exceed the longest period. - * **sync** (optional) - If deployed in a cluster, the key-value store may be synchronized across all instances in the cluster, so that all instances are able to create and validate authenticated sessions. Each instance must be configured to participate in state sharing with the [zone_sync module](http://nginx.org/en/docs/stream/ngx_stream_zone_sync_module.html) and by adding the `sync` parameter to the `keyval_zone` directives above. + * **sync** (optional) - If deployed in a cluster, the key-value store may be synchronized across all instances in the cluster, so that all instances are able to create and validate authenticated sessions. Each instance must be configured to participate in state sharing with the [zone_sync module](http://nginx.org/en/docs/stream/ngx_stream_zone_sync_module.html) and by adding the `sync` parameter to the `keyval_zone` directives above. If deployed in [NGINXaaS for Azure](https://docs.nginx.com/nginxaas/azure/overview/overview/), **sync** is a must configuration. Check [NGINXaaS OIDC](https://docs.nginx.com/nginxaas/azure/quickstart/security-controls/oidc/) for more details. ## Session Management @@ -254,3 +320,6 @@ This reference implementation for OpenID Connect is supported for NGINX Plus sub * **R22** Separate configuration file, supports multiple IdPs. Configurable scopes and cookie flags. JavaScript is imported as an indepedent module with `js_import`. Container-friendly logging. Additional metrics for OIDC activity. * **R23** PKCE support. Added support for deployments behind another proxy or load balancer. * **R28** Access token support. Added support for access token to authorize NGINX to access protected backend. + * **R32** Added support for `client_secret_basic` client authentication method. + * **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/configure.sh b/configure.sh index 17e8920..e48b0cd 100755 --- a/configure.sh +++ b/configure.sh @@ -120,7 +120,7 @@ fi # Build an intermediate configuration file # File format is: # -jq -r '. | "$oidc_authz_endpoint \(.authorization_endpoint)\n$oidc_token_endpoint \(.token_endpoint)\n$oidc_jwks_uri \(.jwks_uri)"' < /tmp/${COMMAND}_$$_json > /tmp/${COMMAND}_$$_conf +jq -r '. | "$oidc_authz_endpoint \(.authorization_endpoint)\n$oidc_token_endpoint \(.token_endpoint)\n$oidc_end_session_endpoint \(.end_session_endpoint // "")\n$oidc_jwks_uri \(.jwks_uri)"' < /tmp/${COMMAND}_$$_json > /tmp/${COMMAND}_$$_conf # Create a random value for HMAC key, adding to the intermediate configuration file echo "\$oidc_hmac_key `openssl rand -base64 18`" >> /tmp/${COMMAND}_$$_conf @@ -178,13 +178,18 @@ fi # Loop through each configuration variable echo "$COMMAND: NOTICE: Configuring $CONFDIR/openid_connect_configuration.conf" -for OIDC_VAR in \$oidc_authz_endpoint \$oidc_token_endpoint \$oidc_jwt_keyfile \$oidc_hmac_key $CLIENT_ID_VAR $CLIENT_SECRET_VAR $PKCE_ENABLE_VAR; do +for OIDC_VAR in \$oidc_authz_endpoint \$oidc_token_endpoint \$oidc_end_session_endpoint \$oidc_jwt_keyfile \$oidc_hmac_key $CLIENT_ID_VAR $CLIENT_SECRET_VAR $PKCE_ENABLE_VAR; do # Pull the configuration value from the intermediate file VALUE=`grep "^$OIDC_VAR " /tmp/${COMMAND}_$$_conf | cut -f2 -d' '` echo -n "$COMMAND: NOTICE: - $OIDC_VAR ..." + # If the value is empty, assign a default value + if [ -z "$VALUE" ]; then + VALUE="\"\"" + fi + # Find where this variable is configured - LINE=`grep -nA10 $OIDC_VAR $CONFDIR/openid_connect_configuration.conf | grep $HOSTNAME | head -1 | cut -f1 -d-` + LINE=`grep -nA10 $OIDC_VAR $CONFDIR/openid_connect_configuration.conf | grep -vE '^[0-9]+-?[[:space:]]*($|#)' | grep $HOSTNAME | head -1 | cut -f1 -d-` if [ "$LINE" == "" ]; then # Add new value LINE=`grep -n $OIDC_VAR $CONFDIR/openid_connect_configuration.conf | head -1 | cut -f1 -d:` diff --git a/openid_connect.js b/openid_connect.js index 49909c9..49d5731 100644 --- a/openid_connect.js +++ b/openid_connect.js @@ -1,312 +1,614 @@ /* * JavaScript functions for providing OpenID Connect with NGINX Plus - * - * Copyright (C) 2020 Nginx, Inc. + * + * Copyright (C) 2025 Nginx, Inc. */ -var newSession = false; // Used by oidcAuth() and validateIdToken() -export default {auth, codeExchange, validateIdToken, logout}; +export default { + auth, + codeExchange, + extractTokenClaims, + logout, + handleFrontChannelLogout +}; -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); +// 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 == "-") { - newSession = true; + // No or expired ID token, but refresh token present, attempt to refresh + const tokenset = await refreshTokens(r); + if (!tokenset) { + return; + } - // 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)); + // Validate refreshed ID token + let claims; + try { + claims = await validateIdToken(r, tokenset.id_token); + } catch (e) { + // If validation failed, reset and reinitiate auth + r.variables.refresh_token = "-"; + r.headersOut["Location"] = r.variables.request_uri; + oidcError(r, 302, getRefId(r, "auth.validate"), e); return; } - - // Pass the refresh token to the /_refresh location so that it can be - // proxied to the IdP in exchange for a new id_token - r.subrequest("/_refresh", "token=" + r.variables.refresh_token, - 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.responseBody); - error_log += ": " + errorset.error + " " + errorset.error_description; - } catch (e) { - error_log += ": " + reply.responseBody; - } - } else { - error_log += " " + reply.status; - } - r.error(error_log); - // 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.responseBody); - 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 an authorization code. +async function codeExchange(r) { + // Check authorization code presence + 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 from IdP: " + 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 from IdP but received: " + r.uri); + oidcError(r, 502, ref, + new Error(`OIDC expected authorization code but received: ` + + `${r.variables.request_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",idpClientAuth(r), 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 + let claims; + try { + claims = await validateIdToken(r, tokenset.id_token); + } catch (e) { + oidcError(r, 500, getRefId(r, "codeExchange.validate"), e); + return; + } - if (reply.status != 200) { + // Determine session ID and store session data for a new session + const sessionId = getSessionId(r, true); + storeSessionData(r, sessionId, claims, 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, reject) => { + r.subrequest('/_token_validation', 'token=' + token, + function(reply) { + if (reply.status !== 200) { + reject(new Error(`Failed to retrieve claims: HTTP ${reply.status}`)); + return; + } try { - var errorset = JSON.parse(reply.responseBody); - if (errorset.error) { - r.error("OIDC error from IdP when sending authorization code: " + errorset.error + ", " + errorset.error_description); - } else { - r.error("OIDC unexpected response from IdP when sending authorization code (HTTP " + reply.status + "). " + reply.responseBody); - } + 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.responseBody); + reject(new Error(`Failed to parse claims: ${e}`)); } - r.return(502); - return; } + ); + }); +} - // Code exchange returned 200, check for errors - try { - var tokenset = JSON.parse(reply.responseBody); - 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); + validateIdTokenClaims(r, claims); + return claims; +} - // 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 + r.variables.cookie_auth_redir); - } - ); - } catch (e) { - r.error("OIDC authorization code sent but token response is not JSON. " + reply.responseBody); - r.return(502); - } +// 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) { + 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) { + 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)) { + 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 + 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) { + throw new Error( + `OIDC ID Token validation error: nonce from token (${claims.nonce}) ` + + `does not match client (${clientNonceHash})` + ); } - ); + } else if (isNewSession(r)) { + throw new Error( + "OIDC ID Token validation error: missing nonce claim during initial authentication." + ); + } } -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]); +// 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.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; } } - 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; +} + +// 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) { + let params; + try { + params = generateTokenRequestParams(r, "authorization_code"); + } catch (e) { + oidcError(r, 500, getRefId(r, "token.params"), e); + return null; } - var validToken = true; + const reply = await new Promise((resolve) => { + r.subrequest("/_token", params, resolve); + }); + + const ref = getRefId(r, "token.exchange"); - // 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) { - r.error("OIDC ID Token validation error: iat claim is not a valid number"); - validToken = false; + if (reply.status === 504) { + oidcError(r, 504, ref, new Error("OIDC timeout connecting to IdP during code exchange")); + return null; } - // Audience matching - var aud = r.variables.jwt_audience.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; - } - - // If we receive a nonce in the ID Token then we will use the auth_nonce cookies - // to check that the JWT can be validated as being directly related to the - // original request by this client. This mitigates against token replay attacks. - if (newSession) { - 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 (reply.status !== 200) { + 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}`; } - 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; + oidcError(r, 502, ref, new Error(message)); + return null; + } + + try { + const tokenset = JSON.parse(reply.responseText); + if (tokenset.error) { + oidcError(r, 500, ref, + new Error(`OIDC token response error: ${tokenset.error}` + + ` ${tokenset.error_description}`) + ); + return null; } + return tokenset; + } catch (_e) { + oidcError(r, 502, ref, new Error(`OIDC token response not JSON: ${reply.responseText}`)); + return null; } +} - if (validToken) { - r.return(204); - } else { - r.return(403); +// 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", params, resolve); + }); + + if (reply.status !== 200) { + handleRefreshError(r, reply); + return null; + } + + try { + const tokenset = JSON.parse(reply.responseText); + if (!tokenset.id_token) { + r.log("OIDC refresh response did not include id_token" + + (tokenset.error ? ("; " + tokenset.error + " " + tokenset.error_description) : "")); + return null; + } + return tokenset; + } catch (_e) { + r.variables.refresh_token = "-"; + r.headersOut["Location"] = r.variables.request_uri; + oidcError(r, 302, getRefId(r, "refresh.parse"), new Error("OIDC refresh response not JSON")); + return null; } } +// Logout handler function logout(r) { - r.log("OIDC logout for " + r.variables.cookie_auth_token); - r.variables.session_jwt = "-"; - r.variables.access_token = "-"; - r.variables.refresh_token = "-"; - r.return(302, r.variables.oidc_logout_redirect); + 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; + } + + 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 !== '-') { + try { + const claims = await getTokenClaims(r, idToken); + if (claims.sid) { + r.variables.idp_sid = claims.sid; + r.variables.client_sid = '-'; + } + } catch (_e) { + } + } + + r.variables.session_jwt = '-'; + r.variables.access_token = '-'; + r.variables.refresh_token = '-'; + r.return(302, redirectUrl); + } + + if (r.variables.oidc_end_session_endpoint) { + // 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; + } + + var logoutArgs = "?post_logout_redirect_uri=" + encodeURIComponent(logoutRedirectUrl) + + "&id_token_hint=" + encodeURIComponent(r.variables.session_jwt); + performLogout(r.variables.oidc_end_session_endpoint + logoutArgs, r.variables.session_jwt); + } else { + 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) { + oidcError(r, 400, getRefId(r, "frontchannel.missingSid"), + new Error("Missing sid parameter in front-channel logout request")); + return; + } + + if (!requestIss) { + oidcError(r, 400, getRefId(r, "frontchannel.missingIss"), + new Error("Missing iss parameter in front-channel logout request")); + 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; + } + + let claims; + try { + claims = await getTokenClaims(r, sessionJwt); + } catch (e) { + oidcError(r, 400, getRefId(r, "frontchannel.claims"), e); + return; + } + + if (claims.iss !== requestIss) { + oidcError(r, 400, getRefId(r, "frontchannel.issMismatch"), + new Error(`Issuer mismatch during logout. ` + + `Received iss: ${requestIss}, expected: ${claims.iss}`)); + 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) { + oidcError(r, 500, getRefId(r, "init.missingConfig"), + new Error(`OIDC missing configuration variables: $oidc_` + + `${missingConfig.join(" $oidc_")}`) + ); + 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=" + r.variables.request_uri + "; " + r.variables.oidc_cookie_flags, + "auth_redir=" + encodedRequestUri + "; " + r.variables.oidc_cookie_flags, "auth_nonce=" + noncePlain + "; " + r.variables.oidc_cookie_flags ]; - 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; } -function idpClientAuth(r) { - // If PKCE is enabled we have to use the code_verifier - if ( r.variables.oidc_pkce_enable == 1 ) { - r.variables.pkce_id = r.variables.arg_state; - return "code=" + r.variables.arg_code + "&code_verifier=" + r.variables.pkce_code_verifier; +// 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; + if (r.variables.oidc_pkce_enable == 1) { + r.variables.pkce_id = r.variables.arg_state; + body += "&code_verifier=" + r.variables.pkce_code_verifier; + } + break; + case "refresh_token": + body += "&refresh_token=" + r.variables.refresh_token; + break; + default: + throw new Error("Unsupported grant type: " + grant_type); + } + + var options = { + body: body, + method: "POST" + }; + + if (r.variables.oidc_pkce_enable != 1) { + if (r.variables.oidc_client_auth_method === "client_secret_basic") { + let auth_basic = "Basic " + Buffer.from(r.variables.oidc_client + ":" + + r.variables.oidc_client_secret).toString('base64'); + options.args = "secret_basic=" + auth_basic; + } else { + options.body += "&client_secret=" + r.variables.oidc_client_secret; + } + } + + return options; +} + +// 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) { + errorLog += ": " + reply.responseText; + } + } else { + errorLog += " " + reply.status; + } + + r.variables.refresh_token = "-"; + 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 + 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 || '')); +} + +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 { - return "code=" + r.variables.arg_code + "&client_secret=" + r.variables.oidc_client_secret; - } + 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 13456d2..bda5911 100644 --- a/openid_connect.server_conf +++ b/openid_connect.server_conf @@ -1,6 +1,8 @@ # Advanced configuration START 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 @@ -8,10 +10,15 @@ location = /_jwks_uri { internal; - proxy_cache jwk; # Cache the JWK Set recieved from IdP + proxy_cache jwk; # Cache the JWK Set received from IdP proxy_cache_valid 200 12h; # How long to consider keys "fresh" proxy_cache_use_stale error timeout updating; # Use old JWK Set if cannot reach IdP - proxy_ssl_server_name on; # For SNI to the IdP + + 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 @@ -29,50 +36,71 @@ # This location is called by the IdP after successful authentication status_zone "OIDC code exchange"; js_content oidc.codeExchange; - error_page 500 502 504 @oidc_error; + error_page 500 502 504 @oidc_error; } - + location = /_token { # This location is called by oidcCodeExchange(). We use the proxy_ directives # to construct the OpenID Connect token request, as per: # http://openid.net/specs/openid-connect-core-1_0.html#TokenRequest internal; - proxy_ssl_server_name on; # For SNI to the IdP + + # Exclude client headers to avoid CORS errors with certain IdPs (e.g., Microsoft Entra ID) + proxy_pass_request_headers off; + + 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_body "grant_type=authorization_code&client_id=$oidc_client&$args&redirect_uri=$redirect_base$redir_location"; - proxy_method POST; + proxy_set_header Authorization $arg_secret_basic; proxy_pass $oidc_token_endpoint; - } + } 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; - proxy_ssl_server_name on; # For SNI to the IdP + + # Exclude client headers to avoid CORS errors with certain IdPs (e.g., Microsoft Entra ID) + proxy_pass_request_headers off; + + 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_body "grant_type=refresh_token&refresh_token=$arg_token&client_id=$oidc_client&client_secret=$oidc_client_secret"; - proxy_method POST; + proxy_set_header Authorization $arg_secret_basic; proxy_pass $oidc_token_endpoint; } - 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; } location = /logout { status_zone "OIDC logout"; - add_header Set-Cookie "auth_token=; $oidc_cookie_flags"; # Send empty cookie - add_header Set-Cookie "auth_redir=; $oidc_cookie_flags"; # Erase original cookie + add_header Set-Cookie "auth_token=; $oidc_cookie_flags"; + add_header Set-Cookie "auth_nonce=; $oidc_cookie_flags"; + add_header Set-Cookie "auth_redir=; $oidc_cookie_flags"; js_content oidc.logout; } + 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 0aa69a4..7477933 100644 --- a/openid_connect_configuration.conf +++ b/openid_connect_configuration.conf @@ -28,6 +28,13 @@ map $host $oidc_jwt_keyfile { default "http://127.0.0.1:8080/auth/realms/master/protocol/openid-connect/certs"; } +map $host $oidc_end_session_endpoint { + # Specifies the end_session_endpoint URL for RP-initiated logout. + # If this variable is empty or not set, the default behavior is maintained, + # which logs out only on the NGINX side. + default ""; +} + map $host $oidc_client { default "my-client-id"; } @@ -40,6 +47,13 @@ map $host $oidc_client_secret { default "my-client-secret"; } +map $host $oidc_client_auth_method { + # Choose either "client_secret_basic" for sending client credentials in the + # Authorization header, or "client_secret_post" for sending them in the + # body of the POST request. This setting is used for confidential clients. + default "client_secret_post"; +} + map $host $oidc_scopes { default "openid+profile+email+offline_access"; } @@ -87,9 +101,10 @@ map $http_x_forwarded_proto $proto { proxy_cache_path /var/cache/nginx/jwk levels=1 keys_zone=jwk:64k max_size=1m; # Change timeout values to at least the validity period of each token type -keyval_zone zone=oidc_id_tokens:1M state=conf.d/oidc_id_tokens.json timeout=1h; -keyval_zone zone=oidc_access_tokens:1M state=conf.d/oidc_access_tokens.json timeout=1h; -keyval_zone zone=refresh_tokens:1M state=conf.d/refresh_tokens.json timeout=8h; +keyval_zone zone=oidc_id_tokens:1M state=/var/lib/nginx/state/oidc_id_tokens.json timeout=1h; +keyval_zone zone=oidc_access_tokens:1M state=/var/lib/nginx/state/oidc_access_tokens.json timeout=1h; +keyval_zone zone=refresh_tokens:1M state=/var/lib/nginx/state/refresh_tokens.json timeout=8h; +keyval_zone zone=oidc_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 @@ -99,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;