diff --git a/README.md b/README.md index 8f3f2e45..a5e0e931 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` | Storage used for cookie session revocation records. Use `nil` or `false` to disable, a storage name such as `"shm"`, `"redis"`, `"mysql"`, or `"postgres"`, a custom storage module name, or a storage `table` with `set`/`get` methods. | +| `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,79 @@ 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 storage-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"`). Select the backend explicitly with +`revocation = "dshm"`, `"file"`, `"memcached"`, `"mysql"`, `"postgres"`, +`"redis"`, or `"shm"`. The backend uses its normal configuration section and +the same storage `set`/`get` contract used for session data. Custom storage +module names and pre-built storage tables are also supported. Setting +`revocation = false` or leaving it unset disables revocation. + +On every `session:open`, the library checks whether the session identifier is +revoked. On `session:destroy`, the identifier is written to the selected +storage 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. + +Use `revocation_fail_mode` to control behavior when the storage is unavailable: + +- `"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. + +Examples: + +```lua +-- Redis denylist +require("resty.session").init({ + storage = "cookie", + revocation = "redis", + redis = { + host = "127.0.0.1", + password = "secret", + prefix = "sessions", + }, +}) + +-- Shared memory denylist +require("resty.session").init({ + storage = "cookie", + revocation = "shm", + shm = { + zone = "sessions", + prefix = "revocations", + }, +}) + +-- MySQL denylist +require("resty.session").init({ + storage = "cookie", + revocation = "mysql", + mysql = { + host = "127.0.0.1", + database = "sessions", + username = "session", + password = "secret", + }, +}) +``` + +The same pattern works for `"dshm"`, `"file"`, `"memcached"`, and `"postgres"`. + + ## DSHM Storage Configuration With DHSM storage you can use the following settings (set the `storage` to `"dshm"`): diff --git a/lib/resty/session.lua b/lib/resty/session.lua index ce766f32..d187683e 100644 --- a/lib/resty/session.lua +++ b/lib/resty/session.lua @@ -148,6 +148,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 +374,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 +795,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 +1340,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 +2433,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 Storage used for cookie session revocation records, use `nil` or `false` to disable, `"dshm"`, `"file"`, `"memcached"`, `"mysql"`, `"postgres"`, `"redis"`, or `"shm"`, a custom storage module name, or a storage `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 +2495,7 @@ local function opt(configuration, name, default) end end end + end else @@ -2451,6 +2546,28 @@ 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_storage(value, configuration), "unable to load session revocation storage") + + 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 +2614,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 +2672,12 @@ 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 storage then + revocation = nil + end if cookie_prefix == "__Host-" then cookie_name = cookie_prefix .. cookie_name @@ -2625,6 +2750,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/file/thread.lua b/lib/resty/session/file/thread.lua index 11d2346f..d42c6889 100644 --- a/lib/resty/session/file/thread.lua +++ b/lib/resty/session/file/thread.lua @@ -128,8 +128,8 @@ local function get(path, prefix, suffix, name, key, current_time) -- TODO: do we want to check expiry here? -- The cookie header already has the info and has a MAC too. local exp = get_modification(file_path) - if exp and exp < current_time then - return nil, "expired" + if not exp or exp < current_time then + return nil end return file_read(file_path) diff --git a/lib/resty/session/mysql.lua b/lib/resty/session/mysql.lua index 4dfe93ef..034bc9cc 100644 --- a/lib/resty/session/mysql.lua +++ b/lib/resty/session/mysql.lua @@ -58,7 +58,7 @@ local DEFAULT_TABLE = "sessions" local DEFAULT_CHARSET = "ascii" -local SET = "INSERT INTO %s (sid, name, data, exp) VALUES ('%s', '%s', '%s', FROM_UNIXTIME(%d)) AS new ON DUPLICATE KEY UPDATE data = new.data" +local SET = "INSERT INTO %s (sid, name, data, exp) VALUES ('%s', '%s', '%s', FROM_UNIXTIME(%d)) AS new ON DUPLICATE KEY UPDATE data = new.data, exp = new.exp" local SET_META_PREFIX = "INSERT INTO %s (aud, sub, sid) VALUES " local SET_META_VALUES = "('%s', '%s', '%s')" local SET_META_SUFFIX = " ON DUPLICATE KEY UPDATE sid = sid" @@ -193,12 +193,12 @@ function metatable:get(name, key, current_time) -- luacheck: ignore local row = res[1] if not row then - return nil, "session not found" + return nil end local data = row.data if not row.data then - return nil, "session not found" + return nil end return data diff --git a/spec/06-revocation-1_spec.lua b/spec/06-revocation-1_spec.lua new file mode 100644 index 00000000..4fdcbafb --- /dev/null +++ b/spec/06-revocation-1_spec.lua @@ -0,0 +1,331 @@ +--- +-- Ensure to keep the tests consistent with those in 07-revocation-2_spec.lua + + +local session = require "resty.session" +local utils = require "resty.session.utils" + + +local before_each = before_each +local after_each = after_each +local lazy_setup = lazy_setup +local describe = describe +local ipairs = ipairs +local assert = assert +local sleep = ngx.sleep +local time = ngx.time +local it = it + + +local storage_configs = { + file = { + suffix = "revocation", + }, + shm = { + prefix = "revocations", + }, + redis = { + prefix = "revocations", + password = "password", + }, + memcached = { + prefix = "revocations", + }, +} + + +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 + + +for _, st in ipairs({ + "file", + "shm", + "redis", + "memcached", +}) do + describe("Revocation tests 1", function() + local current_time + local store + local long_ttl = 60 + local short_ttl = 2 + local key = "test_key_1iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii" + local key1 = "test_key_2iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii" + local key2 = "test_key_3iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii" + local name = "session_cookie" + local mark = "1" + + lazy_setup(function() + local conf = { + storage = "cookie", + revocation = st, + } + conf[st] = storage_configs[st] + store = utils.load_storage(st, conf) + assert.is_not_nil(store) + end) + + before_each(function() + current_time = time() + end) + + describe("[#" .. st .. "] revocation storage: SET + GET", function() + after_each(function() + store:delete(name, key, current_time) + store:delete(name, key1, current_time) + store:delete(name, key2, current_time) + end) + + it("SET: stores revocation mark and GET observes it", function() + local ok = store:set(name, key, mark, long_ttl, current_time) + assert.is_not_nil(ok) + + local data, err = store:get(name, key, current_time) + assert.is_nil(err) + assert.equals(mark, data) + end) + + it("GET: missing revocation key returns not revoked", function() + local data, err = store:get(name, key1, current_time) + assert.is_nil(data) + assert.is_nil(err) + end) + + it("SET: ttl expires revocation entry", function() + local ok = store:set(name, key2, mark, short_ttl, current_time) + assert.is_not_nil(ok) + + local data, err = store:get(name, key2, current_time) + assert.is_nil(err) + assert.equals(mark, data) + + sleep(short_ttl + 1) + + data, err = store:get(name, key2, time()) + assert.is_nil(data) + assert.is_nil(err) + end) + + it("SET: re-mark refreshes ttl", function() + local ok = store:set(name, key, mark, short_ttl, current_time) + assert.is_not_nil(ok) + + sleep(1) + + ok = store:set(name, key, mark, long_ttl, time()) + assert.is_not_nil(ok) + + sleep(short_ttl + 1) + + local data, err = store:get(name, key, time()) + assert.is_nil(err) + assert.equals(mark, data) + end) + end) + + describe("[#" .. st .. "] session: revocation lifecycle", 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() + local conf = { + cookie_name = cookie_name, + storage = "cookie", + revocation = st, + } + conf[st] = storage_configs[st] + session.init(conf) + 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) + end) + end) +end + + +describe("Revocation tests 1 session: configuration", function() + local cookie_name = "session_cookie" + + it("cookie session without revocation remains usable after destroy", function() + session.init({ + cookie_name = cookie_name, + storage = "cookie", + }) + + local cookies = {} + local s = session.new() + session.__set_ngx_header(cookies) + s:set("test_key", "test_data") + local ok, err = s:save() + assert.is_true(ok) + assert.is_nil(err) + local session_cookie = extract_cookie(cookie_name, cookies["Set-Cookie"]) + s:close() + + session.__set_ngx_var({ + ["cookie_" .. cookie_name] = session_cookie, + }) + local s2 = session.new() + ok, err = s2:open() + assert.is_true(ok) + assert.is_nil(err) + + session.__set_ngx_header(cookies) + ok, err = s2:destroy() + assert.is_true(ok) + assert.is_nil(err) + + session.__set_ngx_var({ + ["cookie_" .. cookie_name] = session_cookie, + }) + local s3 = session.new() + ok, err = s3:open() + assert.is_true(ok) + assert.is_nil(err) + assert.is_not_equal("session revoked", err) + end) + + it("skips revocation when storage backend is configured", function() + session.init({ + cookie_name = cookie_name, + storage = "redis", + revocation = "shm", + redis = { + prefix = "sessions", + password = "password", + }, + shm = { + prefix = "revocations", + }, + }) + + local s = session.new() + assert.is_nil(s.revocation) + end) + + it("does not infer revocation storage from backend configuration", function() + session.init({ + cookie_name = cookie_name, + storage = "cookie", + redis = { + host = "127.0.0.1", + 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, + storage = "cookie", + redis = { + host = "127.0.0.1", + password = "password", + }, + revocation = false, + }) + + local s = session.new() + assert.is_nil(s.revocation) + end) +end) diff --git a/spec/07-revocation-2_spec.lua b/spec/07-revocation-2_spec.lua new file mode 100644 index 00000000..87e5bd8d --- /dev/null +++ b/spec/07-revocation-2_spec.lua @@ -0,0 +1,422 @@ +--- +-- For now these tests don't run on CI. +-- Ensure to keep the tests consistent with those in 06-revocation-1_spec.lua + + +local session = require "resty.session" +local utils = require "resty.session.utils" + + +local before_each = before_each +local after_each = after_each +local lazy_setup = lazy_setup +local describe = describe +local ipairs = ipairs +local assert = assert +local pcall = pcall +local sleep = ngx.sleep +local time = ngx.time +local it = it + + +local storage_configs = { + mysql = { + username = "root", + password = "password", + database = "test", + }, + postgres = { + username = "postgres", + password = "password", + database = "test", + }, + redis_sentinel = { + prefix = "revocations", + password = "password", + sentinels = { + { host = "127.0.0.1", port = "26379" } + }, + }, + redis_cluster = { + prefix = "revocations", + password = "password", + nodes = { + { ip = "127.0.0.1", port = "6380" } + }, + name = "somecluster", + lock_zone = "sessions", + }, + dshm = { + prefix = "revocations", + }, +} + + +local function storage_type(ty) + if ty == "redis_cluster" or ty == "redis_sentinel" then + return "redis" + end + return ty +end + + +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 + + +for _, st in ipairs({ + "mysql", + "postgres", + "redis_cluster", + "redis_sentinel", + "dshm", +}) do + describe("Revocation tests 2 #noci", function() + local current_time + local store + local long_ttl = 60 + local short_ttl = 2 + local key = "test_key_1iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii" + local key1 = "test_key_2iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii" + local key2 = "test_key_3iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii" + local name = "session_cookie" + local mark = "1" + local ty = storage_type(st) + + lazy_setup(function() + local conf = { + storage = "cookie", + revocation = ty, + } + conf[ty] = storage_configs[st] + store = utils.load_storage(ty, conf) + assert.is_not_nil(store) + end) + + before_each(function() + current_time = time() + end) + + describe("[#" .. st .. "] revocation storage: SET + GET", function() + after_each(function() + current_time = time() + store:delete(name, key, current_time) + store:delete(name, key1, current_time) + store:delete(name, key2, current_time) + end) + + it("SET: stores revocation mark and GET observes it", function() + local ok = store:set(name, key, mark, long_ttl, current_time) + assert.is_not_nil(ok) + + local data, err = store:get(name, key, current_time) + assert.is_nil(err) + assert.equals(mark, data) + end) + + it("GET: missing revocation key returns not revoked", function() + local data, err = store:get(name, key1, current_time) + assert.is_nil(data) + assert.is_nil(err) + end) + + it("SET: ttl expires revocation entry", function() + local ok = store:set(name, key2, mark, short_ttl, current_time) + assert.is_not_nil(ok) + + local data, err = store:get(name, key2, current_time) + assert.is_nil(err) + assert.equals(mark, data) + + sleep(short_ttl + 1) + + data, err = store:get(name, key2, time()) + assert.is_nil(data) + assert.is_nil(err) + end) + + it("SET: re-mark refreshes ttl", function() + local ok = store:set(name, key, mark, short_ttl, current_time) + assert.is_not_nil(ok) + + sleep(1) + + ok = store:set(name, key, mark, long_ttl, time()) + assert.is_not_nil(ok) + + sleep(short_ttl + 1) + + local data, err = store:get(name, key, time()) + assert.is_nil(err) + assert.equals(mark, data) + end) + end) + + describe("[#" .. st .. "] session: revocation lifecycle", 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() + local conf = { + cookie_name = cookie_name, + storage = "cookie", + revocation = ty, + } + conf[ty] = storage_configs[st] + session.init(conf) + 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) + end) + end) +end + + +describe("Revocation tests 2 session: revocation_fail_mode", function() + local cookie_name = "session_cookie" + local test_key = "test_key" + local value = "test_data" + local session_cookie + local cookies + + local function save_session(s, header_cookies) + session.__set_ngx_header(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, header_cookies["Set-Cookie"]) + end + + local function open_session(cookie_value) + local s = session.new() + session.__set_ngx_var({ + ["cookie_" .. cookie_name] = cookie_value, + }) + + 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", + }) + + cookies = {} + local s = session.new() + session_cookie = save_session(s, cookies) + s:close() + end) + + it("open: default fail mode allows open when store is unreachable", function() + session.init({ + cookie_name = cookie_name, + storage = "cookie", + revocation = { + set = function() + return nil, "connection refused" + end, + get = function() + return nil, "connection refused" + end, + }, + }) + + local opened, err = open_session(session_cookie) + assert.is_not_nil(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, + storage = "cookie", + revocation = { + set = function() + return nil, "connection refused" + end, + get = function() + return nil + end, + }, + 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 store is unreachable", function() + session.init({ + cookie_name = cookie_name, + storage = "cookie", + revocation = { + set = function() + return nil, "connection refused" + end, + get = function() + return nil, "connection refused" + end, + }, + 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("Revocation tests 2 session: Fields validation", function() + local cookie_name = "session_cookie" + + it("new defaults revocation_fail_mode to open", function() + session.init({ + cookie_name = cookie_name, + storage = "cookie", + }) + + local s = session.new() + assert.equals("open", s.revocation_fail_mode) + end) + + it("new requires an explicit revocation storage name", function() + local ok, err = pcall(session.new, { + revocation = true, + }) + assert.is_false(ok) + assert.matches("invalid session revocation", 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) + + it("new rejects a store table without set and get", function() + local ok, err = pcall(session.new, { + revocation = { + set = function() end, + }, + }) + assert.is_false(ok) + assert.matches("invalid session revocation", err) + end) + + it("new rejects an invalid fail mode", function() + local ok, err = pcall(session.new, { + revocation_fail_mode = "deny", + }) + assert.is_false(ok) + assert.matches("invalid revocation fail mode", err) + end) +end)