Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9176508
fix(access): set the http timeouts in the right order
rohilsurana Sep 8, 2026
0fb1f00
fix(jwt): stop a malformed token from raising in the decoder
rohilsurana Sep 8, 2026
61383ef
fix(access): refuse a token the plugin cannot read claims from
rohilsurana Sep 8, 2026
3dced35
feat(cache): cache the frontier authn token call in redis
rohilsurana Sep 8, 2026
1879636
test: cover the cache and the token handling
rohilsurana Sep 8, 2026
c037ab6
docs: document the token cache
rohilsurana Sep 8, 2026
9dc1080
refactor(redis): name the connection pool without hashing the password
rohilsurana Sep 11, 2026
ee3345a
refactor(schema): drop the redis keepalive and pool size knobs
rohilsurana Sep 11, 2026
fb79196
docs: explain how redis connections are pooled
rohilsurana Sep 11, 2026
550c5d5
refactor(cache): store for the configured ttl without parsing the token
rohilsurana Sep 11, 2026
0535ec8
test: drop the ttl clamp cases the cache no longer has
rohilsurana Sep 11, 2026
5482418
docs: state that cache_ttl must stay under the token lifetime
rohilsurana Sep 11, 2026
d58daeb
refactor: name things so the cache and redis code reads without comments
rohilsurana Sep 11, 2026
b635a5b
refactor: name things so the access path reads without comments
rohilsurana Sep 11, 2026
46b8128
fix(cache): read cookie values exactly as the auth server does
rohilsurana Sep 15, 2026
f813aad
fix: do not let a malformed auth server body raise
rohilsurana Sep 15, 2026
e766d48
perf(cache): build the key only when there is a redis to use
rohilsurana Sep 15, 2026
898b2d3
fix(redis): pause the instance when credentials are refused
rohilsurana Sep 15, 2026
4c7af14
test: cover redis.lua against a stubbed client
rohilsurana Sep 15, 2026
162ec08
test: drive every exit of the auth server call
rohilsurana Sep 15, 2026
ddf1341
test: assert the cache ttl ceiling and the removed fields
rohilsurana Sep 15, 2026
2fb8220
chore: bump the plugin version to 0.2.0
rohilsurana Sep 15, 2026
cdb5525
feat(cache): version the entry format so a rollout cannot mix them
rohilsurana Sep 15, 2026
c6f7d59
fix(cache): trim only the whitespace the auth server trims
rohilsurana Sep 16, 2026
31967b2
fix(redis): key the pool and the pause on the password too
rohilsurana Sep 16, 2026
5b0433a
chore: report 0.2.0 from the handler as well
rohilsurana Sep 16, 2026
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
167 changes: 166 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ Kong plugin to use with [frontier](https://github.com/raystack/frontier/) auth s
### TODO
- Add test cases
- https://github.com/lunarmodules/luacheck
- cache frontier response - https://docs.konghq.com/gateway/latest/plugin-development/entities-cache/#cache-custom-entities

### Notes
- Add plugin configuration in kong.yml file where url is a required field
Expand Down Expand Up @@ -65,6 +64,172 @@ disabled = {
default = false
}
```
### Token caching

Works on Kong 3.4 and later. It only uses modules that ship with Kong and
OpenResty, so there is nothing extra to install.

The plugin exchanges the incoming cookie or bearer for a user token on every
request. Setting `redis_host` caches that exchange in redis, so the same
credential is not exchanged again for a few seconds. The lookup is redis first,
then the auth server on a miss.

Redis is shared by every pod, so a token is fetched once for the whole fleet
rather than once per pod.

The default ttl is 5 seconds. It is deliberately short: a cached token means a
change to someone's access is not picked up until the entry expires.

**Caching needs redis.** Without `redis_host` there is nowhere to keep a token,
so `cache_ttl` does nothing on its own and every request goes to the auth
server, exactly as it did before this existed.

```yaml
plugins:
- name: frontier
config:
authn_url: ...
redis_host: redis.internal
redis_port: 6379
```

| Field | Default | What it does |
|---|---|---|
| `cache_ttl` | `5` | Seconds a token is reused for. `0` turns caching off. Max `300` |
| `cache_cookie_names` | `["sid"]` | Only these cookies go into the cache key |
| `redis_host` | unset | Setting it turns caching on |
| `redis_port` | `6379` | |
| `redis_timeout` | `100` | Milliseconds, for connect, send and read |
| `redis_username` | unset | Redis 6 ACL user, if you use one |
| `redis_password` | unset | |
| `redis_database` | `0` | |
| `redis_ssl` | `false` | |
| `redis_ssl_verify` | `false` | Needs `lua_ssl_trusted_certificate` set on the gateway |
| `redis_server_name` | unset | SNI, when using SSL |
| `redis_key_prefix` | `frontier:authn:` | Prefix on every key |
| `redis_breaker_seconds` | `10` | How long a worker stops trying after a failure |

Connections are reused through OpenResty's own connection pool, keyed by host,
port, database, user, hashed password and whether SSL is on. Two plugin configs
that mean the same thing share a pool; two that differ do not. The same key
separates the pause, so a config with the wrong password cannot pause one with
the right password. The pool is not configurable, the same way it is not in the
bundled rate limiting plugin.

The timeout default is 100ms, much lower than the bundled rate limiting
plugin's 2000ms. A healthy redis answers in well under a millisecond, so 100ms
is already a hundred times the expected latency. This sits in the auth path, so
a redis slower than that should be given up on rather than held onto. The cost
of being wrong is small: the request goes to the auth server instead, and the
worker stops trying redis for `redis_breaker_seconds`.

#### How redis behaves

**It never fails a request.** Redis is a cache, not an authority. A connect
error, a timeout, a bad reply, even a raise, is logged and the plugin carries on
to the auth server.

When anything to an instance fails, the worker stops trying that instance for
`redis_breaker_seconds`, so an outage cannot make every request pay the timeout
first. The pause is per instance, so a fault on one redis does not stop the
worker talking to another. A refused password or database index starts the pause
too. Without that, a mistyped password would cost a fresh connection, a login
round trip and a warning line on every single request, and a full TLS handshake
as well when `redis_ssl` is on. The warning says which it was, so the pause
hides nothing.

**Treat write access to this redis as equal to being any user.** The plugin
never checks the token signature, with or without redis. It trusts whatever the
auth server hands back. So anything that can write these keys can put a token of
its choosing in front of the upstream. The entries also hold live user tokens,
which is more sensitive than something like rate limit counters. Turn on auth
and SSL if the instance is shared or reachable from outside the cluster, and
keep `redis_key_prefix` set so the keys cannot collide with anything else using
it.

#### What it does and does not cache

- The cache key is a sha256 of the cookies named in `cache_cookie_names`, the
authorization header, and the config that decides what an entry means:
`authn_url`, `http_method`, `header_name`, `token_response_field` and
`cache_ttl`. The session value is never stored in plain text, and two routes
that would resolve a credential differently cannot share an entry.
- The key also carries an entry format version. Every pod shares these keys, so
during a rollout pods on two plugin versions read the same entries. Bumping
`ENTRY_FORMAT_VERSION` in `cache.lua` whenever the stored value or the key
recipe changes keeps the two apart. A spec pins the recipe so a change that
forgets the bump fails.
- Only the named cookies go into the key. Browsers send analytics and consent
cookies that change constantly, so keying on the whole cookie header would
miss on nearly every request.
- Cookie values are read exactly the way the auth server reads them. Frontier
uses Go's `net/http`, which keeps a space that follows the `=`, strips a
surrounding pair of double quotes, and drops a cookie whose value holds a
byte it does not allow. Getting any of that wrong would let two different
sessions share one entry.
- A request with none of those credentials is never cached, so anonymous
requests cannot share an entry.
- A failed exchange is never cached. A user who has just been given access is
not locked out for the length of the ttl.
- The entry lives for exactly `cache_ttl`. The token is never parsed, so
**`cache_ttl` has to stay well under your auth server's token lifetime**, or
the cache will hand out tokens that have already expired. Frontier mints a
fresh token on every call and its `token.validity` defaults to an hour, so
the default of 5 seconds leaves a very wide margin. The ceiling of 300 is
there so a careless value cannot get close.
- Only the authn call is cached. The authz check in `authz_url` still runs on
every request.
- There is no lock, so several requests arriving together with the same new
credential will each fetch a token. They all write an equivalent entry, and
every request after that is served from redis.
- A token is read for its `exp` when it is stored, and for the claims that
become headers when it is used. Nothing else about it is assumed, so a token
that is valid JSON but is not shaped like a JWT is refused rather than half
applied.

#### What it costs and saves

Measured against a real Frontier and a real redis, with Kong in DB-less mode.
Absolute numbers come from docker on macOS, where container networking is slow,
so read the gaps rather than the values.

One session making requests as fast as it can for 20 seconds, `cache_ttl` at 5:

| | Requests served | Auth server calls |
|---|---|---|
| Caching on | 455 | 4 |
| Caching off | 262 | 262 |

Four calls in a 20 second window is what a 5 second ttl should give. The same
client also got through 1.7 times as many requests, because it was not waiting
on an auth call every time.

Kong's own CPU per request, from the container's cgroup accounting, 500 requests
per run over one reused connection, median of 5 runs:

| | Kong CPU per request | requests/sec |
|---|---|---|
| No plugin | 0.224 ms | 293 |
| Plugin, redis hit | 0.330 ms | 264 |
| Plugin, caching off | 1.731 ms | 26 |

A redis hit costs 0.11ms more CPU than plain proxying, for the cookie parse, the
hash and the redis round trip. The auth call it replaces costs 1.5ms, about
fourteen times more.

Latency, with the plain route and the cached route interleaved to cancel drift:

| Path | Median | p95 |
|---|---|---|
| No plugin | 6.8 ms | 13.8 ms |
| Redis hit | 7.4 ms | 16.1 ms |
| Auth server fetch | 39.2 ms | 52.0 ms |

So the redis hop adds about 0.6ms and saves about 32ms.

An entry costs about 1KB in redis, for a token of roughly 950 bytes, so size it
as `users active within the ttl window x 1KB`.

- For local development linting
```
brew install wget
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
local plugin_name = "frontier"
local package_name = "kong-plugin-" .. plugin_name
local package_version = "0.1.1"
local package_version = "0.2.0"
local rockspec_revision = "1"

local github_account_name = "raystack"
Expand Down Expand Up @@ -32,6 +32,8 @@ build = {
["kong.plugins."..plugin_name..".jwt_decoder"] = "kong/plugins/"..plugin_name.."/jwt_decoder.lua",
["kong.plugins."..plugin_name..".schema"] = "kong/plugins/"..plugin_name.."/schema.lua",
["kong.plugins."..plugin_name..".access"] = "kong/plugins/"..plugin_name.."/access.lua",
["kong.plugins."..plugin_name..".cache"] = "kong/plugins/"..plugin_name.."/cache.lua",
["kong.plugins."..plugin_name..".redis"] = "kong/plugins/"..plugin_name.."/redis.lua",
["kong.plugins."..plugin_name..".utils"] = "kong/plugins/"..plugin_name.."/utils.lua",
}
}
93 changes: 70 additions & 23 deletions kong/plugins/frontier/access.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ local _M = {}
local http = require "resty.http"
local json = require('cjson')
local jwt_decoder = require "kong.plugins.frontier.jwt_decoder"
local cache = require "kong.plugins.frontier.cache"
local kong = kong
local ngx = ngx
local utils = require "kong.plugins.frontier.utils"
Expand All @@ -26,12 +27,13 @@ end

local function get_http_client(conf)
local client = http.new()
client:set_timeouts(conf.http_connect_timeout, conf.http_read_timeout, conf.http_send_timeout)
local connect_timeout, send_timeout, read_timeout =
conf.http_connect_timeout, conf.http_send_timeout, conf.http_read_timeout
client:set_timeouts(connect_timeout, send_timeout, read_timeout)
return client
end

-- send a request to auth server and fetch user token in exchange of cookies
local function check_request_identity(conf, cookies, bearer)
local function fetch_identity_token(conf, cookies, bearer)
local client = get_http_client(conf)
local correlation_id = kong.request.get_header(conf.correlation_header_name)

Expand Down Expand Up @@ -59,13 +61,11 @@ local function check_request_identity(conf, cookies, bearer)
local res, err = client:request_uri(conf.authn_url, request_options)
if not res or err then
kong.log.warn("failed to check request identity: ", err)
return fail_auth()
return nil, err or "no response from auth server"
end
if not err and res and res.status ~= 200 then
kong.log.warn("received non 200 response status: ", res.status)
return kong.response.exit(ngx.HTTP_UNAUTHORIZED, unauthorized_response, {
["x-upstream-status"] = res.status
})
return nil, "non 200 response status", res.status
end

kong.log.debug("check_request_identity: Received successful response with status: ", res.status)
Expand All @@ -77,18 +77,55 @@ local function check_request_identity(conf, cookies, bearer)

-- fallback to response body if header token is not found
if not token and res.body then
kong.log.debug("check_request_identity: Attempting to extract token from response body")
local bodyJson, err = json.decode(res.body)
if not err and bodyJson and bodyJson[conf.token_response_field] then
token = bodyJson[conf.token_response_field]
kong.log.debug("check_request_identity: Token found in response body field '", conf.token_response_field, "'")
local decoded_ok, body = pcall(json.decode, res.body)
local field = decoded_ok and type(body) == "table" and body[conf.token_response_field]

if type(field) == "string" then
token = field
else
kong.log.debug("check_request_identity: Failed to extract token from response body - err: ", err,
", field present: ", bodyJson and bodyJson[conf.token_response_field] and "yes" or "no")
kong.log.debug("no ", conf.token_response_field, " in the auth server's body")
end
end

kong.log.debug("check_request_identity: Returning token: ", token and "found" or "not found")

if not token then
return nil, "no token in auth server response"
end

return token, nil, nil
end

local function check_request_identity(conf, cookies, bearer)
local auth_server_status

local function fetch()
local token, err, status = fetch_identity_token(conf, cookies, bearer)
auth_server_status = status

return token, err
end

local token, err

if cache.enabled(conf) then
token, err = cache.get(conf, cache.build_key(conf, cookies, bearer), fetch)
else
token, err = fetch()
end

if not token then
kong.log.warn("failed to resolve user token: ", err)

if auth_server_status then
return kong.response.exit(ngx.HTTP_UNAUTHORIZED, unauthorized_response, {
["x-upstream-status"] = auth_server_status
})
end

return fail_auth()
end

return token
end

Expand Down Expand Up @@ -146,14 +183,14 @@ local function check_request_permission(conf, cookies, bearer)
})
end

local bodyJson, err = json.decode(res.body)
if err or not bodyJson then
kong.log.warn("failed to parse response body: ", err)
local decoded_ok, body = pcall(json.decode, res.body)
if not decoded_ok or type(body) ~= "table" then
kong.log.warn("could not read the authz response body")
return fail_auth()
end

if bodyJson["status"] ~= true then
kong.log.warn("status value not true: ", bodyJson["status"])
if body["status"] ~= true then
kong.log.warn("status value not true: ", tostring(body["status"]))
return fail_auth()
end
end
Expand Down Expand Up @@ -186,6 +223,13 @@ local function append_claims_as_headers(conf, user_token)

local claims = jwt.claims

local claims_are_readable = type(claims) == "table"

if not claims_are_readable then
kong.log.warn("token payload is not an object, cannot read claims")
return fail_auth()
end

for _, header_name in pairs(conf.token_claims_to_append_as_headers) do
local new_header = conf.frontier_header_prefix .. header_name
local val = claims[header_name]
Expand All @@ -208,12 +252,15 @@ local function verify_organization_id_header(conf, user_token)
end

local claims = jwt.claims
local org_ids = claims[frontier_org_ids_claim_key]
local org_ids = type(claims) == "table" and claims[frontier_org_ids_claim_key] or nil

local org_id_header_verified = false
for word in string.gmatch(org_ids, '([^,]+)') do
if word == request_organization_id then
org_id_header_verified = true

if type(org_ids) == "string" then
for word in string.gmatch(org_ids, '([^,]+)') do
if word == request_organization_id then
org_id_header_verified = true
end
end
end

Expand Down
Loading