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
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ApiKeys
|
|
4
|
+
# Opt-in form builder extensions for API key forms.
|
|
5
|
+
#
|
|
6
|
+
# These helpers reduce boilerplate while letting you control the styling.
|
|
7
|
+
# Include them in your form builder to use:
|
|
8
|
+
#
|
|
9
|
+
# # In config/initializers/api_keys.rb:
|
|
10
|
+
# Rails.application.config.to_prepare do
|
|
11
|
+
# ActionView::Helpers::FormBuilder.include(ApiKeys::FormBuilderExtensions)
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# @example Expiration select
|
|
15
|
+
# <%= form.api_key_expiration_select(class: "my-select-class") %>
|
|
16
|
+
#
|
|
17
|
+
# @example Scopes checkboxes with block for custom rendering
|
|
18
|
+
# <%= form.api_key_scopes_checkboxes(@scopes) do |scope, checked| %>
|
|
19
|
+
# <label>
|
|
20
|
+
# <%= check_box_tag "api_key[scopes][]", scope, checked, class: "my-checkbox" %>
|
|
21
|
+
# <%= scope %>
|
|
22
|
+
# </label>
|
|
23
|
+
# <% end %>
|
|
24
|
+
#
|
|
25
|
+
module FormBuilderExtensions
|
|
26
|
+
# Renders a select field for API key expiration presets.
|
|
27
|
+
#
|
|
28
|
+
# @param options [Hash] Options passed to the select helper
|
|
29
|
+
# @param html_options [Hash] HTML attributes for the select element
|
|
30
|
+
# @return [String] HTML select element
|
|
31
|
+
#
|
|
32
|
+
# @example Basic usage
|
|
33
|
+
# <%= form.api_key_expiration_select %>
|
|
34
|
+
#
|
|
35
|
+
# @example With Tailwind classes
|
|
36
|
+
# <%= form.api_key_expiration_select(class: "w-full px-4 py-3 border rounded-lg") %>
|
|
37
|
+
#
|
|
38
|
+
# @example With custom default
|
|
39
|
+
# <%= form.api_key_expiration_select(selected: "30_days") %>
|
|
40
|
+
#
|
|
41
|
+
def api_key_expiration_select(options = {}, html_options = {})
|
|
42
|
+
# expires_at_preset is a form-only param, not a model attribute
|
|
43
|
+
# So we only use the provided :selected option or default
|
|
44
|
+
selected = options.delete(:selected) || ApiKeys::ExpirationOptions.default_value
|
|
45
|
+
|
|
46
|
+
select(
|
|
47
|
+
:expires_at_preset,
|
|
48
|
+
@template.options_for_select(ApiKeys::ExpirationOptions.for_select, selected),
|
|
49
|
+
options,
|
|
50
|
+
html_options
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Renders checkboxes for API key scopes.
|
|
55
|
+
#
|
|
56
|
+
# If a block is given, yields each scope and its checked state for custom rendering.
|
|
57
|
+
# If no block is given, returns an array of checkbox data for manual iteration.
|
|
58
|
+
#
|
|
59
|
+
# @param scopes [Array<String>] Available scopes to render
|
|
60
|
+
# @param checked [Symbol, Array] Which scopes should be checked:
|
|
61
|
+
# - :all (default for new records) - all scopes checked
|
|
62
|
+
# - :none - no scopes checked
|
|
63
|
+
# - Array - specific scopes to check
|
|
64
|
+
# - nil - uses the object's current scopes
|
|
65
|
+
# @return [String, Array] HTML if block given, otherwise array of scope data
|
|
66
|
+
#
|
|
67
|
+
# @example With block (recommended for custom styling)
|
|
68
|
+
# <%= form.api_key_scopes_checkboxes(@scopes) do |scope, checked| %>
|
|
69
|
+
# <label class="flex items-center gap-2">
|
|
70
|
+
# <%= check_box_tag "api_key[scopes][]", scope, checked, class: "rounded" %>
|
|
71
|
+
# <code><%= scope %></code>
|
|
72
|
+
# </label>
|
|
73
|
+
# <% end %>
|
|
74
|
+
#
|
|
75
|
+
# @example Simple rendering without block
|
|
76
|
+
# <% form.api_key_scopes_checkboxes(@scopes).each do |scope_data| %>
|
|
77
|
+
# <%= check_box_tag "api_key[scopes][]", scope_data[:value], scope_data[:checked] %>
|
|
78
|
+
# <%= scope_data[:value] %>
|
|
79
|
+
# <% end %>
|
|
80
|
+
#
|
|
81
|
+
def api_key_scopes_checkboxes(scopes, checked: nil, &block)
|
|
82
|
+
# Determine which scopes should be checked
|
|
83
|
+
checked_scopes = resolve_checked_scopes(scopes, checked)
|
|
84
|
+
|
|
85
|
+
scope_data = scopes.map do |scope|
|
|
86
|
+
{
|
|
87
|
+
value: scope,
|
|
88
|
+
checked: checked_scopes.include?(scope),
|
|
89
|
+
field_name: "#{object_name}[scopes][]"
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
if block_given?
|
|
94
|
+
# Yield each scope for custom rendering
|
|
95
|
+
safe_buffer = ActiveSupport::SafeBuffer.new
|
|
96
|
+
scope_data.each do |data|
|
|
97
|
+
safe_buffer << @template.capture { yield(data[:value], data[:checked]) }
|
|
98
|
+
end
|
|
99
|
+
safe_buffer
|
|
100
|
+
else
|
|
101
|
+
# Return raw data for manual iteration
|
|
102
|
+
scope_data
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Returns structured data for building a token display UI.
|
|
107
|
+
# Useful when you need to build a custom token display with copy functionality.
|
|
108
|
+
#
|
|
109
|
+
# @return [Hash] Token display data with keys:
|
|
110
|
+
# - :masked [String] The masked token (e.g., "sk_live_••••abc")
|
|
111
|
+
# - :full [String, nil] The full token (only for viewable public keys)
|
|
112
|
+
# - :viewable [Boolean] Whether the full token can be displayed
|
|
113
|
+
# - :type [Symbol, String, nil] A known built-in type is a Symbol; custom
|
|
114
|
+
# untrusted values remain Strings so they are never interned as Symbols.
|
|
115
|
+
# - :environment [String, nil] The environment (e.g., "live", "test")
|
|
116
|
+
#
|
|
117
|
+
# @example
|
|
118
|
+
# <% data = form.api_key_token_data %>
|
|
119
|
+
# <code><%= data[:masked] %></code>
|
|
120
|
+
# <% if data[:viewable] %>
|
|
121
|
+
# <button data-token="<%= data[:full] %>">Copy</button>
|
|
122
|
+
# <% end %>
|
|
123
|
+
#
|
|
124
|
+
def api_key_token_data
|
|
125
|
+
return {} unless object.respond_to?(:masked_token)
|
|
126
|
+
|
|
127
|
+
{
|
|
128
|
+
masked: object.masked_token,
|
|
129
|
+
full: object.viewable_token,
|
|
130
|
+
viewable: object.respond_to?(:public_key_type?) && object.public_key_type?,
|
|
131
|
+
type: safe_key_type(object.key_type),
|
|
132
|
+
environment: object.environment
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def safe_key_type(value)
|
|
139
|
+
case value.to_s
|
|
140
|
+
when "publishable" then :publishable
|
|
141
|
+
when "secret" then :secret
|
|
142
|
+
when "" then nil
|
|
143
|
+
else value.to_s
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def resolve_checked_scopes(scopes, checked)
|
|
148
|
+
case checked
|
|
149
|
+
when :all
|
|
150
|
+
scopes.map(&:to_s)
|
|
151
|
+
when :none
|
|
152
|
+
[]
|
|
153
|
+
when Array
|
|
154
|
+
checked.map(&:to_s)
|
|
155
|
+
when nil
|
|
156
|
+
# For new records, default to all checked
|
|
157
|
+
# For existing records, use the object's scopes
|
|
158
|
+
if object&.persisted?
|
|
159
|
+
(object.scopes || []).map(&:to_s)
|
|
160
|
+
else
|
|
161
|
+
scopes.map(&:to_s)
|
|
162
|
+
end
|
|
163
|
+
else
|
|
164
|
+
scopes.map(&:to_s)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ApiKeys
|
|
4
|
+
module Helpers
|
|
5
|
+
# Helper for handling API key expiration presets.
|
|
6
|
+
#
|
|
7
|
+
# Provides a consistent set of expiration options for use in forms,
|
|
8
|
+
# and parsing logic to convert preset strings to actual dates.
|
|
9
|
+
#
|
|
10
|
+
# @example In your view (form select)
|
|
11
|
+
# <%= form.select :expires_at_preset,
|
|
12
|
+
# ApiKeys::Helpers::ExpirationOptions.for_select %>
|
|
13
|
+
#
|
|
14
|
+
# @example In your controller
|
|
15
|
+
# expires_at = ApiKeys::Helpers::ExpirationOptions.parse(params[:expires_at_preset])
|
|
16
|
+
# @api_key = current_org.create_api_key!(expires_at: expires_at, ...)
|
|
17
|
+
#
|
|
18
|
+
class ExpirationOptions
|
|
19
|
+
# Default preset options with human-readable labels
|
|
20
|
+
DEFAULT_PRESETS = [
|
|
21
|
+
{ label: "No Expiration", value: "no_expiration", days: nil },
|
|
22
|
+
{ label: "7 days", value: "7_days", days: 7 },
|
|
23
|
+
{ label: "30 days", value: "30_days", days: 30 },
|
|
24
|
+
{ label: "60 days", value: "60_days", days: 60 },
|
|
25
|
+
{ label: "90 days", value: "90_days", days: 90 },
|
|
26
|
+
{ label: "1 year", value: "365_days", days: 365 }
|
|
27
|
+
].map(&:freeze).freeze
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# Returns options suitable for a Rails select helper.
|
|
31
|
+
#
|
|
32
|
+
# @param include_no_expiration [Boolean] Whether to include "No Expiration" option (default: true)
|
|
33
|
+
# @param presets [Array<Integer>, nil] Custom list of days to include (e.g., [7, 30, 90])
|
|
34
|
+
# If nil, uses DEFAULT_PRESETS
|
|
35
|
+
# @return [Array<Array>] Array of [label, value] pairs for select helper
|
|
36
|
+
#
|
|
37
|
+
# @example Default options
|
|
38
|
+
# ExpirationOptions.for_select
|
|
39
|
+
# # => [["No Expiration", "no_expiration"], ["7 days", "7_days"], ...]
|
|
40
|
+
#
|
|
41
|
+
# @example Custom presets
|
|
42
|
+
# ExpirationOptions.for_select(presets: [7, 30, 365])
|
|
43
|
+
# # => [["No Expiration", "no_expiration"], ["7 days", "7_days"], ["30 days", "30_days"], ["1 year", "365_days"]]
|
|
44
|
+
#
|
|
45
|
+
# @example Without "No Expiration"
|
|
46
|
+
# ExpirationOptions.for_select(include_no_expiration: false)
|
|
47
|
+
# # => [["7 days", "7_days"], ["30 days", "30_days"], ...]
|
|
48
|
+
#
|
|
49
|
+
def for_select(include_no_expiration: true, presets: nil)
|
|
50
|
+
options = if presets
|
|
51
|
+
build_custom_presets(presets, include_no_expiration)
|
|
52
|
+
else
|
|
53
|
+
filter_default_presets(include_no_expiration)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
options.map { |opt| [opt[:label], opt[:value]] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Parse an expiration preset string into an actual datetime.
|
|
60
|
+
#
|
|
61
|
+
# @param preset [String, nil] The preset value (e.g., "30_days", "no_expiration")
|
|
62
|
+
# @return [ActiveSupport::TimeWithZone, nil] The expiration date, or nil for no expiration
|
|
63
|
+
#
|
|
64
|
+
# @example
|
|
65
|
+
# ExpirationOptions.parse("30_days") # => 30.days.from_now
|
|
66
|
+
# ExpirationOptions.parse("no_expiration") # => nil
|
|
67
|
+
# ExpirationOptions.parse(nil) # => nil
|
|
68
|
+
# ExpirationOptions.parse("invalid") # => raises ArgumentError
|
|
69
|
+
#
|
|
70
|
+
def parse(preset)
|
|
71
|
+
return nil if preset.nil? || preset == ""
|
|
72
|
+
unless preset.is_a?(String) && preset.valid_encoding? && preset.bytesize <= 32
|
|
73
|
+
raise ArgumentError, "Invalid API key expiration preset"
|
|
74
|
+
end
|
|
75
|
+
return nil if preset.blank? || preset == "no_expiration"
|
|
76
|
+
|
|
77
|
+
# Try to extract days from the preset string (e.g., "30_days" => 30)
|
|
78
|
+
if preset =~ /\A(\d+)_days?\z/
|
|
79
|
+
days = ::Regexp.last_match(1).to_i
|
|
80
|
+
# Reasonable max: ~10 years to prevent overflow issues
|
|
81
|
+
return days.days.from_now if days.positive? && days <= 3650
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Check against known presets
|
|
85
|
+
known = DEFAULT_PRESETS.find { |p| p[:value] == preset }
|
|
86
|
+
return known[:days].days.from_now if known && known[:days]
|
|
87
|
+
|
|
88
|
+
raise ArgumentError, "Invalid API key expiration preset"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Returns the default preset value (useful for form defaults)
|
|
92
|
+
#
|
|
93
|
+
# @return [String] The default preset value
|
|
94
|
+
def default_value
|
|
95
|
+
"no_expiration"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def filter_default_presets(include_no_expiration)
|
|
101
|
+
if include_no_expiration
|
|
102
|
+
DEFAULT_PRESETS
|
|
103
|
+
else
|
|
104
|
+
DEFAULT_PRESETS.reject { |p| p[:value] == "no_expiration" }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def build_custom_presets(days_list, include_no_expiration)
|
|
109
|
+
unless days_list.is_a?(Array) && days_list.all? { |days| days.is_a?(Integer) && days.between?(1, 3650) }
|
|
110
|
+
raise ArgumentError, "Expiration presets must be an array of integers between 1 and 3650"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
options = []
|
|
114
|
+
|
|
115
|
+
if include_no_expiration
|
|
116
|
+
options << { label: "No Expiration", value: "no_expiration", days: nil }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
days_list.each do |days|
|
|
120
|
+
options << preset_for_days(days)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
options
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def preset_for_days(days)
|
|
127
|
+
label = case days
|
|
128
|
+
when 1 then "1 day"
|
|
129
|
+
when 365 then "1 year"
|
|
130
|
+
when ->(d) { d % 365 == 0 } then "#{days / 365} years"
|
|
131
|
+
else "#{days} days"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
{ label: label, value: "#{days}_days", days: days }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/numeric/time"
|
|
4
|
+
require "active_support/message_encryptor"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module ApiKeys
|
|
8
|
+
module Helpers
|
|
9
|
+
# Helper for managing API key tokens in the session.
|
|
10
|
+
#
|
|
11
|
+
# Secret keys can only be shown once (immediately after creation) because
|
|
12
|
+
# the plaintext token is not stored in the database. This helper provides
|
|
13
|
+
# a clean interface for the "show token once" pattern:
|
|
14
|
+
#
|
|
15
|
+
# 1. After creating a key, store an encrypted, short-lived handoff in the session
|
|
16
|
+
# 2. On the success page, decrypt, retrieve, and clear the token
|
|
17
|
+
# 3. If the user refreshes, the token is gone
|
|
18
|
+
#
|
|
19
|
+
# @example In your controller
|
|
20
|
+
# # After creating a key:
|
|
21
|
+
# def create
|
|
22
|
+
# @api_key = current_org.create_api_key!(...)
|
|
23
|
+
# ApiKeys::Helpers::TokenSession.store(session, @api_key)
|
|
24
|
+
# redirect_to success_path
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# # On the success page:
|
|
28
|
+
# def success
|
|
29
|
+
# @token = ApiKeys::Helpers::TokenSession.retrieve_once(session)
|
|
30
|
+
# redirect_to index_path, alert: "Token already shown" unless @token
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
class TokenSession
|
|
34
|
+
# Default session key for storing the token
|
|
35
|
+
DEFAULT_SESSION_KEY = :api_keys_new_token
|
|
36
|
+
MAX_TOKEN_BYTESIZE = 512
|
|
37
|
+
MAX_CIPHERTEXT_BYTESIZE = 4096
|
|
38
|
+
MAX_KEY_ID_BYTESIZE = 128
|
|
39
|
+
HANDOFF_VERSION = 2
|
|
40
|
+
HANDOFF_TTL = 10.minutes
|
|
41
|
+
ENCRYPTION_CIPHER = "aes-256-gcm"
|
|
42
|
+
ENCRYPTION_SALT = "api_keys/token_session/v2"
|
|
43
|
+
ENCRYPTION_PURPOSE = "api_keys.token_session"
|
|
44
|
+
JSON_SERIALIZER = Module.new do
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
def dump(value)
|
|
48
|
+
JSON.generate(value)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def load(value)
|
|
52
|
+
JSON.parse(value)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class << self
|
|
57
|
+
# Store an encrypted API key-token handoff in the session for later retrieval.
|
|
58
|
+
#
|
|
59
|
+
# @param session [ActionDispatch::Request::Session] The Rails session
|
|
60
|
+
# @param api_key [ApiKeys::ApiKey] The newly created API key
|
|
61
|
+
# @param key [Symbol] Optional custom session key (default: :api_keys_new_token)
|
|
62
|
+
# @return [String] The token that was stored
|
|
63
|
+
def store(session, api_key, key: DEFAULT_SESSION_KEY)
|
|
64
|
+
token = api_key.respond_to?(:token) ? api_key.token : api_key.to_s
|
|
65
|
+
unless valid_token_payload?(token)
|
|
66
|
+
raise ArgumentError, "Cannot store an invalid API key token in the session"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
api_key_id = normalize_api_key_id(api_key.id) if api_key.respond_to?(:id)
|
|
70
|
+
encrypted_payload = token_encryptor.encrypt_and_sign(
|
|
71
|
+
{ "token" => token, "api_key_id" => api_key_id },
|
|
72
|
+
expires_in: HANDOFF_TTL,
|
|
73
|
+
purpose: ENCRYPTION_PURPOSE
|
|
74
|
+
)
|
|
75
|
+
session[key] = {
|
|
76
|
+
"version" => HANDOFF_VERSION,
|
|
77
|
+
"ciphertext" => encrypted_payload,
|
|
78
|
+
"api_key_id" => api_key_id
|
|
79
|
+
}
|
|
80
|
+
token
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Retrieve and clear the token from the session.
|
|
84
|
+
# Returns nil if no token is stored (e.g., page was refreshed).
|
|
85
|
+
#
|
|
86
|
+
# @param session [ActionDispatch::Request::Session] The Rails session
|
|
87
|
+
# @param key [Symbol] Optional custom session key (default: :api_keys_new_token)
|
|
88
|
+
# @return [String, nil] The token, or nil if not present
|
|
89
|
+
def retrieve_once(session, key: DEFAULT_SESSION_KEY, api_key: nil, api_key_id: nil)
|
|
90
|
+
payload = session.delete(key)
|
|
91
|
+
expected_id = normalize_api_key_id(api_key_id || (api_key.id if api_key.respond_to?(:id)))
|
|
92
|
+
|
|
93
|
+
# Plain string payloads from older versions remain readable only when
|
|
94
|
+
# the caller does not request ID binding.
|
|
95
|
+
return payload if expected_id.nil? && valid_token_payload?(payload)
|
|
96
|
+
decoded_payload = decode_payload(payload)
|
|
97
|
+
return nil unless decoded_payload
|
|
98
|
+
|
|
99
|
+
token = decoded_payload["token"] || decoded_payload[:token]
|
|
100
|
+
stored_id = decoded_payload["api_key_id"] || decoded_payload[:api_key_id]
|
|
101
|
+
return nil if expected_id && stored_id.to_s != expected_id.to_s
|
|
102
|
+
return nil unless valid_token_payload?(token)
|
|
103
|
+
|
|
104
|
+
token
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Check if a token is available in the session without removing it.
|
|
108
|
+
# Useful for conditional rendering.
|
|
109
|
+
#
|
|
110
|
+
# @param session [ActionDispatch::Request::Session] The Rails session
|
|
111
|
+
# @param key [Symbol] Optional custom session key (default: :api_keys_new_token)
|
|
112
|
+
# @return [Boolean] true if a token is stored
|
|
113
|
+
def available?(session, key: DEFAULT_SESSION_KEY, api_key: nil, api_key_id: nil)
|
|
114
|
+
payload = session[key]
|
|
115
|
+
expected_id = normalize_api_key_id(api_key_id || (api_key.id if api_key.respond_to?(:id)))
|
|
116
|
+
|
|
117
|
+
return valid_token_payload?(payload) if payload.is_a?(String) && expected_id.nil?
|
|
118
|
+
decoded_payload = decode_payload(payload)
|
|
119
|
+
return false unless decoded_payload
|
|
120
|
+
|
|
121
|
+
token = decoded_payload["token"] || decoded_payload[:token]
|
|
122
|
+
stored_id = decoded_payload["api_key_id"] || decoded_payload[:api_key_id]
|
|
123
|
+
return false if expected_id && stored_id.to_s != expected_id.to_s
|
|
124
|
+
|
|
125
|
+
valid_token_payload?(token)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def decode_payload(payload)
|
|
131
|
+
return nil unless payload.is_a?(Hash)
|
|
132
|
+
|
|
133
|
+
version_present = payload.key?("version") || payload.key?(:version)
|
|
134
|
+
return legacy_payload(payload) unless version_present
|
|
135
|
+
|
|
136
|
+
version = payload["version"] || payload[:version]
|
|
137
|
+
return nil unless version == HANDOFF_VERSION
|
|
138
|
+
|
|
139
|
+
ciphertext = payload["ciphertext"] || payload[:ciphertext]
|
|
140
|
+
outer_id = normalize_api_key_id(payload["api_key_id"] || payload[:api_key_id])
|
|
141
|
+
return nil unless ciphertext.is_a?(String) && ciphertext.bytesize <= MAX_CIPHERTEXT_BYTESIZE
|
|
142
|
+
|
|
143
|
+
decoded = token_encryptor.decrypt_and_verify(ciphertext, purpose: ENCRYPTION_PURPOSE)
|
|
144
|
+
return nil unless decoded.is_a?(Hash)
|
|
145
|
+
|
|
146
|
+
inner_id = normalize_api_key_id(decoded["api_key_id"] || decoded[:api_key_id])
|
|
147
|
+
return nil unless outer_id == inner_id
|
|
148
|
+
|
|
149
|
+
decoded
|
|
150
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage, ArgumentError
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def legacy_payload(payload)
|
|
155
|
+
token = payload["token"] || payload[:token]
|
|
156
|
+
stored_id = normalize_api_key_id(payload["api_key_id"] || payload[:api_key_id])
|
|
157
|
+
return nil unless valid_token_payload?(token)
|
|
158
|
+
|
|
159
|
+
{ "token" => token, "api_key_id" => stored_id }
|
|
160
|
+
rescue ArgumentError
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def normalize_api_key_id(value)
|
|
165
|
+
return nil if value.nil?
|
|
166
|
+
|
|
167
|
+
normalized = value.to_s
|
|
168
|
+
valid = normalized.present? && normalized.valid_encoding? &&
|
|
169
|
+
normalized.bytesize <= MAX_KEY_ID_BYTESIZE &&
|
|
170
|
+
normalized.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
|
|
171
|
+
raise ArgumentError, "API key ID is invalid" unless valid
|
|
172
|
+
|
|
173
|
+
normalized
|
|
174
|
+
rescue ArgumentError
|
|
175
|
+
raise ArgumentError, "API key ID is invalid"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def token_encryptor
|
|
179
|
+
application = Rails.application if defined?(Rails) && Rails.respond_to?(:application)
|
|
180
|
+
unless application&.respond_to?(:key_generator)
|
|
181
|
+
raise ArgumentError, "Rails.application.key_generator is required for secure token handoff"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
key_length = ActiveSupport::MessageEncryptor.key_len(ENCRYPTION_CIPHER)
|
|
185
|
+
encryption_key = application.key_generator.generate_key(ENCRYPTION_SALT, key_length)
|
|
186
|
+
ActiveSupport::MessageEncryptor.new(
|
|
187
|
+
encryption_key,
|
|
188
|
+
cipher: ENCRYPTION_CIPHER,
|
|
189
|
+
serializer: JSON_SERIALIZER
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def valid_token_payload?(token)
|
|
194
|
+
token.is_a?(String) && token.present? && token.valid_encoding? &&
|
|
195
|
+
token.bytesize <= MAX_TOKEN_BYTESIZE &&
|
|
196
|
+
token.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
|
|
197
|
+
rescue ArgumentError
|
|
198
|
+
false
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|