feat(openid-connect): add consumer selector for consumer-group realm routing - #13038
PiyushMishra318 wants to merge 2 commits into
Conversation
|
Thanks in advance for reviewing this PR. I’m happy to iterate on the implementation based on maintainer feedback, including behavior changes, API/schema adjustments, additional tests, or splitting this into smaller PRs if that is preferred. |
Baoyuantop
left a comment
There was a problem hiding this comment.
A better approach is to directly support multiple Issuer configurations (valid_issuers + multiple discovery) within the openid-connect plugin, instead of distributing routes via "unsigned JWT Claims". Consider supporting multiple Issuer configurations directly within the openid-connect plugin (each issuer corresponds to a set of discovery/client_id/client_secret), which is a more secure and concise solution, eliminating the need for routing before signature verification.
moonming
left a comment
There was a problem hiding this comment.
Hi @PiyushMishra318, thank you for your contribution and the effort you put into this!
After reviewing the approach, I have some concerns that I think warrant a different direction:
-
Core layer modification concern: Adding JWT decoding logic (
jwt_issvariable) intocore/ctx.luaintroduces unsigned JWT parsing at the core routing layer. This could have unintended security implications since it bypasses signature verification. -
Better alternative exists: The same goal (routing to different consumers based on JWT issuer) can be achieved using APISIX's existing mechanisms:
- Multiple routes with
varsconditions to match different issuers - The built-in
consumerandconsumer_groupmechanism with the existingopenid-connectplugin
- Multiple routes with
-
File scope mismatch: The PR modifies
FAQ.mdandradixtree-uri-varstests rather than theopenid-connectplugin itself, which suggests the changes may not be targeting the right layer.
Since this is still in draft status, I'd recommend exploring the route-based approach using vars conditions — it would achieve the same result without modifying the core context. Happy to discuss further if you'd like to explore that direction!
moonming
left a comment
There was a problem hiding this comment.
Hi @PiyushMishra318, thank you for your contribution and the effort you put into this!
After reviewing the approach, I have some concerns that I think warrant a different direction:
-
Core layer modification concern: Adding JWT decoding logic into core/ctx.lua introduces unsigned JWT parsing at the core routing layer. This could have unintended security implications since it bypasses signature verification.
-
Better alternative exists: The same goal (routing to different consumers based on JWT issuer) can be achieved using APISIX existing mechanisms - multiple routes with vars conditions to match different issuers, or the built-in consumer and consumer_group mechanism with the existing openid-connect plugin.
-
File scope mismatch: The PR modifies FAQ.md and radixtree-uri-vars tests rather than the openid-connect plugin itself, which suggests the changes may not be targeting the right layer.
Since this is still in draft status, I would recommend exploring the route-based approach using vars conditions - it would achieve the same result without modifying the core context. Happy to discuss further if you would like to explore that direction!
Thanks for your feedback @moonming. We did explore the vars-based routing approach using the http-x-tenant header for our use case, but it didn’t fully solve the problem for us. Similarly, with the consumer/consumer_group approach, our understanding is that consumer resolution happens after authentication succeeds, which makes it difficult to dynamically switch auth strategies based on the JWT issuer. One of the main reasons we considered APISIX was to avoid maintaining a bespoke multi-auth/realm handling system. However, as we scaled to more auth scopes, the configuration complexity grew significantly, which is what led us to experiment with a core-level change. That said, I completely understand your concern around introducing unsigned JWT parsing at the core layer, especially from a general security standpoint. In our case, since these APIs are primarily internal, we may consider maintaining this as an internal patch instead of pushing it upstream. I do think native support for this kind of use case in APISIX would be valuable in the future—ideally with a more robust and secure approach than what we proposed here. Happy to discuss or explore alternative directions if there’s a recommended pattern we might have missed. |
|
Thanks for the detailed explanation, @PiyushMishra318. The pain point you described is valid — the current openid-connect plugin assumes a single realm per route, and the consumer resolution timing makes it impossible to dynamically switch OIDC configurations before authentication. I think the right direction would be to add multi-issuer support directly within the openid-connect plugin itself, rather than at the core routing layer. The plugin could:
This keeps the unsigned JWT parsing scoped to the auth plugin rather than the core context, and provides a cleaner API for your use case. Maintaining an internal patch is totally reasonable for your immediate needs. If you are interested in contributing an upstream solution along these lines, we would welcome a proposal on the issue for further discussion. |
|
This pull request has been marked as stale due to 60 days of inactivity. It will be closed in 4 weeks if no further activity occurs. If you think that's incorrect or this pull request should instead be reviewed, please simply write any comment. Even if closed, you can still revive the PR at any time or discuss it on the dev@apisix.apache.org list. Thank you for your contributions. |
|
Hi @Baoyuantop Proposed approach for multi-issuer OIDC routingBased on your feedback, I've moved all logic out of core/ctx.lua and into the openid-connect plugin itself. No changes to the core routing layer. What it doesAdds an optional realms array to the plugin config. Each entry maps an OIDC issuer to its own discovery, client_id, and client_secret. On each request, the plugin does a lightweight unsigned base64 decode of the bearer token payload to extract the iss claim, finds the matching realm, and proceeds with standard OIDC validation using that realm's credentials. Unrecognized issuers return a plain 401. Single-issuer configs are completely unchanged — realms is opt-in. Example config plugins:
openid-connect:
bearer_only: true
realms:
- issuer: "https://idp.example.com/realms/tenantA"
discovery: "https://idp.example.com/realms/tenantA/.well-known/openid-configuration"
client_id: "client-a"
client_secret: "secret-a"
- issuer: "https://idp.example.com/realms/tenantB"
discovery: "https://idp.example.com/realms/tenantB/.well-known/openid-configuration"
client_id: "client-b"
client_secret: "secret-b"
What changes
Does this approach align with what you had in mind? Happy to adjust before pushing the full implementation. |
828e7c6 to
5bf6036
Compare
|
This pull request has been marked as stale due to 60 days of inactivity. It will be closed in 4 weeks if no further activity occurs. If you think that's incorrect or this pull request should instead be reviewed, please simply write any comment. Even if closed, you can still revive the PR at any time or discuss it on the dev@apisix.apache.org list. Thank you for your contributions. |
3b82e64 to
0a75b73
Compare
…ntime-variable templates Continues apache#13038 with the direction requested there: keep openid-connect generic and push request-identification/routing logic into a separate, higher-priority plugin instead of baking IdP-specific logic into openid-connect itself. openid-connect: `discovery`, `client_id`, and `client_secret` now resolve `${var}` / `${var ?? default}` templates from the request context (via core.utils.resolve_var, the same mechanism limit-count already uses), unconditionally, in rewrite(). An empty/nil resolution fails closed with a 500 naming the field that could not be resolved, instead of forwarding an empty value to the identity provider. New companion plugin openid-connect-consumer-selector (priority 2895, ahead of openid-connect's 2599) picks a config from a list of candidates and exposes it via ctx.var.oidc_discovery / oidc_client_id / oidc_client_secret for openid-connect's templates to consume. Two match modes, via `match_source`: - "var" (default): match configs[].key against an arbitrary request variable named by `match_var`. - "token_iss": read the Authorization: Bearer <token> header, decode the JWT payload without verifying its signature (same non-verifying use resty.jwt already gets in openid-connect.lua's session_scopes), and match configs[].key against the token's iss claim. Real signature verification still happens downstream in openid-connect against whichever config gets selected. No IdP-specific logic is hard-coded in either plugin, so a single route can serve multiple IdP realms/domains without baking any provider knowledge into openid-connect. client_secret is encrypted at rest for both the single config (openid-connect, existing behavior) and each entry of the new plugin's configs array (via encrypt_fields = {"configs.client_secret"}). redirect_uri is intentionally not templated - out of scope for this change.
0a75b73 to
cb75e57
Compare
…ntime-variable templates Continues apache#13038 with the direction requested there: keep openid-connect generic and push request-identification/routing logic into a separate, higher-priority plugin instead of baking IdP-specific logic into openid-connect itself. openid-connect: `discovery`, `client_id`, and `client_secret` now resolve `${var}` / `${var ?? default}` templates from the request context (via core.utils.resolve_var, the same mechanism limit-count already uses), unconditionally, in rewrite(). An empty/nil resolution fails closed with a 500 naming the field that could not be resolved, instead of forwarding an empty value to the identity provider. New companion plugin openid-connect-consumer-selector (priority 2895, ahead of openid-connect's 2599) picks a config from a list of candidates and exposes it via ctx.var.oidc_discovery / oidc_client_id / oidc_client_secret for openid-connect's templates to consume. Two match modes, via `match_source`: - "var" (default): match configs[].key against an arbitrary request variable named by `match_var`. - "token_iss": read the Authorization: Bearer <token> header, decode the JWT payload without verifying its signature (same non-verifying use resty.jwt already gets in openid-connect.lua's session_scopes), and match configs[].key against the token's iss claim. Real signature verification still happens downstream in openid-connect against whichever config gets selected. No IdP-specific logic is hard-coded in either plugin, so a single route can serve multiple IdP realms/domains without baking any provider knowledge into openid-connect. client_secret is encrypted at rest for both the single config (openid-connect, existing behavior) and each entry of the new plugin's configs array (via encrypt_fields = {"configs.client_secret"}). redirect_uri is intentionally not templated - out of scope for this change.
cb75e57 to
0928862
Compare
…ntime-variable templates Continues apache#13038 with the direction requested there: keep openid-connect generic and push request-identification/routing logic into a separate, higher-priority plugin instead of baking IdP-specific logic into openid-connect itself. openid-connect: `discovery`, `client_id`, and `client_secret` now resolve `${var}` / `${var ?? default}` templates from the request context (via core.utils.resolve_var, the same mechanism limit-count already uses), unconditionally, in rewrite(). An empty/nil resolution fails closed with a 500 naming the field that could not be resolved, instead of forwarding an empty value to the identity provider. New companion plugin openid-connect-consumer-selector (priority 2895, ahead of openid-connect's 2599) picks a config from a list of candidates and exposes it via ctx.var.oidc_discovery / oidc_client_id / oidc_client_secret for openid-connect's templates to consume. Two match modes, via `match_source`: - "var" (default): match configs[].key against an arbitrary request variable named by `match_var`. - "token_iss": read the Authorization: Bearer <token> header, decode the JWT payload without verifying its signature (same non-verifying use resty.jwt already gets in openid-connect.lua's session_scopes), and match configs[].key against the token's iss claim. Real signature verification still happens downstream in openid-connect against whichever config gets selected. No IdP-specific logic is hard-coded in either plugin, so a single route can serve multiple IdP realms/domains without baking any provider knowledge into openid-connect. client_secret is encrypted at rest for both the single config (openid-connect, existing behavior) and each entry of the new plugin's configs array (via encrypt_fields = {"configs.client_secret"}). redirect_uri is intentionally not templated - out of scope for this change.
0928862 to
dac4ae2
Compare
…ntime-variable templates Continues apache#13038 with the direction requested there: keep openid-connect generic and push request-identification/routing logic into a separate, higher-priority plugin instead of baking IdP-specific logic into openid-connect itself. openid-connect: `discovery`, `client_id`, and `client_secret` now resolve `${var}` / `${var ?? default}` templates from the request context (via core.utils.resolve_var, the same mechanism limit-count already uses), unconditionally, in rewrite(). An empty/nil resolution fails closed with a 500 naming the field that could not be resolved, instead of forwarding an empty value to the identity provider. New companion plugin openid-connect-consumer-selector (priority 2895, ahead of openid-connect's 2599) picks a config from a list of candidates and exposes it via ctx.var.oidc_discovery / oidc_client_id / oidc_client_secret for openid-connect's templates to consume. Two match modes, via `match_source`: - "var" (default): match configs[].key against an arbitrary request variable named by `match_var`. - "token_iss": read the Authorization: Bearer <token> header, decode the JWT payload without verifying its signature (same non-verifying use resty.jwt already gets in openid-connect.lua's session_scopes), and match configs[].key against the token's iss claim. Real signature verification still happens downstream in openid-connect against whichever config gets selected. No IdP-specific logic is hard-coded in either plugin, so a single route can serve multiple IdP realms/domains without baking any provider knowledge into openid-connect. client_secret is encrypted at rest for both the single config (openid-connect, existing behavior) and each entry of the new plugin's configs array (via encrypt_fields = {"configs.client_secret"}). redirect_uri is intentionally not templated - out of scope for this change.
dac4ae2 to
2e870ba
Compare
|
Some initial comments:
|
…ntime-variable templates Continues apache#13038 with the direction requested there: keep openid-connect generic and push request-identification/routing logic into a separate, higher-priority plugin instead of baking IdP-specific logic into openid-connect itself. openid-connect: `discovery`, `client_id`, and `client_secret` now resolve `${var}` / `${var ?? default}` templates from the request context (via core.utils.resolve_var, the same mechanism limit-count already uses), unconditionally, in rewrite(). An empty/nil resolution fails closed with a 500 naming the field that could not be resolved, instead of forwarding an empty value to the identity provider. New companion plugin openid-connect-consumer-selector (priority 2895, ahead of openid-connect's 2599) picks a config from a list of candidates and exposes it via ctx.var.oidc_discovery / oidc_client_id / oidc_client_secret for openid-connect's templates to consume. Two match modes, via `match_source`: - "var" (default): match configs[].key against an arbitrary request variable named by `match_var`. - "token_iss": read the Authorization: Bearer <token> header, decode the JWT payload without verifying its signature (same non-verifying use resty.jwt already gets in openid-connect.lua's session_scopes), and match configs[].key against the token's iss claim. Real signature verification still happens downstream in openid-connect against whichever config gets selected. No IdP-specific logic is hard-coded in either plugin, so a single route can serve multiple IdP realms/domains without baking any provider knowledge into openid-connect. client_secret is encrypted at rest for both the single config (openid-connect, existing behavior) and each entry of the new plugin's configs array (via encrypt_fields = {"configs.client_secret"}). redirect_uri is intentionally not templated - out of scope for this change.
2e870ba to
16f5018
Compare
|
Some issues remain:
Thanks! |
Gotcha! Will do the same on my own fork first. |
…ntime-variable templates Continues apache#13038 with the direction requested there: keep openid-connect generic and push request-identification/routing logic into a separate, higher-priority plugin instead of baking IdP-specific logic into openid-connect itself. openid-connect: `discovery`, `client_id`, and `client_secret` now resolve `${var}` / `${var ?? default}` templates from the request context (via core.utils.resolve_var, the same mechanism limit-count already uses), unconditionally, in rewrite(). An empty/nil resolution fails closed with a 500 naming the field that could not be resolved, instead of forwarding an empty value to the identity provider. New companion plugin openid-connect-consumer-selector (priority 2895, ahead of openid-connect's 2599) picks a config from a list of candidates and exposes it via ctx.var.oidc_discovery / oidc_client_id / oidc_client_secret for openid-connect's templates to consume. Two match modes, via `match_source`: - "var" (default): match configs[].key against an arbitrary request variable named by `match_var`. - "token_iss": read the Authorization: Bearer <token> header, decode the JWT payload without verifying its signature (same non-verifying use resty.jwt already gets in openid-connect.lua's session_scopes), and match configs[].key against the token's iss claim. Real signature verification still happens downstream in openid-connect against whichever config gets selected. No IdP-specific logic is hard-coded in either plugin, so a single route can serve multiple IdP realms/domains without baking any provider knowledge into openid-connect. client_secret is encrypted at rest for both the single config (openid-connect, existing behavior) and each entry of the new plugin's configs array (via encrypt_fields = {"configs.client_secret"}). redirect_uri is intentionally not templated - out of scope for this change.
5cca6be to
13edc82
Compare
88fe184 to
63b5725
Compare
- Add Chinese translation for the new selector plugin's docs - Fix e2e login test flakiness (redirect_uri route mismatch, and a scoped-prefix route that didn't survive a full session-login flow; switched it to the catch-all route pattern proven elsewhere in the suite) - Drop the unused var-match selector mode, keeping only token_iss based selection, matching the actual issuer-routing use case - Rename openid-connect-consumer-selector -> openid-connect-idp-selector: the plugin never touches apisix.consumer or Consumer Groups, it only selects raw IdP config (discovery/client_id/client_secret) values, so "consumer selector" misnamed what it actually does
63b5725 to
137217b
Compare
|
Hi @janiussyafiq,
Let me know if there any logical or implementation specific changes that you want. |
There was a problem hiding this comment.
I think we don't need this (and the plugin declaration) anymore, do we?
Removal should include:
- apisix/plugins/openid-connect-idp-selector.lua
- Its plugin registration in apisix/cli/config.lua
- Its entry in conf/config.yaml.example
- English and Chinese selector documentation
- Documentation navigation entries
- t/plugin/openid-connect-idp-selector.t
- Admin plugin-list expectations
- References from openid-connect.md that present it as the built-in selector
|
|
||
| ### Selecting an IdP configuration per request | ||
|
|
||
| `client_id`, `client_secret`, and `discovery` also support `${var}` / `${var ?? default}` runtime-variable templates, resolved from the request context on every request (the same templating [`limit-count`](./limit-count.md) uses for `count`/`time_window`). This lets a higher-priority custom Plugin inspect the request, set `ctx.var.*`, and select the right IdP configuration for it, while `openid-connect` itself stays generic — it has no built-in notion of tenants, realms, or providers. See [`openid-connect-idp-selector`](./openid-connect-idp-selector.md) for a Plugin that does this based on the request's bearer token issuer. |
There was a problem hiding this comment.
FYI: to capitalise on the new feature of templated client_id, client_secret, and discovery, clients can directly send request headers and use http_<header_name> in the template.
having a custom plugin that would inspect the request and fill a ctx.var.** is not compulsory.
|
pls correct the PR scope |
|
The tests should include at least one successful OIDC authentication using runtime-resolved values. Abd a following request with different variable values, confirming the shared plugin configuration was not mutated or |
|
also, the unconditional variable resolution of the fields |
This PR adds an optional
consumer_selectorcapability to theopenid-connectplugin, allowing a single route to select a Consumer (and Consumer Group) from a JWT claim (for example,iss) before OIDC validation.With this flow, APISIX can apply realm-specific
openid-connectconfigurations from Consumer Groups deterministically on one route, without requiringkey-authpre-resolution.Changes included:
consumer_selectorschema fields inopenid-connect:enabledclaim(defaultiss)map(claim value -> consumer name)strictopenid-connectconfigX-Userinfo)consumer.get_consumer(name)helper and safe local-cache cloning inconsumer.luat/plugin/openid-connect2.tdocs/en/latest/plugins/openid-connect.mdWhich issue(s) this PR fixes:
Fixes #13037
Checklist