Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,67 @@ pk.destroy! # Raises ApiKeys::Errors::KeyNotRevocableError

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

### Public Keys (Viewable Tokens)

#### The Problem: Non-Revocable Key Lockout

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.

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.

#### The Solution: Storing Public Keys

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.

The `public: true` option stores the plaintext token in metadata so users can view it again:

```ruby
config.key_types = {
publishable: {
prefix: "pk",
permissions: %w[read validate],
revocable: false,
public: true, # Store token for later viewing
limit: 1
},
secret: {
prefix: "sk",
permissions: :all
# public: false (default) - NEVER store secret keys!
}
}
```

#### Security: Why This is Safe

> [!IMPORTANT]
> The `public` option only works when BOTH conditions are met:
> - `public: true` is set in the key type configuration
> - `revocable: false` is set (non-revocable keys only)

This double-check is a deliberate safety measure:

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.

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.

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.

> [!WARNING]
> ⚠️ **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.

When a key is public, the dashboard shows a "Show" button to reveal the full token:

```ruby
pk = user.create_api_key!(key_type: :publishable)
pk.public_key_type? # => true
pk.viewable_token # => "pk_test_abc123..." (the full token)

sk = user.create_api_key!(key_type: :secret)
sk.public_key_type? # => false
sk.viewable_token # => nil (not stored)
```

### Environment Isolation

With `strict_environment_isolation = true`, keys can only authenticate in their matching environment:
Expand Down
13 changes: 11 additions & 2 deletions app/views/api_keys/keys/_key_row.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<% elsif key.expired? %>
<span style="color: red;">[Expired]</span>
<% end %>
<%= key.name.presence || "Secret key" %>
<%= key.name.presence || (key.key_type.present? ? "#{key.key_type.humanize} key" : "API key") %>
<%# Key type and environment badges %>
<% if key.key_type.present? %>
<% type_config = key.key_type_config %>
Expand All @@ -22,7 +22,16 @@
</span>
<% end %>
</td>
<td><code><%= key.masked_token %></code></td>
<td>
<code><%= key.masked_token %></code>
<% if key.public_key_type? && key.viewable_token.present? %>
<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>
<span style="display: none;">
<code style="word-break: break-all;"><%= key.viewable_token %></code>
<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>
</span>
<% end %>
</td>


<td title="<%= key.created_at.strftime('%Y-%m-%d %H:%M:%S %Z') %>">
Expand Down
2 changes: 1 addition & 1 deletion app/views/api_keys/keys/_keys_table.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<thead>
<tr>
<th>Name</th>
<th>Secret Key</th>
<th>API Key</th>
<th>Created</th>
<th>Expires</th>
<th>Last Used</th>
Expand Down
6 changes: 5 additions & 1 deletion app/views/api_keys/keys/_show_token.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@

<h2>Save your key</h2>

<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>
<% if api_key.public_key_type? %>
<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>
<% else %>
<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>
<% end %>

<p>
<%= link_to api_keys.security_best_practices_path, class: "text-primary api-keys-align-center" do %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/api_keys/keys/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<div class="col api-keys-align-center is-right">
<%= link_to new_key_path, class: "button primary api-keys-align-center", role: "button" do %>
<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>
<span>&nbsp;Create new secret key</span>
<span>&nbsp;Create new API key</span>
<% end %>
</div>

Expand Down
5 changes: 4 additions & 1 deletion lib/api_keys/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,12 @@ class Configuration
# - :permissions [Array<String>, :all] Scope ceiling for this type
# - :revocable [Boolean] Whether keys can be revoked (default: true)
# - :limit [Integer, nil] Max keys per owner per environment (nil = unlimited)
# - :public [Boolean] If true AND revocable: false, store plaintext token in
# metadata so it can be viewed again in dashboard. Use ONLY for publishable
# keys that are designed to be embedded in distributed apps. (default: false)
# @example
# config.key_types = {
# publishable: { prefix: "pk", permissions: %w[read], revocable: false, limit: 1 },
# publishable: { prefix: "pk", permissions: %w[read], revocable: false, public: true, limit: 1 },
# secret: { prefix: "sk", permissions: :all }
# }
#
Expand Down
26 changes: 26 additions & 0 deletions lib/api_keys/models/api_key.rb
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,24 @@ def environment_config
ApiKeys.configuration.environments&.dig(environment.to_sym)
end

# Returns true if this key type is configured as public AND non-revocable.
# Only these keys have their plaintext token stored in metadata for later viewing.
# This is used for publishable keys that are designed to be embedded in distributed apps.
def public_key_type?
return false if key_type.blank?
config = key_type_config
return false if config.nil?
config[:public] == true && config[:revocable] == false
end

# Returns the stored plaintext token for public, non-revocable keys.
# Returns nil for all other key types (the token is only available at creation time).
# @return [String, nil] The full plaintext token, or nil if not stored
def viewable_token
return nil unless public_key_type?
metadata&.dig("token")
end

# Override destroy to prevent destroying non-revocable keys
def destroy
raise ApiKeys::Errors::KeyNotRevocableError unless revocable?
Expand Down Expand Up @@ -237,6 +255,14 @@ def generate_token_and_digest
if ApiKeys.configuration.expire_after.present? && self.expires_at.nil?
self.expires_at = ApiKeys.configuration.expire_after.from_now
end

# Store plaintext token in metadata for public, non-revocable keys.
# This allows users to view the token again in the dashboard.
# SECURITY: Only do this for keys explicitly configured as public: true
# AND revocable: false (e.g., publishable keys for distributed apps).
if public_key_type?
self.metadata = (self.metadata || {}).merge("token" => @token)
end
end

# == Validation Helpers ==
Expand Down
6 changes: 6 additions & 0 deletions lib/generators/api_keys/templates/initializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -111,18 +111,24 @@
# - permissions: Scope ceiling - array of allowed scopes, or :all for unrestricted
# - revocable: Whether keys of this type can be revoked/deleted (default: true)
# - limit: Max keys of this type per owner per environment (nil = unlimited)
# - public: If true AND revocable: false, stores plaintext token in metadata
# so it can be viewed again in the dashboard. Use ONLY for publishable
# keys designed to be embedded in distributed apps. (default: false)
# SECURITY: NEVER set public: true on secret keys!
#
# config.key_types = {
# publishable: {
# prefix: "pk", # → pk_test_, pk_live_
# permissions: %w[read validate], # Can ONLY have these scopes
# revocable: false, # Cannot be revoked - protects deployed apps!
# public: true, # Store token for later viewing in dashboard
# limit: 1 # Only 1 publishable key per environment
# },
# secret: {
# prefix: "sk", # → sk_test_, sk_live_
# permissions: :all # No scope restrictions
# # revocable: true (default)
# # public: false (default) - NEVER store secret keys!
# # limit: nil (default = unlimited)
# }
# }
Expand Down
Loading
Loading