Skip to content

Commit 4cc1eaf

Browse files
authored
feat(openid-connect): make client_secret optional for local JWT verification modes (#13472)
1 parent fbf5106 commit 4cc1eaf

3 files changed

Lines changed: 309 additions & 1 deletion

File tree

apisix/plugins/openid-connect.lua

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,7 @@ local schema = {
403403
},
404404
encrypt_fields = {"client_secret", "client_rsa_private_key",
405405
"session.secret", "session.redis.password"},
406-
required = {"client_id", "client_secret", "discovery"}
406+
required = {"client_id", "discovery"}
407407
}
408408

409409

@@ -424,6 +424,26 @@ function _M.check_schema(conf)
424424
return false, "property \"session.secret\" is required when \"bearer_only\" is false"
425425
end
426426

427+
-- client_secret is not required in certain authentication modes. The exemption
428+
-- is scoped to the flow each alternative actually applies to:
429+
-- bearer_only=true + public_key/use_jwks: local JWT verification, no IdP call needed
430+
-- bearer_only=true + introspection_endpoint_auth_method=private_key_jwt: introspection
431+
-- endpoint authenticates via signed JWT instead of client_secret
432+
-- token_endpoint_auth_method=private_key_jwt (non-bearer): token endpoint uses signed
433+
-- JWT; this exemption applies only to the session/callback flow, not bearer mode
434+
-- use_pkce=true (non-bearer): public-client PKCE flow needs no client_secret
435+
local client_secret_optional
436+
if conf.bearer_only then
437+
client_secret_optional = (conf.public_key or conf.use_jwks)
438+
or (conf.introspection_endpoint_auth_method == "private_key_jwt")
439+
else
440+
client_secret_optional = (conf.token_endpoint_auth_method == "private_key_jwt")
441+
or conf.use_pkce
442+
end
443+
if not client_secret_optional and not conf.client_secret then
444+
return false, "property \"client_secret\" is required"
445+
end
446+
427447
local check = {"discovery", "introspection_endpoint", "redirect_uri",
428448
"post_logout_redirect_uri", "proxy_opts.http_proxy", "proxy_opts.https_proxy"}
429449
core.utils.check_https(check, conf, plugin_name)
@@ -736,6 +756,20 @@ function _M.rewrite(plugin_conf, ctx)
736756
end
737757
end
738758

759+
-- Validate bearer-path claims against claim_schema when configured.
760+
-- The schema is applied directly to the flat JWT payload / introspection
761+
-- response, which is different from the session-flow structure
762+
-- {user, access_token, id_token}.
763+
if conf.claim_schema then
764+
local ok, err = core.schema.check(conf.claim_schema, response)
765+
if not ok then
766+
core.log.error("OIDC claim validation failed: ", err)
767+
ngx.header["WWW-Authenticate"] = 'Bearer realm="' .. conf.realm ..
768+
'", error="invalid_token", error_description="' .. err .. '"'
769+
return ngx.HTTP_UNAUTHORIZED
770+
end
771+
end
772+
739773
-- Add configured access token header, maybe.
740774
add_access_token_header(ctx, conf, access_token)
741775

t/plugin/openid-connect.t

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1630,3 +1630,215 @@ token validate successfully by jwks
16301630
--- response_body
16311631
property "session.secret" is required when "bearer_only" is false
16321632
done
1633+
1634+
1635+
1636+
=== TEST 42: client_secret is optional when bearer_only=true and public_key is set.
1637+
--- config
1638+
location /t {
1639+
content_by_lua_block {
1640+
local plugin = require("apisix.plugins.openid-connect")
1641+
local ok, err = plugin.check_schema({
1642+
client_id = "a",
1643+
discovery = "https://example.com/.well-known/openid-configuration",
1644+
bearer_only = true,
1645+
public_key = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2a2rwplBQLzHPZe5TNJF\n-----END PUBLIC KEY-----",
1646+
token_signing_alg_values_expected = "RS256",
1647+
})
1648+
if not ok then
1649+
ngx.say(err)
1650+
end
1651+
ngx.say("done")
1652+
}
1653+
}
1654+
--- response_body
1655+
done
1656+
1657+
1658+
1659+
=== TEST 43: client_secret is optional when bearer_only=true and use_jwks=true.
1660+
--- config
1661+
location /t {
1662+
content_by_lua_block {
1663+
local plugin = require("apisix.plugins.openid-connect")
1664+
local ok, err = plugin.check_schema({
1665+
client_id = "a",
1666+
discovery = "https://example.com/.well-known/openid-configuration",
1667+
bearer_only = true,
1668+
use_jwks = true,
1669+
})
1670+
if not ok then
1671+
ngx.say(err)
1672+
end
1673+
ngx.say("done")
1674+
}
1675+
}
1676+
--- response_body
1677+
done
1678+
1679+
1680+
1681+
=== TEST 44: client_secret is required when bearer_only=true but neither public_key nor use_jwks is set (introspection mode).
1682+
--- config
1683+
location /t {
1684+
content_by_lua_block {
1685+
local plugin = require("apisix.plugins.openid-connect")
1686+
local ok, err = plugin.check_schema({
1687+
client_id = "a",
1688+
discovery = "https://example.com/.well-known/openid-configuration",
1689+
bearer_only = true,
1690+
introspection_endpoint = "https://example.com/introspect",
1691+
})
1692+
if not ok then
1693+
ngx.say(err)
1694+
end
1695+
ngx.say("done")
1696+
}
1697+
}
1698+
--- response_body
1699+
property "client_secret" is required
1700+
done
1701+
1702+
1703+
1704+
=== TEST 45: client_secret is optional when token_endpoint_auth_method=private_key_jwt.
1705+
--- config
1706+
location /t {
1707+
content_by_lua_block {
1708+
local plugin = require("apisix.plugins.openid-connect")
1709+
local ok, err = plugin.check_schema({
1710+
client_id = "a",
1711+
discovery = "https://example.com/.well-known/openid-configuration",
1712+
bearer_only = false,
1713+
token_endpoint_auth_method = "private_key_jwt",
1714+
client_rsa_private_key = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAK\n-----END RSA PRIVATE KEY-----",
1715+
session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
1716+
})
1717+
if not ok then
1718+
ngx.say(err)
1719+
end
1720+
ngx.say("done")
1721+
}
1722+
}
1723+
--- response_body
1724+
done
1725+
1726+
1727+
1728+
=== TEST 46: client_secret is optional when use_pkce=true (non-bearer PKCE flow).
1729+
--- config
1730+
location /t {
1731+
content_by_lua_block {
1732+
local plugin = require("apisix.plugins.openid-connect")
1733+
local ok, err = plugin.check_schema({
1734+
client_id = "a",
1735+
discovery = "https://example.com/.well-known/openid-configuration",
1736+
bearer_only = false,
1737+
use_pkce = true,
1738+
session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
1739+
})
1740+
if not ok then
1741+
ngx.say(err)
1742+
end
1743+
ngx.say("done")
1744+
}
1745+
}
1746+
--- response_body
1747+
done
1748+
1749+
1750+
1751+
=== TEST 47: client_secret is still required for non-bearer session flow without special auth method.
1752+
--- config
1753+
location /t {
1754+
content_by_lua_block {
1755+
local plugin = require("apisix.plugins.openid-connect")
1756+
local ok, err = plugin.check_schema({
1757+
client_id = "a",
1758+
discovery = "https://example.com/.well-known/openid-configuration",
1759+
bearer_only = false,
1760+
session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
1761+
})
1762+
if not ok then
1763+
ngx.say(err)
1764+
end
1765+
ngx.say("done")
1766+
}
1767+
}
1768+
--- response_body
1769+
property "client_secret" is required
1770+
done
1771+
1772+
1773+
1774+
=== TEST 48: client_secret is optional when bearer_only=true and introspection_endpoint_auth_method=private_key_jwt.
1775+
--- config
1776+
location /t {
1777+
content_by_lua_block {
1778+
local plugin = require("apisix.plugins.openid-connect")
1779+
local ok, err = plugin.check_schema({
1780+
client_id = "a",
1781+
discovery = "https://example.com/.well-known/openid-configuration",
1782+
bearer_only = true,
1783+
introspection_endpoint = "https://example.com/introspect",
1784+
introspection_endpoint_auth_method = "private_key_jwt",
1785+
client_rsa_private_key = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAK\n-----END RSA PRIVATE KEY-----",
1786+
})
1787+
if not ok then
1788+
ngx.say(err)
1789+
end
1790+
ngx.say("done")
1791+
}
1792+
}
1793+
--- response_body
1794+
done
1795+
1796+
1797+
1798+
=== TEST 49: client_secret stays required for bearer introspection even with token_endpoint_auth_method=private_key_jwt (cross-flow: that method does not apply to introspection).
1799+
--- config
1800+
location /t {
1801+
content_by_lua_block {
1802+
local plugin = require("apisix.plugins.openid-connect")
1803+
local ok, err = plugin.check_schema({
1804+
client_id = "a",
1805+
discovery = "https://example.com/.well-known/openid-configuration",
1806+
bearer_only = true,
1807+
introspection_endpoint = "https://example.com/introspect",
1808+
token_endpoint_auth_method = "private_key_jwt",
1809+
client_rsa_private_key = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAK\n-----END RSA PRIVATE KEY-----",
1810+
})
1811+
if not ok then
1812+
ngx.say(err)
1813+
end
1814+
ngx.say("done")
1815+
}
1816+
}
1817+
--- response_body
1818+
property "client_secret" is required
1819+
done
1820+
1821+
1822+
1823+
=== TEST 50: client_secret stays required for non-bearer session flow with a bearer-only alternative (introspection private_key_jwt does not apply here).
1824+
--- config
1825+
location /t {
1826+
content_by_lua_block {
1827+
local plugin = require("apisix.plugins.openid-connect")
1828+
local ok, err = plugin.check_schema({
1829+
client_id = "a",
1830+
discovery = "https://example.com/.well-known/openid-configuration",
1831+
bearer_only = false,
1832+
introspection_endpoint_auth_method = "private_key_jwt",
1833+
client_rsa_private_key = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAK\n-----END RSA PRIVATE KEY-----",
1834+
session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
1835+
})
1836+
if not ok then
1837+
ngx.say(err)
1838+
end
1839+
ngx.say("done")
1840+
}
1841+
}
1842+
--- response_body
1843+
property "client_secret" is required
1844+
done

t/plugin/openid-connect9.t

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,65 @@ GET /hello HTTP/1.1
190190
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3NhbXBsZXMuYXV0aDAuY29tLyIsInN1YiI6InRlc3Qtc3ViamVjdCIsImF1ZCI6ImtieXVG RGlkTExtMjgwTEl3VkZpYXpPcWpPM3R5OEtIIiwic2NvcGUiOiJhcGlzaXgiLCJpYXQiOjEwMDAwMDAwLCJleHAiOjI1MDAwMDAwMDB9.bfcZsd4ABgo0GoLT8EwfnKgf AWbnJZbZ3kOtqyeSkXYqGlSmgMNW3q5Kx1SGjMNhEKVG_KrFfsPrQmcTljSPZA
191191
--- response_body
192192
success
193+
194+
195+
196+
=== TEST 5: configure route with bearer_only + public_key + claim_schema that requires an absent field
197+
--- config
198+
location /t {
199+
content_by_lua_block {
200+
local t = require("lib.test_admin").test
201+
local code, body = t('/apisix/admin/routes/1',
202+
ngx.HTTP_PUT,
203+
[[{
204+
"plugins": {
205+
"openid-connect": {
206+
"client_id": "kbyuFDidLLm280LIwVFiazOqjO3ty8KH",
207+
"discovery": "https://samples.auth0.com/.well-known/openid-configuration",
208+
"ssl_verify": false,
209+
"bearer_only": true,
210+
"public_key": "-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAO6oZg+4sbTPa0oeKcfsJf2bx7N7JkGB\ngVqJeCkMHJ7lKLCTpg6P3UpTfNx5K+pKXsDucQbhjQqmjMwTBEe44EsCAwEAAQ==\n-----END PUBLIC KEY-----",
211+
"token_signing_alg_values_expected": "RS256",
212+
"claim_schema": {
213+
"type": "object",
214+
"required": ["email"]
215+
}
216+
}
217+
},
218+
"upstream": {
219+
"nodes": {
220+
"127.0.0.1:1980": 1
221+
},
222+
"type": "roundrobin"
223+
},
224+
"uri": "/hello"
225+
}]]
226+
)
227+
228+
if code >= 300 then
229+
ngx.status = code
230+
end
231+
ngx.say(body)
232+
}
233+
}
234+
--- response_body
235+
passed
236+
237+
238+
239+
=== TEST 6: bearer-path claim_schema rejection returns 401 with WWW-Authenticate header
240+
--- request
241+
GET /hello HTTP/1.1
242+
--- more_headers
243+
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3NhbXBsZXMuYXV0aDAuY29tLyIsInN1YiI6InRlc3Qtc3ViamVjdCIsImF1ZCI6ImtieXVGRGlkTExtMjgwTEl3VkZpYXpPcWpPM3R5OEtIIiwic2NvcGUiOiJhcGlzaXgiLCJpYXQiOjEwMDAwMDAwLCJleHAiOjI1MDAwMDAwMDB9.yWPMyXHuhiBP3q0xUkg3Iwu8dvXWlaVGBqPC8y8hC1MYoCcj687X85o9mvw1Mz_kGgKHNvDYrl5EQ3B3LAM4OA
244+
--- error_code: 401
245+
--- response_headers_like
246+
WWW-Authenticate: Bearer realm="apisix", error="invalid_token".*
247+
--- no_error_log
248+
[crit]
249+
[alert]
250+
[emerg]
251+
--- grep_error_log eval
252+
qr/OIDC claim validation failed/
253+
--- grep_error_log_out
254+
OIDC claim validation failed

0 commit comments

Comments
 (0)