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:
148
test/controllers/api/forward_auth_bearer_test.rb
Normal file
148
test/controllers/api/forward_auth_bearer_test.rb
Normal file
@@ -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
|
||||
94
test/models/api_key_test.rb
Normal file
94
test/models/api_key_test.rb
Normal 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
|
||||
Reference in New Issue
Block a user