Add API keys / bearer tokens for forward auth
Some checks failed
Some checks failed
Enables server-to-server authentication for forward auth applications (e.g., video players accessing WebDAV) where browser cookies aren't available. API keys use clk_ prefixed tokens stored as HMAC hashes. Bearer token auth is checked before cookie auth in /api/verify. Invalid tokens return 401 JSON (no redirect). Requests without bearer tokens fall through to existing cookie flow unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,10 @@ module Api
|
||||
def verify
|
||||
# Note: app_slug parameter is no longer used - we match domains directly with Application (forward_auth type)
|
||||
|
||||
# Check for bearer token first (API keys for server-to-server auth)
|
||||
bearer_result = authenticate_bearer_token
|
||||
return bearer_result if bearer_result
|
||||
|
||||
# Check for one-time forward auth token first (to handle race condition)
|
||||
session_id = check_forward_auth_token
|
||||
|
||||
@@ -113,6 +117,43 @@ module Api
|
||||
|
||||
private
|
||||
|
||||
def authenticate_bearer_token
|
||||
auth_header = request.headers["Authorization"]
|
||||
return nil unless auth_header&.start_with?("Bearer ")
|
||||
|
||||
token = auth_header.delete_prefix("Bearer ").strip
|
||||
return render_bearer_error("Missing token") if token.blank?
|
||||
|
||||
api_key = ApiKey.find_by_token(token)
|
||||
return render_bearer_error("Invalid or expired API key") unless api_key&.active?
|
||||
|
||||
user = api_key.user
|
||||
return render_bearer_error("User account is not active") unless user.active?
|
||||
|
||||
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
||||
app = api_key.application
|
||||
|
||||
if forwarded_host.present? && !app.matches_domain?(forwarded_host)
|
||||
return render_bearer_error("API key not valid for this domain")
|
||||
end
|
||||
|
||||
unless app.active?
|
||||
return render_bearer_error("Application is inactive")
|
||||
end
|
||||
|
||||
api_key.touch_last_used!
|
||||
|
||||
headers = app.headers_for_user(user)
|
||||
headers.each { |key, value| response.headers[key] = value }
|
||||
|
||||
Rails.logger.info "ForwardAuth: API key '#{api_key.name}' authenticated user #{user.email_address} for #{forwarded_host}"
|
||||
head :ok
|
||||
end
|
||||
|
||||
def render_bearer_error(message)
|
||||
render json: { error: message }, status: :unauthorized
|
||||
end
|
||||
|
||||
def check_forward_auth_token
|
||||
# Check for one-time token in query parameters (for race condition handling)
|
||||
token = params[:fa_token]
|
||||
|
||||
51
app/controllers/api_keys_controller.rb
Normal file
51
app/controllers/api_keys_controller.rb
Normal file
@@ -0,0 +1,51 @@
|
||||
class ApiKeysController < ApplicationController
|
||||
before_action :set_api_key, only: :destroy
|
||||
|
||||
def index
|
||||
@api_keys = Current.session.user.api_keys.includes(:application).order(created_at: :desc)
|
||||
end
|
||||
|
||||
def new
|
||||
@api_key = ApiKey.new
|
||||
@applications = forward_auth_apps_for_user
|
||||
end
|
||||
|
||||
def create
|
||||
@api_key = Current.session.user.api_keys.build(api_key_params)
|
||||
|
||||
if @api_key.save
|
||||
flash[:api_key_token] = @api_key.plaintext_token
|
||||
redirect_to api_key_path(@api_key)
|
||||
else
|
||||
@applications = forward_auth_apps_for_user
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
@api_key = Current.session.user.api_keys.find(params[:id])
|
||||
@plaintext_token = flash[:api_key_token]
|
||||
|
||||
redirect_to api_keys_path unless @plaintext_token
|
||||
end
|
||||
|
||||
def destroy
|
||||
@api_key.revoke!
|
||||
redirect_to api_keys_path, notice: "API key revoked."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_api_key
|
||||
@api_key = Current.session.user.api_keys.find(params[:id])
|
||||
end
|
||||
|
||||
def api_key_params
|
||||
params.require(:api_key).permit(:name, :application_id, :expires_at)
|
||||
end
|
||||
|
||||
def forward_auth_apps_for_user
|
||||
user = Current.session.user
|
||||
Application.forward_auth.active.select { |app| app.user_allowed?(user) }
|
||||
end
|
||||
end
|
||||
15
app/javascript/controllers/clipboard_controller.js
Normal file
15
app/javascript/controllers/clipboard_controller.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["source", "label"]
|
||||
|
||||
async copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.sourceTarget.value)
|
||||
this.labelTarget.textContent = "Copied!"
|
||||
setTimeout(() => { this.labelTarget.textContent = "Copy" }, 2000)
|
||||
} catch {
|
||||
this.sourceTarget.select()
|
||||
}
|
||||
}
|
||||
}
|
||||
66
app/models/api_key.rb
Normal file
66
app/models/api_key.rb
Normal file
@@ -0,0 +1,66 @@
|
||||
class ApiKey < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :application
|
||||
|
||||
before_validation :generate_token, on: :create
|
||||
|
||||
validates :name, presence: true
|
||||
validates :token_hmac, presence: true, uniqueness: true
|
||||
validate :application_must_be_forward_auth
|
||||
validate :user_must_have_access
|
||||
|
||||
scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||
scope :revoked, -> { where.not(revoked_at: nil) }
|
||||
|
||||
attr_accessor :plaintext_token
|
||||
|
||||
def self.find_by_token(plaintext_token)
|
||||
return nil if plaintext_token.blank?
|
||||
|
||||
token_hmac = compute_token_hmac(plaintext_token)
|
||||
find_by(token_hmac: token_hmac)
|
||||
end
|
||||
|
||||
def self.compute_token_hmac(plaintext_token)
|
||||
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
|
||||
end
|
||||
|
||||
def expired?
|
||||
expires_at.present? && expires_at <= Time.current
|
||||
end
|
||||
|
||||
def revoked?
|
||||
revoked_at.present?
|
||||
end
|
||||
|
||||
def active?
|
||||
!expired? && !revoked?
|
||||
end
|
||||
|
||||
def revoke!
|
||||
update!(revoked_at: Time.current)
|
||||
end
|
||||
|
||||
def touch_last_used!
|
||||
update_column(:last_used_at, Time.current)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_token
|
||||
self.plaintext_token ||= "clk_#{SecureRandom.urlsafe_base64(48)}"
|
||||
self.token_hmac ||= self.class.compute_token_hmac(plaintext_token)
|
||||
end
|
||||
|
||||
def application_must_be_forward_auth
|
||||
if application && !application.forward_auth?
|
||||
errors.add(:application, "must be a forward auth application")
|
||||
end
|
||||
end
|
||||
|
||||
def user_must_have_access
|
||||
if user && application && !application.user_allowed?(user)
|
||||
errors.add(:user, "does not have access to this application")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -34,6 +34,7 @@ class Application < ApplicationRecord
|
||||
has_many :oidc_access_tokens, dependent: :destroy
|
||||
has_many :oidc_refresh_tokens, dependent: :destroy
|
||||
has_many :oidc_user_consents, dependent: :destroy
|
||||
has_many :api_keys, dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
validates :slug, presence: true, uniqueness: {case_sensitive: false},
|
||||
|
||||
@@ -9,6 +9,7 @@ class User < ApplicationRecord
|
||||
has_many :application_user_claims, dependent: :destroy
|
||||
has_many :oidc_user_consents, dependent: :destroy
|
||||
has_many :webauthn_credentials, dependent: :destroy
|
||||
has_many :api_keys, dependent: :destroy
|
||||
|
||||
# Token generation for passwordless flows
|
||||
generates_token_for :invitation_login, expires_in: 24.hours do
|
||||
|
||||
71
app/views/api_keys/index.html.erb
Normal file
71
app/views/api_keys/index.html.erb
Normal file
@@ -0,0 +1,71 @@
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">API Keys</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
Bearer tokens for server-to-server access to forward auth applications.
|
||||
</p>
|
||||
</div>
|
||||
<%= link_to "New API Key", new_api_key_path,
|
||||
class: "inline-flex items-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
|
||||
</div>
|
||||
|
||||
<% if @api_keys.any? %>
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Application</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Used</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Expires</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<% @api_keys.each do |key| %>
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"><%= key.name %></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"><%= key.application.name %></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"><%= key.created_at.strftime("%b %d, %Y") %></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"><%= key.last_used_at ? time_ago_in_words(key.last_used_at) + " ago" : "Never" %></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"><%= key.expires_at ? key.expires_at.strftime("%b %d, %Y") : "Never" %></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<% if key.revoked? %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">Revoked</span>
|
||||
<% elsif key.expired? %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Expired</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Active</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<% if key.active? %>
|
||||
<%= button_to "Revoke", api_key_path(key), method: :delete,
|
||||
class: "text-red-600 hover:text-red-900",
|
||||
form: { data: { turbo_confirm: "Revoke this API key? This cannot be undone." } } %>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-gray-50 rounded-lg border border-gray-200 p-8 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
|
||||
</svg>
|
||||
<h3 class="mt-4 text-lg font-medium text-gray-900">No API keys</h3>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
Create an API key to authenticate server-to-server requests.
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<%= link_to "Create API Key", new_api_key_path,
|
||||
class: "inline-flex items-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
55
app/views/api_keys/new.html.erb
Normal file
55
app/views/api_keys/new.html.erb
Normal file
@@ -0,0 +1,55 @@
|
||||
<div class="max-w-lg mx-auto">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">New API Key</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
Create a bearer token for server-to-server access to a forward auth application.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<%= form_with(model: @api_key, class: "space-y-6") do |f| %>
|
||||
<% if @api_key.errors.any? %>
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<div class="text-sm text-red-700">
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<% @api_key.errors.full_messages.each do |msg| %>
|
||||
<li><%= msg %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<%= f.label :name, class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= f.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||
placeholder: "e.g., Video Player WebDAV" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :application_id, "Application", class: "block text-sm font-medium text-gray-700" %>
|
||||
<% if @applications.any? %>
|
||||
<%= f.collection_select :application_id, @applications, :id, :name,
|
||||
{ prompt: "Select an application" },
|
||||
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
||||
<% else %>
|
||||
<p class="mt-1 text-sm text-gray-500">No forward auth applications available.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :expires_at, "Expiration (optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= f.datetime_local_field :expires_at, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
<p class="mt-1 text-xs text-gray-500">Leave blank for no expiration.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<%= link_to "Cancel", api_keys_path, class: "text-sm font-medium text-gray-700 hover:text-gray-500" %>
|
||||
<%= f.submit "Create API Key",
|
||||
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
59
app/views/api_keys/show.html.erb
Normal file
59
app/views/api_keys/show.html.erb
Normal file
@@ -0,0 +1,59 @@
|
||||
<div class="max-w-2xl mx-auto" data-controller="clipboard">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">API Key Created</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
Copy your API key now. You won't be able to see it again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="rounded-md bg-yellow-50 p-4 mb-6">
|
||||
<div class="flex">
|
||||
<svg class="h-5 w-5 text-yellow-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div class="text-sm text-yellow-800">
|
||||
<p class="font-medium">Save this key now!</p>
|
||||
<p class="mt-1">This is the only time you'll see the full API key. Store it securely.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">API Key</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="text" readonly value="<%= @plaintext_token %>"
|
||||
data-clipboard-target="source"
|
||||
class="flex-1 rounded-md border-gray-300 bg-gray-50 font-mono text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500" />
|
||||
<button data-action="click->clipboard#copy"
|
||||
data-clipboard-target="button"
|
||||
class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-3 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||
</svg>
|
||||
<span data-clipboard-target="label">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-2 text-sm text-gray-600">
|
||||
<p><strong>Name:</strong> <%= @api_key.name %></p>
|
||||
<p><strong>Application:</strong> <%= @api_key.application.name %></p>
|
||||
<p><strong>Expires:</strong> <%= @api_key.expires_at ? @api_key.expires_at.strftime("%b %d, %Y %H:%M") : "Never" %></p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-md bg-gray-50 p-4">
|
||||
<p class="text-sm font-medium text-gray-700 mb-2">Usage example:</p>
|
||||
<pre class="text-xs text-gray-600 overflow-x-auto">curl -H "Authorization: Bearer <%= @plaintext_token %>" \
|
||||
-H "X-Forwarded-Host: your-app.example.com" \
|
||||
<%= request.base_url %>/api/verify</pre>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<%= link_to "Done", api_keys_path,
|
||||
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,6 +91,32 @@
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- API Keys Card -->
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">
|
||||
API Keys
|
||||
</dt>
|
||||
<dd class="text-lg font-semibold text-gray-900">
|
||||
<%= @user.api_keys.active.count %>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<%= link_to "Manage API Keys", api_keys_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Your Applications Section -->
|
||||
|
||||
Reference in New Issue
Block a user