Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 165 additions & 16 deletions apisix/plugins/forward-auth.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"}
}
Expand All @@ -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),
Expand Down Expand Up @@ -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)

Expand All @@ -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


Expand Down
51 changes: 51 additions & 0 deletions docs/en/latest/plugins/forward-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<n>`, the effective TTL is capped at `<n>`. |
| 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

Expand Down Expand Up @@ -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.
Loading
Loading