This is an automated email from the ASF dual-hosted git repository.

shreemaanabhishek pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new 5396af968 feat(openidc): support redis for session storage (#12986)
5396af968 is described below

commit 5396af96863ae11dea4abbc2f933a3bc56772830
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Tue Feb 10 15:46:38 2026 +0545

    feat(openidc): support redis for session storage (#12986)
---
 apisix/plugins/openid-connect.lua        |  67 +++++
 docs/en/latest/plugins/openid-connect.md |  15 ++
 docs/zh/latest/plugins/openid-connect.md |  15 ++
 t/plugin/openid-connect-redis.t          | 411 +++++++++++++++++++++++++++++++
 t/plugin/openid-connect.t                |   2 +-
 5 files changed, 509 insertions(+), 1 deletion(-)

diff --git a/apisix/plugins/openid-connect.lua 
b/apisix/plugins/openid-connect.lua
index 012dd02fe..1f84476d1 100644
--- a/apisix/plugins/openid-connect.lua
+++ b/apisix/plugins/openid-connect.lua
@@ -84,9 +84,76 @@ local schema = {
                             description = "it holds the cookie lifetime in 
seconds in the future",
                         }
                     }
+                },
+                storage = {
+                    type = "string",
+                    enum = {"cookie", "redis"},
+                    default = "cookie",
+                },
+                redis = {
+                    type = "object",
+                    properties = {
+                        host = {
+                            type = "string", minLength = 2, default = 
"127.0.0.1"
+                        },
+                        port = {
+                            type = "integer", minimum = 1, default = 6379,
+                        },
+                        username = {
+                            type = "string", minLength = 1,
+                        },
+                        password = {
+                            type = "string", minLength = 0,
+                        },
+                        database = {
+                            type = "integer", minimum = 0, default = 0,
+                            description = "redis database index",
+                        },
+                        prefix = {
+                            type = "string",
+                            default = "sessions",
+                            description = "prefix for keys stored in redis"
+                        },
+                        ssl = {
+                            type = "boolean", default = false,
+                            description = "enable ssl",
+                        },
+                        ssl_verify = {
+                            type = "boolean", default = false,
+                            description = "verify ssl certificate",
+                        },
+                        server_name = {
+                            type = "string",
+                            description = "The server name for the new TLS SNI 
extension.",
+                        },
+                        connect_timeout = {
+                            type = "integer", minimum = 1, default = 1000,
+                            description = "connect timeout in milliseconds",
+                        },
+                        send_timeout = {
+                            type = "integer", minimum = 1, default = 1000,
+                            description = "send timeout in milliseconds",
+                        },
+                        read_timeout = {
+                            type = "integer", minimum = 1, default = 1000,
+                            description = "read timeout in milliseconds",
+                        },
+                        keepalive_timeout = {
+                            type = "integer", minimum = 1000, default = 10000,
+                            description = "keepalive timeout in milliseconds",
+                        },
+                    }
                 }
             },
             required = {"secret"},
+            ["if"] = {
+                properties = {
+                    storage = { enum = {"redis"} },
+                },
+            },
+            ["then"] = {
+                required = {"redis"},
+            },
             additionalProperties = false,
         },
         realm = {
diff --git a/docs/en/latest/plugins/openid-connect.md 
b/docs/en/latest/plugins/openid-connect.md
index 472ab2489..cdee06366 100644
--- a/docs/en/latest/plugins/openid-connect.md
+++ b/docs/en/latest/plugins/openid-connect.md
@@ -67,6 +67,21 @@ The `openid-connect` Plugin supports the integration with 
[OpenID Connect (OIDC)
 | session.secret     | string   | True     |  | 16 or more characters | Key 
used for session encryption and HMAC operation when `bearer_only` is `false`.   
      |
 | session.cookie     | object   | False    |     |             |   Cookie 
configurations.    |
 | session.cookie.lifetime              | integer   | False    | 3600           
       |             | Cookie lifetime in seconds. |
+| session.storage    | string   | False    | cookie | ["cookie", "redis"] | 
Session storage method. |
+| session.redis        | object   | False    |     |             |   Redis 
configuration when `storage` is `redis`.    |
+| session.redis.host   | string   | False    | 127.0.0.1 |             |   
Redis host.    |
+| session.redis.port   | integer   | False    | 6379 |             |   Redis 
port.    |
+| session.redis.password | string   | False    |     |             |   Redis 
password.    |
+| session.redis.username | string   | False    |     |             |   Redis 
username.    |
+| session.redis.database | integer   | False    | 0 |             |   Redis 
database index.    |
+| session.redis.prefix | string   | False    | sessions |             |   
Redis key prefix.    |
+| session.redis.ssl    | boolean   | False    | false |             |   Enable 
SSL for Redis connection.    |
+| session.redis.ssl_verify | boolean   | False    | false |             |   
Verify SSL certificate.    |
+| session.redis.server_name | string   | False    |     |             |   
Redis server name for SNI.    |
+| session.redis.connect_timeout | integer   | False    | 1000 |             |  
 Connect timeout in milliseconds.    |
+| session.redis.send_timeout   | integer   | False    | 1000 |             |   
Send timeout in milliseconds.    |
+| session.redis.read_timeout   | integer   | False    | 1000 |             |   
Read timeout in milliseconds.    |
+| session.redis.keepalive_timeout | integer   | False    | 10000 |             
|   Keepalive timeout in milliseconds.    |
 | session_contents   | object   | False    |                   |             | 
Session content configurations. If unconfigured, all data will be stored in the 
session. |
 | session_contents.access_token   | boolean   | False    |          |          
   | If true, store the access token in the session.  |
 | session_contents.id_token   | boolean   | False    |          |             
| If true, store the ID token in the session.  |
diff --git a/docs/zh/latest/plugins/openid-connect.md 
b/docs/zh/latest/plugins/openid-connect.md
index 7da9ffc2f..33f6499a6 100644
--- a/docs/zh/latest/plugins/openid-connect.md
+++ b/docs/zh/latest/plugins/openid-connect.md
@@ -67,6 +67,21 @@ description: openid-connect 插件支持与 OpenID Connect (OIDC) 身份提供
 | session.secret | string | 是 | | 16 个字符以上 | 当 `bearer_only` 为 `false` 时,用于 
session 加密和 HMAC 运算的密钥。|
 | session.cookie | object | 否 | | | Cookie 配置。 |
 | session.cookie.lifetime | integer | 否 | 3600 | | Cookie 生存时间(秒)。|
+| session.storage | string | 否 | cookie | ["cookie", "redis"] | 会话存储方式。 |
+| session.redis | object | 否 | | | 当 `storage` 为 `redis` 时的 Redis 配置。 |
+| session.redis.host | string | 否 | 127.0.0.1 | | Redis 主机地址。 |
+| session.redis.port | integer | 否 | 6379 | | Redis 端口。 |
+| session.redis.password | string | 否 | | | Redis 密码。 |
+| session.redis.username | string | 否 | | | Redis 用户名。 |
+| session.redis.database | integer | 否 | 0 | | Redis 数据库索引。 |
+| session.redis.prefix | string | 否 | sessions | | Redis 键前缀。 |
+| session.redis.ssl    | boolean   | 否    | false |             |   启用 Redis 
SSL 连接。    |
+| session.redis.ssl_verify | boolean   | 否    | false |             |   验证 SSL 
证书。    |
+| session.redis.server_name | string   | 否    |     |             |   Redis 
SNI 服务器名称。    |
+| session.redis.connect_timeout | integer   | 否    | 1000 |             |   
连接超时时间(毫秒)。    |
+| session.redis.send_timeout   | integer   | 否    | 1000 |             |   
发送超时时间(毫秒)。    |
+| session.redis.read_timeout   | integer   | 否    | 1000 |             |   
读取超时时间(毫秒)。    |
+| session.redis.keepalive_timeout | integer   | 否    | 10000 |             |   
Keepalive 超时时间(毫秒)。    |
 | unauth_action | string | 否 | auth | ["auth","deny","pass"] | 
未经身份验证的请求的操作。设置为 `auth` 时,重定向到 OpenID 提供程序的身份验证端点。设置为 `pass` 时,允许请求而无需身份验证。设置为 
`deny` 时,返回 401 未经身份验证的响应,而不是启动授权代码授予流程。|
 | session_contents   | object   | 否    |       |        | 
会话内容配置。如果未配置,将把所有数据存储在会话中。 |
 | session_contents.access_token   | boolean   | 否    |          |        | 若为 
true,则将访问令牌存储在会话中。 |
diff --git a/t/plugin/openid-connect-redis.t b/t/plugin/openid-connect-redis.t
new file mode 100644
index 000000000..d199486fd
--- /dev/null
+++ b/t/plugin/openid-connect-redis.t
@@ -0,0 +1,411 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_long_string();
+no_root_location();
+no_shuffle();
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: check schema with valid redis session configuration
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local ok, err = plugin.check_schema({
+                client_id = "a",
+                client_secret = "b",
+                discovery = "c",
+                session = {
+                    secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK",
+                    storage = "redis",
+                    redis = {
+                        host = "127.0.0.1",
+                        port = 6379,
+                        prefix = "mysessions",
+                    }
+                }
+            })
+            if not ok then
+                ngx.say(err)
+            else
+                ngx.say("done")
+            end
+        }
+    }
+--- request
+GET /t
+--- response_body
+done
+
+
+
+=== TEST 2: check schema with invalid redis session configuration (port string)
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local ok, err = plugin.check_schema({
+                client_id = "a",
+                client_secret = "b",
+                discovery = "c",
+                session = {
+                    secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK",
+                    storage = "redis",
+                    redis = {
+                        port = "invalid",
+                    }
+                }
+            })
+            if not ok then
+                ngx.say(err)
+            else
+                ngx.say("done")
+            end
+        }
+    }
+--- request
+GET /t
+--- response_body_like
+property "port" validation failed: wrong type: expected integer, got string
+
+
+
+=== TEST 3: verify session sharing across routes with Redis (Simulate Refresh 
Scenario)
+--- http_config
+    server {
+        listen 11980;
+        server_name localhost;
+
+        location / {
+            content_by_lua_block {
+                ngx.say("lesgooo!!")
+            }
+        }
+    }
+    server {
+        listen 16969;
+        server_name localhost;
+
+        location /.well-known/openid-configuration {
+            content_by_lua_block {
+                ngx.header.content_type = "application/json"
+                ngx.say([[
+                {
+                    "issuer": "http://127.0.0.1:16969";,
+                    "authorization_endpoint": 
"http://127.0.0.1:16969/authorize";,
+                    "token_endpoint": "http://127.0.0.1:16969/token";,
+                    "userinfo_endpoint": "http://127.0.0.1:16969/userinfo";,
+                    "jwks_uri": "http://127.0.0.1:16969/jwks";
+                }
+                ]])
+            }
+        }
+
+        location /token {
+            content_by_lua_block {
+                local jwt = require("resty.jwt")
+                local validators = require("resty.jwt-validators")
+                local cjson = require("cjson")
+
+                ngx.header.content_type = "application/json"
+                ngx.req.read_body()
+                local args = ngx.req.get_post_args()
+
+                if args.grant_type == "authorization_code" then
+                    local claim_spec = {
+                       sub = "user_123",
+                       iss = "http://127.0.0.1:16969";,
+                       aud = "test_client",
+                       exp = ngx.time() + 60,
+                       iat = ngx.time(),
+                       name = "Test User"
+                    }
+
+                    local jwt_token = jwt:sign(
+                       "test_secret",
+                       {
+                           header = {typ = "JWT", alg = "HS256"},
+                           payload = claim_spec
+                       }
+                    )
+
+                    ngx.say(cjson.encode({
+                       access_token = "access_token_1",
+                       expires_in = 1,
+                       refresh_token = "refresh_token_1",
+                       id_token = jwt_token,
+                       token_type = "Bearer"
+                    }))
+                elseif args.grant_type == "refresh_token" then
+                    -- Verify that the refresh token matches what we issued
+                    if args.refresh_token == "refresh_token_1" then
+                        local claim_spec = {
+                           sub = "user_123",
+                           iss = "http://127.0.0.1:16969";,
+                           aud = "test_client",
+                           exp = ngx.time() + 3600,
+                           iat = ngx.time(),
+                           name = "Test User"
+                        }
+
+                        local jwt_token = jwt:sign(
+                           "test_secret",
+                           {
+                               header = {typ = "JWT", alg = "HS256"},
+                               payload = claim_spec
+                           }
+                        )
+
+                        ngx.say(cjson.encode({
+                           access_token = "access_token_2",
+                           expires_in = 3600,
+                           refresh_token = "refresh_token_2",
+                           id_token = jwt_token,
+                           token_type = "Bearer"
+                        }))
+                    else
+                        ngx.status = 400
+                        ngx.say('{"error":"invalid_grant"}')
+                    end
+                else
+                    ngx.status = 400
+                    ngx.say('{"error":"unsupported_grant_type"}')
+                end
+            }
+        }
+
+        location /userinfo {
+            content_by_lua_block {
+                ngx.header.content_type = "application/json"
+                ngx.say([[{"sub": "user_123", "name": "Test User"}]])
+            }
+        }
+
+        location /jwks {
+            content_by_lua_block {
+                ngx.header.content_type = "application/json"
+                ngx.say([[{"keys": []}]])
+            }
+        }
+    }
+
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local http = require("resty.http")
+
+            -- Create Route 1
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [=[{
+                    "plugins": {
+                        "openid-connect": {
+                            "client_id": "test_client",
+                            "client_secret": "test_secret",
+                            "discovery": 
"http://127.0.0.1:16969/.well-known/openid-configuration";,
+                            "redirect_uri": 
"http://127.0.0.1/api/route1/callback";,
+                            "session": {
+                                "secret": "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK",
+                                "storage": "redis",
+                                "redis": {
+                                    "host": "127.0.0.1",
+                                    "port": 6379,
+                                    "prefix": "test_shared_sessions"
+                                }
+                            }
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:11980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/api/route1*"
+                }]=]
+            )
+
+            if code >= 300 then
+                ngx.say("setup route 1 failed")
+                return
+            end
+
+            -- Create Route 2
+            local code, body = t('/apisix/admin/routes/2',
+                ngx.HTTP_PUT,
+                [=[{
+                    "plugins": {
+                        "openid-connect": {
+                            "client_id": "test_client",
+                            "client_secret": "test_secret",
+                            "discovery": 
"http://127.0.0.1:16969/.well-known/openid-configuration";,
+                            "redirect_uri": 
"http://127.0.0.1/api/route2/callback";,
+                            "session": {
+                                "secret": "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK",
+                                "storage": "redis",
+                                "redis": {
+                                    "host": "127.0.0.1",
+                                    "port": 6379,
+                                    "prefix": "test_shared_sessions"
+                                }
+                            }
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:11980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/api/route2*"
+                }]=]
+            )
+
+            if code >= 300 then
+                ngx.say("setup route 2 failed")
+                return
+            end
+
+            local httpc = http.new()
+
+            -- extract cookie value by name from Set-Cookie header
+            local function get_cookie(headers, name)
+                local cookies = headers["Set-Cookie"]
+                if not cookies then return nil end
+                if type(cookies) == "string" then cookies = { cookies } end
+                for _, c in ipairs(cookies) do
+                    local val = string.match(c, name .. "=([^;]+)")
+                    if val then return name .. "=" .. val end
+                end
+                return nil
+            end
+
+            -- access without login state
+            local uri_start = "http://127.0.0.1:"; .. ngx.var.server_port .. 
"/api/route1/start"
+            local res, err = httpc:request_uri(uri_start, { method = "GET" })
+
+            if not res or res.status ~= 302 then
+                ngx.say("failed to start flow: ", res and res.status or err)
+                return
+            end
+
+            local initial_cookie = get_cookie(res.headers, "session")
+            if not initial_cookie then
+                ngx.say("failed to get initial session cookie")
+                return
+            end
+
+            -- extract state from the Location URL example: 
http://.../authorize?client_id=...&state=...&nonce=...
+            local loc = res.headers["Location"]
+            local state = string.match(loc, "state=([^&]+)")
+            if not state then
+                ngx.say("failed to extract state from location header")
+                return
+            end
+
+            -- act as the IdP redirecting back with the code and the SAME 
state.
+            local uri_cb = "http://127.0.0.1:"; .. ngx.var.server_port .. 
"/api/route1/callback?code=mock_code&state=" .. state
+            res, err = httpc:request_uri(uri_cb, {
+                method = "GET",
+                headers = {
+                    ["Cookie"] = initial_cookie
+                }
+            })
+
+            -- We expect a successful login (likely redirect to original URL 
or 200)
+            if not res then
+                ngx.say("callback request failed: ", err)
+                return
+            end
+
+            -- After callback, we get the FINAL authenticated session cookie.
+            local auth_cookie = get_cookie(res.headers, "session")
+            if not auth_cookie then
+                ngx.say("failed to get authenticated session cookie after 
callback. status: ", res.status)
+                return
+            end
+            ngx.log(ngx.INFO, "auth_cookie: ", auth_cookie)
+
+            -- wait for token expiry as our mock idp issues tokens with 
'expires_in: 1'
+            ngx.sleep(2)
+
+            -- access route 2 with the expired (but valid refresh) session
+            local uri_r2 = "http://127.0.0.1:"; .. ngx.var.server_port .. 
"/api/route2/resource"
+            local res, err = httpc:request_uri(uri_r2, {
+                method = "GET",
+                headers = {
+                    ["Cookie"] = auth_cookie
+                }
+            })
+
+            if not res then
+                ngx.say("request to route 2 failed: ", err)
+                return
+            end
+
+            if res.status == 200 then
+                ngx.say("refresh successful - request passed to upstream")
+            elseif res.status == 302 then
+                ngx.say("refresh failed - redirected to login")
+            else
+                ngx.say("unexpected status: ", res.status, " body: ", res.body)
+            end
+
+        }
+    }
+--- request
+GET /t
+--- response_body
+refresh successful - request passed to upstream
+--- no_error_log
+[error]
+
+
+
+=== TEST 4: check schema with missing redis configuration when storage is redis
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local ok, err = plugin.check_schema({
+                client_id = "a",
+                client_secret = "b",
+                discovery = "c",
+                session = {
+                    secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK",
+                    storage = "redis",
+                    -- redis object missing
+                }
+            })
+            if not ok then
+                ngx.say(err)
+            else
+                ngx.say("done")
+            end
+        }
+    }
+--- request
+GET /t
+--- response_body
+property "session" validation failed: then clause did not match
diff --git a/t/plugin/openid-connect.t b/t/plugin/openid-connect.t
index 971b15823..3f3a98387 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","in
 [...]
+{"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","in
 [...]
 
 
 

Reply via email to