Skip to content

Instantly share code, notes, and snippets.

@lcrilly
Last active March 12, 2024 14:46
Show Gist options
  • Star 20 You must be signed in to star a gist
  • Fork 11 You must be signed in to fork a gist
  • Save lcrilly/c26baed7d80e84c879439d4f0fefb18a to your computer and use it in GitHub Desktop.
Save lcrilly/c26baed7d80e84c879439d4f0fefb18a to your computer and use it in GitHub Desktop.
NGINX OAuth 2.0 Token Introspection

OAuth 2.0 Token Introspection with NGINX and njs

This configuration enables NGINX to validate an authentication token against an authorization server by using OAuth 2.0 Token Introspection (RFC 7662). This solution uses the auth_request module and the NGINX JavaScript module to require authentication and perform the token introspection request.

By default, the client's authentication token is expected as a bearer token supplied in the Authorization header. If supplied elsewhere in the HTTP request, the $access_token variable must be configured to specify where to obtain the token.

Token introspection requests are authenticated. By default, the $oauth_client_id and $oauth_client_secret variables are used to perform HTTP Basic authentication with the Authorization Server. If only the $oauth_client_secret variable is specified then that value is used to perform authentication with a bearer token on the Authorization header.

Responses from the OAuth 2.0 authorization server are cached to minimize latency on each request.

If the introspection response contains member data then each member can be accessed as NGINX variables by using auth_request_set $new_variable $sent_http_token_membername;. Such variables can then be logged, used for conditional access control, and proxied upstream to provide identity metadata to the backend application.

The token introspection result is logged to the error log. Change the error_log severity level to affect verbosity.

js_import conf.d/oauth2.js;
map $http_authorization $access_token {
"~*^bearer (.*)$" $1;
default $http_authorization;
}
# This is where token introspection responses will be stored if proxy_cache is enabled
proxy_cache_path /var/cache/nginx/tokens levels=1 keys_zone=token_responses:1m max_size=10m;
server {
listen 80; # Use TLS in production
# OAuth 2.0 Token Introspection configuration
resolver 8.8.8.8; # For DNS lookup of OAuth server
subrequest_output_buffer_size 16k; # To fit a complete response from OAuth server
#error_log /var/log/nginx/error.log debug; # Enable to see introspection details
#set $access_token $http_apikey; # Where to find the token. Remove when using Authorization header
set $oauth_token_endpoint "https://idp.example.com/oauth/token/introspect";
set $oauth_token_hint "access_token"; # E.g. access_token, refresh_token
set $oauth_client_id "my-client-id"; # Will use HTTP Basic authentication unless empty
set $oauth_client_secret "my-client-secret"; # If id is empty this will be used as a bearer token
location / {
auth_request /_oauth2_token_introspection;
# Any member of the token introspection response is available as $sent_http_token_member
#auth_request_set $username $sent_http_token_username;
#proxy_set_header X-Username $username;
proxy_pass http://my_backend;
}
location = /_oauth2_token_introspection {
# This location implements an auth_request server that uses the JavaScript
# module to perform the token introspection request.
internal;
js_content oauth2.introspectAccessToken;
}
location = /_oauth2_send_introspection_request {
# This location is called by introspectAccessToken(). We use the proxy_
# directives to construct an OAuth 2.0 token introspection request, as per:
# https://tools.ietf.org/html/rfc7662#section-2
internal;
gunzip on; # Decompress if necessary
proxy_method POST;
proxy_set_header Authorization $arg_authorization;
proxy_set_header Content-Type "application/x-www-form-urlencoded";
proxy_set_body "token=$arg_token&token_hint=$oauth_token_hint";
proxy_pass $oauth_token_endpoint;
proxy_cache token_responses; # Enable caching of token introspection responses
proxy_cache_key $access_token; # Cache the response for each unique access token
proxy_cache_lock on; # Don't allow simultaneous requests for same token
proxy_cache_valid 200 10s; # How long to use cached introspection responses
proxy_cache_use_stale error timeout; # Use old responses if we cannot reach the server
proxy_ignore_headers Cache-Control Expires Set-Cookie; # Cache even when receiving these
}
}
# vim: syntax=nginx
/*
* This function is called by the NGINX auth_request directive to perform OAuth 2.0
* Token Introspection. It uses a subrequest to construct a Token Introspection request
* to the configured authorization server ($oauth_token_endpoint).
*
* Responses are aligned with the valid responses for auth_request:
* 204: token is active
* 403: token is not active
* 401: error condition (details written to error log at error level)
*
* Metadata contained within the token introspection JSON response is converted to response
* headers. These in turn are available to the auth_request location with the auth_request_set
* directive. Each member of the response is available to nginx as $sent_http_oauth_<member name>
*
* Copyright (C) 2019 Nginx, Inc.
*/
function introspectAccessToken(r) {
// Prepare Authorization header for the introspection request
var authHeader = "";
if (r.variables.oauth_client_id.length) {
var basicAuthPlaintext = r.variables.oauth_client_id + ":" + r.variables.oauth_client_secret;
authHeader = "Basic " + basicAuthPlaintext.toBytes().toString('base64');
} else {
authHeader = "Bearer " + r.variables.oauth_client_secret;
}
// Make the OAuth 2.0 Token Introspection request
r.log("OAuth sending introspection request with token: " + r.variables.access_token)
r.subrequest("/_oauth2_send_introspection_request", "token=" + r.variables.access_token + "&authorization=" + authHeader,
function(reply) {
if (reply.status != 200) {
r.error("OAuth unexpected response from authorization server (HTTP " + reply.status + "). " + reply.body);
r.return(401);
}
// We have a response from authorization server, validate it has expected JSON schema
try {
r.log("OAuth token introspection response: " + reply.responseBody)
var response = JSON.parse(reply.responseBody);
// TODO: check for errors in the JSON response first
// We have a valid introspection response
// Check for validation success
if (response.active == true) {
r.warn("OAuth token introspection found ACTIVE token");
// Iterate over all members of the response and return them as response headers
for (var p in response) {
if (!response.hasOwnProperty(p)) continue;
r.log("OAuth token value " + p + ": " + response[p]);
r.headersOut['token-' + p] = response[p];
}
r.status = 204;
r.sendHeader();
r.finish();
} else {
r.warn("OAuth token introspection found inactive token");
r.return(403);
}
} catch (e) {
r.error("OAuth token introspection response is not JSON: " + reply.body);
r.return(401);
}
}
);
r.return(401);
}
export default { introspectAccessToken }
@jaffarbh
Copy link

jaffarbh commented Jan 1, 2022

Thanks for sharing the modules Liam. I used oauth2.js with js_include in the past and that worked perfectly. However, since js_include is now replaced by js_import, the modules need to be updated. I wonder if there is up-to-date version of oauth2.js as that would be very helpful.

@lcrilly
Copy link
Author

lcrilly commented Jan 1, 2022

Done! :)

@jaffarbh
Copy link

jaffarbh commented Jan 1, 2022

You are a star, Liam :)

@Abnormalist
Copy link

Hello! i used to js_include and everything worked fine but now I faced the problem with new js_import and get the error "js function introspectAccessToken not found".

@lcrilly
Copy link
Author

lcrilly commented Jan 31, 2022

Did you also change the js_content directive to reference the imported module name?
https://gist.github.com/lcrilly/c26baed7d80e84c879439d4f0fefb18a#file-frontend-conf-L39

@Abnormalist
Copy link

Opps... I didn't. I'll fix it and try again, thank you for your answer.

@tomvandeputte
Copy link

tomvandeputte commented Oct 17, 2022

Hello everbody,

I use this functionality in our nginx application but it seems that the caching is not working.
I retry multiple times in 10 seconds and see everytime the request re-occurring.

I'm using the following configuration:
map $http_Cf_Access_Jwt_Assertion $access_token {
# "~^bearer (.)$" $1;
default $http_Cf_Access_Jwt_Assertion;
}

set $oauth_token_endpoint "http://192.168.1.10:8080/oauth/token/introspect";
set $oauth_token_hint "access_token"; # E.g. access_token, refresh_token
set $oauth_client_id ""; # Will use HTTP Basic authentication unless empty
set $oauth_client_secret ""; # If id is empty this will be used as a bearer token

proxy_method POST;
proxy_set_header Authorization $arg_authorization;
proxy_set_header Content-Type "application/x-www-form-urlencoded";
proxy_set_body "token=$arg_token&token_hint=$oauth_token_hint";
proxy_pass $oauth_token_endpoint;

        proxy_cache           token_responses; # Enable caching of token introspection responses
        proxy_cache_key       $access_token;      # Cache the response for each unique access token
        proxy_cache_lock      on;              # Don't allow simultaneous requests for same token
        proxy_cache_valid     200 10s;         # How long to use cached introspection responses
        proxy_cache_use_stale error timeout;   # Use old responses if we cannot reach the server
        proxy_ignore_headers  Cache-Control Expires Set-Cookie; # Cache even when receiving these

Any ideas on this?

@lcrilly
Copy link
Author

lcrilly commented Oct 24, 2022

@tomvandeputte , there is probably something about the response that NGINX decides is uncacheable. For example, Vary header. If you can't make a manual call to the endpoint then you may need to enable debug logging to observe the response from your introspect endpoint.

@avega-costaisa
Copy link

You need to add a 'return' to exit the function here. For example, if you don't send the access_token (required by the introspect request -IdP-) you will get a 400 status. And in this case, Nginx has to return 401 and STOP the flow.

if (reply.status != 200) {
r.error("OAuth unexpected response from authorization server (HTTP " + reply.status + "). " + reply.body);
r.return(401);
return;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment