api_keys 0.3.0 → 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 +51 -0
- data/README.md +101 -39
- data/SECURITY.md +33 -0
- data/app/controllers/api_keys/application_controller.rb +58 -10
- data/app/controllers/api_keys/keys_controller.rb +40 -18
- data/app/views/api_keys/keys/_empty_state.html.erb +1 -1
- data/app/views/api_keys/keys/_form.html.erb +3 -3
- data/app/views/api_keys/keys/_key_actions.html.erb +3 -3
- data/app/views/api_keys/keys/_key_badges.html.erb +2 -2
- data/app/views/api_keys/keys/_key_row.html.erb +1 -1
- data/app/views/api_keys/keys/_key_status.html.erb +3 -3
- data/app/views/api_keys/keys/_keys_table.html.erb +1 -4
- data/app/views/api_keys/keys/_show_token.html.erb +5 -46
- data/app/views/api_keys/keys/_token_display.html.erb +3 -3
- data/app/views/api_keys/keys/index.html.erb +2 -2
- data/app/views/api_keys/keys/show.html.erb +2 -2
- data/app/views/api_keys/security/best_practices.html.erb +7 -7
- data/app/views/layouts/api_keys/application.html.erb +159 -12
- data/lib/api_keys/authentication.rb +39 -11
- data/lib/api_keys/configuration.rb +374 -24
- data/lib/api_keys/engine.rb +5 -20
- data/lib/api_keys/form_builder_extensions.rb +12 -2
- data/lib/api_keys/helpers/expiration_options.rb +11 -3
- data/lib/api_keys/helpers/token_session.rb +143 -8
- data/lib/api_keys/helpers/view_helpers.rb +5 -1
- 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 +244 -25
- data/lib/api_keys/models/concerns/has_api_keys.rb +95 -32
- data/lib/api_keys/services/authenticator.rb +263 -118
- data/lib/api_keys/services/digestor.rb +76 -13
- data/lib/api_keys/services/token_generator.rb +41 -1
- data/lib/api_keys/tenant_resolution.rb +2 -4
- data/lib/api_keys/version.rb +1 -1
- data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
- data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +32 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +2 -3
- data/lib/generators/api_keys/templates/initializer.rb +36 -17
- metadata +16 -16
- data/.simplecov +0 -36
- data/AGENTS.md +0 -5
- data/Appraisals +0 -17
- data/CLAUDE.md +0 -5
- data/Rakefile +0 -37
- data/context7.json +0 -4
- data/gemfiles/rails_7.2.gemfile +0 -21
- data/gemfiles/rails_8.0.gemfile +0 -21
- data/gemfiles/rails_8.1.gemfile +0 -21
|
@@ -39,7 +39,7 @@ module ApiKeys
|
|
|
39
39
|
# We need to retrieve the plaintext token stored temporarily
|
|
40
40
|
# after creation. This relies on how we handle creation.
|
|
41
41
|
# We'll likely store it in the session flash or pass it directly.
|
|
42
|
-
@plaintext_token =
|
|
42
|
+
@plaintext_token = ApiKeys::TokenSession.retrieve_once(session, api_key: @api_key)
|
|
43
43
|
unless @plaintext_token
|
|
44
44
|
# If accessed directly without the token, redirect or show an error
|
|
45
45
|
redirect_to keys_path, alert: "API key token can only be shown once immediately after creation."
|
|
@@ -53,22 +53,21 @@ module ApiKeys
|
|
|
53
53
|
|
|
54
54
|
# POST /keys
|
|
55
55
|
def create
|
|
56
|
+
submitted_params = api_key_params
|
|
57
|
+
|
|
56
58
|
# Use the HasApiKeys helper method to create the key
|
|
57
59
|
begin
|
|
58
60
|
# create_api_key! now returns the ApiKey instance
|
|
59
61
|
@api_key = current_api_keys_owner.create_api_key!(
|
|
60
|
-
name:
|
|
61
|
-
scopes:
|
|
62
|
-
expires_at: parse_expiration(
|
|
63
|
-
key_type:
|
|
62
|
+
name: submitted_params[:name],
|
|
63
|
+
scopes: submitted_params[:scopes],
|
|
64
|
+
expires_at: parse_expiration(submitted_params[:expires_at_preset]),
|
|
65
|
+
key_type: submitted_params[:key_type].presence
|
|
64
66
|
# Metadata could be added here if needed
|
|
65
67
|
)
|
|
66
68
|
|
|
67
|
-
# Get the plaintext token from the instance's attr_reader
|
|
68
|
-
plaintext_token = @api_key.token
|
|
69
|
-
|
|
70
69
|
# Store the plaintext token in session to display on the show page
|
|
71
|
-
session
|
|
70
|
+
ApiKeys::TokenSession.store(session, @api_key)
|
|
72
71
|
|
|
73
72
|
redirect_to key_path(@api_key)
|
|
74
73
|
rescue ActiveRecord::RecordInvalid => e
|
|
@@ -76,11 +75,18 @@ module ApiKeys
|
|
|
76
75
|
@api_key = e.record # Get the invalid ApiKey instance
|
|
77
76
|
flash.now[:alert] = "Failed to create API key: #{e.record.errors.full_messages.join(', ')}"
|
|
78
77
|
render :new, status: :unprocessable_entity
|
|
79
|
-
rescue
|
|
80
|
-
flash.now[:alert] = "
|
|
81
|
-
@api_key =
|
|
78
|
+
rescue ArgumentError
|
|
79
|
+
flash.now[:alert] = "Failed to create API key: invalid options."
|
|
80
|
+
@api_key = rebuild_api_key_for_form(submitted_params)
|
|
81
|
+
render :new, status: :unprocessable_entity
|
|
82
|
+
rescue StandardError => error
|
|
83
|
+
log_error "[ApiKeys Dashboard] Unexpected key creation failure (#{error.class}); request ID: #{request.uuid}"
|
|
84
|
+
flash.now[:alert] = "An unexpected error occurred while creating the API key."
|
|
85
|
+
@api_key = rebuild_api_key_for_form(submitted_params)
|
|
82
86
|
render :new, status: :unprocessable_entity
|
|
83
87
|
end
|
|
88
|
+
rescue ActionController::ParameterMissing
|
|
89
|
+
head :bad_request
|
|
84
90
|
end
|
|
85
91
|
|
|
86
92
|
# GET /keys/:id/edit
|
|
@@ -96,6 +102,8 @@ module ApiKeys
|
|
|
96
102
|
flash.now[:alert] = "Failed to update API key: #{@api_key.errors.full_messages.join(', ')}"
|
|
97
103
|
render :edit, status: :unprocessable_entity
|
|
98
104
|
end
|
|
105
|
+
rescue ActionController::ParameterMissing
|
|
106
|
+
head :bad_request
|
|
99
107
|
end
|
|
100
108
|
|
|
101
109
|
# POST /keys/:id/revoke
|
|
@@ -104,9 +112,9 @@ module ApiKeys
|
|
|
104
112
|
redirect_to keys_path, notice: "API key revoked successfully."
|
|
105
113
|
rescue ApiKeys::Errors::KeyNotRevocableError
|
|
106
114
|
redirect_to keys_path, alert: "This API key cannot be revoked."
|
|
107
|
-
rescue =>
|
|
108
|
-
|
|
109
|
-
redirect_to keys_path, alert: "Failed to revoke API key
|
|
115
|
+
rescue StandardError => error
|
|
116
|
+
log_error "[ApiKeys Dashboard] Unexpected key revocation failure (#{error.class}); key ID: #{@api_key&.id}; request ID: #{request.uuid}"
|
|
117
|
+
redirect_to keys_path, alert: "Failed to revoke API key."
|
|
110
118
|
end
|
|
111
119
|
|
|
112
120
|
private
|
|
@@ -122,14 +130,20 @@ module ApiKeys
|
|
|
122
130
|
# Added :expires_at_preset for the dropdown selector.
|
|
123
131
|
# Added :key_type for the key types feature.
|
|
124
132
|
def api_key_params
|
|
125
|
-
|
|
133
|
+
submitted = params.require(:api_key)
|
|
134
|
+
raise ActionController::ParameterMissing, :api_key unless submitted.respond_to?(:permit)
|
|
135
|
+
|
|
136
|
+
permitted_params = submitted.permit(:name, :expires_at_preset, :key_type, scopes: [])
|
|
126
137
|
permitted_params[:scopes]&.reject!(&:blank?) # Filter out blank strings
|
|
127
138
|
permitted_params
|
|
128
139
|
end
|
|
129
140
|
|
|
130
141
|
# Only allow updating name and scopes.
|
|
131
142
|
def api_key_update_params
|
|
132
|
-
|
|
143
|
+
submitted = params.require(:api_key)
|
|
144
|
+
raise ActionController::ParameterMissing, :api_key unless submitted.respond_to?(:permit)
|
|
145
|
+
|
|
146
|
+
permitted_params = submitted.permit(:name, scopes: [])
|
|
133
147
|
permitted_params[:scopes]&.reject!(&:blank?) # Filter out blank strings
|
|
134
148
|
permitted_params
|
|
135
149
|
end
|
|
@@ -143,10 +157,18 @@ module ApiKeys
|
|
|
143
157
|
when "90_days" then 90.days.from_now
|
|
144
158
|
when "365_days" then 365.days.from_now
|
|
145
159
|
when "no_expiration" then nil
|
|
146
|
-
|
|
160
|
+
when nil, "" then nil
|
|
161
|
+
else raise ArgumentError, "Invalid expiration preset"
|
|
147
162
|
end
|
|
148
163
|
end
|
|
149
164
|
|
|
165
|
+
def rebuild_api_key_for_form(submitted_params)
|
|
166
|
+
current_api_keys_owner.api_keys.build(
|
|
167
|
+
name: submitted_params[:name],
|
|
168
|
+
scopes: submitted_params[:scopes]
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
150
172
|
# Check if key types feature is enabled
|
|
151
173
|
def key_types_feature_enabled?
|
|
152
174
|
ApiKeys.configuration.key_types.present? && ApiKeys.configuration.key_types.any?
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<%# Shared form for creating and editing API Keys %>
|
|
2
2
|
<%= form_with(model: [:keys, api_key], url: (api_key.persisted? ? key_path(api_key) : keys_path), local: true) do |form| %>
|
|
3
3
|
<% if api_key.errors.any? %>
|
|
4
|
-
<div
|
|
4
|
+
<div class="api-keys-form-errors">
|
|
5
5
|
<strong><%= pluralize(api_key.errors.count, "error") %> prohibited this API key from being saved:</strong>
|
|
6
6
|
<ul>
|
|
7
7
|
<% api_key.errors.full_messages.each do |message| %>
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
%>
|
|
31
31
|
<% ApiKeys.configuration.key_types.each do |type, config| %>
|
|
32
32
|
<% if config[:permissions] != :all %>
|
|
33
|
-
<small
|
|
33
|
+
<small class="api-keys-form-help">
|
|
34
34
|
<strong><%= type.to_s.humanize %>:</strong>
|
|
35
35
|
Limited to <%= config[:permissions]&.join(", ") || "no" %> permissions
|
|
36
36
|
<% unless config.fetch(:revocable, true) %>
|
|
@@ -132,4 +132,4 @@
|
|
|
132
132
|
<%= link_to "Cancel", keys_path %>
|
|
133
133
|
<% end %>
|
|
134
134
|
</div>
|
|
135
|
-
<% end %>
|
|
135
|
+
<% end %>
|
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
<%# Locals: key (required) - The ApiKey record %>
|
|
3
3
|
|
|
4
4
|
<% if key.active? %>
|
|
5
|
-
<%= link_to
|
|
5
|
+
<%= link_to edit_key_path(key), title: "Edit Key", class: "api-keys-action-edit" do %>
|
|
6
6
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M16.793 2.793a3.121 3.121 0 1 1 4.414 4.414l-8.5 8.5A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l8.5-8.5Zm3 1.414a1.121 1.121 0 0 0-1.586 0L10 12.414V14h1.586l8.207-8.207a1.121 1.121 0 0 0 0-1.586ZM6 5a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-4a1 1 0 1 1 2 0v4a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h4a1 1 0 1 1 0 2H6Z" clip-rule="evenodd"></path></svg>
|
|
7
7
|
<% end %>
|
|
8
8
|
|
|
9
9
|
<% if key.revocable? %>
|
|
10
|
-
<%= button_to
|
|
10
|
+
<%= button_to revoke_key_path(key), title: "Revoke Key", class: "api-keys-action-revoke", data: { turbo_method: :post, turbo_confirm: "Are you sure you want to revoke this key? It will stop working immediately." } do %>
|
|
11
11
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M10.556 4a1 1 0 0 0-.97.751l-.292 1.14h5.421l-.293-1.14A1 1 0 0 0 13.453 4h-2.897Zm6.224 1.892-.421-1.639A3 3 0 0 0 13.453 2h-2.897A3 3 0 0 0 7.65 4.253l-.421 1.639H4a1 1 0 1 0 0 2h.1l1.215 11.425A3 3 0 0 0 8.3 22h7.4a3 3 0 0 0 2.984-2.683l1.214-11.425H20a1 1 0 1 0 0-2h-3.22Zm1.108 2H6.112l1.192 11.214A1 1 0 0 0 8.3 20h7.4a1 1 0 0 0 .995-.894l1.192-11.214ZM10 10a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Z" clip-rule="evenodd"></path></svg>
|
|
12
12
|
<% end %>
|
|
13
13
|
<% else %>
|
|
14
|
-
<span title="This key cannot be revoked" class="api-keys-action-disabled"
|
|
14
|
+
<span title="This key cannot be revoked" class="api-keys-action-disabled">
|
|
15
15
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" clip-rule="evenodd"></path></svg>
|
|
16
16
|
</span>
|
|
17
17
|
<% end %>
|
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
<% if key.key_type.present? %>
|
|
5
5
|
<% type_config = key.key_type_config %>
|
|
6
6
|
<% is_publishable = type_config&.dig(:revocable) == false %>
|
|
7
|
-
<span class="api-keys-badge api-keys-badge-type <%= is_publishable ? 'api-keys-badge-publishable' : 'api-keys-badge-secret' %>"
|
|
7
|
+
<span class="api-keys-badge api-keys-badge-type <%= is_publishable ? 'api-keys-badge-publishable' : 'api-keys-badge-secret' %>">
|
|
8
8
|
<%= key.key_type.humanize %>
|
|
9
9
|
</span>
|
|
10
10
|
<% end %>
|
|
11
11
|
|
|
12
12
|
<% if key.environment.present? %>
|
|
13
13
|
<% is_live = key.environment == 'live' %>
|
|
14
|
-
<span class="api-keys-badge api-keys-badge-env <%= is_live ? 'api-keys-badge-live' : 'api-keys-badge-test' %>"
|
|
14
|
+
<span class="api-keys-badge api-keys-badge-env <%= is_live ? 'api-keys-badge-live' : 'api-keys-badge-test' %>">
|
|
15
15
|
<%= key.environment.upcase %>
|
|
16
16
|
</span>
|
|
17
17
|
<% end %>
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
<td class="api-keys-cell-expires">
|
|
20
20
|
<% if key.expires_at? %>
|
|
21
21
|
<% if key.expired? %>
|
|
22
|
-
<strong
|
|
22
|
+
<strong class="api-keys-expired-text" title="<%= key.expires_at.strftime('%Y-%m-%d %H:%M:%S %Z') %>">
|
|
23
23
|
Expired <%= time_ago_in_words(key.expires_at) %> ago
|
|
24
24
|
</strong>
|
|
25
25
|
<% else %>
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
<%# Locals: key (required) - The ApiKey record %>
|
|
3
3
|
|
|
4
4
|
<% if key.active? %>
|
|
5
|
-
<span class="api-keys-status api-keys-status-active"
|
|
5
|
+
<span class="api-keys-status api-keys-status-active"></span>
|
|
6
6
|
<% elsif key.revoked? %>
|
|
7
|
-
<span class="api-keys-status api-keys-status-revoked"
|
|
7
|
+
<span class="api-keys-status api-keys-status-revoked">[Revoked]</span>
|
|
8
8
|
<% elsif key.expired? %>
|
|
9
|
-
<span class="api-keys-status api-keys-status-expired"
|
|
9
|
+
<span class="api-keys-status api-keys-status-expired">[Expired]</span>
|
|
10
10
|
<% end %>
|
|
@@ -28,9 +28,6 @@
|
|
|
28
28
|
|
|
29
29
|
<%# Render inactive keys below, visually distinct %>
|
|
30
30
|
<% if inactive_keys.any? %>
|
|
31
|
-
<%# Optional: Add a visual separator if desired %>
|
|
32
|
-
<%# <tr><td colspan="7" style="border-top: 2px solid #ccc; text-align: center; padding-top: 1em; color: #555;">Inactive Keys</td></tr> %>
|
|
33
|
-
|
|
34
31
|
<% inactive_keys.each do |key| %>
|
|
35
32
|
<%= render partial: 'api_keys/keys/key_row', locals: { key: key, inactive: true } %>
|
|
36
33
|
<% end %>
|
|
@@ -44,4 +41,4 @@
|
|
|
44
41
|
|
|
45
42
|
</section>
|
|
46
43
|
|
|
47
|
-
<%# The separate inactive keys section is now removed as they are integrated above. %>
|
|
44
|
+
<%# The separate inactive keys section is now removed as they are integrated above. %>
|
|
@@ -10,22 +10,22 @@
|
|
|
10
10
|
<% end %>
|
|
11
11
|
|
|
12
12
|
<p>
|
|
13
|
-
<%= link_to
|
|
13
|
+
<%= link_to security_best_practices_path, class: "text-primary api-keys-align-center" do %>
|
|
14
14
|
Learn more about API key best practices
|
|
15
15
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M15 5a1 1 0 1 1 0-2h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V6.414l-5.293 5.293a1 1 0 0 1-1.414-1.414L17.586 5H15ZM4 7a3 3 0 0 1 3-3h3a1 1 0 1 1 0 2H7a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3a1 1 0 1 1 2 0v3a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7Z" clip-rule="evenodd"></path></svg>
|
|
16
16
|
<% end %>
|
|
17
17
|
</p>
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
<div
|
|
20
|
+
<div class="api-keys-token-panel">
|
|
21
21
|
<div class="card bd-primary">
|
|
22
22
|
<div class="row">
|
|
23
23
|
<div class="col-7 is-vertical-align is-center">
|
|
24
|
-
<pre id="api-key-token"
|
|
24
|
+
<pre id="api-key-token" class="api-keys-token-value"><%= plaintext_token %></pre>
|
|
25
25
|
</div>
|
|
26
26
|
|
|
27
27
|
<div class="col is-vertical-align is-center">
|
|
28
|
-
<button id="copy-api-key-button"
|
|
28
|
+
<button id="copy-api-key-button" type="button" class="button primary api-keys-align-center btn-copy-new-token">
|
|
29
29
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M7 5a3 3 0 0 1 3-3h9a3 3 0 0 1 3 3v9a3 3 0 0 1-3 3h-2v2a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3v-9a3 3 0 0 1 3-3h2V5Zm2 2h5a3 3 0 0 1 3 3v5h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-9a1 1 0 0 0-1 1v2ZM5 9a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1v-9a1 1 0 0 0-1-1H5Z" clip-rule="evenodd"></path></svg>
|
|
30
30
|
<span class="api-keys-button-text">Copy</span>
|
|
31
31
|
</button>
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
<% if api_key.scopes.present? %>
|
|
39
|
-
<div
|
|
39
|
+
<div class="api-keys-token-scopes">
|
|
40
40
|
<p><strong>Permissions</strong></p>
|
|
41
41
|
|
|
42
42
|
<% if api_key.scopes.present? %>
|
|
@@ -49,44 +49,3 @@
|
|
|
49
49
|
</div>
|
|
50
50
|
<% end %>
|
|
51
51
|
</div>
|
|
52
|
-
|
|
53
|
-
<%# Simple JavaScript for copy-to-clipboard functionality %>
|
|
54
|
-
<%# Ensure this script is loaded only once if rendering multiple components %>
|
|
55
|
-
<script>
|
|
56
|
-
function copyTokenToClipboard() {
|
|
57
|
-
const tokenElement = document.getElementById('api-key-token');
|
|
58
|
-
const copyButton = document.getElementById('copy-api-key-button');
|
|
59
|
-
const buttonTextElement = copyButton.querySelector('.api-keys-button-text'); // Target the span containing the text
|
|
60
|
-
const originalButtonText = buttonTextElement.innerHTML; // Store original HTML
|
|
61
|
-
|
|
62
|
-
if (navigator.clipboard && tokenElement && copyButton && buttonTextElement) {
|
|
63
|
-
navigator.clipboard.writeText(tokenElement.textContent || '').then(() => {
|
|
64
|
-
buttonTextElement.innerHTML = 'Copied!'; // Update text
|
|
65
|
-
copyButton.classList.add('success'); // Optional: Add class for styling
|
|
66
|
-
setTimeout(() => {
|
|
67
|
-
buttonTextElement.innerHTML = originalButtonText; // Revert text
|
|
68
|
-
copyButton.classList.remove('success'); // Optional: Remove class
|
|
69
|
-
}, 2000);
|
|
70
|
-
}).catch(err => {
|
|
71
|
-
buttonTextElement.innerHTML = 'Failed'; // Update text on error
|
|
72
|
-
copyButton.classList.add('error'); // Optional: Add class for styling
|
|
73
|
-
console.error('Failed to copy text: ', err);
|
|
74
|
-
setTimeout(() => {
|
|
75
|
-
buttonTextElement.innerHTML = originalButtonText; // Revert text
|
|
76
|
-
copyButton.classList.remove('error'); // Optional: Remove class
|
|
77
|
-
}, 2000);
|
|
78
|
-
});
|
|
79
|
-
} else {
|
|
80
|
-
// Fallback or indicate unavailability more clearly if needed
|
|
81
|
-
buttonTextElement.innerHTML = 'Cannot Copy';
|
|
82
|
-
copyButton.classList.add('error'); // Optional: Add class for styling
|
|
83
|
-
console.warn('Clipboard API not available or element missing.');
|
|
84
|
-
setTimeout(() => {
|
|
85
|
-
buttonTextElement.innerHTML = originalButtonText; // Revert text
|
|
86
|
-
copyButton.classList.remove('error'); // Optional: Remove class
|
|
87
|
-
}, 2000);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
// Automatically try to copy when the partial is rendered, if desired?
|
|
91
|
-
// document.addEventListener('DOMContentLoaded', copyTokenToClipboard); // Example
|
|
92
|
-
</script>
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
<% if key.public_key_type? && key.viewable_token.present? %>
|
|
5
5
|
<span class="token-masked"><code><%= key.masked_token %></code></span>
|
|
6
|
-
<span class="token-full"
|
|
7
|
-
<button type="button" class="btn-show-token"
|
|
8
|
-
<button type="button" class="btn-copy-token"
|
|
6
|
+
<span class="token-full" hidden><code class="api-keys-token-break"><%= key.viewable_token %></code></span>
|
|
7
|
+
<button type="button" class="btn-show-token" title="Show full token">Show</button>
|
|
8
|
+
<button type="button" class="btn-copy-token" title="Copy to clipboard" data-token="<%= key.viewable_token %>" hidden>Copy</button>
|
|
9
9
|
<% else %>
|
|
10
10
|
<code><%= key.masked_token %></code>
|
|
11
11
|
<% end %>
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
<% else %>
|
|
26
26
|
Do not share your API key with others or expose it in the browser or other client-side code.
|
|
27
27
|
<% end %>
|
|
28
|
-
<%= link_to
|
|
28
|
+
<%= link_to security_best_practices_path, class: "text-primary api-keys-align-center" do %>
|
|
29
29
|
Learn more
|
|
30
30
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M15 5a1 1 0 1 1 0-2h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V6.414l-5.293 5.293a1 1 0 0 1-1.414-1.414L17.586 5H15ZM4 7a3 3 0 0 1 3-3h3a1 1 0 1 1 0 2H7a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3a1 1 0 1 1 2 0v3a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7Z" clip-rule="evenodd"></path></svg>
|
|
31
31
|
<% end %>
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
<% end %>
|
|
47
47
|
|
|
48
48
|
<% else %>
|
|
49
|
-
<p>Do not share your API key with others or expose it in the browser or other client-side code. <%= link_to
|
|
49
|
+
<p>Do not share your API key with others or expose it in the browser or other client-side code. <%= link_to security_best_practices_path, class: "text-primary api-keys-align-center" do %>
|
|
50
50
|
Learn more
|
|
51
51
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M15 5a1 1 0 1 1 0-2h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V6.414l-5.293 5.293a1 1 0 0 1-1.414-1.414L17.586 5H15ZM4 7a3 3 0 0 1 3-3h3a1 1 0 1 1 0 2H7a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3a1 1 0 1 1 2 0v3a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7Z" clip-rule="evenodd"></path></svg>
|
|
52
52
|
<% end %>
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
<%= render partial: 'show_token', locals: { api_key: @api_key, plaintext_token: @plaintext_token } %>
|
|
5
5
|
|
|
6
6
|
<%# Add navigation links %>
|
|
7
|
-
<p
|
|
7
|
+
<p class="api-keys-show-footer">
|
|
8
8
|
<%= link_to ApiKeys.configuration.return_text, ApiKeys.configuration.return_url, class: "button outline" %>
|
|
9
9
|
<%= link_to "All API Keys", keys_path, class: "button" %>
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
|
-
</div>
|
|
12
|
+
</div>
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<h2>Understanding Key Types</h2>
|
|
11
11
|
|
|
12
12
|
<h3>Secret Keys</h3>
|
|
13
|
-
<p>Secret keys
|
|
13
|
+
<p>Secret keys may grant sensitive or broad access, depending on their configured permissions, and must be treated like passwords.</p>
|
|
14
14
|
<ul>
|
|
15
15
|
<li><strong>Never expose</strong> in client-side code (browsers, mobile apps, desktop apps)</li>
|
|
16
16
|
<li><strong>Never commit</strong> to version control (Git, etc.)</li>
|
|
@@ -19,12 +19,12 @@
|
|
|
19
19
|
</ul>
|
|
20
20
|
|
|
21
21
|
<h3>Publishable Keys</h3>
|
|
22
|
-
<p>Publishable keys are
|
|
22
|
+
<p>Publishable keys are intentionally exposed identifiers. They are suitable for client-side use <strong>only when every configured permission is safe for an untrusted public client</strong>.</p>
|
|
23
23
|
<ul>
|
|
24
|
-
<li><strong>
|
|
25
|
-
<li><strong>
|
|
24
|
+
<li><strong>Assume anyone can copy and abuse them</strong> from browser JavaScript, mobile apps, or public code</li>
|
|
25
|
+
<li><strong>Grant only public-safe operations</strong> and enforce authorization server-side for every permission</li>
|
|
26
26
|
<li><strong>Always visible</strong> — you can view the full key anytime in your dashboard</li>
|
|
27
|
-
<li><strong>Cannot be revoked</strong> —
|
|
27
|
+
<li><strong>Cannot be revoked individually</strong> — maintain an application-level disable/replacement procedure</li>
|
|
28
28
|
</ul>
|
|
29
29
|
</section>
|
|
30
30
|
<% end %>
|
|
@@ -76,7 +76,7 @@ Rails.application.credentials.your_service_api_key</code></pre>
|
|
|
76
76
|
<h3>Monitor Usage</h3>
|
|
77
77
|
<p>Regularly check for unexpected activity spikes or requests from unusual locations, which could indicate a compromised key.</p>
|
|
78
78
|
|
|
79
|
-
<h3>Rotate Keys Periodically</h3>
|
|
79
|
+
<h3>Rotate Secret Keys Periodically</h3>
|
|
80
80
|
<p>Generate new keys and revoke old ones on a regular schedule (e.g., every 90 days). This limits exposure if a key is leaked undetected.</p>
|
|
81
81
|
<p><em>Tip:</em> Create a new key first, update your application, verify it works, then revoke the old key.</p>
|
|
82
82
|
|
|
@@ -91,6 +91,6 @@ Rails.application.credentials.your_service_api_key</code></pre>
|
|
|
91
91
|
|
|
92
92
|
<hr>
|
|
93
93
|
|
|
94
|
-
<p><%= link_to "Back to API Keys",
|
|
94
|
+
<p><%= link_to "Back to API Keys", keys_path, class: "text-primary" %></p>
|
|
95
95
|
|
|
96
96
|
</article>
|
|
@@ -4,14 +4,12 @@
|
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>🔑 API Keys</title>
|
|
7
|
-
<%#
|
|
8
|
-
<link rel="stylesheet" href="https://unpkg.com/chota@latest">
|
|
9
|
-
<%# Host application is expected to provide styling %>
|
|
7
|
+
<%# Self-contained styles: do not load mutable third-party assets on secret-bearing pages. %>
|
|
10
8
|
<%= csrf_meta_tags %>
|
|
11
9
|
<%= csp_meta_tag %>
|
|
12
10
|
</head>
|
|
13
11
|
<body>
|
|
14
|
-
<style>
|
|
12
|
+
<style nonce="<%= content_security_policy_nonce %>">
|
|
15
13
|
/*
|
|
16
14
|
* API Keys CSS Variables
|
|
17
15
|
* Override these in your host application to customize the dashboard appearance.
|
|
@@ -56,6 +54,92 @@
|
|
|
56
54
|
|
|
57
55
|
body {
|
|
58
56
|
font-family: var(--api-keys-font-family);
|
|
57
|
+
margin: 0;
|
|
58
|
+
color: var(--font-color, #222);
|
|
59
|
+
background: var(--bg-color, #fff);
|
|
60
|
+
line-height: 1.5;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.container {
|
|
64
|
+
width: min(94%, 1080px);
|
|
65
|
+
margin-inline: auto;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.nav, .row {
|
|
69
|
+
display: flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
gap: 1rem;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.nav {
|
|
75
|
+
justify-content: space-between;
|
|
76
|
+
min-height: 4rem;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.nav-left, .nav-right {
|
|
80
|
+
display: flex;
|
|
81
|
+
gap: 1rem;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.col { flex: 1; }
|
|
85
|
+
.col-5 { width: min(100%, 42rem); }
|
|
86
|
+
.col-7 { flex: 0 0 58%; }
|
|
87
|
+
.col-8 { width: min(100%, 54rem); }
|
|
88
|
+
.is-right { justify-content: flex-end; }
|
|
89
|
+
.is-center { justify-content: center; }
|
|
90
|
+
.text-center { text-align: center; }
|
|
91
|
+
|
|
92
|
+
input, select, button, .button {
|
|
93
|
+
box-sizing: border-box;
|
|
94
|
+
font: inherit;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
input[type="text"], select {
|
|
98
|
+
width: 100%;
|
|
99
|
+
padding: 0.65rem;
|
|
100
|
+
border: 1px solid var(--color-grey, #bbb);
|
|
101
|
+
border-radius: 4px;
|
|
102
|
+
background: var(--bg-color, #fff);
|
|
103
|
+
color: inherit;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
button, .button {
|
|
107
|
+
display: inline-block;
|
|
108
|
+
padding: 0.55rem 0.85rem;
|
|
109
|
+
border: 1px solid currentColor;
|
|
110
|
+
border-radius: 4px;
|
|
111
|
+
cursor: pointer;
|
|
112
|
+
text-decoration: none;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.primary {
|
|
116
|
+
color: #fff;
|
|
117
|
+
background: var(--api-keys-primary-color);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.card {
|
|
121
|
+
padding: 1rem;
|
|
122
|
+
border: 1px solid var(--color-grey, #ccc);
|
|
123
|
+
border-radius: var(--api-keys-border-radius);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
table {
|
|
127
|
+
width: 100%;
|
|
128
|
+
border-collapse: collapse;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
th, td {
|
|
132
|
+
padding: 0.65rem;
|
|
133
|
+
border-bottom: 1px solid var(--color-grey, #ddd);
|
|
134
|
+
text-align: left;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.api-keys-table-wrapper { overflow-x: auto; }
|
|
138
|
+
.tag, .api-keys-badge {
|
|
139
|
+
display: inline-block;
|
|
140
|
+
padding: 0.15rem 0.4rem;
|
|
141
|
+
border-radius: 999px;
|
|
142
|
+
background: var(--bg-secondary-color, #eee);
|
|
59
143
|
}
|
|
60
144
|
|
|
61
145
|
@media (prefers-color-scheme: dark) {
|
|
@@ -107,6 +191,50 @@
|
|
|
107
191
|
color: var(--api-keys-danger-color);
|
|
108
192
|
}
|
|
109
193
|
|
|
194
|
+
[hidden] { display: none !important; }
|
|
195
|
+
.api-keys-form-errors { color: var(--api-keys-danger-color); }
|
|
196
|
+
.api-keys-form-help { display: block; color: var(--color-darkGrey, #666); }
|
|
197
|
+
.api-keys-empty-state { text-align: center; padding: 2em; }
|
|
198
|
+
.api-keys-expired-text { color: var(--api-keys-status-expired-color); }
|
|
199
|
+
.api-keys-token-panel { padding: 1em; margin: 1em 0; border-radius: 4px; }
|
|
200
|
+
.api-keys-token-value { overflow-wrap: anywhere; }
|
|
201
|
+
.api-keys-token-scopes, .api-keys-show-footer { margin-top: 2em; }
|
|
202
|
+
.api-keys-token-break { overflow-wrap: anywhere; }
|
|
203
|
+
.btn-show-token, .btn-copy-token {
|
|
204
|
+
margin-left: 0.5rem;
|
|
205
|
+
padding: 0.15rem 0.4rem;
|
|
206
|
+
font-size: 0.75em;
|
|
207
|
+
}
|
|
208
|
+
.api-keys-action-disabled {
|
|
209
|
+
color: var(--api-keys-muted-color);
|
|
210
|
+
cursor: help;
|
|
211
|
+
}
|
|
212
|
+
.api-keys-status-active { color: var(--api-keys-status-active-color); }
|
|
213
|
+
.api-keys-status-revoked { color: var(--api-keys-status-revoked-color); }
|
|
214
|
+
.api-keys-status-expired { color: var(--api-keys-status-expired-color); }
|
|
215
|
+
.api-keys-badge-type, .api-keys-badge-env {
|
|
216
|
+
margin-left: 0.25rem;
|
|
217
|
+
padding: 0.15rem 0.4rem;
|
|
218
|
+
border-radius: 3px;
|
|
219
|
+
font-size: 0.75em;
|
|
220
|
+
}
|
|
221
|
+
.api-keys-badge-publishable {
|
|
222
|
+
color: var(--api-keys-badge-publishable-color);
|
|
223
|
+
background-color: var(--api-keys-badge-publishable-bg);
|
|
224
|
+
}
|
|
225
|
+
.api-keys-badge-secret {
|
|
226
|
+
color: var(--api-keys-badge-secret-color);
|
|
227
|
+
background-color: var(--api-keys-badge-secret-bg);
|
|
228
|
+
}
|
|
229
|
+
.api-keys-badge-live {
|
|
230
|
+
color: var(--api-keys-badge-live-color);
|
|
231
|
+
background-color: var(--api-keys-badge-live-bg);
|
|
232
|
+
}
|
|
233
|
+
.api-keys-badge-test {
|
|
234
|
+
color: var(--api-keys-badge-test-color);
|
|
235
|
+
background-color: var(--api-keys-badge-test-bg);
|
|
236
|
+
}
|
|
237
|
+
|
|
110
238
|
.api-keys-button-text {
|
|
111
239
|
padding-left: 0.2em;
|
|
112
240
|
}
|
|
@@ -114,7 +242,7 @@
|
|
|
114
242
|
#api-keys-flash-container {
|
|
115
243
|
margin-top: 1em;
|
|
116
244
|
}
|
|
117
|
-
|
|
245
|
+
|
|
118
246
|
.api-keys-flash-message {
|
|
119
247
|
background-color: rgba(255, 255, 255, 0.15);
|
|
120
248
|
padding: 0.8em 0;
|
|
@@ -168,7 +296,7 @@
|
|
|
168
296
|
<%# Use configured return URL and text %>
|
|
169
297
|
<%= link_to ApiKeys.configuration.return_text, ApiKeys.configuration.return_url %>
|
|
170
298
|
<%# Link to keys index using engine's path helper %>
|
|
171
|
-
<%= link_to "My API Keys",
|
|
299
|
+
<%= link_to "My API Keys", keys_path, class: "active" %>
|
|
172
300
|
</div>
|
|
173
301
|
<div class="nav-center">
|
|
174
302
|
</div>
|
|
@@ -193,16 +321,16 @@
|
|
|
193
321
|
<%# Footer content %>
|
|
194
322
|
</footer>
|
|
195
323
|
|
|
196
|
-
<script>
|
|
324
|
+
<script nonce="<%= content_security_policy_nonce %>">
|
|
197
325
|
// Minimal JS for API Keys dashboard - event delegation for show/copy tokens
|
|
198
326
|
document.addEventListener('click', function(e) {
|
|
199
327
|
// Show token (one-way: masked -> full, no hiding back)
|
|
200
328
|
if (e.target.matches('.btn-show-token')) {
|
|
201
329
|
var cell = e.target.closest('td');
|
|
202
|
-
cell.querySelector('.token-masked').
|
|
203
|
-
cell.querySelector('.token-full').
|
|
204
|
-
e.target.
|
|
205
|
-
cell.querySelector('.btn-copy-token').
|
|
330
|
+
cell.querySelector('.token-masked').hidden = true;
|
|
331
|
+
cell.querySelector('.token-full').hidden = false;
|
|
332
|
+
e.target.hidden = true;
|
|
333
|
+
cell.querySelector('.btn-copy-token').hidden = false;
|
|
206
334
|
}
|
|
207
335
|
|
|
208
336
|
// Copy token to clipboard
|
|
@@ -212,10 +340,29 @@
|
|
|
212
340
|
var originalText = e.target.textContent;
|
|
213
341
|
e.target.textContent = 'Copied!';
|
|
214
342
|
setTimeout(function() { e.target.textContent = originalText; }, 2000);
|
|
343
|
+
}).catch(function() {
|
|
344
|
+
e.target.textContent = 'Copy failed';
|
|
345
|
+
setTimeout(function() { e.target.textContent = 'Copy'; }, 2000);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Copy a newly issued secret without duplicating it into an HTML attribute.
|
|
350
|
+
if (e.target.closest('.btn-copy-new-token')) {
|
|
351
|
+
var button = e.target.closest('.btn-copy-new-token');
|
|
352
|
+
var tokenElement = document.getElementById('api-key-token');
|
|
353
|
+
var buttonText = button.querySelector('.api-keys-button-text');
|
|
354
|
+
if (!navigator.clipboard || !tokenElement || !buttonText) return;
|
|
355
|
+
|
|
356
|
+
navigator.clipboard.writeText(tokenElement.textContent || '').then(function() {
|
|
357
|
+
buttonText.textContent = 'Copied!';
|
|
358
|
+
setTimeout(function() { buttonText.textContent = 'Copy'; }, 2000);
|
|
359
|
+
}).catch(function() {
|
|
360
|
+
buttonText.textContent = 'Copy failed';
|
|
361
|
+
setTimeout(function() { buttonText.textContent = 'Copy'; }, 2000);
|
|
215
362
|
});
|
|
216
363
|
}
|
|
217
364
|
});
|
|
218
365
|
</script>
|
|
219
366
|
|
|
220
367
|
</body>
|
|
221
|
-
</html>
|
|
368
|
+
</html>
|