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

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