Module: BetterAuth::APIKey::Adapter

Defined in:
lib/better_auth/api_key/adapter.rb

Constant Summary collapse

HASH_STORAGE_PREFIX =
"api-key:"
ID_STORAGE_PREFIX =
"api-key:by-id:"
REFERENCE_STORAGE_PREFIX =
"api-key:by-ref:"
REFERENCE_LOCKS_GUARD =
Mutex.new
REFERENCE_LOCK_STRIPE_COUNT =
256

Class Method Summary collapse

Class Method Details

.batch(storage_instance, &block) ⇒ Object



255
256
257
258
259
260
261
# File 'lib/better_auth/api_key/adapter.rb', line 255

def batch(storage_instance, &block)
  if storage_instance.respond_to?(:batch)
    storage_instance.batch(&block)
  else
    block.call
  end
end

.batch_migrate_legacy_metadata(ctx, records, config) ⇒ Object



282
283
284
285
286
# File 'lib/better_auth/api_key/adapter.rb', line 282

def (ctx, records, config)
  return records unless config[:storage] == "database" || config[:fallback_to_database]

  records.map { |record| (ctx, record, config) }
end

.delete(ctx, record, config) ⇒ Object



177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'lib/better_auth/api_key/adapter.rb', line 177

def delete(ctx, record, config)
  storage_instance = storage(config, ctx.context)
  return unless storage_instance

  reference_id = BetterAuth::Plugins.api_key_record_reference_id(record)
  reference_key = storage_key_by_reference(reference_id)

  batch(storage_instance) do
    operations = [
      -> { storage_instance.delete(storage_key_by_hash(record["key"])) },
      -> { storage_instance.delete(storage_key_by_id(record["id"])) },
      # Ruby-only legacy storage layout cleanup; upstream never wrote here.
      -> { storage_instance.delete("api-key:key:#{record["key"]}") },
      -> { storage_instance.delete("api-key:id:#{record["id"]}") }
    ]
    operations << if config[:fallback_to_database]
      -> { storage_instance.delete(reference_key) }
    else
      -> { ref_list_remove(storage_instance, reference_key, record["id"]) }
    end
    operations.each(&:call)
  end
end

.delete_record(ctx, record, config) ⇒ Object



106
107
108
109
# File 'lib/better_auth/api_key/adapter.rb', line 106

def delete_record(ctx, record, config)
  ctx.context.adapter.delete(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: [{field: "id", value: record["id"]}]) if config[:storage] == "database" || config[:fallback_to_database]
  delete(ctx, record, config) if config[:storage] == "secondary-storage"
end

.deserialize_record(record) ⇒ Object



292
293
294
295
296
297
# File 'lib/better_auth/api_key/adapter.rb', line 292

def deserialize_record(record)
  %w[createdAt updatedAt expiresAt lastRefillAt lastRequest].each do |field|
    record[field] = BetterAuth::APIKey::Utils.normalize_time(record[field]) if record[field]
  end
  record
end

.find_by_hash(ctx, hashed, config) ⇒ Object



40
41
42
43
44
45
46
47
48
49
# File 'lib/better_auth/api_key/adapter.rb', line 40

def find_by_hash(ctx, hashed, config)
  if config[:storage] == "secondary-storage"
    record = get(ctx, storage_key_by_hash(hashed), config) || get(ctx, "api-key:key:#{hashed}", config)
    return record if record
    return nil unless config[:fallback_to_database]
  end
  record = ctx.context.adapter.find_one(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: [{field: "key", value: hashed}])
  set(ctx, record, config) if record && config[:storage] == "secondary-storage" && config[:fallback_to_database]
  record
end

.find_by_id(ctx, id, config) ⇒ Object



51
52
53
54
55
56
57
58
59
60
# File 'lib/better_auth/api_key/adapter.rb', line 51

def find_by_id(ctx, id, config)
  if config[:storage] == "secondary-storage"
    record = get(ctx, storage_key_by_id(id), config) || get(ctx, "api-key:id:#{id}", config)
    return record if record
    return nil unless config[:fallback_to_database]
  end
  record = ctx.context.adapter.find_one(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: [{field: "id", value: id}])
  set(ctx, record, config) if record && config[:storage] == "secondary-storage" && config[:fallback_to_database]
  record
end

.get(ctx, key, config) ⇒ Object



144
145
146
147
148
149
# File 'lib/better_auth/api_key/adapter.rb', line 144

def get(ctx, key, config)
  raw = storage(config, ctx.context)&.get(key)
  raw && deserialize_record(JSON.parse(raw))
rescue JSON::ParserError
  nil
end

.legacy_metadata_migration_needed?(record) ⇒ Boolean

Returns:

  • (Boolean)


135
136
137
138
# File 'lib/better_auth/api_key/adapter.rb', line 135

def (record)
  parsed = BetterAuth::APIKey::Utils.decode_json(record["metadata"])
  parsed.is_a?(Hash) && BetterAuth::APIKey::Utils.encode_json(parsed) != record["metadata"]
end

.list_for_reference(ctx, reference_id, config) ⇒ Object



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/better_auth/api_key/adapter.rb', line 62

def list_for_reference(ctx, reference_id, config)
  if config[:storage] == "secondary-storage"
    begin
      storage_instance = storage(config, ctx.context)
      raw_ids = storage_instance&.get(storage_key_by_reference(reference_id)) || storage_instance&.get("api-key:user:#{reference_id}")
      ids = parse_id_list!(raw_ids)
      records = ids.filter_map { |id| find_by_id(ctx, id, config) }
      return records unless records.empty? && config[:fallback_to_database]
    rescue JSON::ParserError, NoMethodError => error
      if ctx.context.respond_to?(:logger) && ctx.context.logger.respond_to?(:warn)
        ctx.context.logger.warn("[API KEY PLUGIN] Corrupt api-key reference index for #{reference_id.inspect}: #{error.class}: #{error.message}")
      end
      return [] unless config[:fallback_to_database]
    end
  end
  records = ctx.context.adapter.find_many(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: [{field: "referenceId", value: reference_id}])
  legacy = ctx.context.adapter.find_many(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: [{field: "userId", value: reference_id}])
  combined = (records + legacy).uniq { |record| record["id"] }
  populate_reference(ctx, reference_id, combined, config) if config[:storage] == "secondary-storage" && config[:fallback_to_database]
  combined
end

.migrate_legacy_metadata(ctx, record, config) ⇒ Object



120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/better_auth/api_key/adapter.rb', line 120

def (ctx, record, config)
  parsed = BetterAuth::APIKey::Utils.decode_json(record["metadata"])
  return record unless parsed.is_a?(Hash)

  encoded = BetterAuth::APIKey::Utils.encode_json(parsed)
  return record.merge("metadata" => encoded) if record["metadata"] == encoded

  updated = record.merge("metadata" => encoded)
  if config[:storage] == "database" || config[:fallback_to_database]
    ctx.context.adapter.update(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: [{field: "id", value: record["id"]}], update: {metadata: encoded})
  end
  set(ctx, updated, config) if config[:storage] == "secondary-storage"
  updated
end

.parse_id_list!(raw) ⇒ Object



247
248
249
250
251
252
253
# File 'lib/better_auth/api_key/adapter.rb', line 247

def parse_id_list!(raw)
  return [] if raw.nil?
  return raw.dup if raw.is_a?(Array)

  parsed = JSON.parse(raw.to_s)
  parsed.is_a?(Array) ? parsed : []
end

.populate_reference(ctx, reference_id, records, config) ⇒ Object



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

def populate_reference(ctx, reference_id, records, config)
  storage_instance = storage(config, ctx.context)
  return unless storage_instance

  batch(storage_instance) do
    ids = []
    records.each do |record|
      serialized = JSON.generate(storage_record(record))
      expires_at = BetterAuth::APIKey::Utils.normalize_time(record["expiresAt"])
      ttl = expires_at ? [(expires_at - Time.now).to_i, 0].max : nil
      storage_instance.set(storage_key_by_hash(record["key"]), serialized, ttl)
      storage_instance.set(storage_key_by_id(record["id"]), serialized, ttl)
      ids << record["id"]
    end
    reference_key = storage_key_by_reference(reference_id)
    ids.empty? ? storage_instance.delete(reference_key) : storage_instance.set(reference_key, JSON.generate(ids))
  end
end

.ref_list_add(storage_instance, reference_key, id) ⇒ Object



201
202
203
204
205
206
207
208
209
210
211
212
# File 'lib/better_auth/api_key/adapter.rb', line 201

def ref_list_add(storage_instance, reference_key, id)
  if storage_instance.respond_to?(:json_list_add)
    storage_instance.json_list_add(reference_key, id)
    return
  end

  with_reference_lock(reference_key) do
    ids = safe_parse_id_list(storage_instance.get(reference_key))
    ids << id unless ids.include?(id)
    storage_instance.set(reference_key, JSON.generate(ids))
  end
end

.ref_list_remove(storage_instance, reference_key, id) ⇒ Object



214
215
216
217
218
219
220
221
222
223
224
# File 'lib/better_auth/api_key/adapter.rb', line 214

def ref_list_remove(storage_instance, reference_key, id)
  if storage_instance.respond_to?(:json_list_remove)
    storage_instance.json_list_remove(reference_key, id)
    return
  end

  with_reference_lock(reference_key) do
    ids = safe_parse_id_list(storage_instance.get(reference_key)).reject { |existing| existing == id }
    ids.empty? ? storage_instance.delete(reference_key) : storage_instance.set(reference_key, JSON.generate(ids))
  end
end

.safe_parse_id_list(raw) ⇒ Object



238
239
240
241
242
243
244
245
# File 'lib/better_auth/api_key/adapter.rb', line 238

def safe_parse_id_list(raw)
  return [] if raw.nil?
  return raw.dup if raw.is_a?(Array)

  parse_id_list!(raw)
rescue JSON::ParserError
  []
end

.schedule_record_delete(ctx, record, config) ⇒ Object



111
112
113
114
115
116
117
118
# File 'lib/better_auth/api_key/adapter.rb', line 111

def schedule_record_delete(ctx, record, config)
  task = -> { delete_record(ctx, record, config) }
  if config[:defer_updates] && BetterAuth::APIKey::Utils.background_tasks?(ctx)
    BetterAuth::APIKey::Utils.run_background_task(ctx, "Deferred API key delete", task)
  else
    task.call
  end
end

.set(ctx, record, config) ⇒ Object



151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/better_auth/api_key/adapter.rb', line 151

def set(ctx, record, config)
  storage_instance = storage(config, ctx.context)
  unless storage_instance
    raise BetterAuth::APIError.new("INTERNAL_SERVER_ERROR", message: "Secondary storage is required when storage mode is 'secondary-storage'")
  end

  serialized = JSON.generate(storage_record(record))
  expires_at = BetterAuth::APIKey::Utils.normalize_time(record["expiresAt"])
  ttl = expires_at ? [(expires_at - Time.now).to_i, 0].max : nil
  reference_id = BetterAuth::Plugins.api_key_record_reference_id(record)
  reference_key = storage_key_by_reference(reference_id)

  batch(storage_instance) do
    operations = [
      -> { storage_instance.set(storage_key_by_hash(record["key"]), serialized, ttl) },
      -> { storage_instance.set(storage_key_by_id(record["id"]), serialized, ttl) }
    ]
    operations << if config[:fallback_to_database]
      -> { storage_instance.delete(reference_key) }
    else
      -> { ref_list_add(storage_instance, reference_key, record["id"]) }
    end
    operations.each(&:call)
  end
end

.storage(config, context = nil) ⇒ Object



140
141
142
# File 'lib/better_auth/api_key/adapter.rb', line 140

def storage(config, context = nil)
  config[:custom_storage] || context&.options&.secondary_storage
end

.storage_key_by_hash(hashed_key) ⇒ Object



18
19
20
# File 'lib/better_auth/api_key/adapter.rb', line 18

def storage_key_by_hash(hashed_key)
  "#{HASH_STORAGE_PREFIX}#{hashed_key}"
end

.storage_key_by_id(id) ⇒ Object



22
23
24
# File 'lib/better_auth/api_key/adapter.rb', line 22

def storage_key_by_id(id)
  "#{ID_STORAGE_PREFIX}#{id}"
end

.storage_key_by_reference(reference_id) ⇒ Object



26
27
28
# File 'lib/better_auth/api_key/adapter.rb', line 26

def storage_key_by_reference(reference_id)
  "#{REFERENCE_STORAGE_PREFIX}#{reference_id}"
end

.storage_record(record) ⇒ Object



288
289
290
# File 'lib/better_auth/api_key/adapter.rb', line 288

def storage_record(record)
  record.transform_values { |value| value.is_a?(Time) ? value.iso8601 : value }
end

.store(ctx, data, config) ⇒ Object



30
31
32
33
34
35
36
37
38
# File 'lib/better_auth/api_key/adapter.rb', line 30

def store(ctx, data, config)
  record = nil
  if config[:storage] == "database" || config[:fallback_to_database]
    record = ctx.context.adapter.create(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, data: data)
  end
  record ||= data.transform_keys { |key| BetterAuth::Schema.storage_key(key) }.merge("id" => SecureRandom.hex(16))
  set(ctx, record, config) if config[:storage] == "secondary-storage"
  record
end

.update_record(ctx, record, update, config, defer: false) ⇒ Object



84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/better_auth/api_key/adapter.rb', line 84

def update_record(ctx, record, update, config, defer: false)
  performer = lambda do
    updated = nil
    if config[:storage] == "database" || config[:fallback_to_database]
      updated = ctx.context.adapter.update(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: [{field: "id", value: record["id"]}], update: update)
      return nil unless updated
    else
      updated = record.merge(update.transform_keys { |key| BetterAuth::Schema.storage_key(key) })
    end
    set(ctx, updated, config) if config[:storage] == "secondary-storage"
    updated
  end

  if defer && config[:defer_updates] && BetterAuth::Plugins.api_key_background_tasks?(ctx)
    scheduled = record.merge(update.transform_keys { |key| BetterAuth::Schema.storage_key(key) })
    BetterAuth::APIKey::Utils.run_background_task(ctx, "Deferred API key update", performer)
    scheduled
  else
    performer.call
  end
end

.with_reference_lock(reference_key) ⇒ Object

Custom secondary-storage implementations expose only get/set/delete, so serialize reference-list read/modify/write operations within this Ruby process. This lock is deliberately not treated as distributed safety; RedisStorage uses its atomic JSON-list scripts above.



230
231
232
233
234
235
236
# File 'lib/better_auth/api_key/adapter.rb', line 230

def with_reference_lock(reference_key)
  lock = REFERENCE_LOCKS_GUARD.synchronize do
    @reference_lock_stripes ||= Array.new(REFERENCE_LOCK_STRIPE_COUNT) { Mutex.new }
    @reference_lock_stripes[reference_key.hash % REFERENCE_LOCK_STRIPE_COUNT]
  end
  lock.synchronize { yield }
end