---
-- Session library.
--
-- Session library provides HTTP session management capabilities for OpenResty based
-- applications, libraries and proxies.
--
-- @module resty.session


local require = require


local buffer = require "string.buffer"
local utils = require "resty.session.utils"


local clear_request_header = ngx.req.clear_header
local set_request_header = ngx.req.set_header
local setmetatable = setmetatable
local http_time = ngx.http_time
local tonumber = tonumber
local select = select
local assert = assert
local remove = table.remove
local header = ngx.header
local error = error
local floor = math.ceil
local time = ngx.time
local byte = string.byte
local type = type
local sub = string.sub
local fmt = string.format
local var = ngx.var
local log = ngx.log
local max = math.max
local min = math.min


local derive_aes_gcm_256_key_and_iv = utils.derive_aes_gcm_256_key_and_iv
local derive_hmac_sha256_key = utils.derive_hmac_sha256_key
local encrypt_aes_256_gcm = utils.encrypt_aes_256_gcm
local decrypt_aes_256_gcm = utils.decrypt_aes_256_gcm
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 encode_json = utils.encode_json
local decode_json = utils.decode_json
local base64_size = utils.base64_size
local hmac_sha256 = utils.hmac_sha256
local rand_bytes = utils.rand_bytes
local unset_flag = utils.unset_flag
local table_new = utils.table_new
local set_flag = utils.set_flag
local has_flag = utils.has_flag
local inflate = utils.inflate
local deflate = utils.deflate
local bunpack = utils.bunpack
local errmsg = utils.errmsg
local sha256 = utils.sha256
local bpack = utils.bpack
local trim = utils.trim


local NOTICE = ngx.NOTICE
local WARN   = ngx.WARN


local KEY_SIZE = 32


--[ HEADER ----------------------------------------------------------------------------------------------------] || [ PAYLOAD --]
--[ Type || Flags || Session ID || Creation Time || Rolling Offset || Data Size || Tag || Idling Offset || Mac ] || [ Data      ]
--[ 1B   || 2B    || 32B        || 5B            || 4B             || 3B        || 16B || 3B            || 16B ] || [ *B        ]


local COOKIE_TYPE_SIZE    = 1  --  1
local FLAGS_SIZE          = 2  --  3
local SID_SIZE            = 32 -- 35
local CREATION_TIME_SIZE  = 5  -- 40
local ROLLING_OFFSET_SIZE = 4  -- 44
local DATA_SIZE           = 3  -- 47
local TAG_SIZE            = 16 -- 63
local IDLING_OFFSET_SIZE  = 3  -- 66
local MAC_SIZE            = 16 -- 82


local HEADER_TAG_SIZE = COOKIE_TYPE_SIZE + FLAGS_SIZE + SID_SIZE + CREATION_TIME_SIZE + ROLLING_OFFSET_SIZE + DATA_SIZE
local HEADER_TOUCH_SIZE = HEADER_TAG_SIZE + TAG_SIZE
local HEADER_MAC_SIZE = HEADER_TOUCH_SIZE + IDLING_OFFSET_SIZE
local HEADER_SIZE = HEADER_MAC_SIZE + MAC_SIZE
local HEADER_ENCODED_SIZE = base64_size(HEADER_SIZE)


local COOKIE_TYPE = bpack(COOKIE_TYPE_SIZE, 1)


local MAX_COOKIE_SIZE    = 4096
local MAX_COOKIES        = 9
local MAX_COOKIES_SIZE   = MAX_COOKIES * MAX_COOKIE_SIZE     --    36864 bytes
local MAX_CREATION_TIME  = 2 ^ (CREATION_TIME_SIZE  * 8) - 1 --   ~34789 years
local MAX_ROLLING_OFFSET = 2 ^ (ROLLING_OFFSET_SIZE * 8) - 1 --     ~136 years
local MAX_IDLING_OFFSET  = 2 ^ (IDLING_OFFSET_SIZE  * 8) - 1 --     ~194 days
local MAX_DATA_SIZE      = 2 ^ (DATA_SIZE           * 8) - 1 -- 16777215 bytes
local MAX_TTL            = 34560000                          --      400 days
-- see: https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-20#section-5.5


local FLAGS_NONE           = 0x0000 -- 0000 0000 0000 0000
local FLAG_STORAGE         = 0x0001 -- 0000 0000 0000 0001
local FLAG_FORGET          = 0x0002 -- 0000 0000 0000 0010
local FLAG_DEFLATE         = 0x0010 -- 0000 0000 0001 0000
local FLAG_BIND_IP         = 0x0100 -- 0000 0001 0000 0000
local FLAG_BIND_SCHEME     = 0x0200 -- 0000 0010 0000 0000
local FLAG_BIND_USER_AGENT = 0x0400 -- 0000 0100 0000 0000


local DEFAULT_IKM
local DEFAULT_IKM_FALLBACKS
local DEFAULT_COOKIE_PREFIX
local DEFAULT_COOKIE_NAME
local DEFAULT_COOKIE_PATH
local DEFAULT_COOKIE_DOMAIN
local DEFAULT_COOKIE_HTTP_ONLY
local DEFAULT_COOKIE_SECURE
local DEFAULT_COOKIE_PRIORITY
local DEFAULT_COOKIE_SAME_SITE
local DEFAULT_COOKIE_SAME_PARTY
local DEFAULT_COOKIE_PARTITIONED
local DEFAULT_REMEMBER
local DEFAULT_REMEMBER_SAFETY
local DEFAULT_REMEMBER_COOKIE_NAME
local DEFAULT_AUDIENCE
local DEFAULT_SUBJECT
local DEFAULT_ENFORCE_SAME_SUBJECT
local DEFAULT_STALE_TTL
local DEFAULT_IDLING_TIMEOUT
local DEFAULT_ROLLING_TIMEOUT
local DEFAULT_ABSOLUTE_TIMEOUT
local DEFAULT_REMEMBER_ROLLING_TIMEOUT
local DEFAULT_REMEMBER_ABSOLUTE_TIMEOUT
local DEFAULT_HASH_STORAGE_KEY
local DEFAULT_HASH_SUBJECT
local DEFAULT_STORE_METADATA
local DEFAULT_TOUCH_THRESHOLD
local DEFAULT_COMPRESSION_THRESHOLD
local DEFAULT_FLAGS
local DEFAULT_REQUEST_HEADERS
local DEFAULT_RESPONSE_HEADERS
local DEFAULT_STORAGE


local DUMMY_META = {}
local DUMMY_REMEMBER_META = false


local STATE_NEW    = "new"
local STATE_OPEN   = "open"
local STATE_CLOSED = "closed"


local AT_BYTE        = byte("@")
local EQUALS_BYTE    = byte("=")
local SEMICOLON_BYTE = byte(";")


local COOKIE_EXPIRE_FLAGS = "; Expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0"


local HEADER_BUFFER = buffer.new(HEADER_SIZE)
local FLAGS_BUFFER  = buffer.new(128)
local DATA_BUFFER   = buffer.new(MAX_COOKIES_SIZE)
local HIDE_BUFFER   = buffer.new(256)
local BIND_BUFFER   = buffer.new(HEADER_SIZE + 256)


local DATA = table_new(2, 0)


local BINDS = {
  ip                 = FLAG_BIND_IP,
  scheme             = FLAG_BIND_SCHEME,
  user_agent         = FLAG_BIND_USER_AGENT,
  ["user-agent"]     = FLAG_BIND_USER_AGENT,
}


local HEADERS = {
  id                   = "Session-Id",
  audience             = "Session-Audience",
  subject              = "Session-Subject",
  timeout              = "Session-Timeout",
  idling_timeout       = "Session-Idling-Timeout",
  ["idling-timeout"]   = "Session-Idling-Timeout",
  rolling_timeout      = "Session-Rolling-Timeout",
  ["rolling-timeout"]  = "Session-Rolling-Timeout",
  absolute_timeout     = "Session-Absolute-Timeout",
  ["absolute-timeout"] = "Session-Absolute-Timeout",
}


local function set_response_header(name, value)
  header[name] = value
end


local function sha256_storage_key(sid)
  local key, err = sha256(sid)
  if not key then
    return nil, errmsg(err, "unable to sha256 hash session id")
  end

  if SID_SIZE ~= 32 then
    key = sub(key, 1, SID_SIZE)
  end

  key, err = encode_base64url(key)
  if not key then
    return nil, errmsg(err, "unable to base64url encode session id")
  end

  return key
end


local function sha256_subject(subject)
  local hashed_subject, err = sha256(subject)
  if not hashed_subject then
    return nil, errmsg(err, "unable to sha256 hash subject")
  end

  hashed_subject, err = encode_base64url(sub(hashed_subject, 1, 16))
  if not hashed_subject then
    return nil, errmsg(err, "unable to base64url encode subject")
  end

  return hashed_subject
end


local function bind_append(value, bound)
  if value and value ~= "" then
    if not bound then
      BIND_BUFFER:set(value)
    else
      BIND_BUFFER:put("|", value)
    end
    return true
  end
  return bound
end


local function bind(msg, flags)
  if not flags then
    return msg
  end

  local bind_ip = has_flag(flags, FLAG_BIND_IP)
  local bind_scheme = has_flag(flags, FLAG_BIND_SCHEME)
  local bind_user_agent = has_flag(flags, FLAG_BIND_USER_AGENT)
  if bind_ip or bind_scheme or bind_user_agent then
    local bound = false
    if bind_ip         then bound = bind_append(var.remote_addr,     bound) end
    if bind_scheme     then bound = bind_append(var.scheme,          bound) end
    if bind_user_agent then bound = bind_append(var.http_user_agent, bound) end
    if bound then
      return msg .. "#" .. sha256(BIND_BUFFER:get())
    end
  end

  return msg
end


local function calculate_mac(ikm, nonce, msg)
  local mac_key, err = derive_hmac_sha256_key(ikm, nonce)
  if not mac_key then
    return nil, errmsg(err, "unable to derive session message authentication key")
  end

  local mac, err = hmac_sha256(mac_key, msg)
  if not mac then
    return nil, errmsg(err, "unable to calculate session message authentication code")
  end

  if MAC_SIZE ~= 32 then
    return sub(mac, 1, MAC_SIZE)
  end

  return mac
end


local function calculate_cookie_chunks(cookie_name_size, data_size)
  local space_needed = cookie_name_size + 1 + HEADER_ENCODED_SIZE + data_size
  if space_needed > MAX_COOKIES_SIZE then
    return nil, "cookie size limit exceeded"
  end

  if space_needed <= MAX_COOKIE_SIZE then
    return 1
  end

  for i = 2, MAX_COOKIES do
    space_needed = space_needed + cookie_name_size + 2
    if space_needed > MAX_COOKIES_SIZE then
      return nil, "cookie size limit exceeded"
    elseif space_needed <= (MAX_COOKIE_SIZE * i) then
      return i
    end
  end

  return nil, "cookie size limit exceeded"
end


local function merge_cookies(cookies, cookie_name_size, cookie_name, cookie_data)
  if not cookies then
    return cookie_data
  end

  if type(cookies) == "string" then
    if byte(cookies, cookie_name_size + 1) == EQUALS_BYTE and
       sub(cookies, 1, cookie_name_size) == cookie_name
    then
      return cookie_data
    end

    return { cookies, cookie_data }
  end

  if type(cookies) ~= "table" then
    return nil, "unable to merge session cookies with response cookies"
  end

  local count = #cookies
  for i = 1, count do
    if byte(cookies[i], cookie_name_size + 1) == EQUALS_BYTE and
       sub(cookies[i], 1, cookie_name_size) == cookie_name
    then
      cookies[i] = cookie_data
      return cookies
    end

    if i == count then
      cookies[i+1] = cookie_data
      return cookies
    end
  end
end


local function get_store_ttl(self, remember, current_time, creation_time, rolling_offset)
  local ttl = MAX_TTL

  local rolling_timeout = remember and self.remember_rolling_timeout or self.rolling_timeout
  if rolling_timeout > 0 and rolling_timeout < MAX_TTL then
    ttl = rolling_timeout
  end

  if rolling_offset then
    ttl = ttl - (current_time - creation_time - rolling_offset)
  end

  local absolute_timeout = remember and self.remember_absolute_timeout or self.absolute_timeout
  if absolute_timeout > 0 and absolute_timeout < MAX_TTL then
    ttl = min(absolute_timeout - (current_time - creation_time), ttl)
  end

  return max(ttl, 1)
end



local function get_store_metadata(self)
  if not self.store_metadata then
    return
  end

  local data = self.data
  local count = #data
  if count == 1 then
    local audience = data[1][2]
    local subject = data[1][3]
    if audience and subject then
      audience = encode_base64url(audience)
      subject = self.hash_subject(subject)
      return {
        audiences = { audience },
        subjects = { subject },
      }
    end

    return
  end

  local audiences
  local subjects
  local index = 0
  for i = 1, count do
    local audience = data[i][2]
    local subject = data[i][3]
    if audience and subject then
      audience = encode_base64url(audience)
      self.hash_subject(subject)
      if not audiences then
        audiences = table_new(count, 0)
        subjects = table_new(count, 0)
      end

      index = index + 1
      audiences[index] = audience
      subjects[index] = subject
    end
  end

  if not audiences then
    return
  end

  return {
    audiences = audiences,
    subjects = subjects,
  }
end


local function get_property(self, name)
  if name == "id" then
    local sid = self.meta.sid
    if not sid then
      return
    end

    return encode_base64url(sid)

  elseif name == "nonce" then
    return self.meta.sid

  elseif name == "audience" then
    return self.data[self.data_index][2]

  elseif name == "subject" then
    return self.data[self.data_index][3]

  elseif name == "timeout" then
    local timeout
    local meta = self.meta

    if self.idling_timeout > 0 then
      timeout = self.idling_timeout - (meta.timestamp - meta.creation_time - meta.rolling_offset - meta.idling_offset)
    end

    if self.rolling_timeout > 0 then
      local t = self.rolling_timeout - (meta.timestamp - meta.creation_time - meta.rolling_offset)
      timeout = timeout and min(t, timeout) or t
    end

    if self.absolute_timeout > 0 then
      local t = self.absolute_timeout - (meta.timestamp - meta.creation_time)
      timeout = timeout and min(t, timeout) or t
    end

    return timeout

  elseif name == "idling-timeout" or name == "idling_timeout" then
    local idling_timeout = self.idling_timeout
    if idling_timeout == 0 then
      return
    end

    local meta = self.meta
    return idling_timeout - (meta.timestamp - meta.creation_time - meta.rolling_offset - meta.idling_offset)

  elseif name == "rolling-timeout" or name == "rolling_timeout" then
    local rolling_timeout = self.rolling_timeout
    if rolling_timeout == 0 then
      return
    end

    local meta = self.meta
    return rolling_timeout - (meta.timestamp - meta.creation_time - meta.rolling_offset)

  elseif name == "absolute-timeout" or name == "absolute_timeout" then
    local absolute_timeout = self.absolute_timeout
    if absolute_timeout == 0 then
      return
    end

    local meta = self.meta
    return absolute_timeout - (meta.timestamp - meta.creation_time)

  else
    return self.meta[name]
  end
end


local function derive_aes_key(ikm, sid, remember_safety)
  if remember_safety then
    return derive_aes_gcm_256_key_and_iv(ikm, sid, remember_safety)
  else
    return derive_aes_gcm_256_key_and_iv(ikm, sid)
  end
end


local function open(self, remember, meta_only)
  local storage = self.storage
  local current_time = time()
  local cookie_name
  if remember then
    cookie_name = self.remember_cookie_name
  else
    cookie_name = self.cookie_name
  end

  local cookie = var["cookie_" .. cookie_name]
  if not cookie then
    return nil, "missing session cookie"
  end

  local header_decoded do
    header_decoded = sub(cookie, 1, HEADER_ENCODED_SIZE)
    if #header_decoded ~= HEADER_ENCODED_SIZE then
      return nil, "invalid session header"
    end
    local err
    header_decoded, err = decode_base64url(header_decoded)
    if not header_decoded then
      return nil, errmsg(err, "unable to base64url decode session header")
    end
  end

  HEADER_BUFFER:set(header_decoded)

  local cookie_type do
    cookie_type = HEADER_BUFFER:get(COOKIE_TYPE_SIZE)
    if #cookie_type ~= COOKIE_TYPE_SIZE then
      return nil, "invalid session cookie type"
    end
    if cookie_type ~= COOKIE_TYPE then
      return nil, "invalid session cookie type"
    end
  end

  local flags do
    flags = HEADER_BUFFER:get(FLAGS_SIZE)
    if #flags ~= FLAGS_SIZE then
      return nil, "invalid session flags"
    end

    flags = bunpack(FLAGS_SIZE, flags)

    if storage then
      if not has_flag(flags, FLAG_STORAGE) then
        return nil, "invalid session flags"
      end
    elseif has_flag(flags, FLAG_STORAGE) then
      return nil, "invalid session flags"
    end
  end

  local sid do
    sid = HEADER_BUFFER:get(SID_SIZE)
    if #sid ~= SID_SIZE then
      return nil, "invalid session id"
    end
  end

  local creation_time do
    creation_time = HEADER_BUFFER:get(CREATION_TIME_SIZE)
    if #creation_time ~= CREATION_TIME_SIZE then
      return nil, "invalid session creation time"
    end

    creation_time = bunpack(CREATION_TIME_SIZE, creation_time)
    if not creation_time or creation_time < 0 or creation_time > MAX_CREATION_TIME then
      return nil, "invalid session creation time"
    end

    local absolute_elapsed = current_time - creation_time
    if absolute_elapsed > MAX_ROLLING_OFFSET then
      return nil, "session lifetime exceeded"
    end

    if remember then
      local remember_absolute_timeout = self.remember_absolute_timeout
      if remember_absolute_timeout ~= 0 then
        if absolute_elapsed > remember_absolute_timeout then
          return nil, "session remember absolute timeout exceeded"
        end
      end

    else
      local absolute_timeout = self.absolute_timeout
      if absolute_timeout ~= 0 then
        if absolute_elapsed > absolute_timeout then
          return nil, "session absolute timeout exceeded"
        end
      end
    end
  end

  local rolling_offset do
    rolling_offset = HEADER_BUFFER:get(ROLLING_OFFSET_SIZE)
    if #rolling_offset ~= ROLLING_OFFSET_SIZE then
      return nil, "invalid session rolling offset"
    end

    rolling_offset = bunpack(ROLLING_OFFSET_SIZE, rolling_offset)
    if not rolling_offset or rolling_offset < 0 or rolling_offset > MAX_ROLLING_OFFSET then
      return nil, "invalid session rolling offset"
    end

    local rolling_elapsed = current_time - creation_time - rolling_offset

    if remember then
      local remember_rolling_timeout = self.remember_rolling_timeout
      if remember_rolling_timeout ~= 0 then
        if rolling_elapsed > remember_rolling_timeout then
          return nil, "session remember rolling timeout exceeded"
        end
      end

    else
      local rolling_timeout = self.rolling_timeout
      if rolling_timeout ~= 0 then
        if rolling_elapsed > rolling_timeout then
          return nil, "session rolling timeout exceeded"
        end
      end
    end
  end

  local data_size do
    data_size = HEADER_BUFFER:get(DATA_SIZE)
    if #data_size ~= DATA_SIZE then
      return nil, "invalid session data size"
    end

    data_size = bunpack(DATA_SIZE, data_size)
    if not data_size or data_size < 0 or data_size > MAX_DATA_SIZE then
      return nil, "invalid session data size"
    end
  end

  local tag do
    tag = HEADER_BUFFER:get(TAG_SIZE)
    if #tag ~= TAG_SIZE then
      return nil, "invalid session tag"
    end
  end

  local idling_offset do
    idling_offset = HEADER_BUFFER:get(IDLING_OFFSET_SIZE)
    if #idling_offset ~= IDLING_OFFSET_SIZE then
      return nil, "invalid session idling offset"
    end

    idling_offset = bunpack(IDLING_OFFSET_SIZE, idling_offset)
    if not idling_offset or idling_offset < 0 or idling_offset > MAX_IDLING_OFFSET then
      return nil, "invalid session idling offset"
    end

    if remember then
      if idling_offset ~= 0 then
        return nil, "invalid session idling offset"
      end

    else
      local idling_timeout = self.idling_timeout
      if idling_timeout ~= 0 then
        local idling_elapsed = current_time - creation_time - rolling_offset - idling_offset
        if idling_elapsed > idling_timeout then
          return nil, "session idling timeout exceeded"
        end
      end
    end
  end

  local ikm do
    ikm = self.ikm
    local mac = HEADER_BUFFER:get(MAC_SIZE)
    if #mac ~= MAC_SIZE then
      return nil, "invalid session message authentication code"
    end

    local msg = bind(sub(header_decoded, 1, HEADER_MAC_SIZE), flags)
    local expected_mac, err = calculate_mac(ikm, sid, msg)
    if mac ~= expected_mac then
      local fallback_keys = self.ikm_fallbacks
      if fallback_keys then
        local count = #fallback_keys
        if count > 0 then
          for i = 1, count do
            ikm = fallback_keys[i]
            expected_mac, err = calculate_mac(ikm, sid, msg)
            if mac == expected_mac then
              break
            end

            if i == count then
              return nil, errmsg(err, "invalid session message authentication code")
            end
          end

        else
          return nil, errmsg(err, "invalid session message authentication code")
        end

      else
        return nil, errmsg(err, "invalid session message authentication code")
      end
    end
  end

  local data_index = self.data_index
  local audience = self.data[data_index][2]
  local initial_chunk, ciphertext, ciphertext_encoded, info_data do
    if storage then
      local key, err = self.hash_storage_key(sid)
      if not key then
        return nil, err
      end

      local data, err = storage:get(cookie_name, key, current_time)
      if not data then
        return nil, errmsg(err, "unable to load session")
      end

      data, err = decode_json(data)
      if not data then
        return nil, errmsg(err, "unable to json decode session")
      end

      ciphertext = data[1]
      ciphertext_encoded = ciphertext
      info_data = data[2]
      if info_data then
        info_data, err = decode_base64url(info_data)
        if not info_data then
          return nil, errmsg(err, "unable to base64url decode session info")
        end

        info_data, err = decode_json(info_data)
        if not info_data then
          return nil, errmsg(err, "unable to json decode session info")
        end

        if not info_data[audience] then
          info_data[audience] = self.info.data and self.info.data[audience] or nil
        end
      end

    else
      local cookie_chunks, err = calculate_cookie_chunks(#cookie_name, data_size)
      if not cookie_chunks then
        return nil, err
      end

      if cookie_chunks == 1 then
        initial_chunk = sub(cookie, -data_size)
        ciphertext = initial_chunk

      else
        initial_chunk = sub(cookie, HEADER_ENCODED_SIZE + 1)
        DATA_BUFFER:reset():put(initial_chunk)
        for i = 2, cookie_chunks do
          local chunk = var["cookie_" .. cookie_name .. i]
          if not chunk then
            return nil, errmsg(err, "missing session cookie chunk")
          end

          DATA_BUFFER:put(chunk)
        end

        ciphertext = DATA_BUFFER:get()
      end
    end

    if #ciphertext ~= data_size then
      return nil, "invalid session payload"
    end

    local err
    ciphertext, err = decode_base64url(ciphertext)
    if not ciphertext then
      return nil, errmsg(err, "unable to base64url decode session data")
    end
  end

  if remember then
    self.remember_meta = {
      timestamp      = current_time,
      flags          = flags,
      sid            = sid,
      creation_time  = creation_time,
      rolling_offset = rolling_offset,
      data_size      = data_size,
      idling_offset  = idling_offset,
      ikm            = ikm,
      header         = header_decoded,
      initial_chunk  = initial_chunk,
      ciphertext     = ciphertext_encoded,
    }

  else
    self.meta = {
      timestamp      = current_time,
      flags          = flags,
      sid            = sid,
      creation_time  = creation_time,
      rolling_offset = rolling_offset,
      data_size      = data_size,
      idling_offset  = idling_offset,
      ikm            = ikm,
      header         = header_decoded,
      initial_chunk  = initial_chunk,
      ciphertext     = ciphertext_encoded,
    }
  end

  if meta_only then
    return true
  end

  local aes_key, err, iv = derive_aes_key(ikm, sid, remember and self.remember_safety)
  if not aes_key then
    return nil, errmsg(err, "unable to derive session decryption key")
  end

  local aad = sub(header_decoded, 1, HEADER_TAG_SIZE)
  local plaintext, err = decrypt_aes_256_gcm(aes_key, iv, ciphertext, aad, tag)
  if not plaintext then
    return nil, errmsg(err, "unable to decrypt session data")
  end

  local data do
    if has_flag(flags, FLAG_DEFLATE) then
      plaintext, err = inflate(plaintext)
      if not plaintext then
        return nil, errmsg(err, "unable to inflate session data")
      end
    end

    data, err = decode_json(plaintext)
    if not data then
      return nil, errmsg(err, "unable to json decode session data")
    end
  end

  if storage then
    self.info.data = info_data
  end

  local audience_index
  local count = #data
  for i = 1, count do
    if data[i][2] == audience then
      audience_index = i
      break
    end
  end

  if not audience_index then
    data[count + 1] = self.data[data_index]
    self.state = STATE_NEW
    self.data = data
    self.data_index = count + 1
    return nil, "missing session audience", true
  end

  self.state = STATE_OPEN
  self.data = data
  self.data_index = audience_index

  return true
end


local function save(self, state, remember)
  local cookie_name
  local meta
  if remember then
    cookie_name = self.remember_cookie_name
    meta = self.remember_meta or {}
  else
    cookie_name = self.cookie_name
    meta = self.meta
  end

  local cookie_name_size = #cookie_name
  local storage = self.storage
  local flags = self.flags

  if storage then
    flags = set_flag(flags, FLAG_STORAGE)
  else
    flags = unset_flag(flags, FLAG_STORAGE)
  end

  local sid, err = rand_bytes(SID_SIZE)
  if not sid then
    return nil, errmsg(err, "unable to generate session id")
  end

  local current_time = time()
  local rolling_offset

  local creation_time = meta.creation_time
  if creation_time then
    rolling_offset = current_time - creation_time
    if rolling_offset > MAX_ROLLING_OFFSET then
      return nil, "session maximum rolling offset exceeded"
    end

  else
    creation_time = current_time
    rolling_offset = 0
  end

  if creation_time > MAX_CREATION_TIME then
    -- this should only happen at around year 36759 (most likely a clock problem)
    return nil, "session maximum creation time exceeded"
  end

  do
    local meta_flags = meta.flags
    if meta_flags and has_flag(meta_flags, FLAG_FORGET) then
      flags = set_flag(flags, FLAG_FORGET)
    end
  end

  local data, data_size, cookie_chunks do
    data = self.data
    if self.enforce_same_subject then
      local count = #data
      if count > 1 then
        local subject = data[self.data_index][3]
        for i = count, 1, -1 do
          if data[i][3] ~= subject then
            remove(data, i)
          end
        end
      end
    end

    data, err = encode_json(data)
    if not data then
      return nil, errmsg(err, "unable to json encode session data")
    end

    data_size = #data

    local compression_threshold = self.compression_threshold
    if compression_threshold ~= 0 and data_size > compression_threshold then
      local deflated_data, err = deflate(data)
      if not deflated_data then
        log(NOTICE, "[session] unable to deflate session data (", err , ")")

      else
        if deflated_data then
          local deflated_size = #deflated_data
          if deflated_size < data_size then
            flags = set_flag(flags, FLAG_DEFLATE)
            data = deflated_data
            data_size = deflated_size
          end
        end
      end
    end

    data_size = base64_size(data_size)

    if storage then
      if data_size > MAX_DATA_SIZE then
        return nil, "session maximum data size exceeded"
      end

      cookie_chunks = 1
    else
      cookie_chunks, err = calculate_cookie_chunks(cookie_name_size, data_size)
      if not cookie_chunks then
        return nil, err
      end
    end
  end

  local idling_offset = 0

  local packed_flags          = bpack(FLAGS_SIZE, flags)
  local packed_data_size      = bpack(DATA_SIZE, data_size)
  local packed_creation_time  = bpack(CREATION_TIME_SIZE, creation_time)
  local packed_rolling_offset = bpack(ROLLING_OFFSET_SIZE, rolling_offset)
  local packed_idling_offset  = bpack(IDLING_OFFSET_SIZE, idling_offset)

  HEADER_BUFFER:reset()
  HEADER_BUFFER:put(COOKIE_TYPE, packed_flags, sid, packed_creation_time, packed_rolling_offset, packed_data_size)

  local ikm = self.ikm

  local aes_key, err, iv = derive_aes_key(ikm, sid, remember and self.remember_safety)
  if not aes_key then
    return nil, errmsg(err, "unable to derive session encryption key")
  end

  local ciphertext, err, tag = encrypt_aes_256_gcm(aes_key, iv, data, HEADER_BUFFER:tostring())
  if not ciphertext then
    return nil, errmsg(err, "unable to encrypt session data")
  end

  HEADER_BUFFER:put(tag, packed_idling_offset)

  local msg = bind(HEADER_BUFFER:tostring(), flags)
  local mac, err = calculate_mac(ikm, sid, msg)
  if not mac then
    return nil, err
  end

  local header_decoded = HEADER_BUFFER:put(mac):get()
  local header_encoded, err = encode_base64url(header_decoded)
  if not header_encoded then
    return nil, errmsg(err, "unable to base64url encode session header")
  end

  local payload, err = encode_base64url(ciphertext)
  if not payload then
    return nil, errmsg(err, "unable to base64url encode session data")
  end

  local cookies = header["Set-Cookie"]
  local cookie_flags = self.cookie_flags

  local initial_chunk
  local ciphertext_encoded

  local remember_flags
  if remember then
    local max_age = self.remember_rolling_timeout
    if max_age == 0 or max_age > MAX_TTL then
      max_age = MAX_TTL
    end

    local expires = http_time(creation_time + max_age)
    remember_flags = fmt("; Expires=%s; Max-Age=%d", expires, max_age)
  end

  if cookie_chunks == 1 then
    local cookie_data
    if storage then
      ciphertext_encoded = payload
      if remember then
        cookie_data = fmt("%s=%s%s%s", cookie_name, header_encoded, cookie_flags, remember_flags)
      else
        cookie_data = fmt("%s=%s%s", cookie_name, header_encoded, cookie_flags)
      end

    else
      initial_chunk = payload
      if remember then
        cookie_data = fmt("%s=%s%s%s%s", cookie_name, header_encoded, payload, cookie_flags, remember_flags)
      else
        cookie_data = fmt("%s=%s%s%s", cookie_name, header_encoded, payload, cookie_flags)
      end
    end

    cookies, err = merge_cookies(cookies, cookie_name_size, cookie_name, cookie_data)
    if not cookies then
      return nil, err
    end

  else
    DATA_BUFFER:set(payload)

    initial_chunk = DATA_BUFFER:get(MAX_COOKIE_SIZE - HEADER_ENCODED_SIZE - cookie_name_size - 1)

    local cookie_data
    if remember then
      cookie_data = fmt("%s=%s%s%s%s", cookie_name, header_encoded, initial_chunk, cookie_flags, remember_flags)
    else
      cookie_data = fmt("%s=%s%s%s", cookie_name, header_encoded, initial_chunk, cookie_flags)
    end

    cookies, err = merge_cookies(cookies, cookie_name_size, cookie_name, cookie_data)
    if not cookies then
      return nil, err
    end

    for i = 2, cookie_chunks do
      local name = fmt("%s%d", cookie_name, i)
      cookie_data = DATA_BUFFER:get(MAX_COOKIE_SIZE - cookie_name_size - 2)
      if remember then
        cookie_data = fmt("%s=%s%s%s", name, cookie_data, cookie_flags, remember_flags)
      else
        cookie_data = fmt("%s=%s%s", name, cookie_data, cookie_flags)
      end
      cookies, err = merge_cookies(cookies, cookie_name_size + 1, name, cookie_data)
      if not cookies then
        return nil, err
      end
    end
  end

  if storage then
    local key, err = self.hash_storage_key(sid)
    if not key then
      return nil, err
    end

    DATA[1] = payload

    local info_data = self.info.data
    if info_data then
      info_data, err = encode_json(info_data)
      if not info_data then
        return nil, errmsg(err, "unable to json encode session info")
      end

      info_data, err = encode_base64url(info_data)
      if not info_data then
        return nil, errmsg(err, "unable to base64url encode session info")
      end

      DATA[2] = info_data

    else
      DATA[2] = nil
    end

    data, err = encode_json(DATA)
    if not data then
      return nil, errmsg(err, "unable to json encode session data")
    end

    local old_sid = meta.sid
    local old_key
    if old_sid then
      old_key, err = self.hash_storage_key(old_sid)
      if not old_key then
        log(WARN, "[session] ", err)
      end
    end

    local store_ttl = get_store_ttl(self, remember, creation_time, current_time)
    local store_metadata = get_store_metadata(self)

    local ok, err = storage:set(cookie_name, key, data, store_ttl, current_time, old_key, self.stale_ttl, store_metadata, remember)
    if not ok then
      return nil, errmsg(err, "unable to store session data")
    end

  else
    local old_data_size = meta.data_size
    if old_data_size then
      local old_cookie_chunks = calculate_cookie_chunks(cookie_name_size, old_data_size)
      if old_cookie_chunks and old_cookie_chunks > cookie_chunks then
        for i = cookie_chunks + 1, old_cookie_chunks do
          local name = fmt("%s%d", cookie_name, i)
          local cookie_data = fmt("%s=%s%s", name, cookie_flags, COOKIE_EXPIRE_FLAGS)
          cookies, err = merge_cookies(cookies, cookie_name_size + 1, name, cookie_data)
          if not cookies then
            return nil, err
          end
        end
      end
    end
  end

  header["Set-Cookie"] = cookies

  if remember then
    self.remember_meta = {
      timestamp      = current_time,
      flags          = flags,
      sid            = sid,
      creation_time  = creation_time,
      rolling_offset = rolling_offset,
      data_size      = data_size,
      idling_offset  = idling_offset,
      ikm            = ikm,
      header         = header_decoded,
      initial_chunk  = initial_chunk,
      ciphertext     = ciphertext_encoded,
    }

  else
    self.state = state or STATE_OPEN
    self.meta = {
      timestamp      = current_time,
      flags          = flags,
      sid            = sid,
      creation_time  = creation_time,
      rolling_offset = rolling_offset,
      data_size      = data_size,
      idling_offset  = idling_offset,
      ikm            = ikm,
      header         = header_decoded,
      initial_chunk  = initial_chunk,
      ciphertext     = ciphertext_encoded,
    }
  end

  return true
end


local function save_info(self, data, remember)
  local cookie_name
  local meta
  if remember then
    cookie_name = self.remember_cookie_name
    meta = self.remember_meta or {}
  else
    cookie_name = self.cookie_name
    meta = self.meta
  end

  local key, err = self.hash_storage_key(meta.sid)
  if not key then
    return nil, err
  end

  DATA[1] = meta.ciphertext
  DATA[2] = data

  data, err = encode_json(DATA)
  if not data then
    return nil, errmsg(err, "unable to json encode session data")
  end

  local current_time = time()
  local store_ttl = get_store_ttl(self, false, current_time, meta.creation_time, meta.rolling_offset)
  local ok, err = self.storage:set(cookie_name, key, data, store_ttl, current_time)
  if not ok then
    return nil, errmsg(err, "unable to store session info")
  end
end


local function destroy(self, remember)
  local cookie_name
  local meta
  if remember then
    cookie_name = self.remember_cookie_name
    meta = self.remember_meta or {}
  else
    cookie_name = self.cookie_name
    meta = self.meta
  end

  local cookie_name_size = #cookie_name
  local storage = self.storage

  local cookie_chunks = 1
  local data_size = meta.data_size
  if not storage and data_size then
    local err
    cookie_chunks, err = calculate_cookie_chunks(cookie_name_size, data_size)
    if not cookie_chunks then
      return nil, err
    end
  end

  local cookie_flags = self.cookie_flags
  local cookie_data = fmt("%s=%s%s", cookie_name, cookie_flags, COOKIE_EXPIRE_FLAGS)
  local cookies, err = merge_cookies(header["Set-Cookie"], cookie_name_size, cookie_name, cookie_data)
  if not cookies then
    return nil, err
  end

  if cookie_chunks > 1 then
    for i = 2, cookie_chunks do
      local name = fmt("%s%d", cookie_name, i)
      cookie_data = fmt("%s=%s%s", name, cookie_flags, COOKIE_EXPIRE_FLAGS)
      cookies, err = merge_cookies(cookies, cookie_name_size + 1, name, cookie_data)
      if not cookies then
        return nil, err
      end
    end
  end

  if storage then
    local sid = meta.sid
    if sid then
      local key, err = self.hash_storage_key(sid)
      if not key then
        return nil, err
      end

      local ok, err = storage:delete(cookie_name, key, meta.timestamp, get_store_metadata(self))
      if not ok then
        return nil, errmsg(err, "unable to destroy session")
      end
    end
  end

  header["Set-Cookie"] = cookies

  self.state = STATE_CLOSED

  return true
end


local function clear_request_cookie(self, remember)
  local cookies = var.http_cookie
  if not cookies or cookies == "" then
    return
  end

  local cookie_name
  if remember then
    cookie_name = self.remember_cookie_name
  else
    cookie_name = self.cookie_name
  end

  local cookie_name_size = #cookie_name

  local cookie_chunks
  if self.storage then
    cookie_chunks = 1
  else
    local data_size = remember                     and
                      self.remember_meta           and
                      self.remember_meta.data_size or
                      self.meta.data_size
    cookie_chunks = calculate_cookie_chunks(cookie_name_size, data_size) or 1
  end

  HIDE_BUFFER:reset()

  local size = #cookies
  local name
  local skip = false
  local start = 1
  for i = 1, size do
    local b = byte(cookies, i)
    if name then
      if b == SEMICOLON_BYTE or i == size then
        if not skip then
          local value
          if b == SEMICOLON_BYTE then
            value = trim(sub(cookies, start, i - 1))
          else
            value = trim(sub(cookies, start))
          end

          if value ~= "" then
            HIDE_BUFFER:put(value)
          end

          if i ~= size then
            HIDE_BUFFER:put("; ")
          end
        end

        if i == size then
          break
        end

        name = nil
        start = i + 1
        skip = false
      end

    else
      if b == EQUALS_BYTE or b == SEMICOLON_BYTE then
        name = sub(cookies, start, i - 1)
      elseif i == size then
        name = sub(cookies, start, i)
      end

      if name then
        name = trim(name)
        if b == SEMICOLON_BYTE or i == size then
          if name ~= "" then
            HIDE_BUFFER:put(name)
            if i ~= size then
              HIDE_BUFFER:put(";")
            end

          elseif i == size then
            break
          end

          name = nil

        else
          if name == cookie_name then
            skip = true

          elseif cookie_chunks > 1 then
            local chunk_number = tonumber(sub(name, -1), 10)
            if chunk_number and chunk_number > 1 and chunk_number <= cookie_chunks
                            and sub(name, 1, -2) == cookie_name
            then
              skip = true
            end
          end

          if not skip then
            if name ~= "" then
              HIDE_BUFFER:put(name)
            end

            if b == EQUALS_BYTE then
              HIDE_BUFFER:put("=")
            end
          end
        end

        start = i + 1
      end
    end
  end

  if #HIDE_BUFFER == 0 then
    clear_request_header("Cookie")
  else
    set_request_header("Cookie", HIDE_BUFFER:get())
  end

  return true
end



local function get_remember(self)
  local flags = self.meta.flags
  if flags and has_flag(flags, FLAG_FORGET) then
    return false
  end

  if has_flag(self.flags, FLAG_FORGET) then
    return false
  end

  return self.remember
end


local function set_property_header(self, property_header, set_header)
  local name = HEADERS[property_header]
  if not name then
    return
  end

  local value = get_property(self, property_header)
  if not value then
    return
  end

  set_header(name, value)
end


local function set_property_headers(self, headers, set_header)
  if not headers then
    return
  end

  local count = #headers
  if count == 0 then
    return
  end

  for i = 1, count do
    set_property_header(self, headers[i], set_header)
  end
end


local function set_property_headers_vararg(self, set_header, count, ...)
  if count == 1 then
    local header_or_headers = select(1, ...)
    if type(header_or_headers) == "table" then
      return set_property_headers(self, header_or_headers, set_header)
    end

    return set_property_header(self, header_or_headers, set_header)
  end

  for i = 1, count do
    local property_header = select(i, ...)
    set_property_header(self, property_header, set_header)
  end
end


---
-- Session
-- @section instance


local fake_info_mt = {}


function fake_info_mt:set(key, value)
  local session = self.session
  assert(session.state ~= STATE_CLOSED, "unable to set session info on closed session")
  session.data[session.data_index][1]["@" .. key] = value
end


function fake_info_mt:get(key)
  local session = self.session
  assert(session.state ~= STATE_CLOSED, "unable to get session info on closed session")
  return session.data[session.data_index][1]["@" .. key]
end


function fake_info_mt:save()
  local session = self.session
  assert(session.state ~= STATE_CLOSED, "unable to save session info on closed session")
  if session.state ~= STATE_OPEN then
    return nil, "unable to save session info on nonexistent session"
  end
  return session:save()
end


fake_info_mt.__index = fake_info_mt


local fake_info = {}


function fake_info.new(session)
  return setmetatable({
    session = session,
    data = false,
  }, fake_info_mt)
end


local info_mt = {}


info_mt.__index = info_mt


---
-- Set a value in session information store.
--
-- @function instance.info:set
-- @tparam string key key
-- @param value value
function info_mt:set(key, value)
  local session = self.session
  assert(session.state ~= STATE_CLOSED, "unable to set session info on closed session")
  local audience = session.data[session.data_index][2]
  local data = self.data
  if data then
    if data[audience] then
      data[audience][key] = value

    else
      data[audience] = {
        [key] = value,
      }
    end

  else
    self.data = {
      [audience] = {
        [key] = value,
      },
    }
  end
end


---
-- Get a value from session information store.
--
-- @function instance.info:get
-- @tparam string key key
-- @return value
function info_mt:get(key)
  local session = self.session
  assert(session.state ~= STATE_CLOSED, "unable to get session info on closed session")
  local data = self.data
  if not data then
    return
  end

  local audience = session.data[session.data_index][2]
  data = self.data[audience]
  if not data then
    return
  end

  return data[key]
end


---
-- Save information.
--
-- Only updates backend storage. Does not send a new cookie.
--
-- @function instance.info:save
-- @treturn true|nil ok
-- @treturn string error message
function info_mt:save()
  local session = self.session
  assert(session.state ~= STATE_CLOSED, "unable to save session info on closed session")
  if session.state ~= STATE_OPEN then
    return nil, "unable to save session info on nonexistent session"
  end

  local data = self.data
  if not data then
    return true
  end

  local err
  data, err = encode_json(data)
  if not data then
    return nil, errmsg(err, "unable to json encode session info")
  end

  data, err = encode_base64url(data)
  if not data then
    return nil, errmsg(err, "unable to base64url encode session info")
  end

  local ok, err = save_info(session, data)
  if not ok then
    return nil, err
  end

  if get_remember(session) then
    if not session.remember_meta then
      local remembered = open(self, true, true)
      if not remembered then
        return save(session, nil, true)
      end
    end

    return save_info(session, data, true)
  end

  return true
end


local info = {}


function info.new(session)
  return setmetatable({
    session = session,
    data = false,
  }, info_mt)
end


local metatable = {}


metatable.__index = metatable


function metatable.__newindex()
  error("attempt to update a read-only table", 2)
end


---
-- Set session data.
--
-- @function instance:set_data
-- @tparam table data data
--
-- @usage
-- local session, err, exists = require("resty.session").open()
-- if not exists then
--   session:set_data({
--     cart = {},
--   })
--   session:save()
-- end
function metatable:set_data(data)
  assert(self.state ~= STATE_CLOSED, "unable to set session data on closed session")
  assert(type(data) == "table", "invalid session data")
  self.data[self.data_index][1] = data
end


---
-- Get session data.
--
-- @function instance:get_data
-- @treturn table value
--
-- @usage
-- local session, err, exists = require("resty.session").open()
-- if exists then
--   local data = session:get_data()
--   ngx.req.set_header("Authorization", "Bearer " .. data.access_token)
-- end
function metatable:get_data()
  assert(self.state ~= STATE_CLOSED, "unable to set session data on closed session")
  return self.data[self.data_index][1]
end


---
-- Set a value in session.
--
-- @function instance:set
-- @tparam string key key
-- @param value value
--
-- local session, err, exists = require("resty.session").open()
-- if not exists then
--   session:set("access-token", "eyJ...")
--   session:save()
-- end
function metatable:set(key, value)
  assert(self.state ~= STATE_CLOSED, "unable to set session data value on closed session")
  if self.storage or byte(key, 1) ~= AT_BYTE then
    self.data[self.data_index][1][key] = value
  else
    self.data[self.data_index][1]["$" .. key] = value
  end
end


---
-- Get a value from session.
--
-- @function instance:get
-- @tparam string key key
-- @return value
--
-- @usage
-- local session, err, exists = require("resty.session").open()
-- if exists then
--   local access_token = session:get("access-token")
--   ngx.req.set_header("Authorization", "Bearer " .. access_token)
-- end
function metatable:get(key)
  assert(self.state ~= STATE_CLOSED, "unable to get session data value on closed session")
  if self.storage or byte(key, 1) ~= AT_BYTE then
    return self.data[self.data_index][1][key]
  else
    return self.data[self.data_index][1]["$" .. key]
  end
end


---
-- Set session audience.
--
-- @function instance:set_audience
-- @tparam string audience audience
--
-- @usage
-- local session = require("resty.session").new()
-- session.set_audience("my-service")
function metatable:set_audience(audience)
  assert(self.state ~= STATE_CLOSED, "unable to set audience on closed session")

  local data = self.data
  local data_index = self.data_index
  local current_audience = data[data_index][2]
  if audience == current_audience then
    return
  end

  local info_data = self.info.data
  if info_data then
    info_data[audience] = info_data[current_audience]
    info_data[current_audience] = nil
  end

  local count = #data
  if count == 1 then
    data[1][2] = audience
    return
  end

  local previous_index
  for i = 1, count do
    if data[i][2] == audience then
      previous_index = i
      break
    end
  end

  data[data_index][2] = audience

  if not previous_index or previous_index == data_index then
    return
  end

  remove(data, previous_index)

  if previous_index < data_index then
    self.data_index = data_index - 1
  end
end


---
-- Get session audience.
--
-- @function instance:get_audience
-- @treturn string audience
function metatable:get_audience()
  assert(self.state ~= STATE_CLOSED, "unable to get audience on closed session")
  return self.data[self.data_index][2]
end


---
-- Set session subject.
--
-- @function instance:set_subject
-- @tparam string subject subject
--
-- @usage
-- local session = require("resty.session").new()
-- session.set_subject("john@doe.com")
function metatable:set_subject(subject)
  assert(self.state ~= STATE_CLOSED, "unable to set subject on closed session")
  self.data[self.data_index][3] = subject
end


---
-- Get session subject.
--
-- @function instance:get_subject
-- @treturn string subject
--
-- @usage
-- local session, err, exists = require("resty.session").open()
-- if exists then
--   local subject = session.get_subject()
-- end
function metatable:get_subject()
  assert(self.state ~= STATE_CLOSED, "unable to get subject on closed session")
  return self.data[self.data_index][3]
end


---
-- Get session property.
--
-- Possible property names:
-- * `"id"`: 43 bytes session id (same as nonce, but base64 url-encoded)
-- * `"nonce"`: 32 bytes nonce (same as session id but in raw bytes)
-- * `"audience"`: Current session audience
-- * `"subject"`: Current session subject
-- * `"timeout"`: Closest timeout (in seconds) (what's left of it)
-- * `"idling-timeout`"`: Session idling timeout (in seconds) (what's left of it)
-- * `"rolling-timeout`"`: Session rolling timeout (in seconds) (what's left of it)
-- * `"absolute-timeout`"`: Session absolute timeout (in seconds) (what's left of it)
--
-- @function instance:get_property
-- @treturn string|number metadata
--
-- @usage
-- local session, err, exists = require("resty.session").open()
-- if exists then
--   local timeout = session.get_property("timeout")
-- end
function metatable:get_property(name)
  assert(self.state ~= STATE_CLOSED, "unable to get session property on closed session")
  return get_property(self, name)
end


---
-- Set persistent sessions on/off.
--
-- In many login forms user is given an option for "remember me".
-- You can call this function based on what user selected.
--
-- @function instance:set_remember
-- @tparam boolean value `true` to enable persistent session, `false` to disable them
function metatable:set_remember(value)
  assert(self.state ~= STATE_CLOSED, "unable to set remember on closed session")
  assert(type(value) == "boolean", "invalid remember value")
  if value == false then
    self.flags = set_flag(self.flags, FLAG_FORGET)
  else
    self.flags = unset_flag(self.flags, FLAG_FORGET)
  end

  self.remember = value
end


---
-- Get state of persistent sessions.
--
-- @function instance:get_remember
-- @treturn boolean `true` when persistent sessions are enabled, otherwise `false`
function metatable:get_remember()
  assert(self.state ~= STATE_CLOSED, "unable to get remember on closed session")
  return get_remember(self)
end


---
-- Open a session.
--
-- This can be used to open a session.
--
-- @function instance:open
-- @treturn true|nil ok
-- @treturn string error message
function metatable:open()
  local exists, err, audience_error = open(self)
  if exists then
    return true
  end

  if audience_error then
    return nil, err
  end

  if not self.remember then
    return nil, err
  end

  local remembered = open(self, true)
  if not remembered then
    return nil, err
  end

  local ok, err = save(self, nil, true)
  if not ok then
    return nil, err
  end

  self.state = STATE_NEW
  self.meta = DUMMY_META

  ok, err = save(self, STATE_OPEN)
  if not ok then
    return nil, err
  end

  return true
end


---
-- Save the session.
--
-- Saves the session data and issues a new session cookie with a new session id.
-- When `remember` is enabled, it will also issue a new persistent cookie and
-- possibly save the data in backend store.
--
-- @function instance:save
-- @treturn true|nil ok
-- @treturn string error message
function metatable:save()
  assert(self.state ~= STATE_CLOSED, "unable to save closed session")

  local ok, err = save(self)
  if not ok then
    return nil, err
  end

  if get_remember(self) then
    if not self.remember_meta then
      open(self, true, true)
    end

    local ok, err = save(self, nil, true)
    if not ok then
      log(WARN, "[session] ", err)
    end
  end

  return true
end


---
-- Touch the session.
--
-- Updates idling offset of the session by sending an updated session cookie.
-- It only sends the client cookie and never calls any backend session store
-- APIs. Normally the `session:refresh` is used to call this indirectly.
--
-- @function instance:touch
-- @treturn true|nil ok
-- @treturn string error message
function metatable:touch()
  assert(self.state ~= STATE_CLOSED, "unable to touch closed session")
  if self.state ~= STATE_OPEN then
    return nil, "unable to touch nonexistent session"
  end

  local meta = self.meta
  local idling_offset = min(time() - meta.creation_time - meta.rolling_offset, MAX_IDLING_OFFSET)

  HEADER_BUFFER:reset():put(sub(meta.header, 1, HEADER_TOUCH_SIZE),
                            bpack(IDLING_OFFSET_SIZE, idling_offset))

  local msg = bind(HEADER_BUFFER:tostring(), meta.flags)
  local mac, err = calculate_mac(meta.ikm, meta.sid, msg)
  if not mac then
    return nil, err
  end

  local payload_header = HEADER_BUFFER:put(mac):get()

  meta.idling_offset = idling_offset
  meta.header        = payload_header

  payload_header, err = encode_base64url(payload_header)
  if not payload_header then
    return nil, errmsg(err, "unable to base64url encode session header")
  end

  local cookie_flags = self.cookie_flags
  local cookie_name = self.cookie_name
  local cookie_data
  if self.storage then
    cookie_data = fmt("%s=%s%s", cookie_name, payload_header, cookie_flags)
  else
    cookie_data = fmt("%s=%s%s%s", cookie_name, payload_header, meta.initial_chunk, cookie_flags)
  end

  header["Set-Cookie"] = merge_cookies(header["Set-Cookie"], #cookie_name, cookie_name, cookie_data)

  return true
end


---
-- Refresh the session.
--
-- Either saves the session (creating a new session id) or touches the session
-- depending on whether the rolling timeout is getting closer, which means
-- by default when 3/4 of rolling timeout is spent - 45 minutes with default
-- rolling timeout of an hour. The touch has a threshold, by default one minute,
-- so it may be skipped in some cases (you can call `session:touch()` to force it).
--
-- @function instance:refresh
-- @treturn true|nil ok
-- @treturn string error message
function metatable:refresh()
  assert(self.state ~= STATE_CLOSED, "unable to refresh closed session")
  if self.state ~= STATE_OPEN then
    return nil, "unable to refresh nonexistent session"
  end

  local rolling_timeout = self.rolling_timeout
  local idling_timeout = self.idling_timeout
  if rolling_timeout == 0 and idling_timeout == 0 then
    return true
  end

  local meta = self.meta
  local rolling_elapsed = meta.timestamp - meta.creation_time - meta.rolling_offset

  if rolling_timeout > 0 then
    if rolling_elapsed > floor(rolling_timeout * 0.75) then
      -- TODO: in case session was modified before calling this function, the possible remember me cookie needs to be saved too?
      return save(self)
    end
  end

  if idling_timeout > 0 then
    local idling_elapsed = rolling_elapsed - meta.idling_offset
    if idling_elapsed > self.touch_threshold then
      return self:touch()
    end
  end

  return true
end


---
-- Logout the session.
--
-- Logout either destroys the session or just clears the data for the current audience,
-- and saves it (logging out from the current audience).
--
-- @function instance:logout
-- @treturn true|nil ok
-- @treturn string error message
function metatable:logout()
  assert(self.state ~= STATE_CLOSED, "unable to logout closed session")
  if self.state ~= STATE_OPEN then
    return nil, "unable to logout nonexistent session"
  end

  local data = self.data
  if #data == 1 then
    return self:destroy()
  end

  local data_index = self.data_index
  local info_store = self.info
  local info_data = info_store and info_store.data
  if info_data then
    local audience = data[data_index][2]
    info_data[audience] = nil
    if table_is_empty(info_data) then
      info_store.data = false
    end
  end

  remove(data, data_index)

  local ok, err = save(self, STATE_CLOSED)
  if not ok then
    return nil, err
  end

  if get_remember(self) then
    if not self.remember_meta then
      open(self, true, true)
    end
    local ok, err = save(self, nil, true)
    if not ok then
      log(WARN, "[session] ", err)
    end
  end

  return true
end


---
-- Destroy the session.
--
-- Destroy the session and clear the cookies.
--
-- @function instance:destroy
-- @treturn true|nil ok
-- @treturn string error message
function metatable:destroy()
  assert(self.state ~= STATE_CLOSED, "unable to destroy closed session")
  if self.state ~= STATE_OPEN then
    return nil, "unable to destroy nonexistent session"
  end

  local ok, err = destroy(self)
  if not ok then
    return nil, err
  end

  if get_remember(self) then
    if not self.remember_meta then
      local remembered = open(self, true, true)
      if not remembered then
        return true
      end
    end

    ok, err = destroy(self, true)
    if not ok then
      return nil, err
    end
  end

  return true
end


---
-- Close the session.
--
-- Just closes the session instance so that it cannot be used anymore.
--
-- @function instance:close
function metatable:close()
  self.state = STATE_CLOSED
end


---
-- Clear the request session cookie.
--
-- Modifies the request headers by removing the session related
-- cookies. This is useful when you use the session library on
-- a proxy server and don't want the session cookies to be forwarded
-- to the upstream service.
--
-- @function instance:clear_request_cookie
-- @treturn true|nil ok
-- @treturn string error message
function metatable:clear_request_cookie()
  assert(self.state ~= STATE_CLOSED, "unable to clear session request cookie on closed session")
  if self.state ~= STATE_OPEN then
    return nil, "unable to clear session request cookie on nonexistent session"
  end

  local ok = clear_request_cookie(self)
  if not ok then
    return nil, "unable to clear session request cookie"
  end

  if get_remember(self) then
    ok = clear_request_cookie(self, true)
    if not ok then
      return nil, "unable to clear persistent session request cookie"
    end
  end

  return true
end


---
-- Sets request and response headers.
--
-- @function instance:set_headers
-- @tparam[opt] string ...
-- @treturn true|nil ok
-- @treturn string error message
function metatable:set_headers(...)
  assert(self.state ~= STATE_CLOSED, "unable to set request/response headers on closed session")
  if self.state ~= STATE_OPEN then
    return nil, "unable to set request/response headers on nonexistent session"
  end

  local count = select("#", ...)
  if count == 0 then
    set_property_headers(self, self.request_headers, set_request_header)
    set_property_headers(self, self.response_headers, set_response_header)
    return true
  end

  set_property_headers_vararg(self, set_request_header, count, ...)
  set_property_headers_vararg(self, set_response_header, count, ...)

  return true
end


---
-- Set request headers.
--
-- @function instance:set_request_headers
-- @tparam[opt] string ...
-- @treturn true|nil ok
-- @treturn string error message
function metatable:set_request_headers(...)
  assert(self.state ~= STATE_CLOSED, "unable to set request headers on closed session")
  if self.state ~= STATE_OPEN then
    return nil, "unable to set request headers on nonexistent session"
  end

  local count = select("#", ...)
  if count == 0 then
    set_property_headers(self, self.request_headers, set_request_header)
    return true
  end

  set_property_headers_vararg(self, set_request_header, count, ...)
  return true
end


---
-- Set response headers.
--
-- @function instance:set_response_headers
-- @tparam[opt] string ...
-- @treturn true|nil ok
-- @treturn string error message
function metatable:set_response_headers(...)
  assert(self.state ~= STATE_CLOSED, "unable to set response headers on closed session")
  if self.state ~= STATE_OPEN then
    return nil, "unable to set response headers on nonexistent session"
  end

  local count = select("#", ...)
  if count == 0 then
    set_property_headers(self, self.response_headers, set_response_header)
    return true
  end

  set_property_headers_vararg(self, set_response_header, count, ...)
  return true
end



local session = {
  _VERSION = "4.1.4",
  metatable = metatable,
}


---
-- Configuration
-- @section configuration


---
-- Session configuration.
-- @field secret Secret used for the key derivation. The secret is hashed with SHA-256 before using it. E.g. `"RaJKp8UQW1"`.
-- @field secret_fallbacks Array of secrets that can be used as alternative secrets (when doing key rotation), E.g. `{ "6RfrAYYzYq", "MkbTkkyF9C" }`.
-- @field ikm Initial key material (or ikm) can be specified directly (without using a secret) with exactly 32 bytes of data. E.g. `"5ixIW4QVMk0dPtoIhn41Eh1I9enP2060"`
-- @field ikm_fallbacks Array of initial key materials that can be used as alternative keys (when doing key rotation), E.g. `{ "QvPtlPKxOKdP5MCu1oI3lOEXIVuDckp7" }`.
-- @field cookie_prefix Cookie prefix, use `nil`, `"__Host-"` or `"__Secure-"` (defaults to `nil`)
-- @field cookie_name Session cookie name, e.g. `"session"` (defaults to `"session"`)
-- @field cookie_path Cookie path, e.g. `"/"` (defaults to `"/"`)
-- @field cookie_domain Cookie domain, e.g. `"example.com"` (defaults to `nil`)
-- @field cookie_http_only Mark cookie HTTP only, use `true` or `false` (defaults to `true`)
-- @field cookie_secure Mark cookie secure, use `nil`, `true` or `false` (defaults to `nil`)
-- @field cookie_priority Cookie priority, use `nil`, `"Low"`, `"Medium"`, or `"High"` (defaults to `nil`)
-- @field cookie_same_site Cookie same-site policy, use `nil`, `"Lax"`, `"Strict"`, `"None"`, or `"Default"` (defaults to `"Lax"`)
-- @field cookie_same_party Mark cookie with same party flag, use `nil`, `true`, or `false` (default: `nil`)
-- @field cookie_partitioned Mark cookie with partitioned flag, use `nil`, `true`, or `false` (default: `nil`)
-- @field remember Enable or disable persistent sessions, use `nil`, `true`, or `false` (defaults to `false`)
-- @field remember_safety Remember cookie key derivation complexity, use `nil`, `"None"` (fast), `"Low"`, `"Medium"`, `"High"` or `"Very High"` (slow) (defaults to `"Medium"`)
-- @field remember_cookie_name Persistent session cookie name, e.g. `"remember"` (defaults to `"remember"`)
-- @field audience Session audience, e.g. `"my-application"` (defaults to `"default"`)
-- @field subject Session subject, e.g. `"john.doe@example.com"` (defaults to `nil`)
-- @field enforce_same_subject When set to `true`, audiences need to share the same subject. The library removes non-subject matching audience data on save.
-- @field stale_ttl When session is saved a new session is created, stale ttl specifies how long the old one can still be used, e.g. `10` (defaults to `10`) (in seconds)
-- @field idling_timeout Idling timeout specifies how long the session can be inactive until it is considered invalid, e.g. `900` (defaults to `900`, or 15 minutes) (in seconds)
-- @field rolling_timeout Rolling timeout specifies how long the session can be used until it needs to be renewed, e.g. `3600` (defaults to `3600`, or an hour) (in seconds)
-- @field absolute_timeout Absolute timeout limits how long the session can be renewed, until re-authentication is required, e.g. `86400` (defaults to `86400`, or a day) (in seconds)
-- @field remember_rolling_timeout Remember timeout specifies how long the persistent session is considered valid, e.g. `604800` (defaults to `604800`, or a week) (in seconds)
-- @field remember_absolute_timeout Remember absolute timeout limits how long the persistent session can be renewed, until re-authentication is required, e.g. `2592000` (defaults to `2592000`, or 30 days) (in seconds)
-- @field hash_storage_key Whether to hash or not the storage key. With storage key hashed it is impossible to decrypt data on server side without having a cookie too (defaults to `false`).
-- @field hash_subject Whether to hash or not the subject when `store_metadata` is enabled, e.g. for PII reasons (defaults to `false`).
-- @field store_metadata Whether to also store metadata of sessions, such as collecting data of sessions for a specific audience belonging to a specific subject (defaults to `false`).
-- @field touch_threshold Touch threshold controls how frequently or infrequently the `session:refresh` touches the cookie, e.g. `60` (defaults to `60`, or a minute) (in seconds)
-- @field compression_threshold Compression threshold controls when the data is deflated, e.g. `1024` (defaults to `1024`, or a kilobyte) (in bytes)
-- @field bind Bind the session to data acquired from the HTTP request or connection, use `ip`, `scheme`, `user-agent`. E.g. `{ "scheme", "user-agent" }` will calculate MAC utilizing also HTTP request `Scheme` and `User-Agent` header.
-- @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 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" }`
-- @field mysql Configuration for MySQL / MariaDB storage, e.g. `{ database = "sessions" }`
-- @field postgres Configuration for Postgres storage, e.g. `{ database = "sessions" }`
-- @field redis Configuration for Redis / Redis Sentinel / Redis Cluster storages, e.g. `{ prefix = "sessions" }`
-- @field shm Configuration for shared memory storage, e.g. `{ zone = "sessions" }`
-- @field ["custom-storage"] Custom storage (loaded with `require "custom-storage"`) configuration
-- @table configuration


---
-- Initialization
-- @section initialization


---
-- Initialize the session library.
--
-- This function can be called on `init` or `init_worker` phases on OpenResty
-- to set global default configuration to all session instances created by this
-- library.
--
-- @function module.init
-- @tparam[opt] table configuration session @{configuration} overrides
--
-- @usage
-- require("resty.session").init({
--   audience = "my-application",
--   storage = "redis",
--   redis = {
--     username = "session",
--     password = "storage",
--   },
-- })


local function opt(configuration, name, default)
  if not configuration then
    return default
  end

  local value = configuration[name]
  if value == nil then
    if name == "ikm" then
      if configuration.secret then
        value = assert(sha256(configuration.secret))
      end

    elseif name == "ikm_fallbacks" then
      local secret_fallbacks = configuration.secret_fallbacks
      if secret_fallbacks then
        local count = #secret_fallbacks
        if count > 0 then
          value = table_new(count, 0)
          for i = 1, count do
            value[i] = assert(sha256(secret_fallbacks[i]))
          end
        end
      end
    end

  else
    if name == "ikm" then
      assert(#value == KEY_SIZE, "invalid ikm size")

    elseif name == "ikm_fallbacks" then
      local count = #value
      if count > 0 then
        for i = 1, count do
          assert(#value[i] == KEY_SIZE, "invalid ikm size in ikm_fallbacks")
        end
      end

    elseif name == "hash_storage_key" then
      value = value and sha256_storage_key or encode_base64url

    elseif name == "hash_subject" then
      value = value and sha256_subject or encode_base64url

    elseif name == "bind" then
      local count = #value
      if count > 0 then
        local flags = FLAGS_NONE
        for i = 1, count do
          flags = set_flag(flags, assert(BINDS[value[i]], "invalid bind"))
        end
        value = flags
      end

    elseif name == "request_headers" or name == "response_headers" then
      local count = #value
      if count > 0 then
        for i = 1, count do
          assert(HEADERS[value[i]], "invalid header name")
        end
      end

    elseif name == "storage" then
      if value == false then
        value = nil

      else
        local t = type(value)
        if t == "string" then
          value = load_storage(value, configuration)
        else
          assert(t == "table", "invalid session storage")
        end
      end
    end
  end

  if value == nil then
    return default
  end

  return value
end


function session.init(configuration)
  DEFAULT_IKM                       = opt(configuration, "ikm", DEFAULT_IKM)
                                   or assert(sha256(assert(rand_bytes(KEY_SIZE))))
  DEFAULT_IKM_FALLBACKS             = opt(configuration, "ikm_fallbacks")
  DEFAULT_COOKIE_PREFIX             = opt(configuration, "cookie_prefix")
  DEFAULT_COOKIE_NAME               = opt(configuration, "cookie_name", "session")
  DEFAULT_COOKIE_PATH               = opt(configuration, "cookie_path", "/")
  DEFAULT_COOKIE_DOMAIN             = opt(configuration, "cookie_domain")
  DEFAULT_COOKIE_HTTP_ONLY          = opt(configuration, "cookie_http_only", true)
  DEFAULT_COOKIE_SECURE             = opt(configuration, "cookie_secure")
  DEFAULT_COOKIE_PRIORITY           = opt(configuration, "cookie_priority")
  DEFAULT_COOKIE_SAME_SITE          = opt(configuration, "cookie_same_site", "Lax")
  DEFAULT_COOKIE_SAME_PARTY         = opt(configuration, "cookie_same_party")
  DEFAULT_COOKIE_PARTITIONED        = opt(configuration, "cookie_partitioned")
  DEFAULT_REMEMBER                  = opt(configuration, "remember", false)
  DEFAULT_REMEMBER_SAFETY           = opt(configuration, "remember_safety", "Medium")
  DEFAULT_REMEMBER_COOKIE_NAME      = opt(configuration, "remember_cookie_name", "remember")
  DEFAULT_AUDIENCE                  = opt(configuration, "audience", "default")
  DEFAULT_SUBJECT                   = opt(configuration, "subject")
  DEFAULT_ENFORCE_SAME_SUBJECT      = opt(configuration, "enforce_same_subject", false)
  DEFAULT_STALE_TTL                 = opt(configuration, "stale_ttl", 10)                      -- 10 seconds
  DEFAULT_IDLING_TIMEOUT            = opt(configuration, "idling_timeout", 900)                -- 15 minutes
  DEFAULT_ROLLING_TIMEOUT           = opt(configuration, "rolling_timeout", 3600)              --  1 hour
  DEFAULT_ABSOLUTE_TIMEOUT          = opt(configuration, "absolute_timeout", 86400)            --  1 day
  DEFAULT_REMEMBER_ROLLING_TIMEOUT  = opt(configuration, "remember_rolling_timeout", 604800)   --  1 week
  DEFAULT_REMEMBER_ABSOLUTE_TIMEOUT = opt(configuration, "remember_absolute_timeout", 2592000) -- 30 days
  DEFAULT_HASH_STORAGE_KEY          = opt(configuration, "hash_storage_key", encode_base64url)
  DEFAULT_HASH_SUBJECT              = opt(configuration, "hash_subject", encode_base64url)
  DEFAULT_STORE_METADATA            = opt(configuration, "store_metadata", false)
  DEFAULT_TOUCH_THRESHOLD           = opt(configuration, "touch_threshold", 60)                --  1 minute
  DEFAULT_COMPRESSION_THRESHOLD     = opt(configuration, "compression_threshold", 1024)        --  1 kB
  DEFAULT_FLAGS                     = opt(configuration, "bind", FLAGS_NONE)
  DEFAULT_REQUEST_HEADERS           = opt(configuration, "request_headers")
  DEFAULT_RESPONSE_HEADERS          = opt(configuration, "response_headers")
  DEFAULT_STORAGE                   = opt(configuration, "storage")
end

---
-- Constructors
-- @section constructors


---
-- Create a new session.
--
-- This creates a new session instance.
--
-- @function module.new
-- @tparam[opt] table configuration session @{configuration} overrides
-- @treturn table session instance
--
-- @usage
-- local session = require("resty.session").new()
-- -- OR
-- local session = require("resty.session").new({
--   audience = "my-application",
-- })
function session.new(configuration)
  local ikm                       = opt(configuration, "ikm",                       DEFAULT_IKM)
  local ikm_fallbacks             = opt(configuration, "ikm_fallbacks",             DEFAULT_IKM_FALLBACKS)
  local cookie_prefix             = opt(configuration, "cookie_prefix",             DEFAULT_COOKIE_PREFIX)
  local cookie_name               = opt(configuration, "cookie_name",               DEFAULT_COOKIE_NAME)
  local cookie_path               = opt(configuration, "cookie_path",               DEFAULT_COOKIE_PATH)
  local cookie_domain             = opt(configuration, "cookie_domain",             DEFAULT_COOKIE_DOMAIN)
  local cookie_http_only          = opt(configuration, "cookie_http_only",          DEFAULT_COOKIE_HTTP_ONLY)
  local cookie_secure             = opt(configuration, "cookie_secure",             DEFAULT_COOKIE_SECURE)
  local cookie_priority           = opt(configuration, "cookie_priority",           DEFAULT_COOKIE_PRIORITY)
  local cookie_same_site          = opt(configuration, "cookie_same_site",          DEFAULT_COOKIE_SAME_SITE)
  local cookie_same_party         = opt(configuration, "cookie_same_party",         DEFAULT_COOKIE_SAME_PARTY)
  local cookie_partitioned        = opt(configuration, "cookie_partitioned",        DEFAULT_COOKIE_PARTITIONED)
  local remember                  = opt(configuration, "remember",                  DEFAULT_REMEMBER)
  local remember_safety           = opt(configuration, "remember_safety",           DEFAULT_REMEMBER_SAFETY)
  local remember_cookie_name      = opt(configuration, "remember_cookie_name",      DEFAULT_REMEMBER_COOKIE_NAME)
  local audience                  = opt(configuration, "audience",                  DEFAULT_AUDIENCE)
  local subject                   = opt(configuration, "subject",                   DEFAULT_SUBJECT)
  local enforce_same_subject      = opt(configuration, "enforce_same_subject",      DEFAULT_ENFORCE_SAME_SUBJECT)
  local stale_ttl                 = opt(configuration, "stale_ttl",                 DEFAULT_STALE_TTL)
  local idling_timeout            = opt(configuration, "idling_timeout",            DEFAULT_IDLING_TIMEOUT)
  local rolling_timeout           = opt(configuration, "rolling_timeout",           DEFAULT_ROLLING_TIMEOUT)
  local absolute_timeout          = opt(configuration, "absolute_timeout",          DEFAULT_ABSOLUTE_TIMEOUT)
  local remember_rolling_timeout  = opt(configuration, "remember_rolling_timeout",  DEFAULT_REMEMBER_ROLLING_TIMEOUT)
  local remember_absolute_timeout = opt(configuration, "remember_absolute_timeout", DEFAULT_REMEMBER_ABSOLUTE_TIMEOUT)
  local hash_storage_key          = opt(configuration, "hash_storage_key",          DEFAULT_HASH_STORAGE_KEY)
  local hash_subject              = opt(configuration, "hash_subject",              DEFAULT_HASH_SUBJECT)
  local store_metadata            = opt(configuration, "store_metadata",            DEFAULT_STORE_METADATA)
  local touch_threshold           = opt(configuration, "touch_threshold",           DEFAULT_TOUCH_THRESHOLD)
  local compression_threshold     = opt(configuration, "compression_threshold",     DEFAULT_COMPRESSION_THRESHOLD)
  local flags                     = opt(configuration, "bind",                      DEFAULT_FLAGS)
  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)

  if cookie_prefix == "__Host-" then
    cookie_name          = cookie_prefix .. cookie_name
    remember_cookie_name = cookie_prefix .. remember_cookie_name
    cookie_path          = "/"
    cookie_domain        = nil
    cookie_secure        = true

  elseif cookie_prefix == "__Secure-" then
    cookie_name          = cookie_prefix .. cookie_name
    remember_cookie_name = cookie_prefix .. remember_cookie_name
    cookie_secure        = true

  elseif cookie_same_site == "None" then
    cookie_secure = true
  end

  if cookie_same_party then
    assert(cookie_same_site ~= "Strict", "SameParty session cookies cannot use SameSite=Strict")
    cookie_secure = true
  end

  FLAGS_BUFFER:reset()

  if cookie_domain and cookie_domain ~= "localhost" and cookie_domain ~= "" then
    FLAGS_BUFFER:put("; Domain=", cookie_domain)
  end

  FLAGS_BUFFER:put("; Path=", cookie_path, "; SameSite=", cookie_same_site)

  if cookie_priority then
    FLAGS_BUFFER:put("; Priority=", cookie_priority)
  end

  if cookie_same_party then
    FLAGS_BUFFER:put("; SameParty")
  end

  if cookie_partitioned then
    FLAGS_BUFFER:put("; Partitioned")
  end

  if cookie_secure then
    FLAGS_BUFFER:put("; Secure")
  end

  if cookie_http_only then
    FLAGS_BUFFER:put("; HttpOnly")
  end

  local cookie_flags = FLAGS_BUFFER:get()

  local self = setmetatable({
    stale_ttl                 = stale_ttl,
    idling_timeout            = idling_timeout,
    rolling_timeout           = rolling_timeout,
    absolute_timeout          = absolute_timeout,
    remember_rolling_timeout  = remember_rolling_timeout,
    remember_absolute_timeout = remember_absolute_timeout,
    touch_threshold           = touch_threshold,
    compression_threshold     = compression_threshold,
    hash_storage_key          = hash_storage_key,
    hash_subject              = hash_subject,
    store_metadata            = store_metadata,
    enforce_same_subject      = enforce_same_subject,
    cookie_name               = cookie_name,
    cookie_flags              = cookie_flags,
    remember_cookie_name      = remember_cookie_name,
    remember_safety           = remember_safety,
    remember                  = remember,
    flags                     = flags,
    storage                   = storage,
    ikm                       = ikm,
    ikm_fallbacks             = ikm_fallbacks,
    request_headers           = request_headers,
    response_headers          = response_headers,
    state                     = STATE_NEW,
    meta                      = DUMMY_META,
    remember_meta             = DUMMY_REMEMBER_META,
    info                      = info,
    data_index                = 1,
    data                      = {
      {
        {},
        audience,
        subject,
      },
    },
  }, metatable)

  if storage then
    self.info = info.new(self)
  else
    self.info = fake_info.new(self)
  end

  return self
end


---
-- Helpers
-- @section helpers


---
-- Open a session.
--
-- This can be used to open a session, and it will either return an existing
-- session or a new session.
--
-- @function module.open
-- @tparam[opt] table configuration session @{configuration} overrides
-- @treturn table session instance
-- @treturn string error message
-- @treturn boolean `true`, if session existed, otherwise `false`
--
-- @usage
-- local session = require("resty.session").open()
-- -- OR
-- local session, err, exists = require("resty.session").open({
--   audience = "my-application",
-- })
function session.open(configuration)
  local self = session.new(configuration)
  local exists, err = self:open()
  if not exists then
    return self, err, false
  end

  return self, err, true
end


---
-- Start a session and refresh it as needed.
--
-- This can be used to start a session, and it will either return an existing
-- session or a new session. In case there is an existing session, the
-- session will be refreshed as well (as needed).
--
-- @function module.start
-- @tparam[opt] table configuration session @{configuration} overrides
-- @treturn table session instance
-- @treturn string error message
-- @treturn boolean `true`, if session existed, otherwise `false`
-- @treturn boolean `true`, if session was refreshed, otherwise `false`
--
-- @usage
-- local session = require("resty.session").start()
-- -- OR
-- local session, err, exists, refreshed = require("resty.session").start({
--   audience = "my-application",
-- })
function session.start(configuration)
  local self, err, exists = session.open(configuration)
  if not exists then
    return self, err, false, false
  end

  local refreshed, err = self:refresh()
  if not refreshed then
    return self, err, true, false
  end

  return self, nil, true, true
end


---
-- Logout a session.
--
-- It logouts from a specific audience.
--
-- A single session cookie may be shared between multiple audiences
-- (or applications), thus there is a need to be able to logout from
-- just a single audience while keeping the session for the other
-- audiences.
--
-- When there is only a single audience, then this can be considered
-- equal to `session.destroy`.
--
-- When the last audience is logged out, the cookie will be destroyed
-- as well and invalidated on a client.
--
-- @function module.logout
-- @tparam[opt] table configuration session @{configuration} overrides
-- @treturn boolean `true` session exists for an audience and was logged out successfully, otherwise `false`
-- @treturn string error message
-- @treturn boolean `true` if session existed, otherwise `false`
-- @treturn boolean `true` if session was logged out, otherwise `false`
--
-- @usage
-- require("resty.session").logout()
-- -- OR
-- local ok, err, exists, logged_out = require("resty.session").logout({
--   audience = "my-application",
-- })
function session.logout(configuration)
  local self, err, exists = session.open(configuration)
  if not exists then
    return nil, err, false, false
  end

  local ok, err = self:logout()
  if not ok then
    return nil, err, true, false
  end

  return true, nil, true, true
end

---
-- Destroy a session.
--
-- It destroys the whole session and clears the cookies.
--
-- @function module.destroy
-- @tparam[opt] table configuration session @{configuration} overrides
-- @treturn boolean `true` session exists and was destroyed successfully, otherwise `nil`
-- @treturn string error message
-- @treturn boolean `true` if session existed, otherwise `false`
-- @treturn boolean `true` if session was destroyed, otherwise `false`
--
-- @usage
-- require("resty.session").destroy()
-- -- OR
-- local ok, err, exists, destroyed = require("resty.session").destroy({
--   cookie_name = "auth",
-- })
function session.destroy(configuration)
  local self, err, exists = session.open(configuration)
  if not exists then
    return nil, err, false, false
  end

  local ok, err = self:destroy()
  if not ok then
    return nil, err, true, false
  end

  return true, nil, true, true
end


function session.__set_ngx_log(ngx_log)
  log = ngx_log
end


function session.__set_ngx_var(ngx_var)
  var = ngx_var
end


function session.__set_ngx_header(ngx_header)
  header = ngx_header
end


function session.__set_ngx_req_clear_header(clear_header)
  clear_request_header = clear_header
end


function session.__set_ngx_req_set_header(set_header)
  set_request_header = set_header
end


session.init() -- initialize default values


return session
