diff --git a/README.md b/README.md index 8f3f2e45..3a5ddb18 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ http { * [Configuration](#configuration) * [Session Configuration](#session-configuration) * [Cookie Storage Configuration](#cookie-storage-configuration) + * [Session Revocation Configuration](#session-revocation-configuration) * [DSHM Storage Configuration](#dshm-storage-configuration) * [File Storage Configuration](#file-storage-configuration) * [Memcached Storage Configuration](#memcached-storage-configuration) @@ -327,6 +328,8 @@ Here are the possible session configuration options: | `request_headers` | `nil` | Set of headers to send to upstream, use `id`, `audience`, `subject`, `timeout`, `idling-timeout`, `rolling-timeout`, `absolute-timeout`. E.g. `{ "id", "timeout" }` will set `Session-Id` and `Session-Timeout` request headers when `set_headers` is called. | | `response_headers` | `nil` | Set of headers to send to downstream, use `id`, `audience`, `subject`, `timeout`, `idling-timeout`, `rolling-timeout`, `absolute-timeout`. E.g. `{ "id", "timeout" }` will set `Session-Id` and `Session-Timeout` response headers when `set_headers` is called. | | `storage` | `nil` | Storage is responsible of storing session data, use `nil` or `"cookie"` (data is stored in cookie), `"dshm"`, `"file"`, `"memcached"`, `"mysql"`, `"postgres"`, `"redis"`, or `"shm"`, or give a name of custom module (`"custom-storage"`), or a `table` that implements session storage interface. | +| `revocation` | `nil` | Enable Redis-backed session revocation for cookie (stateless) sessions, use `nil`, `true`, `false`, a Redis configuration `table`, or a `table` that implements the revocation store interface (see below). | +| `revocation_fail_mode` | `"open"` | Behavior when the revocation store is unreachable, use `"open"` (treat as not revoked) or `"closed"` (reject the session). | | `dshm` | `nil` | Configuration for dshm storage, e.g. `{ prefix = "sessions" }` (see below) | | `file` | `nil` | Configuration for file storage, e.g. `{ path = "/tmp", suffix = "session" }` (see below) | | `memcached` | `nil` | Configuration for memcached storage, e.g. `{ prefix = "sessions" }` (see below) | @@ -343,6 +346,56 @@ When storing data to cookie, there is no additional configuration required, just set the `storage` to `nil` or `"cookie"`. +## Session Revocation Configuration + +Cookie (stateless) sessions are self-contained: once issued, a cookie remains +valid until it expires according to the configured timeouts. Revocation adds +an optional Redis-backed denylist so that destroyed sessions are rejected +immediately, without waiting for the cookie to expire. + +Revocation is only available when session data is stored in the cookie +(`storage` is `nil` or `"cookie"`). It must be enabled explicitly with +`revocation = true` (using the `redis` configuration) or +`revocation = { ... }` (inline Redis or custom store settings). Setting +`revocation = false` disables it. + +On every `session:open`, the library checks whether the session identifier is +revoked. On `session:destroy`, the identifier is written to Redis with a TTL +equal to the remaining session lifetime (rolling and absolute timeouts). The +revocation mark is a lightweight sentinel; no session payload is stored in +Redis. + +Use `revocation_fail_mode` to control behavior when Redis is unreachable: + +- `"open"` (default): log a warning and treat the session as not revoked. + Destroy still clears the cookie even if the revocation write fails. +- `"closed"`: reject the session open or destroy operation. + +Revocation applies to `session:destroy` (and `session:logout` when it destroys +the last audience). It does not revoke the previous session identifier on +`session:save` (session rotation) or partial `session:logout` (multiple +audiences). After rotation or partial logout, the previous cookie remains +usable until its `stale_ttl` or timeout elapses. + +Example: + +```lua +require("resty.session").init({ + storage = "cookie", + revocation = true, + redis = { + host = "127.0.0.1", + password = "secret", + prefix = "sessions", + }, +}) +``` + +The `redis.mode` setting selects whether a Redis connection is used for +session data (`"storage"`) or for revocation (`"revocation"`). When unset, +it defaults to `"revocation"` for cookie storage and `"storage"` otherwise. + + ## DSHM Storage Configuration With DHSM storage you can use the following settings (set the `storage` to `"dshm"`): @@ -529,6 +582,7 @@ connections. Common configuration settings among them all: | Option | Default | Description | |---------------------|:-------:|----------------------------------------------------------------------------------------------| +| `mode` | `nil` | Role of this Redis connection: `"storage"` for session data or `"revocation"` for the session denylist. Defaults to `"revocation"` when `storage` is `nil` or `"cookie"`, otherwise `"storage"`. | | `prefix` | `nil` | Prefix for the keys stored in Redis. | | `suffix` | `nil` | Suffix for the keys stored in Redis. | | `username` | `nil` | The database username to authenticate. | diff --git a/lib/resty/session.lua b/lib/resty/session.lua index ce766f32..2e565afc 100644 --- a/lib/resty/session.lua +++ b/lib/resty/session.lua @@ -44,6 +44,7 @@ local encode_base64url = utils.encode_base64url local decode_base64url = utils.decode_base64url local table_is_empty = utils.is_empty_table local load_storage = utils.load_storage +local load_revocation = utils.load_revocation local encode_json = utils.encode_json local decode_json = utils.decode_json local base64_size = utils.base64_size @@ -148,6 +149,8 @@ local DEFAULT_FLAGS local DEFAULT_REQUEST_HEADERS local DEFAULT_RESPONSE_HEADERS local DEFAULT_STORAGE +local DEFAULT_REVOCATION +local DEFAULT_REVOCATION_FAIL_MODE local DUMMY_META = {} @@ -372,6 +375,83 @@ local function get_store_ttl(self, remember, current_time, creation_time, rollin end +local REVOCATION_MARK = "1" + + +local function handle_revocation_error(self, err, msg) + if self.revocation_fail_mode == "open" then + log(WARN, "[session] ", msg, ": ", err) + return true + end + + return nil, errmsg(err, msg) +end + + +local function is_session_revoked(self, sid, cookie_name) + if self.storage or not sid then + return false, nil + end + + local revocation = self.revocation + if not revocation then + return false, nil + end + + local key, herr = self.hash_storage_key(sid) + if not key then + return nil, herr + end + + local current_time = time() + local data, err = revocation:get(cookie_name, key, current_time) + if err then + local ok, rerr = handle_revocation_error(self, err, "unable to check session revocation") + if not ok then + return nil, rerr + end + return false, nil + end + + if data == REVOCATION_MARK then + return true, nil + end + + return false, nil +end + + +local function mark_session_revoked(self, remember, meta) + if self.storage then + return true + end + + local revocation = self.revocation + if not revocation then + return true + end + + local sid = meta and meta.sid + if not sid then + return true + end + + local cookie_name = remember and self.remember_cookie_name or self.cookie_name + local key, herr = self.hash_storage_key(sid) + if not key then + return nil, herr + end + + local current_time = time() + local ttl = get_store_ttl(self, remember, current_time, meta.creation_time, meta.rolling_offset) + local ok, err = revocation:set(cookie_name, key, REVOCATION_MARK, ttl, current_time) + if not ok then + return handle_revocation_error(self, err, "unable to mark session revoked") + end + + return true +end + local function get_store_metadata(self) if not self.store_metadata then @@ -716,6 +796,14 @@ local function open(self, remember, meta_only) end end + local revoked, err = is_session_revoked(self, sid, cookie_name) + if err then + return nil, err + end + if revoked then + return nil, "session revoked" + end + local data_index = self.data_index local audience = self.data[data_index][2] local initial_chunk, ciphertext, ciphertext_encoded, info_data do @@ -1253,6 +1341,11 @@ local function destroy(self, remember) local cookie_name_size = #cookie_name local storage = self.storage + local ok, err = mark_session_revoked(self, remember, meta) + if not ok then + return nil, err + end + local cookie_chunks = 1 local data_size = meta.data_size if not storage and data_size then @@ -2341,6 +2434,8 @@ local session = { -- @field request_headers Set of headers to send to upstream, use `id`, `audience`, `subject`, `timeout`, `idling-timeout`, `rolling-timeout`, `absolute-timeout`. E.g. `{ "id", "timeout" }` will set `Session-Id` and `Session-Timeout` request headers when `set_headers` is called. -- @field response_headers Set of headers to send to downstream, use `id`, `audience`, `subject`, `timeout`, `idling-timeout`, `rolling-timeout`, `absolute-timeout`. E.g. `{ "id", "timeout" }` will set `Session-Id` and `Session-Timeout` response headers when `set_headers` is called. -- @field storage Storage is responsible of storing session data, use `nil` or `"cookie"` (data is stored in cookie), `"dshm"`, `"file"`, `"memcached"`, `"mysql"`, `"postgres"`, `"redis"`, or `"shm"`, or give a name of custom module (`"custom-storage"`), or a `table` that implements session storage interface (defaults to `nil`) +-- @field revocation Session revocation backend for cookie (stateless) sessions, use `nil` (auto-load from `redis` when configured), `false` to disable, `"redis"`, `true` (alias for `"redis"`), or a pre-built store `table` with `set`/`get` methods (defaults to `nil`) +-- @field revocation_fail_mode Behavior when the revocation store is unreachable, use `"open"` (treat as not revoked) or `"closed"` (reject the session) (defaults to `"open"`) -- @field dshm Configuration for dshm storage, e.g. `{ prefix = "sessions" }` -- @field file Configuration for file storage, e.g. `{ path = "/tmp", suffix = "session" }` -- @field memcached Configuration for memcached storage, e.g. `{ prefix = "sessions" }` @@ -2401,6 +2496,10 @@ local function opt(configuration, name, default) end end end + + elseif name == "revocation" then + value = load_revocation(nil, configuration) + end else @@ -2451,6 +2550,31 @@ local function opt(configuration, name, default) assert(t == "table", "invalid session storage") end end + + elseif name == "revocation" then + if value == false then + value = nil + + else + local t = type(value) + if t == "string" then + value = assert(load_revocation(value, configuration), "unable to load session revocation") + + elseif value == true then + value = assert(load_revocation("redis", configuration), "unable to load session revocation") + + elseif t == "table" then + if type(value.set) ~= "function" or type(value.get) ~= "function" then + error("invalid session revocation") + end + + else + error("invalid session revocation") + end + end + + elseif name == "revocation_fail_mode" then + assert(value == "open" or value == "closed", "invalid revocation fail mode") end end @@ -2497,6 +2621,8 @@ function session.init(configuration) DEFAULT_REQUEST_HEADERS = opt(configuration, "request_headers") DEFAULT_RESPONSE_HEADERS = opt(configuration, "response_headers") DEFAULT_STORAGE = opt(configuration, "storage") + DEFAULT_REVOCATION = opt(configuration, "revocation") + DEFAULT_REVOCATION_FAIL_MODE = opt(configuration, "revocation_fail_mode", "open") end --- @@ -2553,6 +2679,8 @@ function session.new(configuration) local request_headers = opt(configuration, "request_headers", DEFAULT_REQUEST_HEADERS) local response_headers = opt(configuration, "response_headers", DEFAULT_RESPONSE_HEADERS) local storage = opt(configuration, "storage", DEFAULT_STORAGE) + local revocation = opt(configuration, "revocation", DEFAULT_REVOCATION) + local revocation_fail_mode = opt(configuration, "revocation_fail_mode", DEFAULT_REVOCATION_FAIL_MODE) if cookie_prefix == "__Host-" then cookie_name = cookie_prefix .. cookie_name @@ -2625,6 +2753,8 @@ function session.new(configuration) remember = remember, flags = flags, storage = storage, + revocation = revocation, + revocation_fail_mode = revocation_fail_mode, ikm = ikm, ikm_fallbacks = ikm_fallbacks, request_headers = request_headers, diff --git a/lib/resty/session/utils.lua b/lib/resty/session/utils.lua index b5b88749..f401ae57 100644 --- a/lib/resty/session/utils.lua +++ b/lib/resty/session/utils.lua @@ -923,6 +923,8 @@ local load_storage do elseif storage == "redis" then local cfg = configuration and configuration.redis if cfg then + assert(cfg.mode ~= "revocation", "invalid redis mode for session storage") + if cfg.nodes then if not REDIS_CLUSTER then REDIS_CLUSTER = require("resty.session.redis.cluster") @@ -959,6 +961,76 @@ local load_storage do end + +local load_revocation do + local REDIS + local CUSTOM = {} + + --- + -- Loads session revocation store and creates a new instance using session configuration. + -- + -- @function utils.load_revocation + -- @tparam nil|boolean|string revocation revocation store name, `nil` to auto-load from + -- `redis` when configured for revocation, `true` for `"redis"`, or `false` to disable + -- @tparam[opt] table configuration session configuration + -- @treturn table|nil instance of session revocation store + -- @treturn string|nil error message + -- + -- @usage + -- local redis = require("resty.session.utils").load_revocation("redis", { + -- redis = { + -- host = "127.0.0.1", + -- } + -- }) + load_revocation = function(revocation, configuration) + if revocation == false or revocation == "cookie" then + return nil + end + + if revocation == true then + revocation = "redis" + end + + local session_storage = configuration and configuration.storage + if session_storage and session_storage ~= "cookie" then + return nil + end + + if not revocation then + local redis_cfg = configuration and configuration.redis + if not redis_cfg or not redis_cfg.host or redis_cfg.mode == "storage" then + return nil + end + + revocation = "redis" + end + + if type(revocation) ~= "string" then + error("invalid session revocation") + end + + if revocation == "redis" then + local cfg = configuration and configuration.redis + if not cfg or not cfg.host or cfg.mode == "storage" then + return nil + end + + if not REDIS then + REDIS = require("resty.session.redis") + end + return REDIS.new(cfg) + + else + if not CUSTOM[revocation] then + CUSTOM[revocation] = require(revocation) + end + return CUSTOM[revocation].new(configuration and configuration[revocation]) + end + end +end + + + --- -- Helper to format error messages. -- @@ -1195,6 +1267,7 @@ return { decrypt_aes_256_gcm = decrypt_aes_256_gcm, hmac_sha256 = hmac_sha256, load_storage = load_storage, + load_revocation = load_revocation, errmsg = errmsg, get_name = get_name, set_flag = set_flag, diff --git a/spec/06-revocation-1_spec.lua b/spec/06-revocation-1_spec.lua new file mode 100644 index 00000000..a6d0af05 --- /dev/null +++ b/spec/06-revocation-1_spec.lua @@ -0,0 +1,305 @@ +local session = require "resty.session" +local redis_storage = require "resty.session.redis" +local encode_base64url = require("resty.session.utils").encode_base64url + + +local before_each = before_each +local lazy_setup = lazy_setup +local describe = describe +local assert = assert +local ipairs = ipairs +local sleep = ngx.sleep +local it = it + + +local redis_config = { + host = "127.0.0.1", + password = "password", +} + + +local function extract_cookie(cookie_name, cookies) + local session_cookie + if type(cookies) == "table" then + for _, v in ipairs(cookies) do + session_cookie = ngx.re.match(v, cookie_name .. "=([\\w-]+);") + if session_cookie then + return session_cookie[1] + end + end + return "" + end + session_cookie = ngx.re.match(cookies, cookie_name .. "=([\\w-]+);") + return session_cookie and session_cookie[1] or "" +end + + +describe("Revocation tests 1", function() + local store + local long_ttl = 60 + local short_ttl = 2 + local id = "test_id_1iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii" + local id1 = "test_id_2iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii" + local id2 = "test_id_3iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii" + local cookie = "session_cookie" + + lazy_setup(function() + store = redis_storage.new(redis_config) + assert.is_not_nil(store) + end) + + describe("session: normal use", function() + local cookie_name = "session_cookie" + local test_key = "test_key" + local value = "test_data" + + local function save_session(s, cookies) + session.__set_ngx_header(cookies) + s:set(test_key, value) + local ok, err = s:save() + assert.is_true(ok) + assert.is_nil(err) + return extract_cookie(cookie_name, cookies["Set-Cookie"]) + end + + local function open_session(session_cookie) + local s = session.new() + session.__set_ngx_var({ + ["cookie_" .. cookie_name] = session_cookie, + }) + + local ok, err = s:open() + if not ok then + return nil, err + end + + return s + end + + before_each(function() + session.init({ + cookie_name = cookie_name, + storage = "cookie", + redis = { + host = redis_config.host, + password = redis_config.password, + mode = "revocation", + }, + }) + end) + + it("open succeeds for a valid session with revocation enabled", function() + local cookies = {} + local s = session.new() + local session_cookie = save_session(s, cookies) + s:close() + + local s2, err = open_session(session_cookie) + assert.is_not_nil(s2) + assert.is_nil(err) + assert.equals(value, s2:get(test_key)) + s2:close() + end) + + it("destroy: rejected cookie cannot be reopened", function() + local cookies = {} + local s = session.new() + local session_cookie = save_session(s, cookies) + assert.is_not_equal("", session_cookie) + + s:close() + + local s2, err = open_session(session_cookie) + assert.is_not_nil(s2) + assert.is_nil(err) + assert.equals(value, s2:get(test_key)) + + session.__set_ngx_header(cookies) + local ok + ok, err = s2:destroy() + assert.is_true(ok) + assert.is_nil(err) + + local s3 + s3, err = open_session(session_cookie) + assert.is_nil(s3) + assert.equals("session revoked", err) + end) + + it("save rotation does not revoke the previous cookie", function() + local cookies = {} + local s = session.new() + local session_cookie = save_session(s, cookies) + s:close() + + local s2, err = open_session(session_cookie) + assert.is_not_nil(s2) + assert.is_nil(err) + + s2:set(test_key, "rotated") + session.__set_ngx_header(cookies) + local ok + ok, err = s2:save() + assert.is_true(ok) + assert.is_nil(err) + s2:close() + + local s3 + s3, err = open_session(session_cookie) + assert.is_not_nil(s3) + assert.is_nil(err) + assert.equals(value, s3:get(test_key)) + s3:close() + end) + + it("cookie session without revocation clears cookie on destroy", function() + session.init({ + cookie_name = cookie_name, + storage = "cookie", + }) + + local cookies = {} + local s = session.new() + local session_cookie = save_session(s, cookies) + s:close() + + local s2, err = open_session(session_cookie) + assert.is_not_nil(s2) + assert.is_nil(err) + + session.__set_ngx_header(cookies) + local ok + ok, err = s2:destroy() + assert.is_true(ok) + assert.is_nil(err) + + local s3 + s3, err = open_session(session_cookie) + assert.is_nil(s3) + assert.is_not_equal("session revoked", err) + end) + end) + + describe("[#redis] revocation: SET + GET", function() + it("SET: stores revocation mark and GET observes it", function() + local ok, err = store:set(cookie, encode_base64url(id1), "1", long_ttl, ngx.time()) + assert.is_not_nil(ok) + assert.is_nil(err) + + local data + data, err = store:get(cookie, encode_base64url(id1), ngx.time()) + assert.is_nil(err) + assert.equals("1", data) + end) + + it("GET: missing revocation key returns not revoked marker", function() + local data, err = store:get(cookie, encode_base64url(id), ngx.time()) + assert.is_nil(err) + assert.is_not_equal("1", data) + end) + + it("SET: ttl expires revocation entry", function() + local ok, err = store:set(cookie, encode_base64url(id2), "1", short_ttl, ngx.time()) + assert.is_not_nil(ok) + assert.is_nil(err) + + local data + data, err = store:get(cookie, encode_base64url(id2), ngx.time()) + assert.is_nil(err) + assert.equals("1", data) + + sleep(short_ttl + 1) + + data, err = store:get(cookie, encode_base64url(id2), ngx.time()) + assert.is_nil(err) + assert.is_not_equal("1", data) + end) + end) + + describe("session: configuration", function() + local configuration = {} + local cookie_name = "session_cookie" + + before_each(function() + configuration = { + cookie_name = cookie_name, + redis = redis_config, + } + session.init(configuration) + end) + + it("loads revocation when storage is cookie and redis mode is revocation", function() + session.init({ + cookie_name = cookie_name, + storage = "cookie", + redis = { + host = redis_config.host, + password = redis_config.password, + mode = "revocation", + }, + }) + + local s = session.new() + assert.is_not_nil(s.revocation) + assert.is_function(s.revocation.set) + assert.is_function(s.revocation.get) + end) + + it("loads revocation when cookie storage has redis without storage mode", function() + local s = session.new() + assert.is_not_nil(s.revocation) + assert.is_function(s.revocation.set) + assert.is_function(s.revocation.get) + end) + + it("skips revocation when storage backend is configured", function() + session.init({ + cookie_name = cookie_name, + storage = "redis", + redis = { + prefix = "sessions", + password = "password", + }, + }) + + local s = session.new() + assert.is_nil(s.revocation) + end) + + it("skips revocation when storage is cookie and redis mode is storage", function() + session.init({ + cookie_name = cookie_name, + storage = "cookie", + redis = { + host = redis_config.host, + password = redis_config.password, + mode = "storage", + }, + }) + + local s = session.new() + assert.is_nil(s.revocation) + end) + + it("does not load revocation without a redis host", function() + session.init({ + cookie_name = cookie_name, + redis = { password = "password" }, + }) + + local s = session.new() + assert.is_nil(s.revocation) + end) + + it("skips revocation when revocation is explicitly false", function() + session.init({ + cookie_name = cookie_name, + redis = redis_config, + revocation = false, + }) + + local s = session.new() + assert.is_nil(s.revocation) + end) + end) +end) diff --git a/spec/07-revocation-2_spec.lua b/spec/07-revocation-2_spec.lua new file mode 100644 index 00000000..ae0a55f9 --- /dev/null +++ b/spec/07-revocation-2_spec.lua @@ -0,0 +1,233 @@ +local session = require "resty.session" +local redis_storage = require "resty.session.redis" +local encode_base64url = require("resty.session.utils").encode_base64url + + +local before_each = before_each +local describe = describe +local assert = assert +local pcall = pcall +local ipairs = ipairs +local it = it + + +local redis_config = { + host = "127.0.0.1", + password = "password", +} + + +local bad_redis_config = { + host = "127.0.0.1", + port = 1, + password = "password", + connect_timeout = 100, + send_timeout = 100, + read_timeout = 100, +} + + +local function extract_cookie(cookie_name, cookies) + local session_cookie + if type(cookies) == "table" then + for _, v in ipairs(cookies) do + session_cookie = ngx.re.match(v, cookie_name .. "=([\\w-]+);") + if session_cookie then + return session_cookie[1] + end + end + return "" + end + session_cookie = ngx.re.match(cookies, cookie_name .. "=([\\w-]+);") + return session_cookie and session_cookie[1] or "" +end + + +describe("Revocation tests 2", function() + local long_ttl = 60 + local id = "test_id_1iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii" + local id1 = "test_id_2iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii" + local cookie = "session_cookie" + + describe("session: revocation_fail_mode", function() + local configuration = {} + local cookie_name = "session_cookie" + local test_key = "test_key" + local value = "test_data" + local session_cookie + local cookies + + local function save_session(s, cookies) + session.__set_ngx_header(cookies) + s:set(test_key, value) + local ok, err = s:save() + assert.is_true(ok) + assert.is_nil(err) + return extract_cookie(cookie_name, cookies["Set-Cookie"]) + end + + local function open_session(session_cookie) + local s = session.new() + session.__set_ngx_var({ + ["cookie_" .. cookie_name] = session_cookie, + }) + + local ok, err = s:open() + if not ok then + return nil, err + end + + return s + end + + before_each(function() + configuration = { + cookie_name = cookie_name, + redis = redis_config, + } + session.init(configuration) + + cookies = {} + local s = session.new() + session_cookie = save_session(s, cookies) + s:close() + end) + + it("open: default fail mode allows open when redis is unreachable", function() + session.init({ + cookie_name = cookie_name, + redis = bad_redis_config, + }) + + local opened, err = open_session(session_cookie) + assert.is_true(opened) + assert.is_nil(err) + assert.equals("open", opened.revocation_fail_mode) + assert.equals(value, opened:get(test_key)) + end) + + it("destroy: open fail mode succeeds when marking revoked fails", function() + session.init({ + cookie_name = cookie_name, + redis = bad_redis_config, + revocation_fail_mode = "open", + }) + + local s = session.new() + session.__set_ngx_var({ + ["cookie_" .. cookie_name] = session_cookie, + }) + + local ok, err = s:open() + assert.is_true(ok) + assert.is_nil(err) + + session.__set_ngx_header(cookies) + ok, err = s:destroy() + assert.is_true(ok) + assert.is_nil(err) + assert.equals("closed", s.state) + end) + + it("open: closed fail mode rejects when redis is unreachable", function() + session.init({ + cookie_name = cookie_name, + redis = bad_redis_config, + revocation_fail_mode = "closed", + }) + + local opened, err = open_session(session_cookie) + assert.is_nil(opened) + assert.matches("unable to check session revocation", err) + end) + + it("destroy: closed fail mode fails when marking revoked fails", function() + local s = session.new({ + revocation = { + set = function() + return nil, "connection refused" + end, + get = function() + return nil + end, + }, + revocation_fail_mode = "closed", + }) + session.__set_ngx_var({ + ["cookie_" .. cookie_name] = session_cookie, + }) + + local ok, err = s:open() + assert.is_true(ok) + assert.is_nil(err) + + session.__set_ngx_header(cookies) + ok, err = s:destroy() + assert.is_nil(ok) + assert.matches("unable to mark session revoked", err) + assert.equals("open", s.state) + end) + end) + + describe("session: Fields validation", function() + local configuration = {} + local cookie_name = "session_cookie" + + before_each(function() + configuration = { + cookie_name = cookie_name, + redis = redis_config, + } + session.init(configuration) + end) + + it("new defaults revocation_fail_mode to open", function() + session.init({ + cookie_name = cookie_name, + redis = redis_config, + }) + + local s = session.new() + assert.equals("open", s.revocation_fail_mode) + end) + + it("new rejects redis storage with revocation mode", function() + local ok, err = pcall(session.new, { + storage = "redis", + redis = { + host = redis_config.host, + password = redis_config.password, + mode = "revocation", + }, + }) + assert.is_false(ok) + assert.matches("invalid redis mode for session storage", err) + end) + + it("new validates revocation configuration", function() + local ok, err = pcall(session.new, { + revocation = 123, + }) + assert.is_false(ok) + assert.matches("invalid session revocation", err) + end) + end) + + describe("[#redis] revocation: failures", function() + it("SET: connection failure returns error", function() + local bad_store = redis_storage.new(bad_redis_config) + + local ok, err = bad_store:set(cookie, encode_base64url(id), "1", long_ttl, ngx.time()) + assert.is_nil(ok) + assert.is_not_nil(err) + end) + + it("GET: connection failure returns error", function() + local bad_store = redis_storage.new(bad_redis_config) + + local data, err = bad_store:get(cookie, encode_base64url(id1), ngx.time()) + assert.is_nil(data) + assert.is_not_nil(err) + end) + end) +end)