Add API keys / bearer tokens for forward auth
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled

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:
Dan Milne
2026-03-05 21:45:40 +11:00
parent 444ae6291c
commit fd8785a43d
15 changed files with 651 additions and 1 deletions

View File

@@ -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]

View 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