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
[...]