Module: BetterAuth::Cookies

Defined in:
lib/better_auth/cookies.rb

Defined Under Namespace

Classes: Cookie

Constant Summary collapse

"__Secure-"
"__Host-"
/\A[\x21\x23-\x27\x2A\x2B\x2D\x2E\x30-\x39\x41-\x5A\x5E\x5F\x60\x61-\x7A\x7C\x7E]+\z/
/\A[\x20\x21\x23-\x3A\x3C-\x5B\x5D-\x7E]*\z/

Class Method Summary collapse

Class Method Details

.chunked_value(cookies, name) ⇒ Object



293
294
295
296
297
298
299
300
301
302
# File 'lib/better_auth/cookies.rb', line 293

def chunked_value(cookies, name)
  chunks = cookies.each_with_object([]) do |(cookie_name, value), result|
    next unless cookie_name.start_with?("#{name}.")

    result << [SessionStore.chunk_index(cookie_name), value]
  end
  return nil if chunks.empty?

  chunks.sort_by(&:first).map(&:last).join
end


362
363
364
365
366
367
# File 'lib/better_auth/cookies.rb', line 362

def cookie_cache_version(config, session, user)
  return "1" unless config
  return config.to_s unless config.respond_to?(:call)

  config.call(session, user).to_s
end


29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/better_auth/cookies.rb', line 29

def create_cookie(options, cookie_name, override_attributes = {})
  advanced = options.advanced || {}
  secure = if advanced.key?(:use_secure_cookies)
    advanced[:use_secure_cookies]
  elsif options.base_url.to_s.start_with?("https://")
    true
  else
    production_environment?
  end
  cross_subdomain = advanced.dig(:cross_subdomain_cookies, :enabled)
  domain = if cross_subdomain
    advanced.dig(:cross_subdomain_cookies, :domain) || begin
      uri = URI.parse(options.base_url.to_s)
      uri.host unless uri.host.to_s.empty?
    end
  end
  custom = advanced.dig(:cookies, cookie_name.to_sym) || {}
  prefix = advanced[:cookie_prefix] || "better-auth"
  name = custom[:name] || "#{prefix}.#{cookie_name}"
  attributes = {
    secure: !!secure,
    same_site: "lax",
    path: "/",
    http_only: true
  }
  attributes[:domain] = domain if domain
  attributes = attributes
    .merge(advanced[:default_cookie_attributes] || {})
    .merge(override_attributes || {})
    .merge(custom[:attributes] || {})
    .compact

  cookie_prefix = secure ? SECURE_COOKIE_PREFIX : ""
  Cookie.new(name: "#{cookie_prefix}#{name}", attributes: attributes)
end

.current_millisObject



369
370
371
# File 'lib/better_auth/cookies.rb', line 369

def current_millis
  (Time.now.to_f * 1000).to_i
end


262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/better_auth/cookies.rb', line 262

def decode_cookie_cache(value, secret, strategy:)
  case strategy.to_s
  when "jwt"
    Crypto.verify_jwt(value, secret)
  when "jwe"
    Crypto.symmetric_decode_jwt(value, secret, "better-auth-session")
  else
    payload = JSON.parse(Crypto.base64url_decode(value))
    return nil if payload["expiresAt"].to_i <= current_millis

    signed = payload.fetch("session").merge("expiresAt" => payload.fetch("expiresAt"))
    valid = Crypto.verify_hmac_signature(JSON.generate(signed), payload["signature"], secret, encoding: :base64url)
    valid ? payload["session"] : nil
  end
rescue JSON::ParserError, KeyError, ArgumentError, JWT::DecodeError
  nil
end


328
329
330
331
332
333
# File 'lib/better_auth/cookies.rb', line 328

def decode_cookie_value(value)
  decoded = URI.decode_uri_component(value)
  decoded.valid_encoding? ? decoded : value
rescue ArgumentError
  value
end


228
229
230
231
232
233
234
235
236
237
238
239
240
241
# File 'lib/better_auth/cookies.rb', line 228

def delete_session_cookie(ctx, skip_dont_remember_me: false)
  expire_cookie(ctx, ctx.context.auth_cookies[:session_token])
  expire_cookie(ctx, ctx.context.auth_cookies[:session_data])
  expire_cookie(ctx, ctx.context.auth_cookies[:account_data]) if ctx.context.options.[:store_account_cookie]

  if ctx.context.options.[:store_account_cookie]
     = SessionStore.new(ctx.context.auth_cookies[:account_data].name, ctx.context.auth_cookies[:account_data].attributes, ctx)
    .set_cookies(.clean)
  end

  store = SessionStore.new(ctx.context.auth_cookies[:session_data].name, ctx.context.auth_cookies[:session_data].attributes, ctx)
  store.set_cookies(store.clean)
  expire_cookie(ctx, ctx.context.auth_cookies[:dont_remember]) unless skip_dont_remember_me
end

.dont_remember?(ctx) ⇒ Boolean

Returns:

  • (Boolean)


243
244
245
246
# File 'lib/better_auth/cookies.rb', line 243

def dont_remember?(ctx)
  cookie = ctx.context.auth_cookies[:dont_remember]
  ctx.get_signed_cookie(cookie.name, ctx.context.secret) == "true"
end

.embedded_session_expired?(session) ⇒ Boolean

Returns:

  • (Boolean)


304
305
306
307
308
309
310
# File 'lib/better_auth/cookies.rb', line 304

def embedded_session_expired?(session)
  expires_at = session["expiresAt"] || session[:expiresAt] || session["expires_at"] || session[:expires_at]
  normalized = Session.normalize_time(expires_at)
  normalized && normalized <= Time.now
rescue ArgumentError
  true
end


248
249
250
251
252
253
254
255
256
257
258
259
260
# File 'lib/better_auth/cookies.rb', line 248

def encode_cookie_cache(data, secret, strategy:, max_age:)
  case strategy.to_s
  when "jwt"
    Crypto.sign_jwt(data, secret, expires_in: max_age)
  when "jwe"
    Crypto.symmetric_encode_jwt(data, secret, "better-auth-session", expires_in: max_age)
  else
    expires_at = current_millis + (max_age.to_i * 1000)
    signed = data.merge("expiresAt" => expires_at)
    signature = Crypto.hmac_signature(JSON.generate(signed), secret, encoding: :base64url)
    Crypto.base64url_encode(JSON.generate({"session" => data, "expiresAt" => expires_at, "signature" => signature}))
  end
end


345
346
347
348
349
350
351
352
353
# File 'lib/better_auth/cookies.rb', line 345

def encode_cookie_value(value)
  value.to_s.encode(Encoding::UTF_8).bytes.map do |byte|
    if byte.between?(48, 57) || byte.between?(65, 90) || byte.between?(97, 122) || [33, 39, 40, 41, 42, 45, 46, 95, 126].include?(byte)
      byte.chr
    else
      "%#{byte.to_s(16).upcase.rjust(2, "0")}"
    end
  end.join
end


223
224
225
226
# File 'lib/better_auth/cookies.rb', line 223

def expire_cookie(ctx, cookie)
  remove_set_cookie_entries(ctx, cookie.name)
  ctx.set_cookie(cookie.name, "", cookie.attributes.merge(max_age: 0))
end

.filtered_cache_data(ctx, session) ⇒ Object



280
281
282
283
284
285
286
287
288
289
290
291
# File 'lib/better_auth/cookies.rb', line 280

def filtered_cache_data(ctx, session)
  {
    "session" => stringify_keys(Schema.parse_output(ctx.context.options, "session", stringify_keys(session.fetch(:session)))),
    "user" => stringify_keys(Schema.parse_output(ctx.context.options, "user", stringify_keys(session.fetch(:user)))),
    "updatedAt" => current_millis,
    "version" => cookie_cache_version(
      ctx.context.session_config.dig(:cookie_cache, :version),
      session.fetch(:session),
      session.fetch(:user)
    )
  }
end


189
190
191
192
193
194
195
# File 'lib/better_auth/cookies.rb', line 189

def (ctx)
  cookie = ctx.context.auth_cookies[:account_data]
  value = SessionStore.get_chunked_cookie(ctx, cookie.name)
  return nil unless value

  Crypto.symmetric_decode_jwt(value, ctx.context.secret_config, "better-auth-account")
end


197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
# File 'lib/better_auth/cookies.rb', line 197

def get_cookie_cache(request_or_cookie_header, secret:, strategy: "compact", version: nil, cookie_prefix: "better-auth", cookie_name: "session_data", is_secure: nil, cookie_full_name: nil)
  cookie_header = header_value(request_or_cookie_header)
  return nil if cookie_header.to_s.empty?

  parsed = parse_cookies(cookie_header)
  name = if cookie_full_name
    cookie_full_name
  elsif is_secure.nil?
    production_environment? ? "#{SECURE_COOKIE_PREFIX}#{cookie_prefix}.#{cookie_name}" : "#{cookie_prefix}.#{cookie_name}"
  else
    secure_prefix = is_secure ? SECURE_COOKIE_PREFIX : ""
    "#{secure_prefix}#{cookie_prefix}.#{cookie_name}"
  end
  raw = parsed[name] || chunked_value(parsed, name)
  return nil unless raw

  payload = decode_cookie_cache(raw, secret, strategy: strategy)
  return nil unless payload && payload["session"] && payload["user"]
  return nil if embedded_session_expired?(payload["session"])

  expected_version = cookie_cache_version(version, payload["session"], payload["user"])
  return nil if version && (payload["version"] || "1") != expected_version

  payload
end

.get_cookies(options) ⇒ Object



20
21
22
23
24
25
26
27
# File 'lib/better_auth/cookies.rb', line 20

def get_cookies(options)
  {
    session_token: create_cookie(options, "session_token", max_age: options.session[:expires_in] || 60 * 60 * 24 * 7),
    session_data: create_cookie(options, "session_data", max_age: options.session.dig(:cookie_cache, :max_age) || 60 * 5),
    account_data: create_cookie(options, "account_data", max_age: options.session.dig(:cookie_cache, :max_age) || 60 * 5),
    dont_remember: create_cookie(options, "dont_remember")
  }
end


115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/better_auth/cookies.rb', line 115

def get_session_cookie(request_or_cookie_header, config = {})
  cookie_header = header_value(request_or_cookie_header)
  return nil if cookie_header.to_s.empty?

  parsed = parse_cookies(cookie_header)
  cookie_name = config[:cookie_name] || "session_token"
  cookie_prefix = config[:cookie_prefix] || "better-auth"
  candidates = [
    "#{SECURE_COOKIE_PREFIX}#{cookie_prefix}.#{cookie_name}",
    "#{cookie_prefix}.#{cookie_name}",
    "#{SECURE_COOKIE_PREFIX}#{cookie_prefix}-#{cookie_name}",
    "#{cookie_prefix}-#{cookie_name}"
  ]
  candidates.lazy.filter_map { |candidate| parsed[candidate] }.first
end

.header_value(request_or_cookie_header) ⇒ Object



355
356
357
358
359
360
# File 'lib/better_auth/cookies.rb', line 355

def header_value(request_or_cookie_header)
  return request_or_cookie_header.headers["cookie"] if request_or_cookie_header.respond_to?(:headers)
  return request_or_cookie_header.get_header("HTTP_COOKIE") if request_or_cookie_header.respond_to?(:get_header)

  request_or_cookie_header.to_s
end

.parse_cookies(cookie_header) ⇒ Object



65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/better_auth/cookies.rb', line 65

def parse_cookies(cookie_header)
  header = cookie_header.to_s
  return {} if header.length < 2

  header.split(";", -1).each_with_object({}) do |pair, result|
    separator = pair.index("=")
    next unless separator

    name = trim_cookie_ows(pair[0...separator])
    value = unquote_cookie_value(trim_cookie_ows(pair[(separator + 1)..]))
    next unless COOKIE_NAME_PATTERN.match?(name) && COOKIE_VALUE_PATTERN.match?(value)

    result[name] = decode_cookie_value(value)
  end
end


89
90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/better_auth/cookies.rb', line 89

def parse_set_cookie(line)
  first, *attributes = line.to_s.split(";").map(&:strip)
  name, value = first.split("=", 2)
  return unless name && value

  {
    name: name,
    value: value,
    attributes: attributes.each_with_object({}) do |attribute, result|
      key, attribute_value = attribute.split("=", 2)
      result[key.to_s.downcase] = attribute_value || true unless key.to_s.empty?
    end
  }
end

.production_environment?Boolean

Returns:

  • (Boolean)


380
381
382
# File 'lib/better_auth/cookies.rb', line 380

def production_environment?
  ENV["RACK_ENV"] == "production" || ENV["RAILS_ENV"] == "production" || ENV["APP_ENV"] == "production"
end


312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
# File 'lib/better_auth/cookies.rb', line 312

def remove_set_cookie_entries(ctx, cookie_name)
  header = ctx.response_headers["set-cookie"]
  return unless header

  exact = "#{cookie_name}="
  chunk = "#{cookie_name}."
  survivors = header.to_s.lines(chomp: true).reject do |entry|
    entry.start_with?(exact, chunk)
  end
  if survivors.empty?
    ctx.response_headers.delete("set-cookie")
  else
    ctx.response_headers["set-cookie"] = survivors.join("\n")
  end
end


173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/better_auth/cookies.rb', line 173

def (ctx, )
  return unless ctx.context.options.[:store_account_cookie]

  cookie = ctx.context.auth_cookies[:account_data]
  attributes = cookie.attributes.merge(max_age: cookie.attributes[:max_age] || 60 * 5)
  value = Crypto.symmetric_encode_jwt(stringify_keys(), ctx.context.secret_config, "better-auth-account", expires_in: attributes[:max_age])
  store = SessionStore.new(cookie.name, attributes, ctx)

  if value.length > SessionStore::CHUNK_SIZE
    store.set_cookies(store.chunk(value, attributes))
  else
    store.set_cookies(store.clean) if store.chunks?
    ctx.set_cookie(cookie.name, value, attributes)
  end
end


145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/better_auth/cookies.rb', line 145

def set_cookie_cache(ctx, session, dont_remember_me, max_age: nil)
  config = ctx.context.session_config[:cookie_cache] || {}
  return unless config[:enabled]

  cookie = ctx.context.auth_cookies[:session_data]
  configured_max_age = cookie.attributes[:max_age]
  max_age = if dont_remember_me
    nil
  elsif max_age
    [configured_max_age, max_age].compact.map(&:to_i).min
  else
    configured_max_age
  end
  data = filtered_cache_data(ctx, session)
  strategy = config[:strategy] || "compact"
  secret = (strategy.to_s == "jwe") ? ctx.context.secret_config : ctx.context.secret
  value = encode_cookie_cache(data, secret, strategy: strategy, max_age: max_age || 60 * 5)
  attributes = cookie.attributes.merge(max_age: max_age)
  store = SessionStore.new(cookie.name, attributes, ctx)

  if value.length > SessionStore::CHUNK_SIZE
    store.set_cookies(store.chunk(value, attributes))
  else
    store.set_cookies(store.clean) if store.chunks?
    ctx.set_cookie(cookie.name, value, attributes)
  end
end


104
105
106
107
108
109
# File 'lib/better_auth/cookies.rb', line 104

def set_request_cookie(cookie_header, name, value)
  cookies = parse_cookies(cookie_header)
  cookie_name = name.to_s
  cookies[cookie_name] = value.to_s if COOKIE_NAME_PATTERN.match?(cookie_name)
  cookies.map { |key, semantic_value| "#{key}=#{encode_cookie_value(semantic_value)}" }.join("; ")
end


131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/better_auth/cookies.rb', line 131

def set_session_cookie(ctx, session, dont_remember_me = false, overrides = {})
  token_cookie = ctx.context.auth_cookies[:session_token]
  max_age = dont_remember_me ? nil : ctx.context.session_config[:expires_in]
  ctx.set_signed_cookie(token_cookie.name, session.fetch(:session).fetch("token"), ctx.context.secret, token_cookie.attributes.merge(max_age: max_age).merge(overrides || {}))

  if dont_remember_me
    dont_remember_cookie = ctx.context.auth_cookies[:dont_remember]
    ctx.set_signed_cookie(dont_remember_cookie.name, "true", ctx.context.secret, dont_remember_cookie.attributes)
  end

  set_cookie_cache(ctx, session, dont_remember_me, max_age: overrides&.fetch(:max_age, nil))
  ctx.context.set_new_session(session) if ctx.context.respond_to?(:set_new_session)
end


81
82
83
84
85
86
87
# File 'lib/better_auth/cookies.rb', line 81

def split_set_cookie_header(header)
  Array(header).flat_map do |value|
    value.to_s.lines(chomp: true).flat_map do |line|
      line.split(/,(?=[ \t]*[!#$%&'*+\-.^_`|~0-9A-Za-z]+=)/).map(&:strip)
    end
  end.reject(&:empty?)
end

.stringify_keys(value) ⇒ Object



373
374
375
376
377
378
# File 'lib/better_auth/cookies.rb', line 373

def stringify_keys(value)
  return value.each_with_object({}) { |(key, object_value), result| result[key.to_s] = stringify_keys(object_value) } if value.is_a?(Hash)
  return value.map { |entry| stringify_keys(entry) } if value.is_a?(Array)

  value
end


111
112
113
# File 'lib/better_auth/cookies.rb', line 111

def strip_secure_cookie_prefix(name)
  name.to_s.delete_prefix(SECURE_COOKIE_PREFIX).delete_prefix(HOST_COOKIE_PREFIX)
end


335
336
337
# File 'lib/better_auth/cookies.rb', line 335

def trim_cookie_ows(value)
  value.sub(/\A[ \t]*/, "").sub(/[ \t]*\z/, "")
end


339
340
341
342
343
# File 'lib/better_auth/cookies.rb', line 339

def unquote_cookie_value(value)
  return value unless value.length >= 2 && value.start_with?("\"") && value.end_with?("\"")

  value[1...-1]
end