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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/README.md +851 -25
  4. data/SECURITY.md +33 -0
  5. data/app/controllers/api_keys/application_controller.rb +58 -10
  6. data/app/controllers/api_keys/keys_controller.rb +77 -23
  7. data/app/controllers/api_keys/security_controller.rb +8 -0
  8. data/app/views/api_keys/keys/_empty_state.html.erb +9 -0
  9. data/app/views/api_keys/keys/_form.html.erb +33 -4
  10. data/app/views/api_keys/keys/_key_actions.html.erb +20 -0
  11. data/app/views/api_keys/keys/_key_badges.html.erb +17 -0
  12. data/app/views/api_keys/keys/_key_row.html.erb +21 -35
  13. data/app/views/api_keys/keys/_key_status.html.erb +10 -0
  14. data/app/views/api_keys/keys/_keys_table.html.erb +3 -11
  15. data/app/views/api_keys/keys/_publishable_keys.html.erb +40 -0
  16. data/app/views/api_keys/keys/_secret_keys.html.erb +39 -0
  17. data/app/views/api_keys/keys/_show_token.html.erb +10 -47
  18. data/app/views/api_keys/keys/_token_display.html.erb +11 -0
  19. data/app/views/api_keys/keys/index.html.erb +40 -8
  20. data/app/views/api_keys/keys/show.html.erb +2 -2
  21. data/app/views/api_keys/security/best_practices.html.erb +73 -47
  22. data/app/views/layouts/api_keys/application.html.erb +267 -14
  23. data/lib/api_keys/authentication.rb +39 -11
  24. data/lib/api_keys/configuration.rb +444 -17
  25. data/lib/api_keys/engine.rb +5 -20
  26. data/lib/api_keys/errors.rb +73 -0
  27. data/lib/api_keys/form_builder_extensions.rb +168 -0
  28. data/lib/api_keys/helpers/expiration_options.rb +139 -0
  29. data/lib/api_keys/helpers/token_session.rb +203 -0
  30. data/lib/api_keys/helpers/view_helpers.rb +220 -0
  31. data/lib/api_keys/jobs/callbacks_job.rb +10 -17
  32. data/lib/api_keys/jobs/update_stats_job.rb +27 -12
  33. data/lib/api_keys/models/api_key.rb +452 -21
  34. data/lib/api_keys/models/concerns/has_api_keys.rb +269 -26
  35. data/lib/api_keys/services/authenticator.rb +300 -112
  36. data/lib/api_keys/services/digestor.rb +81 -14
  37. data/lib/api_keys/services/token_generator.rb +41 -1
  38. data/lib/api_keys/tenant_resolution.rb +4 -4
  39. data/lib/api_keys/version.rb +1 -1
  40. data/lib/api_keys.rb +12 -0
  41. data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
  42. data/lib/generators/api_keys/add_key_types_generator.rb +68 -0
  43. data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +32 -0
  44. data/lib/generators/api_keys/templates/add_key_types_to_api_keys.rb.erb +18 -0
  45. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +11 -3
  46. data/lib/generators/api_keys/templates/initializer.rb +261 -120
  47. metadata +29 -63
  48. 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: ApiKeys.configuration.token_length, prefix: ApiKeys.configuration.token_prefix.call, alphabet: ApiKeys.configuration.token_alphabet)
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 => e
32
+ rescue StandardError => error
35
33
  # Log error but don't break the request if resolver fails
36
- Rails.logger.error "[ApiKeys] Tenant resolution failed: #{e.message}" if defined?(Rails.logger)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ApiKeys
4
- VERSION = "0.2.1"
4
+ VERSION = "0.4.0"
5
5
  end
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