diff --git a/app/controllers/api/forward_auth_controller.rb b/app/controllers/api/forward_auth_controller.rb
index 4de8b7a..1708e49 100644
--- a/app/controllers/api/forward_auth_controller.rb
+++ b/app/controllers/api/forward_auth_controller.rb
@@ -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]
diff --git a/app/controllers/api_keys_controller.rb b/app/controllers/api_keys_controller.rb
new file mode 100644
index 0000000..0caf305
--- /dev/null
+++ b/app/controllers/api_keys_controller.rb
@@ -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
diff --git a/app/javascript/controllers/clipboard_controller.js b/app/javascript/controllers/clipboard_controller.js
new file mode 100644
index 0000000..1bfa68a
--- /dev/null
+++ b/app/javascript/controllers/clipboard_controller.js
@@ -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()
+ }
+ }
+}
diff --git a/app/models/api_key.rb b/app/models/api_key.rb
new file mode 100644
index 0000000..d7bab23
--- /dev/null
+++ b/app/models/api_key.rb
@@ -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
diff --git a/app/models/application.rb b/app/models/application.rb
index b6ef9b4..059dcb8 100644
--- a/app/models/application.rb
+++ b/app/models/application.rb
@@ -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},
diff --git a/app/models/user.rb b/app/models/user.rb
index 168cf3f..1966f93 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -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
diff --git a/app/views/api_keys/index.html.erb b/app/views/api_keys/index.html.erb
new file mode 100644
index 0000000..e58aaea
--- /dev/null
+++ b/app/views/api_keys/index.html.erb
@@ -0,0 +1,71 @@
+
+
+
+
API Keys
+
+ Bearer tokens for server-to-server access to forward auth applications.
+
+
+ <%= 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" %>
+
+
+ <% if @api_keys.any? %>
+
+
+
+
+ | Name |
+ Application |
+ Created |
+ Last Used |
+ Expires |
+ Status |
+ |
+
+
+
+ <% @api_keys.each do |key| %>
+
+ | <%= key.name %> |
+ <%= key.application.name %> |
+ <%= key.created_at.strftime("%b %d, %Y") %> |
+ <%= key.last_used_at ? time_ago_in_words(key.last_used_at) + " ago" : "Never" %> |
+ <%= key.expires_at ? key.expires_at.strftime("%b %d, %Y") : "Never" %> |
+
+ <% if key.revoked? %>
+ Revoked
+ <% elsif key.expired? %>
+ Expired
+ <% else %>
+ Active
+ <% end %>
+ |
+
+ <% 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 %>
+ |
+
+ <% end %>
+
+
+
+ <% else %>
+
+
+
No API keys
+
+ Create an API key to authenticate server-to-server requests.
+
+
+ <%= 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" %>
+
+
+ <% end %>
+
diff --git a/app/views/api_keys/new.html.erb b/app/views/api_keys/new.html.erb
new file mode 100644
index 0000000..d9ace45
--- /dev/null
+++ b/app/views/api_keys/new.html.erb
@@ -0,0 +1,55 @@
+
+
+
New API Key
+
+ Create a bearer token for server-to-server access to a forward auth application.
+
+
+
+
+
+ <%= form_with(model: @api_key, class: "space-y-6") do |f| %>
+ <% if @api_key.errors.any? %>
+
+
+
+ <% @api_key.errors.full_messages.each do |msg| %>
+ - <%= msg %>
+ <% end %>
+
+
+
+ <% end %>
+
+
+ <%= 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" %>
+
+
+
+ <%= 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 %>
+
No forward auth applications available.
+ <% end %>
+
+
+
+ <%= 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" %>
+
Leave blank for no expiration.
+
+
+
+ <%= 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" %>
+
+ <% end %>
+
+
+
diff --git a/app/views/api_keys/show.html.erb b/app/views/api_keys/show.html.erb
new file mode 100644
index 0000000..b4fd8a7
--- /dev/null
+++ b/app/views/api_keys/show.html.erb
@@ -0,0 +1,59 @@
+
+
+
API Key Created
+
+ Copy your API key now. You won't be able to see it again.
+
+
+
+
+
+
+
+
+
+
Save this key now!
+
This is the only time you'll see the full API key. Store it securely.
+
+
+
+
+
+
+
+
Name: <%= @api_key.name %>
+
Application: <%= @api_key.application.name %>
+
Expires: <%= @api_key.expires_at ? @api_key.expires_at.strftime("%b %d, %Y %H:%M") : "Never" %>
+
+
+
+
Usage example:
+
curl -H "Authorization: Bearer <%= @plaintext_token %>" \
+ -H "X-Forwarded-Host: your-app.example.com" \
+ <%= request.base_url %>/api/verify
+
+
+
+ <%= 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" %>
+
+
+
+
diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb
index 090312a..825977e 100644
--- a/app/views/dashboard/index.html.erb
+++ b/app/views/dashboard/index.html.erb
@@ -91,6 +91,32 @@
<% end %>
+
+
+
+
+
+
+
+
+ -
+ API Keys
+
+ -
+ <%= @user.api_keys.active.count %>
+
+
+
+
+
+
+ <%= link_to "Manage API Keys", api_keys_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
+
+
diff --git a/config/initializers/version.rb b/config/initializers/version.rb
index 32f3eed..ee1a2a7 100644
--- a/config/initializers/version.rb
+++ b/config/initializers/version.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
module Clinch
- VERSION = "0.8.7"
+ VERSION = "0.8.8"
end
diff --git a/config/routes.rb b/config/routes.rb
index 27ecc3c..78451b5 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -40,6 +40,8 @@ Rails.application.routes.draw do
end
# Authenticated routes
+ resources :api_keys, only: [:index, :new, :create, :show, :destroy]
+
root "dashboard#index"
resource :profile, only: [:show, :update] do
member do
diff --git a/db/migrate/20260305000001_create_api_keys.rb b/db/migrate/20260305000001_create_api_keys.rb
new file mode 100644
index 0000000..cfda763
--- /dev/null
+++ b/db/migrate/20260305000001_create_api_keys.rb
@@ -0,0 +1,20 @@
+class CreateApiKeys < ActiveRecord::Migration[8.1]
+ def change
+ create_table :api_keys do |t|
+ t.references :user, null: false, foreign_key: true
+ t.references :application, null: false, foreign_key: true
+ t.string :name, null: false
+ t.string :token_hmac, null: false
+ t.datetime :expires_at
+ t.datetime :last_used_at
+ t.datetime :revoked_at
+
+ t.timestamps
+ end
+
+ add_index :api_keys, :token_hmac, unique: true
+ add_index :api_keys, [:user_id, :application_id]
+ add_index :api_keys, :expires_at
+ add_index :api_keys, :revoked_at
+ end
+end
diff --git a/test/controllers/api/forward_auth_bearer_test.rb b/test/controllers/api/forward_auth_bearer_test.rb
new file mode 100644
index 0000000..b503354
--- /dev/null
+++ b/test/controllers/api/forward_auth_bearer_test.rb
@@ -0,0 +1,148 @@
+require "test_helper"
+
+module Api
+ class ForwardAuthBearerTest < ActionDispatch::IntegrationTest
+ setup do
+ @user = users(:bob)
+ @app = Application.create!(
+ name: "WebDAV App",
+ slug: "webdav-app",
+ app_type: "forward_auth",
+ domain_pattern: "webdav.example.com",
+ active: true
+ )
+ @api_key = @user.api_keys.create!(name: "Test Key", application: @app)
+ @token = @api_key.plaintext_token
+ end
+
+ test "valid bearer token returns 200 with user headers" do
+ get "/api/verify", headers: {
+ "Authorization" => "Bearer #{@token}",
+ "X-Forwarded-Host" => "webdav.example.com"
+ }
+
+ assert_response :ok
+ assert_equal @user.email_address, response.headers["x-remote-user"]
+ assert_equal @user.email_address, response.headers["x-remote-email"]
+ end
+
+ test "valid bearer token updates last_used_at" do
+ assert_nil @api_key.last_used_at
+
+ get "/api/verify", headers: {
+ "Authorization" => "Bearer #{@token}",
+ "X-Forwarded-Host" => "webdav.example.com"
+ }
+
+ assert_response :ok
+ assert @api_key.reload.last_used_at.present?
+ end
+
+ test "expired bearer token returns 401 JSON" do
+ @api_key.update_column(:expires_at, 1.hour.ago)
+
+ get "/api/verify", headers: {
+ "Authorization" => "Bearer #{@token}",
+ "X-Forwarded-Host" => "webdav.example.com"
+ }
+
+ assert_response :unauthorized
+ json = JSON.parse(response.body)
+ assert_equal "Invalid or expired API key", json["error"]
+ end
+
+ test "revoked bearer token returns 401 JSON" do
+ @api_key.revoke!
+
+ get "/api/verify", headers: {
+ "Authorization" => "Bearer #{@token}",
+ "X-Forwarded-Host" => "webdav.example.com"
+ }
+
+ assert_response :unauthorized
+ json = JSON.parse(response.body)
+ assert_equal "Invalid or expired API key", json["error"]
+ end
+
+ test "invalid bearer token returns 401 JSON" do
+ get "/api/verify", headers: {
+ "Authorization" => "Bearer clk_totally_bogus_token",
+ "X-Forwarded-Host" => "webdav.example.com"
+ }
+
+ assert_response :unauthorized
+ json = JSON.parse(response.body)
+ assert_equal "Invalid or expired API key", json["error"]
+ end
+
+ test "bearer token for wrong domain returns 401 JSON" do
+ get "/api/verify", headers: {
+ "Authorization" => "Bearer #{@token}",
+ "X-Forwarded-Host" => "other.example.com"
+ }
+
+ assert_response :unauthorized
+ json = JSON.parse(response.body)
+ assert_equal "API key not valid for this domain", json["error"]
+ end
+
+ test "bearer token for inactive user returns 401 JSON" do
+ @user.update!(status: :disabled)
+
+ get "/api/verify", headers: {
+ "Authorization" => "Bearer #{@token}",
+ "X-Forwarded-Host" => "webdav.example.com"
+ }
+
+ assert_response :unauthorized
+ json = JSON.parse(response.body)
+ assert_equal "User account is not active", json["error"]
+ end
+
+ test "bearer token for inactive application returns 401 JSON" do
+ @app.update!(active: false)
+
+ get "/api/verify", headers: {
+ "Authorization" => "Bearer #{@token}",
+ "X-Forwarded-Host" => "webdav.example.com"
+ }
+
+ assert_response :unauthorized
+ json = JSON.parse(response.body)
+ assert_equal "Application is inactive", json["error"]
+ end
+
+ test "no bearer token falls through to cookie auth" do
+ # No auth header, no session -> should redirect (cookie flow)
+ get "/api/verify", headers: {
+ "X-Forwarded-Host" => "webdav.example.com"
+ }
+
+ assert_response :redirect
+ assert_match %r{/signin}, response.location
+ end
+
+ test "bearer token does not redirect on failure" do
+ get "/api/verify", headers: {
+ "Authorization" => "Bearer clk_bad",
+ "X-Forwarded-Host" => "webdav.example.com"
+ }
+
+ assert_response :unauthorized
+ assert_equal "application/json", response.media_type
+ # Should NOT be a redirect
+ assert_nil response.headers["Location"]
+ end
+
+ test "cookie auth still works when no bearer token present" do
+ sign_in_as(@user)
+
+ get "/api/verify", headers: {
+ "X-Forwarded-Host" => "webdav.example.com"
+ }
+
+ assert_response :ok
+ assert_equal @user.email_address, response.headers["x-remote-user"]
+ end
+ end
+end
diff --git a/test/models/api_key_test.rb b/test/models/api_key_test.rb
new file mode 100644
index 0000000..e44d3e7
--- /dev/null
+++ b/test/models/api_key_test.rb
@@ -0,0 +1,94 @@
+require "test_helper"
+
+class ApiKeyTest < ActiveSupport::TestCase
+ setup do
+ @user = users(:bob)
+ @app = Application.create!(
+ name: "WebDAV",
+ slug: "webdav",
+ app_type: "forward_auth",
+ domain_pattern: "webdav.example.com",
+ active: true
+ )
+ end
+
+ test "generates clk_ prefixed token on create" do
+ key = @user.api_keys.create!(name: "Test Key", application: @app)
+ assert key.plaintext_token.start_with?("clk_")
+ assert key.token_hmac.present?
+ end
+
+ test "find_by_token looks up via HMAC" do
+ key = @user.api_keys.create!(name: "Test Key", application: @app)
+ found = ApiKey.find_by_token(key.plaintext_token)
+ assert_equal key.id, found.id
+ end
+
+ test "find_by_token returns nil for invalid token" do
+ assert_nil ApiKey.find_by_token("clk_bogus")
+ assert_nil ApiKey.find_by_token("")
+ assert_nil ApiKey.find_by_token(nil)
+ end
+
+ test "active scope excludes revoked and expired keys" do
+ active_key = @user.api_keys.create!(name: "Active", application: @app)
+ revoked_key = @user.api_keys.create!(name: "Revoked", application: @app)
+ revoked_key.revoke!
+ expired_key = @user.api_keys.create!(name: "Expired", application: @app, expires_at: 1.day.ago)
+
+ active_keys = @user.api_keys.active
+ assert_includes active_keys, active_key
+ assert_not_includes active_keys, revoked_key
+ assert_not_includes active_keys, expired_key
+ end
+
+ test "active? expired? revoked? methods" do
+ key = @user.api_keys.create!(name: "Test", application: @app)
+ assert key.active?
+ assert_not key.expired?
+ assert_not key.revoked?
+
+ key.revoke!
+ assert_not key.active?
+ assert key.revoked?
+
+ key2 = @user.api_keys.create!(name: "Expiring", application: @app, expires_at: 1.hour.ago)
+ assert_not key2.active?
+ assert key2.expired?
+ end
+
+ test "nil expires_at means never expires" do
+ key = @user.api_keys.create!(name: "No Expiry", application: @app, expires_at: nil)
+ assert_not key.expired?
+ assert key.active?
+ end
+
+ test "touch_last_used! updates timestamp" do
+ key = @user.api_keys.create!(name: "Test", application: @app)
+ assert_nil key.last_used_at
+ key.touch_last_used!
+ assert key.reload.last_used_at.present?
+ end
+
+ test "validates application must be forward_auth" do
+ oidc_app = applications(:kavita_app)
+ key = @user.api_keys.build(name: "Bad", application: oidc_app)
+ assert_not key.valid?
+ assert_includes key.errors[:application], "must be a forward auth application"
+ end
+
+ test "validates user must have access to application" do
+ group = groups(:admin_group)
+ @app.allowed_groups << group
+ # @user (bob) is not in admin_group
+ key = @user.api_keys.build(name: "No Access", application: @app)
+ assert_not key.valid?
+ assert_includes key.errors[:user], "does not have access to this application"
+ end
+
+ test "validates name presence" do
+ key = @user.api_keys.build(name: "", application: @app)
+ assert_not key.valid?
+ assert_includes key.errors[:name], "can't be blank"
+ end
+end