better_auth-api-key 0.2.1 → 0.6.2

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: 2ac9db4e9d87c466c1bf8ecb7be2978d8ffc17fa168b5a626e82b29ddf36c4c2
4
- data.tar.gz: 90f8b579bdf6fd84d7d0d2b0360f479c50800bc5ac724b0fdde6c721a8290e3a
3
+ metadata.gz: fcc6f211dc2f80b2524e48f5fff897caf734ee3316ba5620379820571d2f0729
4
+ data.tar.gz: 1f111ffa2b175c3ea04c356c1afbc18d361b9b02717c9dea3dd32954b4ac57dc
5
5
  SHA512:
6
- metadata.gz: 970bf80895c95174c91e269560260f6b90281dcc5f3a1be4cd708bcdb53a12dd5d567874a3b046b86867b069000009238982bbd22b980edfa990ed7c68a04b6b
7
- data.tar.gz: c52b6d7e9d2b98e77754e9105681bd1c1f8e619b1a0819ab93798a7eacc62cd10a40e11a32debe1a119578de868e76f3982dd366f745d4eb7abb60eab97a4332
6
+ metadata.gz: fa1c89732eaeba2c8d7d064664d9a34e23287744c534dc19d45254837bd7481b0271b536364e64835b4f750e14d95c3f118bd7ddc0947f02077cbb8d35675034
7
+ data.tar.gz: 8146aca0fd1ad983d0285eedcc707bcfff2336bac8b64c91141af93ca9e231d8d42b67899a405beef304785464ddedf8fdb06ad5e1dad7d74ac7bc754b5981f2
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+
7
+ module BetterAuth
8
+ module APIKey
9
+ module Adapter
10
+ HASH_STORAGE_PREFIX = "api-key:"
11
+ ID_STORAGE_PREFIX = "api-key:by-id:"
12
+ REFERENCE_STORAGE_PREFIX = "api-key:by-ref:"
13
+
14
+ module_function
15
+
16
+ def storage_key_by_hash(hashed_key)
17
+ "#{HASH_STORAGE_PREFIX}#{hashed_key}"
18
+ end
19
+
20
+ def storage_key_by_id(id)
21
+ "#{ID_STORAGE_PREFIX}#{id}"
22
+ end
23
+
24
+ def storage_key_by_reference(reference_id)
25
+ "#{REFERENCE_STORAGE_PREFIX}#{reference_id}"
26
+ end
27
+
28
+ def store(ctx, data, config)
29
+ record = nil
30
+ if config[:storage] == "database" || config[:fallback_to_database]
31
+ record = ctx.context.adapter.create(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, data: data)
32
+ end
33
+ record ||= data.transform_keys { |key| BetterAuth::Schema.storage_key(key) }.merge("id" => SecureRandom.hex(16))
34
+ set(ctx, record, config) if config[:storage] == "secondary-storage"
35
+ record
36
+ end
37
+
38
+ def find_by_hash(ctx, hashed, config)
39
+ if config[:storage] == "secondary-storage"
40
+ record = get(ctx, storage_key_by_hash(hashed), config) || get(ctx, "api-key:key:#{hashed}", config)
41
+ return record if record
42
+ return nil unless config[:fallback_to_database]
43
+ end
44
+ record = ctx.context.adapter.find_one(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: [{field: "key", value: hashed}])
45
+ set(ctx, record, config) if record && config[:storage] == "secondary-storage" && config[:fallback_to_database]
46
+ record
47
+ end
48
+
49
+ def find_by_id(ctx, id, config)
50
+ if config[:storage] == "secondary-storage"
51
+ record = get(ctx, storage_key_by_id(id), config) || get(ctx, "api-key:id:#{id}", config)
52
+ return record if record
53
+ return nil unless config[:fallback_to_database]
54
+ end
55
+ record = ctx.context.adapter.find_one(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: [{field: "id", value: id}])
56
+ set(ctx, record, config) if record && config[:storage] == "secondary-storage" && config[:fallback_to_database]
57
+ record
58
+ end
59
+
60
+ def list_for_reference(ctx, reference_id, config)
61
+ if config[:storage] == "secondary-storage"
62
+ begin
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)
65
+ records = ids.filter_map { |id| find_by_id(ctx, id, config) }
66
+ return records unless records.empty? && config[:fallback_to_database]
67
+ rescue JSON::ParserError, NoMethodError
68
+ return [] unless config[:fallback_to_database]
69
+ end
70
+ end
71
+ records = ctx.context.adapter.find_many(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: [{field: "referenceId", value: reference_id}])
72
+ legacy = ctx.context.adapter.find_many(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: [{field: "userId", value: reference_id}])
73
+ combined = (records + legacy).uniq { |record| record["id"] }
74
+ populate_reference(ctx, reference_id, combined, config) if config[:storage] == "secondary-storage" && config[:fallback_to_database]
75
+ combined
76
+ end
77
+
78
+ def update_record(ctx, record, update, config, defer: false)
79
+ performer = lambda do
80
+ updated = nil
81
+ if config[:storage] == "database" || config[:fallback_to_database]
82
+ updated = ctx.context.adapter.update(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: [{field: "id", value: record["id"]}], update: update)
83
+ end
84
+ updated ||= record.merge(update.transform_keys { |key| BetterAuth::Schema.storage_key(key) })
85
+ set(ctx, updated, config) if config[:storage] == "secondary-storage"
86
+ updated
87
+ end
88
+
89
+ if defer && config[:defer_updates] && BetterAuth::Plugins.api_key_background_tasks?(ctx)
90
+ scheduled = record.merge(update.transform_keys { |key| BetterAuth::Schema.storage_key(key) })
91
+ ctx.context.run_in_background(performer)
92
+ scheduled
93
+ else
94
+ performer.call
95
+ end
96
+ end
97
+
98
+ def delete_record(ctx, record, config)
99
+ 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]
100
+ delete(ctx, record, config) if config[:storage] == "secondary-storage"
101
+ end
102
+
103
+ def schedule_record_delete(ctx, record, config)
104
+ task = -> { delete_record(ctx, record, config) }
105
+ if config[:defer_updates] && BetterAuth::APIKey::Utils.background_tasks?(ctx)
106
+ ctx.context.run_in_background(task)
107
+ else
108
+ task.call
109
+ end
110
+ end
111
+
112
+ def migrate_legacy_metadata(ctx, record, config)
113
+ parsed = BetterAuth::APIKey::Utils.decode_json(record["metadata"])
114
+ return record unless parsed.is_a?(Hash)
115
+
116
+ encoded = BetterAuth::APIKey::Utils.encode_json(parsed)
117
+ return record.merge("metadata" => encoded) if record["metadata"] == encoded
118
+
119
+ updated = record.merge("metadata" => encoded)
120
+ if config[:storage] == "database" || config[:fallback_to_database]
121
+ ctx.context.adapter.update(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: [{field: "id", value: record["id"]}], update: {metadata: encoded})
122
+ end
123
+ set(ctx, updated, config) if config[:storage] == "secondary-storage"
124
+ updated
125
+ end
126
+
127
+ def storage(config, context = nil)
128
+ config[:custom_storage] || context&.options&.secondary_storage
129
+ end
130
+
131
+ def get(ctx, key, config)
132
+ raw = storage(config, ctx.context)&.get(key)
133
+ raw && deserialize_record(JSON.parse(raw))
134
+ rescue JSON::ParserError
135
+ nil
136
+ end
137
+
138
+ def set(ctx, record, config)
139
+ storage_instance = storage(config, ctx.context)
140
+ unless storage_instance
141
+ raise BetterAuth::APIError.new("INTERNAL_SERVER_ERROR", message: "Secondary storage is required when storage mode is 'secondary-storage'")
142
+ end
143
+
144
+ serialized = JSON.generate(storage_record(record))
145
+ expires_at = BetterAuth::APIKey::Utils.normalize_time(record["expiresAt"])
146
+ ttl = expires_at ? [(expires_at - Time.now).to_i, 0].max : nil
147
+ reference_id = BetterAuth::Plugins.api_key_record_reference_id(record)
148
+ reference_key = storage_key_by_reference(reference_id)
149
+
150
+ batch(storage_instance) do
151
+ operations = [
152
+ -> { storage_instance.set(storage_key_by_hash(record["key"]), serialized, ttl) },
153
+ -> { storage_instance.set(storage_key_by_id(record["id"]), serialized, ttl) }
154
+ ]
155
+ operations << if config[:fallback_to_database]
156
+ -> { storage_instance.delete(reference_key) }
157
+ else
158
+ -> { ref_list_add(storage_instance, reference_key, record["id"]) }
159
+ end
160
+ operations.each(&:call)
161
+ end
162
+ end
163
+
164
+ def delete(ctx, record, config)
165
+ storage_instance = storage(config, ctx.context)
166
+ return unless storage_instance
167
+
168
+ reference_id = BetterAuth::Plugins.api_key_record_reference_id(record)
169
+ reference_key = storage_key_by_reference(reference_id)
170
+
171
+ batch(storage_instance) do
172
+ operations = [
173
+ -> { storage_instance.delete(storage_key_by_hash(record["key"])) },
174
+ -> { storage_instance.delete(storage_key_by_id(record["id"])) },
175
+ # Ruby-only legacy storage layout cleanup; upstream never wrote here.
176
+ -> { storage_instance.delete("api-key:key:#{record["key"]}") },
177
+ -> { storage_instance.delete("api-key:id:#{record["id"]}") }
178
+ ]
179
+ operations << if config[:fallback_to_database]
180
+ -> { storage_instance.delete(reference_key) }
181
+ else
182
+ -> { ref_list_remove(storage_instance, reference_key, record["id"]) }
183
+ end
184
+ operations.each(&:call)
185
+ end
186
+ end
187
+
188
+ def ref_list_add(storage_instance, reference_key, id)
189
+ ids = safe_parse_id_list(storage_instance.get(reference_key))
190
+ ids << id unless ids.include?(id)
191
+ storage_instance.set(reference_key, JSON.generate(ids))
192
+ end
193
+
194
+ def ref_list_remove(storage_instance, reference_key, id)
195
+ ids = safe_parse_id_list(storage_instance.get(reference_key)).reject { |existing| existing == id }
196
+ ids.empty? ? storage_instance.delete(reference_key) : storage_instance.set(reference_key, JSON.generate(ids))
197
+ end
198
+
199
+ def safe_parse_id_list(raw)
200
+ return [] if raw.nil?
201
+
202
+ parsed = JSON.parse(raw.to_s)
203
+ parsed.is_a?(Array) ? parsed : []
204
+ rescue JSON::ParserError
205
+ []
206
+ end
207
+
208
+ def batch(storage_instance, &block)
209
+ if storage_instance.respond_to?(:batch)
210
+ storage_instance.batch(&block)
211
+ else
212
+ block.call
213
+ end
214
+ end
215
+
216
+ def populate_reference(ctx, reference_id, records, config)
217
+ storage_instance = storage(config, ctx.context)
218
+ return unless storage_instance
219
+
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"]
228
+ 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))
231
+ end
232
+
233
+ def storage_record(record)
234
+ record.transform_values { |value| value.is_a?(Time) ? value.iso8601 : value }
235
+ end
236
+
237
+ def deserialize_record(record)
238
+ %w[createdAt updatedAt expiresAt lastRefillAt lastRequest].each do |field|
239
+ record[field] = BetterAuth::APIKey::Utils.normalize_time(record[field]) if record[field]
240
+ end
241
+ record
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module APIKey
5
+ module Configuration
6
+ module_function
7
+
8
+ def normalize(configurations, options = nil)
9
+ if configurations.is_a?(Array)
10
+ normalized_configs = configurations.map { |config| single(config) }
11
+ if normalized_configs.any? { |config| config[:config_id].to_s.empty? }
12
+ raise BetterAuth::Error, "configId is required for each API key configuration in the api-key plugin."
13
+ end
14
+ config_ids = normalized_configs.map { |config| config[:config_id] }
15
+ raise BetterAuth::Error, "configId must be unique for each API key configuration in the api-key plugin." if config_ids.uniq.length != config_ids.length
16
+
17
+ plugin_options = BetterAuth::Plugins.normalize_hash(options || {})
18
+ default_config = normalized_configs.find { |config| BetterAuth::APIKey::Routes.default_config_id?(config[:config_id]) }
19
+ default_config ||= normalized_configs.first
20
+ default_config.merge(
21
+ configurations: normalized_configs,
22
+ schema: plugin_options[:schema] || default_config[:schema]
23
+ )
24
+ else
25
+ config = single(configurations)
26
+ config[:config_id] ||= "default"
27
+ config.merge(configurations: [config])
28
+ end
29
+ end
30
+
31
+ def single(options)
32
+ data = BetterAuth::Plugins.normalize_hash(options || {})
33
+ rate_limit_options = data[:rate_limit] || {}
34
+ starting_characters_options = data[:starting_characters_config] || {}
35
+ {
36
+ config_id: data[:config_id],
37
+ api_key_headers: data[:api_key_headers] || "x-api-key",
38
+ default_key_length: data[:default_key_length] || 64,
39
+ default_prefix: data[:default_prefix],
40
+ maximum_prefix_length: data.key?(:maximum_prefix_length) ? data[:maximum_prefix_length] : 32,
41
+ minimum_prefix_length: data.key?(:minimum_prefix_length) ? data[:minimum_prefix_length] : 1,
42
+ maximum_name_length: data.key?(:maximum_name_length) ? data[:maximum_name_length] : 32,
43
+ minimum_name_length: data.key?(:minimum_name_length) ? data[:minimum_name_length] : 1,
44
+ enable_metadata: data[:enable_metadata] || false,
45
+ disable_key_hashing: data[:disable_key_hashing] || false,
46
+ require_name: data[:require_name] || false,
47
+ storage: data[:storage] || "database",
48
+ rate_limit: {
49
+ enabled: rate_limit_options.fetch(:enabled, true),
50
+ time_window: rate_limit_options[:time_window] || 86_400_000,
51
+ max_requests: rate_limit_options[:max_requests] || 10
52
+ },
53
+ key_expiration: {
54
+ default_expires_in: data.dig(:key_expiration, :default_expires_in),
55
+ disable_custom_expires_time: data.dig(:key_expiration, :disable_custom_expires_time) || false,
56
+ max_expires_in: data.dig(:key_expiration, :max_expires_in) || 365,
57
+ min_expires_in: data.dig(:key_expiration, :min_expires_in) || 1
58
+ },
59
+ starting_characters_config: {
60
+ should_store: starting_characters_options.fetch(:should_store, true),
61
+ characters_length: starting_characters_options[:characters_length] || 6
62
+ },
63
+ enable_session_for_api_keys: data[:enable_session_for_api_keys] || false,
64
+ fallback_to_database: data[:fallback_to_database] || false,
65
+ custom_storage: data[:custom_storage],
66
+ custom_key_generator: data[:custom_key_generator],
67
+ custom_api_key_getter: data[:custom_api_key_getter],
68
+ custom_api_key_validator: data[:custom_api_key_validator],
69
+ default_permissions: data[:default_permissions],
70
+ permissions: data[:permissions] || {},
71
+ references: data[:references] || "user",
72
+ defer_updates: data[:defer_updates] || false,
73
+ schema: data[:schema]
74
+ }
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module APIKey
5
+ ERROR_CODES = {
6
+ "INVALID_METADATA_TYPE" => "metadata must be an object or undefined",
7
+ "REFILL_AMOUNT_AND_INTERVAL_REQUIRED" => "refillAmount is required when refillInterval is provided",
8
+ "REFILL_INTERVAL_AND_AMOUNT_REQUIRED" => "refillInterval is required when refillAmount is provided",
9
+ "USER_BANNED" => "User is banned",
10
+ "UNAUTHORIZED_SESSION" => "Unauthorized or invalid session",
11
+ "KEY_NOT_FOUND" => "API Key not found",
12
+ "KEY_DISABLED" => "API Key is disabled",
13
+ "KEY_EXPIRED" => "API Key has expired",
14
+ "USAGE_EXCEEDED" => "API Key has reached its usage limit",
15
+ "KEY_NOT_RECOVERABLE" => "API Key is not recoverable",
16
+ "EXPIRES_IN_IS_TOO_SMALL" => "The expiresIn is smaller than the predefined minimum value.",
17
+ "EXPIRES_IN_IS_TOO_LARGE" => "The expiresIn is larger than the predefined maximum value.",
18
+ "INVALID_REMAINING" => "The remaining count is either too large or too small.",
19
+ "INVALID_PREFIX_LENGTH" => "The prefix length is either too large or too small.",
20
+ "INVALID_NAME_LENGTH" => "The name length is either too large or too small.",
21
+ "METADATA_DISABLED" => "Metadata is disabled.",
22
+ "RATE_LIMIT_EXCEEDED" => "Rate limit exceeded.",
23
+ "NO_VALUES_TO_UPDATE" => "No values to update.",
24
+ "KEY_DISABLED_EXPIRATION" => "Custom key expiration values are disabled.",
25
+ "INVALID_API_KEY" => "Invalid API key.",
26
+ "INVALID_USER_ID_FROM_API_KEY" => "The user id from the API key is invalid.",
27
+ "INVALID_REFERENCE_ID_FROM_API_KEY" => "The reference id from the API key is invalid.",
28
+ "INVALID_API_KEY_GETTER_RETURN_TYPE" => "API Key getter returned an invalid key type. Expected string.",
29
+ "SERVER_ONLY_PROPERTY" => "The property you're trying to set can only be set from the server auth instance only.",
30
+ "FAILED_TO_UPDATE_API_KEY" => "Failed to update API key",
31
+ "NAME_REQUIRED" => "API Key name is required.",
32
+ "ORGANIZATION_ID_REQUIRED" => "Organization ID is required for organization-owned API keys.",
33
+ "USER_NOT_MEMBER_OF_ORGANIZATION" => "You are not a member of the organization that owns this API key.",
34
+ "INSUFFICIENT_API_KEY_PERMISSIONS" => "You do not have permission to perform this action on organization API keys.",
35
+ "NO_DEFAULT_API_KEY_CONFIGURATION_FOUND" => "No default api-key configuration found.",
36
+ "ORGANIZATION_PLUGIN_REQUIRED" => "Organization plugin is required for organization-owned API keys. Please install and configure the organization plugin."
37
+ }.freeze
38
+ end
39
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module BetterAuth
6
+ module APIKey
7
+ module Keys
8
+ module_function
9
+
10
+ def default_hasher(key)
11
+ BetterAuth::Crypto.sha256(key.to_s, encoding: :base64url)
12
+ end
13
+
14
+ def generate(config, prefix)
15
+ generator = config[:custom_key_generator]
16
+ return generator.call({length: config[:default_key_length], prefix: prefix}) if generator.respond_to?(:call)
17
+
18
+ alphabet = [*("a".."z"), *("A".."Z")]
19
+ "#{prefix}#{Array.new(config[:default_key_length].to_i) { alphabet[SecureRandom.random_number(alphabet.length)] }.join}"
20
+ end
21
+
22
+ def hash(key, config)
23
+ config[:disable_key_hashing] ? key.to_s : default_hasher(key)
24
+ end
25
+
26
+ def normalize_body(raw)
27
+ body = BetterAuth::Plugins.normalize_hash(raw)
28
+ return body unless raw.is_a?(Hash)
29
+
30
+ metadata_key = raw.key?(:metadata) ? :metadata : ("metadata" if raw.key?("metadata"))
31
+ body[:metadata] = raw[metadata_key] if metadata_key
32
+ body
33
+ end
34
+
35
+ def expires_at(body, config)
36
+ if body.key?(:expires_in)
37
+ Time.now + body[:expires_in].to_i unless body[:expires_in].nil?
38
+ elsif config[:key_expiration][:default_expires_in]
39
+ Time.now + config[:key_expiration][:default_expires_in].to_i
40
+ end
41
+ end
42
+
43
+ def from_headers(ctx, config)
44
+ getter = config[:custom_api_key_getter]
45
+ return getter.call(ctx) if getter.respond_to?(:call)
46
+
47
+ Array(config[:api_key_headers]).each do |header|
48
+ value = ctx.headers[header.to_s.downcase]
49
+ return value if value
50
+ end
51
+ nil
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module APIKey
5
+ module OrgAuthorization
6
+ PERMISSIONS = {
7
+ apiKey: %w[create read update delete]
8
+ }.freeze
9
+
10
+ module_function
11
+
12
+ def check_permission!(ctx, user_id, organization_id, action)
13
+ org_plugin = ctx.context.options.plugins.find { |plugin| plugin.id == "organization" }
14
+ unless org_plugin
15
+ raise BetterAuth::APIError.new(
16
+ "INTERNAL_SERVER_ERROR",
17
+ message: BetterAuth::Plugins::API_KEY_ERROR_CODES["ORGANIZATION_PLUGIN_REQUIRED"],
18
+ code: "ORGANIZATION_PLUGIN_REQUIRED"
19
+ )
20
+ end
21
+
22
+ member = ctx.context.adapter.find_one(model: "member", where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}])
23
+ unless member
24
+ raise BetterAuth::APIError.new(
25
+ "FORBIDDEN",
26
+ message: BetterAuth::Plugins::API_KEY_ERROR_CODES["USER_NOT_MEMBER_OF_ORGANIZATION"],
27
+ code: "USER_NOT_MEMBER_OF_ORGANIZATION"
28
+ )
29
+ end
30
+
31
+ return member if member["role"].to_s == (org_plugin.options[:creator_role] || "owner").to_s
32
+
33
+ permissions = {"apiKey" => [action]}
34
+ return member if BetterAuth::Plugins.organization_permission?(ctx, org_plugin.options, member["role"], permissions, organization_id)
35
+
36
+ raise BetterAuth::APIError.new(
37
+ "FORBIDDEN",
38
+ message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INSUFFICIENT_API_KEY_PERMISSIONS"],
39
+ code: "INSUFFICIENT_API_KEY_PERMISSIONS"
40
+ )
41
+ end
42
+
43
+ def authorize_reference!(ctx, config, user_id, reference_id, action)
44
+ if config[:references].to_s == "organization"
45
+ check_permission!(ctx, user_id, reference_id, action)
46
+ elsif reference_id != user_id
47
+ raise BetterAuth::APIError.new("NOT_FOUND", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["KEY_NOT_FOUND"])
48
+ end
49
+ end
50
+
51
+ def create_reference_id!(ctx, body, session, config)
52
+ if config[:references].to_s == "organization"
53
+ organization_id = body[:organization_id]
54
+ if organization_id.to_s.empty?
55
+ raise BetterAuth::APIError.new(
56
+ "BAD_REQUEST",
57
+ message: BetterAuth::Plugins::API_KEY_ERROR_CODES["ORGANIZATION_ID_REQUIRED"],
58
+ code: "ORGANIZATION_ID_REQUIRED"
59
+ )
60
+ end
61
+
62
+ user_id = session&.dig(:user, "id") || body[:user_id]
63
+ raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["UNAUTHORIZED_SESSION"]) if user_id.to_s.empty?
64
+
65
+ check_permission!(ctx, user_id, organization_id, "create")
66
+ organization_id
67
+ elsif session && body[:user_id] && body[:user_id] != session[:user]["id"]
68
+ raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["UNAUTHORIZED_SESSION"])
69
+ elsif session
70
+ session[:user]["id"]
71
+ else
72
+ user_id = body[:user_id]
73
+ raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["UNAUTHORIZED_SESSION"]) if user_id.to_s.empty?
74
+
75
+ user_id
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module APIKey
5
+ module PluginFactory
6
+ module_function
7
+
8
+ def build(configurations = {}, options = nil)
9
+ config = BetterAuth::APIKey::Configuration.normalize(configurations, options)
10
+ BetterAuth::Plugin.new(
11
+ id: "api-key",
12
+ version: BetterAuth::APIKey::VERSION,
13
+ hooks: {
14
+ before: [
15
+ {
16
+ matcher: ->(ctx) { !!BetterAuth::APIKey::Session.header_config(ctx, config) },
17
+ handler: ->(ctx) { BetterAuth::APIKey::Session.hook(ctx, config) }
18
+ }
19
+ ]
20
+ },
21
+ endpoints: {
22
+ create_api_key: BetterAuth::APIKey::Routes::CreateAPIKey.endpoint(config),
23
+ verify_api_key: BetterAuth::APIKey::Routes::VerifyAPIKey.endpoint(config),
24
+ get_api_key: BetterAuth::APIKey::Routes::GetAPIKey.endpoint(config),
25
+ update_api_key: BetterAuth::APIKey::Routes::UpdateAPIKey.endpoint(config),
26
+ delete_api_key: BetterAuth::APIKey::Routes::DeleteAPIKey.endpoint(config),
27
+ list_api_keys: BetterAuth::APIKey::Routes::ListAPIKeys.endpoint(config),
28
+ delete_all_expired_api_keys: BetterAuth::APIKey::Routes::DeleteAllExpiredAPIKeys.endpoint(config)
29
+ },
30
+ schema: BetterAuth::APIKey::SchemaDefinition.schema(config, config[:schema]),
31
+ error_codes: BetterAuth::APIKey::ERROR_CODES,
32
+ options: config
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module APIKey
5
+ module RateLimit
6
+ module_function
7
+
8
+ def try_again_in(record, config, now)
9
+ return nil if config[:rate_limit][:enabled] == false || record["rateLimitEnabled"] == false
10
+
11
+ window = record["rateLimitTimeWindow"]
12
+ max = record["rateLimitMax"]
13
+ return nil if window.nil? || max.nil?
14
+
15
+ last = Utils.normalize_time(record["lastRequest"])
16
+ return nil unless last
17
+
18
+ elapsed_ms = (now - last) * 1000
19
+ return nil if elapsed_ms > window.to_i
20
+ return nil if record["requestCount"].to_i < max.to_i
21
+
22
+ (window.to_i - elapsed_ms).ceil
23
+ end
24
+
25
+ def counts_requests?(record, config)
26
+ return false if config[:rate_limit][:enabled] == false || record["rateLimitEnabled"] == false
27
+
28
+ !record["rateLimitTimeWindow"].nil? && !record["rateLimitMax"].nil?
29
+ end
30
+
31
+ def next_request_count(record, now)
32
+ last = Utils.normalize_time(record["lastRequest"])
33
+ window = record["rateLimitTimeWindow"].to_i
34
+ return 1 unless last && window.positive?
35
+
36
+ elapsed_ms = (now - last) * 1000
37
+ (elapsed_ms <= window) ? record["requestCount"].to_i + 1 : 1
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module APIKey
5
+ module Routes
6
+ module CreateAPIKey
7
+ UPSTREAM_SOURCE = "upstream/packages/api-key/src/routes/create-api-key.ts"
8
+
9
+ module_function
10
+
11
+ def endpoint(config)
12
+ BetterAuth::Endpoint.new(path: "/api-key/create", method: "POST") do |ctx|
13
+ body = BetterAuth::Plugins.api_key_normalize_body(ctx.body)
14
+ resolved_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, body[:config_id])
15
+ session = BetterAuth::Routes.current_session(ctx, allow_nil: true)
16
+ reference_id = BetterAuth::Plugins.api_key_create_reference_id!(ctx, body, session, resolved_config)
17
+
18
+ BetterAuth::Plugins.api_key_validate_create_update!(body, resolved_config, create: true, client: !ctx.headers.empty?)
19
+ BetterAuth::Plugins.api_key_delete_expired(ctx.context, resolved_config)
20
+ key_prefix = body.key?(:prefix) ? body[:prefix] : resolved_config[:default_prefix]
21
+ key = BetterAuth::Plugins.api_key_generate_key(resolved_config, key_prefix)
22
+ now = Time.now
23
+ hashed = BetterAuth::Plugins.api_key_hash(key, resolved_config)
24
+ data = {
25
+ configId: resolved_config[:config_id] || "default",
26
+ name: body[:name],
27
+ start: resolved_config[:starting_characters_config][:should_store] ? key[0, resolved_config[:starting_characters_config][:characters_length].to_i] : nil,
28
+ prefix: key_prefix,
29
+ key: hashed,
30
+ referenceId: reference_id,
31
+ enabled: true,
32
+ rateLimitEnabled: body.key?(:rate_limit_enabled) ? body[:rate_limit_enabled] : resolved_config[:rate_limit][:enabled],
33
+ rateLimitTimeWindow: body[:rate_limit_time_window] || resolved_config[:rate_limit][:time_window],
34
+ rateLimitMax: body[:rate_limit_max] || resolved_config[:rate_limit][:max_requests],
35
+ requestCount: 0,
36
+ remaining: body.key?(:remaining) ? body[:remaining] : nil,
37
+ refillAmount: body[:refill_amount],
38
+ refillInterval: body[:refill_interval],
39
+ lastRefillAt: nil,
40
+ expiresAt: BetterAuth::Plugins.api_key_expires_at(body, resolved_config),
41
+ createdAt: now,
42
+ updatedAt: now,
43
+ permissions: BetterAuth::Plugins.api_key_encode_json(body[:permissions] || BetterAuth::Plugins.api_key_default_permissions(resolved_config, reference_id, ctx)),
44
+ metadata: body.key?(:metadata) ? BetterAuth::Plugins.api_key_encode_json(body[:metadata]) : nil
45
+ }
46
+ record = BetterAuth::Plugins.api_key_store(ctx, data, resolved_config)
47
+ BetterAuth::Plugins.api_key_public(record, reveal_key: key, include_key_field: true)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module APIKey
5
+ module Routes
6
+ module DeleteAllExpiredAPIKeys
7
+ UPSTREAM_SOURCE = "upstream/packages/api-key/src/routes/delete-all-expired-api-keys.ts"
8
+
9
+ module_function
10
+
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)
14
+ ctx.json({success: true, error: nil})
15
+ rescue => error
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})
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end