Skip to content
Draft
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
67 changes: 67 additions & 0 deletions apisix/core/ctx.lua
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,17 @@ local gq_parse = require("graphql").parse
local jp = require("jsonpath")
local setmetatable = setmetatable
local sub_str = string.sub
local str_lower = string.lower
local str_rep = string.rep
local ngx = ngx
local ngx_var = ngx.var
local re_gsub = ngx.re.gsub
local re_split = ngx.re.split
local ipairs = ipairs
local type = type
local error = error
local pcall = pcall
local ngx_decode_base64 = ngx.decode_base64


local _M = {version = 0.2}
Expand Down Expand Up @@ -212,6 +216,52 @@ end


do
local function get_bearer_token(ctx)
local auth_header = request.header(ctx, "Authorization")
if not auth_header then
return nil
end

local parts = re_split(auth_header, " ", nil, nil, 2)
if not parts or #parts < 2 then
return nil
end

if str_lower(parts[1]) ~= "bearer" then
return nil
end

return parts[2]
end


local function decode_jwt_payload(token)
local parts = re_split(token, "\\.", nil, nil, 3)
if not parts or #parts < 2 then
return nil
end

local payload = parts[2]
payload = payload:gsub("-", "+"):gsub("_", "/")
local remainder = #payload % 4
if remainder > 0 then
payload = payload .. str_rep("=", 4 - remainder)
end

local payload_raw = ngx_decode_base64(payload)
if not payload_raw then
return nil
end

local decoded = json.decode(payload_raw)
if not decoded or type(decoded) ~= "table" then
return nil
end

return decoded
end


local var_methods = {
method = ngx.req.get_method,
cookie = function ()
Expand Down Expand Up @@ -264,6 +314,23 @@ do
balancer_port = true,
consumer_group_id = true,
consumer_name = true,
jwt_iss = function(ctx)
local token = get_bearer_token(ctx)
if not token then
return nil
end

local payload = decode_jwt_payload(token)
if not payload then
return nil
end

if type(payload.iss) ~= "string" then
return nil
end

return payload.iss
end,
resp_body = function(ctx)
-- only for logger and requires the logger to have a special configuration
return ctx.resp_body or ''
Expand Down
46 changes: 46 additions & 0 deletions docs/en/latest/FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,52 @@ curl -i http://127.0.0.1:9180/apisix/admin/routes/2 -H "X-API-KEY: $admin_key" -

All the available operators of the current `lua-resty-radixtree` are listed [here](https://github.com/api7/lua-resty-radixtree#operator-list).

For multi-realm OpenID Connect routing on a single URI, you can dispatch requests to different routes by matching
the built-in variable `jwt_iss`, then configure realm-specific `openid-connect` per route:

```shell
curl -i http://127.0.0.1:9180/apisix/admin/routes/101 -H "X-API-KEY: $admin_key" -X PUT -d '
{
"uri": "/swr",
"vars": [
["jwt_iss", "==", "https://idp.example/realms/realm-a"]
],
"plugins": {
"openid-connect": {
"discovery": "https://idp.example/realms/realm-a/.well-known/openid-configuration",
"client_id": "realm-a-client",
"client_secret": "realm-a-secret",
"bearer_only": true,
"use_jwks": true
}
},
"upstream_id": "1"
}'

curl -i http://127.0.0.1:9180/apisix/admin/routes/102 -H "X-API-KEY: $admin_key" -X PUT -d '
{
"uri": "/swr",
"vars": [
["jwt_iss", "==", "https://idp.example/realms/realm-b"]
],
"plugins": {
"openid-connect": {
"discovery": "https://idp.example/realms/realm-b/.well-known/openid-configuration",
"client_id": "realm-b-client",
"client_secret": "realm-b-secret",
"bearer_only": true,
"use_jwks": true
}
},
"upstream_id": "1"
}'
```

`jwt_iss` is routing metadata extracted from JWT payload before signature verification. Do not treat route
dispatch as authentication. Always enforce final token validation in route-level `openid-connect` configuration.
To reduce realm mapping exposure, you are recommended to keep failure responses consistent across realm routes and use
a fallback route with uniform error behavior.

2. Using the [traffic-split](plugins/traffic-split.md) Plugin.

## How do I redirect HTTP traffic to HTTPS with Apache APISIX?
Expand Down
2 changes: 1 addition & 1 deletion docs/en/latest/admin-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ ID's as a text string must be of a length between 1 and 64 characters and they s
| remote_addrs | False, can't be used with `remote_addr` | Match Rules | Matches with any one of the multiple `remote_addr`s specified in the form of a non-empty list. | ["127.0.0.1", "192.0.0.0/8", "::1"] |
| methods | False | Match Rules | Matches with the specified methods. Matches all methods if empty or unspecified. | ["GET", "POST"] |
| priority | False | Match Rules | If different Routes matches to the same `uri`, then the Route is matched based on its `priority`. A higher value corresponds to higher priority. It is set to `0` by default. | priority = 10 |
| vars | False | Match Rules | Matches based on the specified variables consistent with variables in Nginx. Takes the form `[[var, operator, val], [var, operator, val], ...]]`. Note that this is case sensitive when matching a cookie name. See [lua-resty-expr](https://github.com/api7/lua-resty-expr) for more details. | [["arg_name", "==", "json"], ["arg_age", ">", 18]] |
| vars | False | Match Rules | Matches based on the specified variables consistent with variables in Nginx. Takes the form `[[var, operator, val], [var, operator, val], ...]]`. Note that this is case sensitive when matching a cookie name. APISIX also supports built-in custom variables such as `jwt_iss` for routing by Bearer token issuer before authentication. See [lua-resty-expr](https://github.com/api7/lua-resty-expr) for more details. | [["arg_name", "==", "json"], ["arg_age", ">", 18]] |
| filter_func | False | Match Rules | Matches using a user-defined function in Lua. Used in scenarios where `vars` is not sufficient. Functions accept an argument `vars` which provides access to built-in variables (including Nginx variables). | function(vars) return tonumber(vars.arg_userid) % 4 > 2; end |
| plugins | False | Plugin | Plugins that are executed during the request/response cycle. See [Plugin](terminology/plugin.md) for more. | |
| script | False | Script | Used for writing arbitrary Lua code or directly calling existing plugins to be executed. See [Script](terminology/script.md) for more. | |
Expand Down
138 changes: 138 additions & 0 deletions t/router/radixtree-uri-vars.t
Original file line number Diff line number Diff line change
Expand Up @@ -437,3 +437,141 @@ GET /t
--- error_code: 400
--- response_body
{"error_msg":"failed to validate the 'vars' expression: rule should be wrapped inside brackets"}



=== TEST 22: set route(id: 101) with vars(jwt_iss == realm-a)
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/101',
ngx.HTTP_PUT,
[=[{
"methods": ["GET"],
"plugins": {
"response-rewrite": {
"body": "realm-a\n"
}
},
"upstream": {
"nodes": {
"127.0.0.1:1980": 1
},
"type": "roundrobin"
},
"uri": "/jwt-split",
"vars": [["jwt_iss", "==", "realm-a"]]
}]=]
)

if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- request
GET /t
--- response_body
passed



=== TEST 23: set route(id: 102) with vars(jwt_iss == realm-b)
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/102',
ngx.HTTP_PUT,
[=[{
"methods": ["GET"],
"plugins": {
"response-rewrite": {
"body": "realm-b\n"
}
},
"upstream": {
"nodes": {
"127.0.0.1:1980": 1
},
"type": "roundrobin"
},
"uri": "/jwt-split",
"vars": [["jwt_iss", "==", "realm-b"]]
}]=]
)

if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- request
GET /t
--- response_body
passed



=== TEST 24: match realm-a route
--- request
GET /jwt-split
--- more_headers
Authorization: Bearer eyJhbGciOiJub25lIn0.eyJpc3MiOiJyZWFsbS1hIn0.
--- response_body
realm-a



=== TEST 25: match realm-b route
--- request
GET /jwt-split
--- more_headers
Authorization: Bearer eyJhbGciOiJub25lIn0.eyJpc3MiOiJyZWFsbS1iIn0.
--- response_body
realm-b



=== TEST 26: no Authorization header should not match realm routes
--- request
GET /jwt-split
--- error_code: 404
--- response_body
{"error_msg":"404 Route Not Found"}



=== TEST 27: non-bearer Authorization should not match realm routes
--- request
GET /jwt-split
--- more_headers
Authorization: Basic dXNlcjpwYXNz
--- error_code: 404
--- response_body
{"error_msg":"404 Route Not Found"}



=== TEST 28: malformed bearer token should not match realm routes
--- request
GET /jwt-split
--- more_headers
Authorization: Bearer malformed.token
--- error_code: 404
--- response_body
{"error_msg":"404 Route Not Found"}



=== TEST 29: bearer token without iss should not match realm routes
--- request
GET /jwt-split
--- more_headers
Authorization: Bearer eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyMSJ9.
--- error_code: 404
--- response_body
{"error_msg":"404 Route Not Found"}