Skip to content

Commit 1407c81

Browse files
authored
Merge pull request #8 from rameerez/feature/public-key-token-storage
Add public key token storage for non-revocable publishable keys
2 parents ffa9f09 + 80c4eda commit 1407c81

9 files changed

Lines changed: 479 additions & 6 deletions

File tree

ā€ŽREADME.mdā€Ž

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,67 @@ pk.destroy! # Raises ApiKeys::Errors::KeyNotRevocableError
555555

556556
The dashboard UI automatically hides the revoke button for non-revocable keys.
557557

558+
### Public Keys (Viewable Tokens)
559+
560+
#### The Problem: Non-Revocable Key Lockout
561+
562+
Non-revocable keys create a potential UX nightmare: if a user creates a publishable key, doesn't copy it immediately, and closes the page—they're locked out. The token is gone forever (we only store the hash), and they can't delete the key to create a new one (it's non-revocable). They're stuck with a useless key slot they can never use or remove.
563+
564+
This is especially problematic when combined with `limit: 1`, which restricts users to a single publishable key per environment. A user who loses their token would be permanently locked out of creating publishable keys.
565+
566+
#### The Solution: Storing Public Keys
567+
568+
For publishable keys—which are *designed* to be embedded in client-side code and distributed apps—there's no security benefit to hiding the token. These keys are meant to be public! Stripe, for example, lets you view your publishable key anytime in the dashboard.
569+
570+
The `public: true` option stores the plaintext token in metadata so users can view it again:
571+
572+
```ruby
573+
config.key_types = {
574+
publishable: {
575+
prefix: "pk",
576+
permissions: %w[read validate],
577+
revocable: false,
578+
public: true, # Store token for later viewing
579+
limit: 1
580+
},
581+
secret: {
582+
prefix: "sk",
583+
permissions: :all
584+
# public: false (default) - NEVER store secret keys!
585+
}
586+
}
587+
```
588+
589+
#### Security: Why This is Safe
590+
591+
> [!IMPORTANT]
592+
> The `public` option only works when BOTH conditions are met:
593+
> - `public: true` is set in the key type configuration
594+
> - `revocable: false` is set (non-revocable keys only)
595+
596+
This double-check is a deliberate safety measure:
597+
598+
1. **Secret keys are NEVER stored** — Even if you accidentally set `public: true` on a secret key type, the gem checks for `revocable: false` as well. Secret keys are revocable by default, so they're protected.
599+
600+
2. **Revocable keys are NEVER stored** — If a key can be revoked, users can always delete it and create a new one. There's no lockout risk, so no need to store the token.
601+
602+
3. **Only truly public keys are stored** — Publishable keys with limited permissions, designed for client-side embedding, are the only keys that get stored. These tokens provide no security benefit when hidden—they're meant to be distributed.
603+
604+
> [!WARNING]
605+
> āš ļø **Never set `public: true` on secret keys or any key type with sensitive permissions.** The gem prevents this by requiring `revocable: false`, but you should also never configure it that way.
606+
607+
When a key is public, the dashboard shows a "Show" button to reveal the full token:
608+
609+
```ruby
610+
pk = user.create_api_key!(key_type: :publishable)
611+
pk.public_key_type? # => true
612+
pk.viewable_token # => "pk_test_abc123..." (the full token)
613+
614+
sk = user.create_api_key!(key_type: :secret)
615+
sk.public_key_type? # => false
616+
sk.viewable_token # => nil (not stored)
617+
```
618+
558619
### Environment Isolation
559620

560621
With `strict_environment_isolation = true`, keys can only authenticate in their matching environment:

ā€Žapp/views/api_keys/keys/_key_row.html.erbā€Ž

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<% elsif key.expired? %>
99
<span style="color: red;">[Expired]</span>
1010
<% end %>
11-
<%= key.name.presence || "Secret key" %>
11+
<%= key.name.presence || (key.key_type.present? ? "#{key.key_type.humanize} key" : "API key") %>
1212
<%# Key type and environment badges %>
1313
<% if key.key_type.present? %>
1414
<% type_config = key.key_type_config %>
@@ -22,7 +22,16 @@
2222
</span>
2323
<% end %>
2424
</td>
25-
<td><code><%= key.masked_token %></code></td>
25+
<td>
26+
<code><%= key.masked_token %></code>
27+
<% if key.public_key_type? && key.viewable_token.present? %>
28+
<button type="button" onclick="this.nextElementSibling.style.display='inline'; this.style.display='none';" style="margin-left: 8px; font-size: 0.75em; padding: 2px 6px; cursor: pointer;" title="Show full token">Show</button>
29+
<span style="display: none;">
30+
<code style="word-break: break-all;"><%= key.viewable_token %></code>
31+
<button type="button" onclick="navigator.clipboard.writeText('<%= key.viewable_token %>'); alert('Copied!');" style="margin-left: 4px; font-size: 0.75em; padding: 2px 6px; cursor: pointer;" title="Copy to clipboard">Copy</button>
32+
</span>
33+
<% end %>
34+
</td>
2635

2736

2837
<td title="<%= key.created_at.strftime('%Y-%m-%d %H:%M:%S %Z') %>">

ā€Žapp/views/api_keys/keys/_keys_table.html.erbā€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
<thead>
1313
<tr>
1414
<th>Name</th>
15-
<th>Secret Key</th>
15+
<th>API Key</th>
1616
<th>Created</th>
1717
<th>Expires</th>
1818
<th>Last Used</th>

ā€Žapp/views/api_keys/keys/_show_token.html.erbā€Ž

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33

44
<h2>Save your key</h2>
55

6-
<p>Please save your secret key in a safe place since <strong>you won't be able to view it again</strong>. Keep it secure, as anyone with your API key can make requests on your behalf. If you do lose it, you'll need to generate a new one.</p>
6+
<% if api_key.public_key_type? %>
7+
<p>Here's your <%= api_key.key_type.humanize.downcase %> key. This key is designed to be embedded in client-side applications. You can view it again anytime from your dashboard.</p>
8+
<% else %>
9+
<p>Please save your API key in a safe place since <strong>you won't be able to view it again</strong>. Keep it secure, as anyone with your API key can make requests on your behalf. If you lose it, you'll need to generate a new one.</p>
10+
<% end %>
711

812
<p>
913
<%= link_to api_keys.security_best_practices_path, class: "text-primary api-keys-align-center" do %>

ā€Žapp/views/api_keys/keys/index.html.erbā€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<div class="col api-keys-align-center is-right">
88
<%= link_to new_key_path, class: "button primary api-keys-align-center", role: "button" do %>
99
<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 5a1 1 0 0 1 1 1v5h5a1 1 0 1 1 0 2h-5v5a1 1 0 1 1-2 0v-5H6a1 1 0 1 1 0-2h5V6a1 1 0 0 1 1-1Z" clip-rule="evenodd"></path></svg>
10-
<span>&nbsp;Create new secret key</span>
10+
<span>&nbsp;Create new API key</span>
1111
<% end %>
1212
</div>
1313

ā€Žlib/api_keys/configuration.rbā€Ž

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,12 @@ class Configuration
6363
# - :permissions [Array<String>, :all] Scope ceiling for this type
6464
# - :revocable [Boolean] Whether keys can be revoked (default: true)
6565
# - :limit [Integer, nil] Max keys per owner per environment (nil = unlimited)
66+
# - :public [Boolean] If true AND revocable: false, store plaintext token in
67+
# metadata so it can be viewed again in dashboard. Use ONLY for publishable
68+
# keys that are designed to be embedded in distributed apps. (default: false)
6669
# @example
6770
# config.key_types = {
68-
# publishable: { prefix: "pk", permissions: %w[read], revocable: false, limit: 1 },
71+
# publishable: { prefix: "pk", permissions: %w[read], revocable: false, public: true, limit: 1 },
6972
# secret: { prefix: "sk", permissions: :all }
7073
# }
7174
#

ā€Žlib/api_keys/models/api_key.rbā€Ž

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,24 @@ def environment_config
102102
ApiKeys.configuration.environments&.dig(environment.to_sym)
103103
end
104104

105+
# Returns true if this key type is configured as public AND non-revocable.
106+
# Only these keys have their plaintext token stored in metadata for later viewing.
107+
# This is used for publishable keys that are designed to be embedded in distributed apps.
108+
def public_key_type?
109+
return false if key_type.blank?
110+
config = key_type_config
111+
return false if config.nil?
112+
config[:public] == true && config[:revocable] == false
113+
end
114+
115+
# Returns the stored plaintext token for public, non-revocable keys.
116+
# Returns nil for all other key types (the token is only available at creation time).
117+
# @return [String, nil] The full plaintext token, or nil if not stored
118+
def viewable_token
119+
return nil unless public_key_type?
120+
metadata&.dig("token")
121+
end
122+
105123
# Override destroy to prevent destroying non-revocable keys
106124
def destroy
107125
raise ApiKeys::Errors::KeyNotRevocableError unless revocable?
@@ -237,6 +255,14 @@ def generate_token_and_digest
237255
if ApiKeys.configuration.expire_after.present? && self.expires_at.nil?
238256
self.expires_at = ApiKeys.configuration.expire_after.from_now
239257
end
258+
259+
# Store plaintext token in metadata for public, non-revocable keys.
260+
# This allows users to view the token again in the dashboard.
261+
# SECURITY: Only do this for keys explicitly configured as public: true
262+
# AND revocable: false (e.g., publishable keys for distributed apps).
263+
if public_key_type?
264+
self.metadata = (self.metadata || {}).merge("token" => @token)
265+
end
240266
end
241267

242268
# == Validation Helpers ==

ā€Žlib/generators/api_keys/templates/initializer.rbā€Ž

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,18 +111,24 @@
111111
# - permissions: Scope ceiling - array of allowed scopes, or :all for unrestricted
112112
# - revocable: Whether keys of this type can be revoked/deleted (default: true)
113113
# - limit: Max keys of this type per owner per environment (nil = unlimited)
114+
# - public: If true AND revocable: false, stores plaintext token in metadata
115+
# so it can be viewed again in the dashboard. Use ONLY for publishable
116+
# keys designed to be embedded in distributed apps. (default: false)
117+
# SECURITY: NEVER set public: true on secret keys!
114118
#
115119
# config.key_types = {
116120
# publishable: {
117121
# prefix: "pk", # → pk_test_, pk_live_
118122
# permissions: %w[read validate], # Can ONLY have these scopes
119123
# revocable: false, # Cannot be revoked - protects deployed apps!
124+
# public: true, # Store token for later viewing in dashboard
120125
# limit: 1 # Only 1 publishable key per environment
121126
# },
122127
# secret: {
123128
# prefix: "sk", # → sk_test_, sk_live_
124129
# permissions: :all # No scope restrictions
125130
# # revocable: true (default)
131+
# # public: false (default) - NEVER store secret keys!
126132
# # limit: nil (default = unlimited)
127133
# }
128134
# }

0 commit comments

Comments
Ā (0)
⚔