better_auth-api-key 0.2.1 → 0.5.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 +4 -4
- data/lib/better_auth/api_key/adapter.rb +245 -0
- data/lib/better_auth/api_key/configuration.rb +78 -0
- data/lib/better_auth/api_key/error_codes.rb +39 -0
- data/lib/better_auth/api_key/keys.rb +55 -0
- data/lib/better_auth/api_key/org_authorization.rb +80 -0
- data/lib/better_auth/api_key/plugin_factory.rb +37 -0
- data/lib/better_auth/api_key/rate_limit.rb +41 -0
- data/lib/better_auth/api_key/routes/create_api_key.rb +53 -0
- data/lib/better_auth/api_key/routes/delete_all_expired_api_keys.rb +23 -0
- data/lib/better_auth/api_key/routes/delete_api_key.rb +35 -0
- data/lib/better_auth/api_key/routes/get_api_key.rb +33 -0
- data/lib/better_auth/api_key/routes/index.rb +72 -0
- data/lib/better_auth/api_key/routes/list_api_keys.rb +44 -0
- data/lib/better_auth/api_key/routes/update_api_key.rb +45 -0
- data/lib/better_auth/api_key/routes/verify_api_key.rb +43 -0
- data/lib/better_auth/api_key/schema.rb +40 -0
- data/lib/better_auth/api_key/session.rb +63 -0
- data/lib/better_auth/api_key/types.rb +30 -0
- data/lib/better_auth/api_key/utils.rb +93 -0
- data/lib/better_auth/api_key/validation.rb +137 -0
- data/lib/better_auth/api_key/version.rb +1 -1
- data/lib/better_auth/api_key.rb +20 -0
- data/lib/better_auth/plugins/api_key.rb +91 -824
- metadata +21 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1dab9830795b22b5ec2a33582da94cb767dcfea047d46521d6580f2756b4e497
|
|
4
|
+
data.tar.gz: ec8579ade754f0376594f58a559ee6819384077dc271c3647cbbe9ce71cbf394
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e5e507af2857165d9b61143f7d3f419296220116c09abb97c0a9a4aaf19244d8dc3c837946e614b3a15065a57ec97d86759f31fd707081709585bb8750ecf48c
|
|
7
|
+
data.tar.gz: da46bca9cdbc09f96dbb2a9a73d2cb23073f912e3ab8a4b1e1e341f852c0308416a44f6cf7bf943e66b0127b23a19cc01f4d40d6cb557493edaf2ed8275f0542
|
|
@@ -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
|