better_auth-api-key 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fcc6f211dc2f80b2524e48f5fff897caf734ee3316ba5620379820571d2f0729
4
- data.tar.gz: 1f111ffa2b175c3ea04c356c1afbc18d361b9b02717c9dea3dd32954b4ac57dc
3
+ metadata.gz: 4685ca8ec56b74b3bfb4a83e24a1bc38c4366fa3419b0b32362fa719e1aee8b1
4
+ data.tar.gz: 86575631ac666a0edc77a5bb4eb693cf6754856fec26d92f6371351d2843349e
5
5
  SHA512:
6
- metadata.gz: fa1c89732eaeba2c8d7d064664d9a34e23287744c534dc19d45254837bd7481b0271b536364e64835b4f750e14d95c3f118bd7ddc0947f02077cbb8d35675034
7
- data.tar.gz: 8146aca0fd1ad983d0285eedcc707bcfff2336bac8b64c91141af93ca9e231d8d42b67899a405beef304785464ddedf8fdb06ad5e1dad7d74ac7bc754b5981f2
6
+ metadata.gz: 6437621c3f7b089604ef61588576b144a9ef996e07808e6ffacff8073900ecf007723c4e3221283ffdebbce7e841b208c56948d30ec950aabdcc61b06a226405
7
+ data.tar.gz: a57b0e0d4c36a030387420d07d5fd914f70777c9a61cac62e914bc24c1d7f1d060158cbde1df5d789de6fca748d927d7d0f9681ae11f7022f1228fe4fffe6156
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.7.0 - 2026-05-05
6
+
7
+ - Changed API-key-backed sessions to expose `tokenFingerprint` instead of storing the raw API key in `session["token"]`.
8
+ - Hardened API key listing and expired-key cleanup behavior.
9
+ - Improved API key metadata handling and added regression coverage for session fingerprint behavior.
10
+
3
11
  ## 0.2.1 - 2026-04-30
4
12
 
5
13
  - Fixed API key metadata normalization so symbol and string metadata keys preserve nested metadata payloads.
data/README.md CHANGED
@@ -44,6 +44,31 @@ export const authClient = createAuthClient({
44
44
 
45
45
  Ruby does not expose a separate `apiKeyClient()` equivalent; the public Ruby surface is the server plugin and route contract.
46
46
 
47
+ | Method | Path | Ruby API method |
48
+ | --- | --- | --- |
49
+ | `POST` | `/api-key/create` | `auth.api.create_api_key` |
50
+ | `POST` | `/api-key/verify` | `auth.api.verify_api_key` |
51
+ | `GET` | `/api-key/get` | `auth.api.get_api_key` |
52
+ | `GET` | `/api-key/list` | `auth.api.list_api_keys` |
53
+ | `POST` | `/api-key/update` | `auth.api.update_api_key` |
54
+ | `POST` | `/api-key/delete` | `auth.api.delete_api_key` |
55
+ | `POST` | `/api-key/delete-all-expired-api-keys` | `auth.api.delete_all_expired_api_keys` |
56
+
57
+ ## Operational notes
58
+
59
+ Expired API key cleanup runs against the database when `storage` is `"database"`
60
+ or `fallback_to_database` is true. Secondary-storage-only deployments should
61
+ align Redis or KV TTLs with API key expiration because database cleanup does not
62
+ purge secondary-only keys.
63
+
64
+ The scheduled expired-key cleanup throttle is per Ruby process. It is not
65
+ coordinated across web workers, hosts, or background job runners.
66
+
67
+ When `defer_updates` is combined with `advanced.background_tasks.handler`, usage
68
+ updates such as request counts, remaining limits, refill state, and scheduled
69
+ cleanup can be reordered under concurrency. Use database transactions or
70
+ deployment-level coordination if strict counters are required.
71
+
47
72
  ## Configuration
48
73
 
49
74
  ```ruby
@@ -165,6 +190,9 @@ Endpoint requests/responses always use the upstream `camelCase` field names, so
165
190
  TypeScript clients targeting `@better-auth/api-key/client` interoperate without
166
191
  configuration changes.
167
192
 
193
+ The cleanup route is also exposed through `auth.api.delete_all_expired_api_keys`
194
+ and returns `{success: true, error: nil}` on success.
195
+
168
196
  ## Organization-owned API keys
169
197
 
170
198
  Setting `references: "organization"` on a configuration delegates ownership to
@@ -61,10 +61,14 @@ module BetterAuth
61
61
  if config[:storage] == "secondary-storage"
62
62
  begin
63
63
  storage_instance = storage(config, ctx.context)
64
- ids = JSON.parse((storage_instance&.get(storage_key_by_reference(reference_id)) || storage_instance&.get("api-key:user:#{reference_id}")).to_s)
64
+ raw_ids = storage_instance&.get(storage_key_by_reference(reference_id)) || storage_instance&.get("api-key:user:#{reference_id}")
65
+ ids = parse_id_list!(raw_ids)
65
66
  records = ids.filter_map { |id| find_by_id(ctx, id, config) }
66
67
  return records unless records.empty? && config[:fallback_to_database]
67
- rescue JSON::ParserError, NoMethodError
68
+ rescue JSON::ParserError, NoMethodError => error
69
+ if ctx.context.respond_to?(:logger) && ctx.context.logger.respond_to?(:warn)
70
+ ctx.context.logger.warn("[API KEY PLUGIN] Corrupt api-key reference index for #{reference_id.inspect}: #{error.class}: #{error.message}")
71
+ end
68
72
  return [] unless config[:fallback_to_database]
69
73
  end
70
74
  end
@@ -88,7 +92,7 @@ module BetterAuth
88
92
 
89
93
  if defer && config[:defer_updates] && BetterAuth::Plugins.api_key_background_tasks?(ctx)
90
94
  scheduled = record.merge(update.transform_keys { |key| BetterAuth::Schema.storage_key(key) })
91
- ctx.context.run_in_background(performer)
95
+ BetterAuth::APIKey::Utils.run_background_task(ctx, "Deferred API key update", performer)
92
96
  scheduled
93
97
  else
94
98
  performer.call
@@ -103,7 +107,7 @@ module BetterAuth
103
107
  def schedule_record_delete(ctx, record, config)
104
108
  task = -> { delete_record(ctx, record, config) }
105
109
  if config[:defer_updates] && BetterAuth::APIKey::Utils.background_tasks?(ctx)
106
- ctx.context.run_in_background(task)
110
+ BetterAuth::APIKey::Utils.run_background_task(ctx, "Deferred API key delete", task)
107
111
  else
108
112
  task.call
109
113
  end
@@ -124,6 +128,11 @@ module BetterAuth
124
128
  updated
125
129
  end
126
130
 
131
+ def legacy_metadata_migration_needed?(record)
132
+ parsed = BetterAuth::APIKey::Utils.decode_json(record["metadata"])
133
+ parsed.is_a?(Hash) && BetterAuth::APIKey::Utils.encode_json(parsed) != record["metadata"]
134
+ end
135
+
127
136
  def storage(config, context = nil)
128
137
  config[:custom_storage] || context&.options&.secondary_storage
129
138
  end
@@ -198,13 +207,21 @@ module BetterAuth
198
207
 
199
208
  def safe_parse_id_list(raw)
200
209
  return [] if raw.nil?
210
+ return raw.dup if raw.is_a?(Array)
201
211
 
202
- parsed = JSON.parse(raw.to_s)
203
- parsed.is_a?(Array) ? parsed : []
212
+ parse_id_list!(raw)
204
213
  rescue JSON::ParserError
205
214
  []
206
215
  end
207
216
 
217
+ def parse_id_list!(raw)
218
+ return [] if raw.nil?
219
+ return raw.dup if raw.is_a?(Array)
220
+
221
+ parsed = JSON.parse(raw.to_s)
222
+ parsed.is_a?(Array) ? parsed : []
223
+ end
224
+
208
225
  def batch(storage_instance, &block)
209
226
  if storage_instance.respond_to?(:batch)
210
227
  storage_instance.batch(&block)
@@ -217,17 +234,25 @@ module BetterAuth
217
234
  storage_instance = storage(config, ctx.context)
218
235
  return unless storage_instance
219
236
 
220
- ids = []
221
- records.each do |record|
222
- serialized = JSON.generate(storage_record(record))
223
- expires_at = BetterAuth::APIKey::Utils.normalize_time(record["expiresAt"])
224
- ttl = expires_at ? [(expires_at - Time.now).to_i, 0].max : nil
225
- storage_instance.set(storage_key_by_hash(record["key"]), serialized, ttl)
226
- storage_instance.set(storage_key_by_id(record["id"]), serialized, ttl)
227
- ids << record["id"]
237
+ batch(storage_instance) do
238
+ ids = []
239
+ records.each do |record|
240
+ serialized = JSON.generate(storage_record(record))
241
+ expires_at = BetterAuth::APIKey::Utils.normalize_time(record["expiresAt"])
242
+ ttl = expires_at ? [(expires_at - Time.now).to_i, 0].max : nil
243
+ storage_instance.set(storage_key_by_hash(record["key"]), serialized, ttl)
244
+ storage_instance.set(storage_key_by_id(record["id"]), serialized, ttl)
245
+ ids << record["id"]
246
+ end
247
+ reference_key = storage_key_by_reference(reference_id)
248
+ ids.empty? ? storage_instance.delete(reference_key) : storage_instance.set(reference_key, JSON.generate(ids))
228
249
  end
229
- reference_key = storage_key_by_reference(reference_id)
230
- ids.empty? ? storage_instance.delete(reference_key) : storage_instance.set(reference_key, JSON.generate(ids))
250
+ end
251
+
252
+ def batch_migrate_legacy_metadata(ctx, records, config)
253
+ return records unless config[:storage] == "database" || config[:fallback_to_database]
254
+
255
+ records.map { |record| migrate_legacy_metadata(ctx, record, config) }
231
256
  end
232
257
 
233
258
  def storage_record(record)
@@ -14,7 +14,7 @@ module BetterAuth
14
14
  ctx.json({success: true, error: nil})
15
15
  rescue => error
16
16
  ctx.context.logger.error("[API KEY PLUGIN] Failed to delete expired API keys: #{error.message}") if ctx.context.logger.respond_to?(:error)
17
- ctx.json({success: false, error: error})
17
+ ctx.json({success: false, error: {message: error.message.to_s, name: error.class.name}})
18
18
  end
19
19
  end
20
20
  end
@@ -51,18 +51,20 @@ module BetterAuth
51
51
  @last_expired_check = now
52
52
  end
53
53
 
54
- expired = context.adapter.find_many(model: BetterAuth::Plugins::API_KEY_TABLE_NAME).select do |record|
55
- record["expiresAt"] && record["expiresAt"] < Time.now
56
- end
57
- expired.each do |record|
58
- context.adapter.delete(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: [{field: "id", value: record["id"]}])
59
- end
54
+ now = Time.now
55
+ context.adapter.delete_many(
56
+ model: BetterAuth::Plugins::API_KEY_TABLE_NAME,
57
+ where: [
58
+ {field: "expiresAt", value: now, operator: "lt"},
59
+ {field: "expiresAt", value: nil, operator: "ne"}
60
+ ]
61
+ )
60
62
  end
61
63
 
62
64
  def schedule_cleanup(ctx, config)
63
65
  task = -> { delete_expired(ctx.context, config) }
64
66
  if config[:defer_updates] && BetterAuth::APIKey::Utils.background_tasks?(ctx)
65
- ctx.context.run_in_background(task)
67
+ BetterAuth::APIKey::Utils.run_background_task(ctx, "Deferred API key cleanup", task)
66
68
  else
67
69
  task.call
68
70
  end
@@ -31,9 +31,21 @@ module BetterAuth
31
31
  records = records.drop(offset) if offset
32
32
  records = records.first(limit) if limit
33
33
  records.each { |record| BetterAuth::Plugins.api_key_delete_expired(ctx.context, BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, BetterAuth::Plugins.api_key_record_config_id(record))) }
34
+ migration_records = records.select { |record| BetterAuth::APIKey::Adapter.legacy_metadata_migration_needed?(record) }
35
+ if migration_records.any?
36
+ BetterAuth::APIKey::Utils.run_background_task(
37
+ ctx,
38
+ "API key metadata migration",
39
+ lambda do
40
+ migration_records.each do |record|
41
+ record_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, BetterAuth::Plugins.api_key_record_config_id(record))
42
+ BetterAuth::APIKey::Adapter.batch_migrate_legacy_metadata(ctx, [record], record_config)
43
+ end
44
+ end
45
+ )
46
+ end
34
47
  api_keys = records.map do |record|
35
- record_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, BetterAuth::Plugins.api_key_record_config_id(record))
36
- BetterAuth::Plugins.api_key_public(BetterAuth::Plugins.api_key_migrate_legacy_metadata(ctx, record, record_config), include_key_field: false)
48
+ BetterAuth::Plugins.api_key_public(record, include_key_field: false)
37
49
  end
38
50
  ctx.json({apiKeys: api_keys, total: total, limit: limit, offset: offset})
39
51
  end
@@ -46,7 +46,7 @@ module BetterAuth
46
46
  user: user,
47
47
  session: {
48
48
  "id" => record["id"],
49
- "token" => key,
49
+ "tokenFingerprint" => BetterAuth::Plugins.default_api_key_hasher(key),
50
50
  "userId" => reference_id,
51
51
  "userAgent" => ctx.headers["user-agent"],
52
52
  "ipAddress" => BetterAuth::RequestIP.client_ip(ctx.request || ctx.headers, ctx.context.options),
@@ -85,6 +85,16 @@ module BetterAuth
85
85
  ctx.context.options.advanced.dig(:background_tasks, :handler).respond_to?(:call)
86
86
  end
87
87
 
88
+ def run_background_task(ctx, label, task)
89
+ wrapped = lambda do
90
+ task.call
91
+ rescue => error
92
+ logger = ctx.context.logger if ctx.context.respond_to?(:logger)
93
+ logger.error("[API KEY PLUGIN] #{label} failed: #{error.message}") if logger.respond_to?(:error)
94
+ end
95
+ ctx.context.run_in_background(wrapped)
96
+ end
97
+
88
98
  def auth_required?(ctx)
89
99
  !!(ctx.request || (ctx.headers && !ctx.headers.empty?))
90
100
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BetterAuth
4
4
  module APIKey
5
- VERSION = "0.6.2"
5
+ VERSION = "0.7.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: better_auth-api-key
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.2
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala