Set up OIDC authentication with Microsoft Entra

Overview

Learn how to configure F5 NGINX as a Service for Azure (NGINXaaS) OpenID Connect (OIDC) authentication with Microsoft Entra as the identity provider.

Prerequisites

  1. Configure an NGINXaaS deployment with SSL/TLS certificates.

  2. Enable Runtime State Sharing on the NGINXaaS deployment.

  3. Register a Microsoft Entra Web application with configuring Web Redirect URIs to https://<nginxaas_deployment_fqdn>:443/_codexch.

  4. The saved information from the prerequistie 3:

    • <Application (client) ID>
    • <Directory (tenant) ID>
    • <Client Secret value>

Fetch the OpenID Connect metadata URL from Microsoft Entra application

Under the registered Microsoft Entra app, select Overview, on the right panel, select Endpoints, save the value of OpenID Connect metadata document as <OpenID Connect metadata URL>. The URL will be different depending on the account type of the Entra application, for example:

  • https://login.microsoftonline.com/<Directory (tenant) ID>/v2.0/.well-known/openid-configuration (Single-tenant - Accounts in this organizational directory only)
  • https://login.microsoftonline.com/organizations/v2.0/.well-known/openid-configuration (Multi-tenant - Accounts in any organizational directory)
  • https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration (Multi-tenant - Accounts in any organizational directory and personal Microsoft accounts).
  • https://login.microsoftonline.com/consumers/v2.0/.well-known/openid-configuration (Personal Microsoft accounts only)

Configure NGINX as a Service for Azure to interact with the Entra ID application

Configure NGINXaaS for Azure to use Microsoft Entra as the identity provider. The following steps are based on a single-tenant Entra application. Make sure use the correct <OpenID Connect metadata URL> for different account type of Entra application.

  1. Create a clone of the nginx-openid-connect GitHub repository.

    git clone https://github.com/nginxinc/nginx-openid-connect
    
  2. Run the configure.sh script, and then continue Step 4.

    ./configure.sh \
        --host <NGINXaaS Deployment FQDN> \
        --client_id <Application (client) ID> \
        --client_secret <Client Secret value> \
        --auth_jwt_key request <OpenID Connect metadata URL>
    
    Note:
    It is recommended to check whether the generated configuration is correct in Step 4.
  3. Run the following command in the terminal to get the OpenID Connect metadata data: authorization endpoint, token endpoint, and JSON Web Key (JWT) file from the <OpenID Connect metadata URL> of Entra application. For example:

    curl https://login.microsoftonline.com/<Directory (tenant) ID>/v2.0/.well-known/openid-configuration | jq
    

    An example of the output is as below:

    {
        "token_endpoint": "https://login.microsoftonline.com/<Directory (tenant) ID>/oauth2/v2.0/token",
        ...
        "jwks_uri": "https://login.microsoftonline.com/<Directory (tenant) ID>/discovery/v2.0/keys",
        ...
        "authorization_endpoint": "https://login.microsoftonline.com/<Directory (tenant) ID>/oauth2/v2.0/authorize",
        ...
    }
    
  4. Modify openid_connect_configuration.conf:

    • Change the default parameter value of each of the following map directives to the specified value. This is also the time to check the generated configuration from Step 2.

      map $host $oidc_authz_endpoint - Value of authorization_endpoint from Step 3
      map $host $oidc_token_endpoint - Value of token_endpoint from Step 3
      map $host $oidc_jwt_keyfile - Value of jwks_uri from Step 3
      map $host $oidc_client – Value of the Application (client) ID from Entra
      map $host $oidc_client_secret – Value of the Client Secret from Entra
      map $host $oidc_hmac_key – A unique, long, and secure phrase
      map $host $zone_sync_leeway - A proper time value of the maximum timeout for zone sync
      
    • If the configuration already has a proxy_cache_path directive, ensure the cache path is different from the path used in this example. For more information on caching, see Enable content caching.

      proxy_cache_path /var/cache/nginx/jwt levels=1 keys_zone=jwk:64k max_size=1m;
      
    • Enable sync for the keyval memory zones and specify the state files to persist the current state across NGINX restarts. The state file paths are subject to NGINXaaS filesystem restrictions and must be placed in a directory accessible to the NGINX worker processes.

      keyval_zone zone=oidc_id_tokens:1M     state=/opt/oidc_id_tokens.json     timeout=1h sync;
      keyval_zone zone=oidc_access_tokens:1M state=/opt/oidc_access_tokens.json timeout=1h sync;
      keyval_zone zone=refresh_tokens:1M     state=/opt/refresh_tokens.json     timeout=8h sync;
      keyval_zone zone=oidc_pkce:128K timeout=90s sync; # Temporary storage for PKCE code verifier.
      
      Note:
      The state parameter for keyval_zone is optional.
    • You may modify the URI defined in map $host $oidc_logout_redirect to specify an unprotected resource to be displayed after requesting the /logout location.

      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.
          default "/_logout"; # Built-in, simple logout page
      }
      
    • Change the js_import oidc from conf.d/openid_connect.js; to match the njs script’s file path. For example nginx/conf.d/openid_connect.js. See details in Step 7 to set the correct value.

      js_import oidc from nginx/conf.d/openid_connect.js;
      
    Example openid_connect_configuration.conf
    # OpenID Connect configuration
    #
    # Each map block allows multiple values so that multiple IdPs can be supported,
    # the $host variable is used as the default input parameter but can be changed.
    #
    map $host $oidc_authz_endpoint {
        <nginxaas_deployment_fqdn> https://login.microsoftonline.com/<Directory (tenant) ID>/oauth2/v2.0/authorize;
        default https://login.microsoftonline.com/<Directory (tenant) ID>/oauth2/v2.0/authorize;
        #www.example.com "https://my-idp/oauth2/v1/authorize";
    }
    
    map $host $oidc_authz_extra_args {
        # Extra arguments to include in the request to the IdP's authorization
        # endpoint.
        # Some IdPs provide extended capabilities controlled by extra arguments,
        # for example Keycloak can select an IdP to delegate to via the
        # "kc_idp_hint" argument.
        # Arguments must be expressed as query string parameters and URL-encoded
        # if required.
        default "";
        #www.example.com "kc_idp_hint=another_provider"
    }
    
    map $host $oidc_token_endpoint {
        <nginxaas_deployment_fqdn> https://login.microsoftonline.com/<Directory (tenant) ID>/oauth2/v2.0/token;
        default https://login.microsoftonline.com/<Directory (tenant) ID>/oauth2/v2.0/token;
    }
    
    map $host $oidc_jwt_keyfile {
        <nginxaas_deployment_fqdn> https://login.microsoftonline.com/<Directory (tenant) ID>/discovery/v2.0/keys;
        default https://login.microsoftonline.com/<Directory (tenant) ID>/discovery/v2.0/keys;
    }
    
    map $host $oidc_client {
        <nginxaas_deployment_fqdn> <Application (client) ID>;
        default <Application (client) ID>;
    }
    
    map $host $oidc_pkce_enable {
        default 0;
    }
    
    map $host $oidc_client_secret {
        <nginxaas_deployment_fqdn> <Client Secret value>;
        default <Client Secret value> ;
    }
    
    map $host $oidc_scopes {
        default "openid+profile+email+offline_access";
    }
    
    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.
        default "/_logout"; # Built-in, simple logout page
    }
    
    map $host $oidc_hmac_key {
        <nginxaas_deployment_fqdn> znKq3IB5YeP1t+RDCfgHaJ2O;
        # This should be unique for every NGINX instance/cluster
        default znKq3IB5YeP1t+RDCfgHaJ2O;
    }
    
    map $host $zone_sync_leeway {
        # Specifies the maximum timeout for synchronizing ID tokens between cluster
        # nodes when you use shared memory zone content sync. This option is only
        # recommended for scenarios where cluster nodes can randomly process
        # requests from user agents and there may be a situation where node "A"
        # successfully received a token, and node "B" receives the next request in
        # less than zone_sync_interval.
        default 2000; # Time in milliseconds, e.g. (zone_sync_interval * 2 * 1000)
    }
    
    map $proto $oidc_cookie_flags {
        http  "Path=/; SameSite=lax;"; # For HTTP/plaintext testing
        https "Path=/; SameSite=lax; HttpOnly; Secure;"; # Production recommendation
    }
    
    map $http_x_forwarded_port $redirect_base {
        ""      $proto://$host:$server_port;
        default $proto://$host:$http_x_forwarded_port;
    }
    
    map $http_x_forwarded_proto $proto {
        ""      $scheme;
        default $http_x_forwarded_proto;
    }
    
    # ADVANCED CONFIGURATION BELOW THIS LINE
    # Additional advanced configuration (server context) in openid_connect.server_conf
    
    # JWK Set will be fetched from $oidc_jwks_uri and cached here - ensure writable by nginx user
    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=/opt/oidc_id_tokens.json     timeout=1h sync;
    keyval_zone zone=oidc_access_tokens:1M state=/opt/oidc_access_tokens.json timeout=1h sync;
    keyval_zone zone=refresh_tokens:1M     state=/opt/refresh_tokens.json     timeout=8h sync;
    keyval_zone zone=oidc_pkce:128K timeout=90s sync; # Temporary storage for PKCE code verifier.
    
    keyval $cookie_auth_token $session_jwt   zone=oidc_id_tokens;     # Exchange cookie for 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 $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 nginx/conf.d/openid_connect.js;
    
  5. Modify openid_connect.server_conf file:

    • Remove the location /api/ block, since NGINXaaS for Azure currently restricts access to the api directive.

      location /api/ {
          api write=on;
          allow 127.0.0.1; # Only the NGINX host may call the NGINX Plus API
          deny all;
          access_log off;
      }
      
    Example openid_connect.server_conf
    # 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;
    subrequest_output_buffer_size 32k; # To fit a complete tokenset response
    gunzip on; # Decompress IdP responses if necessary
    # Advanced configuration END
    
    location = /_jwks_uri {
        internal;
        proxy_cache jwk;                              # Cache the JWK Set recieved 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_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
        proxy_ignore_headers Cache-Control Expires Set-Cookie; # Does not influence caching
    }
    
    location @do_oidc_flow {
        status_zone "OIDC start";
        js_content oidc.auth;
        default_type text/plain; # In case we throw an error
    }
    
    set $redir_location "/_codexch";
    location = /_codexch {
        # 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;
    }
    
    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
        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_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
        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_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
        internal;
        auth_jwt "" token=$arg_token;
        js_content oidc.validateIdToken;
        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
        js_content oidc.logout;
    }
    
    location = /_logout {
        # This location is the default value of $oidc_logout_redirect (in case it wasn't configured)
        default_type text/plain;
        return 200 "Logged out\n";
    }
    
    location @oidc_error {
        # This location is called when oidcAuth() or oidcCodeExchange() returns an error
        status_zone "OIDC error";
        default_type text/plain;
        return 500 $internal_error_message;
    }
    
  6. Modify the frontend.conf file to create a valid NGINX root config file. For an existing nginx.conf, simply copy the location / { ... } block from frontend.conf to the desired server block then follow the steps as below:

    • Wrap all lines in an http block http {} in frontend.conf. Skip for an existing nginx.conf.
    • Change the upstream server address to match your backend applications. Skip for an existing nginx.conf.
    • Add load_module modules/ngx_http_js_module.so; to the top of the root config file, if it doesn’t exist.
    • Add include conf.d/openid_connect_configuration.conf; in the http block before the server block and change to the matched file path . See details in Step 7 to set the correct file path.
    • Add include conf.d/openid_connect.server_conf; in the server block if it doesn’t exist, then change the value to the matched file path. See details in Step 7 to set the correct file path.
    • Replace listen to use 443 ssl; and specify the ssl_certificate and ssl_certificate_key directives to match the NGINXaaS deployment’s certificate and key path in frontend.conf. For an existing nginx.conf, make sure the desired server block has SSL enabled with correct directives.
    • Make sure auth_jwt_key_file $oidc_jwt_keyfile; is commented and auth_jwt_key_request /_jwks_uri; is uncommented to use JWKS URI.
    • Change the location URI, proxy_pass URL and access_log to your own config.
    • Add a stream block with zone_sync enabled if it doesn’t exist; see Runtime State Sharing.
    Example frontend.conf using the localhost as a upstream server
    load_module modules/ngx_http_js_module.so;
    
    http {
    
        # This is the backend application we are protecting with OpenID Connect
        upstream my_backend {
            zone my_backend 64k;
            # Reuse the localhost as a upstream server
            # Modify to the real upstream server address if you have
            server 127.0.0.1;
        }
    
        # A local server block representing the upstream server for testing only
        # Remove if you have the real upstream servers
        server {
            listen 80;
            default_type text/html;
            return 200 '<!DOCTYPE html><h2>This is a site protected by OIDC!</h2>\n';
        }
    
        # Custom log format to include the 'sub' claim in the REMOTE_USER field
        log_format main_jwt '$remote_addr - $jwt_claim_sub [$time_local] "$request" $status '
                            '$body_bytes_sent "$http_referer" "$http_user_agent" "$http_x_forwarded_for"';
    
        # The frontend server - reverse proxy with OpenID Connect authentication
        #
        include conf.d/openid_connect_configuration.conf;
        server {
            include conf.d/openid_connect.server_conf; # Authorization code flow and Relying Party processing
            error_log /var/log/nginx/error.log debug;  # Reduce severity level as required
    
            listen 443 ssl; # Use SSL/TLS in production
            ssl_certificate /etc/nginx/ssl/my-cert.crt;
            ssl_certificate_key /etc/nginx/ssl/my-cert.key;
    
            location / {
                # This site is protected with OpenID Connect
                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
    
                # Successfully authenticated users are proxied to the backend,
                # with 'sub' claim passed as HTTP header
                proxy_set_header username $jwt_claim_sub;
    
                # Bearer token is uses to authorize NGINX to access protected backend
                #proxy_set_header Authorization "Bearer $access_token";
    
                # Intercept and redirect "401 Unauthorized" proxied responses to nginx
                # for processing with the error_page directive. Necessary if Access Token
                # can expire before ID Token.
                #proxy_intercept_errors on;
    
                proxy_pass http://my_backend; # The backend site/app
    
                access_log /var/log/nginx/access.log main_jwt;
            }
        }
    }
    
    stream {
        # Add localhost resolver for internal clustering hostname with resolver metrics collection
        resolver 127.0.0.1:49153 valid=20s status_zone=stream_resolver_zone1;
    
        server {
            listen 9000;
            zone_sync;
            zone_sync_server internal.nginxaas.nginx.com:9000 resolve;
        }
    }
    
  7. Make sure all the file paths in the config are set properly, and upload the NGINX configuration files.

    • When you select + New File to add a file path via the portal, the file path in the configuration should match the path where you add the file. For example, if your existing file structure in the portal is /etc/nginx/, and you add /etc/nginx/conf.d/openid_connect_configuration.conf, /etc/nginx/conf.d/locations/openid_connect.server_conf, and /etc/nginx/conf.d/openid_connect.js, you should set the File path field to these exact paths after each + New File selection. The configuration file paths should look like this:

      • /etc/nginx/conf.d/openid_connect_configuration.conf
      • /etc/nginx/conf.d/locations/openid_connect.server_conf
      • /etc/nginx/conf.d/openid_connect.js

      In the http block before the server block:

      include /etc/nginx/conf.d/openid_connect_configuration.conf;
      

      In the server block:

      include /etc/nginx/conf.d/locations/openid_connect.server_conf;
      

      In openid_connect_configuration.conf:

      js_import oidc from /etc/nginx/conf.d/openid_connect.js;
      

      The expected output file structure example is as below:

      Screenshot of the configuration result using New File in the Azure portal

    • When you upload a GZIP NGINX configuration, the file path in the configuration should match the file path in the archive. Given nginx is a directory with the following structure when nginx.tar.gz is created via tar -czf nginx.tar.gz nginx:

      $ tree nginx
      nginx
      ├── conf.d
      │   ├── openid_connect.js
      │   ├── openid_connect.server_conf
      │   └── openid_connect_configuration.conf
      └── nginx.conf
      

      In the http block before the server block:

      include conf.d/openid_connect_configuration.conf;
      

      In the server block:

      include conf.d/openid_connect.server_conf;
      

      In openid_connect_configuration.conf:

      js_import oidc from nginx/conf.d/openid_connect.js;
      

      The expected output example is as below:

      Screenshot of the configuration result uploading a GZIP file in the Azure portal

    See Upload an NGINX configuration for more details.

  8. In a web browser, enter https://<nginxaas_deployment_fqdn>/<protected_uri>, it will be redirected to the Microsoft IDP server. And after a successful login using the credentials of a user who has the authorization, the protected URI can be accessed. For example, with the frontend.conf in this guide, enter https://<nginxaas_deployment_fqdn>/ and pass the authentication, it will show:

    This is a site protected by OIDC!