diff --git a/apisix/core/ctx.lua b/apisix/core/ctx.lua index a5d1d47510d1..88caf812e56d 100644 --- a/apisix/core/ctx.lua +++ b/apisix/core/ctx.lua @@ -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} @@ -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 () @@ -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 '' diff --git a/docs/en/latest/FAQ.md b/docs/en/latest/FAQ.md index f9e30f52fb6c..680106ff271f 100644 --- a/docs/en/latest/FAQ.md +++ b/docs/en/latest/FAQ.md @@ -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? diff --git a/docs/en/latest/admin-api.md b/docs/en/latest/admin-api.md index 21f416b71272..3c01f11d58af 100644 --- a/docs/en/latest/admin-api.md +++ b/docs/en/latest/admin-api.md @@ -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. | | diff --git a/t/router/radixtree-uri-vars.t b/t/router/radixtree-uri-vars.t index 6e4b78712f6d..383f360c710d 100644 --- a/t/router/radixtree-uri-vars.t +++ b/t/router/radixtree-uri-vars.t @@ -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"}