From 1b5ca1c5f608e57bfda583f2418e1d3fc745ac0e Mon Sep 17 00:00:00 2001 From: Shawn Kim Date: Thu, 3 Nov 2022 14:59:52 -0700 Subject: [PATCH] feat: access token & enhance endpoints (/login, /userinfo, /logout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Issue Item:** - https://github.com/nginxinc/nginx-openid-connect/pull/62 **Description:** **1. access token** - Enhance the NJS Code to capture the `access_token` sent by the IdP. - Store the `access_token` in the k/v store as same as we store `id_token` and `refresh_token` **2. new endpoints** - Add `/userinfo` endpoint: - Add a map variable of `$oidc_userinfo_endpoint` as same as authz and token endpoints here (`openid_connect_configuration.conf`) . - Expose `/userinfo` endpoint here(`openid_connect.server_conf`) in a location block of NGINX Plus to interact with IdP's `userinfo_endpoint` which is defined in the endpoint of`well-known/openid-configuration`. - The nginx location block should proxy to the IdP’s `userinfo_endpoint` by adding `access_token` as a bearer token. ``` Authorization : Bearer ``` - The response coming from IdP should be returned back to the caller as it is. - Expose `/login` endpoint: - Expose the `/login` endpoint as a location block here (`openid_connect.server_conf`) - Proxy it to the IdP's `authorization_endpoint` configured in the map variable of `$oidc_authz_endpoint` in (`openid_connect_configuration.conf`). - This would outsource the login function to IdP as its configured. - Enhance `/logout` endpoint: - Add a map variable of `$oidc_end_session_endpoint` as same as authz and token endpoints here (`openid_connect_configuration.conf`) . - Proxy it to the IdP's `end_session_endpoint` to finish the session by IdP. - Enhance `/_logout` endpoint: - Enhance `/_logout` endpoint which is a callback from IdP as a location block here (`openid_connect.server_conf`) to handle the following sequences. - 1. Redirected by IdP when IdP successfully finished the session. - 2. NGINX Plus: Clear session cookies. - 3. NGINX Plus: Redirect to either the landing page or the custom logout page by calling **3. add endpoints in `configure.sh`** - IdP's userinfo endpoint - IdP's end session endpoint **Compatibility:** - This PR does not block the existing customers as it just adds endpoints and features. --- README.md | 218 ++++++++++++++++++++---------- configure.sh | 4 +- frontend.conf | 37 +++-- openid_connect.js | 105 +++++++++++++- openid_connect.server_conf | 45 +++++- openid_connect_configuration.conf | 84 ++++++++++-- 6 files changed, 391 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index 2b3b006..71c2f0c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,22 @@ Reference implementation of NGINX Plus as relying party for OpenID Connect authentication +- [Description](#description) + - [Refresh Tokens](#refresh-tokens) + - [OpenID Connect Userinfo Endpoint](#openid-connect-userinfo-endpoint) + - [Login Behavior](#login-behavior) + - [Logout Behavior](#logout-behavior) + - [Multiple IdPs](#multiple-idps) +- [Quick Start Guide](#quick-start-guide) +- [Installation](#installation) +- [Configuring your IdP](#configuring-your-idp) +- [Configuring NGINX Plus](#configuring-nginx-plus) +- [Session Management](#session-management) +- [Real time monitoring](#real-time-monitoring) +- [Troubleshooting](#troubleshooting) +- [Support](#support) +- [Changelog](#changelog) + ## Description 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). @@ -12,10 +28,10 @@ This repository describes how to enable OpenID Connect integration for [NGINX Pl This implementation assumes the following environment: - * The identity provider (IdP) supports OpenID Connect 1.0 - * The authorization code flow is in use - * NGINX Plus is configured as a relying party - * The IdP knows NGINX Plus as a confidential client or a public client using PKCE +- The identity provider (IdP) supports OpenID Connect 1.0 +- The authorization code flow is in use +- NGINX Plus is configured as a relying party +- The IdP knows NGINX Plus as a confidential client or a public client using PKCE With this environment, both the client and NGINX Plus communicate directly with the IdP at different stages during the initial authentication event. @@ -32,18 +48,50 @@ For more information on OpenID Connect and JWT validation with NGINX Plus, see [ ### Refresh Tokens -If a [refresh token](https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens) was received from the IdP then it is also stored in the key-value store. When validation of the ID Token fails (typically upon expiry) then NGINX Plus sends the refresh token to the IdP. If the user's session is still valid at the IdP then a new ID token is received, validated, and updated in the key-value store. The refresh process is seamless to the client. +If a [refresh token](https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens) was received from the IdP then it is also stored in the key-value store. When validation of the ID Token fails (typically upon expiry) then NGINX Plus sends the refresh token to the IdP. If the user's session is still valid at the IdP then new ID token and access token are received, validated, and updated in the key-value store. The refresh process is seamless to the client. The ID token is used for user authentication with login and logout, and the access is used for API authorization before proxing to IdP endpoint such as `/userinfo` or custom backend APIs. + +### OpenID Connect UserInfo Endpoint + +The [OpenID Connect UserInfo endpoint](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo) (/userinfo) provides details about the logged-in user. Requests to /userinfo must be authenticated using the access token provided as part of the authorization code flow. + +- When a user is not logged in, requests to the `/userinfo` endpoint return a `401` (unauthorized) response. +- When a user is logged in, requests to `/userinfo` return a `200` response with the requested user information (such as name). + +### Login Behavior + +When a client requests an application's `/login` location, NGINX Plus starts the following OIDC workflow: + +- When an End-User clicks a `Login` button, the frontend app calls `/login` endpoint +- The `/login` endpoint calls IdP's `authorization_endpoint`, and IdP issued the ID token, access token, and refresh token to NGINX Plus. +- The tokens are stored in the key-value store of NGINX Plus. +- NGINX Plus redirects to the frontend landing page with session cookie after these successful login processes. +- The frontend app shows user information in detail by calling `/userinfo` endpoint. -### Logout +### Logout Behavior Requests made to the `/logout` location invalidate both the ID 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. +To avoid breaking changes of API endpoints to customers who use a `/logout` location, the `/v2/logout` location is added to interact with the IdP's `end_session_endpoint` which is to handle [OIDC RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout) as the spec of OpenID Connect. The `$post_logout_return_uri` is the URI to which the RP is requesting that the End-User's User Agent be redirected after a logout has been performed. When setting up an IdP, `/v2/_logout` is used in this example, and you can change it per your preference. + ### 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. > **Note:** When validating OpenID Connect tokens, NGINX Plus can be configured to read the signing key (JWKS) from disk, or a URL. When using multiple IdPs, each one must be configured to use the same method. It is not possible to use a mix of both disk and URLs for the `map…$oidc_jwt_keyfile` variable. +## Quick Start Guide + +Set up a [local demo](https://github.com/nginx-openid-connect/nginx-oidc-examples/tree/main/001-oidc-local-test) that can be used for testing purposes: + +> **Note:** This link is subject to change. + +1. Running a Docker container +2. Checking If a Single Page App works in your browser +3. Setting up an Identity Provider (IdP) +4. Configuring NGINX Plus +5. Testing NGINX Plus OIDC +6. Stop and remove Docker containers + ## Installation Start by [installing NGINX Plus](https://docs.nginx.com/nginx/admin-guide/installing-nginx/installing-nginx-plus/). In addition, the [NGINX JavaScript module](https://www.nginx.com/blog/introduction-nginscript/) (njs) is required for handling the interaction between NGINX Plus and the OpenID Connect provider (IdP). Install the njs module after installing NGINX Plus by running one of the following: @@ -67,6 +115,7 @@ Finally, create a clone of the GitHub repository. All files can be copied to **/etc/nginx/conf.d** ### Non-standard directories + The GitHub repository contains [`include`](http://nginx.org/en/docs/ngx_core_module.html#include) files for NGINX configuration, and JavaScript code for token exchange and initial token validation. These files are referenced with a relative path (relative to /etc/nginx). If NGINX Plus is running from a non-standard location then copy the files from the GitHub repository to `/path/to/conf/conf.d` and use the `-p` flag to start NGINX with a prefix path that specifies the location where the configuration files are located. ```shell @@ -74,6 +123,7 @@ $ nginx -p /path/to/conf -c /path/to/conf/nginx.conf ``` ### Running in containers + This implementation is suitable for running in a container provided that the [base image](https://docs.nginx.com/nginx/admin-guide/installing-nginx/installing-nginx-docker/) includes the NGINX JavaScript module. The GitHub repository is designed to facilitate testing with a container by binding the cloned repository to a mount volume on the container. ```shell @@ -82,20 +132,24 @@ $ docker run -d -p 8010:8010 -v $PWD:/etc/nginx/conf.d nginx-plus nginx -g 'daem ``` ### Running behind another proxy or load balancer + When NGINX Plus is deployed behind another proxy, the original protocol and port number are not available. NGINX Plus needs this information to construct the URIs it passes to the IdP and for redirects. By default NGINX Plus looks for the X-Forwarded-Proto and X-Forwarded-Port request headers to construct these URIs. ## Configuring your IdP - * Create an OpenID Connect client to represent your NGINX Plus instance - * 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) - * Make a note of the `client ID` and `client secret` if set +- Create an OpenID Connect client to represent your NGINX Plus instance + + - 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) + - Make a note of the `client ID` and `client secret` if set - * 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** +- 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** + - Obtain the URL for the **user info endpoint** ## Configuring NGINX Plus @@ -103,45 +157,60 @@ Configuration can typically be completed automatically by using the `configure.s Manual configuration involves reviewing the following files so that they match your IdP(s) configuration. - * **openid_connect_configuration.conf** - this contains the primary configuration for one or more IdPs in `map{}` blocks - * Modify all of the `map…$oidc_` blocks to match your IdP configuration - * Modify the URI defined in `map…$oidc_logout_redirect` to specify an unprotected resource to be displayed after requesting the `/logout` location - * Set a unique value for `$oidc_hmac_key` to ensure nonce values are unpredictable - * If NGINX Plus is deployed behind another proxy or load balancer, modify the `map…$redirect_base` and `map…$proto` blocks to define how to obtain the original protocol and port number. +- **openid_connect_configuration.conf** - this contains the primary configuration for one or more IdPs in `map{}` blocks + + - Modify all of the `map…$oidc_` blocks to match your IdP configuration + - Modify the URI defined in `map…$oidc_logout_redirect` to specify an unprotected resource to be displayed after requesting the `/logout` location for customers who has been using R28. + - Modify the URI defined in `map…$oidc_logout_redirect_uri` to specify an unprotected resource to be displayed after requesting the `/v2/_logout` location for customers who start using R29 and wants to change from `map…$oidc_logout_redirect` to `map…$oidc_logout_redirect_uri` to use the feature of `OIDC RP-Initiated Logout` which is interact with IdP's `end_session_endpoint`. + - Set a unique value for `$oidc_hmac_key` to ensure nonce values are unpredictable + - If NGINX Plus is deployed behind another proxy or load balancer, modify the `map…$redirect_base` and `map…$proto` blocks to define how to obtain the original protocol and port number. + +- **frontend.conf** - this is the reverse proxy configuration + + - Modify the upstream group to match your backend site or app + - Configure the preferred listen port and [enable SSL/TLS configuration](https://docs.nginx.com/nginx/admin-guide/security-controls/terminating-ssl-http/) + - Modify the severity level of the `error_log` directive to suit the deployment environment + - Comment/uncomment the `auth_jwt_key_file` or `auth_jwt_key_request` directives based on whether `$oidc_jwt_keyfile` is a file or URI, respectively + - Examples + 1. **Basic Example:** Landing page starts OIDC flow without a login button. + 2. **Advanced Example:** Landing page, login/logout button to start/finish OIDC workflow. + - Landing page with `login` button + - `login` button to start OIDC flow by validating `id token` with the JWK of IdP. + - Landing page calls the `/userinfo` endpoint to show user information by validating `access token` with the JWK of IdP. + - `logout` button to close the OIDC session among frontend, NGINX Plus, and IdP. + - The proxied API authorization by validating `access token` with the JWK of IdP. + - Use `access token` for most of IdPs such as Amazon Cognito, Auth0, Keycloak, Okta, OneLogin and Ping Identity. + - Use `session_jwt` for Azure AD as for now. - * **frontend.conf** - this is the reverse proxy configuration - * Modify the upstream group to match your backend site or app - * Configure the preferred listen port and [enable SSL/TLS configuration](https://docs.nginx.com/nginx/admin-guide/security-controls/terminating-ssl-http/) - * Modify the severity level of the `error_log` directive to suit the deployment environment - * Comment/uncomment the `auth_jwt_key_file` or `auth_jwt_key_request` directives based on whether `$oidc_jwt_keyfile` is a file or URI, respectively +- **openid_connect.server_conf** - this is the NGINX configuration for handling the various stages of OpenID Connect authorization code flow - * **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` - * 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 + - 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` + - If using [`auth_jwt_key_request`](http://nginx.org/en/docs/http/ngx_http_auth_jwt_module.html#auth_jwt_key_request) to automatically fetch the JWK file from the IdP then modify the validity period and other caching options to suit your IdP - * **openid_connect.js** - this is the JavaScript code for performing the authorization code exchange and nonce hashing - * No changes are required unless modifying the code exchange or validation process +- **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 ### Configuring the Key-Value Store 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=refresh_tokens:1M state=conf.d/refresh_tokens.json timeout=8h; -keyval_zone zone=oidc_pkce:128K timeout=90s; +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_pkce:128K timeout=90s; ``` Each of the `keyval_zone` parameters are described below. - * **zone** - Specifies the name of the key-value store and how much memory to allocate for it. Each session will typically occupy 1-2KB, depending on the size of the tokens, so scale this value to exceed the number of unique users that may authenticate. +- **zone** - Specifies the name of the key-value store and how much memory to allocate for it. Each session will typically occupy 1-2KB, depending on the size of the tokens, so scale this value to exceed the number of unique users that may authenticate. - * **state** (optional) - Specifies where all of the ID Tokens in the key-value store are saved, so that sessions will persist across restart or reboot of the NGINX host. The NGINX Plus user account, typically **nginx**, must have write permission to the directory where the state file is stored. Consider creating a dedicated directory for this purpose. +- **state** (optional) - Specifies where all of the ID Tokens in the key-value store are saved, so that sessions will persist across restart or reboot of the NGINX host. The NGINX Plus user account, typically **nginx**, must have write permission to the directory where the state file is stored. Consider creating a dedicated directory for this purpose. - * **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. +- **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. ## Session Management @@ -157,6 +226,7 @@ To delete a single session: ```shell $ curl -iX PATCH -d '{"":null}' localhost:8010/api/6/http/keyvals/oidc_id_tokens +$ curl -iX PATCH -d '{"":null}' localhost:8010/api/6/http/keyvals/oidc_access_tokens $ curl -iX PATCH -d '{"":null}' localhost:8010/api/6/http/keyvals/refresh_tokens ``` @@ -164,6 +234,7 @@ To delete all sessions: ```shell $ curl -iX DELETE localhost:8010/api/6/http/keyvals/oidc_id_tokens +$ curl -iX DELETE localhost:8010/api/6/http/keyvals/oidc_access_tokens $ curl -iX DELETE localhost:8010/api/6/http/keyvals/refresh_tokens ``` @@ -171,17 +242,17 @@ $ curl -iX DELETE localhost:8010/api/6/http/keyvals/refresh_tokens The **openid_connect.server_conf** file defines several [`status_zone`](http://nginx.org/en/docs/http/ngx_http_api_module.html#status_zone) directives to collect metrics about OpenID Connect activity and errors. Separate metrics counters are recorded for: - * **OIDC start** - New sessions are counted here. See step 2 in Figure 2, above. Success is recorded as a 3xx response. +- **OIDC start** - New sessions are counted here. See step 2 in Figure 2, above. Success is recorded as a 3xx response. - * **OIDC code exchange** - Counters are incremented here when the browser returns to NGINX Plus after authentication. See steps 6-10 in Figure 2, above. Success is recorded as a 3xx response. +- **OIDC code exchange** - Counters are incremented here when the browser returns to NGINX Plus after authentication. See steps 6-10 in Figure 2, above. Success is recorded as a 3xx response. - * **OIDC logout** - Requests to the /logout URI are counted here. Success is recorded as a 3xx response. +- **OIDC logout** - Requests to the URIs of either `/logout` or `/v2/logout` are counted here. Success is recorded as a 3xx response. - * **OIDC error** - Counters are incremented here when errors in the code exchange process are actively detected. Typically there will be a corresponding error_log entry. +- **OIDC error** - Counters are incremented here when errors in the code exchange process are actively detected. Typically there will be a corresponding error_log entry. - To obtain the current set of metrics: +To obtain the current set of metrics: - ```shell +```shell $ curl localhost:8010/api/6/http/location_zones ``` @@ -191,33 +262,38 @@ In addition, the [NGINX Plus Dashboard](https://docs.nginx.com/nginx/admin-guide Any errors generated by the OpenID Connect flow are logged to the error log, `/var/log/nginx/error.log`. Check the contents of this file as it may include error responses received by the IdP. The level of detail recorded can be modified by adjusting the severity level of the `error_log` directive. - * **400 error from IdP** - * This is typically caused by incorrect configuration related to the client ID and client secret. - * Check the values of the `map…$oidc_client` and `map…$oidc_client_secret` variables against the IdP configuration. +- **400 error from IdP** + + - This is typically caused by incorrect configuration related to the client ID and client secret. + - Check the values of the `map…$oidc_client` and `map…$oidc_client_secret` variables against the IdP configuration. + +- **500 error from nginx after successful authentication** + - Check for `could not be resolved` and `empty JWK set while sending to client` messages in the error log. This is common when NGINX Plus cannot reach the IdP's `jwks_uri` endpoint. + - Check the `map…$oidc_jwt_keyfile` variable is correct. + - Check the `resolver` directive in **openid_connect.server_conf** is reachable from the NGINX Plus host. + - Check for `OIDC authorization code sent but token response is not JSON.` messages in the error log. This is common when NGINX Plus cannot decompress the IdP's response. Add the following configuration snippet to the `/_jwks_uri` and `/_token` locations to **openid_connect.server_conf**: - * **500 error from nginx after successful authentication** - * Check for `could not be resolved` and `empty JWK set while sending to client` messages in the error log. This is common when NGINX Plus cannot reach the IdP's `jwks_uri` endpoint. - * Check the `map…$oidc_jwt_keyfile` variable is correct. - * Check the `resolver` directive in **openid_connect.server_conf** is reachable from the NGINX Plus host. - * Check for `OIDC authorization code sent but token response is not JSON.` messages in the error log. This is common when NGINX Plus cannot decompress the IdP's response. Add the following configuration snippet to the `/_jwks_uri` and `/_token` locations to **openid_connect.server_conf**: ```nginx proxy_set_header Accept-Encoding "gzip"; ``` - * **Authentication is successful but browser shows too many redirects** - * This is typically because the JWT sent to the browser cannot be validated, resulting in 'authorization required' `401` response and starting the authentication process again. But the user is already authenticated so is redirected back to NGINX, hence the redirect loop. - * Avoid using `auth_jwt_require` directives in your configuration because this can also return a `401` which is indistinguishable from missing/expired JWT. - * Check the error log `/var/log/nginx/error.log` for JWT/JWK errors. - * Ensure that the JWK file (`map…$oidc_jwt_keyfile` variable) is correct and that the nginx user has permission to read it. +- **Authentication is successful but browser shows too many redirects** + + - This is typically because the JWT sent to the browser cannot be validated, resulting in 'authorization required' `401` response and starting the authentication process again. But the user is already authenticated so is redirected back to NGINX, hence the redirect loop. + - Avoid using `auth_jwt_require` directives in your configuration because this can also return a `401` which is indistinguishable from missing/expired JWT. + - Check the error log `/var/log/nginx/error.log` for JWT/JWK errors. + - Ensure that the JWK file (`map…$oidc_jwt_keyfile` variable) is correct and that the nginx user has permission to read it. + +- **Logged out but next request does not require authentication** + + - This is typically caused by the IdP issuing its own session cookie(s) to the client. NGINX Plus sends the request to the IdP for authentication and the IdP immediately sends back a new authorization code because the session is still valid. + - Check your IdP configuration if this behavior is not desired. - * **Logged out but next request does not require authentication** - * This is typically caused by the IdP issuing its own session cookie(s) to the client. NGINX Plus sends the request to the IdP for authentication and the IdP immediately sends back a new authorization code because the session is still valid. - * Check your IdP configuration if this behavior is not desired. +- **Failed SSL/TLS handshake to IdP** + - Indicated by error log messages including `peer closed connection in SSL handshake (104: Connection reset by peer) while SSL handshaking to upstream`. + - This can occur when the IdP requires Server Name Indication (SNI) information as part of the TLS handshake. Additional configuration is required to satisfy this requirement. + - Edit **openid_connect.server_conf** and for each of the `/_jwks_uri`, `/_token`, and `/_refresh` locations, add the following configuration snippet: - * **Failed SSL/TLS handshake to IdP** - * Indicated by error log messages including `peer closed connection in SSL handshake (104: Connection reset by peer) while SSL handshaking to upstream`. - * This can occur when the IdP requires Server Name Indication (SNI) information as part of the TLS handshake. Additional configuration is required to satisfy this requirement. - * Edit **openid_connect.server_conf** and for each of the `/_jwks_uri`, `/_token`, and `/_refresh` locations, add the following configuration snippet: ```nginx proxy_set_header Host ; proxy_ssl_name ; @@ -229,10 +305,10 @@ This reference implementation for OpenID Connect is supported for NGINX Plus sub ## Changelog - * **R15** Initial release of OpenID Connect reference implementation - * **R16** Added support for opaque session tokens using key-value store - * **R17** Configuration now supports JSON Web Key (JWK) set to be obtained by URI - * **R18** Opaque session tokens now used by default. Added support for refresh tokens. Added `/logout` location. - * **R19** Minor bug fixes - * **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. +- **R15** Initial release of OpenID Connect reference implementation +- **R16** Added support for opaque session tokens using key-value store +- **R17** Configuration now supports JSON Web Key (JWK) set to be obtained by URI +- **R18** Opaque session tokens now used by default. Added support for refresh tokens. Added `/logout` location. +- **R19** Minor bug fixes +- **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. diff --git a/configure.sh b/configure.sh index 17e8920..0637f4c 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_jwks_uri \(.jwks_uri)\n$oidc_end_session_endpoint \(.end_session_endpoint)\n$oidc_userinfo_endpoint \(.userinfo_endpoint)"' < /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,7 +178,7 @@ 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_jwt_keyfile \$oidc_end_session_endpoint \$oidc_userinfo_endpoint \$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 ..." diff --git a/frontend.conf b/frontend.conf index d79f10d..9d266a8 100644 --- a/frontend.conf +++ b/frontend.conf @@ -1,4 +1,4 @@ -# This is the backend application we are protecting with OpenID Connect +# This is the backend site/app we are protecting with OpenID Connect upstream my_backend { zone my_backend 64k; server 10.0.0.1:80; @@ -15,20 +15,39 @@ server { error_log /var/log/nginx/error.log debug; # Reduce severity level as required listen 8010; # Use SSL/TLS in production - + location / { - # This site is protected with OpenID Connect + # This site can be either directly protected with OpenID Connect or + # shown with just a landing page without login. + + # Disable when you need to show a default landing page before login. auth_jwt "" token=$session_jwt; error_page 401 = @do_oidc_flow; + auth_jwt_key_file $oidc_jwt_keyfile; # Enable when using filename + #auth_jwt_key_request /_jwks_uri; # Enable when using URL - auth_jwt_key_file $oidc_jwt_keyfile; # Enable when using filename - #auth_jwt_key_request /_jwks_uri; # Enable when using URL + # Successfully authenticated users are proxied to the backend site/app + # with 'sub' claim passed as HTTP header. It is empty before login. + proxy_set_header userid $jwt_claim_sub; + + # The 'access_token' is set in the OIDC flow. Otherwise, it is empty. + proxy_set_header Authorization "Bearer $access_token"; - # Successfully authenticated users are proxied to the backend, - # with 'sub' claim passed as HTTP header - proxy_set_header username $jwt_claim_sub; proxy_pass http://my_backend; # The backend site/app - + access_log /var/log/nginx/access.log main_jwt; + } + + location = /login { + # This location can be called by SPA to start OIDC flow via login button + # after starting a landing page without login. + auth_jwt "" token=$session_jwt; + error_page 401 = @do_oidc_flow; + + auth_jwt_key_file $oidc_jwt_keyfile; # Enable when using filename + #auth_jwt_key_request /_jwks_uri; # Enable when using URL + + # Redirect to the the landing page after successful login to AS. + js_content oidc.redirectPostLogin; access_log /var/log/nginx/access.log main_jwt; } } diff --git a/openid_connect.js b/openid_connect.js index e4f2084..048fc0b 100644 --- a/openid_connect.js +++ b/openid_connect.js @@ -5,7 +5,18 @@ */ var newSession = false; // Used by oidcAuth() and validateIdToken() -export default {auth, codeExchange, validateIdToken, logout}; +const EXTRA_PARAMS = 1; +const REPLACE_PARAMS = 2; + +export default { + auth, + codeExchange, + validateIdToken, + logout, + redirectPostLogin, + redirectPostLogout, + userInfo +}; function retryOriginalRequest(r) { delete r.headersOut["WWW-Authenticate"]; // Remove evidence of original failed auth_jwt @@ -104,6 +115,11 @@ function auth(r, afterSyncCheck) { // 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) { @@ -187,6 +203,12 @@ function codeExchange(r) { // 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); } @@ -253,11 +275,31 @@ function validateIdToken(r) { } } +// +// Default RP-Initiated or Custom Logout w/ OP. +// +// - An RP requests that the OP log out the end-user by redirecting the +// end-user's User Agent to the OP's Logout endpoint. +// - https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout +// - https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RedirectionAfterLogout +// function logout(r) { r.log("OIDC logout for " + r.variables.cookie_auth_token); - r.variables.session_jwt = "-"; - r.variables.refresh_token = "-"; - r.return(302, r.variables.oidc_logout_redirect); + var idToken = r.variables.session_jwt; + var queryParams = '?post_logout_redirect_uri=' + + r.variables.redirect_base + + r.variables.oidc_logout_redirect + + '&id_token_hint=' + idToken; + if (r.variables.oidc_end_session_query_params_option == REPLACE_PARAMS) { + queryParams = '?' + r.variables.oidc_end_session_query_params; + } else if (r.variables.oidc_end_session_query_params_option == EXTRA_PARAMS) { + queryParams += '&' + r.variables.oidc_end_session_query_params; + } + r.variables.request_id = '-'; + r.variables.session_jwt = '-'; + r.variables.access_token = '-'; + r.variables.refresh_token = '-'; + r.return(302, r.variables.oidc_end_session_endpoint + queryParams); } function getAuthZArgs(r) { @@ -298,4 +340,59 @@ function idpClientAuth(r) { } else { return "code=" + r.variables.arg_code + "&client_secret=" + r.variables.oidc_client_secret; } +} + +// +// Redirect URI after successful login from the OP. +// +function redirectPostLogin(r) { + if (r.variables.oidc_landing_page) { + r.return(302, r.variables.oidc_landing_page); + } else { + r.return(302, r.variables.redirect_base + r.variables.cookie_auth_redir); + } +} + +// +// Redirect URI after logged-out from the OP. +// +function redirectPostLogout(r) { + r.return(302, r.variables.post_logout_return_uri); +} + +// +// Return necessary user info claims after receiving and extracting all claims +// that are received from the OpenID Connect Provider(OP). +// +function userInfo(r) { + r.subrequest('/_userinfo', + function(res) { + if (res.status == 200) { + var error_log = "OIDC userinfo JSON failure"; + var claimsOP = ''; // Claims that are received by the OP. + try { + claimsOP = JSON.parse(res.responseBody); + } catch (e) { + error_log += ": " + res.responseBody; + r.error(error_log); + r.return(500); + return; + } + // The claimsRP is to extract claims that are configured in + // $oidc_userinfo_response_data in the RP and send them to + // the client using the response of the OP. + var claimsRP = r.variables.oidc_userinfo_response_data.split(","); + var ret = {}; + for (var i in claimsRP) { + if (claimsRP[i] in claimsOP) { + ret[claimsRP[i]] = claimsOP[claimsRP[i]]; + } + } + r.variables.user_info = JSON.stringify(ret); + r.return(200, r.variables.user_info); + } else { + r.return(res.status) + } + } + ); } \ No newline at end of file diff --git a/openid_connect.server_conf b/openid_connect.server_conf index 13456d2..9e1fd71 100644 --- a/openid_connect.server_conf +++ b/openid_connect.server_conf @@ -1,7 +1,8 @@ # Advanced configuration START set $internal_error_message "NGINX / OpenID Connect login failure\n"; set $pkce_id ""; - resolver 8.8.8.8; # For DNS lookup of IdP endpoints; + resolver 8.8.8.8; # For global DNS lookup of IDP endpoint + subrequest_output_buffer_size 32k; # To fit a complete tokenset response gunzip on; # Decompress IdP responses if necessary # Advanced configuration END @@ -42,7 +43,7 @@ proxy_set_body "grant_type=authorization_code&client_id=$oidc_client&$args&redirect_uri=$redirect_base$redir_location"; proxy_method POST; proxy_pass $oidc_token_endpoint; - } + } location = /_refresh { # This location is called by oidcAuth() when performing a token refresh. We @@ -66,17 +67,51 @@ error_page 500 502 504 @oidc_error; } + location = /userinfo { + # This location is to provide signed-in user information claims that are + # defined in $oidc_userinfo_response_data. + default_type application/json; + if ($oidc_userinfo_response_data = '') { + return 200 '{"name": "", "message":"details not provided per your policy"}'; + } + js_content oidc.userInfo; + } + + location = /_userinfo { + # This location is called by oidc.userInfo() when calling /userinfo + # to get signed-in user information from the OP: + # - https://openid.net/specs/openid-connect-core-1_0.html#UserInfo + internal; + proxy_ssl_server_name on; # For SNI to the IdP + proxy_set_header Authorization "Bearer $access_token"; + proxy_pass $oidc_userinfo_endpoint; + } + location = /logout { + # RP-Initiated Logout to interact with $oidc_end_session_endpoint as per: + # https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout status_zone "OIDC logout"; - add_header Set-Cookie "auth_token=; $oidc_cookie_flags"; # Send empty cookie - add_header Set-Cookie "auth_redir=; $oidc_cookie_flags"; # Erase original cookie js_content oidc.logout; } location = /_logout { - # This location is the default value of $oidc_logout_redirect (in case it wasn't configured) + # This location is a RP's callback URI that is the default value of + # $oidc_logout_redirect which is called by OP after successful logout. + + # Clean cookies + 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_nonce=; $oidc_cookie_flags"; + + # Enable one of the following examples. + + # Example 1. Built-in, simple logout page default_type text/plain; return 200 "Logged out\n"; + + # Example 2. Redirect to either the landing page or custom logout page + # using the map of $post_logout_return_uri. + #js_content oidc.redirectPostLogout; } location @oidc_error { diff --git a/openid_connect_configuration.conf b/openid_connect_configuration.conf index e8a9759..ddd06a4 100644 --- a/openid_connect_configuration.conf +++ b/openid_connect_configuration.conf @@ -28,6 +28,40 @@ 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 { + default "http://127.0.0.1:8080/auth/realms/master/protocol/openid-connect/logout"; +} + +map $host $oidc_end_session_query_params_option { + # 0: default query params for the RP-initiated logout + # 1: extra query params is added after the default query params + # 2: replace default query params with custom query params + default 0; +} + +map $host $oidc_end_session_query_params { + # Each IdP use different query params of the $oidc_end_session_endpoint. For + # example, The Amazon Cognito requires `client_id` and `logout_uri`. The + # Auth0 requires `client_id` and `returnTo`. If this option is empty, then + # `post_logout_redirect_uri` and `id_token_hint` are used as default query + # params, and the AzureAD/Okta/Keycloak/OneLogin/PingIdentity use them. + default ""; + #www.example.com "client_id=$oidc_client&logout_uri=$redirect_base/_logout"; +} + +map $host $oidc_userinfo_endpoint { + default "http://127.0.0.1:8080/auth/realms/master/protocol/openid-connect/userinfo"; +} + +map $host $oidc_userinfo_response_data { + # The $oidc_userinfo_endpoint returns OP's response that contains default or + # customized claims. This is used for scenarios where the SPA needs to show + # user name or specific profiles instead of forwarding the response from the + # OP to the SPA to minimize exposure of user information. + default ""; + #www.example.com "sub,name,preferred_username,given_name,family_name,email,photo"; +} + map $host $oidc_client { default "my-client-id"; } @@ -44,12 +78,35 @@ map $host $oidc_scopes { default "openid+profile+email+offline_access"; } +map $host $oidc_landing_page { + # Where to send browser after successful login. This option is only + # recommended for scenarios where a landing page shows default information + # without login, and the RP redirects to the landing page after successful + # login from the OP. If this is empty, then the RP redirects to $request_uri. + default ""; + #www.example.com $redirect_base; +} + map $host $oidc_logout_redirect { - # Where to send browser after requesting /logout location. This can be - # replaced with a custom logout page, or complete URL. + # This is a RP's callback URI which is called by OP after successful logout. default "/_logout"; # Built-in, simple logout page } +map $host $post_logout_return_uri { + # Where to send browser after the RP requests /logout to the OP, and after + # the RP (/_logout) is called by the OP and cleans cookies. The following + # examples can be replaced with a custom logout page, or a complete URL. + + # Example 1: Redirect to the langding page. + default $oidc_landing_page; + + # Example 2: Redirect to a custom logout page + #www.example.com $redirect_base/signout; + + # Example 3: Redirect to an another complete URL + #www.example.com https://www.nginx.com; +} + map $host $oidc_hmac_key { # This should be unique for every NGINX instance/cluster default "ChangeMe"; @@ -87,15 +144,20 @@ 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=refresh_tokens:1M state=conf.d/refresh_tokens.json timeout=8h; -keyval_zone zone=oidc_pkce:128K timeout=90s; # Temporary storage for PKCE code verifier. - -keyval $cookie_auth_token $session_jwt zone=oidc_id_tokens; # Exchange cookie for JWT -keyval $cookie_auth_token $refresh_token zone=refresh_tokens; # Exchange cookie for refresh token -keyval $request_id $new_session zone=oidc_id_tokens; # For initial session creation -keyval $request_id $new_refresh zone=refresh_tokens; # '' -keyval $pkce_id $pkce_code_verifier zone=oidc_pkce; +keyval_zone zone=oidc_id_tokens:1M state=/var/lib/nginx/state/oidc_id_tokens.json timeout=1h; +keyval_zone zone=oidc_access_tokens:1M state=/var/lib/nginx/state/oidc_access_tokens.json timeout=1h; +keyval_zone zone=refresh_tokens:1M state=/var/lib/nginx/state/refresh_tokens.json timeout=8h; +keyval_zone zone=oidc_pkce:128K timeout=90s; # Temporary storage for PKCE code verifier. +keyval_zone zone=oidc_userinfo:128K timeout=90s; # Temporary storage for user information. + +keyval $cookie_auth_token $session_jwt zone=oidc_id_tokens; # Exchange cookie for JWT +keyval $cookie_auth_token $access_token zone=oidc_access_tokens; # Exchange cookie for access token +keyval $cookie_auth_token $refresh_token zone=refresh_tokens; # Exchange cookie for refresh token +keyval $request_id $new_session zone=oidc_id_tokens; # For initial session creation +keyval $request_id $new_access_token zone=oidc_access_tokens; +keyval $request_id $new_refresh zone=refresh_tokens; +keyval $request_id $user_info zone=oidc_userinfo; +keyval $pkce_id $pkce_code_verifier zone=oidc_pkce; auth_jwt_claim_set $jwt_audience aud; # In case aud is an array js_import oidc from conf.d/openid_connect.js;