api_keys 0.2.1 ā 0.4.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/CHANGELOG.md +60 -0
- data/README.md +851 -25
- data/SECURITY.md +33 -0
- data/app/controllers/api_keys/application_controller.rb +58 -10
- data/app/controllers/api_keys/keys_controller.rb +77 -23
- data/app/controllers/api_keys/security_controller.rb +8 -0
- data/app/views/api_keys/keys/_empty_state.html.erb +9 -0
- data/app/views/api_keys/keys/_form.html.erb +33 -4
- data/app/views/api_keys/keys/_key_actions.html.erb +20 -0
- data/app/views/api_keys/keys/_key_badges.html.erb +17 -0
- data/app/views/api_keys/keys/_key_row.html.erb +21 -35
- data/app/views/api_keys/keys/_key_status.html.erb +10 -0
- data/app/views/api_keys/keys/_keys_table.html.erb +3 -11
- data/app/views/api_keys/keys/_publishable_keys.html.erb +40 -0
- data/app/views/api_keys/keys/_secret_keys.html.erb +39 -0
- data/app/views/api_keys/keys/_show_token.html.erb +10 -47
- data/app/views/api_keys/keys/_token_display.html.erb +11 -0
- data/app/views/api_keys/keys/index.html.erb +40 -8
- data/app/views/api_keys/keys/show.html.erb +2 -2
- data/app/views/api_keys/security/best_practices.html.erb +73 -47
- data/app/views/layouts/api_keys/application.html.erb +267 -14
- data/lib/api_keys/authentication.rb +39 -11
- data/lib/api_keys/configuration.rb +444 -17
- data/lib/api_keys/engine.rb +5 -20
- data/lib/api_keys/errors.rb +73 -0
- data/lib/api_keys/form_builder_extensions.rb +168 -0
- data/lib/api_keys/helpers/expiration_options.rb +139 -0
- data/lib/api_keys/helpers/token_session.rb +203 -0
- data/lib/api_keys/helpers/view_helpers.rb +220 -0
- data/lib/api_keys/jobs/callbacks_job.rb +10 -17
- data/lib/api_keys/jobs/update_stats_job.rb +27 -12
- data/lib/api_keys/models/api_key.rb +452 -21
- data/lib/api_keys/models/concerns/has_api_keys.rb +269 -26
- data/lib/api_keys/services/authenticator.rb +300 -112
- data/lib/api_keys/services/digestor.rb +81 -14
- data/lib/api_keys/services/token_generator.rb +41 -1
- data/lib/api_keys/tenant_resolution.rb +4 -4
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +12 -0
- data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
- data/lib/generators/api_keys/add_key_types_generator.rb +68 -0
- data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +32 -0
- data/lib/generators/api_keys/templates/add_key_types_to_api_keys.rb.erb +18 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +11 -3
- data/lib/generators/api_keys/templates/initializer.rb +261 -120
- metadata +29 -63
- data/Rakefile +0 -32
|
@@ -7,13 +7,25 @@ module ApiKeys
|
|
|
7
7
|
module Services
|
|
8
8
|
# Generates secure, random API tokens according to configured settings.
|
|
9
9
|
class TokenGenerator
|
|
10
|
+
MIN_RANDOM_BYTES = 16
|
|
11
|
+
MAX_RANDOM_BYTES = 64
|
|
12
|
+
MAX_PREFIX_BYTESIZE = 64
|
|
13
|
+
|
|
10
14
|
# Generates a new token string.
|
|
11
15
|
#
|
|
12
16
|
# @param length [Integer] The desired byte length of the random part (before encoding).
|
|
13
17
|
# @param prefix [String] The prefix to prepend to the token.
|
|
14
18
|
# @param alphabet [Symbol] The encoding alphabet (:base58 or :hex).
|
|
15
19
|
# @return [String] The generated token including the prefix.
|
|
16
|
-
def self.call(length:
|
|
20
|
+
def self.call(length: nil, prefix: nil, alphabet: nil)
|
|
21
|
+
length ||= ApiKeys.configuration.token_length
|
|
22
|
+
prefix ||= ApiKeys.configuration.resolved_token_prefix
|
|
23
|
+
alphabet ||= ApiKeys.configuration.token_alphabet
|
|
24
|
+
|
|
25
|
+
validate_length!(length)
|
|
26
|
+
validate_prefix!(prefix)
|
|
27
|
+
validate_alphabet!(alphabet)
|
|
28
|
+
|
|
17
29
|
random_bytes = SecureRandom.bytes(length)
|
|
18
30
|
|
|
19
31
|
random_part = case alphabet
|
|
@@ -27,6 +39,34 @@ module ApiKeys
|
|
|
27
39
|
|
|
28
40
|
"#{prefix}#{random_part}"
|
|
29
41
|
end
|
|
42
|
+
|
|
43
|
+
def self.validate_length!(length)
|
|
44
|
+
return if length.is_a?(Integer) && length.between?(MIN_RANDOM_BYTES, MAX_RANDOM_BYTES)
|
|
45
|
+
|
|
46
|
+
raise ArgumentError,
|
|
47
|
+
"Token length must be an Integer between #{MIN_RANDOM_BYTES} and #{MAX_RANDOM_BYTES} random bytes."
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.validate_prefix!(prefix)
|
|
51
|
+
valid = prefix.is_a?(String) && prefix.present? && prefix.valid_encoding? &&
|
|
52
|
+
prefix.bytesize <= MAX_PREFIX_BYTESIZE &&
|
|
53
|
+
prefix.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
|
|
54
|
+
return if valid
|
|
55
|
+
|
|
56
|
+
raise ArgumentError,
|
|
57
|
+
"Token prefix must be a non-blank string of at most #{MAX_PREFIX_BYTESIZE} bytes without whitespace or control characters."
|
|
58
|
+
rescue ArgumentError
|
|
59
|
+
raise ArgumentError,
|
|
60
|
+
"Token prefix must be a non-blank string of at most #{MAX_PREFIX_BYTESIZE} bytes without whitespace or control characters."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.validate_alphabet!(alphabet)
|
|
64
|
+
return if %i[base58 hex].include?(alphabet)
|
|
65
|
+
|
|
66
|
+
raise ArgumentError, "Unsupported token alphabet: #{alphabet}. Use :base58 or :hex."
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private_class_method :validate_length!, :validate_prefix!, :validate_alphabet!
|
|
30
70
|
end
|
|
31
71
|
end
|
|
32
72
|
end
|
|
@@ -15,8 +15,6 @@ module ApiKeys
|
|
|
15
15
|
alias_method :current_api_key_tenant, :current_api_tenant
|
|
16
16
|
alias_method :current_api_account, :current_api_tenant
|
|
17
17
|
alias_method :current_api_key_account, :current_api_tenant
|
|
18
|
-
alias_method :current_api_owner, :current_api_tenant
|
|
19
|
-
alias_method :current_api_key_owner, :current_api_tenant
|
|
20
18
|
end
|
|
21
19
|
|
|
22
20
|
# Returns the tenant associated with the current API key.
|
|
@@ -31,9 +29,11 @@ module ApiKeys
|
|
|
31
29
|
|
|
32
30
|
resolver = ApiKeys.configuration.tenant_resolver
|
|
33
31
|
@current_api_tenant = resolver&.call(current_api_key)
|
|
34
|
-
rescue StandardError =>
|
|
32
|
+
rescue StandardError => error
|
|
35
33
|
# Log error but don't break the request if resolver fails
|
|
36
|
-
Rails
|
|
34
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
35
|
+
Rails.logger.error "[ApiKeys] Tenant resolution failed (#{error.class})."
|
|
36
|
+
end
|
|
37
37
|
@current_api_tenant = nil
|
|
38
38
|
end
|
|
39
39
|
end
|
data/lib/api_keys/version.rb
CHANGED
data/lib/api_keys.rb
CHANGED
|
@@ -53,12 +53,24 @@ end
|
|
|
53
53
|
|
|
54
54
|
require "api_keys/version"
|
|
55
55
|
require "api_keys/configuration" # Defines the ApiKeys::Configuration class
|
|
56
|
+
require "api_keys/errors" # Error classes for key types feature
|
|
56
57
|
|
|
57
58
|
# Files that might depend on ApiKeys.configuration being available
|
|
58
59
|
require "api_keys/controller" # This can lead to loading jobs, etc.
|
|
59
60
|
require "api_keys/models/concerns/has_api_keys"
|
|
60
61
|
require "api_keys/models/api_key"
|
|
61
62
|
|
|
63
|
+
# Helpers for integrators building custom UIs
|
|
64
|
+
require "api_keys/helpers/token_session"
|
|
65
|
+
require "api_keys/helpers/expiration_options"
|
|
66
|
+
require "api_keys/helpers/view_helpers"
|
|
67
|
+
require "api_keys/form_builder_extensions"
|
|
68
|
+
|
|
69
|
+
# Top-level aliases for cleaner API (ApiKeys::TokenSession instead of ApiKeys::Helpers::TokenSession)
|
|
70
|
+
ApiKeys::TokenSession = ApiKeys::Helpers::TokenSession
|
|
71
|
+
ApiKeys::ExpirationOptions = ApiKeys::Helpers::ExpirationOptions
|
|
72
|
+
ApiKeys::ViewHelpers = ApiKeys::Helpers::ViewHelpers
|
|
73
|
+
|
|
62
74
|
# Rails integration (Engine)
|
|
63
75
|
# The Engine might also access ApiKeys.configuration during its initialization.
|
|
64
76
|
require "api_keys/engine" if defined?(Rails)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module ApiKeys
|
|
7
|
+
module Generators
|
|
8
|
+
# Adds the bounded bcrypt authentication lookup index to existing installs.
|
|
9
|
+
class AddAuthenticationIndexGenerator < Rails::Generators::Base
|
|
10
|
+
include ActiveRecord::Generators::Migration
|
|
11
|
+
|
|
12
|
+
source_root File.expand_path("templates", __dir__)
|
|
13
|
+
|
|
14
|
+
def self.next_migration_number(dirname)
|
|
15
|
+
number = current_migration_number(dirname) + 1
|
|
16
|
+
ActiveRecord::Migration.next_migration_number(number)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create_migration_file
|
|
20
|
+
migration_template "add_authentication_index_to_api_keys.rb.erb",
|
|
21
|
+
File.join(db_migrate_path, "add_authentication_index_to_api_keys.rb")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def display_post_install_message
|
|
25
|
+
say "\nš API key authentication index migration created.", :green
|
|
26
|
+
say "Run `rails db:migrate` before deploying bcrypt authentication changes."
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def migration_version
|
|
32
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module ApiKeys
|
|
7
|
+
module Generators
|
|
8
|
+
# Rails generator for adding key_type and environment columns to the api_keys table.
|
|
9
|
+
# This generator is for existing installations that want to enable the key types feature.
|
|
10
|
+
class AddKeyTypesGenerator < Rails::Generators::Base
|
|
11
|
+
include ActiveRecord::Generators::Migration
|
|
12
|
+
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
|
|
15
|
+
# Implement the required interface for Rails::Generators::Migration.
|
|
16
|
+
def self.next_migration_number(dirname)
|
|
17
|
+
next_migration_number = current_migration_number(dirname) + 1
|
|
18
|
+
ActiveRecord::Migration.next_migration_number(next_migration_number)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Creates the migration file using the template.
|
|
22
|
+
def create_migration_file
|
|
23
|
+
migration_template "add_key_types_to_api_keys.rb.erb",
|
|
24
|
+
File.join(db_migrate_path, "add_key_types_to_api_keys.rb")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Displays helpful information to the user after installation.
|
|
28
|
+
def display_post_install_message
|
|
29
|
+
say "\nš Key types migration created!", :green
|
|
30
|
+
say "\nNext steps:"
|
|
31
|
+
say " 1. Run `rails db:migrate` to add the key_type and environment columns."
|
|
32
|
+
say "\n 2. Configure key types in `config/initializers/api_keys.rb`:"
|
|
33
|
+
say " ApiKeys.configure do |config|"
|
|
34
|
+
say " config.key_types = {"
|
|
35
|
+
say " publishable: {"
|
|
36
|
+
say " prefix: 'pk',"
|
|
37
|
+
say " permissions: %w[read validate],"
|
|
38
|
+
say " revocable: false,"
|
|
39
|
+
say " limit: 1"
|
|
40
|
+
say " },"
|
|
41
|
+
say " secret: {"
|
|
42
|
+
say " prefix: 'sk',"
|
|
43
|
+
say " permissions: :all"
|
|
44
|
+
say " }"
|
|
45
|
+
say " }"
|
|
46
|
+
say ""
|
|
47
|
+
say " config.environments = {"
|
|
48
|
+
say " test: { prefix_segment: 'test' },"
|
|
49
|
+
say " live: { prefix_segment: 'live' }"
|
|
50
|
+
say " }"
|
|
51
|
+
say ""
|
|
52
|
+
say " config.current_environment = -> { Rails.env.production? ? :live : :test }"
|
|
53
|
+
say " config.strict_environment_isolation = true"
|
|
54
|
+
say " end"
|
|
55
|
+
say "\n 3. Create typed API keys:"
|
|
56
|
+
say " user.create_api_key!(name: 'My Key', key_type: :publishable)"
|
|
57
|
+
say " user.create_api_key!(name: 'Admin Key', key_type: :secret)"
|
|
58
|
+
say "\nSee the api_keys README for detailed usage and examples.", :cyan
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def migration_version
|
|
64
|
+
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddAuthenticationIndexToApiKeys < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
INDEX_NAME = "index_api_keys_authentication_lookup"
|
|
5
|
+
COLUMNS = %i[prefix last4 digest_algorithm].freeze
|
|
6
|
+
|
|
7
|
+
disable_ddl_transaction!
|
|
8
|
+
|
|
9
|
+
def up
|
|
10
|
+
return if index_exists?(:api_keys, COLUMNS, name: INDEX_NAME)
|
|
11
|
+
|
|
12
|
+
options = { name: INDEX_NAME }
|
|
13
|
+
options[:algorithm] = :concurrently if connection.adapter_name.downcase.include?("postgres")
|
|
14
|
+
add_index :api_keys, COLUMNS, **options
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def down
|
|
18
|
+
return unless index_exists?(:api_keys, COLUMNS, name: INDEX_NAME)
|
|
19
|
+
|
|
20
|
+
options = { name: INDEX_NAME }
|
|
21
|
+
options[:algorithm] = :concurrently if connection.adapter_name.downcase.include?("postgres")
|
|
22
|
+
remove_index :api_keys, **options
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def migration_version
|
|
28
|
+
major = ActiveRecord::VERSION::MAJOR
|
|
29
|
+
minor = ActiveRecord::VERSION::MINOR
|
|
30
|
+
major >= 5 ? "[#{major}.#{minor}]" : ""
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Migration to add key_type and environment columns to the api_keys table.
|
|
4
|
+
# This enables the Stripe-style publishable/secret key types feature.
|
|
5
|
+
#
|
|
6
|
+
# Run this migration if you're upgrading from a version of api_keys that
|
|
7
|
+
# didn't have key types support.
|
|
8
|
+
class AddKeyTypesToApiKeys < ActiveRecord::Migration<%= migration_version %>
|
|
9
|
+
def change
|
|
10
|
+
add_column :api_keys, :key_type, :string
|
|
11
|
+
add_column :api_keys, :environment, :string
|
|
12
|
+
|
|
13
|
+
add_index :api_keys, :key_type
|
|
14
|
+
add_index :api_keys, :environment
|
|
15
|
+
add_index :api_keys, [:owner_type, :owner_id, :key_type, :environment],
|
|
16
|
+
name: "index_api_keys_owner_type_env"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -42,6 +42,12 @@ class CreateApiKeysTable < ActiveRecord::Migration<%= migration_version %>
|
|
|
42
42
|
# Timestamp when the key was revoked (null means active)
|
|
43
43
|
t.datetime :revoked_at
|
|
44
44
|
|
|
45
|
+
# Key type identifier (e.g., "publishable", "secret") for Stripe-style key types
|
|
46
|
+
t.string :key_type
|
|
47
|
+
|
|
48
|
+
# Environment identifier (e.g., "test", "live") for environment isolation
|
|
49
|
+
t.string :environment
|
|
50
|
+
|
|
45
51
|
t.timestamps
|
|
46
52
|
|
|
47
53
|
# Critical index for authentication performance
|
|
@@ -49,15 +55,17 @@ class CreateApiKeysTable < ActiveRecord::Migration<%= migration_version %>
|
|
|
49
55
|
|
|
50
56
|
# Index to optimize prefix-based lookups (especially for bcrypt)
|
|
51
57
|
t.index [:prefix, :digest_algorithm], name: "index_api_keys_on_prefix_and_digest_algorithm"
|
|
58
|
+
t.index [:prefix, :last4, :digest_algorithm], name: "index_api_keys_authentication_lookup"
|
|
52
59
|
|
|
53
60
|
# Optional indexes
|
|
54
61
|
t.index :prefix
|
|
55
62
|
t.index :last4 # Index potentially useful for UI lookups/filtering by masked token
|
|
56
|
-
t.index :owner_id if foreign_key_type # Index owner_id only if it's a separate column
|
|
57
|
-
t.index :owner_type if foreign_key_type # Index owner_type only if it's a separate column
|
|
58
63
|
t.index :expires_at
|
|
59
64
|
t.index :revoked_at
|
|
60
65
|
t.index :last_used_at
|
|
66
|
+
t.index :key_type
|
|
67
|
+
t.index :environment
|
|
68
|
+
t.index [:owner_type, :owner_id, :key_type, :environment], name: "index_api_keys_owner_type_env"
|
|
61
69
|
end
|
|
62
70
|
end
|
|
63
71
|
|
|
@@ -97,4 +105,4 @@ class CreateApiKeysTable < ActiveRecord::Migration<%= migration_version %>
|
|
|
97
105
|
""
|
|
98
106
|
end
|
|
99
107
|
end
|
|
100
|
-
end
|
|
108
|
+
end
|