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
131 changes: 129 additions & 2 deletions apisix/plugins/openid-connect.lua
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ local jwt = require("resty.jwt")
local jsonschema = require('jsonschema')
local pkey = require("resty.openssl.pkey")
local dump_jwk = require("resty.openssl.auxiliary.jwk").dump_jwk
local isarray = require("table.isarray")
local string = string
local ngx = ngx
local ipairs = ipairs
local next = next
local type = type
local tostring = tostring
local pcall = pcall
Expand Down Expand Up @@ -638,7 +640,32 @@ local schema = {
description = "JSON schema of OIDC response claim",
type = "object",
default = nil,
}
},
upstream_headers = {
description = "Verified claims to forward to the upstream as request headers.",
type = "array",
minItems = 1,
items = {
type = "object",
properties = {
header = {
description = "Name of the upstream request header.",
type = "string",
pattern = [[^[!#$%&'*+.^_`|~0-9A-Za-z-]+$]],
},
path = {
description = "Keys leading to the claim, one per nesting level.",
type = "array",
minItems = 1,
items = {
type = "string",
minLength = 1,
},
},
},
required = {"header", "path"},
},
},
},
encrypt_fields = {"client_secret", "client_rsa_private_key", "dpop.private_key",
"session.secret", "session.redis.password"},
Expand Down Expand Up @@ -1159,6 +1186,76 @@ local function session_scopes(response)
return split_scopes_by_space(scope)
end


local function lookup_claim(claims, path)
local value = claims
for _, key in ipairs(path) do
if type(value) ~= "table" then
return nil
end
value = value[key]
end
return value
end


-- Strings as is, numbers and booleans as text, arrays of scalars joined
-- by a space, anything else as JSON. Missing, null or empty yields nil.
local function serialize_claim(value)
local value_type = type(value)
if value_type == "string" then
return value
end

if value_type == "number" then
-- tostring() switches to exponent notation for large integers
if value % 1 == 0 and value >= -2^53 and value <= 2^53 then
return string.format("%d", value)
end
return tostring(value)
end

if value_type == "boolean" then
return tostring(value)
end

if value_type ~= "table" or next(value) == nil then
return nil
end

if isarray(value) then
local items = core.table.new(#value, 0)
for i, item in ipairs(value) do
local item_type = type(item)
if item_type ~= "string" and item_type ~= "number" and item_type ~= "boolean" then
return core.json.encode(value)
end
items[i] = serialize_claim(item)
end
return concat(items, " ")
end

return core.json.encode(value)
end


-- Each header gets the value from the first claim source holding the claim.
local function upstream_header_values(upstream_headers, sources)
local values = core.table.new(0, #upstream_headers)
for _, mapping in ipairs(upstream_headers) do
for _, claims in ipairs(sources) do
local value = serialize_claim(lookup_claim(claims, mapping.path))
if value then
values[mapping.header] = value
break
end
end
end
return values
end
_M._upstream_header_values = upstream_header_values


local function validate_claims_in_oidcauth_response(resp, conf)
if not conf.claim_schema then
return true
Expand All @@ -1172,7 +1269,7 @@ local function validate_claims_in_oidcauth_response(resp, conf)
end


function _M.rewrite(plugin_conf, ctx)
local function authenticate(plugin_conf, ctx)
local conf = core.table.clone(plugin_conf)
flatten_openidc_options(conf)

Expand Down Expand Up @@ -1317,6 +1414,10 @@ function _M.rewrite(plugin_conf, ctx)
-- Add configured access token header, maybe.
add_access_token_header(ctx, conf, access_token)

if conf.upstream_headers then
ctx.openid_connect_claim_sources = {userinfo}
end

if userinfo and conf.set_userinfo_header then
-- Set X-Userinfo header to introspection endpoint response.
core.request.set_header(ctx, "X-Userinfo",
Expand Down Expand Up @@ -1511,6 +1612,14 @@ function _M.rewrite(plugin_conf, ctx)
core.request.set_header(ctx, "X-Raw-ID-Token", enc_id_token)
end

if conf.upstream_headers then
-- the ID token is verified, userinfo fills in what it lacks
ctx.openid_connect_claim_sources = {
response.id_token or {},
response.user or {},
}
end

-- a successful authentication resets the restart budget
if session:get("auth_flow_restarts") then
session:set("auth_flow_restarts", nil)
Expand All @@ -1527,4 +1636,22 @@ function _M.rewrite(plugin_conf, ctx)
end


function _M.rewrite(plugin_conf, ctx)
local code, body = authenticate(plugin_conf, ctx)

-- runs on every outcome so spoofed values never reach the upstream
local upstream_headers = plugin_conf.upstream_headers
if upstream_headers then
local values = upstream_header_values(upstream_headers,
ctx.openid_connect_claim_sources or {})
for _, mapping in ipairs(upstream_headers) do
core.request.set_header(ctx, mapping.header, values[mapping.header])
end
end

return code, body
end



return _M
33 changes: 33 additions & 0 deletions docs/en/latest/plugins/openid-connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ The `openid-connect` Plugin supports the integration with [OpenID Connect (OIDC)
| set_raw_id_token_header | boolean | False | false | | If true and if the raw signed ID token JWT is available, set the value in the `X-Raw-ID-Token` request header. Unlike `X-ID-Token`, this header contains the original RS256-signed JWT from the identity provider and can be verified against the provider's JWKS endpoint. The plugin automatically persists the raw JWT in the session when this option is enabled. |
| set_userinfo_header | boolean | False | true | | If true and if user info data is available, set the value in the `X-Userinfo` request header. |
| set_refresh_token_header | boolean | False | false | | If true and if the refresh token is available, set the value in the `X-Refresh-Token` request header. |
| upstream_headers | array[object] | False | | | Claims to forward to the Upstream as request headers. The Plugin clears every declared header first, then sets it only from a verified claim, so a client cannot supply its own value, including on requests let through by `unauth_action` set to `pass`. With `bearer_only`, `public_key`, `use_jwks`, or introspection, claims come from the verified access token payload or the introspection response. In the authorization code flow, claims come from the ID token, then from the user info for claims the ID token does not contain. |
| upstream_headers.header | string | True | | | Name of the Upstream request header. |
| upstream_headers.path | array[string] | True | | | Keys leading to the claim, one key per nesting level, such as `["org", "id"]`. A key is matched literally, so a claim name containing `://`, `/`, or `.` is a single key. Strings are forwarded as is, numbers and booleans as text, arrays of strings, numbers, or booleans joined with a space, and other arrays and objects as JSON. If the claim is missing, `null`, or empty, the header is not set. |
| session | object | False | | | Session configuration used when `bearer_only` is `false` and the Plugin uses Authorization Code flow. |
| session.secret | string | True | | 16 or more characters | Key used for session encryption and HMAC operation when `bearer_only` is `false`. |
| session.cookie_name | string | False | | | Session cookie name. Forwarded to [lua-resty-session](https://github.com/bungle/lua-resty-session#configuration) 4.x as `cookie_name`. |
Expand Down Expand Up @@ -460,6 +463,36 @@ The following diagram illustrates the interaction between different entities whe

When `set_userinfo_header` is `true` (the default), the Plugin sets user info data in the `X-Userinfo` request header, which the Upstream can use for further processing.

### Forward Claims to the Upstream

The following example forwards selected claims of a verified bearer token to the Upstream as request headers, so that the Upstream does not need to parse the token.

Configure `upstream_headers` in the Plugin, where each entry names a header and the `path` of keys leading to the claim:

```json
"openid-connect": {
"client_id": "api",
"discovery": "https://idp.example.com/.well-known/openid-configuration",
"bearer_only": true,
"use_jwks": true,
"upstream_headers": [
{ "header": "X-Profile-Id", "path": ["https://example.com/claims/profile/id"] },
{ "header": "X-Org-Id", "path": ["org", "id"] },
{ "header": "X-Client-Scopes", "path": ["scope"] }
]
}
```

For a token with the payload `{"https://example.com/claims/profile/id": "profile-1", "org": {"id": 42}, "scope": ["read", "write"]}`, the Upstream receives the following headers:

```text
X-Profile-Id: profile-1
X-Org-Id: 42
X-Client-Scopes: read write
```

If a claim is not in the token, the Upstream does not receive the header, even when the client sent one.

## Troubleshooting

This section covers a few commonly seen issues when working with this Plugin to help you troubleshoot.
Expand Down
33 changes: 33 additions & 0 deletions docs/zh/latest/plugins/openid-connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ import TabItem from '@theme/TabItem';
| set_id_token_header | boolean | 否 | true | | 如果为 true 并且 ID 令牌可用,则在 `X-ID-Token` 请求标头中设置值。 |
| set_userinfo_header | boolean | 否 | true | | 如果为 true 并且用户信息数据可用,则在 `X-Userinfo` 请求标头中设置值。 |
| set_refresh_token_header | boolean | 否 | false | | 如果为 true 并且刷新令牌可用,则在 `X-Refresh-Token` 请求标头中设置值。 |
| upstream_headers | array[object] | 否 | | | 以请求头形式转发给上游的 claim。插件会先清除所有声明的请求头,再只用已校验的 claim 设置,因此客户端无法自行提供这些值,包括 `unauth_action` 为 `pass` 时放行的请求。使用 `bearer_only`、`public_key`、`use_jwks` 或令牌自省时,claim 来自已校验的访问令牌负载或自省响应。授权码流程中,claim 先从 ID 令牌读取,ID 令牌中没有的再从用户信息读取。 |
| upstream_headers.header | string | 是 | | | 上游请求头名称。 |
| upstream_headers.path | array[string] | 是 | | | 定位 claim 的键,每层嵌套一个键,例如 `["org", "id"]`。键按字面匹配,因此包含 `://`、`/` 或 `.` 的 claim 名称是一个键。字符串原样转发,数字和布尔值转为文本,由字符串、数字或布尔值组成的数组以空格连接,其他数组和对象以 JSON 转发。claim 不存在、为 `null` 或为空时不设置该请求头。 |
| session | object | 否 | | | 当 `bearer_only` 为 `false` 且插件使用 Authorization Code 流程时使用的 Session 配置。 |
| session.secret | string | 是 | | 16 个字符以上 | 当 `bearer_only` 为 `false` 时,用于 session 加密和 HMAC 运算的密钥。|
| session.cookie_name | string | 否 | | | 会话 Cookie 名称。作为 `cookie_name` 透传到 [lua-resty-session](https://github.com/bungle/lua-resty-session#configuration) 4.x。 |
Expand Down Expand Up @@ -459,6 +462,36 @@ OpenID Connect (OIDC) 中的 UserInfo 端点在 [OpenID Connect Core 1.0 第 5.3

当 `set_userinfo_header` 为 `true`(默认值)时,插件在 `X-Userinfo` 请求头中设置用户信息数据,上游服务可使用该数据进行进一步处理。

### 将 claim 转发给上游

以下示例将已校验 bearer 令牌中的部分 claim 以请求头形式转发给上游,上游无需解析令牌。

在插件中配置 `upstream_headers`,每一项指定请求头名称和定位 claim 的键路径 `path`:

```json
"openid-connect": {
"client_id": "api",
"discovery": "https://idp.example.com/.well-known/openid-configuration",
"bearer_only": true,
"use_jwks": true,
"upstream_headers": [
{ "header": "X-Profile-Id", "path": ["https://example.com/claims/profile/id"] },
{ "header": "X-Org-Id", "path": ["org", "id"] },
{ "header": "X-Client-Scopes", "path": ["scope"] }
]
}
```

对于负载为 `{"https://example.com/claims/profile/id": "profile-1", "org": {"id": 42}, "scope": ["read", "write"]}` 的令牌,上游收到以下请求头:

```text
X-Profile-Id: profile-1
X-Org-Id: 42
X-Client-Scopes: read write
```

令牌中不存在的 claim 不会产生对应请求头,即使客户端自带该请求头。

## 故障排除

本节涵盖使用此插件时常见的一些问题,以帮助你进行故障排查。
Expand Down
Loading
Loading