diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index eb0d3b526c58..035380a74992 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -316,6 +316,12 @@ local schema = { type = "boolean", default = true }, + set_enc_id_token_header = { + description = "Whether the raw signed ID token JWT should be added in the " .. + "X-Enc-ID-Token header to the request for downstream.", + type = "boolean", + default = false + }, set_userinfo_header = { description = "Whether the user info token should be added in the X-Userinfo " .. "header to the request for downstream.", @@ -703,7 +709,7 @@ function _M.rewrite(plugin_conf, ctx) local conf = core.table.clone(plugin_conf) -- Snapshot the client-supplied X-Access-Token (it doubles as a bearer - -- input via get_bearer_access_token) and clear the four headers this + -- input via get_bearer_access_token) and clear the five headers this -- plugin advertises as outputs so client-supplied values cannot bleed -- through to the upstream. ctx.openid_connect_client_x_access_token = core.request.header(ctx, "X-Access-Token") @@ -711,6 +717,7 @@ function _M.rewrite(plugin_conf, ctx) core.request.set_header(ctx, "X-Userinfo", nil) core.request.set_header(ctx, "X-ID-Token", nil) core.request.set_header(ctx, "X-Refresh-Token", nil) + core.request.set_header(ctx, "X-Enc-ID-Token", nil) -- Previously, we multiply conf.timeout before storing it in etcd. -- If the timeout is too large, we should not multiply it again. @@ -854,6 +861,15 @@ function _M.rewrite(plugin_conf, ctx) unauth_action = "deny" end + -- When set_enc_id_token_header is enabled and the user has explicitly restricted + -- session_contents, ensure enc_id_token is included so session:get("enc_id_token") + -- returns the raw signed JWT. When session_contents is nil, lua-resty-openidc stores + -- all session data by default (including enc_id_token), so no action is needed. + if conf.set_enc_id_token_header and conf.session_contents then + conf.session_contents = core.table.clone(conf.session_contents) + conf.session_contents.enc_id_token = true + end + -- Authenticate the request. This will validate the access token if it -- is stored in a sessions cookie, and also renew the token if required. -- If no token can be extracted, the response will redirect to the ID @@ -910,6 +926,12 @@ function _M.rewrite(plugin_conf, ctx) if refresh_token and conf.set_refresh_token_header then core.request.set_header(ctx, "X-Refresh-Token", refresh_token) end + + -- Add X-Enc-ID-Token header, maybe. + local enc_id_token = session:get("enc_id_token") + if enc_id_token and conf.set_enc_id_token_header then + core.request.set_header(ctx, "X-Enc-ID-Token", enc_id_token) + end end end if session then diff --git a/docs/en/latest/plugins/openid-connect.md b/docs/en/latest/plugins/openid-connect.md index e196d6922206..dd6ddb3560b1 100644 --- a/docs/en/latest/plugins/openid-connect.md +++ b/docs/en/latest/plugins/openid-connect.md @@ -63,7 +63,8 @@ The `openid-connect` Plugin supports the integration with [OpenID Connect (OIDC) | token_signing_alg_values_expected | string | False | | | Algorithm used for signing JWT, such as `RS256`. | | set_access_token_header | boolean | False | true | | If true, set the access token in a request header. By default, the `X-Access-Token` header is used. | | access_token_in_authorization_header | boolean | False | false | | If true and if `set_access_token_header` is also true, set the access token in the `Authorization` header. | -| set_id_token_header | boolean | False | true | | If true and if the ID token is available, set the value in the `X-ID-Token` request header. | +| set_id_token_header | boolean | False | true | | If true and if the ID token is available, set the value in the `X-ID-Token` request header. Note: this header contains `base64(JSON(decoded_claims))` and carries no cryptographic signature. | +| set_enc_id_token_header | boolean | False | false | | If true and if the raw signed ID token JWT is available, set the value in the `X-Enc-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. | | session | object | False | | | Session configuration used when `bearer_only` is `false` and the Plugin uses Authorization Code flow. | diff --git a/t/plugin/openid-connect.t b/t/plugin/openid-connect.t index 312104c6789b..415159bb1351 100644 --- a/t/plugin/openid-connect.t +++ b/t/plugin/openid-connect.t @@ -937,7 +937,7 @@ OIDC introspection failed: invalid token } } --- response_body -{"accept_none_alg":false,"accept_unsupported_alg":true,"access_token_expires_leeway":0,"access_token_in_authorization_header":false,"bearer_only":false,"client_id":"kbyuFDidLLm280LIwVFiazOqjO3ty8KH","client_jwt_assertion_expires_in":60,"client_secret":"60Op4HFM0I8ajz0WdiStAbziZ-VFQttXuxixHHs2R7r7-CW8GR79l-mmLqMhc-Sa","discovery":"http://127.0.0.1:1980/.well-known/openid-configuration","force_reauthorize":false,"iat_slack":120,"introspection_endpoint_auth_method":"client_secret_basic","introspection_interval":0,"jwk_expires_in":86400,"jwt_verification_cache_ignore":false,"logout_path":"/logout","realm":"apisix","renew_access_token_on_expiry":true,"revoke_tokens_on_logout":false,"scope":"openid","session":{"secret":"jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK","storage":"cookie"},"set_access_token_header":true,"set_id_token_header":true,"set_refresh_token_header":false,"set_userinfo_header":true,"ssl_verify":true,"timeout":3,"token_endpoint_auth_method":"client_secret_basic","unauth_action":"auth","use_jwks":false,"use_nonce":false,"use_pkce":false} +{"accept_none_alg":false,"accept_unsupported_alg":true,"access_token_expires_leeway":0,"access_token_in_authorization_header":false,"bearer_only":false,"client_id":"kbyuFDidLLm280LIwVFiazOqjO3ty8KH","client_jwt_assertion_expires_in":60,"client_secret":"60Op4HFM0I8ajz0WdiStAbziZ-VFQttXuxixHHs2R7r7-CW8GR79l-mmLqMhc-Sa","discovery":"http://127.0.0.1:1980/.well-known/openid-configuration","force_reauthorize":false,"iat_slack":120,"introspection_endpoint_auth_method":"client_secret_basic","introspection_interval":0,"jwk_expires_in":86400,"jwt_verification_cache_ignore":false,"logout_path":"/logout","realm":"apisix","renew_access_token_on_expiry":true,"revoke_tokens_on_logout":false,"scope":"openid","session":{"secret":"jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK","storage":"cookie"},"set_access_token_header":true,"set_enc_id_token_header":false,"set_id_token_header":true,"set_refresh_token_header":false,"set_userinfo_header":true,"ssl_verify":true,"timeout":3,"token_endpoint_auth_method":"client_secret_basic","unauth_action":"auth","use_jwks":false,"use_nonce":false,"use_pkce":false} @@ -2049,3 +2049,111 @@ passed --- timeout: 20 --- response_body passed + + + +=== TEST 55: Configure plugin with set_enc_id_token_header enabled. +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "openid-connect": { + "discovery": "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration", + "realm": "University", + "client_id": "course_management", + "client_secret": "d1ec69e9-55d2-4109-a3ea-befa071579d5", + "redirect_uri": "http://127.0.0.1:]] .. ngx.var.server_port .. [[/authenticated", + "ssl_verify": false, + "timeout": 10, + "set_access_token_header": false, + "set_id_token_header": false, + "set_userinfo_header": false, + "set_enc_id_token_header": true, + "session": { + "secret": "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/*" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 56: Full OIDC login sets X-Enc-ID-Token with the raw signed JWT; other auth headers are absent. +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local login_keycloak = require("lib.keycloak").login_keycloak + local concatenate_cookies = require("lib.keycloak").concatenate_cookies + + local httpc = http.new() + + local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/uri" + local res, err = login_keycloak(uri, "teacher@gmail.com", "123456") + if err then + ngx.status = 500 + ngx.say(err) + return + end + + local cookie_str = concatenate_cookies(res.headers['Set-Cookie']) + local redirect_uri = "http://127.0.0.1:" .. ngx.var.server_port .. res.headers['Location'] + res, err = httpc:request_uri(redirect_uri, { + method = "GET", + headers = { + ["Cookie"] = cookie_str + } + }) + + if not res then + ngx.status = 500 + ngx.say(err) + return + elseif res.status ~= 200 then + ngx.status = 500 + ngx.say("Invoking the original URI didn't return the expected result.") + return + end + + -- X-Enc-ID-Token must be present and contain a JWT (starts with "ey"). + if not res.body:find("x-enc-id-token: ey", 1, true) then + ngx.status = 500 + ngx.say("expected x-enc-id-token header with a JWT value, body: " .. res.body) + return + end + + -- The other auth headers must be absent (set_*_header = false). + for _, unwanted in ipairs({"x-access-token:", "x-id-token:", "x-userinfo:"}) do + if res.body:find(unwanted, 1, true) then + ngx.status = 500 + ngx.say("unexpected header found: " .. unwanted) + return + end + end + + ngx.say("passed") + } + } +--- response_body +passed