better_auth-api-key 0.8.0 → 0.10.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: 8dd667853c3c3130a0d0bcd0ebd00a5f15425de87979f2cdc00b2a2f5d9001fe
4
- data.tar.gz: f7222b9e0c3a61aabe67ec70ddefbc428f2d39e2181b53586e275ef3b9a63867
3
+ metadata.gz: '0644878a04c3b9f3489304b324a9636aed5411eefc8b8f51ec89f15a5b978cf1'
4
+ data.tar.gz: fc6aa4568b7831ec89a1fbab0abd5191b0d27bf5f5afc84615c038748dce9bd0
5
5
  SHA512:
6
- metadata.gz: a36b2eaee2a0128a50276eff4ccfbfa7604ea06364a041e7f039d6d10f7a3b17216ccb36fff7470e2918148a437ac0fce3801677ed3ca71dc843883c7d3e364d
7
- data.tar.gz: 3ecc422678b620d01b9c6848d60609246c3194e479d629d60ec21d92f8f7be330eade4a96e6971104da7bb62422e334e31d8069816a847df46ded0da005bc89e
6
+ metadata.gz: 2790364cbe835dd0c368aa9a035a57653370ab5d7f06c13fe1967ef04442a1cf3554a61056c920d80d0834ba9fd1c4963206ba46cb9b5f2bea77d46bcef59f42
7
+ data.tar.gz: 170f42d1487f231cb0302a63cd26e90d72f0560f0c0cc9ad5cbb05a7863d842c70517699432afa800c9070a6d1cbbf44e924dfca361972568a0dddae8759cfd8
data/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.10.0 - 2026-05-21
6
+
7
+ - Improved adapter coverage and Redis-backed storage behavior for API key flows.
8
+ - Tightened API key listing behavior so responses stay consistent across supported adapters.
9
+
5
10
  ## 0.7.0 - 2026-05-05
6
11
 
7
12
  - Changed API-key-backed sessions to expose `tokenFingerprint` instead of storing the raw API key in `session["token"]`.
@@ -84,8 +84,10 @@ module BetterAuth
84
84
  updated = nil
85
85
  if config[:storage] == "database" || config[:fallback_to_database]
86
86
  updated = ctx.context.adapter.update(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: [{field: "id", value: record["id"]}], update: update)
87
+ return nil unless updated
88
+ else
89
+ updated = record.merge(update.transform_keys { |key| BetterAuth::Schema.storage_key(key) })
87
90
  end
88
- updated ||= record.merge(update.transform_keys { |key| BetterAuth::Schema.storage_key(key) })
89
91
  set(ctx, updated, config) if config[:storage] == "secondary-storage"
90
92
  updated
91
93
  end
@@ -9,10 +9,13 @@ module BetterAuth
9
9
  module_function
10
10
 
11
11
  def endpoint(config)
12
- BetterAuth::Endpoint.new(path: "/api-key/create", method: "POST") do |ctx|
12
+ BetterAuth::Endpoint.new(path: "/api-key/create", method: "POST", metadata: Routes.openapi_for(:create_api_key)) do |ctx|
13
13
  body = BetterAuth::Plugins.api_key_normalize_body(ctx.body)
14
14
  resolved_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, body[:config_id])
15
15
  session = BetterAuth::Routes.current_session(ctx, allow_nil: true)
16
+ if !session && BetterAuth::Plugins.api_key_auth_required?(ctx)
17
+ raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["UNAUTHORIZED_SESSION"])
18
+ end
16
19
  reference_id = BetterAuth::Plugins.api_key_create_reference_id!(ctx, body, session, resolved_config)
17
20
 
18
21
  BetterAuth::Plugins.api_key_validate_create_update!(body, resolved_config, create: true, client: !ctx.headers.empty?)
@@ -9,8 +9,8 @@ module BetterAuth
9
9
  module_function
10
10
 
11
11
  def endpoint(config)
12
- BetterAuth::Endpoint.new(path: "/api-key/delete-all-expired-api-keys", method: "POST") do |ctx|
13
- BetterAuth::Plugins.api_key_delete_expired(ctx.context, config, bypass_last_check: true)
12
+ BetterAuth::Endpoint.new(path: "/api-key/delete-all-expired-api-keys", method: "POST", metadata: Routes.openapi_for(:delete_all_expired_api_keys)) do |ctx|
13
+ BetterAuth::APIKey::Routes.delete_expired(ctx.context, config, bypass_last_check: true, raise_on_error: true)
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)
@@ -9,7 +9,7 @@ module BetterAuth
9
9
  module_function
10
10
 
11
11
  def endpoint(config)
12
- BetterAuth::Endpoint.new(path: "/api-key/delete", method: "POST") do |ctx|
12
+ BetterAuth::Endpoint.new(path: "/api-key/delete", method: "POST", metadata: Routes.openapi_for(:delete_api_key)) do |ctx|
13
13
  session = BetterAuth::Routes.current_session(ctx)
14
14
  raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["USER_BANNED"]) if session[:user]["banned"] == true
15
15
 
@@ -9,7 +9,7 @@ module BetterAuth
9
9
  module_function
10
10
 
11
11
  def endpoint(config)
12
- BetterAuth::Endpoint.new(path: "/api-key/get", method: "GET") do |ctx|
12
+ BetterAuth::Endpoint.new(path: "/api-key/get", method: "GET", metadata: Routes.openapi_for(:get_api_key)) do |ctx|
13
13
  session = BetterAuth::Routes.current_session(ctx)
14
14
  query = BetterAuth::Plugins.normalize_hash(ctx.query)
15
15
  resolved_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, query[:config_id])
@@ -42,7 +42,7 @@ module BetterAuth
42
42
 
43
43
  @last_expired_check = nil
44
44
 
45
- def delete_expired(context, config, bypass_last_check: false)
45
+ def delete_expired(context, config, bypass_last_check: false, raise_on_error: false)
46
46
  return unless config[:storage] == "database" || config[:fallback_to_database]
47
47
  unless bypass_last_check
48
48
  now = Time.now
@@ -59,6 +59,9 @@ module BetterAuth
59
59
  {field: "expiresAt", value: nil, operator: "ne"}
60
60
  ]
61
61
  )
62
+ rescue => error
63
+ context.logger.error("[API KEY PLUGIN] Failed to delete expired API keys: #{error.message}") if context.respond_to?(:logger) && context.logger.respond_to?(:error)
64
+ raise if raise_on_error
62
65
  end
63
66
 
64
67
  def schedule_cleanup(ctx, config)
@@ -69,6 +72,227 @@ module BetterAuth
69
72
  task.call
70
73
  end
71
74
  end
75
+
76
+ def openapi_for(route)
77
+ {
78
+ create_api_key: create_api_key_openapi,
79
+ verify_api_key: verify_api_key_openapi,
80
+ get_api_key: get_api_key_openapi,
81
+ update_api_key: update_api_key_openapi,
82
+ delete_api_key: delete_api_key_openapi,
83
+ list_api_keys: list_api_keys_openapi,
84
+ delete_all_expired_api_keys: delete_all_expired_api_keys_openapi
85
+ }.fetch(route)
86
+ end
87
+
88
+ def create_api_key_openapi
89
+ {
90
+ openapi: {
91
+ description: "Create a new API key for a user",
92
+ requestBody: BetterAuth::OpenAPI.json_request_body(api_key_create_body_schema, required: true),
93
+ responses: {
94
+ "200" => BetterAuth::OpenAPI.json_response("API key created successfully", api_key_record_schema(include_secret: true))
95
+ }
96
+ }
97
+ }
98
+ end
99
+
100
+ def verify_api_key_openapi
101
+ {
102
+ openapi: {
103
+ description: "Verify and rate-limit an API key",
104
+ requestBody: BetterAuth::OpenAPI.json_request_body(
105
+ BetterAuth::OpenAPI.object_schema(
106
+ {
107
+ key: {type: "string", description: "The API key to verify"},
108
+ configId: {type: "string", description: "Configuration ID to use for the lookup"},
109
+ permissions: api_key_permissions_schema.merge(description: "Permissions required for the request")
110
+ },
111
+ required: ["key"]
112
+ )
113
+ ),
114
+ responses: {
115
+ "200" => BetterAuth::OpenAPI.json_response(
116
+ "API key verification result",
117
+ BetterAuth::OpenAPI.object_schema(
118
+ {
119
+ valid: {type: "boolean"},
120
+ error: {type: ["object", "null"], additionalProperties: true},
121
+ key: api_key_record_schema(include_secret: false).merge(type: ["object", "null"])
122
+ },
123
+ required: ["valid", "error", "key"]
124
+ )
125
+ )
126
+ }
127
+ }
128
+ }
129
+ end
130
+
131
+ def get_api_key_openapi
132
+ {
133
+ openapi: {
134
+ description: "Get an API key by ID",
135
+ parameters: [
136
+ BetterAuth::OpenAPI.query_parameter("id", required: true, description: "The API key ID"),
137
+ BetterAuth::OpenAPI.query_parameter("configId", description: "Configuration ID to use for the lookup")
138
+ ],
139
+ responses: {
140
+ "200" => BetterAuth::OpenAPI.json_response("API key retrieved successfully", api_key_record_schema(include_secret: false))
141
+ }
142
+ }
143
+ }
144
+ end
145
+
146
+ def update_api_key_openapi
147
+ {
148
+ openapi: {
149
+ description: "Update an existing API key by ID",
150
+ requestBody: BetterAuth::OpenAPI.json_request_body(api_key_update_body_schema, required: true),
151
+ responses: {
152
+ "200" => BetterAuth::OpenAPI.json_response("API key updated successfully", api_key_record_schema(include_secret: false))
153
+ }
154
+ }
155
+ }
156
+ end
157
+
158
+ def delete_api_key_openapi
159
+ {
160
+ openapi: {
161
+ description: "Delete an API key by ID",
162
+ requestBody: BetterAuth::OpenAPI.json_request_body(
163
+ BetterAuth::OpenAPI.object_schema(
164
+ {
165
+ keyId: {type: "string", description: "The API key ID"},
166
+ configId: {type: "string", description: "Configuration ID to use for the lookup"}
167
+ },
168
+ required: ["keyId"]
169
+ )
170
+ ),
171
+ responses: {
172
+ "200" => BetterAuth::OpenAPI.json_response("API key deleted successfully", BetterAuth::OpenAPI.success_response_schema)
173
+ }
174
+ }
175
+ }
176
+ end
177
+
178
+ def list_api_keys_openapi
179
+ {
180
+ openapi: {
181
+ description: "List all API keys for the authenticated user or for a specific organization",
182
+ parameters: [
183
+ BetterAuth::OpenAPI.query_parameter("configId", description: "Filter by configuration ID"),
184
+ BetterAuth::OpenAPI.query_parameter("organizationId", description: "Organization ID to list keys for"),
185
+ BetterAuth::OpenAPI.query_parameter("limit", schema: {type: "number"}, description: "The number of API keys to return"),
186
+ BetterAuth::OpenAPI.query_parameter("offset", schema: {type: "number"}, description: "The offset to start from"),
187
+ BetterAuth::OpenAPI.query_parameter("sortBy", description: "The field to sort by"),
188
+ BetterAuth::OpenAPI.query_parameter("sortDirection", schema: {type: "string", enum: ["asc", "desc"]}, description: "The direction to sort by")
189
+ ],
190
+ responses: {
191
+ "200" => BetterAuth::OpenAPI.json_response(
192
+ "API keys retrieved successfully",
193
+ BetterAuth::OpenAPI.object_schema(
194
+ {
195
+ apiKeys: BetterAuth::OpenAPI.array_schema(api_key_record_schema(include_secret: false)),
196
+ total: {type: "number"},
197
+ limit: {type: ["number", "null"]},
198
+ offset: {type: ["number", "null"]}
199
+ },
200
+ required: ["apiKeys", "total"]
201
+ )
202
+ )
203
+ }
204
+ }
205
+ }
206
+ end
207
+
208
+ def delete_all_expired_api_keys_openapi
209
+ {
210
+ openapi: {
211
+ description: "Delete all expired API keys",
212
+ requestBody: BetterAuth::OpenAPI.empty_request_body,
213
+ responses: {
214
+ "200" => BetterAuth::OpenAPI.json_response(
215
+ "Expired API key cleanup result",
216
+ BetterAuth::OpenAPI.object_schema(
217
+ {
218
+ success: {type: "boolean"},
219
+ error: {type: ["object", "null"], additionalProperties: true}
220
+ },
221
+ required: ["success", "error"]
222
+ )
223
+ )
224
+ }
225
+ }
226
+ }
227
+ end
228
+
229
+ def api_key_create_body_schema
230
+ BetterAuth::OpenAPI.object_schema(
231
+ {
232
+ configId: {type: "string", description: "The configuration ID to use for the API key"},
233
+ name: {type: "string", description: "Name of the API key"},
234
+ expiresIn: {type: ["number", "null"], description: "Expiration time of the API key in seconds"},
235
+ prefix: {type: "string", description: "Prefix of the API key"},
236
+ remaining: {type: ["number", "null"], description: "Remaining number of requests"},
237
+ metadata: {nullable: true, description: "Metadata associated with the API key"},
238
+ refillAmount: {type: "number", description: "Amount to refill the remaining count"},
239
+ refillInterval: {type: "number", description: "Interval to refill the API key in milliseconds"},
240
+ rateLimitTimeWindow: {type: "number", description: "Rate limit time window in milliseconds"},
241
+ rateLimitMax: {type: "number", description: "Maximum requests allowed within a window"},
242
+ rateLimitEnabled: {type: "boolean", description: "Whether the key has rate limiting enabled"},
243
+ permissions: api_key_permissions_schema.merge(description: "Permissions of the API key"),
244
+ userId: {type: "string", description: "User ID that the API key belongs to"},
245
+ organizationId: {type: "string", description: "Organization ID that the API key belongs to"}
246
+ }
247
+ )
248
+ end
249
+
250
+ def api_key_update_body_schema
251
+ BetterAuth::OpenAPI.object_schema(
252
+ api_key_create_body_schema[:properties].merge(
253
+ keyId: {type: "string", description: "The API key ID"},
254
+ enabled: {type: "boolean", description: "Whether the API key is enabled"}
255
+ ).except(:prefix, :organizationId),
256
+ required: ["keyId"]
257
+ )
258
+ end
259
+
260
+ def api_key_permissions_schema
261
+ {
262
+ type: "object",
263
+ additionalProperties: {
264
+ type: "array",
265
+ items: {type: "string"}
266
+ }
267
+ }
268
+ end
269
+
270
+ def api_key_record_schema(include_secret:)
271
+ properties = {
272
+ id: {type: "string", description: "Unique identifier of the API key"},
273
+ createdAt: {type: "string", format: "date-time", description: "Creation timestamp"},
274
+ updatedAt: {type: "string", format: "date-time", description: "Last update timestamp"},
275
+ name: {type: ["string", "null"], description: "Name of the API key"},
276
+ start: {type: ["string", "null"], description: "Starting characters of the key"},
277
+ prefix: {type: ["string", "null"], description: "Prefix of the API key"},
278
+ enabled: {type: "boolean", description: "Whether the key is enabled"},
279
+ expiresAt: {type: ["string", "null"], format: "date-time", description: "Expiration timestamp"},
280
+ referenceId: {type: "string", description: "ID of the reference owning the key"},
281
+ lastRefillAt: {type: ["string", "null"], format: "date-time", description: "Last refill timestamp"},
282
+ lastRequest: {type: ["string", "null"], format: "date-time", description: "Last request timestamp"},
283
+ metadata: {type: ["object", "null"], additionalProperties: true, description: "Metadata associated with the key"},
284
+ rateLimitMax: {type: ["number", "null"], description: "Maximum requests in time window"},
285
+ rateLimitTimeWindow: {type: ["number", "null"], description: "Rate limit time window in milliseconds"},
286
+ rateLimitEnabled: {type: "boolean", description: "Whether rate limiting is enabled"},
287
+ remaining: {type: ["number", "null"], description: "Remaining number of requests"},
288
+ refillAmount: {type: ["number", "null"], description: "Amount to refill"},
289
+ refillInterval: {type: ["number", "null"], description: "Refill interval in milliseconds"},
290
+ permissions: api_key_permissions_schema.merge(nullable: true, description: "Permissions of the API key"),
291
+ userId: {type: ["string", "null"], description: "ID of the user owning the key"}
292
+ }
293
+ properties[:key] = {type: "string", description: "The full API key"} if include_secret
294
+ BetterAuth::OpenAPI.object_schema(properties)
295
+ end
72
296
  end
73
297
  end
74
298
  end
@@ -9,28 +9,35 @@ module BetterAuth
9
9
  module_function
10
10
 
11
11
  def endpoint(config)
12
- BetterAuth::Endpoint.new(path: "/api-key/list", method: "GET") do |ctx|
12
+ BetterAuth::Endpoint.new(path: "/api-key/list", method: "GET", metadata: Routes.openapi_for(:list_api_keys)) do |ctx|
13
13
  session = BetterAuth::Routes.current_session(ctx)
14
14
  query = BetterAuth::Plugins.normalize_hash(ctx.query)
15
15
  BetterAuth::Plugins.api_key_validate_list_query!(query)
16
- configs = query[:config_id] ? [BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, query[:config_id])] : config.fetch(:configurations, [config])
16
+ configs = query[:config_id] ? [BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, query[:config_id])] : storage_groups(config.fetch(:configurations, [config]))
17
17
  reference_id = query[:organization_id] || session[:user]["id"]
18
18
  expected_reference = query[:organization_id] ? "organization" : "user"
19
19
  BetterAuth::Plugins.api_key_check_org_permission!(ctx, session[:user]["id"], reference_id, "read") if query[:organization_id]
20
- records = configs.flat_map { |entry| BetterAuth::Plugins.api_key_list_for_reference(ctx, reference_id, entry) }.uniq { |record| record["id"] }
21
- records = records.select do |record|
22
- record_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, BetterAuth::Plugins.api_key_record_config_id(record))
23
- record_config[:references].to_s == expected_reference &&
24
- BetterAuth::Plugins.api_key_record_reference_id(record) == reference_id &&
25
- (!query[:config_id] || BetterAuth::Plugins.api_key_config_id_matches?(BetterAuth::Plugins.api_key_record_config_id(record), query[:config_id]))
26
- end
27
- total = records.length
28
- records = BetterAuth::Plugins.api_key_sort_records(records, query[:sort_by], query[:sort_direction])
29
20
  offset = query.key?(:offset) ? query[:offset].to_i : nil
30
21
  limit = query.key?(:limit) ? query[:limit].to_i : nil
31
- records = records.drop(offset) if offset
32
- records = records.first(limit) if limit
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))) }
22
+ pushed = database_paginated_records(ctx, configs.first, reference_id, expected_reference, query, limit, offset)
23
+ if pushed
24
+ records = pushed.fetch(:records)
25
+ total = pushed.fetch(:total)
26
+ else
27
+ records = configs.flat_map { |entry| BetterAuth::Plugins.api_key_list_for_reference(ctx, reference_id, entry) }.uniq { |record| record["id"] }
28
+ records = records.select do |record|
29
+ record_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, BetterAuth::Plugins.api_key_record_config_id(record))
30
+ record_config[:references].to_s == expected_reference &&
31
+ BetterAuth::Plugins.api_key_record_reference_id(record) == reference_id &&
32
+ (!query[:config_id] || BetterAuth::Plugins.api_key_config_id_matches?(BetterAuth::Plugins.api_key_record_config_id(record), query[:config_id]))
33
+ end
34
+ total = records.length
35
+ records = BetterAuth::Plugins.api_key_sort_records(records, query[:sort_by], query[:sort_direction])
36
+ records = records.drop(offset) if offset
37
+ records = records.first(limit) if limit
38
+ end
39
+ cleanup_config = query[:config_id] ? configs.first : config
40
+ BetterAuth::Plugins.api_key_delete_expired(ctx.context, cleanup_config)
34
41
  migration_records = records.select { |record| BetterAuth::APIKey::Adapter.legacy_metadata_migration_needed?(record) }
35
42
  if migration_records.any?
36
43
  BetterAuth::APIKey::Utils.run_background_task(
@@ -50,6 +57,62 @@ module BetterAuth
50
57
  ctx.json({apiKeys: api_keys, total: total, limit: limit, offset: offset})
51
58
  end
52
59
  end
60
+
61
+ def storage_groups(configurations)
62
+ seen = {}
63
+ configurations.each_with_object([]) do |entry, groups|
64
+ key = storage_identifier(entry)
65
+ next if seen[key]
66
+
67
+ seen[key] = true
68
+ groups << entry
69
+ end
70
+ end
71
+
72
+ def storage_identifier(config)
73
+ return "database" if config[:storage].to_s == "database"
74
+ return "custom:#{config[:config_id] || "default"}" if config[:custom_storage]
75
+
76
+ config[:fallback_to_database] ? "secondary-storage-with-fallback" : "secondary-storage"
77
+ end
78
+
79
+ def database_paginated_records(ctx, config, reference_id, expected_reference, query, limit, offset)
80
+ return nil unless query[:config_id] && config
81
+ return nil unless config[:storage].to_s == "database"
82
+ return nil if BetterAuth::APIKey::Routes.default_config_id?(query[:config_id])
83
+ return nil unless config[:references].to_s == expected_reference
84
+ return nil if expected_reference == "user" && legacy_user_id_records?(ctx, reference_id, query[:config_id])
85
+
86
+ where = [
87
+ {field: "referenceId", value: reference_id},
88
+ {field: "configId", value: query[:config_id]}
89
+ ]
90
+ sort_by = query[:sort_by] ? {field: query[:sort_by].to_s, direction: (query[:sort_direction] || "asc").to_s} : nil
91
+ {
92
+ records: ctx.context.adapter.find_many(
93
+ model: BetterAuth::Plugins::API_KEY_TABLE_NAME,
94
+ where: where,
95
+ sort_by: sort_by,
96
+ limit: limit,
97
+ offset: offset
98
+ ),
99
+ total: ctx.context.adapter.count(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: where)
100
+ }
101
+ end
102
+
103
+ def legacy_user_id_records?(ctx, reference_id, config_id)
104
+ where = [
105
+ {field: "userId", value: reference_id},
106
+ {field: "configId", value: config_id}
107
+ ]
108
+ ctx.context.adapter.count(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: where).positive?
109
+ rescue KeyError, NoMethodError
110
+ false
111
+ rescue BetterAuth::Error => error
112
+ raise unless error.message.include?("Field userId not found")
113
+
114
+ false
115
+ end
53
116
  end
54
117
  end
55
118
  end
@@ -9,10 +9,13 @@ module BetterAuth
9
9
  module_function
10
10
 
11
11
  def endpoint(config)
12
- BetterAuth::Endpoint.new(path: "/api-key/update", method: "POST") do |ctx|
12
+ BetterAuth::Endpoint.new(path: "/api-key/update", method: "POST", metadata: Routes.openapi_for(:update_api_key)) do |ctx|
13
13
  body = BetterAuth::Plugins.api_key_normalize_body(ctx.body)
14
14
  resolved_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, body[:config_id])
15
15
  session = BetterAuth::Routes.current_session(ctx, allow_nil: true)
16
+ if !session && BetterAuth::Plugins.api_key_auth_required?(ctx)
17
+ raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["UNAUTHORIZED_SESSION"])
18
+ end
16
19
  user_id = session&.dig(:user, "id") || body[:user_id]
17
20
  raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["UNAUTHORIZED_SESSION"]) unless user_id
18
21
  if session && body[:user_id] && body[:user_id] != session[:user]["id"]
@@ -34,6 +37,13 @@ module BetterAuth
34
37
  raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["NO_VALUES_TO_UPDATE"]) if update.empty?
35
38
 
36
39
  updated = BetterAuth::Plugins.api_key_update_record(ctx, record, update.merge(updatedAt: Time.now), record_config)
40
+ unless updated
41
+ raise BetterAuth::APIError.new(
42
+ "INTERNAL_SERVER_ERROR",
43
+ message: BetterAuth::Plugins::API_KEY_ERROR_CODES["FAILED_TO_UPDATE_API_KEY"],
44
+ code: "FAILED_TO_UPDATE_API_KEY"
45
+ )
46
+ end
37
47
  updated = BetterAuth::Plugins.api_key_migrate_legacy_metadata(ctx, updated, record_config)
38
48
  BetterAuth::Plugins.api_key_delete_expired(ctx.context, record_config)
39
49
  ctx.json(BetterAuth::Plugins.api_key_public(updated, include_key_field: false))
@@ -9,7 +9,12 @@ module BetterAuth
9
9
  module_function
10
10
 
11
11
  def endpoint(config)
12
- BetterAuth::Endpoint.new(path: "/api-key/verify", method: "POST") do |ctx|
12
+ BetterAuth::Endpoint.new(
13
+ path: "/api-key/verify",
14
+ method: "POST",
15
+ body_schema: ->(value) { value },
16
+ metadata: Routes.openapi_for(:verify_api_key)
17
+ ) do |ctx|
13
18
  body = BetterAuth::Plugins.normalize_hash(ctx.body)
14
19
  resolved_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, body[:config_id])
15
20
  key = body[:key]
@@ -21,10 +26,13 @@ module BetterAuth
21
26
  )
22
27
  end
23
28
 
24
- if resolved_config[:custom_api_key_validator].respond_to?(:call) && !resolved_config[:custom_api_key_validator].call({ctx: ctx, key: key})
29
+ validation_config = body[:config_id] ? resolved_config : config_for_key(ctx, key, config)
30
+ validation_config ||= resolved_config
31
+ validator = validation_config[:custom_api_key_validator]
32
+ if validator.respond_to?(:call) && !validator.call({ctx: ctx, key: key})
25
33
  ctx.json({valid: false, error: {message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"], code: "KEY_NOT_FOUND"}, key: nil})
26
34
  else
27
- record = BetterAuth::Plugins.api_key_validate!(ctx, key, resolved_config, permissions: body[:permissions])
35
+ record = BetterAuth::Plugins.api_key_validate!(ctx, key, validation_config, permissions: body[:permissions])
28
36
  record_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, BetterAuth::Plugins.api_key_record_config_id(record))
29
37
  BetterAuth::Plugins.api_key_schedule_cleanup(ctx, record_config)
30
38
  ctx.json({valid: true, error: nil, key: BetterAuth::Plugins.api_key_public(record, include_key_field: false)})
@@ -37,6 +45,20 @@ module BetterAuth
37
45
  ctx.json({valid: false, error: {message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"], code: "INVALID_API_KEY"}, key: nil})
38
46
  end
39
47
  end
48
+
49
+ def config_for_key(ctx, key, config)
50
+ config.fetch(:configurations, [config]).each do |entry|
51
+ hashed = BetterAuth::Plugins.api_key_hash(key, entry)
52
+ record = BetterAuth::Plugins.api_key_find_by_hash(ctx, hashed, entry)
53
+ next unless record
54
+
55
+ record_config_id = BetterAuth::Plugins.api_key_record_config_id(record)
56
+ return entry if BetterAuth::Plugins.api_key_config_id_matches?(record_config_id, entry[:config_id])
57
+
58
+ return BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, record_config_id)
59
+ end
60
+ nil
61
+ end
40
62
  end
41
63
  end
42
64
  end
@@ -8,6 +8,7 @@ module BetterAuth
8
8
  def schema(config, custom_schema = nil)
9
9
  base = {
10
10
  apikey: {
11
+ model_name: "api_keys",
11
12
  fields: {
12
13
  configId: {type: "string", required: true, default_value: "default", index: true},
13
14
  name: {type: "string", required: false},
@@ -5,6 +5,13 @@ module BetterAuth
5
5
  module Validation
6
6
  module_function
7
7
 
8
+ USAGE_LOCK_STRIPE_COUNT = 256
9
+
10
+ def usage_lock_for(key)
11
+ @usage_lock_stripes ||= Array.new(USAGE_LOCK_STRIPE_COUNT) { Mutex.new }
12
+ @usage_lock_stripes[key.hash % USAGE_LOCK_STRIPE_COUNT]
13
+ end
14
+
8
15
  def validate_create_update!(body, config, create:, client:)
9
16
  name = body[:name]
10
17
  if create && config[:require_name] && name.to_s.empty?
@@ -68,25 +75,34 @@ module BetterAuth
68
75
 
69
76
  def validate_api_key!(ctx, key, config, permissions: nil)
70
77
  hashed = BetterAuth::APIKey::Keys.hash(key, config)
71
- record = BetterAuth::APIKey::Adapter.find_by_hash(ctx, hashed, config)
72
- raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"]) unless record
73
- unless BetterAuth::APIKey::Routes.config_id_matches?(BetterAuth::APIKey::Types.record_config_id(record), config[:config_id])
74
- raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"])
75
- end
76
- raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["KEY_DISABLED"]) if record["enabled"] == false
77
- if record["expiresAt"] && record["expiresAt"] <= Time.now
78
- BetterAuth::APIKey::Adapter.schedule_record_delete(ctx, record, config)
79
- raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["KEY_EXPIRED"])
80
- end
81
- if record["remaining"].to_i <= 0 && !record["remaining"].nil? && record["refillAmount"].nil?
82
- BetterAuth::APIKey::Adapter.schedule_record_delete(ctx, record, config)
83
- raise BetterAuth::APIError.new("TOO_MANY_REQUESTS", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["USAGE_EXCEEDED"])
84
- end
78
+ usage_lock_for(hashed).synchronize do
79
+ record = BetterAuth::APIKey::Adapter.find_by_hash(ctx, hashed, config)
80
+ raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"]) unless record
81
+ unless BetterAuth::APIKey::Routes.config_id_matches?(BetterAuth::APIKey::Types.record_config_id(record), config[:config_id])
82
+ raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"])
83
+ end
84
+ raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["KEY_DISABLED"]) if record["enabled"] == false
85
+ if record["expiresAt"] && record["expiresAt"] <= Time.now
86
+ BetterAuth::APIKey::Adapter.schedule_record_delete(ctx, record, config)
87
+ raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["KEY_EXPIRED"])
88
+ end
89
+ if record["remaining"].to_i <= 0 && !record["remaining"].nil? && record["refillAmount"].nil?
90
+ BetterAuth::APIKey::Adapter.schedule_record_delete(ctx, record, config)
91
+ raise BetterAuth::APIError.new("TOO_MANY_REQUESTS", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["USAGE_EXCEEDED"])
92
+ end
85
93
 
86
- check_permissions!(record, permissions)
87
- update = usage_update(record, config)
88
- updated = BetterAuth::APIKey::Adapter.update_record(ctx, record, update, config, defer: true)
89
- BetterAuth::APIKey::Adapter.migrate_legacy_metadata(ctx, updated || record.merge(update.transform_keys { |key_name| BetterAuth::Schema.storage_key(key_name) }), config)
94
+ check_permissions!(record, permissions)
95
+ update = usage_update(record, config)
96
+ updated = BetterAuth::APIKey::Adapter.update_record(ctx, record, update, config, defer: false)
97
+ unless updated
98
+ raise BetterAuth::APIError.new(
99
+ "INTERNAL_SERVER_ERROR",
100
+ message: BetterAuth::Plugins::API_KEY_ERROR_CODES["FAILED_TO_UPDATE_API_KEY"],
101
+ code: "FAILED_TO_UPDATE_API_KEY"
102
+ )
103
+ end
104
+ BetterAuth::APIKey::Adapter.migrate_legacy_metadata(ctx, updated || record.merge(update.transform_keys { |key_name| BetterAuth::Schema.storage_key(key_name) }), config)
105
+ end
90
106
  end
91
107
 
92
108
  def usage_update(record, config)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BetterAuth
4
4
  module APIKey
5
- VERSION = "0.8.0"
5
+ VERSION = "0.10.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.8.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala
@@ -37,6 +37,34 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '2.5'
40
+ - !ruby/object:Gem::Dependency
41
+ name: better_auth-mongodb
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.9'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.9'
54
+ - !ruby/object:Gem::Dependency
55
+ name: better_auth-redis-storage
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.9'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.9'
40
68
  - !ruby/object:Gem::Dependency
41
69
  name: minitest
42
70
  requirement: !ruby/object:Gem::Requirement
@@ -51,6 +79,34 @@ dependencies:
51
79
  - - "~>"
52
80
  - !ruby/object:Gem::Version
53
81
  version: '5.25'
82
+ - !ruby/object:Gem::Dependency
83
+ name: mysql2
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.5'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.5'
96
+ - !ruby/object:Gem::Dependency
97
+ name: pg
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '1.5'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '1.5'
54
110
  - !ruby/object:Gem::Dependency
55
111
  name: rake
56
112
  requirement: !ruby/object:Gem::Requirement
@@ -65,6 +121,48 @@ dependencies:
65
121
  - - "~>"
66
122
  - !ruby/object:Gem::Version
67
123
  version: '13.2'
124
+ - !ruby/object:Gem::Dependency
125
+ name: redis
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '5.0'
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '5.0'
138
+ - !ruby/object:Gem::Dependency
139
+ name: sequel
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: '5.83'
145
+ type: :development
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '5.83'
152
+ - !ruby/object:Gem::Dependency
153
+ name: sqlite3
154
+ requirement: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - "~>"
157
+ - !ruby/object:Gem::Version
158
+ version: '2.0'
159
+ type: :development
160
+ prerelease: false
161
+ version_requirements: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - "~>"
164
+ - !ruby/object:Gem::Version
165
+ version: '2.0'
68
166
  - !ruby/object:Gem::Dependency
69
167
  name: standardrb
70
168
  requirement: !ruby/object:Gem::Requirement
@@ -79,6 +177,20 @@ dependencies:
79
177
  - - "~>"
80
178
  - !ruby/object:Gem::Version
81
179
  version: '1.0'
180
+ - !ruby/object:Gem::Dependency
181
+ name: tiny_tds
182
+ requirement: !ruby/object:Gem::Requirement
183
+ requirements:
184
+ - - "~>"
185
+ - !ruby/object:Gem::Version
186
+ version: '2.1'
187
+ type: :development
188
+ prerelease: false
189
+ version_requirements: !ruby/object:Gem::Requirement
190
+ requirements:
191
+ - - "~>"
192
+ - !ruby/object:Gem::Version
193
+ version: '2.1'
82
194
  description: Adds API key creation, verification, management, quotas, metadata, and
83
195
  permissions for Better Auth Ruby. Includes storage modes. Also supports API-key-backed
84
196
  sessions. Better Auth Ruby is an independent modern authentication framework for
@@ -114,14 +226,14 @@ files:
114
226
  - lib/better_auth/api_key/validation.rb
115
227
  - lib/better_auth/api_key/version.rb
116
228
  - lib/better_auth/plugins/api_key.rb
117
- homepage: https://github.com/sebasxsala/better-auth
229
+ homepage: https://github.com/sebasxsala/better-auth-rb
118
230
  licenses:
119
231
  - MIT
120
232
  metadata:
121
- homepage_uri: https://github.com/sebasxsala/better-auth
122
- source_code_uri: https://github.com/sebasxsala/better-auth
123
- changelog_uri: https://github.com/sebasxsala/better-auth/blob/main/packages/better_auth-api-key/CHANGELOG.md
124
- bug_tracker_uri: https://github.com/sebasxsala/better-auth/issues
233
+ homepage_uri: https://github.com/sebasxsala/better-auth-rb
234
+ source_code_uri: https://github.com/sebasxsala/better-auth-rb
235
+ changelog_uri: https://github.com/sebasxsala/better-auth-rb/blob/main/packages/better_auth-api-key/CHANGELOG.md
236
+ bug_tracker_uri: https://github.com/sebasxsala/better-auth-rb/issues
125
237
  rdoc_options: []
126
238
  require_paths:
127
239
  - lib