From d051ff656ba3c7ebfa88b7dc088d17248793b693 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Mon, 6 Jul 2026 18:13:22 +0800 Subject: [PATCH] feat(forward-auth): cache auth decisions at the edge forward-auth makes a synchronous HTTP call to the authorization service on every request. Add an opt-in cache block that stores allow/deny decisions in a per-worker lrucache, cutting calls to the auth service for repeated callers. Correctness guardrails, since this is an auth path: - Cache key is namespaced per plugin conf/version and built from the configured identity headers, optionally method/URI, and (for POST) a hash of the forwarded body, so decisions never cross identities. - Honors upstream Cache-Control (no-store/no-cache/private skip caching, max-age caps the TTL) and never caches transient 5xx; short opt-in TTL by default. - Both allow and deny outcomes are replayed faithfully on a hit. Adds t/plugin/forward-auth4.t (hit/miss, identity isolation, deny replay, no-store, include_uri, POST-body keying) and documents the cache attributes with an example. --- apisix/plugins/forward-auth.lua | 181 +++++++++++-- docs/en/latest/plugins/forward-auth.md | 51 ++++ t/plugin/forward-auth4.t | 347 +++++++++++++++++++++++++ 3 files changed, 563 insertions(+), 16 deletions(-) create mode 100644 t/plugin/forward-auth4.t diff --git a/apisix/plugins/forward-auth.lua b/apisix/plugins/forward-auth.lua index d3e965d9153e..dbb04b34d1c5 100644 --- a/apisix/plugins/forward-auth.lua +++ b/apisix/plugins/forward-auth.lua @@ -18,9 +18,22 @@ local ipairs = ipairs local core = require("apisix.core") local http = require("resty.http") +local lrucache = require("resty.lrucache") local pairs = pairs local type = type local tostring = tostring +local tonumber = tonumber +local concat = table.concat +local find = string.find +local lower = string.lower +local match = string.match +local md5 = ngx.md5 + +local plugin_ctx_id = core.lrucache.plugin_ctx_id + +-- edge cache of auth decisions, shared across all routes on this worker; +-- entries are namespaced per plugin conf and identity, so no cross-route bleed +local auth_cache = lrucache.new(4096) local schema = { type = "object", @@ -89,6 +102,40 @@ local schema = { keepalive = {type = "boolean", default = true}, keepalive_timeout = {type = "integer", minimum = 1000, default = 60000}, keepalive_pool = {type = "integer", minimum = 1, default = 5}, + cache = { + type = "object", + properties = { + ttl = { + type = "integer", + minimum = 1, + maximum = 3600, + default = 5, + description = "how long, in seconds, an auth decision is cached at the edge" + }, + key_headers = { + type = "array", + minItems = 1, + items = {type = "string"}, + description = "client request headers whose values identify the caller; " + .. "the auth decision is cached per unique combination of their " + .. "values, so this must cover every token/header the auth " + .. "service uses to decide" + }, + include_method = { + type = "boolean", + default = false, + description = "include the client request method in the cache key" + }, + include_uri = { + type = "boolean", + default = false, + description = "include the client request URI in the cache key" + }, + }, + required = {"key_headers"}, + description = "opt-in caching of auth decisions at the edge (per worker) to cut " + .. "calls to the authorization service" + }, }, required = {"uri"} } @@ -111,6 +158,109 @@ function _M.check_schema(conf) end +-- build a per-conf, per-identity cache key. plugin_ctx_id namespaces it by +-- conf id and version, so config edits invalidate the cache automatically. +local function build_cache_key(conf, ctx, body) + local cache = conf.cache + local parts = {} + local n = 0 + for _, header in ipairs(cache.key_headers) do + n = n + 1 + parts[n] = header + n = n + 1 + -- NUL can never appear in a header value, so it is a safe delimiter + parts[n] = core.request.header(ctx, header) or "" + end + if cache.include_method then + n = n + 1 + parts[n] = "m:" .. core.request.get_method() + end + if cache.include_uri then + n = n + 1 + parts[n] = "u:" .. ctx.var.request_uri + end + -- the forwarded body is an input to the decision, so key on it too; + -- hash it to a fixed-size, NUL-free digest (bodies can be huge and may + -- contain NUL, which would break the delimiter) + if body then + n = n + 1 + parts[n] = "b:" .. md5(body) + end + return plugin_ctx_id(ctx, concat(parts, "\0")) +end + + +-- decide the cache TTL for an auth response, honoring upstream cache-control. +-- returns nil when the response must not be cached. +local function resolve_cache_ttl(conf, res) + -- never cache transient server errors from the auth service + if res.status >= 500 then + return nil + end + + local ttl = conf.cache.ttl + local cc = res.headers["Cache-Control"] + if type(cc) == "table" then + cc = concat(cc, ",") + end + if cc then + cc = lower(cc) + -- a shared cache must not store these + if find(cc, "no-store", 1, true) or find(cc, "no-cache", 1, true) + or find(cc, "private", 1, true) then + return nil + end + local max_age = match(cc, "max%-age%s*=%s*(%d+)") + if max_age then + max_age = tonumber(max_age) + if not max_age or max_age <= 0 then + return nil + end + if max_age < ttl then + ttl = max_age + end + end + end + return ttl +end + + +-- normalize an auth response into a compact, cacheable decision +local function build_result(conf, res) + local result = {status = res.status} + if res.status >= 300 then + local client_headers = {} + for _, header in ipairs(conf.client_headers) do + client_headers[header] = res.headers[header] + end + result.client_headers = client_headers + result.body = res.body + else + local upstream_headers = {} + for _, header in ipairs(conf.upstream_headers) do + upstream_headers[header] = res.headers[header] + end + result.upstream_headers = upstream_headers + end + return result +end + + +-- replay a decision onto the current request/response +local function apply_result(conf, ctx, result) + if result.status >= 300 then + core.response.set_header(result.client_headers) + return result.status, result.body + end + + -- set headers from the auth response, clearing any client-supplied values + -- for configured headers not present in the auth response + for _, header in ipairs(conf.upstream_headers) do + core.request.set_header(ctx, header, result.upstream_headers[header]) + end +end + + function _M.access(conf, ctx) local auth_headers = { ["X-Forwarded-Proto"] = core.request.get_scheme(ctx), @@ -173,6 +323,15 @@ function _M.access(conf, ctx) params.keepalive_pool = conf.keepalive_pool end + local cache_key + if conf.cache then + cache_key = build_cache_key(conf, ctx, params.body) + local cached = auth_cache:get(cache_key) + if cached then + return apply_result(conf, ctx, cached) + end + end + local httpc = http.new() httpc:set_timeout(conf.timeout) @@ -184,26 +343,16 @@ function _M.access(conf, ctx) return conf.status_on_error end - if res.status >= 300 then - local client_headers = {} + local result = build_result(conf, res) - if #conf.client_headers > 0 then - for _, header in ipairs(conf.client_headers) do - client_headers[header] = res.headers[header] - end + if cache_key then + local ttl = resolve_cache_ttl(conf, res) + if ttl then + auth_cache:set(cache_key, result, ttl) end - - core.response.set_header(client_headers) - return res.status, res.body end - -- set headers from the auth response, clearing any client-supplied values - -- for configured headers not present in the auth response - for _, header in ipairs(conf.upstream_headers) do - local header_value = res.headers[header] - -- if header_value is nil, the client header's value will be removed if it exists - core.request.set_header(ctx, header, header_value) - end + return apply_result(conf, ctx, result) end diff --git a/docs/en/latest/plugins/forward-auth.md b/docs/en/latest/plugins/forward-auth.md index 13f7e9ef3c73..f2bf7e6ac26a 100644 --- a/docs/en/latest/plugins/forward-auth.md +++ b/docs/en/latest/plugins/forward-auth.md @@ -63,6 +63,17 @@ The `forward-auth` Plugin supports the integration with an external authorizatio | keepalive_pool | integer | False | 5 | >= 1 | Maximum number of connections in the connection pool. | | allow_degradation | boolean | False | false | | If true, allow APISIX to continue handling requests without the Plugin when the Plugin or its dependencies become unavailable. | | status_on_error | integer | False | 403 | between 200 and 599 inclusive | HTTP status code to return to the client when there is a network error with the external authorization service. | +| cache | object | False | | | Opt-in caching of authorization decisions at the edge (per worker) to reduce the number of calls to the external authorization service. When configured, an allow/deny decision is stored in a per-worker LRU cache and replayed for subsequent matching requests. Since this is an authorization path, correctness takes precedence over speed: the cache key must cover every input that affects the decision. | +| cache.key_headers | array | True | | | Client request headers whose values identify the caller. The decision is cached per unique combination of these values, so this must include every token/header the authorization service uses to decide (e.g. `Authorization`). Required when `cache` is set. | +| cache.ttl | integer | False | 5 | between 1 and 3600 inclusive | How long, in seconds, a decision is cached. A shorter TTL bounds how long a stale decision can be served. If the authorization response carries `Cache-Control: max-age=`, the effective TTL is capped at ``. | +| cache.include_method | boolean | False | false | | If true, include the client request method in the cache key, so different methods are cached separately. | +| cache.include_uri | boolean | False | false | | If true, include the client request URI in the cache key, so different URIs are cached separately. | + +:::note + +Caching is fully opt-in; with no `cache` block the Plugin calls the authorization service on every request as before. Responses carrying `Cache-Control: no-store`, `no-cache`, or `private` are never cached, and transient `5xx` responses from the authorization service are never cached. When `request_method` is `POST`, the forwarded request body is also part of the cache key. Only enable caching when the decision is a stable function of the configured `cache.key_headers` (plus method/URI/body where applicable). + +::: ## Examples @@ -1112,3 +1123,43 @@ You should receive an `HTTP/1.1 403 Forbidden` response of the following: ```text tenant_id is 000 but expecting 123 ``` + +### Cache Authorization Decisions at the Edge + +The following example shows how to cache authorization decisions to reduce the number of calls to the external authorization service. This is useful when the same caller (identified by one or more headers) makes many requests in a short window. + +Reusing the mock auth service from the earlier examples, create a Route with the `forward-auth` Plugin and a `cache` block keyed on the `Authorization` header: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -H 'Content-Type: application/json' \ + -d '{ + "id": "forward-auth-cache", + "uri": "/headers", + "plugins": { + "forward-auth": { + "uri": "http://127.0.0.1:9080/auth", + "request_headers": ["Authorization"], + "cache": { + "key_headers": ["Authorization"], + "ttl": 30 + } + } + }, + "upstream": { + "nodes": { + "httpbin.org:80": 1 + }, + "type": "roundrobin" + } + }' +``` + +Send several requests carrying the same `Authorization` header within the TTL window: + +```shell +curl -i "http://127.0.0.1:9080/headers" -H "Authorization: 123" +``` + +Only the first request reaches the authorization service; subsequent requests with the same `Authorization` value are served from the per-worker cache until the entry expires (30 seconds here) or the authorization response's `Cache-Control` directives shorten or forbid caching. Requests carrying a different `Authorization` value are evaluated independently and never served another caller's cached decision. diff --git a/t/plugin/forward-auth4.t b/t/plugin/forward-auth4.t new file mode 100644 index 000000000000..e196ad3b252e --- /dev/null +++ b/t/plugin/forward-auth4.t @@ -0,0 +1,347 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!defined $block->request) { + $block->set_value("request", "GET /t"); + } +}); + +run_tests(); + +__DATA__ + +=== TEST 1: schema, cache requires key_headers and a bounded ttl +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.forward-auth") + local cases = { + {uri = "http://127.0.0.1:8199", cache = {}}, + {uri = "http://127.0.0.1:8199", cache = {key_headers = {}}}, + {uri = "http://127.0.0.1:8199", cache = {key_headers = {"Authorization"}}}, + {uri = "http://127.0.0.1:8199", cache = {key_headers = {"Authorization"}, ttl = 0}}, + {uri = "http://127.0.0.1:8199", cache = {key_headers = {"Authorization"}, ttl = 4000}}, + } + for _, case in ipairs(cases) do + local ok, err = plugin.check_schema(case) + ngx.say(ok and "done" or err) + end + } + } +--- response_body +property "cache" validation failed: property "key_headers" is required +property "cache" validation failed: property "key_headers" validation failed: expect array to have at least 1 items +done +property "cache" validation failed: property "ttl" validation failed: expected 0 to be at least 1 +property "cache" validation failed: property "ttl" validation failed: expected 4000 to be at most 3600 + + + +=== TEST 2: set up auth service, echo upstream and cached/uncached routes +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local data = { + { + url = "/apisix/admin/upstreams/u1", + data = [[{ + "nodes": { "127.0.0.1:1984": 1 }, + "type": "roundrobin" + }]], + }, + -- auth service: counts every call it actually receives so tests can + -- assert that cache hits skip it. "deny" tokens are rejected. + { + url = "/apisix/admin/routes/auth", + data = [[{ + "plugins": { + "serverless-pre-function": { + "phase": "rewrite", + "functions": [ + "return function(conf, ctx) + local core = require(\"apisix.core\") + local dict = ngx.shared[\"internal-status\"] + dict:incr(\"fa_count\", 1, 0) + local auth = core.request.header(ctx, \"Authorization\") + if auth == \"deny\" then + core.response.exit(403, \"denied\") + end + core.response.set_header(\"X-Auth-User\", auth) + if core.request.header(ctx, \"No-Store\") == \"1\" then + core.response.set_header(\"Cache-Control\", \"no-store\") + end + core.response.exit(200) + end" + ] + } + }, + "uri": "/auth" + }]], + }, + { + url = "/apisix/admin/routes/echo", + data = [[{ + "plugins": { + "serverless-pre-function": { + "phase": "rewrite", + "functions": [ + "return function (conf, ctx) + local core = require(\"apisix.core\") + core.response.exit(200, core.request.headers(ctx)) + end" + ] + } + }, + "uri": "/echo" + }]], + }, + { + url = "/apisix/admin/routes/cached", + data = [[{ + "plugins": { + "forward-auth": { + "uri": "http://127.0.0.1:1984/auth", + "request_headers": ["Authorization", "No-Store"], + "upstream_headers": ["X-Auth-User"], + "cache": { "key_headers": ["Authorization"], "ttl": 10 } + }, + "proxy-rewrite": { "uri": "/echo" } + }, + "upstream_id": "u1", + "uri": "/cached" + }]], + }, + { + url = "/apisix/admin/routes/cached-uri", + data = [[{ + "plugins": { + "forward-auth": { + "uri": "http://127.0.0.1:1984/auth", + "request_headers": ["Authorization"], + "cache": { "key_headers": ["Authorization"], "ttl": 10, "include_uri": true } + }, + "proxy-rewrite": { "uri": "/echo" } + }, + "upstream_id": "u1", + "uri": "/cached-uri" + }]], + }, + { + url = "/apisix/admin/routes/uncached", + data = [[{ + "plugins": { + "forward-auth": { + "uri": "http://127.0.0.1:1984/auth", + "request_headers": ["Authorization"] + }, + "proxy-rewrite": { "uri": "/echo" } + }, + "upstream_id": "u1", + "uri": "/uncached" + }]], + }, + { + url = "/apisix/admin/routes/cached-post", + data = [[{ + "plugins": { + "forward-auth": { + "uri": "http://127.0.0.1:1984/auth", + "request_method": "POST", + "request_headers": ["Authorization"], + "cache": { "key_headers": ["Authorization"], "ttl": 10 } + }, + "proxy-rewrite": { "uri": "/echo" } + }, + "upstream_id": "u1", + "uri": "/cached-post" + }]], + }, + } + for _, d in ipairs(data) do + local code = t(d.url, ngx.HTTP_PUT, d.data) + if code >= 300 then + ngx.say("failed to set ", d.url, ": ", code) + return + end + end + ngx.say("done") + } + } +--- response_body +done + + + +=== TEST 3: identical requests hit the cache, auth service called once +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + local dict = ngx.shared["internal-status"] + dict:set("fa_count", 0) + local function call() + local httpc = http.new() + local res = httpc:request_uri("http://127.0.0.1:1984/cached", + {headers = {["Authorization"] = "alice"}}) + return res.status + end + local s1 = call() + local s2 = call() + ngx.say("status: ", s1, " ", s2) + ngx.say("auth calls: ", dict:get("fa_count")) + } + } +--- response_body +status: 200 200 +auth calls: 1 + + + +=== TEST 4: different identity is never served another's cached decision +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + local dict = ngx.shared["internal-status"] + dict:set("fa_count", 0) + local function call(auth) + local httpc = http.new() + local res = httpc:request_uri("http://127.0.0.1:1984/cached", + {headers = {["Authorization"] = auth}}) + return res.status + end + local allowed = call("bob") -- miss, allowed + local denied = call("deny") -- different key, must be evaluated -> 403 + local hit = call("bob") -- cache hit + local denied2 = call("deny") -- cached deny, still 403 + ngx.say("allowed: ", allowed) + ngx.say("denied: ", denied) + ngx.say("hit: ", hit) + ngx.say("denied2: ", denied2) + ngx.say("auth calls: ", dict:get("fa_count")) + } + } +--- response_body +allowed: 200 +denied: 403 +hit: 200 +denied2: 403 +auth calls: 2 + + + +=== TEST 5: caching disabled, every request calls the auth service +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + local dict = ngx.shared["internal-status"] + dict:set("fa_count", 0) + local function call() + local httpc = http.new() + local res = httpc:request_uri("http://127.0.0.1:1984/uncached", + {headers = {["Authorization"] = "carol"}}) + return res.status + end + call() + call() + call() + ngx.say("auth calls: ", dict:get("fa_count")) + } + } +--- response_body +auth calls: 3 + + + +=== TEST 6: upstream Cache-Control no-store is honored, decision not cached +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + local dict = ngx.shared["internal-status"] + dict:set("fa_count", 0) + local function call() + local httpc = http.new() + local res = httpc:request_uri("http://127.0.0.1:1984/cached", + {headers = {["Authorization"] = "dave", ["No-Store"] = "1"}}) + return res.status + end + call() + call() + ngx.say("auth calls: ", dict:get("fa_count")) + } + } +--- response_body +auth calls: 2 + + + +=== TEST 7: include_uri keys on the request URI, different URIs miss +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + local dict = ngx.shared["internal-status"] + dict:set("fa_count", 0) + local function call(path) + local httpc = http.new() + local res = httpc:request_uri("http://127.0.0.1:1984" .. path, + {headers = {["Authorization"] = "erin"}}) + return res.status + end + call("/cached-uri?a=1") + call("/cached-uri?a=1") -- same uri -> hit + call("/cached-uri?a=2") -- different uri -> miss + ngx.say("auth calls: ", dict:get("fa_count")) + } + } +--- response_body +auth calls: 2 + + + +=== TEST 8: POST body is part of the cache key, different bodies miss +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + local dict = ngx.shared["internal-status"] + dict:set("fa_count", 0) + local function call(body) + local httpc = http.new() + local res = httpc:request_uri("http://127.0.0.1:1984/cached-post", + {method = "POST", headers = {["Authorization"] = "frank"}, body = body}) + return res.status + end + call("payload-a") + call("payload-a") -- same body -> hit + call("payload-b") -- different body -> miss + ngx.say("auth calls: ", dict:get("fa_count")) + } + } +--- response_body +auth calls: 2