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.
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module APIKey
5
+ module Routes
6
+ module DeleteAPIKey
7
+ UPSTREAM_SOURCE = "upstream/packages/api-key/src/routes/delete-api-key.ts"
8
+
9
+ module_function
10
+
11
+ def endpoint(config)
12
+ BetterAuth::Endpoint.new(path: "/api-key/delete", method: "POST") do |ctx|
13
+ session = BetterAuth::Routes.current_session(ctx)
14
+ raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["USER_BANNED"]) if session[:user]["banned"] == true
15
+
16
+ body = BetterAuth::Plugins.normalize_hash(ctx.body)
17
+ resolved_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, body[:config_id])
18
+ key_id = body[:key_id]
19
+ record = BetterAuth::Plugins.api_key_find_by_id(ctx, key_id, resolved_config)
20
+ unless record && BetterAuth::Plugins.api_key_config_id_matches?(BetterAuth::Plugins.api_key_record_config_id(record), resolved_config[:config_id])
21
+ raise BetterAuth::APIError.new("NOT_FOUND", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["KEY_NOT_FOUND"])
22
+ end
23
+
24
+ record_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, BetterAuth::Plugins.api_key_record_config_id(record))
25
+ BetterAuth::Plugins.api_key_authorize_reference!(ctx, record_config, session[:user]["id"], BetterAuth::Plugins.api_key_record_reference_id(record), "delete")
26
+
27
+ BetterAuth::Plugins.api_key_delete_record(ctx, record, record_config)
28
+ BetterAuth::Plugins.api_key_delete_expired(ctx.context, record_config)
29
+ ctx.json({success: true})
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module APIKey
5
+ module Routes
6
+ module GetAPIKey
7
+ UPSTREAM_SOURCE = "upstream/packages/api-key/src/routes/get-api-key.ts"
8
+
9
+ module_function
10
+
11
+ def endpoint(config)
12
+ BetterAuth::Endpoint.new(path: "/api-key/get", method: "GET") do |ctx|
13
+ session = BetterAuth::Routes.current_session(ctx)
14
+ query = BetterAuth::Plugins.normalize_hash(ctx.query)
15
+ resolved_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, query[:config_id])
16
+ id = query[:id]
17
+ record = BetterAuth::Plugins.api_key_find_by_id(ctx, id, resolved_config)
18
+ unless record && BetterAuth::Plugins.api_key_config_id_matches?(BetterAuth::Plugins.api_key_record_config_id(record), resolved_config[:config_id])
19
+ raise BetterAuth::APIError.new("NOT_FOUND", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["KEY_NOT_FOUND"])
20
+ end
21
+
22
+ record_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, BetterAuth::Plugins.api_key_record_config_id(record))
23
+ BetterAuth::Plugins.api_key_authorize_reference!(ctx, record_config, session[:user]["id"], BetterAuth::Plugins.api_key_record_reference_id(record), "read")
24
+
25
+ record = BetterAuth::Plugins.api_key_migrate_legacy_metadata(ctx, record, record_config)
26
+ BetterAuth::Plugins.api_key_delete_expired(ctx.context, record_config)
27
+ ctx.json(BetterAuth::Plugins.api_key_public(record, include_key_field: false))
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module APIKey
5
+ module Routes
6
+ ROUTE_NAMES = %i[
7
+ create_api_key
8
+ verify_api_key
9
+ get_api_key
10
+ update_api_key
11
+ delete_api_key
12
+ list_api_keys
13
+ delete_all_expired_api_keys
14
+ ].freeze
15
+
16
+ module_function
17
+
18
+ def resolve_config(context, config, config_id = nil)
19
+ configurations = config.fetch(:configurations, [config])
20
+ return configurations.find { |entry| default_config_id?(entry[:config_id]) } || configurations.first if config_id.to_s.empty?
21
+
22
+ configurations.find { |entry| entry[:config_id].to_s == config_id.to_s } ||
23
+ begin
24
+ default = configurations.find { |entry| default_config_id?(entry[:config_id]) }
25
+ unless default
26
+ context.logger.error(BetterAuth::Plugins::API_KEY_ERROR_CODES["NO_DEFAULT_API_KEY_CONFIGURATION_FOUND"]) if context.respond_to?(:logger) && context.logger.respond_to?(:error)
27
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["NO_DEFAULT_API_KEY_CONFIGURATION_FOUND"])
28
+ end
29
+ default
30
+ end
31
+ end
32
+
33
+ def default_config_id?(value)
34
+ value.nil? || value.to_s.empty? || value.to_s == "default"
35
+ end
36
+
37
+ def config_id_matches?(record_config_id, expected_config_id)
38
+ return true if default_config_id?(record_config_id) && default_config_id?(expected_config_id)
39
+
40
+ record_config_id.to_s == expected_config_id.to_s
41
+ end
42
+
43
+ @last_expired_check = nil
44
+
45
+ def delete_expired(context, config, bypass_last_check: false)
46
+ return unless config[:storage] == "database" || config[:fallback_to_database]
47
+ unless bypass_last_check
48
+ now = Time.now
49
+ return if @last_expired_check && ((now - @last_expired_check) * 1000) < 10_000
50
+
51
+ @last_expired_check = now
52
+ end
53
+
54
+ expired = context.adapter.find_many(model: BetterAuth::Plugins::API_KEY_TABLE_NAME).select do |record|
55
+ record["expiresAt"] && record["expiresAt"] < Time.now
56
+ end
57
+ expired.each do |record|
58
+ context.adapter.delete(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: [{field: "id", value: record["id"]}])
59
+ end
60
+ end
61
+
62
+ def schedule_cleanup(ctx, config)
63
+ task = -> { delete_expired(ctx.context, config) }
64
+ if config[:defer_updates] && BetterAuth::APIKey::Utils.background_tasks?(ctx)
65
+ ctx.context.run_in_background(task)
66
+ else
67
+ task.call
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module APIKey
5
+ module Routes
6
+ module ListAPIKeys
7
+ UPSTREAM_SOURCE = "upstream/packages/api-key/src/routes/list-api-keys.ts"
8
+
9
+ module_function
10
+
11
+ def endpoint(config)
12
+ BetterAuth::Endpoint.new(path: "/api-key/list", method: "GET") do |ctx|
13
+ session = BetterAuth::Routes.current_session(ctx)
14
+ query = BetterAuth::Plugins.normalize_hash(ctx.query)
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])
17
+ reference_id = query[:organization_id] || session[:user]["id"]
18
+ expected_reference = query[:organization_id] ? "organization" : "user"
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
+ offset = query.key?(:offset) ? query[:offset].to_i : nil
30
+ 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))) }
34
+ api_keys = records.map do |record|
35
+ record_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, BetterAuth::Plugins.api_key_record_config_id(record))
36
+ BetterAuth::Plugins.api_key_public(BetterAuth::Plugins.api_key_migrate_legacy_metadata(ctx, record, record_config), include_key_field: false)
37
+ end
38
+ ctx.json({apiKeys: api_keys, total: total, limit: limit, offset: offset})
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module APIKey
5
+ module Routes
6
+ module UpdateAPIKey
7
+ UPSTREAM_SOURCE = "upstream/packages/api-key/src/routes/update-api-key.ts"
8
+
9
+ module_function
10
+
11
+ def endpoint(config)
12
+ BetterAuth::Endpoint.new(path: "/api-key/update", 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
+ user_id = session&.dig(:user, "id") || body[:user_id]
17
+ raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["UNAUTHORIZED_SESSION"]) unless user_id
18
+ if session && body[:user_id] && body[:user_id] != session[:user]["id"]
19
+ raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["UNAUTHORIZED_SESSION"])
20
+ end
21
+
22
+ key_id = body[:key_id]
23
+ record = BetterAuth::Plugins.api_key_find_by_id(ctx, key_id, resolved_config)
24
+ raise BetterAuth::APIError.new("NOT_FOUND", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["KEY_NOT_FOUND"]) unless record
25
+ unless BetterAuth::Plugins.api_key_config_id_matches?(BetterAuth::Plugins.api_key_record_config_id(record), resolved_config[:config_id])
26
+ raise BetterAuth::APIError.new("NOT_FOUND", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["KEY_NOT_FOUND"])
27
+ end
28
+
29
+ record_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, BetterAuth::Plugins.api_key_record_config_id(record))
30
+ BetterAuth::Plugins.api_key_authorize_reference!(ctx, record_config, user_id, BetterAuth::Plugins.api_key_record_reference_id(record), "update")
31
+
32
+ BetterAuth::Plugins.api_key_validate_create_update!(body, record_config, create: false, client: BetterAuth::Plugins.api_key_auth_required?(ctx))
33
+ update = BetterAuth::Plugins.api_key_update_payload(body, record_config)
34
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["NO_VALUES_TO_UPDATE"]) if update.empty?
35
+
36
+ updated = BetterAuth::Plugins.api_key_update_record(ctx, record, update.merge(updatedAt: Time.now), record_config)
37
+ updated = BetterAuth::Plugins.api_key_migrate_legacy_metadata(ctx, updated, record_config)
38
+ BetterAuth::Plugins.api_key_delete_expired(ctx.context, record_config)
39
+ ctx.json(BetterAuth::Plugins.api_key_public(updated, include_key_field: false))
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module APIKey
5
+ module Routes
6
+ module VerifyAPIKey
7
+ UPSTREAM_SOURCE = "upstream/packages/api-key/src/routes/verify-api-key.ts"
8
+
9
+ module_function
10
+
11
+ def endpoint(config)
12
+ BetterAuth::Endpoint.new(path: "/api-key/verify", method: "POST") do |ctx|
13
+ body = BetterAuth::Plugins.normalize_hash(ctx.body)
14
+ resolved_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, body[:config_id])
15
+ key = body[:key]
16
+ if key.to_s.empty?
17
+ raise BetterAuth::APIError.new(
18
+ "FORBIDDEN",
19
+ message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"],
20
+ code: "INVALID_API_KEY"
21
+ )
22
+ end
23
+
24
+ if resolved_config[:custom_api_key_validator].respond_to?(:call) && !resolved_config[:custom_api_key_validator].call({ctx: ctx, key: key})
25
+ ctx.json({valid: false, error: {message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"], code: "KEY_NOT_FOUND"}, key: nil})
26
+ else
27
+ record = BetterAuth::Plugins.api_key_validate!(ctx, key, resolved_config, permissions: body[:permissions])
28
+ record_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, BetterAuth::Plugins.api_key_record_config_id(record))
29
+ BetterAuth::Plugins.api_key_schedule_cleanup(ctx, record_config)
30
+ ctx.json({valid: true, error: nil, key: BetterAuth::Plugins.api_key_public(record, include_key_field: false)})
31
+ end
32
+ rescue BetterAuth::APIError => error
33
+ ctx.context.logger.error("Failed to validate API key: #{error.message}") if ctx.context.logger.respond_to?(:error)
34
+ ctx.json({valid: false, error: BetterAuth::Plugins.api_key_error_payload(error), key: nil})
35
+ rescue => error
36
+ ctx.context.logger.error("Failed to validate API key: #{error.message}") if ctx.context.logger.respond_to?(:error)
37
+ ctx.json({valid: false, error: {message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"], code: "INVALID_API_KEY"}, key: nil})
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module APIKey
5
+ module SchemaDefinition
6
+ module_function
7
+
8
+ def schema(config, custom_schema = nil)
9
+ base = {
10
+ apikey: {
11
+ fields: {
12
+ configId: {type: "string", required: true, default_value: "default", index: true},
13
+ name: {type: "string", required: false},
14
+ start: {type: "string", required: false},
15
+ prefix: {type: "string", required: false},
16
+ key: {type: "string", required: true, index: true},
17
+ referenceId: {type: "string", required: true, index: true},
18
+ refillInterval: {type: "number", required: false},
19
+ refillAmount: {type: "number", required: false},
20
+ lastRefillAt: {type: "date", required: false},
21
+ enabled: {type: "boolean", required: false, default_value: true},
22
+ rateLimitEnabled: {type: "boolean", required: false, default_value: true},
23
+ rateLimitTimeWindow: {type: "number", required: false, default_value: config[:rate_limit][:time_window]},
24
+ rateLimitMax: {type: "number", required: false, default_value: config[:rate_limit][:max_requests]},
25
+ requestCount: {type: "number", required: false, default_value: 0},
26
+ remaining: {type: "number", required: false},
27
+ lastRequest: {type: "date", required: false},
28
+ expiresAt: {type: "date", required: false},
29
+ createdAt: {type: "date", required: true},
30
+ updatedAt: {type: "date", required: true},
31
+ permissions: {type: "string", required: false},
32
+ metadata: {type: "string", required: false}
33
+ }
34
+ }
35
+ }
36
+ BetterAuth::Plugins.deep_merge_hashes(base, BetterAuth::Plugins.normalize_hash(custom_schema || {}))
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module APIKey
5
+ module Session
6
+ module_function
7
+
8
+ def header_config(ctx, config)
9
+ config.fetch(:configurations, [config]).find do |entry|
10
+ entry[:enable_session_for_api_keys] && BetterAuth::APIKey::Keys.from_headers(ctx, entry)
11
+ end
12
+ end
13
+
14
+ def hook(ctx, config)
15
+ config = header_config(ctx, config) || config
16
+ key = BetterAuth::APIKey::Keys.from_headers(ctx, config)
17
+ unless key.is_a?(String)
18
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY_GETTER_RETURN_TYPE"])
19
+ end
20
+ raise BetterAuth::APIError.new("FORBIDDEN", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"]) if key.length < config[:default_key_length].to_i
21
+
22
+ if config[:custom_api_key_validator].respond_to?(:call) && !config[:custom_api_key_validator].call({ctx: ctx, key: key})
23
+ raise BetterAuth::APIError.new("FORBIDDEN", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"])
24
+ end
25
+
26
+ record = BetterAuth::Plugins.api_key_validate!(ctx, key, config)
27
+ BetterAuth::APIKey::Routes.schedule_cleanup(ctx, config)
28
+ if config[:references].to_s != "user"
29
+ raise BetterAuth::APIError.new(
30
+ "UNAUTHORIZED",
31
+ message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_REFERENCE_ID_FROM_API_KEY"],
32
+ code: "INVALID_REFERENCE_ID_FROM_API_KEY"
33
+ )
34
+ end
35
+ reference_id = BetterAuth::APIKey::Types.record_reference_id(record)
36
+ user = ctx.context.internal_adapter.find_user_by_id(reference_id)
37
+ unless user
38
+ raise BetterAuth::APIError.new(
39
+ "UNAUTHORIZED",
40
+ message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_REFERENCE_ID_FROM_API_KEY"],
41
+ code: "INVALID_REFERENCE_ID_FROM_API_KEY"
42
+ )
43
+ end
44
+
45
+ session = {
46
+ user: user,
47
+ session: {
48
+ "id" => record["id"],
49
+ "token" => key,
50
+ "userId" => reference_id,
51
+ "userAgent" => ctx.headers["user-agent"],
52
+ "ipAddress" => BetterAuth::RequestIP.client_ip(ctx.request || ctx.headers, ctx.context.options),
53
+ "createdAt" => Time.now,
54
+ "updatedAt" => Time.now,
55
+ "expiresAt" => record["expiresAt"] || (Time.now + ctx.context.options.session[:expires_in].to_i)
56
+ }
57
+ }
58
+ ctx.context.set_current_session(session)
59
+ nil
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module APIKey
5
+ module Types
6
+ API_KEY_TABLE_NAME = "apikey"
7
+
8
+ module_function
9
+
10
+ def record_reference_id(record)
11
+ record["referenceId"] || record[:referenceId] || record["userId"] || record[:userId]
12
+ end
13
+
14
+ def record_user_id(record)
15
+ record["userId"] || record[:userId] || (BetterAuth::APIKey::Routes.default_config_id?(record["configId"] || record[:configId]) && (record["referenceId"] || record[:referenceId]))
16
+ end
17
+
18
+ def record_config_id(record)
19
+ record["configId"] || record[:configId] || "default"
20
+ end
21
+
22
+ def default_permissions(config, reference_id, ctx)
23
+ permissions = config.dig(:permissions, :default_permissions) || config[:default_permissions]
24
+ return permissions.call(reference_id, ctx) if permissions.respond_to?(:call)
25
+
26
+ permissions
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module BetterAuth
7
+ module APIKey
8
+ module Utils
9
+ module_function
10
+
11
+ def encode_json(value)
12
+ return nil if value.nil?
13
+
14
+ JSON.generate(value)
15
+ end
16
+
17
+ def decode_json(value)
18
+ return nil if value.nil?
19
+ return value if value.is_a?(Hash)
20
+
21
+ parsed = JSON.parse(value.to_s)
22
+ parsed.is_a?(String) ? decode_json(parsed) : parsed
23
+ rescue JSON::ParserError
24
+ nil
25
+ end
26
+
27
+ def normalize_time(value)
28
+ return value if value.is_a?(Time)
29
+ return nil if value.nil?
30
+
31
+ Time.parse(value.to_s)
32
+ rescue ArgumentError
33
+ nil
34
+ end
35
+
36
+ def public_record(record, reveal_key: nil, include_key_field: false)
37
+ data = record.transform_keys(&:to_sym)
38
+ output = data.except(:key)
39
+ output[:configId] ||= BetterAuth::APIKey::Types.record_config_id(record)
40
+ output[:referenceId] ||= BetterAuth::APIKey::Types.record_reference_id(record)
41
+ output[:key] = reveal_key if include_key_field && reveal_key
42
+ output[:metadata] = decode_json(data[:metadata])
43
+ output[:permissions] = decode_json(data[:permissions])
44
+ output
45
+ end
46
+
47
+ def sort_records(records, sort_by, direction)
48
+ return records unless sort_by
49
+
50
+ key = BetterAuth::Schema.storage_key(sort_by)
51
+ sorted = records.sort_by { |record| record[key] || record[key.to_sym] || "" }
52
+ if direction.to_s.downcase == "desc"
53
+ sorted.reverse
54
+ else
55
+ sorted
56
+ end
57
+ end
58
+
59
+ def validate_list_query!(query)
60
+ %i[limit offset].each do |key|
61
+ next unless query.key?(key)
62
+
63
+ value = query[key]
64
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: "Invalid #{key}") unless value.to_s.match?(/\A\d+\z/)
65
+ end
66
+
67
+ direction = query[:sort_direction]
68
+ return if direction.nil? || %w[asc desc].include?(direction.to_s.downcase)
69
+
70
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: "Invalid sortDirection")
71
+ end
72
+
73
+ def error_code(error)
74
+ BetterAuth::Plugins::API_KEY_ERROR_CODES.key(error.message) || error.code.to_s
75
+ end
76
+
77
+ def error_payload(error)
78
+ payload = error.to_h
79
+ return payload if payload.is_a?(Hash) && payload.key?(:details)
80
+
81
+ {message: error.message, code: error_code(error)}
82
+ end
83
+
84
+ def background_tasks?(ctx)
85
+ ctx.context.options.advanced.dig(:background_tasks, :handler).respond_to?(:call)
86
+ end
87
+
88
+ def auth_required?(ctx)
89
+ !!(ctx.request || (ctx.headers && !ctx.headers.empty?))
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module APIKey
5
+ module Validation
6
+ module_function
7
+
8
+ def validate_create_update!(body, config, create:, client:)
9
+ name = body[:name]
10
+ if create && config[:require_name] && name.to_s.empty?
11
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["NAME_REQUIRED"])
12
+ end
13
+ if name && !name.to_s.length.between?(config[:minimum_name_length].to_i, config[:maximum_name_length].to_i)
14
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_NAME_LENGTH"])
15
+ end
16
+ prefix = body[:prefix]
17
+ if prefix && !prefix.to_s.length.between?(config[:minimum_prefix_length].to_i, config[:maximum_prefix_length].to_i)
18
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_PREFIX_LENGTH"])
19
+ end
20
+ if prefix && !prefix.to_s.match?(/\A[a-zA-Z0-9_-]+\z/)
21
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_PREFIX_LENGTH"])
22
+ end
23
+ if body.key?(:remaining) && !body[:remaining].nil?
24
+ minimum = create ? 0 : 1
25
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_REMAINING"]) if body[:remaining].to_i < minimum
26
+ end
27
+ if body[:metadata] && (create || config[:enable_metadata])
28
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["METADATA_DISABLED"]) unless config[:enable_metadata]
29
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_METADATA_TYPE"]) unless body[:metadata].nil? || body[:metadata].is_a?(Hash)
30
+ end
31
+ server_only_keys = %i[refill_amount refill_interval rate_limit_max rate_limit_time_window rate_limit_enabled remaining permissions]
32
+ if client && server_only_keys.any? { |key| (create && key == :remaining) ? !body[:remaining].nil? : body.key?(key) }
33
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["SERVER_ONLY_PROPERTY"])
34
+ end
35
+ amount_present = body.key?(:refill_amount)
36
+ interval_present = body.key?(:refill_interval)
37
+ if amount_present && !interval_present
38
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["REFILL_AMOUNT_AND_INTERVAL_REQUIRED"])
39
+ end
40
+ if interval_present && !amount_present
41
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["REFILL_INTERVAL_AND_AMOUNT_REQUIRED"])
42
+ end
43
+ if body.key?(:expires_in)
44
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["KEY_DISABLED_EXPIRATION"]) if config[:key_expiration][:disable_custom_expires_time]
45
+ return if body[:expires_in].nil?
46
+
47
+ days = body[:expires_in].to_f / 86_400
48
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["EXPIRES_IN_IS_TOO_SMALL"]) if days < config[:key_expiration][:min_expires_in].to_f
49
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["EXPIRES_IN_IS_TOO_LARGE"]) if days > config[:key_expiration][:max_expires_in].to_f
50
+ end
51
+ end
52
+
53
+ def update_payload(body, config)
54
+ update = {}
55
+ update[:name] = body[:name] if body.key?(:name)
56
+ update[:enabled] = body[:enabled] unless body[:enabled].nil?
57
+ update[:remaining] = body[:remaining] if body.key?(:remaining)
58
+ update[:refillAmount] = body[:refill_amount] if body.key?(:refill_amount)
59
+ update[:refillInterval] = body[:refill_interval] if body.key?(:refill_interval)
60
+ update[:rateLimitEnabled] = body[:rate_limit_enabled] if body.key?(:rate_limit_enabled)
61
+ update[:rateLimitTimeWindow] = body[:rate_limit_time_window] if body.key?(:rate_limit_time_window)
62
+ update[:rateLimitMax] = body[:rate_limit_max] if body.key?(:rate_limit_max)
63
+ update[:expiresAt] = body[:expires_in].nil? ? nil : Time.now + body[:expires_in].to_i if body.key?(:expires_in)
64
+ update[:metadata] = BetterAuth::APIKey::Utils.encode_json(body[:metadata]) if body.key?(:metadata) && config[:enable_metadata]
65
+ update[:permissions] = BetterAuth::APIKey::Utils.encode_json(body[:permissions]) if body.key?(:permissions)
66
+ update
67
+ end
68
+
69
+ def validate_api_key!(ctx, key, config, permissions: nil)
70
+ 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
85
+
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)
90
+ end
91
+
92
+ def usage_update(record, config)
93
+ now = Time.now
94
+ update = {lastRequest: now, updatedAt: now}
95
+
96
+ if (try_again_in = BetterAuth::APIKey::RateLimit.try_again_in(record, config, now))
97
+ raise BetterAuth::APIError.new(
98
+ "UNAUTHORIZED",
99
+ message: BetterAuth::Plugins::API_KEY_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
100
+ code: "RATE_LIMITED",
101
+ body: {
102
+ message: BetterAuth::Plugins::API_KEY_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
103
+ code: "RATE_LIMITED",
104
+ details: {tryAgainIn: try_again_in}
105
+ }
106
+ )
107
+ end
108
+ update[:requestCount] = BetterAuth::APIKey::RateLimit.next_request_count(record, now) if BetterAuth::APIKey::RateLimit.counts_requests?(record, config)
109
+
110
+ remaining = record["remaining"]
111
+ if !remaining.nil?
112
+ if remaining.to_i <= 0 && record["refillAmount"] && record["refillInterval"]
113
+ last_refill = BetterAuth::APIKey::Utils.normalize_time(record["lastRefillAt"] || record["createdAt"])
114
+ if !last_refill || ((now - last_refill) * 1000) > record["refillInterval"].to_i
115
+ remaining = record["refillAmount"].to_i
116
+ update[:lastRefillAt] = now
117
+ end
118
+ end
119
+ raise BetterAuth::APIError.new("TOO_MANY_REQUESTS", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["USAGE_EXCEEDED"]) if remaining.to_i <= 0
120
+
121
+ update[:remaining] = remaining.to_i - 1
122
+ end
123
+ update
124
+ end
125
+
126
+ def check_permissions!(record, required)
127
+ return if required.nil? || required == {}
128
+
129
+ actual = BetterAuth::APIKey::Utils.decode_json(record["permissions"]) || {}
130
+ result = BetterAuth::Plugins::Role.new(actual).authorize(required)
131
+ unless result[:success]
132
+ raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["KEY_NOT_FOUND"], code: "KEY_NOT_FOUND")
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BetterAuth
4
4
  module APIKey
5
- VERSION = "0.2.1"
5
+ VERSION = "0.6.2"
6
6
  end
7
7
  end