From fd8785a43d9fb3895fb7a8afe59132b955ce56b5 Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Thu, 5 Mar 2026 21:45:40 +1100 Subject: [PATCH] Add API keys / bearer tokens for forward auth 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 --- .../api/forward_auth_controller.rb | 41 +++++ app/controllers/api_keys_controller.rb | 51 ++++++ .../controllers/clipboard_controller.js | 15 ++ app/models/api_key.rb | 66 ++++++++ app/models/application.rb | 1 + app/models/user.rb | 1 + app/views/api_keys/index.html.erb | 71 +++++++++ app/views/api_keys/new.html.erb | 55 +++++++ app/views/api_keys/show.html.erb | 59 +++++++ app/views/dashboard/index.html.erb | 26 +++ config/initializers/version.rb | 2 +- config/routes.rb | 2 + db/migrate/20260305000001_create_api_keys.rb | 20 +++ .../api/forward_auth_bearer_test.rb | 148 ++++++++++++++++++ test/models/api_key_test.rb | 94 +++++++++++ 15 files changed, 651 insertions(+), 1 deletion(-) create mode 100644 app/controllers/api_keys_controller.rb create mode 100644 app/javascript/controllers/clipboard_controller.js create mode 100644 app/models/api_key.rb create mode 100644 app/views/api_keys/index.html.erb create mode 100644 app/views/api_keys/new.html.erb create mode 100644 app/views/api_keys/show.html.erb create mode 100644 db/migrate/20260305000001_create_api_keys.rb create mode 100644 test/controllers/api/forward_auth_bearer_test.rb create mode 100644 test/models/api_key_test.rb 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? %> +
+ + + + + + + + + + + + + + <% @api_keys.each do |key| %> + + + + + + + + + + <% end %> + +
NameApplicationCreatedLast UsedExpiresStatus
<%= 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 %> +
+
+ <% 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